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