first commit
This commit is contained in:
commit
c2dc89397b
9
.env
Normal file
9
.env
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# Vite 环境变量配置
|
||||
# 以 VITE_ 开头的变量会在客户端代码中通过 import.meta.env 访问
|
||||
# 在 vite.config.ts 中可以通过 process.env 访问
|
||||
|
||||
# API 基础地址
|
||||
# 开发环境默认: http://127.0.0.1:8080/admin/v1
|
||||
# 生产环境默认: https://admin.duiduiedu.com/admin/v1
|
||||
# 如果需要覆盖,取消下面的注释并设置值
|
||||
# VITE_API_BASE_URL=http://127.0.0.1:8080/admin/v1
|
||||
5
.env.development
Normal file
5
.env.development
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# 开发环境配置
|
||||
# 此文件仅在 development 模式下生效(npm run dev)
|
||||
|
||||
# 开发环境 API 地址
|
||||
VITE_API_BASE_URL=http://127.0.0.1:8080/admin/v1
|
||||
5
.env.production
Normal file
5
.env.production
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# 生产环境配置
|
||||
# 此文件仅在 production 模式下生效(npm run build)
|
||||
|
||||
# 生产环境 API 地址
|
||||
VITE_API_BASE_URL=https://admin.duiduiedu.com/admin/v1
|
||||
20
.eslintrc.cjs
Normal file
20
.eslintrc.cjs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2020: true },
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
],
|
||||
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['react-refresh'],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
},
|
||||
}
|
||||
|
||||
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# 环境变量文件(可选,根据团队需求决定是否提交)
|
||||
# .env.local
|
||||
# .env.*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
15
Dockerfile
Normal file
15
Dockerfile
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# 使用 nginx 作为基础镜像
|
||||
FROM nginx:alpine
|
||||
|
||||
# 复制 dist 目录的内容到 nginx 的默认网站目录
|
||||
COPY dist /usr/share/nginx/html
|
||||
|
||||
# 复制自定义 nginx 配置文件
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# 暴露 80 端口
|
||||
EXPOSE 80
|
||||
|
||||
# 启动 nginx
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
81
Makefile
Normal file
81
Makefile
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
.PHONY: build build-prod tar clean dev preview lint
|
||||
|
||||
# 变量定义
|
||||
IMAGE_NAME := duidui-admin-web
|
||||
TAR_FILE := deploy/duidui-admin-web.tar
|
||||
DEPLOY_DIR := deploy
|
||||
|
||||
# 生产环境 API 地址
|
||||
PROD_API_URL := https://admin.duiduiedu.com/admin/v1
|
||||
|
||||
# 构建前端项目(开发环境)
|
||||
build:
|
||||
@echo "构建前端项目(开发环境)..."
|
||||
@npm run build
|
||||
@echo "✅ 构建完成: dist/"
|
||||
|
||||
# 构建前端项目(生产环境,先 clean 再构建,保证 dist 为全新)
|
||||
build-prod: clean
|
||||
@echo "=========================================="
|
||||
@echo "步骤 1: 配置生产环境 API 地址"
|
||||
@echo "=========================================="
|
||||
@echo "✅ 使用生产环境 API 地址: $(PROD_API_URL)"
|
||||
@echo ""
|
||||
@echo "=========================================="
|
||||
@echo "步骤 2: 构建前端项目"
|
||||
@echo "=========================================="
|
||||
@VITE_API_BASE_URL=$(PROD_API_URL) npm run build
|
||||
@echo "✅ 构建完成: dist/"
|
||||
@echo ""
|
||||
@echo "=========================================="
|
||||
@echo "步骤 3: 验证构建结果"
|
||||
@echo "=========================================="
|
||||
@if [ ! -d "dist" ]; then \
|
||||
echo "❌ 错误: dist 目录不存在"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@if grep -q "admin.duiduiedu.com" dist/assets/*.js 2>/dev/null; then \
|
||||
echo "✅ 已确认使用生产环境 API 地址"; \
|
||||
else \
|
||||
echo "⚠️ 警告: 未找到生产环境 API 地址,请检查构建配置"; \
|
||||
fi
|
||||
|
||||
# 开发模式运行
|
||||
dev:
|
||||
@echo "启动开发服务器..."
|
||||
@npm run dev
|
||||
|
||||
# 预览构建结果
|
||||
preview:
|
||||
@echo "预览构建结果..."
|
||||
@npm run preview
|
||||
|
||||
# 代码检查
|
||||
lint:
|
||||
@echo "运行代码检查..."
|
||||
@npm run lint
|
||||
|
||||
# 打包部署(先构建生产 dist,再打 tar 包)
|
||||
tar: build-prod
|
||||
@echo ""
|
||||
@echo "=========================================="
|
||||
@echo "步骤 4: 创建部署包"
|
||||
@echo "=========================================="
|
||||
@if [ ! -d "dist" ]; then \
|
||||
echo "❌ 错误: dist 目录不存在,构建失败"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@chmod +x build-deploy.sh
|
||||
@./build-deploy.sh
|
||||
@echo ""
|
||||
@echo "=========================================="
|
||||
@echo "✅ 部署包创建完成: $(TAR_FILE)"
|
||||
@echo "=========================================="
|
||||
|
||||
# 清理构建产物
|
||||
clean:
|
||||
@echo "清理构建产物..."
|
||||
@rm -rf dist/
|
||||
@rm -f $(TAR_FILE)
|
||||
@echo "✅ 清理完成"
|
||||
|
||||
126
README.md
Normal file
126
README.md
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
# 怼怼后台管理系统
|
||||
|
||||
基于 React + Vite + Ant Design 的现代化后台管理系统脚手架。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **框架**: React 18
|
||||
- **构建工具**: Vite 5
|
||||
- **UI 组件库**: Ant Design 5
|
||||
- **路由**: React Router 6
|
||||
- **状态管理**: Zustand
|
||||
- **HTTP 客户端**: Axios
|
||||
- **语言**: TypeScript
|
||||
- **日期处理**: Day.js
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
duidui_new_admin_web/
|
||||
├── public/ # 静态资源
|
||||
├── src/
|
||||
│ ├── assets/ # 资源文件
|
||||
│ ├── components/ # 公共组件
|
||||
│ ├── constants/ # 常量和枚举
|
||||
│ ├── layouts/ # 布局组件
|
||||
│ ├── pages/ # 页面组件
|
||||
│ │ ├── Dashboard/ # 仪表盘
|
||||
│ │ ├── Login/ # 登录页
|
||||
│ │ ├── User/ # 用户管理
|
||||
│ │ └── NotFound/ # 404页面
|
||||
│ ├── routes/ # 路由配置
|
||||
│ ├── store/ # 状态管理
|
||||
│ ├── types/ # 类型定义
|
||||
│ ├── utils/ # 工具函数
|
||||
│ ├── App.tsx # 应用入口
|
||||
│ ├── main.tsx # 主文件
|
||||
│ └── index.css # 全局样式
|
||||
├── index.html # HTML模板
|
||||
├── package.json # 依赖配置
|
||||
├── tsconfig.json # TS配置
|
||||
└── vite.config.ts # Vite配置
|
||||
```
|
||||
|
||||
## 功能特性
|
||||
|
||||
- ✅ 用户登录/登出
|
||||
- ✅ 路由守卫
|
||||
- ✅ 响应式布局
|
||||
- ✅ 侧边栏菜单
|
||||
- ✅ 用户信息管理
|
||||
- ✅ HTTP 请求拦截
|
||||
- ✅ 本地存储封装
|
||||
- ✅ TypeScript 类型支持
|
||||
- ✅ 枚举常量管理
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 安装依赖
|
||||
|
||||
```bash
|
||||
npm install
|
||||
# 或
|
||||
yarn install
|
||||
# 或
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### 开发模式
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
访问 http://localhost:3000
|
||||
|
||||
### 构建生产版本
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
### 预览生产构建
|
||||
|
||||
```bash
|
||||
npm run preview
|
||||
```
|
||||
|
||||
## 默认账号
|
||||
|
||||
用户名:任意
|
||||
密码:任意
|
||||
|
||||
(当前为模拟登录,可输入任意账号密码)
|
||||
|
||||
## 开发说明
|
||||
|
||||
### 路由配置
|
||||
|
||||
路由配置在 `src/routes/index.tsx` 中,使用了路由守卫来保护需要登录的页面。
|
||||
|
||||
### 状态管理
|
||||
|
||||
使用 Zustand 进行状态管理,用户信息存储在 `src/store/useUserStore.ts` 中。
|
||||
|
||||
### API 请求
|
||||
|
||||
封装了 Axios 请求,配置了请求/响应拦截器,统一处理错误和 Token。
|
||||
|
||||
### 类型定义
|
||||
|
||||
所有类型定义和枚举都在 `src/types/` 和 `src/constants/` 中,便于维护和类型检查。
|
||||
|
||||
## 后续开发建议
|
||||
|
||||
1. 接入真实的后端 API
|
||||
2. 完善用户权限控制
|
||||
3. 添加更多业务页面
|
||||
4. 完善表单验证
|
||||
5. 添加数据可视化图表
|
||||
6. 优化性能和用户体验
|
||||
7. 添加单元测试
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
69
build-deploy.sh
Executable file
69
build-deploy.sh
Executable file
|
|
@ -0,0 +1,69 @@
|
|||
#!/bin/bash
|
||||
|
||||
# 构建和打包脚本 - 在本地运行
|
||||
# 用法: ./build-deploy.sh
|
||||
|
||||
set -e
|
||||
|
||||
IMAGE_NAME="duidui-admin-web"
|
||||
TAR_FILE="deploy/duidui-admin-web.tar"
|
||||
DEPLOY_DIR="deploy"
|
||||
# 目标平台(默认构建 linux/amd64,避免在 x86 服务器上出现 arm64 运行警告)
|
||||
PLATFORM=${1:-linux/amd64}
|
||||
|
||||
echo "=========================================="
|
||||
echo "开始构建和打包 (platform: $PLATFORM)"
|
||||
echo "=========================================="
|
||||
|
||||
# 检测可用的容器工具(优先 docker,其次 podman)
|
||||
if command -v docker >/dev/null 2>&1; then
|
||||
CONTAINER_CLI="docker"
|
||||
elif command -v podman >/dev/null 2>&1; then
|
||||
CONTAINER_CLI="podman"
|
||||
else
|
||||
echo "错误: 未找到 docker 或 podman,请先安装其一。"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "使用容器工具: $CONTAINER_CLI"
|
||||
|
||||
# 检查 dist 目录是否存在
|
||||
if [ ! -d "dist" ]; then
|
||||
echo "错误: dist 目录不存在,请先运行 npm run build"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 创建 deploy 目录
|
||||
echo "创建部署目录..."
|
||||
mkdir -p $DEPLOY_DIR
|
||||
|
||||
# 构建 Docker 镜像(不使用缓存,确保使用最新的 dist 目录)
|
||||
echo "构建 Docker 镜像(不使用缓存)..."
|
||||
$CONTAINER_CLI build --no-cache --platform $PLATFORM -t $IMAGE_NAME:latest .
|
||||
|
||||
# 导出镜像为 tar 包
|
||||
echo "导出镜像为 tar 包..."
|
||||
$CONTAINER_CLI save $IMAGE_NAME:latest -o $TAR_FILE
|
||||
|
||||
# 复制部署脚本到 deploy 目录(如果还没有)
|
||||
if [ ! -f "$DEPLOY_DIR/deploy.sh" ]; then
|
||||
echo "复制部署脚本..."
|
||||
cp deploy.sh $DEPLOY_DIR/
|
||||
chmod +x $DEPLOY_DIR/deploy.sh
|
||||
fi
|
||||
|
||||
# 获取文件大小
|
||||
TAR_SIZE=$(du -h $TAR_FILE | cut -f1)
|
||||
|
||||
echo "=========================================="
|
||||
echo "构建完成!"
|
||||
echo "=========================================="
|
||||
echo "镜像名称: $IMAGE_NAME:latest"
|
||||
echo "tar 包路径: $TAR_FILE"
|
||||
echo "文件大小: $TAR_SIZE"
|
||||
echo ""
|
||||
echo "部署步骤:"
|
||||
echo "1. 将 deploy 目录下的所有文件上传到服务器"
|
||||
echo "2. 在服务器上执行: cd deploy && ./deploy.sh [端口号]"
|
||||
echo "=========================================="
|
||||
|
||||
95
deploy/deploy.sh
Executable file
95
deploy/deploy.sh
Executable file
|
|
@ -0,0 +1,95 @@
|
|||
#!/bin/bash
|
||||
|
||||
# 部署脚本 - 在服务器上运行
|
||||
# 用法: ./deploy.sh [端口号,默认 49999]
|
||||
|
||||
set -e
|
||||
|
||||
IMAGE_NAME="duidui-admin-web"
|
||||
CONTAINER_NAME="duidui-admin-web-container"
|
||||
TAR_FILE="duidui-admin-web.tar"
|
||||
NETWORK_NAME="dd.net"
|
||||
PORT=${1:-49999}
|
||||
|
||||
echo "=========================================="
|
||||
echo "开始部署 $IMAGE_NAME"
|
||||
echo "=========================================="
|
||||
|
||||
# 检查 tar 文件是否存在
|
||||
if [ ! -f "$TAR_FILE" ]; then
|
||||
echo "错误: 找不到 $TAR_FILE 文件"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 停止并删除旧容器(如果存在)
|
||||
echo "检查并清理旧容器..."
|
||||
if [ "$(docker ps -a -q -f name=$CONTAINER_NAME)" ]; then
|
||||
echo "停止旧容器..."
|
||||
docker stop $CONTAINER_NAME || true
|
||||
echo "删除旧容器..."
|
||||
docker rm $CONTAINER_NAME || true
|
||||
fi
|
||||
|
||||
# 删除旧镜像(如果存在)
|
||||
echo "检查并清理旧镜像..."
|
||||
if [ "$(docker images -q $IMAGE_NAME)" ]; then
|
||||
echo "删除旧镜像..."
|
||||
docker rmi $IMAGE_NAME || true
|
||||
fi
|
||||
|
||||
# 加载镜像
|
||||
echo "加载 Docker 镜像..."
|
||||
docker load -i $TAR_FILE
|
||||
|
||||
# 处理镜像标签(兼容 podman 构建时的 localhost/ 前缀)
|
||||
if ! docker image inspect $IMAGE_NAME:latest >/dev/null 2>&1; then
|
||||
if docker image inspect localhost/$IMAGE_NAME:latest >/dev/null 2>&1; then
|
||||
echo "检测到镜像标签为 localhost/$IMAGE_NAME:latest,重新标记为 $IMAGE_NAME:latest"
|
||||
docker tag localhost/$IMAGE_NAME:latest $IMAGE_NAME:latest
|
||||
fi
|
||||
fi
|
||||
|
||||
# 检查并创建网络(如果不存在)
|
||||
echo "检查 Docker 网络..."
|
||||
if ! docker network ls | grep -q "$NETWORK_NAME"; then
|
||||
echo "创建 Docker 网络: $NETWORK_NAME"
|
||||
docker network create $NETWORK_NAME
|
||||
else
|
||||
echo "网络 $NETWORK_NAME 已存在"
|
||||
fi
|
||||
|
||||
# 运行容器
|
||||
echo "启动容器..."
|
||||
docker run -d \
|
||||
--name $CONTAINER_NAME \
|
||||
--network $NETWORK_NAME \
|
||||
--restart unless-stopped \
|
||||
-p $PORT:80 \
|
||||
$IMAGE_NAME
|
||||
|
||||
# 检查容器状态
|
||||
echo "等待容器启动..."
|
||||
sleep 2
|
||||
|
||||
if [ "$(docker ps -q -f name=$CONTAINER_NAME)" ]; then
|
||||
echo "=========================================="
|
||||
echo "部署成功!"
|
||||
echo "=========================================="
|
||||
echo "容器名称: $CONTAINER_NAME"
|
||||
echo "Docker 网络: $NETWORK_NAME"
|
||||
echo "访问地址: http://localhost:$PORT"
|
||||
echo ""
|
||||
echo "查看容器状态: docker ps -a | grep $CONTAINER_NAME"
|
||||
echo "查看容器日志: docker logs $CONTAINER_NAME"
|
||||
echo "查看网络信息: docker network inspect $NETWORK_NAME"
|
||||
echo "停止容器: docker stop $CONTAINER_NAME"
|
||||
echo "启动容器: docker start $CONTAINER_NAME"
|
||||
echo "删除容器: docker rm -f $CONTAINER_NAME"
|
||||
else
|
||||
echo "=========================================="
|
||||
echo "部署失败!请检查日志"
|
||||
echo "=========================================="
|
||||
docker logs $CONTAINER_NAME
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BIN
deploy/duidui-admin-web.tar
Normal file
BIN
deploy/duidui-admin-web.tar
Normal file
Binary file not shown.
14
index.html
Normal file
14
index.html
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>后台管理系统</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
61
nginx.conf
Normal file
61
nginx.conf
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# 启用 gzip 压缩
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json application/javascript;
|
||||
|
||||
# API 代理配置 - 将 /api 代理到后端服务器
|
||||
location /api {
|
||||
proxy_pass https://api.duiduiedu.com;
|
||||
proxy_set_header Host api.duiduiedu.com;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# 处理跨域
|
||||
add_header Access-Control-Allow-Origin * always;
|
||||
add_header Access-Control-Allow-Methods 'GET, POST, PUT, DELETE, OPTIONS' always;
|
||||
add_header Access-Control-Allow-Headers 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
|
||||
|
||||
# 处理预检请求
|
||||
if ($request_method = 'OPTIONS') {
|
||||
add_header Access-Control-Allow-Origin *;
|
||||
add_header Access-Control-Allow-Methods 'GET, POST, PUT, DELETE, OPTIONS';
|
||||
add_header Access-Control-Allow-Headers 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization';
|
||||
add_header Access-Control-Max-Age 1728000;
|
||||
add_header Content-Type 'text/plain; charset=utf-8';
|
||||
add_header Content-Length 0;
|
||||
return 204;
|
||||
}
|
||||
|
||||
# SSL 相关配置
|
||||
proxy_ssl_verify off;
|
||||
proxy_ssl_server_name on;
|
||||
|
||||
# 超时配置
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
# 前端静态资源
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
|
||||
# 静态资源缓存配置
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
|
||||
# 错误页面
|
||||
error_page 404 /index.html;
|
||||
}
|
||||
|
||||
5549
package-lock.json
generated
Normal file
5549
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
47
package.json
Normal file
47
package.json
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
{
|
||||
"name": "duidui-admin-web",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.2.6",
|
||||
"@tiptap/extension-color": "^3.15.3",
|
||||
"@tiptap/extension-image": "^3.15.3",
|
||||
"@tiptap/extension-link": "^3.15.3",
|
||||
"@tiptap/extension-strike": "^3.15.3",
|
||||
"@tiptap/extension-text-align": "^3.15.3",
|
||||
"@tiptap/extension-text-style": "^3.15.3",
|
||||
"@tiptap/extension-underline": "^3.15.3",
|
||||
"@tiptap/react": "^3.15.3",
|
||||
"@tiptap/starter-kit": "^3.15.3",
|
||||
"antd": "^5.12.0",
|
||||
"axios": "^1.6.2",
|
||||
"dayjs": "^1.11.10",
|
||||
"quill": "^2.0.3",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-quill": "^2.0.0",
|
||||
"react-router-dom": "^6.20.0",
|
||||
"zustand": "^4.4.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
||||
"@typescript-eslint/parser": "^6.14.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"baseline-browser-mapping": "^2.9.11",
|
||||
"eslint": "^8.55.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.5",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.0.8"
|
||||
}
|
||||
}
|
||||
2
public/vite.svg
Normal file
2
public/vite.svg
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
132
src/App.tsx
Normal file
132
src/App.tsx
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import { createContext, useEffect, useMemo, useState } from 'react'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { ConfigProvider, theme } from 'antd'
|
||||
import zhCN from 'antd/locale/zh_CN'
|
||||
import AppRoutes from './routes'
|
||||
|
||||
export enum ThemeMode {
|
||||
LIGHT = 'light',
|
||||
DARK = 'dark',
|
||||
}
|
||||
|
||||
// 预设的主题颜色
|
||||
export const THEME_COLORS = [
|
||||
{ name: '蓝色', value: '#1677ff' },
|
||||
{ name: '紫色', value: '#722ed1' },
|
||||
{ name: '绿色', value: '#52c41a' },
|
||||
{ name: '橙色', value: '#fa8c16' },
|
||||
{ name: '红色', value: '#ff4d4f' },
|
||||
{ name: '青色', value: '#13c2c2' },
|
||||
{ name: '粉色', value: '#eb2f96' },
|
||||
{ name: '金色', value: '#faad14' },
|
||||
] as const
|
||||
|
||||
export interface ThemeModeContextValue {
|
||||
mode: ThemeMode
|
||||
toggleMode: () => void
|
||||
primaryColor: string
|
||||
setPrimaryColor: (color: string) => void
|
||||
}
|
||||
|
||||
export const ThemeModeContext = createContext<ThemeModeContextValue | null>(null)
|
||||
|
||||
const THEME_STORAGE_KEY = 'admin_theme_mode'
|
||||
const PRIMARY_COLOR_STORAGE_KEY = 'admin_primary_color'
|
||||
|
||||
function getInitialThemeMode(): ThemeMode {
|
||||
if (typeof window === 'undefined') {
|
||||
return ThemeMode.LIGHT
|
||||
}
|
||||
|
||||
const stored = window.localStorage.getItem(THEME_STORAGE_KEY) as ThemeMode | null
|
||||
if (stored === ThemeMode.DARK || stored === ThemeMode.LIGHT) {
|
||||
return stored
|
||||
}
|
||||
|
||||
const prefersDark =
|
||||
window.matchMedia &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
|
||||
return prefersDark ? ThemeMode.DARK : ThemeMode.LIGHT
|
||||
}
|
||||
|
||||
function getInitialPrimaryColor(): string {
|
||||
if (typeof window === 'undefined') {
|
||||
return THEME_COLORS[0].value
|
||||
}
|
||||
|
||||
const stored = window.localStorage.getItem(PRIMARY_COLOR_STORAGE_KEY)
|
||||
if (stored && THEME_COLORS.some((c) => c.value === stored)) {
|
||||
return stored
|
||||
}
|
||||
|
||||
return THEME_COLORS[0].value
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [mode, setMode] = useState<ThemeMode>(getInitialThemeMode)
|
||||
const [primaryColor, setPrimaryColorState] = useState<string>(getInitialPrimaryColor)
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') return
|
||||
document.documentElement.dataset.theme = mode
|
||||
window.localStorage.setItem(THEME_STORAGE_KEY, mode)
|
||||
}, [mode])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
window.localStorage.setItem(PRIMARY_COLOR_STORAGE_KEY, primaryColor)
|
||||
}, [primaryColor])
|
||||
|
||||
const isDark = mode === ThemeMode.DARK
|
||||
|
||||
const antdTheme = useMemo(
|
||||
() => ({
|
||||
algorithm: isDark ? theme.darkAlgorithm : theme.defaultAlgorithm,
|
||||
token: {
|
||||
colorPrimary: primaryColor,
|
||||
// 其他常用的主题颜色:
|
||||
// colorSuccess: '#52c41a', // 成功色(绿色)
|
||||
// colorWarning: '#faad14', // 警告色(橙色)
|
||||
// colorError: '#ff4d4f', // 错误色(红色)
|
||||
// colorInfo: '#1677ff', // 信息色(蓝色)
|
||||
},
|
||||
}),
|
||||
[isDark, primaryColor],
|
||||
)
|
||||
|
||||
const setPrimaryColor = (color: string) => {
|
||||
setPrimaryColorState(color)
|
||||
}
|
||||
|
||||
const contextValue = useMemo<ThemeModeContextValue>(
|
||||
() => ({
|
||||
mode,
|
||||
toggleMode: () =>
|
||||
setMode((prev) =>
|
||||
prev === ThemeMode.DARK ? ThemeMode.LIGHT : ThemeMode.DARK,
|
||||
),
|
||||
primaryColor,
|
||||
setPrimaryColor,
|
||||
}),
|
||||
[mode, primaryColor],
|
||||
)
|
||||
|
||||
return (
|
||||
<ThemeModeContext.Provider value={contextValue}>
|
||||
<ConfigProvider locale={zhCN} theme={antdTheme}>
|
||||
<BrowserRouter
|
||||
future={{
|
||||
v7_startTransition: true,
|
||||
v7_relativeSplatPath: true,
|
||||
}}
|
||||
>
|
||||
<AppRoutes />
|
||||
</BrowserRouter>
|
||||
</ConfigProvider>
|
||||
</ThemeModeContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
|
||||
163
src/api/admin.ts
Normal file
163
src/api/admin.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import request from '@/utils/request'
|
||||
|
||||
// 管理员用户接口
|
||||
export interface AdminUser {
|
||||
id: string
|
||||
username: string
|
||||
phone: string
|
||||
nickname: string
|
||||
avatar: string
|
||||
status: number // 0=禁用,1=启用
|
||||
is_super_admin: number // 0=否,1=是
|
||||
last_login_at?: string
|
||||
last_login_ip?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// 创建管理员请求
|
||||
export interface CreateAdminUserRequest {
|
||||
username?: string
|
||||
phone?: string
|
||||
password: string
|
||||
nickname?: string
|
||||
avatar?: string
|
||||
status?: number
|
||||
is_super_admin?: number
|
||||
}
|
||||
|
||||
// 更新管理员请求
|
||||
export interface UpdateAdminUserRequest {
|
||||
id: string
|
||||
username?: string
|
||||
phone?: string
|
||||
password?: string // 可选,如果为空则不更新密码
|
||||
nickname?: string
|
||||
avatar?: string
|
||||
status?: number
|
||||
is_super_admin?: number
|
||||
}
|
||||
|
||||
// 管理员列表响应
|
||||
export interface AdminUserListResponse {
|
||||
list: AdminUser[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
// 获取管理员列表
|
||||
export const getAdminUserList = (params: {
|
||||
keyword?: string
|
||||
page?: number
|
||||
page_size?: number
|
||||
}) => {
|
||||
return request.get('/admin-users/list', { params }).then((response) => {
|
||||
const backendData = response.data
|
||||
|
||||
return {
|
||||
data: {
|
||||
code: 200,
|
||||
message: backendData.message || '获取管理员列表成功',
|
||||
data: backendData.data || {
|
||||
list: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
page_size: 10,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 获取管理员详情
|
||||
export const getAdminUser = (id: string) => {
|
||||
return request.get('/admin-users/detail', { params: { id } }).then((response) => {
|
||||
const backendData = response.data
|
||||
|
||||
return {
|
||||
data: {
|
||||
code: 200,
|
||||
message: backendData.message || '获取管理员详情成功',
|
||||
data: backendData.data
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 创建管理员
|
||||
export const createAdminUser = (data: CreateAdminUserRequest) => {
|
||||
return request.post('/admin-users/create', data).then((response) => {
|
||||
const backendData = response.data
|
||||
|
||||
return {
|
||||
data: {
|
||||
code: 200,
|
||||
message: backendData.message || '创建管理员成功',
|
||||
data: backendData.data
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 更新管理员
|
||||
export const updateAdminUser = (data: UpdateAdminUserRequest) => {
|
||||
return request.post('/admin-users/update', data).then((response) => {
|
||||
const backendData = response.data
|
||||
|
||||
return {
|
||||
data: {
|
||||
code: 200,
|
||||
message: backendData.message || '更新管理员成功',
|
||||
data: backendData.data
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 删除管理员
|
||||
export const deleteAdminUser = (id: string) => {
|
||||
return request.post('/admin-users/delete', {}, { params: { id } }).then((response) => {
|
||||
const backendData = response.data
|
||||
|
||||
return {
|
||||
data: {
|
||||
code: 200,
|
||||
message: backendData.message || '删除管理员成功'
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 获取用户角色
|
||||
export const getUserRoles = (userId: string) => {
|
||||
return request.get('/admin-users/roles', { params: { user_id: userId } }).then((response) => {
|
||||
const backendData = response.data
|
||||
|
||||
return {
|
||||
data: {
|
||||
code: 200,
|
||||
message: backendData.message || '获取用户角色成功',
|
||||
data: backendData.data || []
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 设置用户角色
|
||||
export const setUserRoles = (userId: string, roleIds: string[]) => {
|
||||
return request.post('/admin-users/roles', {
|
||||
user_id: userId,
|
||||
role_ids: roleIds
|
||||
}).then((response) => {
|
||||
const backendData = response.data
|
||||
|
||||
return {
|
||||
data: {
|
||||
code: 200,
|
||||
message: backendData.message || '设置用户角色成功'
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
63
src/api/auth.ts
Normal file
63
src/api/auth.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import request from '@/utils/request'
|
||||
|
||||
// 登录请求参数
|
||||
export interface LoginRequest {
|
||||
username?: string // 用户名(可选)
|
||||
phone?: string // 手机号(可选)
|
||||
password: string // 密码(必填)
|
||||
}
|
||||
|
||||
// 登录响应
|
||||
export interface LoginResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
token?: string
|
||||
user?: AdminUserInfo
|
||||
}
|
||||
|
||||
// 管理员用户信息
|
||||
export interface AdminUserInfo {
|
||||
id: string
|
||||
username: string
|
||||
phone: string
|
||||
nickname: string
|
||||
avatar: string
|
||||
is_super_admin: boolean
|
||||
roles: string[]
|
||||
permissions: string[]
|
||||
}
|
||||
|
||||
// 获取用户信息响应
|
||||
export interface GetMeResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
user?: AdminUserInfo
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员登录(支持用户名或手机号登录)
|
||||
* @param data 登录数据
|
||||
* @returns 登录响应
|
||||
*/
|
||||
export const login = (data: LoginRequest): Promise<LoginResponse> => {
|
||||
return request.post('/auth/login', data).then((res) => {
|
||||
// 响应拦截器已经处理了 success 字段,这里直接返回 data
|
||||
return res.data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户信息
|
||||
* @returns 用户信息
|
||||
*/
|
||||
export const getMe = (): Promise<GetMeResponse> => {
|
||||
return request.get('/auth/me').then((res) => res.data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 登出
|
||||
*/
|
||||
export const logout = (): Promise<{ success: boolean; message: string }> => {
|
||||
return request.post('/auth/logout').then((res) => res.data)
|
||||
}
|
||||
|
||||
1141
src/api/camp.ts
Normal file
1141
src/api/camp.ts
Normal file
File diff suppressed because it is too large
Load Diff
66
src/api/document.ts
Normal file
66
src/api/document.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import request from '@/utils/request'
|
||||
|
||||
export interface DocFolder {
|
||||
id: string
|
||||
name: string
|
||||
sort_order: number
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
export interface DocFile {
|
||||
id: string
|
||||
folder_id: string
|
||||
name: string
|
||||
file_name: string
|
||||
file_url: string
|
||||
file_size: number
|
||||
mime_type: string
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
export const listFolders = () =>
|
||||
request.get<{ success: boolean; list: DocFolder[] }>('/document/folders').then((res) => res.data)
|
||||
|
||||
export const createFolder = (data: { name: string; sort_order?: number }) =>
|
||||
request.post<{ success: boolean; folder: DocFolder }>('/document/folders/create', {
|
||||
name: data.name,
|
||||
sort_order: data.sort_order ?? 0,
|
||||
}).then((res) => res.data)
|
||||
|
||||
export const updateFolder = (data: { id: string; name: string; sort_order?: number }) =>
|
||||
request.post<{ success: boolean }>('/document/folders/update', {
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
sort_order: data.sort_order ?? 0,
|
||||
}).then((res) => res.data)
|
||||
|
||||
export const deleteFolder = (id: string) =>
|
||||
request.post<{ success: boolean }>('/document/folders/delete', null, { params: { id } }).then((res) => res.data)
|
||||
|
||||
export const listFiles = (folderId: string) =>
|
||||
request.get<{ success: boolean; list: DocFile[] }>('/document/files', { params: { folder_id: folderId } }).then((res) => res.data)
|
||||
|
||||
export const createFile = (data: {
|
||||
folder_id: string
|
||||
name?: string
|
||||
file_name: string
|
||||
file_url: string
|
||||
file_size: number
|
||||
mime_type: string
|
||||
}) =>
|
||||
request.post<{ success: boolean; file: DocFile }>('/document/files/create', {
|
||||
folder_id: data.folder_id,
|
||||
name: data.name ?? data.file_name,
|
||||
file_name: data.file_name,
|
||||
file_url: data.file_url,
|
||||
file_size: data.file_size,
|
||||
mime_type: data.mime_type,
|
||||
}).then((res) => res.data)
|
||||
|
||||
export const updateFile = (data: { id: string; name: string }) =>
|
||||
request.post<{ success: boolean }>('/document/files/update', { id: data.id, name: data.name }).then((res) => res.data)
|
||||
|
||||
export const deleteFile = (id: string) =>
|
||||
request.post<{ success: boolean }>('/document/files/delete', null, { params: { id } }).then((res) => res.data)
|
||||
28
src/api/oss.ts
Normal file
28
src/api/oss.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import request from '@/utils/request'
|
||||
|
||||
/**
|
||||
* OSS 上传凭证响应
|
||||
*/
|
||||
export interface PolicyToken {
|
||||
policy: string
|
||||
security_token: string
|
||||
x_oss_signature_version: string
|
||||
x_oss_credential: string
|
||||
x_oss_date: string
|
||||
signature: string
|
||||
host: string
|
||||
dir: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 OSS 上传凭证
|
||||
* @param dir 上传目录,默认为 'camp'
|
||||
* @returns OSS 上传凭证
|
||||
*/
|
||||
export async function getUploadSignature(dir: string = 'camp'): Promise<PolicyToken> {
|
||||
const response = await request.get<PolicyToken>('/oss/upload/signature', {
|
||||
params: { dir },
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
140
src/api/permission.ts
Normal file
140
src/api/permission.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import request from '@/utils/request'
|
||||
|
||||
// 权限接口
|
||||
export interface Permission {
|
||||
id: string
|
||||
name: string
|
||||
code: string
|
||||
resource: string
|
||||
action: string
|
||||
description: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// 创建权限请求
|
||||
export interface CreatePermissionRequest {
|
||||
name: string
|
||||
code: string
|
||||
resource: string
|
||||
action: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
// 更新权限请求
|
||||
export interface UpdatePermissionRequest {
|
||||
id: string
|
||||
name: string
|
||||
code: string
|
||||
resource: string
|
||||
action: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
// 权限列表响应
|
||||
export interface PermissionListResponse {
|
||||
list: Permission[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
// 获取权限列表
|
||||
export const getPermissionList = (params: {
|
||||
keyword?: string
|
||||
resource?: string
|
||||
page?: number
|
||||
page_size?: number
|
||||
}) => {
|
||||
return request.get('/permissions/list', { params }).then((response) => {
|
||||
const backendData = response.data
|
||||
|
||||
return {
|
||||
data: {
|
||||
code: 200,
|
||||
message: backendData.message || '获取权限列表成功',
|
||||
data: backendData.data || {
|
||||
list: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
page_size: 10,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 获取权限详情
|
||||
export const getPermission = (id: string) => {
|
||||
return request.get('/permissions/detail', { params: { id } }).then((response) => {
|
||||
const backendData = response.data
|
||||
|
||||
return {
|
||||
data: {
|
||||
code: 200,
|
||||
message: backendData.message || '获取权限详情成功',
|
||||
data: backendData.data
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 创建权限
|
||||
export const createPermission = (data: CreatePermissionRequest) => {
|
||||
return request.post('/permissions/create', data).then((response) => {
|
||||
const backendData = response.data
|
||||
|
||||
return {
|
||||
data: {
|
||||
code: 200,
|
||||
message: backendData.message || '创建权限成功',
|
||||
data: backendData.data
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 更新权限
|
||||
export const updatePermission = (data: UpdatePermissionRequest) => {
|
||||
return request.post('/permissions/update', data).then((response) => {
|
||||
const backendData = response.data
|
||||
|
||||
return {
|
||||
data: {
|
||||
code: 200,
|
||||
message: backendData.message || '更新权限成功',
|
||||
data: backendData.data
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 删除权限
|
||||
export const deletePermission = (id: string) => {
|
||||
return request.post('/permissions/delete', {}, { params: { id } }).then((response) => {
|
||||
const backendData = response.data
|
||||
|
||||
return {
|
||||
data: {
|
||||
code: 200,
|
||||
message: backendData.message || '删除权限成功'
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 获取资源列表
|
||||
export const getResources = () => {
|
||||
return request.get('/permissions/resources').then((response) => {
|
||||
const backendData = response.data
|
||||
|
||||
return {
|
||||
data: {
|
||||
code: 200,
|
||||
message: backendData.message || '获取资源列表成功',
|
||||
data: backendData.data || []
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
910
src/api/question.ts
Normal file
910
src/api/question.ts
Normal file
|
|
@ -0,0 +1,910 @@
|
|||
import request from '@/utils/request'
|
||||
import type {
|
||||
Question,
|
||||
Paper,
|
||||
Material,
|
||||
KnowledgeTreeNode,
|
||||
CreateKnowledgeTreeNodeRequest,
|
||||
UpdateKnowledgeTreeNodeRequest,
|
||||
SearchQuestionsRequest,
|
||||
SearchPapersRequest,
|
||||
SearchMaterialsRequest,
|
||||
CreateAnswerRecordsRequest,
|
||||
AddQuestionToPaperRequest,
|
||||
RemoveQuestionFromPaperRequest,
|
||||
} from '@/types/question'
|
||||
import { MaterialType } from '@/types/question'
|
||||
|
||||
// ==================== 题目管理 ====================
|
||||
|
||||
export const createQuestion = (data: Omit<Question, 'id' | 'createdAt' | 'updatedAt'>) => {
|
||||
const apiData: any = {
|
||||
type: data.type,
|
||||
content: data.content,
|
||||
options: data.options,
|
||||
answer: data.answer,
|
||||
explanation: data.explanation,
|
||||
}
|
||||
|
||||
if (data.name) {
|
||||
apiData.name = data.name
|
||||
}
|
||||
|
||||
if (data.source) {
|
||||
apiData.source = data.source
|
||||
}
|
||||
|
||||
if (data.materialId) {
|
||||
apiData.material_id = data.materialId
|
||||
}
|
||||
|
||||
// 始终发送 knowledge_tree_ids,即使是空数组
|
||||
apiData.knowledge_tree_ids = Array.isArray(data.knowledgeTreeIds) ? data.knowledgeTreeIds : []
|
||||
|
||||
return request.post('/questions', apiData).then((response) => {
|
||||
const backendData = response.data
|
||||
|
||||
// 后端返回的数据结构可能是:
|
||||
// { question: { id: ... }, ... } 或 { id: ..., question_id: ... }
|
||||
const questionId = backendData.question?.id || backendData.id || backendData.question_id || ''
|
||||
|
||||
console.log('createQuestion API - 后端响应:', backendData)
|
||||
console.log('createQuestion API - 提取的题目ID:', questionId)
|
||||
|
||||
return {
|
||||
data: {
|
||||
code: 200,
|
||||
message: backendData.message || '创建成功',
|
||||
data: {
|
||||
id: questionId,
|
||||
question: backendData.question, // 保留完整的问题对象,方便调试
|
||||
}
|
||||
}
|
||||
}
|
||||
}).catch((error: any) => {
|
||||
console.error('创建题目失败:', error)
|
||||
const errorMsg = error?.response?.data?.message || error?.message || '创建失败'
|
||||
throw new Error(errorMsg)
|
||||
})
|
||||
}
|
||||
|
||||
export const getQuestion = (id: string) => {
|
||||
return request.get(`/questions/detail`, { params: { id } }).then((response) => {
|
||||
const backendData = response.data
|
||||
const question = backendData.question || backendData
|
||||
|
||||
// 转换题目类型字符串到数字
|
||||
const convertQuestionType = (type: any): number => {
|
||||
if (typeof type === 'number') return type
|
||||
const typeMap: Record<string, number> = {
|
||||
'QUESTION_TYPE_UNSPECIFIED': 0,
|
||||
'QUESTION_TYPE_SUBJECTIVE': 1,
|
||||
'QUESTION_TYPE_SINGLE_CHOICE': 2,
|
||||
'QUESTION_TYPE_MULTIPLE_CHOICE': 3,
|
||||
'QUESTION_TYPE_TRUE_FALSE': 4,
|
||||
}
|
||||
return typeMap[type] ?? 0
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
code: 200,
|
||||
message: backendData.message || '获取成功',
|
||||
data: {
|
||||
id: question.id,
|
||||
type: convertQuestionType(question.type),
|
||||
content: question.content,
|
||||
options: question.options || [],
|
||||
answer: question.answer,
|
||||
explanation: question.explanation,
|
||||
knowledgeTreeIds: question.knowledge_tree_ids || [],
|
||||
createdAt: question.created_at || 0,
|
||||
updatedAt: question.updated_at || 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const searchQuestions = (params: SearchQuestionsRequest) => {
|
||||
const apiData: any = {
|
||||
page: params.page,
|
||||
page_size: params.pageSize,
|
||||
}
|
||||
|
||||
if (params.query) {
|
||||
apiData.query = params.query
|
||||
}
|
||||
|
||||
if (params.type !== undefined && params.type !== 0) {
|
||||
apiData.type = params.type
|
||||
}
|
||||
|
||||
if (params.tags && params.tags.length > 0) {
|
||||
apiData.tags = params.tags
|
||||
}
|
||||
|
||||
return request.get('/questions/search', { params: apiData }).then((response) => {
|
||||
const backendData = response.data
|
||||
|
||||
let questionsList = []
|
||||
if (Array.isArray(backendData.questions)) {
|
||||
questionsList = backendData.questions
|
||||
} else if (backendData.questions && typeof backendData.questions === 'object') {
|
||||
questionsList = Object.values(backendData.questions)
|
||||
}
|
||||
|
||||
// 转换题目类型字符串到数字
|
||||
const convertQuestionType = (type: any): number => {
|
||||
if (typeof type === 'number') return type
|
||||
const typeMap: Record<string, number> = {
|
||||
'QUESTION_TYPE_UNSPECIFIED': 0,
|
||||
'QUESTION_TYPE_SUBJECTIVE': 1,
|
||||
'QUESTION_TYPE_SINGLE_CHOICE': 2,
|
||||
'QUESTION_TYPE_MULTIPLE_CHOICE': 3,
|
||||
'QUESTION_TYPE_TRUE_FALSE': 4,
|
||||
}
|
||||
return typeMap[type] ?? 0
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
code: 200,
|
||||
message: backendData.message,
|
||||
data: {
|
||||
list: questionsList.map((item: any) => {
|
||||
// 调试:检查原始数据(包括所有字段)
|
||||
console.log('API原始数据 - 完整对象:', item)
|
||||
console.log('API原始数据 - 题目ID:', item.id)
|
||||
console.log('API原始数据 - knowledge_tree_ids字段:', item.knowledge_tree_ids)
|
||||
console.log('API原始数据 - knowledge_tree_ids类型:', typeof item.knowledge_tree_ids)
|
||||
console.log('API原始数据 - knowledge_tree_ids是否为数组:', Array.isArray(item.knowledge_tree_ids))
|
||||
|
||||
const knowledgeTreeIds = Array.isArray(item.knowledge_tree_ids) ? item.knowledge_tree_ids : []
|
||||
const knowledgeTreeNames = Array.isArray(item.knowledge_tree_names) ? item.knowledge_tree_names : []
|
||||
console.log('API转换后 - 知识树IDs:', knowledgeTreeIds)
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
type: convertQuestionType(item.type),
|
||||
content: item.content,
|
||||
options: Array.isArray(item.options) ? item.options : [],
|
||||
answer: item.answer,
|
||||
explanation: item.explanation,
|
||||
knowledgeTreeIds: knowledgeTreeIds,
|
||||
knowledgeTreeNames: knowledgeTreeNames,
|
||||
name: item.name || '',
|
||||
source: item.source || '',
|
||||
materialId: item.material_id || '',
|
||||
createdAt: item.created_at || 0,
|
||||
updatedAt: item.updated_at || 0,
|
||||
}
|
||||
}),
|
||||
total: backendData.total || 0,
|
||||
page: params.page,
|
||||
pageSize: params.pageSize,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const updateQuestion = (data: Question) => {
|
||||
const apiData: any = {
|
||||
id: data.id,
|
||||
type: data.type,
|
||||
content: data.content,
|
||||
options: data.options,
|
||||
answer: data.answer,
|
||||
explanation: data.explanation,
|
||||
}
|
||||
|
||||
if (data.name) {
|
||||
apiData.name = data.name
|
||||
}
|
||||
|
||||
if (data.source) {
|
||||
apiData.source = data.source
|
||||
}
|
||||
|
||||
if (data.materialId) {
|
||||
apiData.material_id = data.materialId
|
||||
}
|
||||
|
||||
// 始终发送 knowledge_tree_ids,即使是空数组
|
||||
apiData.knowledge_tree_ids = Array.isArray(data.knowledgeTreeIds) ? data.knowledgeTreeIds : []
|
||||
|
||||
console.log('API发送的完整数据:', JSON.stringify(apiData, null, 2))
|
||||
console.log('API发送的知识树IDs:', apiData.knowledge_tree_ids)
|
||||
|
||||
return request.post('/questions/update', apiData).then((response) => {
|
||||
const backendData = response.data
|
||||
|
||||
return {
|
||||
data: {
|
||||
code: 200,
|
||||
message: backendData.message || '更新成功',
|
||||
}
|
||||
}
|
||||
}).catch((error: any) => {
|
||||
console.error('更新题目失败:', error)
|
||||
const errorMsg = error?.response?.data?.message || error?.message || '更新失败'
|
||||
throw new Error(errorMsg)
|
||||
})
|
||||
}
|
||||
|
||||
export const deleteQuestion = (id: string) => {
|
||||
return request.post(`/questions/delete?id=${id}`).then((response) => {
|
||||
const backendData = response.data
|
||||
|
||||
return {
|
||||
data: {
|
||||
code: 200,
|
||||
message: backendData.message || '删除成功',
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const batchDeleteQuestions = (questionIds: string[]) => {
|
||||
return request.post('/questions/batch_delete', { question_ids: questionIds }).then((response) => {
|
||||
const backendData = response.data
|
||||
|
||||
return {
|
||||
data: {
|
||||
code: 200,
|
||||
message: backendData.message || '批量删除成功',
|
||||
data: {
|
||||
deletedCount: backendData.deleted_count || 0,
|
||||
failedQuestionIds: backendData.failed_question_ids || [],
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== 试卷管理 ====================
|
||||
|
||||
export const createPaper = (data: Omit<Paper, 'id' | 'questions' | 'createdAt' | 'updatedAt'>) => {
|
||||
const apiData: any = {
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
question_ids: data.questionIds || [],
|
||||
}
|
||||
|
||||
if (data.source) {
|
||||
apiData.source = data.source
|
||||
}
|
||||
|
||||
if (data.materialIds && data.materialIds.length > 0) {
|
||||
apiData.material_ids = data.materialIds
|
||||
}
|
||||
|
||||
console.log('API createPaper - 发送的数据:', apiData)
|
||||
console.log('API createPaper - question_ids:', apiData.question_ids)
|
||||
|
||||
return request.post('/papers/create', apiData).then((response) => {
|
||||
const backendData = response.data
|
||||
|
||||
return {
|
||||
data: {
|
||||
code: 200,
|
||||
message: backendData.message || '创建成功',
|
||||
data: {
|
||||
id: backendData.id || backendData.paper_id || '',
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const getPaper = (id: string) => {
|
||||
return request.get(`/papers/detail`, { params: { id } }).then((response) => {
|
||||
const backendData = response.data
|
||||
const paper = backendData.paper || backendData
|
||||
|
||||
// questions 可能是对象或数组,需要处理
|
||||
let questionsList = []
|
||||
if (Array.isArray(paper.questions)) {
|
||||
questionsList = paper.questions
|
||||
} else if (paper.questions && typeof paper.questions === 'object') {
|
||||
questionsList = Object.values(paper.questions)
|
||||
}
|
||||
|
||||
// question_ids 可能是对象或数组
|
||||
let questionIds = []
|
||||
if (Array.isArray(paper.question_ids)) {
|
||||
questionIds = paper.question_ids
|
||||
} else if (paper.question_ids && typeof paper.question_ids === 'object') {
|
||||
questionIds = Object.values(paper.question_ids)
|
||||
}
|
||||
|
||||
// material_ids 可能是对象或数组
|
||||
let materialIds = []
|
||||
if (Array.isArray(paper.material_ids)) {
|
||||
materialIds = paper.material_ids
|
||||
} else if (paper.material_ids && typeof paper.material_ids === 'object') {
|
||||
materialIds = Object.values(paper.material_ids)
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
code: 200,
|
||||
message: backendData.message || '获取成功',
|
||||
data: {
|
||||
id: paper.id,
|
||||
title: paper.title,
|
||||
description: paper.description,
|
||||
source: paper.source || '',
|
||||
questionIds,
|
||||
materialIds: materialIds,
|
||||
knowledgeTreeIds: paper.knowledge_tree_ids || [],
|
||||
questions: questionsList,
|
||||
createdAt: paper.created_at || 0,
|
||||
updatedAt: paper.updated_at || 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const searchPapers = (params: SearchPapersRequest) => {
|
||||
const apiData: any = {
|
||||
page: params.page,
|
||||
page_size: params.pageSize,
|
||||
}
|
||||
|
||||
if (params.query) {
|
||||
apiData.query = params.query
|
||||
}
|
||||
|
||||
return request.get('/papers/search', { params: apiData }).then((response) => {
|
||||
const backendData = response.data
|
||||
|
||||
let papersList = []
|
||||
if (Array.isArray(backendData.papers)) {
|
||||
papersList = backendData.papers
|
||||
} else if (backendData.papers && typeof backendData.papers === 'object') {
|
||||
papersList = Object.values(backendData.papers)
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
code: 200,
|
||||
message: backendData.message,
|
||||
data: {
|
||||
list: papersList.map((item: any) => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
source: item.source || '',
|
||||
questionIds: item.question_ids || [],
|
||||
materialIds: item.material_ids || [],
|
||||
knowledgeTreeIds: item.knowledge_tree_ids || [],
|
||||
knowledgeTreeNames: item.knowledge_tree_names || [],
|
||||
questions: item.questions || [],
|
||||
createdAt: item.created_at || 0,
|
||||
updatedAt: item.updated_at || 0,
|
||||
})),
|
||||
total: backendData.total || 0,
|
||||
page: params.page,
|
||||
pageSize: params.pageSize,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const updatePaper = (data: Paper) => {
|
||||
const apiData: any = {
|
||||
id: data.id,
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
question_ids: data.questionIds || [],
|
||||
}
|
||||
|
||||
if (data.source) {
|
||||
apiData.source = data.source
|
||||
}
|
||||
|
||||
if (data.materialIds && data.materialIds.length > 0) {
|
||||
apiData.material_ids = data.materialIds
|
||||
}
|
||||
|
||||
console.log('API updatePaper - 发送的数据:', apiData)
|
||||
console.log('API updatePaper - question_ids:', apiData.question_ids)
|
||||
|
||||
return request.post('/papers/update', apiData).then((response) => {
|
||||
const backendData = response.data
|
||||
|
||||
return {
|
||||
data: {
|
||||
code: 200,
|
||||
message: backendData.message || '更新成功',
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const deletePaper = (id: string) => {
|
||||
return request.post(`/papers/delete?id=${id}`).then((response) => {
|
||||
const backendData = response.data
|
||||
|
||||
return {
|
||||
data: {
|
||||
code: 200,
|
||||
message: backendData.message || '删除成功',
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const batchDeletePapers = (paperIds: string[]) => {
|
||||
return request.post('/papers/batch_delete', { paper_ids: paperIds }).then((response) => {
|
||||
const backendData = response.data
|
||||
|
||||
return {
|
||||
data: {
|
||||
code: 200,
|
||||
message: backendData.message || '批量删除成功',
|
||||
data: {
|
||||
deletedCount: backendData.deleted_count || 0,
|
||||
failedPaperIds: backendData.failed_paper_ids || [],
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const addQuestionToPaper = (data: AddQuestionToPaperRequest) => {
|
||||
const apiData = {
|
||||
paper_id: data.paperId,
|
||||
question_ids: data.questionIds,
|
||||
}
|
||||
|
||||
return request.post('/papers/add_question', apiData).then((response) => {
|
||||
const backendData = response.data
|
||||
|
||||
return {
|
||||
data: {
|
||||
code: 200,
|
||||
message: backendData.message || '添加成功',
|
||||
data: {
|
||||
addedCount: backendData.added_count || 0,
|
||||
failedQuestionIds: backendData.failed_question_ids || [],
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const removeQuestionFromPaper = (data: RemoveQuestionFromPaperRequest) => {
|
||||
const apiData = {
|
||||
paper_id: data.paperId,
|
||||
question_ids: data.questionIds,
|
||||
}
|
||||
|
||||
return request.post('/papers/remove_question', apiData).then((response) => {
|
||||
const backendData = response.data
|
||||
|
||||
return {
|
||||
data: {
|
||||
code: 200,
|
||||
message: backendData.message || '移除成功',
|
||||
data: {
|
||||
removedCount: backendData.removed_count || 0,
|
||||
failedQuestionIds: backendData.failed_question_ids || [],
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== 答题记录管理 ====================
|
||||
|
||||
export const createAnswerRecords = (data: CreateAnswerRecordsRequest) => {
|
||||
const apiData = {
|
||||
user_id: data.userId,
|
||||
paper_id: data.paperId,
|
||||
answers: data.answers.map((a) => ({
|
||||
question_id: a.questionId,
|
||||
user_answer: a.userAnswer,
|
||||
})),
|
||||
start_time: data.startTime,
|
||||
end_time: data.endTime,
|
||||
}
|
||||
|
||||
return request.post('/answer-records/create', apiData).then((response) => {
|
||||
const backendData = response.data
|
||||
|
||||
return {
|
||||
data: {
|
||||
code: 200,
|
||||
message: backendData.message || '提交成功',
|
||||
data: {
|
||||
results: backendData.results || [],
|
||||
totalQuestions: backendData.total_questions || 0,
|
||||
correctCount: backendData.correct_count || 0,
|
||||
wrongCount: backendData.wrong_count || 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const getAnswerRecord = (id: string) => {
|
||||
return request.get(`/answer-records/detail`, { params: { id } }).then((response) => {
|
||||
const backendData = response.data
|
||||
const record = backendData.record || backendData
|
||||
|
||||
return {
|
||||
data: {
|
||||
code: 200,
|
||||
message: backendData.message || '获取成功',
|
||||
data: {
|
||||
id: record.id,
|
||||
userId: record.user_id,
|
||||
questionId: record.question_id,
|
||||
paperId: record.paper_id,
|
||||
userAnswer: record.user_answer,
|
||||
correctAnswer: record.correct_answer,
|
||||
isCorrect: record.is_correct ?? false,
|
||||
startTime: record.start_time || 0,
|
||||
endTime: record.end_time || 0,
|
||||
createdAt: record.created_at || 0,
|
||||
updatedAt: record.updated_at || 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const getUserAnswerRecord = (userId: string, questionId: string) => {
|
||||
return request.get('/answer-records/user', {
|
||||
params: { user_id: userId, question_id: questionId },
|
||||
}).then((response) => {
|
||||
const backendData = response.data
|
||||
const record = backendData.record || backendData
|
||||
|
||||
return {
|
||||
data: {
|
||||
code: 200,
|
||||
message: backendData.message || '获取成功',
|
||||
data: record ? {
|
||||
id: record.id,
|
||||
userId: record.user_id,
|
||||
questionId: record.question_id,
|
||||
paperId: record.paper_id,
|
||||
userAnswer: record.user_answer,
|
||||
correctAnswer: record.correct_answer,
|
||||
isCorrect: record.is_correct ?? false,
|
||||
startTime: record.start_time || 0,
|
||||
endTime: record.end_time || 0,
|
||||
createdAt: record.created_at || 0,
|
||||
updatedAt: record.updated_at || 0,
|
||||
} : null
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const getPaperAnswerStatistics = (userId: string, paperId: string) => {
|
||||
return request.get('/answer-records/statistics', {
|
||||
params: {
|
||||
user_id: userId,
|
||||
paper_id: paperId,
|
||||
}
|
||||
}).then((response) => {
|
||||
const backendData = response.data
|
||||
|
||||
return {
|
||||
data: {
|
||||
code: 200,
|
||||
message: backendData.message || '获取成功',
|
||||
data: {
|
||||
userId: backendData.user_id,
|
||||
paperId: backendData.paper_id,
|
||||
totalQuestions: backendData.total_questions || 0,
|
||||
answeredQuestions: backendData.answered_questions || 0,
|
||||
correctAnswers: backendData.correct_answers || 0,
|
||||
wrongAnswers: backendData.wrong_answers || 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const deleteAnswerRecord = (userId: string) => {
|
||||
return request.post(`/answer-records/delete?user_id=${userId}`).then((response) => {
|
||||
const backendData = response.data
|
||||
|
||||
return {
|
||||
data: {
|
||||
code: 200,
|
||||
message: backendData.message || '删除成功',
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== 材料管理 ====================
|
||||
|
||||
export const createMaterial = (data: Omit<Material, 'id' | 'createdAt' | 'updatedAt'>) => {
|
||||
const apiData = {
|
||||
type: data.type || MaterialType.OBJECTIVE,
|
||||
name: data.name || '',
|
||||
content: data.content,
|
||||
}
|
||||
|
||||
return request.post('/materials', apiData).then((response) => {
|
||||
const backendData = response.data
|
||||
|
||||
return {
|
||||
data: {
|
||||
code: 200,
|
||||
message: backendData.message || '创建成功',
|
||||
data: {
|
||||
id: backendData.id || backendData.material_id || '',
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const getMaterial = (id: string) => {
|
||||
return request.get(`/materials/detail`, { params: { id } }).then((response) => {
|
||||
const backendData = response.data
|
||||
const material = backendData.material || backendData
|
||||
|
||||
return {
|
||||
data: {
|
||||
code: 200,
|
||||
message: backendData.message || '获取成功',
|
||||
data: {
|
||||
id: material.id || material.material_id || '',
|
||||
type: material.type || MaterialType.OBJECTIVE,
|
||||
name: material.name || '',
|
||||
content: material.content || '',
|
||||
createdAt: material.created_at || material.createdAt || 0,
|
||||
updatedAt: material.updated_at || material.updatedAt || 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const searchMaterials = (params: SearchMaterialsRequest) => {
|
||||
const apiParams: any = {
|
||||
page: params.page,
|
||||
page_size: params.pageSize,
|
||||
}
|
||||
|
||||
if (params.query) {
|
||||
apiParams.query = params.query
|
||||
}
|
||||
|
||||
if (params.type) {
|
||||
apiParams.type = params.type
|
||||
}
|
||||
|
||||
return request.get('/materials/search', { params: apiParams }).then((response) => {
|
||||
const backendData = response.data
|
||||
const materialsList = backendData.materials || backendData.list || []
|
||||
|
||||
return {
|
||||
data: {
|
||||
code: 200,
|
||||
message: backendData.message || '获取成功',
|
||||
data: {
|
||||
list: materialsList.map((item: any) => ({
|
||||
id: item.id || item.material_id || '',
|
||||
type: item.type || MaterialType.OBJECTIVE,
|
||||
name: item.name || '',
|
||||
content: item.content || '',
|
||||
createdAt: item.created_at || item.createdAt || 0,
|
||||
updatedAt: item.updated_at || item.updatedAt || 0,
|
||||
})),
|
||||
total: backendData.total || 0,
|
||||
page: params.page,
|
||||
pageSize: params.pageSize,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const updateMaterial = (data: Material) => {
|
||||
const apiData = {
|
||||
id: data.id,
|
||||
type: data.type || MaterialType.OBJECTIVE,
|
||||
name: data.name || '',
|
||||
content: data.content,
|
||||
}
|
||||
|
||||
return request.put(`/materials/${data.id}`, apiData).then((response) => {
|
||||
const backendData = response.data
|
||||
|
||||
return {
|
||||
data: {
|
||||
code: 200,
|
||||
message: backendData.message || '更新成功',
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const deleteMaterial = (id: string) => {
|
||||
return request.delete(`/materials/${id}`).then((response) => {
|
||||
const backendData = response.data
|
||||
|
||||
return {
|
||||
data: {
|
||||
code: 200,
|
||||
message: backendData.message || '删除成功',
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== 知识树管理 ====================
|
||||
|
||||
// 创建知识树节点
|
||||
export const createKnowledgeTreeNode = (data: CreateKnowledgeTreeNodeRequest) => {
|
||||
const apiData = {
|
||||
type: data.type,
|
||||
title: data.title,
|
||||
parent_id: data.parentId !== undefined ? data.parentId : '',
|
||||
}
|
||||
|
||||
return request.post('/knowledge-trees', apiData).then((response) => {
|
||||
const backendData = response.data
|
||||
|
||||
return {
|
||||
data: {
|
||||
code: 200,
|
||||
message: backendData.message || '创建成功',
|
||||
data: {
|
||||
id: backendData.node?.id || '',
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 获取知识树节点详情
|
||||
export const getKnowledgeTreeNode = (id: string) => {
|
||||
return request.get('/knowledge-trees/detail', { params: { id } }).then((response) => {
|
||||
const backendData = response.data
|
||||
const node = backendData.node || backendData
|
||||
|
||||
return {
|
||||
data: {
|
||||
code: 200,
|
||||
message: backendData.message || '获取成功',
|
||||
data: {
|
||||
id: node.id || '',
|
||||
type: node.type || '',
|
||||
title: node.title || '',
|
||||
parentId: node.parent_id || node.parentId || '',
|
||||
createdAt: node.created_at || node.createdAt || 0,
|
||||
updatedAt: node.updated_at || node.updatedAt || 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 获取所有知识树节点(扁平列表)
|
||||
export const getAllKnowledgeTreeNodes = (type: 'objective' | 'subjective') => {
|
||||
return request.get('/knowledge-trees/list', { params: { type } }).then((response) => {
|
||||
const backendData = response.data
|
||||
const nodes = backendData.nodes || []
|
||||
|
||||
return {
|
||||
data: {
|
||||
code: 200,
|
||||
message: backendData.message || '获取成功',
|
||||
data: {
|
||||
list: nodes.map((item: any) => ({
|
||||
id: item.id || '',
|
||||
type: item.type || '',
|
||||
title: item.title || '',
|
||||
parentId: item.parent_id || item.parentId || '',
|
||||
createdAt: item.created_at || item.createdAt || 0,
|
||||
updatedAt: item.updated_at || item.updatedAt || 0,
|
||||
})),
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 根据父节点ID获取子节点列表
|
||||
export const getKnowledgeTreeByParentID = (parentId: string = '', type: 'objective' | 'subjective') => {
|
||||
return request.get('/knowledge-trees/children', { params: { parent_id: parentId, type } }).then((response) => {
|
||||
const backendData = response.data
|
||||
const nodes = backendData.nodes || []
|
||||
|
||||
return {
|
||||
data: {
|
||||
code: 200,
|
||||
message: backendData.message || '获取成功',
|
||||
data: {
|
||||
list: nodes.map((item: any) => ({
|
||||
id: item.id || '',
|
||||
type: item.type || '',
|
||||
title: item.title || '',
|
||||
parentId: item.parent_id || item.parentId || '',
|
||||
createdAt: item.created_at || item.createdAt || 0,
|
||||
updatedAt: item.updated_at || item.updatedAt || 0,
|
||||
})),
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 获取完整知识树
|
||||
export const getKnowledgeTree = (type: 'objective' | 'subjective') => {
|
||||
return request.get('/knowledge-trees/tree', { params: { type } }).then((response) => {
|
||||
const backendData = response.data
|
||||
const tree = backendData.tree || []
|
||||
|
||||
// 递归转换树形结构
|
||||
const convertTree = (nodes: any[]): KnowledgeTreeNode[] => {
|
||||
return nodes.map((item: any) => ({
|
||||
id: item.id || '',
|
||||
type: item.type || '',
|
||||
title: item.title || '',
|
||||
parentId: item.parent_id || item.parentId || '',
|
||||
children: item.children ? convertTree(item.children) : undefined,
|
||||
createdAt: item.created_at || item.createdAt || 0,
|
||||
updatedAt: item.updated_at || item.updatedAt || 0,
|
||||
}))
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
code: 200,
|
||||
message: backendData.message || '获取成功',
|
||||
data: {
|
||||
tree: convertTree(tree),
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 更新知识树节点
|
||||
export const updateKnowledgeTreeNode = (data: UpdateKnowledgeTreeNodeRequest) => {
|
||||
const apiData = {
|
||||
id: data.id,
|
||||
title: data.title,
|
||||
parent_id: data.parentId || '',
|
||||
}
|
||||
|
||||
return request.post('/knowledge-trees/update', apiData).then((response) => {
|
||||
const backendData = response.data
|
||||
|
||||
return {
|
||||
data: {
|
||||
code: 200,
|
||||
message: backendData.message || '更新成功',
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 删除知识树节点
|
||||
export const deleteKnowledgeTreeNode = (id: string) => {
|
||||
return request.delete(`/knowledge-trees/${id}`).then((response) => {
|
||||
const backendData = response.data
|
||||
|
||||
return {
|
||||
data: {
|
||||
code: 200,
|
||||
message: backendData.message || '删除成功',
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
153
src/api/role.ts
Normal file
153
src/api/role.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import request from '@/utils/request'
|
||||
|
||||
// 角色接口
|
||||
export interface Role {
|
||||
id: string
|
||||
name: string
|
||||
code: string
|
||||
description: string
|
||||
status: number // 0=禁用,1=启用
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// 创建角色请求
|
||||
export interface CreateRoleRequest {
|
||||
name: string
|
||||
code?: string // 可选,如果不提供则由后端自动生成
|
||||
description?: string
|
||||
status?: number
|
||||
}
|
||||
|
||||
// 更新角色请求
|
||||
export interface UpdateRoleRequest {
|
||||
id: string
|
||||
name: string
|
||||
code: string
|
||||
description?: string
|
||||
status?: number
|
||||
}
|
||||
|
||||
// 角色列表响应
|
||||
export interface RoleListResponse {
|
||||
list: Role[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
// 获取角色列表
|
||||
export const getRoleList = (params: {
|
||||
keyword?: string
|
||||
page?: number
|
||||
page_size?: number
|
||||
}) => {
|
||||
return request.get('/roles/list', { params }).then((response) => {
|
||||
const backendData = response.data
|
||||
|
||||
return {
|
||||
data: {
|
||||
code: 200,
|
||||
message: backendData.message || '获取角色列表成功',
|
||||
data: backendData.data || {
|
||||
list: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
page_size: 10,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 获取角色详情
|
||||
export const getRole = (id: string) => {
|
||||
return request.get('/roles/detail', { params: { id } }).then((response) => {
|
||||
const backendData = response.data
|
||||
|
||||
return {
|
||||
data: {
|
||||
code: 200,
|
||||
message: backendData.message || '获取角色详情成功',
|
||||
data: backendData.data
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 创建角色
|
||||
export const createRole = (data: CreateRoleRequest) => {
|
||||
return request.post('/roles/create', data).then((response) => {
|
||||
const backendData = response.data
|
||||
|
||||
return {
|
||||
data: {
|
||||
code: 200,
|
||||
message: backendData.message || '创建角色成功',
|
||||
data: backendData.data
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 更新角色
|
||||
export const updateRole = (data: UpdateRoleRequest) => {
|
||||
return request.post('/roles/update', data).then((response) => {
|
||||
const backendData = response.data
|
||||
|
||||
return {
|
||||
data: {
|
||||
code: 200,
|
||||
message: backendData.message || '更新角色成功',
|
||||
data: backendData.data
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 删除角色
|
||||
export const deleteRole = (id: string) => {
|
||||
return request.post('/roles/delete', {}, { params: { id } }).then((response) => {
|
||||
const backendData = response.data
|
||||
|
||||
return {
|
||||
data: {
|
||||
code: 200,
|
||||
message: backendData.message || '删除角色成功'
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 获取角色权限
|
||||
export const getRolePermissions = (roleId: string) => {
|
||||
return request.get('/roles/permissions', { params: { role_id: roleId } }).then((response) => {
|
||||
const backendData = response.data
|
||||
|
||||
return {
|
||||
data: {
|
||||
code: 200,
|
||||
message: backendData.message || '获取角色权限成功',
|
||||
data: backendData.data || []
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 设置角色权限
|
||||
export const setRolePermissions = (roleId: string, permissionIds: string[]) => {
|
||||
return request.post('/roles/permissions', {
|
||||
role_id: roleId,
|
||||
permission_ids: permissionIds
|
||||
}).then((response) => {
|
||||
const backendData = response.data
|
||||
|
||||
return {
|
||||
data: {
|
||||
code: 200,
|
||||
message: backendData.message || '设置角色权限成功'
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
31
src/api/statistics.ts
Normal file
31
src/api/statistics.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import request from '@/utils/request'
|
||||
|
||||
// 统计数据接口
|
||||
export interface Statistics {
|
||||
user_count: number // 用户总数
|
||||
camp_count: number // 打卡营数量
|
||||
question_count: number // 题目数量
|
||||
paper_count: number // 试卷数量
|
||||
}
|
||||
|
||||
// 获取仪表盘统计数据
|
||||
export const getDashboardStatistics = () => {
|
||||
return request.get('/statistics/dashboard').then((response) => {
|
||||
const backendData = response.data
|
||||
|
||||
// 转换为前端期望的格式
|
||||
return {
|
||||
data: {
|
||||
code: 200,
|
||||
message: backendData.message || '获取统计数据成功',
|
||||
data: backendData.data || {
|
||||
user_count: 0,
|
||||
camp_count: 0,
|
||||
question_count: 0,
|
||||
paper_count: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
212
src/components/ImageUpload.tsx
Normal file
212
src/components/ImageUpload.tsx
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
import { useState } from 'react'
|
||||
import { Upload, Button, message, Image } from 'antd'
|
||||
import { UploadOutlined, DeleteOutlined } from '@ant-design/icons'
|
||||
import type { UploadProps } from 'antd'
|
||||
import { getUploadSignature, type PolicyToken } from '@/api/oss'
|
||||
import request from '@/utils/request'
|
||||
|
||||
interface ImageUploadProps {
|
||||
value?: string
|
||||
onChange?: (url: string) => void
|
||||
placeholder?: string
|
||||
maxSize?: number // MB
|
||||
accept?: string
|
||||
}
|
||||
|
||||
const ImageUpload: React.FC<ImageUploadProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = '请选择图片',
|
||||
maxSize = 5,
|
||||
accept = 'image/*',
|
||||
}) => {
|
||||
const [uploading, setUploading] = useState(false)
|
||||
|
||||
// 获取OSS上传凭证
|
||||
const getUploadCredentials = async (dir: string = 'camp'): Promise<PolicyToken> => {
|
||||
try {
|
||||
return await getUploadSignature(dir)
|
||||
} catch (error) {
|
||||
throw new Error('获取上传凭证失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 通过后端代理上传(备用方案)
|
||||
const uploadViaBackend = async (file: File) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('dir', 'camp')
|
||||
|
||||
try {
|
||||
const response = await request.post('/upload_to_oss', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
})
|
||||
return response.data.url
|
||||
} catch (error) {
|
||||
throw new Error('上传失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
// 上传到OSS
|
||||
const uploadToOSS = async (file: File, credentials: PolicyToken) => {
|
||||
// 使用后端返回的 host
|
||||
const host = credentials.host
|
||||
|
||||
// 生成唯一的文件名(使用时间戳 + 随机数)
|
||||
const timestamp = Date.now()
|
||||
const random = Math.random().toString(36).substring(2, 8)
|
||||
const fileExtension = file.name.split('.').pop()
|
||||
const fileName = `${timestamp}_${random}.${fileExtension}`
|
||||
const key = `${credentials.dir}${fileName}`
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('success_action_status', '200')
|
||||
formData.append('policy', credentials.policy)
|
||||
formData.append('x-oss-signature', credentials.signature)
|
||||
formData.append('x-oss-signature-version', credentials.x_oss_signature_version)
|
||||
formData.append('x-oss-credential', credentials.x_oss_credential)
|
||||
formData.append('x-oss-date', credentials.x_oss_date)
|
||||
formData.append('key', key)
|
||||
formData.append('x-oss-security-token', credentials.security_token)
|
||||
formData.append('file', file)
|
||||
|
||||
console.log('上传参数:', {
|
||||
host: host,
|
||||
key: key,
|
||||
fileSize: file.size,
|
||||
fileName: fileName
|
||||
})
|
||||
|
||||
const response = await fetch(host, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
console.log('上传响应:', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: Object.fromEntries(response.headers.entries())
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
console.error('上传失败详情:', errorText)
|
||||
throw new Error(`上传失败: ${response.status} - ${errorText}`)
|
||||
}
|
||||
|
||||
// 返回完整的图片URL
|
||||
return `${host}/${key}`
|
||||
}
|
||||
|
||||
const handleUpload: UploadProps['customRequest'] = async ({ file, onSuccess, onError }) => {
|
||||
const fileObj = file as File
|
||||
|
||||
// 检查文件大小
|
||||
if (fileObj.size > maxSize * 1024 * 1024) {
|
||||
message.error(`文件大小不能超过 ${maxSize}MB`)
|
||||
onError?.(new Error(`文件大小不能超过 ${maxSize}MB`))
|
||||
return
|
||||
}
|
||||
|
||||
// 检查文件类型
|
||||
if (!fileObj.type.startsWith('image/')) {
|
||||
message.error('只能上传图片文件')
|
||||
onError?.(new Error('只能上传图片文件'))
|
||||
return
|
||||
}
|
||||
|
||||
setUploading(true)
|
||||
|
||||
try {
|
||||
// 1. 获取上传凭证
|
||||
const credentials = await getUploadCredentials('camp')
|
||||
|
||||
// 2. 尝试直接上传到OSS
|
||||
try {
|
||||
const imageUrl = await uploadToOSS(fileObj, credentials)
|
||||
|
||||
// 3. 更新表单值
|
||||
onChange?.(imageUrl)
|
||||
onSuccess?.(imageUrl)
|
||||
message.success('图片上传成功')
|
||||
} catch (corsError: any) {
|
||||
// 如果CORS错误,尝试后端代理上传
|
||||
if (corsError.message.includes('CORS') || corsError.message.includes('Access-Control-Allow-Origin')) {
|
||||
console.log('检测到CORS错误,尝试后端代理上传...')
|
||||
const imageUrl = await uploadViaBackend(fileObj)
|
||||
|
||||
onChange?.(imageUrl)
|
||||
onSuccess?.(imageUrl)
|
||||
message.success('图片上传成功(通过后端代理)')
|
||||
} else {
|
||||
throw corsError
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('上传失败:', error)
|
||||
message.error(error.message || '上传失败,请重试')
|
||||
onError?.(error)
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemove = () => {
|
||||
onChange?.('')
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap' }}>
|
||||
{/* 图片预览 */}
|
||||
{value && (
|
||||
<div style={{ position: 'relative', display: 'inline-block' }}>
|
||||
<Image
|
||||
src={value}
|
||||
alt="预览"
|
||||
width={80}
|
||||
height={80}
|
||||
style={{ objectFit: 'cover', borderRadius: 4 }}
|
||||
preview={{
|
||||
mask: false,
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={handleRemove}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -8,
|
||||
right: -8,
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: '50%',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 上传按钮:始终显示,允许重新上传 */}
|
||||
<Upload
|
||||
accept={accept}
|
||||
showUploadList={false}
|
||||
customRequest={handleUpload}
|
||||
disabled={uploading}
|
||||
>
|
||||
<Button
|
||||
icon={<UploadOutlined />}
|
||||
loading={uploading}
|
||||
disabled={uploading}
|
||||
>
|
||||
{uploading ? '上传中...' : (value ? '重新上传' : placeholder)}
|
||||
</Button>
|
||||
</Upload>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ImageUpload
|
||||
315
src/components/KnowledgeTree/index.tsx
Normal file
315
src/components/KnowledgeTree/index.tsx
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Tree, Button, Input, Modal, Form, message, Space, Popconfirm } from 'antd'
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||
import type { DataNode } from 'antd/es/tree'
|
||||
import type { KnowledgeTreeNode, CreateKnowledgeTreeNodeRequest, UpdateKnowledgeTreeNodeRequest } from '@/types/question'
|
||||
import {
|
||||
getKnowledgeTree,
|
||||
createKnowledgeTreeNode,
|
||||
updateKnowledgeTreeNode,
|
||||
deleteKnowledgeTreeNode,
|
||||
} from '@/api/question'
|
||||
|
||||
interface KnowledgeTreeProps {
|
||||
type: 'objective' | 'subjective' // 知识树类型
|
||||
onSelect?: (node: KnowledgeTreeNode | null) => void
|
||||
}
|
||||
|
||||
const STORAGE_KEY_PREFIX = 'knowledge_tree_expanded_'
|
||||
|
||||
const KnowledgeTree: React.FC<KnowledgeTreeProps> = ({ type, onSelect }) => {
|
||||
const [treeData, setTreeData] = useState<DataNode[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([])
|
||||
const [expandedKeys, setExpandedKeys] = useState<React.Key[]>([])
|
||||
const [modalVisible, setModalVisible] = useState(false)
|
||||
const [editingNode, setEditingNode] = useState<KnowledgeTreeNode | null>(null)
|
||||
const [parentNodeId, setParentNodeId] = useState<string>('')
|
||||
const [form] = Form.useForm()
|
||||
|
||||
// 将知识树节点转换为 Ant Design Tree 的 DataNode 格式
|
||||
const convertToTreeData = (nodes: KnowledgeTreeNode[]): DataNode[] => {
|
||||
return nodes.map((node) => ({
|
||||
title: node.title,
|
||||
key: node.id,
|
||||
children: node.children ? convertToTreeData(node.children) : undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
// 从 localStorage 读取上次展开的 key 列表(仅保留当前树中仍存在的 key)
|
||||
const loadExpandedKeys = (validKeys: React.Key[]): React.Key[] => {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY_PREFIX + type)
|
||||
if (!raw) return []
|
||||
const saved = JSON.parse(raw) as React.Key[]
|
||||
if (!Array.isArray(saved)) return []
|
||||
return saved.filter((k) => validKeys.includes(k))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const saveExpandedKeys = (keys: React.Key[]) => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY_PREFIX + type, JSON.stringify(keys))
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
// 获取知识树数据
|
||||
const fetchTree = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await getKnowledgeTree(type)
|
||||
if (res.data.code === 200) {
|
||||
const tree = res.data.data.tree || []
|
||||
const convertedTree = convertToTreeData(tree)
|
||||
setTreeData(convertedTree)
|
||||
const validKeys = getAllKeys(convertedTree)
|
||||
const restored = loadExpandedKeys(validKeys)
|
||||
setExpandedKeys(restored)
|
||||
} else {
|
||||
message.error(res.data.message || '获取知识树失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error('获取知识树失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取所有节点的key(用于展开)
|
||||
const getAllKeys = (nodes: DataNode[]): React.Key[] => {
|
||||
let keys: React.Key[] = []
|
||||
nodes.forEach((node) => {
|
||||
keys.push(node.key)
|
||||
if (node.children) {
|
||||
keys = keys.concat(getAllKeys(node.children))
|
||||
}
|
||||
})
|
||||
return keys
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchTree()
|
||||
}, [])
|
||||
|
||||
// 处理节点选择
|
||||
const handleSelect = (selectedKeys: React.Key[]) => {
|
||||
setSelectedKeys(selectedKeys)
|
||||
if (onSelect && selectedKeys.length > 0) {
|
||||
// 这里需要从原始数据中找到对应的节点
|
||||
// 为了简化,我们只传递ID,让父组件自己处理
|
||||
onSelect({ id: selectedKeys[0] as string } as KnowledgeTreeNode)
|
||||
} else if (onSelect) {
|
||||
onSelect(null)
|
||||
}
|
||||
}
|
||||
|
||||
// 打开创建节点弹窗
|
||||
const handleAddNode = (parentId: string = '') => {
|
||||
setParentNodeId(parentId)
|
||||
setEditingNode(null)
|
||||
form.resetFields()
|
||||
form.setFieldsValue({ title: '' })
|
||||
setModalVisible(true)
|
||||
}
|
||||
|
||||
// 打开编辑节点弹窗
|
||||
const handleEditNode = (nodeId: string) => {
|
||||
// 从树数据中查找节点信息
|
||||
const findNode = (nodes: KnowledgeTreeNode[], id: string): KnowledgeTreeNode | null => {
|
||||
for (const node of nodes) {
|
||||
if (node.id === id) {
|
||||
return node
|
||||
}
|
||||
if (node.children) {
|
||||
const found = findNode(node.children, id)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 需要重新获取完整树数据来找到节点
|
||||
getKnowledgeTree(type).then((res) => {
|
||||
if (res.data.code === 200) {
|
||||
const tree = res.data.data.tree || []
|
||||
const node = findNode(tree, nodeId)
|
||||
if (node) {
|
||||
setEditingNode(node)
|
||||
setParentNodeId(node.parentId)
|
||||
form.setFieldsValue({ title: node.title })
|
||||
setModalVisible(true)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 删除节点
|
||||
const handleDeleteNode = async (nodeId: string) => {
|
||||
try {
|
||||
const res = await deleteKnowledgeTreeNode(nodeId)
|
||||
if (res.data.code === 200) {
|
||||
message.success('删除成功')
|
||||
fetchTree()
|
||||
if (selectedKeys.includes(nodeId)) {
|
||||
setSelectedKeys([])
|
||||
if (onSelect) {
|
||||
onSelect(null)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
message.error(res.data.message || '删除失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.response?.data?.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 提交表单(创建或更新)
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields()
|
||||
|
||||
if (editingNode) {
|
||||
// 更新节点
|
||||
const updateData: UpdateKnowledgeTreeNodeRequest = {
|
||||
id: editingNode.id,
|
||||
title: values.title,
|
||||
parentId: parentNodeId,
|
||||
}
|
||||
const res = await updateKnowledgeTreeNode(updateData)
|
||||
if (res.data.code === 200) {
|
||||
message.success('更新成功')
|
||||
setModalVisible(false)
|
||||
fetchTree()
|
||||
} else {
|
||||
message.error(res.data.message || '更新失败')
|
||||
}
|
||||
} else {
|
||||
// 创建节点
|
||||
const createData: CreateKnowledgeTreeNodeRequest = {
|
||||
type: type,
|
||||
title: values.title,
|
||||
parentId: parentNodeId || '', // 确保根节点时 parentId 为空字符串
|
||||
}
|
||||
const res = await createKnowledgeTreeNode(createData)
|
||||
if (res.data.code === 200) {
|
||||
message.success('创建成功')
|
||||
setModalVisible(false)
|
||||
fetchTree()
|
||||
} else {
|
||||
message.error(res.data.message || '创建失败')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// 表单验证失败
|
||||
}
|
||||
}
|
||||
|
||||
// 自定义树节点标题(添加操作按钮)
|
||||
const renderTitle = (node: DataNode) => {
|
||||
// 确保 title 是字符串类型
|
||||
const title = typeof node.title === 'string' ? node.title : String(node.title || '')
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}>
|
||||
<span>{title}</span>
|
||||
<Space size="small" onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => handleAddNode(node.key as string)}
|
||||
title="添加子节点"
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleEditNode(node.key as string)}
|
||||
title="编辑节点"
|
||||
/>
|
||||
<Popconfirm
|
||||
title="确定要删除这个节点吗?"
|
||||
description="删除后无法恢复,且如果有子节点将无法删除"
|
||||
onConfirm={() => handleDeleteNode(node.key as string)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
title="删除节点"
|
||||
/>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h3 style={{ margin: 0 }}>知识树</h3>
|
||||
<Space>
|
||||
<Button
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => handleAddNode('')}
|
||||
type="primary"
|
||||
>
|
||||
添加根节点
|
||||
</Button>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={fetchTree}
|
||||
loading={loading}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Tree
|
||||
treeData={treeData}
|
||||
selectedKeys={selectedKeys}
|
||||
expandedKeys={expandedKeys}
|
||||
onSelect={handleSelect}
|
||||
onExpand={(keys) => {
|
||||
setExpandedKeys(keys)
|
||||
saveExpandedKeys(keys)
|
||||
}}
|
||||
titleRender={renderTitle}
|
||||
showLine
|
||||
blockNode
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editingNode ? '编辑节点' : '添加节点'}
|
||||
open={modalVisible}
|
||||
onOk={handleSubmit}
|
||||
onCancel={() => {
|
||||
setModalVisible(false)
|
||||
form.resetFields()
|
||||
}}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
name="title"
|
||||
label="节点标题"
|
||||
rules={[{ required: true, message: '请输入节点标题' }]}
|
||||
>
|
||||
<Input placeholder="请输入节点标题" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default KnowledgeTree
|
||||
44
src/components/Permission/PermissionWrapper.tsx
Normal file
44
src/components/Permission/PermissionWrapper.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { ReactNode } from 'react'
|
||||
import { useHasPermission } from '@/utils/permission'
|
||||
|
||||
interface PermissionWrapperProps {
|
||||
/**
|
||||
* 需要的权限代码
|
||||
*/
|
||||
permission: string
|
||||
/**
|
||||
* 子组件
|
||||
*/
|
||||
children: ReactNode
|
||||
/**
|
||||
* 无权限时显示的内容(可选)
|
||||
*/
|
||||
fallback?: ReactNode
|
||||
/**
|
||||
* 是否反向显示(有权限时隐藏,无权限时显示)
|
||||
*/
|
||||
reverse?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 权限包装组件
|
||||
* 根据用户权限决定是否显示子组件
|
||||
*/
|
||||
export const PermissionWrapper = ({
|
||||
permission,
|
||||
children,
|
||||
fallback = null,
|
||||
reverse = false,
|
||||
}: PermissionWrapperProps) => {
|
||||
const hasPermission = useHasPermission(permission)
|
||||
|
||||
const shouldShow = reverse ? !hasPermission : hasPermission
|
||||
|
||||
return shouldShow ? <>{children}</> : <>{fallback}</>
|
||||
}
|
||||
|
||||
/**
|
||||
* 权限检查组件(简化版)
|
||||
*/
|
||||
export const RequirePermission = PermissionWrapper
|
||||
|
||||
2
src/components/Permission/index.ts
Normal file
2
src/components/Permission/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { PermissionWrapper, RequirePermission } from './PermissionWrapper'
|
||||
|
||||
173
src/components/RichTextEditor/RichTextEditor.css
Normal file
173
src/components/RichTextEditor/RichTextEditor.css
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
/* Tiptap 富文本编辑器样式 */
|
||||
.rich-text-editor-tiptap {
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.rich-text-editor-tiptap:hover {
|
||||
border-color: #4096ff;
|
||||
}
|
||||
|
||||
.rich-text-editor-tiptap:focus-within {
|
||||
border-color: #4096ff;
|
||||
box-shadow: 0 0 0 2px rgba(5, 145, 255, 0.1);
|
||||
}
|
||||
|
||||
/* 工具栏 */
|
||||
.rich-text-editor-toolbar {
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
background: #fafafa;
|
||||
border-radius: 6px 6px 0 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* 编辑器内容区 */
|
||||
.rich-text-editor-wrapper {
|
||||
padding: 12px;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
/* Tiptap 编辑器内容样式 */
|
||||
.rich-text-editor-content {
|
||||
outline: none;
|
||||
min-height: 150px;
|
||||
}
|
||||
|
||||
.rich-text-editor-content p {
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
.rich-text-editor-content p.is-editor-empty:first-child::before {
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
color: #bfbfbf;
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.rich-text-editor-content h1 {
|
||||
font-size: 2em;
|
||||
font-weight: bold;
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
.rich-text-editor-content h2 {
|
||||
font-size: 1.5em;
|
||||
font-weight: bold;
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
.rich-text-editor-content h3 {
|
||||
font-size: 1.17em;
|
||||
font-weight: bold;
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
.rich-text-editor-content ul,
|
||||
.rich-text-editor-content ol {
|
||||
padding-left: 1.5em;
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
.rich-text-editor-content li {
|
||||
margin: 0.25em 0;
|
||||
}
|
||||
|
||||
.rich-text-editor-content a {
|
||||
color: #1890ff;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rich-text-editor-content a:hover {
|
||||
color: #40a9ff;
|
||||
}
|
||||
|
||||
.rich-text-editor-content img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
margin: 0.5em 0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.rich-text-editor-content blockquote {
|
||||
border-left: 3px solid #d9d9d9;
|
||||
padding-left: 1em;
|
||||
margin: 0.5em 0;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.rich-text-editor-content code {
|
||||
background: #f5f5f5;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.rich-text-editor-content pre {
|
||||
background: #f5f5f5;
|
||||
padding: 1em;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
.rich-text-editor-content pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* 暗色模式支持 */
|
||||
[data-theme='dark'] .rich-text-editor-tiptap {
|
||||
background: #141414;
|
||||
border-color: #434343;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .rich-text-editor-tiptap:hover {
|
||||
border-color: #4096ff;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .rich-text-editor-toolbar {
|
||||
background: #1f1f1f;
|
||||
border-bottom-color: #434343;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .rich-text-editor-content {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .rich-text-editor-content p.is-editor-empty:first-child::before {
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .rich-text-editor-content blockquote {
|
||||
border-left-color: #434343;
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .rich-text-editor-content code,
|
||||
[data-theme='dark'] .rich-text-editor-content pre {
|
||||
background: #262626;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
/* Tiptap 特定样式 */
|
||||
.ProseMirror {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.ProseMirror-focused {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* 选中文本样式 */
|
||||
.ProseMirror-selectednode {
|
||||
outline: 2px solid #1890ff;
|
||||
}
|
||||
248
src/components/RichTextEditor/RichTextEditor.tsx
Normal file
248
src/components/RichTextEditor/RichTextEditor.tsx
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
import { useEditor, EditorContent } from '@tiptap/react'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import Image from '@tiptap/extension-image'
|
||||
import Link from '@tiptap/extension-link'
|
||||
import Underline from '@tiptap/extension-underline'
|
||||
import Strike from '@tiptap/extension-strike'
|
||||
import { TextStyle } from '@tiptap/extension-text-style'
|
||||
import Color from '@tiptap/extension-color'
|
||||
import TextAlign from '@tiptap/extension-text-align'
|
||||
import { memo, useEffect } from 'react'
|
||||
import { message, Button, Space } from 'antd'
|
||||
import {
|
||||
BoldOutlined,
|
||||
ItalicOutlined,
|
||||
UnderlineOutlined,
|
||||
StrikethroughOutlined,
|
||||
OrderedListOutlined,
|
||||
UnorderedListOutlined,
|
||||
LinkOutlined,
|
||||
PictureOutlined,
|
||||
UndoOutlined,
|
||||
RedoOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { getUploadSignature } from '@/api/oss'
|
||||
import './RichTextEditor.css'
|
||||
|
||||
interface RichTextEditorProps {
|
||||
value?: string
|
||||
onChange?: (value: string) => void
|
||||
placeholder?: string
|
||||
rows?: number
|
||||
}
|
||||
|
||||
// 富文本编辑器(基于 Tiptap)
|
||||
const RichTextEditor: React.FC<RichTextEditorProps> = memo(({
|
||||
value = '',
|
||||
onChange,
|
||||
placeholder = '请输入内容',
|
||||
rows = 4,
|
||||
}) => {
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: {
|
||||
levels: [1, 2, 3],
|
||||
},
|
||||
// 禁用 StarterKit 中已包含的扩展,使用单独的扩展以支持更多配置
|
||||
strike: false,
|
||||
link: false,
|
||||
}),
|
||||
Underline,
|
||||
Strike,
|
||||
Image.configure({
|
||||
inline: true,
|
||||
allowBase64: false,
|
||||
}),
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
HTMLAttributes: {
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
},
|
||||
}),
|
||||
TextStyle,
|
||||
Color,
|
||||
TextAlign.configure({
|
||||
types: ['heading', 'paragraph'],
|
||||
}),
|
||||
],
|
||||
content: value,
|
||||
onUpdate: ({ editor }) => {
|
||||
const html = editor.getHTML()
|
||||
onChange?.(html)
|
||||
},
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: 'rich-text-editor-content',
|
||||
'data-placeholder': placeholder,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// 当外部 value 变化时更新编辑器内容
|
||||
useEffect(() => {
|
||||
if (editor && value !== editor.getHTML()) {
|
||||
editor.commands.setContent(value, { emitUpdate: false })
|
||||
}
|
||||
}, [value, editor])
|
||||
|
||||
// 图片上传处理
|
||||
const handleImageUpload = async () => {
|
||||
const input = document.createElement('input')
|
||||
input.setAttribute('type', 'file')
|
||||
input.setAttribute('accept', 'image/*')
|
||||
input.click()
|
||||
|
||||
input.onchange = async () => {
|
||||
const file = input.files?.[0]
|
||||
if (!file || !editor) return
|
||||
|
||||
try {
|
||||
message.loading({ content: '正在上传图片...', key: 'upload', duration: 0 })
|
||||
|
||||
// 获取上传凭证
|
||||
const credentials = await getUploadSignature('question')
|
||||
|
||||
// 生成唯一文件名
|
||||
const fileExtension = file.name.split('.').pop() || 'jpg'
|
||||
const fileName = `${Date.now()}_${Math.random().toString(36).substring(7)}.${fileExtension}`
|
||||
const key = `${credentials.dir}${fileName}`
|
||||
|
||||
// 构建 FormData
|
||||
const formData = new FormData()
|
||||
formData.append('success_action_status', '200')
|
||||
formData.append('policy', credentials.policy)
|
||||
formData.append('x-oss-signature', credentials.signature)
|
||||
formData.append('x-oss-signature-version', credentials.x_oss_signature_version)
|
||||
formData.append('x-oss-credential', credentials.x_oss_credential)
|
||||
formData.append('x-oss-date', credentials.x_oss_date)
|
||||
formData.append('key', key)
|
||||
formData.append('x-oss-security-token', credentials.security_token)
|
||||
formData.append('file', file)
|
||||
|
||||
// 上传到 OSS
|
||||
const uploadRes = await fetch(credentials.host, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (!uploadRes.ok) {
|
||||
throw new Error('上传失败')
|
||||
}
|
||||
|
||||
// 构建图片 URL
|
||||
const imageUrl = `${credentials.host}/${key}`
|
||||
|
||||
// 插入图片到编辑器
|
||||
editor.chain().focus().setImage({ src: imageUrl }).run()
|
||||
|
||||
message.success({ content: '图片上传成功', key: 'upload' })
|
||||
} catch (error: any) {
|
||||
console.error('图片上传失败:', error)
|
||||
message.error({ content: error.message || '图片上传失败', key: 'upload' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 添加链接
|
||||
const handleAddLink = () => {
|
||||
if (!editor) return
|
||||
|
||||
const url = window.prompt('请输入链接地址:')
|
||||
if (url) {
|
||||
editor.chain().focus().setLink({ href: url }).run()
|
||||
}
|
||||
}
|
||||
|
||||
if (!editor) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rich-text-editor-tiptap" style={{ minHeight: `${rows * 24 + 100}px` }}>
|
||||
{/* 工具栏 */}
|
||||
<div className="rich-text-editor-toolbar">
|
||||
<Space size="small" wrap>
|
||||
<Button
|
||||
type={editor.isActive('bold') ? 'primary' : 'default'}
|
||||
size="small"
|
||||
icon={<BoldOutlined />}
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
title="加粗"
|
||||
/>
|
||||
<Button
|
||||
type={editor.isActive('italic') ? 'primary' : 'default'}
|
||||
size="small"
|
||||
icon={<ItalicOutlined />}
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
title="斜体"
|
||||
/>
|
||||
<Button
|
||||
type={editor.isActive('underline') ? 'primary' : 'default'}
|
||||
size="small"
|
||||
icon={<UnderlineOutlined />}
|
||||
onClick={() => editor.chain().focus().toggleUnderline().run()}
|
||||
title="下划线"
|
||||
/>
|
||||
<Button
|
||||
type={editor.isActive('strike') ? 'primary' : 'default'}
|
||||
size="small"
|
||||
icon={<StrikethroughOutlined />}
|
||||
onClick={() => editor.chain().focus().toggleStrike().run()}
|
||||
title="删除线"
|
||||
/>
|
||||
<Button
|
||||
type={editor.isActive('bulletList') ? 'primary' : 'default'}
|
||||
size="small"
|
||||
icon={<UnorderedListOutlined />}
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
title="无序列表"
|
||||
/>
|
||||
<Button
|
||||
type={editor.isActive('orderedList') ? 'primary' : 'default'}
|
||||
size="small"
|
||||
icon={<OrderedListOutlined />}
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
title="有序列表"
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<LinkOutlined />}
|
||||
onClick={handleAddLink}
|
||||
title="添加链接"
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<PictureOutlined />}
|
||||
onClick={handleImageUpload}
|
||||
title="插入图片"
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<UndoOutlined />}
|
||||
onClick={() => editor.chain().focus().undo().run()}
|
||||
disabled={!editor.can().undo()}
|
||||
title="撤销"
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<RedoOutlined />}
|
||||
onClick={() => editor.chain().focus().redo().run()}
|
||||
disabled={!editor.can().redo()}
|
||||
title="重做"
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* 编辑器内容区 */}
|
||||
<div className="rich-text-editor-wrapper">
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
RichTextEditor.displayName = 'RichTextEditor'
|
||||
|
||||
export default RichTextEditor
|
||||
187
src/components/SubjectiveQuestionForm/index.tsx
Normal file
187
src/components/SubjectiveQuestionForm/index.tsx
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { Modal, Form, Input, TreeSelect, message } from 'antd'
|
||||
import type { Question, KnowledgeTreeNode } from '@/types/question'
|
||||
import { QuestionType } from '@/types/question'
|
||||
import { createQuestion, updateQuestion, getKnowledgeTree } from '@/api/question'
|
||||
import RichTextEditor from '@/components/RichTextEditor/RichTextEditor'
|
||||
|
||||
interface SubjectiveQuestionFormProps {
|
||||
visible: boolean
|
||||
editingRecord?: Question | null
|
||||
onCancel: () => void
|
||||
onSuccess?: (questionId: string) => void // 创建/编辑成功后回调,传递题目ID
|
||||
}
|
||||
|
||||
const SubjectiveQuestionForm = ({
|
||||
visible,
|
||||
editingRecord,
|
||||
onCancel,
|
||||
onSuccess,
|
||||
}: SubjectiveQuestionFormProps) => {
|
||||
const [form] = Form.useForm()
|
||||
const [knowledgeTreeData, setKnowledgeTreeData] = useState<KnowledgeTreeNode[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
// 获取知识树
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
const fetchKnowledgeTree = async () => {
|
||||
try {
|
||||
const res = await getKnowledgeTree('subjective')
|
||||
if (res.data.code === 200) {
|
||||
const tree = res.data.data?.tree || []
|
||||
setKnowledgeTreeData(tree)
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('获取知识树错误:', error)
|
||||
}
|
||||
}
|
||||
fetchKnowledgeTree()
|
||||
}
|
||||
}, [visible])
|
||||
|
||||
// 设置表单初始值
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
if (editingRecord) {
|
||||
form.setFieldsValue({
|
||||
name: editingRecord.name || '',
|
||||
source: editingRecord.source || '',
|
||||
content: editingRecord.content || '',
|
||||
explanation: editingRecord.explanation || '',
|
||||
knowledgeTreeIds: editingRecord.knowledgeTreeIds || [],
|
||||
})
|
||||
} else {
|
||||
form.resetFields()
|
||||
form.setFieldsValue({
|
||||
knowledgeTreeIds: [],
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [visible, editingRecord, form])
|
||||
|
||||
// 将知识树转换为 TreeSelect 格式:有下级的节点展开但不可选,仅最低层级(叶子)可选
|
||||
const convertKnowledgeTreeToTreeData = (nodes: KnowledgeTreeNode[]): any[] => {
|
||||
return nodes.map((node) => {
|
||||
const children = node.children ?? []
|
||||
const hasChildren = children.length > 0
|
||||
return {
|
||||
title: node.title,
|
||||
value: node.id,
|
||||
key: node.id,
|
||||
disabled: hasChildren, // 有下级则不可选,只有叶子节点可选
|
||||
children: hasChildren ? convertKnowledgeTreeToTreeData(children) : undefined,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields()
|
||||
|
||||
const payload: Omit<Question, 'id' | 'createdAt' | 'updatedAt'> = {
|
||||
id: editingRecord?.id || '',
|
||||
type: QuestionType.SUBJECTIVE,
|
||||
name: values.name || '',
|
||||
source: values.source || '',
|
||||
materialId: undefined,
|
||||
content: values.content,
|
||||
options: [],
|
||||
answer: '',
|
||||
explanation: values.explanation || '',
|
||||
knowledgeTreeIds: values.knowledgeTreeIds || [],
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
} as any
|
||||
|
||||
setLoading(true)
|
||||
|
||||
if (editingRecord) {
|
||||
// 编辑模式
|
||||
await updateQuestion({
|
||||
...editingRecord,
|
||||
...payload,
|
||||
} as Question)
|
||||
message.success('编辑题目成功')
|
||||
if (onSuccess && editingRecord.id) {
|
||||
onSuccess(editingRecord.id)
|
||||
}
|
||||
} else {
|
||||
// 新增模式
|
||||
const res = await createQuestion(payload)
|
||||
// 提取题目ID
|
||||
const questionId =
|
||||
res.data.data?.id ||
|
||||
res.data.data?.question?.id ||
|
||||
''
|
||||
|
||||
if (questionId) {
|
||||
message.success('新增题目成功')
|
||||
if (onSuccess) {
|
||||
onSuccess(questionId)
|
||||
}
|
||||
} else {
|
||||
message.error('创建题目失败:无法获取题目ID')
|
||||
}
|
||||
}
|
||||
|
||||
form.resetFields()
|
||||
onCancel()
|
||||
} catch (error: any) {
|
||||
const errorMsg = error?.message || '操作失败'
|
||||
message.error(errorMsg)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={editingRecord ? '编辑主观题' : '新增主观题'}
|
||||
open={visible}
|
||||
onOk={handleSubmit}
|
||||
onCancel={onCancel}
|
||||
width={900}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
confirmLoading={loading}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item label="题目名称" name="name">
|
||||
<Input placeholder="请输入题目名称(可选,用于搜索)" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="题目出处" name="source">
|
||||
<Input placeholder="请输入题目出处(可选)" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="题干"
|
||||
name="content"
|
||||
rules={[{ required: true, message: '请输入题干内容' }]}
|
||||
>
|
||||
<RichTextEditor placeholder="请输入题干内容" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="解析" name="explanation">
|
||||
<RichTextEditor placeholder="请输入解析内容(可选)" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="关联题型分类(知识树)" name="knowledgeTreeIds" extra="有下级的节点会展开,仅最低层级(叶子节点)可选">
|
||||
<TreeSelect
|
||||
treeData={convertKnowledgeTreeToTreeData(knowledgeTreeData)}
|
||||
placeholder="请选择知识树节点(仅叶子节点可选,可多选)"
|
||||
treeCheckable
|
||||
showCheckedStrategy="SHOW_ALL"
|
||||
allowClear
|
||||
multiple
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default SubjectiveQuestionForm
|
||||
60
src/constants/index.ts
Normal file
60
src/constants/index.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
// 本地存储键名
|
||||
export const STORAGE_KEYS = {
|
||||
TOKEN: 'admin_token',
|
||||
USER_INFO: 'admin_user_info',
|
||||
} as const
|
||||
|
||||
// API 响应码
|
||||
export enum ApiCode {
|
||||
SUCCESS = 200,
|
||||
BAD_REQUEST = 400,
|
||||
UNAUTHORIZED = 401,
|
||||
FORBIDDEN = 403,
|
||||
NOT_FOUND = 404,
|
||||
SERVER_ERROR = 500,
|
||||
}
|
||||
|
||||
// 默认分页配置
|
||||
export const DEFAULT_PAGE_SIZE = 10
|
||||
export const PAGE_SIZE_OPTIONS = ['10', '20', '50', '100']
|
||||
|
||||
// 路由路径
|
||||
export enum RoutePath {
|
||||
LOGIN = '/login',
|
||||
HOME = '/',
|
||||
DASHBOARD = '/dashboard',
|
||||
USER_LIST = '/user/list',
|
||||
USER_DETAIL = '/user/detail/:id',
|
||||
ROLE_LIST = '/admin/role/list',
|
||||
PERMISSION_LIST = '/admin/permission/list',
|
||||
// 打卡营管理
|
||||
CAMP_CATEGORY_LIST = '/camp/category',
|
||||
CAMP_LIST = '/camp/list',
|
||||
CAMP_SECTION_LIST = '/camp/section',
|
||||
CAMP_TASK_LIST = '/camp/task',
|
||||
CAMP_PROGRESS_LIST = '/camp/progress',
|
||||
CAMP_PENDING_TASK_LIST = '/camp/task-review',
|
||||
// 客观题管理
|
||||
OBJECTIVE_QUESTION_LIST = '/objective/question/list', // 题目管理
|
||||
OBJECTIVE_MATERIAL_LIST = '/objective/material/list', // 材料管理
|
||||
OBJECTIVE_PAPER_LIST = '/objective/paper/list', // 组卷管理
|
||||
OBJECTIVE_PAPER_EDIT = '/objective/paper/edit', // 组卷编辑(新增/编辑)
|
||||
OBJECTIVE_KNOWLEDGE_TREE = '/objective/knowledge-tree', // 知识树
|
||||
// 主观题管理
|
||||
SUBJECTIVE_QUESTION_LIST = '/subjective/question/list',
|
||||
SUBJECTIVE_MATERIAL_LIST = '/subjective/material/list', // 材料管理
|
||||
SUBJECTIVE_PAPER_LIST = '/subjective/paper/list', // 组卷管理
|
||||
SUBJECTIVE_KNOWLEDGE_TREE = '/subjective/knowledge-tree', // 知识树
|
||||
// 文档管理
|
||||
DOCUMENT_LIST = '/document',
|
||||
// 兼容旧路由
|
||||
QUESTION_LIST = '/question/list',
|
||||
PAPER_LIST = '/question/paper/list',
|
||||
PAPER_QUESTIONS = '/question/paper/:paperId/questions',
|
||||
ANSWER_RECORD_LIST = '/question/answer-record',
|
||||
NOT_FOUND = '*',
|
||||
}
|
||||
|
||||
// 从 types 中重新导出常用枚举,方便使用
|
||||
export { UserRole, MenuType } from '@/types'
|
||||
|
||||
248
src/index.css
Normal file
248
src/index.css
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #f0f2f5;
|
||||
}
|
||||
|
||||
[data-theme='dark'] body {
|
||||
background-color: #000000;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
|
||||
/* 响应式全局样式 */
|
||||
@media (max-width: 768px) {
|
||||
/* 移动端字体大小调整 */
|
||||
body {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 页面容器响应式 - 处理所有直接子 div 的 padding */
|
||||
.layout-content > div {
|
||||
padding: 16px !important;
|
||||
}
|
||||
|
||||
/* 页面标题区域响应式 */
|
||||
.layout-content > div > div:first-child {
|
||||
margin-bottom: 16px !important;
|
||||
}
|
||||
|
||||
.layout-content > div > div:first-child > h2 {
|
||||
font-size: 18px !important;
|
||||
margin-bottom: 8px !important;
|
||||
}
|
||||
|
||||
.layout-content > div > div:first-child > div {
|
||||
font-size: 12px !important;
|
||||
margin-top: 4px !important;
|
||||
}
|
||||
|
||||
/* 页面标题响应式 */
|
||||
h2 {
|
||||
font-size: 18px !important;
|
||||
}
|
||||
|
||||
/* 表格响应式 */
|
||||
.ant-table-wrapper {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.ant-table {
|
||||
min-width: 600px;
|
||||
}
|
||||
|
||||
.ant-table-thead > tr > th,
|
||||
.ant-table-tbody > tr > td {
|
||||
padding: 8px 4px !important;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 表单响应式 */
|
||||
.ant-form-item {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.ant-form-item-label {
|
||||
padding-bottom: 4px !important;
|
||||
}
|
||||
|
||||
/* Modal 响应式 */
|
||||
.ant-modal {
|
||||
max-width: calc(100vw - 32px) !important;
|
||||
margin: 16px auto !important;
|
||||
top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.ant-modal-content {
|
||||
max-height: calc(100vh - 32px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.ant-modal-header {
|
||||
padding: 16px !important;
|
||||
}
|
||||
|
||||
.ant-modal-body {
|
||||
padding: 16px !important;
|
||||
}
|
||||
|
||||
.ant-modal-footer {
|
||||
padding: 12px 16px !important;
|
||||
}
|
||||
|
||||
/* Card 响应式 */
|
||||
.ant-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.ant-card-head {
|
||||
padding: 12px 16px !important;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.ant-card-body {
|
||||
padding: 16px !important;
|
||||
}
|
||||
|
||||
/* Space 组件响应式 */
|
||||
.ant-space {
|
||||
flex-wrap: wrap;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ant-space-item {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 搜索和筛选区域响应式 */
|
||||
.ant-space-horizontal > .ant-space-item {
|
||||
width: 100%;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.ant-space-horizontal > .ant-space-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Input.Search 在移动端 */
|
||||
.ant-input-search.ant-input-search-enter-button > .ant-input-group > .ant-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 按钮组响应式 */
|
||||
.ant-btn-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* 搜索框响应式 */
|
||||
.ant-input-search {
|
||||
width: 100% !important;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* Select 下拉框响应式 */
|
||||
.ant-select {
|
||||
width: 100% !important;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* 按钮响应式 */
|
||||
.ant-btn {
|
||||
font-size: 14px;
|
||||
height: 36px;
|
||||
padding: 4px 12px;
|
||||
}
|
||||
|
||||
/* 输入框响应式 */
|
||||
.ant-input,
|
||||
.ant-input-number {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 标签响应式 */
|
||||
.ant-tag {
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
margin: 2px;
|
||||
}
|
||||
|
||||
/* 分页响应式 */
|
||||
.ant-pagination {
|
||||
margin: 16px 0 !important;
|
||||
}
|
||||
|
||||
.ant-pagination-item,
|
||||
.ant-pagination-prev,
|
||||
.ant-pagination-next {
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
/* Transfer 组件响应式 */
|
||||
.ant-transfer {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.ant-transfer-list {
|
||||
width: calc(50% - 8px) !important;
|
||||
}
|
||||
|
||||
/* ImageUpload 组件响应式 */
|
||||
.ant-upload {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Drawer 响应式 */
|
||||
.ant-drawer-content-wrapper {
|
||||
width: 280px !important;
|
||||
}
|
||||
|
||||
/* Statistic 统计卡片响应式 */
|
||||
.ant-statistic-title {
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
.ant-statistic-content {
|
||||
font-size: 20px !important;
|
||||
}
|
||||
|
||||
/* Row 和 Col 响应式 */
|
||||
.ant-row {
|
||||
margin-left: -8px !important;
|
||||
margin-right: -8px !important;
|
||||
}
|
||||
|
||||
.ant-col {
|
||||
padding-left: 8px !important;
|
||||
padding-right: 8px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 平板样式 */
|
||||
@media (min-width: 769px) and (max-width: 1024px) {
|
||||
body {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
122
src/layouts/BasicLayout.css
Normal file
122
src/layouts/BasicLayout.css
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
.basic-layout {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.logo h1 {
|
||||
color: white;
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.trigger {
|
||||
padding: 0 24px;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.trigger:hover {
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
padding: 6px 10px;
|
||||
border-radius: 16px;
|
||||
transition: background-color 0.2s, color 0.2s;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.theme-toggle-text {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.user-info:hover {
|
||||
background-color: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.username {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 移动端样式 */
|
||||
@media (max-width: 768px) {
|
||||
.header-content {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.trigger {
|
||||
padding: 0 16px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.layout-content {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* 隐藏移动端侧边栏 */
|
||||
.mobile-sider {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 平板样式 */
|
||||
@media (min-width: 769px) and (max-width: 1024px) {
|
||||
.header-content {
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.trigger {
|
||||
padding: 0 20px;
|
||||
}
|
||||
}
|
||||
|
||||
485
src/layouts/BasicLayout.tsx
Normal file
485
src/layouts/BasicLayout.tsx
Normal file
|
|
@ -0,0 +1,485 @@
|
|||
import { useState, useEffect, useContext, useMemo } from 'react'
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom'
|
||||
import { Layout, Menu, Dropdown, Avatar, theme, Drawer, Tooltip, Popover, Space, Badge } from 'antd'
|
||||
import {
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
DashboardOutlined,
|
||||
UserOutlined,
|
||||
LogoutOutlined,
|
||||
TrophyOutlined,
|
||||
QuestionCircleOutlined,
|
||||
BulbOutlined,
|
||||
BgColorsOutlined,
|
||||
AuditOutlined,
|
||||
FolderOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import type { MenuProps } from 'antd'
|
||||
import { useUserStore } from '@/store/useUserStore'
|
||||
import { RoutePath } from '@/constants'
|
||||
import { ThemeModeContext, ThemeMode, THEME_COLORS } from '@/App'
|
||||
import { logout as logoutApi } from '@/api/auth'
|
||||
import { getPendingReviewCount } from '@/api/camp'
|
||||
import { getUserPermissions } from '@/utils/permission'
|
||||
import './BasicLayout.css'
|
||||
|
||||
const { Header, Sider, Content } = Layout
|
||||
|
||||
const BasicLayout = () => {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const [mobileMenuVisible, setMobileMenuVisible] = useState(false)
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
const [pendingReviewCount, setPendingReviewCount] = useState(0)
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { userInfo, logout } = useUserStore()
|
||||
const userPermissions = getUserPermissions()
|
||||
const canReadProgress = userInfo?.is_super_admin || userPermissions.includes('camp:progress:read')
|
||||
const themeModeContext = useContext(ThemeModeContext)
|
||||
const currentMode = themeModeContext?.mode ?? ThemeMode.LIGHT
|
||||
const isDarkMode = currentMode === ThemeMode.DARK
|
||||
const currentPrimaryColor = themeModeContext?.primaryColor ?? THEME_COLORS[0].value
|
||||
const {
|
||||
token: { colorBgContainer, borderRadiusLG, colorText, colorBorder },
|
||||
} = theme.useToken()
|
||||
|
||||
// 菜单项配置(带权限要求)
|
||||
// 使用 any 类型来支持自定义的 requiredPermission 属性
|
||||
const menuItemsWithPermissions: any[] = [
|
||||
{
|
||||
key: RoutePath.DASHBOARD,
|
||||
icon: <DashboardOutlined />,
|
||||
label: '仪表盘',
|
||||
requiredPermission: 'statistics:dashboard:read',
|
||||
},
|
||||
{
|
||||
key: '/admin',
|
||||
icon: <UserOutlined />,
|
||||
label: '管理员管理',
|
||||
requiredPermission: 'admin:user:read', // 至少需要查看管理员的权限
|
||||
children: [
|
||||
{
|
||||
key: RoutePath.USER_LIST,
|
||||
label: '管理员列表',
|
||||
requiredPermission: 'admin:user:read',
|
||||
},
|
||||
{
|
||||
key: RoutePath.ROLE_LIST,
|
||||
label: '角色管理',
|
||||
requiredPermission: 'admin:role:read',
|
||||
},
|
||||
{
|
||||
key: RoutePath.PERMISSION_LIST,
|
||||
label: '权限管理',
|
||||
requiredPermission: 'admin:permission:read',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: '/camp',
|
||||
icon: <TrophyOutlined />,
|
||||
label: '打卡营管理',
|
||||
requiredPermission: 'camp:camp:read', // 至少需要查看打卡营的权限
|
||||
children: [
|
||||
{
|
||||
key: RoutePath.CAMP_CATEGORY_LIST,
|
||||
label: '分类管理',
|
||||
requiredPermission: 'camp:category:read',
|
||||
},
|
||||
{
|
||||
key: RoutePath.CAMP_LIST,
|
||||
label: '打卡营列表',
|
||||
requiredPermission: 'camp:camp:read',
|
||||
},
|
||||
{
|
||||
key: RoutePath.CAMP_SECTION_LIST,
|
||||
label: '小节管理',
|
||||
requiredPermission: 'camp:section:read',
|
||||
},
|
||||
{
|
||||
key: RoutePath.CAMP_TASK_LIST,
|
||||
label: '任务管理',
|
||||
requiredPermission: 'camp:task:read',
|
||||
},
|
||||
{
|
||||
key: RoutePath.CAMP_PROGRESS_LIST,
|
||||
label: '打卡营进度管理',
|
||||
requiredPermission: 'camp:progress:read',
|
||||
},
|
||||
{
|
||||
key: RoutePath.CAMP_PENDING_TASK_LIST,
|
||||
label: '任务审批列表',
|
||||
requiredPermission: 'camp:progress:read',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: '/objective',
|
||||
icon: <QuestionCircleOutlined />,
|
||||
label: '客观题管理',
|
||||
requiredPermission: 'question:read', // 至少需要查看题目的权限
|
||||
children: [
|
||||
{
|
||||
key: RoutePath.OBJECTIVE_QUESTION_LIST,
|
||||
label: '题目管理',
|
||||
requiredPermission: 'question:read',
|
||||
},
|
||||
{
|
||||
key: RoutePath.OBJECTIVE_MATERIAL_LIST,
|
||||
label: '材料管理',
|
||||
requiredPermission: 'question:read', // 暂时使用相同权限
|
||||
},
|
||||
{
|
||||
key: RoutePath.OBJECTIVE_PAPER_LIST,
|
||||
label: '组卷管理',
|
||||
requiredPermission: 'paper:read',
|
||||
},
|
||||
{
|
||||
key: RoutePath.OBJECTIVE_KNOWLEDGE_TREE,
|
||||
label: '知识树',
|
||||
requiredPermission: 'knowledge_tree:read',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: '/subjective',
|
||||
icon: <QuestionCircleOutlined />,
|
||||
label: '主观题管理',
|
||||
requiredPermission: 'question:read',
|
||||
children: [
|
||||
// {
|
||||
// key: RoutePath.SUBJECTIVE_QUESTION_LIST,
|
||||
// label: '题目管理',
|
||||
// requiredPermission: 'question:read',
|
||||
// },
|
||||
{
|
||||
key: RoutePath.SUBJECTIVE_MATERIAL_LIST,
|
||||
label: '资料管理',
|
||||
requiredPermission: 'material:read',
|
||||
},
|
||||
{
|
||||
key: RoutePath.SUBJECTIVE_PAPER_LIST,
|
||||
label: '组卷管理',
|
||||
requiredPermission: 'paper:read',
|
||||
},
|
||||
{
|
||||
key: RoutePath.SUBJECTIVE_KNOWLEDGE_TREE,
|
||||
label: '题型分类',
|
||||
requiredPermission: 'knowledge_tree:read',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: RoutePath.DOCUMENT_LIST,
|
||||
icon: <FolderOutlined />,
|
||||
label: '文档管理',
|
||||
requiredPermission: 'document:manage',
|
||||
},
|
||||
]
|
||||
|
||||
// 根据权限过滤菜单项
|
||||
const menuItems = useMemo(() => {
|
||||
const userPermissions = getUserPermissions()
|
||||
const isSuper = userInfo?.is_super_admin
|
||||
|
||||
const filterMenuItems = (items: any[]): MenuProps['items'] => {
|
||||
if (!items) return []
|
||||
|
||||
return items
|
||||
.map(item => {
|
||||
if (!item) return null
|
||||
|
||||
// 检查是否有权限
|
||||
const requiredPermission = item.requiredPermission
|
||||
if (requiredPermission) {
|
||||
// 超级管理员或拥有权限的用户可以访问
|
||||
if (!isSuper && !userPermissions.includes(requiredPermission)) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 创建新的菜单项,移除 requiredPermission 属性,避免传递到 DOM
|
||||
const { requiredPermission: _, ...menuItem } = item
|
||||
|
||||
// 如果有子菜单,递归过滤
|
||||
if (menuItem.children) {
|
||||
const filteredChildren = filterMenuItems(menuItem.children)
|
||||
// 如果子菜单全部被过滤掉,则隐藏父菜单
|
||||
if (!filteredChildren || filteredChildren.length === 0) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
...menuItem,
|
||||
children: filteredChildren,
|
||||
}
|
||||
}
|
||||
|
||||
return menuItem
|
||||
})
|
||||
.filter(item => item !== null) as MenuProps['items']
|
||||
}
|
||||
|
||||
return filterMenuItems(menuItemsWithPermissions)
|
||||
}, [userInfo?.permissions, userInfo?.is_super_admin])
|
||||
|
||||
// 用户下拉菜单
|
||||
const userMenuItems: MenuProps['items'] = [
|
||||
{
|
||||
key: 'logout',
|
||||
icon: <LogoutOutlined />,
|
||||
label: '退出登录',
|
||||
onClick: async () => {
|
||||
try {
|
||||
// 调用后端登出 API(可选,即使失败也清除本地数据)
|
||||
await logoutApi().catch(() => {
|
||||
// 忽略登出 API 错误,继续清除本地数据
|
||||
})
|
||||
} catch {
|
||||
// 忽略错误
|
||||
} finally {
|
||||
logout()
|
||||
navigate(RoutePath.LOGIN)
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
// 菜单点击事件
|
||||
const onMenuClick: MenuProps['onClick'] = ({ key }) => {
|
||||
navigate(key)
|
||||
// 移动端点击菜单后关闭抽屉
|
||||
if (isMobile) {
|
||||
setMobileMenuVisible(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 检测屏幕尺寸
|
||||
useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
const isMobileScreen = window.innerWidth < 768
|
||||
setIsMobile(isMobileScreen)
|
||||
// 移动端默认收起侧边栏
|
||||
if (isMobileScreen) {
|
||||
setCollapsed(true)
|
||||
}
|
||||
}
|
||||
|
||||
checkMobile()
|
||||
window.addEventListener('resize', checkMobile)
|
||||
return () => window.removeEventListener('resize', checkMobile)
|
||||
}, [])
|
||||
|
||||
// 待审核任务数量(有进度读权限时拉取)
|
||||
useEffect(() => {
|
||||
if (!canReadProgress) return
|
||||
getPendingReviewCount()
|
||||
.then((res) => {
|
||||
const count = res?.data?.count ?? 0
|
||||
setPendingReviewCount(typeof count === 'number' ? count : 0)
|
||||
})
|
||||
.catch(() => setPendingReviewCount(0))
|
||||
}, [canReadProgress])
|
||||
|
||||
// 侧边栏配置
|
||||
const siderProps = {
|
||||
trigger: null,
|
||||
collapsible: true,
|
||||
collapsed: isMobile ? true : collapsed,
|
||||
breakpoint: 'lg' as const,
|
||||
collapsedWidth: isMobile ? 0 : 80,
|
||||
className: isMobile ? 'mobile-sider' : '',
|
||||
}
|
||||
|
||||
// 根据当前路径自动展开对应父菜单,使「待审核」跳转后左侧菜单联动高亮
|
||||
const pathBasedOpenKeys = useMemo(() => {
|
||||
const path = location.pathname
|
||||
const keys: string[] = []
|
||||
if (path.startsWith('/admin')) keys.push('/admin')
|
||||
if (path.startsWith('/camp')) keys.push('/camp')
|
||||
if (path.startsWith('/objective')) keys.push('/objective')
|
||||
if (path.startsWith('/subjective')) keys.push('/subjective')
|
||||
return keys
|
||||
}, [location.pathname])
|
||||
|
||||
const [menuOpenKeys, setMenuOpenKeys] = useState<string[]>(pathBasedOpenKeys)
|
||||
|
||||
useEffect(() => {
|
||||
setMenuOpenKeys(pathBasedOpenKeys)
|
||||
}, [pathBasedOpenKeys])
|
||||
|
||||
// 菜单组件
|
||||
const menuComponent = (
|
||||
<Menu
|
||||
theme="dark"
|
||||
mode="inline"
|
||||
selectedKeys={[location.pathname]}
|
||||
openKeys={menuOpenKeys}
|
||||
onOpenChange={setMenuOpenKeys}
|
||||
items={menuItems}
|
||||
onClick={onMenuClick}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<Layout className="basic-layout">
|
||||
{/* 桌面端侧边栏 */}
|
||||
{!isMobile && (
|
||||
<Sider {...siderProps}>
|
||||
<div className="logo">
|
||||
<h1>{collapsed ? '怼怼' : '怼怼后台'}</h1>
|
||||
</div>
|
||||
{menuComponent}
|
||||
</Sider>
|
||||
)}
|
||||
|
||||
{/* 移动端抽屉菜单 */}
|
||||
<Drawer
|
||||
title="菜单"
|
||||
placement="left"
|
||||
onClose={() => setMobileMenuVisible(false)}
|
||||
open={mobileMenuVisible}
|
||||
styles={{ body: { padding: 0 } }}
|
||||
width={200}
|
||||
>
|
||||
<div className="logo" style={{ height: 64, background: '#001529' }}>
|
||||
<h1 style={{ color: 'white', margin: 0, fontSize: 20, fontWeight: 'bold' }}>怼怼后台</h1>
|
||||
</div>
|
||||
{menuComponent}
|
||||
</Drawer>
|
||||
|
||||
<Layout>
|
||||
<Header style={{ padding: 0, background: colorBgContainer }}>
|
||||
<div className="header-content">
|
||||
<div className="header-left">
|
||||
{isMobile ? (
|
||||
<MenuUnfoldOutlined
|
||||
className="trigger"
|
||||
onClick={() => setMobileMenuVisible(true)}
|
||||
/>
|
||||
) : collapsed ? (
|
||||
<MenuUnfoldOutlined
|
||||
className="trigger"
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
/>
|
||||
) : (
|
||||
<MenuFoldOutlined
|
||||
className="trigger"
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="header-right">
|
||||
{canReadProgress && (
|
||||
<Tooltip title="需要审核的任务">
|
||||
<Badge count={pendingReviewCount} size="small" offset={[-2, 2]}>
|
||||
<span
|
||||
className="theme-toggle"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate(RoutePath.CAMP_PENDING_TASK_LIST)}
|
||||
>
|
||||
<AuditOutlined />
|
||||
{!isMobile && <span className="theme-toggle-text">待审核</span>}
|
||||
</span>
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{themeModeContext && (
|
||||
<>
|
||||
<Popover
|
||||
content={
|
||||
<div style={{ width: 200 }}>
|
||||
<div style={{ marginBottom: 8, fontSize: 12, color: colorText }}>
|
||||
选择主题颜色
|
||||
</div>
|
||||
<Space size={8} wrap>
|
||||
{THEME_COLORS.map((color) => (
|
||||
<div
|
||||
key={color.value}
|
||||
onClick={() => themeModeContext.setPrimaryColor(color.value)}
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 4,
|
||||
backgroundColor: color.value,
|
||||
cursor: 'pointer',
|
||||
border:
|
||||
currentPrimaryColor === color.value
|
||||
? `2px solid ${colorBorder}`
|
||||
: '2px solid transparent',
|
||||
boxShadow:
|
||||
currentPrimaryColor === color.value
|
||||
? `0 0 0 2px ${colorBorder}40`
|
||||
: 'none',
|
||||
transition: 'all 0.2s',
|
||||
opacity: currentPrimaryColor === color.value ? 1 : 0.8,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.opacity = '1'
|
||||
e.currentTarget.style.transform = 'scale(1.1)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.opacity =
|
||||
currentPrimaryColor === color.value ? '1' : '0.8'
|
||||
e.currentTarget.style.transform = 'scale(1)'
|
||||
}}
|
||||
title={color.name}
|
||||
/>
|
||||
))}
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
placement="bottomRight"
|
||||
trigger="click"
|
||||
>
|
||||
<Tooltip title="选择主题颜色">
|
||||
<div className="theme-toggle">
|
||||
<BgColorsOutlined />
|
||||
<span className="theme-toggle-text">颜色</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
<Tooltip title={isDarkMode ? '切换为亮色模式' : '切换为深色模式'}>
|
||||
<div
|
||||
className="theme-toggle"
|
||||
onClick={themeModeContext.toggleMode}
|
||||
>
|
||||
<BulbOutlined />
|
||||
<span className="theme-toggle-text">
|
||||
{isDarkMode ? '深色' : '亮色'}
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
|
||||
<div className="user-info">
|
||||
<Avatar src={userInfo?.avatar} icon={<UserOutlined />} />
|
||||
{!isMobile && (
|
||||
<span className="username">
|
||||
{userInfo?.nickname || userInfo?.username || '管理员'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</Header>
|
||||
<Content
|
||||
className="layout-content"
|
||||
style={{
|
||||
margin: isMobile ? '16px 8px' : '24px 16px',
|
||||
padding: isMobile ? 16 : 24,
|
||||
minHeight: 280,
|
||||
background: colorBgContainer,
|
||||
borderRadius: borderRadiusLG,
|
||||
}}
|
||||
>
|
||||
<Outlet />
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
export default BasicLayout
|
||||
|
||||
15
src/main.tsx
Normal file
15
src/main.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import dayjs from 'dayjs'
|
||||
import 'dayjs/locale/zh-cn'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
dayjs.locale('zh-cn')
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
|
||||
501
src/pages/Admin/AdminUserList.tsx
Normal file
501
src/pages/Admin/AdminUserList.tsx
Normal file
|
|
@ -0,0 +1,501 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Table, Button, Space, Tag, Input, message, Modal, Form, Radio, Checkbox, Divider } from 'antd'
|
||||
import { SearchOutlined, PlusOutlined, EditOutlined, DeleteOutlined, UserOutlined, PhoneOutlined, SafetyOutlined } from '@ant-design/icons'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { getAdminUserList, createAdminUser, updateAdminUser, deleteAdminUser, getUserRoles, setUserRoles, type AdminUser, type CreateAdminUserRequest, type UpdateAdminUserRequest } from '@/api/admin'
|
||||
import { getRoleList, type Role } from '@/api/role'
|
||||
|
||||
const AdminUserList = () => {
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [data, setData] = useState<AdminUser[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
const [form] = Form.useForm()
|
||||
const [modalVisible, setModalVisible] = useState(false)
|
||||
const [editingUser, setEditingUser] = useState<AdminUser | null>(null)
|
||||
const [roleModalVisible, setRoleModalVisible] = useState(false)
|
||||
const [currentUser, setCurrentUser] = useState<AdminUser | null>(null)
|
||||
const [allRoles, setAllRoles] = useState<Role[]>([])
|
||||
const [selectedRoleIds, setSelectedRoleIds] = useState<string[]>([])
|
||||
const [roleLoading, setRoleLoading] = useState(false)
|
||||
|
||||
// 获取管理员列表
|
||||
const fetchAdminUsers = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await getAdminUserList({
|
||||
keyword: searchText || undefined,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
})
|
||||
|
||||
if (response.data.code === 200 && response.data.data) {
|
||||
setData(response.data.data.list || [])
|
||||
setTotal(response.data.data.total || 0)
|
||||
} else {
|
||||
message.error(response.data.message || '获取管理员列表失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error?.response?.data?.message || '获取管理员列表失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchAdminUsers()
|
||||
}, [page, pageSize])
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
setPage(1)
|
||||
fetchAdminUsers()
|
||||
}
|
||||
|
||||
// 新增管理员
|
||||
const handleAdd = () => {
|
||||
setEditingUser(null)
|
||||
form.resetFields()
|
||||
setModalVisible(true)
|
||||
}
|
||||
|
||||
// 编辑管理员
|
||||
const handleEdit = (record: AdminUser) => {
|
||||
setEditingUser(record)
|
||||
form.setFieldsValue({
|
||||
username: record.username,
|
||||
phone: record.phone,
|
||||
nickname: record.nickname,
|
||||
avatar: record.avatar,
|
||||
status: record.status,
|
||||
is_super_admin: record.is_super_admin,
|
||||
})
|
||||
setModalVisible(true)
|
||||
}
|
||||
|
||||
// 删除管理员
|
||||
const handleDelete = (record: AdminUser) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: `确定要删除管理员 "${record.username || record.phone || record.nickname}" 吗?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
const response = await deleteAdminUser(record.id)
|
||||
if (response.data.code === 200) {
|
||||
message.success('删除成功')
|
||||
fetchAdminUsers()
|
||||
} else {
|
||||
message.error(response.data.message || '删除失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error?.response?.data?.message || '删除失败')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 打开角色分配Modal
|
||||
const handleAssignRoles = async (record: AdminUser) => {
|
||||
setCurrentUser(record)
|
||||
setRoleModalVisible(true)
|
||||
setRoleLoading(true)
|
||||
setSelectedRoleIds([])
|
||||
|
||||
try {
|
||||
// 获取所有角色
|
||||
const allRolesResponse = await getRoleList({ page: 1, page_size: 1000 })
|
||||
if (allRolesResponse.data.code === 200 && allRolesResponse.data.data) {
|
||||
setAllRoles(allRolesResponse.data.data.list || [])
|
||||
}
|
||||
|
||||
// 获取当前用户的角色
|
||||
const userRolesResponse = await getUserRoles(record.id)
|
||||
if (userRolesResponse.data.code === 200 && userRolesResponse.data.data) {
|
||||
setSelectedRoleIds(userRolesResponse.data.data || [])
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error?.response?.data?.message || '获取角色列表失败')
|
||||
} finally {
|
||||
setRoleLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 保存角色分配
|
||||
const handleSaveRoles = async () => {
|
||||
if (!currentUser) return
|
||||
|
||||
try {
|
||||
const response = await setUserRoles(currentUser.id, selectedRoleIds)
|
||||
if (response.data.code === 200) {
|
||||
message.success('角色分配成功')
|
||||
setRoleModalVisible(false)
|
||||
} else {
|
||||
message.error(response.data.message || '角色分配失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error?.response?.data?.message || '角色分配失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields()
|
||||
|
||||
if (editingUser) {
|
||||
// 更新
|
||||
const updateData: UpdateAdminUserRequest = {
|
||||
id: editingUser.id,
|
||||
username: values.username,
|
||||
phone: values.phone,
|
||||
nickname: values.nickname,
|
||||
avatar: values.avatar,
|
||||
status: values.status,
|
||||
is_super_admin: values.is_super_admin,
|
||||
}
|
||||
|
||||
// 如果密码不为空,则更新密码
|
||||
if (values.password) {
|
||||
updateData.password = values.password
|
||||
}
|
||||
|
||||
const response = await updateAdminUser(updateData)
|
||||
if (response.data.code === 200) {
|
||||
message.success('更新成功')
|
||||
setModalVisible(false)
|
||||
fetchAdminUsers()
|
||||
} else {
|
||||
message.error(response.data.message || '更新失败')
|
||||
}
|
||||
} else {
|
||||
// 创建
|
||||
if (!values.password) {
|
||||
message.error('密码不能为空')
|
||||
return
|
||||
}
|
||||
|
||||
const createData: CreateAdminUserRequest = {
|
||||
username: values.username,
|
||||
phone: values.phone,
|
||||
password: values.password,
|
||||
nickname: values.nickname,
|
||||
avatar: values.avatar,
|
||||
status: values.status ?? 1,
|
||||
is_super_admin: values.is_super_admin ?? 0,
|
||||
}
|
||||
|
||||
const response = await createAdminUser(createData)
|
||||
if (response.data.code === 200) {
|
||||
message.success('创建成功')
|
||||
setModalVisible(false)
|
||||
fetchAdminUsers()
|
||||
} else {
|
||||
message.error(response.data.message || '创建失败')
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error?.errorFields) {
|
||||
// 表单验证错误
|
||||
return
|
||||
}
|
||||
message.error(error?.response?.data?.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<AdminUser> = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '用户名',
|
||||
dataIndex: 'username',
|
||||
key: 'username',
|
||||
render: (text: string) => text || '-',
|
||||
},
|
||||
{
|
||||
title: '手机号',
|
||||
dataIndex: 'phone',
|
||||
key: 'phone',
|
||||
render: (text: string) => text || '-',
|
||||
},
|
||||
{
|
||||
title: '昵称',
|
||||
dataIndex: 'nickname',
|
||||
key: 'nickname',
|
||||
render: (text: string) => text || '-',
|
||||
},
|
||||
{
|
||||
title: '超级管理员',
|
||||
dataIndex: 'is_super_admin',
|
||||
key: 'is_super_admin',
|
||||
width: 120,
|
||||
render: (isSuperAdmin: number) => (
|
||||
<Tag color={isSuperAdmin === 1 ? 'red' : 'default'}>
|
||||
{isSuperAdmin === 1 ? '是' : '否'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
render: (status: number) => (
|
||||
<Tag color={status === 1 ? 'success' : 'default'}>
|
||||
{status === 1 ? '启用' : '禁用'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '最后登录',
|
||||
dataIndex: 'last_login_at',
|
||||
key: 'last_login_at',
|
||||
width: 180,
|
||||
render: (text: string) => text || '-',
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 320,
|
||||
fixed: 'right',
|
||||
render: (_: any, record: AdminUser) => (
|
||||
<Space size="middle">
|
||||
<Button type="link" icon={<EditOutlined />} size="small" onClick={() => handleEdit(record)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Button type="link" icon={<SafetyOutlined />} size="small" onClick={() => handleAssignRoles(record)}>
|
||||
分配角色
|
||||
</Button>
|
||||
<Button type="link" danger icon={<DeleteOutlined />} size="small" onClick={() => handleDelete(record)}>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Space>
|
||||
<Input
|
||||
placeholder="搜索用户名、手机号或昵称"
|
||||
prefix={<SearchOutlined />}
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
onPressEnter={handleSearch}
|
||||
style={{ width: 300 }}
|
||||
allowClear
|
||||
/>
|
||||
<Button type="primary" onClick={handleSearch}>
|
||||
搜索
|
||||
</Button>
|
||||
</Space>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>
|
||||
新增管理员
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize: pageSize,
|
||||
total: total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
onChange: (page, pageSize) => {
|
||||
setPage(page)
|
||||
setPageSize(pageSize)
|
||||
},
|
||||
}}
|
||||
scroll={{ x: 1200 }}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editingUser ? '编辑管理员' : '新增管理员'}
|
||||
open={modalVisible}
|
||||
onOk={handleSubmit}
|
||||
onCancel={() => {
|
||||
setModalVisible(false)
|
||||
form.resetFields()
|
||||
}}
|
||||
width={600}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
status: 1,
|
||||
is_super_admin: 0,
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
name="username"
|
||||
label="用户名"
|
||||
rules={[
|
||||
{
|
||||
validator: (_: any, value: string) => {
|
||||
const phone = form.getFieldValue('phone')
|
||||
if (!value && !phone) {
|
||||
return Promise.reject(new Error('用户名和手机号至少填写一个'))
|
||||
}
|
||||
return Promise.resolve()
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input prefix={<UserOutlined />} placeholder="请输入用户名(可选)" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="phone"
|
||||
label="手机号"
|
||||
rules={[
|
||||
{
|
||||
validator: (_: any, value: string) => {
|
||||
const username = form.getFieldValue('username')
|
||||
if (!value && !username) {
|
||||
return Promise.reject(new Error('用户名和手机号至少填写一个'))
|
||||
}
|
||||
if (value && !/^1[3-9]\d{9}$/.test(value)) {
|
||||
return Promise.reject(new Error('请输入正确的手机号'))
|
||||
}
|
||||
return Promise.resolve()
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input prefix={<PhoneOutlined />} placeholder="请输入手机号(可选)" />
|
||||
</Form.Item>
|
||||
|
||||
{!editingUser && (
|
||||
<Form.Item
|
||||
name="password"
|
||||
label="密码"
|
||||
rules={[{ required: true, message: '请输入密码' }]}
|
||||
>
|
||||
<Input.Password placeholder="请输入密码" />
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{editingUser && (
|
||||
<Form.Item
|
||||
name="password"
|
||||
label="密码"
|
||||
help="留空则不修改密码"
|
||||
>
|
||||
<Input.Password placeholder="留空则不修改密码" />
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item name="nickname" label="昵称">
|
||||
<Input placeholder="请输入昵称(可选)" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="avatar" label="头像URL">
|
||||
<Input placeholder="请输入头像URL(可选)" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="status" label="状态" rules={[{ required: true }]}>
|
||||
<Radio.Group>
|
||||
<Radio value={1}>启用</Radio>
|
||||
<Radio value={0}>禁用</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="is_super_admin" label="超级管理员" rules={[{ required: true }]}>
|
||||
<Radio.Group>
|
||||
<Radio value={0}>否</Radio>
|
||||
<Radio value={1}>是</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 角色分配Modal */}
|
||||
<Modal
|
||||
title={`分配角色 - ${currentUser?.username || currentUser?.phone || currentUser?.nickname || ''}`}
|
||||
open={roleModalVisible}
|
||||
onOk={handleSaveRoles}
|
||||
onCancel={() => {
|
||||
setRoleModalVisible(false)
|
||||
setCurrentUser(null)
|
||||
setSelectedRoleIds([])
|
||||
}}
|
||||
width={600}
|
||||
okText="保存"
|
||||
cancelText="取消"
|
||||
confirmLoading={roleLoading}
|
||||
>
|
||||
<div style={{ maxHeight: '60vh', overflowY: 'auto' }}>
|
||||
{roleLoading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px 0' }}>加载中...</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Checkbox
|
||||
checked={allRoles.length > 0 && selectedRoleIds.length === allRoles.length}
|
||||
indeterminate={selectedRoleIds.length > 0 && selectedRoleIds.length < allRoles.length}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setSelectedRoleIds(allRoles.map(r => r.id))
|
||||
} else {
|
||||
setSelectedRoleIds([])
|
||||
}
|
||||
}}
|
||||
>
|
||||
全选
|
||||
</Checkbox>
|
||||
<span style={{ marginLeft: 16, color: '#666' }}>
|
||||
已选择 {selectedRoleIds.length} / {allRoles.length} 个角色
|
||||
</span>
|
||||
</div>
|
||||
<Divider />
|
||||
{allRoles.map(role => (
|
||||
<div key={role.id} style={{ marginBottom: 12 }}>
|
||||
<Checkbox
|
||||
checked={selectedRoleIds.includes(role.id)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setSelectedRoleIds([...selectedRoleIds, role.id])
|
||||
} else {
|
||||
setSelectedRoleIds(selectedRoleIds.filter(id => id !== role.id))
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span style={{ marginRight: 8, fontWeight: 500 }}>{role.name}</span>
|
||||
<Tag color="blue">{role.code}</Tag>
|
||||
{role.status === 0 && <Tag color="default">已禁用</Tag>}
|
||||
{role.description && (
|
||||
<span style={{ marginLeft: 8, color: '#999', fontSize: 12 }}>{role.description}</span>
|
||||
)}
|
||||
</Checkbox>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AdminUserList
|
||||
|
||||
340
src/pages/Admin/PermissionList.tsx
Normal file
340
src/pages/Admin/PermissionList.tsx
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Table, Button, Space, Tag, Input, message, Modal, Form, Select } from 'antd'
|
||||
import { SearchOutlined, PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { getPermissionList, createPermission, updatePermission, deletePermission, getResources, type Permission, type CreatePermissionRequest, type UpdatePermissionRequest } from '@/api/permission'
|
||||
|
||||
const PermissionList = () => {
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const [resourceFilter, setResourceFilter] = useState<string>('')
|
||||
const [resources, setResources] = useState<string[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [data, setData] = useState<Permission[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
const [form] = Form.useForm()
|
||||
const [modalVisible, setModalVisible] = useState(false)
|
||||
const [editingPermission, setEditingPermission] = useState<Permission | null>(null)
|
||||
|
||||
// 获取资源列表
|
||||
const fetchResources = async () => {
|
||||
try {
|
||||
const response = await getResources()
|
||||
if (response.data.code === 200 && response.data.data) {
|
||||
setResources(response.data.data || [])
|
||||
}
|
||||
} catch (error) {
|
||||
// 忽略错误
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchResources()
|
||||
}, [])
|
||||
|
||||
// 获取权限列表
|
||||
const fetchPermissions = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await getPermissionList({
|
||||
keyword: searchText || undefined,
|
||||
resource: resourceFilter || undefined,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
})
|
||||
|
||||
if (response.data.code === 200 && response.data.data) {
|
||||
setData(response.data.data.list || [])
|
||||
setTotal(response.data.data.total || 0)
|
||||
} else {
|
||||
message.error(response.data.message || '获取权限列表失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error?.response?.data?.message || '获取权限列表失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchPermissions()
|
||||
}, [page, pageSize, resourceFilter])
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
setPage(1)
|
||||
fetchPermissions()
|
||||
}
|
||||
|
||||
// 新增权限
|
||||
const handleAdd = () => {
|
||||
setEditingPermission(null)
|
||||
form.resetFields()
|
||||
setModalVisible(true)
|
||||
}
|
||||
|
||||
// 编辑权限
|
||||
const handleEdit = (record: Permission) => {
|
||||
setEditingPermission(record)
|
||||
form.setFieldsValue({
|
||||
name: record.name,
|
||||
code: record.code,
|
||||
resource: record.resource,
|
||||
action: record.action,
|
||||
description: record.description,
|
||||
})
|
||||
setModalVisible(true)
|
||||
}
|
||||
|
||||
// 删除权限
|
||||
const handleDelete = (record: Permission) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: `确定要删除权限 "${record.name}" 吗?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
const response = await deletePermission(record.id)
|
||||
if (response.data.code === 200) {
|
||||
message.success('删除成功')
|
||||
fetchPermissions()
|
||||
fetchResources() // 刷新资源列表
|
||||
} else {
|
||||
message.error(response.data.message || '删除失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error?.response?.data?.message || '删除失败')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields()
|
||||
|
||||
if (editingPermission) {
|
||||
// 更新
|
||||
const updateData: UpdatePermissionRequest = {
|
||||
id: editingPermission.id,
|
||||
name: values.name,
|
||||
code: values.code,
|
||||
resource: values.resource,
|
||||
action: values.action,
|
||||
description: values.description,
|
||||
}
|
||||
|
||||
const response = await updatePermission(updateData)
|
||||
if (response.data.code === 200) {
|
||||
message.success('更新成功')
|
||||
setModalVisible(false)
|
||||
fetchPermissions()
|
||||
fetchResources() // 刷新资源列表
|
||||
} else {
|
||||
message.error(response.data.message || '更新失败')
|
||||
}
|
||||
} else {
|
||||
// 创建
|
||||
const createData: CreatePermissionRequest = {
|
||||
name: values.name,
|
||||
code: values.code,
|
||||
resource: values.resource,
|
||||
action: values.action,
|
||||
description: values.description,
|
||||
}
|
||||
|
||||
const response = await createPermission(createData)
|
||||
if (response.data.code === 200) {
|
||||
message.success('创建成功')
|
||||
setModalVisible(false)
|
||||
fetchPermissions()
|
||||
fetchResources() // 刷新资源列表
|
||||
} else {
|
||||
message.error(response.data.message || '创建失败')
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error?.errorFields) {
|
||||
// 表单验证错误
|
||||
return
|
||||
}
|
||||
message.error(error?.response?.data?.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Permission> = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '权限名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
title: '权限代码',
|
||||
dataIndex: 'code',
|
||||
key: 'code',
|
||||
},
|
||||
{
|
||||
title: '资源',
|
||||
dataIndex: 'resource',
|
||||
key: 'resource',
|
||||
render: (text: string) => <Tag color="blue">{text}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'action',
|
||||
key: 'action',
|
||||
render: (text: string) => <Tag color="green">{text}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '描述',
|
||||
dataIndex: 'description',
|
||||
key: 'description',
|
||||
render: (text: string) => text || '-',
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 180,
|
||||
fixed: 'right',
|
||||
render: (_: any, record: Permission) => (
|
||||
<Space size="middle">
|
||||
<Button type="link" icon={<EditOutlined />} size="small" onClick={() => handleEdit(record)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Button type="link" danger icon={<DeleteOutlined />} size="small" onClick={() => handleDelete(record)}>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Space>
|
||||
<Input
|
||||
placeholder="搜索权限名称、代码或描述"
|
||||
prefix={<SearchOutlined />}
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
onPressEnter={handleSearch}
|
||||
style={{ width: 250 }}
|
||||
allowClear
|
||||
/>
|
||||
<Select
|
||||
placeholder="选择资源"
|
||||
value={resourceFilter || undefined}
|
||||
onChange={(value) => {
|
||||
setResourceFilter(value)
|
||||
setPage(1)
|
||||
}}
|
||||
allowClear
|
||||
style={{ width: 150 }}
|
||||
>
|
||||
{resources.map((resource) => (
|
||||
<Select.Option key={resource} value={resource}>
|
||||
{resource}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Button type="primary" onClick={handleSearch}>
|
||||
搜索
|
||||
</Button>
|
||||
</Space>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>
|
||||
新增权限
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize: pageSize,
|
||||
total: total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
onChange: (page, pageSize) => {
|
||||
setPage(page)
|
||||
setPageSize(pageSize)
|
||||
},
|
||||
}}
|
||||
scroll={{ x: 1200 }}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editingPermission ? '编辑权限' : '新增权限'}
|
||||
open={modalVisible}
|
||||
onOk={handleSubmit}
|
||||
onCancel={() => {
|
||||
setModalVisible(false)
|
||||
form.resetFields()
|
||||
}}
|
||||
width={600}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="权限名称"
|
||||
rules={[{ required: true, message: '请输入权限名称' }]}
|
||||
>
|
||||
<Input placeholder="请输入权限名称" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="code"
|
||||
label="权限代码"
|
||||
rules={[{ required: true, message: '请输入权限代码' }]}
|
||||
>
|
||||
<Input placeholder="请输入权限代码(唯一标识,如:camp:category:create)" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="resource"
|
||||
label="资源"
|
||||
rules={[{ required: true, message: '请选择资源' }]}
|
||||
>
|
||||
<Input placeholder="请输入资源(如:camp, question, order)" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="action"
|
||||
label="操作"
|
||||
rules={[{ required: true, message: '请输入操作' }]}
|
||||
>
|
||||
<Input placeholder="请输入操作(如:create, read, update, delete)" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="description" label="描述">
|
||||
<Input.TextArea rows={3} placeholder="请输入权限描述(可选)" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PermissionList
|
||||
|
||||
445
src/pages/Admin/RoleList.tsx
Normal file
445
src/pages/Admin/RoleList.tsx
Normal file
|
|
@ -0,0 +1,445 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Table, Button, Space, Tag, Input, message, Modal, Form, Radio, Checkbox, Divider } from 'antd'
|
||||
import { SearchOutlined, PlusOutlined, EditOutlined, DeleteOutlined, SafetyOutlined } from '@ant-design/icons'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { getRoleList, createRole, updateRole, deleteRole, getRolePermissions, setRolePermissions, type Role, type CreateRoleRequest, type UpdateRoleRequest } from '@/api/role'
|
||||
import { getPermissionList, type Permission } from '@/api/permission'
|
||||
|
||||
const RoleList = () => {
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [data, setData] = useState<Role[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
const [form] = Form.useForm()
|
||||
const [modalVisible, setModalVisible] = useState(false)
|
||||
const [editingRole, setEditingRole] = useState<Role | null>(null)
|
||||
const [permissionModalVisible, setPermissionModalVisible] = useState(false)
|
||||
const [currentRole, setCurrentRole] = useState<Role | null>(null)
|
||||
const [allPermissions, setAllPermissions] = useState<Permission[]>([])
|
||||
const [selectedPermissionIds, setSelectedPermissionIds] = useState<string[]>([])
|
||||
const [permissionLoading, setPermissionLoading] = useState(false)
|
||||
|
||||
// 获取角色列表
|
||||
const fetchRoles = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await getRoleList({
|
||||
keyword: searchText || undefined,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
})
|
||||
|
||||
if (response.data.code === 200 && response.data.data) {
|
||||
setData(response.data.data.list || [])
|
||||
setTotal(response.data.data.total || 0)
|
||||
} else {
|
||||
message.error(response.data.message || '获取角色列表失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error?.response?.data?.message || '获取角色列表失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchRoles()
|
||||
}, [page, pageSize])
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
setPage(1)
|
||||
fetchRoles()
|
||||
}
|
||||
|
||||
// 新增角色
|
||||
const handleAdd = () => {
|
||||
setEditingRole(null)
|
||||
form.resetFields()
|
||||
setModalVisible(true)
|
||||
}
|
||||
|
||||
// 编辑角色
|
||||
const handleEdit = (record: Role) => {
|
||||
setEditingRole(record)
|
||||
form.setFieldsValue({
|
||||
name: record.name,
|
||||
description: record.description,
|
||||
status: record.status,
|
||||
})
|
||||
setModalVisible(true)
|
||||
}
|
||||
|
||||
// 删除角色
|
||||
const handleDelete = (record: Role) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: `确定要删除角色 "${record.name}" 吗?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
const response = await deleteRole(record.id)
|
||||
if (response.data.code === 200) {
|
||||
message.success('删除成功')
|
||||
fetchRoles()
|
||||
} else {
|
||||
message.error(response.data.message || '删除失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error?.response?.data?.message || '删除失败')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 打开权限分配Modal
|
||||
const handleAssignPermissions = async (record: Role) => {
|
||||
setCurrentRole(record)
|
||||
setPermissionModalVisible(true)
|
||||
setPermissionLoading(true)
|
||||
setSelectedPermissionIds([])
|
||||
|
||||
try {
|
||||
// 获取所有权限
|
||||
const allPermsResponse = await getPermissionList({ page: 1, page_size: 1000 })
|
||||
if (allPermsResponse.data.code === 200 && allPermsResponse.data.data) {
|
||||
setAllPermissions(allPermsResponse.data.data.list || [])
|
||||
}
|
||||
|
||||
// 获取当前角色的权限
|
||||
const rolePermsResponse = await getRolePermissions(record.id)
|
||||
if (rolePermsResponse.data.code === 200 && rolePermsResponse.data.data) {
|
||||
setSelectedPermissionIds(rolePermsResponse.data.data || [])
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error?.response?.data?.message || '获取权限列表失败')
|
||||
} finally {
|
||||
setPermissionLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 保存权限分配
|
||||
const handleSavePermissions = async () => {
|
||||
if (!currentRole) return
|
||||
|
||||
try {
|
||||
const response = await setRolePermissions(currentRole.id, selectedPermissionIds)
|
||||
if (response.data.code === 200) {
|
||||
message.success('权限分配成功')
|
||||
setPermissionModalVisible(false)
|
||||
} else {
|
||||
message.error(response.data.message || '权限分配失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error?.response?.data?.message || '权限分配失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 按资源分组权限
|
||||
const groupPermissionsByResource = () => {
|
||||
const grouped: Record<string, Permission[]> = {}
|
||||
allPermissions.forEach(perm => {
|
||||
if (!grouped[perm.resource]) {
|
||||
grouped[perm.resource] = []
|
||||
}
|
||||
grouped[perm.resource].push(perm)
|
||||
})
|
||||
return grouped
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields()
|
||||
|
||||
if (editingRole) {
|
||||
// 更新(不更新代码,代码由后端维护)
|
||||
const updateData: UpdateRoleRequest = {
|
||||
id: editingRole.id,
|
||||
name: values.name,
|
||||
code: editingRole.code, // 保持原有代码不变
|
||||
description: values.description,
|
||||
status: values.status,
|
||||
}
|
||||
|
||||
const response = await updateRole(updateData)
|
||||
if (response.data.code === 200) {
|
||||
message.success('更新成功')
|
||||
setModalVisible(false)
|
||||
fetchRoles()
|
||||
} else {
|
||||
message.error(response.data.message || '更新失败')
|
||||
}
|
||||
} else {
|
||||
// 创建(不传 code,由后端自动生成)
|
||||
const createData: CreateRoleRequest = {
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
status: values.status ?? 1,
|
||||
}
|
||||
|
||||
const response = await createRole(createData)
|
||||
if (response.data.code === 200) {
|
||||
message.success('创建成功')
|
||||
setModalVisible(false)
|
||||
fetchRoles()
|
||||
} else {
|
||||
message.error(response.data.message || '创建失败')
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error?.errorFields) {
|
||||
// 表单验证错误
|
||||
return
|
||||
}
|
||||
message.error(error?.response?.data?.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Role> = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '角色名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
title: '角色代码',
|
||||
dataIndex: 'code',
|
||||
key: 'code',
|
||||
},
|
||||
{
|
||||
title: '描述',
|
||||
dataIndex: 'description',
|
||||
key: 'description',
|
||||
render: (text: string) => text || '-',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
render: (status: number) => (
|
||||
<Tag color={status === 1 ? 'success' : 'default'}>
|
||||
{status === 1 ? '启用' : '禁用'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 320,
|
||||
fixed: 'right',
|
||||
render: (_: any, record: Role) => (
|
||||
<Space size="middle">
|
||||
<Button type="link" icon={<EditOutlined />} size="small" onClick={() => handleEdit(record)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Button type="link" icon={<SafetyOutlined />} size="small" onClick={() => handleAssignPermissions(record)}>
|
||||
分配权限
|
||||
</Button>
|
||||
<Button type="link" danger icon={<DeleteOutlined />} size="small" onClick={() => handleDelete(record)}>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Space>
|
||||
<Input
|
||||
placeholder="搜索角色名称、代码或描述"
|
||||
prefix={<SearchOutlined />}
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
onPressEnter={handleSearch}
|
||||
style={{ width: 300 }}
|
||||
allowClear
|
||||
/>
|
||||
<Button type="primary" onClick={handleSearch}>
|
||||
搜索
|
||||
</Button>
|
||||
</Space>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>
|
||||
新增角色
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize: pageSize,
|
||||
total: total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
onChange: (page, pageSize) => {
|
||||
setPage(page)
|
||||
setPageSize(pageSize)
|
||||
},
|
||||
}}
|
||||
scroll={{ x: 1000 }}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editingRole ? '编辑角色' : '新增角色'}
|
||||
open={modalVisible}
|
||||
onOk={handleSubmit}
|
||||
onCancel={() => {
|
||||
setModalVisible(false)
|
||||
form.resetFields()
|
||||
}}
|
||||
width={600}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
status: 1,
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="角色名称"
|
||||
rules={[{ required: true, message: '请输入角色名称' }]}
|
||||
>
|
||||
<Input placeholder="请输入角色名称" />
|
||||
</Form.Item>
|
||||
|
||||
{editingRole && (
|
||||
<Form.Item label="角色代码">
|
||||
<Input value={editingRole.code} disabled />
|
||||
<div style={{ color: '#999', fontSize: 12, marginTop: 4 }}>
|
||||
角色代码由系统自动生成,不可修改
|
||||
</div>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item name="description" label="描述">
|
||||
<Input.TextArea rows={3} placeholder="请输入角色描述(可选)" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="status" label="状态" rules={[{ required: true }]}>
|
||||
<Radio.Group>
|
||||
<Radio value={1}>启用</Radio>
|
||||
<Radio value={0}>禁用</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 权限分配Modal */}
|
||||
<Modal
|
||||
title={`分配权限 - ${currentRole?.name || ''}`}
|
||||
open={permissionModalVisible}
|
||||
onOk={handleSavePermissions}
|
||||
onCancel={() => {
|
||||
setPermissionModalVisible(false)
|
||||
setCurrentRole(null)
|
||||
setSelectedPermissionIds([])
|
||||
}}
|
||||
width={800}
|
||||
okText="保存"
|
||||
cancelText="取消"
|
||||
confirmLoading={permissionLoading}
|
||||
>
|
||||
<div style={{ maxHeight: '60vh', overflowY: 'auto' }}>
|
||||
{permissionLoading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px 0' }}>加载中...</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Checkbox
|
||||
checked={allPermissions.length > 0 && selectedPermissionIds.length === allPermissions.length}
|
||||
indeterminate={selectedPermissionIds.length > 0 && selectedPermissionIds.length < allPermissions.length}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setSelectedPermissionIds(allPermissions.map(p => p.id))
|
||||
} else {
|
||||
setSelectedPermissionIds([])
|
||||
}
|
||||
}}
|
||||
>
|
||||
全选
|
||||
</Checkbox>
|
||||
<span style={{ marginLeft: 16, color: '#666' }}>
|
||||
已选择 {selectedPermissionIds.length} / {allPermissions.length} 个权限
|
||||
</span>
|
||||
</div>
|
||||
<Divider />
|
||||
{Object.entries(groupPermissionsByResource()).map(([resource, permissions]) => (
|
||||
<div key={resource} style={{ marginBottom: 24 }}>
|
||||
<div style={{ marginBottom: 12, fontWeight: 600, fontSize: 16 }}>
|
||||
<Tag color="blue" style={{ marginRight: 8 }}>{resource}</Tag>
|
||||
<Checkbox
|
||||
checked={permissions.every(p => selectedPermissionIds.includes(p.id))}
|
||||
indeterminate={
|
||||
permissions.some(p => selectedPermissionIds.includes(p.id)) &&
|
||||
!permissions.every(p => selectedPermissionIds.includes(p.id))
|
||||
}
|
||||
onChange={(e) => {
|
||||
const resourcePermIds = permissions.map(p => p.id)
|
||||
if (e.target.checked) {
|
||||
setSelectedPermissionIds([...new Set([...selectedPermissionIds, ...resourcePermIds])])
|
||||
} else {
|
||||
setSelectedPermissionIds(selectedPermissionIds.filter(id => !resourcePermIds.includes(id)))
|
||||
}
|
||||
}}
|
||||
>
|
||||
全选该资源
|
||||
</Checkbox>
|
||||
</div>
|
||||
<div style={{ paddingLeft: 24 }}>
|
||||
{permissions.map(perm => (
|
||||
<div key={perm.id} style={{ marginBottom: 8 }}>
|
||||
<Checkbox
|
||||
checked={selectedPermissionIds.includes(perm.id)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setSelectedPermissionIds([...selectedPermissionIds, perm.id])
|
||||
} else {
|
||||
setSelectedPermissionIds(selectedPermissionIds.filter(id => id !== perm.id))
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span style={{ marginRight: 8 }}>{perm.name}</span>
|
||||
<Tag color="green">{perm.action}</Tag>
|
||||
{perm.description && (
|
||||
<span style={{ marginLeft: 8, color: '#999', fontSize: 12 }}>{perm.description}</span>
|
||||
)}
|
||||
</Checkbox>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default RoleList
|
||||
|
||||
487
src/pages/Camp/CampList.tsx
Normal file
487
src/pages/Camp/CampList.tsx
Normal file
|
|
@ -0,0 +1,487 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
message,
|
||||
Space,
|
||||
Popconfirm,
|
||||
Select,
|
||||
Switch,
|
||||
Image,
|
||||
Tag,
|
||||
} from 'antd'
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, ReloadOutlined, UnorderedListOutlined, UserOutlined, ClearOutlined } from '@ant-design/icons'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import type { Camp, Category } from '@/types/camp'
|
||||
import { IntroType, RecommendFilter } from '@/types/camp'
|
||||
import { listCamps, createCamp, updateCamp, deleteCamp, listCategories } from '@/api/camp'
|
||||
import { DEFAULT_PAGE_SIZE, RoutePath } from '@/constants'
|
||||
import ImageUpload from '@/components/ImageUpload'
|
||||
|
||||
const { TextArea } = Input
|
||||
|
||||
const CampList = () => {
|
||||
const navigate = useNavigate()
|
||||
const [searchParams] = useSearchParams()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [dataSource, setDataSource] = useState<Camp[]>([])
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE)
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>()
|
||||
const [recommendFilter, setRecommendFilter] = useState<RecommendFilter>(RecommendFilter.ALL)
|
||||
const [modalVisible, setModalVisible] = useState(false)
|
||||
const [editingRecord, setEditingRecord] = useState<Camp | null>(null)
|
||||
|
||||
const [form] = Form.useForm()
|
||||
const introTypeValue = Form.useWatch('introType', form)
|
||||
|
||||
// 从 URL 参数中读取分类筛选
|
||||
useEffect(() => {
|
||||
const categoryId = searchParams.get('categoryId')
|
||||
if (categoryId) {
|
||||
setCategoryFilter(categoryId)
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
// 获取分类列表
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
const res = await listCategories({ page: 1, pageSize: 100 })
|
||||
if (res.data.code === 200) {
|
||||
setCategories(res.data.data.list)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取分类列表失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 获取列表数据
|
||||
const fetchData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await listCamps({
|
||||
page,
|
||||
pageSize,
|
||||
keyword,
|
||||
categoryId: categoryFilter,
|
||||
recommendFilter,
|
||||
})
|
||||
if (res.data.code === 200) {
|
||||
const list = res.data.data.list || []
|
||||
const total = res.data.data.total || 0
|
||||
setDataSource(list)
|
||||
setTotal(total)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取打卡营列表错误:', error)
|
||||
// 设置空列表,不弹出提示
|
||||
setDataSource([])
|
||||
setTotal(0)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchCategories()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [page, pageSize, keyword, categoryFilter, recommendFilter])
|
||||
|
||||
// 监听 Modal 打开,确保表单值正确设置(作为备用方案)
|
||||
useEffect(() => {
|
||||
if (modalVisible && editingRecord) {
|
||||
console.log('编辑打卡营数据:', editingRecord)
|
||||
// 确保表单值已设置(如果 handleOpenModal 中已设置,这里不会重复设置)
|
||||
const currentValues = form.getFieldsValue()
|
||||
if (!currentValues.coverImage && editingRecord.coverImage) {
|
||||
form.setFieldsValue({
|
||||
coverImage: editingRecord.coverImage,
|
||||
introContent: editingRecord.introContent,
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [modalVisible, editingRecord, form])
|
||||
|
||||
// 打开新增/编辑弹窗
|
||||
const handleOpenModal = (record?: Camp) => {
|
||||
console.log('handleOpenModal 被调用', { record })
|
||||
|
||||
// 先打开 Modal
|
||||
setModalVisible(true)
|
||||
|
||||
// 然后设置编辑记录和表单值
|
||||
if (record) {
|
||||
setEditingRecord(record)
|
||||
// 使用 setTimeout 确保 Modal 完全打开后再设置表单值
|
||||
setTimeout(() => {
|
||||
try {
|
||||
form.setFieldsValue({
|
||||
title: record.title,
|
||||
coverImage: record.coverImage,
|
||||
description: record.description,
|
||||
categoryId: record.categoryId,
|
||||
introType: record.introType,
|
||||
introContent: record.introContent,
|
||||
isRecommended: record.isRecommended,
|
||||
})
|
||||
console.log('表单值已设置', {
|
||||
coverImage: record.coverImage,
|
||||
introContent: record.introContent,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('设置表单值失败:', error)
|
||||
}
|
||||
}, 100)
|
||||
} else {
|
||||
setEditingRecord(null)
|
||||
form.resetFields()
|
||||
const initialValues: any = {
|
||||
introType: IntroType.NONE,
|
||||
isRecommended: false,
|
||||
}
|
||||
if (categoryFilter) {
|
||||
initialValues.categoryId = categoryFilter
|
||||
}
|
||||
setTimeout(() => {
|
||||
form.setFieldsValue(initialValues)
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields()
|
||||
|
||||
if (editingRecord) {
|
||||
// 编辑
|
||||
await updateCamp({ ...editingRecord, ...values })
|
||||
message.success('编辑打卡营成功')
|
||||
} else {
|
||||
// 新增
|
||||
await createCamp(values)
|
||||
message.success('新增打卡营成功')
|
||||
}
|
||||
|
||||
setModalVisible(false)
|
||||
setEditingRecord(null)
|
||||
form.resetFields()
|
||||
fetchData()
|
||||
} catch (error: any) {
|
||||
const errorMsg = error?.message || '操作失败'
|
||||
message.error(errorMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// 删除打卡营
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteCamp(id)
|
||||
message.success('删除成功')
|
||||
fetchData()
|
||||
} catch (error: any) {
|
||||
const errorMsg = error?.message || '删除失败'
|
||||
message.error(errorMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索
|
||||
const handleSearch = (value: string) => {
|
||||
setKeyword(value)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
// 重置筛选
|
||||
const handleResetFilters = () => {
|
||||
setKeyword('')
|
||||
setCategoryFilter(undefined)
|
||||
setRecommendFilter(RecommendFilter.ALL)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
// 简介类型标签
|
||||
const getIntroTypeTag = (type: IntroType) => {
|
||||
const tagMap = {
|
||||
[IntroType.NONE]: { color: 'default', text: '无简介' },
|
||||
[IntroType.IMAGE_TEXT]: { color: 'blue', text: '图文简介' },
|
||||
[IntroType.VIDEO]: { color: 'green', text: '视频简介' },
|
||||
}
|
||||
const config = tagMap[type]
|
||||
return <Tag color={config.color}>{config.text}</Tag>
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Camp> = [
|
||||
{
|
||||
title: '封面图',
|
||||
dataIndex: 'coverImage',
|
||||
key: 'coverImage',
|
||||
width: 100,
|
||||
render: (url: string) => <Image src={url} width={60} height={60} style={{ objectFit: 'cover' }} />,
|
||||
},
|
||||
{
|
||||
title: '标题',
|
||||
dataIndex: 'title',
|
||||
key: 'title',
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
title: '描述',
|
||||
dataIndex: 'description',
|
||||
key: 'description',
|
||||
ellipsis: true,
|
||||
width: 250,
|
||||
},
|
||||
{
|
||||
title: '分类',
|
||||
dataIndex: 'categoryId',
|
||||
key: 'categoryId',
|
||||
width: 120,
|
||||
render: (categoryId: string) => {
|
||||
const category = categories.find((c) => c.id === categoryId)
|
||||
return category?.name || '-'
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '简介类型',
|
||||
dataIndex: 'introType',
|
||||
key: 'introType',
|
||||
width: 100,
|
||||
render: getIntroTypeTag,
|
||||
},
|
||||
{
|
||||
title: '小节数',
|
||||
dataIndex: 'sectionCount',
|
||||
key: 'sectionCount',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: '推荐',
|
||||
dataIndex: 'isRecommended',
|
||||
key: 'isRecommended',
|
||||
width: 80,
|
||||
render: (recommended: boolean) => (
|
||||
<Tag color={recommended ? 'success' : 'default'}>{recommended ? '是' : '否'}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 280,
|
||||
fixed: 'right',
|
||||
render: (_, record) => (
|
||||
<Space wrap>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<UnorderedListOutlined />}
|
||||
onClick={() => navigate(`${RoutePath.CAMP_SECTION_LIST}?campId=${record.id}`)}
|
||||
>
|
||||
查看小节
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<UserOutlined />}
|
||||
onClick={() => navigate(`${RoutePath.CAMP_PROGRESS_LIST}?campId=${record.id}`)}
|
||||
>
|
||||
打卡营进度管理
|
||||
</Button>
|
||||
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => handleOpenModal(record)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确定要删除这个打卡营吗?"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
{/* 页面标题 */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 600 }}>打卡营列表</h2>
|
||||
<div style={{ color: '#666', fontSize: 14, marginTop: 8 }}>
|
||||
管理所有打卡营,支持按分类和推荐状态筛选,可查看打卡营下的小节列表
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Space wrap>
|
||||
<Input.Search
|
||||
placeholder="搜索打卡营标题"
|
||||
allowClear
|
||||
style={{ width: 300 }}
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onSearch={handleSearch}
|
||||
/>
|
||||
<Select
|
||||
placeholder="选择分类"
|
||||
allowClear
|
||||
style={{ width: 150 }}
|
||||
value={categoryFilter}
|
||||
onChange={(value) => {
|
||||
setCategoryFilter(value)
|
||||
setPage(1)
|
||||
}}
|
||||
options={categories.map((c) => ({ label: c.name, value: c.id }))}
|
||||
/>
|
||||
<Select
|
||||
placeholder="推荐状态"
|
||||
style={{ width: 150 }}
|
||||
value={recommendFilter}
|
||||
onChange={(value) => {
|
||||
setRecommendFilter(value)
|
||||
setPage(1)
|
||||
}}
|
||||
options={[
|
||||
{ label: '全部', value: RecommendFilter.ALL },
|
||||
{ label: '仅推荐', value: RecommendFilter.ONLY_TRUE },
|
||||
{ label: '仅非推荐', value: RecommendFilter.ONLY_FALSE },
|
||||
]}
|
||||
/>
|
||||
<Button icon={<ReloadOutlined />} onClick={fetchData}>
|
||||
刷新
|
||||
</Button>
|
||||
<Button icon={<ClearOutlined />} onClick={handleResetFilters}>
|
||||
重置
|
||||
</Button>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => handleOpenModal()}>
|
||||
新增打卡营
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={dataSource}
|
||||
rowKey="id"
|
||||
scroll={{ x: 1200 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
onChange: (page, pageSize) => {
|
||||
setPage(page)
|
||||
setPageSize(pageSize)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editingRecord ? '编辑打卡营' : '新增打卡营'}
|
||||
open={modalVisible}
|
||||
onOk={handleSubmit}
|
||||
onCancel={() => {
|
||||
setModalVisible(false)
|
||||
setEditingRecord(null)
|
||||
form.resetFields()
|
||||
}}
|
||||
width={700}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item label="标题" name="title" rules={[{ required: true, message: '请输入标题' }]}>
|
||||
<Input placeholder="请输入打卡营标题" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="封面图"
|
||||
name="coverImage"
|
||||
extra="建议图片比例 16:9"
|
||||
rules={[{ required: true, message: '请上传封面图' }]}
|
||||
>
|
||||
<ImageUpload
|
||||
placeholder="选择封面图"
|
||||
maxSize={10}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="描述" name="description" rules={[{ required: true, message: '请输入描述' }]}>
|
||||
<TextArea rows={4} placeholder="请输入打卡营描述" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="分类"
|
||||
name="categoryId"
|
||||
rules={[{ required: true, message: '请选择分类' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="请选择分类"
|
||||
options={categories.map((c) => ({ label: c.name, value: c.id }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="简介类型"
|
||||
name="introType"
|
||||
rules={[{ required: true, message: '请选择简介类型' }]}
|
||||
initialValue={IntroType.NONE}
|
||||
>
|
||||
<Select
|
||||
placeholder="请选择简介类型"
|
||||
onChange={() => {
|
||||
// 切换简介类型时,清空简介内容
|
||||
form.setFieldsValue({ introContent: '' })
|
||||
}}
|
||||
options={[
|
||||
{ label: '无简介', value: IntroType.NONE },
|
||||
{ label: '图文简介', value: IntroType.IMAGE_TEXT },
|
||||
{ label: '视频简介', value: IntroType.VIDEO },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
{/* 图文简介:显示图片上传 */}
|
||||
{introTypeValue === IntroType.IMAGE_TEXT && (
|
||||
<Form.Item
|
||||
label="简介内容"
|
||||
name="introContent"
|
||||
rules={[{ required: true, message: '请上传图片' }]}
|
||||
>
|
||||
<ImageUpload placeholder="上传图片" />
|
||||
</Form.Item>
|
||||
)}
|
||||
{/* 视频简介:显示URL输入框 */}
|
||||
{introTypeValue === IntroType.VIDEO && (
|
||||
<Form.Item
|
||||
label="简介内容"
|
||||
name="introContent"
|
||||
rules={[{ required: true, message: '请输入视频URL' }]}
|
||||
>
|
||||
<Input placeholder="请输入视频URL" />
|
||||
</Form.Item>
|
||||
)}
|
||||
{/* 无简介时不显示简介内容字段 */}
|
||||
<Form.Item
|
||||
label="是否推荐"
|
||||
name="isRecommended"
|
||||
valuePropName="checked"
|
||||
initialValue={false}
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CampList
|
||||
|
||||
263
src/pages/Camp/CategoryList.tsx
Normal file
263
src/pages/Camp/CategoryList.tsx
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Table, Button, Form, Input, Modal, message, Space, Popconfirm, InputNumber } from 'antd'
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, ReloadOutlined, UnorderedListOutlined, ClearOutlined } from '@ant-design/icons'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import type { Category } from '@/types/camp'
|
||||
import { listCategories, createCategory, updateCategory, deleteCategory } from '@/api/camp'
|
||||
import { DEFAULT_PAGE_SIZE, RoutePath } from '@/constants'
|
||||
|
||||
const CategoryList = () => {
|
||||
const navigate = useNavigate()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [dataSource, setDataSource] = useState<Category[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE)
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [modalVisible, setModalVisible] = useState(false)
|
||||
const [editingRecord, setEditingRecord] = useState<Category | null>(null)
|
||||
|
||||
const [form] = Form.useForm()
|
||||
|
||||
// 获取列表数据
|
||||
const fetchData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await listCategories({ page, pageSize, keyword })
|
||||
if (res.data.code === 200) {
|
||||
const list = res.data.data.list || []
|
||||
const total = res.data.data.total || 0
|
||||
setDataSource(list)
|
||||
setTotal(total)
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('获取分类列表错误:', error)
|
||||
// 设置空列表,不显示错误提示
|
||||
setDataSource([])
|
||||
setTotal(0)
|
||||
// 只在真正的服务器错误时提示
|
||||
if (error.response?.status >= 500 || !error.response) {
|
||||
message.error('服务器错误,请稍后重试')
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [page, pageSize, keyword])
|
||||
|
||||
// 监听 Modal 打开,设置表单初始值
|
||||
useEffect(() => {
|
||||
if (modalVisible && editingRecord) {
|
||||
// Modal 打开且有编辑记录时,设置表单值
|
||||
console.log('编辑分类数据:', editingRecord)
|
||||
form.setFieldsValue({
|
||||
name: editingRecord.name,
|
||||
sortOrder: editingRecord.sortOrder,
|
||||
})
|
||||
}
|
||||
}, [modalVisible, editingRecord, form])
|
||||
|
||||
// 打开新增/编辑弹窗
|
||||
const handleOpenModal = (record?: Category) => {
|
||||
if (record) {
|
||||
setEditingRecord(record)
|
||||
} else {
|
||||
setEditingRecord(null)
|
||||
form.resetFields()
|
||||
}
|
||||
setModalVisible(true)
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields()
|
||||
|
||||
if (editingRecord) {
|
||||
// 编辑
|
||||
await updateCategory({ ...editingRecord, ...values })
|
||||
message.success('编辑分类成功')
|
||||
} else {
|
||||
// 新增
|
||||
await createCategory(values)
|
||||
message.success('新增分类成功')
|
||||
}
|
||||
|
||||
setModalVisible(false)
|
||||
setEditingRecord(null)
|
||||
form.resetFields()
|
||||
fetchData()
|
||||
} catch (error: any) {
|
||||
const errorMsg = error?.message || '操作失败'
|
||||
message.error(errorMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// 删除分类
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteCategory(id)
|
||||
message.success('删除成功')
|
||||
fetchData()
|
||||
} catch (error: any) {
|
||||
// 优先使用后端返回的 message,避免显示 "Request failed with status code 400"
|
||||
const msg = error?.response?.data?.message || error?.message || '删除失败'
|
||||
message.error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索
|
||||
const handleSearch = (value: string) => {
|
||||
setKeyword(value)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
// 重置筛选
|
||||
const handleResetFilters = () => {
|
||||
setKeyword('')
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Category> = [
|
||||
{
|
||||
title: '分类ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
title: '分类名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
title: '排序',
|
||||
dataIndex: 'sortOrder',
|
||||
key: 'sortOrder',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 220,
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<UnorderedListOutlined />}
|
||||
onClick={() => navigate(`${RoutePath.CAMP_LIST}?categoryId=${record.id}`)}
|
||||
>
|
||||
查看打卡营
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleOpenModal(record)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确定要删除这个分类吗?"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
{/* 页面标题 */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 600 }}>分类管理</h2>
|
||||
<div style={{ color: '#666', fontSize: 14, marginTop: 8 }}>
|
||||
管理打卡营的分类,支持新增、编辑、删除分类,并可查看每个分类下的打卡营列表
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Space>
|
||||
<Input.Search
|
||||
placeholder="搜索分类名称"
|
||||
allowClear
|
||||
style={{ width: 300 }}
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onSearch={handleSearch}
|
||||
/>
|
||||
<Button icon={<ReloadOutlined />} onClick={fetchData}>
|
||||
刷新
|
||||
</Button>
|
||||
<Button icon={<ClearOutlined />} onClick={handleResetFilters}>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => handleOpenModal()}>
|
||||
新增分类
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={dataSource}
|
||||
rowKey="id"
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
onChange: (page, pageSize) => {
|
||||
setPage(page)
|
||||
setPageSize(pageSize)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editingRecord ? '编辑分类' : '新增分类'}
|
||||
open={modalVisible}
|
||||
onOk={handleSubmit}
|
||||
onCancel={() => {
|
||||
setModalVisible(false)
|
||||
setEditingRecord(null)
|
||||
form.resetFields()
|
||||
}}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
label="分类名称"
|
||||
name="name"
|
||||
rules={[{ required: true, message: '请输入分类名称' }]}
|
||||
>
|
||||
<Input placeholder="请输入分类名称" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="排序"
|
||||
name="sortOrder"
|
||||
rules={[{ required: true, message: '请输入排序值' }]}
|
||||
initialValue={0}
|
||||
>
|
||||
<InputNumber min={0} style={{ width: '100%' }} placeholder="请输入排序值" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CategoryList
|
||||
|
||||
452
src/pages/Camp/PendingTaskList.tsx
Normal file
452
src/pages/Camp/PendingTaskList.tsx
Normal file
|
|
@ -0,0 +1,452 @@
|
|||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { Table, Tag, Space, Select, Button, message, theme, Typography, Form } from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import dayjs from 'dayjs'
|
||||
import { TaskType, type Task, type Section, type Camp, type UserProgress } from '@/types/camp'
|
||||
import { listUserProgress, listTasks, listSections, listCamps, updateUserProgress } from '@/api/camp'
|
||||
import { getUploadSignature } from '@/api/oss'
|
||||
import { DEFAULT_PAGE_SIZE } from '@/constants'
|
||||
import { ReviewModal, type ReviewingProgress } from './UserProgressList/ReviewModal'
|
||||
import { TASK_TYPE_MAP, IMG_COUNTS_MARKER } from './UserProgressList/constants'
|
||||
import { parseEssayReview, splitReviewImagesByCounts } from './UserProgressList/utils'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
const PAGE_SIZE = Math.max(DEFAULT_PAGE_SIZE, 50)
|
||||
|
||||
const PendingTaskList = () => {
|
||||
const { token } = theme.useToken()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [camps, setCamps] = useState<Camp[]>([])
|
||||
const [sections, setSections] = useState<Section[]>([])
|
||||
const [tasks, setTasks] = useState<Task[]>([])
|
||||
const [campFilter, setCampFilter] = useState<string | undefined>()
|
||||
const [sectionFilter, setSectionFilter] = useState<string | undefined>()
|
||||
const [page, setPage] = useState(1)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [progressList, setProgressList] = useState<UserProgress[]>([])
|
||||
|
||||
const [reviewModalVisible, setReviewModalVisible] = useState(false)
|
||||
const [reviewingProgress, setReviewingProgress] = useState<ReviewingProgress | null>(null)
|
||||
const [reviewImageUploading, setReviewImageUploading] = useState(false)
|
||||
const [form] = Form.useForm()
|
||||
|
||||
const campMap = useMemo(() => new Map(camps.map((c) => [c.id, c.title])), [camps])
|
||||
const sectionMap = useMemo(() => new Map(sections.map((s) => [s.id, s.title])), [sections])
|
||||
const taskMap = useMemo(() => new Map(tasks.map((t) => [t.id, t])), [tasks])
|
||||
|
||||
const fetchCamps = useCallback(async () => {
|
||||
try {
|
||||
const res = await listCamps({ page: 1, pageSize: 1000 })
|
||||
if (res.data.code === 200) setCamps(res.data.data.list || [])
|
||||
} catch {
|
||||
setCamps([])
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchSections = useCallback(async (campId?: string) => {
|
||||
try {
|
||||
const params: any = { page: 1, pageSize: 1000 }
|
||||
if (campId) params.campId = campId
|
||||
const res = await listSections(params)
|
||||
if (res.data.code === 200) setSections(res.data.data.list || [])
|
||||
} catch {
|
||||
setSections([])
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchTasks = useCallback(async () => {
|
||||
try {
|
||||
const res = await listTasks({ page: 1, pageSize: 2000 })
|
||||
if (res.data.code === 200) setTasks(res.data.data.list || [])
|
||||
} catch {
|
||||
setTasks([])
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await listUserProgress({
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
campId: campFilter || undefined,
|
||||
sectionId: sectionFilter || undefined,
|
||||
reviewStatus: 'pending',
|
||||
})
|
||||
if (res.data.code === 200) {
|
||||
setProgressList(res.data.data.list || [])
|
||||
setTotal(res.data.data.total ?? 0)
|
||||
} else {
|
||||
setProgressList([])
|
||||
setTotal(0)
|
||||
}
|
||||
} catch (error: any) {
|
||||
setProgressList([])
|
||||
setTotal(0)
|
||||
if (error.response?.status >= 500 || !error.response) message.error('服务器错误,请稍后重试')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, campFilter, sectionFilter])
|
||||
|
||||
useEffect(() => {
|
||||
fetchCamps()
|
||||
fetchSections()
|
||||
fetchTasks()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [fetchData])
|
||||
|
||||
const handleCampChange = (value?: string) => {
|
||||
setCampFilter(value)
|
||||
setSectionFilter(undefined)
|
||||
setPage(1)
|
||||
fetchSections(value)
|
||||
}
|
||||
|
||||
const handleSectionChange = (value?: string) => {
|
||||
setSectionFilter(value)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const handleReviewImageUpload = useCallback(async (options: any) => {
|
||||
const { file, onSuccess, onError } = options
|
||||
const fileObj = file as File
|
||||
if (fileObj.size > 5 * 1024 * 1024) {
|
||||
message.error('文件大小不能超过 5MB')
|
||||
onError?.(new Error('文件大小不能超过 5MB'))
|
||||
return
|
||||
}
|
||||
if (!fileObj.type.startsWith('image/')) {
|
||||
message.error('只能上传图片文件')
|
||||
onError?.(new Error('只能上传图片文件'))
|
||||
return
|
||||
}
|
||||
const current: string[] = form.getFieldValue('reviewImages') || []
|
||||
if (current.length >= 6) {
|
||||
message.error('审核图片最多 6 张')
|
||||
onError?.(new Error('审核图片最多 6 张'))
|
||||
return
|
||||
}
|
||||
setReviewImageUploading(true)
|
||||
try {
|
||||
const credentials = await getUploadSignature('camp')
|
||||
const host = credentials.host
|
||||
const timestamp = Date.now()
|
||||
const random = Math.random().toString(36).substring(2, 8)
|
||||
const ext = fileObj.name.split('.').pop()
|
||||
const key = `${credentials.dir}${timestamp}_${random}.${ext}`
|
||||
const formData = new FormData()
|
||||
formData.append('success_action_status', '200')
|
||||
formData.append('policy', credentials.policy)
|
||||
formData.append('x-oss-signature', credentials.signature)
|
||||
formData.append('x-oss-signature-version', credentials.x_oss_signature_version)
|
||||
formData.append('x-oss-credential', credentials.x_oss_credential)
|
||||
formData.append('x-oss-date', credentials.x_oss_date)
|
||||
formData.append('key', key)
|
||||
formData.append('x-oss-security-token', credentials.security_token)
|
||||
formData.append('file', fileObj)
|
||||
const resp = await fetch(host, { method: 'POST', body: formData })
|
||||
if (!resp.ok) throw new Error(`上传失败: ${resp.status}`)
|
||||
const imageUrl = `${host}/${key}`
|
||||
const cur: string[] = form.getFieldValue('reviewImages') || []
|
||||
if (cur.length < 6) form.setFieldsValue({ reviewImages: [...cur, imageUrl] })
|
||||
onSuccess?.(imageUrl)
|
||||
message.success('图片上传成功')
|
||||
} catch (err: any) {
|
||||
message.error(err.message || '上传失败')
|
||||
onError?.(err)
|
||||
} finally {
|
||||
setReviewImageUploading(false)
|
||||
}
|
||||
}, [form])
|
||||
|
||||
const handleOpenReviewModal = useCallback((userId: string, taskId: string, progress: UserProgress | null) => {
|
||||
setReviewingProgress({ userId, taskId, progress })
|
||||
const task = tasks.find((t) => t.id === taskId)
|
||||
const isEssay = Boolean(task && Number(task.taskType) === TaskType.ESSAY && progress?.essayAnswerImages?.length)
|
||||
if (progress) {
|
||||
const validReviewImages = (progress.reviewImages || []).filter((url: string) => url && url.trim() !== '')
|
||||
if (isEssay) {
|
||||
const { comments, imageCounts } = parseEssayReview(progress)
|
||||
const questionCount = progress.essayAnswerImages!.length
|
||||
const commentsPadded = Array.from({ length: questionCount }, (_, i) => comments[i] ?? '')
|
||||
const statuses = Array.isArray(progress.essayReviewStatuses) && progress.essayReviewStatuses.length === questionCount
|
||||
? progress.essayReviewStatuses
|
||||
: Array.from({ length: questionCount }, () => progress.reviewStatus)
|
||||
let essayReviewImages: string[][] = []
|
||||
if (imageCounts.length === questionCount && validReviewImages.length > 0) {
|
||||
essayReviewImages = splitReviewImagesByCounts(validReviewImages, imageCounts)
|
||||
} else {
|
||||
essayReviewImages = Array.from({ length: questionCount }, () => [])
|
||||
}
|
||||
form.setFieldsValue({
|
||||
reviewStatus: progress.reviewStatus,
|
||||
reviewComment: '',
|
||||
reviewImages: [],
|
||||
essayReviewComments: commentsPadded,
|
||||
essayReviewImages,
|
||||
essayReviewStatuses: statuses,
|
||||
})
|
||||
} else {
|
||||
form.setFieldsValue({
|
||||
reviewStatus: progress.reviewStatus,
|
||||
reviewComment: progress.reviewComment,
|
||||
reviewImages: validReviewImages,
|
||||
essayReviewComments: undefined,
|
||||
essayReviewImages: undefined,
|
||||
essayReviewStatuses: undefined,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
form.resetFields()
|
||||
}
|
||||
setReviewModalVisible(true)
|
||||
}, [form, tasks])
|
||||
|
||||
const handleEssayReviewImageUpload = useCallback(
|
||||
async (questionIndex: number, options: any) => {
|
||||
const { file, onSuccess, onError } = options
|
||||
const fileObj = file as File
|
||||
if (fileObj.size > 5 * 1024 * 1024) {
|
||||
message.error('文件大小不能超过 5MB')
|
||||
onError?.(new Error('文件大小不能超过 5MB'))
|
||||
return
|
||||
}
|
||||
if (!fileObj.type.startsWith('image/')) {
|
||||
message.error('只能上传图片文件')
|
||||
onError?.(new Error('只能上传图片文件'))
|
||||
return
|
||||
}
|
||||
const essayReviewImages: string[][] = form.getFieldValue('essayReviewImages') || []
|
||||
const totalImages = essayReviewImages.flat().length
|
||||
if (totalImages >= 12) {
|
||||
message.error('各题批改图片合计最多 12 张')
|
||||
onError?.(new Error('各题批改图片合计最多 12 张'))
|
||||
return
|
||||
}
|
||||
setReviewImageUploading(true)
|
||||
try {
|
||||
const credentials = await getUploadSignature('camp')
|
||||
const host = credentials.host
|
||||
const timestamp = Date.now()
|
||||
const random = Math.random().toString(36).substring(2, 8)
|
||||
const ext = fileObj.name.split('.').pop()
|
||||
const key = `${credentials.dir}${timestamp}_${random}.${ext}`
|
||||
const formData = new FormData()
|
||||
formData.append('success_action_status', '200')
|
||||
formData.append('policy', credentials.policy)
|
||||
formData.append('x-oss-signature', credentials.signature)
|
||||
formData.append('x-oss-signature-version', credentials.x_oss_signature_version)
|
||||
formData.append('x-oss-credential', credentials.x_oss_credential)
|
||||
formData.append('x-oss-date', credentials.x_oss_date)
|
||||
formData.append('key', key)
|
||||
formData.append('x-oss-security-token', credentials.security_token)
|
||||
formData.append('file', fileObj)
|
||||
const resp = await fetch(host, { method: 'POST', body: formData })
|
||||
if (!resp.ok) throw new Error(`上传失败: ${resp.status}`)
|
||||
const imageUrl = `${host}/${key}`
|
||||
const cur: string[][] = form.getFieldValue('essayReviewImages') || []
|
||||
const next = cur.map((arr: string[], i: number) =>
|
||||
i === questionIndex ? [...(arr || []), imageUrl] : arr || []
|
||||
)
|
||||
if (questionIndex >= next.length) {
|
||||
while (next.length <= questionIndex) next.push([])
|
||||
next[questionIndex] = [imageUrl]
|
||||
}
|
||||
form.setFieldsValue({ essayReviewImages: next })
|
||||
onSuccess?.(imageUrl)
|
||||
message.success('图片上传成功')
|
||||
} catch (err: any) {
|
||||
message.error(err.message || '上传失败')
|
||||
onError?.(err)
|
||||
} finally {
|
||||
setReviewImageUploading(false)
|
||||
}
|
||||
},
|
||||
[form]
|
||||
)
|
||||
|
||||
const handleSubmitReview = useCallback(async () => {
|
||||
if (!reviewingProgress) return
|
||||
try {
|
||||
const values = await form.validateFields()
|
||||
const { userId, taskId, progress } = reviewingProgress
|
||||
const task = tasks.find((t) => t.id === taskId)
|
||||
const isEssay = Boolean(task && Number(task.taskType) === TaskType.ESSAY && progress?.essayAnswerImages?.length)
|
||||
let reviewComment: string
|
||||
let cleanedReviewImages: string[]
|
||||
if (isEssay) {
|
||||
const comments: string[] = values.essayReviewComments ?? []
|
||||
const perQuestionImages: string[][] = values.essayReviewImages ?? []
|
||||
reviewComment = comments
|
||||
.map((c: string, i: number) => `【第${i + 1}题】\n${(c || '').trim()}`)
|
||||
.join('\n\n')
|
||||
const counts = perQuestionImages.map((arr: string[]) => (arr || []).filter((u: string) => u && String(u).trim() !== '').length)
|
||||
if (counts.some((n) => n > 0)) reviewComment += `\n\n${IMG_COUNTS_MARKER}${counts.join(',')}`
|
||||
cleanedReviewImages = (perQuestionImages || []).flat().filter((url: string) => url && String(url).trim() !== '')
|
||||
} else {
|
||||
reviewComment = values.reviewComment ?? ''
|
||||
cleanedReviewImages = Array.isArray(values.reviewImages)
|
||||
? values.reviewImages.filter((url: string) => url && url.trim() !== '')
|
||||
: []
|
||||
}
|
||||
await updateUserProgress({
|
||||
userId,
|
||||
taskId,
|
||||
isCompleted: progress?.isCompleted ?? false,
|
||||
completedAt: progress?.completedAt || String(Math.floor(Date.now() / 1000)),
|
||||
reviewStatus: isEssay ? values.essayReviewStatuses?.[0] ?? 0 : values.reviewStatus,
|
||||
reviewComment,
|
||||
reviewImages: cleanedReviewImages,
|
||||
...(isEssay && Array.isArray(values.essayReviewStatuses) ? { essayReviewStatuses: values.essayReviewStatuses } : {}),
|
||||
})
|
||||
message.success('审核成功')
|
||||
setReviewModalVisible(false)
|
||||
setReviewingProgress(null)
|
||||
fetchData()
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '审核失败')
|
||||
}
|
||||
}, [reviewingProgress, form, fetchData, tasks])
|
||||
|
||||
const columns: ColumnsType<UserProgress> = [
|
||||
{
|
||||
title: '打卡营',
|
||||
dataIndex: 'campId',
|
||||
render: (id: string | undefined) => campMap.get(id || '') || '-',
|
||||
},
|
||||
{
|
||||
title: '小节',
|
||||
dataIndex: 'sectionId',
|
||||
render: (id: string | undefined) => sectionMap.get(id || '') || '-',
|
||||
},
|
||||
{
|
||||
title: '任务',
|
||||
dataIndex: 'taskId',
|
||||
render: (id: string) => {
|
||||
const t = taskMap.get(id)
|
||||
const title = t?.title || id
|
||||
const type = t?.taskType
|
||||
return (
|
||||
<Space size={4} direction="vertical">
|
||||
<Text>{title}</Text>
|
||||
{type !== undefined && <Tag color="blue">{TASK_TYPE_MAP[type] || type}</Tag>}
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '用户ID',
|
||||
dataIndex: 'userId',
|
||||
width: 140,
|
||||
},
|
||||
{
|
||||
title: '审核状态',
|
||||
dataIndex: 'reviewStatus',
|
||||
width: 120,
|
||||
render: (s: any) => {
|
||||
const v = typeof s === 'number' ? s : 0
|
||||
const color = v === 1 ? 'green' : v === 2 ? 'red' : 'orange'
|
||||
const text = v === 1 ? '通过' : v === 2 ? '驳回' : '待审核'
|
||||
return <Tag color={color}>{text}</Tag>
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '提交时间',
|
||||
dataIndex: 'completedAt',
|
||||
width: 180,
|
||||
render: (v: string) => {
|
||||
if (!v || v === '0') return '-'
|
||||
if (/^\d+$/.test(v)) return dayjs(Number(v) * 1000).format('YYYY-MM-DD HH:mm:ss')
|
||||
return dayjs(v).isValid() ? dayjs(v).format('YYYY-MM-DD HH:mm:ss') : v
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 120,
|
||||
render: (_, record) => (
|
||||
<Button type="link" onClick={() => handleOpenReviewModal(record.userId, record.taskId, record)}>审核</Button>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<Text style={{ fontSize: 16, fontWeight: 600, marginRight: 12 }}>任务审批列表</Text>
|
||||
<Text type="secondary">当前仅展示待审核的任务</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 16, padding: 12, background: token.colorFillTertiary, borderRadius: 8 }}>
|
||||
<Space size={16} wrap>
|
||||
<span>
|
||||
<Text style={{ marginRight: 8 }}>打卡营:</Text>
|
||||
<Select
|
||||
style={{ width: 220 }}
|
||||
allowClear
|
||||
placeholder="全部打卡营"
|
||||
value={campFilter}
|
||||
onChange={handleCampChange}
|
||||
options={camps.map((c) => ({ label: c.title, value: c.id }))}
|
||||
/>
|
||||
</span>
|
||||
<span>
|
||||
<Text style={{ marginRight: 8 }}>小节:</Text>
|
||||
<Select
|
||||
style={{ width: 220 }}
|
||||
allowClear
|
||||
placeholder="全部小节"
|
||||
value={sectionFilter}
|
||||
onChange={handleSectionChange}
|
||||
options={sections
|
||||
.filter((s) => !campFilter || s.campId === campFilter)
|
||||
.map((s) => ({ label: s.title, value: s.id }))}
|
||||
/>
|
||||
</span>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={progressList}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize: PAGE_SIZE,
|
||||
total,
|
||||
onChange: (p) => setPage(p),
|
||||
showSizeChanger: false,
|
||||
}}
|
||||
/>
|
||||
|
||||
<ReviewModal
|
||||
open={reviewModalVisible}
|
||||
form={form}
|
||||
reviewingProgress={reviewingProgress}
|
||||
isEssayReview={Boolean(reviewingProgress && tasks.find((t) => t.id === reviewingProgress.taskId && Number(t.taskType) === TaskType.ESSAY))}
|
||||
isSubjectiveReview={Boolean(reviewingProgress && tasks.find((t) => t.id === reviewingProgress.taskId && Number(t.taskType) === TaskType.SUBJECTIVE))}
|
||||
reviewImageUploading={reviewImageUploading}
|
||||
formatCompletedAt={(completedAt: string) => {
|
||||
if (!completedAt || completedAt === '0') return null
|
||||
if (/^\d+$/.test(completedAt)) return dayjs(Number(completedAt) * 1000).format('YYYY-MM-DD HH:mm:ss')
|
||||
return dayjs(completedAt).isValid() ? dayjs(completedAt).format('YYYY-MM-DD HH:mm:ss') : completedAt
|
||||
}}
|
||||
onOk={handleSubmitReview}
|
||||
onCancel={() => setReviewModalVisible(false)}
|
||||
onReviewImageUpload={handleReviewImageUpload}
|
||||
onEssayReviewImageUpload={handleEssayReviewImageUpload}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PendingTaskList
|
||||
|
||||
497
src/pages/Camp/SectionList.tsx
Normal file
497
src/pages/Camp/SectionList.tsx
Normal file
|
|
@ -0,0 +1,497 @@
|
|||
import { useState, useEffect, useMemo } from 'react'
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
message,
|
||||
Space,
|
||||
Popconfirm,
|
||||
Select,
|
||||
InputNumber,
|
||||
Tag,
|
||||
} from 'antd'
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, ReloadOutlined, UnorderedListOutlined, UserOutlined, ClearOutlined } from '@ant-design/icons'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import type { Section, Camp } from '@/types/camp'
|
||||
import { TimeIntervalType } from '@/types/camp'
|
||||
import { listSections, createSection, updateSection, deleteSection, listCamps } from '@/api/camp'
|
||||
import { DEFAULT_PAGE_SIZE, RoutePath } from '@/constants'
|
||||
|
||||
const SectionList = () => {
|
||||
const navigate = useNavigate()
|
||||
const [searchParams] = useSearchParams()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [dataSource, setDataSource] = useState<Section[]>([])
|
||||
const [camps, setCamps] = useState<Camp[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE)
|
||||
const [keyword, setKeyword] = useState('')
|
||||
// 从 URL 初始化打卡营筛选,避免从 CampList 跳转过来时首屏请求未带 campId
|
||||
const [campFilter, setCampFilter] = useState<string | undefined>(() => searchParams.get('campId') ?? undefined)
|
||||
const [modalVisible, setModalVisible] = useState(false)
|
||||
const [editingRecord, setEditingRecord] = useState<Section | null>(null)
|
||||
|
||||
const [form] = Form.useForm()
|
||||
const timeIntervalType = Form.useWatch('timeIntervalType', form)
|
||||
const sectionMaxNumberMap = useMemo(() => {
|
||||
const map: Record<string, number> = {}
|
||||
dataSource.forEach((section) => {
|
||||
const key = section.campId
|
||||
const sectionNumber = section.sectionNumber ?? 0
|
||||
if (map[key] === undefined || sectionNumber > map[key]) {
|
||||
map[key] = sectionNumber
|
||||
}
|
||||
})
|
||||
return map
|
||||
}, [dataSource])
|
||||
|
||||
// 从 URL 参数中读取打卡营筛选
|
||||
useEffect(() => {
|
||||
const campId = searchParams.get('campId')
|
||||
if (campId) {
|
||||
setCampFilter(campId)
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
// 获取打卡营列表
|
||||
const fetchCamps = async () => {
|
||||
try {
|
||||
const res = await listCamps({ page: 1, pageSize: 100 })
|
||||
if (res.data.code === 200) {
|
||||
setCamps(res.data.data.list)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取打卡营列表失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 获取列表数据
|
||||
const fetchData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await listSections({
|
||||
page,
|
||||
pageSize,
|
||||
keyword,
|
||||
campId: campFilter,
|
||||
})
|
||||
if (res.data.code === 200) {
|
||||
const list = res.data.data.list || []
|
||||
const total = res.data.data.total || 0
|
||||
setDataSource(list)
|
||||
setTotal(total)
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('获取小节列表错误:', error)
|
||||
setDataSource([])
|
||||
setTotal(0)
|
||||
if (error.response?.status >= 500 || !error.response) {
|
||||
message.error('服务器错误,请稍后重试')
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchCamps()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [page, pageSize, keyword, campFilter])
|
||||
|
||||
// 监听 Modal 打开,设置表单初始值
|
||||
useEffect(() => {
|
||||
if (modalVisible && editingRecord) {
|
||||
console.log('编辑小节数据:', editingRecord)
|
||||
form.setFieldsValue({
|
||||
campId: editingRecord.campId,
|
||||
title: editingRecord.title,
|
||||
sectionNumber: editingRecord.sectionNumber,
|
||||
priceFen: editingRecord.priceFen,
|
||||
timeIntervalType: editingRecord.timeIntervalType,
|
||||
timeIntervalValue: editingRecord.timeIntervalValue,
|
||||
})
|
||||
}
|
||||
}, [modalVisible, editingRecord, form])
|
||||
|
||||
// 打开新增/编辑弹窗
|
||||
const handleOpenModal = (record?: Section, presetValues: Partial<Section> = {}) => {
|
||||
if (record) {
|
||||
setEditingRecord(record)
|
||||
} else {
|
||||
setEditingRecord(null)
|
||||
form.resetFields()
|
||||
const initialValues: any = {}
|
||||
if (campFilter) {
|
||||
initialValues.campId = campFilter
|
||||
}
|
||||
form.setFieldsValue({
|
||||
...initialValues,
|
||||
...presetValues,
|
||||
})
|
||||
}
|
||||
setModalVisible(true)
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields()
|
||||
const type = values.timeIntervalType ?? TimeIntervalType.NONE
|
||||
if (type === TimeIntervalType.NONE) {
|
||||
values.timeIntervalValue = 0
|
||||
} else if (type === TimeIntervalType.NATURAL_DAY) {
|
||||
values.timeIntervalValue = 1
|
||||
} else if (type === TimeIntervalType.PAID) {
|
||||
values.timeIntervalValue = 0
|
||||
}
|
||||
// 非「收费」类型时不展示价格,提交时默认 0
|
||||
if (type !== TimeIntervalType.PAID) {
|
||||
values.priceFen = 0
|
||||
}
|
||||
// 小时间隔时 timeIntervalValue 由表单输入
|
||||
|
||||
if (editingRecord) {
|
||||
// 编辑
|
||||
await updateSection({ ...editingRecord, ...values })
|
||||
message.success('编辑小节成功')
|
||||
} else {
|
||||
// 新增:自动取该打卡营下当前最大编号 +1
|
||||
const listRes = await listSections({ page: 1, pageSize: 1000, campId: values.campId })
|
||||
const list = listRes?.data?.data?.list || []
|
||||
const maxNum = list.length === 0 ? 0 : Math.max(...list.map((s: Section) => s.sectionNumber ?? 0))
|
||||
values.sectionNumber = maxNum + 1
|
||||
await createSection(values)
|
||||
message.success('新增小节成功')
|
||||
}
|
||||
|
||||
setModalVisible(false)
|
||||
setEditingRecord(null)
|
||||
form.resetFields()
|
||||
fetchData()
|
||||
} catch (error: any) {
|
||||
const errorMsg = error?.message || '操作失败'
|
||||
message.error(errorMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// 删除小节
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteSection(id)
|
||||
message.success('删除成功')
|
||||
fetchData()
|
||||
} catch (error: any) {
|
||||
const errorMsg = error?.message || '删除失败'
|
||||
message.error(errorMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索
|
||||
const handleSearch = (value: string) => {
|
||||
setKeyword(value)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
// 重置筛选
|
||||
const handleResetFilters = () => {
|
||||
setKeyword('')
|
||||
setCampFilter(undefined)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
// 时间间隔类型标签
|
||||
const getTimeIntervalTag = (type: TimeIntervalType | undefined) => {
|
||||
const tagMap: Record<number, { color: string; text: string }> = {
|
||||
[TimeIntervalType.NONE]: { color: 'default', text: '无限制' },
|
||||
[TimeIntervalType.HOUR]: { color: 'blue', text: '小时间隔' },
|
||||
[TimeIntervalType.NATURAL_DAY]: { color: 'green', text: '自然天' },
|
||||
[TimeIntervalType.PAID]: { color: 'orange', text: '收费' },
|
||||
}
|
||||
const config = tagMap[type ?? TimeIntervalType.NONE]
|
||||
if (!config) {
|
||||
return <Tag color="default">未知</Tag>
|
||||
}
|
||||
return <Tag color={config.color}>{config.text}</Tag>
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Section> = [
|
||||
{
|
||||
title: '小节编号',
|
||||
dataIndex: 'sectionNumber',
|
||||
key: 'sectionNumber',
|
||||
width: 100,
|
||||
sorter: (a, b) => a.sectionNumber - b.sectionNumber,
|
||||
},
|
||||
{
|
||||
title: '小节标题',
|
||||
dataIndex: 'title',
|
||||
key: 'title',
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
title: '所属打卡营',
|
||||
dataIndex: 'campId',
|
||||
key: 'campId',
|
||||
width: 180,
|
||||
render: (campId: string) => {
|
||||
const camp = camps.find((c) => c.id === campId)
|
||||
return camp?.title || '-'
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '价格(元)',
|
||||
dataIndex: 'priceFen',
|
||||
key: 'priceFen',
|
||||
width: 100,
|
||||
render: (priceFen: number) => {
|
||||
const price = priceFen ?? 0
|
||||
return price === 0 ? '免费' : `¥${(price / 100).toFixed(2)}`
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '时间间隔类型',
|
||||
dataIndex: 'timeIntervalType',
|
||||
key: 'timeIntervalType',
|
||||
width: 120,
|
||||
render: getTimeIntervalTag,
|
||||
},
|
||||
{
|
||||
title: '间隔值',
|
||||
dataIndex: 'timeIntervalValue',
|
||||
key: 'timeIntervalValue',
|
||||
width: 100,
|
||||
render: (value: number, record) => {
|
||||
const intervalType = record.timeIntervalType ?? TimeIntervalType.NONE
|
||||
if (intervalType === TimeIntervalType.NONE || intervalType === TimeIntervalType.PAID) return '-'
|
||||
const unit = intervalType === TimeIntervalType.HOUR ? '小时' : '天'
|
||||
return `${value ?? 0}${unit}`
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 300,
|
||||
fixed: 'right',
|
||||
render: (_, record) => {
|
||||
const currentNumber = record.sectionNumber ?? 0
|
||||
const maxNumber = sectionMaxNumberMap[record.campId] ?? currentNumber
|
||||
const isLastSection = currentNumber >= maxNumber
|
||||
|
||||
return (
|
||||
<Space wrap>
|
||||
{isLastSection && (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() =>
|
||||
handleOpenModal(undefined, {
|
||||
campId: record.campId,
|
||||
})
|
||||
}
|
||||
>
|
||||
添加下节
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<UnorderedListOutlined />}
|
||||
onClick={() => navigate(`${RoutePath.CAMP_TASK_LIST}?sectionId=${record.id}`)}
|
||||
>
|
||||
查看任务
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<UserOutlined />}
|
||||
onClick={() => navigate(`${RoutePath.CAMP_PROGRESS_LIST}?sectionId=${record.id}`)}
|
||||
>
|
||||
打卡营进度管理
|
||||
</Button>
|
||||
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => handleOpenModal(record)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确定要删除这个小节吗?"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
{/* 页面标题 */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 600 }}>小节管理</h2>
|
||||
<div style={{ color: '#666', fontSize: 14, marginTop: 8 }}>
|
||||
管理打卡营的小节,包括价格设置、时间间隔配置等,可查看小节下的任务列表和用户完成进度
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Space wrap>
|
||||
<Input.Search
|
||||
placeholder="搜索小节标题"
|
||||
allowClear
|
||||
style={{ width: 300 }}
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onSearch={handleSearch}
|
||||
/>
|
||||
<Select
|
||||
placeholder="选择打卡营"
|
||||
allowClear
|
||||
style={{ width: 200 }}
|
||||
value={campFilter}
|
||||
onChange={(value) => {
|
||||
setCampFilter(value)
|
||||
setPage(1)
|
||||
}}
|
||||
options={camps.map((c) => ({ label: c.title, value: c.id }))}
|
||||
/>
|
||||
<Button icon={<ReloadOutlined />} onClick={fetchData}>
|
||||
刷新
|
||||
</Button>
|
||||
<Button icon={<ClearOutlined />} onClick={handleResetFilters}>
|
||||
重置
|
||||
</Button>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => handleOpenModal()}>
|
||||
新增小节
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={dataSource}
|
||||
rowKey="id"
|
||||
scroll={{ x: 1200 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
onChange: (page, pageSize) => {
|
||||
setPage(page)
|
||||
setPageSize(pageSize)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editingRecord ? '编辑小节' : '新增小节'}
|
||||
open={modalVisible}
|
||||
onOk={handleSubmit}
|
||||
onCancel={() => {
|
||||
setModalVisible(false)
|
||||
setEditingRecord(null)
|
||||
form.resetFields()
|
||||
}}
|
||||
width={700}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
label="所属打卡营"
|
||||
name="campId"
|
||||
rules={[{ required: true, message: '请选择所属打卡营' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="请选择打卡营"
|
||||
options={camps.map((c) => ({ label: c.title, value: c.id }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="小节标题" name="title" rules={[{ required: true, message: '请输入小节标题' }]}>
|
||||
<Input placeholder="请输入小节标题" />
|
||||
</Form.Item>
|
||||
{editingRecord != null && (
|
||||
<Form.Item
|
||||
label="小节编号"
|
||||
name="sectionNumber"
|
||||
rules={[{ required: true, message: '请输入小节编号' }]}
|
||||
>
|
||||
<InputNumber min={1} style={{ width: '100%' }} placeholder="小节编号" disabled />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item
|
||||
label="时间间隔类型"
|
||||
name="timeIntervalType"
|
||||
rules={[{ required: true, message: '请选择时间间隔类型' }]}
|
||||
initialValue={TimeIntervalType.NONE}
|
||||
>
|
||||
<Select
|
||||
placeholder="请选择时间间隔类型"
|
||||
options={[
|
||||
{ label: '无时间限制', value: TimeIntervalType.NONE },
|
||||
{ label: '小时间隔', value: TimeIntervalType.HOUR },
|
||||
{ label: '自然天', value: TimeIntervalType.NATURAL_DAY },
|
||||
{ label: '收费', value: TimeIntervalType.PAID },
|
||||
]}
|
||||
onChange={(value) => {
|
||||
if (value === TimeIntervalType.NONE || value === TimeIntervalType.PAID) {
|
||||
form.setFieldValue('timeIntervalValue', 0)
|
||||
if (value === TimeIntervalType.PAID) {
|
||||
const cur = form.getFieldValue('priceFen')
|
||||
if (cur === undefined || cur === null) form.setFieldValue('priceFen', 0)
|
||||
} else {
|
||||
form.setFieldValue('priceFen', 0)
|
||||
}
|
||||
} else if (value === TimeIntervalType.NATURAL_DAY) {
|
||||
form.setFieldValue('timeIntervalValue', 1)
|
||||
form.setFieldValue('priceFen', 0)
|
||||
} else if (value === TimeIntervalType.HOUR) {
|
||||
const cur = form.getFieldValue('timeIntervalValue')
|
||||
if (cur === undefined || cur === null) form.setFieldValue('timeIntervalValue', 1)
|
||||
form.setFieldValue('priceFen', 0)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
{timeIntervalType === TimeIntervalType.PAID && (
|
||||
<Form.Item
|
||||
label="价格(分)"
|
||||
name="priceFen"
|
||||
rules={[{ required: true, message: '请输入价格' }]}
|
||||
initialValue={0}
|
||||
tooltip="0表示免费,100分=1元"
|
||||
>
|
||||
<InputNumber min={0} style={{ width: '100%' }} placeholder="请输入价格(分)" />
|
||||
</Form.Item>
|
||||
)}
|
||||
{timeIntervalType === TimeIntervalType.HOUR && (
|
||||
<Form.Item
|
||||
label="时间间隔值(小时)"
|
||||
name="timeIntervalValue"
|
||||
rules={[{ required: true, message: '请输入时间间隔值' }]}
|
||||
initialValue={0}
|
||||
tooltip="完成上一小节后,需间隔多少小时才能进入本节"
|
||||
>
|
||||
<InputNumber min={1} style={{ width: '100%' }} placeholder="请输入小时数" />
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SectionList
|
||||
|
||||
1039
src/pages/Camp/TaskList.tsx
Normal file
1039
src/pages/Camp/TaskList.tsx
Normal file
File diff suppressed because it is too large
Load Diff
82
src/pages/Camp/UserProgressList/FilterBar.tsx
Normal file
82
src/pages/Camp/UserProgressList/FilterBar.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { Button, Input, Select, Space, theme } from 'antd'
|
||||
import { ClearOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||
|
||||
export interface FilterBarProps {
|
||||
campFilter: string | undefined
|
||||
sectionFilter: string | undefined
|
||||
userKeyword: string
|
||||
campOptions: { label: string; value: string }[]
|
||||
sectionOptions: { label: string; value: string }[]
|
||||
onCampChange: (value: string | undefined) => void
|
||||
onSectionChange: (value: string | undefined) => void
|
||||
onUserKeywordChange: (value: string) => void
|
||||
onRefresh: () => void
|
||||
onReset: () => void
|
||||
}
|
||||
|
||||
export function FilterBar({
|
||||
campFilter,
|
||||
sectionFilter,
|
||||
userKeyword,
|
||||
campOptions,
|
||||
sectionOptions,
|
||||
onCampChange,
|
||||
onSectionChange,
|
||||
onUserKeywordChange,
|
||||
onRefresh,
|
||||
onReset,
|
||||
}: FilterBarProps) {
|
||||
const { token } = theme.useToken()
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 20,
|
||||
padding: '14px 16px',
|
||||
background: token.colorFillQuaternary,
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${token.colorBorder}`,
|
||||
}}
|
||||
>
|
||||
<Space wrap size="middle">
|
||||
<Select
|
||||
placeholder="请选择打卡营(必选)"
|
||||
allowClear
|
||||
style={{ width: 260 }}
|
||||
value={campFilter}
|
||||
showSearch
|
||||
filterOption={(input, option) =>
|
||||
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
onChange={(value) => onCampChange(value)}
|
||||
options={campOptions}
|
||||
/>
|
||||
<Select
|
||||
placeholder="小节(可选)"
|
||||
allowClear
|
||||
style={{ width: 280 }}
|
||||
value={sectionFilter}
|
||||
showSearch
|
||||
filterOption={(input, option) =>
|
||||
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
onChange={(value) => onSectionChange(value)}
|
||||
options={sectionOptions}
|
||||
/>
|
||||
<Input
|
||||
placeholder="搜索用户(昵称/ID/手机号)"
|
||||
allowClear
|
||||
style={{ width: 240 }}
|
||||
value={userKeyword}
|
||||
onChange={(e) => onUserKeywordChange(e.target.value)}
|
||||
onPressEnter={onRefresh}
|
||||
/>
|
||||
<Button icon={<ReloadOutlined />} onClick={onRefresh}>
|
||||
刷新
|
||||
</Button>
|
||||
<Button icon={<ClearOutlined />} onClick={onReset}>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
190
src/pages/Camp/UserProgressList/ProgressMatrix.tsx
Normal file
190
src/pages/Camp/UserProgressList/ProgressMatrix.tsx
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
import { Avatar, theme } from 'antd'
|
||||
import { UserOutlined } from '@ant-design/icons'
|
||||
import type { Section, Task, UserProgress } from '@/types/camp'
|
||||
import { USER_COLUMN_WIDTH, SECTION_COLUMN_WIDTH } from './constants'
|
||||
import { TaskProgressCard } from './TaskProgressCard'
|
||||
|
||||
export interface ProgressMatrixProps {
|
||||
selectedCampTitle: string | null
|
||||
sectionsForCamp: Section[]
|
||||
userIds: string[]
|
||||
tasksBySection: Map<string, Task[]>
|
||||
progressByUserTask: Map<string, UserProgress>
|
||||
getTaskTypeName: (t: number) => string
|
||||
onOpenReview: (userId: string, taskId: string, progress: UserProgress | null) => void
|
||||
}
|
||||
|
||||
export function ProgressMatrix({
|
||||
selectedCampTitle,
|
||||
sectionsForCamp,
|
||||
userIds,
|
||||
tasksBySection,
|
||||
progressByUserTask,
|
||||
getTaskTypeName,
|
||||
onOpenReview,
|
||||
}: ProgressMatrixProps) {
|
||||
const { token } = theme.useToken()
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
overflowX: 'auto',
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${token.colorBorder}`,
|
||||
boxShadow: token.boxShadowSecondary,
|
||||
background: token.colorBgContainer,
|
||||
}}
|
||||
>
|
||||
{/* 表头:营地标题 + 小节列 */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
minWidth: 800,
|
||||
borderBottom: `1px solid ${token.colorBorder}`,
|
||||
background: token.colorFillQuaternary,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: USER_COLUMN_WIDTH,
|
||||
minWidth: USER_COLUMN_WIDTH,
|
||||
padding: '16px 20px',
|
||||
borderRight: `1px solid ${token.colorBorder}`,
|
||||
fontWeight: 600,
|
||||
fontSize: 15,
|
||||
color: token.colorTextHeading,
|
||||
}}
|
||||
>
|
||||
{selectedCampTitle || '打卡营'}
|
||||
</div>
|
||||
{sectionsForCamp.map((sec) => (
|
||||
<div
|
||||
key={sec.id}
|
||||
style={{
|
||||
width: SECTION_COLUMN_WIDTH,
|
||||
minWidth: SECTION_COLUMN_WIDTH,
|
||||
padding: '14px 16px',
|
||||
borderRight: `1px solid ${token.colorBorderSecondary}`,
|
||||
borderLeft: sectionsForCamp.indexOf(sec) === 0 ? 'none' : `1px solid ${token.colorBorderSecondary}`,
|
||||
fontWeight: 600,
|
||||
fontSize: 14,
|
||||
color: token.colorTextHeading,
|
||||
}}
|
||||
>
|
||||
第{sec.sectionNumber}节
|
||||
<div style={{ fontSize: 12, color: token.colorTextSecondary, fontWeight: 400, marginTop: 4 }}>
|
||||
{sec.title}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{userIds.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
minWidth: 800,
|
||||
padding: 48,
|
||||
background: token.colorBgContainer,
|
||||
}}
|
||||
>
|
||||
<div style={{ width: USER_COLUMN_WIDTH, minWidth: USER_COLUMN_WIDTH }} />
|
||||
<div style={{ flex: 1, textAlign: 'center', color: token.colorTextSecondary, fontSize: 14 }}>
|
||||
暂无用户进度数据,可更换打卡营或小节后刷新
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
userIds.map((userId, userIndex) => (
|
||||
<div
|
||||
key={userId}
|
||||
style={{
|
||||
display: 'flex',
|
||||
minWidth: 800,
|
||||
borderBottom: userIndex < userIds.length - 1 ? `1px solid ${token.colorBorderSecondary}` : 'none',
|
||||
background: userIndex % 2 === 0 ? token.colorBgContainer : token.colorFillQuaternary,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: USER_COLUMN_WIDTH,
|
||||
minWidth: USER_COLUMN_WIDTH,
|
||||
padding: 16,
|
||||
borderRight: `1px solid ${token.colorBorderSecondary}`,
|
||||
background: userIndex % 2 === 0 ? token.colorFillQuaternary : token.colorFillTertiary,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
size={44}
|
||||
icon={<UserOutlined />}
|
||||
style={{ background: token.colorPrimaryBg, color: token.colorPrimary, flexShrink: 0 }}
|
||||
/>
|
||||
<span style={{ fontWeight: 600, fontSize: 14, color: token.colorTextHeading }}>
|
||||
用户{userIndex + 1}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: token.colorTextSecondary,
|
||||
wordBreak: 'break-all',
|
||||
textAlign: 'center',
|
||||
maxWidth: '100%',
|
||||
lineHeight: 1.3,
|
||||
}}
|
||||
>
|
||||
{userId}
|
||||
</span>
|
||||
</div>
|
||||
{sectionsForCamp.map((sec) => {
|
||||
const sectionTasks = tasksBySection.get(sec.id) || []
|
||||
return (
|
||||
<div
|
||||
key={sec.id}
|
||||
style={{
|
||||
width: SECTION_COLUMN_WIDTH,
|
||||
minWidth: SECTION_COLUMN_WIDTH,
|
||||
padding: 12,
|
||||
borderRight: `1px solid ${token.colorBorderSecondary}`,
|
||||
background: 'transparent',
|
||||
verticalAlign: 'top',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
{sectionTasks.map((task, idx) => {
|
||||
const progress = progressByUserTask.get(`${userId}_${task.id}`) ?? null
|
||||
const taskIndex = idx + 1
|
||||
return (
|
||||
<TaskProgressCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
progress={progress}
|
||||
taskIndex={taskIndex}
|
||||
getTaskTypeName={getTaskTypeName}
|
||||
onReview={() => onOpenReview(userId, task.id, progress)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{sectionTasks.length === 0 && (
|
||||
<div
|
||||
style={{
|
||||
color: token.colorTextTertiary,
|
||||
fontSize: 13,
|
||||
textAlign: 'center',
|
||||
padding: '20px 8px',
|
||||
}}
|
||||
>
|
||||
—
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
31
src/pages/Camp/UserProgressList/README.md
Normal file
31
src/pages/Camp/UserProgressList/README.md
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
# 打卡营进度管理(UserProgressList)
|
||||
|
||||
按「打卡营 × 小节」矩阵展示用户任务完成与审核状态,支持主观题/申论题批改。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
src/pages/Camp/UserProgressList/
|
||||
├── index.tsx # 页面入口:状态、拉数、回调,组合各子组件
|
||||
├── constants.ts # 常量:任务类型、审核状态、状态块样式、列宽等
|
||||
├── utils.ts # 工具:parseEssayReview、splitReviewImagesByCounts
|
||||
├── TaskProgressCard.tsx # 单条任务进度卡片(可点击审核)
|
||||
├── FilterBar.tsx # 筛选栏:打卡营、小节、用户关键词、刷新、重置
|
||||
├── StatusLegend.tsx # 颜色图例(已完成/待审核/已驳回等)
|
||||
├── ProgressMatrix.tsx # 进度矩阵表格:表头 + 用户行 × 小节列
|
||||
└── ReviewModal.tsx # 审核弹窗:主观题评语/图片 + 申论题按题批改
|
||||
```
|
||||
|
||||
## 拆分说明
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `constants.ts` | 任务类型映射、审核状态选项、状态块样式、列宽、申论批改标记,便于统一修改 |
|
||||
| `utils.ts` | 申论批改解析与回显(评语按题、图片数量拆分),与 UI 解耦 |
|
||||
| `TaskProgressCard.tsx` | 单格任务卡片 UI 与状态样式,接收 `task` / `progress` / `onReview` |
|
||||
| `FilterBar.tsx` | 所有筛选项和操作按钮,通过 props 回调与父组件通信 |
|
||||
| `StatusLegend.tsx` | 仅负责渲染图例,无状态 |
|
||||
| `ProgressMatrix.tsx` | 表头 + 用户行循环,每格渲染 TaskProgressCard,接收数据与 `onOpenReview` |
|
||||
| `ReviewModal.tsx` | 弹窗 + 表单:主观题(评语+图片)、申论题(按题意见+图片),上传与提交由父组件传入 |
|
||||
| `index.tsx` | 保留原有数据流与业务:listUserProgress、handleOpenReviewModal、handleSubmitReview、上传逻辑等,组合上述组件 |
|
||||
|
||||
623
src/pages/Camp/UserProgressList/ReviewModal.tsx
Normal file
623
src/pages/Camp/UserProgressList/ReviewModal.tsx
Normal file
|
|
@ -0,0 +1,623 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Button, Form, Input, Modal, Image, Radio, Space, Tag, Upload, theme } from 'antd'
|
||||
import type { FormInstance } from 'antd'
|
||||
import { DeleteOutlined, LeftOutlined, LoadingOutlined, PlusOutlined, RightOutlined } from '@ant-design/icons'
|
||||
import type { UserProgress } from '@/types/camp'
|
||||
import { ReviewStatus } from '@/types/camp'
|
||||
import { REVIEW_STATUS_OPTIONS } from './constants'
|
||||
|
||||
const { TextArea } = Input
|
||||
|
||||
const ESSAY_STATUS_LABEL: Record<number, string> = {
|
||||
[ReviewStatus.PENDING]: '待审核',
|
||||
[ReviewStatus.APPROVED]: '通过',
|
||||
[ReviewStatus.REJECTED]: '驳回',
|
||||
}
|
||||
|
||||
export interface ReviewingProgress {
|
||||
userId: string
|
||||
taskId: string
|
||||
progress: UserProgress | null
|
||||
}
|
||||
|
||||
export interface ReviewModalProps {
|
||||
open: boolean
|
||||
form: FormInstance
|
||||
reviewingProgress: ReviewingProgress | null
|
||||
isEssayReview: boolean
|
||||
isSubjectiveReview?: boolean
|
||||
reviewImageUploading: boolean
|
||||
formatCompletedAt: (completedAt: string) => string | null
|
||||
onOk: () => void
|
||||
onCancel: () => void
|
||||
onReviewImageUpload: (options: any) => void
|
||||
onEssayReviewImageUpload: (questionIndex: number, options: any) => void
|
||||
}
|
||||
|
||||
export function ReviewModal({
|
||||
open,
|
||||
form,
|
||||
reviewingProgress,
|
||||
isEssayReview,
|
||||
isSubjectiveReview = false,
|
||||
reviewImageUploading,
|
||||
formatCompletedAt,
|
||||
onOk,
|
||||
onCancel,
|
||||
onReviewImageUpload,
|
||||
onEssayReviewImageUpload,
|
||||
}: ReviewModalProps) {
|
||||
const { token } = theme.useToken()
|
||||
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0)
|
||||
const questionCount = (reviewingProgress?.progress?.essayAnswerImages || []).length
|
||||
|
||||
useEffect(() => {
|
||||
if (open && isEssayReview) {
|
||||
setCurrentQuestionIndex(0)
|
||||
}
|
||||
}, [open, isEssayReview])
|
||||
|
||||
const goPrev = () => setCurrentQuestionIndex((i) => Math.max(0, i - 1))
|
||||
const goNext = () => setCurrentQuestionIndex((i) => Math.min(questionCount - 1, i + 1))
|
||||
|
||||
/** 申论题点击确定:先校验,有下一题则切到下一题;最后一题直接提交全部 */
|
||||
const handleEssayConfirm = async () => {
|
||||
try {
|
||||
await form.validateFields()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (currentQuestionIndex < questionCount - 1) {
|
||||
setCurrentQuestionIndex((i) => i + 1)
|
||||
} else {
|
||||
Modal.confirm({
|
||||
title: '确认提交',
|
||||
content: '任务已通过,是否确定提交?',
|
||||
okText: '确定',
|
||||
cancelText: '取消',
|
||||
onOk: () => onOk(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={reviewingProgress ? `审核 - 用户: ${reviewingProgress.userId}` : '审核用户进度'}
|
||||
open={open}
|
||||
onOk={isEssayReview ? undefined : onOk}
|
||||
onCancel={onCancel}
|
||||
width={isEssayReview || isSubjectiveReview ? 960 : 600}
|
||||
footer={isEssayReview ? null : undefined}
|
||||
>
|
||||
{reviewingProgress && (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
padding: '12px 16px',
|
||||
background: token.colorFillQuaternary,
|
||||
borderRadius: 8,
|
||||
color: token.colorText,
|
||||
border: `1px solid ${token.colorBorder}`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
{/* 两行网格:第一列对齐任务ID/完成状态,第二列对齐完成时间/描述 */}
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'minmax(320px, auto) 1fr',
|
||||
gap: '8px 12px 8px 16px',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{/* 第一行:任务ID | 完成时间 */}
|
||||
<span title={reviewingProgress.taskId} style={{ maxWidth: '100%', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
<strong>任务ID:</strong>
|
||||
{reviewingProgress.taskId}
|
||||
</span>
|
||||
<span>
|
||||
{reviewingProgress.progress?.completedAt && reviewingProgress.progress.completedAt !== '0' && (
|
||||
<>
|
||||
<strong>完成时间:</strong>
|
||||
{formatCompletedAt(reviewingProgress.progress.completedAt)}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
{/* 第二行:完成状态 | 描述 */}
|
||||
{(reviewingProgress.progress || isEssayReview) && (
|
||||
<>
|
||||
<span style={{ paddingTop: 8, borderTop: `1px solid ${token.colorBorder}`, gridColumn: 1 }}>
|
||||
{reviewingProgress.progress ? (
|
||||
<>
|
||||
<strong>完成状态:</strong>
|
||||
{reviewingProgress.progress.isCompleted ? (
|
||||
<Tag color="success" style={{ marginLeft: 4 }}>已完成</Tag>
|
||||
) : (
|
||||
<Tag color="default" style={{ marginLeft: 4 }}>未完成</Tag>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</span>
|
||||
<span style={{ paddingTop: 8, borderTop: `1px solid ${token.colorBorder}`, fontSize: 12, color: token.colorTextSecondary, lineHeight: 1.5, gridColumn: 2 }}>
|
||||
{isEssayReview
|
||||
? '所有题目通过才是通过;有一道题未审核、待审核或驳回,则最终结果为驳回'
|
||||
: null}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Form form={form} layout="vertical">
|
||||
{!isEssayReview && !isSubjectiveReview && (
|
||||
<Form.Item
|
||||
label="审核状态(本任务唯一)"
|
||||
name="reviewStatus"
|
||||
rules={[{ required: true, message: '请选择审核状态' }]}
|
||||
>
|
||||
<Radio.Group options={REVIEW_STATUS_OPTIONS} />
|
||||
</Form.Item>
|
||||
)}
|
||||
{isEssayReview ? (
|
||||
<>
|
||||
{/* 题目标签:可点击切换 */}
|
||||
<Form.Item noStyle shouldUpdate>
|
||||
{() => {
|
||||
const statuses: number[] = form.getFieldValue('essayReviewStatuses') || []
|
||||
return (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 16 }}>
|
||||
{(reviewingProgress?.progress?.essayAnswerImages || []).map((_: string[], idx: number) => {
|
||||
const status = statuses[idx]
|
||||
const label = status !== undefined ? ESSAY_STATUS_LABEL[status] ?? '待审核' : '待审核'
|
||||
const isActive = idx === currentQuestionIndex
|
||||
return (
|
||||
<button
|
||||
key={`tab-${idx}`}
|
||||
type="button"
|
||||
onClick={() => setCurrentQuestionIndex(idx)}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '8px 14px',
|
||||
border: `1px solid ${isActive ? token.colorPrimary : token.colorBorderSecondary}`,
|
||||
borderRadius: 8,
|
||||
background: isActive ? token.colorPrimaryBg : token.colorFillQuaternary,
|
||||
cursor: 'pointer',
|
||||
fontSize: 13,
|
||||
fontWeight: isActive ? 600 : 400,
|
||||
transition: 'border-color .2s, background .2s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.borderColor = token.colorBorder
|
||||
e.currentTarget.style.background = token.colorFillTertiary
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.borderColor = token.colorBorderSecondary
|
||||
e.currentTarget.style.background = token.colorFillQuaternary
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span>第{idx + 1}题</span>
|
||||
<Tag
|
||||
color={status === ReviewStatus.APPROVED ? 'success' : status === ReviewStatus.REJECTED ? 'error' : 'default'}
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
{label}
|
||||
</Tag>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Form.Item>
|
||||
{/* 叠放展示:全部题目都渲染以保留每题独立表单值,仅用 display 隐藏非当前题 */}
|
||||
{(reviewingProgress?.progress?.essayAnswerImages || []).map((urls: string[], questionIndex: number) => {
|
||||
const validUrls = (urls || []).filter((url: string) => url && String(url).trim() !== '')
|
||||
const isCurrent = questionIndex === currentQuestionIndex
|
||||
return (
|
||||
<div
|
||||
key={`essay-group-${questionIndex}`}
|
||||
style={{
|
||||
display: isCurrent ? 'flex' : 'none',
|
||||
flexDirection: 'column',
|
||||
padding: 16,
|
||||
background: token.colorFillQuaternary,
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${token.colorBorder}`,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: token.colorTextHeading, marginBottom: 12 }}>
|
||||
第{questionIndex + 1}题
|
||||
</div>
|
||||
|
||||
{/* 横向布局:左侧学生回答区,右侧教师批复区 */}
|
||||
<div style={{ display: 'flex', flexDirection: 'row', flexWrap: 'wrap', gap: 20, alignItems: 'stretch', minHeight: 0 }}>
|
||||
{/* 学生回答区 - 左侧(仅展示约 6 张图,稍窄) */}
|
||||
<div
|
||||
style={{
|
||||
flex: '0 0 34%',
|
||||
minWidth: 0,
|
||||
maxWidth: 320,
|
||||
padding: 14,
|
||||
background: token.colorInfoBg,
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${token.colorInfoBorder}`,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: token.colorInfo, marginBottom: 10, textTransform: 'uppercase', letterSpacing: '0.5px' }}>
|
||||
学生回答区
|
||||
</div>
|
||||
{validUrls.length > 0 ? (
|
||||
<Image.PreviewGroup>
|
||||
<Space size={8} wrap>
|
||||
{validUrls.map((url: string, idx: number) => (
|
||||
<Image
|
||||
key={`essay-${questionIndex}-${idx}-${url}`}
|
||||
src={url}
|
||||
width={80}
|
||||
height={80}
|
||||
style={{ objectFit: 'cover', borderRadius: 4 }}
|
||||
/>
|
||||
))}
|
||||
</Space>
|
||||
</Image.PreviewGroup>
|
||||
) : (
|
||||
<span style={{ fontSize: 12, color: token.colorTextTertiary }}>暂无提交</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 教师批复区 - 右侧(占更多宽度) */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 280,
|
||||
padding: 14,
|
||||
background: token.colorWarningBg,
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${token.colorWarningBorder}`,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: token.colorWarning, marginBottom: 10, textTransform: 'uppercase', letterSpacing: '0.5px' }}>
|
||||
教师批复区
|
||||
</div>
|
||||
<Form.Item label="审核状态" name={['essayReviewStatuses', questionIndex]} rules={[{ required: true, message: '请选择该题审核状态' }]} style={{ marginBottom: 10 }}>
|
||||
<Radio.Group options={REVIEW_STATUS_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item label="批改图片" name={['essayReviewImages', questionIndex]}>
|
||||
<Form.Item noStyle shouldUpdate>
|
||||
{() => {
|
||||
const perQuestion: string[] = (form.getFieldValue('essayReviewImages') || [])[questionIndex] || []
|
||||
return (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
|
||||
{perQuestion.filter((u: string) => u && String(u).trim()).map((url: string, idx: number) => (
|
||||
<div key={`eq-${questionIndex}-${idx}`} style={{ position: 'relative', display: 'inline-block' }}>
|
||||
<Image src={url} width={80} height={80} style={{ objectFit: 'cover', borderRadius: 4 }} />
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => {
|
||||
const all: string[][] = form.getFieldValue('essayReviewImages') || []
|
||||
const next = (all[questionIndex] || []).filter((_: string, i: number) => i !== idx)
|
||||
const nextAll = all.map((arr: string[], i: number) => (i === questionIndex ? next : arr || []))
|
||||
form.setFieldsValue({ essayReviewImages: nextAll })
|
||||
}}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -8,
|
||||
right: -8,
|
||||
backgroundColor: token.colorBgContainer,
|
||||
borderRadius: '50%',
|
||||
boxShadow: token.boxShadowSecondary,
|
||||
width: 22,
|
||||
height: 22,
|
||||
padding: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{perQuestion.filter((u: string) => u && String(u).trim()).length < 4 && (
|
||||
<Upload
|
||||
accept="image/*"
|
||||
showUploadList={false}
|
||||
customRequest={(opts: any) => onEssayReviewImageUpload(questionIndex, opts)}
|
||||
disabled={reviewImageUploading}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 80,
|
||||
height: 80,
|
||||
border: `1px dashed ${token.colorBorderSecondary}`,
|
||||
borderRadius: 4,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: reviewImageUploading ? 'not-allowed' : 'pointer',
|
||||
color: token.colorTextTertiary,
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{reviewImageUploading ? <LoadingOutlined /> : <PlusOutlined />}
|
||||
<span style={{ marginTop: 4 }}>上传</span>
|
||||
</div>
|
||||
</Upload>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Form.Item>
|
||||
</Form.Item>
|
||||
<Form.Item label="批改意见" name={['essayReviewComments', questionIndex]} style={{ marginBottom: 10 }}>
|
||||
<TextArea rows={3} placeholder={`请输入第${questionIndex + 1}题的批改意见`} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 本题单独确定 + 上一题/下一题 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: 14, paddingTop: 14, borderTop: `1px solid ${token.colorBorder}`, flexShrink: 0 }}>
|
||||
<Space size="middle">
|
||||
<Button icon={<LeftOutlined />} onClick={goPrev} disabled={currentQuestionIndex === 0}>
|
||||
上一题
|
||||
</Button>
|
||||
<Button icon={<RightOutlined />} onClick={goNext} disabled={currentQuestionIndex >= questionCount - 1}>
|
||||
下一题
|
||||
</Button>
|
||||
</Space>
|
||||
<Button type="primary" onClick={handleEssayConfirm}>
|
||||
{currentQuestionIndex < questionCount - 1 ? '确定并前往下一题' : '确定并提交'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
) : isSubjectiveReview ? (
|
||||
/* 主观题:学生回答区 | 教师批复区 两分区 */
|
||||
<div style={{ display: 'flex', flexDirection: 'row', flexWrap: 'wrap', gap: 20, alignItems: 'stretch' }}>
|
||||
<div
|
||||
style={{
|
||||
flex: '0 0 34%',
|
||||
minWidth: 0,
|
||||
maxWidth: 320,
|
||||
padding: 14,
|
||||
background: token.colorInfoBg,
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${token.colorInfoBorder}`,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: token.colorInfo, marginBottom: 10, textTransform: 'uppercase', letterSpacing: '0.5px' }}>
|
||||
学生回答区
|
||||
</div>
|
||||
{reviewingProgress?.progress?.answerImages && reviewingProgress.progress.answerImages.filter((url: string) => url?.trim()).length > 0 ? (
|
||||
<Image.PreviewGroup>
|
||||
<Space size={8} wrap>
|
||||
{(reviewingProgress.progress.answerImages ?? [])
|
||||
.filter((url: string) => url?.trim())
|
||||
.map((url: string, idx: number) => (
|
||||
<Image
|
||||
key={`answer-${idx}-${url}`}
|
||||
src={url}
|
||||
width={80}
|
||||
height={80}
|
||||
style={{ objectFit: 'cover', borderRadius: 4 }}
|
||||
/>
|
||||
))}
|
||||
</Space>
|
||||
</Image.PreviewGroup>
|
||||
) : (
|
||||
<span style={{ fontSize: 12, color: token.colorTextTertiary }}>暂无提交</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 280,
|
||||
padding: 14,
|
||||
background: token.colorWarningBg,
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${token.colorWarningBorder}`,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: token.colorWarning, marginBottom: 10, textTransform: 'uppercase', letterSpacing: '0.5px' }}>
|
||||
教师批复区
|
||||
</div>
|
||||
<Form.Item label="审核状态" name="reviewStatus" rules={[{ required: true, message: '请选择审核状态' }]} style={{ marginBottom: 10 }}>
|
||||
<Radio.Group options={REVIEW_STATUS_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item label="审核图片" shouldUpdate>
|
||||
{() => {
|
||||
const reviewImages: string[] = form.getFieldValue('reviewImages') || []
|
||||
return (
|
||||
<>
|
||||
<div style={{ color: token.colorTextSecondary, fontSize: 12, marginBottom: 8 }}>最多可上传 6 张图片</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
|
||||
{reviewImages.map((url: string, idx: number) => (
|
||||
<div key={`ri-${idx}`} style={{ position: 'relative', display: 'inline-block' }}>
|
||||
<Image src={url} width={80} height={80} style={{ objectFit: 'cover', borderRadius: 4 }} />
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => {
|
||||
const updated = reviewImages.filter((_: string, i: number) => i !== idx)
|
||||
form.setFieldsValue({ reviewImages: updated })
|
||||
}}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -8,
|
||||
right: -8,
|
||||
backgroundColor: token.colorBgContainer,
|
||||
borderRadius: '50%',
|
||||
boxShadow: token.boxShadowSecondary,
|
||||
width: 22,
|
||||
height: 22,
|
||||
padding: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{reviewImages.length < 6 && (
|
||||
<Upload
|
||||
accept="image/*"
|
||||
showUploadList={false}
|
||||
customRequest={onReviewImageUpload}
|
||||
disabled={reviewImageUploading}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 80,
|
||||
height: 80,
|
||||
border: `1px dashed ${token.colorBorderSecondary}`,
|
||||
borderRadius: 4,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: reviewImageUploading ? 'not-allowed' : 'pointer',
|
||||
color: token.colorTextTertiary,
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{reviewImageUploading ? <LoadingOutlined /> : <PlusOutlined />}
|
||||
<span style={{ marginTop: 4 }}>{reviewImageUploading ? '上传中' : '上传'}</span>
|
||||
</div>
|
||||
</Upload>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</Form.Item>
|
||||
<Form.Item name="reviewImages" hidden>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label="审核评语" name="reviewComment" style={{ marginBottom: 10 }}>
|
||||
<TextArea rows={4} placeholder="请输入审核评语" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Form.Item label="审核评语" name="reviewComment">
|
||||
<TextArea rows={4} placeholder="请输入审核评语" />
|
||||
</Form.Item>
|
||||
<Form.Item label="审核图片" shouldUpdate>
|
||||
{() => {
|
||||
const reviewImages: string[] = form.getFieldValue('reviewImages') || []
|
||||
return (
|
||||
<>
|
||||
<div style={{ color: token.colorTextSecondary, fontSize: 12, marginBottom: 8 }}>最多可上传 6 张图片</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
|
||||
{reviewImages.map((url: string, idx: number) => (
|
||||
<div key={`ri-${idx}`} style={{ position: 'relative', display: 'inline-block' }}>
|
||||
<Image
|
||||
src={url}
|
||||
width={80}
|
||||
height={80}
|
||||
style={{ objectFit: 'cover', borderRadius: 4 }}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => {
|
||||
const updated = reviewImages.filter((_: string, i: number) => i !== idx)
|
||||
form.setFieldsValue({ reviewImages: updated })
|
||||
}}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -8,
|
||||
right: -8,
|
||||
backgroundColor: token.colorBgContainer,
|
||||
borderRadius: '50%',
|
||||
boxShadow: token.boxShadowSecondary,
|
||||
width: 22,
|
||||
height: 22,
|
||||
padding: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{reviewImages.length < 6 && (
|
||||
<Upload
|
||||
accept="image/*"
|
||||
showUploadList={false}
|
||||
customRequest={onReviewImageUpload}
|
||||
disabled={reviewImageUploading}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 80,
|
||||
height: 80,
|
||||
border: `1px dashed ${token.colorBorderSecondary}`,
|
||||
borderRadius: 4,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: reviewImageUploading ? 'not-allowed' : 'pointer',
|
||||
color: token.colorTextTertiary,
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{reviewImageUploading ? <LoadingOutlined /> : <PlusOutlined />}
|
||||
<span style={{ marginTop: 4 }}>{reviewImageUploading ? '上传中' : '上传'}</span>
|
||||
</div>
|
||||
</Upload>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</Form.Item>
|
||||
<Form.Item name="reviewImages" hidden>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
{reviewingProgress?.progress?.answerImages &&
|
||||
reviewingProgress.progress.answerImages.filter((url: string) => url?.trim()).length > 0 && (
|
||||
<Form.Item label="用户提交的答案">
|
||||
<Image.PreviewGroup>
|
||||
<Space size={8} wrap>
|
||||
{(reviewingProgress.progress.answerImages ?? [])
|
||||
.filter((url: string) => url?.trim())
|
||||
.map((url: string, idx: number) => (
|
||||
<Image
|
||||
key={`answer-${idx}-${url}`}
|
||||
src={url}
|
||||
width={80}
|
||||
height={80}
|
||||
style={{ objectFit: 'cover', borderRadius: 4 }}
|
||||
/>
|
||||
))}
|
||||
</Space>
|
||||
</Image.PreviewGroup>
|
||||
</Form.Item>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
66
src/pages/Camp/UserProgressList/StatusLegend.tsx
Normal file
66
src/pages/Camp/UserProgressList/StatusLegend.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { theme } from 'antd'
|
||||
import { STATUS_COLOR_LEGEND } from './constants'
|
||||
|
||||
function getStatusStyle(key: string, token: ReturnType<typeof theme.useToken>['token']) {
|
||||
const map: Record<string, { bg: string; border: string }> = {
|
||||
completed: { bg: token.colorSuccessBg, border: token.colorSuccessBorder },
|
||||
pending: { bg: token.colorWarningBg, border: token.colorWarningBorder },
|
||||
rejected: { bg: token.colorErrorBg, border: token.colorErrorBorder },
|
||||
inProgress: { bg: token.colorInfoBg, border: token.colorInfoBorder },
|
||||
notStarted: { bg: token.colorFillQuaternary, border: token.colorBorderSecondary },
|
||||
}
|
||||
return map[key] ?? { bg: token.colorFillQuaternary, border: token.colorBorderSecondary }
|
||||
}
|
||||
|
||||
export function StatusLegend() {
|
||||
const { token } = theme.useToken()
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
padding: '12px 16px',
|
||||
background: token.colorBgContainer,
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${token.colorBorder}`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
gap: '0 4px',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 12, color: token.colorTextSecondary, marginRight: 8, lineHeight: '20px' }}>
|
||||
颜色说明:
|
||||
</span>
|
||||
{STATUS_COLOR_LEGEND.map(({ key, label }) => {
|
||||
const style = getStatusStyle(key, token)
|
||||
return (
|
||||
<span
|
||||
key={key}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
marginRight: 16,
|
||||
fontSize: 12,
|
||||
color: token.colorTextSecondary,
|
||||
lineHeight: '20px',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: 20,
|
||||
height: 14,
|
||||
borderRadius: 4,
|
||||
background: style.bg,
|
||||
border: `1px solid ${style.border}`,
|
||||
marginRight: 6,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
153
src/pages/Camp/UserProgressList/TaskProgressCard.tsx
Normal file
153
src/pages/Camp/UserProgressList/TaskProgressCard.tsx
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import type { Task, UserProgress } from '@/types/camp'
|
||||
import { ReviewStatus, TaskType } from '@/types/camp'
|
||||
import { RightOutlined } from '@ant-design/icons'
|
||||
import { theme } from 'antd'
|
||||
import { STATUS_BLOCK_STYLES } from './constants'
|
||||
|
||||
type StatusKey = keyof typeof STATUS_BLOCK_STYLES
|
||||
|
||||
function getStatusStyle(key: StatusKey, token: ReturnType<typeof theme.useToken>['token']) {
|
||||
const map: Record<StatusKey, { bg: string; border: string }> = {
|
||||
completed: { bg: token.colorSuccessBg, border: token.colorSuccessBorder },
|
||||
pending: { bg: token.colorWarningBg, border: token.colorWarningBorder },
|
||||
rejected: { bg: token.colorErrorBg, border: token.colorErrorBorder },
|
||||
inProgress: { bg: token.colorInfoBg, border: token.colorInfoBorder },
|
||||
notStarted: { bg: token.colorFillQuaternary, border: token.colorBorder },
|
||||
}
|
||||
return map[key] ?? { bg: token.colorFillQuaternary, border: token.colorBorder }
|
||||
}
|
||||
|
||||
export interface TaskProgressCardProps {
|
||||
task: Task
|
||||
progress: UserProgress | null
|
||||
taskIndex: number
|
||||
getTaskTypeName: (t: number) => string
|
||||
onReview: () => void
|
||||
}
|
||||
|
||||
export function TaskProgressCard({
|
||||
task,
|
||||
progress,
|
||||
taskIndex,
|
||||
getTaskTypeName,
|
||||
onReview,
|
||||
}: TaskProgressCardProps) {
|
||||
const { token } = theme.useToken()
|
||||
const taskTypeNum = typeof task.taskType === 'number' ? task.taskType : Number(task.taskType)
|
||||
const typeName = getTaskTypeName(taskTypeNum)
|
||||
const completed = progress?.isCompleted ?? false
|
||||
const isReviewable = taskTypeNum === TaskType.SUBJECTIVE || taskTypeNum === TaskType.ESSAY || Boolean(progress?.needReview)
|
||||
const reviewStatus = progress?.reviewStatus
|
||||
|
||||
let statusKey: StatusKey = 'inProgress'
|
||||
if (!progress) {
|
||||
statusKey = 'notStarted'
|
||||
} else if (completed && (reviewStatus === ReviewStatus.APPROVED || !isReviewable)) {
|
||||
statusKey = 'completed'
|
||||
} else if (isReviewable && reviewStatus === ReviewStatus.REJECTED) {
|
||||
statusKey = 'rejected'
|
||||
} else if (isReviewable && reviewStatus === ReviewStatus.PENDING) {
|
||||
statusKey = 'pending'
|
||||
} else if (completed) {
|
||||
statusKey = 'completed'
|
||||
}
|
||||
|
||||
const blockStyle = getStatusStyle(statusKey, token)
|
||||
|
||||
const handleClick = () => {
|
||||
if (isReviewable) onReview()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role={isReviewable ? 'button' : undefined}
|
||||
tabIndex={isReviewable ? 0 : undefined}
|
||||
onClick={handleClick}
|
||||
onKeyDown={(e) => {
|
||||
if (isReviewable && (e.key === 'Enter' || e.key === ' ')) {
|
||||
e.preventDefault()
|
||||
onReview()
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
border: `1px solid ${blockStyle.border}`,
|
||||
borderRadius: 10,
|
||||
padding: '10px 12px',
|
||||
background: blockStyle.bg,
|
||||
marginBottom: 8,
|
||||
boxShadow: token.boxShadow,
|
||||
transition: 'all 0.2s ease',
|
||||
cursor: isReviewable ? 'pointer' : 'default',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
const el = e.currentTarget
|
||||
if (isReviewable) {
|
||||
el.style.boxShadow = token.boxShadowSecondary
|
||||
el.style.borderColor = token.colorPrimaryBorder
|
||||
} else {
|
||||
el.style.boxShadow = token.boxShadowSecondary
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
const el = e.currentTarget
|
||||
el.style.boxShadow = token.boxShadow
|
||||
el.style.borderColor = blockStyle.border
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
gap: 8,
|
||||
minHeight: 20,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: token.colorTextHeading,
|
||||
lineHeight: 1.4,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
title={task.title || `任务${taskIndex}`}
|
||||
>
|
||||
{task.title || `任务${taskIndex}`}
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
padding: '3px 6px',
|
||||
borderRadius: 6,
|
||||
background: token.colorFillTertiary,
|
||||
color: token.colorTextSecondary,
|
||||
fontWeight: 500,
|
||||
border: `1px solid ${token.colorBorderSecondary}`,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
任务{taskIndex} · {typeName}
|
||||
</span>
|
||||
</div>
|
||||
{isReviewable && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 8,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 11, color: token.colorPrimary }}>点击查看</span>
|
||||
<RightOutlined style={{ fontSize: 11, color: token.colorPrimary }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
54
src/pages/Camp/UserProgressList/constants.ts
Normal file
54
src/pages/Camp/UserProgressList/constants.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { TaskType, ReviewStatus } from '@/types/camp'
|
||||
|
||||
export const TASK_TYPE_MAP: Record<number, string> = {
|
||||
[TaskType.UNKNOWN]: '未知',
|
||||
[TaskType.IMAGE_TEXT]: '图文',
|
||||
[TaskType.VIDEO]: '视频',
|
||||
[TaskType.SUBJECTIVE]: '主观',
|
||||
[TaskType.OBJECTIVE]: '客观',
|
||||
[TaskType.ESSAY]: '申论',
|
||||
}
|
||||
|
||||
export const REVIEW_STATUS_OPTIONS = [
|
||||
{ label: '待审核', value: ReviewStatus.PENDING },
|
||||
{ label: '审核通过', value: ReviewStatus.APPROVED },
|
||||
{ label: '审核拒绝', value: ReviewStatus.REJECTED },
|
||||
]
|
||||
|
||||
/** 任务类型标签统一背景色(橙色系) */
|
||||
export const TASK_TYPE_TAG_BG = '#fff3e0'
|
||||
|
||||
export const TASK_TYPE_TAG_COLORS: Record<number, string> = {
|
||||
[TaskType.VIDEO]: TASK_TYPE_TAG_BG,
|
||||
[TaskType.OBJECTIVE]: TASK_TYPE_TAG_BG,
|
||||
[TaskType.SUBJECTIVE]: TASK_TYPE_TAG_BG,
|
||||
[TaskType.ESSAY]: TASK_TYPE_TAG_BG,
|
||||
[TaskType.IMAGE_TEXT]: TASK_TYPE_TAG_BG,
|
||||
}
|
||||
|
||||
/** 任务状态对应的整块背景色、边框色(偏浅,减轻视觉重量) */
|
||||
export const STATUS_BLOCK_STYLES: Record<string, { bg: string; border: string }> = {
|
||||
completed: { bg: '#f0fdf4', border: '#bbf7d0' },
|
||||
pending: { bg: '#fffbeb', border: '#fde68a' },
|
||||
rejected: { bg: '#fff5f5', border: '#fecaca' },
|
||||
inProgress: { bg: '#f0f9ff', border: '#bae6fd' },
|
||||
notStarted: { bg: '#f5f5f5', border: '#e5e5e5' },
|
||||
}
|
||||
|
||||
/** 颜色说明(与 STATUS_BLOCK_STYLES 对应,用于页内图例) */
|
||||
export const STATUS_COLOR_LEGEND: { key: keyof typeof STATUS_BLOCK_STYLES; label: string }[] = [
|
||||
{ key: 'completed', label: '已完成 / 已通过' },
|
||||
{ key: 'pending', label: '待审核' },
|
||||
{ key: 'rejected', label: '已驳回' },
|
||||
{ key: 'inProgress', label: '进行中' },
|
||||
{ key: 'notStarted', label: '未开始' },
|
||||
]
|
||||
|
||||
/** 左侧用户列宽度 */
|
||||
export const USER_COLUMN_WIDTH = 150
|
||||
|
||||
/** 小节列统一宽度(表头与任务单元格一致) */
|
||||
export const SECTION_COLUMN_WIDTH = 280
|
||||
|
||||
/** 申论批改评语中用于存储各题图片数量的分隔标记 */
|
||||
export const IMG_COUNTS_MARKER = '__IMG_COUNTS__:'
|
||||
514
src/pages/Camp/UserProgressList/index.tsx
Normal file
514
src/pages/Camp/UserProgressList/index.tsx
Normal file
|
|
@ -0,0 +1,514 @@
|
|||
import { useState, useEffect, useMemo, useCallback } from 'react'
|
||||
import { Button, Form, message, theme } from 'antd'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import type { Task, Section, UserProgress, Camp } from '@/types/camp'
|
||||
import { TaskType } from '@/types/camp'
|
||||
import { getUploadSignature } from '@/api/oss'
|
||||
import { listUserProgress, updateUserProgress, listTasks, listSections, listCamps } from '@/api/camp'
|
||||
import { DEFAULT_PAGE_SIZE } from '@/constants'
|
||||
import dayjs from 'dayjs'
|
||||
import { TASK_TYPE_MAP, IMG_COUNTS_MARKER } from './constants'
|
||||
import { parseEssayReview, splitReviewImagesByCounts } from './utils'
|
||||
import { FilterBar } from './FilterBar'
|
||||
import { StatusLegend } from './StatusLegend'
|
||||
import { ProgressMatrix } from './ProgressMatrix'
|
||||
import { ReviewModal, type ReviewingProgress } from './ReviewModal'
|
||||
|
||||
export default function UserProgressList() {
|
||||
const { token } = theme.useToken()
|
||||
const [searchParams] = useSearchParams()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [tasks, setTasks] = useState<Task[]>([])
|
||||
const [camps, setCamps] = useState<Camp[]>([])
|
||||
const [sections, setSections] = useState<Section[]>([])
|
||||
const [campFilter, setCampFilter] = useState<string>()
|
||||
const [sectionFilter, setSectionFilter] = useState<string>()
|
||||
const [userKeyword, setUserKeyword] = useState('')
|
||||
const [progressList, setProgressList] = useState<UserProgress[]>([])
|
||||
const [campUserIds, setCampUserIds] = useState<string[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize] = useState(Math.max(DEFAULT_PAGE_SIZE, 50))
|
||||
const [reviewModalVisible, setReviewModalVisible] = useState(false)
|
||||
const [reviewingProgress, setReviewingProgress] = useState<ReviewingProgress | null>(null)
|
||||
const [reviewImageUploading, setReviewImageUploading] = useState(false)
|
||||
|
||||
const [form] = Form.useForm()
|
||||
|
||||
useEffect(() => {
|
||||
const campId = searchParams.get('campId')
|
||||
if (campId) setCampFilter(campId)
|
||||
const sectionId = searchParams.get('sectionId')
|
||||
if (sectionId) setSectionFilter(sectionId)
|
||||
}, [searchParams])
|
||||
|
||||
const fetchCamps = async () => {
|
||||
try {
|
||||
const res = await listCamps({ page: 1, pageSize: 1000 })
|
||||
if (res.data.code === 200) setCamps(res.data.data.list)
|
||||
} catch {
|
||||
setCamps([])
|
||||
}
|
||||
}
|
||||
|
||||
const fetchSections = async (campId?: string) => {
|
||||
try {
|
||||
const params: any = { page: 1, pageSize: 1000 }
|
||||
if (campId) params.campId = campId
|
||||
const res = await listSections(params)
|
||||
if (res.data.code === 200) setSections(res.data.data.list)
|
||||
} catch {
|
||||
setSections([])
|
||||
}
|
||||
}
|
||||
|
||||
const fetchTasksForMap = useCallback(async () => {
|
||||
try {
|
||||
const res = await listTasks({ page: 1, pageSize: 2000 })
|
||||
if (res.data.code === 200) setTasks(res.data.data.list || [])
|
||||
} catch {
|
||||
setTasks([])
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await listUserProgress({
|
||||
page,
|
||||
pageSize,
|
||||
campId: campFilter || undefined,
|
||||
sectionId: sectionFilter || undefined,
|
||||
userKeyword: userKeyword?.trim() || undefined,
|
||||
})
|
||||
if (res.data.code === 200) {
|
||||
setProgressList(res.data.data.list || [])
|
||||
setTotal(res.data.data.total ?? 0)
|
||||
setCampUserIds(Array.isArray(res.data.data.userIds) ? res.data.data.userIds : [])
|
||||
} else {
|
||||
setProgressList([])
|
||||
setTotal(0)
|
||||
setCampUserIds([])
|
||||
}
|
||||
} catch (error: any) {
|
||||
setProgressList([])
|
||||
setTotal(0)
|
||||
setCampUserIds([])
|
||||
if (error.response?.status >= 500 || !error.response) message.error('服务器错误,请稍后重试')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, pageSize, campFilter, sectionFilter, userKeyword])
|
||||
|
||||
useEffect(() => {
|
||||
fetchCamps()
|
||||
fetchSections()
|
||||
fetchTasksForMap()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [fetchData])
|
||||
|
||||
const handleReviewImageUpload = useCallback(async (options: any) => {
|
||||
const { file, onSuccess, onError } = options
|
||||
const fileObj = file as File
|
||||
if (fileObj.size > 5 * 1024 * 1024) {
|
||||
message.error('文件大小不能超过 5MB')
|
||||
onError?.(new Error('文件大小不能超过 5MB'))
|
||||
return
|
||||
}
|
||||
if (!fileObj.type.startsWith('image/')) {
|
||||
message.error('只能上传图片文件')
|
||||
onError?.(new Error('只能上传图片文件'))
|
||||
return
|
||||
}
|
||||
const current: string[] = form.getFieldValue('reviewImages') || []
|
||||
if (current.length >= 6) {
|
||||
message.error('审核图片最多 6 张')
|
||||
onError?.(new Error('审核图片最多 6 张'))
|
||||
return
|
||||
}
|
||||
setReviewImageUploading(true)
|
||||
try {
|
||||
const credentials = await getUploadSignature('camp')
|
||||
const host = credentials.host
|
||||
const timestamp = Date.now()
|
||||
const random = Math.random().toString(36).substring(2, 8)
|
||||
const ext = fileObj.name.split('.').pop()
|
||||
const key = `${credentials.dir}${timestamp}_${random}.${ext}`
|
||||
const formData = new FormData()
|
||||
formData.append('success_action_status', '200')
|
||||
formData.append('policy', credentials.policy)
|
||||
formData.append('x-oss-signature', credentials.signature)
|
||||
formData.append('x-oss-signature-version', credentials.x_oss_signature_version)
|
||||
formData.append('x-oss-credential', credentials.x_oss_credential)
|
||||
formData.append('x-oss-date', credentials.x_oss_date)
|
||||
formData.append('key', key)
|
||||
formData.append('x-oss-security-token', credentials.security_token)
|
||||
formData.append('file', fileObj)
|
||||
const resp = await fetch(host, { method: 'POST', body: formData })
|
||||
if (!resp.ok) throw new Error(`上传失败: ${resp.status}`)
|
||||
const imageUrl = `${host}/${key}`
|
||||
const cur: string[] = form.getFieldValue('reviewImages') || []
|
||||
if (cur.length < 6) form.setFieldsValue({ reviewImages: [...cur, imageUrl] })
|
||||
onSuccess?.(imageUrl)
|
||||
message.success('图片上传成功')
|
||||
} catch (err: any) {
|
||||
message.error(err.message || '上传失败')
|
||||
onError?.(err)
|
||||
} finally {
|
||||
setReviewImageUploading(false)
|
||||
}
|
||||
}, [form])
|
||||
|
||||
const handleOpenReviewModal = useCallback((userId: string, taskId: string, progress: UserProgress | null) => {
|
||||
setReviewingProgress({ userId, taskId, progress })
|
||||
const task = tasks.find((t) => t.id === taskId)
|
||||
const isEssay = Boolean(task && Number(task.taskType) === TaskType.ESSAY && progress?.essayAnswerImages?.length)
|
||||
if (progress) {
|
||||
const validReviewImages = (progress.reviewImages || []).filter((url: string) => url && url.trim() !== '')
|
||||
if (isEssay) {
|
||||
const { comments, imageCounts } = parseEssayReview(progress)
|
||||
const questionCount = progress.essayAnswerImages!.length
|
||||
const commentsPadded = Array.from({ length: questionCount }, (_, i) => comments[i] ?? '')
|
||||
// 每题审核状态:优先 progress.essayReviewStatuses,否则用整体 reviewStatus 填充
|
||||
const statuses = Array.isArray(progress.essayReviewStatuses) && progress.essayReviewStatuses.length === questionCount
|
||||
? progress.essayReviewStatuses
|
||||
: Array.from({ length: questionCount }, () => progress.reviewStatus)
|
||||
let essayReviewImages: string[][] = []
|
||||
if (imageCounts.length === questionCount && validReviewImages.length > 0) {
|
||||
essayReviewImages = splitReviewImagesByCounts(validReviewImages, imageCounts)
|
||||
} else {
|
||||
essayReviewImages = Array.from({ length: questionCount }, () => [])
|
||||
}
|
||||
form.setFieldsValue({
|
||||
reviewStatus: progress.reviewStatus,
|
||||
reviewComment: '',
|
||||
reviewImages: [],
|
||||
essayReviewComments: commentsPadded,
|
||||
essayReviewImages,
|
||||
essayReviewStatuses: statuses,
|
||||
})
|
||||
} else {
|
||||
form.setFieldsValue({
|
||||
reviewStatus: progress.reviewStatus,
|
||||
reviewComment: progress.reviewComment,
|
||||
reviewImages: validReviewImages,
|
||||
essayReviewComments: undefined,
|
||||
essayReviewImages: undefined,
|
||||
essayReviewStatuses: undefined,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
form.resetFields()
|
||||
}
|
||||
setReviewModalVisible(true)
|
||||
}, [form, tasks])
|
||||
|
||||
const handleEssayReviewImageUpload = useCallback(
|
||||
async (questionIndex: number, options: any) => {
|
||||
const { file, onSuccess, onError } = options
|
||||
const fileObj = file as File
|
||||
if (fileObj.size > 5 * 1024 * 1024) {
|
||||
message.error('文件大小不能超过 5MB')
|
||||
onError?.(new Error('文件大小不能超过 5MB'))
|
||||
return
|
||||
}
|
||||
if (!fileObj.type.startsWith('image/')) {
|
||||
message.error('只能上传图片文件')
|
||||
onError?.(new Error('只能上传图片文件'))
|
||||
return
|
||||
}
|
||||
const essayReviewImages: string[][] = form.getFieldValue('essayReviewImages') || []
|
||||
const totalImages = essayReviewImages.flat().length
|
||||
if (totalImages >= 12) {
|
||||
message.error('各题批改图片合计最多 12 张')
|
||||
onError?.(new Error('各题批改图片合计最多 12 张'))
|
||||
return
|
||||
}
|
||||
setReviewImageUploading(true)
|
||||
try {
|
||||
const credentials = await getUploadSignature('camp')
|
||||
const host = credentials.host
|
||||
const timestamp = Date.now()
|
||||
const random = Math.random().toString(36).substring(2, 8)
|
||||
const ext = fileObj.name.split('.').pop()
|
||||
const key = `${credentials.dir}${timestamp}_${random}.${ext}`
|
||||
const formData = new FormData()
|
||||
formData.append('success_action_status', '200')
|
||||
formData.append('policy', credentials.policy)
|
||||
formData.append('x-oss-signature', credentials.signature)
|
||||
formData.append('x-oss-signature-version', credentials.x_oss_signature_version)
|
||||
formData.append('x-oss-credential', credentials.x_oss_credential)
|
||||
formData.append('x-oss-date', credentials.x_oss_date)
|
||||
formData.append('key', key)
|
||||
formData.append('x-oss-security-token', credentials.security_token)
|
||||
formData.append('file', fileObj)
|
||||
const resp = await fetch(host, { method: 'POST', body: formData })
|
||||
if (!resp.ok) throw new Error(`上传失败: ${resp.status}`)
|
||||
const imageUrl = `${host}/${key}`
|
||||
const cur: string[][] = form.getFieldValue('essayReviewImages') || []
|
||||
const next = cur.map((arr: string[], i: number) =>
|
||||
i === questionIndex ? [...(arr || []), imageUrl] : arr || []
|
||||
)
|
||||
if (questionIndex >= next.length) {
|
||||
while (next.length <= questionIndex) next.push([])
|
||||
next[questionIndex] = [imageUrl]
|
||||
}
|
||||
form.setFieldsValue({ essayReviewImages: next })
|
||||
onSuccess?.(imageUrl)
|
||||
message.success('图片上传成功')
|
||||
} catch (err: any) {
|
||||
message.error(err.message || '上传失败')
|
||||
onError?.(err)
|
||||
} finally {
|
||||
setReviewImageUploading(false)
|
||||
}
|
||||
},
|
||||
[form]
|
||||
)
|
||||
|
||||
const handleSubmitReview = useCallback(async () => {
|
||||
if (!reviewingProgress) return
|
||||
try {
|
||||
const values = await form.validateFields()
|
||||
const { userId, taskId, progress } = reviewingProgress
|
||||
const task = tasks.find((t) => t.id === taskId)
|
||||
const isEssay = Boolean(task && Number(task.taskType) === TaskType.ESSAY && progress?.essayAnswerImages?.length)
|
||||
let reviewComment: string
|
||||
let cleanedReviewImages: string[]
|
||||
if (isEssay) {
|
||||
const comments: string[] = values.essayReviewComments ?? []
|
||||
const perQuestionImages: string[][] = values.essayReviewImages ?? []
|
||||
reviewComment = comments
|
||||
.map((c: string, i: number) => `【第${i + 1}题】\n${(c || '').trim()}`)
|
||||
.join('\n\n')
|
||||
const counts = perQuestionImages.map((arr: string[]) => (arr || []).filter((u: string) => u && String(u).trim() !== '').length)
|
||||
if (counts.some((n) => n > 0)) reviewComment += `\n\n${IMG_COUNTS_MARKER}${counts.join(',')}`
|
||||
cleanedReviewImages = (perQuestionImages || []).flat().filter((url: string) => url && String(url).trim() !== '')
|
||||
} else {
|
||||
reviewComment = values.reviewComment ?? ''
|
||||
cleanedReviewImages = Array.isArray(values.reviewImages)
|
||||
? values.reviewImages.filter((url: string) => url && url.trim() !== '')
|
||||
: []
|
||||
}
|
||||
await updateUserProgress({
|
||||
userId,
|
||||
taskId,
|
||||
isCompleted: progress?.isCompleted ?? false,
|
||||
completedAt: progress?.completedAt || String(Math.floor(Date.now() / 1000)),
|
||||
reviewStatus: isEssay ? values.essayReviewStatuses?.[0] ?? 0 : values.reviewStatus,
|
||||
reviewComment,
|
||||
reviewImages: cleanedReviewImages,
|
||||
...(isEssay && Array.isArray(values.essayReviewStatuses) ? { essayReviewStatuses: values.essayReviewStatuses } : {}),
|
||||
})
|
||||
message.success('审核成功')
|
||||
await fetchData()
|
||||
setReviewModalVisible(false)
|
||||
setReviewingProgress(null)
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '审核失败')
|
||||
}
|
||||
}, [reviewingProgress, form, fetchData, tasks])
|
||||
|
||||
const getTaskTypeName = useCallback((taskType: number | string) => {
|
||||
const typeNum = typeof taskType === 'number' ? taskType : Number(taskType)
|
||||
return TASK_TYPE_MAP[typeNum] || '未知'
|
||||
}, [])
|
||||
|
||||
const formatCompletedAt = useCallback((completedAt: string) => {
|
||||
if (!completedAt || completedAt === '0') return null
|
||||
if (/^\d+$/.test(completedAt)) return dayjs(Number(completedAt) * 1000).format('YYYY-MM-DD HH:mm:ss')
|
||||
return dayjs(completedAt).isValid() ? dayjs(completedAt).format('YYYY-MM-DD HH:mm:ss') : completedAt
|
||||
}, [])
|
||||
|
||||
const campOptions = useMemo(() => camps.map((c) => ({ label: c.title, value: c.id })), [camps])
|
||||
const sectionOptions = useMemo(
|
||||
() =>
|
||||
sections
|
||||
.filter((s) => !campFilter || s.campId === campFilter)
|
||||
.map((s) => ({ label: s.title, value: s.id })),
|
||||
[sections, campFilter]
|
||||
)
|
||||
const campMap = useMemo(() => new Map(camps.map((c) => [c.id, c.title])), [camps])
|
||||
|
||||
const sectionsForCamp = useMemo(() => {
|
||||
let list = sections.filter((s) => s.campId === campFilter)
|
||||
list = [...list].sort((a, b) => a.sectionNumber - b.sectionNumber)
|
||||
if (sectionFilter) list = list.filter((s) => s.id === sectionFilter)
|
||||
return list
|
||||
}, [sections, campFilter, sectionFilter])
|
||||
|
||||
const userIds = useMemo(() => {
|
||||
if (campFilter && campUserIds.length > 0) return campUserIds
|
||||
const set = new Set<string>()
|
||||
progressList.forEach((p) => {
|
||||
if (campFilter && p.campId !== campFilter) return
|
||||
if (p.userId) set.add(p.userId)
|
||||
})
|
||||
return Array.from(set)
|
||||
}, [campFilter, campUserIds, progressList])
|
||||
|
||||
const tasksBySection = useMemo(() => {
|
||||
const map = new Map<string, Task[]>()
|
||||
const sectionIdSet = new Set(sectionsForCamp.map((s) => s.id))
|
||||
tasks.forEach((t) => {
|
||||
if (t.campId !== campFilter || !t.sectionId || !sectionIdSet.has(t.sectionId)) return
|
||||
const list = map.get(t.sectionId) || []
|
||||
list.push(t)
|
||||
map.set(t.sectionId, list)
|
||||
})
|
||||
map.forEach((list) => {
|
||||
list.sort((a, b) => (a.id || '').localeCompare(b.id || ''))
|
||||
})
|
||||
return map
|
||||
}, [tasks, campFilter, sectionsForCamp])
|
||||
|
||||
const progressByUserTask = useMemo(() => {
|
||||
const map = new Map<string, UserProgress>()
|
||||
progressList.forEach((p) => map.set(`${p.userId}_${p.taskId}`, p))
|
||||
return map
|
||||
}, [progressList])
|
||||
|
||||
const reviewingTask = useMemo(
|
||||
() => tasks.find((t) => t.id === reviewingProgress?.taskId),
|
||||
[tasks, reviewingProgress?.taskId]
|
||||
)
|
||||
const isEssayReview = Boolean(
|
||||
reviewingTask && Number(reviewingTask.taskType) === TaskType.ESSAY && reviewingProgress?.progress?.essayAnswerImages?.length
|
||||
)
|
||||
const isSubjectiveReview = Boolean(
|
||||
reviewingTask && Number(reviewingTask.taskType) === TaskType.SUBJECTIVE
|
||||
)
|
||||
|
||||
const handleCampChange = useCallback((value: string | undefined) => {
|
||||
setCampFilter(value)
|
||||
setSectionFilter(undefined)
|
||||
setPage(1)
|
||||
}, [])
|
||||
|
||||
const handleSectionChange = useCallback((value: string | undefined) => {
|
||||
setSectionFilter(value)
|
||||
setPage(1)
|
||||
}, [])
|
||||
|
||||
const handleResetFilters = useCallback(() => {
|
||||
setCampFilter(undefined)
|
||||
setSectionFilter(undefined)
|
||||
setUserKeyword('')
|
||||
setPage(1)
|
||||
}, [])
|
||||
|
||||
const handleReviewModalCancel = useCallback(() => {
|
||||
setReviewModalVisible(false)
|
||||
setReviewingProgress(null)
|
||||
form.resetFields()
|
||||
}, [form])
|
||||
|
||||
const selectedCampTitle = campFilter ? campMap.get(campFilter) || campFilter : null
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24, maxWidth: 1600, margin: '0 auto' }}>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<h2 style={{ margin: 0, fontSize: 22, fontWeight: 600, color: token.colorTextHeading }}>
|
||||
打卡营进度管理
|
||||
</h2>
|
||||
<div style={{ color: token.colorTextSecondary, fontSize: 14, marginTop: 6 }}>
|
||||
按「打卡营 × 小节」矩阵展示:先选择打卡营,可搜索用户(昵称/ID/手机号),按用户行、小节列查看各任务完成与审核状态
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FilterBar
|
||||
campFilter={campFilter}
|
||||
sectionFilter={sectionFilter}
|
||||
userKeyword={userKeyword}
|
||||
campOptions={campOptions}
|
||||
sectionOptions={sectionOptions}
|
||||
onCampChange={handleCampChange}
|
||||
onSectionChange={handleSectionChange}
|
||||
onUserKeywordChange={setUserKeyword}
|
||||
onRefresh={fetchData}
|
||||
onReset={handleResetFilters}
|
||||
/>
|
||||
|
||||
{campFilter && <StatusLegend />}
|
||||
|
||||
{loading && (
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: 48,
|
||||
background: token.colorFillQuaternary,
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${token.colorBorderSecondary}`,
|
||||
}}
|
||||
>
|
||||
<Button type="primary" loading />
|
||||
<span style={{ marginLeft: 10, color: token.colorTextSecondary }}>加载中...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !campFilter && (
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: 56,
|
||||
background: token.colorFillQuaternary,
|
||||
borderRadius: 12,
|
||||
border: `1px dashed ${token.colorBorderSecondary}`,
|
||||
color: token.colorTextSecondary,
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
请先选择打卡营,即可查看用户 × 小节的进度矩阵
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && campFilter && (
|
||||
<ProgressMatrix
|
||||
selectedCampTitle={selectedCampTitle}
|
||||
sectionsForCamp={sectionsForCamp}
|
||||
userIds={userIds}
|
||||
tasksBySection={tasksBySection}
|
||||
progressByUserTask={progressByUserTask}
|
||||
getTaskTypeName={getTaskTypeName}
|
||||
onOpenReview={handleOpenReviewModal}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!loading && campFilter && userIds.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 12,
|
||||
padding: '8px 14px',
|
||||
background: token.colorFillQuaternary,
|
||||
borderRadius: 8,
|
||||
color: token.colorTextSecondary,
|
||||
fontSize: 12,
|
||||
display: 'inline-block',
|
||||
border: `1px solid ${token.colorBorderSecondary}`,
|
||||
}}
|
||||
>
|
||||
{campUserIds.length > 0
|
||||
? `共 ${total} 名用户加入该营,本页 ${userIds.length} 名`
|
||||
: `共 ${userIds.length} 名用户,${total} 条进度记录`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ReviewModal
|
||||
open={reviewModalVisible}
|
||||
form={form}
|
||||
reviewingProgress={reviewingProgress}
|
||||
isEssayReview={isEssayReview}
|
||||
isSubjectiveReview={isSubjectiveReview}
|
||||
reviewImageUploading={reviewImageUploading}
|
||||
formatCompletedAt={formatCompletedAt}
|
||||
onOk={handleSubmitReview}
|
||||
onCancel={handleReviewModalCancel}
|
||||
onReviewImageUpload={handleReviewImageUpload}
|
||||
onEssayReviewImageUpload={handleEssayReviewImageUpload}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
41
src/pages/Camp/UserProgressList/utils.ts
Normal file
41
src/pages/Camp/UserProgressList/utils.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import type { UserProgress } from '@/types/camp'
|
||||
import { IMG_COUNTS_MARKER } from './constants'
|
||||
|
||||
/** 从 reviewComment + reviewImages 解析出申论各题批改(comments 与 各题图片数量,用于回显) */
|
||||
export function parseEssayReview(progress: UserProgress | null): { comments: string[]; imageCounts: number[] } {
|
||||
const comments: string[] = []
|
||||
const imageCounts: number[] = []
|
||||
if (!progress?.reviewComment) return { comments, imageCounts }
|
||||
let text = progress.reviewComment.trim()
|
||||
const idx = text.indexOf(IMG_COUNTS_MARKER)
|
||||
if (idx >= 0) {
|
||||
const rest = text.slice(idx + IMG_COUNTS_MARKER.length).trim()
|
||||
const parts = rest.split(',').map((s) => parseInt(s, 10))
|
||||
imageCounts.push(...parts.filter((n) => !Number.isNaN(n)))
|
||||
text = text.slice(0, idx).trim()
|
||||
}
|
||||
const regex = /【第(\d+)题】\s*\n?/g
|
||||
let match: RegExpExecArray | null
|
||||
const starts: { num: number; index: number }[] = []
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
starts.push({ num: parseInt(match[1], 10), index: match.index })
|
||||
}
|
||||
for (let i = 0; i < starts.length; i++) {
|
||||
const start = starts[i].index
|
||||
const end = starts[i + 1] ? starts[i + 1].index : text.length
|
||||
const block = text.slice(start, end).replace(/^【第\d+题】\s*\n?/, '').trim()
|
||||
comments[i] = block
|
||||
}
|
||||
return { comments, imageCounts }
|
||||
}
|
||||
|
||||
/** 将 review_images 按各题数量拆成二维数组 */
|
||||
export function splitReviewImagesByCounts(flat: string[], counts: number[]): string[][] {
|
||||
const result: string[][] = []
|
||||
let offset = 0
|
||||
for (const n of counts) {
|
||||
result.push(flat.slice(offset, offset + n).filter((u) => u && String(u).trim() !== ''))
|
||||
offset += n
|
||||
}
|
||||
return result
|
||||
}
|
||||
80
src/pages/Camp/buildUserSectionRows.ts
Normal file
80
src/pages/Camp/buildUserSectionRows.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import type { Task, Section, UserProgress } from '@/types/camp'
|
||||
import { TaskType } from '@/types/camp'
|
||||
|
||||
type Slot = { task: Task | null; progress: UserProgress | null }
|
||||
|
||||
export interface UserSectionRow {
|
||||
rowKey: string
|
||||
userId: string
|
||||
sectionId: string
|
||||
campId: string
|
||||
campTitle: string
|
||||
sectionTitle: string
|
||||
slots: Slot[]
|
||||
}
|
||||
|
||||
const TASK_TYPE_ORDER: TaskType[] = [
|
||||
TaskType.IMAGE_TEXT,
|
||||
TaskType.VIDEO,
|
||||
TaskType.OBJECTIVE,
|
||||
TaskType.ESSAY,
|
||||
TaskType.SUBJECTIVE,
|
||||
]
|
||||
|
||||
function makeSlot(task: Task | null, progress: UserProgress | null): Slot {
|
||||
return { task, progress }
|
||||
}
|
||||
|
||||
function fillSlotList(
|
||||
orderedTasks: Array<Task | null>,
|
||||
userId: string,
|
||||
progressByUserTask: Map<string, UserProgress>,
|
||||
slotList: Slot[]
|
||||
) {
|
||||
for (const t of orderedTasks) {
|
||||
const progress = t != null ? progressByUserTask.get(`${userId}_${t.id}`) ?? null : null
|
||||
slotList.push(makeSlot(t, progress))
|
||||
}
|
||||
}
|
||||
|
||||
const makeRow = (
|
||||
rowKey: string,
|
||||
userId: string,
|
||||
sectionId: string,
|
||||
campId: string,
|
||||
campTitle: string,
|
||||
sectionTitle: string,
|
||||
slots: Slot[]
|
||||
) => ( { rowKey, userId, sectionId, campId, campTitle, sectionTitle, slots } as UserSectionRow )
|
||||
|
||||
export function buildUserSectionRows(
|
||||
progressList: UserProgress[],
|
||||
taskMap: Map<string, Task>,
|
||||
sections: Section[],
|
||||
campMap: Map<string, string>,
|
||||
sectionMap: Map<string, string>,
|
||||
tasksBySectionOrdered: Map<string, Array<Task | null>>,
|
||||
progressByUserTask: Map<string, UserProgress>
|
||||
): UserSectionRow[] {
|
||||
const SEP = '::'
|
||||
const keySet = new Set<string>()
|
||||
progressList.forEach((p) => {
|
||||
const sectionId = p.sectionId || (p.taskId ? taskMap.get(p.taskId)?.sectionId : undefined)
|
||||
if (sectionId) keySet.add(`${p.userId}${SEP}${sectionId}`)
|
||||
})
|
||||
const rows: UserSectionRow[] = []
|
||||
keySet.forEach(function (combo) {
|
||||
const [userId, ...sectionIdParts] = combo.split(SEP)
|
||||
const sectionId = sectionIdParts.join(SEP)
|
||||
if (!userId || !sectionId) return
|
||||
const section = sections.find((s) => s.id === sectionId)
|
||||
const campId = section?.campId || ''
|
||||
const campTitle = campMap.get(campId) ?? (campId || '—')
|
||||
const sectionTitle = sectionMap.get(sectionId) ?? (sectionId || '—')
|
||||
const orderedTasks = tasksBySectionOrdered.get(sectionId) ?? TASK_TYPE_ORDER.map(() => null)
|
||||
const slotList: Slot[] = []
|
||||
fillSlotList(orderedTasks, userId, progressByUserTask, slotList)
|
||||
rows.push(makeRow(combo, userId, sectionId, campId, campTitle, sectionTitle, slotList))
|
||||
})
|
||||
return rows
|
||||
}
|
||||
122
src/pages/Dashboard/index.tsx
Normal file
122
src/pages/Dashboard/index.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Card, Row, Col, Statistic, Spin, message } from 'antd'
|
||||
import {
|
||||
UserOutlined,
|
||||
TrophyOutlined,
|
||||
QuestionCircleOutlined,
|
||||
FileTextOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { getDashboardStatistics, type Statistics } from '@/api/statistics'
|
||||
|
||||
const Dashboard = () => {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [statistics, setStatistics] = useState<Statistics>({
|
||||
user_count: 0,
|
||||
camp_count: 0,
|
||||
question_count: 0,
|
||||
paper_count: 0,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStatistics = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await getDashboardStatistics()
|
||||
if (response.data.code === 200 && response.data.data) {
|
||||
setStatistics(response.data.data)
|
||||
} else {
|
||||
message.error(response.data.message || '获取统计数据失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error?.response?.data?.message || '获取统计数据失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchStatistics()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 style={{ marginBottom: 24 }}>仪表盘</h1>
|
||||
|
||||
<Spin spinning={loading}>
|
||||
<Row gutter={16}>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="用户总数"
|
||||
value={statistics.user_count}
|
||||
prefix={<UserOutlined />}
|
||||
valueStyle={{ color: '#3f8600' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="打卡营数量"
|
||||
value={statistics.camp_count}
|
||||
prefix={<TrophyOutlined />}
|
||||
valueStyle={{ color: '#1890ff' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="题库题目"
|
||||
value={statistics.question_count}
|
||||
prefix={<QuestionCircleOutlined />}
|
||||
valueStyle={{ color: '#722ed1' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="试卷数量"
|
||||
value={statistics.paper_count}
|
||||
prefix={<FileTextOutlined />}
|
||||
valueStyle={{ color: '#fa8c16' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Spin>
|
||||
|
||||
<Row gutter={16} style={{ marginTop: 24 }}>
|
||||
<Col span={24}>
|
||||
<Card title="欢迎使用怼怼后台管理系统">
|
||||
<p>这是一个功能完善的教育培训管理系统,基于 React + TypeScript + Ant Design 开发。</p>
|
||||
<p style={{ marginTop: 16, fontSize: 16, fontWeight: 600 }}>核心功能模块:</p>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>打卡营管理</strong> - 管理打卡营分类、打卡营、小节、任务和用户学习进度
|
||||
</li>
|
||||
<li>
|
||||
<strong>题目试卷管理</strong> - 题库管理、试卷组卷、答题统计,支持主观题、单选题、多选题、判断题
|
||||
</li>
|
||||
<li>
|
||||
<strong>用户管理</strong> - 用户信息管理和权限控制
|
||||
</li>
|
||||
</ul>
|
||||
<p style={{ marginTop: 16, fontSize: 16, fontWeight: 600 }}>技术特性:</p>
|
||||
<ul>
|
||||
<li>完整的 TypeScript 类型支持</li>
|
||||
<li>统一的 API 请求封装和错误处理</li>
|
||||
<li>路由守卫和权限控制</li>
|
||||
<li>状态管理(Zustand)</li>
|
||||
<li>响应式布局和现代化 UI</li>
|
||||
<li>页面联动和智能筛选</li>
|
||||
</ul>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Dashboard
|
||||
|
||||
236
src/pages/Document/DocumentList.tsx
Normal file
236
src/pages/Document/DocumentList.tsx
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Breadcrumb, Button, Card, Table, Modal, Form, Input, InputNumber, message, Space, Popconfirm, Upload, Typography } from 'antd'
|
||||
import { FolderOutlined, FileOutlined, PlusOutlined, EditOutlined, DeleteOutlined, UploadOutlined, ArrowLeftOutlined } from '@ant-design/icons'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { listFolders, createFolder, updateFolder, deleteFolder, listFiles, createFile, deleteFile, type DocFolder, type DocFile } from '@/api/document'
|
||||
import { getUploadSignature } from '@/api/oss'
|
||||
|
||||
const ACCEPT_MIME = '.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt'
|
||||
/** 可上传文件类型说明(展示用) */
|
||||
const UPLOAD_FILE_TIP = '支持上传:PDF、Word(.doc/.docx)、Excel(.xls/.xlsx)、PPT(.ppt/.pptx)、TXT 等格式。'
|
||||
|
||||
const DocumentList = () => {
|
||||
const [folders, setFolders] = useState<DocFolder[]>([])
|
||||
const [files, setFiles] = useState<DocFile[]>([])
|
||||
const [currentFolder, setCurrentFolder] = useState<DocFolder | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [folderModalVisible, setFolderModalVisible] = useState(false)
|
||||
const [editingFolder, setEditingFolder] = useState<DocFolder | null>(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [form] = Form.useForm()
|
||||
|
||||
const fetchFolders = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await listFolders()
|
||||
if (res.success && res.list) setFolders(res.list)
|
||||
} catch (e) {
|
||||
message.error('获取文件夹列表失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchFiles = useCallback(async (folderId: string) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await listFiles(folderId)
|
||||
if (res.success && res.list) setFiles(res.list)
|
||||
} catch (e) {
|
||||
message.error('获取文件列表失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchFolders()
|
||||
}, [fetchFolders])
|
||||
|
||||
useEffect(() => {
|
||||
if (currentFolder) fetchFiles(currentFolder.id)
|
||||
else setFiles([])
|
||||
}, [currentFolder, fetchFiles])
|
||||
|
||||
const handleCreateFolder = () => {
|
||||
setEditingFolder(null)
|
||||
form.resetFields()
|
||||
setFolderModalVisible(true)
|
||||
}
|
||||
|
||||
const handleEditFolder = (record: DocFolder) => {
|
||||
setEditingFolder(record)
|
||||
form.setFieldsValue({ name: record.name, sort_order: record.sort_order })
|
||||
setFolderModalVisible(true)
|
||||
}
|
||||
|
||||
const handleFolderSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields()
|
||||
if (editingFolder) {
|
||||
await updateFolder({ id: editingFolder.id, name: values.name, sort_order: values.sort_order ?? 0 })
|
||||
message.success('更新成功')
|
||||
} else {
|
||||
await createFolder({ name: values.name, sort_order: values.sort_order ?? 0 })
|
||||
message.success('创建成功')
|
||||
}
|
||||
setFolderModalVisible(false)
|
||||
fetchFolders()
|
||||
} catch (e: any) {
|
||||
if (e.errorFields) return
|
||||
message.error(e.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteFolder = async (id: string) => {
|
||||
try {
|
||||
await deleteFolder(id)
|
||||
message.success('删除成功')
|
||||
if (currentFolder?.id === id) setCurrentFolder(null)
|
||||
fetchFolders()
|
||||
} catch (e: any) {
|
||||
message.error(e.response?.data?.message || e.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleUploadFile = async (file: File) => {
|
||||
if (!currentFolder) return
|
||||
setUploading(true)
|
||||
try {
|
||||
const credentials = await getUploadSignature('doc')
|
||||
const key = `${credentials.dir}${Date.now()}_${Math.random().toString(36).slice(2, 8)}.${(file.name.split('.').pop() || '')}`
|
||||
const formData = new FormData()
|
||||
formData.append('success_action_status', '200')
|
||||
formData.append('policy', credentials.policy)
|
||||
formData.append('x-oss-signature', credentials.signature)
|
||||
formData.append('x-oss-signature-version', credentials.x_oss_signature_version)
|
||||
formData.append('x-oss-credential', credentials.x_oss_credential)
|
||||
formData.append('x-oss-date', credentials.x_oss_date)
|
||||
formData.append('key', key)
|
||||
formData.append('x-oss-security-token', credentials.security_token)
|
||||
formData.append('file', file)
|
||||
const resp = await fetch(credentials.host, { method: 'POST', body: formData })
|
||||
if (!resp.ok) throw new Error(`上传失败: ${resp.status}`)
|
||||
const fileUrl = `${credentials.host}/${key}`
|
||||
await createFile({
|
||||
folder_id: currentFolder.id,
|
||||
file_name: file.name,
|
||||
file_url: fileUrl,
|
||||
file_size: file.size,
|
||||
mime_type: file.type || '',
|
||||
})
|
||||
message.success('上传成功')
|
||||
fetchFiles(currentFolder.id)
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '上传失败')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
return false // 阻止 Upload 默认上传
|
||||
}
|
||||
|
||||
const handleDeleteFile = async (id: string) => {
|
||||
try {
|
||||
await deleteFile(id)
|
||||
message.success('删除成功')
|
||||
if (currentFolder) fetchFiles(currentFolder.id)
|
||||
} catch (e: any) {
|
||||
message.error(e.response?.data?.message || e.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const formatSize = (n: number) => {
|
||||
if (n < 1024) return `${n} B`
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
|
||||
return `${(n / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
const folderColumns: ColumnsType<DocFolder> = [
|
||||
{ title: '名称', dataIndex: 'name', key: 'name', render: (name, record) => <a onClick={() => setCurrentFolder(record)}><FolderOutlined /> {name}</a> },
|
||||
{ title: '排序', dataIndex: 'sort_order', key: 'sort_order', width: 80 },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 140,
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => handleEditFolder(record)}>编辑</Button>
|
||||
<Popconfirm title="确定删除该文件夹?其下文件记录将一并删除。" onConfirm={() => handleDeleteFolder(record.id)}>
|
||||
<Button type="link" danger size="small" icon={<DeleteOutlined />}>删除</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const fileColumns: ColumnsType<DocFile> = [
|
||||
{ title: '名称', dataIndex: 'name', key: 'name', render: (name, row) => <a href={row.file_url} target="_blank" rel="noopener noreferrer"><FileOutlined /> {name}</a> },
|
||||
{ title: '原始文件名', dataIndex: 'file_name', key: 'file_name' },
|
||||
{ title: '大小', dataIndex: 'file_size', key: 'file_size', width: 100, render: (n: number) => formatSize(n) },
|
||||
{ title: '类型', dataIndex: 'mime_type', key: 'mime_type', width: 120 },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 80,
|
||||
render: (_, record) => (
|
||||
<Popconfirm title="确定删除该文档记录?" onConfirm={() => handleDeleteFile(record.id)}>
|
||||
<Button type="link" danger size="small" icon={<DeleteOutlined />}>删除</Button>
|
||||
</Popconfirm>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Breadcrumb style={{ marginBottom: 16 }} items={[
|
||||
{ title: <a onClick={() => setCurrentFolder(null)}>文档管理</a> },
|
||||
...(currentFolder ? [{ title: currentFolder.name }] : []),
|
||||
]} />
|
||||
<Card
|
||||
title={currentFolder ? `文件夹:${currentFolder.name}` : '文件夹列表'}
|
||||
extra={
|
||||
<Space>
|
||||
{currentFolder ? (
|
||||
<>
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={() => setCurrentFolder(null)}>返回</Button>
|
||||
<Upload accept={ACCEPT_MIME} showUploadList={false} beforeUpload={handleUploadFile} disabled={uploading}>
|
||||
<Button type="primary" icon={<UploadOutlined />} loading={uploading}>上传文档</Button>
|
||||
</Upload>
|
||||
</>
|
||||
) : (
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreateFolder}>新建文件夹</Button>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{currentFolder ? (
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>{UPLOAD_FILE_TIP}</Typography.Text>
|
||||
) : null}
|
||||
{!currentFolder ? (
|
||||
<Table rowKey="id" loading={loading} columns={folderColumns} dataSource={folders} pagination={false} />
|
||||
) : (
|
||||
<Table rowKey="id" loading={loading} columns={fileColumns} dataSource={files} pagination={false} />
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={editingFolder ? '编辑文件夹' : '新建文件夹'}
|
||||
open={folderModalVisible}
|
||||
onOk={handleFolderSubmit}
|
||||
onCancel={() => setFolderModalVisible(false)}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="name" label="文件夹名称" rules={[{ required: true, message: '请输入名称' }]}>
|
||||
<Input placeholder="请输入文件夹名称" />
|
||||
</Form.Item>
|
||||
<Form.Item name="sort_order" label="排序" initialValue={0}>
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DocumentList
|
||||
193
src/pages/Login/index.css
Normal file
193
src/pages/Login/index.css
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
.login-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
background: #141414;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
background: #1f1f1f;
|
||||
border-radius: 8px;
|
||||
padding: 40px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.login-card .ant-card-body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
margin: 0 !important;
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.login-type-selector {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.login-radio-group {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.login-radio-group .ant-radio-button-wrapper {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #434343;
|
||||
background: transparent;
|
||||
color: #a0a0a0;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.login-radio-group .ant-radio-button-wrapper:first-child {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
.login-radio-group .ant-radio-button-wrapper:last-child {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.login-radio-group .ant-radio-button-wrapper-checked {
|
||||
background: #177ddc;
|
||||
border-color: #177ddc;
|
||||
color: #ffffff;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.login-input {
|
||||
border-radius: 4px;
|
||||
background: #141414 !important;
|
||||
border-color: #434343;
|
||||
color: #ffffff;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.login-input:hover {
|
||||
border-color: #595959;
|
||||
background: #141414 !important;
|
||||
}
|
||||
|
||||
.login-input:focus,
|
||||
.login-input-focused,
|
||||
.login-input.ant-input-affix-wrapper-focused {
|
||||
border-color: #177ddc;
|
||||
box-shadow: 0 0 0 2px rgba(23, 125, 220, 0.2);
|
||||
background: #141414 !important;
|
||||
}
|
||||
|
||||
.login-input input {
|
||||
background: #141414 !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.login-input input:hover {
|
||||
background: #141414 !important;
|
||||
}
|
||||
|
||||
.login-input input:focus {
|
||||
background: #141414 !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.login-input input::placeholder {
|
||||
color: #595959 !important;
|
||||
}
|
||||
|
||||
/* 处理 Input.Password 组件 */
|
||||
.login-input .ant-input-password {
|
||||
background: #141414 !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.login-input .ant-input-password:hover {
|
||||
background: #141414 !important;
|
||||
}
|
||||
|
||||
.login-input .ant-input-password.ant-input-affix-wrapper-focused {
|
||||
background: #141414 !important;
|
||||
}
|
||||
|
||||
.login-input .ant-input-password input {
|
||||
background: #141414 !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.login-input .ant-input-password input:hover {
|
||||
background: #141414 !important;
|
||||
}
|
||||
|
||||
.login-input .ant-input-password input:focus {
|
||||
background: #141414 !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.login-input .ant-input-password input::placeholder {
|
||||
color: #595959 !important;
|
||||
}
|
||||
|
||||
/* 处理 Ant Design Input 组件的内部元素 */
|
||||
.login-input .ant-input-affix-wrapper {
|
||||
background: #141414 !important;
|
||||
}
|
||||
|
||||
.login-input .ant-input-affix-wrapper:hover {
|
||||
background: #141414 !important;
|
||||
}
|
||||
|
||||
.login-input .ant-input-affix-wrapper-focused {
|
||||
background: #141414 !important;
|
||||
}
|
||||
|
||||
/* 处理 Input 前缀图标 */
|
||||
.login-input .ant-input-prefix {
|
||||
color: #a0a0a0;
|
||||
}
|
||||
|
||||
.login-input .ant-input-prefix svg {
|
||||
color: #a0a0a0;
|
||||
}
|
||||
|
||||
.login-submit {
|
||||
margin-top: 24px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.login-submit .ant-btn-primary {
|
||||
height: 40px;
|
||||
border-radius: 4px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 480px) {
|
||||
.login-card {
|
||||
padding: 32px 24px;
|
||||
margin: 20px;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 20px !important;
|
||||
}
|
||||
}
|
||||
213
src/pages/Login/index.tsx
Normal file
213
src/pages/Login/index.tsx
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
import { Form, Input, Button, Card, message, Radio, Typography } from 'antd'
|
||||
import { UserOutlined, LockOutlined, PhoneOutlined } from '@ant-design/icons'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useUserStore } from '@/store/useUserStore'
|
||||
import { RoutePath } from '@/constants'
|
||||
import { login, LoginRequest } from '@/api/auth'
|
||||
import { useState, useEffect } from 'react'
|
||||
import './index.css'
|
||||
|
||||
const { Title } = Typography
|
||||
|
||||
type LoginType = 'username' | 'phone'
|
||||
|
||||
interface LoginForm {
|
||||
username?: string
|
||||
phone?: string
|
||||
password: string
|
||||
}
|
||||
|
||||
const Login = () => {
|
||||
const navigate = useNavigate()
|
||||
const { setUserInfo, setToken } = useUserStore()
|
||||
const [form] = Form.useForm()
|
||||
const [loginType, setLoginType] = useState<LoginType>('username')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
// 添加键盘快捷键支持
|
||||
useEffect(() => {
|
||||
const handleKeyPress = (e: KeyboardEvent) => {
|
||||
// Ctrl/Cmd + Enter 快速登录
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||
form.submit()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyPress)
|
||||
return () => window.removeEventListener('keydown', handleKeyPress)
|
||||
}, [form])
|
||||
|
||||
const onFinish = async (values: LoginForm) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
// 构建登录请求数据
|
||||
const loginData: LoginRequest = {
|
||||
password: values.password,
|
||||
}
|
||||
|
||||
// 根据登录类型设置用户名或手机号
|
||||
if (loginType === 'username') {
|
||||
if (!values.username) {
|
||||
message.error('请输入用户名')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
loginData.username = values.username
|
||||
} else {
|
||||
if (!values.phone) {
|
||||
message.error('请输入手机号')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
loginData.phone = values.phone
|
||||
}
|
||||
|
||||
// 调用登录 API
|
||||
const res = await login(loginData)
|
||||
|
||||
// 检查响应格式
|
||||
if (!res) {
|
||||
message.error('登录失败:未收到响应')
|
||||
return
|
||||
}
|
||||
|
||||
if (!res.success) {
|
||||
message.error(res.message || '登录失败,请检查用户名和密码')
|
||||
return
|
||||
}
|
||||
|
||||
if (!res.token) {
|
||||
message.error('登录失败:未收到 token')
|
||||
return
|
||||
}
|
||||
|
||||
if (!res.user) {
|
||||
message.error('登录失败:未收到用户信息')
|
||||
return
|
||||
}
|
||||
|
||||
// 保存 token 和用户信息
|
||||
setToken(res.token)
|
||||
setUserInfo(res.user)
|
||||
|
||||
message.success('登录成功')
|
||||
|
||||
// 使用 setTimeout 确保状态已更新,并直接跳转
|
||||
setTimeout(() => {
|
||||
navigate(RoutePath.DASHBOARD, { replace: true })
|
||||
}, 100)
|
||||
} catch (error: any) {
|
||||
// 提取错误信息
|
||||
let errorMessage = '登录失败,请检查用户名和密码'
|
||||
if (error?.response?.data?.message) {
|
||||
errorMessage = error.response.data.message
|
||||
} else if (error?.message) {
|
||||
errorMessage = error.message
|
||||
} else if (error?.response?.statusText) {
|
||||
errorMessage = `${error.response.status} ${error.response.statusText}`
|
||||
}
|
||||
|
||||
message.error(errorMessage)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLoginTypeChange = (e: any) => {
|
||||
setLoginType(e.target.value)
|
||||
form.resetFields(['username', 'phone'])
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-container dark-theme">
|
||||
<Card className="login-card" bordered={false}>
|
||||
<div className="login-header">
|
||||
<Title level={2} className="login-title">
|
||||
登录
|
||||
</Title>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
name="login"
|
||||
onFinish={onFinish}
|
||||
autoComplete="off"
|
||||
size="large"
|
||||
className="login-form"
|
||||
>
|
||||
<Form.Item className="login-type-selector">
|
||||
<Radio.Group
|
||||
value={loginType}
|
||||
onChange={handleLoginTypeChange}
|
||||
className="login-radio-group"
|
||||
buttonStyle="solid"
|
||||
>
|
||||
<Radio.Button value="username">
|
||||
用户名
|
||||
</Radio.Button>
|
||||
<Radio.Button value="phone">
|
||||
手机号
|
||||
</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
|
||||
{loginType === 'username' ? (
|
||||
<Form.Item
|
||||
name="username"
|
||||
rules={[{ required: true, message: '请输入用户名' }]}
|
||||
>
|
||||
<Input
|
||||
prefix={<UserOutlined />}
|
||||
placeholder="用户名"
|
||||
autoComplete="username"
|
||||
className="login-input"
|
||||
/>
|
||||
</Form.Item>
|
||||
) : (
|
||||
<Form.Item
|
||||
name="phone"
|
||||
rules={[
|
||||
{ required: true, message: '请输入手机号' },
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号' },
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
prefix={<PhoneOutlined />}
|
||||
placeholder="手机号"
|
||||
autoComplete="tel"
|
||||
className="login-input"
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item
|
||||
name="password"
|
||||
rules={[{ required: true, message: '请输入密码' }]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder="密码"
|
||||
autoComplete="current-password"
|
||||
className="login-input"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item className="login-submit">
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
block
|
||||
loading={loading}
|
||||
size="large"
|
||||
>
|
||||
{loading ? '登录中...' : '登录'}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Login
|
||||
|
||||
25
src/pages/NotFound/index.tsx
Normal file
25
src/pages/NotFound/index.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { Button, Result } from 'antd'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { RoutePath } from '@/constants'
|
||||
|
||||
const NotFound = () => {
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh' }}>
|
||||
<Result
|
||||
status="404"
|
||||
title="404"
|
||||
subTitle="抱歉,您访问的页面不存在。"
|
||||
extra={
|
||||
<Button type="primary" onClick={() => navigate(RoutePath.DASHBOARD)}>
|
||||
返回首页
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default NotFound
|
||||
|
||||
17
src/pages/Objective/KnowledgeTree.tsx
Normal file
17
src/pages/Objective/KnowledgeTree.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import KnowledgeTree from '@/components/KnowledgeTree'
|
||||
|
||||
const ObjectiveKnowledgeTree = () => {
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 600 }}>客观题管理 - 知识树</h2>
|
||||
<div style={{ color: '#666', fontSize: 14, marginTop: 8 }}>
|
||||
管理客观题的知识树结构
|
||||
</div>
|
||||
</div>
|
||||
<KnowledgeTree type="objective" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ObjectiveKnowledgeTree
|
||||
364
src/pages/Objective/MaterialList.tsx
Normal file
364
src/pages/Objective/MaterialList.tsx
Normal file
|
|
@ -0,0 +1,364 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
message,
|
||||
Space,
|
||||
Popconfirm,
|
||||
} from 'antd'
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, ReloadOutlined, EyeOutlined } from '@ant-design/icons'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import type { Material } from '@/types/question'
|
||||
import { MaterialType } from '@/types/question'
|
||||
import { searchMaterials, createMaterial, updateMaterial, deleteMaterial } from '@/api/question'
|
||||
import { DEFAULT_PAGE_SIZE } from '@/constants'
|
||||
import dayjs from 'dayjs'
|
||||
import RichTextEditor from '@/components/RichTextEditor/RichTextEditor'
|
||||
|
||||
const MaterialList = () => {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [dataSource, setDataSource] = useState<Material[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE)
|
||||
const [query, setQuery] = useState('')
|
||||
const [modalVisible, setModalVisible] = useState(false)
|
||||
const [previewVisible, setPreviewVisible] = useState(false)
|
||||
const [editingRecord, setEditingRecord] = useState<Material | null>(null)
|
||||
const [previewContent, setPreviewContent] = useState('')
|
||||
|
||||
const [form] = Form.useForm()
|
||||
|
||||
// 获取列表数据
|
||||
const fetchData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await searchMaterials({
|
||||
query,
|
||||
type: MaterialType.OBJECTIVE,
|
||||
page,
|
||||
pageSize,
|
||||
})
|
||||
if (res.data.code === 200) {
|
||||
const list = res.data.data.list || []
|
||||
const total = res.data.data.total || 0
|
||||
setDataSource(list)
|
||||
setTotal(total)
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('获取材料列表错误:', error)
|
||||
setDataSource([])
|
||||
setTotal(0)
|
||||
if (error.response?.status >= 500 || !error.response) {
|
||||
message.error('服务器错误,请稍后重试')
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [page, pageSize, query])
|
||||
|
||||
// 监听 Modal 打开,设置表单初始值
|
||||
useEffect(() => {
|
||||
if (modalVisible && editingRecord) {
|
||||
console.log('编辑材料数据:', editingRecord)
|
||||
form.setFieldsValue({
|
||||
name: editingRecord.name || '',
|
||||
content: editingRecord.content || '',
|
||||
})
|
||||
}
|
||||
}, [modalVisible, editingRecord, form])
|
||||
|
||||
// 打开新增/编辑弹窗
|
||||
const handleOpenModal = (record?: Material) => {
|
||||
if (record) {
|
||||
setEditingRecord(record)
|
||||
} else {
|
||||
setEditingRecord(null)
|
||||
form.resetFields()
|
||||
form.setFieldsValue({
|
||||
name: '',
|
||||
content: '',
|
||||
})
|
||||
}
|
||||
setModalVisible(true)
|
||||
}
|
||||
|
||||
// 预览材料内容
|
||||
const handlePreview = (record: Material) => {
|
||||
setPreviewContent(record.content)
|
||||
setPreviewVisible(true)
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields()
|
||||
|
||||
const formData = {
|
||||
type: MaterialType.OBJECTIVE,
|
||||
name: values.name || '',
|
||||
content: values.content || '',
|
||||
}
|
||||
|
||||
if (editingRecord) {
|
||||
// 编辑
|
||||
await updateMaterial({ ...editingRecord, ...formData })
|
||||
message.success('编辑材料成功')
|
||||
} else {
|
||||
// 新增
|
||||
await createMaterial(formData)
|
||||
message.success('新增材料成功')
|
||||
}
|
||||
|
||||
setModalVisible(false)
|
||||
setEditingRecord(null)
|
||||
form.resetFields()
|
||||
fetchData()
|
||||
} catch (error: any) {
|
||||
const errorMsg = error?.message || '操作失败'
|
||||
message.error(errorMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// 删除材料
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteMaterial(id)
|
||||
message.success('删除成功')
|
||||
fetchData()
|
||||
} catch (error: any) {
|
||||
const errorMsg = error?.message || '删除失败'
|
||||
message.error(errorMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索
|
||||
const handleSearch = (value: string) => {
|
||||
setQuery(value)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Material> = [
|
||||
{
|
||||
title: '材料名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: 200,
|
||||
ellipsis: true,
|
||||
render: (name: string) => name || <span style={{ color: '#999' }}>未命名</span>,
|
||||
},
|
||||
{
|
||||
title: '材料内容',
|
||||
dataIndex: 'content',
|
||||
key: 'content',
|
||||
width: 400,
|
||||
ellipsis: true,
|
||||
render: (content: string) => {
|
||||
// 移除 HTML 标签,只显示纯文本
|
||||
const textContent = content
|
||||
.replace(/<img[^>]*>/gi, '[图片]')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/\n/g, ' ')
|
||||
.trim()
|
||||
return textContent || <span style={{ color: '#999' }}>无内容</span>
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
width: 180,
|
||||
render: (timestamp: number) => {
|
||||
if (!timestamp) return '-'
|
||||
return dayjs(timestamp * 1000).format('YYYY-MM-DD HH:mm')
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '更新时间',
|
||||
dataIndex: 'updatedAt',
|
||||
key: 'updatedAt',
|
||||
width: 180,
|
||||
render: (timestamp: number) => {
|
||||
if (!timestamp) return '-'
|
||||
return dayjs(timestamp * 1000).format('YYYY-MM-DD HH:mm')
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 200,
|
||||
fixed: 'right',
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => handlePreview(record)}
|
||||
>
|
||||
预览
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleOpenModal(record)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确定要删除这个材料吗?"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
{/* 页面标题 */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 600 }}>客观题管理 - 材料管理</h2>
|
||||
<div style={{ color: '#666', fontSize: 14, marginTop: 8 }}>
|
||||
管理客观题材料,材料可用于题目中
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Space wrap>
|
||||
<Input.Search
|
||||
placeholder="搜索材料名称或内容"
|
||||
allowClear
|
||||
style={{ width: 300 }}
|
||||
onSearch={handleSearch}
|
||||
/>
|
||||
<Button icon={<ReloadOutlined />} onClick={fetchData}>
|
||||
刷新
|
||||
</Button>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => handleOpenModal()}>
|
||||
新增材料
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={dataSource}
|
||||
rowKey="id"
|
||||
scroll={{ x: 1200 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 个材料`,
|
||||
onChange: (page, pageSize) => {
|
||||
setPage(page)
|
||||
setPageSize(pageSize)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 新增/编辑弹窗 */}
|
||||
<Modal
|
||||
title={editingRecord ? '编辑材料' : '新增材料'}
|
||||
open={modalVisible}
|
||||
onOk={handleSubmit}
|
||||
onCancel={() => {
|
||||
setModalVisible(false)
|
||||
setEditingRecord(null)
|
||||
form.resetFields()
|
||||
}}
|
||||
width={900}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
label="材料名称"
|
||||
name="name"
|
||||
rules={[{ required: true, message: '请输入材料名称' }]}
|
||||
>
|
||||
<Input placeholder="请输入材料名称" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="材料内容"
|
||||
name="content"
|
||||
rules={[{ required: true, message: '请输入材料内容' }]}
|
||||
>
|
||||
<RichTextEditor placeholder="请输入材料内容,支持插入图片和文字格式" rows={10} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 预览弹窗 */}
|
||||
<Modal
|
||||
title="材料预览"
|
||||
open={previewVisible}
|
||||
onCancel={() => setPreviewVisible(false)}
|
||||
footer={[
|
||||
<Button key="close" onClick={() => setPreviewVisible(false)}>
|
||||
关闭
|
||||
</Button>,
|
||||
]}
|
||||
width={900}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxHeight: '70vh',
|
||||
overflow: 'auto',
|
||||
padding: '20px',
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 4,
|
||||
border: '1px solid #e8e8e8',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
wordBreak: 'break-word',
|
||||
lineHeight: '1.8',
|
||||
fontSize: '14px',
|
||||
color: '#333',
|
||||
}}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: (() => {
|
||||
let html = previewContent || ''
|
||||
if (!html) return ''
|
||||
|
||||
// react-quill 生成的 HTML 已经是完整的格式,直接使用
|
||||
// 只需要确保图片标签有正确的样式
|
||||
return html.replace(
|
||||
/<img([^>]*)>/gi,
|
||||
(match, attrs) => {
|
||||
if (!attrs.includes('style=')) {
|
||||
return `<img${attrs} style="max-width: 100%; height: auto; margin: 12px 0; border-radius: 4px; display: block;">`
|
||||
}
|
||||
return match
|
||||
}
|
||||
)
|
||||
})()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MaterialList
|
||||
661
src/pages/Objective/PaperEdit.tsx
Normal file
661
src/pages/Objective/PaperEdit.tsx
Normal file
|
|
@ -0,0 +1,661 @@
|
|||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Input,
|
||||
message,
|
||||
Space,
|
||||
Tag,
|
||||
Select,
|
||||
Form,
|
||||
Card,
|
||||
Checkbox,
|
||||
Tooltip,
|
||||
Modal,
|
||||
Divider,
|
||||
} from 'antd'
|
||||
import { ArrowLeftOutlined, SaveOutlined, DeleteOutlined, EyeOutlined, HolderOutlined, OrderedListOutlined } from '@ant-design/icons'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import type { Question, Paper } from '@/types/question'
|
||||
import { QuestionType } from '@/types/question'
|
||||
import { searchQuestions, createPaper, updatePaper, getPaper } from '@/api/question'
|
||||
import { DEFAULT_PAGE_SIZE, RoutePath } from '@/constants'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
|
||||
const { TextArea } = Input
|
||||
|
||||
const ObjectivePaperEdit = () => {
|
||||
const navigate = useNavigate()
|
||||
const [searchParams] = useSearchParams()
|
||||
const paperId = searchParams.get('id') // 编辑模式
|
||||
const isEdit = !!paperId
|
||||
|
||||
// 试卷基础信息
|
||||
const [form] = Form.useForm()
|
||||
|
||||
// 题目列表
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [questionList, setQuestionList] = useState<Question[]>([])
|
||||
const [questionTotal, setQuestionTotal] = useState(0)
|
||||
const [questionPage, setQuestionPage] = useState(1)
|
||||
const [questionPageSize, setQuestionPageSize] = useState(DEFAULT_PAGE_SIZE)
|
||||
|
||||
// 筛选条件
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [typeFilter, setTypeFilter] = useState<QuestionType | undefined>()
|
||||
|
||||
// 已选题目 ID
|
||||
const [selectedQuestionIds, setSelectedQuestionIds] = useState<string[]>([])
|
||||
|
||||
// 已选题目详情(用于右侧展示)
|
||||
const [selectedQuestions, setSelectedQuestions] = useState<Question[]>([])
|
||||
|
||||
// 加载试卷数据(编辑模式)
|
||||
const [paperLoading, setPaperLoading] = useState(false)
|
||||
|
||||
// 提交状态
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
// 预览弹窗
|
||||
const [previewQuestion, setPreviewQuestion] = useState<Question | null>(null)
|
||||
const [previewVisible, setPreviewVisible] = useState(false)
|
||||
|
||||
// 排序弹窗
|
||||
const [sortModalVisible, setSortModalVisible] = useState(false)
|
||||
const [sortList, setSortList] = useState<string[]>([])
|
||||
const dragItemRef = useRef<number | null>(null)
|
||||
const dragOverItemRef = useRef<number | null>(null)
|
||||
|
||||
// 编辑模式:加载试卷数据
|
||||
useEffect(() => {
|
||||
if (!paperId) return
|
||||
const fetchPaper = async () => {
|
||||
setPaperLoading(true)
|
||||
try {
|
||||
const res = await getPaper(paperId)
|
||||
if (res.data.code === 200) {
|
||||
const paper = res.data.data as Paper
|
||||
form.setFieldsValue({
|
||||
title: paper.title,
|
||||
description: paper.description,
|
||||
})
|
||||
setSelectedQuestionIds(paper.questionIds || [])
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error('加载试卷数据失败')
|
||||
console.error('加载试卷错误:', error)
|
||||
} finally {
|
||||
setPaperLoading(false)
|
||||
}
|
||||
}
|
||||
fetchPaper()
|
||||
}, [paperId, form])
|
||||
|
||||
// 获取题目列表
|
||||
const fetchQuestions = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await searchQuestions({
|
||||
query: searchQuery || undefined,
|
||||
type: typeFilter,
|
||||
page: questionPage,
|
||||
pageSize: questionPageSize,
|
||||
})
|
||||
if (res.data.code === 200) {
|
||||
const list = res.data.data.list || []
|
||||
// 过滤出客观题
|
||||
const objectiveList = list.filter(
|
||||
(q: Question) =>
|
||||
q.type === QuestionType.SINGLE_CHOICE ||
|
||||
q.type === QuestionType.MULTIPLE_CHOICE ||
|
||||
q.type === QuestionType.TRUE_FALSE
|
||||
)
|
||||
setQuestionList(objectiveList)
|
||||
setQuestionTotal(objectiveList.length)
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('获取题目列表错误:', error)
|
||||
setQuestionList([])
|
||||
setQuestionTotal(0)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [searchQuery, typeFilter, questionPage, questionPageSize])
|
||||
|
||||
useEffect(() => {
|
||||
fetchQuestions()
|
||||
}, [fetchQuestions])
|
||||
|
||||
// 编辑模式加载已选题目详情
|
||||
useEffect(() => {
|
||||
if (selectedQuestionIds.length === 0) {
|
||||
setSelectedQuestions([])
|
||||
return
|
||||
}
|
||||
// 从已加载的题目列表中找已选题目
|
||||
const found = questionList.filter(q => selectedQuestionIds.includes(q.id))
|
||||
setSelectedQuestions(prev => {
|
||||
// 合并:保留之前找到的 + 新增从当前列表中找到的
|
||||
const existingIds = new Set(prev.map(q => q.id))
|
||||
const newOnes = found.filter(q => !existingIds.has(q.id))
|
||||
const merged = [...prev.filter(q => selectedQuestionIds.includes(q.id)), ...newOnes]
|
||||
return merged
|
||||
})
|
||||
}, [selectedQuestionIds, questionList])
|
||||
|
||||
// 题目类型标签
|
||||
const getQuestionTypeTag = (type: QuestionType) => {
|
||||
const tagMap: Record<number, { color: string; text: string }> = {
|
||||
[QuestionType.SINGLE_CHOICE]: { color: 'green', text: '单选题' },
|
||||
[QuestionType.MULTIPLE_CHOICE]: { color: 'orange', text: '多选题' },
|
||||
[QuestionType.TRUE_FALSE]: { color: 'purple', text: '判断题' },
|
||||
}
|
||||
const config = tagMap[type]
|
||||
if (!config) return <Tag color="default">未知</Tag>
|
||||
return <Tag color={config.color}>{config.text}</Tag>
|
||||
}
|
||||
|
||||
// 渲染选项
|
||||
const renderOptions = (options: any[]) => {
|
||||
if (!options || options.length === 0) return '-'
|
||||
return options.map((opt, idx) => {
|
||||
const letter = String.fromCharCode(65 + idx)
|
||||
const text = typeof opt === 'string' ? opt : (opt.value || opt.content || '')
|
||||
return (
|
||||
<div key={idx} style={{ fontSize: 12, color: '#666', lineHeight: '20px' }}>
|
||||
{letter}. {text.length > 30 ? text.substring(0, 30) + '...' : text}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// 去掉 HTML 标签,用于展示纯文本
|
||||
const stripHtml = (html: string) => {
|
||||
if (!html) return ''
|
||||
return html.replace(/<[^>]+>/g, '').replace(/ /g, ' ').trim()
|
||||
}
|
||||
|
||||
// 切换选中
|
||||
const toggleSelect = (questionId: string) => {
|
||||
setSelectedQuestionIds(prev => {
|
||||
if (prev.includes(questionId)) {
|
||||
return prev.filter(id => id !== questionId)
|
||||
}
|
||||
return [...prev, questionId]
|
||||
})
|
||||
}
|
||||
|
||||
// 移除已选题目
|
||||
const removeSelected = (questionId: string) => {
|
||||
setSelectedQuestionIds(prev => prev.filter(id => id !== questionId))
|
||||
setSelectedQuestions(prev => prev.filter(q => q.id !== questionId))
|
||||
}
|
||||
|
||||
// 预览题目
|
||||
const handlePreview = (question: Question) => {
|
||||
setPreviewQuestion(question)
|
||||
setPreviewVisible(true)
|
||||
}
|
||||
|
||||
// 打开排序弹窗
|
||||
const openSortModal = () => {
|
||||
setSortList([...selectedQuestionIds])
|
||||
setSortModalVisible(true)
|
||||
}
|
||||
|
||||
// 拖拽排序
|
||||
const handleDragStart = (index: number) => {
|
||||
dragItemRef.current = index
|
||||
}
|
||||
|
||||
const handleDragEnter = (index: number) => {
|
||||
dragOverItemRef.current = index
|
||||
if (dragItemRef.current === null || dragItemRef.current === index) return
|
||||
const newList = [...sortList]
|
||||
const dragItem = newList[dragItemRef.current]
|
||||
newList.splice(dragItemRef.current, 1)
|
||||
newList.splice(index, 0, dragItem)
|
||||
dragItemRef.current = index
|
||||
setSortList(newList)
|
||||
}
|
||||
|
||||
const handleDragEnd = () => {
|
||||
dragItemRef.current = null
|
||||
dragOverItemRef.current = null
|
||||
}
|
||||
|
||||
// 确认排序
|
||||
const handleSortConfirm = () => {
|
||||
setSelectedQuestionIds(sortList)
|
||||
setSortModalVisible(false)
|
||||
message.success('排序已更新')
|
||||
}
|
||||
|
||||
// 渲染全部选项(预览弹窗用)
|
||||
const renderFullOptions = (options: any[], answer?: string) => {
|
||||
if (!options || options.length === 0) return null
|
||||
return options.map((opt, idx) => {
|
||||
const letter = String.fromCharCode(65 + idx)
|
||||
const text = typeof opt === 'string' ? opt : (opt.value || opt.content || '')
|
||||
const isCorrect = answer?.includes(letter)
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
marginBottom: 6,
|
||||
background: isCorrect ? '#f6ffed' : '#fafafa',
|
||||
border: isCorrect ? '1px solid #b7eb8f' : '1px solid #f0f0f0',
|
||||
borderRadius: 6,
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: 500, marginRight: 8 }}>{letter}.</span>
|
||||
{text}
|
||||
{isCorrect && <Tag color="green" style={{ marginLeft: 8, fontSize: 11 }}>正确答案</Tag>}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// 题目列表列定义
|
||||
const columns: ColumnsType<Question> = [
|
||||
{
|
||||
title: '',
|
||||
width: 50,
|
||||
fixed: 'left',
|
||||
render: (_, record) => (
|
||||
<Checkbox
|
||||
checked={selectedQuestionIds.includes(record.id)}
|
||||
onChange={() => toggleSelect(record.id)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 100,
|
||||
render: (id: string) => (
|
||||
<Tooltip title={id}>
|
||||
<span style={{ fontSize: 12, color: '#999' }}>{id.substring(0, 8)}...</span>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'type',
|
||||
key: 'type',
|
||||
width: 80,
|
||||
render: getQuestionTypeTag,
|
||||
},
|
||||
{
|
||||
title: '题干',
|
||||
dataIndex: 'content',
|
||||
key: 'content',
|
||||
width: 300,
|
||||
ellipsis: true,
|
||||
render: (content: string) => {
|
||||
const text = stripHtml(content)
|
||||
return (
|
||||
<Tooltip title={text}>
|
||||
<span>{text.length > 60 ? text.substring(0, 60) + '...' : text}</span>
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '选项',
|
||||
dataIndex: 'options',
|
||||
key: 'options',
|
||||
width: 250,
|
||||
render: renderOptions,
|
||||
},
|
||||
]
|
||||
|
||||
// 提交保存
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields()
|
||||
|
||||
if (selectedQuestionIds.length === 0) {
|
||||
message.warning('请至少选择一道题目')
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
|
||||
const formData = {
|
||||
title: values.title,
|
||||
description: values.description || '',
|
||||
questionIds: selectedQuestionIds,
|
||||
}
|
||||
|
||||
if (isEdit && paperId) {
|
||||
await updatePaper({
|
||||
id: paperId,
|
||||
...formData,
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
} as Paper)
|
||||
message.success('编辑试卷成功')
|
||||
} else {
|
||||
await createPaper(formData)
|
||||
message.success('新增试卷成功')
|
||||
}
|
||||
|
||||
navigate(RoutePath.OBJECTIVE_PAPER_LIST)
|
||||
} catch (error: any) {
|
||||
console.error('提交表单错误:', error)
|
||||
const errorMsg = error?.response?.data?.message || error?.message || '操作失败'
|
||||
message.error(errorMsg)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
{/* 顶部导航 */}
|
||||
<div style={{ marginBottom: 24, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Space>
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate(RoutePath.OBJECTIVE_PAPER_LIST)}>
|
||||
返回
|
||||
</Button>
|
||||
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 600 }}>
|
||||
{isEdit ? '编辑组卷' : '新增组卷'}
|
||||
</h2>
|
||||
</Space>
|
||||
<Button type="primary" icon={<SaveOutlined />} onClick={handleSubmit} loading={submitting}>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 试卷基本信息 */}
|
||||
<Card title="试卷信息" style={{ marginBottom: 16 }} loading={paperLoading}>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
label="试卷标题"
|
||||
name="title"
|
||||
rules={[{ required: true, message: '请输入试卷标题' }]}
|
||||
>
|
||||
<Input placeholder="请输入试卷标题" />
|
||||
</Form.Item>
|
||||
<Form.Item label="试卷描述" name="description">
|
||||
<TextArea rows={2} placeholder="请输入试卷描述(可选)" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
{/* 左侧:题目列表 */}
|
||||
<Card
|
||||
title="题目列表"
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
extra={<span style={{ color: '#999', fontSize: 13 }}>点击复选框选择题目</span>}
|
||||
>
|
||||
{/* 筛选条件 */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Space wrap style={{ width: '100%' }}>
|
||||
<Select
|
||||
placeholder="题目类型"
|
||||
allowClear
|
||||
style={{ width: 120 }}
|
||||
value={typeFilter}
|
||||
onChange={(value) => {
|
||||
setTypeFilter(value)
|
||||
setQuestionPage(1)
|
||||
}}
|
||||
options={[
|
||||
{ label: '单选题', value: QuestionType.SINGLE_CHOICE },
|
||||
{ label: '多选题', value: QuestionType.MULTIPLE_CHOICE },
|
||||
{ label: '判断题', value: QuestionType.TRUE_FALSE },
|
||||
]}
|
||||
/>
|
||||
<Input.Search
|
||||
placeholder="搜索题干、选项内容"
|
||||
allowClear
|
||||
style={{ width: 250 }}
|
||||
onSearch={(value) => {
|
||||
setSearchQuery(value)
|
||||
setQuestionPage(1)
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={questionList}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
scroll={{ x: 960 }}
|
||||
rowClassName={(record) =>
|
||||
selectedQuestionIds.includes(record.id) ? 'ant-table-row-selected' : ''
|
||||
}
|
||||
pagination={{
|
||||
current: questionPage,
|
||||
pageSize: questionPageSize,
|
||||
total: questionTotal,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 题`,
|
||||
size: 'small',
|
||||
onChange: (page, pageSize) => {
|
||||
setQuestionPage(page)
|
||||
setQuestionPageSize(pageSize)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 右侧:已选题目 */}
|
||||
<Card
|
||||
title={`已选题目 (${selectedQuestionIds.length})`}
|
||||
style={{ width: 480, flexShrink: 0 }}
|
||||
extra={
|
||||
selectedQuestionIds.length > 1 ? (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<OrderedListOutlined />}
|
||||
onClick={openSortModal}
|
||||
>
|
||||
调整顺序
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{selectedQuestionIds.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', color: '#999', padding: '40px 0' }}>
|
||||
请从左侧列表中选择题目
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ maxHeight: 600, overflowY: 'auto' }}>
|
||||
{selectedQuestionIds.map((qId, index) => {
|
||||
const q = selectedQuestions.find(sq => sq.id === qId)
|
||||
return (
|
||||
<div
|
||||
key={qId}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
marginBottom: 8,
|
||||
background: '#fafafa',
|
||||
borderRadius: 6,
|
||||
border: '1px solid #f0f0f0',
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 8,
|
||||
cursor: q ? 'pointer' : 'default',
|
||||
}}
|
||||
onClick={() => q && handlePreview(q)}
|
||||
>
|
||||
<span style={{ color: '#999', fontSize: 12, flexShrink: 0, marginTop: 2 }}>
|
||||
{index + 1}.
|
||||
</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{q ? (
|
||||
<>
|
||||
<div style={{ fontSize: 13, marginBottom: 4 }}>
|
||||
{getQuestionTypeTag(q.type)}
|
||||
<span style={{ marginLeft: 4 }}>
|
||||
{stripHtml(q.content).substring(0, 60)}
|
||||
{stripHtml(q.content).length > 60 ? '...' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#999' }}>
|
||||
ID: {qId.substring(0, 10)}...
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ fontSize: 12, color: '#999' }}>
|
||||
ID: {qId.substring(0, 16)}...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Space size={0}>
|
||||
{q && (
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={(e) => { e.stopPropagation(); handlePreview(q) }}
|
||||
style={{ flexShrink: 0, color: '#1890ff' }}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={(e) => { e.stopPropagation(); removeSelected(qId) }}
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 题目预览弹窗 */}
|
||||
<Modal
|
||||
title="题目预览"
|
||||
open={previewVisible}
|
||||
onCancel={() => setPreviewVisible(false)}
|
||||
footer={null}
|
||||
width={700}
|
||||
>
|
||||
{previewQuestion && (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
{getQuestionTypeTag(previewQuestion.type)}
|
||||
<span style={{ fontSize: 12, color: '#999', marginLeft: 8 }}>
|
||||
ID: {previewQuestion.id}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ fontWeight: 500, marginBottom: 8, color: '#333' }}>题干</div>
|
||||
<div
|
||||
style={{ fontSize: 14, lineHeight: 1.8, padding: '8px 12px', background: '#fafafa', borderRadius: 6 }}
|
||||
dangerouslySetInnerHTML={{ __html: previewQuestion.content || '' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ fontWeight: 500, marginBottom: 8, color: '#333' }}>选项</div>
|
||||
{renderFullOptions(previewQuestion.options as any[], previewQuestion.answer)}
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ fontWeight: 500, marginBottom: 8, color: '#333' }}>答案</div>
|
||||
<Tag color="blue" style={{ fontSize: 14, padding: '2px 12px' }}>
|
||||
{previewQuestion.answer || '未设置'}
|
||||
</Tag>
|
||||
</div>
|
||||
|
||||
{previewQuestion.explanation && (
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, marginBottom: 8, color: '#333' }}>解析</div>
|
||||
<div
|
||||
style={{ fontSize: 14, lineHeight: 1.8, padding: '8px 12px', background: '#fffbe6', borderRadius: 6, border: '1px solid #ffe58f' }}
|
||||
dangerouslySetInnerHTML={{ __html: previewQuestion.explanation }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* 排序弹窗 */}
|
||||
<Modal
|
||||
title="调整题目顺序"
|
||||
open={sortModalVisible}
|
||||
onOk={handleSortConfirm}
|
||||
onCancel={() => setSortModalVisible(false)}
|
||||
okText="确认排序"
|
||||
cancelText="取消"
|
||||
width={500}
|
||||
>
|
||||
<div style={{ color: '#999', fontSize: 13, marginBottom: 12 }}>
|
||||
拖拽题目卡片来调整顺序
|
||||
</div>
|
||||
<div style={{ maxHeight: 500, overflowY: 'auto' }}>
|
||||
{sortList.map((qId, index) => {
|
||||
const q = selectedQuestions.find(sq => sq.id === qId)
|
||||
return (
|
||||
<div
|
||||
key={qId}
|
||||
draggable
|
||||
onDragStart={() => handleDragStart(index)}
|
||||
onDragEnter={() => handleDragEnter(index)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
style={{
|
||||
padding: '10px 14px',
|
||||
marginBottom: 6,
|
||||
background: '#fff',
|
||||
borderRadius: 6,
|
||||
border: '1px solid #e8e8e8',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
cursor: 'grab',
|
||||
userSelect: 'none',
|
||||
transition: 'box-shadow 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.boxShadow = '0 2px 8px rgba(0,0,0,0.1)' }}
|
||||
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.boxShadow = 'none' }}
|
||||
>
|
||||
<HolderOutlined style={{ color: '#bbb', fontSize: 16 }} />
|
||||
<span style={{ color: '#999', fontSize: 13, fontWeight: 500, width: 28, textAlign: 'center' }}>
|
||||
{index + 1}
|
||||
</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{q ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
{getQuestionTypeTag(q.type)}
|
||||
<span style={{ fontSize: 13, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{stripHtml(q.content).substring(0, 50)}
|
||||
{stripHtml(q.content).length > 50 ? '...' : ''}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ fontSize: 12, color: '#999' }}>ID: {qId.substring(0, 16)}...</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ObjectivePaperEdit
|
||||
183
src/pages/Objective/PaperList.tsx
Normal file
183
src/pages/Objective/PaperList.tsx
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Input,
|
||||
message,
|
||||
Space,
|
||||
Popconfirm,
|
||||
} from 'antd'
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import type { Paper } from '@/types/question'
|
||||
import { searchPapers, deletePaper } from '@/api/question'
|
||||
import { DEFAULT_PAGE_SIZE, RoutePath } from '@/constants'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const ObjectivePaperList = () => {
|
||||
const navigate = useNavigate()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [dataSource, setDataSource] = useState<Paper[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE)
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
// 获取试卷列表
|
||||
const fetchData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await searchPapers({
|
||||
query,
|
||||
page,
|
||||
pageSize,
|
||||
})
|
||||
if (res.data.code === 200) {
|
||||
const list = res.data.data.list || []
|
||||
setDataSource(list)
|
||||
setTotal(res.data.data.total || 0)
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('获取试卷列表错误:', error)
|
||||
setDataSource([])
|
||||
setTotal(0)
|
||||
if (error.response?.status >= 500 || !error.response) {
|
||||
message.error('服务器错误,请稍后重试')
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [page, pageSize, query])
|
||||
|
||||
// 删除试卷
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deletePaper(id)
|
||||
message.success('删除成功')
|
||||
fetchData()
|
||||
} catch (error: any) {
|
||||
const errorMsg = error?.message || '删除失败'
|
||||
message.error(errorMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索
|
||||
const handleSearch = (value: string) => {
|
||||
setQuery(value)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Paper> = [
|
||||
{
|
||||
title: '试卷标题',
|
||||
dataIndex: 'title',
|
||||
key: 'title',
|
||||
width: 200,
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '题目数量',
|
||||
dataIndex: 'questionIds',
|
||||
key: 'questionCount',
|
||||
width: 100,
|
||||
render: (questionIds: string[]) => questionIds?.length || 0,
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
width: 180,
|
||||
render: (timestamp: number) => {
|
||||
if (!timestamp) return '-'
|
||||
return dayjs(timestamp * 1000).format('YYYY-MM-DD HH:mm')
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 180,
|
||||
fixed: 'right',
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => navigate(`${RoutePath.OBJECTIVE_PAPER_EDIT}?id=${record.id}`)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确定要删除这个试卷吗?"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
{/* 页面标题 */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 600 }}>客观题管理 - 组卷管理</h2>
|
||||
<div style={{ color: '#666', fontSize: 14, marginTop: 8 }}>
|
||||
管理客观题试卷,将题目组成试卷
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Space wrap>
|
||||
<Input.Search
|
||||
placeholder="搜索试卷标题"
|
||||
allowClear
|
||||
style={{ width: 300 }}
|
||||
onSearch={handleSearch}
|
||||
/>
|
||||
<Button icon={<ReloadOutlined />} onClick={fetchData}>
|
||||
刷新
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => navigate(RoutePath.OBJECTIVE_PAPER_EDIT)}
|
||||
>
|
||||
新增试卷
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={dataSource}
|
||||
rowKey="id"
|
||||
scroll={{ x: 1200 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 个试卷`,
|
||||
onChange: (page, pageSize) => {
|
||||
setPage(page)
|
||||
setPageSize(pageSize)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ObjectivePaperList
|
||||
770
src/pages/Objective/QuestionList.tsx
Normal file
770
src/pages/Objective/QuestionList.tsx
Normal file
|
|
@ -0,0 +1,770 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
message,
|
||||
Space,
|
||||
Popconfirm,
|
||||
Select,
|
||||
Tag,
|
||||
Checkbox,
|
||||
Switch,
|
||||
TreeSelect,
|
||||
} from 'antd'
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, ReloadOutlined, MinusCircleOutlined } from '@ant-design/icons'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import type { Question, QuestionOption, KnowledgeTreeNode } from '@/types/question'
|
||||
import { QuestionType, MaterialType } from '@/types/question'
|
||||
import { searchQuestions, createQuestion, updateQuestion, deleteQuestion, searchMaterials, getKnowledgeTree } from '@/api/question'
|
||||
import type { Material } from '@/types/question'
|
||||
// import { DEFAULT_PAGE_SIZE } from '@/constants'
|
||||
import dayjs from 'dayjs'
|
||||
import RichTextEditor from '@/components/RichTextEditor/RichTextEditor'
|
||||
|
||||
const ObjectiveQuestionList = () => {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [dataSource, setDataSource] = useState<Question[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(100)
|
||||
const [query, setQuery] = useState('')
|
||||
const [typeFilter, setTypeFilter] = useState<QuestionType>()
|
||||
const [modalVisible, setModalVisible] = useState(false)
|
||||
const [editingRecord, setEditingRecord] = useState<Question | null>(null)
|
||||
const [materialSearchVisible, setMaterialSearchVisible] = useState(false) // 材料搜索栏显示
|
||||
const [materialList, setMaterialList] = useState<Material[]>([]) // 材料列表
|
||||
const [materialSearchLoading, setMaterialSearchLoading] = useState(false) // 材料搜索加载状态
|
||||
const [knowledgeTreeData, setKnowledgeTreeData] = useState<KnowledgeTreeNode[]>([]) // 知识树数据
|
||||
|
||||
const [form] = Form.useForm()
|
||||
const questionType = Form.useWatch('type', form)
|
||||
|
||||
// 获取列表数据(只获取客观题:单选题、多选题、判断题)
|
||||
const fetchData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await searchQuestions({
|
||||
query,
|
||||
type: typeFilter,
|
||||
page,
|
||||
pageSize,
|
||||
})
|
||||
if (res.data.code === 200) {
|
||||
const list = res.data.data.list || []
|
||||
setDataSource(list)
|
||||
setTotal(res.data.data.total || list.length)
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('获取题目列表错误:', error)
|
||||
setDataSource([])
|
||||
setTotal(0)
|
||||
if (error.response?.status >= 500 || !error.response) {
|
||||
message.error('服务器错误,请稍后重试')
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [page, pageSize, query, typeFilter])
|
||||
|
||||
// 获取知识树(用于选择)
|
||||
const fetchKnowledgeTree = async () => {
|
||||
try {
|
||||
const res = await getKnowledgeTree('objective')
|
||||
if (res.data.code === 200) {
|
||||
const tree = res.data.data?.tree || []
|
||||
setKnowledgeTreeData(tree)
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('获取知识树错误:', error)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (modalVisible) {
|
||||
fetchKnowledgeTree()
|
||||
}
|
||||
}, [modalVisible])
|
||||
|
||||
// 将知识树转换为 TreeSelect 格式:有下级的节点展开但不可选,仅最低层级(叶子)可选
|
||||
const convertKnowledgeTreeToTreeData = (nodes: KnowledgeTreeNode[]): any[] => {
|
||||
return nodes.map((node) => {
|
||||
const children = node.children ?? []
|
||||
const hasChildren = children.length > 0
|
||||
return {
|
||||
title: node.title,
|
||||
value: node.id,
|
||||
key: node.id,
|
||||
disabled: hasChildren, // 有下级则不可选,只有叶子节点可选
|
||||
children: hasChildren ? convertKnowledgeTreeToTreeData(children) : undefined,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 监听 Modal 打开,设置表单初始值
|
||||
useEffect(() => {
|
||||
if (!modalVisible) return
|
||||
|
||||
// 先重置表单,清除所有字段
|
||||
form.resetFields()
|
||||
|
||||
// 使用 requestAnimationFrame 确保 DOM 更新完成后再设置值
|
||||
requestAnimationFrame(() => {
|
||||
if (editingRecord) {
|
||||
console.log('编辑题目数据:', editingRecord)
|
||||
console.log('知识树IDs:', editingRecord.knowledgeTreeIds)
|
||||
|
||||
// 处理选项格式(兼容旧格式和新格式)
|
||||
let options: QuestionOption[] = []
|
||||
if (Array.isArray(editingRecord.options)) {
|
||||
if (editingRecord.options.length > 0) {
|
||||
// 判断是新格式还是旧格式
|
||||
const firstOption = editingRecord.options[0]
|
||||
if (typeof firstOption === 'object' && 'value' in firstOption) {
|
||||
// 新格式:确保每个选项都有正确的结构
|
||||
options = (editingRecord.options as QuestionOption[])
|
||||
.filter(opt => opt && typeof opt === 'object')
|
||||
.map(opt => ({
|
||||
value: opt.value || '',
|
||||
isCorrect: opt.isCorrect || false,
|
||||
}))
|
||||
} else {
|
||||
// 旧格式:字符串数组,需要转换为新格式
|
||||
const answer = editingRecord.answer || ''
|
||||
options = (editingRecord.options as string[])
|
||||
.filter(opt => opt !== null && opt !== undefined)
|
||||
.map((opt, index) => ({
|
||||
value: opt || '',
|
||||
isCorrect: answer.includes(String.fromCharCode(65 + index)) || answer === opt,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const knowledgeTreeIds = Array.isArray(editingRecord.knowledgeTreeIds)
|
||||
? editingRecord.knowledgeTreeIds.filter(id => id) // 过滤掉空值
|
||||
: []
|
||||
|
||||
console.log('设置知识树IDs到表单:', knowledgeTreeIds)
|
||||
|
||||
form.setFieldsValue({
|
||||
type: editingRecord.type || QuestionType.SINGLE_CHOICE,
|
||||
name: editingRecord.name || '',
|
||||
source: editingRecord.source || '',
|
||||
materialId: editingRecord.materialId || '',
|
||||
useMaterial: !!editingRecord.materialId,
|
||||
content: editingRecord.content || '',
|
||||
options: options.length > 0 ? options : [],
|
||||
answer: editingRecord.answer || '',
|
||||
explanation: editingRecord.explanation || '',
|
||||
knowledgeTreeIds: knowledgeTreeIds,
|
||||
})
|
||||
const hasMaterial = !!editingRecord.materialId
|
||||
setMaterialSearchVisible(hasMaterial)
|
||||
// 如果编辑的题目已关联材料,则自动加载一次材料列表,
|
||||
// 这样材料下拉框会显示材料名称而不是纯 ID
|
||||
if (hasMaterial) {
|
||||
handleMaterialSearch('')
|
||||
}
|
||||
} else {
|
||||
// 新增模式
|
||||
form.setFieldsValue({
|
||||
type: QuestionType.SINGLE_CHOICE,
|
||||
useMaterial: false,
|
||||
knowledgeTreeIds: [],
|
||||
options: [
|
||||
{ value: 'A选项', isCorrect: false },
|
||||
{ value: 'B选项', isCorrect: false },
|
||||
{ value: 'C选项', isCorrect: false },
|
||||
{ value: 'D选项', isCorrect: false },
|
||||
],
|
||||
})
|
||||
setMaterialSearchVisible(false)
|
||||
}
|
||||
})
|
||||
}, [modalVisible, editingRecord, form])
|
||||
|
||||
// 当知识树数据加载完成后,重新设置知识树字段的值(确保 TreeSelect 能正确显示)
|
||||
useEffect(() => {
|
||||
if (modalVisible && editingRecord && knowledgeTreeData.length > 0) {
|
||||
const knowledgeTreeIds = Array.isArray(editingRecord.knowledgeTreeIds)
|
||||
? editingRecord.knowledgeTreeIds.filter(id => id)
|
||||
: []
|
||||
if (knowledgeTreeIds.length > 0) {
|
||||
// 使用 setTimeout 确保 TreeSelect 已经渲染完成
|
||||
setTimeout(() => {
|
||||
form.setFieldValue('knowledgeTreeIds', knowledgeTreeIds)
|
||||
console.log('重新设置知识树IDs:', knowledgeTreeIds)
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
}, [modalVisible, editingRecord, knowledgeTreeData, form])
|
||||
|
||||
// 监听题目类型变化
|
||||
const handleTypeChange = (type: QuestionType) => {
|
||||
if (type === QuestionType.SINGLE_CHOICE || type === QuestionType.MULTIPLE_CHOICE) {
|
||||
// 单选题/多选题默认4个选项
|
||||
form.setFieldsValue({
|
||||
options: [
|
||||
{ value: 'A选项', isCorrect: false },
|
||||
{ value: 'B选项', isCorrect: false },
|
||||
{ value: 'C选项', isCorrect: false },
|
||||
{ value: 'D选项', isCorrect: false },
|
||||
]
|
||||
})
|
||||
} else if (type === QuestionType.TRUE_FALSE) {
|
||||
// 判断题默认2个选项
|
||||
form.setFieldsValue({
|
||||
options: [
|
||||
{ value: '正确', isCorrect: false },
|
||||
{ value: '错误', isCorrect: false },
|
||||
]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 监听是否使用材料
|
||||
const handleUseMaterialChange = (checked: boolean) => {
|
||||
setMaterialSearchVisible(checked)
|
||||
if (!checked) {
|
||||
form.setFieldsValue({ materialId: '' })
|
||||
setMaterialList([])
|
||||
} else {
|
||||
// 如果勾选使用材料,自动搜索一次
|
||||
handleMaterialSearch('')
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索材料
|
||||
const handleMaterialSearch = async (query: string) => {
|
||||
setMaterialSearchLoading(true)
|
||||
try {
|
||||
const res = await searchMaterials({
|
||||
query: query || undefined,
|
||||
type: MaterialType.OBJECTIVE,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
})
|
||||
if (res.data.code === 200) {
|
||||
const list = res.data.data.list || []
|
||||
setMaterialList(list)
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('搜索材料失败:', error)
|
||||
message.error('搜索材料失败')
|
||||
setMaterialList([])
|
||||
} finally {
|
||||
setMaterialSearchLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 打开新增/编辑弹窗
|
||||
const handleOpenModal = (record?: Question) => {
|
||||
if (record) {
|
||||
setEditingRecord(record)
|
||||
} else {
|
||||
setEditingRecord(null)
|
||||
form.resetFields()
|
||||
// 设置默认值
|
||||
form.setFieldsValue({
|
||||
type: QuestionType.SINGLE_CHOICE,
|
||||
useMaterial: false,
|
||||
knowledgeTreeIds: [],
|
||||
options: [
|
||||
{ value: 'A选项', isCorrect: false },
|
||||
{ value: 'B选项', isCorrect: false },
|
||||
{ value: 'C选项', isCorrect: false },
|
||||
{ value: 'D选项', isCorrect: false },
|
||||
],
|
||||
})
|
||||
setMaterialSearchVisible(false)
|
||||
}
|
||||
setModalVisible(true)
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields()
|
||||
|
||||
// 处理选项格式:提取正确答案
|
||||
const options = Array.isArray(values.options)
|
||||
? values.options.map((opt: QuestionOption) => opt.value).filter((v: string) => v)
|
||||
: []
|
||||
|
||||
// 从选项中提取正确答案
|
||||
let answer = ''
|
||||
if (Array.isArray(values.options)) {
|
||||
const correctOptions = values.options
|
||||
.map((opt: QuestionOption, index: number) => {
|
||||
if (opt.isCorrect) {
|
||||
// 单选题:返回选项字母;多选题:返回多个字母;判断题:返回选项内容
|
||||
if (questionType === QuestionType.SINGLE_CHOICE || questionType === QuestionType.MULTIPLE_CHOICE) {
|
||||
return String.fromCharCode(65 + index)
|
||||
} else {
|
||||
return opt.value
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
.filter((v: string | null) => v !== null)
|
||||
|
||||
answer = correctOptions.join(questionType === QuestionType.MULTIPLE_CHOICE ? ',' : '')
|
||||
}
|
||||
|
||||
const formData = {
|
||||
...values,
|
||||
options,
|
||||
answer,
|
||||
knowledgeTreeIds: Array.isArray(values.knowledgeTreeIds) ? values.knowledgeTreeIds : [],
|
||||
materialId: values.useMaterial ? values.materialId : undefined,
|
||||
}
|
||||
|
||||
// 删除 useMaterial 字段(不需要提交到后端)
|
||||
delete formData.useMaterial
|
||||
|
||||
console.log('提交表单数据:', formData)
|
||||
|
||||
let updatedQuestionId: string | null = null
|
||||
if (editingRecord) {
|
||||
// 编辑
|
||||
const updateData = { ...editingRecord, ...formData }
|
||||
updatedQuestionId = updateData.id
|
||||
console.log('更新题目数据:', updateData)
|
||||
console.log('更新题目知识树IDs:', updateData.knowledgeTreeIds)
|
||||
await updateQuestion(updateData)
|
||||
message.success('编辑题目成功')
|
||||
} else {
|
||||
// 新增
|
||||
console.log('创建题目数据:', formData)
|
||||
await createQuestion(formData)
|
||||
message.success('新增题目成功')
|
||||
}
|
||||
|
||||
setModalVisible(false)
|
||||
setEditingRecord(null)
|
||||
form.resetFields()
|
||||
// 延迟一下再刷新,确保后端数据已更新
|
||||
setTimeout(async () => {
|
||||
await fetchData()
|
||||
// 如果更新了题目,检查获取到的数据
|
||||
if (updatedQuestionId) {
|
||||
// 使用 setTimeout 确保状态已更新
|
||||
setTimeout(() => {
|
||||
const updatedQuestion = dataSource.find(q => q.id === updatedQuestionId)
|
||||
if (updatedQuestion) {
|
||||
console.log('刷新后获取到的题目数据:', updatedQuestion)
|
||||
console.log('刷新后获取到的知识树IDs:', updatedQuestion.knowledgeTreeIds)
|
||||
} else {
|
||||
console.warn('未找到更新后的题目,ID:', updatedQuestionId)
|
||||
}
|
||||
}, 50)
|
||||
}
|
||||
}, 100)
|
||||
} catch (error: any) {
|
||||
console.error('提交表单错误:', error)
|
||||
const errorMsg = error?.response?.data?.message || error?.message || '操作失败,请检查网络连接或联系管理员'
|
||||
message.error(errorMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// 删除题目
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteQuestion(id)
|
||||
message.success('删除成功')
|
||||
fetchData()
|
||||
} catch (error: any) {
|
||||
const errorMsg = error?.message || '删除失败'
|
||||
message.error(errorMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索
|
||||
const handleSearch = (value: string) => {
|
||||
setQuery(value)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
// 题目类型标签
|
||||
const getQuestionTypeTag = (type: QuestionType | undefined) => {
|
||||
const tagMap: Record<number, { color: string; text: string }> = {
|
||||
[QuestionType.SINGLE_CHOICE]: { color: 'green', text: '单选题' },
|
||||
[QuestionType.MULTIPLE_CHOICE]: { color: 'orange', text: '多选题' },
|
||||
[QuestionType.TRUE_FALSE]: { color: 'purple', text: '判断题' },
|
||||
}
|
||||
const config = tagMap[type ?? QuestionType.SINGLE_CHOICE]
|
||||
if (!config) {
|
||||
return <Tag color="default">未知</Tag>
|
||||
}
|
||||
return <Tag color={config.color}>{config.text}</Tag>
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Question> = [
|
||||
{
|
||||
title: '题目类型',
|
||||
dataIndex: 'type',
|
||||
key: 'type',
|
||||
width: 100,
|
||||
render: getQuestionTypeTag,
|
||||
},
|
||||
{
|
||||
title: '题目名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: 150,
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '题目出处',
|
||||
dataIndex: 'source',
|
||||
key: 'source',
|
||||
width: 150,
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '答案',
|
||||
dataIndex: 'answer',
|
||||
key: 'answer',
|
||||
width: 100,
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '知识树',
|
||||
dataIndex: 'knowledgeTreeNames',
|
||||
key: 'knowledgeTreeNames',
|
||||
width: 200,
|
||||
render: (knowledgeTreeNames: string[] | undefined) => {
|
||||
if (!knowledgeTreeNames || knowledgeTreeNames.length === 0) {
|
||||
return <span style={{ color: '#999' }}>未关联</span>
|
||||
}
|
||||
const names = knowledgeTreeNames.filter((n) => !!n)
|
||||
if (names.length === 0) {
|
||||
return <span style={{ color: '#999' }}>未关联</span>
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{names.slice(0, 2).map((name) => (
|
||||
<Tag key={name}>{name}</Tag>
|
||||
))}
|
||||
{names.length > 2 && <span>+{names.length - 2}</span>}
|
||||
</>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
width: 180,
|
||||
render: (timestamp: number) => {
|
||||
if (!timestamp) return '-'
|
||||
return dayjs(timestamp * 1000).format('YYYY-MM-DD HH:mm')
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 180,
|
||||
fixed: 'right',
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => handleOpenModal(record)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确定要删除这道题目吗?"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
// 是否显示选项字段(客观题都需要)
|
||||
const needOptions = questionType === QuestionType.SINGLE_CHOICE ||
|
||||
questionType === QuestionType.MULTIPLE_CHOICE ||
|
||||
questionType === QuestionType.TRUE_FALSE
|
||||
|
||||
// 是否允许多选(多选题)
|
||||
const allowMultiple = questionType === QuestionType.MULTIPLE_CHOICE
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
{/* 页面标题 */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 600 }}>客观题管理 - 题目管理</h2>
|
||||
<div style={{ color: '#666', fontSize: 14, marginTop: 8 }}>
|
||||
管理客观题(单选题、多选题、判断题)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Space wrap>
|
||||
<Input.Search
|
||||
placeholder="搜索题目名称、标题或内容"
|
||||
allowClear
|
||||
style={{ width: 300 }}
|
||||
onSearch={handleSearch}
|
||||
/>
|
||||
<Select
|
||||
placeholder="题目类型"
|
||||
allowClear
|
||||
style={{ width: 150 }}
|
||||
value={typeFilter}
|
||||
onChange={(value) => {
|
||||
setTypeFilter(value)
|
||||
setPage(1)
|
||||
}}
|
||||
options={[
|
||||
{ label: '单选题', value: QuestionType.SINGLE_CHOICE },
|
||||
{ label: '多选题', value: QuestionType.MULTIPLE_CHOICE },
|
||||
{ label: '判断题', value: QuestionType.TRUE_FALSE },
|
||||
]}
|
||||
/>
|
||||
<Button icon={<ReloadOutlined />} onClick={fetchData}>
|
||||
刷新
|
||||
</Button>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => handleOpenModal()}>
|
||||
新增题目
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={dataSource}
|
||||
rowKey="id"
|
||||
scroll={{ x: 1200 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 道题`,
|
||||
onChange: (page, pageSize) => {
|
||||
setPage(page)
|
||||
setPageSize(pageSize)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editingRecord ? '编辑题目' : '新增题目'}
|
||||
open={modalVisible}
|
||||
onOk={handleSubmit}
|
||||
onCancel={() => {
|
||||
setModalVisible(false)
|
||||
setEditingRecord(null)
|
||||
form.resetFields()
|
||||
setMaterialSearchVisible(false)
|
||||
}}
|
||||
width={900}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
label="题目类型"
|
||||
name="type"
|
||||
rules={[{ required: true, message: '请选择题目类型' }]}
|
||||
initialValue={QuestionType.SINGLE_CHOICE}
|
||||
>
|
||||
<Select
|
||||
placeholder="请选择题目类型"
|
||||
onChange={handleTypeChange}
|
||||
options={[
|
||||
{ label: '单选题', value: QuestionType.SINGLE_CHOICE },
|
||||
{ label: '多选题', value: QuestionType.MULTIPLE_CHOICE },
|
||||
{ label: '判断题', value: QuestionType.TRUE_FALSE },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="题目名称" name="name" tooltip="用于搜索和组卷,不在前端显示">
|
||||
<Input placeholder="请输入题目名称(可选)" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="题目出处" name="source">
|
||||
<Input placeholder="请输入题目出处(可选)" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="调用材料" name="useMaterial" valuePropName="checked" initialValue={false}>
|
||||
<Switch checkedChildren="使用" unCheckedChildren="不使用" onChange={handleUseMaterialChange} />
|
||||
</Form.Item>
|
||||
|
||||
{materialSearchVisible && (
|
||||
<>
|
||||
<Form.Item label="材料搜索" name="materialId">
|
||||
<Select
|
||||
showSearch
|
||||
placeholder="搜索并选择材料"
|
||||
filterOption={false}
|
||||
onSearch={handleMaterialSearch}
|
||||
loading={materialSearchLoading}
|
||||
notFoundContent={materialSearchLoading ? '搜索中...' : '暂无材料'}
|
||||
options={materialList.map((material) => ({
|
||||
label: material.name || `材料 ${material.id.substring(0, 8)}`,
|
||||
value: material.id,
|
||||
title: material.name || material.id,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
{form.getFieldValue('materialId') && (
|
||||
<div style={{ marginBottom: 16, padding: 12, backgroundColor: '#f5f5f5', borderRadius: 4 }}>
|
||||
<div style={{ fontSize: 12, color: '#666', marginBottom: 4 }}>已选择材料</div>
|
||||
<div style={{ fontSize: 14 }}>
|
||||
{materialList.find((m) => m.id === form.getFieldValue('materialId'))?.name ||
|
||||
`材料 ${form.getFieldValue('materialId')?.substring(0, 8)}`}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Form.Item label="题干" name="content" rules={[{ required: true, message: '请输入题干' }]}>
|
||||
<RichTextEditor
|
||||
key={`content-${editingRecord?.id || 'new'}`}
|
||||
placeholder="请输入题干,支持插入图片"
|
||||
rows={6}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{needOptions && (
|
||||
<Form.Item label="选项" required>
|
||||
<Form.List name="options">
|
||||
{(fields, { add, remove }) => (
|
||||
<>
|
||||
{fields.map((field, index) => {
|
||||
// 从 field 中提取 key,避免通过 spread 传递 key
|
||||
const { key, ...fieldProps } = field
|
||||
return (
|
||||
<Space key={key} style={{ display: 'flex', marginBottom: 8 }} align="baseline">
|
||||
<span style={{ width: 30, textAlign: 'center', fontWeight: 600 }}>
|
||||
{String.fromCharCode(65 + index)}
|
||||
</span>
|
||||
<Form.Item
|
||||
{...fieldProps}
|
||||
name={[field.name, 'value']}
|
||||
rules={[{ required: true, message: '请输入选项内容' }]}
|
||||
style={{ marginBottom: 0, flex: 1 }}
|
||||
>
|
||||
<Input placeholder={`请输入选项${String.fromCharCode(65 + index)}`} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
{...fieldProps}
|
||||
name={[field.name, 'isCorrect']}
|
||||
valuePropName="checked"
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Checkbox
|
||||
onChange={(e) => {
|
||||
// 如果是单选题,取消其他选项的正确答案标记
|
||||
if (!allowMultiple && e.target.checked) {
|
||||
const options = form.getFieldValue('options') || []
|
||||
options.forEach((_: QuestionOption, idx: number) => {
|
||||
if (idx !== index) {
|
||||
form.setFieldValue(['options', idx, 'isCorrect'], false)
|
||||
}
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
正确答案
|
||||
</Checkbox>
|
||||
</Form.Item>
|
||||
{fields.length > 2 && (
|
||||
<MinusCircleOutlined
|
||||
style={{ color: '#ff4d4f', fontSize: 16 }}
|
||||
onClick={() => remove(field.name)}
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
)
|
||||
})}
|
||||
<Form.Item style={{ marginBottom: 0 }}>
|
||||
<Button
|
||||
type="dashed"
|
||||
onClick={() => add({ value: '', isCorrect: false })}
|
||||
block
|
||||
icon={<PlusOutlined />}
|
||||
>
|
||||
添加选项
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item label="解析" name="explanation">
|
||||
<RichTextEditor
|
||||
key={`explanation-${editingRecord?.id || 'new'}`}
|
||||
placeholder="请输入解析,支持插入图片"
|
||||
rows={4}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="关联知识树"
|
||||
name="knowledgeTreeIds"
|
||||
rules={[
|
||||
{
|
||||
validator: (_, value) => {
|
||||
if (value && value.length > 4) {
|
||||
return Promise.reject(new Error('最多只能选择4个知识树'))
|
||||
}
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
]}
|
||||
extra="有下级的节点会展开,仅最低层级(叶子节点)可选,最多选4个"
|
||||
>
|
||||
<TreeSelect
|
||||
key={editingRecord?.id || 'new'} // 使用 key 确保编辑不同题目时重新渲染
|
||||
treeData={convertKnowledgeTreeToTreeData(knowledgeTreeData)}
|
||||
placeholder="搜索并选择知识树(仅叶子节点可选,最多4个)"
|
||||
treeCheckable
|
||||
showCheckedStrategy="SHOW_ALL"
|
||||
allowClear
|
||||
multiple
|
||||
showSearch
|
||||
treeNodeFilterProp="title"
|
||||
filterTreeNode={(input, treeNode) => {
|
||||
const title = treeNode.title as string
|
||||
return title?.toLowerCase().includes(input.toLowerCase())
|
||||
}}
|
||||
maxTagCount={4}
|
||||
onChange={(value) => {
|
||||
// 限制最多选择4个
|
||||
if (value && value.length > 4) {
|
||||
message.warning('最多只能选择4个知识树')
|
||||
// 只保留前4个
|
||||
form.setFieldValue('knowledgeTreeIds', value.slice(0, 4))
|
||||
}
|
||||
}}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ObjectiveQuestionList
|
||||
|
||||
17
src/pages/Subjective/KnowledgeTree.tsx
Normal file
17
src/pages/Subjective/KnowledgeTree.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import KnowledgeTree from '@/components/KnowledgeTree'
|
||||
|
||||
const SubjectiveKnowledgeTree = () => {
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 600 }}>主观题管理 - 知识树</h2>
|
||||
<div style={{ color: '#666', fontSize: 14, marginTop: 8 }}>
|
||||
管理主观题的知识树结构
|
||||
</div>
|
||||
</div>
|
||||
<KnowledgeTree type="subjective" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SubjectiveKnowledgeTree
|
||||
369
src/pages/Subjective/MaterialList.tsx
Normal file
369
src/pages/Subjective/MaterialList.tsx
Normal file
|
|
@ -0,0 +1,369 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
message,
|
||||
Space,
|
||||
Popconfirm,
|
||||
} from 'antd'
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, ReloadOutlined, EyeOutlined } from '@ant-design/icons'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import type { Material } from '@/types/question'
|
||||
import { MaterialType } from '@/types/question'
|
||||
import { searchMaterials, createMaterial, updateMaterial, deleteMaterial } from '@/api/question'
|
||||
import { DEFAULT_PAGE_SIZE } from '@/constants'
|
||||
import dayjs from 'dayjs'
|
||||
import RichTextEditor from '@/components/RichTextEditor/RichTextEditor'
|
||||
|
||||
const MaterialList = () => {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [dataSource, setDataSource] = useState<Material[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE)
|
||||
const [query, setQuery] = useState('')
|
||||
const [modalVisible, setModalVisible] = useState(false)
|
||||
const [previewVisible, setPreviewVisible] = useState(false)
|
||||
const [editingRecord, setEditingRecord] = useState<Material | null>(null)
|
||||
const [previewContent, setPreviewContent] = useState('')
|
||||
const [previewTitle, setPreviewTitle] = useState('')
|
||||
|
||||
const [form] = Form.useForm()
|
||||
|
||||
// 获取列表数据
|
||||
const fetchData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await searchMaterials({
|
||||
query,
|
||||
type: MaterialType.SUBJECTIVE,
|
||||
page,
|
||||
pageSize,
|
||||
})
|
||||
if (res.data.code === 200) {
|
||||
const list = res.data.data.list || []
|
||||
const total = res.data.data.total || 0
|
||||
setDataSource(list)
|
||||
setTotal(total)
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('获取材料列表错误:', error)
|
||||
setDataSource([])
|
||||
setTotal(0)
|
||||
if (error.response?.status >= 500 || !error.response) {
|
||||
message.error('服务器错误,请稍后重试')
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [page, pageSize, query])
|
||||
|
||||
// 监听 Modal 打开,设置表单初始值
|
||||
useEffect(() => {
|
||||
if (modalVisible && editingRecord) {
|
||||
console.log('编辑材料数据:', editingRecord)
|
||||
form.setFieldsValue({
|
||||
name: editingRecord.name || '',
|
||||
content: editingRecord.content || '',
|
||||
})
|
||||
}
|
||||
}, [modalVisible, editingRecord, form])
|
||||
|
||||
// 打开新增/编辑弹窗
|
||||
const handleOpenModal = (record?: Material) => {
|
||||
if (record) {
|
||||
setEditingRecord(record)
|
||||
} else {
|
||||
setEditingRecord(null)
|
||||
form.resetFields()
|
||||
form.setFieldsValue({
|
||||
name: '',
|
||||
content: '',
|
||||
})
|
||||
}
|
||||
setModalVisible(true)
|
||||
}
|
||||
|
||||
// 预览材料内容
|
||||
const handlePreview = (record: Material) => {
|
||||
setPreviewContent(record.content)
|
||||
setPreviewTitle(record.name || '未命名')
|
||||
setPreviewVisible(true)
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields()
|
||||
|
||||
const formData = {
|
||||
type: MaterialType.SUBJECTIVE,
|
||||
name: values.name || '',
|
||||
content: values.content || '',
|
||||
}
|
||||
|
||||
if (editingRecord) {
|
||||
// 编辑
|
||||
await updateMaterial({ ...editingRecord, ...formData })
|
||||
message.success('编辑材料成功')
|
||||
} else {
|
||||
// 新增
|
||||
await createMaterial(formData)
|
||||
message.success('新增材料成功')
|
||||
}
|
||||
|
||||
setModalVisible(false)
|
||||
setEditingRecord(null)
|
||||
form.resetFields()
|
||||
fetchData()
|
||||
} catch (error: any) {
|
||||
const errorMsg = error?.message || '操作失败'
|
||||
message.error(errorMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// 删除材料
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteMaterial(id)
|
||||
message.success('删除成功')
|
||||
fetchData()
|
||||
} catch (error: any) {
|
||||
const errorMsg = error?.message || '删除失败'
|
||||
message.error(errorMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索
|
||||
const handleSearch = (value: string) => {
|
||||
setQuery(value)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Material> = [
|
||||
{
|
||||
title: '材料名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: 200,
|
||||
ellipsis: true,
|
||||
render: (name: string) => name || <span style={{ color: '#999' }}>未命名</span>,
|
||||
},
|
||||
{
|
||||
title: '材料内容',
|
||||
dataIndex: 'content',
|
||||
key: 'content',
|
||||
width: 400,
|
||||
ellipsis: true,
|
||||
render: (content: string) => {
|
||||
// 移除 HTML 标签,只显示纯文本
|
||||
const textContent = content
|
||||
.replace(/<img[^>]*>/gi, '[图片]')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/\n/g, ' ')
|
||||
.trim()
|
||||
return textContent || <span style={{ color: '#999' }}>无内容</span>
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
width: 180,
|
||||
render: (timestamp: number) => {
|
||||
if (!timestamp) return '-'
|
||||
return dayjs(timestamp * 1000).format('YYYY-MM-DD HH:mm')
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '更新时间',
|
||||
dataIndex: 'updatedAt',
|
||||
key: 'updatedAt',
|
||||
width: 180,
|
||||
render: (timestamp: number) => {
|
||||
if (!timestamp) return '-'
|
||||
return dayjs(timestamp * 1000).format('YYYY-MM-DD HH:mm')
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 200,
|
||||
fixed: 'right',
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => handlePreview(record)}
|
||||
>
|
||||
预览
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleOpenModal(record)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确定要删除这个材料吗?"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
{/* 页面标题 */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 600 }}>主观题管理 - 材料管理</h2>
|
||||
<div style={{ color: '#666', fontSize: 14, marginTop: 8 }}>
|
||||
管理主观题材料,材料可用于组卷中
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Space wrap>
|
||||
<Input.Search
|
||||
placeholder="搜索材料名称或内容"
|
||||
allowClear
|
||||
style={{ width: 300 }}
|
||||
onSearch={handleSearch}
|
||||
/>
|
||||
<Button icon={<ReloadOutlined />} onClick={fetchData}>
|
||||
刷新
|
||||
</Button>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => handleOpenModal()}>
|
||||
新增材料
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={dataSource}
|
||||
rowKey="id"
|
||||
scroll={{ x: 1200 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 个材料`,
|
||||
onChange: (page, pageSize) => {
|
||||
setPage(page)
|
||||
setPageSize(pageSize)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 新增/编辑弹窗 */}
|
||||
<Modal
|
||||
title={editingRecord ? '编辑材料' : '新增材料'}
|
||||
open={modalVisible}
|
||||
onOk={handleSubmit}
|
||||
onCancel={() => {
|
||||
setModalVisible(false)
|
||||
setEditingRecord(null)
|
||||
form.resetFields()
|
||||
}}
|
||||
width={900}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
label="材料名称"
|
||||
name="name"
|
||||
rules={[{ required: true, message: '请输入材料名称' }]}
|
||||
>
|
||||
<Input placeholder="请输入材料名称" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="材料内容"
|
||||
name="content"
|
||||
rules={[{ required: true, message: '请输入材料内容' }]}
|
||||
>
|
||||
<RichTextEditor placeholder="请输入材料内容,支持插入图片和文字格式" rows={10} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 预览弹窗 */}
|
||||
<Modal
|
||||
title="材料预览"
|
||||
open={previewVisible}
|
||||
onCancel={() => setPreviewVisible(false)}
|
||||
footer={[
|
||||
<Button key="close" onClick={() => setPreviewVisible(false)}>
|
||||
关闭
|
||||
</Button>,
|
||||
]}
|
||||
width={900}
|
||||
>
|
||||
<div style={{ fontSize: 16, fontWeight: 600, marginBottom: 12 }}>
|
||||
{previewTitle}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
maxHeight: '70vh',
|
||||
overflow: 'auto',
|
||||
padding: '20px',
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 4,
|
||||
border: '1px solid #e8e8e8',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
wordBreak: 'break-word',
|
||||
lineHeight: '1.8',
|
||||
fontSize: '14px',
|
||||
color: '#333',
|
||||
}}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: (() => {
|
||||
let html = previewContent || ''
|
||||
if (!html) return ''
|
||||
|
||||
// react-quill 生成的 HTML 已经是完整的格式,直接使用
|
||||
// 只需要确保图片标签有正确的样式
|
||||
return html.replace(
|
||||
/<img([^>]*)>/gi,
|
||||
(match, attrs) => {
|
||||
if (!attrs.includes('style=')) {
|
||||
return `<img${attrs} style="max-width: 100%; height: auto; margin: 12px 0; border-radius: 4px; display: block;">`
|
||||
}
|
||||
return match
|
||||
}
|
||||
)
|
||||
})()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MaterialList
|
||||
1165
src/pages/Subjective/PaperList.tsx
Normal file
1165
src/pages/Subjective/PaperList.tsx
Normal file
File diff suppressed because it is too large
Load Diff
201
src/pages/Subjective/QuestionList.tsx
Normal file
201
src/pages/Subjective/QuestionList.tsx
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Table, Button, Input, message, Space, Popconfirm } from 'antd'
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import type { Question } from '@/types/question'
|
||||
import { QuestionType } from '@/types/question'
|
||||
import { searchQuestions, deleteQuestion } from '@/api/question'
|
||||
import { DEFAULT_PAGE_SIZE } from '@/constants'
|
||||
import dayjs from 'dayjs'
|
||||
import SubjectiveQuestionForm from '@/components/SubjectiveQuestionForm'
|
||||
|
||||
const SubjectiveQuestionList = () => {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [dataSource, setDataSource] = useState<Question[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE)
|
||||
const [query, setQuery] = useState('')
|
||||
const [modalVisible, setModalVisible] = useState(false)
|
||||
const [editingRecord, setEditingRecord] = useState<Question | null>(null)
|
||||
|
||||
// 获取主观题列表
|
||||
const fetchData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await searchQuestions({
|
||||
query,
|
||||
type: QuestionType.SUBJECTIVE,
|
||||
page,
|
||||
pageSize,
|
||||
})
|
||||
if (res.data.code === 200) {
|
||||
const list = res.data.data.list || []
|
||||
const subjectiveList = list.filter((q: Question) => q.type === QuestionType.SUBJECTIVE)
|
||||
setDataSource(subjectiveList)
|
||||
setTotal(res.data.data.total || subjectiveList.length || 0)
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('获取题目列表错误:', error)
|
||||
setDataSource([])
|
||||
setTotal(0)
|
||||
if (error.response?.status >= 500 || !error.response) {
|
||||
message.error('服务器错误,请稍后重试')
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [page, pageSize, query])
|
||||
|
||||
// 打开新增/编辑弹窗
|
||||
const handleOpenModal = (record?: Question) => {
|
||||
if (record) {
|
||||
setEditingRecord(record)
|
||||
} else {
|
||||
setEditingRecord(null)
|
||||
}
|
||||
setModalVisible(true)
|
||||
}
|
||||
|
||||
// 题目创建/编辑成功回调
|
||||
const handleQuestionSuccess = () => {
|
||||
setModalVisible(false)
|
||||
setEditingRecord(null)
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 删除题目
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteQuestion(id)
|
||||
message.success('删除成功')
|
||||
fetchData()
|
||||
} catch (error: any) {
|
||||
const errorMsg = error?.message || '删除失败'
|
||||
message.error(errorMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索
|
||||
const handleSearch = (value: string) => {
|
||||
setQuery(value)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
|
||||
const columns: ColumnsType<Question> = [
|
||||
{
|
||||
title: '题目名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: 200,
|
||||
ellipsis: true,
|
||||
render: (name: string) => name || <span style={{ color: '#999' }}>未设置</span>,
|
||||
},
|
||||
{
|
||||
title: '题目出处',
|
||||
dataIndex: 'source',
|
||||
key: 'source',
|
||||
width: 150,
|
||||
ellipsis: true,
|
||||
render: (source: string) => source || <span style={{ color: '#999' }}>未设置</span>,
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
width: 180,
|
||||
render: (timestamp: number) => {
|
||||
if (!timestamp) return '-'
|
||||
return dayjs(timestamp * 1000).format('YYYY-MM-DD HH:mm')
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 180,
|
||||
fixed: 'right',
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => handleOpenModal(record)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确定要删除这个题目吗?"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
{/* 页面标题 */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 600 }}>主观题管理 - 题目管理</h2>
|
||||
<div style={{ color: '#666', fontSize: 14, marginTop: 8 }}>
|
||||
管理主观题题目,可按题目名称、内容搜索,并关联题型分类(知识树)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Space wrap>
|
||||
<Input.Search
|
||||
placeholder="搜索题目名称或内容"
|
||||
allowClear
|
||||
style={{ width: 300 }}
|
||||
onSearch={handleSearch}
|
||||
/>
|
||||
<Button icon={<ReloadOutlined />} onClick={fetchData}>
|
||||
刷新
|
||||
</Button>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => handleOpenModal()}>
|
||||
新增题目
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={dataSource}
|
||||
rowKey="id"
|
||||
scroll={{ x: 1200 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 道题目`,
|
||||
onChange: (page, pageSize) => {
|
||||
setPage(page)
|
||||
setPageSize(pageSize)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<SubjectiveQuestionForm
|
||||
visible={modalVisible}
|
||||
editingRecord={editingRecord}
|
||||
onCancel={() => {
|
||||
setModalVisible(false)
|
||||
setEditingRecord(null)
|
||||
}}
|
||||
onSuccess={handleQuestionSuccess}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SubjectiveQuestionList
|
||||
142
src/pages/User/UserList.tsx
Normal file
142
src/pages/User/UserList.tsx
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import { useState } from 'react'
|
||||
import { Table, Button, Space, Tag, Input } from 'antd'
|
||||
import { SearchOutlined, PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { UserRole } from '@/types'
|
||||
|
||||
interface UserData {
|
||||
id: string
|
||||
username: string
|
||||
email: string
|
||||
role: UserRole
|
||||
status: 'active' | 'inactive'
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
const UserList = () => {
|
||||
const [searchText, setSearchText] = useState('')
|
||||
|
||||
// 模拟数据
|
||||
const mockData: UserData[] = [
|
||||
{
|
||||
id: '1',
|
||||
username: 'admin',
|
||||
email: 'admin@example.com',
|
||||
role: UserRole.ADMIN,
|
||||
status: 'active',
|
||||
createdAt: '2024-01-01',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
username: 'user1',
|
||||
email: 'user1@example.com',
|
||||
role: UserRole.USER,
|
||||
status: 'active',
|
||||
createdAt: '2024-01-15',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
username: 'user2',
|
||||
email: 'user2@example.com',
|
||||
role: UserRole.USER,
|
||||
status: 'inactive',
|
||||
createdAt: '2024-02-01',
|
||||
},
|
||||
]
|
||||
|
||||
const columns: ColumnsType<UserData> = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: '用户名',
|
||||
dataIndex: 'username',
|
||||
key: 'username',
|
||||
},
|
||||
{
|
||||
title: '邮箱',
|
||||
dataIndex: 'email',
|
||||
key: 'email',
|
||||
},
|
||||
{
|
||||
title: '角色',
|
||||
dataIndex: 'role',
|
||||
key: 'role',
|
||||
render: (role: UserRole) => {
|
||||
const colorMap = {
|
||||
[UserRole.ADMIN]: 'red',
|
||||
[UserRole.USER]: 'blue',
|
||||
[UserRole.GUEST]: 'default',
|
||||
}
|
||||
return <Tag color={colorMap[role]}>{role}</Tag>
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (status: string) => (
|
||||
<Tag color={status === 'active' ? 'success' : 'default'}>
|
||||
{status === 'active' ? '启用' : '禁用'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
render: () => (
|
||||
<Space size="middle">
|
||||
<Button type="link" icon={<EditOutlined />} size="small">
|
||||
编辑
|
||||
</Button>
|
||||
<Button type="link" danger icon={<DeleteOutlined />} size="small">
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Space>
|
||||
<Input
|
||||
placeholder="搜索用户名或邮箱"
|
||||
prefix={<SearchOutlined />}
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
style={{ width: 250 }}
|
||||
/>
|
||||
<Button type="primary">搜索</Button>
|
||||
</Space>
|
||||
<Button type="primary" icon={<PlusOutlined />}>
|
||||
新增用户
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={mockData}
|
||||
rowKey="id"
|
||||
pagination={{
|
||||
total: mockData.length,
|
||||
pageSize: 10,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UserList
|
||||
|
||||
28
src/routes/ProtectedRoute.tsx
Normal file
28
src/routes/ProtectedRoute.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { ReactNode } from 'react'
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import { hasPermission } from '@/utils/permission'
|
||||
import { RoutePath } from '@/constants'
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
/**
|
||||
* 需要的权限代码
|
||||
*/
|
||||
permission: string
|
||||
/**
|
||||
* 子组件
|
||||
*/
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* 路由权限保护组件
|
||||
* 如果用户没有权限,则重定向到首页
|
||||
*/
|
||||
export const ProtectedRoute = ({ permission, children }: ProtectedRouteProps) => {
|
||||
if (!hasPermission(permission)) {
|
||||
return <Navigate to={RoutePath.DASHBOARD} replace />
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
159
src/routes/index.tsx
Normal file
159
src/routes/index.tsx
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import { Navigate, useRoutes } from 'react-router-dom'
|
||||
import { useUserStore } from '@/store/useUserStore'
|
||||
import { RoutePath, STORAGE_KEYS } from '@/constants'
|
||||
import BasicLayout from '@/layouts/BasicLayout'
|
||||
import Login from '@/pages/Login'
|
||||
import Dashboard from '@/pages/Dashboard'
|
||||
import AdminUserList from '@/pages/Admin/AdminUserList'
|
||||
import RoleList from '@/pages/Admin/RoleList'
|
||||
import PermissionList from '@/pages/Admin/PermissionList'
|
||||
import CategoryList from '@/pages/Camp/CategoryList'
|
||||
import CampList from '@/pages/Camp/CampList'
|
||||
import SectionList from '@/pages/Camp/SectionList'
|
||||
import TaskList from '@/pages/Camp/TaskList'
|
||||
import UserProgressList from '@/pages/Camp/UserProgressList/index'
|
||||
import PendingTaskList from '@/pages/Camp/PendingTaskList'
|
||||
// 客观题管理
|
||||
import ObjectiveQuestionList from '@/pages/Objective/QuestionList'
|
||||
import ObjectiveMaterialList from '@/pages/Objective/MaterialList'
|
||||
import ObjectivePaperList from '@/pages/Objective/PaperList'
|
||||
import ObjectivePaperEdit from '@/pages/Objective/PaperEdit'
|
||||
import ObjectiveKnowledgeTree from '@/pages/Objective/KnowledgeTree'
|
||||
// 主观题管理
|
||||
import SubjectiveQuestionList from '@/pages/Subjective/QuestionList'
|
||||
import SubjectiveMaterialList from '@/pages/Subjective/MaterialList'
|
||||
import SubjectivePaperList from '@/pages/Subjective/PaperList'
|
||||
import SubjectiveKnowledgeTree from '@/pages/Subjective/KnowledgeTree'
|
||||
import DocumentList from '@/pages/Document/DocumentList'
|
||||
import NotFound from '@/pages/NotFound'
|
||||
|
||||
// 路由守卫组件
|
||||
const AuthRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
const { token } = useUserStore()
|
||||
|
||||
// 同时检查 store 中的 token 和 localStorage 中的 token
|
||||
// token 直接存储为字符串,不使用 JSON 序列化
|
||||
const localToken = localStorage.getItem(STORAGE_KEYS.TOKEN)
|
||||
const isAuthenticated = !!token || !!localToken
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to={RoutePath.LOGIN} replace />
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
const AppRoutes = () => {
|
||||
const routes = useRoutes([
|
||||
{
|
||||
path: RoutePath.LOGIN,
|
||||
element: <Login />,
|
||||
},
|
||||
{
|
||||
path: RoutePath.HOME,
|
||||
element: (
|
||||
<AuthRoute>
|
||||
<BasicLayout />
|
||||
</AuthRoute>
|
||||
),
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <Navigate to={RoutePath.DASHBOARD} replace />,
|
||||
},
|
||||
{
|
||||
path: RoutePath.DASHBOARD,
|
||||
element: <Dashboard />,
|
||||
},
|
||||
{
|
||||
path: RoutePath.USER_LIST,
|
||||
element: <AdminUserList />,
|
||||
},
|
||||
{
|
||||
path: RoutePath.ROLE_LIST,
|
||||
element: <RoleList />,
|
||||
},
|
||||
{
|
||||
path: RoutePath.PERMISSION_LIST,
|
||||
element: <PermissionList />,
|
||||
},
|
||||
// 打卡营管理路由
|
||||
{
|
||||
path: RoutePath.CAMP_CATEGORY_LIST,
|
||||
element: <CategoryList />,
|
||||
},
|
||||
{
|
||||
path: RoutePath.CAMP_LIST,
|
||||
element: <CampList />,
|
||||
},
|
||||
{
|
||||
path: RoutePath.CAMP_SECTION_LIST,
|
||||
element: <SectionList />,
|
||||
},
|
||||
{
|
||||
path: RoutePath.CAMP_TASK_LIST,
|
||||
element: <TaskList />,
|
||||
},
|
||||
{
|
||||
path: RoutePath.CAMP_PROGRESS_LIST,
|
||||
element: <UserProgressList />,
|
||||
},
|
||||
{
|
||||
path: RoutePath.CAMP_PENDING_TASK_LIST,
|
||||
element: <PendingTaskList />,
|
||||
},
|
||||
// 客观题管理路由
|
||||
{
|
||||
path: RoutePath.OBJECTIVE_QUESTION_LIST,
|
||||
element: <ObjectiveQuestionList />,
|
||||
},
|
||||
{
|
||||
path: RoutePath.OBJECTIVE_MATERIAL_LIST,
|
||||
element: <ObjectiveMaterialList />,
|
||||
},
|
||||
{
|
||||
path: RoutePath.OBJECTIVE_PAPER_LIST,
|
||||
element: <ObjectivePaperList />,
|
||||
},
|
||||
{
|
||||
path: RoutePath.OBJECTIVE_PAPER_EDIT,
|
||||
element: <ObjectivePaperEdit />,
|
||||
},
|
||||
{
|
||||
path: RoutePath.OBJECTIVE_KNOWLEDGE_TREE,
|
||||
element: <ObjectiveKnowledgeTree />,
|
||||
},
|
||||
// 主观题管理路由
|
||||
{
|
||||
path: RoutePath.SUBJECTIVE_QUESTION_LIST,
|
||||
element: <SubjectiveQuestionList />,
|
||||
},
|
||||
{
|
||||
path: RoutePath.SUBJECTIVE_MATERIAL_LIST,
|
||||
element: <SubjectiveMaterialList />,
|
||||
},
|
||||
{
|
||||
path: RoutePath.SUBJECTIVE_PAPER_LIST,
|
||||
element: <SubjectivePaperList />,
|
||||
},
|
||||
{
|
||||
path: RoutePath.SUBJECTIVE_KNOWLEDGE_TREE,
|
||||
element: <SubjectiveKnowledgeTree />,
|
||||
},
|
||||
{
|
||||
path: RoutePath.DOCUMENT_LIST,
|
||||
element: <DocumentList />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: RoutePath.NOT_FOUND,
|
||||
element: <NotFound />,
|
||||
},
|
||||
])
|
||||
|
||||
return routes
|
||||
}
|
||||
|
||||
export default AppRoutes
|
||||
|
||||
41
src/store/useUserStore.ts
Normal file
41
src/store/useUserStore.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { create } from 'zustand'
|
||||
import type { UserInfo } from '@/types'
|
||||
import { STORAGE_KEYS } from '@/constants'
|
||||
import { storage } from '@/utils/storage'
|
||||
|
||||
interface UserState {
|
||||
userInfo: UserInfo | null
|
||||
token: string | null
|
||||
setUserInfo: (userInfo: UserInfo) => void
|
||||
setToken: (token: string) => void
|
||||
logout: () => void
|
||||
isLogin: () => boolean
|
||||
}
|
||||
|
||||
export const useUserStore = create<UserState>((set, get) => ({
|
||||
userInfo: storage.get<UserInfo>(STORAGE_KEYS.USER_INFO),
|
||||
// token 直接读取,不使用 JSON 解析
|
||||
token: localStorage.getItem(STORAGE_KEYS.TOKEN),
|
||||
|
||||
setUserInfo: (userInfo: UserInfo) => {
|
||||
storage.set(STORAGE_KEYS.USER_INFO, userInfo)
|
||||
set({ userInfo })
|
||||
},
|
||||
|
||||
setToken: (token: string) => {
|
||||
// token 直接存储为字符串,不使用 JSON 序列化
|
||||
localStorage.setItem(STORAGE_KEYS.TOKEN, token)
|
||||
set({ token })
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
localStorage.removeItem(STORAGE_KEYS.TOKEN)
|
||||
storage.remove(STORAGE_KEYS.USER_INFO)
|
||||
set({ userInfo: null, token: null })
|
||||
},
|
||||
|
||||
isLogin: () => {
|
||||
return !!get().token
|
||||
},
|
||||
}))
|
||||
|
||||
227
src/types/camp.ts
Normal file
227
src/types/camp.ts
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
// 简介类型枚举
|
||||
export enum IntroType {
|
||||
NONE = 0, // 没有简介
|
||||
IMAGE_TEXT = 1, // 图文简介
|
||||
VIDEO = 2, // 视频简介
|
||||
}
|
||||
|
||||
// 时间间隔类型枚举
|
||||
export enum TimeIntervalType {
|
||||
NONE = 0, // 无时间限制
|
||||
HOUR = 1, // 小时间隔
|
||||
NATURAL_DAY = 2, // 自然天
|
||||
PAID = 3, // 收费(选中时展示价格,未选时价格默认 0)
|
||||
}
|
||||
|
||||
// 任务类型枚举
|
||||
export enum TaskType {
|
||||
UNKNOWN = 0, // 未知类型
|
||||
IMAGE_TEXT = 1, // 图文任务
|
||||
VIDEO = 2, // 视频任务
|
||||
SUBJECTIVE = 3, // 主观题任务
|
||||
OBJECTIVE = 4, // 客观题任务
|
||||
ESSAY = 5, // 申论题任务
|
||||
}
|
||||
|
||||
// 审核状态枚举
|
||||
export enum ReviewStatus {
|
||||
PENDING = 0, // 待审核
|
||||
APPROVED = 1, // 审核通过
|
||||
REJECTED = 2, // 审核拒绝
|
||||
}
|
||||
|
||||
// 推荐状态筛选枚举
|
||||
export enum RecommendFilter {
|
||||
ALL = 0, // 不筛选,返回所有
|
||||
ONLY_TRUE = 1, // 只返回推荐的
|
||||
ONLY_FALSE = 2, // 只返回非推荐的
|
||||
}
|
||||
|
||||
// 打卡营分类
|
||||
export interface Category {
|
||||
id: string
|
||||
name: string
|
||||
sortOrder: number
|
||||
}
|
||||
|
||||
// 打卡营
|
||||
export interface Camp {
|
||||
id: string
|
||||
title: string
|
||||
coverImage: string
|
||||
description: string
|
||||
introType: IntroType
|
||||
introContent: string
|
||||
categoryId: string
|
||||
isRecommended: boolean
|
||||
sectionCount: number
|
||||
}
|
||||
|
||||
// 小节
|
||||
export interface Section {
|
||||
id: string
|
||||
campId: string
|
||||
title: string
|
||||
sectionNumber: number
|
||||
priceFen: number
|
||||
requirePreviousSection: boolean
|
||||
timeIntervalType: TimeIntervalType
|
||||
timeIntervalValue: number
|
||||
}
|
||||
|
||||
// 图文内容
|
||||
export interface ImageTextContent {
|
||||
imageUrl: string
|
||||
textContent: string
|
||||
viewDurationSeconds: number
|
||||
}
|
||||
|
||||
// 视频内容
|
||||
export interface VideoContent {
|
||||
videoUrl: string
|
||||
completionPercentage: number
|
||||
}
|
||||
|
||||
// 主观题内容
|
||||
export interface SubjectiveContent {
|
||||
pdfUrl: string
|
||||
description: string
|
||||
}
|
||||
|
||||
// 申论题内容
|
||||
export interface EssayContent {
|
||||
examId: string
|
||||
description: string
|
||||
}
|
||||
|
||||
// 客观题内容
|
||||
export interface ObjectiveContent {
|
||||
examId: string
|
||||
correctRatePercentage: number
|
||||
}
|
||||
|
||||
// 任务内容
|
||||
export type TaskContent =
|
||||
| { type: 'imageText'; data: ImageTextContent }
|
||||
| { type: 'video'; data: VideoContent }
|
||||
| { type: 'subjective'; data: SubjectiveContent }
|
||||
| { type: 'objective'; data: ObjectiveContent }
|
||||
| { type: 'essay'; data: EssayContent }
|
||||
|
||||
// 图文条件
|
||||
export interface ImageTextCondition {
|
||||
viewDurationSeconds: number
|
||||
}
|
||||
|
||||
// 视频条件
|
||||
export interface VideoCondition {
|
||||
completionPercentage: number
|
||||
}
|
||||
|
||||
// 主观题条件
|
||||
export interface SubjectiveCondition {
|
||||
needReview: boolean
|
||||
reviewStatus: ReviewStatus
|
||||
/** 审核中是否允许开启下一任务,默认 true */
|
||||
allowNextWhileReviewing?: boolean
|
||||
}
|
||||
|
||||
// 申论题条件
|
||||
export interface EssayCondition {
|
||||
needReview: boolean
|
||||
reviewStatus: ReviewStatus
|
||||
/** 审核中是否允许开启下一任务,默认 true */
|
||||
allowNextWhileReviewing?: boolean
|
||||
}
|
||||
|
||||
// 客观题条件
|
||||
export interface ObjectiveCondition {
|
||||
correctRatePercentage: number
|
||||
}
|
||||
|
||||
// 任务条件
|
||||
export type TaskCondition =
|
||||
| { type: 'imageText'; data: ImageTextCondition }
|
||||
| { type: 'video'; data: VideoCondition }
|
||||
| { type: 'subjective'; data: SubjectiveCondition }
|
||||
| { type: 'objective'; data: ObjectiveCondition }
|
||||
| { type: 'essay'; data: EssayCondition }
|
||||
|
||||
// 任务
|
||||
export interface Task {
|
||||
id: string
|
||||
campId: string
|
||||
sectionId: string
|
||||
taskType: TaskType
|
||||
/** 任务标题(可选,用于展示) */
|
||||
title?: string
|
||||
/** 前置任务ID,需完成该任务后才能开启本任务(递进/解锁关系) */
|
||||
prerequisiteTaskId?: string
|
||||
content: TaskContent
|
||||
condition: TaskCondition
|
||||
}
|
||||
|
||||
// 用户进度
|
||||
export interface UserProgress {
|
||||
id: string
|
||||
userId: string
|
||||
taskId: string
|
||||
campId?: string
|
||||
sectionId?: string
|
||||
isCompleted: boolean
|
||||
completedAt: string
|
||||
reviewStatus: ReviewStatus
|
||||
reviewComment: string
|
||||
reviewImages: string[]
|
||||
answerImages?: string[]
|
||||
/** 申论题按题答案图片,每题为 string[] */
|
||||
essayAnswerImages?: string[][]
|
||||
/** 申论题每题审核状态,与 essayAnswerImages 一一对应;任务最终状态由所有题目状态汇总 */
|
||||
essayReviewStatuses?: ReviewStatus[]
|
||||
needReview?: boolean
|
||||
}
|
||||
|
||||
// 分页参数
|
||||
export interface PaginationParams {
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
// 分类列表请求
|
||||
export interface ListCategoriesRequest extends PaginationParams {
|
||||
keyword?: string
|
||||
}
|
||||
|
||||
// 打卡营列表请求
|
||||
export interface ListCampsRequest extends PaginationParams {
|
||||
keyword?: string
|
||||
categoryId?: string
|
||||
recommendFilter?: RecommendFilter
|
||||
}
|
||||
|
||||
// 小节列表请求
|
||||
export interface ListSectionsRequest extends PaginationParams {
|
||||
keyword?: string
|
||||
campId?: string
|
||||
}
|
||||
|
||||
// 任务列表请求
|
||||
export interface ListTasksRequest extends PaginationParams {
|
||||
keyword?: string
|
||||
campId?: string
|
||||
sectionId?: string
|
||||
taskType?: TaskType
|
||||
}
|
||||
|
||||
// 用户进度列表请求
|
||||
export interface ListUserProgressRequest extends PaginationParams {
|
||||
userId?: string
|
||||
/** 用户关键词:昵称/ID/手机号(后端当前按 user_id 模糊匹配) */
|
||||
userKeyword?: string
|
||||
taskId?: string
|
||||
sectionId?: string
|
||||
campId?: string
|
||||
/** 审核状态筛选:0 待审核 1 通过 2 拒绝,传字符串 pending/approved/rejected 也可 */
|
||||
reviewStatus?: number | string
|
||||
}
|
||||
|
||||
58
src/types/index.ts
Normal file
58
src/types/index.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
// 用户角色枚举
|
||||
export enum UserRole {
|
||||
ADMIN = 'admin',
|
||||
USER = 'user',
|
||||
GUEST = 'guest',
|
||||
}
|
||||
|
||||
// 菜单类型枚举
|
||||
export enum MenuType {
|
||||
MENU = 'menu',
|
||||
SUBMENU = 'submenu',
|
||||
MENUITEM = 'menuitem',
|
||||
}
|
||||
|
||||
// 用户信息接口(与后端 AdminUserInfo 对应)
|
||||
export interface UserInfo {
|
||||
id: string
|
||||
username: string
|
||||
phone: string
|
||||
nickname: string
|
||||
avatar: string
|
||||
is_super_admin: boolean
|
||||
roles: string[]
|
||||
permissions: string[]
|
||||
token?: string // token 单独存储,不包含在用户信息中
|
||||
}
|
||||
|
||||
// 菜单项接口
|
||||
export interface MenuItem {
|
||||
key: string
|
||||
label: string
|
||||
icon?: React.ReactNode
|
||||
path?: string
|
||||
type: MenuType
|
||||
children?: MenuItem[]
|
||||
}
|
||||
|
||||
// API 响应接口
|
||||
export interface ApiResponse<T = any> {
|
||||
code: number
|
||||
data: T
|
||||
message: string
|
||||
}
|
||||
|
||||
// 分页参数接口
|
||||
export interface PageParams {
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
// 分页数据接口
|
||||
export interface PageData<T = any> {
|
||||
list: T[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
194
src/types/question.ts
Normal file
194
src/types/question.ts
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
// 题目类型枚举
|
||||
export enum QuestionType {
|
||||
UNSPECIFIED = 0, // 未指定
|
||||
SUBJECTIVE = 1, // 主观题
|
||||
SINGLE_CHOICE = 2, // 单选题
|
||||
MULTIPLE_CHOICE = 3, // 多选题
|
||||
TRUE_FALSE = 4, // 判断题
|
||||
}
|
||||
|
||||
// 选项结构(支持标记正确答案)
|
||||
export interface QuestionOption {
|
||||
value: string // 选项内容
|
||||
isCorrect: boolean // 是否为正确答案
|
||||
}
|
||||
|
||||
// 题目结构
|
||||
export interface Question {
|
||||
id: string
|
||||
type: QuestionType
|
||||
name?: string // 题目名称(可选,用于搜索,不在前端显示)
|
||||
source?: string // 题目出处(可选)
|
||||
materialId?: string // 关联材料ID(可选)
|
||||
content: string // 题干(支持富文本和图片)
|
||||
options: QuestionOption[] | string[] // 选项(新格式支持正确答案标记,兼容旧格式)
|
||||
answer: string // 答案(兼容字段,新格式从 options 中提取)
|
||||
explanation: string // 解析(支持富文本和图片)
|
||||
knowledgeTreeIds?: string[] // 关联的知识树ID列表(替代原来的tags)
|
||||
knowledgeTreeNames?: string[] // 关联的知识树名称列表(后端已返回,便于展示)
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
// 题目信息(用于试卷中的题目列表)
|
||||
export interface QuestionInfo {
|
||||
id: string
|
||||
type: number
|
||||
content: string
|
||||
answer: string
|
||||
explanation: string
|
||||
options: string[]
|
||||
knowledgeTreeIds?: string[] // 关联的知识树ID列表(替代原来的tags)
|
||||
}
|
||||
|
||||
// 试卷结构
|
||||
export interface Paper {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
source?: string // 题目出处(可选)
|
||||
questionIds: string[] // 题目ID列表
|
||||
materialIds?: string[] // 关联的材料ID列表(可选,用于主观题组卷)
|
||||
knowledgeTreeIds?: string[] // 关联的知识树ID列表(可选,替代原来的tags)
|
||||
knowledgeTreeNames?: string[] // 关联的知识树名称列表(后端返回,便于展示)
|
||||
questions?: QuestionInfo[] // 题目详细信息列表(可选)
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
// 主观题试卷中的问题项(用于组卷时动态添加问题)
|
||||
export interface SubjectivePaperQuestion {
|
||||
content: string // 问题内容(富文本)
|
||||
knowledgeTreeId?: string // 关联的题型分类(知识树节点ID,可选)
|
||||
}
|
||||
|
||||
// 答题记录
|
||||
export interface AnswerRecord {
|
||||
id: string
|
||||
userId: string
|
||||
questionId: string
|
||||
paperId: string // 可选
|
||||
userAnswer: string // 用户答案
|
||||
correctAnswer: string // 正确答案
|
||||
isCorrect: boolean // 是否正确
|
||||
startTime: number // 开始答题时间
|
||||
endTime: number // 结束答题时间
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
// 单道题答题信息
|
||||
export interface QuestionAnswer {
|
||||
questionId: string
|
||||
userAnswer: string
|
||||
}
|
||||
|
||||
// 答题结果
|
||||
export interface AnswerResult {
|
||||
questionId: string
|
||||
isCorrect: boolean
|
||||
correctAnswer: string
|
||||
}
|
||||
|
||||
// 试卷答题统计
|
||||
export interface PaperAnswerStatistics {
|
||||
userId: string
|
||||
paperId: string
|
||||
totalQuestions: number
|
||||
answeredQuestions: number
|
||||
correctAnswers: number
|
||||
wrongAnswers: number
|
||||
}
|
||||
|
||||
// ==================== 请求参数 ====================
|
||||
|
||||
// 题目搜索请求
|
||||
export interface SearchQuestionsRequest {
|
||||
query?: string
|
||||
type?: QuestionType
|
||||
tags?: string[]
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
// 试卷搜索请求
|
||||
export interface SearchPapersRequest {
|
||||
query?: string
|
||||
knowledgeTreeIds?: string[]
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
// 创建答题记录请求
|
||||
export interface CreateAnswerRecordsRequest {
|
||||
userId: string
|
||||
paperId: string
|
||||
answers: QuestionAnswer[]
|
||||
startTime: number
|
||||
endTime: number
|
||||
}
|
||||
|
||||
// 添加题目到试卷请求
|
||||
export interface AddQuestionToPaperRequest {
|
||||
paperId: string
|
||||
questionIds: string[]
|
||||
}
|
||||
|
||||
// 从试卷移除题目请求
|
||||
export interface RemoveQuestionFromPaperRequest {
|
||||
paperId: string
|
||||
questionIds: string[]
|
||||
}
|
||||
|
||||
// ==================== 材料管理 ====================
|
||||
|
||||
// 材料类型
|
||||
export enum MaterialType {
|
||||
OBJECTIVE = 'objective', // 客观题材料
|
||||
SUBJECTIVE = 'subjective', // 主观题材料
|
||||
}
|
||||
|
||||
// 材料结构
|
||||
export interface Material {
|
||||
id: string
|
||||
type: MaterialType // 材料类型:objective 或 subjective
|
||||
name: string // 材料名称(用于搜索和显示)
|
||||
content: string // 材料内容(富文本)
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
// 材料搜索请求
|
||||
export interface SearchMaterialsRequest {
|
||||
query?: string
|
||||
type?: MaterialType // 材料类型:objective 或 subjective
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
// ==================== 知识树管理 ====================
|
||||
|
||||
// 知识树节点结构
|
||||
export interface KnowledgeTreeNode {
|
||||
id: string
|
||||
type?: 'objective' | 'subjective' // 知识树类型
|
||||
title: string
|
||||
parentId: string // 父节点ID,根节点为空字符串
|
||||
children?: KnowledgeTreeNode[] // 子节点列表(可选,用于树形结构返回)
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
// 创建知识树节点请求
|
||||
export interface CreateKnowledgeTreeNodeRequest {
|
||||
type: 'objective' | 'subjective' // 知识树类型
|
||||
title: string
|
||||
parentId?: string // 父节点ID,根节点为空字符串
|
||||
}
|
||||
|
||||
// 更新知识树节点请求
|
||||
export interface UpdateKnowledgeTreeNodeRequest {
|
||||
id: string
|
||||
title: string
|
||||
parentId?: string // 父节点ID
|
||||
}
|
||||
91
src/utils/permission.ts
Normal file
91
src/utils/permission.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { useUserStore } from '@/store/useUserStore'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
/**
|
||||
* 检查用户是否有指定权限(Hook版本,支持响应式更新)
|
||||
* @param permissionCode 权限代码
|
||||
* @returns 是否有权限
|
||||
*/
|
||||
export const useHasPermission = (permissionCode: string): boolean => {
|
||||
const { userInfo } = useUserStore()
|
||||
|
||||
return useMemo(() => {
|
||||
// 超级管理员拥有所有权限
|
||||
if (userInfo?.is_super_admin) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 检查权限列表
|
||||
if (!userInfo?.permissions || userInfo.permissions.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
return userInfo.permissions.includes(permissionCode)
|
||||
}, [userInfo?.is_super_admin, userInfo?.permissions, permissionCode])
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户是否有指定权限(非Hook版本,用于非组件场景)
|
||||
* @param permissionCode 权限代码
|
||||
* @returns 是否有权限
|
||||
*/
|
||||
export const hasPermission = (permissionCode: string): boolean => {
|
||||
const { userInfo } = useUserStore.getState()
|
||||
|
||||
// 超级管理员拥有所有权限
|
||||
if (userInfo?.is_super_admin) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 检查权限列表
|
||||
if (!userInfo?.permissions || userInfo.permissions.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
return userInfo.permissions.includes(permissionCode)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户是否有任意一个权限
|
||||
* @param permissionCodes 权限代码数组
|
||||
* @returns 是否有权限
|
||||
*/
|
||||
export const hasAnyPermission = (permissionCodes: string[]): boolean => {
|
||||
if (permissionCodes.length === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
return permissionCodes.some(code => hasPermission(code))
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户是否有所有权限
|
||||
* @param permissionCodes 权限代码数组
|
||||
* @returns 是否有权限
|
||||
*/
|
||||
export const hasAllPermissions = (permissionCodes: string[]): boolean => {
|
||||
if (permissionCodes.length === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
return permissionCodes.every(code => hasPermission(code))
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户所有权限
|
||||
* @returns 权限代码数组
|
||||
*/
|
||||
export const getUserPermissions = (): string[] => {
|
||||
const { userInfo } = useUserStore.getState()
|
||||
return userInfo?.permissions || []
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为超级管理员
|
||||
* @returns 是否为超级管理员
|
||||
*/
|
||||
export const isSuperAdmin = (): boolean => {
|
||||
const { userInfo } = useUserStore.getState()
|
||||
return userInfo?.is_super_admin || false
|
||||
}
|
||||
|
||||
134
src/utils/request.ts
Normal file
134
src/utils/request.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import axios, { AxiosResponse } from 'axios'
|
||||
import { message } from 'antd'
|
||||
import { STORAGE_KEYS, ApiCode } from '@/constants'
|
||||
|
||||
// 创建 axios 实例
|
||||
// 根据环境变量自动切换 API 地址
|
||||
// 开发环境:使用本地 Fiber 后端 (http://127.0.0.1:8080/admin/v1)
|
||||
// 生产环境:使用线上 API (https://admin.duiduiedu.com/admin/v1)
|
||||
const getBaseURL = (): string => {
|
||||
// 优先使用环境变量
|
||||
if (import.meta.env.VITE_API_BASE_URL) {
|
||||
return import.meta.env.VITE_API_BASE_URL
|
||||
}
|
||||
// 生产环境使用线上地址
|
||||
if (import.meta.env.PROD) {
|
||||
return 'https://admin.duiduiedu.com/admin/v1'
|
||||
}
|
||||
// 开发环境使用本地地址
|
||||
return 'http://127.0.0.1:8080/admin/v1'
|
||||
}
|
||||
|
||||
const request = axios.create({
|
||||
baseURL: getBaseURL(),
|
||||
timeout: 30000,
|
||||
})
|
||||
|
||||
// 请求拦截器
|
||||
request.interceptors.request.use(
|
||||
(config) => {
|
||||
// 添加 token
|
||||
const token = localStorage.getItem(STORAGE_KEYS.TOKEN)
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// 响应拦截器
|
||||
request.interceptors.response.use(
|
||||
(response: AxiosResponse<any>) => {
|
||||
const responseData = response.data
|
||||
|
||||
// 兼容两种响应格式:
|
||||
// 格式1: { code: 200, data: {...}, message: '...' }
|
||||
// 格式2: { success: true, data: {...}, message: '...' }
|
||||
|
||||
const { code, success, message: msg } = responseData
|
||||
|
||||
// 格式1:使用 code 字段
|
||||
if (code !== undefined) {
|
||||
// 成功响应
|
||||
if (code === ApiCode.SUCCESS) {
|
||||
return response
|
||||
}
|
||||
|
||||
// 未授权,跳转登录页(但不在登录页面跳转)
|
||||
if (code === ApiCode.UNAUTHORIZED) {
|
||||
const currentPath = window.location.pathname
|
||||
if (currentPath !== '/login') {
|
||||
message.error('登录已过期,请重新登录')
|
||||
localStorage.removeItem(STORAGE_KEYS.TOKEN)
|
||||
localStorage.removeItem(STORAGE_KEYS.USER_INFO)
|
||||
window.location.href = '/login'
|
||||
}
|
||||
return Promise.reject(new Error(msg || '未授权'))
|
||||
}
|
||||
|
||||
// 其他错误(不在这里显示错误提示,由各页面自行处理)
|
||||
return Promise.reject(new Error(msg || '请求失败'))
|
||||
}
|
||||
|
||||
// 格式2:使用 success 字段
|
||||
if (success !== undefined) {
|
||||
if (success === true) {
|
||||
// 成功时直接返回 response,让调用方通过 res.data 获取数据
|
||||
return response
|
||||
} else {
|
||||
// 失败时不显示错误提示,由各页面自行处理
|
||||
return Promise.reject(new Error(msg || '请求失败'))
|
||||
}
|
||||
}
|
||||
|
||||
// 如果都没有,直接返回响应
|
||||
return response
|
||||
},
|
||||
(error) => {
|
||||
if (error.response) {
|
||||
const { status, data } = error.response
|
||||
const errorMessage = data?.message || data?.error || `HTTP ${status} 错误`
|
||||
|
||||
switch (status) {
|
||||
case 401:
|
||||
// 401 错误:只在非登录页面跳转
|
||||
const currentPath = window.location.pathname
|
||||
if (currentPath !== '/login') {
|
||||
message.error('登录已过期,请重新登录')
|
||||
localStorage.removeItem(STORAGE_KEYS.TOKEN)
|
||||
localStorage.removeItem(STORAGE_KEYS.USER_INFO)
|
||||
window.location.href = '/login'
|
||||
}
|
||||
// 在登录页面,不跳转,让登录页面自己处理错误
|
||||
break
|
||||
case 403:
|
||||
message.error('没有权限访问')
|
||||
break
|
||||
case 404:
|
||||
message.error('请求的资源不存在')
|
||||
break
|
||||
case 400:
|
||||
// 400 业务错误:不在这里统一弹窗,由调用方根据 response.data.message 提示,避免与页面重复或出现 "Request failed with status code 400"
|
||||
break
|
||||
case 500:
|
||||
message.error('服务器错误')
|
||||
break
|
||||
default:
|
||||
message.error(errorMessage || '网络错误')
|
||||
}
|
||||
} else if (error.request) {
|
||||
// 请求已发出但没有收到响应
|
||||
message.error('网络连接失败,请检查网络')
|
||||
} else {
|
||||
// 请求配置出错
|
||||
message.error('请求配置错误: ' + error.message)
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export default request
|
||||
|
||||
34
src/utils/storage.ts
Normal file
34
src/utils/storage.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
// 本地存储工具函数
|
||||
|
||||
export const storage = {
|
||||
// 获取数据
|
||||
get<T = any>(key: string): T | null {
|
||||
try {
|
||||
const item = localStorage.getItem(key)
|
||||
return item ? JSON.parse(item) : null
|
||||
} catch (error) {
|
||||
console.error('获取本地存储失败:', error)
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
// 设置数据
|
||||
set(key: string, value: any): void {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value))
|
||||
} catch (error) {
|
||||
console.error('设置本地存储失败:', error)
|
||||
}
|
||||
},
|
||||
|
||||
// 删除数据
|
||||
remove(key: string): void {
|
||||
localStorage.removeItem(key)
|
||||
},
|
||||
|
||||
// 清空所有数据
|
||||
clear(): void {
|
||||
localStorage.clear()
|
||||
},
|
||||
}
|
||||
|
||||
2
src/vite-env.d.ts
vendored
Normal file
2
src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
||||
32
tsconfig.json
Normal file
32
tsconfig.json
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
/* Path Alias */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
|
||||
12
tsconfig.node.json
Normal file
12
tsconfig.node.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
|
||||
1
tsconfig.node.tsbuildinfo
Normal file
1
tsconfig.node.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
36
vite.config.ts
Normal file
36
vite.config.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig(({ mode }) => {
|
||||
// 获取 API 基础地址(环境变量或默认值)
|
||||
// 生产环境默认使用线上地址,开发环境使用本地地址
|
||||
// 注意:在 vite.config.ts 中使用 process.env,在代码中使用 import.meta.env
|
||||
const apiBaseURL = process.env.VITE_API_BASE_URL ||
|
||||
(mode === 'production' ? 'https://admin.duiduiedu.com/admin/v1' : 'http://127.0.0.1:8080/admin/v1')
|
||||
|
||||
return {
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': '/src',
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/admin': {
|
||||
target: 'http://127.0.0.1:8080', // 开发环境代理到本地
|
||||
changeOrigin: true,
|
||||
secure: false, // 忽略 SSL 证书验证
|
||||
},
|
||||
},
|
||||
},
|
||||
// 定义全局常量,在代码中可以通过 import.meta.env.VITE_API_BASE_URL 访问
|
||||
define: {
|
||||
'import.meta.env.VITE_API_BASE_URL': JSON.stringify(apiBaseURL),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
Loading…
Reference in New Issue
Block a user