first commit

This commit is contained in:
well 2026-03-27 10:38:12 +08:00
commit c2dc89397b
86 changed files with 22652 additions and 0 deletions

9
.env Normal file
View 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
View 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
View 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
View 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
View 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?

2
.npmrc Normal file
View File

@ -0,0 +1,2 @@
registry=https://registry.npmmirror.com

15
Dockerfile Normal file
View 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
View 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
View 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
View 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
View 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

Binary file not shown.

14
index.html Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

47
package.json Normal file
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

66
src/api/document.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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,
}
}
}
})
}

View 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

View 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

View 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

View File

@ -0,0 +1,2 @@
export { PermissionWrapper, RequirePermission } from './PermissionWrapper'

View 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;
}

View 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

View 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
View 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
View 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
View 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
View 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
View 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>,
)

View 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

View 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

View 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
View 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

View 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

View 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

View 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

File diff suppressed because it is too large Load Diff

View 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>
)
}

View 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>
)
}

View 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、上传逻辑等组合上述组件 |

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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__:'

View 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>
)
}

View 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
}

View 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
}

View 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

View 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
View 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
View 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

View 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

View 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

View 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

View 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(/&nbsp;/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

View 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

View 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

View 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

View 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

File diff suppressed because it is too large Load Diff

View 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
View 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

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,2 @@
/// <reference types="vite/client" />

32
tsconfig.json Normal file
View 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
View File

@ -0,0 +1,12 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"types": ["node"]
},
"include": ["vite.config.ts"]
}

File diff suppressed because one or more lines are too long

36
vite.config.ts Normal file
View 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),
},
}
})