Compare commits
87 Commits
b882411f42
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e092ede8d8 | |||
| c6e1d94510 | |||
| 8c437cd082 | |||
| 4c95de2e56 | |||
| ce9bde35eb | |||
| 9d74bd2d51 | |||
| f4072ce93a | |||
| 29d987ab74 | |||
| f0321b5bdd | |||
| fb9927e712 | |||
| db11a57e98 | |||
| 1289aea7ce | |||
| a32c0a0731 | |||
| f50301148d | |||
| 99ea931409 | |||
| 3bcad138a1 | |||
| 9565a0f23c | |||
| 4dd7b5a0b1 | |||
| 7905f1ee23 | |||
| 782b43ef0a | |||
| 8936453e84 | |||
| 92ba2e779f | |||
| 8ddc3ea690 | |||
| d324b2fb0a | |||
| 74d5b90faa | |||
| dab28f85e8 | |||
| 01cceea237 | |||
| 9cc9ed09df | |||
| 260a7517d2 | |||
| d27f10eb84 | |||
| 00cdb322d2 | |||
| 6c11fd7e16 | |||
| e50f660811 | |||
| bb8228bd0f | |||
| f03f22c605 | |||
| b73ac5e3e4 | |||
| f160256865 | |||
| f96c1c26c3 | |||
| a0829f17ce | |||
| 57639a3869 | |||
| b37ae9471e | |||
| 1e3d6ee20f | |||
| 4b60e0c018 | |||
| bcd2d1a559 | |||
| a644a8de42 | |||
| 8b5fa98028 | |||
| 1b4ba893fd | |||
| c10476d203 | |||
| bcbf3c8b2e | |||
| ff3b6cdfde | |||
| 8dc72d6e1b | |||
| 00e2bc5acf | |||
| 67435e46ca | |||
| db0d972633 | |||
| 8d4ebcf9c0 | |||
| 5f9566e26d | |||
| 5b5ffb5b9e | |||
| 899d2dde5b | |||
| 54b002455f | |||
| b032890b4f | |||
| f560b046a4 | |||
| 549a3ff14b | |||
| 7fdfdbc717 | |||
| edb798b752 | |||
| a436d9aa38 | |||
| bbeea440f9 | |||
| 9048816abc | |||
| 14db28afc6 | |||
| c72ff2cb8a | |||
| 24e0ecbdaf | |||
| d9c541dacc | |||
| ae88372ef8 | |||
| 259271f56c | |||
| 824c33a71c | |||
| 7f09d5271e | |||
| 7f3e30ba38 | |||
| d0ab8da01b | |||
| 6a18fd264d | |||
| 026a9f35d8 | |||
| 2e7bb81ebd | |||
| 67e14e357c | |||
| 6249fefc64 | |||
| d1c933f032 | |||
| 1a1e90c72f | |||
| ab4765cee5 | |||
| b70f45fb04 | |||
| 5f89335dbb |
@@ -33,3 +33,11 @@ AI_CONFIG_ENCRYPTION_KEY=
|
|||||||
|
|
||||||
# 允许内网地址作为 OPENAI_COMPATIBLE 的 baseUrl(仅内网部署使用)
|
# 允许内网地址作为 OPENAI_COMPATIBLE 的 baseUrl(仅内网部署使用)
|
||||||
# AI_ALLOW_PRIVATE_BASE_URL=true
|
# AI_ALLOW_PRIVATE_BASE_URL=true
|
||||||
|
|
||||||
|
# ---- 安全 ----
|
||||||
|
# 是否信任反向代理的 X-Forwarded-For / X-Real-IP(仅当部署在可信代理/Nginx 后时才设 true;
|
||||||
|
# 不设置时服务端只用 TCP socket 地址,防止伪造客户端 IP)
|
||||||
|
# TRUST_PROXY=true
|
||||||
|
|
||||||
|
# 说明:第三方集成配置(钉钉/企微 appSecret)的静态加密复用 AI_CONFIG_ENCRYPTION_KEY,
|
||||||
|
# 存量明文可用 `cd apps/server && npm run encrypt:integration-secrets` 一次性加密。
|
||||||
|
|||||||
44
.gitea/workflows/dependency-check.yml
Normal file
44
.gitea/workflows/dependency-check.yml
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
name: Dependency Check
|
||||||
|
|
||||||
|
# 依赖安全检查:生产依赖 moderate+ 或全局 critical 漏洞即失败;
|
||||||
|
# npm outdated 作为信息输出,不阻断。
|
||||||
|
# 注意:npmmirror 未实现 audit 接口,audit 必须显式指定官方 registry。
|
||||||
|
|
||||||
|
env:
|
||||||
|
NPM_CONFIG_REGISTRY: https://registry.npmmirror.com
|
||||||
|
NPM_CONFIG_AUDIT: "false"
|
||||||
|
NPM_CONFIG_FUND: "false"
|
||||||
|
NPM_CONFIG_REPLACE_REGISTRY_HOST: always
|
||||||
|
CI: "true"
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
schedule:
|
||||||
|
- cron: '30 2 * * 1' # 每周一 02:30 复查已发布的新漏洞
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container: node:22.22.0-bookworm
|
||||||
|
timeout-minutes: 20
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: https://gitee.com/mirrors_actions/checkout@v4
|
||||||
|
with:
|
||||||
|
github-server-url: https://git.gongxue100.com
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Audit production dependencies (moderate+)
|
||||||
|
run: npm audit --registry=https://registry.npmjs.org --omit=dev --audit-level=moderate
|
||||||
|
|
||||||
|
- name: Audit all dependencies (critical only)
|
||||||
|
run: npm audit --registry=https://registry.npmjs.org --audit-level=critical
|
||||||
|
|
||||||
|
- name: Outdated report (informational)
|
||||||
|
run: npm outdated --registry=https://registry.npmjs.org || true
|
||||||
@@ -1,80 +1,86 @@
|
|||||||
# PM2 手动部署工作流
|
# PM2 本地 runner 手动部署工作流
|
||||||
# 手动触发 → 构建 → rsync → 安装依赖 → 数据库迁移 → PM2 重载
|
# 手动触发 → 服务器本地 git pull → 构建 → 依赖安装 → 数据库迁移 → PM2 重载
|
||||||
|
#
|
||||||
|
# 适用场景:Gitea runner 与生产服务器同机(本机直跑,不需要 SSH/Secrets)。
|
||||||
#
|
#
|
||||||
# 前置准备:
|
# 前置准备:
|
||||||
# 1. 服务器 MySQL 已在运行
|
# 1. Gitea Actions 已开启,且已注册 self-hosted runner(runs-on 标签与下面一致)
|
||||||
# 2. 服务器已安装 PM2: npm i -g pm2
|
# 2. 服务器已 clone 仓库到部署目录(默认 /opt/gongxue,可用仓库 Variable REMOTE_DIR 覆盖)
|
||||||
# 3. Gitea 仓库 Settings → Secrets 配置:
|
# 3. 服务器已安装 Node 22 + PM2(npm i -g pm2),MySQL 已运行
|
||||||
# - SSH_PRIVATE_KEY : 部署用 SSH 私钥
|
# 4. runner 运行账户对部署目录有写权限、可执行 npm/pm2
|
||||||
# - SSH_HOST : 服务器地址
|
#
|
||||||
# - SSH_USER : SSH 用户名
|
# 关于 .env:不需要配置在 Gitea 界面。.env 只放在服务器部署目录下,
|
||||||
# - SSH_PORT : SSH 端口(默认 22,可选)
|
# git pull 不会覆盖它,PM2/Nest 启动时直接读取服务器上的 .env。
|
||||||
# - REMOTE_DIR : 服务器项目目录,如 /opt/gongxue
|
#
|
||||||
|
# 关于“在 docker 里构建/迁移”:
|
||||||
|
# - 如果 runner 以“宿主机进程”方式运行(推荐,最省事):本 workflow 的
|
||||||
|
# npm ci / build / migration 直接在宿主机执行,pm2 直接操作宿主 pm2。
|
||||||
|
# - 如果 runner 跑在 docker 容器里:需要把部署目录挂载进容器
|
||||||
|
# (如 -v /opt/gongxue:/opt/gongxue),构建/迁移在容器内完成;
|
||||||
|
# PM2 重载则必须打到宿主机(挂载 docker.sock 后 docker exec 到宿主,
|
||||||
|
# 或在宿主机单独跑 pm2 服务端)——容器里的 pm2 管理不了宿主进程。
|
||||||
|
|
||||||
name: PM2 部署
|
name: PM2 本地部署
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
seed_roles:
|
||||||
|
description: '强制播种 RBAC 权限码(首次部署或新增权限码时勾选,跑完会自动去掉)'
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
type: boolean
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy-local
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: self-hosted # 改为你注册 runner 时使用的 label(如 ubuntu-latest)
|
||||||
steps:
|
steps:
|
||||||
- name: 检出代码
|
- name: 拉取最新代码
|
||||||
uses: actions/checkout@v4
|
run: |
|
||||||
|
cd ${{ vars.REMOTE_DIR || '/opt/gongxue' }}
|
||||||
- name: 安装 Node.js
|
git fetch origin
|
||||||
uses: actions/setup-node@v4
|
git checkout main
|
||||||
with:
|
git pull origin main
|
||||||
node-version: '22'
|
|
||||||
|
|
||||||
- name: 安装依赖 & 构建
|
- name: 安装依赖 & 构建
|
||||||
run: |
|
run: |
|
||||||
|
cd ${{ vars.REMOTE_DIR || '/opt/gongxue' }}
|
||||||
npm ci
|
npm ci
|
||||||
npm run build -w @gongxue/server
|
npm run build -w @gongxue/server
|
||||||
npm run build -w @gongxue/admin
|
npm run build -w @gongxue/admin
|
||||||
|
|
||||||
- name: 配置 SSH
|
- name: 执行数据库迁移
|
||||||
run: |
|
run: |
|
||||||
mkdir -p ~/.ssh
|
cd ${{ vars.REMOTE_DIR || '/opt/gongxue' }}
|
||||||
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
|
npm run migration:run -w @gongxue/server
|
||||||
chmod 600 ~/.ssh/deploy_key
|
|
||||||
cat >> ~/.ssh/config <<'EOF'
|
|
||||||
Host deploy-server
|
|
||||||
HostName ${{ secrets.SSH_HOST }}
|
|
||||||
User ${{ secrets.SSH_USER }}
|
|
||||||
Port ${{ secrets.SSH_PORT || '22' }}
|
|
||||||
IdentityFile ~/.ssh/deploy_key
|
|
||||||
StrictHostKeyChecking accept-new
|
|
||||||
EOF
|
|
||||||
|
|
||||||
- name: 同步代码到服务器
|
- name: PM2 重载
|
||||||
run: |
|
run: |
|
||||||
rsync -avz --delete \
|
cd ${{ vars.REMOTE_DIR || '/opt/gongxue' }}
|
||||||
--exclude='node_modules' \
|
if [ "${{ github.event.inputs.seed_roles }}" = "true" ]; then
|
||||||
--exclude='.git' \
|
echo '>>> 强制播种 RBAC 权限码(本次进程带 SEED_ROLES=true)'
|
||||||
--exclude='*.db' \
|
SEED_ROLES=true pm2 startOrReload ecosystem.config.cjs --only gongxue-backend --update-env
|
||||||
--exclude='.DS_Store' \
|
pm2 restart gongxue-backend --update-env # 去掉 SEED_ROLES 正常重启
|
||||||
--exclude='logs/' \
|
else
|
||||||
--exclude='.turbo/' \
|
|
||||||
--exclude='.claude/' \
|
|
||||||
--exclude='.codegraph/' \
|
|
||||||
./ deploy-server:${{ secrets.REMOTE_DIR }}/
|
|
||||||
|
|
||||||
- name: 安装依赖 → 迁移 → PM2 重载
|
|
||||||
run: |
|
|
||||||
ssh deploy-server "
|
|
||||||
cd ${{ secrets.REMOTE_DIR }}
|
|
||||||
mkdir -p logs
|
|
||||||
if [ ! -d node_modules ]; then
|
|
||||||
echo '首次部署,安装生产依赖...'
|
|
||||||
npm ci --omit=dev
|
|
||||||
fi
|
|
||||||
echo '执行数据库迁移...'
|
|
||||||
npm run migration:run -w @gongxue/server
|
|
||||||
echo 'PM2 重载...'
|
|
||||||
pm2 startOrReload ecosystem.config.cjs --update-env
|
pm2 startOrReload ecosystem.config.cjs --update-env
|
||||||
pm2 save
|
fi
|
||||||
echo '=== PM2 状态 ==='
|
pm2 save
|
||||||
pm2 status
|
pm2 status
|
||||||
"
|
|
||||||
|
- name: 健康检查
|
||||||
|
run: |
|
||||||
|
cd ${{ vars.REMOTE_DIR || '/opt/gongxue' }}
|
||||||
|
sleep 3
|
||||||
|
# 从 .env 读 PORT(默认 3000),避免硬编码端口与后端不一致
|
||||||
|
PORT="$(grep '^PORT=' .env 2>/dev/null | cut -d= -f2- | tr -d '"' || true)"
|
||||||
|
PORT="${PORT:-3000}"
|
||||||
|
code="$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:${PORT}/api/dashboard/stats" || true)"
|
||||||
|
echo "后端 HTTP 状态: ${code}(401=正常,接口需登录)"
|
||||||
|
if [ "${code}" != "401" ] && [ "${code}" != "200" ]; then
|
||||||
|
echo "健康检查失败:后端未按预期响应" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|||||||
35
README.md
35
README.md
@@ -16,6 +16,11 @@
|
|||||||
| 账单导出 | Excel(汇总+明细双Sheet)、单条PDF账单 |
|
| 账单导出 | Excel(汇总+明细双Sheet)、单条PDF账单 |
|
||||||
| 教室管理 | 教室信息维护、教室租赁记录 |
|
| 教室管理 | 教室信息维护、教室租赁记录 |
|
||||||
| 押金管理 | 押金收取与退还 |
|
| 押金管理 | 押金收取与退还 |
|
||||||
|
| 班级/排课 | 班级档案、分班、教室日程与排课 |
|
||||||
|
| 考勤管理 | 手工考勤、钉钉考勤同步、自动匹配 |
|
||||||
|
| 教室租赁 | 租赁订单、合同、租赁日程 |
|
||||||
|
| AI 助手 | 对话式查询、表单/导入向导/图表、业务待办引导 |
|
||||||
|
| 组织/校区 | 组织机构与数据范围 |
|
||||||
| 操作日志 | 所有涉及钱的操作自动审计留痕 |
|
| 操作日志 | 所有涉及钱的操作自动审计留痕 |
|
||||||
| 账号管理 | 用户增删改查、角色区分、启用/禁用、重置密码 |
|
| 账号管理 | 用户增删改查、角色区分、启用/禁用、重置密码 |
|
||||||
|
|
||||||
@@ -42,7 +47,7 @@
|
|||||||
### 后端启动
|
### 后端启动
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
cd apps/server
|
||||||
cp .env.example .env # 复制并修改环境配置
|
cp .env.example .env # 复制并修改环境配置
|
||||||
npm install
|
npm install
|
||||||
npm run start:dev # 开发模式启动,默认端口 3000
|
npm run start:dev # 开发模式启动,默认端口 3000
|
||||||
@@ -51,46 +56,48 @@ npm run start:dev # 开发模式启动,默认端口 3000
|
|||||||
### 前端启动
|
### 前端启动
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
cd apps/admin
|
||||||
npm install
|
npm install
|
||||||
npm run dev # 开发模式启动,默认端口 5173
|
npm run dev # 开发模式启动,默认端口 5173
|
||||||
```
|
```
|
||||||
|
|
||||||
### Docker 部署
|
### 常用命令
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose up -d # 一键启动 MySQL + 后端 + 前端
|
npm run typecheck # 全仓类型检查
|
||||||
|
npm run lint # 全仓 lint
|
||||||
|
npm run test # 全仓测试
|
||||||
|
npm run build # 全仓构建
|
||||||
```
|
```
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
```
|
```
|
||||||
├── backend/ # 后端 NestJS 服务
|
├── apps/server/ # 后端 NestJS 服务
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
│ │ ├── auth/ # 认证模块 (JWT)
|
│ │ ├── ai-chat/ # AI 对话、表单/导入向导/图表
|
||||||
|
│ │ ├── attendance/ # 考勤与钉钉同步
|
||||||
│ │ ├── bills/ # 账单模块
|
│ │ ├── bills/ # 账单模块
|
||||||
│ │ ├── classrooms/ # 教室管理
|
|
||||||
│ │ ├── dashboard/ # 数据面板
|
│ │ ├── dashboard/ # 数据面板
|
||||||
│ │ ├── deposits/ # 押金管理
|
|
||||||
│ │ ├── entities/ # 数据实体
|
│ │ ├── entities/ # 数据实体
|
||||||
│ │ ├── expenses/ # 费用录入
|
|
||||||
│ │ ├── occupancies/# 入住管理
|
│ │ ├── occupancies/# 入住管理
|
||||||
|
│ │ ├── rbac/ # 角色权限
|
||||||
│ │ ├── rooms/ # 宿舍管理
|
│ │ ├── rooms/ # 宿舍管理
|
||||||
│ │ ├── students/ # 学生管理
|
│ │ └── students/ # 学生管理
|
||||||
│ │ └── tenants/ # 租户管理
|
|
||||||
│ └── .env.example # 环境配置模板
|
│ └── .env.example # 环境配置模板
|
||||||
├── frontend/ # 前端 React 应用
|
├── apps/admin/ # 前端 React 应用
|
||||||
│ └── src/
|
│ └── src/
|
||||||
│ ├── api/ # API 请求封装
|
│ ├── api/ # API 请求封装
|
||||||
|
│ ├── components/ # 通用组件
|
||||||
│ ├── layouts/ # 布局组件
|
│ ├── layouts/ # 布局组件
|
||||||
│ └── pages/ # 页面组件
|
│ └── pages/ # 页面组件
|
||||||
├── docker-compose.yml # Docker 编排配置
|
├── packages/ # 共享配置包
|
||||||
└── 技术文档.md # 详细技术文档
|
└── 技术文档.md # 详细技术文档
|
||||||
```
|
```
|
||||||
|
|
||||||
## 环境配置
|
## 环境配置
|
||||||
|
|
||||||
复制 `backend/.env.example` 为 `backend/.env`,按需修改:
|
复制 `apps/server/.env.example` 为 `apps/server/.env`,按需修改:
|
||||||
|
|
||||||
| 配置项 | 说明 | 默认值 |
|
| 配置项 | 说明 | 默认值 |
|
||||||
|--------|------|--------|
|
|--------|------|--------|
|
||||||
|
|||||||
@@ -14,9 +14,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons": "^6.1.1",
|
"@ant-design/icons": "^6.1.1",
|
||||||
"@ant-design/x": "^2.8.0",
|
"@ant-design/x": "^2.9.0",
|
||||||
"@ant-design/x-card": "^2.9.0",
|
"@ant-design/x-card": "^2.9.0",
|
||||||
"@ant-design/x-markdown": "^2.8.0",
|
"@ant-design/x-markdown": "^2.9.0",
|
||||||
"@ant-design/x-sdk": "^2.8.0",
|
"@ant-design/x-sdk": "^2.8.0",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
@@ -26,13 +26,15 @@
|
|||||||
"antd": "^6.3.6",
|
"antd": "^6.3.6",
|
||||||
"axios": "^1.15.1",
|
"axios": "^1.15.1",
|
||||||
"dayjs": "^1.11.20",
|
"dayjs": "^1.11.20",
|
||||||
|
"dompurify": "^3.4.13",
|
||||||
"echarts": "^6.0.0",
|
"echarts": "^6.0.0",
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"mermaid": "^11.16.0",
|
"mermaid": "^11.16.1",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
"react-router": "^8.3.0",
|
"react-router": "^8.3.0",
|
||||||
|
"react-syntax-highlighter": "^16.1.1",
|
||||||
"use-immer": "^0.11.0",
|
"use-immer": "^0.11.0",
|
||||||
"usehooks-ts": "^3.1.1",
|
"usehooks-ts": "^3.1.1",
|
||||||
"zod": "^4.4.3",
|
"zod": "^4.4.3",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import zhCN from 'antd/es/locale/zh_CN';
|
|||||||
import MainLayout from './layouts/MainLayout';
|
import MainLayout from './layouts/MainLayout';
|
||||||
import PermissionRoute from './components/PermissionRoute';
|
import PermissionRoute from './components/PermissionRoute';
|
||||||
import DefaultRoute from './components/DefaultRoute';
|
import DefaultRoute from './components/DefaultRoute';
|
||||||
|
import ScrollToTop from './components/ScrollToTop';
|
||||||
import AppMessageBridge from './ui/AppMessageBridge';
|
import AppMessageBridge from './ui/AppMessageBridge';
|
||||||
import { useUserStore } from './store/user/userStore';
|
import { useUserStore } from './store/user/userStore';
|
||||||
|
|
||||||
@@ -74,6 +75,7 @@ const App: React.FC = () => {
|
|||||||
<AntdApp>
|
<AntdApp>
|
||||||
<AppMessageBridge />
|
<AppMessageBridge />
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
<ScrollToTop />
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={
|
fallback={
|
||||||
<div style={{ minHeight: '40vh', display: 'grid', placeItems: 'center' }}>
|
<div style={{ minHeight: '40vh', display: 'grid', placeItems: 'center' }}>
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ export async function createImportRun(
|
|||||||
conversationId?: number;
|
conversationId?: number;
|
||||||
stages?: ImportStageRequest[];
|
stages?: ImportStageRequest[];
|
||||||
mapping?: Record<string, Record<string, string>>;
|
mapping?: Record<string, Record<string, string>>;
|
||||||
|
/** 上传进度回调(0-100) */
|
||||||
|
onProgress?: (percent: number) => void;
|
||||||
},
|
},
|
||||||
): Promise<ImportRunDetail> {
|
): Promise<ImportRunDetail> {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
@@ -32,7 +34,10 @@ export async function createImportRun(
|
|||||||
form.append('mapping', JSON.stringify(options.mapping));
|
form.append('mapping', JSON.stringify(options.mapping));
|
||||||
}
|
}
|
||||||
const res = await api.post<ApiEnvelope<ImportRunDetail>>('/imports/runs', form, {
|
const res = await api.post<ApiEnvelope<ImportRunDetail>>('/imports/runs', form, {
|
||||||
headers: { 'Content-Type': 'multipart/form-data' },
|
onUploadProgress: (event) => {
|
||||||
|
if (!options.onProgress || !event.total) return;
|
||||||
|
options.onProgress(Math.min(Math.round((event.loaded / event.total) * 100), 100));
|
||||||
|
},
|
||||||
});
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|||||||
20
apps/admin/src/api/queryClient.ts
Normal file
20
apps/admin/src/api/queryClient.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { QueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全局 QueryClient:统一缓存/重试策略。
|
||||||
|
* - retry 1:接口失败最多重试 1 次,避免瞬时错误直接白屏
|
||||||
|
* - staleTime 30s:30 秒内重复请求走缓存
|
||||||
|
* - gcTime 5min:不活跃缓存 5 分钟后回收
|
||||||
|
* - refetchOnWindowFocus false:切回窗口不自动全量刷新,
|
||||||
|
* 保活页面由 useVisibleRefetch 按需刷新,避免重复请求
|
||||||
|
*/
|
||||||
|
export const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: 1,
|
||||||
|
staleTime: 30_000,
|
||||||
|
gcTime: 5 * 60_000,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
145
apps/admin/src/api/queryKeys.ts
Normal file
145
apps/admin/src/api/queryKeys.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
/**
|
||||||
|
* 统一 QueryKey 工厂。
|
||||||
|
*
|
||||||
|
* 每个模块一个命名空间,key 由工厂函数生成:
|
||||||
|
* - 避免散落字符串字面量导致拼写不一致、缓存串扰
|
||||||
|
* - invalidate / refetch / prefetch 与 useQuery 使用同一来源,不会对不上
|
||||||
|
*
|
||||||
|
* 约定:
|
||||||
|
* - `all` 是该模块的「根 key」,用于整体失效(invalidateQueries 会匹配前缀)
|
||||||
|
* - 列表 key 按过滤条件展开;详情/子资源用固定段 + 参数
|
||||||
|
*/
|
||||||
|
export const queryKeys = {
|
||||||
|
students: {
|
||||||
|
all: ['students'] as const,
|
||||||
|
list: (filters: {
|
||||||
|
search?: string;
|
||||||
|
status?: string;
|
||||||
|
archived?: boolean;
|
||||||
|
organizationId?: number;
|
||||||
|
classId?: number;
|
||||||
|
teacherId?: number;
|
||||||
|
}) => ['students', filters] as const,
|
||||||
|
organizations: (canView: boolean) => ['students', 'organizations', canView] as const,
|
||||||
|
filterLookups: () => ['students', 'filter-lookups'] as const,
|
||||||
|
},
|
||||||
|
classes: {
|
||||||
|
all: ['classes'] as const,
|
||||||
|
list: (filters: { status?: string; type?: string; archived?: boolean }) =>
|
||||||
|
['classes', filters] as const,
|
||||||
|
detail: (id: number) => ['classes', 'detail', id] as const,
|
||||||
|
schedule: (id: number, dateRange: unknown) => ['classes', 'schedule', id, dateRange] as const,
|
||||||
|
attendanceSummary: (id: number, dateRange: unknown) =>
|
||||||
|
['classes', 'attendance-summary', id, dateRange] as const,
|
||||||
|
},
|
||||||
|
classSchedules: {
|
||||||
|
all: ['class-schedules'] as const,
|
||||||
|
list: (params: { startDate?: string; endDate?: string; classroomIds?: unknown[] }) =>
|
||||||
|
['class-schedules', params] as const,
|
||||||
|
},
|
||||||
|
classrooms: {
|
||||||
|
all: ['classrooms'] as const,
|
||||||
|
list: (archived: boolean) => ['classrooms', archived] as const,
|
||||||
|
},
|
||||||
|
classroomRentals: {
|
||||||
|
all: ['classroom-rentals'] as const,
|
||||||
|
list: (month?: string) => ['classroom-rentals', month] as const,
|
||||||
|
schedule: (year: number, month: number) =>
|
||||||
|
['classroom-rentals', 'schedule', year, month] as const,
|
||||||
|
meta: () => ['classroom-rentals', 'meta'] as const,
|
||||||
|
},
|
||||||
|
organizations: {
|
||||||
|
all: ['organizations'] as const,
|
||||||
|
list: () => ['organizations'] as const,
|
||||||
|
options: () => ['organizations', 'options'] as const,
|
||||||
|
},
|
||||||
|
rbac: {
|
||||||
|
all: ['rbac'] as const,
|
||||||
|
users: (archived: boolean) => ['rbac', 'users', archived] as const,
|
||||||
|
allUsers: () => ['rbac', 'users', 'all'] as const,
|
||||||
|
teachers: (params: { page: number; pageSize: number; search?: string }) =>
|
||||||
|
['rbac', 'teachers', params] as const,
|
||||||
|
teacherWorkspace: () => ['rbac', 'teacher-workspace'] as const,
|
||||||
|
roles: () => ['rbac', 'roles'] as const,
|
||||||
|
permissionTree: () => ['rbac', 'roles', 'permission-tree'] as const,
|
||||||
|
permissionsTree: () => ['rbac', 'permissions', 'tree'] as const,
|
||||||
|
},
|
||||||
|
expenses: {
|
||||||
|
all: ['expenses'] as const,
|
||||||
|
list: (archived: boolean) => ['expenses', archived ? 'archived' : 'active'] as const,
|
||||||
|
},
|
||||||
|
expenseTypes: {
|
||||||
|
map: () => ['expense-types', 'map'] as const,
|
||||||
|
},
|
||||||
|
expenseLookups: {
|
||||||
|
all: ['expense-lookups'] as const,
|
||||||
|
},
|
||||||
|
bills: {
|
||||||
|
all: ['bills'] as const,
|
||||||
|
list: (filters: { status?: string; expenseType?: string }) => ['bills', filters] as const,
|
||||||
|
},
|
||||||
|
wallets: {
|
||||||
|
all: ['wallets'] as const,
|
||||||
|
list: (params: { keyword?: string; debtOnly?: boolean; roomType?: string }) =>
|
||||||
|
['wallets', params] as const,
|
||||||
|
transactions: (studentId: number) => ['wallets', 'transactions', studentId] as const,
|
||||||
|
roomTypes: () => ['wallets', 'room-types'] as const,
|
||||||
|
},
|
||||||
|
deposits: {
|
||||||
|
all: ['deposits'] as const,
|
||||||
|
list: () => ['deposits'] as const,
|
||||||
|
eligible: (roomType?: string) => ['deposits', 'eligible', roomType] as const,
|
||||||
|
},
|
||||||
|
occupancies: {
|
||||||
|
all: ['occupancies'] as const,
|
||||||
|
list: (params: { viewMode?: string; dateRange?: unknown }) => ['occupancies', params] as const,
|
||||||
|
},
|
||||||
|
rooms: {
|
||||||
|
all: ['rooms'] as const,
|
||||||
|
overview: (archived: boolean) => ['rooms', 'overview', archived] as const,
|
||||||
|
visual: (params: { historical: boolean; asOf?: unknown }) =>
|
||||||
|
['rooms', 'visual', params] as const,
|
||||||
|
},
|
||||||
|
exams: {
|
||||||
|
all: ['exams'] as const,
|
||||||
|
detail: (id: number) => ['exams', 'detail', id] as const,
|
||||||
|
classes: () => ['exams', 'classes'] as const,
|
||||||
|
},
|
||||||
|
operationLogs: {
|
||||||
|
all: ['operation-logs'] as const,
|
||||||
|
list: (params: { page: number; pageSize: number; module?: string; dateRange?: unknown }) =>
|
||||||
|
['operation-logs', params] as const,
|
||||||
|
},
|
||||||
|
attendance: {
|
||||||
|
all: ['attendance'] as const,
|
||||||
|
workspace: () => ['attendance', 'workspace'] as const,
|
||||||
|
syncStatus: () => ['attendance', 'sync-status'] as const,
|
||||||
|
schedules: (classId: number, date: string) => ['attendance', 'schedules', classId, date] as const,
|
||||||
|
meta: {
|
||||||
|
periods: () => ['attendance', 'meta', 'periods'] as const,
|
||||||
|
classes: () => ['attendance', 'meta', 'classes'] as const,
|
||||||
|
alerts: () => ['attendance', 'meta', 'alerts'] as const,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
attendanceDevices: {
|
||||||
|
all: ['attendance-devices'] as const,
|
||||||
|
},
|
||||||
|
dashboard: {
|
||||||
|
all: ['dashboard'] as const,
|
||||||
|
summary: (period: unknown) => ['dashboard', period] as const,
|
||||||
|
},
|
||||||
|
integration: {
|
||||||
|
config: () => ['integration', 'config'] as const,
|
||||||
|
},
|
||||||
|
sync: {
|
||||||
|
jinshujuRules: () => ['sync', 'jinshuju', 'rules'] as const,
|
||||||
|
},
|
||||||
|
archive: {
|
||||||
|
detail: (studentId: number) => ['archive', studentId] as const,
|
||||||
|
},
|
||||||
|
ai: {
|
||||||
|
config: () => ['ai', 'config'] as const,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type QueryKeys = typeof queryKeys;
|
||||||
@@ -153,8 +153,8 @@ export const classroomSchema = z
|
|||||||
.object({
|
.object({
|
||||||
id: z.number(),
|
id: z.number(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
building: z.string().optional(),
|
building: z.string().nullable().optional(),
|
||||||
status: z.string().optional(),
|
status: z.string().nullable().optional(),
|
||||||
})
|
})
|
||||||
.passthrough();
|
.passthrough();
|
||||||
|
|
||||||
@@ -165,7 +165,7 @@ export const studentSchema = z
|
|||||||
.object({
|
.object({
|
||||||
id: z.number(),
|
id: z.number(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
studentNo: z.string().optional(),
|
studentNo: z.string().nullable().optional(),
|
||||||
status: z.string(),
|
status: z.string(),
|
||||||
})
|
})
|
||||||
.passthrough();
|
.passthrough();
|
||||||
@@ -180,7 +180,11 @@ export const depositSchema = z
|
|||||||
export const depositsSchema = z.array(depositSchema);
|
export const depositsSchema = z.array(depositSchema);
|
||||||
|
|
||||||
export const depositStudentLookupSchema = z
|
export const depositStudentLookupSchema = z
|
||||||
.object({ studentId: z.number(), name: z.string().optional() })
|
.object({
|
||||||
|
id: z.number(),
|
||||||
|
name: z.string().nullable().optional(),
|
||||||
|
studentNo: z.string().nullable().optional(),
|
||||||
|
})
|
||||||
.passthrough();
|
.passthrough();
|
||||||
|
|
||||||
export const depositStudentLookupsSchema = z.array(depositStudentLookupSchema);
|
export const depositStudentLookupsSchema = z.array(depositStudentLookupSchema);
|
||||||
@@ -217,6 +221,18 @@ export const expenseRecordSchema = z
|
|||||||
|
|
||||||
export const expenseRecordsSchema = z.array(expenseRecordSchema);
|
export const expenseRecordsSchema = z.array(expenseRecordSchema);
|
||||||
|
|
||||||
|
export const expenseStudentLookupSchema = z
|
||||||
|
.object({
|
||||||
|
id: z.number(),
|
||||||
|
name: z.string().nullable().optional(),
|
||||||
|
studentNo: z.string().nullable().optional(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
export const expenseStudentLookupsSchema = z.array(expenseStudentLookupSchema);
|
||||||
|
|
||||||
|
export const expenseRoomsListSchema = z.array(z.record(z.string(), z.unknown()));
|
||||||
|
|
||||||
export const expenseLookupsSchema = z
|
export const expenseLookupsSchema = z
|
||||||
.object({
|
.object({
|
||||||
rooms: z.array(z.record(z.string(), z.unknown())),
|
rooms: z.array(z.record(z.string(), z.unknown())),
|
||||||
|
|||||||
44
apps/admin/src/api/schemas/dashboard.integration.test.ts
Normal file
44
apps/admin/src/api/schemas/dashboard.integration.test.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { ganttRoomsSchema } from './dashboard';
|
||||||
|
|
||||||
|
describe('ganttRoomsSchema 接口校验', () => {
|
||||||
|
const validPayload = [
|
||||||
|
{
|
||||||
|
roomNumber: 'A101',
|
||||||
|
occupancies: [
|
||||||
|
{
|
||||||
|
studentName: '张三',
|
||||||
|
studentId: 3,
|
||||||
|
checkInDate: '2026-05-01',
|
||||||
|
checkOutDate: null,
|
||||||
|
billingStartDate: '2026-05-01',
|
||||||
|
billingEndDate: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
it('接受合法的甘特图数据(studentId 为数字)', () => {
|
||||||
|
expect(ganttRoomsSchema.safeParse(validPayload).success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('拒绝缺少 checkInDate 的入住记录', () => {
|
||||||
|
const payload = [
|
||||||
|
{
|
||||||
|
roomNumber: 'A101',
|
||||||
|
occupancies: [{ studentName: '张三', checkOutDate: null }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
expect(ganttRoomsSchema.safeParse(payload).success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('拒绝缺少 studentName 的入住记录', () => {
|
||||||
|
const payload = [
|
||||||
|
{
|
||||||
|
roomNumber: 'A101',
|
||||||
|
occupancies: [{ checkInDate: '2026-05-01', checkOutDate: null }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
expect(ganttRoomsSchema.safeParse(payload).success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,7 +4,8 @@ export const classroomScheduleSchema = z
|
|||||||
.object({
|
.object({
|
||||||
classrooms: z.array(z.record(z.string(), z.unknown())),
|
classrooms: z.array(z.record(z.string(), z.unknown())),
|
||||||
organizations: z.array(z.record(z.string(), z.unknown())),
|
organizations: z.array(z.record(z.string(), z.unknown())),
|
||||||
matrix: z.record(z.string(), z.record(z.string(), z.array(z.record(z.string(), z.unknown())))),
|
// matrix: 教室 id → 日期 → 单条排课/租赁记录(对象,非数组)
|
||||||
|
matrix: z.record(z.string(), z.record(z.string(), z.record(z.string(), z.unknown()))),
|
||||||
summary: z.record(z.string(), z.record(z.string(), z.unknown())),
|
summary: z.record(z.string(), z.record(z.string(), z.unknown())),
|
||||||
days: z.number().optional(),
|
days: z.number().optional(),
|
||||||
})
|
})
|
||||||
@@ -58,8 +59,19 @@ export const classAttendanceRankingSchema = z
|
|||||||
|
|
||||||
export const ganttRoomsSchema = z.array(
|
export const ganttRoomsSchema = z.array(
|
||||||
z
|
z
|
||||||
.object({ roomNumber: z.string(), occupancies: z.array(z.record(z.string(), z.unknown())) })
|
.object({
|
||||||
.passthrough(),
|
roomNumber: z.string(),
|
||||||
|
occupancies: z.array(
|
||||||
|
z.object({
|
||||||
|
studentName: z.string(),
|
||||||
|
studentId: z.union([z.string(), z.number()]).optional(),
|
||||||
|
checkInDate: z.string(),
|
||||||
|
checkOutDate: z.string().nullable(),
|
||||||
|
billingStartDate: z.string().optional(),
|
||||||
|
billingEndDate: z.string().nullable().optional(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const classroomOccupanciesSchema = z.array(
|
export const classroomOccupanciesSchema = z.array(
|
||||||
|
|||||||
@@ -34,6 +34,18 @@ export const importStepDetailSchema = z
|
|||||||
})
|
})
|
||||||
.passthrough();
|
.passthrough();
|
||||||
|
|
||||||
|
export const importRunSettingsSchema = z
|
||||||
|
.object({
|
||||||
|
mapping: z.record(z.string(), z.record(z.string(), z.string())).optional(),
|
||||||
|
organization: z.string().nullable().optional(),
|
||||||
|
updateExisting: z.boolean().optional(),
|
||||||
|
duplicatePolicy: z.enum(['error', 'skip']).optional(),
|
||||||
|
skipUnmatched: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
.passthrough()
|
||||||
|
.nullable()
|
||||||
|
.optional();
|
||||||
|
|
||||||
export const importRunDetailSchema = z
|
export const importRunDetailSchema = z
|
||||||
.object({
|
.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
@@ -44,6 +56,7 @@ export const importRunDetailSchema = z
|
|||||||
createdAt: z.string(),
|
createdAt: z.string(),
|
||||||
sheets: z.array(importSheetMetaSchema),
|
sheets: z.array(importSheetMetaSchema),
|
||||||
steps: z.array(importStepDetailSchema),
|
steps: z.array(importStepDetailSchema),
|
||||||
|
settings: importRunSettingsSchema,
|
||||||
})
|
})
|
||||||
.passthrough();
|
.passthrough();
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,14 @@ export const AiChatSidebar: React.FC<AiChatSidebarProps> = ({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{loadingList && <Spin className="ai-chat-sidebar__loading" />}
|
{loadingList && <Spin className="ai-chat-sidebar__loading" />}
|
||||||
|
{!loadingList && !selectionMode && conversationCount === 0 ? (
|
||||||
|
<div className="ai-chat-sidebar__empty">
|
||||||
|
<Typography.Text type="secondary">暂无会话</Typography.Text>
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
点击「新对话」开始提问
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div className="ai-chat-sidebar__footer">
|
<div className="ai-chat-sidebar__footer">
|
||||||
{selectionMode ? (
|
{selectionMode ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -20,8 +20,11 @@ import {
|
|||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { MenuProps } from 'antd';
|
import type { MenuProps } from 'antd';
|
||||||
import { message } from '../../ui/app-message';
|
import { message } from '../../ui/app-message';
|
||||||
|
import { useUserStore } from '../../store/user/userStore';
|
||||||
|
import { usePermissionStore } from '../../store/permission/permissionStore';
|
||||||
import { aiChatApi, conversationStreamUrl } from './api';
|
import { aiChatApi, conversationStreamUrl } from './api';
|
||||||
import { GongxueAiChatProvider } from './provider';
|
import { GongxueAiChatProvider } from './provider';
|
||||||
|
import { welcomeDescription, workflowPromptExamples } from './welcomeCopy';
|
||||||
import { ImportWizardModal } from '../ImportWizard/ImportWizardModal';
|
import { ImportWizardModal } from '../ImportWizard/ImportWizardModal';
|
||||||
import type { AiSkill } from './types';
|
import type { AiSkill } from './types';
|
||||||
import { useAiChatMessageActions } from './useAiChatMessageActions';
|
import { useAiChatMessageActions } from './useAiChatMessageActions';
|
||||||
@@ -51,10 +54,14 @@ interface AiChatDrawerProps {
|
|||||||
|
|
||||||
const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose, onRequestingChange }) => {
|
const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose, onRequestingChange }) => {
|
||||||
const { modal } = App.useApp();
|
const { modal } = App.useApp();
|
||||||
|
const user = useUserStore((state) => state.user);
|
||||||
|
const permissions = usePermissionStore((state) => state.permissions);
|
||||||
const screens = Grid.useBreakpoint();
|
const screens = Grid.useBreakpoint();
|
||||||
const isMobile = !screens.sm;
|
const isMobile = !screens.sm;
|
||||||
const [loadingList, setLoadingList] = useState(false);
|
const [loadingList, setLoadingList] = useState(false);
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(!isMobile);
|
// 断点首帧可能尚未解析(isMobile 误判为 true),桌面端默认展开会话侧边栏,
|
||||||
|
// 移动端通过 effectiveSidebarOpen 统一隐藏。
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||||
const effectiveSidebarOpen = isMobile ? false : sidebarOpen;
|
const effectiveSidebarOpen = isMobile ? false : sidebarOpen;
|
||||||
const [skills, setSkills] = useState<AiSkill[]>([]);
|
const [skills, setSkills] = useState<AiSkill[]>([]);
|
||||||
const [conversationStatus, setConversationStatus] = useState<
|
const [conversationStatus, setConversationStatus] = useState<
|
||||||
@@ -182,11 +189,32 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose, onRequesting
|
|||||||
|
|
||||||
const switchConversation = useCallback(
|
const switchConversation = useCallback(
|
||||||
(key: string) => {
|
(key: string) => {
|
||||||
discardPendingAttachments();
|
const doSwitch = () => {
|
||||||
if (isMobile) setSidebarOpen(false);
|
discardPendingAttachments();
|
||||||
setActiveConversationKey(key);
|
if (isMobile) setSidebarOpen(false);
|
||||||
|
setActiveConversationKey(key);
|
||||||
|
};
|
||||||
|
// 有待发送的附件时先确认,避免静默删除已上传文件
|
||||||
|
if (uploadItems.length > 0) {
|
||||||
|
modal.confirm({
|
||||||
|
title: '切换会话将丢弃未发送的附件',
|
||||||
|
content: `当前有 ${uploadItems.length} 个已上传但未发送的附件,切换会话后将被删除,此操作不可恢复。`,
|
||||||
|
okText: '切换并丢弃',
|
||||||
|
okButtonProps: { danger: true },
|
||||||
|
cancelText: '留在当前会话',
|
||||||
|
onOk: doSwitch,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
doSwitch();
|
||||||
},
|
},
|
||||||
[discardPendingAttachments, isMobile, setActiveConversationKey],
|
[
|
||||||
|
discardPendingAttachments,
|
||||||
|
isMobile,
|
||||||
|
modal,
|
||||||
|
setActiveConversationKey,
|
||||||
|
uploadItems.length,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
@@ -228,7 +256,7 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose, onRequesting
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[setConversation],
|
[setConversation, modal],
|
||||||
);
|
);
|
||||||
|
|
||||||
/** 删除单个会话时中止请求并清理会话运行时状态 */
|
/** 删除单个会话时中止请求并清理会话运行时状态 */
|
||||||
@@ -269,13 +297,14 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose, onRequesting
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
activeId,
|
activeId,
|
||||||
conversations,
|
conversations,
|
||||||
switchConversation,
|
switchConversation,
|
||||||
removeConversation,
|
removeConversation,
|
||||||
removeConversationEntry,
|
removeConversationEntry,
|
||||||
],
|
modal,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const enterSelectionMode = useCallback(() => {
|
const enterSelectionMode = useCallback(() => {
|
||||||
@@ -358,11 +387,12 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose, onRequesting
|
|||||||
activeId,
|
activeId,
|
||||||
conversations,
|
conversations,
|
||||||
removeConversation,
|
removeConversation,
|
||||||
removeConversationEntry,
|
removeConversationEntry,
|
||||||
selectedKeys,
|
selectedKeys,
|
||||||
switchConversation,
|
switchConversation,
|
||||||
setConversations,
|
setConversations,
|
||||||
]);
|
modal,
|
||||||
|
]);
|
||||||
|
|
||||||
const conversationMenu = useCallback(
|
const conversationMenu = useCallback(
|
||||||
(item: ConversationItemType): MenuProps => ({
|
(item: ConversationItemType): MenuProps => ({
|
||||||
@@ -520,12 +550,21 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose, onRequesting
|
|||||||
title="你好,我是恭学 AI 助手"
|
title="你好,我是恭学 AI 助手"
|
||||||
description={
|
description={
|
||||||
lockedSkill?.description ||
|
lockedSkill?.description ||
|
||||||
'我会在你的权限范围内查询数据,也能通过表单帮你录入学生等业务信息。'
|
welcomeDescription(user?.roles ?? [], permissions)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Prompts
|
<Prompts
|
||||||
title="你可以这样问"
|
title="你可以这样问"
|
||||||
items={promptItems}
|
items={[
|
||||||
|
...promptItems,
|
||||||
|
...workflowPromptExamples(user?.roles ?? [], permissions).map(
|
||||||
|
(item, index) => ({
|
||||||
|
key: `workflow-${index}`,
|
||||||
|
label: item.label,
|
||||||
|
description: item.description,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
]}
|
||||||
wrap
|
wrap
|
||||||
onItemClick={({ data }) => submit(String(data.label || ''))}
|
onItemClick={({ data }) => submit(String(data.label || ''))}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -13,9 +13,12 @@ import type { ThoughtChainItemType } from '@ant-design/x';
|
|||||||
import XMarkdown, { type ComponentProps } from '@ant-design/x-markdown';
|
import XMarkdown, { type ComponentProps } from '@ant-design/x-markdown';
|
||||||
import { Alert, Button, Flex, Input, Space, Typography } from 'antd';
|
import { Alert, Button, Flex, Input, Space, Typography } from 'antd';
|
||||||
import { useUserStore } from '../../store/user/userStore';
|
import { useUserStore } from '../../store/user/userStore';
|
||||||
|
import { message } from '../../ui/app-message';
|
||||||
import { DynamicChart } from './DynamicChart';
|
import { DynamicChart } from './DynamicChart';
|
||||||
import { DynamicForm } from './DynamicForm';
|
import { DynamicForm } from './DynamicForm';
|
||||||
import { DynamicReview } from './DynamicReview';
|
import { DynamicReview } from './DynamicReview';
|
||||||
|
import { deriveCharts, deriveForms, deriveReviews } from './uiArtifacts';
|
||||||
|
import { ArtifactErrorBoundary } from './ArtifactErrorBoundary';
|
||||||
import { LiteCodeHighlighter } from './LiteCodeHighlighter';
|
import { LiteCodeHighlighter } from './LiteCodeHighlighter';
|
||||||
import { LiteMermaid } from './LiteMermaid';
|
import { LiteMermaid } from './LiteMermaid';
|
||||||
import type {
|
import type {
|
||||||
@@ -41,7 +44,6 @@ const toolLabels: Record<string, string> = {
|
|||||||
search_bills: '查询账单',
|
search_bills: '查询账单',
|
||||||
get_dashboard_stats: '读取经营概览',
|
get_dashboard_stats: '读取经营概览',
|
||||||
render_form: '生成表单',
|
render_form: '生成表单',
|
||||||
render_review: '生成导入预览',
|
|
||||||
render_chart: '生成图表',
|
render_chart: '生成图表',
|
||||||
start_import_wizard: '生成导入向导',
|
start_import_wizard: '生成导入向导',
|
||||||
create_student: '创建学生',
|
create_student: '创建学生',
|
||||||
@@ -52,6 +54,9 @@ const toolLabels: Record<string, string> = {
|
|||||||
search_classrooms: '查询教室',
|
search_classrooms: '查询教室',
|
||||||
search_classroom_rentals: '查询教室租用',
|
search_classroom_rentals: '查询教室租用',
|
||||||
get_sync_status: '查询同步状态',
|
get_sync_status: '查询同步状态',
|
||||||
|
get_business_context: '读取业务流程',
|
||||||
|
get_entity_schema: '读取实体字典',
|
||||||
|
get_pending_tasks: '查询业务待办',
|
||||||
};
|
};
|
||||||
|
|
||||||
const markdownComponents = {
|
const markdownComponents = {
|
||||||
@@ -100,6 +105,24 @@ async function openSourceUrl(item: { url?: string }): Promise<void> {
|
|||||||
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
|
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 打开附件,失败时给出明确提示(避免「点了没反应」) */
|
||||||
|
async function handleOpenAttachment(attachment: AiAttachment): Promise<void> {
|
||||||
|
try {
|
||||||
|
await openAttachment(attachment);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
message.error(error instanceof Error ? error.message : '附件打开失败,请重试');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 打出来源链接,失败时给出明确提示 */
|
||||||
|
async function handleOpenSource(item: { url?: string }): Promise<void> {
|
||||||
|
try {
|
||||||
|
await openSourceUrl(item);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
message.error(error instanceof Error ? error.message : '来源打开失败,请重试');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function ToolChain({ tools }: { tools: AiToolRun[] }) {
|
function ToolChain({ tools }: { tools: AiToolRun[] }) {
|
||||||
const items = useMemo<ThoughtChainItemType[]>(
|
const items = useMemo<ThoughtChainItemType[]>(
|
||||||
() =>
|
() =>
|
||||||
@@ -203,6 +226,11 @@ export const AiMessageContent: React.FC<AiMessageContentProps> = ({
|
|||||||
const streaming = status === 'loading' || status === 'updating';
|
const streaming = status === 'loading' || status === 'updating';
|
||||||
const formSubmission = message.metadata?.a2uiSubmit;
|
const formSubmission = message.metadata?.a2uiSubmit;
|
||||||
const reviewSubmission = message.metadata?.a2uiReviewSubmit;
|
const reviewSubmission = message.metadata?.a2uiReviewSubmit;
|
||||||
|
// 统一 artifact 优先,历史消息(仅 legacy 字段)回退
|
||||||
|
const forms = deriveForms(message).length > 0 ? deriveForms(message) : (message.forms ?? []);
|
||||||
|
const reviews =
|
||||||
|
deriveReviews(message).length > 0 ? deriveReviews(message) : (message.reviews ?? []);
|
||||||
|
const charts = deriveCharts(message).length > 0 ? deriveCharts(message) : (message.charts ?? []);
|
||||||
const sourceMeta = message.metadata?.a2uiSources;
|
const sourceMeta = message.metadata?.a2uiSources;
|
||||||
const sourceItems = Array.isArray(sourceMeta)
|
const sourceItems = Array.isArray(sourceMeta)
|
||||||
? sourceMeta
|
? sourceMeta
|
||||||
@@ -224,7 +252,7 @@ export const AiMessageContent: React.FC<AiMessageContentProps> = ({
|
|||||||
byte={attachment.size}
|
byte={attachment.size}
|
||||||
size="small"
|
size="small"
|
||||||
icon={attachmentIcon(attachment)}
|
icon={attachmentIcon(attachment)}
|
||||||
onClick={() => void openAttachment(attachment)}
|
onClick={() => void handleOpenAttachment(attachment)}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
|
|
||||||
@@ -348,30 +376,34 @@ export const AiMessageContent: React.FC<AiMessageContentProps> = ({
|
|||||||
<Sources
|
<Sources
|
||||||
items={sourceItems}
|
items={sourceItems}
|
||||||
title="引用来源"
|
title="引用来源"
|
||||||
onClick={(item) => void openSourceUrl(item as { url?: string })}
|
onClick={(item) => void handleOpenSource(item as { url?: string })}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{(message.forms ?? []).map((form) => (
|
{(forms ?? []).map((form) => (
|
||||||
<DynamicForm
|
<ArtifactErrorBoundary key={form.id} title="表单">
|
||||||
key={form.id}
|
<DynamicForm
|
||||||
form={form}
|
form={form}
|
||||||
disabled={streaming}
|
disabled={streaming}
|
||||||
onSubmit={(values) => onSubmitForm?.(form, values)}
|
onSubmit={(values) => onSubmitForm?.(form, values)}
|
||||||
/>
|
/>
|
||||||
|
</ArtifactErrorBoundary>
|
||||||
))}
|
))}
|
||||||
{(message.reviews ?? []).map((review: AiReviewSchema) => (
|
{(reviews ?? []).map((review: AiReviewSchema) => (
|
||||||
<DynamicReview
|
<ArtifactErrorBoundary key={review.id} title="导入预览">
|
||||||
key={review.id}
|
<DynamicReview
|
||||||
review={review}
|
review={review}
|
||||||
messageId={typeof message.id === 'number' ? message.id : undefined}
|
messageId={typeof message.id === 'number' ? message.id : undefined}
|
||||||
disabled={streaming}
|
disabled={streaming}
|
||||||
onSubmit={(reviewId) => onSubmitReview?.(reviewId, review.title)}
|
onSubmit={(reviewId) => onSubmitReview?.(reviewId, review.title)}
|
||||||
onConfirmStep={onConfirmReviewStep}
|
onConfirmStep={onConfirmReviewStep}
|
||||||
onConfirmGroup={onConfirmReviewGroup}
|
onConfirmGroup={onConfirmReviewGroup}
|
||||||
/>
|
/>
|
||||||
|
</ArtifactErrorBoundary>
|
||||||
))}
|
))}
|
||||||
{(message.charts ?? []).map((chart: AiChartSchema) => (
|
{(charts ?? []).map((chart: AiChartSchema) => (
|
||||||
<DynamicChart key={chart.id} chart={chart} />
|
<ArtifactErrorBoundary key={chart.id} title="图表">
|
||||||
|
<DynamicChart chart={chart} />
|
||||||
|
</ArtifactErrorBoundary>
|
||||||
))}
|
))}
|
||||||
{message.error && <Alert type="error" showIcon title={message.error} />}
|
{message.error && <Alert type="error" showIcon title={message.error} />}
|
||||||
{message.cancelled && <Typography.Text type="secondary">回答已停止</Typography.Text>}
|
{message.cancelled && <Typography.Text type="secondary">回答已停止</Typography.Text>}
|
||||||
|
|||||||
47
apps/admin/src/components/AiChat/ArtifactErrorBoundary.tsx
Normal file
47
apps/admin/src/components/AiChat/ArtifactErrorBoundary.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Alert } from 'antd';
|
||||||
|
|
||||||
|
interface ArtifactErrorBoundaryProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
/** 制品标题(用于错误提示文案) */
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ArtifactErrorBoundaryState {
|
||||||
|
hasError: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A2UI 制品(表单/审查卡/图表)渲染错误兜底:单个制品渲染失败只降级为
|
||||||
|
* 错误占位卡片,不影响同气泡内其他消息与制品。
|
||||||
|
*/
|
||||||
|
export class ArtifactErrorBoundary extends React.Component<
|
||||||
|
ArtifactErrorBoundaryProps,
|
||||||
|
ArtifactErrorBoundaryState
|
||||||
|
> {
|
||||||
|
state: ArtifactErrorBoundaryState = { hasError: false };
|
||||||
|
|
||||||
|
static getDerivedStateFromError(): ArtifactErrorBoundaryState {
|
||||||
|
return { hasError: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, info: React.ErrorInfo): void {
|
||||||
|
console.error('[ArtifactErrorBoundary] 制品渲染异常:', error, info.componentStack);
|
||||||
|
}
|
||||||
|
|
||||||
|
render(): React.ReactNode {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return (
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
showIcon
|
||||||
|
title={this.props.title ? `${this.props.title}渲染失败` : '此内容渲染失败'}
|
||||||
|
description="请让 AI 重新生成,或刷新后重试。"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ArtifactErrorBoundary;
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import React, { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react';
|
import React, { lazy, Suspense, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { saveAs } from 'file-saver';
|
||||||
import { XCard, registerCatalog, type XAgentCommand_v0_9 } from '@ant-design/x-card';
|
import { XCard, registerCatalog, type XAgentCommand_v0_9 } from '@ant-design/x-card';
|
||||||
import { Button, Spin, Tag, Tooltip, Typography } from 'antd';
|
import { Button, Spin, Tag, Tooltip, Typography } from 'antd';
|
||||||
import { DownloadOutlined } from '@ant-design/icons';
|
import { DownloadOutlined } from '@ant-design/icons';
|
||||||
import type { EChartsType } from 'echarts/core';
|
import type { EChartsType } from 'echarts/core';
|
||||||
import type { EChartsOption } from '../../components/ECharts';
|
import type { EChartsOption } from '../../components/ECharts';
|
||||||
import type { AiChartSchema } from './types';
|
import type { AiChartSchema } from './types';
|
||||||
|
import { useXCardSurface } from './useSubmissionState';
|
||||||
|
|
||||||
// echarts 体积较大,仅在真正渲染图表时加载,避免打开 AI 抽屉就拉取
|
// echarts 体积较大,仅在真正渲染图表时加载,避免打开 AI 抽屉就拉取
|
||||||
const ReactECharts = lazy(() => import('../../components/ECharts'));
|
const ReactECharts = lazy(() => import('../../components/ECharts'));
|
||||||
@@ -199,9 +201,35 @@ interface ChartPreviewProps {
|
|||||||
* renders an ECharts option built from it.
|
* renders an ECharts option built from it.
|
||||||
*/
|
*/
|
||||||
const ChartPreview: React.FC<ChartPreviewProps> = ({ chart }) => {
|
const ChartPreview: React.FC<ChartPreviewProps> = ({ chart }) => {
|
||||||
const option = useMemo<EChartsOption>(() => (chart ? buildOption(chart) : {}), [chart]);
|
// 空/无数据时先短路,避免 buildOption 在空数据集上执行
|
||||||
|
const hasData = !!chart && !!chart.rows && chart.rows.length > 0;
|
||||||
|
const option = useMemo<EChartsOption>(
|
||||||
|
() => (chart && hasData ? buildOption(chart) : {}),
|
||||||
|
[chart, hasData],
|
||||||
|
);
|
||||||
const [instance, setInstance] = useState<EChartsType | null>(null);
|
const [instance, setInstance] = useState<EChartsType | null>(null);
|
||||||
if (!chart) return null;
|
if (!chart) return null;
|
||||||
|
// 空数据集:渲染明确占位,而不是一张空白图
|
||||||
|
if (!hasData) {
|
||||||
|
return (
|
||||||
|
<div className="ai-chat-chart-card">
|
||||||
|
<div className="ai-chat-chart-card__header">
|
||||||
|
<Typography.Text strong>{chart.title}</Typography.Text>
|
||||||
|
<Tag color="blue">{CHART_TYPE_LABELS[chart.chartType] ?? chart.chartType}</Tag>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: 120,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography.Text type="secondary">暂无数据</Typography.Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const downloadImage = () => {
|
const downloadImage = () => {
|
||||||
if (!instance) return;
|
if (!instance) return;
|
||||||
@@ -210,12 +238,7 @@ const ChartPreview: React.FC<ChartPreviewProps> = ({ chart }) => {
|
|||||||
pixelRatio: 2,
|
pixelRatio: 2,
|
||||||
backgroundColor: '#fff',
|
backgroundColor: '#fff',
|
||||||
});
|
});
|
||||||
const link = document.createElement('a');
|
saveAs(url, `${chart.title || '图表'}.png`);
|
||||||
link.href = url;
|
|
||||||
link.download = `${chart.title || '图表'}.png`;
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
link.remove();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -254,46 +277,39 @@ export interface DynamicChartProps {
|
|||||||
* so history replays identically.
|
* so history replays identically.
|
||||||
*/
|
*/
|
||||||
export const DynamicChart: React.FC<DynamicChartProps> = ({ chart }) => {
|
export const DynamicChart: React.FC<DynamicChartProps> = ({ chart }) => {
|
||||||
const commandsRef = useRef<XAgentCommand_v0_9[]>([]);
|
const sid = surfaceId(chart.id);
|
||||||
const [commands, setCommands] = useState<XAgentCommand_v0_9[]>([]);
|
const { commands, pushCommands } = useXCardSurface(sid);
|
||||||
const idRef = useRef<string>('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const sid = surfaceId(chart.id);
|
const cmds: XAgentCommand_v0_9[] = [
|
||||||
if (idRef.current !== sid) {
|
{
|
||||||
commandsRef.current = [];
|
|
||||||
idRef.current = sid;
|
|
||||||
}
|
|
||||||
const cmds = commandsRef.current;
|
|
||||||
if (cmds.length === 0) {
|
|
||||||
cmds.push({
|
|
||||||
version: 'v0.9',
|
version: 'v0.9',
|
||||||
createSurface: { surfaceId: sid, catalogId: CHART_CATALOG_ID },
|
createSurface: { surfaceId: sid, catalogId: CHART_CATALOG_ID },
|
||||||
});
|
|
||||||
}
|
|
||||||
cmds.push({
|
|
||||||
version: 'v0.9',
|
|
||||||
updateDataModel: {
|
|
||||||
surfaceId: sid,
|
|
||||||
path: '/chart',
|
|
||||||
value: chart,
|
|
||||||
},
|
},
|
||||||
});
|
{
|
||||||
cmds.push({
|
version: 'v0.9',
|
||||||
version: 'v0.9',
|
updateDataModel: {
|
||||||
updateComponents: {
|
surfaceId: sid,
|
||||||
surfaceId: sid,
|
path: '/chart',
|
||||||
components: [
|
value: chart,
|
||||||
{
|
},
|
||||||
id: 'root',
|
|
||||||
component: 'ChartPreview',
|
|
||||||
chart: { path: '/chart' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
});
|
{
|
||||||
setCommands([...cmds]);
|
version: 'v0.9',
|
||||||
}, [chart]);
|
updateComponents: {
|
||||||
|
surfaceId: sid,
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
id: 'root',
|
||||||
|
component: 'ChartPreview',
|
||||||
|
chart: { path: '/chart' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
pushCommands(cmds);
|
||||||
|
}, [chart, pushCommands, sid]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ai-chat-chart">
|
<div className="ai-chat-chart">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
XCard,
|
XCard,
|
||||||
registerCatalog,
|
registerCatalog,
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
} from 'antd';
|
} from 'antd';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import type { AiFormField, AiFormSchema } from './types';
|
import type { AiFormField, AiFormSchema } from './types';
|
||||||
|
import { useSubmissionState, useXCardSurface } from './useSubmissionState';
|
||||||
|
|
||||||
const FORM_CATALOG_ID = 'gongxue-form-catalog';
|
const FORM_CATALOG_ID = 'gongxue-form-catalog';
|
||||||
|
|
||||||
@@ -84,6 +85,7 @@ const FormPreview: React.FC<FormPreviewProps> = ({ form, disabled, onAction }) =
|
|||||||
);
|
);
|
||||||
if (!form) return null;
|
if (!form) return null;
|
||||||
const finished = Boolean(form.submitted) || form.status === 'submitted';
|
const finished = Boolean(form.submitted) || form.status === 'submitted';
|
||||||
|
const expired = form.status === 'expired';
|
||||||
|
|
||||||
const handleFinish = (values: Record<string, unknown>) => {
|
const handleFinish = (values: Record<string, unknown>) => {
|
||||||
onAction?.('form:submit', { values: normalizeValues(form.fields, values) });
|
onAction?.('form:submit', { values: normalizeValues(form.fields, values) });
|
||||||
@@ -97,8 +99,10 @@ const FormPreview: React.FC<FormPreviewProps> = ({ form, disabled, onAction }) =
|
|||||||
{form.description}
|
{form.description}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
)}
|
)}
|
||||||
{finished ? (
|
{expired ? (
|
||||||
<Alert type="success" showIcon message="已提交,AI 正在处理…" />
|
<Alert type="warning" showIcon title="表单已失效" description="此表单已被新的请求替代,请让助手重新生成。" />
|
||||||
|
) : finished ? (
|
||||||
|
<Alert type="success" showIcon title="已提交,AI 正在处理…" />
|
||||||
) : (
|
) : (
|
||||||
<Form
|
<Form
|
||||||
layout="vertical"
|
layout="vertical"
|
||||||
@@ -176,63 +180,46 @@ export interface DynamicFormProps {
|
|||||||
* success/failure/loading transitions are pushed as incremental commands.
|
* success/failure/loading transitions are pushed as incremental commands.
|
||||||
*/
|
*/
|
||||||
export const DynamicForm: React.FC<DynamicFormProps> = ({ form, disabled, onSubmit }) => {
|
export const DynamicForm: React.FC<DynamicFormProps> = ({ form, disabled, onSubmit }) => {
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const sid = surfaceId(form.id);
|
||||||
const [submitted, setSubmitted] = useState(false);
|
const { submitting, submitted, error, run } = useSubmissionState();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const { commands, pushCommands } = useXCardSurface(sid);
|
||||||
const commandsRef = useRef<XAgentCommand_v0_9[]>([]);
|
|
||||||
const [commands, setCommands] = useState<XAgentCommand_v0_9[]>([]);
|
|
||||||
const idRef = useRef<string>('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const sid = surfaceId(form.id);
|
const cmds: XAgentCommand_v0_9[] = [
|
||||||
if (idRef.current !== sid) {
|
{
|
||||||
commandsRef.current = [];
|
|
||||||
idRef.current = sid;
|
|
||||||
}
|
|
||||||
const cmds = commandsRef.current;
|
|
||||||
if (cmds.length === 0) {
|
|
||||||
cmds.push({
|
|
||||||
version: 'v0.9',
|
version: 'v0.9',
|
||||||
createSurface: { surfaceId: sid, catalogId: FORM_CATALOG_ID },
|
createSurface: { surfaceId: sid, catalogId: FORM_CATALOG_ID },
|
||||||
});
|
|
||||||
}
|
|
||||||
cmds.push({
|
|
||||||
version: 'v0.9',
|
|
||||||
updateDataModel: {
|
|
||||||
surfaceId: sid,
|
|
||||||
path: '/form',
|
|
||||||
value: { ...form, submitting, submitted, error },
|
|
||||||
},
|
},
|
||||||
});
|
{
|
||||||
cmds.push({
|
version: 'v0.9',
|
||||||
version: 'v0.9',
|
updateDataModel: {
|
||||||
updateComponents: {
|
surfaceId: sid,
|
||||||
surfaceId: sid,
|
path: '/form',
|
||||||
components: [
|
value: { ...form, submitting, submitted, error },
|
||||||
{
|
},
|
||||||
id: 'root',
|
|
||||||
component: 'FormPreview',
|
|
||||||
form: { path: '/form' },
|
|
||||||
disabled: Boolean(disabled),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
});
|
{
|
||||||
setCommands([...cmds]);
|
version: 'v0.9',
|
||||||
}, [disabled, error, form, submitted, submitting]);
|
updateComponents: {
|
||||||
|
surfaceId: sid,
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
id: 'root',
|
||||||
|
component: 'FormPreview',
|
||||||
|
form: { path: '/form' },
|
||||||
|
disabled: Boolean(disabled),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
pushCommands(cmds);
|
||||||
|
}, [disabled, error, form, pushCommands, sid, submitted, submitting]);
|
||||||
|
|
||||||
const handleSubmit = async (values: Record<string, unknown>) => {
|
const handleSubmit = (values: Record<string, unknown>) => {
|
||||||
if (submitting) return;
|
void run(async () => {
|
||||||
setSubmitting(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
await onSubmit(values);
|
await onSubmit(values);
|
||||||
setSubmitted(true);
|
});
|
||||||
} catch (reason) {
|
|
||||||
setError(reason instanceof Error ? reason.message : '提交失败,请稍后重试');
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAction = (payload: ActionPayload) => {
|
const handleAction = (payload: ActionPayload) => {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
type TableProps,
|
type TableProps,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { AiReviewRow, AiReviewSchema, AiReviewSection, AiReviewSectionType } from './types';
|
import type { AiReviewRow, AiReviewSchema, AiReviewSection, AiReviewSectionType } from './types';
|
||||||
|
import { useXCardSurface } from './useSubmissionState';
|
||||||
import {
|
import {
|
||||||
GROUP_STATUS_LABELS,
|
GROUP_STATUS_LABELS,
|
||||||
SECTION_ORDER,
|
SECTION_ORDER,
|
||||||
@@ -183,7 +184,7 @@ const ReviewPreview: React.FC<ReviewPreviewProps> = ({ review, disabled, onActio
|
|||||||
<Alert
|
<Alert
|
||||||
type="warning"
|
type="warning"
|
||||||
showIcon
|
showIcon
|
||||||
message="此导入预览已被新的预览替代,已失效"
|
title="此导入预览已被新的预览替代,已失效"
|
||||||
description="如需导入,请使用最新的预览卡。"
|
description="如需导入,请使用最新的预览卡。"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -341,7 +342,7 @@ const ReviewPreview: React.FC<ReviewPreviewProps> = ({ review, disabled, onActio
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{activeStatus === 'failed' && (
|
{activeStatus === 'failed' && (
|
||||||
<Alert type="error" showIcon message="本步导入失败,可重试" />
|
<Alert type="error" showIcon title="本步导入失败,可重试" />
|
||||||
)}
|
)}
|
||||||
{activeSection.resultSummary && activeStatus === 'submitted' && (
|
{activeSection.resultSummary && activeStatus === 'submitted' && (
|
||||||
<Typography.Text type="secondary" className="ai-chat-review-card__step-result">
|
<Typography.Text type="secondary" className="ai-chat-review-card__step-result">
|
||||||
@@ -377,7 +378,7 @@ const ReviewPreview: React.FC<ReviewPreviewProps> = ({ review, disabled, onActio
|
|||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
)}
|
)}
|
||||||
</Flex>
|
</Flex>
|
||||||
{submitted && <Alert type="success" showIcon message="已确认导入,数据已入库" />}
|
{submitted && <Alert type="success" showIcon title="已确认导入,数据已入库" />}
|
||||||
{review.error && (
|
{review.error && (
|
||||||
<Alert
|
<Alert
|
||||||
type="error"
|
type="error"
|
||||||
@@ -430,9 +431,8 @@ export const DynamicReview: React.FC<DynamicReviewProps> = ({
|
|||||||
activeTypeRef.current = activeType;
|
activeTypeRef.current = activeType;
|
||||||
const [localReview, setLocalReview] = useState<AiReviewSchema>(review);
|
const [localReview, setLocalReview] = useState<AiReviewSchema>(review);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const commandsRef = useRef<XAgentCommand_v0_9[]>([]);
|
const sid = surfaceId(localReview.id);
|
||||||
const [commands, setCommands] = useState<XAgentCommand_v0_9[]>([]);
|
const { commands, pushCommands } = useXCardSurface(sid);
|
||||||
const idRef = useRef<string>('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLocalReview(review);
|
setLocalReview(review);
|
||||||
@@ -455,55 +455,51 @@ export const DynamicReview: React.FC<DynamicReviewProps> = ({
|
|||||||
}, [review]);
|
}, [review]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const sid = surfaceId(localReview.id);
|
const cmds: XAgentCommand_v0_9[] = [
|
||||||
if (idRef.current !== sid) {
|
{
|
||||||
commandsRef.current = [];
|
|
||||||
idRef.current = sid;
|
|
||||||
}
|
|
||||||
const cmds = commandsRef.current;
|
|
||||||
if (cmds.length === 0) {
|
|
||||||
cmds.push({
|
|
||||||
version: 'v0.9',
|
version: 'v0.9',
|
||||||
createSurface: { surfaceId: sid, catalogId: REVIEW_CATALOG_ID },
|
createSurface: { surfaceId: sid, catalogId: REVIEW_CATALOG_ID },
|
||||||
});
|
},
|
||||||
}
|
{
|
||||||
cmds.push({
|
version: 'v0.9',
|
||||||
version: 'v0.9',
|
updateDataModel: {
|
||||||
updateDataModel: {
|
surfaceId: sid,
|
||||||
surfaceId: sid,
|
path: '/review',
|
||||||
path: '/review',
|
value: {
|
||||||
value: {
|
...localReview,
|
||||||
...localReview,
|
submitting,
|
||||||
submitting,
|
activeKey,
|
||||||
activeKey,
|
activeType,
|
||||||
activeType,
|
submittingKey,
|
||||||
submittingKey,
|
submittingGroup,
|
||||||
submittingGroup,
|
error,
|
||||||
error,
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
{
|
||||||
cmds.push({
|
version: 'v0.9',
|
||||||
version: 'v0.9',
|
updateComponents: {
|
||||||
updateComponents: {
|
surfaceId: sid,
|
||||||
surfaceId: sid,
|
components: [
|
||||||
components: [
|
{
|
||||||
{
|
id: 'root',
|
||||||
id: 'root',
|
component: 'ReviewPreview',
|
||||||
component: 'ReviewPreview',
|
review: { path: '/review' },
|
||||||
review: { path: '/review' },
|
disabled: Boolean(disabled),
|
||||||
disabled: Boolean(disabled),
|
},
|
||||||
},
|
],
|
||||||
],
|
},
|
||||||
},
|
},
|
||||||
});
|
];
|
||||||
setCommands([...cmds]);
|
pushCommands(cmds);
|
||||||
}, [
|
}, [
|
||||||
activeKey,
|
activeKey,
|
||||||
activeType,
|
activeType,
|
||||||
disabled,
|
disabled,
|
||||||
error,
|
error,
|
||||||
localReview,
|
localReview,
|
||||||
|
pushCommands,
|
||||||
|
sid,
|
||||||
submitting,
|
submitting,
|
||||||
submittingGroup,
|
submittingGroup,
|
||||||
submittingKey,
|
submittingKey,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useIsMounted } from 'usehooks-ts';
|
||||||
|
|
||||||
interface LiteMermaidProps {
|
interface LiteMermaidProps {
|
||||||
children: string;
|
children: string;
|
||||||
@@ -11,9 +12,9 @@ interface LiteMermaidProps {
|
|||||||
export function LiteMermaid({ children }: LiteMermaidProps) {
|
export function LiteMermaid({ children }: LiteMermaidProps) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const isMounted = useIsMounted();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
@@ -22,21 +23,17 @@ export function LiteMermaid({ children }: LiteMermaidProps) {
|
|||||||
const mermaid = (await import('mermaid')).default;
|
const mermaid = (await import('mermaid')).default;
|
||||||
mermaid.initialize({ startOnLoad: false, theme: 'neutral', securityLevel: 'strict' });
|
mermaid.initialize({ startOnLoad: false, theme: 'neutral', securityLevel: 'strict' });
|
||||||
const { svg } = await mermaid.render(`mermaid-${crypto.randomUUID()}`, children);
|
const { svg } = await mermaid.render(`mermaid-${crypto.randomUUID()}`, children);
|
||||||
if (!cancelled) {
|
if (isMounted()) {
|
||||||
const doc = new DOMParser().parseFromString(svg, 'image/svg+xml');
|
const doc = new DOMParser().parseFromString(svg, 'image/svg+xml');
|
||||||
container.replaceChildren(doc.documentElement);
|
container.replaceChildren(doc.documentElement);
|
||||||
setError(null);
|
setError(null);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!cancelled) {
|
if (isMounted()) {
|
||||||
setError(e instanceof Error ? e.message : '图表渲染失败');
|
setError(e instanceof Error ? e.message : '图表渲染失败');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [children]);
|
}, [children]);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|||||||
@@ -30,13 +30,19 @@ export const aiChatApi = {
|
|||||||
`${basePath}/${conversationId}/messages/${messageId}`,
|
`${basePath}/${conversationId}/messages/${messageId}`,
|
||||||
)
|
)
|
||||||
).data,
|
).data,
|
||||||
uploadAttachment: async (file: File): Promise<AiAttachment> => {
|
uploadAttachment: async (
|
||||||
|
file: File,
|
||||||
|
onProgress?: (percent: number) => void,
|
||||||
|
): Promise<AiAttachment> => {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append('file', file);
|
form.append('file', file);
|
||||||
return (
|
return (
|
||||||
await api.post<AiApiResponse<AiAttachment>>('/ai/chat/attachments', form, {
|
await api.post<AiApiResponse<AiAttachment>>('/ai/chat/attachments', form, {
|
||||||
headers: { 'Content-Type': 'multipart/form-data' },
|
|
||||||
timeout: 120_000,
|
timeout: 120_000,
|
||||||
|
onUploadProgress: (event) => {
|
||||||
|
if (!onProgress || !event.total) return;
|
||||||
|
onProgress(Math.min(Math.round((event.loaded / event.total) * 100), 100));
|
||||||
|
},
|
||||||
})
|
})
|
||||||
).data;
|
).data;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Bubble } from '@ant-design/x';
|
|||||||
import { afterEach, describe, expect, it } from 'vitest';
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
import { aiBubbleRoles, conversationStatusMeta } from './AiChatDrawer';
|
import { aiBubbleRoles, conversationStatusMeta } from './AiChatDrawer';
|
||||||
import { AiMessageContent } from './AiMessageContent';
|
import { AiMessageContent } from './AiMessageContent';
|
||||||
|
import { ArtifactErrorBoundary } from './ArtifactErrorBoundary';
|
||||||
import { DynamicChart } from './DynamicChart';
|
import { DynamicChart } from './DynamicChart';
|
||||||
import { DynamicForm } from './DynamicForm';
|
import { DynamicForm } from './DynamicForm';
|
||||||
import { DynamicReview } from './DynamicReview';
|
import { DynamicReview } from './DynamicReview';
|
||||||
@@ -102,6 +103,38 @@ describe('AI chat bubble rendering', () => {
|
|||||||
expect(container.textContent).toContain('已提交');
|
expect(container.textContent).toContain('已提交');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders an expired A2UI form as disabled without submit action', async () => {
|
||||||
|
let submitted = false;
|
||||||
|
const el = document.createElement('div');
|
||||||
|
container = el;
|
||||||
|
document.body.appendChild(el);
|
||||||
|
root = createRoot(el);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(
|
||||||
|
<DynamicForm
|
||||||
|
form={{
|
||||||
|
id: 'form-expired',
|
||||||
|
title: '已失效的表单',
|
||||||
|
status: 'expired',
|
||||||
|
fields: [{ name: 'name', label: '姓名', type: 'input', required: true }],
|
||||||
|
}}
|
||||||
|
onSubmit={() => {
|
||||||
|
submitted = true;
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(el.textContent).toContain('已失效');
|
||||||
|
expect(el.querySelector('button[type="submit"]')).toBeNull();
|
||||||
|
await act(async () => {
|
||||||
|
const buttons = Array.from(el.querySelectorAll('button'));
|
||||||
|
buttons.forEach((button) => button.click());
|
||||||
|
});
|
||||||
|
expect(submitted).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('renders an A2UI review card and submits via the confirm button', async () => {
|
it('renders an A2UI review card and submits via the confirm button', async () => {
|
||||||
let submittedId: string | null = null;
|
let submittedId: string | null = null;
|
||||||
const review: AiReviewSchema = {
|
const review: AiReviewSchema = {
|
||||||
@@ -472,4 +505,49 @@ describe('AI chat bubble rendering', () => {
|
|||||||
expect(container.textContent).toContain(label);
|
expect(container.textContent).toContain(label);
|
||||||
expect(container.querySelector('.ai-chat-chart-card canvas')).not.toBeNull();
|
expect(container.querySelector('.ai-chat-chart-card canvas')).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders an empty-data placeholder instead of a blank chart', async () => {
|
||||||
|
const chart: AiChartSchema = {
|
||||||
|
id: 'chart-empty',
|
||||||
|
title: '空图表',
|
||||||
|
chartType: 'bar',
|
||||||
|
columns: [{ key: 'name', title: '名称' }],
|
||||||
|
rows: [],
|
||||||
|
};
|
||||||
|
container = document.createElement('div');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(<DynamicChart chart={chart} />);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.textContent).toContain('空图表');
|
||||||
|
expect(container.textContent).toContain('暂无数据');
|
||||||
|
expect(container.querySelector('canvas')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('degrades a single failing artifact to an error card without crashing the bubble', async () => {
|
||||||
|
const Bomb: React.FC = () => {
|
||||||
|
throw new Error('boom');
|
||||||
|
};
|
||||||
|
container = document.createElement('div');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
|
||||||
|
// 用无错误边界的兄弟节点 + 错误边界内的炸弹组件验证隔离
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(
|
||||||
|
<div>
|
||||||
|
<div className="neighbor">正常内容</div>
|
||||||
|
<ArtifactErrorBoundary title="表单">
|
||||||
|
<Bomb />
|
||||||
|
</ArtifactErrorBoundary>
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.querySelector('.neighbor')?.textContent).toContain('正常内容');
|
||||||
|
expect(container.textContent).toContain('表单渲染失败');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -78,6 +78,58 @@ describe('AI chat history mapper', () => {
|
|||||||
expect(mapped.message.forms?.[0]).toMatchObject({ id: 'form-9', title: '新增学生' });
|
expect(mapped.message.forms?.[0]).toMatchObject({ id: 'form-9', title: '新增学生' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('restores uiArtifacts from message metadata (统一协议)', () => {
|
||||||
|
const mapped = mapHistoryMessage({
|
||||||
|
id: 8,
|
||||||
|
role: 'assistant',
|
||||||
|
content: '请处理',
|
||||||
|
reasoningContent: null,
|
||||||
|
status: 'completed',
|
||||||
|
errorCode: null,
|
||||||
|
createdAt: '2026-07-23T00:00:00.000Z',
|
||||||
|
metadata: {
|
||||||
|
uiArtifacts: [
|
||||||
|
{
|
||||||
|
id: 'form-10',
|
||||||
|
type: 'form',
|
||||||
|
status: 'submitted',
|
||||||
|
messageId: 8,
|
||||||
|
conversationId: 3,
|
||||||
|
payload: {
|
||||||
|
id: 'form-10',
|
||||||
|
title: '新增学生',
|
||||||
|
status: 'submitted',
|
||||||
|
fields: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'review-10',
|
||||||
|
type: 'review',
|
||||||
|
status: 'expired',
|
||||||
|
messageId: 8,
|
||||||
|
conversationId: 3,
|
||||||
|
payload: {
|
||||||
|
id: 'review-10',
|
||||||
|
title: '旧预览',
|
||||||
|
status: 'expired',
|
||||||
|
sections: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mapped.message.uiArtifacts).toHaveLength(2);
|
||||||
|
expect(mapped.message.uiArtifacts?.[0].payload).toMatchObject({
|
||||||
|
id: 'form-10',
|
||||||
|
status: 'submitted',
|
||||||
|
});
|
||||||
|
expect(mapped.message.uiArtifacts?.[1].payload).toMatchObject({
|
||||||
|
id: 'review-10',
|
||||||
|
status: 'expired',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('restores a persisted A2UI review from message metadata', () => {
|
it('restores a persisted A2UI review from message metadata', () => {
|
||||||
const mapped = mapHistoryMessage({
|
const mapped = mapHistoryMessage({
|
||||||
id: 6,
|
id: 6,
|
||||||
@@ -139,4 +191,5 @@ describe('AI chat history mapper', () => {
|
|||||||
expect(mapped.message.charts).toHaveLength(1);
|
expect(mapped.message.charts).toHaveLength(1);
|
||||||
expect(mapped.message.charts?.[0]).toMatchObject({ id: 'chart-9', chartType: 'bar' });
|
expect(mapped.message.charts?.[0]).toMatchObject({ id: 'chart-9', chartType: 'bar' });
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ import type {
|
|||||||
AiChatMessage,
|
AiChatMessage,
|
||||||
AiChatMessageStatus,
|
AiChatMessageStatus,
|
||||||
AiChartSchema,
|
AiChartSchema,
|
||||||
|
AiArtifactSchema,
|
||||||
AiFormSchema,
|
AiFormSchema,
|
||||||
AiMessageRecord,
|
AiMessageRecord,
|
||||||
AiReviewSchema,
|
AiReviewSchema,
|
||||||
AiToolRun,
|
AiToolRun,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
import { mergeArtifactIntoMessage } from './uiArtifacts';
|
||||||
|
|
||||||
function mapStatus(record: AiMessageRecord): AiChatMessageStatus {
|
function mapStatus(record: AiMessageRecord): AiChatMessageStatus {
|
||||||
if (record.status === 'pending') return 'loading';
|
if (record.status === 'pending') return 'loading';
|
||||||
@@ -50,24 +52,40 @@ function historyCharts(record: AiMessageRecord): AiChartSchema[] | undefined {
|
|||||||
return [a2uiChart as AiChartSchema];
|
return [a2uiChart as AiChartSchema];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function historyArtifacts(record: AiMessageRecord) {
|
||||||
|
const artifacts = record.metadata?.uiArtifacts;
|
||||||
|
if (!Array.isArray(artifacts)) return undefined;
|
||||||
|
return artifacts.filter(
|
||||||
|
(item): item is AiArtifactSchema =>
|
||||||
|
Boolean(item) && typeof item === 'object' && typeof (item as AiArtifactSchema).id === 'string',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function mapHistoryMessage(record: AiMessageRecord): MessageInfo<AiChatMessage> {
|
export function mapHistoryMessage(record: AiMessageRecord): MessageInfo<AiChatMessage> {
|
||||||
|
const artifacts = historyArtifacts(record);
|
||||||
|
const baseMessage = {
|
||||||
|
id: record.id,
|
||||||
|
role: record.role,
|
||||||
|
content: record.content || '',
|
||||||
|
reasoningContent: record.reasoningContent || '',
|
||||||
|
toolRuns: (record.toolRuns || []).map(normalizeToolRun),
|
||||||
|
attachments: record.attachments ?? [],
|
||||||
|
forms: historyForms(record),
|
||||||
|
reviews: historyReviews(record),
|
||||||
|
charts: historyCharts(record),
|
||||||
|
replyToMessageId: record.replyToMessageId,
|
||||||
|
metadata: record.metadata,
|
||||||
|
error: record.status === 'failed' ? record.errorCode || 'AI 回答生成失败' : undefined,
|
||||||
|
cancelled: record.status === 'cancelled',
|
||||||
|
};
|
||||||
|
if (artifacts) {
|
||||||
|
for (const artifact of artifacts) {
|
||||||
|
mergeArtifactIntoMessage(baseMessage as AiChatMessage, artifact);
|
||||||
|
}
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
id: record.id,
|
id: record.id,
|
||||||
status: mapStatus(record),
|
status: mapStatus(record),
|
||||||
message: {
|
message: baseMessage as AiChatMessage,
|
||||||
id: record.id,
|
|
||||||
role: record.role,
|
|
||||||
content: record.content || '',
|
|
||||||
reasoningContent: record.reasoningContent || '',
|
|
||||||
toolRuns: (record.toolRuns || []).map(normalizeToolRun),
|
|
||||||
attachments: record.attachments ?? [],
|
|
||||||
forms: historyForms(record),
|
|
||||||
reviews: historyReviews(record),
|
|
||||||
charts: historyCharts(record),
|
|
||||||
replyToMessageId: record.replyToMessageId,
|
|
||||||
metadata: record.metadata,
|
|
||||||
error: record.status === 'failed' ? record.errorCode || 'AI 回答生成失败' : undefined,
|
|
||||||
cancelled: record.status === 'cancelled',
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,37 +123,42 @@ describe('AI chat SSE message reducer', () => {
|
|||||||
expect(message.id).toBe(9);
|
expect(message.id).toBe(9);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('merges ui.form events into the assistant message by id', () => {
|
it('merges ui.artifact events into uiArtifacts by id', () => {
|
||||||
const form = {
|
|
||||||
id: 'form-1',
|
|
||||||
title: '新增学生',
|
|
||||||
submitLabel: '提交创建',
|
|
||||||
fields: [
|
|
||||||
{ name: 'name', label: '姓名', type: 'input', required: true },
|
|
||||||
{ name: 'gender', label: '性别', type: 'select', options: [{ label: '男', value: 'male' }] },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
let message = reduceAiSseMessage(undefined, {
|
let message = reduceAiSseMessage(undefined, {
|
||||||
event: 'ui.form',
|
event: 'ui.artifact',
|
||||||
data: JSON.stringify({ messageId: 8, form }),
|
|
||||||
});
|
|
||||||
message = reduceAiSseMessage(message, {
|
|
||||||
event: 'ui.form',
|
|
||||||
data: JSON.stringify({ messageId: 8, form: { ...form, id: 'form-1' } }),
|
|
||||||
});
|
|
||||||
message = reduceAiSseMessage(message, {
|
|
||||||
event: 'ui.form',
|
|
||||||
data: JSON.stringify({
|
data: JSON.stringify({
|
||||||
messageId: 8,
|
messageId: 12,
|
||||||
form: { id: 'form-2', title: '入住确认', fields: [] },
|
artifact: {
|
||||||
|
id: 'form-1',
|
||||||
|
type: 'form',
|
||||||
|
status: 'pending',
|
||||||
|
messageId: 12,
|
||||||
|
conversationId: 3,
|
||||||
|
payload: { id: 'form-1', title: '新增学生', fields: [] },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
message = reduceAiSseMessage(message, {
|
||||||
|
event: 'ui.artifact',
|
||||||
|
data: JSON.stringify({
|
||||||
|
messageId: 12,
|
||||||
|
artifact: {
|
||||||
|
id: 'review-1',
|
||||||
|
type: 'review',
|
||||||
|
status: 'expired',
|
||||||
|
messageId: 12,
|
||||||
|
conversationId: 3,
|
||||||
|
payload: { id: 'review-1', title: '旧导入预览', status: 'expired', sections: [] },
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(message.forms).toHaveLength(2);
|
expect(message.uiArtifacts).toHaveLength(2);
|
||||||
expect(message.forms?.[0]).toMatchObject({ id: 'form-1', title: '新增学生' });
|
expect(message.uiArtifacts?.[0].payload).toMatchObject({ id: 'form-1', title: '新增学生' });
|
||||||
expect(message.forms?.[1]).toMatchObject({ id: 'form-2' });
|
expect(message.uiArtifacts?.[1].payload).toMatchObject({ id: 'review-1', status: 'expired' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
it('restores a persisted form from message.completed metadata', () => {
|
it('restores a persisted form from message.completed metadata', () => {
|
||||||
const message = reduceAiSseMessage(undefined, {
|
const message = reduceAiSseMessage(undefined, {
|
||||||
event: 'message.completed',
|
event: 'message.completed',
|
||||||
@@ -177,43 +182,6 @@ describe('AI chat SSE message reducer', () => {
|
|||||||
expect(message.forms?.[0].id).toBe('form-9');
|
expect(message.forms?.[0].id).toBe('form-9');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('merges ui.review events into the assistant message and updates by id', () => {
|
|
||||||
const review = {
|
|
||||||
id: 'review-1',
|
|
||||||
title: '开学导入',
|
|
||||||
summary: '来自报名 Excel',
|
|
||||||
status: 'pending',
|
|
||||||
sections: [
|
|
||||||
{
|
|
||||||
key: 'students',
|
|
||||||
type: 'students',
|
|
||||||
title: '学生',
|
|
||||||
kind: 'table',
|
|
||||||
columns: [
|
|
||||||
{ key: 'name', title: '姓名' },
|
|
||||||
{ key: 'phone', title: '手机号' },
|
|
||||||
],
|
|
||||||
rows: [{ name: '张三', phone: '13800138000' }],
|
|
||||||
issues: [],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
let message = reduceAiSseMessage(undefined, {
|
|
||||||
event: 'ui.review',
|
|
||||||
data: JSON.stringify({ messageId: 8, review }),
|
|
||||||
});
|
|
||||||
message = reduceAiSseMessage(message, {
|
|
||||||
event: 'ui.review',
|
|
||||||
data: JSON.stringify({
|
|
||||||
messageId: 8,
|
|
||||||
review: { ...review, status: 'submitted', resultSummary: '{"students":{"created":1}}' },
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(message.reviews).toHaveLength(1);
|
|
||||||
expect(message.reviews?.[0]).toMatchObject({ id: 'review-1', status: 'submitted' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows model retrying state and clears it when content starts', () => {
|
it('shows model retrying state and clears it when content starts', () => {
|
||||||
let message = reduceAiSseMessage(undefined, {
|
let message = reduceAiSseMessage(undefined, {
|
||||||
event: 'model.retrying',
|
event: 'model.retrying',
|
||||||
@@ -265,37 +233,6 @@ describe('AI chat SSE message reducer', () => {
|
|||||||
expect(message.reviews?.[0]).toMatchObject({ id: 'review-9', title: '批量导入' });
|
expect(message.reviews?.[0]).toMatchObject({ id: 'review-9', title: '批量导入' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('merges ui.chart events into the assistant message by id', () => {
|
|
||||||
const chart = {
|
|
||||||
id: 'chart-1',
|
|
||||||
title: '各班级人数',
|
|
||||||
chartType: 'bar',
|
|
||||||
columns: [
|
|
||||||
{ key: 'className', title: '班级' },
|
|
||||||
{ key: 'count', title: '人数' },
|
|
||||||
],
|
|
||||||
rows: [
|
|
||||||
{ className: '一班', count: 20 },
|
|
||||||
{ className: '二班', count: 15 },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
let message = reduceAiSseMessage(undefined, {
|
|
||||||
event: 'ui.chart',
|
|
||||||
data: JSON.stringify({ messageId: 8, chart }),
|
|
||||||
});
|
|
||||||
message = reduceAiSseMessage(message, {
|
|
||||||
event: 'ui.chart',
|
|
||||||
data: JSON.stringify({
|
|
||||||
messageId: 8,
|
|
||||||
chart: { ...chart, id: 'chart-2', title: '女生人数' },
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(message.charts).toHaveLength(2);
|
|
||||||
expect(message.charts?.[0]).toMatchObject({ id: 'chart-1', chartType: 'bar' });
|
|
||||||
expect(message.charts?.[1]).toMatchObject({ id: 'chart-2' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('restores persisted charts from message.completed metadata', () => {
|
it('restores persisted charts from message.completed metadata', () => {
|
||||||
const message = reduceAiSseMessage(undefined, {
|
const message = reduceAiSseMessage(undefined, {
|
||||||
event: 'message.completed',
|
event: 'message.completed',
|
||||||
@@ -423,15 +360,16 @@ describe('AI chat SSE message reducer', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('routes submit-time ui.review to the original message instead of the streaming one', () => {
|
it('routes ui.artifact targeting another message to the external handler', () => {
|
||||||
const provider = new GongxueAiChatProvider('http://x/api/ai/chat/conversations/3/stream');
|
const provider = new GongxueAiChatProvider('http://x/api/ai/chat/conversations/3/stream');
|
||||||
const onExternalReview = vi.fn();
|
const onExternalArtifact = vi.fn();
|
||||||
provider.onExternalReview = onExternalReview;
|
provider.onExternalArtifact = onExternalArtifact;
|
||||||
const review = {
|
const artifact = {
|
||||||
id: 'review-1',
|
id: 'artifact-1',
|
||||||
title: '批量导入',
|
type: 'form',
|
||||||
status: 'submitted',
|
status: 'submitted',
|
||||||
sections: [],
|
messageId: 12,
|
||||||
|
payload: { id: 'form-1', title: '批量导入', status: 'submitted' },
|
||||||
};
|
};
|
||||||
const origin = {
|
const origin = {
|
||||||
id: 13,
|
id: 13,
|
||||||
@@ -440,31 +378,37 @@ describe('AI chat SSE message reducer', () => {
|
|||||||
reasoningContent: '',
|
reasoningContent: '',
|
||||||
toolRuns: [],
|
toolRuns: [],
|
||||||
attachments: [],
|
attachments: [],
|
||||||
reviews: [],
|
uiArtifacts: [],
|
||||||
};
|
};
|
||||||
const next = provider.transformMessage({
|
const next = provider.transformMessage({
|
||||||
originMessage: origin,
|
originMessage: origin,
|
||||||
chunk: { event: 'ui.review', data: JSON.stringify({ messageId: 12, review }) },
|
chunk: { event: 'ui.artifact', data: JSON.stringify({ messageId: 12, artifact }) },
|
||||||
status: 'updating',
|
status: 'updating',
|
||||||
chunks: [],
|
chunks: [],
|
||||||
responseHeaders: {} as Headers,
|
responseHeaders: {} as Headers,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(onExternalReview).toHaveBeenCalledWith(12, review);
|
expect(onExternalArtifact).toHaveBeenCalledWith(12, artifact);
|
||||||
expect(next).toBe(origin);
|
expect(next).toBe(origin);
|
||||||
expect(next.reviews ?? []).toHaveLength(0);
|
expect(next.uiArtifacts ?? []).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('routes ui.review without an origin message to the external handler', () => {
|
it('routes ui.artifact without an origin message to the external handler', () => {
|
||||||
const provider = new GongxueAiChatProvider('http://x/api/ai/chat/conversations/3/stream');
|
const provider = new GongxueAiChatProvider('http://x/api/ai/chat/conversations/3/stream');
|
||||||
const onExternalReview = vi.fn();
|
const onExternalArtifact = vi.fn();
|
||||||
provider.onExternalReview = onExternalReview;
|
provider.onExternalArtifact = onExternalArtifact;
|
||||||
const next = provider.transformMessage({
|
const next = provider.transformMessage({
|
||||||
chunk: {
|
chunk: {
|
||||||
event: 'ui.review',
|
event: 'ui.artifact',
|
||||||
data: JSON.stringify({
|
data: JSON.stringify({
|
||||||
messageId: 12,
|
messageId: 12,
|
||||||
review: { id: 'review-1', title: '批量导入', status: 'submitted', sections: [] },
|
artifact: {
|
||||||
|
id: 'artifact-1',
|
||||||
|
type: 'review',
|
||||||
|
status: 'submitted',
|
||||||
|
messageId: 12,
|
||||||
|
payload: { id: 'review-1', title: '批量导入', status: 'submitted', sections: [] },
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
status: 'updating',
|
status: 'updating',
|
||||||
@@ -472,11 +416,11 @@ describe('AI chat SSE message reducer', () => {
|
|||||||
responseHeaders: {} as Headers,
|
responseHeaders: {} as Headers,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(onExternalReview).toHaveBeenCalledWith(
|
expect(onExternalArtifact).toHaveBeenCalledWith(
|
||||||
12,
|
12,
|
||||||
expect.objectContaining({ id: 'review-1' }),
|
expect.objectContaining({ id: 'artifact-1' }),
|
||||||
);
|
);
|
||||||
expect(next.reviews ?? []).toHaveLength(0);
|
expect(next.uiArtifacts ?? []).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('tolerates non-JSON event data', () => {
|
it('tolerates non-JSON event data', () => {
|
||||||
|
|||||||
@@ -6,231 +6,10 @@ import {
|
|||||||
} from '@ant-design/x-sdk';
|
} from '@ant-design/x-sdk';
|
||||||
import { usePermissionStore } from '../../store/permission/permissionStore';
|
import { usePermissionStore } from '../../store/permission/permissionStore';
|
||||||
import { useUserStore } from '../../store/user/userStore';
|
import { useUserStore } from '../../store/user/userStore';
|
||||||
import type {
|
import type { AiArtifactSchema, AiChatInput, AiChatMessage, AiReviewSchema, AiSseChunk } from './types';
|
||||||
AiAttachment,
|
import { emptyAssistant, parseSsePayload, reduceAiSseMessage } from './sseReducer';
|
||||||
AiChatInput,
|
|
||||||
AiChatMessage,
|
|
||||||
AiChartSchema,
|
|
||||||
AiFormSchema,
|
|
||||||
AiModelRetryInfo,
|
|
||||||
AiReviewSchema,
|
|
||||||
AiSseChunk,
|
|
||||||
AiToolRun,
|
|
||||||
} from './types';
|
|
||||||
|
|
||||||
interface AiSsePayload {
|
export { parseSsePayload, reduceAiSseMessage };
|
||||||
messageId?: number;
|
|
||||||
userMessageId?: number;
|
|
||||||
assistantMessageId?: number;
|
|
||||||
delta?: string;
|
|
||||||
content?: string;
|
|
||||||
reasoningContent?: string | null;
|
|
||||||
toolCallId?: string;
|
|
||||||
toolName?: string;
|
|
||||||
skillKey?: string | null;
|
|
||||||
status?: string;
|
|
||||||
summary?: string | null;
|
|
||||||
durationMs?: number | null;
|
|
||||||
attachment?: AiAttachment;
|
|
||||||
form?: AiFormSchema;
|
|
||||||
review?: AiReviewSchema;
|
|
||||||
chart?: AiChartSchema;
|
|
||||||
wizard?: unknown;
|
|
||||||
retry?: AiModelRetryInfo;
|
|
||||||
message?:
|
|
||||||
| string
|
|
||||||
| {
|
|
||||||
id?: number;
|
|
||||||
content?: string;
|
|
||||||
reasoningContent?: string | null;
|
|
||||||
status?: string;
|
|
||||||
toolRuns?: AiToolRun[];
|
|
||||||
attachments?: AiAttachment[];
|
|
||||||
replyToMessageId?: number | null;
|
|
||||||
metadata?: Record<string, unknown> | null;
|
|
||||||
};
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function emptyAssistant(): AiChatMessage {
|
|
||||||
return {
|
|
||||||
role: 'assistant',
|
|
||||||
content: '',
|
|
||||||
reasoningContent: '',
|
|
||||||
toolRuns: [],
|
|
||||||
attachments: [],
|
|
||||||
forms: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function mergeForms(
|
|
||||||
current: AiFormSchema[] | undefined,
|
|
||||||
incoming: AiFormSchema | AiFormSchema[] | undefined,
|
|
||||||
): AiFormSchema[] {
|
|
||||||
const items = Array.isArray(incoming) ? incoming : incoming ? [incoming] : [];
|
|
||||||
if (!items.length) return current ?? [];
|
|
||||||
const next = [...(current ?? [])];
|
|
||||||
for (const item of items) {
|
|
||||||
if (item && typeof item === 'object' && !next.some((existing) => existing.id === item.id)) {
|
|
||||||
next.push(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
function mergeById<T extends { id: string }>(
|
|
||||||
current: T[] | undefined,
|
|
||||||
incoming: T | T[] | undefined,
|
|
||||||
): T[] {
|
|
||||||
const items = Array.isArray(incoming) ? incoming : incoming ? [incoming] : [];
|
|
||||||
if (!items.length) return current ?? [];
|
|
||||||
const next = [...(current ?? [])];
|
|
||||||
for (const item of items) {
|
|
||||||
if (!item || typeof item !== 'object') continue;
|
|
||||||
const index = next.findIndex((existing) => existing.id === item.id);
|
|
||||||
if (index === -1) {
|
|
||||||
next.push(item);
|
|
||||||
} else {
|
|
||||||
next[index] = item;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseSsePayload(chunk?: AiSseChunk): {
|
|
||||||
event: string;
|
|
||||||
payload: AiSsePayload;
|
|
||||||
} {
|
|
||||||
if (!chunk) return { event: '', payload: {} };
|
|
||||||
const event = chunk.event?.trim() || 'message';
|
|
||||||
if (!chunk.data || chunk.data === '[DONE]') return { event, payload: {} };
|
|
||||||
try {
|
|
||||||
const parsed: unknown = JSON.parse(chunk.data);
|
|
||||||
return {
|
|
||||||
event,
|
|
||||||
payload: parsed && typeof parsed === 'object' ? (parsed as AiSsePayload) : {},
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return { event, payload: { delta: chunk.data } };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function upsertToolRun(
|
|
||||||
toolRuns: AiToolRun[],
|
|
||||||
payload: AiSsePayload,
|
|
||||||
fallbackStatus: AiToolRun['status'],
|
|
||||||
): AiToolRun[] {
|
|
||||||
const toolCallId = payload.toolCallId || `${payload.toolName || 'tool'}-${toolRuns.length}`;
|
|
||||||
const next: AiToolRun = {
|
|
||||||
toolCallId,
|
|
||||||
toolName: payload.toolName || '查询工具',
|
|
||||||
skillKey: payload.skillKey,
|
|
||||||
status: (payload.status as AiToolRun['status']) || fallbackStatus,
|
|
||||||
summary: payload.summary,
|
|
||||||
resultSummary: fallbackStatus === 'running' ? undefined : payload.summary,
|
|
||||||
argumentsSummary: fallbackStatus === 'running' ? payload.summary : undefined,
|
|
||||||
durationMs: payload.durationMs,
|
|
||||||
};
|
|
||||||
const index = toolRuns.findIndex((item) => item.toolCallId === toolCallId);
|
|
||||||
if (index === -1) return [...toolRuns, next];
|
|
||||||
return toolRuns.map((item, itemIndex) => (itemIndex === index ? { ...item, ...next } : item));
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeToolRuns(toolRuns: AiToolRun[] | undefined, fallback: AiToolRun[]): AiToolRun[] {
|
|
||||||
if (!toolRuns) return fallback;
|
|
||||||
return toolRuns.map((tool) => ({
|
|
||||||
...tool,
|
|
||||||
status: tool.status === 'error' ? 'failed' : tool.status,
|
|
||||||
summary: tool.resultSummary ?? tool.argumentsSummary ?? tool.summary,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyMessagePayload(
|
|
||||||
message: AiChatMessage,
|
|
||||||
nested: AiSsePayload['message'],
|
|
||||||
payload: AiSsePayload,
|
|
||||||
): void {
|
|
||||||
if (typeof nested !== 'object' || nested === null) return;
|
|
||||||
message.forms = mergeForms(
|
|
||||||
message.forms,
|
|
||||||
(nested.metadata?.a2uiForm as AiFormSchema | undefined) ?? payload.form,
|
|
||||||
);
|
|
||||||
message.reviews = mergeById<AiReviewSchema>(
|
|
||||||
message.reviews,
|
|
||||||
(nested.metadata?.a2uiReview as AiReviewSchema | undefined) ?? payload.review,
|
|
||||||
);
|
|
||||||
message.charts = mergeById<AiChartSchema>(
|
|
||||||
message.charts,
|
|
||||||
(nested.metadata?.a2uiChart as AiChartSchema | AiChartSchema[] | undefined) ?? payload.chart,
|
|
||||||
);
|
|
||||||
message.replyToMessageId = nested.replyToMessageId ?? message.replyToMessageId;
|
|
||||||
message.metadata = nested.metadata ?? message.metadata;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function reduceAiSseMessage(
|
|
||||||
originMessage: AiChatMessage | undefined,
|
|
||||||
chunk?: AiSseChunk,
|
|
||||||
): AiChatMessage {
|
|
||||||
const message = originMessage ? { ...originMessage } : emptyAssistant();
|
|
||||||
const { event, payload } = parseSsePayload(chunk);
|
|
||||||
|
|
||||||
if (event === 'message.created') {
|
|
||||||
const nested = typeof payload.message === 'object' ? payload.message : undefined;
|
|
||||||
message.id = nested?.id ?? payload.assistantMessageId ?? payload.messageId ?? message.id;
|
|
||||||
message.content = nested?.content ?? message.content;
|
|
||||||
message.reasoningContent = nested?.reasoningContent ?? message.reasoningContent;
|
|
||||||
message.toolRuns = normalizeToolRuns(nested?.toolRuns, message.toolRuns);
|
|
||||||
message.attachments = nested?.attachments ?? message.attachments;
|
|
||||||
applyMessagePayload(message, nested, payload);
|
|
||||||
} else if (event === 'reasoning.delta') {
|
|
||||||
message.retrying = null;
|
|
||||||
message.reasoningContent += payload.delta ?? payload.reasoningContent ?? '';
|
|
||||||
} else if (event === 'content.delta') {
|
|
||||||
message.retrying = null;
|
|
||||||
message.content += payload.delta ?? payload.content ?? '';
|
|
||||||
} else if (event === 'model.retrying' && payload.retry) {
|
|
||||||
message.retrying = payload.retry;
|
|
||||||
} else if (event === 'ui.form' && payload.form) {
|
|
||||||
message.forms = mergeForms(message.forms, payload.form);
|
|
||||||
} else if (event === 'ui.review' && payload.review) {
|
|
||||||
message.reviews = mergeById<AiReviewSchema>(message.reviews, payload.review);
|
|
||||||
} else if (event === 'ui.chart' && payload.chart) {
|
|
||||||
message.charts = mergeById<AiChartSchema>(message.charts, payload.chart);
|
|
||||||
} else if (event === 'ui.import_wizard' && payload.wizard) {
|
|
||||||
message.metadata = { ...message.metadata, a2uiImportWizard: payload.wizard };
|
|
||||||
} else if (event === 'tool.started') {
|
|
||||||
message.toolRuns = upsertToolRun(message.toolRuns, payload, 'running');
|
|
||||||
} else if (event === 'tool.completed') {
|
|
||||||
message.toolRuns = upsertToolRun(message.toolRuns, payload, 'success');
|
|
||||||
} else if (event === 'tool.failed') {
|
|
||||||
message.toolRuns = upsertToolRun(message.toolRuns, payload, 'failed');
|
|
||||||
} else if (event === 'attachment.processed' && payload.attachment) {
|
|
||||||
if (!message.attachments.some((item) => item.id === payload.attachment?.id)) {
|
|
||||||
message.attachments = [...message.attachments, payload.attachment];
|
|
||||||
}
|
|
||||||
} else if (event === 'message.completed') {
|
|
||||||
const nested = typeof payload.message === 'object' ? payload.message : undefined;
|
|
||||||
message.id = nested?.id ?? payload.messageId ?? message.id;
|
|
||||||
message.content = nested?.content ?? payload.content ?? message.content;
|
|
||||||
message.reasoningContent =
|
|
||||||
nested?.reasoningContent ?? payload.reasoningContent ?? message.reasoningContent;
|
|
||||||
message.toolRuns = normalizeToolRuns(nested?.toolRuns, message.toolRuns);
|
|
||||||
message.attachments = nested?.attachments ?? message.attachments;
|
|
||||||
applyMessagePayload(message, nested, payload);
|
|
||||||
message.retrying = null;
|
|
||||||
} else if (event === 'message.cancelled') {
|
|
||||||
message.id = payload.messageId ?? message.id;
|
|
||||||
message.cancelled = true;
|
|
||||||
message.retrying = null;
|
|
||||||
} else if (event === 'error') {
|
|
||||||
message.retrying = null;
|
|
||||||
message.error =
|
|
||||||
(typeof payload.message === 'string' ? payload.message : undefined) ||
|
|
||||||
payload.error ||
|
|
||||||
'AI 回答生成失败';
|
|
||||||
}
|
|
||||||
return message;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function authenticatedFetch(
|
export async function authenticatedFetch(
|
||||||
input: RequestInfo | URL,
|
input: RequestInfo | URL,
|
||||||
@@ -301,6 +80,8 @@ export async function authenticatedFetch(
|
|||||||
}
|
}
|
||||||
const response = await fetch(requestInput, { ...requestInit, headers });
|
const response = await fetch(requestInput, { ...requestInit, headers });
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
|
// 提示由登录页读取展示:直接弹 toast 会被跳转销毁
|
||||||
|
sessionStorage.setItem('login_expired_hint', '1');
|
||||||
useUserStore.getState().logout();
|
useUserStore.getState().logout();
|
||||||
usePermissionStore.getState().clearPermissions();
|
usePermissionStore.getState().clearPermissions();
|
||||||
window.location.href = '/login';
|
window.location.href = '/login';
|
||||||
@@ -315,6 +96,7 @@ export class GongxueAiChatProvider extends AbstractChatProvider<
|
|||||||
> {
|
> {
|
||||||
/** Routes events that target another (already streamed) message. */
|
/** Routes events that target another (already streamed) message. */
|
||||||
onExternalReview?: (messageId: number, review: AiReviewSchema) => void;
|
onExternalReview?: (messageId: number, review: AiReviewSchema) => void;
|
||||||
|
onExternalArtifact?: (messageId: number, artifact: AiArtifactSchema) => void;
|
||||||
|
|
||||||
constructor(url: string, onSettled?: (result?: { ok: boolean; aborted?: boolean }) => void) {
|
constructor(url: string, onSettled?: (result?: { ok: boolean; aborted?: boolean }) => void) {
|
||||||
super({
|
super({
|
||||||
@@ -401,14 +183,12 @@ export class GongxueAiChatProvider extends AbstractChatProvider<
|
|||||||
transformMessage(info: TransformMessage<AiChatMessage, AiSseChunk>): AiChatMessage {
|
transformMessage(info: TransformMessage<AiChatMessage, AiSseChunk>): AiChatMessage {
|
||||||
const { event, payload } = parseSsePayload(info.chunk);
|
const { event, payload } = parseSsePayload(info.chunk);
|
||||||
if (
|
if (
|
||||||
event === 'ui.review' &&
|
event === 'ui.artifact' &&
|
||||||
payload.review &&
|
payload.artifact &&
|
||||||
typeof payload.messageId === 'number' &&
|
typeof payload.messageId === 'number' &&
|
||||||
info.originMessage?.id !== payload.messageId
|
info.originMessage?.id !== payload.messageId
|
||||||
) {
|
) {
|
||||||
// The submitted review belongs to the original assistant message;
|
this.onExternalArtifact?.(payload.messageId, payload.artifact);
|
||||||
// do not merge it into the message currently being streamed.
|
|
||||||
this.onExternalReview?.(payload.messageId, payload.review);
|
|
||||||
return info.originMessage ?? emptyAssistant();
|
return info.originMessage ?? emptyAssistant();
|
||||||
}
|
}
|
||||||
return reduceAiSseMessage(info.originMessage, info.chunk);
|
return reduceAiSseMessage(info.originMessage, info.chunk);
|
||||||
|
|||||||
197
apps/admin/src/components/AiChat/sseReducer.ts
Normal file
197
apps/admin/src/components/AiChat/sseReducer.ts
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import type {
|
||||||
|
AiArtifactSchema,
|
||||||
|
AiAttachment,
|
||||||
|
AiChartSchema,
|
||||||
|
AiChatMessage,
|
||||||
|
AiFormSchema,
|
||||||
|
AiModelRetryInfo,
|
||||||
|
AiReviewSchema,
|
||||||
|
AiSseChunk,
|
||||||
|
AiToolRun,
|
||||||
|
} from './types';
|
||||||
|
import { mergeArtifactIntoMessage, mergeById } from './uiArtifacts';
|
||||||
|
|
||||||
|
export interface AiSsePayload {
|
||||||
|
messageId?: number;
|
||||||
|
userMessageId?: number;
|
||||||
|
assistantMessageId?: number;
|
||||||
|
delta?: string;
|
||||||
|
content?: string;
|
||||||
|
reasoningContent?: string | null;
|
||||||
|
toolCallId?: string;
|
||||||
|
toolName?: string;
|
||||||
|
skillKey?: string | null;
|
||||||
|
status?: string;
|
||||||
|
summary?: string | null;
|
||||||
|
durationMs?: number | null;
|
||||||
|
attachment?: AiAttachment;
|
||||||
|
artifact?: AiArtifactSchema;
|
||||||
|
wizard?: unknown;
|
||||||
|
retry?: AiModelRetryInfo;
|
||||||
|
message?:
|
||||||
|
| string
|
||||||
|
| {
|
||||||
|
id?: number;
|
||||||
|
content?: string;
|
||||||
|
reasoningContent?: string | null;
|
||||||
|
status?: string;
|
||||||
|
toolRuns?: AiToolRun[];
|
||||||
|
attachments?: AiAttachment[];
|
||||||
|
replyToMessageId?: number | null;
|
||||||
|
metadata?: Record<string, unknown> | null;
|
||||||
|
};
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emptyAssistant(): AiChatMessage {
|
||||||
|
return {
|
||||||
|
role: 'assistant',
|
||||||
|
content: '',
|
||||||
|
reasoningContent: '',
|
||||||
|
toolRuns: [],
|
||||||
|
attachments: [],
|
||||||
|
forms: [],
|
||||||
|
uiArtifacts: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSsePayload(chunk?: AiSseChunk): {
|
||||||
|
event: string;
|
||||||
|
payload: AiSsePayload;
|
||||||
|
} {
|
||||||
|
if (!chunk) return { event: '', payload: {} };
|
||||||
|
const event = chunk.event?.trim() || 'message';
|
||||||
|
if (!chunk.data || chunk.data === '[DONE]') return { event, payload: {} };
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(chunk.data);
|
||||||
|
return {
|
||||||
|
event,
|
||||||
|
payload: parsed && typeof parsed === 'object' ? (parsed as AiSsePayload) : {},
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { event, payload: { delta: chunk.data } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertToolRun(
|
||||||
|
toolRuns: AiToolRun[],
|
||||||
|
payload: AiSsePayload,
|
||||||
|
fallbackStatus: AiToolRun['status'],
|
||||||
|
): AiToolRun[] {
|
||||||
|
const toolCallId = payload.toolCallId || `${payload.toolName || 'tool'}-${toolRuns.length}`;
|
||||||
|
const next: AiToolRun = {
|
||||||
|
toolCallId,
|
||||||
|
toolName: payload.toolName || '查询工具',
|
||||||
|
skillKey: payload.skillKey,
|
||||||
|
status: (payload.status as AiToolRun['status']) || fallbackStatus,
|
||||||
|
summary: payload.summary,
|
||||||
|
resultSummary: fallbackStatus === 'running' ? undefined : payload.summary,
|
||||||
|
argumentsSummary: fallbackStatus === 'running' ? payload.summary : undefined,
|
||||||
|
durationMs: payload.durationMs,
|
||||||
|
};
|
||||||
|
const index = toolRuns.findIndex((item) => item.toolCallId === toolCallId);
|
||||||
|
if (index === -1) return [...toolRuns, next];
|
||||||
|
return toolRuns.map((item, itemIndex) => (itemIndex === index ? { ...item, ...next } : item));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeToolRuns(toolRuns: AiToolRun[] | undefined, fallback: AiToolRun[]): AiToolRun[] {
|
||||||
|
if (!toolRuns) return fallback;
|
||||||
|
return toolRuns.map((tool) => ({
|
||||||
|
...tool,
|
||||||
|
status: tool.status === 'error' ? 'failed' : tool.status,
|
||||||
|
summary: tool.resultSummary ?? tool.argumentsSummary ?? tool.summary,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyMessagePayload(
|
||||||
|
message: AiChatMessage,
|
||||||
|
nested: AiSsePayload['message'],
|
||||||
|
): void {
|
||||||
|
if (typeof nested !== 'object' || nested === null) return;
|
||||||
|
// 历史消息兼容:老数据只有 metadata.a2uiForm/a2uiReview/a2uiChart,
|
||||||
|
// 恢复为 legacy 字段供渲染层在 uiArtifacts 为空时回退使用。
|
||||||
|
message.forms = mergeById<AiFormSchema>(
|
||||||
|
message.forms,
|
||||||
|
nested.metadata?.a2uiForm as AiFormSchema | undefined,
|
||||||
|
);
|
||||||
|
message.reviews = mergeById<AiReviewSchema>(
|
||||||
|
message.reviews,
|
||||||
|
nested.metadata?.a2uiReview as AiReviewSchema | undefined,
|
||||||
|
);
|
||||||
|
message.charts = mergeById<AiChartSchema>(
|
||||||
|
message.charts,
|
||||||
|
nested.metadata?.a2uiChart as AiChartSchema | AiChartSchema[] | undefined,
|
||||||
|
);
|
||||||
|
const artifacts = nested.metadata?.uiArtifacts;
|
||||||
|
if (Array.isArray(artifacts)) {
|
||||||
|
for (const artifact of artifacts) {
|
||||||
|
if (artifact && typeof artifact === 'object' && typeof artifact.id === 'string') {
|
||||||
|
mergeArtifactIntoMessage(message, artifact as AiArtifactSchema);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
message.replyToMessageId = nested.replyToMessageId ?? message.replyToMessageId;
|
||||||
|
message.metadata = nested.metadata ?? message.metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reduceAiSseMessage(
|
||||||
|
originMessage: AiChatMessage | undefined,
|
||||||
|
chunk?: AiSseChunk,
|
||||||
|
): AiChatMessage {
|
||||||
|
const message = originMessage ? { ...originMessage } : emptyAssistant();
|
||||||
|
const { event, payload } = parseSsePayload(chunk);
|
||||||
|
|
||||||
|
if (event === 'message.created') {
|
||||||
|
const nested = typeof payload.message === 'object' ? payload.message : undefined;
|
||||||
|
message.id = nested?.id ?? payload.assistantMessageId ?? payload.messageId ?? message.id;
|
||||||
|
message.content = nested?.content ?? message.content;
|
||||||
|
message.reasoningContent = nested?.reasoningContent ?? message.reasoningContent;
|
||||||
|
message.toolRuns = normalizeToolRuns(nested?.toolRuns, message.toolRuns);
|
||||||
|
message.attachments = nested?.attachments ?? message.attachments;
|
||||||
|
applyMessagePayload(message, nested);
|
||||||
|
} else if (event === 'reasoning.delta') {
|
||||||
|
message.retrying = null;
|
||||||
|
message.reasoningContent += payload.delta ?? payload.reasoningContent ?? '';
|
||||||
|
} else if (event === 'content.delta') {
|
||||||
|
message.retrying = null;
|
||||||
|
message.content += payload.delta ?? payload.content ?? '';
|
||||||
|
} else if (event === 'model.retrying' && payload.retry) {
|
||||||
|
message.retrying = payload.retry;
|
||||||
|
} else if (event === 'ui.artifact' && payload.artifact) {
|
||||||
|
// 统一 artifact 事件;legacy 列表由渲染层从 uiArtifacts 派生。
|
||||||
|
mergeArtifactIntoMessage(message, payload.artifact);
|
||||||
|
} else if (event === 'ui.import_wizard' && payload.wizard) {
|
||||||
|
message.metadata = { ...message.metadata, a2uiImportWizard: payload.wizard };
|
||||||
|
} else if (event === 'tool.started') {
|
||||||
|
message.toolRuns = upsertToolRun(message.toolRuns, payload, 'running');
|
||||||
|
} else if (event === 'tool.completed') {
|
||||||
|
message.toolRuns = upsertToolRun(message.toolRuns, payload, 'success');
|
||||||
|
} else if (event === 'tool.failed') {
|
||||||
|
message.toolRuns = upsertToolRun(message.toolRuns, payload, 'failed');
|
||||||
|
} else if (event === 'attachment.processed' && payload.attachment) {
|
||||||
|
if (!message.attachments.some((item) => item.id === payload.attachment?.id)) {
|
||||||
|
message.attachments = [...message.attachments, payload.attachment];
|
||||||
|
}
|
||||||
|
} else if (event === 'message.completed') {
|
||||||
|
const nested = typeof payload.message === 'object' ? payload.message : undefined;
|
||||||
|
message.id = nested?.id ?? payload.messageId ?? message.id;
|
||||||
|
message.content = nested?.content ?? payload.content ?? message.content;
|
||||||
|
message.reasoningContent =
|
||||||
|
nested?.reasoningContent ?? payload.reasoningContent ?? message.reasoningContent;
|
||||||
|
message.toolRuns = normalizeToolRuns(nested?.toolRuns, message.toolRuns);
|
||||||
|
message.attachments = nested?.attachments ?? message.attachments;
|
||||||
|
applyMessagePayload(message, nested);
|
||||||
|
message.retrying = null;
|
||||||
|
} else if (event === 'message.cancelled') {
|
||||||
|
message.id = payload.messageId ?? message.id;
|
||||||
|
message.cancelled = true;
|
||||||
|
message.retrying = null;
|
||||||
|
} else if (event === 'error') {
|
||||||
|
message.retrying = null;
|
||||||
|
message.error =
|
||||||
|
(typeof payload.message === 'string' ? payload.message : undefined) ||
|
||||||
|
payload.error ||
|
||||||
|
'AI 回答生成失败';
|
||||||
|
}
|
||||||
|
return message;
|
||||||
|
}
|
||||||
@@ -138,6 +138,16 @@
|
|||||||
inset: 68px 0 auto;
|
inset: 68px 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ai-chat-sidebar__empty {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 24px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.ai-chat-sidebar__footer {
|
.ai-chat-sidebar__footer {
|
||||||
flex: none;
|
flex: none;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -165,6 +175,18 @@
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ai-chat-main .ai-chat-toolbar {
|
||||||
|
order: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-chat-main .ai-chat-messages {
|
||||||
|
order: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-chat-main .ai-chat-composer {
|
||||||
|
order: 2;
|
||||||
|
}
|
||||||
|
|
||||||
.ai-chat-toolbar {
|
.ai-chat-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 0 0 48px;
|
flex: 0 0 48px;
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export interface AiFormSchema {
|
|||||||
description?: string | null;
|
description?: string | null;
|
||||||
submitLabel?: string;
|
submitLabel?: string;
|
||||||
fields: AiFormField[];
|
fields: AiFormField[];
|
||||||
status?: 'pending' | 'submitted';
|
status?: 'pending' | 'submitted' | 'expired';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AiReviewColumn {
|
export interface AiReviewColumn {
|
||||||
@@ -98,6 +98,26 @@ export interface AiChartSchema {
|
|||||||
rows: AiReviewRow[];
|
rows: AiReviewRow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AiArtifactType =
|
||||||
|
| 'form'
|
||||||
|
| 'review'
|
||||||
|
| 'chart'
|
||||||
|
| 'import_wizard';
|
||||||
|
|
||||||
|
export type AiArtifactStatus = 'rendering' | 'pending' | 'submitted' | 'expired' | 'cancelled';
|
||||||
|
|
||||||
|
export interface AiArtifactSchema<T = unknown> {
|
||||||
|
id: string;
|
||||||
|
type: AiArtifactType;
|
||||||
|
status: AiArtifactStatus;
|
||||||
|
messageId: number;
|
||||||
|
conversationId?: number;
|
||||||
|
payload: T;
|
||||||
|
createdAt?: string | null;
|
||||||
|
submittedAt?: string | null;
|
||||||
|
supersededBy?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AiImportWizard {
|
export interface AiImportWizard {
|
||||||
runId: string;
|
runId: string;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
@@ -151,9 +171,14 @@ export interface AiChatMessage {
|
|||||||
reasoningContent: string;
|
reasoningContent: string;
|
||||||
toolRuns: AiToolRun[];
|
toolRuns: AiToolRun[];
|
||||||
attachments: AiAttachment[];
|
attachments: AiAttachment[];
|
||||||
|
/** @deprecated 仅历史消息兼容读取(metadata.a2uiForm);新数据统一走 uiArtifacts */
|
||||||
forms?: AiFormSchema[];
|
forms?: AiFormSchema[];
|
||||||
|
/** @deprecated 仅历史消息兼容读取(metadata.a2uiReview);新数据统一走 uiArtifacts */
|
||||||
reviews?: AiReviewSchema[];
|
reviews?: AiReviewSchema[];
|
||||||
|
/** @deprecated 仅历史消息兼容读取(metadata.a2uiChart);新数据统一走 uiArtifacts */
|
||||||
charts?: AiChartSchema[];
|
charts?: AiChartSchema[];
|
||||||
|
/** 统一 A2UI 制品协议(唯一事实源) */
|
||||||
|
uiArtifacts?: AiArtifactSchema[];
|
||||||
replyToMessageId?: number | null;
|
replyToMessageId?: number | null;
|
||||||
metadata?: Record<string, unknown> | null;
|
metadata?: Record<string, unknown> | null;
|
||||||
retrying?: AiModelRetryInfo | null;
|
retrying?: AiModelRetryInfo | null;
|
||||||
|
|||||||
68
apps/admin/src/components/AiChat/uiArtifacts.ts
Normal file
68
apps/admin/src/components/AiChat/uiArtifacts.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import type {
|
||||||
|
AiArtifactSchema,
|
||||||
|
AiChartSchema,
|
||||||
|
AiChatMessage,
|
||||||
|
AiFormSchema,
|
||||||
|
AiReviewSchema,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
export function mergeById<T extends { id: string }>(
|
||||||
|
current: T[] | undefined,
|
||||||
|
incoming: T | T[] | undefined,
|
||||||
|
): T[] {
|
||||||
|
const items = Array.isArray(incoming) ? incoming : incoming ? [incoming] : [];
|
||||||
|
if (!items.length) return current ?? [];
|
||||||
|
const next = [...(current ?? [])];
|
||||||
|
for (const item of items) {
|
||||||
|
if (!item || typeof item !== 'object') continue;
|
||||||
|
const index = next.findIndex((existing) => existing.id === item.id);
|
||||||
|
if (index === -1) {
|
||||||
|
next.push(item);
|
||||||
|
} else {
|
||||||
|
next[index] = item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将统一 artifact 归入 uiArtifacts。
|
||||||
|
*
|
||||||
|
* 注意:不再派发到 legacy 列表(forms/reviews/charts)——渲染层从
|
||||||
|
* uiArtifacts 派生,legacy 字段仅保留给历史消息(metadata 中只有
|
||||||
|
* a2uiForm/a2uiReview/a2uiChart 的老数据)作兼容读取。
|
||||||
|
*/
|
||||||
|
export function mergeArtifactIntoMessage(
|
||||||
|
message: AiChatMessage,
|
||||||
|
artifact: AiArtifactSchema,
|
||||||
|
): AiChatMessage {
|
||||||
|
message.uiArtifacts = mergeById<AiArtifactSchema>(message.uiArtifacts, artifact);
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 uiArtifacts 派生 legacy 列表(渲染用)。
|
||||||
|
* 仅当 message 上没有显式 legacy 数据(历史消息)时,渲染层回退到 message.forms 等。
|
||||||
|
*/
|
||||||
|
export function deriveForms(message: AiChatMessage): AiFormSchema[] {
|
||||||
|
return (message.uiArtifacts ?? [])
|
||||||
|
.filter((artifact) => artifact.type === 'form')
|
||||||
|
.map((artifact) => artifact.payload)
|
||||||
|
.filter((payload): payload is AiFormSchema => Boolean(payload) && typeof payload === 'object');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveReviews(message: AiChatMessage): AiReviewSchema[] {
|
||||||
|
return (message.uiArtifacts ?? [])
|
||||||
|
.filter((artifact) => artifact.type === 'review')
|
||||||
|
.map((artifact) => artifact.payload)
|
||||||
|
.filter(
|
||||||
|
(payload): payload is AiReviewSchema => Boolean(payload) && typeof payload === 'object',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveCharts(message: AiChatMessage): AiChartSchema[] {
|
||||||
|
return (message.uiArtifacts ?? [])
|
||||||
|
.filter((artifact) => artifact.type === 'chart')
|
||||||
|
.map((artifact) => artifact.payload)
|
||||||
|
.filter((payload): payload is AiChartSchema => Boolean(payload) && typeof payload === 'object');
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { aiChatApi } from './api';
|
|||||||
import { AiMessageContent } from './AiMessageContent';
|
import { AiMessageContent } from './AiMessageContent';
|
||||||
import { mapHistoryMessage } from './message-mappers';
|
import { mapHistoryMessage } from './message-mappers';
|
||||||
import { GongxueAiChatProvider } from './provider';
|
import { GongxueAiChatProvider } from './provider';
|
||||||
|
import { mergeArtifactIntoMessage } from './uiArtifacts';
|
||||||
import {
|
import {
|
||||||
emptyAssistant,
|
emptyAssistant,
|
||||||
MessageHoverActions,
|
MessageHoverActions,
|
||||||
@@ -110,6 +111,11 @@ export function useAiChatMessageActions({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
provider.onExternalArtifact = (messageId, artifact) => {
|
||||||
|
setMessage(messageId, (info) => ({
|
||||||
|
message: mergeArtifactIntoMessage(info.message, artifact),
|
||||||
|
}));
|
||||||
|
};
|
||||||
}, [provider, setMessage]);
|
}, [provider, setMessage]);
|
||||||
|
|
||||||
requestingRef.current = isRequesting;
|
requestingRef.current = isRequesting;
|
||||||
@@ -271,7 +277,7 @@ export function useAiChatMessageActions({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[activeId, isRequesting, refreshConversations, removeMessage],
|
[activeId, isRequesting, refreshConversations, removeMessage, modal],
|
||||||
);
|
);
|
||||||
|
|
||||||
const confirmEditMessage = useCallback(
|
const confirmEditMessage = useCallback(
|
||||||
@@ -290,30 +296,46 @@ export function useAiChatMessageActions({
|
|||||||
setEditingMessageId(null);
|
setEditingMessageId(null);
|
||||||
if (content === messageInfo.message.content) return;
|
if (content === messageInfo.message.content) return;
|
||||||
|
|
||||||
setMessage(messageInfo.id, (info) => ({
|
// 编辑旧消息会删除其后的全部消息并重新生成,需先告知用户
|
||||||
message: {
|
|
||||||
...info.message,
|
|
||||||
content,
|
|
||||||
metadata: { ...info.message.metadata, edited: true },
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
const index = messagesRef.current.findIndex((item) => item.id === messageInfo.id);
|
const index = messagesRef.current.findIndex((item) => item.id === messageInfo.id);
|
||||||
if (index >= 0) {
|
const followingCount = index >= 0 ? messagesRef.current.length - index - 1 : 0;
|
||||||
for (const item of messagesRef.current.slice(index + 1)) removeMessage(item.id);
|
const doEdit = () => {
|
||||||
|
setMessage(messageInfo.id, (info) => ({
|
||||||
|
message: {
|
||||||
|
...info.message,
|
||||||
|
content,
|
||||||
|
metadata: { ...info.message.metadata, edited: true },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
if (index >= 0) {
|
||||||
|
for (const item of messagesRef.current.slice(index + 1)) removeMessage(item.id);
|
||||||
|
}
|
||||||
|
requestWithStatus({
|
||||||
|
message: content,
|
||||||
|
attachmentIds: [],
|
||||||
|
skillKey: activeConversation?.lockedSkillKey ?? null,
|
||||||
|
clientRequestId: crypto.randomUUID(),
|
||||||
|
reasoningEffort: deepThinking ? 'high' : null,
|
||||||
|
editMessageId: messageId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
if (followingCount > 0) {
|
||||||
|
modal.confirm({
|
||||||
|
title: '编辑消息将删除后续内容',
|
||||||
|
content: `编辑这条消息会删除其后的 ${followingCount} 条消息并重新生成回答,此操作不可恢复。`,
|
||||||
|
okText: '继续编辑',
|
||||||
|
cancelText: '取消',
|
||||||
|
onOk: doEdit,
|
||||||
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
requestWithStatus({
|
doEdit();
|
||||||
message: content,
|
|
||||||
attachmentIds: [],
|
|
||||||
skillKey: activeConversation?.lockedSkillKey ?? null,
|
|
||||||
clientRequestId: crypto.randomUUID(),
|
|
||||||
reasoningEffort: deepThinking ? 'high' : null,
|
|
||||||
editMessageId: messageId,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
activeConversation?.lockedSkillKey,
|
activeConversation?.lockedSkillKey,
|
||||||
activeId,
|
activeId,
|
||||||
deepThinking,
|
deepThinking,
|
||||||
|
modal,
|
||||||
removeMessage,
|
removeMessage,
|
||||||
requestWithStatus,
|
requestWithStatus,
|
||||||
setMessage,
|
setMessage,
|
||||||
@@ -321,8 +343,9 @@ export function useAiChatMessageActions({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const submitForm = useCallback(
|
const submitForm = useCallback(
|
||||||
(form: AiFormSchema, values: Record<string, unknown>) => {
|
async (form: AiFormSchema, values: Record<string, unknown>): Promise<void> => {
|
||||||
if (!activeId || isRequesting) return;
|
if (!activeId) throw new Error('当前会话不可用,请稍后重试');
|
||||||
|
if (isRequesting) throw new Error('请等待当前 AI 回复完成后再提交表单');
|
||||||
requestWithStatus({
|
requestWithStatus({
|
||||||
message: '表单提交',
|
message: '表单提交',
|
||||||
attachmentIds: [],
|
attachmentIds: [],
|
||||||
@@ -336,8 +359,9 @@ export function useAiChatMessageActions({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const submitReview = useCallback(
|
const submitReview = useCallback(
|
||||||
(reviewId: string, reviewTitle?: string) => {
|
async (reviewId: string, reviewTitle?: string): Promise<void> => {
|
||||||
if (!activeId || isRequesting) return;
|
if (!activeId) throw new Error('当前会话不可用,请稍后重试');
|
||||||
|
if (isRequesting) throw new Error('请等待当前 AI 回复完成后再确认导入');
|
||||||
requestWithStatus({
|
requestWithStatus({
|
||||||
message: '确认批量导入',
|
message: '确认批量导入',
|
||||||
attachmentIds: [],
|
attachmentIds: [],
|
||||||
@@ -418,7 +442,9 @@ export function useAiChatMessageActions({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const uploaded = await aiChatApi.uploadAttachment(file);
|
const uploaded = await aiChatApi.uploadAttachment(file, (percent) => {
|
||||||
|
options.onProgress?.({ percent });
|
||||||
|
});
|
||||||
setAttachments((items) => [...items, uploaded]);
|
setAttachments((items) => [...items, uploaded]);
|
||||||
options.onSuccess?.(uploaded, file);
|
options.onSuccess?.(uploaded, file);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
161
apps/admin/src/components/AiChat/useSubmissionState.test.tsx
Normal file
161
apps/admin/src/components/AiChat/useSubmissionState.test.tsx
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import { act } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
import { useSubmissionState, useXCardSurface } from './useSubmissionState';
|
||||||
|
|
||||||
|
// 项目未安装 @testing-library/react,用 createRoot + harness 组件暴露 hook API
|
||||||
|
let container: HTMLDivElement | null = null;
|
||||||
|
let root: ReturnType<typeof createRoot> | null = null;
|
||||||
|
let api: ReturnType<typeof useSubmissionState> | null = null;
|
||||||
|
let surface: ReturnType<typeof useXCardSurface> | null = null;
|
||||||
|
let surfaceId = 'surface-test';
|
||||||
|
|
||||||
|
function Harness() {
|
||||||
|
api = useSubmissionState();
|
||||||
|
surface = useXCardSurface(surfaceId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHarness(): void {
|
||||||
|
container = document.createElement('div');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
act(() => {
|
||||||
|
root?.render(<Harness />);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (root) await act(async () => root?.unmount());
|
||||||
|
container?.remove();
|
||||||
|
root = null;
|
||||||
|
container = null;
|
||||||
|
api = null;
|
||||||
|
surface = null;
|
||||||
|
surfaceId = 'surface-test';
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useSubmissionState', () => {
|
||||||
|
it('tracks submitting during the task and succeeds afterwards', async () => {
|
||||||
|
renderHarness();
|
||||||
|
let resolveTask: () => void = () => undefined;
|
||||||
|
const task = () =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
resolveTask = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
let promise: Promise<void> | undefined;
|
||||||
|
act(() => {
|
||||||
|
promise = api?.run(task);
|
||||||
|
});
|
||||||
|
expect(api?.submitting).toBe(true);
|
||||||
|
expect(api?.error).toBeNull();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
resolveTask();
|
||||||
|
await promise;
|
||||||
|
});
|
||||||
|
expect(api?.submitting).toBe(false);
|
||||||
|
expect(api?.submitted).toBe(true);
|
||||||
|
expect(api?.error).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('captures the error message and keeps submitted false on failure', async () => {
|
||||||
|
renderHarness();
|
||||||
|
const failing = () => {
|
||||||
|
throw new Error('接口 500');
|
||||||
|
};
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await api?.run(failing);
|
||||||
|
});
|
||||||
|
expect(api?.submitting).toBe(false);
|
||||||
|
expect(api?.submitted).toBe(false);
|
||||||
|
expect(api?.error).toBe('接口 500');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes non-Error rejections to a generic message', async () => {
|
||||||
|
renderHarness();
|
||||||
|
const failing = () => Promise.reject('raw string');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await api?.run(failing);
|
||||||
|
});
|
||||||
|
expect(api?.error).toBe('提交失败,请稍后重试');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores re-entrant calls while a task is in flight', async () => {
|
||||||
|
renderHarness();
|
||||||
|
let resolveTask: () => void = () => undefined;
|
||||||
|
const task = () =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
resolveTask = resolve;
|
||||||
|
});
|
||||||
|
let secondRan = false;
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
void api?.run(task);
|
||||||
|
void api?.run(() => {
|
||||||
|
secondRan = true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
expect(secondRan).toBe(false);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
resolveTask();
|
||||||
|
});
|
||||||
|
expect(api?.submitted).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reset clears submitted and error states', async () => {
|
||||||
|
renderHarness();
|
||||||
|
await act(async () => {
|
||||||
|
await api?.run(() => undefined);
|
||||||
|
});
|
||||||
|
expect(api?.submitted).toBe(true);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
api?.reset();
|
||||||
|
});
|
||||||
|
expect(api?.submitted).toBe(false);
|
||||||
|
expect(api?.error).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useXCardSurface', () => {
|
||||||
|
it('deduplicates createSurface commands for the same surface id', () => {
|
||||||
|
renderHarness();
|
||||||
|
act(() => {
|
||||||
|
surface?.pushCommands([
|
||||||
|
{ version: 'v0.9', createSurface: { surfaceId: 'surface-test', catalogId: 'catalog' } },
|
||||||
|
{ version: 'v0.9', updateDataModel: { surfaceId: 'surface-test', path: '/x', value: 1 } },
|
||||||
|
]);
|
||||||
|
surface?.pushCommands([
|
||||||
|
{ version: 'v0.9', createSurface: { surfaceId: 'surface-test', catalogId: 'catalog' } },
|
||||||
|
{ version: 'v0.9', updateDataModel: { surfaceId: 'surface-test', path: '/x', value: 2 } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
const createCommands = surface?.commands.filter((command) => 'createSurface' in command);
|
||||||
|
expect(createCommands).toHaveLength(1);
|
||||||
|
expect(surface?.commands).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets the command stream when the surface id changes', () => {
|
||||||
|
renderHarness();
|
||||||
|
act(() => {
|
||||||
|
surface?.pushCommands([
|
||||||
|
{ version: 'v0.9', createSurface: { surfaceId: 'surface-test', catalogId: 'c' } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
expect(surface?.commands).toHaveLength(1);
|
||||||
|
|
||||||
|
surfaceId = 'surface-other';
|
||||||
|
act(() => {
|
||||||
|
root?.render(<Harness />);
|
||||||
|
surface?.pushCommands([
|
||||||
|
{ version: 'v0.9', createSurface: { surfaceId: 'surface-other', catalogId: 'c' } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
expect(surface?.commands).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
89
apps/admin/src/components/AiChat/useSubmissionState.ts
Normal file
89
apps/admin/src/components/AiChat/useSubmissionState.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { useCallback, useRef, useState } from 'react';
|
||||||
|
import { useLayoutEffect } from 'react';
|
||||||
|
import type { XAgentCommand_v0_9 } from '@ant-design/x-card';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提交状态管理:收敛 DynamicForm / DynamicReview 中重复的
|
||||||
|
* submitting / submitted / error 状态与「防重复提交 + 失败可重试」逻辑。
|
||||||
|
*
|
||||||
|
* 用法:
|
||||||
|
* const { submitting, submitted, error, run } = useSubmissionState();
|
||||||
|
* const handleSubmit = (values) => run(async () => { await onSubmit(values); });
|
||||||
|
*/
|
||||||
|
export function useSubmissionState() {
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [submitted, setSubmitted] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const submittingRef = useRef(false);
|
||||||
|
|
||||||
|
const run = useCallback(async (task: () => Promise<void> | void) => {
|
||||||
|
if (submittingRef.current) return;
|
||||||
|
submittingRef.current = true;
|
||||||
|
setSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await task();
|
||||||
|
setSubmitted(true);
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : '提交失败,请稍后重试');
|
||||||
|
} finally {
|
||||||
|
submittingRef.current = false;
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
setSubmitted(false);
|
||||||
|
setError(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { submitting, submitted, error, run, reset };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A2UI surface 的 XCard commands 增量更新生命周期:
|
||||||
|
* 每个 surface 只创建一次,后续通过 updateDataModel / updateComponents 增量更新。
|
||||||
|
*
|
||||||
|
* 用法:
|
||||||
|
* const { pushCommands } = useXCardSurface(surfaceId);
|
||||||
|
* useEffect(() => {
|
||||||
|
* pushCommands([
|
||||||
|
* { version: 'v0.9', createSurface: { surfaceId, catalogId } },
|
||||||
|
* { version: 'v0.9', updateDataModel: { surfaceId, path: '/x', value } },
|
||||||
|
* { version: 'v0.9', updateComponents: { surfaceId, components } },
|
||||||
|
* ]);
|
||||||
|
* }, [value]);
|
||||||
|
*/
|
||||||
|
export function useXCardSurface(surfaceId: string) {
|
||||||
|
const commandsRef = useRef<XAgentCommand_v0_9[]>([]);
|
||||||
|
const [commands, setCommands] = useState<XAgentCommand_v0_9[]>([]);
|
||||||
|
const idRef = useRef<string>('');
|
||||||
|
|
||||||
|
const pushCommands = useCallback(
|
||||||
|
(cmds: XAgentCommand_v0_9[]) => {
|
||||||
|
if (cmds.length === 0) return;
|
||||||
|
// 同一 surface 的 createSurface 命令只允许出现一次,自动去重
|
||||||
|
const hasSurface = commandsRef.current.some(
|
||||||
|
(c) => 'createSurface' in c && c.createSurface.surfaceId === surfaceId,
|
||||||
|
);
|
||||||
|
const filtered = hasSurface
|
||||||
|
? cmds.filter((c) => !('createSurface' in c))
|
||||||
|
: cmds;
|
||||||
|
commandsRef.current = [...commandsRef.current, ...filtered];
|
||||||
|
setCommands([...commandsRef.current]);
|
||||||
|
},
|
||||||
|
[surfaceId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const surfaceKey = surfaceId;
|
||||||
|
// 渲染期保持纯函数:ref 变更放到 layout effect 里
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (idRef.current !== surfaceKey) {
|
||||||
|
// 组件复用到新 surface 时,清空历史命令重新初始化
|
||||||
|
commandsRef.current = [];
|
||||||
|
idRef.current = surfaceKey;
|
||||||
|
}
|
||||||
|
}, [surfaceKey]);
|
||||||
|
|
||||||
|
return { commands, pushCommands };
|
||||||
|
}
|
||||||
69
apps/admin/src/components/AiChat/welcomeCopy.ts
Normal file
69
apps/admin/src/components/AiChat/welcomeCopy.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { getRoleDomains } from '../../auth/menu-policy';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按用户角色生成 AI 助手欢迎语,引导用户使用与其岗位匹配的业务闭环。
|
||||||
|
* 优先级:教师 > 住宿运营 > 教务 > 教室运营 > 系统/超管 > 兜底。
|
||||||
|
*/
|
||||||
|
export function welcomeDescription(roles: readonly string[], permissions: readonly string[]): string {
|
||||||
|
const domains = getRoleDomains(roles, permissions);
|
||||||
|
|
||||||
|
if (domains.has('teacher')) {
|
||||||
|
return '我可以帮你查询今日课程、拉取钉钉考勤、查看排课。课程开始后就能看到打卡结果。';
|
||||||
|
}
|
||||||
|
if (domains.has('accommodation')) {
|
||||||
|
return '我可以帮你完成「宿舍档案 → 学生 → 入住 → 费用 → 账单」的住宿计费闭环,先告诉我你手头有什么数据。';
|
||||||
|
}
|
||||||
|
if (domains.has('academic')) {
|
||||||
|
return '我可以帮你完成「学生档案 → 分班 → 排课 → 考勤」的教学闭环,支持 Excel 批量导入与预览确认。';
|
||||||
|
}
|
||||||
|
if (domains.has('classroom')) {
|
||||||
|
return '我可以帮你管理教室排期与租赁订单,查询占用情况,避免时间冲突。';
|
||||||
|
}
|
||||||
|
if (domains.has('system') || domains.has('super')) {
|
||||||
|
return '我是恭学 AI 助手。我可以查询经营数据、管理业务数据、生成批量导入预览——所有写操作都会先经你确认。';
|
||||||
|
}
|
||||||
|
return '我会在你的权限范围内查询数据,也能通过表单帮你录入学生等业务信息。';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 与角色匹配的业务闭环引导示例(用于 Prompts 建议话术)。
|
||||||
|
* 返回空数组表示当前角色无匹配示例。
|
||||||
|
*/
|
||||||
|
export function workflowPromptExamples(
|
||||||
|
roles: readonly string[],
|
||||||
|
permissions: readonly string[],
|
||||||
|
): { label: string; description: string }[] {
|
||||||
|
const domains = getRoleDomains(roles, permissions);
|
||||||
|
const examples: { label: string; description: string }[] = [];
|
||||||
|
|
||||||
|
if (domains.has('academic')) {
|
||||||
|
examples.push(
|
||||||
|
{ label: '帮我从 Excel 导入学生并完成分班', description: '教学闭环' },
|
||||||
|
{ label: '查一下这周有哪些班级还没排课', description: '教学闭环' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (domains.has('accommodation')) {
|
||||||
|
examples.push(
|
||||||
|
{ label: '帮我从 Excel 导入学生并安排入住', description: '住宿计费闭环' },
|
||||||
|
{ label: '查一下本月还没生成账单的入住学生', description: '住宿计费闭环' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (domains.has('classroom')) {
|
||||||
|
examples.push(
|
||||||
|
{ label: '查一下这间教室本周的占用情况', description: '教室运营' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (domains.has('teacher')) {
|
||||||
|
examples.push(
|
||||||
|
{ label: '今天我有哪几节课?', description: '今日教学' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (domains.has('system') || domains.has('super')) {
|
||||||
|
examples.push(
|
||||||
|
{ label: '看一下本月的经营概览', description: '数据面板' },
|
||||||
|
{ label: '帮我梳理宿舍计费的完整流程', description: '业务流程' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return examples;
|
||||||
|
}
|
||||||
52
apps/admin/src/components/AppErrorBoundary.tsx
Normal file
52
apps/admin/src/components/AppErrorBoundary.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Button, Result } from 'antd';
|
||||||
|
|
||||||
|
interface AppErrorBoundaryProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
/** 自定义降级内容;不传则使用默认错误卡片 */
|
||||||
|
fallback?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AppErrorBoundaryState {
|
||||||
|
hasError: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全局渲染错误兜底:捕获子树内的渲染异常,展示可恢复的错误卡片,
|
||||||
|
* 避免单个页面/组件崩溃导致整个应用白屏。
|
||||||
|
*/
|
||||||
|
export class AppErrorBoundary extends React.Component<
|
||||||
|
AppErrorBoundaryProps,
|
||||||
|
AppErrorBoundaryState
|
||||||
|
> {
|
||||||
|
state: AppErrorBoundaryState = { hasError: false };
|
||||||
|
|
||||||
|
static getDerivedStateFromError(): AppErrorBoundaryState {
|
||||||
|
return { hasError: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, info: React.ErrorInfo): void {
|
||||||
|
console.error('[AppErrorBoundary] 渲染异常:', error, info.componentStack);
|
||||||
|
}
|
||||||
|
|
||||||
|
render(): React.ReactNode {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
if (this.props.fallback) return this.props.fallback;
|
||||||
|
return (
|
||||||
|
<Result
|
||||||
|
status="error"
|
||||||
|
title="页面出现异常"
|
||||||
|
subTitle="请刷新页面重试;若问题持续,请联系管理员。"
|
||||||
|
extra={
|
||||||
|
<Button type="primary" onClick={() => window.location.reload()}>
|
||||||
|
刷新页面
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AppErrorBoundary;
|
||||||
41
apps/admin/src/components/BackTop.tsx
Normal file
41
apps/admin/src/components/BackTop.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Button, Tooltip } from 'antd';
|
||||||
|
import { VerticalAlignTopOutlined } from '@ant-design/icons';
|
||||||
|
import { useEventCallback, useEventListener } from 'usehooks-ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全局「回到顶部」浮动按钮:长列表滚动超过 400px 后出现。
|
||||||
|
* 尊重系统「减少动态效果」偏好,平滑滚动仅在未开启该偏好时使用。
|
||||||
|
*/
|
||||||
|
export const BackTop: React.FC<{ threshold?: number }> = ({ threshold = 400 }) => {
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const updateVisible = useEventCallback(() => setVisible(window.scrollY > threshold));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updateVisible();
|
||||||
|
}, [threshold, updateVisible]);
|
||||||
|
|
||||||
|
useEventListener('scroll', updateVisible, undefined, { passive: true });
|
||||||
|
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
const scrollToTop = () => {
|
||||||
|
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
window.scrollTo({ top: 0, behavior: reduceMotion ? 'auto' : 'smooth' });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip title="回到顶部">
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
shape="circle"
|
||||||
|
icon={<VerticalAlignTopOutlined />}
|
||||||
|
aria-label="回到顶部"
|
||||||
|
onClick={scrollToTop}
|
||||||
|
style={{ position: 'fixed', right: 24, bottom: 48, zIndex: 1000 }}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BackTop;
|
||||||
105
apps/admin/src/components/DefaultRoute.integration.test.tsx
Normal file
105
apps/admin/src/components/DefaultRoute.integration.test.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { act } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { MemoryRouter, Route, Routes, useNavigate } from 'react-router';
|
||||||
|
import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import DefaultRoute from './DefaultRoute';
|
||||||
|
import { RouteKeeper } from './RouteKeeper';
|
||||||
|
import { usePermissionStore } from '../store/permission/permissionStore';
|
||||||
|
import { useUserStore } from '../store/user/userStore';
|
||||||
|
|
||||||
|
let container: HTMLDivElement | null = null;
|
||||||
|
let root: ReturnType<typeof createRoot> | null = null;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
(
|
||||||
|
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }
|
||||||
|
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// 清掉可能由其他测试文件遗留的持久化数据,保证会话状态可控
|
||||||
|
Object.keys(localStorage).forEach((key) => localStorage.removeItem(key));
|
||||||
|
useUserStore.setState({ token: null, user: null });
|
||||||
|
usePermissionStore.setState({ permissions: [], status: 'unknown' });
|
||||||
|
useUserStore.getState().setSession('test-token', {
|
||||||
|
id: 1,
|
||||||
|
username: 'admin',
|
||||||
|
roles: ['超级管理员'],
|
||||||
|
});
|
||||||
|
usePermissionStore.getState().writePermissions(['dashboard:view', 'student:view']);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (root) await act(async () => root?.unmount());
|
||||||
|
container?.remove();
|
||||||
|
root = null;
|
||||||
|
container = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
function PageDashboard() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div data-testid="page-dashboard">数据面板</div>
|
||||||
|
<button data-testid="go-students" onClick={() => navigate('/students')}>
|
||||||
|
去学生管理
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PageStudents() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div data-testid="page-students">学生管理</div>
|
||||||
|
<button data-testid="go-home" onClick={() => navigate('/')}>
|
||||||
|
回首页
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Harness() {
|
||||||
|
return (
|
||||||
|
<MemoryRouter initialEntries={['/']}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<RouteKeeper />}>
|
||||||
|
<Route index element={<DefaultRoute />} />
|
||||||
|
<Route path="dashboard" element={<PageDashboard />} />
|
||||||
|
<Route path="students" element={<PageStudents />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderHarness() {
|
||||||
|
container = document.createElement('div');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(<Harness />);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DefaultRoute keep-alive regression', () => {
|
||||||
|
it('redirects to landing page once and does not hijack later navigations', async () => {
|
||||||
|
await renderHarness();
|
||||||
|
|
||||||
|
// 首次进入 '/' 应跳转到落地页 /dashboard,且只跳一次
|
||||||
|
expect(document.querySelector('[data-testid="page-dashboard"]')).not.toBeNull();
|
||||||
|
|
||||||
|
// 再导航到 /students,不应被保活的首页节点拉回 /dashboard
|
||||||
|
await act(async () => {
|
||||||
|
(document.querySelector('[data-testid="go-students"]') as HTMLButtonElement).click();
|
||||||
|
});
|
||||||
|
expect(document.querySelector('[data-testid="page-students"]')).not.toBeNull();
|
||||||
|
|
||||||
|
// 回到 '/' 时仍应再次跳转到落地页
|
||||||
|
await act(async () => {
|
||||||
|
(document.querySelector('[data-testid="go-home"]') as HTMLButtonElement).click();
|
||||||
|
});
|
||||||
|
expect(document.querySelector('[data-testid="page-dashboard"]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React, { useEffect, useRef } from 'react';
|
||||||
import { Navigate } from 'react-router';
|
import { useLocation, useNavigate } from 'react-router';
|
||||||
import { Result, Spin } from 'antd';
|
import { Result, Spin } from 'antd';
|
||||||
import { usePermission } from '../hooks/usePermission';
|
import { usePermission } from '../hooks/usePermission';
|
||||||
import { findRoleAwareLandingPath } from '../auth/menu-policy';
|
import { findRoleAwareLandingPath } from '../auth/menu-policy';
|
||||||
@@ -8,14 +8,31 @@ import { useUserStore } from '../store/user/userStore';
|
|||||||
const DefaultRoute: React.FC = () => {
|
const DefaultRoute: React.FC = () => {
|
||||||
const { permissions, permissionsReady } = usePermission();
|
const { permissions, permissionsReady } = usePermission();
|
||||||
const roles = useUserStore((state) => state.user?.roles ?? []);
|
const roles = useUserStore((state) => state.user?.roles ?? []);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
const firstPath = permissionsReady ? findRoleAwareLandingPath(roles, permissions) : null;
|
||||||
|
|
||||||
|
// 用 effect 导航替代声明式 <Navigate>:RouteKeeper 会把首页(index)节点保活缓存,
|
||||||
|
// 声明式 <Navigate> 在缓存节点随路由变化重渲染时会反复触发,导致首次进入系统后
|
||||||
|
// 点击任何按钮都被拉回 dashboard,必须刷新页面才能恢复。这里仅在确实处于首页
|
||||||
|
// 且已计算出落点时跳转;navigate 通过 ref 持有,避免其每次渲染变化导致 effect 空转。
|
||||||
|
const navigateRef = useRef(navigate);
|
||||||
|
navigateRef.current = navigate;
|
||||||
|
useEffect(() => {
|
||||||
|
if (pathname === '/' && firstPath) {
|
||||||
|
navigateRef.current(firstPath, { replace: true });
|
||||||
|
}
|
||||||
|
}, [pathname, firstPath]);
|
||||||
|
|
||||||
if (!permissionsReady) {
|
if (!permissionsReady) {
|
||||||
return <Spin size="large" style={{ display: 'block', margin: '80px auto' }} />;
|
return <Spin size="large" style={{ display: 'block', margin: '80px auto' }} />;
|
||||||
}
|
}
|
||||||
const firstPath = findRoleAwareLandingPath(roles, permissions);
|
if (!firstPath) {
|
||||||
if (firstPath) return <Navigate to={firstPath} replace />;
|
return (
|
||||||
return (
|
<Result status="403" title="暂无可访问功能" subTitle="请联系管理员为当前账号分配功能权限" />
|
||||||
<Result status="403" title="暂无可访问功能" subTitle="请联系管理员为当前账号分配功能权限" />
|
);
|
||||||
);
|
}
|
||||||
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default DefaultRoute;
|
export default DefaultRoute;
|
||||||
|
|||||||
@@ -50,13 +50,15 @@ interface EChartsProps {
|
|||||||
|
|
||||||
const ECharts: React.FC<EChartsProps> = ({ option, style, className, onReady }) => {
|
const ECharts: React.FC<EChartsProps> = ({ option, style, className, onReady }) => {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const optionRef = useRef(option);
|
||||||
|
optionRef.current = option;
|
||||||
const onReadyRef = useRef(onReady);
|
const onReadyRef = useRef(onReady);
|
||||||
onReadyRef.current = onReady;
|
onReadyRef.current = onReady;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!containerRef.current) return;
|
if (!containerRef.current) return;
|
||||||
const chart = echarts.init(containerRef.current);
|
const chart = echarts.init(containerRef.current);
|
||||||
chart.setOption(option);
|
chart.setOption(optionRef.current);
|
||||||
onReadyRef.current?.(chart);
|
onReadyRef.current?.(chart);
|
||||||
const observer = new ResizeObserver(() => chart.resize());
|
const observer = new ResizeObserver(() => chart.resize());
|
||||||
observer.observe(containerRef.current);
|
observer.observe(containerRef.current);
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { DatePicker, Input, InputNumber, Select, Spin, Tooltip } from 'antd';
|
|||||||
import dayjs, { type Dayjs } from 'dayjs';
|
import dayjs, { type Dayjs } from 'dayjs';
|
||||||
import equal from 'fast-deep-equal';
|
import equal from 'fast-deep-equal';
|
||||||
import { usePermission } from '../../hooks/usePermission';
|
import { usePermission } from '../../hooks/usePermission';
|
||||||
|
import { useTimeout } from 'usehooks-ts';
|
||||||
|
import { useEditableCellStore } from '../../store/editableCell/editableCellStore';
|
||||||
import { message } from '../../ui/app-message';
|
import { message } from '../../ui/app-message';
|
||||||
import './style.css';
|
import './style.css';
|
||||||
import { getErrorMessage } from '../../utils/error';
|
import { getErrorMessage } from '../../utils/error';
|
||||||
@@ -39,9 +41,6 @@ export interface EditableCellProps<Value = unknown> {
|
|||||||
onSave: (value: Value) => Promise<void>;
|
onSave: (value: Value) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
let activeCell: { id: string; save: () => Promise<boolean> } | null = null;
|
|
||||||
let replayingOutsideAction = false;
|
|
||||||
|
|
||||||
export function normalizeEditableValue(value: unknown, editor: EditableCellEditor) {
|
export function normalizeEditableValue(value: unknown, editor: EditableCellEditor) {
|
||||||
if (editor === 'date') return value ? dayjs(value as string) : null;
|
if (editor === 'date') return value ? dayjs(value as string) : null;
|
||||||
if (editor === 'date-range')
|
if (editor === 'date-range')
|
||||||
@@ -102,6 +101,10 @@ const EditableCell = <Value,>({
|
|||||||
const [draft, setDraft] = useState<unknown>(() =>
|
const [draft, setDraft] = useState<unknown>(() =>
|
||||||
normalizeEditableValue(formatValue ? formatValue(value) : value, editor),
|
normalizeEditableValue(formatValue ? formatValue(value) : value, editor),
|
||||||
);
|
);
|
||||||
|
// 保存成功后短暂显示「撤销」入口:记录保存前的序列化旧值
|
||||||
|
const [undoMeta, setUndoMeta] = useState<{ serializedPrevious: unknown } | null>(null);
|
||||||
|
// 撤销入口 6 秒后自动消失;useTimeout 在 undoMeta 置空/组件卸载时自动清理
|
||||||
|
useTimeout(() => setUndoMeta(null), undoMeta ? 6_000 : null);
|
||||||
const enabled = !disabled && (!permission || hasPermission(permission));
|
const enabled = !disabled && (!permission || hasPermission(permission));
|
||||||
|
|
||||||
const original = useMemo(
|
const original = useMemo(
|
||||||
@@ -115,7 +118,7 @@ const EditableCell = <Value,>({
|
|||||||
|
|
||||||
const cancel = useCallback(() => {
|
const cancel = useCallback(() => {
|
||||||
setDraft(normalizeEditableValue(formatValue ? formatValue(value) : value, editor));
|
setDraft(normalizeEditableValue(formatValue ? formatValue(value) : value, editor));
|
||||||
if (activeCell?.id === idRef.current) activeCell = null;
|
useEditableCellStore.getState().clearIfActive(idRef.current);
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
}, [editor, formatValue, value]);
|
}, [editor, formatValue, value]);
|
||||||
|
|
||||||
@@ -128,15 +131,18 @@ const EditableCell = <Value,>({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (editableValuesEqual(serialized, original)) {
|
if (editableValuesEqual(serialized, original)) {
|
||||||
if (activeCell?.id === idRef.current) activeCell = null;
|
useEditableCellStore.getState().clearIfActive(idRef.current);
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
|
const previousValue = original;
|
||||||
try {
|
try {
|
||||||
await onSave(parseValue ? parseValue(serialized) : (serialized as Value));
|
await onSave(parseValue ? parseValue(serialized) : (serialized as Value));
|
||||||
if (activeCell?.id === idRef.current) activeCell = null;
|
useEditableCellStore.getState().clearIfActive(idRef.current);
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
|
// 提供 6 秒内的撤销入口(把旧值再保存一次);useTimeout 负责到时自动清除
|
||||||
|
setUndoMeta({ serializedPrevious: previousValue });
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error(getErrorMessage(error, '保存失败'));
|
message.error(getErrorMessage(error, '保存失败'));
|
||||||
@@ -152,16 +158,14 @@ const EditableCell = <Value,>({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const cellId = idRef.current;
|
const cellId = idRef.current;
|
||||||
if (editing && activeCell?.id === cellId) activeCell.save = save;
|
if (editing) useEditableCellStore.getState().updateActiveSave(cellId, save);
|
||||||
return () => {
|
return () => useEditableCellStore.getState().clearIfActive(cellId);
|
||||||
if (activeCell?.id === cellId) activeCell = null;
|
|
||||||
};
|
|
||||||
}, [editing, save]);
|
}, [editing, save]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editing) return;
|
if (!editing) return;
|
||||||
const onPointerDown = (event: PointerEvent) => {
|
const onPointerDown = (event: PointerEvent) => {
|
||||||
if (replayingOutsideAction) return;
|
if (useEditableCellStore.getState().replayingOutsideAction) return;
|
||||||
if (rootRef.current?.contains(event.target as Node) || isEditorOverlay(event.target)) return;
|
if (rootRef.current?.contains(event.target as Node) || isEditorOverlay(event.target)) return;
|
||||||
const actionTarget =
|
const actionTarget =
|
||||||
event.target instanceof Element
|
event.target instanceof Element
|
||||||
@@ -177,10 +181,10 @@ const EditableCell = <Value,>({
|
|||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
void save().then((saved) => {
|
void save().then((saved) => {
|
||||||
if (!saved) return;
|
if (!saved) return;
|
||||||
replayingOutsideAction = true;
|
useEditableCellStore.getState().setReplayingOutsideAction(true);
|
||||||
actionTarget.click();
|
actionTarget.click();
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
replayingOutsideAction = false;
|
useEditableCellStore.getState().setReplayingOutsideAction(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -192,15 +196,33 @@ const EditableCell = <Value,>({
|
|||||||
|
|
||||||
const beginEdit = async () => {
|
const beginEdit = async () => {
|
||||||
if (!enabled || saving) return;
|
if (!enabled || saving) return;
|
||||||
|
const { activeCell } = useEditableCellStore.getState();
|
||||||
if (activeCell && activeCell.id !== idRef.current) {
|
if (activeCell && activeCell.id !== idRef.current) {
|
||||||
const saved = await activeCell.save();
|
const saved = await activeCell.save();
|
||||||
if (!saved) return;
|
if (!saved) return;
|
||||||
}
|
}
|
||||||
activeCell = { id: idRef.current, save };
|
useEditableCellStore.getState().setActiveCell({ id: idRef.current, save });
|
||||||
|
// 重新进入编辑时清掉上一次的撤销入口
|
||||||
|
setUndoMeta(null);
|
||||||
setDraft(normalizeEditableValue(formatValue ? formatValue(value) : value, editor));
|
setDraft(normalizeEditableValue(formatValue ? formatValue(value) : value, editor));
|
||||||
setEditing(true);
|
setEditing(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleUndo = async () => {
|
||||||
|
if (!undoMeta) return;
|
||||||
|
setUndoMeta(null);
|
||||||
|
try {
|
||||||
|
await onSave(
|
||||||
|
parseValue
|
||||||
|
? parseValue(undoMeta.serializedPrevious)
|
||||||
|
: (undoMeta.serializedPrevious as Value),
|
||||||
|
);
|
||||||
|
message.success('已撤销修改');
|
||||||
|
} catch (error) {
|
||||||
|
message.error(getErrorMessage(error, '撤销失败'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
|
const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||||
if (event.pointerType !== 'touch' || editing) return;
|
if (event.pointerType !== 'touch' || editing) return;
|
||||||
touchStartRef.current = {
|
touchStartRef.current = {
|
||||||
@@ -241,6 +263,16 @@ const EditableCell = <Value,>({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (event.key === 'Enter' && editor !== 'textarea') {
|
if (event.key === 'Enter' && editor !== 'textarea') {
|
||||||
|
// 这些编辑器会自己消费 Enter(确认/提交选中值),不重复触发单元格保存
|
||||||
|
if (
|
||||||
|
editor === 'select' ||
|
||||||
|
editor === 'multi-select' ||
|
||||||
|
editor === 'tags' ||
|
||||||
|
editor === 'date' ||
|
||||||
|
editor === 'date-range'
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
await save();
|
await save();
|
||||||
return;
|
return;
|
||||||
@@ -308,7 +340,23 @@ const EditableCell = <Value,>({
|
|||||||
{editing ? (
|
{editing ? (
|
||||||
<Spin spinning={saving}>{control}</Spin>
|
<Spin spinning={saving}>{control}</Spin>
|
||||||
) : (
|
) : (
|
||||||
<Tooltip title={enabled ? '双击编辑,触屏双击编辑' : undefined}>{children}</Tooltip>
|
<Tooltip title={enabled ? '双击编辑,触屏双击编辑' : undefined}>
|
||||||
|
<span className="editable-cell-display">
|
||||||
|
{children}
|
||||||
|
{undoMeta ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="editable-cell-undo"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
void handleUndo();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
撤销
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,6 +5,29 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.editable-cell-display {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editable-cell-undo {
|
||||||
|
flex: none;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: #1677ff;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editable-cell-undo:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
.editable-cell--enabled {
|
.editable-cell--enabled {
|
||||||
cursor: cell;
|
cursor: cell;
|
||||||
touch-action: manipulation;
|
touch-action: manipulation;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
Descriptions,
|
Descriptions,
|
||||||
Flex,
|
Flex,
|
||||||
Modal,
|
Modal,
|
||||||
|
Progress,
|
||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
Spin,
|
Spin,
|
||||||
@@ -34,11 +35,13 @@ import {
|
|||||||
importErrorReportUrl,
|
importErrorReportUrl,
|
||||||
previewImportStep,
|
previewImportStep,
|
||||||
} from '../../api/imports';
|
} from '../../api/imports';
|
||||||
|
import { saveAs } from 'file-saver';
|
||||||
import {
|
import {
|
||||||
STEP_FIELDS,
|
STEP_FIELDS,
|
||||||
type ImportPreviewResult,
|
type ImportPreviewResult,
|
||||||
type ImportReceipt,
|
type ImportReceipt,
|
||||||
type ImportRunDetail,
|
type ImportRunDetail,
|
||||||
|
type ImportStageRequest,
|
||||||
type ImportStepKey,
|
type ImportStepKey,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
@@ -99,12 +102,7 @@ async function downloadErrorReport(runId: string, stepKey?: ImportStepKey): Prom
|
|||||||
});
|
});
|
||||||
if (!response.ok) throw new Error('错误报告下载失败');
|
if (!response.ok) throw new Error('错误报告下载失败');
|
||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
const url = URL.createObjectURL(blob);
|
saveAs(blob, `导入错误报告-${runId.slice(0, 8)}.csv`);
|
||||||
const anchor = document.createElement('a');
|
|
||||||
anchor.href = url;
|
|
||||||
anchor.download = `导入错误报告-${runId.slice(0, 8)}.csv`;
|
|
||||||
anchor.click();
|
|
||||||
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ImportWizardModal: React.FC<ImportWizardModalProps> = ({
|
export const ImportWizardModal: React.FC<ImportWizardModalProps> = ({
|
||||||
@@ -115,6 +113,7 @@ export const ImportWizardModal: React.FC<ImportWizardModalProps> = ({
|
|||||||
const [run, setRun] = useState<ImportRunDetail | null>(null);
|
const [run, setRun] = useState<ImportRunDetail | null>(null);
|
||||||
const [loadingRun, setLoadingRun] = useState(false);
|
const [loadingRun, setLoadingRun] = useState(false);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [uploadPercent, setUploadPercent] = useState(0);
|
||||||
const [activeStepKey, setActiveStepKey] = useState<ImportStepKey | null>(null);
|
const [activeStepKey, setActiveStepKey] = useState<ImportStepKey | null>(null);
|
||||||
const [sheetSelection, setSheetSelection] = useState<Record<string, string[]>>({});
|
const [sheetSelection, setSheetSelection] = useState<Record<string, string[]>>({});
|
||||||
const [mappingDraft, setMappingDraft] = useState<Record<string, Record<string, string>>>({});
|
const [mappingDraft, setMappingDraft] = useState<Record<string, Record<string, string>>>({});
|
||||||
@@ -185,20 +184,41 @@ export const ImportWizardModal: React.FC<ImportWizardModalProps> = ({
|
|||||||
return [...headers];
|
return [...headers];
|
||||||
}, [run, activeStepKey, sheetSelection]);
|
}, [run, activeStepKey, sheetSelection]);
|
||||||
|
|
||||||
const handleUpload: UploadProps['customRequest'] = async (options) => {
|
/** 创建导入任务并加载详情:统一处理上传进度与 loading 状态。成功返回 run 详情,失败返回 null */
|
||||||
const file = options.file as File;
|
const uploadRun = async (
|
||||||
|
file: File,
|
||||||
|
options: {
|
||||||
|
source: 'ai' | 'manual';
|
||||||
|
conversationId?: number;
|
||||||
|
stages?: ImportStageRequest[];
|
||||||
|
mapping?: Record<string, Record<string, string>>;
|
||||||
|
},
|
||||||
|
errorMessage: string,
|
||||||
|
): Promise<ImportRunDetail | null> => {
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
|
setUploadPercent(0);
|
||||||
try {
|
try {
|
||||||
const detail = await createImportRun(file, { source: 'manual' });
|
const detail = await createImportRun(file, {
|
||||||
|
...options,
|
||||||
|
onProgress: (percent) => setUploadPercent(percent),
|
||||||
|
});
|
||||||
await loadRun(detail.id);
|
await loadRun(detail.id);
|
||||||
message.success(`已识别 ${detail.sheets.length} 个工作表`);
|
return detail;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error(error instanceof Error ? error.message : '文件上传失败');
|
message.error(error instanceof Error ? error.message : errorMessage);
|
||||||
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
|
setUploadPercent(0);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleUpload: UploadProps['customRequest'] = async (options) => {
|
||||||
|
const file = options.file as File;
|
||||||
|
const detail = await uploadRun(file, { source: 'manual' }, '文件上传失败');
|
||||||
|
if (detail) message.success(`已识别 ${detail.sheets.length} 个工作表`);
|
||||||
|
};
|
||||||
|
|
||||||
const handlePreview = async () => {
|
const handlePreview = async () => {
|
||||||
if (!run || !activeStepKey || !activeStep) return;
|
if (!run || !activeStepKey || !activeStep) return;
|
||||||
const mapping = mappingDraft[activeStepKey] ?? {};
|
const mapping = mappingDraft[activeStepKey] ?? {};
|
||||||
@@ -255,20 +275,16 @@ export const ImportWizardModal: React.FC<ImportWizardModalProps> = ({
|
|||||||
|
|
||||||
const handleReupload = async (file: File) => {
|
const handleReupload = async (file: File) => {
|
||||||
if (!run || !activeStepKey) return;
|
if (!run || !activeStepKey) return;
|
||||||
setUploading(true);
|
const detail = await uploadRun(
|
||||||
try {
|
file,
|
||||||
const detail = await createImportRun(file, {
|
{
|
||||||
source: 'manual',
|
source: 'manual',
|
||||||
stages: [{ stepKey: activeStepKey, sheet: sheetSelection[activeStepKey]?.[0] }],
|
stages: [{ stepKey: activeStepKey, sheets: sheetSelection[activeStepKey] ?? [] }],
|
||||||
mapping: { [activeStepKey]: mappingDraft[activeStepKey] ?? {} },
|
mapping: { [activeStepKey]: mappingDraft[activeStepKey] ?? {} },
|
||||||
});
|
},
|
||||||
await loadRun(detail.id);
|
'重新上传失败',
|
||||||
message.success('已重新上传,并保留原列映射');
|
);
|
||||||
} catch (error) {
|
if (detail) message.success('已重新上传,并保留原列映射');
|
||||||
message.error(error instanceof Error ? error.message : '重新上传失败');
|
|
||||||
} finally {
|
|
||||||
setUploading(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const previewRows = useMemo(() => {
|
const previewRows = useMemo(() => {
|
||||||
@@ -390,7 +406,7 @@ export const ImportWizardModal: React.FC<ImportWizardModalProps> = ({
|
|||||||
<Alert
|
<Alert
|
||||||
type="info"
|
type="info"
|
||||||
showIcon
|
showIcon
|
||||||
message="上传 Excel 后,系统会自动识别工作表并按业务依赖分阶段(学生/宿舍 → 入住/换宿)。每一阶段都需要先预览、再确认,确认后才会写入数据库。"
|
title="上传 Excel 后,系统会自动识别工作表并按业务依赖分阶段(学生/宿舍 → 入住/换宿)。每一阶段都需要先预览、再确认,确认后才会写入数据库。"
|
||||||
/>
|
/>
|
||||||
<Upload.Dragger
|
<Upload.Dragger
|
||||||
accept=".xlsx,.csv"
|
accept=".xlsx,.csv"
|
||||||
@@ -405,6 +421,20 @@ export const ImportWizardModal: React.FC<ImportWizardModalProps> = ({
|
|||||||
<p className="ant-upload-text">点击或拖拽 .xlsx / .csv 文件到此区域</p>
|
<p className="ant-upload-text">点击或拖拽 .xlsx / .csv 文件到此区域</p>
|
||||||
<p className="ant-upload-hint">单文件不超过 10MB;.xls 请先另存为 .xlsx</p>
|
<p className="ant-upload-hint">单文件不超过 10MB;.xls 请先另存为 .xlsx</p>
|
||||||
</Upload.Dragger>
|
</Upload.Dragger>
|
||||||
|
{uploading ? (
|
||||||
|
<Flex vertical gap={4} style={{ marginTop: 8 }}>
|
||||||
|
<Progress
|
||||||
|
percent={uploadPercent}
|
||||||
|
size="small"
|
||||||
|
status={uploadPercent > 0 && uploadPercent < 100 ? 'active' : 'normal'}
|
||||||
|
/>
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12, textAlign: 'center' }}>
|
||||||
|
{uploadPercent > 0 && uploadPercent < 100
|
||||||
|
? `正在上传 ${uploadPercent}%...`
|
||||||
|
: '正在上传并解析文件...'}
|
||||||
|
</Typography.Text>
|
||||||
|
</Flex>
|
||||||
|
) : null}
|
||||||
</Space>
|
</Space>
|
||||||
) : (
|
) : (
|
||||||
<Space orientation="vertical" size={16} style={{ width: '100%' }}>
|
<Space orientation="vertical" size={16} style={{ width: '100%' }}>
|
||||||
@@ -456,7 +486,7 @@ export const ImportWizardModal: React.FC<ImportWizardModalProps> = ({
|
|||||||
</Flex>
|
</Flex>
|
||||||
) : allCommitted ? (
|
) : allCommitted ? (
|
||||||
<Space orientation="vertical" size={12} style={{ width: '100%' }}>
|
<Space orientation="vertical" size={12} style={{ width: '100%' }}>
|
||||||
<Alert type="success" showIcon message="全部阶段已提交完成" />
|
<Alert type="success" showIcon title="全部阶段已提交完成" />
|
||||||
<Descriptions
|
<Descriptions
|
||||||
bordered
|
bordered
|
||||||
size="small"
|
size="small"
|
||||||
@@ -607,7 +637,7 @@ export const ImportWizardModal: React.FC<ImportWizardModalProps> = ({
|
|||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
) : (
|
) : (
|
||||||
<Alert type="warning" showIcon message="当前没有可处理的阶段" />
|
<Alert type="warning" showIcon title="当前没有可处理的阶段" />
|
||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -36,11 +36,21 @@ export interface ImportRunDetail {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
sheets: ImportSheetMeta[];
|
sheets: ImportSheetMeta[];
|
||||||
steps: ImportStepDetail[];
|
steps: ImportStepDetail[];
|
||||||
|
settings?: {
|
||||||
|
mapping?: Partial<Record<ImportStepKey, Record<string, string>>>;
|
||||||
|
organization?: string | null;
|
||||||
|
updateExisting?: boolean;
|
||||||
|
duplicatePolicy?: 'error' | 'skip';
|
||||||
|
skipUnmatched?: boolean;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ImportStageRequest {
|
export interface ImportStageRequest {
|
||||||
stepKey: ImportStepKey;
|
stepKey: ImportStepKey;
|
||||||
|
/** 兼容旧调用:单个工作表名。 */
|
||||||
sheet?: string;
|
sheet?: string;
|
||||||
|
/** 一个阶段可包含多张工作表;与 sheet 二选一(sheets 优先)。 */
|
||||||
|
sheets?: string[];
|
||||||
headerRow?: number;
|
headerRow?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useImmer } from 'use-immer';
|
import { useImmer } from 'use-immer';
|
||||||
import { Button, Form, Input, Modal, Select, Spin, Steps, Typography } from 'antd';
|
import { App, Button, Form, Input, Modal, Select, Spin, Steps, Typography } from 'antd';
|
||||||
import { CloudUploadOutlined, EditOutlined, PlusOutlined, SearchOutlined } from '@ant-design/icons';
|
import { CloudUploadOutlined, EditOutlined, PlusOutlined, SearchOutlined } from '@ant-design/icons';
|
||||||
import api from '../api';
|
import api from '../api';
|
||||||
import { message } from '../ui/app-message';
|
import { message } from '../ui/app-message';
|
||||||
@@ -30,6 +30,7 @@ interface MatchModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const JinshujuMatchModal: React.FC<MatchModalProps> = ({ open, onClose, onApplied }) => {
|
const JinshujuMatchModal: React.FC<MatchModalProps> = ({ open, onClose, onApplied }) => {
|
||||||
|
const { modal } = App.useApp();
|
||||||
const { hasPermission, hasAllPermissions, permissionsReady } = usePermission();
|
const { hasPermission, hasAllPermissions, permissionsReady } = usePermission();
|
||||||
const canTriggerSync = hasPermission('sync:trigger');
|
const canTriggerSync = hasPermission('sync:trigger');
|
||||||
const canEnterModal = permissionsReady && hasAllPermissions('sync:read', 'sync:trigger');
|
const canEnterModal = permissionsReady && hasAllPermissions('sync:read', 'sync:trigger');
|
||||||
@@ -145,6 +146,10 @@ const JinshujuMatchModal: React.FC<MatchModalProps> = ({ open, onClose, onApplie
|
|||||||
message.success(res.log.message || `处理 ${res.log.recordsCount} 条记录`);
|
message.success(res.log.message || `处理 ${res.log.recordsCount} 条记录`);
|
||||||
onApplied();
|
onApplied();
|
||||||
reset();
|
reset();
|
||||||
|
} else {
|
||||||
|
// 接口返回 success:false 时也要结束「处理中」并给出错误提示
|
||||||
|
message.error(res.log?.message || '处理失败,请检查后重试');
|
||||||
|
setStep('match');
|
||||||
}
|
}
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const err = e as { message?: string };
|
const err = e as { message?: string };
|
||||||
@@ -168,6 +173,22 @@ const JinshujuMatchModal: React.FC<MatchModalProps> = ({ open, onClose, onApplie
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
|
// 已进入匹配步骤且存在待处理数据时,关闭会丢失全部决策,需先确认
|
||||||
|
const hasPendingWork = step === 'match' && (entries.length > 0 || decisions.size > 0);
|
||||||
|
if (hasPendingWork) {
|
||||||
|
modal.confirm({
|
||||||
|
title: '放弃当前匹配?',
|
||||||
|
content: '已确认的匹配决策将全部丢失,且不会写入系统。',
|
||||||
|
okText: '放弃',
|
||||||
|
okButtonProps: { danger: true },
|
||||||
|
cancelText: '继续匹配',
|
||||||
|
onOk: () => {
|
||||||
|
reset();
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
reset();
|
reset();
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
@@ -329,7 +350,8 @@ const JinshujuMatchModal: React.FC<MatchModalProps> = ({ open, onClose, onApplie
|
|||||||
open={open && canEnterModal}
|
open={open && canEnterModal}
|
||||||
onCancel={handleClose}
|
onCancel={handleClose}
|
||||||
width={step === 'match' || step === 'applying' ? 900 : 640}
|
width={step === 'match' || step === 'applying' ? 900 : 640}
|
||||||
maskClosable={false}
|
mask={{ closable: false }}
|
||||||
|
closable={step !== 'applying'}
|
||||||
footer={
|
footer={
|
||||||
step === 'connection'
|
step === 'connection'
|
||||||
? [
|
? [
|
||||||
@@ -380,7 +402,12 @@ const JinshujuMatchModal: React.FC<MatchModalProps> = ({ open, onClose, onApplie
|
|||||||
{step === 'rule' ? renderRuleStep() : null}
|
{step === 'rule' ? renderRuleStep() : null}
|
||||||
{step === 'match' ? renderMatchStep() : null}
|
{step === 'match' ? renderMatchStep() : null}
|
||||||
{step === 'applying' ? (
|
{step === 'applying' ? (
|
||||||
<Spin description="正在同步..." style={{ display: 'block', margin: '48px auto' }} />
|
<>
|
||||||
|
<Spin description="正在同步,请勿关闭窗口..." style={{ display: 'block', margin: '48px auto' }} />
|
||||||
|
<Typography.Text type="secondary" style={{ display: 'block', textAlign: 'center' }}>
|
||||||
|
数据正在写入,关闭窗口不会中断同步
|
||||||
|
</Typography.Text>
|
||||||
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|||||||
69
apps/admin/src/components/NextStepHint.tsx
Normal file
69
apps/admin/src/components/NextStepHint.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Button, Card, Flex, Typography } from 'antd';
|
||||||
|
import { CloseOutlined, RightOutlined, StepForwardOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
export interface NextStepHintProps {
|
||||||
|
/** 提示标题,如「下一步:分班」 */
|
||||||
|
title: string;
|
||||||
|
/** 补充说明 */
|
||||||
|
description?: string;
|
||||||
|
/** 主操作按钮(跳转到下一步) */
|
||||||
|
action?: { label: string; onClick: () => void };
|
||||||
|
/** 是否可关闭,默认 true */
|
||||||
|
closable?: boolean;
|
||||||
|
/** 关闭回调 */
|
||||||
|
onClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「下一步」引导卡片:在操作成功后或空状态下提示用户业务闭环的下一步,
|
||||||
|
* 让用户始终知道接下来该做什么。
|
||||||
|
*/
|
||||||
|
export const NextStepHint: React.FC<NextStepHintProps> = ({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
action,
|
||||||
|
closable = true,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
const [dismissed, setDismissed] = useState(false);
|
||||||
|
if (dismissed) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
className="next-step-hint"
|
||||||
|
style={{ marginBottom: 16, borderColor: '#b7d4ff', background: '#f0f7ff' }}
|
||||||
|
styles={{ body: { padding: '10px 16px' } }}
|
||||||
|
>
|
||||||
|
<Flex align="center" justify="space-between" gap={8} wrap>
|
||||||
|
<Flex align="center" gap={8} wrap>
|
||||||
|
<StepForwardOutlined style={{ color: '#1677ff' }} />
|
||||||
|
<Typography.Text strong>{title}</Typography.Text>
|
||||||
|
{description ? <Typography.Text type="secondary">{description}</Typography.Text> : null}
|
||||||
|
</Flex>
|
||||||
|
<Flex gap={4} align="center">
|
||||||
|
{action ? (
|
||||||
|
<Button type="primary" size="small" onClick={action.onClick}>
|
||||||
|
{action.label} <RightOutlined />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{closable ? (
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<CloseOutlined />}
|
||||||
|
aria-label="关闭提示"
|
||||||
|
onClick={() => {
|
||||||
|
setDismissed(true);
|
||||||
|
onClose?.();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NextStepHint;
|
||||||
@@ -5,6 +5,7 @@ import { useNavigate } from 'react-router';
|
|||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { useInterval } from 'usehooks-ts';
|
import { useInterval } from 'usehooks-ts';
|
||||||
import api from '../api';
|
import api from '../api';
|
||||||
|
import { message } from '../ui/app-message';
|
||||||
import { formatNotificationText, notificationTypeLabels } from '../utils/notification-display';
|
import { formatNotificationText, notificationTypeLabels } from '../utils/notification-display';
|
||||||
import { useUserStore } from '../store/user/userStore';
|
import { useUserStore } from '../store/user/userStore';
|
||||||
|
|
||||||
@@ -33,8 +34,9 @@ const NotificationBell: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const data = await api.get<NotificationItem[]>('/notifications?limit=20');
|
const data = await api.get<NotificationItem[]>('/notifications?limit=20');
|
||||||
setNotifications(data);
|
setNotifications(data);
|
||||||
} catch {
|
} catch (error) {
|
||||||
/* ignore */
|
console.error('全部已读失败', error);
|
||||||
|
message.error('全部已读失败,请重试');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -118,7 +120,7 @@ const NotificationBell: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography.Text strong>通知中心</Typography.Text>
|
<Typography.Text strong>通知中心</Typography.Text>
|
||||||
<Button type="link" size="small" onClick={handleMarkAll}>
|
<Button type="link" size="small" disabled={unreadCount === 0} onClick={handleMarkAll}>
|
||||||
全部已读
|
全部已读
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -199,7 +201,12 @@ const NotificationBell: React.FC = () => {
|
|||||||
placement="bottomRight"
|
placement="bottomRight"
|
||||||
>
|
>
|
||||||
<Badge count={unreadCount} size="small" offset={[-2, 2]}>
|
<Badge count={unreadCount} size="small" offset={[-2, 2]}>
|
||||||
<BellOutlined style={{ fontSize: 18, cursor: 'pointer' }} />
|
<Button
|
||||||
|
type="text"
|
||||||
|
shape="circle"
|
||||||
|
icon={<BellOutlined />}
|
||||||
|
aria-label="通知中心"
|
||||||
|
/>
|
||||||
</Badge>
|
</Badge>
|
||||||
</Popover>
|
</Popover>
|
||||||
);
|
);
|
||||||
|
|||||||
35
apps/admin/src/components/QueryState/QueryEmpty.tsx
Normal file
35
apps/admin/src/components/QueryState/QueryEmpty.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Button, Empty } from 'antd';
|
||||||
|
|
||||||
|
export interface QueryEmptyAction {
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
type?: 'primary' | 'default';
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueryEmptyProps {
|
||||||
|
/** 空状态描述,默认「暂无数据」 */
|
||||||
|
description?: string;
|
||||||
|
/** 主操作按钮(如「添加学生」「导入 Excel」) */
|
||||||
|
action?: QueryEmptyAction;
|
||||||
|
/** 自定义空态插图 */
|
||||||
|
image?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一空状态:数据确实为空时渲染本组件,并附带主操作按钮引导用户开始。
|
||||||
|
*/
|
||||||
|
export const QueryEmpty: React.FC<QueryEmptyProps> = ({ description = '暂无数据', action, image }) => {
|
||||||
|
return (
|
||||||
|
<Empty image={image} description={description}>
|
||||||
|
{action ? (
|
||||||
|
<Button type={action.type ?? 'primary'} icon={action.icon} onClick={action.onClick}>
|
||||||
|
{action.label}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Empty>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default QueryEmpty;
|
||||||
56
apps/admin/src/components/QueryState/QueryErrorState.tsx
Normal file
56
apps/admin/src/components/QueryState/QueryErrorState.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Button, Result, Typography } from 'antd';
|
||||||
|
|
||||||
|
export interface QueryErrorStateProps {
|
||||||
|
/** 错误标题,默认「数据加载失败」 */
|
||||||
|
title?: string;
|
||||||
|
/** 错误描述,默认「请检查网络后重试」 */
|
||||||
|
description?: string;
|
||||||
|
/** 点击重试回调;不传则不显示重试按钮 */
|
||||||
|
onRetry?: () => void;
|
||||||
|
/** 紧凑模式:用于表格内部、弹窗等空间受限场景 */
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一查询错误态:任何数据加载失败都应渲染本组件(而非伪装成空状态),
|
||||||
|
* 并提供重试入口,让用户明确知道「加载失败」而非「没有数据」。
|
||||||
|
*/
|
||||||
|
export const QueryErrorState: React.FC<QueryErrorStateProps> = ({
|
||||||
|
title = '数据加载失败',
|
||||||
|
description = '请检查网络后重试。',
|
||||||
|
onRetry,
|
||||||
|
compact = false,
|
||||||
|
}) => {
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '32px 16px', textAlign: 'center' }}>
|
||||||
|
<Typography.Text type="secondary">{title}</Typography.Text>
|
||||||
|
{description ? (
|
||||||
|
<div>
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{description}
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{onRetry ? (
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
<Button size="small" onClick={onRetry}>
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Result
|
||||||
|
status="warning"
|
||||||
|
title={title}
|
||||||
|
subTitle={description}
|
||||||
|
extra={onRetry ? <Button type="primary" onClick={onRetry}>重试</Button> : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default QueryErrorState;
|
||||||
4
apps/admin/src/components/QueryState/index.ts
Normal file
4
apps/admin/src/components/QueryState/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export { QueryErrorState } from './QueryErrorState';
|
||||||
|
export type { QueryErrorStateProps } from './QueryErrorState';
|
||||||
|
export { QueryEmpty } from './QueryEmpty';
|
||||||
|
export type { QueryEmptyProps, QueryEmptyAction } from './QueryEmpty';
|
||||||
17
apps/admin/src/components/RefreshButton.tsx
Normal file
17
apps/admin/src/components/RefreshButton.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Button, Tooltip } from 'antd';
|
||||||
|
import { ReloadOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
interface RefreshButtonProps {
|
||||||
|
onRefresh: () => void;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列表工具栏刷新入口:手动重新拉取当前数据,带加载反馈。 */
|
||||||
|
export const RefreshButton: React.FC<RefreshButtonProps> = ({ onRefresh, loading }) => (
|
||||||
|
<Tooltip title="刷新">
|
||||||
|
<Button icon={<ReloadOutlined />} loading={loading} onClick={onRefresh} aria-label="刷新" />
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default RefreshButton;
|
||||||
41
apps/admin/src/components/RouteDock/dockTabs.test.ts
Normal file
41
apps/admin/src/components/RouteDock/dockTabs.test.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { DockTab } from '../../store/app/appTypes';
|
||||||
|
import { MAX_DOCK_TABS, upsertDockTab } from './dockTabs';
|
||||||
|
|
||||||
|
const tab = (key: string, label = key): DockTab => ({ key, label });
|
||||||
|
|
||||||
|
describe('upsertDockTab', () => {
|
||||||
|
it('追加新页签', () => {
|
||||||
|
expect(upsertDockTab([], '/students', '学生管理')).toEqual([tab('/students', '学生管理')]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('标题未变时保持原引用,避免无谓渲染', () => {
|
||||||
|
const tabs = [tab('/students', '学生管理')];
|
||||||
|
expect(upsertDockTab(tabs, '/students', '学生管理')).toBe(tabs);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('菜单标题变化时更新页签标题', () => {
|
||||||
|
const tabs = [tab('/students', '学生管理')];
|
||||||
|
expect(upsertDockTab(tabs, '/students', '学生档案')).toEqual([tab('/students', '学生档案')]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('超过上限时保留当前页签 + 最新页签(LRU 淘汰最旧)', () => {
|
||||||
|
const tabs = Array.from({ length: MAX_DOCK_TABS }, (_, i) => tab(`/p${i + 1}`));
|
||||||
|
const next = upsertDockTab(tabs, '/new', '新页');
|
||||||
|
expect(next).toHaveLength(MAX_DOCK_TABS);
|
||||||
|
expect(next[0]).toEqual(tab('/new', '新页'));
|
||||||
|
expect(next[next.length - 1]).toEqual(tab(`/p${MAX_DOCK_TABS}`));
|
||||||
|
expect(next.some((t) => t.key === '/p1')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('恢复持久化的超量页签时压缩到上限,并保留当前页', () => {
|
||||||
|
const tabs = Array.from({ length: 25 }, (_, i) => tab(`/p${i + 1}`));
|
||||||
|
tabs.push(tab('/wallets', '学生余额'));
|
||||||
|
const next = upsertDockTab(tabs, '/wallets', '学生余额');
|
||||||
|
expect(next).toHaveLength(MAX_DOCK_TABS);
|
||||||
|
expect(next[0]).toEqual(tab('/wallets', '学生余额'));
|
||||||
|
expect(next.some((t) => t.key === '/p1')).toBe(false);
|
||||||
|
expect(next.some((t) => t.key === '/p6')).toBe(false);
|
||||||
|
expect(next.some((t) => t.key === '/p7')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
31
apps/admin/src/components/RouteDock/dockTabs.ts
Normal file
31
apps/admin/src/components/RouteDock/dockTabs.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import type { DockTab } from '../../store/app/appTypes';
|
||||||
|
|
||||||
|
/** 页签数量上限:超出后淘汰最旧的非当前页签(LRU 式),避免无限堆积。 */
|
||||||
|
export const MAX_DOCK_TABS = 20;
|
||||||
|
|
||||||
|
function clampToLimit(list: readonly DockTab[], activeKey: string): DockTab[] {
|
||||||
|
// 未超限时保留原引用,避免触发无谓的 tab 列表重渲染
|
||||||
|
if (list.length <= MAX_DOCK_TABS) return list as DockTab[];
|
||||||
|
// 恢复/迁移或新增后超出上限:保留当前页签 + 最新的其余页签(LRU 式淘汰)
|
||||||
|
const active = list.find((tab) => tab.key === activeKey);
|
||||||
|
const rest = list.filter((tab) => tab.key !== activeKey);
|
||||||
|
const keptRest = rest.slice(rest.length - (MAX_DOCK_TABS - 1));
|
||||||
|
return active ? [active, ...keptRest] : keptRest.slice(-MAX_DOCK_TABS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 路由页签合并:按 pathname 建 tab,重复时更新标题,并始终把列表压回上限。
|
||||||
|
*/
|
||||||
|
export function upsertDockTab(
|
||||||
|
tabs: readonly DockTab[],
|
||||||
|
activeKey: string,
|
||||||
|
label: string,
|
||||||
|
): DockTab[] {
|
||||||
|
const existing = tabs.find((tab) => tab.key === activeKey);
|
||||||
|
if (existing) {
|
||||||
|
const updated =
|
||||||
|
existing.label === label ? tabs : tabs.map((tab) => (tab.key === activeKey ? { ...tab, label } : tab));
|
||||||
|
return clampToLimit(updated, activeKey);
|
||||||
|
}
|
||||||
|
return clampToLimit([...tabs, { key: activeKey, label }], activeKey);
|
||||||
|
}
|
||||||
@@ -14,10 +14,12 @@ import {
|
|||||||
useSortable,
|
useSortable,
|
||||||
} from '@dnd-kit/sortable';
|
} from '@dnd-kit/sortable';
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import { Tabs, type TabsProps } from 'antd';
|
import { Button, Dropdown, Tabs, type TabsProps } from 'antd';
|
||||||
|
import { DownOutlined } from '@ant-design/icons';
|
||||||
import type { Location } from 'react-router';
|
import type { Location } from 'react-router';
|
||||||
import type { AppMenuItem } from '../../auth/menu-policy';
|
import type { AppMenuItem } from '../../auth/menu-policy';
|
||||||
import { useAppStore } from '../../store';
|
import { useAppStore } from '../../store';
|
||||||
|
import { upsertDockTab } from './dockTabs';
|
||||||
|
|
||||||
interface RouteDockProps {
|
interface RouteDockProps {
|
||||||
location: Location;
|
location: Location;
|
||||||
@@ -72,20 +74,16 @@ const DraggableTabNode: React.FC<Readonly<DraggableTabNodeProps>> = ({ ...props
|
|||||||
};
|
};
|
||||||
|
|
||||||
const RouteDock: React.FC<RouteDockProps> = ({ location, menuItems, onNavigate, draggable }) => {
|
const RouteDock: React.FC<RouteDockProps> = ({ location, menuItems, onNavigate, draggable }) => {
|
||||||
const activeKey = `${location.pathname}${location.search}`;
|
// 与 RouteKeeper 缓存 key 保持一致:只按 pathname 建 tab,避免 query 变化产生重复页签。
|
||||||
|
const activeKey = location.pathname;
|
||||||
const tabs = useAppStore((state) => state.routeDockTabs);
|
const tabs = useAppStore((state) => state.routeDockTabs);
|
||||||
const setRouteDockTabs = useAppStore((state) => state.setRouteDockTabs);
|
const setRouteDockTabs = useAppStore((state) => state.setRouteDockTabs);
|
||||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }));
|
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (location.pathname === '/') return;
|
if (location.pathname === '/') return;
|
||||||
setRouteDockTabs((currentTabs) => {
|
const label = getRouteLabel(menuItems, location.pathname);
|
||||||
const label = getRouteLabel(menuItems, location.pathname);
|
setRouteDockTabs((currentTabs) => upsertDockTab(currentTabs, activeKey, label));
|
||||||
const existing = currentTabs.find((tab) => tab.key === activeKey);
|
|
||||||
if (!existing) return [...currentTabs, { key: activeKey, label }];
|
|
||||||
if (existing.label === label) return currentTabs;
|
|
||||||
return currentTabs.map((tab) => (tab.key === activeKey ? { ...tab, label } : tab));
|
|
||||||
});
|
|
||||||
}, [activeKey, location.pathname, menuItems, setRouteDockTabs]);
|
}, [activeKey, location.pathname, menuItems, setRouteDockTabs]);
|
||||||
|
|
||||||
const tabItems = useMemo<NonNullable<TabsProps['items']>>(
|
const tabItems = useMemo<NonNullable<TabsProps['items']>>(
|
||||||
@@ -109,6 +107,27 @@ const RouteDock: React.FC<RouteDockProps> = ({ location, menuItems, onNavigate,
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const closeOthers = () => {
|
||||||
|
setRouteDockTabs(tabs.filter((tab) => tab.key === activeKey));
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeLeft = () => {
|
||||||
|
const index = tabs.findIndex((tab) => tab.key === activeKey);
|
||||||
|
if (index <= 0) return;
|
||||||
|
setRouteDockTabs(tabs.filter((_, i) => i >= index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeRight = () => {
|
||||||
|
const index = tabs.findIndex((tab) => tab.key === activeKey);
|
||||||
|
if (index < 0 || index === tabs.length - 1) return;
|
||||||
|
setRouteDockTabs(tabs.filter((_, i) => i <= index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeAll = () => {
|
||||||
|
setRouteDockTabs([]);
|
||||||
|
onNavigate('/dashboard');
|
||||||
|
};
|
||||||
|
|
||||||
const handleDragEnd = ({ active, over }: DragEndEvent) => {
|
const handleDragEnd = ({ active, over }: DragEndEvent) => {
|
||||||
if (!over || active.id === over.id) return;
|
if (!over || active.id === over.id) return;
|
||||||
setRouteDockTabs((currentTabs) => {
|
setRouteDockTabs((currentTabs) => {
|
||||||
@@ -166,6 +185,32 @@ const RouteDock: React.FC<RouteDockProps> = ({ location, menuItems, onNavigate,
|
|||||||
if (action === 'remove') closeTab(String(targetKey));
|
if (action === 'remove') closeTab(String(targetKey));
|
||||||
}}
|
}}
|
||||||
renderTabBar={renderTabBar}
|
renderTabBar={renderTabBar}
|
||||||
|
tabBarExtraContent={
|
||||||
|
tabs.length > 1 ? (
|
||||||
|
<Dropdown
|
||||||
|
trigger={['click']}
|
||||||
|
menu={{
|
||||||
|
items: [
|
||||||
|
{ key: 'close-others', label: '关闭其他' },
|
||||||
|
{ key: 'close-left', label: '关闭左侧' },
|
||||||
|
{ key: 'close-right', label: '关闭右侧' },
|
||||||
|
{ type: 'divider' },
|
||||||
|
{ key: 'close-all', label: '关闭全部' },
|
||||||
|
],
|
||||||
|
onClick: ({ key }) => {
|
||||||
|
if (key === 'close-others') closeOthers();
|
||||||
|
else if (key === 'close-left') closeLeft();
|
||||||
|
else if (key === 'close-right') closeRight();
|
||||||
|
else if (key === 'close-all') closeAll();
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button type="text" size="small" icon={<DownOutlined />} aria-label="更多页签操作">
|
||||||
|
更多
|
||||||
|
</Button>
|
||||||
|
</Dropdown>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import React, { useRef } from 'react';
|
import React, { useRef } from 'react';
|
||||||
import { useLocation, useOutlet } from 'react-router';
|
import { useLocation, useOutlet } from 'react-router';
|
||||||
|
import AppErrorBoundary from './AppErrorBoundary';
|
||||||
|
import { ActivePageContext } from './routeKeeperContext';
|
||||||
|
|
||||||
const MAX_CACHED_PAGES = 30;
|
const MAX_CACHED_PAGES = 30;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 路由保活:切换页面时保留已访问页面的组件实例(输入、滚动、弹窗状态不丢失)。
|
* 路由保活:切换页面时保留已访问页面的组件实例(输入、滚动、弹窗状态不丢失)。
|
||||||
* 隐藏页面仍挂载在 DOM 中,仅通过 display:none 隐藏。
|
* 隐藏页面仍挂载在 DOM 中,仅通过 display:none 隐藏。
|
||||||
|
*
|
||||||
|
* - 每个缓存页外层包裹 AppErrorBoundary:单页渲染异常不影响其他缓存页。
|
||||||
|
* - 通过 ActivePageContext 向页面暴露「当前激活页路径」,供 usePageVisible 使用。
|
||||||
*/
|
*/
|
||||||
export const RouteKeeper: React.FC = () => {
|
export const RouteKeeper: React.FC = () => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -26,17 +31,17 @@ export const RouteKeeper: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<ActivePageContext.Provider value={pageKey}>
|
||||||
{Array.from(cacheRef.current.entries()).map(([key, node]) => (
|
{Array.from(cacheRef.current.entries()).map(([key, node]) => (
|
||||||
<div
|
<div
|
||||||
key={key}
|
key={key}
|
||||||
className="route-keeper-page"
|
className="route-keeper-page"
|
||||||
style={{ display: key === pageKey ? undefined : 'none' }}
|
style={{ display: key === pageKey ? undefined : 'none' }}
|
||||||
>
|
>
|
||||||
{node}
|
<AppErrorBoundary>{node}</AppErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</>
|
</ActivePageContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
16
apps/admin/src/components/ScrollToTop.tsx
Normal file
16
apps/admin/src/components/ScrollToTop.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useLocation } from 'react-router';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SPA 路由切换后把滚动位置复位到顶部。
|
||||||
|
* 只在 pathname 变化时触发,避免干扰弹窗/抽屉等局部滚动。
|
||||||
|
*/
|
||||||
|
export const ScrollToTop: React.FC = () => {
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
useEffect(() => {
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
}, [pathname]);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ScrollToTop;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useRef, useState } from 'react';
|
||||||
import { App, Button, Popconfirm, Space, Table, Upload } from 'antd';
|
import { App, Button, Modal, Popconfirm, Space, Table, Upload } from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { EyeOutlined, InboxOutlined, UploadOutlined } from '@ant-design/icons';
|
import { EyeOutlined, InboxOutlined, UploadOutlined } from '@ant-design/icons';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
@@ -10,6 +10,20 @@ import { getErrorMessage } from '../../utils/error';
|
|||||||
import { ATTACHMENT_CATEGORY_OPTIONS, formatFileSize } from './shared';
|
import { ATTACHMENT_CATEGORY_OPTIONS, formatFileSize } from './shared';
|
||||||
import type { AttachmentRecord, TabProps } from './shared';
|
import type { AttachmentRecord, TabProps } from './shared';
|
||||||
|
|
||||||
|
type AttachmentPreview = {
|
||||||
|
url: string;
|
||||||
|
name: string;
|
||||||
|
kind: 'image' | 'pdf';
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 根据文件扩展名决定安全展示方式:图片/PDF 内联预览,其余一律下载 */
|
||||||
|
function getAttachmentKind(fileName: string, mimeType?: string): 'image' | 'pdf' | 'download' {
|
||||||
|
const ext = fileName.split('.').pop()?.toLowerCase() ?? '';
|
||||||
|
if (['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp', 'svg'].includes(ext)) return 'image';
|
||||||
|
if (ext === 'pdf' || mimeType === 'application/pdf') return 'pdf';
|
||||||
|
return 'download';
|
||||||
|
}
|
||||||
|
|
||||||
export const AttachmentsTab: React.FC<TabProps & { data: AttachmentRecord[] }> = ({
|
export const AttachmentsTab: React.FC<TabProps & { data: AttachmentRecord[] }> = ({
|
||||||
data,
|
data,
|
||||||
studentId,
|
studentId,
|
||||||
@@ -18,6 +32,9 @@ export const AttachmentsTab: React.FC<TabProps & { data: AttachmentRecord[] }> =
|
|||||||
const { hasPermission } = usePermission();
|
const { hasPermission } = usePermission();
|
||||||
const canPurgeArchive = hasPermission('archive:purge');
|
const canPurgeArchive = hasPermission('archive:purge');
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [preview, setPreview] = useState<AttachmentPreview | null>(null);
|
||||||
|
// 预览请求序号:快速点不同行「查看」时,慢的旧响应回来直接丢弃,避免覆盖新预览
|
||||||
|
const previewSeqRef = useRef(0);
|
||||||
|
|
||||||
const deleteAttachmentMutation = useApiMutation(
|
const deleteAttachmentMutation = useApiMutation(
|
||||||
async (attachmentId: number) => api.delete(`/archive/attachments/${attachmentId}`),
|
async (attachmentId: number) => api.delete(`/archive/attachments/${attachmentId}`),
|
||||||
@@ -29,12 +46,43 @@ export const AttachmentsTab: React.FC<TabProps & { data: AttachmentRecord[] }> =
|
|||||||
);
|
);
|
||||||
const uploadAttachmentMutation = useApiMutation(
|
const uploadAttachmentMutation = useApiMutation(
|
||||||
async (formData: FormData) =>
|
async (formData: FormData) =>
|
||||||
api.post(`/archive/${studentId}/attachments`, formData, {
|
api.post(`/archive/${studentId}/attachments`, formData),
|
||||||
headers: { 'Content-Type': 'multipart/form-data' },
|
|
||||||
}),
|
|
||||||
{ invalidate: [['archive', studentId]] },
|
{ invalidate: [['archive', studentId]] },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const closePreview = () => {
|
||||||
|
previewSeqRef.current += 1; // 关闭后仍在途的旧响应也不再落地
|
||||||
|
if (preview?.url) URL.revokeObjectURL(preview.url);
|
||||||
|
setPreview(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openAttachment = async (record: AttachmentRecord) => {
|
||||||
|
const seq = ++previewSeqRef.current;
|
||||||
|
try {
|
||||||
|
const blob = await api.get<Blob>(`/archive/${studentId}/attachments/${record.id}`, {
|
||||||
|
responseType: 'blob',
|
||||||
|
});
|
||||||
|
if (seq !== previewSeqRef.current) return; // 已有更新的查看请求,丢弃本次慢响应
|
||||||
|
const kind = getAttachmentKind(record.fileName, record.mimeType);
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
if (kind === 'download') {
|
||||||
|
// 非内联类型通过 download 属性触发下载,避免以页面同源打开可执行内容
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = record.fileName || 'attachment';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||||
|
} else {
|
||||||
|
if (preview?.url) URL.revokeObjectURL(preview.url);
|
||||||
|
setPreview({ url, name: record.fileName || 'attachment', kind });
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
message.error(getErrorMessage(e, '查看失败'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleDelete = async (attachmentId: number) => {
|
const handleDelete = async (attachmentId: number) => {
|
||||||
try {
|
try {
|
||||||
await deleteAttachmentMutation.mutateAsync(attachmentId);
|
await deleteAttachmentMutation.mutateAsync(attachmentId);
|
||||||
@@ -74,22 +122,7 @@ export const AttachmentsTab: React.FC<TabProps & { data: AttachmentRecord[] }> =
|
|||||||
title: '操作',
|
title: '操作',
|
||||||
render: (_: unknown, record: AttachmentRecord) => (
|
render: (_: unknown, record: AttachmentRecord) => (
|
||||||
<Space>
|
<Space>
|
||||||
<Button
|
<Button size="small" icon={<EyeOutlined />} onClick={() => openAttachment(record)}>
|
||||||
size="small"
|
|
||||||
icon={<EyeOutlined />}
|
|
||||||
onClick={async () => {
|
|
||||||
try {
|
|
||||||
const blob = await api.get<Blob>(`/archive/${studentId}/attachments/${record.id}`, {
|
|
||||||
responseType: 'blob',
|
|
||||||
});
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
window.open(url, '_blank');
|
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
|
||||||
} catch (e: unknown) {
|
|
||||||
message.error(getErrorMessage(e, '查看失败'));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
查看
|
查看
|
||||||
</Button>
|
</Button>
|
||||||
{hasPermission('student:edit') && record.status !== 'archived' ? (
|
{hasPermission('student:edit') && record.status !== 'archived' ? (
|
||||||
@@ -139,7 +172,7 @@ export const AttachmentsTab: React.FC<TabProps & { data: AttachmentRecord[] }> =
|
|||||||
</Button>
|
</Button>
|
||||||
</Upload>
|
</Upload>
|
||||||
) : null}
|
) : null}
|
||||||
<Table<AttachmentRecord>
|
<Table<AttachmentRecord> scroll={{ x: 'max-content' }}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data}
|
dataSource={data}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
@@ -151,6 +184,25 @@ export const AttachmentsTab: React.FC<TabProps & { data: AttachmentRecord[] }> =
|
|||||||
}}
|
}}
|
||||||
style={{ marginTop: 16 }}
|
style={{ marginTop: 16 }}
|
||||||
/>
|
/>
|
||||||
|
<Modal
|
||||||
|
title={preview?.name}
|
||||||
|
open={!!preview}
|
||||||
|
footer={null}
|
||||||
|
onCancel={closePreview}
|
||||||
|
width={preview?.kind === 'pdf' ? 900 : undefined}
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
{preview?.kind === 'image' ? (
|
||||||
|
<img src={preview.url} alt={preview.name} style={{ width: '100%' }} />
|
||||||
|
) : preview?.kind === 'pdf' ? (
|
||||||
|
<iframe
|
||||||
|
src={preview.url}
|
||||||
|
title={preview.name}
|
||||||
|
sandbox=""
|
||||||
|
style={{ width: '100%', height: '70vh', border: 'none' }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ export const EnrollmentsTab: React.FC<TabProps & { data: EnrollmentRecord[] }> =
|
|||||||
>
|
>
|
||||||
添加报读记录
|
添加报读记录
|
||||||
</PermissionButton>
|
</PermissionButton>
|
||||||
<Table<EnrollmentRecord>
|
<Table<EnrollmentRecord> scroll={{ x: 'max-content' }}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data}
|
dataSource={data}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ export const ExamScoresTab: React.FC<
|
|||||||
>
|
>
|
||||||
添加考试成绩
|
添加考试成绩
|
||||||
</PermissionButton>
|
</PermissionButton>
|
||||||
<Table<ExamScoreRecord>
|
<Table<ExamScoreRecord> scroll={{ x: 'max-content' }}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data}
|
dataSource={data}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ export const LearningTab: React.FC<TabProps & { data: LearningRecord[] }> = ({
|
|||||||
>
|
>
|
||||||
添加学情记录
|
添加学情记录
|
||||||
</PermissionButton>
|
</PermissionButton>
|
||||||
<Table<LearningRecord>
|
<Table<LearningRecord> scroll={{ x: 'max-content' }}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data}
|
dataSource={data}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
|
|||||||
@@ -28,10 +28,11 @@ import { message } from '../../ui/app-message';
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useApiMutation } from '../../hooks/useApiMutation';
|
import { useApiMutation } from '../../hooks/useApiMutation';
|
||||||
import { validateResponse } from '../../utils/validate';
|
import { validateResponse } from '../../utils/validate';
|
||||||
|
import { queryKeys } from '../../api/queryKeys';
|
||||||
import { organizationOptionsSchema, studentProfileAggregateSchema } from '../../api/schemas';
|
import { organizationOptionsSchema, studentProfileAggregateSchema } from '../../api/schemas';
|
||||||
import EditableCell from '../EditableCell';
|
import EditableCell from '../EditableCell';
|
||||||
import { usePermission } from '../../hooks/usePermission';
|
import { usePermission } from '../../hooks/usePermission';
|
||||||
import { getErrorMessage } from '../../utils/error';
|
import { QueryErrorState } from '../QueryState';
|
||||||
|
|
||||||
import { ADMISSION_STATUS_MAP, ATTENDANCE_STATUS_MAP, SESSION_LABELS, getOptionLabel } from './shared';
|
import { ADMISSION_STATUS_MAP, ATTENDANCE_STATUS_MAP, SESSION_LABELS, getOptionLabel } from './shared';
|
||||||
import type { AttendanceRecordItem, ProfileData, ResultData, StudentInfo, StudentProfileAggregate, StudentProfileContentProps } from './shared';
|
import type { AttendanceRecordItem, ProfileData, ResultData, StudentInfo, StudentProfileAggregate, StudentProfileContentProps } from './shared';
|
||||||
@@ -187,7 +188,7 @@ const InlineArchiveSummary: React.FC<{
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Descriptions bordered column={3} size="small" style={{ marginBottom: 24 }}>
|
<Descriptions bordered column={{ xs: 1, sm: 2, lg: 3 }} size="small" style={{ marginBottom: 24 }}>
|
||||||
<Descriptions.Item label="手机号">
|
<Descriptions.Item label="手机号">
|
||||||
<EditableCell
|
<EditableCell
|
||||||
value={student.phone}
|
value={student.phone}
|
||||||
@@ -478,25 +479,21 @@ const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
|
|||||||
data: aggregateData,
|
data: aggregateData,
|
||||||
isLoading,
|
isLoading,
|
||||||
isFetching,
|
isFetching,
|
||||||
|
isError,
|
||||||
refetch,
|
refetch,
|
||||||
} = useQuery<StudentProfileAggregate | null>({
|
} = useQuery<StudentProfileAggregate | null>({
|
||||||
queryKey: ['archive', studentId],
|
queryKey: queryKeys.archive.detail(studentId),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
try {
|
return validateResponse<StudentProfileAggregate>(
|
||||||
return validateResponse<StudentProfileAggregate>(
|
studentProfileAggregateSchema,
|
||||||
studentProfileAggregateSchema,
|
await api.get<StudentProfileAggregate>(`/archive/${studentId}`),
|
||||||
await api.get<StudentProfileAggregate>(`/archive/${studentId}`),
|
);
|
||||||
);
|
|
||||||
} catch (e: unknown) {
|
|
||||||
message.error(getErrorMessage(e, '加载失败'));
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const { data: organizations = [] } = useQuery<
|
const { data: organizations = [] } = useQuery<
|
||||||
Array<{ id: number; name: string; isHost?: boolean }>
|
Array<{ id: number; name: string; isHost?: boolean }>
|
||||||
>({
|
>({
|
||||||
queryKey: ['organizations', 'options'],
|
queryKey: queryKeys.organizations.options(),
|
||||||
enabled: canLoadOrganizations,
|
enabled: canLoadOrganizations,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
try {
|
try {
|
||||||
@@ -582,6 +579,15 @@ const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<QueryErrorState
|
||||||
|
title="档案数据加载失败"
|
||||||
|
description="请检查网络后重试。"
|
||||||
|
onRetry={() => void refetch()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ export interface AttachmentRecord {
|
|||||||
category: string;
|
category: string;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
fileSize: number;
|
fileSize: number;
|
||||||
|
mimeType?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AttendanceRecordItem {
|
export interface AttendanceRecordItem {
|
||||||
|
|||||||
10
apps/admin/src/components/routeKeeperContext.ts
Normal file
10
apps/admin/src/components/routeKeeperContext.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { createContext, useContext } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前激活的页面路径(由 RouteKeeper 提供)。
|
||||||
|
* RouteKeeper 用 display:none 保活已访问页面,页面组件本身不会重新挂载,
|
||||||
|
* 因此需要该上下文让每个缓存页感知「自己是否处于激活状态」。
|
||||||
|
*/
|
||||||
|
export const ActivePageContext = createContext<string>('');
|
||||||
|
|
||||||
|
export const useActivePage = (): string => useContext(ActivePageContext);
|
||||||
125
apps/admin/src/components/ux.integration.test.tsx
Normal file
125
apps/admin/src/components/ux.integration.test.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import React, { act } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { MemoryRouter, Route, Routes, useNavigate } from 'react-router';
|
||||||
|
import { RefreshButton } from './RefreshButton';
|
||||||
|
import { BackTop } from './BackTop';
|
||||||
|
import { ScrollToTop } from './ScrollToTop';
|
||||||
|
import { useSubmitShortcut } from '../hooks/useSubmitShortcut';
|
||||||
|
|
||||||
|
let container: HTMLDivElement | null = null;
|
||||||
|
let root: ReturnType<typeof createRoot> | null = null;
|
||||||
|
|
||||||
|
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
const mount = (node: React.ReactNode) => {
|
||||||
|
const host = document.createElement('div');
|
||||||
|
container = host;
|
||||||
|
document.body.appendChild(host);
|
||||||
|
root = createRoot(host);
|
||||||
|
act(() => root?.render(node));
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (root) {
|
||||||
|
await act(async () => root?.unmount());
|
||||||
|
}
|
||||||
|
container?.remove();
|
||||||
|
root = null;
|
||||||
|
container = null;
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('UX 组件与交互', () => {
|
||||||
|
it('RefreshButton 点击触发 onRefresh,loading 时展示加载态', async () => {
|
||||||
|
const onRefresh = vi.fn();
|
||||||
|
mount(<RefreshButton onRefresh={onRefresh} />);
|
||||||
|
const button = container?.querySelector('button');
|
||||||
|
if (!button) throw new Error('button not rendered');
|
||||||
|
act(() => button.dispatchEvent(new MouseEvent('click', { bubbles: true })));
|
||||||
|
await flush();
|
||||||
|
expect(onRefresh).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
mount(<RefreshButton onRefresh={onRefresh} loading />);
|
||||||
|
expect(container?.querySelector('.ant-btn-loading')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('useSubmitShortcut 未激活时不响应 Cmd+Enter', async () => {
|
||||||
|
const onSubmit = vi.fn();
|
||||||
|
const Harness = () => {
|
||||||
|
useSubmitShortcut(false, onSubmit);
|
||||||
|
return <button type="button">ok</button>;
|
||||||
|
};
|
||||||
|
mount(<Harness />);
|
||||||
|
window.dispatchEvent(
|
||||||
|
new KeyboardEvent('keydown', { key: 'Enter', metaKey: true, bubbles: true }),
|
||||||
|
);
|
||||||
|
await flush();
|
||||||
|
expect(onSubmit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('useSubmitShortcut 激活时响应 Cmd/Ctrl+Enter 且阻止默认行为', async () => {
|
||||||
|
const onSubmit = vi.fn();
|
||||||
|
const Harness = () => {
|
||||||
|
useSubmitShortcut(true, onSubmit);
|
||||||
|
return <button type="button">ok</button>;
|
||||||
|
};
|
||||||
|
mount(<Harness />);
|
||||||
|
|
||||||
|
const metaEvent = new KeyboardEvent('keydown', {
|
||||||
|
key: 'Enter',
|
||||||
|
metaKey: true,
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
});
|
||||||
|
window.dispatchEvent(metaEvent);
|
||||||
|
expect(metaEvent.defaultPrevented).toBe(true);
|
||||||
|
|
||||||
|
window.dispatchEvent(
|
||||||
|
new KeyboardEvent('keydown', { key: 'Enter', ctrlKey: true, bubbles: true }),
|
||||||
|
);
|
||||||
|
await flush();
|
||||||
|
expect(onSubmit).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('BackTop 超过阈值后出现,点击回到顶部', async () => {
|
||||||
|
const scrollSpy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
|
||||||
|
mount(<BackTop threshold={-1} />);
|
||||||
|
await flush();
|
||||||
|
const button = container?.querySelector('button');
|
||||||
|
expect(button).toBeTruthy();
|
||||||
|
if (button) {
|
||||||
|
act(() => button.dispatchEvent(new MouseEvent('click', { bubbles: true })));
|
||||||
|
}
|
||||||
|
await flush();
|
||||||
|
expect(scrollSpy).toHaveBeenCalledWith(expect.objectContaining({ top: 0 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ScrollToTop 在路由切换时把滚动位置复位到顶部', async () => {
|
||||||
|
const scrollSpy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
|
||||||
|
const Nav = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
return (
|
||||||
|
<button type="button" onClick={() => navigate('/other')}>
|
||||||
|
go
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
mount(
|
||||||
|
<MemoryRouter initialEntries={['/']}>
|
||||||
|
<ScrollToTop />
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Nav />} />
|
||||||
|
<Route path="/other" element={<div>other</div>} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
scrollSpy.mockClear();
|
||||||
|
|
||||||
|
const button = container?.querySelector('button');
|
||||||
|
if (!button) throw new Error('button not rendered');
|
||||||
|
act(() => button.dispatchEvent(new MouseEvent('click', { bubbles: true })));
|
||||||
|
await flush();
|
||||||
|
expect(scrollSpy).toHaveBeenCalledWith(0, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,38 +2,62 @@ import { useMutation, useQueryClient, type QueryKey } from '@tanstack/react-quer
|
|||||||
import { message } from '../ui/app-message';
|
import { message } from '../ui/app-message';
|
||||||
import { getErrorMessage } from '../utils/error';
|
import { getErrorMessage } from '../utils/error';
|
||||||
|
|
||||||
interface UseApiMutationOptions<TData, TVars> {
|
interface UseApiMutationOptions<TData, TVars, TContext> {
|
||||||
/** 成功后自动失效的查询 key(触发列表/详情刷新) */
|
/** 成功后自动失效的查询 key(触发列表/详情刷新) */
|
||||||
invalidate?: QueryKey[];
|
invalidate?: QueryKey[];
|
||||||
|
/** 乐观更新:mutate 前同步改缓存,返回回滚上下文(失败时传给 onError) */
|
||||||
|
onMutate?: (vars: TVars) => Promise<TContext | undefined> | TContext | undefined;
|
||||||
/** 成功后回调(例如关闭弹窗) */
|
/** 成功后回调(例如关闭弹窗) */
|
||||||
onSuccess?: (data: TData, vars: TVars) => void;
|
onSuccess?: (data: TData, vars: TVars, context?: TContext) => void;
|
||||||
/** 失败回调;默认统一用 getErrorMessage 弹错误提示 */
|
/** 失败回调;提供时由调用方负责(含乐观更新回滚),否则默认用 getErrorMessage 弹错误提示 */
|
||||||
onError?: (error: unknown) => void;
|
onError?: (error: unknown, vars: TVars, context?: TContext) => void;
|
||||||
|
/** 结束后回调(无论成败) */
|
||||||
|
onSettled?: (data: TData | undefined, error: unknown, vars: TVars, context?: TContext) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* useMutation 的轻量封装:统一错误提示 + 成功后 invalidateQueries,
|
* useMutation 的轻量封装:统一错误提示 + 成功后 invalidateQueries,
|
||||||
* 消除手写 `await api.xxx(); await fetchData();` 样板。
|
* 消除手写 `await api.xxx(); await fetchData();` 样板。
|
||||||
|
*
|
||||||
|
* 乐观更新示例:
|
||||||
|
* ```ts
|
||||||
|
* const mutation = useApiMutation(fn, {
|
||||||
|
* onMutate: async (vars) => {
|
||||||
|
* await queryClient.cancelQueries({ queryKey });
|
||||||
|
* const previous = queryClient.getQueryData(queryKey);
|
||||||
|
* queryClient.setQueryData(queryKey, updater);
|
||||||
|
* return previous; // 回滚上下文
|
||||||
|
* },
|
||||||
|
* onError: (_e, _v, previous) => queryClient.setQueryData(queryKey, previous),
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
*/
|
*/
|
||||||
export function useApiMutation<TData = unknown, TVars = void>(
|
export function useApiMutation<TData = unknown, TVars = void, TContext = unknown>(
|
||||||
mutationFn: (vars: TVars) => Promise<TData>,
|
mutationFn: (vars: TVars) => Promise<TData>,
|
||||||
options: UseApiMutationOptions<TData, TVars> = {},
|
options: UseApiMutationOptions<TData, TVars, TContext> = {},
|
||||||
) {
|
) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation<TData, Error, TVars>({
|
return useMutation<TData, Error, TVars, TContext>({
|
||||||
mutationFn,
|
mutationFn,
|
||||||
onSuccess: (data, vars) => {
|
// 包装 onMutate:允许调用方返回 undefined(无回滚上下文),
|
||||||
|
// React Query 的 onMutate 类型要求返回 TContext
|
||||||
|
onMutate: async (vars) => {
|
||||||
|
const context = await options.onMutate?.(vars);
|
||||||
|
return context as TContext;
|
||||||
|
},
|
||||||
|
onSuccess: (data, vars, context) => {
|
||||||
for (const key of options.invalidate ?? []) {
|
for (const key of options.invalidate ?? []) {
|
||||||
void queryClient.invalidateQueries({ queryKey: key });
|
void queryClient.invalidateQueries({ queryKey: key });
|
||||||
}
|
}
|
||||||
options.onSuccess?.(data, vars);
|
options.onSuccess?.(data, vars, context);
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error, vars, context) => {
|
||||||
if (options.onError) {
|
if (options.onError) {
|
||||||
options.onError(error);
|
options.onError(error, vars, context);
|
||||||
} else {
|
} else {
|
||||||
message.error(getErrorMessage(error));
|
message.error(getErrorMessage(error));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onSettled: options.onSettled,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
31
apps/admin/src/hooks/useApiQuery.ts
Normal file
31
apps/admin/src/hooks/useApiQuery.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import type { QueryKey } from '@tanstack/react-query';
|
||||||
|
import type { z } from 'zod';
|
||||||
|
import { validateResponse } from '../utils/validate';
|
||||||
|
|
||||||
|
interface UseApiQueryOptions<T, TSelected = T> {
|
||||||
|
queryKey: QueryKey;
|
||||||
|
queryFn: () => Promise<unknown>;
|
||||||
|
/** zod schema:响应校验失败会抛出带字段路径的错误,由统一错误处理展示 */
|
||||||
|
schema: z.ZodType<unknown>;
|
||||||
|
enabled?: boolean;
|
||||||
|
staleTime?: number;
|
||||||
|
/** 可选的数据转换(React Query select),例如列表原始行 → UI 模型 */
|
||||||
|
select?: (data: T) => TSelected;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useQuery 的类型安全封装:queryFn 返回 unknown,
|
||||||
|
* 由 zod schema 校验并收敛为 T,消除各页面重复的
|
||||||
|
* `validateResponse(schema, await api.get(...))` 样板。
|
||||||
|
*/
|
||||||
|
export function useApiQuery<T, TSelected = T>(options: UseApiQueryOptions<T, TSelected>) {
|
||||||
|
const { queryKey, queryFn, schema, enabled, staleTime, select } = options;
|
||||||
|
return useQuery<T, Error, TSelected>({
|
||||||
|
queryKey,
|
||||||
|
enabled,
|
||||||
|
staleTime,
|
||||||
|
queryFn: async () => validateResponse<T>(schema, await queryFn()),
|
||||||
|
select,
|
||||||
|
});
|
||||||
|
}
|
||||||
53
apps/admin/src/hooks/useDirtyGuard.ts
Normal file
53
apps/admin/src/hooks/useDirtyGuard.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { useCallback, useMemo, useRef } from 'react';
|
||||||
|
import { App } from 'antd';
|
||||||
|
import type { FormInstance } from 'antd';
|
||||||
|
import equal from 'fast-deep-equal';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 弹窗「未保存内容」保护:关闭弹窗时若表单值已被修改,先确认再关闭,
|
||||||
|
* 避免用户误关丢失已填内容。
|
||||||
|
*
|
||||||
|
* 用法:
|
||||||
|
* const { confirmClose, snapshot } = useDirtyGuard(form);
|
||||||
|
* // 打开弹窗(或编辑回填后)时调用一次 snapshot() 记录初始值
|
||||||
|
* const openEdit = () => { form.setFieldsValue(record); snapshot(); setOpen(true); };
|
||||||
|
* // Modal 的 onCancel 改用确认关闭
|
||||||
|
* <Modal onCancel={() => confirmClose(() => setOpen(false))} ...>
|
||||||
|
*/
|
||||||
|
export function useDirtyGuard(form: FormInstance) {
|
||||||
|
const { modal } = App.useApp();
|
||||||
|
const pristineRef = useRef<unknown>(null);
|
||||||
|
|
||||||
|
/** 记录当前表单值为「未修改」基准;打开弹窗/回填后调用 */
|
||||||
|
const snapshot = useCallback(() => {
|
||||||
|
pristineRef.current = form.getFieldsValue();
|
||||||
|
}, [form]);
|
||||||
|
|
||||||
|
/** 表单是否有未保存修改(与 snapshot 时对比) */
|
||||||
|
const isDirty = useCallback(() => {
|
||||||
|
return !equal(form.getFieldsValue(), pristineRef.current);
|
||||||
|
}, [form]);
|
||||||
|
|
||||||
|
const confirmClose = useCallback(
|
||||||
|
(close: () => void) => {
|
||||||
|
if (!isDirty()) {
|
||||||
|
close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
modal.confirm({
|
||||||
|
title: '放弃未保存的修改?',
|
||||||
|
content: '当前表单有未保存的内容,关闭后修改将丢失。',
|
||||||
|
okText: '放弃修改',
|
||||||
|
okButtonProps: { danger: true },
|
||||||
|
cancelText: '继续编辑',
|
||||||
|
onOk: close,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[isDirty, modal],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 用 useMemo 稳定返回对象引用,避免消费方 useEffect 依赖每次渲染都变化
|
||||||
|
return useMemo(() => ({ confirmClose, snapshot, isDirty }), [confirmClose, snapshot, isDirty]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useDirtyGuard;
|
||||||
46
apps/admin/src/hooks/useDownload.ts
Normal file
46
apps/admin/src/hooks/useDownload.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { useCallback, useRef, useState } from 'react';
|
||||||
|
import { downloadBlob } from '../utils/download';
|
||||||
|
import { message } from '../ui/app-message';
|
||||||
|
|
||||||
|
export interface DownloadOptions {
|
||||||
|
/** 成功提示文案;默认「下载成功」 */
|
||||||
|
successMsg?: string;
|
||||||
|
/** 失败提示文案;默认使用接口返回的错误信息 */
|
||||||
|
errorMsg?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一下载/导出状态:防重复点击 + 成功/失败反馈。
|
||||||
|
*
|
||||||
|
* 用法:
|
||||||
|
* const { downloading, run } = useDownload();
|
||||||
|
* <Button loading={downloading} onClick={() => run('/students/export', '名单.xlsx')}>
|
||||||
|
*/
|
||||||
|
export function useDownload() {
|
||||||
|
const [downloading, setDownloading] = useState(false);
|
||||||
|
const busyRef = useRef(false);
|
||||||
|
|
||||||
|
const run = useCallback(
|
||||||
|
async (endpoint: string, filename: string, options?: DownloadOptions) => {
|
||||||
|
if (busyRef.current) return;
|
||||||
|
busyRef.current = true;
|
||||||
|
setDownloading(true);
|
||||||
|
try {
|
||||||
|
await downloadBlob(endpoint, filename);
|
||||||
|
message.success(options?.successMsg ?? '下载成功');
|
||||||
|
} catch (error: unknown) {
|
||||||
|
message.error(
|
||||||
|
options?.errorMsg ?? (error instanceof Error ? error.message : '下载失败,请重试'),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
busyRef.current = false;
|
||||||
|
setDownloading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { downloading, run };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useDownload;
|
||||||
37
apps/admin/src/hooks/usePageVisible.ts
Normal file
37
apps/admin/src/hooks/usePageVisible.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useLocation } from 'react-router';
|
||||||
|
import { useQueryClient, type QueryKey } from '@tanstack/react-query';
|
||||||
|
import { useActivePage } from '../components/routeKeeperContext';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前页面是否处于激活(可见)状态。
|
||||||
|
* RouteKeeper 保活页面始终挂载,只有激活页可见;配合
|
||||||
|
* `useVisibleRefetch` 可在切回页面时刷新数据。
|
||||||
|
*/
|
||||||
|
export function usePageVisible(): boolean {
|
||||||
|
const activePage = useActivePage();
|
||||||
|
const location = useLocation();
|
||||||
|
return activePage === location.pathname;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 页面重新变为可见时刷新指定 queryKey 的数据。
|
||||||
|
* 解决 RouteKeeper 保活导致的「切回列表页看不到新增/删除数据」问题。
|
||||||
|
*
|
||||||
|
* 用法:`useVisibleRefetch(['students']);`
|
||||||
|
*
|
||||||
|
* 注意:queryKey 通过 ref 持有,effect 只依赖 visible,
|
||||||
|
* 避免调用方每次渲染传入新数组字面量导致频繁重复请求。
|
||||||
|
*/
|
||||||
|
export function useVisibleRefetch(queryKey: QueryKey | undefined): void {
|
||||||
|
const visible = usePageVisible();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const keyRef = useRef(queryKey);
|
||||||
|
keyRef.current = queryKey;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible && keyRef.current) {
|
||||||
|
void queryClient.refetchQueries({ queryKey: keyRef.current });
|
||||||
|
}
|
||||||
|
}, [visible, queryClient]);
|
||||||
|
}
|
||||||
19
apps/admin/src/hooks/useSubmitShortcut.ts
Normal file
19
apps/admin/src/hooks/useSubmitShortcut.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 弹窗/表单内按 Cmd/Ctrl+Enter 触发表单提交。
|
||||||
|
* 仅在 active(弹窗打开且非保存中)时监听,避免误触。
|
||||||
|
*/
|
||||||
|
export function useSubmitShortcut(active: boolean, onSubmit: () => void): void {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return;
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
onSubmit();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [active, onSubmit]);
|
||||||
|
}
|
||||||
@@ -62,6 +62,6 @@ export function useViewSensitive(studentId: number, module: string, canLog: bool
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[studentId, module],
|
[studentId, module, modal],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -514,3 +514,68 @@ canvas {
|
|||||||
padding-inline: 0;
|
padding-inline: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── 全局无障碍与质感增强 ─── */
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: rgba(0, 122, 255, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 键盘导航焦点可见(鼠标点击不显示,符合 WCAG 2.4.7) */
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid #007aff;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 细滚动条(macOS/Chromium),降低大面积滚动条对视觉的干扰 */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(0, 0, 0, 0.16);
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
background-clip: content-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 尊重系统「减少动态效果」偏好 */
|
||||||
|
/* 窄屏下压缩路由标签尺寸,避免横向裁切 */
|
||||||
|
@media (max-width: 575px) {
|
||||||
|
.route-dock {
|
||||||
|
padding: 6px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-dock .ant-tabs-tab {
|
||||||
|
min-width: 88px;
|
||||||
|
max-width: 160px;
|
||||||
|
padding: 0 8px 0 10px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
html {
|
||||||
|
scroll-behavior: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -36,9 +36,11 @@ import api from '../api';
|
|||||||
import { useAppStore } from '../store/app/appStore';
|
import { useAppStore } from '../store/app/appStore';
|
||||||
import { usePermissionStore } from '../store/permission/permissionStore';
|
import { usePermissionStore } from '../store/permission/permissionStore';
|
||||||
import { useUserStore } from '../store/user/userStore';
|
import { useUserStore } from '../store/user/userStore';
|
||||||
|
import { AUTH_STORAGE_NAME, PERMISSION_STORAGE_NAME } from '../store/middleware/persist';
|
||||||
import NotificationBell from '../components/NotificationBell';
|
import NotificationBell from '../components/NotificationBell';
|
||||||
import RouteDock from '../components/RouteDock';
|
import RouteDock from '../components/RouteDock';
|
||||||
import RouteKeeper from '../components/RouteKeeper';
|
import RouteKeeper from '../components/RouteKeeper';
|
||||||
|
import BackTop from '../components/BackTop';
|
||||||
import { buildMenu, type AppMenuItem } from '../auth/menu-policy';
|
import { buildMenu, type AppMenuItem } from '../auth/menu-policy';
|
||||||
|
|
||||||
const AiChatDrawer = React.lazy(() => import('../components/AiChat/AiChatDrawer'));
|
const AiChatDrawer = React.lazy(() => import('../components/AiChat/AiChatDrawer'));
|
||||||
@@ -125,9 +127,42 @@ const MainLayout: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleStorage = (event: StorageEvent) => {
|
const handleStorage = (event: StorageEvent) => {
|
||||||
if (event.key !== 'token' && event.key !== 'permissions') return;
|
if (event.key === PERMISSION_STORAGE_NAME) {
|
||||||
usePermissionStore.getState().beginPermissionVerification();
|
// 其他标签页的权限更新:原地应用,避免整页刷新造成刷新风暴。
|
||||||
window.location.reload();
|
try {
|
||||||
|
if (event.newValue === null) {
|
||||||
|
usePermissionStore.getState().clearPermissions();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = JSON.parse(event.newValue) as {
|
||||||
|
state?: { permissions?: string[] };
|
||||||
|
};
|
||||||
|
const permissions = parsed?.state?.permissions;
|
||||||
|
if (Array.isArray(permissions)) {
|
||||||
|
usePermissionStore.getState().writePermissions(permissions);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 忽略无法解析的跨标签页权限写入
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === AUTH_STORAGE_NAME) {
|
||||||
|
// 仅当登录态(token)确实变化时才整页刷新:登录、退出或切换账号。
|
||||||
|
const currentToken = useUserStore.getState().token;
|
||||||
|
let otherToken: string | null = null;
|
||||||
|
if (event.newValue) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(event.newValue) as {
|
||||||
|
state?: { token?: string | null };
|
||||||
|
};
|
||||||
|
otherToken = parsed?.state?.token ?? null;
|
||||||
|
} catch {
|
||||||
|
otherToken = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (otherToken === currentToken) return;
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
const handleOnline = () => verifyPermissions();
|
const handleOnline = () => verifyPermissions();
|
||||||
const handleVisibilityChange = () => {
|
const handleVisibilityChange = () => {
|
||||||
@@ -161,6 +196,7 @@ const MainLayout: React.FC = () => {
|
|||||||
const handleLogout = useCallback(() => {
|
const handleLogout = useCallback(() => {
|
||||||
logoutUser();
|
logoutUser();
|
||||||
usePermissionStore.getState().clearPermissions();
|
usePermissionStore.getState().clearPermissions();
|
||||||
|
useAppStore.getState().setRouteDockTabs([]);
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
}, [logoutUser, navigate]);
|
}, [logoutUser, navigate]);
|
||||||
|
|
||||||
@@ -169,19 +205,22 @@ const MainLayout: React.FC = () => {
|
|||||||
navigate(key);
|
navigate(key);
|
||||||
if (usesDrawer) setDrawerOpen(false);
|
if (usesDrawer) setDrawerOpen(false);
|
||||||
},
|
},
|
||||||
[navigate, usesDrawer],
|
[navigate, usesDrawer, setDrawerOpen],
|
||||||
);
|
);
|
||||||
|
|
||||||
const findSelectedKeys = (items: AppMenuItem[], pathname: string): string[] => {
|
const findSelectedKeys = useCallback(
|
||||||
for (const item of items) {
|
(items: AppMenuItem[], pathname: string): string[] => {
|
||||||
if (item.key === pathname) return [item.key];
|
for (const item of items) {
|
||||||
if (item.children) {
|
if (item.key === pathname) return [item.key];
|
||||||
const found = findSelectedKeys(item.children, pathname);
|
if (item.children) {
|
||||||
if (found.length > 0) return found;
|
const found = findSelectedKeys(item.children, pathname);
|
||||||
|
if (found.length > 0) return found;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
return [pathname];
|
||||||
return [pathname];
|
},
|
||||||
};
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const findOpenKeys = (items: AppMenuItem[], pathname: string): string[] => {
|
const findOpenKeys = (items: AppMenuItem[], pathname: string): string[] => {
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
@@ -201,7 +240,7 @@ const MainLayout: React.FC = () => {
|
|||||||
|
|
||||||
const selectedKeys = useMemo(
|
const selectedKeys = useMemo(
|
||||||
() => findSelectedKeys(menuItems, location.pathname),
|
() => findSelectedKeys(menuItems, location.pathname),
|
||||||
[menuItems, location.pathname],
|
[menuItems, location.pathname, findSelectedKeys],
|
||||||
);
|
);
|
||||||
// 路径变化时同步展开的菜单(不干扰用户手动展开/收起)
|
// 路径变化时同步展开的菜单(不干扰用户手动展开/收起)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -210,20 +249,20 @@ const MainLayout: React.FC = () => {
|
|||||||
const routeOpenKeys = findOpenKeys(menuItems, location.pathname);
|
const routeOpenKeys = findOpenKeys(menuItems, location.pathname);
|
||||||
setOpenKeys((currentKeys) => [...new Set([...currentKeys, ...routeOpenKeys])]);
|
setOpenKeys((currentKeys) => [...new Set([...currentKeys, ...routeOpenKeys])]);
|
||||||
}
|
}
|
||||||
}, [location.pathname, menuItems]);
|
}, [location.pathname, menuItems, setOpenKeys]);
|
||||||
|
|
||||||
const handleOpenChange = useCallback((keys: string[]) => {
|
const handleOpenChange = useCallback((keys: string[]) => {
|
||||||
setOpenKeys(keys);
|
setOpenKeys(keys);
|
||||||
}, []);
|
}, [setOpenKeys]);
|
||||||
|
|
||||||
const transformToMenuItems = (items: AppMenuItem[]): any[] => {
|
const transformToMenuItems = useCallback((items: AppMenuItem[]): any[] => {
|
||||||
return items.map((item) => ({
|
return items.map((item) => ({
|
||||||
key: item.key,
|
key: item.key,
|
||||||
icon: item.icon ? iconMap[item.icon] : undefined,
|
icon: item.icon ? iconMap[item.icon] : undefined,
|
||||||
label: item.label,
|
label: item.label,
|
||||||
children: item.children ? transformToMenuItems(item.children) : undefined,
|
children: item.children ? transformToMenuItems(item.children) : undefined,
|
||||||
}));
|
}));
|
||||||
};
|
}, []);
|
||||||
const menuContent = useMemo(
|
const menuContent = useMemo(
|
||||||
() => (
|
() => (
|
||||||
<Menu
|
<Menu
|
||||||
@@ -237,7 +276,7 @@ const MainLayout: React.FC = () => {
|
|||||||
style={{ border: 'none' }}
|
style={{ border: 'none' }}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
[selectedKeys, openKeys, menuItems, handleMenuClick],
|
[selectedKeys, openKeys, menuItems, handleMenuClick, handleOpenChange, transformToMenuItems],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -394,6 +433,7 @@ const MainLayout: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</React.Suspense>
|
</React.Suspense>
|
||||||
)}
|
)}
|
||||||
|
<BackTop />
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { queryClient } from './api/queryClient';
|
||||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
|
import AppErrorBoundary from './components/AppErrorBoundary';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import 'dayjs/locale/zh-cn';
|
import 'dayjs/locale/zh-cn';
|
||||||
@@ -28,20 +30,16 @@ dayjs.extend(updateLocale);
|
|||||||
// 必须在所有插件加载后设置 locale
|
// 必须在所有插件加载后设置 locale
|
||||||
dayjs.locale('zh-cn');
|
dayjs.locale('zh-cn');
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const rootElement = document.getElementById('root');
|
||||||
defaultOptions: {
|
if (!rootElement) throw new Error('未找到 #root 挂载点');
|
||||||
queries: {
|
|
||||||
retry: 1,
|
|
||||||
staleTime: 30_000,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(rootElement).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<AppErrorBoundary>
|
||||||
<App />
|
<QueryClientProvider client={queryClient}>
|
||||||
{import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} />}
|
<App />
|
||||||
</QueryClientProvider>
|
{import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} />}
|
||||||
|
</QueryClientProvider>
|
||||||
|
</AppErrorBoundary>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -355,7 +355,7 @@ export const SaveTestStep: React.FC<{
|
|||||||
<Card title={<span className={styles.cardTitle}>保存并测试</span>} extra={<CheckCircleOutlined />}>
|
<Card title={<span className={styles.cardTitle}>保存并测试</span>} extra={<CheckCircleOutlined />}>
|
||||||
<Alert
|
<Alert
|
||||||
type="info"
|
type="info"
|
||||||
message="配置预览"
|
title="配置预览"
|
||||||
description={
|
description={
|
||||||
<Descriptions column={1} size="small" style={{ marginTop: 8 }}>
|
<Descriptions column={1} size="small" style={{ marginTop: 8 }}>
|
||||||
<Descriptions.Item label="服务商">
|
<Descriptions.Item label="服务商">
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import { useApiMutation } from '../../hooks/useApiMutation';
|
import { useApiMutation } from '../../hooks/useApiMutation';
|
||||||
import { validateResponse } from '../../utils/validate';
|
import { validateResponse } from '../../utils/validate';
|
||||||
import { aiConfigEnvelopeSchema } from '../../api/schemas';
|
import { aiConfigEnvelopeSchema } from '../../api/schemas';
|
||||||
import { App, Alert, Button, Form, Space, Spin, Steps, Tag } from 'antd';
|
import {App, Alert, Button, Form, Space, Steps, Tag, Skeleton} from 'antd';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import { message } from '../../ui/app-message';
|
import { message } from '../../ui/app-message';
|
||||||
import { usePermission } from '../../hooks/usePermission';
|
import { usePermission } from '../../hooks/usePermission';
|
||||||
@@ -48,10 +48,10 @@ const DEFAULT_FORM_VALUES: FormValues = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const STEP_ITEMS = [
|
const STEP_ITEMS = [
|
||||||
{ title: '服务商', description: '选择 AI 服务商' },
|
{ title: '服务商', content: '选择 AI 服务商' },
|
||||||
{ title: '密钥', description: '配置 API 密钥' },
|
{ title: '密钥', content: '配置 API 密钥' },
|
||||||
{ title: '模型', description: '获取并选择模型' },
|
{ title: '模型', content: '获取并选择模型' },
|
||||||
{ title: '完成', description: '保存并测试连接' },
|
{ title: '完成', content: '保存并测试连接' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const AiConfigPage: React.FC = () => {
|
const AiConfigPage: React.FC = () => {
|
||||||
@@ -276,7 +276,7 @@ const AiConfigPage: React.FC = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
setTesting(false);
|
setTesting(false);
|
||||||
}
|
}
|
||||||
}, [formValues, form, currentProvider]);
|
}, [formValues, form, currentProvider, refreshConfig]);
|
||||||
|
|
||||||
// ── Clear key ──
|
// ── Clear key ──
|
||||||
|
|
||||||
@@ -299,7 +299,7 @@ const AiConfigPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, [config, modal]);
|
}, [config, modal, clearKeyMutation]);
|
||||||
|
|
||||||
// ── Step navigation ──
|
// ── Step navigation ──
|
||||||
|
|
||||||
@@ -332,8 +332,8 @@ const AiConfigPage: React.FC = () => {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.container} style={{ textAlign: 'center', paddingTop: 80 }}>
|
<div className={styles.container} style={{ paddingTop: 24 }}>
|
||||||
<Spin size="large" />
|
<Skeleton active paragraph={{ rows: 10 }} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ export const ADMIN_METRIC_META = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
function pickPrimaryStatus(records: AttendanceRecordItem[]) {
|
function pickPrimaryStatus(records: AttendanceRecordItem[]) {
|
||||||
const priority = ['absent', 'leave', 'present'];
|
const priority = ['absent', 'leave', 'late', 'present'];
|
||||||
return (
|
return (
|
||||||
priority.find((item) =>
|
priority.find((item) =>
|
||||||
records.some((record) => displayAttendanceStatus(record.status) === item),
|
records.some((record) => displayAttendanceStatus(record.status) === item),
|
||||||
@@ -189,7 +189,10 @@ export function buildAdminStudentPanels(records: AttendanceRecordItem[]): AdminS
|
|||||||
}
|
}
|
||||||
|
|
||||||
return Array.from(map.values()).map((item) => {
|
return Array.from(map.values()).map((item) => {
|
||||||
const checked = item.records.filter((record) => record.status === 'present').length;
|
// late 与 present 一样视为已出勤(与 attendance-workspace 一致)
|
||||||
|
const checked = item.records.filter(
|
||||||
|
(record) => record.status === 'present' || record.status === 'late',
|
||||||
|
).length;
|
||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
primaryStatus: pickPrimaryStatus(item.records),
|
primaryStatus: pickPrimaryStatus(item.records),
|
||||||
|
|||||||
@@ -196,7 +196,6 @@ export const AttendanceAdminHeader: React.FC<{
|
|||||||
<section className="student-class-overview" aria-label="班级考勤汇总">
|
<section className="student-class-overview" aria-label="班级考勤汇总">
|
||||||
<div className="student-class-identity">
|
<div className="student-class-identity">
|
||||||
<div className="student-class-heading">
|
<div className="student-class-heading">
|
||||||
<span className="student-overview-kicker">班级考勤概览</span>
|
|
||||||
<h2>{selectedClass}</h2>
|
<h2>{selectedClass}</h2>
|
||||||
<p>
|
<p>
|
||||||
{dateLabel} · 当前展示 {visibleStudentCount} 名学生 / {total} 条记录
|
{dateLabel} · 当前展示 {visibleStudentCount} 名学生 / {total} 条记录
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ export const PeriodConfigModal: React.FC<{
|
|||||||
onOk: () => void;
|
onOk: () => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onReset: () => void;
|
onReset: () => void;
|
||||||
}> = ({ open, form, onOk, onCancel, onReset }) => {
|
confirmLoading?: boolean;
|
||||||
|
}> = ({ open, form, onOk, onCancel, onReset, confirmLoading }) => {
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
open={open}
|
open={open}
|
||||||
@@ -37,6 +38,7 @@ export const PeriodConfigModal: React.FC<{
|
|||||||
className="attendance-period-modal"
|
className="attendance-period-modal"
|
||||||
onOk={onOk}
|
onOk={onOk}
|
||||||
onCancel={onCancel}
|
onCancel={onCancel}
|
||||||
|
confirmLoading={confirmLoading}
|
||||||
footer={(_, { OkBtn, CancelBtn }) => (
|
footer={(_, { OkBtn, CancelBtn }) => (
|
||||||
<>
|
<>
|
||||||
<Button icon={<UndoOutlined />} onClick={onReset}>
|
<Button icon={<UndoOutlined />} onClick={onReset}>
|
||||||
@@ -54,7 +56,7 @@ export const PeriodConfigModal: React.FC<{
|
|||||||
description="默认:07:30-08:30 早自习,09:00-12:00 早课,14:00-17:00 晚课,18:30-21:00 晚自习。"
|
description="默认:07:30-08:30 早自习,09:00-12:00 早课,14:00-17:00 晚课,18:30-21:00 晚自习。"
|
||||||
style={{ marginBottom: 16 }}
|
style={{ marginBottom: 16 }}
|
||||||
/>
|
/>
|
||||||
<Form form={form} layout="vertical">
|
<Form form={form} layout="vertical" scrollToFirstError>
|
||||||
<Form.List name="periods">
|
<Form.List name="periods">
|
||||||
{(fields, { add, remove }) => (
|
{(fields, { add, remove }) => (
|
||||||
<div className="attendance-period-editor">
|
<div className="attendance-period-editor">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Card, Empty, Input, Spin, Table } from 'antd';
|
import { Button, Card, Empty, Input, Spin, Table } from 'antd';
|
||||||
import { ExportOutlined } from '@ant-design/icons';
|
import { ExportOutlined } from '@ant-design/icons';
|
||||||
import PermissionButton from '../../components/PermissionButton';
|
import PermissionButton from '../../components/PermissionButton';
|
||||||
import {
|
import {
|
||||||
@@ -27,6 +27,11 @@ export const AttendanceAdminWorkspace: React.FC<{
|
|||||||
pageSize: number;
|
pageSize: number;
|
||||||
total: number;
|
total: number;
|
||||||
onPageChange: (page: number, pageSize: number) => void;
|
onPageChange: (page: number, pageSize: number) => void;
|
||||||
|
/** 明细表批量纠错 */
|
||||||
|
selectedRecordKeys: number[];
|
||||||
|
onSelectRecords: (keys: number[]) => void;
|
||||||
|
onBatchCorrect: (status: string) => void;
|
||||||
|
batchCorrecting: boolean;
|
||||||
}> = ({
|
}> = ({
|
||||||
metricFilter,
|
metricFilter,
|
||||||
studentSearch,
|
studentSearch,
|
||||||
@@ -44,6 +49,10 @@ export const AttendanceAdminWorkspace: React.FC<{
|
|||||||
pageSize,
|
pageSize,
|
||||||
total,
|
total,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
|
selectedRecordKeys,
|
||||||
|
onSelectRecords,
|
||||||
|
onBatchCorrect,
|
||||||
|
batchCorrecting,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -134,13 +143,52 @@ export const AttendanceAdminWorkspace: React.FC<{
|
|||||||
</Spin>
|
</Spin>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<Card className="student-record-card" bordered={false} title="原始考勤明细">
|
<Card className="student-record-card" variant="borderless" title="原始考勤明细">
|
||||||
|
{selectedRecordKeys.length > 0 ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: 8,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>已选 {selectedRecordKeys.length} 条</span>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
loading={batchCorrecting}
|
||||||
|
onClick={() => onBatchCorrect('present')}
|
||||||
|
>
|
||||||
|
标记正常
|
||||||
|
</Button>
|
||||||
|
<Button size="small" loading={batchCorrecting} onClick={() => onBatchCorrect('leave')}>
|
||||||
|
标记请假
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
danger
|
||||||
|
loading={batchCorrecting}
|
||||||
|
onClick={() => onBatchCorrect('absent')}
|
||||||
|
>
|
||||||
|
标记缺勤
|
||||||
|
</Button>
|
||||||
|
<Button size="small" type="text" onClick={() => onSelectRecords([])}>
|
||||||
|
清空选择
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<Table<AttendanceRecordItem>
|
<Table<AttendanceRecordItem>
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={records}
|
dataSource={records}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
scroll={{ x: 'max-content' }}
|
scroll={{ x: 'max-content' }}
|
||||||
|
rowSelection={{
|
||||||
|
selectedRowKeys: selectedRecordKeys,
|
||||||
|
onChange: (keys) => onSelectRecords(keys as number[]),
|
||||||
|
}}
|
||||||
pagination={{
|
pagination={{
|
||||||
current: page,
|
current: page,
|
||||||
pageSize,
|
pageSize,
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Alert, Avatar, Button, Drawer, Empty, Input, Progress, Select, Table, Tag } from 'antd';
|
import { Alert, App, Avatar, Button, Drawer, Empty, Input, Progress, Select, Table, Tag } from 'antd';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import { message } from '../../ui/app-message';
|
import { message } from '../../ui/app-message';
|
||||||
|
import { QueryErrorState } from '../../components/QueryState';
|
||||||
import {
|
import {
|
||||||
filterLessonAttendanceRecords,
|
filterLessonAttendanceRecords,
|
||||||
getPunchDisplayInfo,
|
getPunchDisplayInfo,
|
||||||
@@ -78,43 +79,108 @@ const LessonAttendanceDetail: React.FC<LessonAttendanceDetailProps> = ({
|
|||||||
className,
|
className,
|
||||||
onClose,
|
onClose,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { modal } = App.useApp();
|
||||||
const { hasAnyPermission } = usePermission();
|
const { hasAnyPermission } = usePermission();
|
||||||
const canEditAttendance = hasAnyPermission('attendance:edit', 'attendance:self-edit');
|
const canEditAttendance = hasAnyPermission('attendance:edit', 'attendance:self-edit');
|
||||||
const [loadedSchedule, setLoadedSchedule] = useState<LessonAttendanceSchedule | null>(null);
|
const [loadedSchedule, setLoadedSchedule] = useState<LessonAttendanceSchedule | null>(null);
|
||||||
const [session, setSession] = useState<LessonAttendanceSession | null>(null);
|
const [session, setSession] = useState<LessonAttendanceSession | null>(null);
|
||||||
const [records, setRecords] = useState<LessonAttendanceRecord[]>([]);
|
const [records, setRecords] = useState<LessonAttendanceRecord[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [keyword, setKeyword] = useState('');
|
const [keyword, setKeyword] = useState('');
|
||||||
const [filter, setFilter] = useState<LessonAttendanceFilter>('all');
|
const [filter, setFilter] = useState<LessonAttendanceFilter>('all');
|
||||||
|
const [batchUpdating, setBatchUpdating] = useState<'present' | 'absent' | null>(null);
|
||||||
|
// 组件以 key 重挂载(关闭/切换课节),卸载后 in-flight 请求不再更新状态或弹提示
|
||||||
|
const cancelledRef = useRef(false);
|
||||||
|
|
||||||
|
/** 一键全部已打卡/全部未打卡(仅对状态不一致的记录) */
|
||||||
|
const handleBatchMark = async (status: 'present' | 'absent') => {
|
||||||
|
if (batchUpdating || records.length === 0) return;
|
||||||
|
const targetIds = records
|
||||||
|
.filter((record) =>
|
||||||
|
status === 'present'
|
||||||
|
? record.status !== 'present' && record.status !== 'late'
|
||||||
|
: record.status !== 'absent',
|
||||||
|
)
|
||||||
|
.map((record) => record.id);
|
||||||
|
if (targetIds.length === 0) {
|
||||||
|
message.success(status === 'present' ? '所有学生都已打卡' : '所有学生都未打卡');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
modal.confirm({
|
||||||
|
title: status === 'present' ? `将 ${targetIds.length} 名学生标记为已打卡?` : `将 ${targetIds.length} 名学生标记为未打卡?`,
|
||||||
|
content:
|
||||||
|
'此操作会立即写入考勤记录;已结算(课程截止后)的记录无法修改。',
|
||||||
|
okText: '确认',
|
||||||
|
cancelText: '取消',
|
||||||
|
onOk: async () => {
|
||||||
|
setBatchUpdating(status);
|
||||||
|
try {
|
||||||
|
const res = await api.put<{
|
||||||
|
updated: number;
|
||||||
|
failed: number;
|
||||||
|
failedIds: number[];
|
||||||
|
systemFailed: number;
|
||||||
|
}>('/attendance-records/batch-status', { ids: targetIds, status });
|
||||||
|
if (cancelledRef.current) return;
|
||||||
|
const failedSet = new Set(res.failedIds);
|
||||||
|
setRecords((items) =>
|
||||||
|
items.map((record) =>
|
||||||
|
targetIds.includes(record.id) && !failedSet.has(record.id)
|
||||||
|
? { ...record, status }
|
||||||
|
: record,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
message.success(`已更新 ${res.updated} 条记录`);
|
||||||
|
if (res.failed > 0) {
|
||||||
|
const bizFailed = res.failed - (res.systemFailed ?? 0);
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (bizFailed > 0) parts.push(`${bizFailed} 条可能已结算`);
|
||||||
|
if (res.systemFailed > 0) parts.push(`${res.systemFailed} 条系统错误`);
|
||||||
|
message.warning(`有 ${res.failed} 条更新失败:${parts.join(',')}`);
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (cancelledRef.current) return;
|
||||||
|
message.error(getErrorMessage(error, '批量更新失败'));
|
||||||
|
} finally {
|
||||||
|
if (!cancelledRef.current) setBatchUpdating(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadLesson = useCallback(async () => {
|
||||||
|
if (!schedule) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const date = dayjs().format('YYYY-MM-DD');
|
||||||
|
try {
|
||||||
|
const data = await api.post<LessonAttendanceResponse>(
|
||||||
|
`/attendance-lessons/schedules/${schedule.id}/pull`,
|
||||||
|
{ date },
|
||||||
|
);
|
||||||
|
if (cancelledRef.current) return;
|
||||||
|
setLoadedSchedule(data.schedule);
|
||||||
|
setSession(data.session);
|
||||||
|
setRecords(data.records);
|
||||||
|
message.success('钉钉打卡已更新;课程截止后系统将自动结算');
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (cancelledRef.current) return;
|
||||||
|
setError(getErrorMessage(error, '加载本节课考勤失败'));
|
||||||
|
} finally {
|
||||||
|
if (!cancelledRef.current) setLoading(false);
|
||||||
|
}
|
||||||
|
}, [schedule]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!schedule) return;
|
if (!schedule) return;
|
||||||
let cancelled = false;
|
cancelledRef.current = false;
|
||||||
setLoadedSchedule(schedule);
|
setLoadedSchedule(schedule);
|
||||||
setLoading(true);
|
void loadLesson();
|
||||||
const date = dayjs().format('YYYY-MM-DD');
|
|
||||||
void api
|
|
||||||
.post<LessonAttendanceResponse>(`/attendance-lessons/schedules/${schedule.id}/pull`, {
|
|
||||||
date,
|
|
||||||
})
|
|
||||||
.then((data) => {
|
|
||||||
if (cancelled) return;
|
|
||||||
setLoadedSchedule(data.schedule);
|
|
||||||
setSession(data.session);
|
|
||||||
setRecords(data.records);
|
|
||||||
message.success('钉钉打卡已更新;课程截止后系统将自动结算');
|
|
||||||
})
|
|
||||||
.catch((error: unknown) => {
|
|
||||||
if (cancelled) return;
|
|
||||||
message.error(getErrorMessage(error, '加载本节课考勤失败'));
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (!cancelled) setLoading(false);
|
|
||||||
});
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelledRef.current = true;
|
||||||
};
|
};
|
||||||
}, [schedule]);
|
}, [schedule, loadLesson]);
|
||||||
|
|
||||||
const updateRecord = useCallback(async (record: LessonAttendanceRecord, status: string) => {
|
const updateRecord = useCallback(async (record: LessonAttendanceRecord, status: string) => {
|
||||||
const previous = record.status;
|
const previous = record.status;
|
||||||
@@ -148,7 +214,6 @@ const LessonAttendanceDetail: React.FC<LessonAttendanceDetailProps> = ({
|
|||||||
destroyOnHidden
|
destroyOnHidden
|
||||||
>
|
>
|
||||||
<div className="lesson-record-header">
|
<div className="lesson-record-header">
|
||||||
<span className="attendance-eyebrow">LESSON ATTENDANCE</span>
|
|
||||||
<h2>{displayedSchedule?.subject || '课程考勤'}</h2>
|
<h2>{displayedSchedule?.subject || '课程考勤'}</h2>
|
||||||
<p>
|
<p>
|
||||||
{className} · {displayedSchedule?.startTime}–{displayedSchedule?.endTime} ·{' '}
|
{className} · {displayedSchedule?.startTime}–{displayedSchedule?.endTime} ·{' '}
|
||||||
@@ -185,20 +250,47 @@ const LessonAttendanceDetail: React.FC<LessonAttendanceDetailProps> = ({
|
|||||||
<span className="lesson-record-filter-count">
|
<span className="lesson-record-filter-count">
|
||||||
显示 {filteredRecords.length} / {records.length} 人
|
显示 {filteredRecords.length} / {records.length} 人
|
||||||
</span>
|
</span>
|
||||||
|
{canEditAttendance && records.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
loading={batchUpdating === 'present'}
|
||||||
|
onClick={() => void handleBatchMark('present')}
|
||||||
|
>
|
||||||
|
全部已打卡
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
danger
|
||||||
|
loading={batchUpdating === 'absent'}
|
||||||
|
onClick={() => void handleBatchMark('absent')}
|
||||||
|
>
|
||||||
|
全部未打卡
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<Table<LessonAttendanceRecord>
|
{error ? (
|
||||||
rowKey="id"
|
<QueryErrorState
|
||||||
loading={loading}
|
title="本节课考勤加载失败"
|
||||||
dataSource={filteredRecords}
|
description={error}
|
||||||
pagination={false}
|
onRetry={() => void loadLesson()}
|
||||||
locale={{
|
/>
|
||||||
emptyText: (
|
) : (
|
||||||
<Empty
|
<Table<LessonAttendanceRecord> scroll={{ x: 'max-content' }}
|
||||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
rowKey="id"
|
||||||
description={records.length === 0 ? '本节课尚未开始点名' : '没有符合条件的学生'}
|
loading={loading}
|
||||||
/>
|
dataSource={filteredRecords}
|
||||||
),
|
pagination={false}
|
||||||
}}
|
locale={{
|
||||||
|
emptyText: (
|
||||||
|
<Empty
|
||||||
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
|
description={records.length === 0 ? '本节课尚未开始点名' : '没有符合条件的学生'}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
columns={[
|
columns={[
|
||||||
{
|
{
|
||||||
title: '学生',
|
title: '学生',
|
||||||
@@ -265,7 +357,8 @@ const LessonAttendanceDetail: React.FC<LessonAttendanceDetailProps> = ({
|
|||||||
render: (value: string | null) => value || <span className="muted-text">—</span>,
|
render: (value: string | null) => value || <span className="muted-text">—</span>,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
|||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useApiMutation } from '../../hooks/useApiMutation';
|
import { useApiMutation } from '../../hooks/useApiMutation';
|
||||||
import { validateResponse } from '../../utils/validate';
|
import { validateResponse } from '../../utils/validate';
|
||||||
|
import { getErrorMessage } from '../../utils/error';
|
||||||
import {
|
import {
|
||||||
attendanceAlertsSchema,
|
attendanceAlertsSchema,
|
||||||
attendanceClassOptionsSchema,
|
attendanceClassOptionsSchema,
|
||||||
@@ -11,13 +12,14 @@ import {
|
|||||||
attendanceSummarySchema,
|
attendanceSummarySchema,
|
||||||
dingTalkSyncStatusSchema,
|
dingTalkSyncStatusSchema,
|
||||||
} from '../../api/schemas';
|
} from '../../api/schemas';
|
||||||
import { Form, Grid } from 'antd';
|
import { App, Form, Grid } from 'antd';
|
||||||
import dayjs, { type Dayjs } from 'dayjs';
|
import dayjs, { type Dayjs } from 'dayjs';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import { message } from '../../ui/app-message';
|
import { message } from '../../ui/app-message';
|
||||||
import { useUserStore } from '../../store/user/userStore';
|
import { useUserStore } from '../../store/user/userStore';
|
||||||
import type { AttendanceSummary } from './attendance-workspace';
|
import type { AttendanceSummary } from './attendance-workspace';
|
||||||
import { getErrorMessage } from '../../utils/error';
|
import { QueryErrorState } from '../../components/QueryState';
|
||||||
|
import { useVisibleRefetch } from '../../hooks/usePageVisible';
|
||||||
import { AttendanceAdminHeader } from './AttendanceAdminHeader';
|
import { AttendanceAdminHeader } from './AttendanceAdminHeader';
|
||||||
import { buildAttendanceAdminColumns } from './AttendanceAdminColumns';
|
import { buildAttendanceAdminColumns } from './AttendanceAdminColumns';
|
||||||
import { PeriodConfigModal, StudentDetailDrawer } from './AttendanceAdminModals';
|
import { PeriodConfigModal, StudentDetailDrawer } from './AttendanceAdminModals';
|
||||||
@@ -37,8 +39,10 @@ import {
|
|||||||
type DingTalkSyncStatus,
|
type DingTalkSyncStatus,
|
||||||
type HistoryScheduleOption,
|
type HistoryScheduleOption,
|
||||||
} from './AttendanceAdmin.helpers';
|
} from './AttendanceAdmin.helpers';
|
||||||
|
import { saveAs } from 'file-saver';
|
||||||
|
|
||||||
export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||||
|
const { modal } = App.useApp();
|
||||||
const screens = Grid.useBreakpoint();
|
const screens = Grid.useBreakpoint();
|
||||||
const isMobile = !screens.sm;
|
const isMobile = !screens.sm;
|
||||||
const [periodModalOpen, setPeriodModalOpen] = useState(false);
|
const [periodModalOpen, setPeriodModalOpen] = useState(false);
|
||||||
@@ -55,7 +59,11 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit
|
|||||||
const [studentSearch, setStudentSearch] = useState('');
|
const [studentSearch, setStudentSearch] = useState('');
|
||||||
const [selectedStudent, setSelectedStudent] = useState<AdminStudentPanel | null>(null);
|
const [selectedStudent, setSelectedStudent] = useState<AdminStudentPanel | null>(null);
|
||||||
const [correctingRecordId, setCorrectingRecordId] = useState<number | null>(null);
|
const [correctingRecordId, setCorrectingRecordId] = useState<number | null>(null);
|
||||||
|
// 明细表批量纠错:选中的记录 ID + 执行中状态
|
||||||
|
const [selectedRecordIds, setSelectedRecordIds] = useState<number[]>([]);
|
||||||
|
const [batchCorrecting, setBatchCorrecting] = useState(false);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
useVisibleRefetch(['attendance', 'records']);
|
||||||
const recordQueryKey = [
|
const recordQueryKey = [
|
||||||
'attendance',
|
'attendance',
|
||||||
'records',
|
'records',
|
||||||
@@ -68,44 +76,34 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit
|
|||||||
scheduleId,
|
scheduleId,
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
// 筛选条件变化时清空批量选择,避免把上一批条件的记录带到新条件下提交
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedRecordIds([]);
|
||||||
|
}, [classId, attendanceDate, status, session, scheduleId]);
|
||||||
|
|
||||||
const { data: classOptions = [] } = useQuery<ClassOption[]>({
|
const { data: classOptions = [] } = useQuery<ClassOption[]>({
|
||||||
queryKey: ['attendance', 'meta', 'classes'],
|
queryKey: ['attendance', 'meta', 'classes'],
|
||||||
queryFn: async () => {
|
queryFn: async () =>
|
||||||
try {
|
validateResponse<ClassOption[]>(
|
||||||
return validateResponse<ClassOption[]>(
|
attendanceClassOptionsSchema,
|
||||||
attendanceClassOptionsSchema,
|
await api.get<ClassOption[]>('/attendance-records/classes'),
|
||||||
await api.get<ClassOption[]>('/attendance-records/classes'),
|
),
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
const { data: alerts = [] } = useQuery<AlertItem[]>({
|
const { data: alerts = [] } = useQuery<AlertItem[]>({
|
||||||
queryKey: ['attendance', 'meta', 'alerts'],
|
queryKey: ['attendance', 'meta', 'alerts'],
|
||||||
queryFn: async () => {
|
queryFn: async () =>
|
||||||
try {
|
validateResponse<AlertItem[]>(
|
||||||
return validateResponse<AlertItem[]>(
|
attendanceAlertsSchema,
|
||||||
attendanceAlertsSchema,
|
await api.get<AlertItem[]>('/attendance-records/alerts'),
|
||||||
await api.get<AlertItem[]>('/attendance-records/alerts'),
|
),
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
const { data: periods = DEFAULT_ATTENDANCE_PERIODS } = useQuery<AttendancePeriodConfigItem[]>({
|
const { data: periods = DEFAULT_ATTENDANCE_PERIODS } = useQuery<AttendancePeriodConfigItem[]>({
|
||||||
queryKey: ['attendance', 'meta', 'periods'],
|
queryKey: ['attendance', 'meta', 'periods'],
|
||||||
queryFn: async () => {
|
queryFn: async () =>
|
||||||
try {
|
validateResponse<AttendancePeriodConfigItem[]>(
|
||||||
return validateResponse<AttendancePeriodConfigItem[]>(
|
attendancePeriodsSchema,
|
||||||
attendancePeriodsSchema,
|
await api.get<AttendancePeriodConfigItem[]>('/attendance-period-configs'),
|
||||||
await api.get<AttendancePeriodConfigItem[]>('/attendance-period-configs'),
|
),
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
return DEFAULT_ATTENDANCE_PERIODS;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
const {
|
const {
|
||||||
data: scheduleOptions = [],
|
data: scheduleOptions = [],
|
||||||
@@ -114,17 +112,13 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit
|
|||||||
queryKey: ['attendance', 'schedules', classId, attendanceDate],
|
queryKey: ['attendance', 'schedules', classId, attendanceDate],
|
||||||
enabled: !!classId && !!attendanceDate,
|
enabled: !!classId && !!attendanceDate,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
try {
|
if (!attendanceDate) return [];
|
||||||
return validateResponse<HistoryScheduleOption[]>(
|
return validateResponse<HistoryScheduleOption[]>(
|
||||||
attendanceScheduleOptionsSchema,
|
attendanceScheduleOptionsSchema,
|
||||||
await api.get<HistoryScheduleOption[]>('/attendance-records/schedules', {
|
await api.get<HistoryScheduleOption[]>('/attendance-records/schedules', {
|
||||||
params: { classId, date: attendanceDate!.format('YYYY-MM-DD') },
|
params: { classId, date: attendanceDate.format('YYYY-MM-DD') },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
} catch (error: unknown) {
|
|
||||||
message.error(getErrorMessage(error, '加载班级科目失败'));
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const scheduleOptionsLoading = scheduleOptionsFetching;
|
const scheduleOptionsLoading = scheduleOptionsFetching;
|
||||||
@@ -133,10 +127,6 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit
|
|||||||
? scheduleId
|
? scheduleId
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
periodForm.setFieldsValue({ periods });
|
|
||||||
}, [periods, periodForm]);
|
|
||||||
|
|
||||||
const enabledPeriods = useMemo(
|
const enabledPeriods = useMemo(
|
||||||
() => periods.filter((period) => period.enabled).sort((a, b) => a.sortOrder - b.sortOrder),
|
() => periods.filter((period) => period.enabled).sort((a, b) => a.sortOrder - b.sortOrder),
|
||||||
[periods],
|
[periods],
|
||||||
@@ -190,13 +180,24 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const resetPeriodConfig = async () => {
|
const resetPeriodConfig = () => {
|
||||||
try {
|
modal.confirm({
|
||||||
await resetPeriodConfigMutation.mutateAsync();
|
title: '恢复默认考勤时段?',
|
||||||
message.success('已恢复默认考勤时段');
|
content: '当前自定义的考勤时段配置将被系统默认值覆盖,此操作不可撤销。',
|
||||||
} catch {
|
okText: '恢复默认',
|
||||||
// 错误提示由 useApiMutation 统一处理
|
okButtonProps: { danger: true },
|
||||||
}
|
cancelText: '取消',
|
||||||
|
onOk: async () => {
|
||||||
|
try {
|
||||||
|
const data = await resetPeriodConfigMutation.mutateAsync();
|
||||||
|
// 同步回填表单,避免界面仍显示旧配置、用户再点保存把旧值写回
|
||||||
|
periodForm.setFieldsValue({ periods: data });
|
||||||
|
message.success('已恢复默认考勤时段');
|
||||||
|
} catch {
|
||||||
|
// 错误提示由 useApiMutation 统一处理
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildParams = useCallback(
|
const buildParams = useCallback(
|
||||||
@@ -214,27 +215,23 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit
|
|||||||
if (effectiveScheduleId) params.scheduleId = effectiveScheduleId;
|
if (effectiveScheduleId) params.scheduleId = effectiveScheduleId;
|
||||||
return params;
|
return params;
|
||||||
},
|
},
|
||||||
[page, pageSize, classId, attendanceDate, status, session, scheduleId],
|
[page, pageSize, classId, attendanceDate, status, session, effectiveScheduleId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: syncStatus = null, refetch: refetchSyncStatus } = useQuery<DingTalkSyncStatus | null>({
|
const { data: syncStatus = null, refetch: refetchSyncStatus } = useQuery<DingTalkSyncStatus | null>({
|
||||||
queryKey: ['attendance', 'sync-status'],
|
queryKey: ['attendance', 'sync-status'],
|
||||||
queryFn: async () => {
|
queryFn: async () =>
|
||||||
try {
|
validateResponse<DingTalkSyncStatus>(
|
||||||
return validateResponse<DingTalkSyncStatus>(
|
dingTalkSyncStatusSchema,
|
||||||
dingTalkSyncStatusSchema,
|
await api.get<DingTalkSyncStatus>('/attendance-records/dingtalk-sync-status'),
|
||||||
await api.get<DingTalkSyncStatus>('/attendance-records/dingtalk-sync-status'),
|
),
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
const loadSyncStatus = useCallback(() => refetchSyncStatus(), [refetchSyncStatus]);
|
const loadSyncStatus = useCallback(() => refetchSyncStatus(), [refetchSyncStatus]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: recordQuery = { records: [], total: 0, summary: EMPTY_SUMMARY },
|
data: recordQuery = { records: [], total: 0, summary: EMPTY_SUMMARY },
|
||||||
isFetching: recordsFetching,
|
isFetching: recordsFetching,
|
||||||
|
isError: recordsError,
|
||||||
refetch: refetchRecords,
|
refetch: refetchRecords,
|
||||||
} = useQuery<{
|
} = useQuery<{
|
||||||
records: AttendanceRecordItem[];
|
records: AttendanceRecordItem[];
|
||||||
@@ -243,32 +240,27 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit
|
|||||||
}>({
|
}>({
|
||||||
queryKey: recordQueryKey,
|
queryKey: recordQueryKey,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
try {
|
const [recordData, summaryData] = await Promise.all([
|
||||||
const [recordData, summaryData] = await Promise.all([
|
api.get<{ list: AttendanceRecordItem[]; total: number }>('/attendance-records', {
|
||||||
api.get<{ list: AttendanceRecordItem[]; total: number }>('/attendance-records', {
|
params: buildParams(true),
|
||||||
params: buildParams(true),
|
}),
|
||||||
}),
|
api.get<AttendanceSummary>('/attendance-records/summary', {
|
||||||
api.get<AttendanceSummary>('/attendance-records/summary', {
|
params: buildParams(false),
|
||||||
params: buildParams(false),
|
}),
|
||||||
}),
|
]);
|
||||||
]);
|
const validatedRecords = validateResponse<{
|
||||||
const validatedRecords = validateResponse<{
|
list: AttendanceRecordItem[];
|
||||||
list: AttendanceRecordItem[];
|
total: number;
|
||||||
total: number;
|
}>(attendanceRecordsResponseSchema, recordData);
|
||||||
}>(attendanceRecordsResponseSchema, recordData);
|
const validatedSummary = validateResponse<AttendanceSummary>(
|
||||||
const validatedSummary = validateResponse<AttendanceSummary>(
|
attendanceSummarySchema,
|
||||||
attendanceSummarySchema,
|
summaryData,
|
||||||
summaryData,
|
);
|
||||||
);
|
return {
|
||||||
return {
|
records: validatedRecords.list,
|
||||||
records: validatedRecords.list,
|
total: validatedRecords.total,
|
||||||
total: validatedRecords.total,
|
summary: { ...EMPTY_SUMMARY, ...validatedSummary },
|
||||||
summary: { ...EMPTY_SUMMARY, ...validatedSummary },
|
};
|
||||||
};
|
|
||||||
} catch (error: unknown) {
|
|
||||||
message.error(getErrorMessage(error, '加载学生考勤失败'));
|
|
||||||
return { records: [], total: 0, summary: EMPTY_SUMMARY };
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const records = recordQuery.records;
|
const records = recordQuery.records;
|
||||||
@@ -334,7 +326,7 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit
|
|||||||
} finally {
|
} finally {
|
||||||
setRefreshingDingTalk(false);
|
setRefreshingDingTalk(false);
|
||||||
}
|
}
|
||||||
}, [attendanceDate, classId, loadRecords, loadSyncStatus, session]);
|
}, [attendanceDate, classId, loadRecords, loadSyncStatus, session, refreshDingTalkMutation]);
|
||||||
|
|
||||||
const resetFilters = () => {
|
const resetFilters = () => {
|
||||||
setClassId(undefined);
|
setClassId(undefined);
|
||||||
@@ -358,14 +350,7 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit
|
|||||||
if (!response.ok) throw new Error('导出失败');
|
if (!response.ok) throw new Error('导出失败');
|
||||||
return response.blob();
|
return response.blob();
|
||||||
})
|
})
|
||||||
.then((blob) => {
|
.then((blob) => saveAs(blob, `学生考勤-${dayjs().format('YYYYMMDD')}.xlsx`))
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const anchor = document.createElement('a');
|
|
||||||
anchor.href = url;
|
|
||||||
anchor.download = `学生考勤-${dayjs().format('YYYYMMDD')}.xlsx`;
|
|
||||||
anchor.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
})
|
|
||||||
.catch(() => message.error('导出失败'));
|
.catch(() => message.error('导出失败'));
|
||||||
}, [buildParams]);
|
}, [buildParams]);
|
||||||
|
|
||||||
@@ -418,6 +403,44 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 批量纠错:对选中的明细记录统一标记状态 */
|
||||||
|
const batchCorrectStatus = async (nextStatus: string) => {
|
||||||
|
if (selectedRecordIds.length === 0) return;
|
||||||
|
const statusLabel =
|
||||||
|
nextStatus === 'present' ? '正常' : nextStatus === 'leave' ? '请假' : '缺勤';
|
||||||
|
modal.confirm({
|
||||||
|
title: `将选中的 ${selectedRecordIds.length} 条记录标记为「${statusLabel}」?`,
|
||||||
|
content: '此操作会立即写入考勤记录;已结算的记录无法修改。',
|
||||||
|
okText: '确认',
|
||||||
|
cancelText: '取消',
|
||||||
|
onOk: async () => {
|
||||||
|
setBatchCorrecting(true);
|
||||||
|
try {
|
||||||
|
const res = await api.put<{
|
||||||
|
updated: number;
|
||||||
|
failed: number;
|
||||||
|
failedIds: number[];
|
||||||
|
systemFailed: number;
|
||||||
|
}>('/attendance-records/batch-status', { ids: selectedRecordIds, status: nextStatus });
|
||||||
|
setSelectedRecordIds([]);
|
||||||
|
message.success(`已更新 ${res.updated} 条记录`);
|
||||||
|
if (res.failed > 0) {
|
||||||
|
const bizFailed = res.failed - (res.systemFailed ?? 0);
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (bizFailed > 0) parts.push(`${bizFailed} 条可能已结算`);
|
||||||
|
if (res.systemFailed > 0) parts.push(`${res.systemFailed} 条系统错误`);
|
||||||
|
message.warning(`有 ${res.failed} 条更新失败:${parts.join(',')}`);
|
||||||
|
}
|
||||||
|
void refetchRecords();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
message.error(getErrorMessage(e, '批量更新失败'));
|
||||||
|
} finally {
|
||||||
|
setBatchCorrecting(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const saveAdminRecordCell = async (
|
const saveAdminRecordCell = async (
|
||||||
record: AttendanceRecordItem,
|
record: AttendanceRecordItem,
|
||||||
field: 'status' | 'remark',
|
field: 'status' | 'remark',
|
||||||
@@ -535,27 +558,39 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit
|
|||||||
alerts={alerts}
|
alerts={alerts}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AttendanceAdminWorkspace
|
{recordsError ? (
|
||||||
metricFilter={metricFilter}
|
<QueryErrorState
|
||||||
studentSearch={studentSearch}
|
title="学生考勤数据加载失败"
|
||||||
onSearchChange={setStudentSearch}
|
description="请检查网络后重试。"
|
||||||
onExport={handleExport}
|
onRetry={() => void refetchRecords()}
|
||||||
visibleStudents={visibleStudents}
|
/>
|
||||||
loading={loading}
|
) : (
|
||||||
selectedStudentId={selectedStudent?.studentId}
|
<AttendanceAdminWorkspace
|
||||||
onSelectStudent={setSelectedStudent}
|
metricFilter={metricFilter}
|
||||||
sortAttendanceRecords={sortAttendanceRecords}
|
studentSearch={studentSearch}
|
||||||
sessionMap={sessionMap}
|
onSearchChange={setStudentSearch}
|
||||||
records={records}
|
onExport={handleExport}
|
||||||
columns={columns}
|
visibleStudents={visibleStudents}
|
||||||
page={page}
|
loading={loading}
|
||||||
pageSize={pageSize}
|
selectedStudentId={selectedStudent?.studentId}
|
||||||
total={total}
|
onSelectStudent={setSelectedStudent}
|
||||||
onPageChange={(nextPage, nextPageSize) => {
|
sortAttendanceRecords={sortAttendanceRecords}
|
||||||
setPage(nextPage);
|
sessionMap={sessionMap}
|
||||||
setPageSize(nextPageSize);
|
records={records}
|
||||||
}}
|
columns={columns}
|
||||||
/>
|
page={page}
|
||||||
|
pageSize={pageSize}
|
||||||
|
total={total}
|
||||||
|
onPageChange={(nextPage, nextPageSize) => {
|
||||||
|
setPage(nextPage);
|
||||||
|
setPageSize(nextPageSize);
|
||||||
|
}}
|
||||||
|
selectedRecordKeys={selectedRecordIds}
|
||||||
|
onSelectRecords={setSelectedRecordIds}
|
||||||
|
onBatchCorrect={batchCorrectStatus}
|
||||||
|
batchCorrecting={batchCorrecting}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<StudentDetailDrawer
|
<StudentDetailDrawer
|
||||||
student={selectedStudent}
|
student={selectedStudent}
|
||||||
@@ -574,6 +609,7 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit
|
|||||||
onOk={savePeriodConfig}
|
onOk={savePeriodConfig}
|
||||||
onCancel={() => setPeriodModalOpen(false)}
|
onCancel={() => setPeriodModalOpen(false)}
|
||||||
onReset={() => void resetPeriodConfig()}
|
onReset={() => void resetPeriodConfig()}
|
||||||
|
confirmLoading={savePeriodConfigMutation.isPending}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -59,13 +59,6 @@
|
|||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.attendance-eyebrow {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 1.7px;
|
|
||||||
opacity: 0.72;
|
|
||||||
}
|
|
||||||
|
|
||||||
.teacher-topbar {
|
.teacher-topbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -761,7 +754,6 @@
|
|||||||
padding: 0 24px;
|
padding: 0 24px;
|
||||||
border-bottom: 1px solid var(--student-line);
|
border-bottom: 1px solid var(--student-line);
|
||||||
background: rgb(255 255 255 / 96%);
|
background: rgb(255 255 255 / 96%);
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.student-center-title {
|
.student-center-title {
|
||||||
@@ -911,21 +903,7 @@
|
|||||||
min-height: 176px;
|
min-height: 176px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
background:
|
background: var(--student-surface);
|
||||||
radial-gradient(circle at 100% 0%, rgb(21 122 101 / 10%), transparent 34%),
|
|
||||||
linear-gradient(135deg, #ffffff 0%, #f8fcfa 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.student-class-identity::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
right: -36px;
|
|
||||||
bottom: -44px;
|
|
||||||
width: 118px;
|
|
||||||
height: 118px;
|
|
||||||
border: 18px solid rgb(21 122 101 / 7%);
|
|
||||||
border-radius: 999px;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.student-class-heading {
|
.student-class-heading {
|
||||||
@@ -933,19 +911,6 @@
|
|||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.student-overview-kicker {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
min-height: 24px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
padding: 0 9px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: rgb(21 122 101 / 10%);
|
|
||||||
color: var(--student-primary);
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.student-class-identity h2 {
|
.student-class-identity h2 {
|
||||||
margin: 0 0 6px;
|
margin: 0 0 6px;
|
||||||
color: #111c18;
|
color: #111c18;
|
||||||
|
|||||||
@@ -1,18 +1,16 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React from 'react';
|
||||||
import { usePermission } from '../../hooks/usePermission';
|
import { usePermission } from '../../hooks/usePermission';
|
||||||
import { useUserStore } from '../../store/user/userStore';
|
import { useUserStore } from '../../store/user/userStore';
|
||||||
import { getAttendanceExperience } from './attendance-workspace';
|
import { getAttendanceExperience } from './attendance-workspace';
|
||||||
import { TeacherAttendanceWorkspace } from './teacher';
|
import { TeacherAttendanceWorkspace } from './teacher';
|
||||||
import { AdminAttendanceArchive } from './admin';
|
import { AdminAttendanceArchive } from './admin';
|
||||||
|
import './attendance.css';
|
||||||
function readCurrentRoles(): string[] {
|
|
||||||
const roles = useUserStore.getState().user?.roles;
|
|
||||||
return Array.isArray(roles) ? roles : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const AttendancePage: React.FC = () => {
|
const AttendancePage: React.FC = () => {
|
||||||
const { permissions, hasPermission } = usePermission();
|
const { permissions, hasPermission } = usePermission();
|
||||||
const roles = useMemo(readCurrentRoles, []);
|
// 订阅 store:角色变化时重新计算体验(不再只在首渲染读一次)
|
||||||
|
const rolesRef = useUserStore((s) => s.user?.roles);
|
||||||
|
const roles = Array.isArray(rolesRef) ? rolesRef : [];
|
||||||
const experience = getAttendanceExperience(permissions, roles);
|
const experience = getAttendanceExperience(permissions, roles);
|
||||||
|
|
||||||
if (experience === 'teacher') {
|
if (experience === 'teacher') {
|
||||||
|
|||||||
@@ -11,8 +11,7 @@ import {
|
|||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import { message } from '../../ui/app-message';
|
import { QueryErrorState } from '../../components/QueryState';
|
||||||
import { getErrorMessage } from '../../utils/error';
|
|
||||||
import {
|
import {
|
||||||
canPullAttendance,
|
canPullAttendance,
|
||||||
getSchedulePhase,
|
getSchedulePhase,
|
||||||
@@ -41,21 +40,18 @@ export const TeacherAttendanceWorkspace: React.FC<{ canCreate: boolean }> = ({ c
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
data: workspace,
|
data: workspace,
|
||||||
isLoading,
|
|
||||||
isFetching,
|
isFetching,
|
||||||
|
isPending,
|
||||||
|
isError,
|
||||||
refetch,
|
refetch,
|
||||||
} = useQuery<TeacherWorkspaceData | null>({
|
} = useQuery<TeacherWorkspaceData | null>({
|
||||||
queryKey: ['attendance', 'workspace'],
|
queryKey: ['attendance', 'workspace'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
try {
|
return await api.get<TeacherWorkspaceData>('/rbac/teacher-workspace');
|
||||||
return await api.get<TeacherWorkspaceData>('/rbac/teacher-workspace');
|
|
||||||
} catch (error: unknown) {
|
|
||||||
message.error(getErrorMessage(error, '加载今日课程失败'));
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const loading = isLoading || isFetching;
|
// isPending 覆盖自动重试的退避窗口,避免「加载失败/重试中」短暂闪现为空态
|
||||||
|
const loading = isPending || isFetching;
|
||||||
const loadWorkspace = useCallback(() => refetch(), [refetch]);
|
const loadWorkspace = useCallback(() => refetch(), [refetch]);
|
||||||
|
|
||||||
const classNameById = useMemo(
|
const classNameById = useMemo(
|
||||||
@@ -80,11 +76,10 @@ export const TeacherAttendanceWorkspace: React.FC<{ canCreate: boolean }> = ({ c
|
|||||||
<div className="attendance-page teacher-attendance">
|
<div className="attendance-page teacher-attendance">
|
||||||
<section className="attendance-hero attendance-hero--teacher">
|
<section className="attendance-hero attendance-hero--teacher">
|
||||||
<div>
|
<div>
|
||||||
<span className="attendance-eyebrow">
|
|
||||||
TEACHING DAY · {dayjs().format('MM月DD日 dddd')}
|
|
||||||
</span>
|
|
||||||
<h1>今天,从课程开始</h1>
|
<h1>今天,从课程开始</h1>
|
||||||
<p>课程开始后可查看最新打卡结果;课程截止时系统自动拉取并结算缺勤。</p>
|
<p>
|
||||||
|
{dayjs().format('MM月DD日 dddd')} · 课程开始后可查看最新打卡结果;课程截止时系统自动拉取并结算缺勤。
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button icon={<ReloadOutlined />} onClick={() => void loadWorkspace()}>
|
<Button icon={<ReloadOutlined />} onClick={() => void loadWorkspace()}>
|
||||||
刷新
|
刷新
|
||||||
@@ -124,7 +119,15 @@ export const TeacherAttendanceWorkspace: React.FC<{ canCreate: boolean }> = ({ c
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Spin spinning={loading}>
|
<Spin spinning={loading}>
|
||||||
{schedules.length === 0 ? (
|
{isError ? (
|
||||||
|
<Card className="attendance-empty-card">
|
||||||
|
<QueryErrorState
|
||||||
|
title="课程数据加载失败"
|
||||||
|
description="请检查网络后点击重试;若持续失败请联系管理员。"
|
||||||
|
onRetry={() => void loadWorkspace()}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
) : schedules.length === 0 ? (
|
||||||
<Card className="attendance-empty-card">
|
<Card className="attendance-empty-card">
|
||||||
<Empty
|
<Empty
|
||||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
|
|||||||
@@ -3,18 +3,22 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import { useApiMutation } from '../hooks/useApiMutation';
|
import { useApiMutation } from '../hooks/useApiMutation';
|
||||||
import { validateResponse } from '../utils/validate';
|
import { validateResponse } from '../utils/validate';
|
||||||
import { attendanceDevicesSchema, classroomOptionsSchema } from '../api/schemas';
|
import { attendanceDevicesSchema, classroomOptionsSchema } from '../api/schemas';
|
||||||
import { Empty, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag } from 'antd';
|
import { Form, Input, Modal, Popconfirm, Select, Space, Table, Tag } from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { PlusOutlined } from '@ant-design/icons';
|
import { PlusOutlined } from '@ant-design/icons';
|
||||||
import api from '../api';
|
import api from '../api';
|
||||||
import PermissionButton from '../components/PermissionButton';
|
import PermissionButton from '../components/PermissionButton';
|
||||||
import EditableCell from '../components/EditableCell';
|
import EditableCell from '../components/EditableCell';
|
||||||
|
import { QueryErrorState, QueryEmpty } from '../components/QueryState';
|
||||||
import { message } from '../ui/app-message';
|
import { message } from '../ui/app-message';
|
||||||
|
import { useDirtyGuard } from '../hooks/useDirtyGuard';
|
||||||
|
import { usePermission } from '../hooks/usePermission';
|
||||||
|
|
||||||
interface ClassroomOption {
|
interface ClassroomOption {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
building?: string | null;
|
building?: string | null;
|
||||||
|
status?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AttendanceDeviceRow {
|
interface AttendanceDeviceRow {
|
||||||
@@ -34,35 +38,34 @@ const statusMeta = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const AttendanceDevicesPage: React.FC = () => {
|
const AttendanceDevicesPage: React.FC = () => {
|
||||||
|
const { hasPermission } = usePermission();
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [editing, setEditing] = useState<AttendanceDeviceRow | null>(null);
|
const [editing, setEditing] = useState<AttendanceDeviceRow | null>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [keyword, setKeyword] = useState('');
|
const [keyword, setKeyword] = useState('');
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
|
const formGuard = useDirtyGuard(form);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: fetchResult = { devices: [], classrooms: [] },
|
data: fetchResult = { devices: [], classrooms: [] },
|
||||||
isLoading,
|
isLoading,
|
||||||
isFetching,
|
isFetching,
|
||||||
|
isError,
|
||||||
|
refetch,
|
||||||
} = useQuery<{ devices: AttendanceDeviceRow[]; classrooms: ClassroomOption[] }>({
|
} = useQuery<{ devices: AttendanceDeviceRow[]; classrooms: ClassroomOption[] }>({
|
||||||
queryKey: ['attendance-devices'],
|
queryKey: ['attendance-devices'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
try {
|
const [devices, classroomList] = await Promise.all([
|
||||||
const [devices, classroomList] = await Promise.all([
|
api.get<AttendanceDeviceRow[]>('/attendance-devices'),
|
||||||
api.get<AttendanceDeviceRow[]>('/attendance-devices'),
|
api.get<ClassroomOption[]>('/classrooms'),
|
||||||
api.get<ClassroomOption[]>('/classrooms'),
|
]);
|
||||||
]);
|
return {
|
||||||
return {
|
devices: validateResponse<AttendanceDeviceRow[]>(attendanceDevicesSchema, devices),
|
||||||
devices: validateResponse<AttendanceDeviceRow[]>(attendanceDevicesSchema, devices),
|
classrooms: validateResponse<ClassroomOption[]>(
|
||||||
classrooms: validateResponse<ClassroomOption[]>(
|
classroomOptionsSchema,
|
||||||
classroomOptionsSchema,
|
classroomList,
|
||||||
classroomList,
|
).filter((item: ClassroomOption) => item.status !== 'archived'),
|
||||||
).filter((item: any) => item.status !== 'archived'),
|
};
|
||||||
};
|
|
||||||
} catch (error: any) {
|
|
||||||
message.error(error?.message || '加载考勤机绑定失败');
|
|
||||||
return { devices: [], classrooms: [] };
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const data = fetchResult.devices;
|
const data = fetchResult.devices;
|
||||||
@@ -109,6 +112,7 @@ const AttendanceDevicesPage: React.FC = () => {
|
|||||||
setEditing(null);
|
setEditing(null);
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
form.setFieldsValue({ status: 'active' });
|
form.setFieldsValue({ status: 'active' });
|
||||||
|
formGuard.snapshot();
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -122,6 +126,7 @@ const AttendanceDevicesPage: React.FC = () => {
|
|||||||
location: record.location,
|
location: record.location,
|
||||||
notes: record.notes,
|
notes: record.notes,
|
||||||
});
|
});
|
||||||
|
formGuard.snapshot();
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -307,22 +312,43 @@ const AttendanceDevicesPage: React.FC = () => {
|
|||||||
添加考勤机
|
添加考勤机
|
||||||
</PermissionButton>
|
</PermissionButton>
|
||||||
</div>
|
</div>
|
||||||
<Table<AttendanceDeviceRow>
|
{isError ? (
|
||||||
rowKey="id"
|
<QueryErrorState
|
||||||
columns={columns}
|
title="考勤机数据加载失败"
|
||||||
dataSource={filteredData}
|
description="请检查网络后重试。"
|
||||||
loading={loading}
|
onRetry={() => void refetch()}
|
||||||
locale={{ emptyText: <Empty description="暂无考勤机绑定" /> }}
|
/>
|
||||||
pagination={{ defaultPageSize: 20, showSizeChanger: true }}
|
) : (
|
||||||
/>
|
<Table<AttendanceDeviceRow> scroll={{ x: 'max-content' }}
|
||||||
|
rowKey="id"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={filteredData}
|
||||||
|
loading={loading}
|
||||||
|
locale={{
|
||||||
|
emptyText: (
|
||||||
|
<QueryEmpty
|
||||||
|
description="暂无考勤机绑定"
|
||||||
|
action={
|
||||||
|
hasPermission('classroom:edit')
|
||||||
|
? { label: '添加考勤机', icon: <PlusOutlined />, onClick: openCreate }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
pagination={{ defaultPageSize: 20, showSizeChanger: true }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Modal
|
<Modal
|
||||||
title={editing ? '编辑考勤机绑定' : '添加考勤机绑定'}
|
title={editing ? '编辑考勤机绑定' : '添加考勤机绑定'}
|
||||||
open={modalOpen}
|
open={modalOpen}
|
||||||
onOk={handleSave}
|
onOk={handleSave}
|
||||||
onCancel={() => {
|
onCancel={() =>
|
||||||
setModalOpen(false);
|
formGuard.confirmClose(() => {
|
||||||
setEditing(null);
|
setModalOpen(false);
|
||||||
}}
|
setEditing(null);
|
||||||
|
})
|
||||||
|
}
|
||||||
confirmLoading={saving}
|
confirmLoading={saving}
|
||||||
okText="保存"
|
okText="保存"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
App,
|
App,
|
||||||
Table,
|
Table,
|
||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
Select,
|
Select,
|
||||||
Spin,
|
Spin,
|
||||||
Empty,
|
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
FileTextOutlined,
|
FileTextOutlined,
|
||||||
@@ -24,8 +23,12 @@ import {
|
|||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import PermissionButton from '../../components/PermissionButton';
|
import PermissionButton from '../../components/PermissionButton';
|
||||||
import { downloadBlob } from '../../utils/download';
|
import { QueryErrorState, QueryEmpty } from '../../components/QueryState';
|
||||||
|
import { NextStepHint } from '../../components/NextStepHint';
|
||||||
|
import { useVisibleRefetch } from '../../hooks/usePageVisible';
|
||||||
|
import { useDownload } from '../../hooks/useDownload';
|
||||||
import { message } from '../../ui/app-message';
|
import { message } from '../../ui/app-message';
|
||||||
|
import { RefreshButton } from '../../components/RefreshButton';
|
||||||
import { buildBillPrintHtml, type BillPrintData } from './bill-print';
|
import { buildBillPrintHtml, type BillPrintData } from './bill-print';
|
||||||
import { newOperationId } from '../../utils/operation-id';
|
import { newOperationId } from '../../utils/operation-id';
|
||||||
import { usePermission } from '../../hooks/usePermission';
|
import { usePermission } from '../../hooks/usePermission';
|
||||||
@@ -64,26 +67,27 @@ const BillsPage: React.FC = () => {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [detailLoading, setDetailLoading] = useState(false);
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
const [batchLoading, setBatchLoading] = useState(false);
|
const [batchLoading, setBatchLoading] = useState(false);
|
||||||
|
// 生成账单成功后的「下一步」引导提示
|
||||||
|
const [billGeneratedHint, setBillGeneratedHint] = useState(false);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: bills = [],
|
data: bills = [],
|
||||||
isLoading,
|
isLoading,
|
||||||
isFetching,
|
isFetching,
|
||||||
|
isError,
|
||||||
|
refetch,
|
||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: ['bills', filterStatus, filterExpenseType],
|
queryKey: ['bills', filterStatus, filterExpenseType],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
try {
|
const params: Record<string, string | undefined> = {};
|
||||||
const params: Record<string, string | undefined> = {};
|
if (filterStatus) params.status = filterStatus;
|
||||||
if (filterStatus) params.status = filterStatus;
|
if (filterExpenseType) params.expenseType = filterExpenseType;
|
||||||
if (filterExpenseType) params.expenseType = filterExpenseType;
|
return validateResponse<unknown[]>(billsSchema, await api.get('/bills', { params }));
|
||||||
return validateResponse<unknown[]>(billsSchema, await api.get('/bills', { params }));
|
|
||||||
} catch (e: any) {
|
|
||||||
message.error(e?.message || '加载失败,请稍后重试');
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const loading = isLoading || isFetching;
|
const loading = isLoading || isFetching;
|
||||||
|
// RouteKeeper 保活页面切回时刷新账单列表
|
||||||
|
useVisibleRefetch(['bills']);
|
||||||
|
|
||||||
const generateMutation = useApiMutation(
|
const generateMutation = useApiMutation(
|
||||||
async (payload: { operationId: string; billingMonth: string }) =>
|
async (payload: { operationId: string; billingMonth: string }) =>
|
||||||
@@ -122,8 +126,8 @@ const BillsPage: React.FC = () => {
|
|||||||
}, [bills, searchText, filterStatus]);
|
}, [bills, searchText, filterStatus]);
|
||||||
|
|
||||||
const handleGenerate = async () => {
|
const handleGenerate = async () => {
|
||||||
setSaving(true);
|
|
||||||
const values = await generateForm.validateFields();
|
const values = await generateForm.validateFields();
|
||||||
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const res: any = await generateMutation.mutateAsync({
|
const res: any = await generateMutation.mutateAsync({
|
||||||
operationId: newOperationId(),
|
operationId: newOperationId(),
|
||||||
@@ -132,6 +136,7 @@ const BillsPage: React.FC = () => {
|
|||||||
message.success(res.message || '生成成功');
|
message.success(res.message || '生成成功');
|
||||||
setGenerateModal(false);
|
setGenerateModal(false);
|
||||||
generateForm.resetFields();
|
generateForm.resetFields();
|
||||||
|
setBillGeneratedHint(true);
|
||||||
} catch {
|
} catch {
|
||||||
// 错误提示由 useApiMutation 统一处理
|
// 错误提示由 useApiMutation 统一处理
|
||||||
} finally {
|
} finally {
|
||||||
@@ -139,7 +144,12 @@ const BillsPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const showDetail = async (id: number) => {
|
const openGenerateModal = () => {
|
||||||
|
generateForm.resetFields();
|
||||||
|
setGenerateModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const showDetail = useCallback(async (id: number) => {
|
||||||
setDetailLoading(true);
|
setDetailLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await api.get(`/bills/${id}`);
|
const res = await api.get(`/bills/${id}`);
|
||||||
@@ -149,60 +159,69 @@ const BillsPage: React.FC = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
setDetailLoading(false);
|
setDetailLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleCancel = async (id: number) => {
|
const handleCancel = useCallback(
|
||||||
let reason = '';
|
(id: number) => {
|
||||||
modal.confirm({
|
let reason = '';
|
||||||
title: '取消账单并退回已扣余额',
|
modal.confirm({
|
||||||
content: (
|
title: '取消账单并退回已扣余额',
|
||||||
<Input.TextArea
|
content: (
|
||||||
placeholder="请输入取消原因"
|
<Input.TextArea
|
||||||
maxLength={300}
|
placeholder="请输入取消原因"
|
||||||
onChange={(event) => {
|
maxLength={300}
|
||||||
reason = event.target.value;
|
onChange={(event) => {
|
||||||
}}
|
reason = event.target.value;
|
||||||
/>
|
}}
|
||||||
),
|
/>
|
||||||
okText: '确认取消',
|
),
|
||||||
cancelText: '返回',
|
okText: '确认取消',
|
||||||
onOk: async () => {
|
cancelText: '返回',
|
||||||
if (!reason.trim()) {
|
onOk: async () => {
|
||||||
message.error('请输入取消原因');
|
if (!reason.trim()) {
|
||||||
throw new Error('reason required');
|
message.error('请输入取消原因');
|
||||||
}
|
throw new Error('reason required');
|
||||||
await cancelMutation.mutateAsync({ id, reason: reason.trim() });
|
}
|
||||||
message.success('账单已取消,已扣余额已冲正退回');
|
await cancelMutation.mutateAsync({ id, reason: reason.trim() });
|
||||||
},
|
message.success('账单已取消,已扣余额已冲正退回');
|
||||||
});
|
},
|
||||||
};
|
});
|
||||||
|
},
|
||||||
|
[modal, cancelMutation],
|
||||||
|
);
|
||||||
|
|
||||||
const handleArchive = async (id: number) => {
|
const handleArchive = useCallback(
|
||||||
try {
|
async (id: number) => {
|
||||||
await archiveMutation.mutateAsync(id);
|
try {
|
||||||
message.success('账单已归档');
|
await archiveMutation.mutateAsync(id);
|
||||||
} catch {
|
message.success('账单已归档');
|
||||||
// 错误提示由 useApiMutation 统一处理
|
} catch {
|
||||||
}
|
// 错误提示由 useApiMutation 统一处理
|
||||||
};
|
}
|
||||||
|
},
|
||||||
|
[archiveMutation],
|
||||||
|
);
|
||||||
|
|
||||||
const handlePurge = (id: number, studentName: string, period: string) => {
|
const handlePurge = useCallback(
|
||||||
modal.confirm({
|
(id: number, studentName: string, period: string) => {
|
||||||
title: `永久删除账单(${studentName} ${period})?`,
|
modal.confirm({
|
||||||
content: '删除后不可恢复,该账单及其明细将被物理删除。确定继续?',
|
title: `永久删除账单(${studentName} ${period})?`,
|
||||||
okText: '永久删除',
|
content: '删除后不可恢复,该账单及其明细将被物理删除。确定继续?',
|
||||||
okButtonProps: { danger: true },
|
okText: '永久删除',
|
||||||
cancelText: '取消',
|
okButtonProps: { danger: true },
|
||||||
onOk: async () => {
|
cancelText: '取消',
|
||||||
try {
|
onOk: async () => {
|
||||||
await purgeMutation.mutateAsync(id);
|
try {
|
||||||
message.success('已永久删除(不可恢复)');
|
await purgeMutation.mutateAsync(id);
|
||||||
} catch {
|
message.success('已永久删除(不可恢复)');
|
||||||
// 错误提示由 useApiMutation 统一处理
|
} catch {
|
||||||
}
|
// 错误提示由 useApiMutation 统一处理
|
||||||
},
|
}
|
||||||
});
|
},
|
||||||
};
|
});
|
||||||
|
},
|
||||||
|
[modal, purgeMutation],
|
||||||
|
);
|
||||||
|
|
||||||
const batchArchive = async () => {
|
const batchArchive = async () => {
|
||||||
if (selectedRows.length === 0) return message.warning('请先选择账单');
|
if (selectedRows.length === 0) return message.warning('请先选择账单');
|
||||||
@@ -219,14 +238,16 @@ const BillsPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const { downloading: exportExcelDownloading, run: runExportExcel } = useDownload();
|
||||||
|
|
||||||
const handleExportExcel = () => {
|
const handleExportExcel = () => {
|
||||||
downloadBlob('/bills/export/excel', `账单导出_${dayjs().format('YYYYMMDD_HHmmss')}.xlsx`).then(
|
void runExportExcel(`/bills/export/excel`, `账单导出_${dayjs().format('YYYYMMDD_HHmmss')}.xlsx`, {
|
||||||
() => message.success('Excel 导出成功'),
|
successMsg: 'Excel 导出成功',
|
||||||
() => message.error('导出失败'),
|
errorMsg: '导出失败',
|
||||||
);
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleExportPdf = async (billId: number) => {
|
const handleExportPdf = useCallback(async (billId: number) => {
|
||||||
const printWindow = window.open('', '_blank');
|
const printWindow = window.open('', '_blank');
|
||||||
if (!printWindow) {
|
if (!printWindow) {
|
||||||
message.error('浏览器阻止了打印窗口,请允许弹出窗口后重试');
|
message.error('浏览器阻止了打印窗口,请允许弹出窗口后重试');
|
||||||
@@ -245,7 +266,7 @@ const BillsPage: React.FC = () => {
|
|||||||
printWindow.close();
|
printWindow.close();
|
||||||
message.error(error?.message || '账单加载失败');
|
message.error(error?.message || '账单加载失败');
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const columns = useMemo(
|
const columns = useMemo(
|
||||||
() => [
|
() => [
|
||||||
@@ -314,6 +335,7 @@ const BillsPage: React.FC = () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
|
fixed: 'right' as const,
|
||||||
width: 320,
|
width: 320,
|
||||||
render: (_: any, record: any) => (
|
render: (_: any, record: any) => (
|
||||||
<Space>
|
<Space>
|
||||||
@@ -441,39 +463,72 @@ const BillsPage: React.FC = () => {
|
|||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
</Space>
|
</Space>
|
||||||
<Space wrap className="responsive-toolbar__group">
|
<Space wrap className="responsive-toolbar__group">
|
||||||
|
<RefreshButton loading={isFetching} onRefresh={() => void refetch()} />
|
||||||
<PermissionButton
|
<PermissionButton
|
||||||
permission="bill:generate"
|
permission="bill:generate"
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<FileTextOutlined />}
|
icon={<FileTextOutlined />}
|
||||||
onClick={() => {
|
onClick={openGenerateModal}
|
||||||
generateForm.resetFields();
|
|
||||||
setGenerateModal(true);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
生成账单
|
生成账单
|
||||||
</PermissionButton>
|
</PermissionButton>
|
||||||
<PermissionButton
|
<PermissionButton
|
||||||
permission="bill:export-excel"
|
permission="bill:export-excel"
|
||||||
icon={<DownloadOutlined />}
|
icon={<DownloadOutlined />}
|
||||||
|
loading={exportExcelDownloading}
|
||||||
onClick={handleExportExcel}
|
onClick={handleExportExcel}
|
||||||
>
|
>
|
||||||
导出Excel
|
导出Excel
|
||||||
</PermissionButton>
|
</PermissionButton>
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
<Table
|
{billGeneratedHint && (
|
||||||
scroll={{ x: 1400 }}
|
<NextStepHint
|
||||||
columns={columns}
|
title="账单已生成"
|
||||||
dataSource={filteredBills}
|
description="请核对账单明细,确认后标记已付,完成「住宿→计费」闭环。"
|
||||||
rowKey="id"
|
action={{
|
||||||
loading={loading}
|
label: '筛选待确认账单',
|
||||||
pagination={{ pageSize: 15, showTotal: (total) => `共 ${total} 条` }}
|
onClick: () => {
|
||||||
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
// 账单生成后即为 unpaid(待支付)状态
|
||||||
rowSelection={{
|
setFilterStatus('unpaid');
|
||||||
selectedRowKeys: selectedRows,
|
setBillGeneratedHint(false);
|
||||||
onChange: (keys) => setSelectedRows(keys as number[]),
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
onClose={() => setBillGeneratedHint(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isError ? (
|
||||||
|
<QueryErrorState
|
||||||
|
title="账单数据加载失败"
|
||||||
|
description="请检查网络后重试。"
|
||||||
|
onRetry={() => void refetch()}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Table
|
||||||
|
scroll={{ x: 1400 }}
|
||||||
|
columns={columns}
|
||||||
|
dataSource={filteredBills}
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
pagination={{ pageSize: 15, showTotal: (total) => `共 ${total} 条` }}
|
||||||
|
locale={{
|
||||||
|
emptyText: (
|
||||||
|
<QueryEmpty
|
||||||
|
description="暂无账单"
|
||||||
|
action={
|
||||||
|
hasPermission('bill:generate')
|
||||||
|
? { label: '生成账单', onClick: openGenerateModal }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
rowSelection={{
|
||||||
|
selectedRowKeys: selectedRows,
|
||||||
|
onChange: (keys) => setSelectedRows(keys as number[]),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
title="生成账单"
|
title="生成账单"
|
||||||
@@ -512,7 +567,7 @@ const BillsPage: React.FC = () => {
|
|||||||
>
|
>
|
||||||
{detailModal && (
|
{detailModal && (
|
||||||
<Spin spinning={detailLoading}>
|
<Spin spinning={detailLoading}>
|
||||||
<Descriptions bordered size="small" column={2} style={{ marginBottom: 16 }}>
|
<Descriptions bordered size="small" column={{ xs: 1, sm: 2 }} style={{ marginBottom: 16 }}>
|
||||||
<Descriptions.Item label="学生">{detailModal.student?.name}</Descriptions.Item>
|
<Descriptions.Item label="学生">{detailModal.student?.name}</Descriptions.Item>
|
||||||
<Descriptions.Item label="状态">
|
<Descriptions.Item label="状态">
|
||||||
<Tag color={statusMap[detailModal.status]?.color}>
|
<Tag color={statusMap[detailModal.status]?.color}>
|
||||||
@@ -537,7 +592,7 @@ const BillsPage: React.FC = () => {
|
|||||||
</strong>
|
</strong>
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
<Descriptions bordered size="small" column={3} style={{ marginBottom: 16 }}>
|
<Descriptions bordered size="small" column={{ xs: 1, sm: 2, lg: 3 }} style={{ marginBottom: 16 }}>
|
||||||
<Descriptions.Item label="已扣余额">
|
<Descriptions.Item label="已扣余额">
|
||||||
¥{Number(detailModal.paidAmount || 0).toFixed(2)}
|
¥{Number(detailModal.paidAmount || 0).toFixed(2)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
@@ -22,8 +22,11 @@ import { DownloadOutlined, PlusOutlined } from '@ant-design/icons';
|
|||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { useUserStore } from '../../store/user/userStore';
|
import { useUserStore } from '../../store/user/userStore';
|
||||||
import PermissionButton from '../../components/PermissionButton';
|
import PermissionButton from '../../components/PermissionButton';
|
||||||
|
import { QueryEmpty } from '../../components/QueryState';
|
||||||
|
import { useSubmitShortcut } from '../../hooks/useSubmitShortcut';
|
||||||
import { message } from '../../ui/app-message';
|
import { message } from '../../ui/app-message';
|
||||||
import { buildTeacherCandidateOptions, type TeacherCandidateUser } from './teacher-candidate';
|
import { buildTeacherCandidateOptions, type TeacherCandidateUser } from './teacher-candidate';
|
||||||
|
import { saveAs } from 'file-saver';
|
||||||
|
|
||||||
export interface ClassStudent {
|
export interface ClassStudent {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -204,7 +207,7 @@ export const ClassInfoTab: React.FC<{
|
|||||||
</Form>
|
</Form>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
<Descriptions column={3} bordered size="small">
|
<Descriptions column={{ xs: 1, sm: 2, lg: 3 }} bordered size="small">
|
||||||
<Descriptions.Item label="班型">{TYPE_MAP[detail.classType]}</Descriptions.Item>
|
<Descriptions.Item label="班型">{TYPE_MAP[detail.classType]}</Descriptions.Item>
|
||||||
<Descriptions.Item label="开班日期">
|
<Descriptions.Item label="开班日期">
|
||||||
{detail.startDate ? dayjs(detail.startDate).format('YYYY-MM-DD') : '-'}
|
{detail.startDate ? dayjs(detail.startDate).format('YYYY-MM-DD') : '-'}
|
||||||
@@ -246,6 +249,7 @@ export const ClassStudentsTab: React.FC<{
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onRemove: (studentId: number) => void;
|
onRemove: (studentId: number) => void;
|
||||||
onSelect: (ids: number[]) => void;
|
onSelect: (ids: number[]) => void;
|
||||||
|
adding?: boolean;
|
||||||
}> = ({
|
}> = ({
|
||||||
id,
|
id,
|
||||||
detail,
|
detail,
|
||||||
@@ -258,7 +262,9 @@ export const ClassStudentsTab: React.FC<{
|
|||||||
onClose,
|
onClose,
|
||||||
onRemove,
|
onRemove,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
adding,
|
||||||
}) => {
|
}) => {
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
const studentColumns: ColumnsType<ClassStudent> = [
|
const studentColumns: ColumnsType<ClassStudent> = [
|
||||||
{ title: '姓名', dataIndex: 'studentName' },
|
{ title: '姓名', dataIndex: 'studentName' },
|
||||||
{ title: '学号', dataIndex: 'studentNo' },
|
{ title: '学号', dataIndex: 'studentNo' },
|
||||||
@@ -297,25 +303,24 @@ export const ClassStudentsTab: React.FC<{
|
|||||||
<PermissionButton
|
<PermissionButton
|
||||||
permission="class:view"
|
permission="class:view"
|
||||||
icon={<DownloadOutlined />}
|
icon={<DownloadOutlined />}
|
||||||
onClick={() => {
|
loading={exporting}
|
||||||
const token = useUserStore.getState().token;
|
onClick={async () => {
|
||||||
fetch(`/api/classes/${id}/roster/export`, {
|
setExporting(true);
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
try {
|
||||||
})
|
const token = useUserStore.getState().token;
|
||||||
.then((res) => {
|
const res = await fetch(`/api/classes/${id}/roster/export`, {
|
||||||
if (!res.ok) throw new Error('导出失败');
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
return res.blob();
|
});
|
||||||
})
|
if (!res.ok) throw new Error('导出失败');
|
||||||
.then((blob) => {
|
const blob = await res.blob();
|
||||||
const url = URL.createObjectURL(blob);
|
saveAs(blob, `班级花名册-${detail?.name || id}.xlsx`);
|
||||||
const a = document.createElement('a');
|
message.success('花名册导出成功');
|
||||||
a.href = url;
|
} catch (error) {
|
||||||
a.download = `班级花名册-${detail?.name || id}.xlsx`;
|
console.error('花名册导出失败', error);
|
||||||
a.click();
|
message.error('花名册导出失败');
|
||||||
URL.revokeObjectURL(url);
|
} finally {
|
||||||
message.success('花名册导出成功');
|
setExporting(false);
|
||||||
})
|
}
|
||||||
.catch(() => message.error('花名册导出失败'));
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
导出花名册
|
导出花名册
|
||||||
@@ -324,13 +329,26 @@ export const ClassStudentsTab: React.FC<{
|
|||||||
columns={studentColumns}
|
columns={studentColumns}
|
||||||
dataSource={students}
|
dataSource={students}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
|
locale={{
|
||||||
|
emptyText: (
|
||||||
|
<QueryEmpty
|
||||||
|
description="班级还没有学员"
|
||||||
|
action={{
|
||||||
|
label: '添加学员',
|
||||||
|
type: 'primary',
|
||||||
|
icon: <PlusOutlined />,
|
||||||
|
onClick: onOpen,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
pagination={{
|
pagination={{
|
||||||
defaultPageSize: 20,
|
defaultPageSize: 20,
|
||||||
showSizeChanger: true,
|
showSizeChanger: true,
|
||||||
pageSizeOptions: [20, 50, 100],
|
pageSizeOptions: [20, 50, 100],
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Modal title="添加学员" open={modalOpen} onOk={onAdd} onCancel={onClose}>
|
<Modal title="添加学员" open={modalOpen} onOk={onAdd} onCancel={onClose} confirmLoading={adding}>
|
||||||
<Select
|
<Select
|
||||||
mode="multiple"
|
mode="multiple"
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
@@ -365,6 +383,7 @@ export const ClassTeachersTab: React.FC<{
|
|||||||
onSubjectChange: (subject: string) => void;
|
onSubjectChange: (subject: string) => void;
|
||||||
onUserChange: (userId?: number) => void;
|
onUserChange: (userId?: number) => void;
|
||||||
getTeacherName: (teacher: ClassTeacher) => string;
|
getTeacherName: (teacher: ClassTeacher) => string;
|
||||||
|
adding?: boolean;
|
||||||
}> = ({
|
}> = ({
|
||||||
teachers,
|
teachers,
|
||||||
allUsers,
|
allUsers,
|
||||||
@@ -380,7 +399,9 @@ export const ClassTeachersTab: React.FC<{
|
|||||||
onSubjectChange,
|
onSubjectChange,
|
||||||
onUserChange,
|
onUserChange,
|
||||||
getTeacherName,
|
getTeacherName,
|
||||||
|
adding,
|
||||||
}) => {
|
}) => {
|
||||||
|
useSubmitShortcut(modalOpen, onAdd);
|
||||||
const teacherColumns: ColumnsType<ClassTeacher> = [
|
const teacherColumns: ColumnsType<ClassTeacher> = [
|
||||||
{ title: '姓名', render: (_: unknown, teacher) => getTeacherName(teacher) },
|
{ title: '姓名', render: (_: unknown, teacher) => getTeacherName(teacher) },
|
||||||
{
|
{
|
||||||
@@ -425,7 +446,7 @@ export const ClassTeachersTab: React.FC<{
|
|||||||
pageSizeOptions: [20, 50, 100],
|
pageSizeOptions: [20, 50, 100],
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Modal title="添加教师" open={modalOpen} onOk={onAdd} onCancel={onClose}>
|
<Modal title="添加教师" open={modalOpen} onOk={onAdd} onCancel={onClose} confirmLoading={adding}>
|
||||||
<Space orientation="vertical" style={{ width: '100%' }}>
|
<Space orientation="vertical" style={{ width: '100%' }}>
|
||||||
<Select
|
<Select
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
@@ -531,22 +552,22 @@ export const ClassAttendanceTab: React.FC<{
|
|||||||
{attendanceSummary && (
|
{attendanceSummary && (
|
||||||
<Row gutter={[16, 16]}>
|
<Row gutter={[16, 16]}>
|
||||||
<Col xs={12} md={6}>
|
<Col xs={12} md={6}>
|
||||||
<Card bordered={false}>
|
<Card variant="borderless">
|
||||||
<Statistic title="总记录" value={attendanceSummary.total} />
|
<Statistic title="总记录" value={attendanceSummary.total} />
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} md={6}>
|
<Col xs={12} md={6}>
|
||||||
<Card bordered={false}>
|
<Card variant="borderless">
|
||||||
<Statistic title="出勤率" value={attendanceSummary.presentRate} suffix="%" />
|
<Statistic title="出勤率" value={attendanceSummary.presentRate} suffix="%" />
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} md={6}>
|
<Col xs={12} md={6}>
|
||||||
<Card bordered={false}>
|
<Card variant="borderless">
|
||||||
<Statistic title="缺勤率" value={attendanceSummary.absentRate} suffix="%" />
|
<Statistic title="缺勤率" value={attendanceSummary.absentRate} suffix="%" />
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} md={6}>
|
<Col xs={12} md={6}>
|
||||||
<Card bordered={false}>
|
<Card variant="borderless">
|
||||||
<Statistic title="迟到率" value={attendanceSummary.lateRate} suffix="%" />
|
<Statistic title="迟到率" value={attendanceSummary.lateRate} suffix="%" />
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useCallback } from 'react';
|
import React, { useState, useCallback } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router';
|
import { useParams, useNavigate } from 'react-router';
|
||||||
import { Button, Card, Form, Space, Tabs, Tag } from 'antd';
|
import {Button, Card, Form, Space, Tabs, Tag, Skeleton} from 'antd';
|
||||||
import { ArrowLeftOutlined } from '@ant-design/icons';
|
import { ArrowLeftOutlined } from '@ant-design/icons';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
@@ -8,6 +8,7 @@ import { message } from '../../ui/app-message';
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import type { TeacherCandidateUser } from './teacher-candidate';
|
import type { TeacherCandidateUser } from './teacher-candidate';
|
||||||
import { getErrorMessage } from '../../utils/error';
|
import { getErrorMessage } from '../../utils/error';
|
||||||
|
import { QueryErrorState } from '../../components/QueryState';
|
||||||
import {
|
import {
|
||||||
ClassAttendanceTab,
|
ClassAttendanceTab,
|
||||||
ClassInfoTab,
|
ClassInfoTab,
|
||||||
@@ -32,6 +33,8 @@ const ClassDetailPage: React.FC = () => {
|
|||||||
const [studentModalOpen, setStudentModalOpen] = useState(false);
|
const [studentModalOpen, setStudentModalOpen] = useState(false);
|
||||||
const [allStudents, setAllStudents] = useState<StudentItem[]>([]);
|
const [allStudents, setAllStudents] = useState<StudentItem[]>([]);
|
||||||
const [selectedStudentIds, setSelectedStudentIds] = useState<number[]>([]);
|
const [selectedStudentIds, setSelectedStudentIds] = useState<number[]>([]);
|
||||||
|
const [addingStudents, setAddingStudents] = useState(false);
|
||||||
|
const [addingTeacher, setAddingTeacher] = useState(false);
|
||||||
|
|
||||||
// Teacher modal state
|
// Teacher modal state
|
||||||
const [teacherModalOpen, setTeacherModalOpen] = useState(false);
|
const [teacherModalOpen, setTeacherModalOpen] = useState(false);
|
||||||
@@ -51,6 +54,7 @@ const ClassDetailPage: React.FC = () => {
|
|||||||
data: detailResult = { detail: null, students: [], teachers: [] },
|
data: detailResult = { detail: null, students: [], teachers: [] },
|
||||||
isLoading: detailLoading,
|
isLoading: detailLoading,
|
||||||
isFetching: detailFetching,
|
isFetching: detailFetching,
|
||||||
|
isError: detailError,
|
||||||
refetch: refetchDetail,
|
refetch: refetchDetail,
|
||||||
} = useQuery<{
|
} = useQuery<{
|
||||||
detail: ClassDetail | null;
|
detail: ClassDetail | null;
|
||||||
@@ -59,13 +63,8 @@ const ClassDetailPage: React.FC = () => {
|
|||||||
}>({
|
}>({
|
||||||
queryKey: ['classes', 'detail', id],
|
queryKey: ['classes', 'detail', id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
try {
|
const res = (await api.get(`/classes/${id}`)) as ClassDetail;
|
||||||
const res = (await api.get(`/classes/${id}`)) as ClassDetail;
|
return { detail: res, students: res.students || [], teachers: res.teachers || [] };
|
||||||
return { detail: res, students: res.students || [], teachers: res.teachers || [] };
|
|
||||||
} catch (e: unknown) {
|
|
||||||
message.error(getErrorMessage(e, '加载失败'));
|
|
||||||
return { detail: null, students: [], teachers: [] };
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const detail = detailResult.detail;
|
const detail = detailResult.detail;
|
||||||
@@ -74,52 +73,50 @@ const ClassDetailPage: React.FC = () => {
|
|||||||
const loading = detailLoading || detailFetching;
|
const loading = detailLoading || detailFetching;
|
||||||
const fetchDetail = useCallback(() => refetchDetail(), [refetchDetail]);
|
const fetchDetail = useCallback(() => refetchDetail(), [refetchDetail]);
|
||||||
|
|
||||||
const { data: allUsers = [], refetch: refetchUsers } = useQuery<TeacherCandidateUser[]>({
|
const {
|
||||||
|
data: allUsers = [],
|
||||||
|
isError: allUsersError,
|
||||||
|
refetch: refetchUsers,
|
||||||
|
} = useQuery<TeacherCandidateUser[]>({
|
||||||
queryKey: ['rbac', 'users', 'all'],
|
queryKey: ['rbac', 'users', 'all'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
try {
|
return (await api.get('/rbac/users')) as TeacherCandidateUser[];
|
||||||
return (await api.get('/rbac/users')) as TeacherCandidateUser[];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const fetchUsers = useCallback(() => refetchUsers(), [refetchUsers]);
|
const fetchUsers = useCallback(() => refetchUsers(), [refetchUsers]);
|
||||||
|
|
||||||
const { data: schedules = [] } = useQuery<ClassScheduleItem[]>({
|
const {
|
||||||
|
data: schedules = [],
|
||||||
|
isError: schedulesError,
|
||||||
|
refetch: refetchSchedules,
|
||||||
|
} = useQuery<ClassScheduleItem[]>({
|
||||||
queryKey: ['classes', 'schedule', id, scheduleDateRange],
|
queryKey: ['classes', 'schedule', id, scheduleDateRange],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!id) return [];
|
if (!id) return [];
|
||||||
try {
|
const params: Record<string, string> = {};
|
||||||
const params: Record<string, string> = {};
|
if (scheduleDateRange?.[0]) params.startDate = scheduleDateRange[0].format('YYYY-MM-DD');
|
||||||
if (scheduleDateRange?.[0]) params.startDate = scheduleDateRange[0].format('YYYY-MM-DD');
|
if (scheduleDateRange?.[1]) params.endDate = scheduleDateRange[1].format('YYYY-MM-DD');
|
||||||
if (scheduleDateRange?.[1]) params.endDate = scheduleDateRange[1].format('YYYY-MM-DD');
|
return (await api.get<ClassScheduleItem[]>(`/classes/${id}/schedule`, { params })) || [];
|
||||||
return (await api.get<ClassScheduleItem[]>(`/classes/${id}/schedule`, { params })) || [];
|
|
||||||
} catch (e: unknown) {
|
|
||||||
message.error(getErrorMessage(e, '加载课表失败'));
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: attendanceSummary = null } = useQuery<AttendanceSummary | null>({
|
const {
|
||||||
|
data: attendanceSummary = null,
|
||||||
|
isError: attendanceSummaryError,
|
||||||
|
refetch: refetchAttendanceSummary,
|
||||||
|
} = useQuery<AttendanceSummary | null>({
|
||||||
queryKey: ['classes', 'attendance-summary', id, attendanceDateRange],
|
queryKey: ['classes', 'attendance-summary', id, attendanceDateRange],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!id) return null;
|
if (!id) return null;
|
||||||
try {
|
const params: Record<string, string> = {};
|
||||||
const params: Record<string, string> = {};
|
if (attendanceDateRange?.[0])
|
||||||
if (attendanceDateRange?.[0])
|
params.startDate = attendanceDateRange[0].format('YYYY-MM-DD');
|
||||||
params.startDate = attendanceDateRange[0].format('YYYY-MM-DD');
|
if (attendanceDateRange?.[1]) params.endDate = attendanceDateRange[1].format('YYYY-MM-DD');
|
||||||
if (attendanceDateRange?.[1]) params.endDate = attendanceDateRange[1].format('YYYY-MM-DD');
|
return (
|
||||||
return (
|
(await api.get<AttendanceSummary>(`/classes/${id}/attendance-summary`, {
|
||||||
(await api.get<AttendanceSummary>(`/classes/${id}/attendance-summary`, {
|
params,
|
||||||
params,
|
})) || null
|
||||||
})) || null
|
);
|
||||||
);
|
|
||||||
} catch (e: unknown) {
|
|
||||||
message.error(getErrorMessage(e, '加载出勤汇总失败'));
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -156,6 +153,7 @@ const ClassDetailPage: React.FC = () => {
|
|||||||
|
|
||||||
const handleAddStudents = async () => {
|
const handleAddStudents = async () => {
|
||||||
if (!selectedStudentIds.length) return;
|
if (!selectedStudentIds.length) return;
|
||||||
|
setAddingStudents(true);
|
||||||
try {
|
try {
|
||||||
await api.post(`/classes/${id}/students`, { studentIds: selectedStudentIds });
|
await api.post(`/classes/${id}/students`, { studentIds: selectedStudentIds });
|
||||||
setStudentModalOpen(false);
|
setStudentModalOpen(false);
|
||||||
@@ -164,11 +162,14 @@ const ClassDetailPage: React.FC = () => {
|
|||||||
message.success('已添加');
|
message.success('已添加');
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
message.error(getErrorMessage(e, '添加失败'));
|
message.error(getErrorMessage(e, '添加失败'));
|
||||||
|
} finally {
|
||||||
|
setAddingStudents(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddTeacher = async () => {
|
const handleAddTeacher = async () => {
|
||||||
if (!teacherUserId) return;
|
if (!teacherUserId) return;
|
||||||
|
setAddingTeacher(true);
|
||||||
try {
|
try {
|
||||||
await api.post(`/classes/${id}/teachers`, {
|
await api.post(`/classes/${id}/teachers`, {
|
||||||
userId: teacherUserId,
|
userId: teacherUserId,
|
||||||
@@ -180,6 +181,8 @@ const ClassDetailPage: React.FC = () => {
|
|||||||
message.success('已添加');
|
message.success('已添加');
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
message.error(getErrorMessage(e, '添加失败'));
|
message.error(getErrorMessage(e, '添加失败'));
|
||||||
|
} finally {
|
||||||
|
setAddingTeacher(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -221,7 +224,25 @@ const ClassDetailPage: React.FC = () => {
|
|||||||
const getTeacherName = (teacher: ClassTeacher) =>
|
const getTeacherName = (teacher: ClassTeacher) =>
|
||||||
allUsers.find((user) => user.id === teacher.userId)?.name?.trim() || '-';
|
allUsers.find((user) => user.id === teacher.userId)?.name?.trim() || '-';
|
||||||
|
|
||||||
if (!detail) return null;
|
if (!detail) {
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 24 }}>
|
||||||
|
<Skeleton active paragraph={{ rows: 8 }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (detailError) {
|
||||||
|
return (
|
||||||
|
<QueryErrorState
|
||||||
|
title="班级详情加载失败"
|
||||||
|
description="请检查网络后重试。"
|
||||||
|
onRetry={() => void refetchDetail()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
@@ -283,13 +304,20 @@ const ClassDetailPage: React.FC = () => {
|
|||||||
onClose={() => setStudentModalOpen(false)}
|
onClose={() => setStudentModalOpen(false)}
|
||||||
onRemove={handleRemoveStudent}
|
onRemove={handleRemoveStudent}
|
||||||
onSelect={setSelectedStudentIds}
|
onSelect={setSelectedStudentIds}
|
||||||
|
adding={addingStudents}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'teachers',
|
key: 'teachers',
|
||||||
label: `教师 (${teachers.length})`,
|
label: `教师 (${teachers.length})`,
|
||||||
children: (
|
children: allUsersError ? (
|
||||||
|
<QueryErrorState
|
||||||
|
title="可添加教师加载失败"
|
||||||
|
description="请检查网络后重试。"
|
||||||
|
onRetry={() => void fetchUsers()}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<ClassTeachersTab
|
<ClassTeachersTab
|
||||||
teachers={teachers}
|
teachers={teachers}
|
||||||
allUsers={allUsers}
|
allUsers={allUsers}
|
||||||
@@ -305,13 +333,20 @@ const ClassDetailPage: React.FC = () => {
|
|||||||
onSubjectChange={setTeacherSubject}
|
onSubjectChange={setTeacherSubject}
|
||||||
onUserChange={setTeacherUserId}
|
onUserChange={setTeacherUserId}
|
||||||
getTeacherName={getTeacherName}
|
getTeacherName={getTeacherName}
|
||||||
|
adding={addingTeacher}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'schedule',
|
key: 'schedule',
|
||||||
label: '课表',
|
label: '课表',
|
||||||
children: (
|
children: schedulesError ? (
|
||||||
|
<QueryErrorState
|
||||||
|
title="课表加载失败"
|
||||||
|
description="请检查网络后重试。"
|
||||||
|
onRetry={() => void refetchSchedules()}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<ClassScheduleTab
|
<ClassScheduleTab
|
||||||
schedules={schedules}
|
schedules={schedules}
|
||||||
scheduleDateRange={scheduleDateRange}
|
scheduleDateRange={scheduleDateRange}
|
||||||
@@ -322,7 +357,13 @@ const ClassDetailPage: React.FC = () => {
|
|||||||
{
|
{
|
||||||
key: 'attendance-summary',
|
key: 'attendance-summary',
|
||||||
label: '出勤汇总',
|
label: '出勤汇总',
|
||||||
children: (
|
children: attendanceSummaryError ? (
|
||||||
|
<QueryErrorState
|
||||||
|
title="出勤汇总加载失败"
|
||||||
|
description="请检查网络后重试。"
|
||||||
|
onRetry={() => void refetchAttendanceSummary()}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<ClassAttendanceTab
|
<ClassAttendanceTab
|
||||||
attendanceSummary={attendanceSummary}
|
attendanceSummary={attendanceSummary}
|
||||||
attendanceDateRange={attendanceDateRange}
|
attendanceDateRange={attendanceDateRange}
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
Popconfirm,
|
Popconfirm,
|
||||||
Card,
|
Card,
|
||||||
Switch,
|
Switch,
|
||||||
Empty,
|
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { PlusOutlined, SearchOutlined, TeamOutlined, InboxOutlined } from '@ant-design/icons';
|
import { PlusOutlined, SearchOutlined, TeamOutlined, InboxOutlined } from '@ant-design/icons';
|
||||||
@@ -30,6 +29,9 @@ import PermissionButton from '../../components/PermissionButton';
|
|||||||
import EditableCell from '../../components/EditableCell';
|
import EditableCell from '../../components/EditableCell';
|
||||||
import { message } from '../../ui/app-message';
|
import { message } from '../../ui/app-message';
|
||||||
import { usePermission } from '../../hooks/usePermission';
|
import { usePermission } from '../../hooks/usePermission';
|
||||||
|
import { QueryErrorState, QueryEmpty } from '../../components/QueryState';
|
||||||
|
import { useVisibleRefetch } from '../../hooks/usePageVisible';
|
||||||
|
import { useDirtyGuard } from '../../hooks/useDirtyGuard';
|
||||||
|
|
||||||
interface ClassItem {
|
interface ClassItem {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -86,59 +88,32 @@ const ClassesPage: React.FC = () => {
|
|||||||
const [filterStatus, setFilterStatus] = useState<string>();
|
const [filterStatus, setFilterStatus] = useState<string>();
|
||||||
const [filterType, setFilterType] = useState<string>();
|
const [filterType, setFilterType] = useState<string>();
|
||||||
const [form] = Form.useForm<ClassFormValues>();
|
const [form] = Form.useForm<ClassFormValues>();
|
||||||
|
const classFormGuard = useDirtyGuard(form);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [showArchived, setShowArchived] = useState(false);
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
|
|
||||||
const handleArchive = async (id: number, archive: boolean) => {
|
|
||||||
try {
|
|
||||||
await archiveMutation.mutateAsync({ id, archive });
|
|
||||||
message.success(archive ? '已归档' : '已恢复');
|
|
||||||
} catch {
|
|
||||||
// 错误提示由 useApiMutation 统一处理
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePurge = (record: ClassItem) => {
|
|
||||||
modal.confirm({
|
|
||||||
title: `永久删除班级「${record.name}」?`,
|
|
||||||
content: '删除后不可恢复,存在学生、教师、排课、考试或考勤关联时将无法删除。确定继续?',
|
|
||||||
okText: '永久删除',
|
|
||||||
okButtonProps: { danger: true },
|
|
||||||
cancelText: '取消',
|
|
||||||
onOk: async () => {
|
|
||||||
try {
|
|
||||||
await purgeMutation.mutateAsync(record.id);
|
|
||||||
message.success('已永久删除(不可恢复)');
|
|
||||||
} catch {
|
|
||||||
// 错误提示由 useApiMutation 统一处理
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data = [],
|
data = [],
|
||||||
isLoading,
|
isLoading,
|
||||||
isFetching,
|
isFetching,
|
||||||
|
isError,
|
||||||
|
refetch,
|
||||||
} = useQuery<ClassItem[]>({
|
} = useQuery<ClassItem[]>({
|
||||||
queryKey: ['classes', filterStatus, filterType, showArchived],
|
queryKey: ['classes', filterStatus, filterType, showArchived],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
try {
|
const params: Record<string, string | boolean | undefined> = {};
|
||||||
const params: Record<string, string | boolean | undefined> = {};
|
if (filterStatus) params.status = filterStatus;
|
||||||
if (filterStatus) params.status = filterStatus;
|
if (filterType) params.classType = filterType;
|
||||||
if (filterType) params.classType = filterType;
|
params.isArchived = showArchived;
|
||||||
params.isArchived = showArchived;
|
return validateResponse<ClassItem[]>(
|
||||||
return validateResponse<ClassItem[]>(
|
classesSchema,
|
||||||
classesSchema,
|
await api.get<ClassItem[]>('/classes', { params } as Record<string, unknown>),
|
||||||
await api.get<ClassItem[]>('/classes', { params } as Record<string, unknown>),
|
);
|
||||||
);
|
|
||||||
} catch (e: any) {
|
|
||||||
message.error(e?.message || '加载失败,请稍后重试');
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const loading = isLoading || isFetching;
|
const loading = isLoading || isFetching;
|
||||||
|
// RouteKeeper 保活页面切回时刷新列表,避免看到陈旧数据
|
||||||
|
useVisibleRefetch(['classes']);
|
||||||
|
|
||||||
const saveMutation = useApiMutation(
|
const saveMutation = useApiMutation(
|
||||||
async (payload: Record<string, unknown>) =>
|
async (payload: Record<string, unknown>) =>
|
||||||
@@ -160,6 +135,39 @@ const ClassesPage: React.FC = () => {
|
|||||||
{ invalidate: [['classes']] },
|
{ invalidate: [['classes']] },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleArchive = useCallback(
|
||||||
|
async (id: number, archive: boolean) => {
|
||||||
|
try {
|
||||||
|
await archiveMutation.mutateAsync({ id, archive });
|
||||||
|
message.success(archive ? '已归档' : '已恢复');
|
||||||
|
} catch {
|
||||||
|
// 错误提示由 useApiMutation 统一处理
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[archiveMutation],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePurge = useCallback(
|
||||||
|
(record: ClassItem) => {
|
||||||
|
modal.confirm({
|
||||||
|
title: `永久删除班级「${record.name}」?`,
|
||||||
|
content: '删除后不可恢复,存在学生、教师、排课、考试或考勤关联时将无法删除。确定继续?',
|
||||||
|
okText: '永久删除',
|
||||||
|
okButtonProps: { danger: true },
|
||||||
|
cancelText: '取消',
|
||||||
|
onOk: async () => {
|
||||||
|
try {
|
||||||
|
await purgeMutation.mutateAsync(record.id);
|
||||||
|
message.success('已永久删除(不可恢复)');
|
||||||
|
} catch {
|
||||||
|
// 错误提示由 useApiMutation 统一处理
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[modal, purgeMutation],
|
||||||
|
);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
if (!searchText) return data;
|
if (!searchText) return data;
|
||||||
const q = searchText.toLowerCase();
|
const q = searchText.toLowerCase();
|
||||||
@@ -171,19 +179,24 @@ const ClassesPage: React.FC = () => {
|
|||||||
const handleCreate = () => {
|
const handleCreate = () => {
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
|
classFormGuard.snapshot();
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEdit = (record: ClassItem) => {
|
const handleEdit = useCallback(
|
||||||
setEditing(record);
|
(record: ClassItem) => {
|
||||||
form.setFieldsValue({
|
setEditing(record);
|
||||||
...record,
|
form.setFieldsValue({
|
||||||
notes: record.notes ?? undefined,
|
...record,
|
||||||
startDate: record.startDate ? dayjs(record.startDate) : undefined,
|
notes: record.notes ?? undefined,
|
||||||
endDate: record.endDate ? dayjs(record.endDate) : undefined,
|
startDate: record.startDate ? dayjs(record.startDate) : undefined,
|
||||||
});
|
endDate: record.endDate ? dayjs(record.endDate) : undefined,
|
||||||
setModalOpen(true);
|
});
|
||||||
};
|
classFormGuard.snapshot();
|
||||||
|
setModalOpen(true);
|
||||||
|
},
|
||||||
|
[form, classFormGuard],
|
||||||
|
);
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
@@ -369,7 +382,7 @@ const ClassesPage: React.FC = () => {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[saveCell, canPurgeClass, handlePurge],
|
[saveCell, canPurgeClass, handlePurge, navigate, handleEdit, handleArchive],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -421,25 +434,44 @@ const ClassesPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
</Space>
|
</Space>
|
||||||
<Table<ClassItem>
|
{isError ? (
|
||||||
columns={columns}
|
<QueryErrorState
|
||||||
dataSource={filtered}
|
title="班级数据加载失败"
|
||||||
rowKey="id"
|
description="请检查网络后重试。"
|
||||||
loading={loading}
|
onRetry={() => void refetch()}
|
||||||
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
/>
|
||||||
pagination={{
|
) : (
|
||||||
defaultPageSize: 20,
|
<Table<ClassItem>
|
||||||
showSizeChanger: true,
|
columns={columns}
|
||||||
pageSizeOptions: [20, 50, 100],
|
dataSource={filtered}
|
||||||
}}
|
rowKey="id"
|
||||||
scroll={{ x: 1100 }}
|
loading={loading}
|
||||||
/>
|
locale={{
|
||||||
|
emptyText: (
|
||||||
|
<QueryEmpty
|
||||||
|
description="暂无班级数据"
|
||||||
|
action={
|
||||||
|
hasPermission('class:create')
|
||||||
|
? { label: '创建班级', icon: <PlusOutlined />, onClick: handleCreate }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
pagination={{
|
||||||
|
defaultPageSize: 20,
|
||||||
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: [20, 50, 100],
|
||||||
|
}}
|
||||||
|
scroll={{ x: 1100 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
title={editing ? '编辑班级' : '创建班级'}
|
title={editing ? '编辑班级' : '创建班级'}
|
||||||
open={modalOpen}
|
open={modalOpen}
|
||||||
onOk={handleSubmit}
|
onOk={handleSubmit}
|
||||||
onCancel={() => setModalOpen(false)}
|
onCancel={() => classFormGuard.confirmClose(() => setModalOpen(false))}
|
||||||
confirmLoading={saving}
|
confirmLoading={saving}
|
||||||
width={600}
|
width={600}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Empty,
|
|
||||||
Popconfirm,
|
Popconfirm,
|
||||||
Space,
|
Space,
|
||||||
Table,
|
Table,
|
||||||
@@ -19,6 +18,7 @@ import dayjs from 'dayjs';
|
|||||||
import PermissionButton from '../../components/PermissionButton';
|
import PermissionButton from '../../components/PermissionButton';
|
||||||
import EditableCell from '../../components/EditableCell';
|
import EditableCell from '../../components/EditableCell';
|
||||||
import { message } from '../../ui/app-message';
|
import { message } from '../../ui/app-message';
|
||||||
|
import { QueryEmpty } from '../../components/QueryState';
|
||||||
|
|
||||||
const RENTAL_FIELDS = {
|
const RENTAL_FIELDS = {
|
||||||
classroomId: 'classroomId',
|
classroomId: 'classroomId',
|
||||||
@@ -43,7 +43,11 @@ export interface RentalTableProps {
|
|||||||
onPurge: (id: number, name: string) => void;
|
onPurge: (id: number, name: string) => void;
|
||||||
onDownloadContract: (id: number, filename?: string) => void;
|
onDownloadContract: (id: number, filename?: string) => void;
|
||||||
onDeleteContract: (id: number) => void;
|
onDeleteContract: (id: number) => void;
|
||||||
onUploadContract: (id: number, formData: FormData) => Promise<unknown>;
|
onUploadContract: (
|
||||||
|
id: number,
|
||||||
|
formData: FormData,
|
||||||
|
onProgress?: (percent: number) => void,
|
||||||
|
) => Promise<unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RentalTable: React.FC<RentalTableProps> = ({
|
export const RentalTable: React.FC<RentalTableProps> = ({
|
||||||
@@ -62,6 +66,9 @@ export const RentalTable: React.FC<RentalTableProps> = ({
|
|||||||
onDeleteContract,
|
onDeleteContract,
|
||||||
onUploadContract,
|
onUploadContract,
|
||||||
}) => {
|
}) => {
|
||||||
|
const [uploadingContractId, setUploadingContractId] = useState<number | null>(null);
|
||||||
|
const [contractPercent, setContractPercent] = useState(0);
|
||||||
|
|
||||||
const EditableRentalCell = <R extends { id: number; effectiveStatus?: string }>({
|
const EditableRentalCell = <R extends { id: number; effectiveStatus?: string }>({
|
||||||
value,
|
value,
|
||||||
field,
|
field,
|
||||||
@@ -250,17 +257,28 @@ export const RentalTable: React.FC<RentalTableProps> = ({
|
|||||||
}
|
}
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
|
setUploadingContractId(r.id);
|
||||||
|
setContractPercent(0);
|
||||||
try {
|
try {
|
||||||
await onUploadContract(r.id, formData);
|
await onUploadContract(r.id, formData, (percent) => setContractPercent(percent));
|
||||||
message.success('合同已上传');
|
message.success('合同已上传');
|
||||||
onSuccess?.({});
|
onSuccess?.({});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
onError?.(e as Error);
|
onError?.(e as Error);
|
||||||
|
} finally {
|
||||||
|
setUploadingContractId(null);
|
||||||
|
setContractPercent(0);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Button size="small" icon={<UploadOutlined />}>
|
<Button
|
||||||
上传PDF
|
size="small"
|
||||||
|
icon={<UploadOutlined />}
|
||||||
|
loading={uploadingContractId === r.id}
|
||||||
|
>
|
||||||
|
{uploadingContractId === r.id && contractPercent > 0 && contractPercent < 100
|
||||||
|
? `上传中 ${contractPercent}%`
|
||||||
|
: '上传PDF'}
|
||||||
</Button>
|
</Button>
|
||||||
</Upload>
|
</Upload>
|
||||||
) : (
|
) : (
|
||||||
@@ -327,7 +345,7 @@ export const RentalTable: React.FC<RentalTableProps> = ({
|
|||||||
dataSource={data}
|
dataSource={data}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
locale={{ emptyText: <QueryEmpty description="暂无租赁订单,点击右上角「新增租赁」创建第一笔订单" /> }}
|
||||||
pagination={{
|
pagination={{
|
||||||
defaultPageSize: 15,
|
defaultPageSize: 15,
|
||||||
showSizeChanger: true,
|
showSizeChanger: true,
|
||||||
|
|||||||
@@ -15,8 +15,11 @@ import dayjs, { Dayjs } from 'dayjs';
|
|||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import { downloadBlob } from '../../utils/download';
|
import { downloadBlob } from '../../utils/download';
|
||||||
import PermissionButton from '../../components/PermissionButton';
|
import PermissionButton from '../../components/PermissionButton';
|
||||||
|
import { QueryErrorState } from '../../components/QueryState';
|
||||||
import { message } from '../../ui/app-message';
|
import { message } from '../../ui/app-message';
|
||||||
|
import { useSubmitShortcut } from '../../hooks/useSubmitShortcut';
|
||||||
import { usePermission } from '../../hooks/usePermission';
|
import { usePermission } from '../../hooks/usePermission';
|
||||||
|
import { useVisibleRefetch } from '../../hooks/usePageVisible';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useApiMutation } from '../../hooks/useApiMutation';
|
import { useApiMutation } from '../../hooks/useApiMutation';
|
||||||
import { getErrorMessage } from '../../utils/error';
|
import { getErrorMessage } from '../../utils/error';
|
||||||
@@ -42,6 +45,7 @@ const ClassroomRentalsPage: React.FC = () => {
|
|||||||
const [filterStatus, setFilterStatus] = useState<string | undefined>();
|
const [filterStatus, setFilterStatus] = useState<string | undefined>();
|
||||||
const [searchText, setSearchText] = useState('');
|
const [searchText, setSearchText] = useState('');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
useSubmitShortcut(modalOpen && !saving, () => handleSave());
|
||||||
const [unavailableDates, setUnavailableDates] = useImmer<Set<string>>(new Set());
|
const [unavailableDates, setUnavailableDates] = useImmer<Set<string>>(new Set());
|
||||||
const loadedUnavailableMonths = useRef<Set<string>>(new Set());
|
const loadedUnavailableMonths = useRef<Set<string>>(new Set());
|
||||||
const unavailableRequestVersion = useRef(0);
|
const unavailableRequestVersion = useRef(0);
|
||||||
@@ -52,21 +56,18 @@ const ClassroomRentalsPage: React.FC = () => {
|
|||||||
data = [],
|
data = [],
|
||||||
isLoading,
|
isLoading,
|
||||||
isFetching,
|
isFetching,
|
||||||
|
isError,
|
||||||
|
refetch,
|
||||||
} = useQuery<any[]>({
|
} = useQuery<any[]>({
|
||||||
queryKey: ['classroom-rentals', filterMonth],
|
queryKey: ['classroom-rentals', filterMonth],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
try {
|
const params: any = {};
|
||||||
const params: any = {};
|
if (filterMonth) params.month = filterMonth.format('YYYY-MM');
|
||||||
if (filterMonth) params.month = filterMonth.format('YYYY-MM');
|
params.includeEnded = true;
|
||||||
params.includeEnded = true;
|
return validateResponse<any[]>(
|
||||||
return validateResponse<any[]>(
|
rentalsSchema,
|
||||||
rentalsSchema,
|
await api.get('/classroom-rentals', { params }),
|
||||||
await api.get('/classroom-rentals', { params }),
|
);
|
||||||
);
|
|
||||||
} catch (e: any) {
|
|
||||||
message.error(e?.message || '加载失败,请稍后重试');
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const {
|
const {
|
||||||
@@ -93,6 +94,8 @@ const ClassroomRentalsPage: React.FC = () => {
|
|||||||
const classrooms = meta.classrooms;
|
const classrooms = meta.classrooms;
|
||||||
const organizations = meta.organizations;
|
const organizations = meta.organizations;
|
||||||
const loading = isLoading || isFetching;
|
const loading = isLoading || isFetching;
|
||||||
|
// RouteKeeper 保活页面切回时刷新列表,避免看到陈旧数据
|
||||||
|
useVisibleRefetch(['classroom-rentals']);
|
||||||
|
|
||||||
const saveMutation = useApiMutation(
|
const saveMutation = useApiMutation(
|
||||||
async (payload: Record<string, unknown>) =>
|
async (payload: Record<string, unknown>) =>
|
||||||
@@ -139,9 +142,20 @@ const ClassroomRentalsPage: React.FC = () => {
|
|||||||
{ invalidate: [['classroom-rentals']] },
|
{ invalidate: [['classroom-rentals']] },
|
||||||
);
|
);
|
||||||
const uploadContractMutation = useApiMutation(
|
const uploadContractMutation = useApiMutation(
|
||||||
async ({ id, formData }: { id: number; formData: FormData }) =>
|
async ({
|
||||||
|
id,
|
||||||
|
formData,
|
||||||
|
onProgress,
|
||||||
|
}: {
|
||||||
|
id: number;
|
||||||
|
formData: FormData;
|
||||||
|
onProgress?: (percent: number) => void;
|
||||||
|
}) =>
|
||||||
api.post(`/classroom-rentals/${id}/contract`, formData, {
|
api.post(`/classroom-rentals/${id}/contract`, formData, {
|
||||||
headers: { 'Content-Type': 'multipart/form-data' },
|
onUploadProgress: (event) => {
|
||||||
|
if (!onProgress || !event.total) return;
|
||||||
|
onProgress(Math.min(Math.round((event.loaded / event.total) * 100), 100));
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
{ invalidate: [['classroom-rentals']] },
|
{ invalidate: [['classroom-rentals']] },
|
||||||
);
|
);
|
||||||
@@ -198,7 +212,7 @@ const ClassroomRentalsPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[],
|
[setUnavailableDates],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleClassroomChange = (classroomId: number) => {
|
const handleClassroomChange = (classroomId: number) => {
|
||||||
@@ -320,8 +334,12 @@ const ClassroomRentalsPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUploadContract = async (id: number, formData: FormData) => {
|
const handleUploadContract = async (
|
||||||
return uploadContractMutation.mutateAsync({ id, formData });
|
id: number,
|
||||||
|
formData: FormData,
|
||||||
|
onProgress?: (percent: number) => void,
|
||||||
|
) => {
|
||||||
|
return uploadContractMutation.mutateAsync({ id, formData, onProgress });
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEdit = (record: any) => {
|
const openEdit = (record: any) => {
|
||||||
@@ -401,22 +419,30 @@ const ClassroomRentalsPage: React.FC = () => {
|
|||||||
新增租赁
|
新增租赁
|
||||||
</PermissionButton>
|
</PermissionButton>
|
||||||
</div>
|
</div>
|
||||||
<RentalTable
|
{isError ? (
|
||||||
data={filteredData}
|
<QueryErrorState
|
||||||
loading={loading}
|
title="租赁订单加载失败"
|
||||||
classrooms={classrooms}
|
description="请检查网络后重试。"
|
||||||
organizations={organizations}
|
onRetry={() => void refetch()}
|
||||||
canPurgeRental={canPurgeRental}
|
/>
|
||||||
hasPermission={hasPermission}
|
) : (
|
||||||
onSaveCell={saveCell}
|
<RentalTable
|
||||||
onEdit={openEdit}
|
data={filteredData}
|
||||||
onAction={handleRentalAction}
|
loading={loading}
|
||||||
onArchive={handleDelete}
|
classrooms={classrooms}
|
||||||
onPurge={handlePurge}
|
organizations={organizations}
|
||||||
onDownloadContract={handleDownloadContract}
|
canPurgeRental={canPurgeRental}
|
||||||
onDeleteContract={handleDeleteContract}
|
hasPermission={hasPermission}
|
||||||
onUploadContract={handleUploadContract}
|
onSaveCell={saveCell}
|
||||||
/>
|
onEdit={openEdit}
|
||||||
|
onAction={handleRentalAction}
|
||||||
|
onArchive={handleDelete}
|
||||||
|
onPurge={handlePurge}
|
||||||
|
onDownloadContract={handleDownloadContract}
|
||||||
|
onDeleteContract={handleDeleteContract}
|
||||||
|
onUploadContract={handleUploadContract}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Modal
|
<Modal
|
||||||
title={editing ? '编辑租赁' : '新增租赁'}
|
title={editing ? '编辑租赁' : '新增租赁'}
|
||||||
open={modalOpen}
|
open={modalOpen}
|
||||||
@@ -430,7 +456,7 @@ const ClassroomRentalsPage: React.FC = () => {
|
|||||||
okText="保存"
|
okText="保存"
|
||||||
width={600}
|
width={600}
|
||||||
>
|
>
|
||||||
<Form form={form} layout="vertical">
|
<Form form={form} layout="vertical" scrollToFirstError>
|
||||||
<Form.Item name="classroomId" label="教室" rules={[{ required: true }]}>
|
<Form.Item name="classroomId" label="教室" rules={[{ required: true }]}>
|
||||||
<Select
|
<Select
|
||||||
showSearch
|
showSearch
|
||||||
@@ -481,6 +507,7 @@ const ClassroomRentalsPage: React.FC = () => {
|
|||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
placeholder={['开始日期', '结束日期']}
|
placeholder={['开始日期', '结束日期']}
|
||||||
format="YYYY-MM-DD"
|
format="YYYY-MM-DD"
|
||||||
|
allowEmpty={[true, true]}
|
||||||
disabled={!selectedClassroomId}
|
disabled={!selectedClassroomId}
|
||||||
disabledDate={(date) => unavailableDatesLoading || isDateUnavailable(date)}
|
disabledDate={(date) => unavailableDatesLoading || isDateUnavailable(date)}
|
||||||
onPanelChange={(dates) => dates.forEach((date) => date && handleCalendarChange(date))}
|
onPanelChange={(dates) => dates.forEach((date) => date && handleCalendarChange(date))}
|
||||||
|
|||||||
@@ -13,15 +13,16 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Modal,
|
Modal,
|
||||||
Spin,
|
Spin,
|
||||||
Empty,
|
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import { CalendarOutlined, FileTextOutlined } from '@ant-design/icons';
|
import { CalendarOutlined, FileTextOutlined, ReadOutlined } from '@ant-design/icons';
|
||||||
import dayjs, { Dayjs } from 'dayjs';
|
import dayjs, { Dayjs } from 'dayjs';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import { downloadBlob } from '../../utils/download';
|
import { downloadBlob } from '../../utils/download';
|
||||||
import { message } from '../../ui/app-message';
|
import { message } from '../../ui/app-message';
|
||||||
import { getErrorMessage } from '../../utils/error';
|
import { getErrorMessage } from '../../utils/error';
|
||||||
|
import { QueryErrorState, QueryEmpty } from '../../components/QueryState';
|
||||||
|
import { useVisibleRefetch } from '../../hooks/usePageVisible';
|
||||||
|
|
||||||
interface ScheduleData {
|
interface ScheduleData {
|
||||||
year: number;
|
year: number;
|
||||||
@@ -40,23 +41,19 @@ const ClassroomSchedulePage: React.FC = () => {
|
|||||||
const [month, setMonth] = useState<Dayjs>(dayjs());
|
const [month, setMonth] = useState<Dayjs>(dayjs());
|
||||||
const [detailModal, setDetailModal] = useState<any>(null);
|
const [detailModal, setDetailModal] = useState<any>(null);
|
||||||
|
|
||||||
const { data, isLoading, isFetching } = useQuery<ScheduleData | null>({
|
const { data, isLoading, isFetching, isError, refetch } = useQuery<ScheduleData | null>({
|
||||||
queryKey: ['classroom-rentals', 'schedule', month.year(), month.month()],
|
queryKey: ['classroom-rentals', 'schedule', month.year(), month.month()],
|
||||||
queryFn: async () => {
|
queryFn: async () =>
|
||||||
try {
|
validateResponse<ScheduleData | null>(
|
||||||
return validateResponse<ScheduleData | null>(
|
classroomScheduleSchema,
|
||||||
classroomScheduleSchema,
|
await api.get('/classroom-rentals/schedule', {
|
||||||
await api.get('/classroom-rentals/schedule', {
|
params: { year: month.year(), month: month.month() + 1 },
|
||||||
params: { year: month.year(), month: month.month() + 1 },
|
}),
|
||||||
}),
|
),
|
||||||
);
|
|
||||||
} catch (e: unknown) {
|
|
||||||
message.error(getErrorMessage(e, '加载失败,请稍后重试'));
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
const loading = isLoading || isFetching;
|
const loading = isLoading || isFetching;
|
||||||
|
// RouteKeeper 保活页面切回时刷新排期数据
|
||||||
|
useVisibleRefetch(['classroom-rentals', 'schedule']);
|
||||||
|
|
||||||
// 按楼栋+楼层分组教室
|
// 按楼栋+楼层分组教室
|
||||||
const groups = useMemo(() => {
|
const groups = useMemo(() => {
|
||||||
@@ -65,7 +62,7 @@ const ClassroomSchedulePage: React.FC = () => {
|
|||||||
for (const c of data.classrooms) {
|
for (const c of data.classrooms) {
|
||||||
const key = `${c.building || '其他'}${c.floor ? ` · ${c.floor}层` : ''}`;
|
const key = `${c.building || '其他'}${c.floor ? ` · ${c.floor}层` : ''}`;
|
||||||
if (!map.has(key)) map.set(key, []);
|
if (!map.has(key)) map.set(key, []);
|
||||||
map.get(key)!.push(c);
|
map.get(key)?.push(c);
|
||||||
}
|
}
|
||||||
return Array.from(map.entries()).map(([name, classrooms]) => ({ name, classrooms }));
|
return Array.from(map.entries()).map(([name, classrooms]) => ({ name, classrooms }));
|
||||||
}, [data]);
|
}, [data]);
|
||||||
@@ -135,202 +132,218 @@ const ClassroomSchedulePage: React.FC = () => {
|
|||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 统计卡片 */}
|
{isError ? (
|
||||||
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
<QueryErrorState
|
||||||
<Col xs={12} sm={6}>
|
title="教室排期加载失败"
|
||||||
<Card size="small">
|
description="请检查网络后重试。"
|
||||||
<Statistic title="教室总数" value={data?.classrooms.length || 0} />
|
onRetry={() => void refetch()}
|
||||||
</Card>
|
/>
|
||||||
</Col>
|
) : (
|
||||||
<Col xs={12} sm={6}>
|
<>
|
||||||
<Card size="small">
|
{/* 统计卡片 */}
|
||||||
<Statistic title="本月天数" value={data?.days || 0} />
|
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
||||||
</Card>
|
<Col xs={12} sm={6}>
|
||||||
</Col>
|
<Card size="small">
|
||||||
<Col xs={12} sm={6}>
|
<Statistic title="教室总数" value={data?.classrooms.length || 0} />
|
||||||
<Card size="small">
|
</Card>
|
||||||
<Statistic title="总占用天数" value={overall.rented} suffix={`/${overall.total}`} />
|
</Col>
|
||||||
</Card>
|
<Col xs={12} sm={6}>
|
||||||
</Col>
|
<Card size="small">
|
||||||
<Col xs={12} sm={6}>
|
<Statistic title="本月天数" value={data?.days || 0} />
|
||||||
<Card size="small">
|
</Card>
|
||||||
<Statistic
|
</Col>
|
||||||
title="整体占用率"
|
<Col xs={12} sm={6}>
|
||||||
value={overall.rate}
|
<Card size="small">
|
||||||
suffix="%"
|
<Statistic title="总占用天数" value={overall.rented} suffix={`/${overall.total}`} />
|
||||||
styles={{
|
</Card>
|
||||||
value: {
|
</Col>
|
||||||
color: overall.rate > 70 ? '#cf1322' : overall.rate > 40 ? '#fa8c16' : '#3f8600',
|
<Col xs={12} sm={6}>
|
||||||
},
|
<Card size="small">
|
||||||
}}
|
<Statistic
|
||||||
/>
|
title="整体占用率"
|
||||||
</Card>
|
value={overall.rate}
|
||||||
</Col>
|
suffix="%"
|
||||||
</Row>
|
styles={{
|
||||||
|
value: {
|
||||||
|
color: overall.rate > 70 ? '#cf1322' : overall.rate > 40 ? '#fa8c16' : '#3f8600',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
{/* 图例 */}
|
{/* 图例 */}
|
||||||
{data && (
|
{data && (
|
||||||
<Card size="small" style={{ marginBottom: 16 }} title="图例">
|
<Card size="small" style={{ marginBottom: 16 }} title="图例">
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
<Tag color="#52c41a">内部排课</Tag>
|
<Tag color="#52c41a">内部排课</Tag>
|
||||||
{data.organizations.map((t) => (
|
{data.organizations.map((t) => (
|
||||||
<Tag
|
<Tag
|
||||||
key={t.id}
|
key={t.id}
|
||||||
color={t.color}
|
color={t.color}
|
||||||
style={{ background: t.color, color: '#fff', borderColor: t.color }}
|
style={{ background: t.color, color: '#fff', borderColor: t.color }}
|
||||||
>
|
>
|
||||||
{t.name} (租赁)
|
{t.name} (租赁)
|
||||||
</Tag>
|
</Tag>
|
||||||
))}
|
))}
|
||||||
<Tag color="#d9d9d9" style={{ color: '#999' }}>
|
<Tag color="#d9d9d9" style={{ color: '#999' }}>
|
||||||
空闲
|
空闲
|
||||||
</Tag>
|
</Tag>
|
||||||
</Space>
|
</Space>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Spin spinning={loading}>
|
<Spin spinning={loading}>
|
||||||
{!data || data.classrooms.length === 0 ? (
|
{!data || data.classrooms.length === 0 ? (
|
||||||
<Empty description="暂无教室数据" />
|
<QueryEmpty description="暂无教室数据,可在「教室管理」中添加教室后查看排期" />
|
||||||
) : (
|
) : (
|
||||||
<div style={{ overflowX: 'auto' }}>
|
<div style={{ overflowX: 'auto' }}>
|
||||||
{groups.map((group) => (
|
{groups.map((group) => (
|
||||||
<Card
|
<Card
|
||||||
key={group.name}
|
key={group.name}
|
||||||
size="small"
|
size="small"
|
||||||
title={group.name}
|
title={group.name}
|
||||||
style={{ marginBottom: 12 }}
|
style={{ marginBottom: 12 }}
|
||||||
styles={{ body: { padding: 0 } }}
|
styles={{ body: { padding: 0 } }}
|
||||||
>
|
>
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ background: '#fafafa' }}>
|
<tr style={{ background: '#fafafa' }}>
|
||||||
<th
|
<th
|
||||||
style={{
|
|
||||||
position: 'sticky',
|
|
||||||
left: 0,
|
|
||||||
background: '#fafafa',
|
|
||||||
zIndex: 2,
|
|
||||||
padding: '8px',
|
|
||||||
border: '1px solid #f0f0f0',
|
|
||||||
minWidth: 120,
|
|
||||||
textAlign: 'left',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
教室
|
|
||||||
</th>
|
|
||||||
<th style={{ padding: '8px 6px', border: '1px solid #f0f0f0', minWidth: 60 }}>
|
|
||||||
类型
|
|
||||||
</th>
|
|
||||||
<th style={{ padding: '8px 6px', border: '1px solid #f0f0f0', minWidth: 70 }}>
|
|
||||||
占用率
|
|
||||||
</th>
|
|
||||||
{Array.from({ length: data.days }, (_, i) => i + 1).map((d) => (
|
|
||||||
<th
|
|
||||||
key={d}
|
|
||||||
style={{
|
|
||||||
padding: '8px 4px',
|
|
||||||
border: '1px solid #f0f0f0',
|
|
||||||
minWidth: 26,
|
|
||||||
textAlign: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{d}
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{group.classrooms.map((c) => {
|
|
||||||
const sum = data.summary[c.id] || {
|
|
||||||
rentedDays: 0,
|
|
||||||
totalDays: data.days,
|
|
||||||
occupancyRate: 0,
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<tr key={c.id}>
|
|
||||||
<td
|
|
||||||
style={{
|
style={{
|
||||||
position: 'sticky',
|
position: 'sticky',
|
||||||
left: 0,
|
left: 0,
|
||||||
background: '#fff',
|
background: '#fafafa',
|
||||||
zIndex: 1,
|
zIndex: 2,
|
||||||
padding: '6px 8px',
|
padding: '8px',
|
||||||
border: '1px solid #f0f0f0',
|
border: '1px solid #f0f0f0',
|
||||||
fontWeight: 500,
|
minWidth: 120,
|
||||||
|
textAlign: 'left',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{c.name}
|
教室
|
||||||
</td>
|
</th>
|
||||||
<td
|
<th style={{ padding: '8px 6px', border: '1px solid #f0f0f0', minWidth: 60 }}>
|
||||||
style={{
|
类型
|
||||||
padding: '6px',
|
</th>
|
||||||
border: '1px solid #f0f0f0',
|
<th style={{ padding: '8px 6px', border: '1px solid #f0f0f0', minWidth: 70 }}>
|
||||||
textAlign: 'center',
|
占用率
|
||||||
}}
|
</th>
|
||||||
>
|
{Array.from({ length: data.days }, (_, i) => i + 1).map((d) => (
|
||||||
{c.roomType}
|
<th
|
||||||
</td>
|
key={d}
|
||||||
<td
|
style={{
|
||||||
style={{
|
padding: '8px 4px',
|
||||||
padding: '6px',
|
border: '1px solid #f0f0f0',
|
||||||
border: '1px solid #f0f0f0',
|
minWidth: 26,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
color:
|
}}
|
||||||
sum.occupancyRate > 0.7
|
>
|
||||||
? '#cf1322'
|
{d}
|
||||||
: sum.occupancyRate > 0.4
|
</th>
|
||||||
? '#fa8c16'
|
))}
|
||||||
: '#3f8600',
|
</tr>
|
||||||
}}
|
</thead>
|
||||||
>
|
<tbody>
|
||||||
{Math.round(sum.occupancyRate * 100)}%
|
{group.classrooms.map((c) => {
|
||||||
</td>
|
const sum = data.summary[c.id] || {
|
||||||
{Array.from({ length: data.days }, (_, i) => i + 1).map((d) => {
|
rentedDays: 0,
|
||||||
const cell = data.matrix[c.id]?.[d];
|
totalDays: data.days,
|
||||||
const isInternal = cell?.scheduleType === 'INTERNAL';
|
occupancyRate: 0,
|
||||||
const isRental = cell?.scheduleType === 'RENTAL';
|
};
|
||||||
return (
|
return (
|
||||||
|
<tr key={c.id}>
|
||||||
<td
|
<td
|
||||||
key={d}
|
|
||||||
onClick={() => {
|
|
||||||
if (isRental) showDetail(cell.rentalId);
|
|
||||||
}}
|
|
||||||
style={{
|
style={{
|
||||||
padding: 0,
|
position: 'sticky',
|
||||||
|
left: 0,
|
||||||
|
background: '#fff',
|
||||||
|
zIndex: 1,
|
||||||
|
padding: '6px 8px',
|
||||||
|
border: '1px solid #f0f0f0',
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{c.name}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: '6px',
|
||||||
border: '1px solid #f0f0f0',
|
border: '1px solid #f0f0f0',
|
||||||
background: cell?.color || '#fff',
|
|
||||||
height: 26,
|
|
||||||
cursor: isRental ? 'pointer' : 'default',
|
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{cell && (
|
{c.roomType}
|
||||||
<Tooltip
|
|
||||||
title={
|
|
||||||
isInternal
|
|
||||||
? `${cell.className} · ${cell.subject}\n${cell.teacherName} · ${cell.startTime}-${cell.endTime}`
|
|
||||||
: `${cell.organizationName}${cell.hasContract ? ' · 有合同' : ''}`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span style={{ color: '#fff', fontSize: 10, fontWeight: 600 }}>
|
|
||||||
{isInternal ? '📖' : cell.hasContract ? '📄' : ''}
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
);
|
<td
|
||||||
})}
|
style={{
|
||||||
</tr>
|
padding: '6px',
|
||||||
);
|
border: '1px solid #f0f0f0',
|
||||||
})}
|
textAlign: 'center',
|
||||||
</tbody>
|
color:
|
||||||
</table>
|
sum.occupancyRate > 0.7
|
||||||
</Card>
|
? '#cf1322'
|
||||||
))}
|
: sum.occupancyRate > 0.4
|
||||||
</div>
|
? '#fa8c16'
|
||||||
)}
|
: '#3f8600',
|
||||||
</Spin>
|
}}
|
||||||
|
>
|
||||||
|
{Math.round(sum.occupancyRate * 100)}%
|
||||||
|
</td>
|
||||||
|
{Array.from({ length: data.days }, (_, i) => i + 1).map((d) => {
|
||||||
|
const cell = data.matrix[c.id]?.[d];
|
||||||
|
const isInternal = cell?.scheduleType === 'INTERNAL';
|
||||||
|
const isRental = cell?.scheduleType === 'RENTAL';
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={d}
|
||||||
|
onClick={() => {
|
||||||
|
if (isRental) showDetail(cell.rentalId);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: 0,
|
||||||
|
border: '1px solid #f0f0f0',
|
||||||
|
background: cell?.color || '#fff',
|
||||||
|
height: 26,
|
||||||
|
cursor: isRental ? 'pointer' : 'default',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cell && (
|
||||||
|
<Tooltip
|
||||||
|
title={
|
||||||
|
isInternal
|
||||||
|
? `${cell.className} · ${cell.subject}\n${cell.teacherName} · ${cell.startTime}-${cell.endTime}`
|
||||||
|
: `${cell.organizationName}${cell.hasContract ? ' · 有合同' : ''}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span style={{ color: '#fff', fontSize: 10, fontWeight: 600 }}>
|
||||||
|
{isInternal ? (
|
||||||
|
<ReadOutlined style={{ fontSize: 12 }} />
|
||||||
|
) : cell.hasContract ? (
|
||||||
|
<FileTextOutlined style={{ fontSize: 12 }} />
|
||||||
|
) : (
|
||||||
|
''
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Spin>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
title="租赁详情"
|
title="租赁详情"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo, useCallback } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useApiMutation } from '../../hooks/useApiMutation';
|
import { useApiMutation } from '../../hooks/useApiMutation';
|
||||||
import { validateResponse } from '../../utils/validate';
|
import { validateResponse } from '../../utils/validate';
|
||||||
@@ -17,7 +17,6 @@ import {
|
|||||||
Popconfirm,
|
Popconfirm,
|
||||||
Upload,
|
Upload,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Empty,
|
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
@@ -29,9 +28,15 @@ import {
|
|||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import PermissionButton from '../../components/PermissionButton';
|
import PermissionButton from '../../components/PermissionButton';
|
||||||
import EditableCell from '../../components/EditableCell';
|
import EditableCell from '../../components/EditableCell';
|
||||||
|
import { QueryErrorState, QueryEmpty } from '../../components/QueryState';
|
||||||
import { message } from '../../ui/app-message';
|
import { message } from '../../ui/app-message';
|
||||||
|
import { RefreshButton } from '../../components/RefreshButton';
|
||||||
|
import { useSubmitShortcut } from '../../hooks/useSubmitShortcut';
|
||||||
import { usePermission } from '../../hooks/usePermission';
|
import { usePermission } from '../../hooks/usePermission';
|
||||||
import { useUserStore } from '../../store/user/userStore';
|
import { useUserStore } from '../../store/user/userStore';
|
||||||
|
import { useVisibleRefetch } from '../../hooks/usePageVisible';
|
||||||
|
import { useDirtyGuard } from '../../hooks/useDirtyGuard';
|
||||||
|
import { saveAs } from 'file-saver';
|
||||||
|
|
||||||
const statusMap: Record<string, { text: string; color: string }> = {
|
const statusMap: Record<string, { text: string; color: string }> = {
|
||||||
available: { text: '可用', color: 'green' },
|
available: { text: '可用', color: 'green' },
|
||||||
@@ -61,30 +66,30 @@ const ClassroomsPage: React.FC = () => {
|
|||||||
const [editing, setEditing] = useState<any>(null);
|
const [editing, setEditing] = useState<any>(null);
|
||||||
const [showArchived, setShowArchived] = useState(false);
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
|
const formGuard = useDirtyGuard(form);
|
||||||
const [searchText, setSearchText] = useState('');
|
const [searchText, setSearchText] = useState('');
|
||||||
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
|
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
useSubmitShortcut(modalOpen && !saving, () => handleSave());
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data = [],
|
data = [],
|
||||||
isLoading,
|
isLoading,
|
||||||
isFetching,
|
isFetching,
|
||||||
|
isError,
|
||||||
|
refetch,
|
||||||
} = useQuery<any[]>({
|
} = useQuery<any[]>({
|
||||||
queryKey: ['classrooms', showArchived],
|
queryKey: ['classrooms', showArchived],
|
||||||
queryFn: async () => {
|
queryFn: async () =>
|
||||||
try {
|
validateResponse<any[]>(
|
||||||
return validateResponse<any[]>(
|
classroomsSchema,
|
||||||
classroomsSchema,
|
await api.get('/classrooms', { params: { includeArchived: showArchived } }),
|
||||||
await api.get('/classrooms', { params: { includeArchived: showArchived } }),
|
),
|
||||||
);
|
|
||||||
} catch (e: any) {
|
|
||||||
message.error(e?.message || '加载失败,请稍后重试');
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
const loading = isLoading || isFetching;
|
const loading = isLoading || isFetching;
|
||||||
|
// RouteKeeper 保活页面切回时刷新列表,避免看到陈旧数据
|
||||||
|
useVisibleRefetch(['classrooms']);
|
||||||
|
|
||||||
const saveMutation = useApiMutation(
|
const saveMutation = useApiMutation(
|
||||||
async (values: Record<string, unknown>) =>
|
async (values: Record<string, unknown>) =>
|
||||||
@@ -110,9 +115,7 @@ const ClassroomsPage: React.FC = () => {
|
|||||||
);
|
);
|
||||||
const importMutation = useApiMutation(
|
const importMutation = useApiMutation(
|
||||||
async (formData: FormData) =>
|
async (formData: FormData) =>
|
||||||
api.post('/classrooms/import', formData, {
|
api.post('/classrooms/import', formData),
|
||||||
headers: { 'Content-Type': 'multipart/form-data' },
|
|
||||||
}),
|
|
||||||
{ invalidate: [['classrooms']] },
|
{ invalidate: [['classrooms']] },
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -147,50 +150,70 @@ const ClassroomsPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveCell = async (record: any, field: string, value: unknown) => {
|
const openCreateModal = () => {
|
||||||
try {
|
setEditing(null);
|
||||||
await saveCellMutation.mutateAsync({ record, field, value });
|
form.resetFields();
|
||||||
message.success('已保存');
|
formGuard.snapshot();
|
||||||
} catch {
|
setModalOpen(true);
|
||||||
// 错误提示由 useApiMutation 统一处理
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleArchive = async (id: number) => {
|
const saveCell = useCallback(
|
||||||
try {
|
async (record: any, field: string, value: unknown) => {
|
||||||
await archiveMutation.mutateAsync(id);
|
try {
|
||||||
message.success('已归档');
|
await saveCellMutation.mutateAsync({ record, field, value });
|
||||||
} catch {
|
message.success('已保存');
|
||||||
// 错误提示由 useApiMutation 统一处理
|
} catch {
|
||||||
}
|
// 错误提示由 useApiMutation 统一处理
|
||||||
};
|
}
|
||||||
|
},
|
||||||
|
[saveCellMutation],
|
||||||
|
);
|
||||||
|
|
||||||
const handleRestore = async (id: number) => {
|
const handleArchive = useCallback(
|
||||||
try {
|
async (id: number) => {
|
||||||
await restoreMutation.mutateAsync(id);
|
try {
|
||||||
message.success('已恢复');
|
await archiveMutation.mutateAsync(id);
|
||||||
} catch {
|
message.success('已归档');
|
||||||
// 错误提示由 useApiMutation 统一处理
|
} catch {
|
||||||
}
|
// 错误提示由 useApiMutation 统一处理
|
||||||
};
|
}
|
||||||
|
},
|
||||||
|
[archiveMutation],
|
||||||
|
);
|
||||||
|
|
||||||
const handlePurge = (id: number, name: string) => {
|
const handleRestore = useCallback(
|
||||||
modal.confirm({
|
async (id: number) => {
|
||||||
title: `永久删除教室「${name}」?`,
|
try {
|
||||||
content: '删除后不可恢复,该教室及其排课、租赁、考勤机关联将被清除(存在关联数据时将无法删除)。确定继续?',
|
await restoreMutation.mutateAsync(id);
|
||||||
okText: '永久删除',
|
message.success('已恢复');
|
||||||
okButtonProps: { danger: true },
|
} catch {
|
||||||
cancelText: '取消',
|
// 错误提示由 useApiMutation 统一处理
|
||||||
onOk: async () => {
|
}
|
||||||
try {
|
},
|
||||||
await purgeMutation.mutateAsync(id);
|
[restoreMutation],
|
||||||
message.success('已永久删除(不可恢复)');
|
);
|
||||||
} catch {
|
|
||||||
// 错误提示由 useApiMutation 统一处理
|
const handlePurge = useCallback(
|
||||||
}
|
(id: number, name: string) => {
|
||||||
},
|
modal.confirm({
|
||||||
});
|
title: `永久删除教室「${name}」?`,
|
||||||
};
|
content:
|
||||||
|
'删除后不可恢复,该教室及其排课、租赁、考勤机关联将被清除(存在关联数据时将无法删除)。确定继续?',
|
||||||
|
okText: '永久删除',
|
||||||
|
okButtonProps: { danger: true },
|
||||||
|
cancelText: '取消',
|
||||||
|
onOk: async () => {
|
||||||
|
try {
|
||||||
|
await purgeMutation.mutateAsync(id);
|
||||||
|
message.success('已永久删除(不可恢复)');
|
||||||
|
} catch {
|
||||||
|
// 错误提示由 useApiMutation 统一处理
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[modal, purgeMutation],
|
||||||
|
);
|
||||||
|
|
||||||
const handleDownloadTemplate = () => {
|
const handleDownloadTemplate = () => {
|
||||||
const baseURL = import.meta.env.PROD
|
const baseURL = import.meta.env.PROD
|
||||||
@@ -199,14 +222,7 @@ const ClassroomsPage: React.FC = () => {
|
|||||||
const token = useUserStore.getState().token;
|
const token = useUserStore.getState().token;
|
||||||
fetch(`${baseURL}/classrooms/template`, { headers: { Authorization: `Bearer ${token}` } })
|
fetch(`${baseURL}/classrooms/template`, { headers: { Authorization: `Bearer ${token}` } })
|
||||||
.then((res) => res.blob())
|
.then((res) => res.blob())
|
||||||
.then((blob) => {
|
.then((blob) => saveAs(blob, '教室导入模板.xlsx'))
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = '教室导入模板.xlsx';
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
})
|
|
||||||
.catch(() => message.error('下载失败'));
|
.catch(() => message.error('下载失败'));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -338,6 +354,7 @@ const ClassroomsPage: React.FC = () => {
|
|||||||
|
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
|
fixed: 'right' as const,
|
||||||
width: 180,
|
width: 180,
|
||||||
render: (_: any, record: any) => (
|
render: (_: any, record: any) => (
|
||||||
<Space>
|
<Space>
|
||||||
@@ -372,6 +389,7 @@ const ClassroomsPage: React.FC = () => {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditing(record);
|
setEditing(record);
|
||||||
form.setFieldsValue(record);
|
form.setFieldsValue(record);
|
||||||
|
formGuard.snapshot();
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -397,7 +415,7 @@ const ClassroomsPage: React.FC = () => {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[handlePurge, hasPermission],
|
[handlePurge, hasPermission, saveCell, handleArchive, handleRestore, form, formGuard],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -443,15 +461,12 @@ const ClassroomsPage: React.FC = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
|
<RefreshButton loading={isFetching} onRefresh={() => void refetch()} />
|
||||||
<PermissionButton
|
<PermissionButton
|
||||||
permission="classroom:create"
|
permission="classroom:create"
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<PlusOutlined />}
|
icon={<PlusOutlined />}
|
||||||
onClick={() => {
|
onClick={openCreateModal}
|
||||||
setEditing(null);
|
|
||||||
form.resetFields();
|
|
||||||
setModalOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
添加教室
|
添加教室
|
||||||
</PermissionButton>
|
</PermissionButton>
|
||||||
@@ -465,12 +480,8 @@ const ClassroomsPage: React.FC = () => {
|
|||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
})
|
})
|
||||||
.then((r) => r.blob())
|
.then((r) => r.blob())
|
||||||
.then((b) => {
|
.then((b) => saveAs(b, '教室使用报表.xlsx'))
|
||||||
const a = document.createElement('a');
|
.catch(() => message.error('导出失败'));
|
||||||
a.href = URL.createObjectURL(b);
|
|
||||||
a.download = '教室使用报表.xlsx';
|
|
||||||
a.click();
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
导出报表
|
导出报表
|
||||||
@@ -503,32 +514,53 @@ const ClassroomsPage: React.FC = () => {
|
|||||||
</PermissionButton>
|
</PermissionButton>
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
<Table
|
{isError ? (
|
||||||
scroll={{ x: 1100 }}
|
<QueryErrorState
|
||||||
columns={columns}
|
title="教室列表加载失败"
|
||||||
dataSource={filteredData}
|
description="请检查网络后重试。"
|
||||||
rowKey="id"
|
onRetry={() => void refetch()}
|
||||||
loading={loading}
|
/>
|
||||||
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
) : (
|
||||||
pagination={{
|
<Table
|
||||||
defaultPageSize: 20,
|
scroll={{ x: 1100 }}
|
||||||
showSizeChanger: true,
|
columns={columns}
|
||||||
pageSizeOptions: [20, 50, 100],
|
dataSource={filteredData}
|
||||||
showTotal: (total) => `共 ${total} 条`,
|
rowKey="id"
|
||||||
}}
|
loading={loading}
|
||||||
/>
|
locale={{
|
||||||
|
emptyText: (
|
||||||
|
<QueryEmpty
|
||||||
|
description="暂无数据"
|
||||||
|
action={
|
||||||
|
hasPermission('classroom:create')
|
||||||
|
? { label: '添加教室', onClick: openCreateModal }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
pagination={{
|
||||||
|
defaultPageSize: 20,
|
||||||
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: [20, 50, 100],
|
||||||
|
showTotal: (total) => `共 ${total} 条`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Modal
|
<Modal
|
||||||
title={editing ? '编辑教室' : '添加教室'}
|
title={editing ? '编辑教室' : '添加教室'}
|
||||||
open={modalOpen}
|
open={modalOpen}
|
||||||
onOk={handleSave}
|
onOk={handleSave}
|
||||||
onCancel={() => {
|
onCancel={() =>
|
||||||
setModalOpen(false);
|
formGuard.confirmClose(() => {
|
||||||
setEditing(null);
|
setModalOpen(false);
|
||||||
}}
|
setEditing(null);
|
||||||
|
})
|
||||||
|
}
|
||||||
confirmLoading={saving}
|
confirmLoading={saving}
|
||||||
okText="保存"
|
okText="保存"
|
||||||
>
|
>
|
||||||
<Form form={form} layout="vertical">
|
<Form form={form} layout="vertical" scrollToFirstError>
|
||||||
<Form.Item name="name" label="教室名" rules={[{ required: true }]}>
|
<Form.Item name="name" label="教室名" rules={[{ required: true }]}>
|
||||||
<Input placeholder="如:A201 / B301" />
|
<Input placeholder="如:A201 / B301" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export interface ExpenseByTypeRow {
|
|||||||
}
|
}
|
||||||
export interface GanttOccupancy {
|
export interface GanttOccupancy {
|
||||||
studentName: string;
|
studentName: string;
|
||||||
studentId?: string;
|
studentId?: string | number;
|
||||||
checkInDate: string;
|
checkInDate: string;
|
||||||
checkOutDate: string | null;
|
checkOutDate: string | null;
|
||||||
billingStartDate?: string;
|
billingStartDate?: string;
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { buildGanttOption } from './DashboardCharts';
|
||||||
|
import type { GanttOccupancy } from './Dashboard.types';
|
||||||
|
|
||||||
|
const ganttRoom = (occupancies: GanttOccupancy[]) => [
|
||||||
|
{ roomNumber: 'A101', occupancies },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('buildGanttOption 甘特图时间线', () => {
|
||||||
|
it('未退宿的入住条在查看过去月份时截断到 periodEnd,而不是画到今天', () => {
|
||||||
|
const option = buildGanttOption(
|
||||||
|
ganttRoom([
|
||||||
|
{
|
||||||
|
studentName: '张三',
|
||||||
|
checkInDate: '2026-05-01',
|
||||||
|
checkOutDate: null,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
{ periodEnd: '2026-06-30', today: '2026-08-07' },
|
||||||
|
);
|
||||||
|
|
||||||
|
const series = option.series as Array<{ data: Array<{ value: [string, string, string, boolean] }> }>;
|
||||||
|
expect(series[0].data[0].value[2]).toBe('2026-06-30');
|
||||||
|
expect(series[0].data[0].value[3]).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('未退宿的入住条在查看当前月时截断到今天', () => {
|
||||||
|
const option = buildGanttOption(
|
||||||
|
ganttRoom([
|
||||||
|
{
|
||||||
|
studentName: '张三',
|
||||||
|
checkInDate: '2026-07-01',
|
||||||
|
checkOutDate: null,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
{ periodEnd: '2026-08-31', today: '2026-08-07' },
|
||||||
|
);
|
||||||
|
|
||||||
|
const series = option.series as Array<{ data: Array<{ value: [string, string, string, boolean] }> }>;
|
||||||
|
expect(series[0].data[0].value[2]).toBe('2026-08-07');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('已退宿的入住条保留真实退宿日期', () => {
|
||||||
|
const option = buildGanttOption(
|
||||||
|
ganttRoom([
|
||||||
|
{
|
||||||
|
studentName: '李四',
|
||||||
|
checkInDate: '2026-05-01',
|
||||||
|
checkOutDate: '2026-06-15',
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
{ periodEnd: '2026-06-30', today: '2026-08-07' },
|
||||||
|
);
|
||||||
|
|
||||||
|
const series = option.series as Array<{ data: Array<{ value: [string, string, string, boolean] }> }>;
|
||||||
|
expect(series[0].data[0].value[2]).toBe('2026-06-15');
|
||||||
|
expect(series[0].data[0].value[3]).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { EChartsOption } from '../../components/ECharts';
|
import type { EChartsOption } from '../../components/ECharts';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
import {
|
import {
|
||||||
attendanceLabelMap,
|
attendanceLabelMap,
|
||||||
COLORS,
|
COLORS,
|
||||||
@@ -169,16 +170,6 @@ export function buildClassroomHeatmapOption(
|
|||||||
data: classroomOccupancy.map((r) => r.name),
|
data: classroomOccupancy.map((r) => r.name),
|
||||||
inverse: true,
|
inverse: true,
|
||||||
},
|
},
|
||||||
visualMap: {
|
|
||||||
min: 0,
|
|
||||||
max: 1,
|
|
||||||
orient: 'horizontal',
|
|
||||||
left: 'center',
|
|
||||||
bottom: 0,
|
|
||||||
inRange: {
|
|
||||||
color: ['#e6f4ff', '#91caff', '#40a9ff', '#0050b3', '#002c8c'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
type: 'bar',
|
type: 'bar',
|
||||||
@@ -189,7 +180,7 @@ export function buildClassroomHeatmapOption(
|
|||||||
rentalCount: r.rentalCount,
|
rentalCount: r.rentalCount,
|
||||||
occupancy: r.occupancy,
|
occupancy: r.occupancy,
|
||||||
})),
|
})),
|
||||||
itemStyle: { borderRadius: [0, 4, 4, 0] },
|
itemStyle: { color: '#1677ff', borderRadius: [0, 4, 4, 0] },
|
||||||
label: {
|
label: {
|
||||||
show: true,
|
show: true,
|
||||||
position: 'right',
|
position: 'right',
|
||||||
@@ -201,7 +192,13 @@ export function buildClassroomHeatmapOption(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildGanttOption(ganttData: GanttRoom[]): EChartsOption {
|
export function buildGanttOption(
|
||||||
|
ganttData: GanttRoom[],
|
||||||
|
options?: { periodEnd?: string; today?: string },
|
||||||
|
): EChartsOption {
|
||||||
|
const today = options?.today ?? dayjs().format('YYYY-MM-DD');
|
||||||
|
const periodEnd = options?.periodEnd;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tooltip: {
|
tooltip: {
|
||||||
formatter: (p: { data: { name: string; value: [string, string, string, boolean] } }) =>
|
formatter: (p: { data: { name: string; value: [string, string, string, boolean] } }) =>
|
||||||
@@ -246,15 +243,18 @@ export function buildGanttOption(ganttData: GanttRoom[]): EChartsOption {
|
|||||||
},
|
},
|
||||||
encode: { x: [1, 2], y: 0 },
|
encode: { x: [1, 2], y: 0 },
|
||||||
data: ganttData.flatMap((r) =>
|
data: ganttData.flatMap((r) =>
|
||||||
(r.occupancies || []).map((o) => ({
|
(r.occupancies || []).map((o) => {
|
||||||
name: o.studentName,
|
const activeEnd = periodEnd && periodEnd < today ? periodEnd : today;
|
||||||
value: [
|
return {
|
||||||
r.roomNumber,
|
name: o.studentName,
|
||||||
o.checkInDate,
|
value: [
|
||||||
o.checkOutDate || new Date().toISOString().slice(0, 10),
|
r.roomNumber,
|
||||||
!o.checkOutDate,
|
o.checkInDate,
|
||||||
] as [string, string, string, boolean],
|
o.checkOutDate || activeEnd,
|
||||||
})),
|
!o.checkOutDate,
|
||||||
|
] as [string, string, string, boolean],
|
||||||
|
};
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -49,22 +49,25 @@ export const ClassroomHeatmapCard: React.FC<{
|
|||||||
minHeight={isMobile ? 340 : 440}
|
minHeight={isMobile ? 340 : 440}
|
||||||
style={{ marginBottom: 24 }}
|
style={{ marginBottom: 24 }}
|
||||||
>
|
>
|
||||||
{data.length > 0 ? (
|
{data.some((r) => Number(r.occupancy) > 0) ? (
|
||||||
<ReactECharts
|
<ReactECharts
|
||||||
option={buildClassroomHeatmapOption(data)}
|
option={buildClassroomHeatmapOption(data)}
|
||||||
style={{ width: '100%', height: isMobile ? 300 : 400 }}
|
style={{ width: '100%', height: isMobile ? 300 : 400 }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}>暂无教室数据</div>
|
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}>
|
||||||
|
暂无教室占用数据
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</LazySection>
|
</LazySection>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const GanttCard: React.FC<{ data: GanttRoom[]; isMobile: boolean }> = ({
|
export const GanttCard: React.FC<{
|
||||||
data,
|
data: GanttRoom[];
|
||||||
isMobile,
|
isMobile: boolean;
|
||||||
}) => {
|
periodEnd?: string;
|
||||||
|
}> = ({ data, isMobile, periodEnd }) => {
|
||||||
const vp = useInViewport('200px');
|
const vp = useInViewport('200px');
|
||||||
return (
|
return (
|
||||||
<LazySection
|
<LazySection
|
||||||
@@ -74,7 +77,7 @@ export const GanttCard: React.FC<{ data: GanttRoom[]; isMobile: boolean }> = ({
|
|||||||
>
|
>
|
||||||
{data.length > 0 ? (
|
{data.length > 0 ? (
|
||||||
<ReactECharts
|
<ReactECharts
|
||||||
option={buildGanttOption(data)}
|
option={buildGanttOption(data, { periodEnd })}
|
||||||
style={{ width: '100%', height: isMobile ? 300 : 450 }}
|
style={{ width: '100%', height: isMobile ? 300 : 450 }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { shouldNavigateTodoCard } from './DashboardTodoCards';
|
||||||
|
|
||||||
|
describe('DashboardTodoCards 点击导航守卫', () => {
|
||||||
|
it('目标路径与当前路径不同且冷却已过时允许导航', () => {
|
||||||
|
expect(shouldNavigateTodoCard('/dashboard', '/attendance', 1000, 1600)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('目标路径与当前路径相同时不允许导航', () => {
|
||||||
|
expect(shouldNavigateTodoCard('/attendance', '/attendance', 1000, 1600)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('冷却期内忽略重复点击,防止产生多条历史记录', () => {
|
||||||
|
expect(shouldNavigateTodoCard('/dashboard', '/attendance', 1000, 1300)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
import React, { useRef } from 'react';
|
||||||
import { Card, Col, Row } from 'antd';
|
import { Card, Col, Row } from 'antd';
|
||||||
import {
|
import {
|
||||||
ArrowRightOutlined,
|
ArrowRightOutlined,
|
||||||
@@ -6,9 +6,22 @@ import {
|
|||||||
DollarOutlined,
|
DollarOutlined,
|
||||||
ExclamationCircleOutlined,
|
ExclamationCircleOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { useNavigate } from 'react-router';
|
import { useLocation, useNavigate } from 'react-router';
|
||||||
import { MARGIN_BOTTOM_16_STYLE, TODO_CARD_DANGER, TODO_CARD_DRAFT, TODO_CARD_OK, TODO_CARD_WARN } from './Dashboard.types';
|
import { MARGIN_BOTTOM_16_STYLE, TODO_CARD_DANGER, TODO_CARD_DRAFT, TODO_CARD_OK, TODO_CARD_WARN } from './Dashboard.types';
|
||||||
|
|
||||||
|
/** 冷却期内忽略重复点击,避免快速/幽灵点击压入多条历史记录。 */
|
||||||
|
const TODO_CLICK_LOCK_MS = 400;
|
||||||
|
|
||||||
|
export function shouldNavigateTodoCard(
|
||||||
|
currentPath: string,
|
||||||
|
targetPath: string,
|
||||||
|
lastNavAt: number,
|
||||||
|
now: number,
|
||||||
|
): boolean {
|
||||||
|
if (currentPath === targetPath) return false;
|
||||||
|
return now - lastNavAt >= TODO_CLICK_LOCK_MS;
|
||||||
|
}
|
||||||
|
|
||||||
export const DashboardTodoCards: React.FC<{
|
export const DashboardTodoCards: React.FC<{
|
||||||
absentCount: number;
|
absentCount: number;
|
||||||
draftCount: number;
|
draftCount: number;
|
||||||
@@ -16,6 +29,14 @@ export const DashboardTodoCards: React.FC<{
|
|||||||
pendingDeposits: number;
|
pendingDeposits: number;
|
||||||
}> = ({ absentCount, draftCount, draftTotal, pendingDeposits }) => {
|
}> = ({ absentCount, draftCount, draftTotal, pendingDeposits }) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const lastNavAtRef = useRef(0);
|
||||||
|
const go = (target: string) => {
|
||||||
|
const now = Date.now();
|
||||||
|
if (!shouldNavigateTodoCard(location.pathname, target, lastNavAtRef.current, now)) return;
|
||||||
|
lastNavAtRef.current = now;
|
||||||
|
navigate(target);
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<Card title="待办与异常" style={MARGIN_BOTTOM_16_STYLE}>
|
<Card title="待办与异常" style={MARGIN_BOTTOM_16_STYLE}>
|
||||||
<Row gutter={[16, 16]}>
|
<Row gutter={[16, 16]}>
|
||||||
@@ -23,7 +44,7 @@ export const DashboardTodoCards: React.FC<{
|
|||||||
<Card
|
<Card
|
||||||
style={absentCount > 0 ? TODO_CARD_WARN : TODO_CARD_OK}
|
style={absentCount > 0 ? TODO_CARD_WARN : TODO_CARD_OK}
|
||||||
styles={{ body: { padding: 16 } }}
|
styles={{ body: { padding: 16 } }}
|
||||||
onClick={() => navigate('/attendance')}
|
onClick={() => go('/attendance')}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
<ExclamationCircleOutlined
|
<ExclamationCircleOutlined
|
||||||
@@ -55,7 +76,7 @@ export const DashboardTodoCards: React.FC<{
|
|||||||
<Card
|
<Card
|
||||||
style={draftCount > 0 ? TODO_CARD_DRAFT : TODO_CARD_OK}
|
style={draftCount > 0 ? TODO_CARD_DRAFT : TODO_CARD_OK}
|
||||||
styles={{ body: { padding: 16 } }}
|
styles={{ body: { padding: 16 } }}
|
||||||
onClick={() => navigate('/bills')}
|
onClick={() => go('/bills')}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
<DollarOutlined
|
<DollarOutlined
|
||||||
@@ -87,7 +108,7 @@ export const DashboardTodoCards: React.FC<{
|
|||||||
<Card
|
<Card
|
||||||
style={pendingDeposits > 0 ? TODO_CARD_DANGER : TODO_CARD_OK}
|
style={pendingDeposits > 0 ? TODO_CARD_DANGER : TODO_CARD_OK}
|
||||||
styles={{ body: { padding: 16 } }}
|
styles={{ body: { padding: 16 } }}
|
||||||
onClick={() => navigate('/deposits')}
|
onClick={() => go('/deposits')}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
<BankOutlined
|
<BankOutlined
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user