commit c2dc89397bfaa8932f762cc2a48295b50d696eb8
Author: well <347471159@qq.com>
Date: Fri Mar 27 10:38:12 2026 +0800
first commit
diff --git a/.env b/.env
new file mode 100644
index 0000000..9f3936c
--- /dev/null
+++ b/.env
@@ -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
diff --git a/.env.development b/.env.development
new file mode 100644
index 0000000..6dd1895
--- /dev/null
+++ b/.env.development
@@ -0,0 +1,5 @@
+# 开发环境配置
+# 此文件仅在 development 模式下生效(npm run dev)
+
+# 开发环境 API 地址
+VITE_API_BASE_URL=http://127.0.0.1:8080/admin/v1
diff --git a/.env.production b/.env.production
new file mode 100644
index 0000000..f6bbad8
--- /dev/null
+++ b/.env.production
@@ -0,0 +1,5 @@
+# 生产环境配置
+# 此文件仅在 production 模式下生效(npm run build)
+
+# 生产环境 API 地址
+VITE_API_BASE_URL=https://admin.duiduiedu.com/admin/v1
diff --git a/.eslintrc.cjs b/.eslintrc.cjs
new file mode 100644
index 0000000..95bec8e
--- /dev/null
+++ b/.eslintrc.cjs
@@ -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',
+ },
+}
+
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..135523d
--- /dev/null
+++ b/.gitignore
@@ -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?
+
diff --git a/.npmrc b/.npmrc
new file mode 100644
index 0000000..f72a6b6
--- /dev/null
+++ b/.npmrc
@@ -0,0 +1,2 @@
+registry=https://registry.npmmirror.com
+
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..f2b2d73
--- /dev/null
+++ b/Dockerfile
@@ -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;"]
+
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..763f769
--- /dev/null
+++ b/Makefile
@@ -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 "✅ 清理完成"
+
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..e342587
--- /dev/null
+++ b/README.md
@@ -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
+
diff --git a/build-deploy.sh b/build-deploy.sh
new file mode 100755
index 0000000..ea12f39
--- /dev/null
+++ b/build-deploy.sh
@@ -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 "=========================================="
+
diff --git a/deploy/deploy.sh b/deploy/deploy.sh
new file mode 100755
index 0000000..c7da20e
--- /dev/null
+++ b/deploy/deploy.sh
@@ -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
+
diff --git a/deploy/duidui-admin-web.tar b/deploy/duidui-admin-web.tar
new file mode 100644
index 0000000..0631561
Binary files /dev/null and b/deploy/duidui-admin-web.tar differ
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..f8bb0f0
--- /dev/null
+++ b/index.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+ 后台管理系统
+
+
+
+
+
+
+
diff --git a/nginx.conf b/nginx.conf
new file mode 100644
index 0000000..0f7d7dc
--- /dev/null
+++ b/nginx.conf
@@ -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;
+}
+
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..ab0bd7d
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,5549 @@
+{
+ "name": "duidui-admin-web",
+ "version": "0.0.1",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "duidui-admin-web",
+ "version": "0.0.1",
+ "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"
+ }
+ },
+ "node_modules/@ant-design/colors": {
+ "version": "7.2.1",
+ "license": "MIT",
+ "dependencies": {
+ "@ant-design/fast-color": "^2.0.6"
+ }
+ },
+ "node_modules/@ant-design/cssinjs": {
+ "version": "1.24.0",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.11.1",
+ "@emotion/hash": "^0.8.0",
+ "@emotion/unitless": "^0.7.5",
+ "classnames": "^2.3.1",
+ "csstype": "^3.1.3",
+ "rc-util": "^5.35.0",
+ "stylis": "^4.3.4"
+ },
+ "peerDependencies": {
+ "react": ">=16.0.0",
+ "react-dom": ">=16.0.0"
+ }
+ },
+ "node_modules/@ant-design/cssinjs-utils": {
+ "version": "1.1.3",
+ "license": "MIT",
+ "dependencies": {
+ "@ant-design/cssinjs": "^1.21.0",
+ "@babel/runtime": "^7.23.2",
+ "rc-util": "^5.38.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/@ant-design/fast-color": {
+ "version": "2.0.6",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.24.7"
+ },
+ "engines": {
+ "node": ">=8.x"
+ }
+ },
+ "node_modules/@ant-design/icons": {
+ "version": "5.6.1",
+ "resolved": "https://registry.npmmirror.com/@ant-design/icons/-/icons-5.6.1.tgz",
+ "integrity": "sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg==",
+ "license": "MIT",
+ "dependencies": {
+ "@ant-design/colors": "^7.0.0",
+ "@ant-design/icons-svg": "^4.4.0",
+ "@babel/runtime": "^7.24.8",
+ "classnames": "^2.2.6",
+ "rc-util": "^5.31.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "peerDependencies": {
+ "react": ">=16.0.0",
+ "react-dom": ">=16.0.0"
+ }
+ },
+ "node_modules/@ant-design/icons-svg": {
+ "version": "4.4.2",
+ "resolved": "https://registry.npmmirror.com/@ant-design/icons-svg/-/icons-svg-4.4.2.tgz",
+ "integrity": "sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA==",
+ "license": "MIT"
+ },
+ "node_modules/@ant-design/react-slick": {
+ "version": "1.1.2",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.10.4",
+ "classnames": "^2.2.5",
+ "json2mq": "^0.2.0",
+ "resize-observer-polyfill": "^1.5.1",
+ "throttle-debounce": "^5.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.27.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.27.1",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.28.4",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.28.4",
+ "resolved": "https://registry.npmmirror.com/@babel/core/-/core-7.28.4.tgz",
+ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.27.1",
+ "@babel/generator": "^7.28.3",
+ "@babel/helper-compilation-targets": "^7.27.2",
+ "@babel/helper-module-transforms": "^7.28.3",
+ "@babel/helpers": "^7.28.4",
+ "@babel/parser": "^7.28.4",
+ "@babel/template": "^7.27.2",
+ "@babel/traverse": "^7.28.4",
+ "@babel/types": "^7.28.4",
+ "@jridgewell/remapping": "^2.3.5",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/core/node_modules/semver": {
+ "version": "6.3.1",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.28.3",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.28.3",
+ "@babel/types": "^7.28.2",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.27.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.27.2",
+ "@babel/helper-validator-option": "^7.27.1",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets/node_modules/semver": {
+ "version": "6.3.1",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.28.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.27.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.27.1",
+ "@babel/types": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.28.3",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.27.1",
+ "@babel/helper-validator-identifier": "^7.27.1",
+ "@babel/traverse": "^7.28.3"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.27.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.27.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.27.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.27.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.28.4",
+ "resolved": "https://registry.npmmirror.com/@babel/helpers/-/helpers-7.28.4.tgz",
+ "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.27.2",
+ "@babel/types": "^7.28.4"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.28.4",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.28.4"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-self": {
+ "version": "7.27.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-source": {
+ "version": "7.27.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/runtime": {
+ "version": "7.28.4",
+ "resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.28.4.tgz",
+ "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.27.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.27.1",
+ "@babel/parser": "^7.27.2",
+ "@babel/types": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.28.4",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.27.1",
+ "@babel/generator": "^7.28.3",
+ "@babel/helper-globals": "^7.28.0",
+ "@babel/parser": "^7.28.4",
+ "@babel/template": "^7.27.2",
+ "@babel/types": "^7.28.4",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.28.4",
+ "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.28.4.tgz",
+ "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.27.1",
+ "@babel/helper-validator-identifier": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@emotion/hash": {
+ "version": "0.8.0",
+ "license": "MIT"
+ },
+ "node_modules/@emotion/unitless": {
+ "version": "0.7.5",
+ "license": "MIT"
+ },
+ "node_modules/@eslint-community/eslint-utils": {
+ "version": "4.9.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "eslint-visitor-keys": "^3.4.3"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
+ }
+ },
+ "node_modules/@eslint-community/regexpp": {
+ "version": "4.12.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
+ }
+ },
+ "node_modules/@eslint/eslintrc": {
+ "version": "2.1.4",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^6.12.4",
+ "debug": "^4.3.2",
+ "espree": "^9.6.0",
+ "globals": "^13.19.0",
+ "ignore": "^5.2.0",
+ "import-fresh": "^3.2.1",
+ "js-yaml": "^4.1.0",
+ "minimatch": "^3.1.2",
+ "strip-json-comments": "^3.1.1"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
+ "version": "1.1.12",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/@eslint/eslintrc/node_modules/minimatch": {
+ "version": "3.1.2",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/@eslint/js": {
+ "version": "8.57.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ }
+ },
+ "node_modules/@floating-ui/core": {
+ "version": "1.7.3",
+ "resolved": "https://registry.npmmirror.com/@floating-ui/core/-/core-1.7.3.tgz",
+ "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@floating-ui/utils": "^0.2.10"
+ }
+ },
+ "node_modules/@floating-ui/dom": {
+ "version": "1.7.4",
+ "resolved": "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.7.4.tgz",
+ "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@floating-ui/core": "^1.7.3",
+ "@floating-ui/utils": "^0.2.10"
+ }
+ },
+ "node_modules/@floating-ui/utils": {
+ "version": "0.2.10",
+ "resolved": "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.10.tgz",
+ "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/@humanwhocodes/config-array": {
+ "version": "0.13.0",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@humanwhocodes/object-schema": "^2.0.3",
+ "debug": "^4.3.1",
+ "minimatch": "^3.0.5"
+ },
+ "engines": {
+ "node": ">=10.10.0"
+ }
+ },
+ "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": {
+ "version": "1.1.12",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/@humanwhocodes/config-array/node_modules/minimatch": {
+ "version": "3.1.2",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/@humanwhocodes/module-importer": {
+ "version": "1.0.1",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.22"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@humanwhocodes/object-schema": {
+ "version": "2.0.3",
+ "dev": true,
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@nodelib/fs.scandir": {
+ "version": "2.1.5",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "2.0.5",
+ "run-parallel": "^1.1.9"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.stat": {
+ "version": "2.0.5",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.walk": {
+ "version": "1.2.8",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.scandir": "2.1.5",
+ "fastq": "^1.6.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@rc-component/async-validator": {
+ "version": "5.0.4",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.24.4"
+ },
+ "engines": {
+ "node": ">=14.x"
+ }
+ },
+ "node_modules/@rc-component/color-picker": {
+ "version": "2.0.1",
+ "license": "MIT",
+ "dependencies": {
+ "@ant-design/fast-color": "^2.0.6",
+ "@babel/runtime": "^7.23.6",
+ "classnames": "^2.2.6",
+ "rc-util": "^5.38.1"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/@rc-component/context": {
+ "version": "1.4.0",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.10.1",
+ "rc-util": "^5.27.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/@rc-component/mini-decimal": {
+ "version": "1.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.18.0"
+ },
+ "engines": {
+ "node": ">=8.x"
+ }
+ },
+ "node_modules/@rc-component/mutate-observer": {
+ "version": "1.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.18.0",
+ "classnames": "^2.3.2",
+ "rc-util": "^5.24.4"
+ },
+ "engines": {
+ "node": ">=8.x"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/@rc-component/portal": {
+ "version": "1.1.2",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.18.0",
+ "classnames": "^2.3.2",
+ "rc-util": "^5.24.4"
+ },
+ "engines": {
+ "node": ">=8.x"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/@rc-component/qrcode": {
+ "version": "1.0.1",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.24.7",
+ "classnames": "^2.3.2"
+ },
+ "engines": {
+ "node": ">=8.x"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/@rc-component/tour": {
+ "version": "1.15.1",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.18.0",
+ "@rc-component/portal": "^1.0.0-9",
+ "@rc-component/trigger": "^2.0.0",
+ "classnames": "^2.3.2",
+ "rc-util": "^5.24.4"
+ },
+ "engines": {
+ "node": ">=8.x"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/@rc-component/trigger": {
+ "version": "2.3.0",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.23.2",
+ "@rc-component/portal": "^1.1.0",
+ "classnames": "^2.3.2",
+ "rc-motion": "^2.0.0",
+ "rc-resize-observer": "^1.3.1",
+ "rc-util": "^5.44.0"
+ },
+ "engines": {
+ "node": ">=8.x"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/@remirror/core-constants": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmmirror.com/@remirror/core-constants/-/core-constants-3.0.0.tgz",
+ "integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==",
+ "license": "MIT"
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-beta.27",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.52.4",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@tiptap/core": {
+ "version": "3.15.3",
+ "resolved": "https://registry.npmmirror.com/@tiptap/core/-/core-3.15.3.tgz",
+ "integrity": "sha512-bmXydIHfm2rEtGju39FiQNfzkFx9CDvJe+xem1dgEZ2P6Dj7nQX9LnA1ZscW7TuzbBRkL5p3dwuBIi3f62A66A==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/pm": "^3.15.3"
+ }
+ },
+ "node_modules/@tiptap/extension-blockquote": {
+ "version": "3.15.3",
+ "resolved": "https://registry.npmmirror.com/@tiptap/extension-blockquote/-/extension-blockquote-3.15.3.tgz",
+ "integrity": "sha512-13x5UsQXtttFpoS/n1q173OeurNxppsdWgP3JfsshzyxIghhC141uL3H6SGYQLPU31AizgDs2OEzt6cSUevaZg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.15.3"
+ }
+ },
+ "node_modules/@tiptap/extension-bold": {
+ "version": "3.15.3",
+ "resolved": "https://registry.npmmirror.com/@tiptap/extension-bold/-/extension-bold-3.15.3.tgz",
+ "integrity": "sha512-I8JYbkkUTNUXbHd/wCse2bR0QhQtJD7+0/lgrKOmGfv5ioLxcki079Nzuqqay3PjgYoJLIJQvm3RAGxT+4X91w==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.15.3"
+ }
+ },
+ "node_modules/@tiptap/extension-bubble-menu": {
+ "version": "3.15.3",
+ "resolved": "https://registry.npmmirror.com/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.15.3.tgz",
+ "integrity": "sha512-e88DG1bTy6hKxrt7iPVQhJnH5/EOrnKpIyp09dfRDgWrrW88fE0Qjys7a/eT8W+sXyXM3z10Ye7zpERWsrLZDg==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@floating-ui/dom": "^1.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.15.3",
+ "@tiptap/pm": "^3.15.3"
+ }
+ },
+ "node_modules/@tiptap/extension-bullet-list": {
+ "version": "3.15.3",
+ "resolved": "https://registry.npmmirror.com/@tiptap/extension-bullet-list/-/extension-bullet-list-3.15.3.tgz",
+ "integrity": "sha512-MGwEkNT7ltst6XaWf0ObNgpKQ4PvuuV3igkBrdYnQS+qaAx9IF4isygVPqUc9DvjYC306jpyKsNqNrENIXcosA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/extension-list": "^3.15.3"
+ }
+ },
+ "node_modules/@tiptap/extension-code": {
+ "version": "3.15.3",
+ "resolved": "https://registry.npmmirror.com/@tiptap/extension-code/-/extension-code-3.15.3.tgz",
+ "integrity": "sha512-x6LFt3Og6MFINYpsMzrJnz7vaT9Yk1t4oXkbJsJRSavdIWBEBcoRudKZ4sSe/AnsYlRJs8FY2uR76mt9e+7xAQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.15.3"
+ }
+ },
+ "node_modules/@tiptap/extension-code-block": {
+ "version": "3.15.3",
+ "resolved": "https://registry.npmmirror.com/@tiptap/extension-code-block/-/extension-code-block-3.15.3.tgz",
+ "integrity": "sha512-q1UB9icNfdJppTqMIUWfoRKkx5SSdWIpwZoL2NeOI5Ah3E20/dQKVttIgLhsE521chyvxCYCRaHD5tMNGKfhyw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.15.3",
+ "@tiptap/pm": "^3.15.3"
+ }
+ },
+ "node_modules/@tiptap/extension-color": {
+ "version": "3.15.3",
+ "resolved": "https://registry.npmmirror.com/@tiptap/extension-color/-/extension-color-3.15.3.tgz",
+ "integrity": "sha512-GS+LEJ7YC7J6CiQ/caTDVyKg+ZlU4B5ofzAZ0iCWPahjMyUUZImzXvoRlfMumAiPG+IUW9PC2BztSGd3SCLpGA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/extension-text-style": "^3.15.3"
+ }
+ },
+ "node_modules/@tiptap/extension-document": {
+ "version": "3.15.3",
+ "resolved": "https://registry.npmmirror.com/@tiptap/extension-document/-/extension-document-3.15.3.tgz",
+ "integrity": "sha512-AC72nI2gnogBuETCKbZekn+h6t5FGGcZG2abPGKbz/x9rwpb6qV2hcbAQ30t6M7H6cTOh2/Ut8bEV2MtMB15sw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.15.3"
+ }
+ },
+ "node_modules/@tiptap/extension-dropcursor": {
+ "version": "3.15.3",
+ "resolved": "https://registry.npmmirror.com/@tiptap/extension-dropcursor/-/extension-dropcursor-3.15.3.tgz",
+ "integrity": "sha512-jGI5XZpdo8GSYQFj7HY15/oEwC2m2TqZz0/Fln5qIhY32XlZhWrsMuMI6WbUJrTH16es7xO6jmRlDsc6g+vJWg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/extensions": "^3.15.3"
+ }
+ },
+ "node_modules/@tiptap/extension-floating-menu": {
+ "version": "3.15.3",
+ "resolved": "https://registry.npmmirror.com/@tiptap/extension-floating-menu/-/extension-floating-menu-3.15.3.tgz",
+ "integrity": "sha512-+3DVBleKKffadEJEdLYxmYAJOjHjLSqtiSFUE3RABT4V2ka1ODy2NIpyKX0o1SvQ5N1jViYT9Q+yUbNa6zCcDw==",
+ "license": "MIT",
+ "optional": true,
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@floating-ui/dom": "^1.0.0",
+ "@tiptap/core": "^3.15.3",
+ "@tiptap/pm": "^3.15.3"
+ }
+ },
+ "node_modules/@tiptap/extension-gapcursor": {
+ "version": "3.15.3",
+ "resolved": "https://registry.npmmirror.com/@tiptap/extension-gapcursor/-/extension-gapcursor-3.15.3.tgz",
+ "integrity": "sha512-Kaw0sNzP0bQI/xEAMSfIpja6xhsu9WqqAK/puzOIS1RKWO47Wps/tzqdSJ9gfslPIb5uY5mKCfy8UR8Xgiia8w==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/extensions": "^3.15.3"
+ }
+ },
+ "node_modules/@tiptap/extension-hard-break": {
+ "version": "3.15.3",
+ "resolved": "https://registry.npmmirror.com/@tiptap/extension-hard-break/-/extension-hard-break-3.15.3.tgz",
+ "integrity": "sha512-8HjxmeRbBiXW+7JKemAJtZtHlmXQ9iji398CPQ0yYde68WbIvUhHXjmbJE5pxFvvQTJ/zJv1aISeEOZN2bKBaw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.15.3"
+ }
+ },
+ "node_modules/@tiptap/extension-heading": {
+ "version": "3.15.3",
+ "resolved": "https://registry.npmmirror.com/@tiptap/extension-heading/-/extension-heading-3.15.3.tgz",
+ "integrity": "sha512-G1GG6iN1YXPS+75arDpo+bYRzhr3dNDw99c7D7na3aDawa9Qp7sZ/bVrzFUUcVEce0cD6h83yY7AooBxEc67hA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.15.3"
+ }
+ },
+ "node_modules/@tiptap/extension-horizontal-rule": {
+ "version": "3.15.3",
+ "resolved": "https://registry.npmmirror.com/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.15.3.tgz",
+ "integrity": "sha512-FYkN7L6JsfwwNEntmLklCVKvgL0B0N47OXMacRk6kYKQmVQ4Nvc7q/VJLpD9sk4wh4KT1aiCBfhKEBTu5pv1fg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.15.3",
+ "@tiptap/pm": "^3.15.3"
+ }
+ },
+ "node_modules/@tiptap/extension-image": {
+ "version": "3.15.3",
+ "resolved": "https://registry.npmmirror.com/@tiptap/extension-image/-/extension-image-3.15.3.tgz",
+ "integrity": "sha512-Tjq9BHlC/0bGR9/uySA0tv6I1Ua1Q5t5P/mdbWyZi4JdUpKHRfgenzfXF5DYnklJ01QJ7uOPSp9sAGgPzBixtQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.15.3"
+ }
+ },
+ "node_modules/@tiptap/extension-italic": {
+ "version": "3.15.3",
+ "resolved": "https://registry.npmmirror.com/@tiptap/extension-italic/-/extension-italic-3.15.3.tgz",
+ "integrity": "sha512-6XeuPjcWy7OBxpkgOV7bD6PATO5jhIxc8SEK4m8xn8nelGTBIbHGqK37evRv+QkC7E0MUryLtzwnmmiaxcKL0Q==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.15.3"
+ }
+ },
+ "node_modules/@tiptap/extension-link": {
+ "version": "3.15.3",
+ "resolved": "https://registry.npmmirror.com/@tiptap/extension-link/-/extension-link-3.15.3.tgz",
+ "integrity": "sha512-PdDXyBF9Wco9U1x6e+b7tKBWG+kqBDXDmaYXHkFm/gYuQCQafVJ5mdrDdKgkHDWVnJzMWZXBcZjT9r57qtlLWg==",
+ "license": "MIT",
+ "dependencies": {
+ "linkifyjs": "^4.3.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.15.3",
+ "@tiptap/pm": "^3.15.3"
+ }
+ },
+ "node_modules/@tiptap/extension-list": {
+ "version": "3.15.3",
+ "resolved": "https://registry.npmmirror.com/@tiptap/extension-list/-/extension-list-3.15.3.tgz",
+ "integrity": "sha512-n7y/MF9lAM5qlpuH5IR4/uq+kJPEJpe9NrEiH+NmkO/5KJ6cXzpJ6F4U17sMLf2SNCq+TWN9QK8QzoKxIn50VQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.15.3",
+ "@tiptap/pm": "^3.15.3"
+ }
+ },
+ "node_modules/@tiptap/extension-list-item": {
+ "version": "3.15.3",
+ "resolved": "https://registry.npmmirror.com/@tiptap/extension-list-item/-/extension-list-item-3.15.3.tgz",
+ "integrity": "sha512-CCxL5ek1p0lO5e8aqhnPzIySldXRSigBFk2fP9OLgdl5qKFLs2MGc19jFlx5+/kjXnEsdQTFbGY1Sizzt0TVDw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/extension-list": "^3.15.3"
+ }
+ },
+ "node_modules/@tiptap/extension-list-keymap": {
+ "version": "3.15.3",
+ "resolved": "https://registry.npmmirror.com/@tiptap/extension-list-keymap/-/extension-list-keymap-3.15.3.tgz",
+ "integrity": "sha512-UxqnTEEAKrL+wFQeSyC9z0mgyUUVRS2WTcVFoLZCE6/Xus9F53S4bl7VKFadjmqI4GpDk5Oe2IOUc72o129jWg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/extension-list": "^3.15.3"
+ }
+ },
+ "node_modules/@tiptap/extension-ordered-list": {
+ "version": "3.15.3",
+ "resolved": "https://registry.npmmirror.com/@tiptap/extension-ordered-list/-/extension-ordered-list-3.15.3.tgz",
+ "integrity": "sha512-/8uhw528Iy0c9wF6tHCiIn0ToM0Ml6Ll2c/3iPRnKr4IjXwx2Lr994stUFihb+oqGZwV1J8CPcZJ4Ufpdqi4Dw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/extension-list": "^3.15.3"
+ }
+ },
+ "node_modules/@tiptap/extension-paragraph": {
+ "version": "3.15.3",
+ "resolved": "https://registry.npmmirror.com/@tiptap/extension-paragraph/-/extension-paragraph-3.15.3.tgz",
+ "integrity": "sha512-lc0Qu/1AgzcEfS67NJMj5tSHHhH6NtA6uUpvppEKGsvJwgE2wKG1onE4isrVXmcGRdxSMiCtyTDemPNMu6/ozQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.15.3"
+ }
+ },
+ "node_modules/@tiptap/extension-strike": {
+ "version": "3.15.3",
+ "resolved": "https://registry.npmmirror.com/@tiptap/extension-strike/-/extension-strike-3.15.3.tgz",
+ "integrity": "sha512-Y1P3eGNY7RxQs2BcR6NfLo9VfEOplXXHAqkOM88oowWWOE7dMNeFFZM9H8HNxoQgXJ7H0aWW9B7ZTWM9hWli2Q==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.15.3"
+ }
+ },
+ "node_modules/@tiptap/extension-text": {
+ "version": "3.15.3",
+ "resolved": "https://registry.npmmirror.com/@tiptap/extension-text/-/extension-text-3.15.3.tgz",
+ "integrity": "sha512-MhkBz8ZvrqOKtKNp+ZWISKkLUlTrDR7tbKZc2OnNcUTttL9dz0HwT+cg91GGz19fuo7ttDcfsPV6eVmflvGToA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.15.3"
+ }
+ },
+ "node_modules/@tiptap/extension-text-align": {
+ "version": "3.15.3",
+ "resolved": "https://registry.npmmirror.com/@tiptap/extension-text-align/-/extension-text-align-3.15.3.tgz",
+ "integrity": "sha512-hkLeEKm44aqimyjv+D8JUxzDG/iNjDrSCGvGrMOPcpaKn4f8C5z1EKnEufT61RitNPBAxQMXUhmGQUNrmlICmQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.15.3"
+ }
+ },
+ "node_modules/@tiptap/extension-text-style": {
+ "version": "3.15.3",
+ "resolved": "https://registry.npmmirror.com/@tiptap/extension-text-style/-/extension-text-style-3.15.3.tgz",
+ "integrity": "sha512-/M7fuGRPVkeM14rQ1bNiLZUs2N+FuVhIsLEwNKKk7GaTGKHzmkC1b2COmbICivuFYf90KWzaG0R+Pm7cnW6KaA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.15.3"
+ }
+ },
+ "node_modules/@tiptap/extension-underline": {
+ "version": "3.15.3",
+ "resolved": "https://registry.npmmirror.com/@tiptap/extension-underline/-/extension-underline-3.15.3.tgz",
+ "integrity": "sha512-r/IwcNN0W366jGu4Y0n2MiFq9jGa4aopOwtfWO4d+J0DyeS2m7Go3+KwoUqi0wQTiVU74yfi4DF6eRsMQ9/iHQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.15.3"
+ }
+ },
+ "node_modules/@tiptap/extensions": {
+ "version": "3.15.3",
+ "resolved": "https://registry.npmmirror.com/@tiptap/extensions/-/extensions-3.15.3.tgz",
+ "integrity": "sha512-ycx/BgxR4rc9tf3ZyTdI98Z19yKLFfqM3UN+v42ChuIwkzyr9zyp7kG8dB9xN2lNqrD+5y/HyJobz/VJ7T90gA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.15.3",
+ "@tiptap/pm": "^3.15.3"
+ }
+ },
+ "node_modules/@tiptap/pm": {
+ "version": "3.15.3",
+ "resolved": "https://registry.npmmirror.com/@tiptap/pm/-/pm-3.15.3.tgz",
+ "integrity": "sha512-Zm1BaU1TwFi3CQiisxjgnzzIus+q40bBKWLqXf6WEaus8Z6+vo1MT2pU52dBCMIRaW9XNDq3E5cmGtMc1AlveA==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-changeset": "^2.3.0",
+ "prosemirror-collab": "^1.3.1",
+ "prosemirror-commands": "^1.6.2",
+ "prosemirror-dropcursor": "^1.8.1",
+ "prosemirror-gapcursor": "^1.3.2",
+ "prosemirror-history": "^1.4.1",
+ "prosemirror-inputrules": "^1.4.0",
+ "prosemirror-keymap": "^1.2.2",
+ "prosemirror-markdown": "^1.13.1",
+ "prosemirror-menu": "^1.2.4",
+ "prosemirror-model": "^1.24.1",
+ "prosemirror-schema-basic": "^1.2.3",
+ "prosemirror-schema-list": "^1.5.0",
+ "prosemirror-state": "^1.4.3",
+ "prosemirror-tables": "^1.6.4",
+ "prosemirror-trailing-node": "^3.0.0",
+ "prosemirror-transform": "^1.10.2",
+ "prosemirror-view": "^1.38.1"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ }
+ },
+ "node_modules/@tiptap/react": {
+ "version": "3.15.3",
+ "resolved": "https://registry.npmmirror.com/@tiptap/react/-/react-3.15.3.tgz",
+ "integrity": "sha512-XvouB+Hrqw8yFmZLPEh+HWlMeRSjZfHSfWfWuw5d8LSwnxnPeu3Bg/rjHrRrdwb+7FumtzOnNWMorpb/PSOttQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/use-sync-external-store": "^0.0.6",
+ "fast-equals": "^5.3.3",
+ "use-sync-external-store": "^1.4.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "optionalDependencies": {
+ "@tiptap/extension-bubble-menu": "^3.15.3",
+ "@tiptap/extension-floating-menu": "^3.15.3"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.15.3",
+ "@tiptap/pm": "^3.15.3",
+ "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
+ "@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react": "^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/@tiptap/starter-kit": {
+ "version": "3.15.3",
+ "resolved": "https://registry.npmmirror.com/@tiptap/starter-kit/-/starter-kit-3.15.3.tgz",
+ "integrity": "sha512-ia+eQr9Mt1ln2UO+kK4kFTJOrZK4GhvZXFjpCCYuHtco3rhr2fZAIxEEY4cl/vo5VO5WWyPqxhkFeLcoWmNjSw==",
+ "license": "MIT",
+ "dependencies": {
+ "@tiptap/core": "^3.15.3",
+ "@tiptap/extension-blockquote": "^3.15.3",
+ "@tiptap/extension-bold": "^3.15.3",
+ "@tiptap/extension-bullet-list": "^3.15.3",
+ "@tiptap/extension-code": "^3.15.3",
+ "@tiptap/extension-code-block": "^3.15.3",
+ "@tiptap/extension-document": "^3.15.3",
+ "@tiptap/extension-dropcursor": "^3.15.3",
+ "@tiptap/extension-gapcursor": "^3.15.3",
+ "@tiptap/extension-hard-break": "^3.15.3",
+ "@tiptap/extension-heading": "^3.15.3",
+ "@tiptap/extension-horizontal-rule": "^3.15.3",
+ "@tiptap/extension-italic": "^3.15.3",
+ "@tiptap/extension-link": "^3.15.3",
+ "@tiptap/extension-list": "^3.15.3",
+ "@tiptap/extension-list-item": "^3.15.3",
+ "@tiptap/extension-list-keymap": "^3.15.3",
+ "@tiptap/extension-ordered-list": "^3.15.3",
+ "@tiptap/extension-paragraph": "^3.15.3",
+ "@tiptap/extension-strike": "^3.15.3",
+ "@tiptap/extension-text": "^3.15.3",
+ "@tiptap/extension-underline": "^3.15.3",
+ "@tiptap/extensions": "^3.15.3",
+ "@tiptap/pm": "^3.15.3"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ }
+ },
+ "node_modules/@types/babel__core": {
+ "version": "7.20.5",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.20.7",
+ "@babel/types": "^7.20.7",
+ "@types/babel__generator": "*",
+ "@types/babel__template": "*",
+ "@types/babel__traverse": "*"
+ }
+ },
+ "node_modules/@types/babel__generator": {
+ "version": "7.27.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__template": {
+ "version": "7.4.4",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.1.0",
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__traverse": {
+ "version": "7.28.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.28.2"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.8",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/json-schema": {
+ "version": "7.0.15",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/linkify-it": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmmirror.com/@types/linkify-it/-/linkify-it-5.0.0.tgz",
+ "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
+ "license": "MIT"
+ },
+ "node_modules/@types/markdown-it": {
+ "version": "14.1.2",
+ "resolved": "https://registry.npmmirror.com/@types/markdown-it/-/markdown-it-14.1.2.tgz",
+ "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/linkify-it": "^5",
+ "@types/mdurl": "^2"
+ }
+ },
+ "node_modules/@types/mdurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmmirror.com/@types/mdurl/-/mdurl-2.0.0.tgz",
+ "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "25.0.3",
+ "resolved": "https://registry.npmmirror.com/@types/node/-/node-25.0.3.tgz",
+ "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~7.16.0"
+ }
+ },
+ "node_modules/@types/prop-types": {
+ "version": "15.7.15",
+ "license": "MIT"
+ },
+ "node_modules/@types/quill": {
+ "version": "1.3.10",
+ "resolved": "https://registry.npmmirror.com/@types/quill/-/quill-1.3.10.tgz",
+ "integrity": "sha512-IhW3fPW+bkt9MLNlycw8u8fWb7oO7W5URC9MfZYHBlA24rex9rs23D5DETChu1zvgVdc5ka64ICjJOgQMr6Shw==",
+ "license": "MIT",
+ "dependencies": {
+ "parchment": "^1.1.2"
+ }
+ },
+ "node_modules/@types/quill/node_modules/parchment": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmmirror.com/parchment/-/parchment-1.1.4.tgz",
+ "integrity": "sha512-J5FBQt/pM2inLzg4hEWmzQx/8h8D0CiDxaG3vyp9rKrQRSDgBlhjdP5jQGgosEajXPSQouXGHOmVdgo7QmJuOg==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@types/react": {
+ "version": "18.3.26",
+ "license": "MIT",
+ "dependencies": {
+ "@types/prop-types": "*",
+ "csstype": "^3.0.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "18.3.7",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^18.0.0"
+ }
+ },
+ "node_modules/@types/semver": {
+ "version": "7.7.1",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/use-sync-external-store": {
+ "version": "0.0.6",
+ "resolved": "https://registry.npmmirror.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
+ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
+ "license": "MIT"
+ },
+ "node_modules/@typescript-eslint/eslint-plugin": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmmirror.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz",
+ "integrity": "sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/regexpp": "^4.5.1",
+ "@typescript-eslint/scope-manager": "6.21.0",
+ "@typescript-eslint/type-utils": "6.21.0",
+ "@typescript-eslint/utils": "6.21.0",
+ "@typescript-eslint/visitor-keys": "6.21.0",
+ "debug": "^4.3.4",
+ "graphemer": "^1.4.0",
+ "ignore": "^5.2.4",
+ "natural-compare": "^1.4.0",
+ "semver": "^7.5.4",
+ "ts-api-utils": "^1.0.1"
+ },
+ "engines": {
+ "node": "^16.0.0 || >=18.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "@typescript-eslint/parser": "^6.0.0 || ^6.0.0-alpha",
+ "eslint": "^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@typescript-eslint/parser": {
+ "version": "6.21.0",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "@typescript-eslint/scope-manager": "6.21.0",
+ "@typescript-eslint/types": "6.21.0",
+ "@typescript-eslint/typescript-estree": "6.21.0",
+ "@typescript-eslint/visitor-keys": "6.21.0",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": "^16.0.0 || >=18.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@typescript-eslint/scope-manager": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmmirror.com/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz",
+ "integrity": "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "6.21.0",
+ "@typescript-eslint/visitor-keys": "6.21.0"
+ },
+ "engines": {
+ "node": "^16.0.0 || >=18.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/type-utils": {
+ "version": "6.21.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/typescript-estree": "6.21.0",
+ "@typescript-eslint/utils": "6.21.0",
+ "debug": "^4.3.4",
+ "ts-api-utils": "^1.0.1"
+ },
+ "engines": {
+ "node": "^16.0.0 || >=18.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@typescript-eslint/types": {
+ "version": "6.21.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^16.0.0 || >=18.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree": {
+ "version": "6.21.0",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "@typescript-eslint/types": "6.21.0",
+ "@typescript-eslint/visitor-keys": "6.21.0",
+ "debug": "^4.3.4",
+ "globby": "^11.1.0",
+ "is-glob": "^4.0.3",
+ "minimatch": "9.0.3",
+ "semver": "^7.5.4",
+ "ts-api-utils": "^1.0.1"
+ },
+ "engines": {
+ "node": "^16.0.0 || >=18.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@typescript-eslint/utils": {
+ "version": "6.21.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.4.0",
+ "@types/json-schema": "^7.0.12",
+ "@types/semver": "^7.5.0",
+ "@typescript-eslint/scope-manager": "6.21.0",
+ "@typescript-eslint/types": "6.21.0",
+ "@typescript-eslint/typescript-estree": "6.21.0",
+ "semver": "^7.5.4"
+ },
+ "engines": {
+ "node": "^16.0.0 || >=18.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^7.0.0 || ^8.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/visitor-keys": {
+ "version": "6.21.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "6.21.0",
+ "eslint-visitor-keys": "^3.4.1"
+ },
+ "engines": {
+ "node": "^16.0.0 || >=18.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@ungap/structured-clone": {
+ "version": "1.3.0",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "4.7.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.28.0",
+ "@babel/plugin-transform-react-jsx-self": "^7.27.1",
+ "@babel/plugin-transform-react-jsx-source": "^7.27.1",
+ "@rolldown/pluginutils": "1.0.0-beta.27",
+ "@types/babel__core": "^7.20.5",
+ "react-refresh": "^0.17.0"
+ },
+ "engines": {
+ "node": "^14.18.0 || >=16.0.0"
+ },
+ "peerDependencies": {
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
+ }
+ },
+ "node_modules/acorn": {
+ "version": "8.15.0",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/acorn-jsx": {
+ "version": "5.3.2",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "6.12.6",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/antd": {
+ "version": "5.27.5",
+ "resolved": "https://registry.npmmirror.com/antd/-/antd-5.27.5.tgz",
+ "integrity": "sha512-Ehd9mqtHvJ1clon1yJ/1BTV6eX/3SH2YXZZPTHUk8XdzXFwUioI+Lht47s+MaHIUBY77RnZrmtKwwR+VVu0l7A==",
+ "license": "MIT",
+ "dependencies": {
+ "@ant-design/colors": "^7.2.1",
+ "@ant-design/cssinjs": "^1.23.0",
+ "@ant-design/cssinjs-utils": "^1.1.3",
+ "@ant-design/fast-color": "^2.0.6",
+ "@ant-design/icons": "^5.6.1",
+ "@ant-design/react-slick": "~1.1.2",
+ "@babel/runtime": "^7.26.0",
+ "@rc-component/color-picker": "~2.0.1",
+ "@rc-component/mutate-observer": "^1.1.0",
+ "@rc-component/qrcode": "~1.0.1",
+ "@rc-component/tour": "~1.15.1",
+ "@rc-component/trigger": "^2.3.0",
+ "classnames": "^2.5.1",
+ "copy-to-clipboard": "^3.3.3",
+ "dayjs": "^1.11.11",
+ "rc-cascader": "~3.34.0",
+ "rc-checkbox": "~3.5.0",
+ "rc-collapse": "~3.9.0",
+ "rc-dialog": "~9.6.0",
+ "rc-drawer": "~7.3.0",
+ "rc-dropdown": "~4.2.1",
+ "rc-field-form": "~2.7.0",
+ "rc-image": "~7.12.0",
+ "rc-input": "~1.8.0",
+ "rc-input-number": "~9.5.0",
+ "rc-mentions": "~2.20.0",
+ "rc-menu": "~9.16.1",
+ "rc-motion": "^2.9.5",
+ "rc-notification": "~5.6.4",
+ "rc-pagination": "~5.1.0",
+ "rc-picker": "~4.11.3",
+ "rc-progress": "~4.0.0",
+ "rc-rate": "~2.13.1",
+ "rc-resize-observer": "^1.4.3",
+ "rc-segmented": "~2.7.0",
+ "rc-select": "~14.16.8",
+ "rc-slider": "~11.1.9",
+ "rc-steps": "~6.0.1",
+ "rc-switch": "~4.1.0",
+ "rc-table": "~7.54.0",
+ "rc-tabs": "~15.7.0",
+ "rc-textarea": "~1.10.2",
+ "rc-tooltip": "~6.4.0",
+ "rc-tree": "~5.13.1",
+ "rc-tree-select": "~5.27.0",
+ "rc-upload": "~4.9.2",
+ "rc-util": "^5.44.4",
+ "scroll-into-view-if-needed": "^3.1.0",
+ "throttle-debounce": "^5.0.2"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/ant-design"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "license": "Python-2.0"
+ },
+ "node_modules/array-union": {
+ "version": "2.1.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/asynckit": {
+ "version": "0.4.0",
+ "license": "MIT"
+ },
+ "node_modules/axios": {
+ "version": "1.12.2",
+ "resolved": "https://registry.npmmirror.com/axios/-/axios-1.12.2.tgz",
+ "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
+ "license": "MIT",
+ "dependencies": {
+ "follow-redirects": "^1.15.6",
+ "form-data": "^4.0.4",
+ "proxy-from-env": "^1.1.0"
+ }
+ },
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.9.11",
+ "resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz",
+ "integrity": "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.js"
+ }
+ },
+ "node_modules/brace-expansion": {
+ "version": "2.0.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/braces": {
+ "version": "3.0.3",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fill-range": "^7.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.26.3",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "baseline-browser-mapping": "^2.8.9",
+ "caniuse-lite": "^1.0.30001746",
+ "electron-to-chromium": "^1.5.227",
+ "node-releases": "^2.0.21",
+ "update-browserslist-db": "^1.1.3"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/browserslist/node_modules/caniuse-lite": {
+ "version": "1.0.30001750",
+ "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001750.tgz",
+ "integrity": "sha512-cuom0g5sdX6rw00qOoLNSFCJ9/mYIsuSOA+yzpDw8eopiFqcVwQvZHqov0vmEighRxX++cfC0Vg1G+1Iy/mSpQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/call-bind": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmmirror.com/call-bind/-/call-bind-1.0.8.tgz",
+ "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.0",
+ "es-define-property": "^1.0.0",
+ "get-intrinsic": "^1.2.4",
+ "set-function-length": "^1.2.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmmirror.com/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/callsites": {
+ "version": "3.1.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/chalk": {
+ "version": "4.1.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/classnames": {
+ "version": "2.5.1",
+ "license": "MIT"
+ },
+ "node_modules/clone": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmmirror.com/clone/-/clone-2.1.2.tgz",
+ "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/combined-stream": {
+ "version": "1.0.8",
+ "license": "MIT",
+ "dependencies": {
+ "delayed-stream": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/compute-scroll-into-view": {
+ "version": "3.1.1",
+ "license": "MIT"
+ },
+ "node_modules/concat-map": {
+ "version": "0.0.1",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/copy-to-clipboard": {
+ "version": "3.3.3",
+ "license": "MIT",
+ "dependencies": {
+ "toggle-selection": "^1.0.6"
+ }
+ },
+ "node_modules/crelt": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmmirror.com/crelt/-/crelt-1.0.6.tgz",
+ "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
+ "license": "MIT"
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.1.3",
+ "license": "MIT"
+ },
+ "node_modules/dayjs": {
+ "version": "1.11.18",
+ "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.18.tgz",
+ "integrity": "sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==",
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/deep-equal": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmmirror.com/deep-equal/-/deep-equal-1.1.2.tgz",
+ "integrity": "sha512-5tdhKF6DbU7iIzrIOa1AOUt39ZRm13cmL1cGEh//aqR8x9+tNfbywRf0n5FD/18OKMdo7DNEtrX2t22ZAkI+eg==",
+ "license": "MIT",
+ "dependencies": {
+ "is-arguments": "^1.1.1",
+ "is-date-object": "^1.0.5",
+ "is-regex": "^1.1.4",
+ "object-is": "^1.1.5",
+ "object-keys": "^1.1.1",
+ "regexp.prototype.flags": "^1.5.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/deep-is": {
+ "version": "0.1.4",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/define-data-property": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmmirror.com/define-data-property/-/define-data-property-1.1.4.tgz",
+ "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
+ "license": "MIT",
+ "dependencies": {
+ "es-define-property": "^1.0.0",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/define-properties": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmmirror.com/define-properties/-/define-properties-1.2.1.tgz",
+ "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.0.1",
+ "has-property-descriptors": "^1.0.0",
+ "object-keys": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/delayed-stream": {
+ "version": "1.0.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/dir-glob": {
+ "version": "3.0.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-type": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/doctrine": {
+ "version": "3.0.0",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "esutils": "^2.0.2"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.237",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/entities": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmmirror.com/entities/-/entities-4.5.0.tgz",
+ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.1",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-set-tostringtag": {
+ "version": "2.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.21.5",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.21.5",
+ "@esbuild/android-arm": "0.21.5",
+ "@esbuild/android-arm64": "0.21.5",
+ "@esbuild/android-x64": "0.21.5",
+ "@esbuild/darwin-arm64": "0.21.5",
+ "@esbuild/darwin-x64": "0.21.5",
+ "@esbuild/freebsd-arm64": "0.21.5",
+ "@esbuild/freebsd-x64": "0.21.5",
+ "@esbuild/linux-arm": "0.21.5",
+ "@esbuild/linux-arm64": "0.21.5",
+ "@esbuild/linux-ia32": "0.21.5",
+ "@esbuild/linux-loong64": "0.21.5",
+ "@esbuild/linux-mips64el": "0.21.5",
+ "@esbuild/linux-ppc64": "0.21.5",
+ "@esbuild/linux-riscv64": "0.21.5",
+ "@esbuild/linux-s390x": "0.21.5",
+ "@esbuild/linux-x64": "0.21.5",
+ "@esbuild/netbsd-x64": "0.21.5",
+ "@esbuild/openbsd-x64": "0.21.5",
+ "@esbuild/sunos-x64": "0.21.5",
+ "@esbuild/win32-arm64": "0.21.5",
+ "@esbuild/win32-ia32": "0.21.5",
+ "@esbuild/win32-x64": "0.21.5"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/aix-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/android-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
+ "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/android-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
+ "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/android-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
+ "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/darwin-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
+ "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/darwin-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
+ "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
+ "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/freebsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
+ "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/linux-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
+ "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/linux-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
+ "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/linux-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
+ "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/linux-loong64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
+ "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/linux-mips64el": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
+ "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/linux-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/linux-riscv64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
+ "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/linux-s390x": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
+ "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/linux-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
+ "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/netbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/openbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/sunos-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
+ "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/win32-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
+ "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/win32-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
+ "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/win32-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
+ "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/eslint": {
+ "version": "8.57.1",
+ "resolved": "https://registry.npmmirror.com/eslint/-/eslint-8.57.1.tgz",
+ "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
+ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.2.0",
+ "@eslint-community/regexpp": "^4.6.1",
+ "@eslint/eslintrc": "^2.1.4",
+ "@eslint/js": "8.57.1",
+ "@humanwhocodes/config-array": "^0.13.0",
+ "@humanwhocodes/module-importer": "^1.0.1",
+ "@nodelib/fs.walk": "^1.2.8",
+ "@ungap/structured-clone": "^1.2.0",
+ "ajv": "^6.12.4",
+ "chalk": "^4.0.0",
+ "cross-spawn": "^7.0.2",
+ "debug": "^4.3.2",
+ "doctrine": "^3.0.0",
+ "escape-string-regexp": "^4.0.0",
+ "eslint-scope": "^7.2.2",
+ "eslint-visitor-keys": "^3.4.3",
+ "espree": "^9.6.1",
+ "esquery": "^1.4.2",
+ "esutils": "^2.0.2",
+ "fast-deep-equal": "^3.1.3",
+ "file-entry-cache": "^6.0.1",
+ "find-up": "^5.0.0",
+ "glob-parent": "^6.0.2",
+ "globals": "^13.19.0",
+ "graphemer": "^1.4.0",
+ "ignore": "^5.2.0",
+ "imurmurhash": "^0.1.4",
+ "is-glob": "^4.0.0",
+ "is-path-inside": "^3.0.3",
+ "js-yaml": "^4.1.0",
+ "json-stable-stringify-without-jsonify": "^1.0.1",
+ "levn": "^0.4.1",
+ "lodash.merge": "^4.6.2",
+ "minimatch": "^3.1.2",
+ "natural-compare": "^1.4.0",
+ "optionator": "^0.9.3",
+ "strip-ansi": "^6.0.1",
+ "text-table": "^0.2.0"
+ },
+ "bin": {
+ "eslint": "bin/eslint.js"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint-plugin-react-hooks": {
+ "version": "4.6.2",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0"
+ }
+ },
+ "node_modules/eslint-plugin-react-refresh": {
+ "version": "0.4.24",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "eslint": ">=8.40"
+ }
+ },
+ "node_modules/eslint-scope": {
+ "version": "7.2.2",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "esrecurse": "^4.3.0",
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint-visitor-keys": {
+ "version": "3.4.3",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint/node_modules/brace-expansion": {
+ "version": "1.1.12",
+ "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.12.tgz",
+ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/eslint/node_modules/minimatch": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz",
+ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/espree": {
+ "version": "9.6.1",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "acorn": "^8.9.0",
+ "acorn-jsx": "^5.3.2",
+ "eslint-visitor-keys": "^3.4.1"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/esquery": {
+ "version": "1.6.0",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "estraverse": "^5.1.0"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/esrecurse": {
+ "version": "4.3.0",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/estraverse": {
+ "version": "5.3.0",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/esutils": {
+ "version": "2.0.3",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/eventemitter3": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmmirror.com/eventemitter3/-/eventemitter3-5.0.1.tgz",
+ "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
+ "license": "MIT"
+ },
+ "node_modules/extend": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmmirror.com/extend/-/extend-3.0.2.tgz",
+ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
+ "license": "MIT"
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-diff": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmmirror.com/fast-diff/-/fast-diff-1.3.0.tgz",
+ "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/fast-equals": {
+ "version": "5.4.0",
+ "resolved": "https://registry.npmmirror.com/fast-equals/-/fast-equals-5.4.0.tgz",
+ "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/fast-glob": {
+ "version": "3.3.3",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "^2.0.2",
+ "@nodelib/fs.walk": "^1.2.3",
+ "glob-parent": "^5.1.2",
+ "merge2": "^1.3.0",
+ "micromatch": "^4.0.8"
+ },
+ "engines": {
+ "node": ">=8.6.0"
+ }
+ },
+ "node_modules/fast-glob/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/fast-json-stable-stringify": {
+ "version": "2.1.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-levenshtein": {
+ "version": "2.0.6",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fastq": {
+ "version": "1.19.1",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "reusify": "^1.0.4"
+ }
+ },
+ "node_modules/file-entry-cache": {
+ "version": "6.0.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flat-cache": "^3.0.4"
+ },
+ "engines": {
+ "node": "^10.12.0 || >=12.0.0"
+ }
+ },
+ "node_modules/fill-range": {
+ "version": "7.1.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/find-up": {
+ "version": "5.0.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "locate-path": "^6.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/flat-cache": {
+ "version": "3.2.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flatted": "^3.2.9",
+ "keyv": "^4.5.3",
+ "rimraf": "^3.0.2"
+ },
+ "engines": {
+ "node": "^10.12.0 || >=12.0.0"
+ }
+ },
+ "node_modules/flatted": {
+ "version": "3.3.3",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/follow-redirects": {
+ "version": "1.15.11",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/RubenVerborgh"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0"
+ },
+ "peerDependenciesMeta": {
+ "debug": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/form-data": {
+ "version": "4.0.4",
+ "license": "MIT",
+ "dependencies": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "es-set-tostringtag": "^2.1.0",
+ "hasown": "^2.0.2",
+ "mime-types": "^2.1.12"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/fs.realpath": {
+ "version": "1.0.0",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/functions-have-names": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmmirror.com/functions-have-names/-/functions-have-names-1.2.3.tgz",
+ "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/glob": {
+ "version": "7.2.3",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "fs.realpath": "^1.0.0",
+ "inflight": "^1.0.4",
+ "inherits": "2",
+ "minimatch": "^3.1.1",
+ "once": "^1.3.0",
+ "path-is-absolute": "^1.0.0"
+ },
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "6.0.2",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/glob/node_modules/brace-expansion": {
+ "version": "1.1.12",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/glob/node_modules/minimatch": {
+ "version": "3.1.2",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/globals": {
+ "version": "13.24.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "type-fest": "^0.20.2"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/globby": {
+ "version": "11.1.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "array-union": "^2.1.0",
+ "dir-glob": "^3.0.1",
+ "fast-glob": "^3.2.9",
+ "ignore": "^5.2.0",
+ "merge2": "^1.4.1",
+ "slash": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/graphemer": {
+ "version": "1.4.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/has-property-descriptors": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmmirror.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
+ "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
+ "license": "MIT",
+ "dependencies": {
+ "es-define-property": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-tostringtag": {
+ "version": "1.0.2",
+ "license": "MIT",
+ "dependencies": {
+ "has-symbols": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.2",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ignore": {
+ "version": "5.3.2",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/import-fresh": {
+ "version": "3.3.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "parent-module": "^1.0.0",
+ "resolve-from": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/imurmurhash": {
+ "version": "0.1.4",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8.19"
+ }
+ },
+ "node_modules/inflight": {
+ "version": "1.0.6",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "once": "^1.3.0",
+ "wrappy": "1"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/is-arguments": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmmirror.com/is-arguments/-/is-arguments-1.2.0.tgz",
+ "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-date-object": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmmirror.com/is-date-object/-/is-date-object-1.1.0.tgz",
+ "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
+ "node_modules/is-path-inside": {
+ "version": "3.0.3",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-regex": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmmirror.com/is-regex/-/is-regex-1.2.1.tgz",
+ "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "gopd": "^1.2.0",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "license": "MIT"
+ },
+ "node_modules/js-yaml": {
+ "version": "4.1.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json-buffer": {
+ "version": "3.0.1",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "0.4.1",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-stable-stringify-without-jsonify": {
+ "version": "1.0.1",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json2mq": {
+ "version": "0.2.0",
+ "license": "MIT",
+ "dependencies": {
+ "string-convert": "^0.2.0"
+ }
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/keyv": {
+ "version": "4.5.4",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "json-buffer": "3.0.1"
+ }
+ },
+ "node_modules/levn": {
+ "version": "0.4.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1",
+ "type-check": "~0.4.0"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/linkify-it": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmmirror.com/linkify-it/-/linkify-it-5.0.0.tgz",
+ "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
+ "license": "MIT",
+ "dependencies": {
+ "uc.micro": "^2.0.0"
+ }
+ },
+ "node_modules/linkifyjs": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmmirror.com/linkifyjs/-/linkifyjs-4.3.2.tgz",
+ "integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==",
+ "license": "MIT"
+ },
+ "node_modules/locate-path": {
+ "version": "6.0.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-locate": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/lodash": {
+ "version": "4.17.21",
+ "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz",
+ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
+ "license": "MIT"
+ },
+ "node_modules/lodash-es": {
+ "version": "4.17.22",
+ "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.22.tgz",
+ "integrity": "sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.clonedeep": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmmirror.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
+ "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isequal": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmmirror.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
+ "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
+ "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
+ "license": "MIT"
+ },
+ "node_modules/lodash.merge": {
+ "version": "4.6.2",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/loose-envify": {
+ "version": "1.4.0",
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ },
+ "bin": {
+ "loose-envify": "cli.js"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "5.1.1",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/markdown-it": {
+ "version": "14.1.0",
+ "resolved": "https://registry.npmmirror.com/markdown-it/-/markdown-it-14.1.0.tgz",
+ "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==",
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1",
+ "entities": "^4.4.0",
+ "linkify-it": "^5.0.0",
+ "mdurl": "^2.0.0",
+ "punycode.js": "^2.3.1",
+ "uc.micro": "^2.1.0"
+ },
+ "bin": {
+ "markdown-it": "bin/markdown-it.mjs"
+ }
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/mdurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmmirror.com/mdurl/-/mdurl-2.0.0.tgz",
+ "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
+ "license": "MIT"
+ },
+ "node_modules/merge2": {
+ "version": "1.4.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/micromatch": {
+ "version": "4.0.8",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "braces": "^3.0.3",
+ "picomatch": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=8.6"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "9.0.3",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/natural-compare": {
+ "version": "1.4.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.23",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/object-is": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmmirror.com/object-is/-/object-is-1.1.6.tgz",
+ "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "define-properties": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/object-keys": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmmirror.com/object-keys/-/object-keys-1.1.1.tgz",
+ "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/optionator": {
+ "version": "0.9.4",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "deep-is": "^0.1.3",
+ "fast-levenshtein": "^2.0.6",
+ "levn": "^0.4.1",
+ "prelude-ls": "^1.2.1",
+ "type-check": "^0.4.0",
+ "word-wrap": "^1.2.5"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/orderedmap": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmmirror.com/orderedmap/-/orderedmap-2.1.1.tgz",
+ "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
+ "license": "MIT"
+ },
+ "node_modules/p-limit": {
+ "version": "3.1.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "yocto-queue": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-locate": {
+ "version": "5.0.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/parchment": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmmirror.com/parchment/-/parchment-3.0.0.tgz",
+ "integrity": "sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/parent-module": {
+ "version": "1.0.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "callsites": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/path-exists": {
+ "version": "4.0.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-is-absolute": {
+ "version": "1.0.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-type": {
+ "version": "4.0.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "2.3.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.6",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/prelude-ls": {
+ "version": "1.2.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/prosemirror-changeset": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmmirror.com/prosemirror-changeset/-/prosemirror-changeset-2.3.1.tgz",
+ "integrity": "sha512-j0kORIBm8ayJNl3zQvD1TTPHJX3g042et6y/KQhZhnPrruO8exkTgG8X+NRpj7kIyMMEx74Xb3DyMIBtO0IKkQ==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-transform": "^1.0.0"
+ }
+ },
+ "node_modules/prosemirror-collab": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmmirror.com/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz",
+ "integrity": "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-state": "^1.0.0"
+ }
+ },
+ "node_modules/prosemirror-commands": {
+ "version": "1.7.1",
+ "resolved": "https://registry.npmmirror.com/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz",
+ "integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-model": "^1.0.0",
+ "prosemirror-state": "^1.0.0",
+ "prosemirror-transform": "^1.10.2"
+ }
+ },
+ "node_modules/prosemirror-dropcursor": {
+ "version": "1.8.2",
+ "resolved": "https://registry.npmmirror.com/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz",
+ "integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-state": "^1.0.0",
+ "prosemirror-transform": "^1.1.0",
+ "prosemirror-view": "^1.1.0"
+ }
+ },
+ "node_modules/prosemirror-gapcursor": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmmirror.com/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.0.tgz",
+ "integrity": "sha512-z00qvurSdCEWUIulij/isHaqu4uLS8r/Fi61IbjdIPJEonQgggbJsLnstW7Lgdk4zQ68/yr6B6bf7sJXowIgdQ==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-keymap": "^1.0.0",
+ "prosemirror-model": "^1.0.0",
+ "prosemirror-state": "^1.0.0",
+ "prosemirror-view": "^1.0.0"
+ }
+ },
+ "node_modules/prosemirror-history": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmmirror.com/prosemirror-history/-/prosemirror-history-1.5.0.tgz",
+ "integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-state": "^1.2.2",
+ "prosemirror-transform": "^1.0.0",
+ "prosemirror-view": "^1.31.0",
+ "rope-sequence": "^1.3.0"
+ }
+ },
+ "node_modules/prosemirror-inputrules": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmmirror.com/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz",
+ "integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-state": "^1.0.0",
+ "prosemirror-transform": "^1.0.0"
+ }
+ },
+ "node_modules/prosemirror-keymap": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmmirror.com/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
+ "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-state": "^1.0.0",
+ "w3c-keyname": "^2.2.0"
+ }
+ },
+ "node_modules/prosemirror-markdown": {
+ "version": "1.13.2",
+ "resolved": "https://registry.npmmirror.com/prosemirror-markdown/-/prosemirror-markdown-1.13.2.tgz",
+ "integrity": "sha512-FPD9rHPdA9fqzNmIIDhhnYQ6WgNoSWX9StUZ8LEKapaXU9i6XgykaHKhp6XMyXlOWetmaFgGDS/nu/w9/vUc5g==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/markdown-it": "^14.0.0",
+ "markdown-it": "^14.0.0",
+ "prosemirror-model": "^1.25.0"
+ }
+ },
+ "node_modules/prosemirror-menu": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmmirror.com/prosemirror-menu/-/prosemirror-menu-1.2.5.tgz",
+ "integrity": "sha512-qwXzynnpBIeg1D7BAtjOusR+81xCp53j7iWu/IargiRZqRjGIlQuu1f3jFi+ehrHhWMLoyOQTSRx/IWZJqOYtQ==",
+ "license": "MIT",
+ "dependencies": {
+ "crelt": "^1.0.0",
+ "prosemirror-commands": "^1.0.0",
+ "prosemirror-history": "^1.0.0",
+ "prosemirror-state": "^1.0.0"
+ }
+ },
+ "node_modules/prosemirror-model": {
+ "version": "1.25.4",
+ "resolved": "https://registry.npmmirror.com/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
+ "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
+ "license": "MIT",
+ "dependencies": {
+ "orderedmap": "^2.0.0"
+ }
+ },
+ "node_modules/prosemirror-schema-basic": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmmirror.com/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz",
+ "integrity": "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-model": "^1.25.0"
+ }
+ },
+ "node_modules/prosemirror-schema-list": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmmirror.com/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
+ "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-model": "^1.0.0",
+ "prosemirror-state": "^1.0.0",
+ "prosemirror-transform": "^1.7.3"
+ }
+ },
+ "node_modules/prosemirror-state": {
+ "version": "1.4.4",
+ "resolved": "https://registry.npmmirror.com/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
+ "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-model": "^1.0.0",
+ "prosemirror-transform": "^1.0.0",
+ "prosemirror-view": "^1.27.0"
+ }
+ },
+ "node_modules/prosemirror-tables": {
+ "version": "1.8.5",
+ "resolved": "https://registry.npmmirror.com/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
+ "integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-keymap": "^1.2.3",
+ "prosemirror-model": "^1.25.4",
+ "prosemirror-state": "^1.4.4",
+ "prosemirror-transform": "^1.10.5",
+ "prosemirror-view": "^1.41.4"
+ }
+ },
+ "node_modules/prosemirror-trailing-node": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmmirror.com/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz",
+ "integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@remirror/core-constants": "3.0.0",
+ "escape-string-regexp": "^4.0.0"
+ },
+ "peerDependencies": {
+ "prosemirror-model": "^1.22.1",
+ "prosemirror-state": "^1.4.2",
+ "prosemirror-view": "^1.33.8"
+ }
+ },
+ "node_modules/prosemirror-transform": {
+ "version": "1.10.5",
+ "resolved": "https://registry.npmmirror.com/prosemirror-transform/-/prosemirror-transform-1.10.5.tgz",
+ "integrity": "sha512-RPDQCxIDhIBb1o36xxwsaeAvivO8VLJcgBtzmOwQ64bMtsVFh5SSuJ6dWSxO1UsHTiTXPCgQm3PDJt7p6IOLbw==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-model": "^1.21.0"
+ }
+ },
+ "node_modules/prosemirror-view": {
+ "version": "1.41.4",
+ "resolved": "https://registry.npmmirror.com/prosemirror-view/-/prosemirror-view-1.41.4.tgz",
+ "integrity": "sha512-WkKgnyjNncri03Gjaz3IFWvCAE94XoiEgvtr0/r2Xw7R8/IjK3sKLSiDoCHWcsXSAinVaKlGRZDvMCsF1kbzjA==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-model": "^1.20.0",
+ "prosemirror-state": "^1.0.0",
+ "prosemirror-transform": "^1.1.0"
+ }
+ },
+ "node_modules/proxy-from-env": {
+ "version": "1.1.0",
+ "license": "MIT"
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/punycode.js": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmmirror.com/punycode.js/-/punycode.js-2.3.1.tgz",
+ "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/queue-microtask": {
+ "version": "1.2.3",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/quill": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmmirror.com/quill/-/quill-2.0.3.tgz",
+ "integrity": "sha512-xEYQBqfYx/sfb33VJiKnSJp8ehloavImQ2A6564GAbqG55PGw1dAWUn1MUbQB62t0azawUS2CZZhWCjO8gRvTw==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "eventemitter3": "^5.0.1",
+ "lodash-es": "^4.17.21",
+ "parchment": "^3.0.0",
+ "quill-delta": "^5.1.0"
+ },
+ "engines": {
+ "npm": ">=8.2.3"
+ }
+ },
+ "node_modules/quill-delta": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmmirror.com/quill-delta/-/quill-delta-5.1.0.tgz",
+ "integrity": "sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-diff": "^1.3.0",
+ "lodash.clonedeep": "^4.5.0",
+ "lodash.isequal": "^4.5.0"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ }
+ },
+ "node_modules/rc-cascader": {
+ "version": "3.34.0",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.25.7",
+ "classnames": "^2.3.1",
+ "rc-select": "~14.16.2",
+ "rc-tree": "~5.13.0",
+ "rc-util": "^5.43.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/rc-checkbox": {
+ "version": "3.5.0",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.10.1",
+ "classnames": "^2.3.2",
+ "rc-util": "^5.25.2"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/rc-collapse": {
+ "version": "3.9.0",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.10.1",
+ "classnames": "2.x",
+ "rc-motion": "^2.3.4",
+ "rc-util": "^5.27.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/rc-dialog": {
+ "version": "9.6.0",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.10.1",
+ "@rc-component/portal": "^1.0.0-8",
+ "classnames": "^2.2.6",
+ "rc-motion": "^2.3.0",
+ "rc-util": "^5.21.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/rc-drawer": {
+ "version": "7.3.0",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.23.9",
+ "@rc-component/portal": "^1.1.1",
+ "classnames": "^2.2.6",
+ "rc-motion": "^2.6.1",
+ "rc-util": "^5.38.1"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/rc-dropdown": {
+ "version": "4.2.1",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.18.3",
+ "@rc-component/trigger": "^2.0.0",
+ "classnames": "^2.2.6",
+ "rc-util": "^5.44.1"
+ },
+ "peerDependencies": {
+ "react": ">=16.11.0",
+ "react-dom": ">=16.11.0"
+ }
+ },
+ "node_modules/rc-field-form": {
+ "version": "2.7.0",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.18.0",
+ "@rc-component/async-validator": "^5.0.3",
+ "rc-util": "^5.32.2"
+ },
+ "engines": {
+ "node": ">=8.x"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/rc-image": {
+ "version": "7.12.0",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.11.2",
+ "@rc-component/portal": "^1.0.2",
+ "classnames": "^2.2.6",
+ "rc-dialog": "~9.6.0",
+ "rc-motion": "^2.6.2",
+ "rc-util": "^5.34.1"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/rc-input": {
+ "version": "1.8.0",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.11.1",
+ "classnames": "^2.2.1",
+ "rc-util": "^5.18.1"
+ },
+ "peerDependencies": {
+ "react": ">=16.0.0",
+ "react-dom": ">=16.0.0"
+ }
+ },
+ "node_modules/rc-input-number": {
+ "version": "9.5.0",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.10.1",
+ "@rc-component/mini-decimal": "^1.0.1",
+ "classnames": "^2.2.5",
+ "rc-input": "~1.8.0",
+ "rc-util": "^5.40.1"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/rc-mentions": {
+ "version": "2.20.0",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.22.5",
+ "@rc-component/trigger": "^2.0.0",
+ "classnames": "^2.2.6",
+ "rc-input": "~1.8.0",
+ "rc-menu": "~9.16.0",
+ "rc-textarea": "~1.10.0",
+ "rc-util": "^5.34.1"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/rc-menu": {
+ "version": "9.16.1",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.10.1",
+ "@rc-component/trigger": "^2.0.0",
+ "classnames": "2.x",
+ "rc-motion": "^2.4.3",
+ "rc-overflow": "^1.3.1",
+ "rc-util": "^5.27.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/rc-motion": {
+ "version": "2.9.5",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.11.1",
+ "classnames": "^2.2.1",
+ "rc-util": "^5.44.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/rc-notification": {
+ "version": "5.6.4",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.10.1",
+ "classnames": "2.x",
+ "rc-motion": "^2.9.0",
+ "rc-util": "^5.20.1"
+ },
+ "engines": {
+ "node": ">=8.x"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/rc-overflow": {
+ "version": "1.4.1",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.11.1",
+ "classnames": "^2.2.1",
+ "rc-resize-observer": "^1.0.0",
+ "rc-util": "^5.37.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/rc-pagination": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmmirror.com/rc-pagination/-/rc-pagination-5.1.0.tgz",
+ "integrity": "sha512-8416Yip/+eclTFdHXLKTxZvn70duYVGTvUUWbckCCZoIl3jagqke3GLsFrMs0bsQBikiYpZLD9206Ej4SOdOXQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.10.1",
+ "classnames": "^2.3.2",
+ "rc-util": "^5.38.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/rc-picker": {
+ "version": "4.11.3",
+ "resolved": "https://registry.npmmirror.com/rc-picker/-/rc-picker-4.11.3.tgz",
+ "integrity": "sha512-MJ5teb7FlNE0NFHTncxXQ62Y5lytq6sh5nUw0iH8OkHL/TjARSEvSHpr940pWgjGANpjCwyMdvsEV55l5tYNSg==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.24.7",
+ "@rc-component/trigger": "^2.0.0",
+ "classnames": "^2.2.1",
+ "rc-overflow": "^1.3.2",
+ "rc-resize-observer": "^1.4.0",
+ "rc-util": "^5.43.0"
+ },
+ "engines": {
+ "node": ">=8.x"
+ },
+ "peerDependencies": {
+ "date-fns": ">= 2.x",
+ "dayjs": ">= 1.x",
+ "luxon": ">= 3.x",
+ "moment": ">= 2.x",
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ },
+ "peerDependenciesMeta": {
+ "date-fns": {
+ "optional": true
+ },
+ "dayjs": {
+ "optional": true
+ },
+ "luxon": {
+ "optional": true
+ },
+ "moment": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/rc-progress": {
+ "version": "4.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.10.1",
+ "classnames": "^2.2.6",
+ "rc-util": "^5.16.1"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/rc-rate": {
+ "version": "2.13.1",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.10.1",
+ "classnames": "^2.2.5",
+ "rc-util": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8.x"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/rc-resize-observer": {
+ "version": "1.4.3",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.20.7",
+ "classnames": "^2.2.1",
+ "rc-util": "^5.44.1",
+ "resize-observer-polyfill": "^1.5.1"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/rc-segmented": {
+ "version": "2.7.0",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.11.1",
+ "classnames": "^2.2.1",
+ "rc-motion": "^2.4.4",
+ "rc-util": "^5.17.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.0.0",
+ "react-dom": ">=16.0.0"
+ }
+ },
+ "node_modules/rc-select": {
+ "version": "14.16.8",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.10.1",
+ "@rc-component/trigger": "^2.1.1",
+ "classnames": "2.x",
+ "rc-motion": "^2.0.1",
+ "rc-overflow": "^1.3.1",
+ "rc-util": "^5.16.1",
+ "rc-virtual-list": "^3.5.2"
+ },
+ "engines": {
+ "node": ">=8.x"
+ },
+ "peerDependencies": {
+ "react": "*",
+ "react-dom": "*"
+ }
+ },
+ "node_modules/rc-slider": {
+ "version": "11.1.9",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.10.1",
+ "classnames": "^2.2.5",
+ "rc-util": "^5.36.0"
+ },
+ "engines": {
+ "node": ">=8.x"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/rc-steps": {
+ "version": "6.0.1",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.16.7",
+ "classnames": "^2.2.3",
+ "rc-util": "^5.16.1"
+ },
+ "engines": {
+ "node": ">=8.x"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/rc-switch": {
+ "version": "4.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.21.0",
+ "classnames": "^2.2.1",
+ "rc-util": "^5.30.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/rc-table": {
+ "version": "7.54.0",
+ "resolved": "https://registry.npmmirror.com/rc-table/-/rc-table-7.54.0.tgz",
+ "integrity": "sha512-/wDTkki6wBTjwylwAGjpLKYklKo9YgjZwAU77+7ME5mBoS32Q4nAwoqhA2lSge6fobLW3Tap6uc5xfwaL2p0Sw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.10.1",
+ "@rc-component/context": "^1.4.0",
+ "classnames": "^2.2.5",
+ "rc-resize-observer": "^1.1.0",
+ "rc-util": "^5.44.3",
+ "rc-virtual-list": "^3.14.2"
+ },
+ "engines": {
+ "node": ">=8.x"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/rc-tabs": {
+ "version": "15.7.0",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.11.2",
+ "classnames": "2.x",
+ "rc-dropdown": "~4.2.0",
+ "rc-menu": "~9.16.0",
+ "rc-motion": "^2.6.2",
+ "rc-resize-observer": "^1.0.0",
+ "rc-util": "^5.34.1"
+ },
+ "engines": {
+ "node": ">=8.x"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/rc-textarea": {
+ "version": "1.10.2",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.10.1",
+ "classnames": "^2.2.1",
+ "rc-input": "~1.8.0",
+ "rc-resize-observer": "^1.0.0",
+ "rc-util": "^5.27.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/rc-tooltip": {
+ "version": "6.4.0",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.11.2",
+ "@rc-component/trigger": "^2.0.0",
+ "classnames": "^2.3.1",
+ "rc-util": "^5.44.3"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/rc-tree": {
+ "version": "5.13.1",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.10.1",
+ "classnames": "2.x",
+ "rc-motion": "^2.0.1",
+ "rc-util": "^5.16.1",
+ "rc-virtual-list": "^3.5.1"
+ },
+ "engines": {
+ "node": ">=10.x"
+ },
+ "peerDependencies": {
+ "react": "*",
+ "react-dom": "*"
+ }
+ },
+ "node_modules/rc-tree-select": {
+ "version": "5.27.0",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.25.7",
+ "classnames": "2.x",
+ "rc-select": "~14.16.2",
+ "rc-tree": "~5.13.0",
+ "rc-util": "^5.43.0"
+ },
+ "peerDependencies": {
+ "react": "*",
+ "react-dom": "*"
+ }
+ },
+ "node_modules/rc-upload": {
+ "version": "4.9.2",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.18.3",
+ "classnames": "^2.2.5",
+ "rc-util": "^5.2.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/rc-util": {
+ "version": "5.44.4",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.18.3",
+ "react-is": "^18.2.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/rc-virtual-list": {
+ "version": "3.19.2",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.20.0",
+ "classnames": "^2.2.6",
+ "rc-resize-observer": "^1.0.0",
+ "rc-util": "^5.36.0"
+ },
+ "engines": {
+ "node": ">=8.x"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/react": {
+ "version": "18.3.1",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-18.3.1.tgz",
+ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0",
+ "scheduler": "^0.23.2"
+ },
+ "peerDependencies": {
+ "react": "^18.3.1"
+ }
+ },
+ "node_modules/react-is": {
+ "version": "18.3.1",
+ "license": "MIT"
+ },
+ "node_modules/react-quill": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmmirror.com/react-quill/-/react-quill-2.0.0.tgz",
+ "integrity": "sha512-4qQtv1FtCfLgoD3PXAur5RyxuUbPXQGOHgTlFie3jtxp43mXDtzCKaOgQ3mLyZfi1PUlyjycfivKelFhy13QUg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/quill": "^1.3.10",
+ "lodash": "^4.17.4",
+ "quill": "^1.3.7"
+ },
+ "peerDependencies": {
+ "react": "^16 || ^17 || ^18",
+ "react-dom": "^16 || ^17 || ^18"
+ }
+ },
+ "node_modules/react-quill/node_modules/eventemitter3": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmmirror.com/eventemitter3/-/eventemitter3-2.0.3.tgz",
+ "integrity": "sha512-jLN68Dx5kyFHaePoXWPsCGW5qdyZQtLYHkxkg02/Mz6g0kYpDx4FyP6XfArhQdlOC4b8Mv+EMxPo/8La7Tzghg==",
+ "license": "MIT"
+ },
+ "node_modules/react-quill/node_modules/fast-diff": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmmirror.com/fast-diff/-/fast-diff-1.1.2.tgz",
+ "integrity": "sha512-KaJUt+M9t1qaIteSvjc6P3RbMdXsNhK61GRftR6SNxqmhthcd9MGIi4T+o0jD8LUSpSnSKXE20nLtJ3fOHxQig==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/react-quill/node_modules/parchment": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmmirror.com/parchment/-/parchment-1.1.4.tgz",
+ "integrity": "sha512-J5FBQt/pM2inLzg4hEWmzQx/8h8D0CiDxaG3vyp9rKrQRSDgBlhjdP5jQGgosEajXPSQouXGHOmVdgo7QmJuOg==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/react-quill/node_modules/quill": {
+ "version": "1.3.7",
+ "resolved": "https://registry.npmmirror.com/quill/-/quill-1.3.7.tgz",
+ "integrity": "sha512-hG/DVzh/TiknWtE6QmWAF/pxoZKYxfe3J/d/+ShUWkDvvkZQVTPeVmUJVu1uE6DDooC4fWTiCLh84ul89oNz5g==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "clone": "^2.1.1",
+ "deep-equal": "^1.0.1",
+ "eventemitter3": "^2.0.3",
+ "extend": "^3.0.2",
+ "parchment": "^1.1.4",
+ "quill-delta": "^3.6.2"
+ }
+ },
+ "node_modules/react-quill/node_modules/quill-delta": {
+ "version": "3.6.3",
+ "resolved": "https://registry.npmmirror.com/quill-delta/-/quill-delta-3.6.3.tgz",
+ "integrity": "sha512-wdIGBlcX13tCHOXGMVnnTVFtGRLoP0imqxM696fIPwIf5ODIYUHIvHbZcyvGlZFiFhK5XzDC2lpjbxRhnM05Tg==",
+ "license": "MIT",
+ "dependencies": {
+ "deep-equal": "^1.0.1",
+ "extend": "^3.0.2",
+ "fast-diff": "1.1.2"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/react-refresh": {
+ "version": "0.17.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-router": {
+ "version": "6.30.1",
+ "license": "MIT",
+ "dependencies": {
+ "@remix-run/router": "1.23.0"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8"
+ }
+ },
+ "node_modules/react-router-dom": {
+ "version": "6.30.1",
+ "license": "MIT",
+ "dependencies": {
+ "@remix-run/router": "1.23.0",
+ "react-router": "6.30.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8",
+ "react-dom": ">=16.8"
+ }
+ },
+ "node_modules/react-router-dom/node_modules/@remix-run/router": {
+ "version": "1.23.0",
+ "resolved": "https://registry.npmmirror.com/@remix-run/router/-/router-1.23.0.tgz",
+ "integrity": "sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/react-router/node_modules/@remix-run/router": {
+ "version": "1.23.0",
+ "resolved": "https://registry.npmmirror.com/@remix-run/router/-/router-1.23.0.tgz",
+ "integrity": "sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/regexp.prototype.flags": {
+ "version": "1.5.4",
+ "resolved": "https://registry.npmmirror.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
+ "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "define-properties": "^1.2.1",
+ "es-errors": "^1.3.0",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "set-function-name": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/resize-observer-polyfill": {
+ "version": "1.5.1",
+ "license": "MIT"
+ },
+ "node_modules/resolve-from": {
+ "version": "4.0.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/reusify": {
+ "version": "1.1.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "iojs": ">=1.0.0",
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rimraf": {
+ "version": "3.0.2",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "glob": "^7.1.3"
+ },
+ "bin": {
+ "rimraf": "bin.js"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.52.4",
+ "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.52.4.tgz",
+ "integrity": "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.8"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.52.4",
+ "@rollup/rollup-android-arm64": "4.52.4",
+ "@rollup/rollup-darwin-arm64": "4.52.4",
+ "@rollup/rollup-darwin-x64": "4.52.4",
+ "@rollup/rollup-freebsd-arm64": "4.52.4",
+ "@rollup/rollup-freebsd-x64": "4.52.4",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.52.4",
+ "@rollup/rollup-linux-arm-musleabihf": "4.52.4",
+ "@rollup/rollup-linux-arm64-gnu": "4.52.4",
+ "@rollup/rollup-linux-arm64-musl": "4.52.4",
+ "@rollup/rollup-linux-loong64-gnu": "4.52.4",
+ "@rollup/rollup-linux-ppc64-gnu": "4.52.4",
+ "@rollup/rollup-linux-riscv64-gnu": "4.52.4",
+ "@rollup/rollup-linux-riscv64-musl": "4.52.4",
+ "@rollup/rollup-linux-s390x-gnu": "4.52.4",
+ "@rollup/rollup-linux-x64-gnu": "4.52.4",
+ "@rollup/rollup-linux-x64-musl": "4.52.4",
+ "@rollup/rollup-openharmony-arm64": "4.52.4",
+ "@rollup/rollup-win32-arm64-msvc": "4.52.4",
+ "@rollup/rollup-win32-ia32-msvc": "4.52.4",
+ "@rollup/rollup-win32-x64-gnu": "4.52.4",
+ "@rollup/rollup-win32-x64-msvc": "4.52.4",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/rollup/node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.52.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.4.tgz",
+ "integrity": "sha512-BTm2qKNnWIQ5auf4deoetINJm2JzvihvGb9R6K/ETwKLql/Bb3Eg2H1FBp1gUb4YGbydMA3jcmQTR73q7J+GAA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/rollup/node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.52.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.4.tgz",
+ "integrity": "sha512-P9LDQiC5vpgGFgz7GSM6dKPCiqR3XYN1WwJKA4/BUVDjHpYsf3iBEmVz62uyq20NGYbiGPR5cNHI7T1HqxNs2w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/rollup/node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.52.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.4.tgz",
+ "integrity": "sha512-QRWSW+bVccAvZF6cbNZBJwAehmvG9NwfWHwMy4GbWi/BQIA/laTIktebT2ipVjNncqE6GLPxOok5hsECgAxGZg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/rollup/node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.52.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.4.tgz",
+ "integrity": "sha512-hZgP05pResAkRJxL1b+7yxCnXPGsXU0fG9Yfd6dUaoGk+FhdPKCJ5L1Sumyxn8kvw8Qi5PvQ8ulenUbRjzeCTw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/rollup/node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.52.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.4.tgz",
+ "integrity": "sha512-xmc30VshuBNUd58Xk4TKAEcRZHaXlV+tCxIXELiE9sQuK3kG8ZFgSPi57UBJt8/ogfhAF5Oz4ZSUBN77weM+mQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/rollup/node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.52.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.4.tgz",
+ "integrity": "sha512-WdSLpZFjOEqNZGmHflxyifolwAiZmDQzuOzIq9L27ButpCVpD7KzTRtEG1I0wMPFyiyUdOO+4t8GvrnBLQSwpw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/rollup/node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.52.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.4.tgz",
+ "integrity": "sha512-xRiOu9Of1FZ4SxVbB0iEDXc4ddIcjCv2aj03dmW8UrZIW7aIQ9jVJdLBIhxBI+MaTnGAKyvMwPwQnoOEvP7FgQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/rollup/node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.52.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.4.tgz",
+ "integrity": "sha512-FbhM2p9TJAmEIEhIgzR4soUcsW49e9veAQCziwbR+XWB2zqJ12b4i/+hel9yLiD8pLncDH4fKIPIbt5238341Q==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/rollup/node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.52.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.4.tgz",
+ "integrity": "sha512-u0n17nGA0nvi/11gcZKsjkLj1QIpAuPFQbR48Subo7SmZJnGxDpspyw2kbpuoQnyK+9pwf3pAoEXerJs/8Mi9g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/rollup/node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.52.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.4.tgz",
+ "integrity": "sha512-0G2c2lpYtbTuXo8KEJkDkClE/+/2AFPdPAbmaHoE870foRFs4pBrDehilMcrSScrN/fB/1HTaWO4bqw+ewBzMQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/rollup/node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.52.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.4.tgz",
+ "integrity": "sha512-teSACug1GyZHmPDv14VNbvZFX779UqWTsd7KtTM9JIZRDI5NUwYSIS30kzI8m06gOPB//jtpqlhmraQ68b5X2g==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/rollup/node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.52.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.4.tgz",
+ "integrity": "sha512-/MOEW3aHjjs1p4Pw1Xk4+3egRevx8Ji9N6HUIA1Ifh8Q+cg9dremvFCUbOX2Zebz80BwJIgCBUemjqhU5XI5Eg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/rollup/node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.52.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.4.tgz",
+ "integrity": "sha512-1HHmsRyh845QDpEWzOFtMCph5Ts+9+yllCrREuBR/vg2RogAQGGBRC8lDPrPOMnrdOJ+mt1WLMOC2Kao/UwcvA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/rollup/node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.52.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.4.tgz",
+ "integrity": "sha512-seoeZp4L/6D1MUyjWkOMRU6/iLmCU2EjbMTyAG4oIOs1/I82Y5lTeaxW0KBfkUdHAWN7j25bpkt0rjnOgAcQcA==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/rollup/node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.52.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.4.tgz",
+ "integrity": "sha512-Wi6AXf0k0L7E2gteNsNHUs7UMwCIhsCTs6+tqQ5GPwVRWMaflqGec4Sd8n6+FNFDw9vGcReqk2KzBDhCa1DLYg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/rollup/node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.52.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.4.tgz",
+ "integrity": "sha512-dtBZYjDmCQ9hW+WgEkaffvRRCKm767wWhxsFW3Lw86VXz/uJRuD438/XvbZT//B96Vs8oTA8Q4A0AfHbrxP9zw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/rollup/node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.52.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.4.tgz",
+ "integrity": "sha512-1ox+GqgRWqaB1RnyZXL8PD6E5f7YyRUJYnCqKpNzxzP0TkaUh112NDrR9Tt+C8rJ4x5G9Mk8PQR3o7Ku2RKqKA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/rollup/node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.52.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.4.tgz",
+ "integrity": "sha512-8GKr640PdFNXwzIE0IrkMWUNUomILLkfeHjXBi/nUvFlpZP+FA8BKGKpacjW6OUUHaNI6sUURxR2U2g78FOHWQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/rollup/node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.52.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.4.tgz",
+ "integrity": "sha512-AIy/jdJ7WtJ/F6EcfOb2GjR9UweO0n43jNObQMb6oGxkYTfLcnN7vYYpG+CN3lLxrQkzWnMOoNSHTW54pgbVxw==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/rollup/node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.52.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.4.tgz",
+ "integrity": "sha512-UF9KfsH9yEam0UjTwAgdK0anlQ7c8/pWPU2yVjyWcF1I1thABt6WXE47cI71pGiZ8wGvxohBoLnxM04L/wj8mQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/rollup/node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.52.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.4.tgz",
+ "integrity": "sha512-bf9PtUa0u8IXDVxzRToFQKsNCRz9qLYfR/MpECxl4mRoWYjAeFjgxj1XdZr2M/GNVpT05p+LgQOHopYDlUu6/w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/rope-sequence": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmmirror.com/rope-sequence/-/rope-sequence-1.3.4.tgz",
+ "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
+ "license": "MIT"
+ },
+ "node_modules/run-parallel": {
+ "version": "1.2.0",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "queue-microtask": "^1.2.2"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.23.2",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ }
+ },
+ "node_modules/scroll-into-view-if-needed": {
+ "version": "3.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "compute-scroll-into-view": "^3.0.2"
+ }
+ },
+ "node_modules/semver": {
+ "version": "7.7.3",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/set-function-length": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmmirror.com/set-function-length/-/set-function-length-1.2.2.tgz",
+ "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.1.4",
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2",
+ "get-intrinsic": "^1.2.4",
+ "gopd": "^1.0.1",
+ "has-property-descriptors": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/set-function-name": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmmirror.com/set-function-name/-/set-function-name-2.0.2.tgz",
+ "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==",
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.1.4",
+ "es-errors": "^1.3.0",
+ "functions-have-names": "^1.2.3",
+ "has-property-descriptors": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/slash": {
+ "version": "3.0.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/string-convert": {
+ "version": "0.2.1",
+ "license": "MIT"
+ },
+ "node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-json-comments": {
+ "version": "3.1.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/stylis": {
+ "version": "4.3.6",
+ "license": "MIT"
+ },
+ "node_modules/supports-color": {
+ "version": "7.2.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/text-table": {
+ "version": "0.2.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/throttle-debounce": {
+ "version": "5.0.2",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.22"
+ }
+ },
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/toggle-selection": {
+ "version": "1.0.6",
+ "license": "MIT"
+ },
+ "node_modules/ts-api-utils": {
+ "version": "1.4.3",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=16"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.2.0"
+ }
+ },
+ "node_modules/type-check": {
+ "version": "0.4.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/type-fest": {
+ "version": "0.20.2",
+ "dev": true,
+ "license": "(MIT OR CC0-1.0)",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/uc.micro": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmmirror.com/uc.micro/-/uc.micro-2.1.0.tgz",
+ "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
+ "license": "MIT"
+ },
+ "node_modules/undici-types": {
+ "version": "7.16.0",
+ "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.16.0.tgz",
+ "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.1.3",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/uri-js": {
+ "version": "4.4.1",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "punycode": "^2.1.0"
+ }
+ },
+ "node_modules/use-sync-external-store": {
+ "version": "1.6.0",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/vite": {
+ "version": "5.4.20",
+ "resolved": "https://registry.npmmirror.com/vite/-/vite-5.4.20.tgz",
+ "integrity": "sha512-j3lYzGC3P+B5Yfy/pfKNgVEg4+UtcIJcVRt2cDjIOmhLourAqPqf8P7acgxeiSgUB7E3p2P8/3gNIgDLpwzs4g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.21.3",
+ "postcss": "^8.4.43",
+ "rollup": "^4.20.0"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || >=20.0.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/w3c-keyname": {
+ "version": "2.2.8",
+ "resolved": "https://registry.npmmirror.com/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
+ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
+ "license": "MIT"
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/word-wrap": {
+ "version": "1.2.5",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/yocto-queue": {
+ "version": "0.1.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/zustand": {
+ "version": "4.5.7",
+ "license": "MIT",
+ "dependencies": {
+ "use-sync-external-store": "^1.2.2"
+ },
+ "engines": {
+ "node": ">=12.7.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=16.8",
+ "immer": ">=9.0.6",
+ "react": ">=16.8"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "immer": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ }
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..6d375fc
--- /dev/null
+++ b/package.json
@@ -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"
+ }
+}
diff --git a/public/vite.svg b/public/vite.svg
new file mode 100644
index 0000000..9e047aa
--- /dev/null
+++ b/public/vite.svg
@@ -0,0 +1,2 @@
+
+
diff --git a/src/App.tsx b/src/App.tsx
new file mode 100644
index 0000000..f258e6d
--- /dev/null
+++ b/src/App.tsx
@@ -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(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(getInitialThemeMode)
+ const [primaryColor, setPrimaryColorState] = useState(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(
+ () => ({
+ mode,
+ toggleMode: () =>
+ setMode((prev) =>
+ prev === ThemeMode.DARK ? ThemeMode.LIGHT : ThemeMode.DARK,
+ ),
+ primaryColor,
+ setPrimaryColor,
+ }),
+ [mode, primaryColor],
+ )
+
+ return (
+
+
+
+
+
+
+
+ )
+}
+
+export default App
+
diff --git a/src/api/admin.ts b/src/api/admin.ts
new file mode 100644
index 0000000..6b7f470
--- /dev/null
+++ b/src/api/admin.ts
@@ -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 || '设置用户角色成功'
+ }
+ }
+ })
+}
+
diff --git a/src/api/auth.ts b/src/api/auth.ts
new file mode 100644
index 0000000..17b6dc9
--- /dev/null
+++ b/src/api/auth.ts
@@ -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 => {
+ return request.post('/auth/login', data).then((res) => {
+ // 响应拦截器已经处理了 success 字段,这里直接返回 data
+ return res.data
+ })
+}
+
+/**
+ * 获取当前用户信息
+ * @returns 用户信息
+ */
+export const getMe = (): Promise => {
+ 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)
+}
+
diff --git a/src/api/camp.ts b/src/api/camp.ts
new file mode 100644
index 0000000..f814b21
--- /dev/null
+++ b/src/api/camp.ts
@@ -0,0 +1,1141 @@
+import request from '@/utils/request'
+import type {
+ Category,
+ Camp,
+ Section,
+ Task,
+ UserProgress,
+ ListCategoriesRequest,
+ ListCampsRequest,
+ ListSectionsRequest,
+ ListTasksRequest,
+ ListUserProgressRequest,
+} from '@/types/camp'
+
+// ==================== 分类管理 ====================
+
+export const createCategory = (data: Omit) => {
+ // 转换字段格式:驼峰 -> 下划线
+ const apiData = {
+ name: data.name,
+ sort_order: data.sortOrder,
+ }
+
+ return request.post('/camp/categories/create', apiData).then((response) => {
+ const backendData = response.data
+
+ // 转换为前端期望的格式
+ return {
+ data: {
+ code: 200,
+ message: backendData.message || '创建成功',
+ data: {
+ id: backendData.id || backendData.category_id || '',
+ }
+ }
+ }
+ })
+}
+
+export const getCategory = (id: string) => {
+ return request.get(`/camp/categories/detail`, { params: { id } }).then((response) => {
+ const backendData = response.data
+
+ // 转换为前端期望的格式
+ return {
+ data: {
+ code: 200,
+ message: backendData.message || '获取成功',
+ data: {
+ id: backendData.category?.id || backendData.id,
+ name: backendData.category?.name || backendData.name,
+ sortOrder: backendData.category?.sort_order || backendData.sort_order,
+ }
+ }
+ }
+ })
+}
+
+export const listCategories = (params: ListCategoriesRequest) => {
+ // 转换参数格式:驼峰 -> 下划线
+ const apiParams: any = {
+ page: params.page,
+ page_size: params.pageSize,
+ }
+
+ // 只在有值时传递 keyword
+ if (params.keyword) {
+ apiParams.keyword = params.keyword
+ }
+
+ return request.get('/camp/categories', { params: apiParams }).then((response) => {
+ // response.data 是后端返回的原始数据
+ const backendData = response.data
+
+ // 转换为前端期望的格式
+ return {
+ data: {
+ code: 200,
+ message: backendData.message,
+ data: {
+ list: backendData.categories?.map((item: any) => ({
+ id: item.id,
+ name: item.name,
+ sortOrder: item.sort_order,
+ })) || [],
+ total: backendData.total || 0,
+ page: params.page,
+ pageSize: params.pageSize,
+ }
+ }
+ }
+ })
+}
+
+export const updateCategory = (data: Category) => {
+ // 转换字段格式:驼峰 -> 下划线
+ const apiData = {
+ id: data.id,
+ name: data.name,
+ sort_order: data.sortOrder,
+ }
+
+ return request.post(`/camp/categories/update`, apiData).then((response) => {
+ const backendData = response.data
+
+ return {
+ data: {
+ code: 200,
+ message: backendData.message || '更新成功',
+ }
+ }
+ })
+}
+
+export const deleteCategory = (id: string) => {
+ return request.post(`/camp/categories/delete`, { params: { id } }).then((response) => {
+ const backendData = response.data
+
+ return {
+ data: {
+ code: 200,
+ message: backendData.message || '删除成功',
+ }
+ }
+ })
+}
+
+// ==================== 打卡营管理 ====================
+
+export const createCamp = (data: Omit) => {
+ // 转换字段格式:驼峰 -> 下划线
+ const apiData = {
+ title: data.title,
+ cover_image: data.coverImage,
+ description: data.description,
+ intro_type: data.introType,
+ intro_content: data.introContent,
+ category_id: data.categoryId,
+ is_recommended: data.isRecommended,
+ }
+
+ return request.post('/camp/camps/create', apiData).then((response) => {
+ const backendData = response.data
+
+ return {
+ data: {
+ code: 200,
+ message: backendData.message || '创建成功',
+ data: {
+ id: backendData.id || backendData.camp_id || '',
+ }
+ }
+ }
+ })
+}
+
+export const getCamp = (id: string) => {
+ return request.get(`/camp/camps/detail`, { params: { id } }).then((response) => {
+ const backendData = response.data
+
+ // 转换 intro_type 字符串到数字
+ const convertIntroType = (type: string | number): number => {
+ if (typeof type === 'number') return type
+ const typeMap: Record = {
+ 'INTRO_TYPE_NONE': 0,
+ 'INTRO_TYPE_IMAGE_TEXT': 1,
+ 'INTRO_TYPE_VIDEO': 2,
+ }
+ return typeMap[type] ?? 0
+ }
+
+ const camp = backendData.camp || backendData
+
+ return {
+ data: {
+ code: 200,
+ message: backendData.message || '获取成功',
+ data: {
+ id: camp.id,
+ title: camp.title,
+ coverImage: camp.cover_image,
+ description: camp.description,
+ introType: convertIntroType(camp.intro_type),
+ introContent: camp.intro_content,
+ categoryId: camp.category_id,
+ isRecommended: camp.is_recommended,
+ sectionCount: camp.section_count || 0,
+ }
+ }
+ }
+ })
+}
+
+export const listCamps = (params: ListCampsRequest) => {
+ // 转换参数格式:驼峰 -> 下划线
+ const apiParams: any = {
+ page: params.page,
+ page_size: params.pageSize,
+ }
+
+ // 只在有值时传递 keyword
+ if (params.keyword) {
+ apiParams.keyword = params.keyword
+ }
+
+ if (params.categoryId) {
+ apiParams.category_id = params.categoryId
+ }
+
+ if (params.recommendFilter !== undefined) {
+ // 0=全部, 1=仅推荐, 2=仅非推荐
+ if (params.recommendFilter === 1) {
+ apiParams.is_recommended = true
+ } else if (params.recommendFilter === 2) {
+ apiParams.is_recommended = false
+ }
+ }
+
+ return request.get('/camp/camps', { params: apiParams }).then((response) => {
+ const backendData = response.data
+
+ // 转换 intro_type 字符串到数字
+ const convertIntroType = (type: string): number => {
+ const typeMap: Record = {
+ 'INTRO_TYPE_NONE': 0,
+ 'INTRO_TYPE_IMAGE_TEXT': 1,
+ 'INTRO_TYPE_VIDEO': 2,
+ }
+ return typeMap[type] ?? 0
+ }
+
+ return {
+ data: {
+ code: 200,
+ message: backendData.message,
+ data: {
+ list: backendData.camps?.map((item: any) => ({
+ id: item.id,
+ title: item.title,
+ coverImage: item.cover_image,
+ description: item.description,
+ introType: convertIntroType(item.intro_type),
+ introContent: item.intro_content,
+ categoryId: item.category_id,
+ isRecommended: item.is_recommended,
+ sectionCount: item.section_count || 0,
+ })) || [],
+ total: backendData.total || 0,
+ page: params.page,
+ pageSize: params.pageSize,
+ }
+ }
+ }
+ })
+}
+
+export const updateCamp = (data: Camp) => {
+ // 转换字段格式:驼峰 -> 下划线
+ const apiData = {
+ id: data.id,
+ title: data.title,
+ cover_image: data.coverImage,
+ description: data.description,
+ intro_type: data.introType,
+ intro_content: data.introContent,
+ category_id: data.categoryId,
+ is_recommended: data.isRecommended,
+ }
+
+ return request.post('/camp/camps/update', apiData).then((response) => {
+ const backendData = response.data
+
+ return {
+ data: {
+ code: 200,
+ message: backendData.message || '更新成功',
+ }
+ }
+ })
+}
+
+export const deleteCamp = (id: string) => {
+ return request.post('/camp/camps/delete', { params: { id } }).then((response) => {
+ const backendData = response.data
+
+ return {
+ data: {
+ code: 200,
+ message: backendData.message || '删除成功',
+ }
+ }
+ })
+}
+
+// ==================== 小节管理 ====================
+
+export const createSection = (data: Omit) => {
+ // 转换字段格式:驼峰 -> 下划线
+ const apiData = {
+ camp_id: data.campId,
+ title: data.title,
+ section_number: data.sectionNumber,
+ price_fen: data.priceFen,
+ time_interval_type: data.timeIntervalType,
+ time_interval_value: data.timeIntervalValue,
+ }
+
+ return request.post('/camp/sections/create', apiData).then((response) => {
+ const backendData = response.data
+
+ return {
+ data: {
+ code: 200,
+ message: backendData.message || '创建成功',
+ data: {
+ id: backendData.id || backendData.section_id || '',
+ }
+ }
+ }
+ })
+}
+
+export const getSection = (id: string) => {
+ return request.get(`/camp/sections/detail`, { params: { id } }).then((response) => {
+ const backendData = response.data
+ const section = backendData.section || backendData
+
+ // 转换 time_interval_type
+ const convertTimeIntervalType = (type: any): number => {
+ if (typeof type === 'number') return type
+ const typeMap: Record = {
+ 'TIME_INTERVAL_NONE': 0,
+ 'TIME_INTERVAL_HOUR': 1,
+ 'TIME_INTERVAL_NATURAL_DAY': 2,
+ none: 0,
+ hour: 1,
+ natural_day: 2,
+ paid: 3,
+ PAID: 3,
+ }
+ return typeMap[type] ?? 0
+ }
+
+ return {
+ data: {
+ code: 200,
+ message: backendData.message || '获取成功',
+ data: {
+ id: section.id,
+ campId: section.camp_id,
+ title: section.title,
+ sectionNumber: section.section_number ?? 0,
+ priceFen: section.price_fen ?? 0,
+ requirePreviousSection: section.require_previous_section ?? false,
+ timeIntervalType: convertTimeIntervalType(section.time_interval_type),
+ timeIntervalValue: section.time_interval_value ?? 0,
+ }
+ }
+ }
+ })
+}
+
+export const listSections = (params: ListSectionsRequest) => {
+ // 转换参数格式:驼峰 -> 下划线
+ const apiParams: any = {
+ page: params.page,
+ page_size: params.pageSize,
+ }
+
+ if (params.keyword) {
+ apiParams.keyword = params.keyword
+ }
+
+ if (params.campId) {
+ apiParams.camp_id = params.campId
+ }
+
+ return request.get('/camp/sections', { params: apiParams }).then((response) => {
+ const backendData = response.data
+
+ // sections 可能是对象或数组,需要处理
+ let sectionsList = []
+ if (Array.isArray(backendData.sections)) {
+ sectionsList = backendData.sections
+ } else if (backendData.sections && typeof backendData.sections === 'object') {
+ // 如果是对象,尝试转换为数组
+ sectionsList = Object.values(backendData.sections)
+ }
+
+ // 转换 time_interval_type
+ const convertTimeIntervalType = (type: any): number => {
+ if (typeof type === 'number') return type
+ const typeMap: Record = {
+ 'TIME_INTERVAL_NONE': 0,
+ 'TIME_INTERVAL_HOUR': 1,
+ 'TIME_INTERVAL_NATURAL_DAY': 2,
+ none: 0,
+ hour: 1,
+ natural_day: 2,
+ paid: 3,
+ PAID: 3,
+ }
+ return typeMap[type] ?? 0
+ }
+
+ return {
+ data: {
+ code: 200,
+ message: backendData.message,
+ data: {
+ list: sectionsList.map((item: any) => ({
+ id: item.id,
+ campId: item.camp_id,
+ title: item.title,
+ sectionNumber: item.section_number ?? 0,
+ priceFen: item.price_fen ?? 0,
+ requirePreviousSection: item.require_previous_section ?? false,
+ timeIntervalType: convertTimeIntervalType(item.time_interval_type),
+ timeIntervalValue: item.time_interval_value ?? 0,
+ })),
+ total: backendData.total || 0,
+ page: params.page,
+ pageSize: params.pageSize,
+ }
+ }
+ }
+ })
+}
+
+export const updateSection = (data: Section) => {
+ // 转换字段格式:驼峰 -> 下划线
+ const apiData = {
+ id: data.id,
+ camp_id: data.campId,
+ title: data.title,
+ section_number: data.sectionNumber,
+ price_fen: data.priceFen,
+ time_interval_type: data.timeIntervalType,
+ time_interval_value: data.timeIntervalValue,
+ }
+
+ return request.post('/camp/sections/update', apiData).then((response) => {
+ const backendData = response.data
+
+ return {
+ data: {
+ code: 200,
+ message: backendData.message || '更新成功',
+ }
+ }
+ })
+}
+
+export const deleteSection = (id: string) => {
+ return request.post(`/camp/sections/delete?id=${encodeURIComponent(id)}`).then((response) => {
+ const backendData = response.data
+
+ return {
+ data: {
+ code: 200,
+ message: backendData.message || '删除成功',
+ }
+ }
+ })
+}
+
+// ==================== 任务管理 ====================
+
+export const createTask = (data: Omit) => {
+ // 转换前端格式到后端格式
+ const convertContent = (content: any) => {
+ const type = content.type
+ const contentData = content.data
+
+ // 转换字段名:驼峰 -> 下划线
+ const convertedData: any = {}
+ if (type === 'imageText') {
+ convertedData.image_text = {
+ image_url: contentData.imageUrl,
+ text_content: contentData.textContent,
+ view_duration_seconds: contentData.viewDurationSeconds,
+ }
+ } else if (type === 'video') {
+ convertedData.video = {
+ video_url: contentData.videoUrl,
+ completion_percentage: contentData.completionPercentage,
+ }
+ } else if (type === 'subjective') {
+ convertedData.subjective = {
+ pdf_url: contentData.pdfUrl,
+ description: contentData.description,
+ }
+ } else if (type === 'objective') {
+ convertedData.objective = {
+ exam_id: contentData.examId,
+ correct_rate_percentage: contentData.correctRatePercentage,
+ }
+ } else if (type === 'essay') {
+ convertedData.essay = {
+ exam_id: contentData.examId,
+ description: contentData.description,
+ }
+ }
+
+ return convertedData
+ }
+
+ const convertCondition = (condition: any) => {
+ const type = condition.type
+ const conditionData = condition.data
+
+ const convertedData: any = {}
+ if (type === 'imageText') {
+ convertedData.image_text = {
+ view_duration_seconds: conditionData.viewDurationSeconds,
+ }
+ } else if (type === 'video') {
+ convertedData.video = {
+ completion_percentage: conditionData.completionPercentage,
+ }
+ } else if (type === 'subjective') {
+ convertedData.subjective = {
+ need_review: conditionData.needReview,
+ review_status: conditionData.reviewStatus,
+ }
+ } else if (type === 'objective') {
+ convertedData.objective = {
+ correct_rate_percentage: conditionData.correctRatePercentage,
+ }
+ } else if (type === 'essay') {
+ convertedData.essay = {
+ need_review: conditionData.needReview,
+ review_status: conditionData.reviewStatus,
+ allow_next_while_reviewing: conditionData.allowNextWhileReviewing ?? true,
+ }
+ }
+
+ return convertedData
+ }
+
+ const apiData = {
+ camp_id: data.campId,
+ section_id: data.sectionId,
+ task_type: data.taskType,
+ title: data.title ?? '',
+ content: convertContent(data.content),
+ condition: convertCondition(data.condition),
+ prerequisite_task_id: data.prerequisiteTaskId || '',
+ }
+
+ return request.post('/camp/tasks/create', apiData).then((response) => {
+ const backendData = response.data
+
+ return {
+ data: {
+ code: 200,
+ message: backendData.message || '创建成功',
+ data: {
+ id: backendData.id || backendData.task_id || '',
+ }
+ }
+ }
+ })
+}
+
+export const getTask = (id: string) => {
+ return request.get(`/camp/tasks/detail`, { params: { id } }).then((response) => {
+ const backendData = response.data
+ const task = backendData.task || backendData
+
+ // TODO: 需要解析复杂的任务数据结构
+ return {
+ data: {
+ code: 200,
+ message: backendData.message || '获取成功',
+ data: task
+ }
+ }
+ })
+}
+
+export const listTasks = (params: ListTasksRequest) => {
+ // 转换参数格式:驼峰 -> 下划线
+ const apiParams: any = {
+ page: params.page,
+ page_size: params.pageSize,
+ }
+
+ if (params.keyword) {
+ apiParams.keyword = params.keyword
+ }
+ if (params.campId) {
+ apiParams.camp_id = params.campId
+ }
+ if (params.sectionId) {
+ apiParams.section_id = params.sectionId
+ }
+ if (params.taskType !== undefined && params.taskType !== 0) {
+ apiParams.task_type = params.taskType
+ }
+ return request.get('/camp/tasks', { params: apiParams }).then((response) => {
+ const backendData = response.data
+
+ // tasks 可能是对象或数组,需要处理
+ let tasksList = []
+ if (Array.isArray(backendData.tasks)) {
+ tasksList = backendData.tasks
+ } else if (backendData.tasks && typeof backendData.tasks === 'object') {
+ tasksList = Object.values(backendData.tasks)
+ }
+
+ // 转换 task_type 字符串到数字
+ const convertTaskType = (type: any): number => {
+ if (typeof type === 'number') return type
+
+ // 处理小写下划线格式(后端返回的格式)
+ const lowercaseMap: Record = {
+ 'image_text': 1,
+ 'video': 2,
+ 'subjective': 3,
+ 'objective': 4,
+ 'essay': 5,
+ }
+
+ // 处理大写枚举格式
+ const uppercaseMap: Record = {
+ 'TASK_TYPE_UNKNOWN': 0,
+ 'TASK_TYPE_IMAGE_TEXT': 1,
+ 'TASK_TYPE_VIDEO': 2,
+ 'TASK_TYPE_SUBJECTIVE': 3,
+ 'TASK_TYPE_OBJECTIVE': 4,
+ 'TASK_TYPE_ESSAY': 5,
+ }
+
+ // 先尝试小写格式,再尝试大写格式
+ return lowercaseMap[type] ?? uppercaseMap[type] ?? 0
+ }
+
+ // 转换 review_status 字符串到数字
+ const convertReviewStatus = (status: any): number => {
+ if (typeof status === 'number') return status
+ const statusMap: Record = {
+ 'REVIEW_STATUS_PENDING': 0,
+ 'REVIEW_STATUS_APPROVED': 1,
+ 'REVIEW_STATUS_REJECTED': 2,
+ }
+ return statusMap[status] ?? 0
+ }
+
+ // 解析后端的 content 结构
+ // 后端返回格式:content 是一个对象,里面包含 task_type 对应的键(如 image_text, video 等)
+ const parseContent = (backendContent: any, taskType: string | number): any => {
+ if (!backendContent) return { type: 'imageText', data: {} }
+
+ // 根据 task_type 判断内容类型(支持字符串和数字)
+ if (taskType === 'image_text' || taskType === 1 || taskType === '1') {
+ // 后端返回格式:content.image_text = { image_url, text_content, view_duration_seconds }
+ const imageTextData = backendContent.image_text || backendContent
+ return {
+ type: 'imageText',
+ data: {
+ imageUrl: imageTextData.image_url || '',
+ textContent: imageTextData.text_content || '',
+ viewDurationSeconds: imageTextData.view_duration_seconds || 0,
+ }
+ }
+ } else if (taskType === 'video' || taskType === 2 || taskType === '2') {
+ // 后端返回格式:content.video = { video_url, completion_percentage }
+ // 或者 content 直接包含 video_url 和 completion_percentage
+ const videoData = backendContent.video || backendContent
+ return {
+ type: 'video',
+ data: {
+ videoUrl: videoData.video_url || '',
+ completionPercentage: videoData.completion_percentage || 0,
+ }
+ }
+ } else if (taskType === 'subjective' || taskType === 3 || taskType === '3') {
+ // 后端返回格式:content.subjective = { pdf_url, description }
+ const subjectiveData = backendContent.subjective || backendContent
+ return {
+ type: 'subjective',
+ data: {
+ pdfUrl: subjectiveData.pdf_url || '',
+ description: subjectiveData.description || '',
+ }
+ }
+ } else if (taskType === 'objective' || taskType === 4 || taskType === '4') {
+ // 后端返回格式:content.objective = { exam_id, correct_rate_percentage }
+ const objectiveData = backendContent.objective || backendContent
+ return {
+ type: 'objective',
+ data: {
+ examId: objectiveData.exam_id || '',
+ correctRatePercentage: objectiveData.correct_rate_percentage || 0,
+ }
+ }
+ } else if (taskType === 'essay' || taskType === 5 || taskType === '5') {
+ // 后端返回格式:content.essay = { exam_id, description }
+ const essayData = backendContent.essay || backendContent
+ return {
+ type: 'essay',
+ data: {
+ examId: essayData.exam_id || '',
+ description: essayData.description || '',
+ }
+ }
+ }
+
+ return { type: 'imageText', data: {} }
+ }
+
+ // 解析后端的 condition 结构
+ // 后端返回格式:condition 是一个对象,里面包含 task_type 对应的键(如 image_text, video 等)
+ const parseCondition = (backendCondition: any, taskType: string | number): any => {
+ if (!backendCondition) return { type: 'imageText', data: {} }
+
+ // 根据 task_type 判断条件类型(支持字符串和数字)
+ if (taskType === 'image_text' || taskType === 1 || taskType === '1') {
+ // 后端返回格式:condition.image_text = { view_duration_seconds }
+ // 或者 condition 直接包含 view_duration_seconds
+ const imageTextCondition = backendCondition.image_text || backendCondition
+ return {
+ type: 'imageText',
+ data: {
+ viewDurationSeconds: imageTextCondition.view_duration_seconds || 0,
+ }
+ }
+ } else if (taskType === 'video' || taskType === 2 || taskType === '2') {
+ // 后端返回格式:condition.video = { completion_percentage }
+ // 或者 condition 直接包含 completion_percentage
+ const videoCondition = backendCondition.video || backendCondition
+ return {
+ type: 'video',
+ data: {
+ completionPercentage: videoCondition.completion_percentage || 0,
+ }
+ }
+ } else if (taskType === 'subjective' || taskType === 3 || taskType === '3') {
+ // 后端返回格式:condition.subjective = { need_review, review_status, allow_next_while_reviewing }
+ const subjectiveCondition = backendCondition.subjective || backendCondition
+ return {
+ type: 'subjective',
+ data: {
+ needReview: subjectiveCondition.need_review ?? false,
+ reviewStatus: convertReviewStatus(subjectiveCondition.review_status),
+ allowNextWhileReviewing: subjectiveCondition.allow_next_while_reviewing ?? true,
+ }
+ }
+ } else if (taskType === 'objective' || taskType === 4 || taskType === '4') {
+ // 后端返回格式:condition.objective = { correct_rate_percentage }
+ // 或者 condition 直接包含 correct_rate_percentage
+ const objectiveCondition = backendCondition.objective || backendCondition
+ return {
+ type: 'objective',
+ data: {
+ correctRatePercentage: objectiveCondition.correct_rate_percentage || 0,
+ }
+ }
+ } else if (taskType === 'essay' || taskType === 5 || taskType === '5') {
+ // 后端返回格式:condition.essay = { need_review, review_status, allow_next_while_reviewing }
+ const essayCondition = backendCondition.essay || backendCondition
+ return {
+ type: 'essay',
+ data: {
+ needReview: essayCondition.need_review ?? false,
+ reviewStatus: convertReviewStatus(essayCondition.review_status),
+ allowNextWhileReviewing: essayCondition.allow_next_while_reviewing ?? true,
+ }
+ }
+ }
+
+ return { type: 'imageText', data: {} }
+ }
+
+ return {
+ data: {
+ code: 200,
+ message: backendData.message,
+ data: {
+ list: tasksList.map((item: any) => ({
+ id: item.id,
+ campId: item.camp_id,
+ sectionId: item.section_id,
+ taskType: convertTaskType(item.task_type),
+ title: item.title ?? '',
+ prerequisiteTaskId: item.prerequisite_task_id ?? '',
+ content: parseContent(item.content, item.task_type),
+ condition: parseCondition(item.condition, item.task_type),
+ })),
+ total: backendData.total || 0,
+ page: params.page,
+ pageSize: params.pageSize,
+ }
+ }
+ }
+ })
+}
+
+export const updateTask = (data: Task) => {
+ // 转换前端格式到后端格式(复用 createTask 的逻辑)
+ const convertContent = (content: any) => {
+ const type = content.type
+ const contentData = content.data
+
+ const convertedData: any = {}
+ if (type === 'imageText') {
+ convertedData.image_text = {
+ image_url: contentData.imageUrl,
+ text_content: contentData.textContent,
+ view_duration_seconds: contentData.viewDurationSeconds,
+ }
+ } else if (type === 'video') {
+ convertedData.video = {
+ video_url: contentData.videoUrl,
+ completion_percentage: contentData.completionPercentage,
+ }
+ } else if (type === 'subjective') {
+ convertedData.subjective = {
+ pdf_url: contentData.pdfUrl,
+ description: contentData.description,
+ }
+ } else if (type === 'objective') {
+ convertedData.objective = {
+ exam_id: contentData.examId,
+ correct_rate_percentage: contentData.correctRatePercentage,
+ }
+ } else if (type === 'essay') {
+ convertedData.essay = {
+ exam_id: contentData.examId,
+ description: contentData.description,
+ }
+ }
+
+ return convertedData
+ }
+
+ const convertCondition = (condition: any) => {
+ const type = condition.type
+ const conditionData = condition.data
+
+ const convertedData: any = {}
+ if (type === 'imageText') {
+ convertedData.image_text = {
+ view_duration_seconds: conditionData.viewDurationSeconds,
+ }
+ } else if (type === 'video') {
+ convertedData.video = {
+ completion_percentage: conditionData.completionPercentage,
+ }
+ } else if (type === 'subjective') {
+ convertedData.subjective = {
+ need_review: conditionData.needReview,
+ review_status: conditionData.reviewStatus,
+ }
+ } else if (type === 'objective') {
+ convertedData.objective = {
+ correct_rate_percentage: conditionData.correctRatePercentage,
+ }
+ } else if (type === 'essay') {
+ convertedData.essay = {
+ need_review: conditionData.needReview,
+ review_status: conditionData.reviewStatus,
+ allow_next_while_reviewing: conditionData.allowNextWhileReviewing ?? true,
+ }
+ }
+
+ return convertedData
+ }
+
+ const apiData = {
+ id: data.id,
+ camp_id: data.campId,
+ section_id: data.sectionId,
+ task_type: data.taskType,
+ title: data.title ?? '',
+ content: convertContent(data.content),
+ condition: convertCondition(data.condition),
+ prerequisite_task_id: data.prerequisiteTaskId || '',
+ }
+
+ return request.post('/camp/tasks/update', apiData).then((response) => {
+ const backendData = response.data
+
+ return {
+ data: {
+ code: 200,
+ message: backendData.message || '更新成功',
+ }
+ }
+ })
+}
+
+export const deleteTask = (id: string) => {
+ return request.post('/camp/tasks/delete', { params: { id } }).then((response) => {
+ const backendData = response.data
+
+ return {
+ data: {
+ code: 200,
+ message: backendData.message || '删除成功',
+ }
+ }
+ })
+}
+
+// ==================== 用户进度管理 ====================
+
+export const updateUserProgress = (data: Omit) => {
+ // 转换 review_status 数字枚举到后端期望的字符串格式
+ const convertReviewStatus = (status: number): string => {
+ const statusMap: Record = {
+ 0: 'pending', // PENDING
+ 1: 'approved', // APPROVED
+ 2: 'rejected', // REJECTED
+ }
+ return statusMap[status] ?? 'pending'
+ }
+
+ // 转换字段格式:驼峰 -> 下划线
+ const apiData: Record = {
+ user_id: data.userId,
+ task_id: data.taskId,
+ is_completed: data.isCompleted,
+ completed_at: data.completedAt,
+ review_status: convertReviewStatus(data.reviewStatus),
+ review_comment: data.reviewComment,
+ review_images: data.reviewImages,
+ }
+
+ // 申论题每题审核状态:后端据此计算任务整体 review_status
+ if (Array.isArray(data.essayReviewStatuses) && data.essayReviewStatuses.length > 0) {
+ apiData.essay_review_statuses = data.essayReviewStatuses.map((s) =>
+ convertReviewStatus(typeof s === 'number' ? s : (s as number))
+ )
+ }
+
+ // 后端路由是 PUT /camp/progress,不是 POST /camp/progress/update
+ return request.put('/camp/progress', apiData).then((response) => {
+ const backendData = response.data
+
+ return {
+ data: {
+ code: 200,
+ message: backendData.message || '更新成功',
+ }
+ }
+ })
+}
+
+export const getUserProgress = (userId: string, taskId: string) => {
+ return request.get('/camp/progress', {
+ params: { user_id: userId, task_id: taskId },
+ }).then((response) => {
+ const backendData = response.data
+ const progress = backendData.progress || backendData
+
+ // 转换 review_status 字符串到数字
+ const convertReviewStatus = (status: any): number => {
+ if (typeof status === 'number') return status
+ const s = typeof status === 'string' ? status.trim() : ''
+ const lowercaseMap: Record = {
+ 'pending': 0,
+ 'approved': 1,
+ 'rejected': 2,
+ }
+ const uppercaseMap: Record = {
+ 'PENDING': 0,
+ 'APPROVED': 1,
+ 'REJECTED': 2,
+ }
+ const prefixedMap: Record = {
+ 'REVIEW_STATUS_PENDING': 0,
+ 'REVIEW_STATUS_APPROVED': 1,
+ 'REVIEW_STATUS_REJECTED': 2,
+ }
+ return lowercaseMap[s] ?? uppercaseMap[s] ?? prefixedMap[s] ?? 0
+ }
+
+ return {
+ data: {
+ code: 200,
+ message: backendData.message || '获取成功',
+ data: {
+ id: progress.id,
+ userId: progress.user_id,
+ taskId: progress.task_id,
+ isCompleted: progress.is_completed ?? false,
+ completedAt: progress.completed_at || '',
+ reviewStatus: convertReviewStatus(progress.review_status),
+ reviewComment: progress.review_comment || '',
+ reviewImages: Array.isArray(progress.review_images)
+ ? progress.review_images
+ : progress.review_images
+ ? Object.values(progress.review_images)
+ : [],
+ answerImages: Array.isArray(progress.answer_images)
+ ? progress.answer_images
+ : progress.answer_images
+ ? Object.values(progress.answer_images)
+ : [],
+ essayReviewStatuses: Array.isArray(progress.essay_review_statuses)
+ ? progress.essay_review_statuses.map((s: string) => convertReviewStatus(s))
+ : undefined,
+ needReview: progress.need_review ?? false,
+ }
+ }
+ }
+ })
+}
+
+export const listUserProgress = (params: ListUserProgressRequest) => {
+ // 转换参数格式:驼峰 -> 下划线
+ const apiParams: any = {
+ page: params.page,
+ page_size: params.pageSize,
+ }
+
+ if (params.userId) {
+ apiParams.user_id = params.userId
+ }
+
+ if (params.userKeyword?.trim()) {
+ apiParams.user_keyword = params.userKeyword.trim()
+ }
+
+ if (params.taskId) {
+ apiParams.task_id = params.taskId
+ }
+
+ if (params.sectionId) {
+ apiParams.section_id = params.sectionId
+ }
+
+ if (params.campId) {
+ apiParams.camp_id = params.campId
+ }
+
+ if (params.reviewStatus !== undefined && params.reviewStatus !== '') {
+ apiParams.review_status =
+ typeof params.reviewStatus === 'number'
+ ? (params.reviewStatus === 0 ? 'pending' : params.reviewStatus === 1 ? 'approved' : 'rejected')
+ : String(params.reviewStatus)
+ }
+
+ return request.get('/camp/progress/list', { params: apiParams }).then((response) => {
+ const backendData = response.data
+
+ // progress_list 可能是对象或数组
+ let progressList = []
+ if (Array.isArray(backendData.progress_list)) {
+ progressList = backendData.progress_list
+ } else if (backendData.progress_list && typeof backendData.progress_list === 'object') {
+ progressList = Object.values(backendData.progress_list)
+ }
+
+ // 转换 review_status 字符串到数字
+ const convertReviewStatus = (status: any): number => {
+ if (typeof status === 'number') return status
+ const s = typeof status === 'string' ? status.trim() : ''
+ // 小写(后端 review_status 常用)
+ const lowercaseMap: Record = {
+ 'pending': 0,
+ 'approved': 1,
+ 'rejected': 2,
+ }
+ // 大写(后端 essay_review_statuses 存的是 PENDING/APPROVED/REJECTED)
+ const uppercaseMap: Record = {
+ 'PENDING': 0,
+ 'APPROVED': 1,
+ 'REJECTED': 2,
+ }
+ // 带前缀的枚举(兼容)
+ const prefixedMap: Record = {
+ 'REVIEW_STATUS_PENDING': 0,
+ 'REVIEW_STATUS_APPROVED': 1,
+ 'REVIEW_STATUS_REJECTED': 2,
+ }
+ return lowercaseMap[s] ?? uppercaseMap[s] ?? prefixedMap[s] ?? 0
+ }
+
+ return {
+ data: {
+ code: 200,
+ message: backendData.message,
+ data: {
+ list: progressList.map((item: any) => {
+ // 提取并过滤空字符串
+ const rawReviewImages = Array.isArray(item.review_images)
+ ? item.review_images
+ : item.review_images
+ ? Object.values(item.review_images)
+ : []
+ const rawAnswerImages = Array.isArray(item.answer_images)
+ ? item.answer_images
+ : item.answer_images
+ ? Object.values(item.answer_images)
+ : []
+ // 申论题按题答案:essay_answer_images 为二维数组 [[url,url],[url],...]
+ let rawEssayAnswerImages: string[][] = []
+ if (Array.isArray(item.essay_answer_images) && item.essay_answer_images.length > 0) {
+ rawEssayAnswerImages = item.essay_answer_images.map((arr: any) =>
+ Array.isArray(arr) ? arr.filter((url: string) => url && String(url).trim() !== '') : []
+ )
+ }
+ return {
+ id: item.id,
+ userId: item.user_id,
+ taskId: item.task_id,
+ campId: item.camp_id || item.campId,
+ sectionId: item.section_id || item.sectionId,
+ isCompleted: item.is_completed ?? false,
+ completedAt: item.completed_at || '',
+ reviewStatus: convertReviewStatus(item.review_status),
+ reviewComment: item.review_comment || '',
+ reviewImages: rawReviewImages.filter((url: string) => url && url.trim() !== ''),
+ answerImages: rawAnswerImages.filter((url: string) => url && url.trim() !== ''),
+ essayAnswerImages: rawEssayAnswerImages,
+ essayReviewStatuses: Array.isArray(item.essay_review_statuses)
+ ? item.essay_review_statuses.map((s: string) => convertReviewStatus(s))
+ : undefined,
+ needReview: item.need_review ?? false,
+ }
+ }),
+ total: backendData.total || 0,
+ userIds: Array.isArray(backendData.user_ids) ? backendData.user_ids : [],
+ page: params.page,
+ pageSize: params.pageSize,
+ }
+ }
+ }
+ })
+}
+
+/** 获取待审核任务数量(后台右上角提示用) */
+export const getPendingReviewCount = () => {
+ return request.get<{ success: boolean; count: number }>('/camp/progress/pending-count').then((response) => {
+ const data = response.data as { success?: boolean; count?: number }
+ return { data: { code: 200, count: data?.count ?? 0 } }
+ })
+}
diff --git a/src/api/document.ts b/src/api/document.ts
new file mode 100644
index 0000000..bdedc2e
--- /dev/null
+++ b/src/api/document.ts
@@ -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)
diff --git a/src/api/oss.ts b/src/api/oss.ts
new file mode 100644
index 0000000..365f385
--- /dev/null
+++ b/src/api/oss.ts
@@ -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 {
+ const response = await request.get('/oss/upload/signature', {
+ params: { dir },
+ })
+ return response.data
+}
+
diff --git a/src/api/permission.ts b/src/api/permission.ts
new file mode 100644
index 0000000..71aa82b
--- /dev/null
+++ b/src/api/permission.ts
@@ -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 || []
+ }
+ }
+ })
+}
+
diff --git a/src/api/question.ts b/src/api/question.ts
new file mode 100644
index 0000000..4adc12a
--- /dev/null
+++ b/src/api/question.ts
@@ -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) => {
+ 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 = {
+ '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 = {
+ '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) => {
+ 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) => {
+ 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 || '删除成功',
+ }
+ }
+ })
+}
diff --git a/src/api/role.ts b/src/api/role.ts
new file mode 100644
index 0000000..680b6ae
--- /dev/null
+++ b/src/api/role.ts
@@ -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 || '设置角色权限成功'
+ }
+ }
+ })
+}
+
diff --git a/src/api/statistics.ts b/src/api/statistics.ts
new file mode 100644
index 0000000..733cac8
--- /dev/null
+++ b/src/api/statistics.ts
@@ -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,
+ }
+ }
+ }
+ })
+}
+
diff --git a/src/components/ImageUpload.tsx b/src/components/ImageUpload.tsx
new file mode 100644
index 0000000..2f57883
--- /dev/null
+++ b/src/components/ImageUpload.tsx
@@ -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 = ({
+ value,
+ onChange,
+ placeholder = '请选择图片',
+ maxSize = 5,
+ accept = 'image/*',
+}) => {
+ const [uploading, setUploading] = useState(false)
+
+ // 获取OSS上传凭证
+ const getUploadCredentials = async (dir: string = 'camp'): Promise => {
+ 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 (
+
+ {/* 图片预览 */}
+ {value && (
+
+
+ }
+ onClick={handleRemove}
+ style={{
+ position: 'absolute',
+ top: -8,
+ right: -8,
+ backgroundColor: '#fff',
+ borderRadius: '50%',
+ boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
+ }}
+ />
+
+ )}
+
+ {/* 上传按钮:始终显示,允许重新上传 */}
+
+ }
+ loading={uploading}
+ disabled={uploading}
+ >
+ {uploading ? '上传中...' : (value ? '重新上传' : placeholder)}
+
+
+
+ )
+}
+
+export default ImageUpload
diff --git a/src/components/KnowledgeTree/index.tsx b/src/components/KnowledgeTree/index.tsx
new file mode 100644
index 0000000..abe4a60
--- /dev/null
+++ b/src/components/KnowledgeTree/index.tsx
@@ -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 = ({ type, onSelect }) => {
+ const [treeData, setTreeData] = useState([])
+ const [loading, setLoading] = useState(false)
+ const [selectedKeys, setSelectedKeys] = useState([])
+ const [expandedKeys, setExpandedKeys] = useState([])
+ const [modalVisible, setModalVisible] = useState(false)
+ const [editingNode, setEditingNode] = useState(null)
+ const [parentNodeId, setParentNodeId] = useState('')
+ 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 (
+
+
{title}
+
e.stopPropagation()}>
+ }
+ onClick={() => handleAddNode(node.key as string)}
+ title="添加子节点"
+ />
+ }
+ onClick={() => handleEditNode(node.key as string)}
+ title="编辑节点"
+ />
+ handleDeleteNode(node.key as string)}
+ okText="确定"
+ cancelText="取消"
+ >
+ }
+ title="删除节点"
+ />
+
+
+
+ )
+ }
+
+ return (
+
+
+
知识树
+
+ }
+ onClick={() => handleAddNode('')}
+ type="primary"
+ >
+ 添加根节点
+
+ }
+ onClick={fetchTree}
+ loading={loading}
+ >
+ 刷新
+
+
+
+
+
{
+ setExpandedKeys(keys)
+ saveExpandedKeys(keys)
+ }}
+ titleRender={renderTitle}
+ showLine
+ blockNode
+ />
+
+ {
+ setModalVisible(false)
+ form.resetFields()
+ }}
+ okText="确定"
+ cancelText="取消"
+ >
+
+
+
+
+
+
+ )
+}
+
+export default KnowledgeTree
diff --git a/src/components/Permission/PermissionWrapper.tsx b/src/components/Permission/PermissionWrapper.tsx
new file mode 100644
index 0000000..815d941
--- /dev/null
+++ b/src/components/Permission/PermissionWrapper.tsx
@@ -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
+
diff --git a/src/components/Permission/index.ts b/src/components/Permission/index.ts
new file mode 100644
index 0000000..052d340
--- /dev/null
+++ b/src/components/Permission/index.ts
@@ -0,0 +1,2 @@
+export { PermissionWrapper, RequirePermission } from './PermissionWrapper'
+
diff --git a/src/components/RichTextEditor/RichTextEditor.css b/src/components/RichTextEditor/RichTextEditor.css
new file mode 100644
index 0000000..bb885ed
--- /dev/null
+++ b/src/components/RichTextEditor/RichTextEditor.css
@@ -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;
+}
diff --git a/src/components/RichTextEditor/RichTextEditor.tsx b/src/components/RichTextEditor/RichTextEditor.tsx
new file mode 100644
index 0000000..dbe20f5
--- /dev/null
+++ b/src/components/RichTextEditor/RichTextEditor.tsx
@@ -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 = 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 (
+
+ {/* 工具栏 */}
+
+
+ }
+ onClick={() => editor.chain().focus().toggleBold().run()}
+ title="加粗"
+ />
+ }
+ onClick={() => editor.chain().focus().toggleItalic().run()}
+ title="斜体"
+ />
+ }
+ onClick={() => editor.chain().focus().toggleUnderline().run()}
+ title="下划线"
+ />
+ }
+ onClick={() => editor.chain().focus().toggleStrike().run()}
+ title="删除线"
+ />
+ }
+ onClick={() => editor.chain().focus().toggleBulletList().run()}
+ title="无序列表"
+ />
+ }
+ onClick={() => editor.chain().focus().toggleOrderedList().run()}
+ title="有序列表"
+ />
+ }
+ onClick={handleAddLink}
+ title="添加链接"
+ />
+ }
+ onClick={handleImageUpload}
+ title="插入图片"
+ />
+ }
+ onClick={() => editor.chain().focus().undo().run()}
+ disabled={!editor.can().undo()}
+ title="撤销"
+ />
+ }
+ onClick={() => editor.chain().focus().redo().run()}
+ disabled={!editor.can().redo()}
+ title="重做"
+ />
+
+
+
+ {/* 编辑器内容区 */}
+
+
+
+
+ )
+})
+
+RichTextEditor.displayName = 'RichTextEditor'
+
+export default RichTextEditor
diff --git a/src/components/SubjectiveQuestionForm/index.tsx b/src/components/SubjectiveQuestionForm/index.tsx
new file mode 100644
index 0000000..db73934
--- /dev/null
+++ b/src/components/SubjectiveQuestionForm/index.tsx
@@ -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([])
+ 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 = {
+ 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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export default SubjectiveQuestionForm
diff --git a/src/constants/index.ts b/src/constants/index.ts
new file mode 100644
index 0000000..7cc4679
--- /dev/null
+++ b/src/constants/index.ts
@@ -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'
+
diff --git a/src/index.css b/src/index.css
new file mode 100644
index 0000000..47651f7
--- /dev/null
+++ b/src/index.css
@@ -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;
+ }
+}
+
diff --git a/src/layouts/BasicLayout.css b/src/layouts/BasicLayout.css
new file mode 100644
index 0000000..97c2f4d
--- /dev/null
+++ b/src/layouts/BasicLayout.css
@@ -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;
+ }
+}
+
diff --git a/src/layouts/BasicLayout.tsx b/src/layouts/BasicLayout.tsx
new file mode 100644
index 0000000..b736136
--- /dev/null
+++ b/src/layouts/BasicLayout.tsx
@@ -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: ,
+ label: '仪表盘',
+ requiredPermission: 'statistics:dashboard:read',
+ },
+ {
+ key: '/admin',
+ icon: ,
+ 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: ,
+ 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: ,
+ 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: ,
+ 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: ,
+ 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: ,
+ 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(pathBasedOpenKeys)
+
+ useEffect(() => {
+ setMenuOpenKeys(pathBasedOpenKeys)
+ }, [pathBasedOpenKeys])
+
+ // 菜单组件
+ const menuComponent = (
+
+ )
+
+ return (
+
+ {/* 桌面端侧边栏 */}
+ {!isMobile && (
+
+
+
{collapsed ? '怼怼' : '怼怼后台'}
+
+ {menuComponent}
+
+ )}
+
+ {/* 移动端抽屉菜单 */}
+ setMobileMenuVisible(false)}
+ open={mobileMenuVisible}
+ styles={{ body: { padding: 0 } }}
+ width={200}
+ >
+
+
怼怼后台
+
+ {menuComponent}
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export default BasicLayout
+
diff --git a/src/main.tsx b/src/main.tsx
new file mode 100644
index 0000000..a31c2f2
--- /dev/null
+++ b/src/main.tsx
@@ -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(
+
+
+ ,
+)
+
diff --git a/src/pages/Admin/AdminUserList.tsx b/src/pages/Admin/AdminUserList.tsx
new file mode 100644
index 0000000..0e91a9f
--- /dev/null
+++ b/src/pages/Admin/AdminUserList.tsx
@@ -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([])
+ 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(null)
+ const [roleModalVisible, setRoleModalVisible] = useState(false)
+ const [currentUser, setCurrentUser] = useState(null)
+ const [allRoles, setAllRoles] = useState([])
+ const [selectedRoleIds, setSelectedRoleIds] = useState([])
+ 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 = [
+ {
+ 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) => (
+
+ {isSuperAdmin === 1 ? '是' : '否'}
+
+ ),
+ },
+ {
+ title: '状态',
+ dataIndex: 'status',
+ key: 'status',
+ width: 100,
+ render: (status: number) => (
+
+ {status === 1 ? '启用' : '禁用'}
+
+ ),
+ },
+ {
+ 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) => (
+
+ } size="small" onClick={() => handleEdit(record)}>
+ 编辑
+
+ } size="small" onClick={() => handleAssignRoles(record)}>
+ 分配角色
+
+ } size="small" onClick={() => handleDelete(record)}>
+ 删除
+
+
+ ),
+ },
+ ]
+
+ return (
+
+
+
+ }
+ value={searchText}
+ onChange={(e) => setSearchText(e.target.value)}
+ onPressEnter={handleSearch}
+ style={{ width: 300 }}
+ allowClear
+ />
+
+
+ } onClick={handleAdd}>
+ 新增管理员
+
+
+
+
`共 ${total} 条`,
+ onChange: (page, pageSize) => {
+ setPage(page)
+ setPageSize(pageSize)
+ },
+ }}
+ scroll={{ x: 1200 }}
+ />
+
+ {
+ setModalVisible(false)
+ form.resetFields()
+ }}
+ width={600}
+ okText="确定"
+ cancelText="取消"
+ >
+ {
+ const phone = form.getFieldValue('phone')
+ if (!value && !phone) {
+ return Promise.reject(new Error('用户名和手机号至少填写一个'))
+ }
+ return Promise.resolve()
+ },
+ },
+ ]}
+ >
+ } placeholder="请输入用户名(可选)" />
+
+
+ {
+ 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()
+ },
+ },
+ ]}
+ >
+ } placeholder="请输入手机号(可选)" />
+
+
+ {!editingUser && (
+
+
+
+ )}
+
+ {editingUser && (
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ 启用
+ 禁用
+
+
+
+
+
+ 否
+ 是
+
+
+
+
+
+ {/* 角色分配Modal */}
+ {
+ setRoleModalVisible(false)
+ setCurrentUser(null)
+ setSelectedRoleIds([])
+ }}
+ width={600}
+ okText="保存"
+ cancelText="取消"
+ confirmLoading={roleLoading}
+ >
+
+ {roleLoading ? (
+
加载中...
+ ) : (
+ <>
+
+ 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([])
+ }
+ }}
+ >
+ 全选
+
+
+ 已选择 {selectedRoleIds.length} / {allRoles.length} 个角色
+
+
+
+ {allRoles.map(role => (
+
+ {
+ if (e.target.checked) {
+ setSelectedRoleIds([...selectedRoleIds, role.id])
+ } else {
+ setSelectedRoleIds(selectedRoleIds.filter(id => id !== role.id))
+ }
+ }}
+ >
+ {role.name}
+ {role.code}
+ {role.status === 0 && 已禁用}
+ {role.description && (
+ {role.description}
+ )}
+
+
+ ))}
+ >
+ )}
+
+
+
+ )
+}
+
+export default AdminUserList
+
diff --git a/src/pages/Admin/PermissionList.tsx b/src/pages/Admin/PermissionList.tsx
new file mode 100644
index 0000000..2a8c1b4
--- /dev/null
+++ b/src/pages/Admin/PermissionList.tsx
@@ -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('')
+ const [resources, setResources] = useState([])
+ const [loading, setLoading] = useState(false)
+ const [data, setData] = useState([])
+ 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(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 = [
+ {
+ 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) => {text},
+ },
+ {
+ title: '操作',
+ dataIndex: 'action',
+ key: 'action',
+ render: (text: string) => {text},
+ },
+ {
+ 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) => (
+
+ } size="small" onClick={() => handleEdit(record)}>
+ 编辑
+
+ } size="small" onClick={() => handleDelete(record)}>
+ 删除
+
+
+ ),
+ },
+ ]
+
+ return (
+
+
+
+ }
+ value={searchText}
+ onChange={(e) => setSearchText(e.target.value)}
+ onPressEnter={handleSearch}
+ style={{ width: 250 }}
+ allowClear
+ />
+
+
+
+ } onClick={handleAdd}>
+ 新增权限
+
+
+
+
`共 ${total} 条`,
+ onChange: (page, pageSize) => {
+ setPage(page)
+ setPageSize(pageSize)
+ },
+ }}
+ scroll={{ x: 1200 }}
+ />
+
+ {
+ setModalVisible(false)
+ form.resetFields()
+ }}
+ width={600}
+ okText="确定"
+ cancelText="取消"
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export default PermissionList
+
diff --git a/src/pages/Admin/RoleList.tsx b/src/pages/Admin/RoleList.tsx
new file mode 100644
index 0000000..2471fb8
--- /dev/null
+++ b/src/pages/Admin/RoleList.tsx
@@ -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([])
+ 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(null)
+ const [permissionModalVisible, setPermissionModalVisible] = useState(false)
+ const [currentRole, setCurrentRole] = useState(null)
+ const [allPermissions, setAllPermissions] = useState([])
+ const [selectedPermissionIds, setSelectedPermissionIds] = useState([])
+ 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 = {}
+ 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 = [
+ {
+ 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) => (
+
+ {status === 1 ? '启用' : '禁用'}
+
+ ),
+ },
+ {
+ title: '创建时间',
+ dataIndex: 'created_at',
+ key: 'created_at',
+ width: 180,
+ },
+ {
+ title: '操作',
+ key: 'action',
+ width: 320,
+ fixed: 'right',
+ render: (_: any, record: Role) => (
+
+ } size="small" onClick={() => handleEdit(record)}>
+ 编辑
+
+ } size="small" onClick={() => handleAssignPermissions(record)}>
+ 分配权限
+
+ } size="small" onClick={() => handleDelete(record)}>
+ 删除
+
+
+ ),
+ },
+ ]
+
+ return (
+
+
+
+ }
+ value={searchText}
+ onChange={(e) => setSearchText(e.target.value)}
+ onPressEnter={handleSearch}
+ style={{ width: 300 }}
+ allowClear
+ />
+
+
+ } onClick={handleAdd}>
+ 新增角色
+
+
+
+
`共 ${total} 条`,
+ onChange: (page, pageSize) => {
+ setPage(page)
+ setPageSize(pageSize)
+ },
+ }}
+ scroll={{ x: 1000 }}
+ />
+
+ {
+ setModalVisible(false)
+ form.resetFields()
+ }}
+ width={600}
+ okText="确定"
+ cancelText="取消"
+ >
+
+
+
+
+ {editingRole && (
+
+
+
+ 角色代码由系统自动生成,不可修改
+
+
+ )}
+
+
+
+
+
+
+
+ 启用
+ 禁用
+
+
+
+
+
+ {/* 权限分配Modal */}
+ {
+ setPermissionModalVisible(false)
+ setCurrentRole(null)
+ setSelectedPermissionIds([])
+ }}
+ width={800}
+ okText="保存"
+ cancelText="取消"
+ confirmLoading={permissionLoading}
+ >
+
+ {permissionLoading ? (
+
加载中...
+ ) : (
+ <>
+
+ 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([])
+ }
+ }}
+ >
+ 全选
+
+
+ 已选择 {selectedPermissionIds.length} / {allPermissions.length} 个权限
+
+
+
+ {Object.entries(groupPermissionsByResource()).map(([resource, permissions]) => (
+
+
+ {resource}
+ 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)))
+ }
+ }}
+ >
+ 全选该资源
+
+
+
+ {permissions.map(perm => (
+
+ {
+ if (e.target.checked) {
+ setSelectedPermissionIds([...selectedPermissionIds, perm.id])
+ } else {
+ setSelectedPermissionIds(selectedPermissionIds.filter(id => id !== perm.id))
+ }
+ }}
+ >
+ {perm.name}
+ {perm.action}
+ {perm.description && (
+ {perm.description}
+ )}
+
+
+ ))}
+
+
+ ))}
+ >
+ )}
+
+
+
+ )
+}
+
+export default RoleList
+
diff --git a/src/pages/Camp/CampList.tsx b/src/pages/Camp/CampList.tsx
new file mode 100644
index 0000000..c24b80c
--- /dev/null
+++ b/src/pages/Camp/CampList.tsx
@@ -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([])
+ const [categories, setCategories] = useState([])
+ 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()
+ const [recommendFilter, setRecommendFilter] = useState(RecommendFilter.ALL)
+ const [modalVisible, setModalVisible] = useState(false)
+ const [editingRecord, setEditingRecord] = useState(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 {config.text}
+ }
+
+ const columns: ColumnsType = [
+ {
+ title: '封面图',
+ dataIndex: 'coverImage',
+ key: 'coverImage',
+ width: 100,
+ render: (url: string) => ,
+ },
+ {
+ 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) => (
+ {recommended ? '是' : '否'}
+ ),
+ },
+ {
+ title: '操作',
+ key: 'action',
+ width: 280,
+ fixed: 'right',
+ render: (_, record) => (
+
+ }
+ onClick={() => navigate(`${RoutePath.CAMP_SECTION_LIST}?campId=${record.id}`)}
+ >
+ 查看小节
+
+ }
+ onClick={() => navigate(`${RoutePath.CAMP_PROGRESS_LIST}?campId=${record.id}`)}
+ >
+ 打卡营进度管理
+
+ } onClick={() => handleOpenModal(record)}>
+ 编辑
+
+ handleDelete(record.id)}
+ okText="确定"
+ cancelText="取消"
+ >
+ }>
+ 删除
+
+
+
+ ),
+ },
+ ]
+
+ return (
+
+ {/* 页面标题 */}
+
+
打卡营列表
+
+ 管理所有打卡营,支持按分类和推荐状态筛选,可查看打卡营下的小节列表
+
+
+
+
+
+ setKeyword(e.target.value)}
+ onSearch={handleSearch}
+ />
+
+
+
+
`共 ${total} 条`,
+ onChange: (page, pageSize) => {
+ setPage(page)
+ setPageSize(pageSize)
+ },
+ }}
+ />
+
+ {
+ setModalVisible(false)
+ setEditingRecord(null)
+ form.resetFields()
+ }}
+ width={700}
+ >
+
+
+
+
+
+
+
+
+
+
+ ({ label: c.name, value: c.id }))}
+ />
+
+
+ {
+ // 切换简介类型时,清空简介内容
+ form.setFieldsValue({ introContent: '' })
+ }}
+ options={[
+ { label: '无简介', value: IntroType.NONE },
+ { label: '图文简介', value: IntroType.IMAGE_TEXT },
+ { label: '视频简介', value: IntroType.VIDEO },
+ ]}
+ />
+
+ {/* 图文简介:显示图片上传 */}
+ {introTypeValue === IntroType.IMAGE_TEXT && (
+
+
+
+ )}
+ {/* 视频简介:显示URL输入框 */}
+ {introTypeValue === IntroType.VIDEO && (
+
+
+
+ )}
+ {/* 无简介时不显示简介内容字段 */}
+
+
+
+
+
+
+ )
+}
+
+export default CampList
+
diff --git a/src/pages/Camp/CategoryList.tsx b/src/pages/Camp/CategoryList.tsx
new file mode 100644
index 0000000..6451c5b
--- /dev/null
+++ b/src/pages/Camp/CategoryList.tsx
@@ -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([])
+ 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(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 = [
+ {
+ 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) => (
+
+ }
+ onClick={() => navigate(`${RoutePath.CAMP_LIST}?categoryId=${record.id}`)}
+ >
+ 查看打卡营
+
+ }
+ onClick={() => handleOpenModal(record)}
+ >
+ 编辑
+
+ handleDelete(record.id)}
+ okText="确定"
+ cancelText="取消"
+ >
+ }>
+ 删除
+
+
+
+ ),
+ },
+ ]
+
+ return (
+
+ {/* 页面标题 */}
+
+
分类管理
+
+ 管理打卡营的分类,支持新增、编辑、删除分类,并可查看每个分类下的打卡营列表
+
+
+
+
+
+ setKeyword(e.target.value)}
+ onSearch={handleSearch}
+ />
+ } onClick={fetchData}>
+ 刷新
+
+ } onClick={handleResetFilters}>
+ 重置
+
+
+ } onClick={() => handleOpenModal()}>
+ 新增分类
+
+
+
+
`共 ${total} 条`,
+ onChange: (page, pageSize) => {
+ setPage(page)
+ setPageSize(pageSize)
+ },
+ }}
+ />
+
+ {
+ setModalVisible(false)
+ setEditingRecord(null)
+ form.resetFields()
+ }}
+ >
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export default CategoryList
+
diff --git a/src/pages/Camp/PendingTaskList.tsx b/src/pages/Camp/PendingTaskList.tsx
new file mode 100644
index 0000000..fc9f69c
--- /dev/null
+++ b/src/pages/Camp/PendingTaskList.tsx
@@ -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([])
+ const [sections, setSections] = useState([])
+ const [tasks, setTasks] = useState([])
+ const [campFilter, setCampFilter] = useState()
+ const [sectionFilter, setSectionFilter] = useState()
+ const [page, setPage] = useState(1)
+ const [total, setTotal] = useState(0)
+ const [progressList, setProgressList] = useState([])
+
+ const [reviewModalVisible, setReviewModalVisible] = useState(false)
+ const [reviewingProgress, setReviewingProgress] = useState(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 = [
+ {
+ 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 (
+
+ {title}
+ {type !== undefined && {TASK_TYPE_MAP[type] || type}}
+
+ )
+ },
+ },
+ {
+ 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 {text}
+ },
+ },
+ {
+ 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) => (
+
+ ),
+ },
+ ]
+
+ return (
+
+
+
+ 任务审批列表
+ 当前仅展示待审核的任务
+
+
+
+
+
+
+ 打卡营:
+ ({ label: c.title, value: c.id }))}
+ />
+
+
+ 小节:
+ !campFilter || s.campId === campFilter)
+ .map((s) => ({ label: s.title, value: s.id }))}
+ />
+
+
+
+
+
setPage(p),
+ showSizeChanger: false,
+ }}
+ />
+
+ 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}
+ />
+
+ )
+}
+
+export default PendingTaskList
+
diff --git a/src/pages/Camp/SectionList.tsx b/src/pages/Camp/SectionList.tsx
new file mode 100644
index 0000000..9a41fb3
--- /dev/null
+++ b/src/pages/Camp/SectionList.tsx
@@ -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([])
+ const [camps, setCamps] = useState([])
+ 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(() => searchParams.get('campId') ?? undefined)
+ const [modalVisible, setModalVisible] = useState(false)
+ const [editingRecord, setEditingRecord] = useState(null)
+
+ const [form] = Form.useForm()
+ const timeIntervalType = Form.useWatch('timeIntervalType', form)
+ const sectionMaxNumberMap = useMemo(() => {
+ const map: Record = {}
+ 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 = {}) => {
+ 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 = {
+ [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 未知
+ }
+ return {config.text}
+ }
+
+ const columns: ColumnsType = [
+ {
+ 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 (
+
+ {isLastSection && (
+ }
+ onClick={() =>
+ handleOpenModal(undefined, {
+ campId: record.campId,
+ })
+ }
+ >
+ 添加下节
+
+ )}
+ }
+ onClick={() => navigate(`${RoutePath.CAMP_TASK_LIST}?sectionId=${record.id}`)}
+ >
+ 查看任务
+
+ }
+ onClick={() => navigate(`${RoutePath.CAMP_PROGRESS_LIST}?sectionId=${record.id}`)}
+ >
+ 打卡营进度管理
+
+ } onClick={() => handleOpenModal(record)}>
+ 编辑
+
+ handleDelete(record.id)}
+ okText="确定"
+ cancelText="取消"
+ >
+ }>
+ 删除
+
+
+
+ )
+ },
+ },
+ ]
+
+ return (
+
+ {/* 页面标题 */}
+
+
小节管理
+
+ 管理打卡营的小节,包括价格设置、时间间隔配置等,可查看小节下的任务列表和用户完成进度
+
+
+
+
+
+ setKeyword(e.target.value)}
+ onSearch={handleSearch}
+ />
+ {
+ setCampFilter(value)
+ setPage(1)
+ }}
+ options={camps.map((c) => ({ label: c.title, value: c.id }))}
+ />
+ } onClick={fetchData}>
+ 刷新
+
+ } onClick={handleResetFilters}>
+ 重置
+
+ } onClick={() => handleOpenModal()}>
+ 新增小节
+
+
+
+
+
`共 ${total} 条`,
+ onChange: (page, pageSize) => {
+ setPage(page)
+ setPageSize(pageSize)
+ },
+ }}
+ />
+
+ {
+ setModalVisible(false)
+ setEditingRecord(null)
+ form.resetFields()
+ }}
+ width={700}
+ >
+
+ ({ label: c.title, value: c.id }))}
+ />
+
+
+
+
+ {editingRecord != null && (
+
+
+
+ )}
+
+ {
+ 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)
+ }
+ }}
+ />
+
+ {timeIntervalType === TimeIntervalType.PAID && (
+
+
+
+ )}
+ {timeIntervalType === TimeIntervalType.HOUR && (
+
+
+
+ )}
+
+
+
+ )
+}
+
+export default SectionList
+
diff --git a/src/pages/Camp/TaskList.tsx b/src/pages/Camp/TaskList.tsx
new file mode 100644
index 0000000..b54e0c7
--- /dev/null
+++ b/src/pages/Camp/TaskList.tsx
@@ -0,0 +1,1039 @@
+import { useState, useEffect, useMemo } from 'react'
+import {
+ Table,
+ Button,
+ Form,
+ Input,
+ Modal,
+ message,
+ Space,
+ Popconfirm,
+ Select,
+ Tag,
+ InputNumber,
+ Switch,
+} from 'antd'
+import { PlusOutlined, EditOutlined, DeleteOutlined, ReloadOutlined, ClearOutlined, FolderOpenOutlined } from '@ant-design/icons'
+import { useSearchParams } from 'react-router-dom'
+import type { ColumnsType } from 'antd/es/table'
+import type { Task, Section, Camp } from '@/types/camp'
+import type { Paper } from '@/types/question'
+import { TaskType } from '@/types/camp'
+import { listTasks, createTask, updateTask, deleteTask, listSections, listCamps } from '@/api/camp'
+import { searchPapers } from '@/api/question'
+import { DEFAULT_PAGE_SIZE } from '@/constants'
+import ImageUpload from '@/components/ImageUpload'
+import { listFolders as listDocFolders, listFiles as listDocFiles, type DocFolder, type DocFile } from '@/api/document'
+
+const { TextArea } = Input
+
+const TaskList = () => {
+ const [searchParams] = useSearchParams()
+ const [loading, setLoading] = useState(false)
+ const [dataSource, setDataSource] = useState([])
+ const [sections, setSections] = useState([])
+ const [camps, setCamps] = useState([])
+ const [papers, setPapers] = useState([])
+ const [total, setTotal] = useState(0)
+ const [page, setPage] = useState(1)
+ const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE)
+ const [keyword, setKeyword] = useState('')
+ const [campFilter, setCampFilter] = useState()
+ const [sectionFilter, setSectionFilter] = useState()
+ const [taskTypeFilter, setTaskTypeFilter] = useState()
+ const [modalVisible, setModalVisible] = useState(false)
+ const [editingRecord, setEditingRecord] = useState(null)
+ const [modalSections, setModalSections] = useState([])
+ const [sectionTasksForPrerequisite, setSectionTasksForPrerequisite] = useState([])
+ // 主观题 PDF:从「文档管理」选择
+ const [docModalVisible, setDocModalVisible] = useState(false)
+ const [docFolders, setDocFolders] = useState([])
+ const [docFiles, setDocFiles] = useState([])
+ const [docFolderId, setDocFolderId] = useState()
+ const [docLoading, setDocLoading] = useState(false)
+ const [pdfDocName, setPdfDocName] = useState('') // 展示用:当前选择的文档名称
+
+ const [form] = Form.useForm()
+ const taskTypeValue = Form.useWatch('taskType', form)
+ const videoUrlValue = Form.useWatch('videoUrl', form)
+ const formCampId = Form.useWatch('campId', form)
+ const formSectionId = Form.useWatch('sectionId', form)
+ const pdfUrlValue = Form.useWatch('pdfUrl', form)
+
+ const pdfNameFromUrl = useMemo(() => {
+ const raw = (pdfUrlValue || '').trim()
+ if (!raw) return ''
+ const withoutQuery = raw.split('?')[0]
+ const last = withoutQuery.split('/').pop() || ''
+ try {
+ return decodeURIComponent(last)
+ } catch {
+ return last
+ }
+ }, [pdfUrlValue])
+
+ const pdfDisplayName = (pdfDocName || '').trim() || pdfNameFromUrl
+
+ // 从 URL 参数中读取小节筛选
+ useEffect(() => {
+ const sectionId = searchParams.get('sectionId')
+ if (sectionId) {
+ setSectionFilter(sectionId)
+ }
+ }, [searchParams])
+
+ // 当编辑记录切换 / URL 变化时同步展示名(避免仅显示 URL)
+ useEffect(() => {
+ if (!modalVisible) return
+ // 若用户已从文档选择过(pdfDocName 有值)则不覆盖
+ if ((pdfDocName || '').trim()) return
+ if ((pdfNameFromUrl || '').trim()) setPdfDocName(pdfNameFromUrl)
+ }, [modalVisible, editingRecord?.id, pdfNameFromUrl])
+
+ // 获取打卡营列表
+ 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 fetchSections = async (campId?: string) => {
+ try {
+ const res = await listSections({ page: 1, pageSize: 1000, campId: campId || undefined })
+ if (res.data.code === 200) {
+ setSections(res.data.data.list)
+ }
+ } catch (error) {
+ console.error('获取小节列表失败')
+ }
+ }
+
+ // 弹窗内按打卡营拉取小节(与顶部筛选的 sections 分离,避免互相覆盖)
+ const fetchModalSections = async (campId: string) => {
+ if (!campId) {
+ setModalSections([])
+ return
+ }
+ try {
+ const res = await listSections({ page: 1, pageSize: 1000, campId })
+ if (res.data.code === 200) {
+ setModalSections(res.data.data.list)
+ } else {
+ setModalSections([])
+ }
+ } catch (error) {
+ console.error('获取小节列表失败')
+ setModalSections([])
+ }
+ }
+
+ // 获取试卷列表(用于关联客观题/申论题)
+ const fetchPapers = async () => {
+ try {
+ const res = await searchPapers({ page: 1, pageSize: 1000 })
+ if (res.data.code === 200) {
+ setPapers(res.data.data.list)
+ }
+ } catch (error) {
+ console.error('获取试卷列表失败')
+ }
+ }
+
+ // 获取列表数据
+ const fetchData = async () => {
+ setLoading(true)
+ try {
+ const res = await listTasks({
+ page,
+ pageSize,
+ keyword,
+ campId: campFilter,
+ sectionId: sectionFilter,
+ taskType: taskTypeFilter,
+ })
+ 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()
+ fetchPapers()
+ }, [])
+
+ // 打卡营筛选变化时:清空小节筛选并重新拉取该营下的小节(初始 mount 时 campFilter 为 undefined,拉取全部小节)
+ useEffect(() => {
+ if (campFilter !== undefined) {
+ setSectionFilter(undefined)
+ fetchSections(campFilter)
+ } else {
+ fetchSections()
+ }
+ }, [campFilter])
+
+ // 弹窗内:打卡营变化时拉取该营下的小节并清空小节选择
+ useEffect(() => {
+ if (!modalVisible) return
+ if (formCampId) {
+ fetchModalSections(formCampId)
+ } else {
+ setModalSections([])
+ }
+ }, [modalVisible, formCampId])
+
+ // 弹窗内:小节变化时拉取该小节下的任务列表,用于“前置任务”下拉
+ useEffect(() => {
+ if (!modalVisible || !formSectionId) {
+ setSectionTasksForPrerequisite([])
+ return
+ }
+ listTasks({ page: 1, pageSize: 100, sectionId: formSectionId }).then((res) => {
+ if (res.data.code === 200 && res.data.data?.list) {
+ setSectionTasksForPrerequisite(res.data.data.list)
+ } else {
+ setSectionTasksForPrerequisite([])
+ }
+ }).catch(() => setSectionTasksForPrerequisite([]))
+ }, [modalVisible, formSectionId])
+
+ useEffect(() => {
+ fetchData()
+ }, [page, pageSize, keyword, campFilter, sectionFilter, taskTypeFilter])
+
+ // 监听任务类型变化,确保图文任务的图片 URL 正确设置
+ useEffect(() => {
+ if (modalVisible && editingRecord && taskTypeValue === TaskType.IMAGE_TEXT) {
+ const contentData = editingRecord.content?.type === 'imageText' ? editingRecord.content.data : null
+ const imageUrl = (contentData as any)?.imageUrl
+ if (imageUrl) {
+ const currentImageUrl = form.getFieldValue('imageUrl')
+ if (currentImageUrl !== imageUrl) {
+ console.log('检测到任务类型为图文任务,设置图片URL:', imageUrl)
+ form.setFieldValue('imageUrl', imageUrl)
+ }
+ }
+ }
+ }, [modalVisible, editingRecord, taskTypeValue, form])
+
+ // 监听 Modal 打开,确保表单值正确设置(作为备用方案)
+ useEffect(() => {
+ if (modalVisible && editingRecord) {
+ console.log('编辑任务数据:', editingRecord)
+ // 确保表单值已设置(如果 handleOpenModal 中已设置,这里不会重复设置)
+ const currentValues = form.getFieldsValue()
+ if (!currentValues.taskType && editingRecord.taskType) {
+ // 根据任务类型解析内容和条件
+ const formValues: any = {
+ campId: editingRecord.campId,
+ sectionId: editingRecord.sectionId,
+ taskType: editingRecord.taskType,
+ prerequisiteTaskId: editingRecord.prerequisiteTaskId ?? '',
+ }
+
+ // 解析任务内容(API 层已转换,直接使用)
+ if (editingRecord.content?.type === 'imageText') {
+ formValues.imageUrl = editingRecord.content.data?.imageUrl || ''
+ formValues.textContent = editingRecord.content.data?.textContent || ''
+ formValues.viewDurationSeconds = editingRecord.content.data?.viewDurationSeconds || 10
+ } else if (editingRecord.content?.type === 'video') {
+ formValues.videoUrl = editingRecord.content.data?.videoUrl || ''
+ formValues.completionPercentage = editingRecord.content.data?.completionPercentage || 0
+ } else if (editingRecord.content?.type === 'subjective') {
+ formValues.pdfUrl = editingRecord.content.data?.pdfUrl || ''
+ formValues.description = editingRecord.content.data?.description || ''
+ } else if (editingRecord.content?.type === 'objective') {
+ formValues.examId = editingRecord.content.data?.examId || ''
+ formValues.correctRatePercentage = editingRecord.content.data?.correctRatePercentage || 0
+ } else if (editingRecord.content?.type === 'essay') {
+ formValues.examId = editingRecord.content.data?.examId || ''
+ formValues.description = editingRecord.content.data?.description || ''
+ }
+
+ // 解析任务条件
+ if (
+ editingRecord.condition?.type === 'subjective' ||
+ editingRecord.condition?.type === 'essay'
+ ) {
+ formValues.needReview = editingRecord.condition.data?.needReview ?? true
+ }
+
+ setTimeout(() => {
+ form.setFieldsValue(formValues)
+ }, 100)
+ }
+ }
+ }, [modalVisible, editingRecord, form])
+
+ // 打开新增/编辑弹窗
+ const handleOpenModal = (record?: Task) => {
+ console.log('handleOpenModal 被调用', { record })
+
+ // 先打开 Modal
+ setModalVisible(true)
+
+ // 然后设置编辑记录和表单值
+ if (record) {
+ setEditingRecord(record)
+ // 使用 setTimeout 确保 Modal 完全打开后再设置表单值
+ setTimeout(() => {
+ try {
+ // 根据任务类型解析内容和条件
+ const formValues: any = {
+ campId: record.campId,
+ sectionId: record.sectionId,
+ taskType: record.taskType,
+ title: record.title ?? '',
+ prerequisiteTaskId: record.prerequisiteTaskId ?? '',
+ }
+
+ // 解析任务内容(API 层已转换,直接使用)
+ if (record.content?.type === 'imageText') {
+ formValues.imageUrl = record.content.data?.imageUrl || ''
+ formValues.textContent = record.content.data?.textContent || ''
+ formValues.viewDurationSeconds = record.content.data?.viewDurationSeconds || 10
+ console.log('图文任务数据:', {
+ imageUrl: formValues.imageUrl,
+ textContent: formValues.textContent,
+ viewDurationSeconds: formValues.viewDurationSeconds,
+ })
+ } else if (record.content?.type === 'video') {
+ formValues.videoUrl = record.content.data?.videoUrl || ''
+ formValues.completionPercentage = record.content.data?.completionPercentage || 0
+ } else if (record.content?.type === 'subjective') {
+ formValues.pdfUrl = record.content.data?.pdfUrl || ''
+ formValues.description = record.content.data?.description || ''
+ } else if (record.content?.type === 'objective') {
+ formValues.examId = record.content.data?.examId || ''
+ formValues.correctRatePercentage = record.content.data?.correctRatePercentage || 0
+ } else if (record.content?.type === 'essay') {
+ formValues.examId = record.content.data?.examId || ''
+ formValues.description = record.content.data?.description || ''
+ }
+
+ // 解析任务条件(主观题/申论题:needReview;申论题不再展示“审核中允许开启下一任务”,后端固定为 true)
+ if (
+ record.condition?.type === 'subjective' ||
+ record.condition?.type === 'essay'
+ ) {
+ const condData = record.condition.data as { needReview?: boolean }
+ formValues.needReview = condData?.needReview ?? true
+ }
+
+ form.setFieldsValue(formValues)
+ console.log('表单值已设置', formValues)
+ } catch (error) {
+ console.error('设置表单值失败:', error)
+ }
+ }, 150)
+ } else {
+ setEditingRecord(null)
+ form.resetFields()
+ const initialValues: any = {}
+ if (sectionFilter) {
+ initialValues.sectionId = sectionFilter
+ const defaultSection = sections.find((section) => section.id === sectionFilter)
+ if (defaultSection) {
+ initialValues.campId = defaultSection.campId
+ }
+ }
+ setTimeout(() => {
+ form.setFieldsValue({
+ viewDurationSeconds: 10,
+ needReview: true,
+ ...initialValues,
+ })
+ }, 100)
+ }
+ }
+
+ // 提交表单
+ const handleSubmit = async () => {
+ try {
+ const values = await form.validateFields()
+
+ // 根据任务类型构建任务内容和条件
+ let content: any
+ let condition: any
+ let needReview = false
+
+ switch (values.taskType) {
+ case TaskType.IMAGE_TEXT:
+ content = {
+ type: 'imageText',
+ data: {
+ imageUrl: values.imageUrl,
+ textContent: values.textContent,
+ viewDurationSeconds: values.viewDurationSeconds,
+ },
+ }
+ condition = {
+ type: 'imageText',
+ data: {
+ viewDurationSeconds: values.viewDurationSeconds,
+ },
+ }
+ break
+
+ case TaskType.VIDEO:
+ content = {
+ type: 'video',
+ data: {
+ videoUrl: values.videoUrl,
+ completionPercentage: values.completionPercentage,
+ },
+ }
+ condition = {
+ type: 'video',
+ data: {
+ completionPercentage: values.completionPercentage,
+ },
+ }
+ break
+
+ case TaskType.SUBJECTIVE:
+ content = {
+ type: 'subjective',
+ data: {
+ pdfUrl: values.pdfUrl,
+ description: values.description,
+ },
+ }
+ condition = {
+ type: 'subjective',
+ data: {
+ needReview: values.needReview ?? true,
+ reviewStatus: 0,
+ },
+ }
+ needReview = values.needReview ?? true
+ break
+
+ case TaskType.OBJECTIVE:
+ content = {
+ type: 'objective',
+ data: {
+ examId: values.examId,
+ correctRatePercentage: values.correctRatePercentage,
+ },
+ }
+ condition = {
+ type: 'objective',
+ data: {
+ correctRatePercentage: values.correctRatePercentage,
+ },
+ }
+ break
+
+ case TaskType.ESSAY:
+ content = {
+ type: 'essay',
+ data: {
+ examId: values.examId,
+ description: values.description,
+ },
+ }
+ condition = {
+ type: 'essay',
+ data: {
+ needReview: values.needReview ?? true,
+ reviewStatus: 0,
+ allowNextWhileReviewing: true,
+ },
+ }
+ needReview = values.needReview ?? true
+ break
+
+ default:
+ throw new Error('未知的任务类型')
+ }
+
+ const taskData = {
+ campId: values.campId,
+ sectionId: values.sectionId,
+ taskType: values.taskType,
+ title: values.title ?? '',
+ prerequisiteTaskId: values.prerequisiteTaskId || '',
+ content,
+ condition,
+ needReview,
+ }
+
+ if (editingRecord) {
+ // 编辑
+ await updateTask({ ...editingRecord, ...taskData })
+ message.success('编辑任务成功')
+ } else {
+ // 新增
+ await createTask(taskData)
+ 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 deleteTask(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)
+ setSectionFilter(undefined)
+ setTaskTypeFilter(undefined)
+ setPage(1)
+ }
+
+ // 任务类型标签
+ const getTaskTypeTag = (type: TaskType | undefined) => {
+ const tagMap: Record = {
+ [TaskType.UNKNOWN]: { color: 'default', text: '未知' },
+ [TaskType.IMAGE_TEXT]: { color: 'blue', text: '图文任务' },
+ [TaskType.VIDEO]: { color: 'green', text: '视频任务' },
+ [TaskType.SUBJECTIVE]: { color: 'orange', text: '主观题' },
+ [TaskType.OBJECTIVE]: { color: 'purple', text: '客观题' },
+ [TaskType.ESSAY]: { color: 'red', text: '申论题' },
+ }
+ const config = tagMap[type ?? TaskType.UNKNOWN]
+ if (!config) {
+ return 未知
+ }
+ return {config.text}
+ }
+
+ const columns: ColumnsType = [
+ {
+ title: '任务ID',
+ dataIndex: 'id',
+ key: 'id',
+ width: 200,
+ ellipsis: true,
+ },
+ {
+ title: '任务标题',
+ dataIndex: 'title',
+ key: 'title',
+ width: 180,
+ ellipsis: true,
+ render: (title: string) => title || '-',
+ },
+ {
+ title: '所属打卡营',
+ dataIndex: 'campId',
+ key: 'campId',
+ width: 180,
+ render: (campId: string) => {
+ const camp = camps.find((c) => c.id === campId)
+ return camp?.title || '-'
+ },
+ },
+ {
+ title: '所属小节',
+ dataIndex: 'sectionId',
+ key: 'sectionId',
+ width: 180,
+ render: (sectionId: string) => {
+ const section = sections.find((s) => s.id === sectionId)
+ return section?.title || '-'
+ },
+ },
+ {
+ title: '任务类型',
+ dataIndex: 'taskType',
+ key: 'taskType',
+ width: 120,
+ render: getTaskTypeTag,
+ },
+ {
+ title: '操作',
+ key: 'action',
+ width: 180,
+ fixed: 'right',
+ render: (_, record) => (
+
+ } onClick={() => handleOpenModal(record)}>
+ 编辑
+
+ handleDelete(record.id)}
+ okText="确定"
+ cancelText="取消"
+ >
+ }>
+ 删除
+
+
+
+ ),
+ },
+ ]
+
+ return (
+
+ {/* 页面标题 */}
+
+
任务管理
+
+ 管理打卡营的学习任务,支持图文、视频、主观题、客观题、申论题等5种任务类型
+
+
+
+
+
+ setKeyword(e.target.value)}
+ onSearch={handleSearch}
+ />
+ {
+ setCampFilter(value)
+ setPage(1)
+ }}
+ options={camps.map((c) => ({ label: c.title, value: c.id }))}
+ />
+ {
+ setSectionFilter(value)
+ setPage(1)
+ }}
+ options={sections.map((s) => ({ label: s.title, value: s.id }))}
+ />
+ {
+ setTaskTypeFilter(value)
+ setPage(1)
+ }}
+ options={[
+ { label: '图文任务', value: TaskType.IMAGE_TEXT },
+ { label: '视频任务', value: TaskType.VIDEO },
+ { label: '主观题', value: TaskType.SUBJECTIVE },
+ { label: '客观题', value: TaskType.OBJECTIVE },
+ { label: '申论题', value: TaskType.ESSAY },
+ ]}
+ />
+ } onClick={fetchData}>
+ 刷新
+
+ } onClick={handleResetFilters}>
+ 重置
+
+ } onClick={() => handleOpenModal()}>
+ 新增任务
+
+
+
+
+
`共 ${total} 条`,
+ onChange: (page, pageSize) => {
+ setPage(page)
+ setPageSize(pageSize)
+ },
+ }}
+ />
+
+ {
+ setModalVisible(false)
+ setEditingRecord(null)
+ setModalSections([])
+ setSectionTasksForPrerequisite([])
+ form.resetFields()
+ }}
+ width={800}
+ >
+
+ ({ label: c.title, value: c.id }))}
+ onChange={() => form.setFieldValue('sectionId', undefined)}
+ />
+
+
+ ({ label: `${s.sectionNumber}. ${s.title}`, value: s.id }))}
+ disabled={!formCampId}
+ />
+
+
+
+
+
+
+
+ {
+ const sectionTasks = sectionTasksForPrerequisite
+ const firstTaskId =
+ sectionTasks.length > 0
+ ? sectionTasks.reduce((min, t) => (t.id < min ? t.id : min), sectionTasks[0].id)
+ : undefined
+ const isFirstTask =
+ editingRecord && firstTaskId !== undefined && editingRecord.id === firstTaskId
+ if (isFirstTask) {
+ return '第一个任务无需设置解锁关系'
+ }
+ return '选择完成后才能开启本任务的任务,形成递进关系;不选则按列表顺序解锁'
+ })()
+ }
+ >
+ {
+ if (t.id === editingRecord?.id) return false
+ // 仅允许选择 ID 小于当前任务的任务作为前置(按 ID 顺序)
+ if (editingRecord?.id && t.id >= editingRecord.id) return false
+ return true
+ })
+ .map((t) => ({
+ label: t.title ? `${t.title}` : `任务 ${t.id.slice(-8)}`,
+ value: t.id,
+ })),
+ ]}
+ disabled={
+ !formSectionId ||
+ (!!editingRecord &&
+ sectionTasksForPrerequisite.length > 0 &&
+ editingRecord.id ===
+ sectionTasksForPrerequisite.reduce(
+ (min, t) => (t.id < min ? t.id : min),
+ sectionTasksForPrerequisite[0].id
+ ))
+ }
+ />
+
+
+ {/* 图文任务字段 */}
+ {taskTypeValue === TaskType.IMAGE_TEXT && (
+ <>
+
+
+
+
+
+
+
+
+
+ >
+ )}
+
+ {/* 视频任务字段 */}
+ {taskTypeValue === TaskType.VIDEO && (
+ <>
+
+
+
+ {/* 视频预览 */}
+ {videoUrlValue && (
+
+
+
+
+
+ )}
+
+
+
+ >
+ )}
+
+ {/* 主观题字段 */}
+ {taskTypeValue === TaskType.SUBJECTIVE && (
+ <>
+
+ {/* 实际存储字段:隐藏 url,只展示文档名 */}
+
+
+
+
+ {
+ if (pdfUrlValue) window.open(pdfUrlValue, '_blank', 'noopener,noreferrer')
+ }}
+ />
+ }
+ onClick={async () => {
+ try {
+ setDocLoading(true)
+ const folderRes = await listDocFolders()
+ if (folderRes.success) {
+ setDocFolders(folderRes.list)
+ const first = folderRes.list[0]
+ if (first) {
+ setDocFolderId(first.id)
+ const fileRes = await listDocFiles(first.id)
+ if (fileRes.success) setDocFiles(fileRes.list)
+ } else {
+ setDocFiles([])
+ }
+ setDocModalVisible(true)
+ } else {
+ message.error('获取文档文件夹失败')
+ }
+ } catch (e: any) {
+ message.error(e?.message || '获取文档失败')
+ } finally {
+ setDocLoading(false)
+ }
+ }}
+ >
+ 从文档选择
+
+
+
+
+
+
+
+
+
+ >
+ )}
+
+ {/* 客观题字段 */}
+ {taskTypeValue === TaskType.OBJECTIVE && (
+ <>
+
+ ({ label: p.title, value: p.id }))}
+ showSearch
+ filterOption={(input, option) => (option?.label as string)?.toLowerCase().includes(input.toLowerCase())}
+ />
+
+
+
+
+ >
+ )}
+
+ {/* 申论题字段 */}
+ {taskTypeValue === TaskType.ESSAY && (
+ <>
+
+ ({ label: p.title, value: p.id }))}
+ showSearch
+ filterOption={(input, option) => (option?.label as string)?.toLowerCase().includes(input.toLowerCase())}
+ />
+
+
+
+
+
+
+
+ >
+ )}
+
+
+
+ {/* 从文档管理选择 PDF */}
+ setDocModalVisible(false)}
+ footer={null}
+ width={720}
+ >
+
+ {
+ setDocFolderId(id)
+ try {
+ setDocLoading(true)
+ const fileRes = await listDocFiles(id)
+ if (fileRes.success) setDocFiles(fileRes.list)
+ } catch (e: any) {
+ message.error(e?.message || '获取文档失败')
+ } finally {
+ setDocLoading(false)
+ }
+ }}
+ options={docFolders.map((f) => ({ label: f.name, value: f.id }))}
+ />
+ 点击列表行即可选择并回填到 PDF 文件 URL
+
+ (f.mime_type || '').toLowerCase().includes('pdf') || (f.file_name || '').toLowerCase().endsWith('.pdf')
+ )}
+ pagination={false}
+ onRow={(record: DocFile) => ({
+ onClick: () => {
+ form.setFieldsValue({ pdfUrl: record.file_url })
+ setPdfDocName((record.name || '').trim() || (record.file_name || '').trim())
+ setDocModalVisible(false)
+ },
+ })}
+ />
+
+
+ )
+}
+
+export default TaskList
+
diff --git a/src/pages/Camp/UserProgressList/FilterBar.tsx b/src/pages/Camp/UserProgressList/FilterBar.tsx
new file mode 100644
index 0000000..67ff831
--- /dev/null
+++ b/src/pages/Camp/UserProgressList/FilterBar.tsx
@@ -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 (
+
+
+
+ (option?.label ?? '').toLowerCase().includes(input.toLowerCase())
+ }
+ onChange={(value) => onCampChange(value)}
+ options={campOptions}
+ />
+
+ (option?.label ?? '').toLowerCase().includes(input.toLowerCase())
+ }
+ onChange={(value) => onSectionChange(value)}
+ options={sectionOptions}
+ />
+ onUserKeywordChange(e.target.value)}
+ onPressEnter={onRefresh}
+ />
+ } onClick={onRefresh}>
+ 刷新
+
+ } onClick={onReset}>
+ 重置
+
+
+
+ )
+}
diff --git a/src/pages/Camp/UserProgressList/ProgressMatrix.tsx b/src/pages/Camp/UserProgressList/ProgressMatrix.tsx
new file mode 100644
index 0000000..098afac
--- /dev/null
+++ b/src/pages/Camp/UserProgressList/ProgressMatrix.tsx
@@ -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
+ progressByUserTask: Map
+ 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 (
+
+ {/* 表头:营地标题 + 小节列 */}
+
+
+ {selectedCampTitle || '打卡营'}
+
+ {sectionsForCamp.map((sec) => (
+
+ 第{sec.sectionNumber}节
+
+ {sec.title}
+
+
+ ))}
+
+
+ {userIds.length === 0 ? (
+
+
+
+ 暂无用户进度数据,可更换打卡营或小节后刷新
+
+
+ ) : (
+ userIds.map((userId, userIndex) => (
+
+
+
}
+ style={{ background: token.colorPrimaryBg, color: token.colorPrimary, flexShrink: 0 }}
+ />
+
+ 用户{userIndex + 1}
+
+
+ {userId}
+
+
+ {sectionsForCamp.map((sec) => {
+ const sectionTasks = tasksBySection.get(sec.id) || []
+ return (
+
+ {sectionTasks.map((task, idx) => {
+ const progress = progressByUserTask.get(`${userId}_${task.id}`) ?? null
+ const taskIndex = idx + 1
+ return (
+
onOpenReview(userId, task.id, progress)}
+ />
+ )
+ })}
+ {sectionTasks.length === 0 && (
+
+ —
+
+ )}
+
+ )
+ })}
+
+ ))
+ )}
+
+ )
+}
diff --git a/src/pages/Camp/UserProgressList/README.md b/src/pages/Camp/UserProgressList/README.md
new file mode 100644
index 0000000..20df550
--- /dev/null
+++ b/src/pages/Camp/UserProgressList/README.md
@@ -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、上传逻辑等,组合上述组件 |
+
diff --git a/src/pages/Camp/UserProgressList/ReviewModal.tsx b/src/pages/Camp/UserProgressList/ReviewModal.tsx
new file mode 100644
index 0000000..9238ce2
--- /dev/null
+++ b/src/pages/Camp/UserProgressList/ReviewModal.tsx
@@ -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 = {
+ [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 (
+
+ {reviewingProgress && (
+ <>
+
+ {/* 两行网格:第一列对齐任务ID/完成状态,第二列对齐完成时间/描述 */}
+
+ {/* 第一行:任务ID | 完成时间 */}
+
+ 任务ID:
+ {reviewingProgress.taskId}
+
+
+ {reviewingProgress.progress?.completedAt && reviewingProgress.progress.completedAt !== '0' && (
+ <>
+ 完成时间:
+ {formatCompletedAt(reviewingProgress.progress.completedAt)}
+ >
+ )}
+
+ {/* 第二行:完成状态 | 描述 */}
+ {(reviewingProgress.progress || isEssayReview) && (
+ <>
+
+ {reviewingProgress.progress ? (
+ <>
+ 完成状态:
+ {reviewingProgress.progress.isCompleted ? (
+ 已完成
+ ) : (
+ 未完成
+ )}
+ >
+ ) : null}
+
+
+ {isEssayReview
+ ? '所有题目通过才是通过;有一道题未审核、待审核或驳回,则最终结果为驳回'
+ : null}
+
+ >
+ )}
+
+
+
+
+
+ )}
+ {isEssayReview ? (
+ <>
+ {/* 题目标签:可点击切换 */}
+
+ {() => {
+ const statuses: number[] = form.getFieldValue('essayReviewStatuses') || []
+ return (
+
+ {(reviewingProgress?.progress?.essayAnswerImages || []).map((_: string[], idx: number) => {
+ const status = statuses[idx]
+ const label = status !== undefined ? ESSAY_STATUS_LABEL[status] ?? '待审核' : '待审核'
+ const isActive = idx === currentQuestionIndex
+ return (
+
+ )
+ })}
+
+ )
+ }}
+
+ {/* 叠放展示:全部题目都渲染以保留每题独立表单值,仅用 display 隐藏非当前题 */}
+ {(reviewingProgress?.progress?.essayAnswerImages || []).map((urls: string[], questionIndex: number) => {
+ const validUrls = (urls || []).filter((url: string) => url && String(url).trim() !== '')
+ const isCurrent = questionIndex === currentQuestionIndex
+ return (
+
+
+ 第{questionIndex + 1}题
+
+
+ {/* 横向布局:左侧学生回答区,右侧教师批复区 */}
+
+ {/* 学生回答区 - 左侧(仅展示约 6 张图,稍窄) */}
+
+
+ 学生回答区
+
+ {validUrls.length > 0 ? (
+
+
+ {validUrls.map((url: string, idx: number) => (
+
+ ))}
+
+
+ ) : (
+
暂无提交
+ )}
+
+
+ {/* 教师批复区 - 右侧(占更多宽度) */}
+
+
+ 教师批复区
+
+
+
+
+
+
+ {() => {
+ const perQuestion: string[] = (form.getFieldValue('essayReviewImages') || [])[questionIndex] || []
+ return (
+
+ {perQuestion.filter((u: string) => u && String(u).trim()).map((url: string, idx: number) => (
+
+
+ }
+ 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',
+ }}
+ />
+
+ ))}
+ {perQuestion.filter((u: string) => u && String(u).trim()).length < 4 && (
+
onEssayReviewImageUpload(questionIndex, opts)}
+ disabled={reviewImageUploading}
+ >
+
+ {reviewImageUploading ?
:
}
+
上传
+
+
+ )}
+
+ )
+ }}
+
+
+
+
+
+
+
+
+ {/* 本题单独确定 + 上一题/下一题 */}
+
+
+ } onClick={goPrev} disabled={currentQuestionIndex === 0}>
+ 上一题
+
+ } onClick={goNext} disabled={currentQuestionIndex >= questionCount - 1}>
+ 下一题
+
+
+
+
+
+ )
+ })}
+ >
+ ) : isSubjectiveReview ? (
+ /* 主观题:学生回答区 | 教师批复区 两分区 */
+
+
+
+ 学生回答区
+
+ {reviewingProgress?.progress?.answerImages && reviewingProgress.progress.answerImages.filter((url: string) => url?.trim()).length > 0 ? (
+
+
+ {(reviewingProgress.progress.answerImages ?? [])
+ .filter((url: string) => url?.trim())
+ .map((url: string, idx: number) => (
+
+ ))}
+
+
+ ) : (
+
暂无提交
+ )}
+
+
+
+ 教师批复区
+
+
+
+
+
+ {() => {
+ const reviewImages: string[] = form.getFieldValue('reviewImages') || []
+ return (
+ <>
+ 最多可上传 6 张图片
+
+ {reviewImages.map((url: string, idx: number) => (
+
+
+ }
+ 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',
+ }}
+ />
+
+ ))}
+ {reviewImages.length < 6 && (
+
+
+ {reviewImageUploading ?
:
}
+
{reviewImageUploading ? '上传中' : '上传'}
+
+
+ )}
+
+ >
+ )
+ }}
+
+
+
+
+
+
+
+
+
+ ) : (
+ <>
+
+
+
+
+ {() => {
+ const reviewImages: string[] = form.getFieldValue('reviewImages') || []
+ return (
+ <>
+ 最多可上传 6 张图片
+
+ {reviewImages.map((url: string, idx: number) => (
+
+
+ }
+ 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',
+ }}
+ />
+
+ ))}
+ {reviewImages.length < 6 && (
+
+
+ {reviewImageUploading ?
:
}
+
{reviewImageUploading ? '上传中' : '上传'}
+
+
+ )}
+
+ >
+ )
+ }}
+
+
+
+
+ {reviewingProgress?.progress?.answerImages &&
+ reviewingProgress.progress.answerImages.filter((url: string) => url?.trim()).length > 0 && (
+
+
+
+ {(reviewingProgress.progress.answerImages ?? [])
+ .filter((url: string) => url?.trim())
+ .map((url: string, idx: number) => (
+
+ ))}
+
+
+
+ )}
+ >
+ )}
+
+ >
+ )}
+
+ )
+}
diff --git a/src/pages/Camp/UserProgressList/StatusLegend.tsx b/src/pages/Camp/UserProgressList/StatusLegend.tsx
new file mode 100644
index 0000000..4f6ee36
--- /dev/null
+++ b/src/pages/Camp/UserProgressList/StatusLegend.tsx
@@ -0,0 +1,66 @@
+import { theme } from 'antd'
+import { STATUS_COLOR_LEGEND } from './constants'
+
+function getStatusStyle(key: string, token: ReturnType['token']) {
+ const map: Record = {
+ 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 (
+
+
+ 颜色说明:
+
+ {STATUS_COLOR_LEGEND.map(({ key, label }) => {
+ const style = getStatusStyle(key, token)
+ return (
+
+
+ {label}
+
+ )
+ })}
+
+ )
+}
diff --git a/src/pages/Camp/UserProgressList/TaskProgressCard.tsx b/src/pages/Camp/UserProgressList/TaskProgressCard.tsx
new file mode 100644
index 0000000..6d1944a
--- /dev/null
+++ b/src/pages/Camp/UserProgressList/TaskProgressCard.tsx
@@ -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['token']) {
+ const map: Record = {
+ 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 (
+ {
+ 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
+ }}
+ >
+
+
+ {task.title || `任务${taskIndex}`}
+
+
+ 任务{taskIndex} · {typeName}
+
+
+ {isReviewable && (
+
+ 点击查看
+
+
+ )}
+
+ )
+}
diff --git a/src/pages/Camp/UserProgressList/constants.ts b/src/pages/Camp/UserProgressList/constants.ts
new file mode 100644
index 0000000..48b702b
--- /dev/null
+++ b/src/pages/Camp/UserProgressList/constants.ts
@@ -0,0 +1,54 @@
+import { TaskType, ReviewStatus } from '@/types/camp'
+
+export const TASK_TYPE_MAP: Record = {
+ [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 = {
+ [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 = {
+ 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__:'
diff --git a/src/pages/Camp/UserProgressList/index.tsx b/src/pages/Camp/UserProgressList/index.tsx
new file mode 100644
index 0000000..95035e6
--- /dev/null
+++ b/src/pages/Camp/UserProgressList/index.tsx
@@ -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([])
+ const [camps, setCamps] = useState([])
+ const [sections, setSections] = useState([])
+ const [campFilter, setCampFilter] = useState()
+ const [sectionFilter, setSectionFilter] = useState()
+ const [userKeyword, setUserKeyword] = useState('')
+ const [progressList, setProgressList] = useState([])
+ const [campUserIds, setCampUserIds] = useState([])
+ 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(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()
+ 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()
+ 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()
+ 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 (
+
+
+
+ 打卡营进度管理
+
+
+ 按「打卡营 × 小节」矩阵展示:先选择打卡营,可搜索用户(昵称/ID/手机号),按用户行、小节列查看各任务完成与审核状态
+
+
+
+
+
+ {campFilter &&
}
+
+ {loading && (
+
+
+ 加载中...
+
+ )}
+
+ {!loading && !campFilter && (
+
+ 请先选择打卡营,即可查看用户 × 小节的进度矩阵
+
+ )}
+
+ {!loading && campFilter && (
+
+ )}
+
+ {!loading && campFilter && userIds.length > 0 && (
+
+ {campUserIds.length > 0
+ ? `共 ${total} 名用户加入该营,本页 ${userIds.length} 名`
+ : `共 ${userIds.length} 名用户,${total} 条进度记录`}
+
+ )}
+
+
+
+ )
+}
diff --git a/src/pages/Camp/UserProgressList/utils.ts b/src/pages/Camp/UserProgressList/utils.ts
new file mode 100644
index 0000000..b0485e5
--- /dev/null
+++ b/src/pages/Camp/UserProgressList/utils.ts
@@ -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
+}
diff --git a/src/pages/Camp/buildUserSectionRows.ts b/src/pages/Camp/buildUserSectionRows.ts
new file mode 100644
index 0000000..3bcc10b
--- /dev/null
+++ b/src/pages/Camp/buildUserSectionRows.ts
@@ -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,
+ userId: string,
+ progressByUserTask: Map,
+ 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,
+ sections: Section[],
+ campMap: Map,
+ sectionMap: Map,
+ tasksBySectionOrdered: Map>,
+ progressByUserTask: Map
+): UserSectionRow[] {
+ const SEP = '::'
+ const keySet = new Set()
+ 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
+}
diff --git a/src/pages/Dashboard/index.tsx b/src/pages/Dashboard/index.tsx
new file mode 100644
index 0000000..06e6e2a
--- /dev/null
+++ b/src/pages/Dashboard/index.tsx
@@ -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({
+ 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 (
+
+
仪表盘
+
+
+
+
+
+ }
+ valueStyle={{ color: '#3f8600' }}
+ />
+
+
+
+
+ }
+ valueStyle={{ color: '#1890ff' }}
+ />
+
+
+
+
+ }
+ valueStyle={{ color: '#722ed1' }}
+ />
+
+
+
+
+ }
+ valueStyle={{ color: '#fa8c16' }}
+ />
+
+
+
+
+
+
+
+
+ 这是一个功能完善的教育培训管理系统,基于 React + TypeScript + Ant Design 开发。
+ 核心功能模块:
+
+ -
+ 打卡营管理 - 管理打卡营分类、打卡营、小节、任务和用户学习进度
+
+ -
+ 题目试卷管理 - 题库管理、试卷组卷、答题统计,支持主观题、单选题、多选题、判断题
+
+ -
+ 用户管理 - 用户信息管理和权限控制
+
+
+ 技术特性:
+
+ - 完整的 TypeScript 类型支持
+ - 统一的 API 请求封装和错误处理
+ - 路由守卫和权限控制
+ - 状态管理(Zustand)
+ - 响应式布局和现代化 UI
+ - 页面联动和智能筛选
+
+
+
+
+
+ )
+}
+
+export default Dashboard
+
diff --git a/src/pages/Document/DocumentList.tsx b/src/pages/Document/DocumentList.tsx
new file mode 100644
index 0000000..b0d2547
--- /dev/null
+++ b/src/pages/Document/DocumentList.tsx
@@ -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([])
+ const [files, setFiles] = useState([])
+ const [currentFolder, setCurrentFolder] = useState(null)
+ const [loading, setLoading] = useState(false)
+ const [folderModalVisible, setFolderModalVisible] = useState(false)
+ const [editingFolder, setEditingFolder] = useState(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 = [
+ { title: '名称', dataIndex: 'name', key: 'name', render: (name, record) => setCurrentFolder(record)}> {name} },
+ { title: '排序', dataIndex: 'sort_order', key: 'sort_order', width: 80 },
+ {
+ title: '操作',
+ key: 'action',
+ width: 140,
+ render: (_, record) => (
+
+ } onClick={() => handleEditFolder(record)}>编辑
+ handleDeleteFolder(record.id)}>
+ }>删除
+
+
+ ),
+ },
+ ]
+
+ const fileColumns: ColumnsType = [
+ { title: '名称', dataIndex: 'name', key: 'name', render: (name, row) => {name} },
+ { 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) => (
+ handleDeleteFile(record.id)}>
+ }>删除
+
+ ),
+ },
+ ]
+
+ return (
+
+ )
+}
+
+export default DocumentList
diff --git a/src/pages/Login/index.css b/src/pages/Login/index.css
new file mode 100644
index 0000000..8a8ed7a
--- /dev/null
+++ b/src/pages/Login/index.css
@@ -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;
+ }
+}
diff --git a/src/pages/Login/index.tsx b/src/pages/Login/index.tsx
new file mode 100644
index 0000000..d3f4938
--- /dev/null
+++ b/src/pages/Login/index.tsx
@@ -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('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 (
+
+ )
+}
+
+export default Login
+
diff --git a/src/pages/NotFound/index.tsx b/src/pages/NotFound/index.tsx
new file mode 100644
index 0000000..f18c322
--- /dev/null
+++ b/src/pages/NotFound/index.tsx
@@ -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 (
+
+ navigate(RoutePath.DASHBOARD)}>
+ 返回首页
+
+ }
+ />
+
+ )
+}
+
+export default NotFound
+
diff --git a/src/pages/Objective/KnowledgeTree.tsx b/src/pages/Objective/KnowledgeTree.tsx
new file mode 100644
index 0000000..a5e8f51
--- /dev/null
+++ b/src/pages/Objective/KnowledgeTree.tsx
@@ -0,0 +1,17 @@
+import KnowledgeTree from '@/components/KnowledgeTree'
+
+const ObjectiveKnowledgeTree = () => {
+ return (
+
+
+
客观题管理 - 知识树
+
+ 管理客观题的知识树结构
+
+
+
+
+ )
+}
+
+export default ObjectiveKnowledgeTree
diff --git a/src/pages/Objective/MaterialList.tsx b/src/pages/Objective/MaterialList.tsx
new file mode 100644
index 0000000..4d64c54
--- /dev/null
+++ b/src/pages/Objective/MaterialList.tsx
@@ -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([])
+ 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(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 = [
+ {
+ title: '材料名称',
+ dataIndex: 'name',
+ key: 'name',
+ width: 200,
+ ellipsis: true,
+ render: (name: string) => name || 未命名,
+ },
+ {
+ title: '材料内容',
+ dataIndex: 'content',
+ key: 'content',
+ width: 400,
+ ellipsis: true,
+ render: (content: string) => {
+ // 移除 HTML 标签,只显示纯文本
+ const textContent = content
+ .replace(/
]*>/gi, '[图片]')
+ .replace(/<[^>]+>/g, '')
+ .replace(/\n/g, ' ')
+ .trim()
+ return textContent || 无内容
+ },
+ },
+ {
+ 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) => (
+
+ }
+ onClick={() => handlePreview(record)}
+ >
+ 预览
+
+ }
+ onClick={() => handleOpenModal(record)}
+ >
+ 编辑
+
+ handleDelete(record.id)}
+ okText="确定"
+ cancelText="取消"
+ >
+ }>
+ 删除
+
+
+
+ ),
+ },
+ ]
+
+ return (
+
+ {/* 页面标题 */}
+
+
客观题管理 - 材料管理
+
+ 管理客观题材料,材料可用于题目中
+
+
+
+
+
+
+ } onClick={fetchData}>
+ 刷新
+
+ } onClick={() => handleOpenModal()}>
+ 新增材料
+
+
+
+
+
`共 ${total} 个材料`,
+ onChange: (page, pageSize) => {
+ setPage(page)
+ setPageSize(pageSize)
+ },
+ }}
+ />
+
+ {/* 新增/编辑弹窗 */}
+ {
+ setModalVisible(false)
+ setEditingRecord(null)
+ form.resetFields()
+ }}
+ width={900}
+ okText="确定"
+ cancelText="取消"
+ >
+
+
+
+
+
+
+
+
+
+
+ {/* 预览弹窗 */}
+ setPreviewVisible(false)}
+ footer={[
+ ,
+ ]}
+ width={900}
+ >
+
+
{
+ let html = previewContent || ''
+ if (!html) return ''
+
+ // react-quill 生成的 HTML 已经是完整的格式,直接使用
+ // 只需要确保图片标签有正确的样式
+ return html.replace(
+ /
![]()
]*)>/gi,
+ (match, attrs) => {
+ if (!attrs.includes('style=')) {
+ return `
![]()
`
+ }
+ return match
+ }
+ )
+ })()
+ }}
+ />
+
+
+
+ )
+}
+
+export default MaterialList
diff --git a/src/pages/Objective/PaperEdit.tsx b/src/pages/Objective/PaperEdit.tsx
new file mode 100644
index 0000000..3497581
--- /dev/null
+++ b/src/pages/Objective/PaperEdit.tsx
@@ -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([])
+ 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()
+
+ // 已选题目 ID
+ const [selectedQuestionIds, setSelectedQuestionIds] = useState([])
+
+ // 已选题目详情(用于右侧展示)
+ const [selectedQuestions, setSelectedQuestions] = useState([])
+
+ // 加载试卷数据(编辑模式)
+ const [paperLoading, setPaperLoading] = useState(false)
+
+ // 提交状态
+ const [submitting, setSubmitting] = useState(false)
+
+ // 预览弹窗
+ const [previewQuestion, setPreviewQuestion] = useState(null)
+ const [previewVisible, setPreviewVisible] = useState(false)
+
+ // 排序弹窗
+ const [sortModalVisible, setSortModalVisible] = useState(false)
+ const [sortList, setSortList] = useState([])
+ const dragItemRef = useRef(null)
+ const dragOverItemRef = useRef(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 = {
+ [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 未知
+ return {config.text}
+ }
+
+ // 渲染选项
+ 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 (
+
+ {letter}. {text.length > 30 ? text.substring(0, 30) + '...' : text}
+
+ )
+ })
+ }
+
+ // 去掉 HTML 标签,用于展示纯文本
+ const stripHtml = (html: string) => {
+ if (!html) return ''
+ return html.replace(/<[^>]+>/g, '').replace(/ /g, ' ').trim()
+ }
+
+ // 切换选中
+ const toggleSelect = (questionId: string) => {
+ setSelectedQuestionIds(prev => {
+ if (prev.includes(questionId)) {
+ return prev.filter(id => id !== questionId)
+ }
+ return [...prev, questionId]
+ })
+ }
+
+ // 移除已选题目
+ const removeSelected = (questionId: string) => {
+ setSelectedQuestionIds(prev => prev.filter(id => id !== questionId))
+ setSelectedQuestions(prev => prev.filter(q => q.id !== questionId))
+ }
+
+ // 预览题目
+ const handlePreview = (question: Question) => {
+ setPreviewQuestion(question)
+ setPreviewVisible(true)
+ }
+
+ // 打开排序弹窗
+ const openSortModal = () => {
+ setSortList([...selectedQuestionIds])
+ setSortModalVisible(true)
+ }
+
+ // 拖拽排序
+ const handleDragStart = (index: number) => {
+ dragItemRef.current = index
+ }
+
+ const handleDragEnter = (index: number) => {
+ dragOverItemRef.current = index
+ if (dragItemRef.current === null || dragItemRef.current === index) return
+ const newList = [...sortList]
+ const dragItem = newList[dragItemRef.current]
+ newList.splice(dragItemRef.current, 1)
+ newList.splice(index, 0, dragItem)
+ dragItemRef.current = index
+ setSortList(newList)
+ }
+
+ const handleDragEnd = () => {
+ dragItemRef.current = null
+ dragOverItemRef.current = null
+ }
+
+ // 确认排序
+ const handleSortConfirm = () => {
+ setSelectedQuestionIds(sortList)
+ setSortModalVisible(false)
+ message.success('排序已更新')
+ }
+
+ // 渲染全部选项(预览弹窗用)
+ const renderFullOptions = (options: any[], answer?: string) => {
+ if (!options || options.length === 0) return null
+ return options.map((opt, idx) => {
+ const letter = String.fromCharCode(65 + idx)
+ const text = typeof opt === 'string' ? opt : (opt.value || opt.content || '')
+ const isCorrect = answer?.includes(letter)
+ return (
+
+ {letter}.
+ {text}
+ {isCorrect && 正确答案}
+
+ )
+ })
+ }
+
+ // 题目列表列定义
+ const columns: ColumnsType = [
+ {
+ title: '',
+ width: 50,
+ fixed: 'left',
+ render: (_, record) => (
+ toggleSelect(record.id)}
+ />
+ ),
+ },
+ {
+ title: 'ID',
+ dataIndex: 'id',
+ key: 'id',
+ width: 100,
+ render: (id: string) => (
+
+ {id.substring(0, 8)}...
+
+ ),
+ },
+ {
+ 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 (
+
+ {text.length > 60 ? text.substring(0, 60) + '...' : text}
+
+ )
+ },
+ },
+ {
+ 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 (
+
+ {/* 顶部导航 */}
+
+
+ } onClick={() => navigate(RoutePath.OBJECTIVE_PAPER_LIST)}>
+ 返回
+
+
+ {isEdit ? '编辑组卷' : '新增组卷'}
+
+
+ } onClick={handleSubmit} loading={submitting}>
+ 保存
+
+
+
+ {/* 试卷基本信息 */}
+
+
+
+
+
+
+
+
+
+
+
+ {/* 左侧:题目列表 */}
+
点击复选框选择题目}
+ >
+ {/* 筛选条件 */}
+
+
+ {
+ setTypeFilter(value)
+ setQuestionPage(1)
+ }}
+ options={[
+ { label: '单选题', value: QuestionType.SINGLE_CHOICE },
+ { label: '多选题', value: QuestionType.MULTIPLE_CHOICE },
+ { label: '判断题', value: QuestionType.TRUE_FALSE },
+ ]}
+ />
+ {
+ setSearchQuery(value)
+ setQuestionPage(1)
+ }}
+ />
+
+
+
+
+ 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)
+ },
+ }}
+ />
+
+
+ {/* 右侧:已选题目 */}
+ 1 ? (
+ }
+ onClick={openSortModal}
+ >
+ 调整顺序
+
+ ) : null
+ }
+ >
+ {selectedQuestionIds.length === 0 ? (
+
+ 请从左侧列表中选择题目
+
+ ) : (
+
+ {selectedQuestionIds.map((qId, index) => {
+ const q = selectedQuestions.find(sq => sq.id === qId)
+ return (
+
q && handlePreview(q)}
+ >
+
+ {index + 1}.
+
+
+ {q ? (
+ <>
+
+ {getQuestionTypeTag(q.type)}
+
+ {stripHtml(q.content).substring(0, 60)}
+ {stripHtml(q.content).length > 60 ? '...' : ''}
+
+
+
+ ID: {qId.substring(0, 10)}...
+
+ >
+ ) : (
+
+ ID: {qId.substring(0, 16)}...
+
+ )}
+
+
+ {q && (
+ }
+ onClick={(e) => { e.stopPropagation(); handlePreview(q) }}
+ style={{ flexShrink: 0, color: '#1890ff' }}
+ />
+ )}
+ }
+ onClick={(e) => { e.stopPropagation(); removeSelected(qId) }}
+ style={{ flexShrink: 0 }}
+ />
+
+
+ )
+ })}
+
+ )}
+
+
+
+ {/* 题目预览弹窗 */}
+ setPreviewVisible(false)}
+ footer={null}
+ width={700}
+ >
+ {previewQuestion && (
+
+
+ {getQuestionTypeTag(previewQuestion.type)}
+
+ ID: {previewQuestion.id}
+
+
+
+
+
+
+
+
+
选项
+ {renderFullOptions(previewQuestion.options as any[], previewQuestion.answer)}
+
+
+
+
答案
+
+ {previewQuestion.answer || '未设置'}
+
+
+
+ {previewQuestion.explanation && (
+
+ )}
+
+ )}
+
+
+ {/* 排序弹窗 */}
+ setSortModalVisible(false)}
+ okText="确认排序"
+ cancelText="取消"
+ width={500}
+ >
+
+ 拖拽题目卡片来调整顺序
+
+
+ {sortList.map((qId, index) => {
+ const q = selectedQuestions.find(sq => sq.id === qId)
+ return (
+
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' }}
+ >
+
+
+ {index + 1}
+
+
+ {q ? (
+
+ {getQuestionTypeTag(q.type)}
+
+ {stripHtml(q.content).substring(0, 50)}
+ {stripHtml(q.content).length > 50 ? '...' : ''}
+
+
+ ) : (
+
ID: {qId.substring(0, 16)}...
+ )}
+
+
+ )
+ })}
+
+
+
+ )
+}
+
+export default ObjectivePaperEdit
diff --git a/src/pages/Objective/PaperList.tsx b/src/pages/Objective/PaperList.tsx
new file mode 100644
index 0000000..37fb28e
--- /dev/null
+++ b/src/pages/Objective/PaperList.tsx
@@ -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([])
+ 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 = [
+ {
+ 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) => (
+
+ }
+ onClick={() => navigate(`${RoutePath.OBJECTIVE_PAPER_EDIT}?id=${record.id}`)}
+ >
+ 编辑
+
+ handleDelete(record.id)}
+ okText="确定"
+ cancelText="取消"
+ >
+ }>
+ 删除
+
+
+
+ ),
+ },
+ ]
+
+ return (
+
+ {/* 页面标题 */}
+
+
客观题管理 - 组卷管理
+
+ 管理客观题试卷,将题目组成试卷
+
+
+
+
+
+
+ } onClick={fetchData}>
+ 刷新
+
+ }
+ onClick={() => navigate(RoutePath.OBJECTIVE_PAPER_EDIT)}
+ >
+ 新增试卷
+
+
+
+
+
`共 ${total} 个试卷`,
+ onChange: (page, pageSize) => {
+ setPage(page)
+ setPageSize(pageSize)
+ },
+ }}
+ />
+
+ )
+}
+
+export default ObjectivePaperList
diff --git a/src/pages/Objective/QuestionList.tsx b/src/pages/Objective/QuestionList.tsx
new file mode 100644
index 0000000..6eb3183
--- /dev/null
+++ b/src/pages/Objective/QuestionList.tsx
@@ -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([])
+ const [total, setTotal] = useState(0)
+ const [page, setPage] = useState(1)
+ const [pageSize, setPageSize] = useState(100)
+ const [query, setQuery] = useState('')
+ const [typeFilter, setTypeFilter] = useState()
+ const [modalVisible, setModalVisible] = useState(false)
+ const [editingRecord, setEditingRecord] = useState(null)
+ const [materialSearchVisible, setMaterialSearchVisible] = useState(false) // 材料搜索栏显示
+ const [materialList, setMaterialList] = useState([]) // 材料列表
+ const [materialSearchLoading, setMaterialSearchLoading] = useState(false) // 材料搜索加载状态
+ const [knowledgeTreeData, setKnowledgeTreeData] = useState([]) // 知识树数据
+
+ 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 = {
+ [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 未知
+ }
+ return {config.text}
+ }
+
+ const columns: ColumnsType = [
+ {
+ 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 未关联
+ }
+ const names = knowledgeTreeNames.filter((n) => !!n)
+ if (names.length === 0) {
+ return 未关联
+ }
+ return (
+ <>
+ {names.slice(0, 2).map((name) => (
+ {name}
+ ))}
+ {names.length > 2 && +{names.length - 2}}
+ >
+ )
+ },
+ },
+ {
+ 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) => (
+
+ } onClick={() => handleOpenModal(record)}>
+ 编辑
+
+ handleDelete(record.id)}
+ okText="确定"
+ cancelText="取消"
+ >
+ }>
+ 删除
+
+
+
+ ),
+ },
+ ]
+
+ // 是否显示选项字段(客观题都需要)
+ const needOptions = questionType === QuestionType.SINGLE_CHOICE ||
+ questionType === QuestionType.MULTIPLE_CHOICE ||
+ questionType === QuestionType.TRUE_FALSE
+
+ // 是否允许多选(多选题)
+ const allowMultiple = questionType === QuestionType.MULTIPLE_CHOICE
+
+ return (
+
+ {/* 页面标题 */}
+
+
客观题管理 - 题目管理
+
+ 管理客观题(单选题、多选题、判断题)
+
+
+
+
+
+
+ {
+ setTypeFilter(value)
+ setPage(1)
+ }}
+ options={[
+ { label: '单选题', value: QuestionType.SINGLE_CHOICE },
+ { label: '多选题', value: QuestionType.MULTIPLE_CHOICE },
+ { label: '判断题', value: QuestionType.TRUE_FALSE },
+ ]}
+ />
+ } onClick={fetchData}>
+ 刷新
+
+ } onClick={() => handleOpenModal()}>
+ 新增题目
+
+
+
+
+
`共 ${total} 道题`,
+ onChange: (page, pageSize) => {
+ setPage(page)
+ setPageSize(pageSize)
+ },
+ }}
+ />
+
+ {
+ setModalVisible(false)
+ setEditingRecord(null)
+ form.resetFields()
+ setMaterialSearchVisible(false)
+ }}
+ width={900}
+ okText="确定"
+ cancelText="取消"
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {materialSearchVisible && (
+ <>
+
+ ({
+ label: material.name || `材料 ${material.id.substring(0, 8)}`,
+ value: material.id,
+ title: material.name || material.id,
+ }))}
+ />
+
+ {form.getFieldValue('materialId') && (
+
+
已选择材料
+
+ {materialList.find((m) => m.id === form.getFieldValue('materialId'))?.name ||
+ `材料 ${form.getFieldValue('materialId')?.substring(0, 8)}`}
+
+
+ )}
+ >
+ )}
+
+
+
+
+
+ {needOptions && (
+
+
+ {(fields, { add, remove }) => (
+ <>
+ {fields.map((field, index) => {
+ // 从 field 中提取 key,避免通过 spread 传递 key
+ const { key, ...fieldProps } = field
+ return (
+
+
+ {String.fromCharCode(65 + index)}
+
+
+
+
+
+ {
+ // 如果是单选题,取消其他选项的正确答案标记
+ if (!allowMultiple && e.target.checked) {
+ const options = form.getFieldValue('options') || []
+ options.forEach((_: QuestionOption, idx: number) => {
+ if (idx !== index) {
+ form.setFieldValue(['options', idx, 'isCorrect'], false)
+ }
+ })
+ }
+ }}
+ >
+ 正确答案
+
+
+ {fields.length > 2 && (
+ remove(field.name)}
+ />
+ )}
+
+ )
+ })}
+
+
+
+ >
+ )}
+
+
+ )}
+
+
+
+
+
+ {
+ if (value && value.length > 4) {
+ return Promise.reject(new Error('最多只能选择4个知识树'))
+ }
+ return Promise.resolve()
+ }
+ }
+ ]}
+ extra="有下级的节点会展开,仅最低层级(叶子节点)可选,最多选4个"
+ >
+ {
+ 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%' }}
+ />
+
+
+
+
+ )
+}
+
+export default ObjectiveQuestionList
+
diff --git a/src/pages/Subjective/KnowledgeTree.tsx b/src/pages/Subjective/KnowledgeTree.tsx
new file mode 100644
index 0000000..630d43e
--- /dev/null
+++ b/src/pages/Subjective/KnowledgeTree.tsx
@@ -0,0 +1,17 @@
+import KnowledgeTree from '@/components/KnowledgeTree'
+
+const SubjectiveKnowledgeTree = () => {
+ return (
+
+
+
主观题管理 - 知识树
+
+ 管理主观题的知识树结构
+
+
+
+
+ )
+}
+
+export default SubjectiveKnowledgeTree
diff --git a/src/pages/Subjective/MaterialList.tsx b/src/pages/Subjective/MaterialList.tsx
new file mode 100644
index 0000000..6b0e799
--- /dev/null
+++ b/src/pages/Subjective/MaterialList.tsx
@@ -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([])
+ 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(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 = [
+ {
+ title: '材料名称',
+ dataIndex: 'name',
+ key: 'name',
+ width: 200,
+ ellipsis: true,
+ render: (name: string) => name || 未命名,
+ },
+ {
+ title: '材料内容',
+ dataIndex: 'content',
+ key: 'content',
+ width: 400,
+ ellipsis: true,
+ render: (content: string) => {
+ // 移除 HTML 标签,只显示纯文本
+ const textContent = content
+ .replace(/
]*>/gi, '[图片]')
+ .replace(/<[^>]+>/g, '')
+ .replace(/\n/g, ' ')
+ .trim()
+ return textContent || 无内容
+ },
+ },
+ {
+ 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) => (
+
+ }
+ onClick={() => handlePreview(record)}
+ >
+ 预览
+
+ }
+ onClick={() => handleOpenModal(record)}
+ >
+ 编辑
+
+ handleDelete(record.id)}
+ okText="确定"
+ cancelText="取消"
+ >
+ }>
+ 删除
+
+
+
+ ),
+ },
+ ]
+
+ return (
+
+ {/* 页面标题 */}
+
+
主观题管理 - 材料管理
+
+ 管理主观题材料,材料可用于组卷中
+
+
+
+
+
+
+ } onClick={fetchData}>
+ 刷新
+
+ } onClick={() => handleOpenModal()}>
+ 新增材料
+
+
+
+
+
`共 ${total} 个材料`,
+ onChange: (page, pageSize) => {
+ setPage(page)
+ setPageSize(pageSize)
+ },
+ }}
+ />
+
+ {/* 新增/编辑弹窗 */}
+ {
+ setModalVisible(false)
+ setEditingRecord(null)
+ form.resetFields()
+ }}
+ width={900}
+ okText="确定"
+ cancelText="取消"
+ >
+
+
+
+
+
+
+
+
+
+
+ {/* 预览弹窗 */}
+ setPreviewVisible(false)}
+ footer={[
+ ,
+ ]}
+ width={900}
+ >
+
+ {previewTitle}
+
+
+
{
+ let html = previewContent || ''
+ if (!html) return ''
+
+ // react-quill 生成的 HTML 已经是完整的格式,直接使用
+ // 只需要确保图片标签有正确的样式
+ return html.replace(
+ /
![]()
]*)>/gi,
+ (match, attrs) => {
+ if (!attrs.includes('style=')) {
+ return `
![]()
`
+ }
+ return match
+ }
+ )
+ })()
+ }}
+ />
+
+
+
+ )
+}
+
+export default MaterialList
diff --git a/src/pages/Subjective/PaperList.tsx b/src/pages/Subjective/PaperList.tsx
new file mode 100644
index 0000000..2fe6990
--- /dev/null
+++ b/src/pages/Subjective/PaperList.tsx
@@ -0,0 +1,1165 @@
+import { useState, useEffect } from 'react'
+import {
+ Table,
+ Button,
+ Form,
+ Input,
+ Modal,
+ message,
+ Space,
+ Popconfirm,
+ TreeSelect,
+ Card,
+ Tabs,
+ Row,
+ Col,
+} from 'antd'
+import { PlusOutlined, EditOutlined, DeleteOutlined, ReloadOutlined } from '@ant-design/icons'
+import type { ColumnsType } from 'antd/es/table'
+import type { Paper, Material, KnowledgeTreeNode, Question } from '@/types/question'
+import { QuestionType, MaterialType } from '@/types/question'
+import {
+ searchPapers,
+ createPaper,
+ updatePaper,
+ deletePaper,
+ searchMaterials,
+ createMaterial,
+ updateMaterial,
+ getMaterial,
+ getKnowledgeTree,
+ searchQuestions,
+ getPaper,
+ getQuestion,
+ createQuestion,
+ updateQuestion,
+} from '@/api/question'
+import { DEFAULT_PAGE_SIZE } from '@/constants'
+import dayjs from 'dayjs'
+import RichTextEditor from '@/components/RichTextEditor/RichTextEditor'
+
+const { TextArea } = Input
+
+type MaterialDraft = Material & { __isNew?: boolean }
+type QuestionDraft = Question & { __isNew?: boolean }
+
+const SubjectivePaperList = () => {
+ const [loading, setLoading] = useState(false)
+ const [dataSource, setDataSource] = useState([])
+ 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(null)
+ const [materialList, setMaterialList] = useState([])
+ const [materialLoading, setMaterialLoading] = useState(false)
+ const [materialSearch, setMaterialSearch] = useState('')
+ // 材料选择对话框
+ const [materialModalVisible, setMaterialModalVisible] = useState(false)
+ const [selectedMaterials, setSelectedMaterials] = useState([]) // 试卷内材料(可编辑)
+ const [materialModalSelectedIds, setMaterialModalSelectedIds] = useState([]) // 对话框中的临时选中状态
+ const [activeMaterialKey, setActiveMaterialKey] = useState('')
+ const [knowledgeTreeData, setKnowledgeTreeData] = useState([])
+ const [questionList, setQuestionList] = useState([])
+ const [questionLoading, setQuestionLoading] = useState(false)
+ const [questionFilterKnowledgeId, setQuestionFilterKnowledgeId] = useState()
+ const [questionSearch, setQuestionSearch] = useState('')
+ // 题目选择对话框
+ const [questionModalVisible, setQuestionModalVisible] = useState(false)
+ const [selectedQuestions, setSelectedQuestions] = useState([]) // 试卷内题目(可编辑)
+ const [questionModalSelectedIds, setQuestionModalSelectedIds] = useState([]) // 对话框中的临时选中状态
+ const [activeQuestionKey, setActiveQuestionKey] = useState('')
+
+ const [form] = Form.useForm()
+
+ const genTempId = (prefix: 'mat' | 'q') =>
+ `tmp_${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`
+
+ const isTempId = (id?: string) => typeof id === 'string' && id.startsWith('tmp_')
+
+ const stripHtmlToText = (html: string) =>
+ (html || '')
+ .replace(/
]*>/gi, ' ') // 图片算内容
+ .replace(/<[^>]+>/g, ' ')
+ .replace(/ /gi, ' ')
+ .replace(/\s+/g, ' ')
+ .trim()
+
+ const hasMeaningfulHtml = (html: string) => stripHtmlToText(html).length > 0
+
+ /** 是否为占位材料(未从材料库选择过的空槽) */
+ const isPlaceholderMaterial = (m: MaterialDraft) =>
+ (m.__isNew === true || isTempId(m.id)) && !(m.name || '').trim() && !hasMeaningfulHtml(m.content || '')
+
+ const createEmptyMaterialDraft = (): MaterialDraft => ({
+ id: genTempId('mat'),
+ __isNew: true,
+ type: MaterialType.SUBJECTIVE,
+ name: '',
+ content: '',
+ createdAt: 0,
+ updatedAt: 0,
+ })
+
+ const createEmptyQuestionDraft = (): QuestionDraft => ({
+ id: genTempId('q'),
+ __isNew: true,
+ type: QuestionType.SUBJECTIVE,
+ name: '',
+ source: '',
+ materialId: '',
+ content: '',
+ options: [],
+ answer: '',
+ explanation: '',
+ knowledgeTreeIds: [],
+ createdAt: 0,
+ updatedAt: 0,
+ })
+
+ // 获取试卷列表(只获取主观题试卷)
+ 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) {
+ setDataSource([])
+ setTotal(0)
+ if (error.response?.status >= 500 || !error.response) {
+ message.error('服务器错误,请稍后重试')
+ }
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ // 获取材料列表(用于选择)
+ const fetchMaterials = async (searchQuery?: string): Promise => {
+ setMaterialLoading(true)
+ try {
+ const res = await searchMaterials({
+ query: searchQuery || materialSearch || undefined,
+ type: MaterialType.SUBJECTIVE, // 只获取主观题材料
+ page: 1,
+ pageSize: 1000, // 获取所有材料用于选择
+ })
+ if (res.data.code === 200) {
+ const list = res.data.data.list || []
+ setMaterialList(list)
+ return list
+ }
+ return []
+ } catch (error: any) {
+ return []
+ } finally {
+ setMaterialLoading(false)
+ }
+ }
+
+ // 获取知识树(用于选择题型分类)
+ 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)
+ }
+ }
+
+ // 获取主观题列表(用于从已有题目中选择)
+ const fetchQuestions = async (searchQuery?: string, filterKnowledgeId?: string): Promise => {
+ setQuestionLoading(true)
+ try {
+ const res = await searchQuestions({
+ query: searchQuery || questionSearch || undefined,
+ type: QuestionType.SUBJECTIVE,
+ page: 1,
+ pageSize: 1000,
+ })
+ if (res.data.code === 200) {
+ let list: Question[] = res.data.data.list || []
+ const knowledgeId = filterKnowledgeId !== undefined ? filterKnowledgeId : questionFilterKnowledgeId
+ if (knowledgeId) {
+ list = list.filter(
+ (q) =>
+ Array.isArray(q.knowledgeTreeIds) && q.knowledgeTreeIds.includes(knowledgeId),
+ )
+ }
+ setQuestionList(list)
+ return list
+ }
+ return []
+ } catch (error: any) {
+ console.error('获取主观题列表错误:', error)
+ return []
+ } finally {
+ setQuestionLoading(false)
+ }
+ }
+
+ useEffect(() => {
+ fetchData()
+ }, [page, pageSize, query])
+
+ useEffect(() => {
+ if (modalVisible) {
+ fetchKnowledgeTree()
+ }
+ }, [modalVisible])
+
+ // 材料选择对话框打开时获取材料列表
+ useEffect(() => {
+ if (materialModalVisible) {
+ fetchMaterials()
+ // 初始化对话框中的选中状态为当前已选中的材料ID
+ setMaterialModalSelectedIds(
+ selectedMaterials.filter((m) => !m.__isNew && !m.id.startsWith('tmp_')).map((m) => m.id),
+ )
+ }
+ }, [materialModalVisible])
+
+ // 材料搜索条件变化时刷新可选材料(在材料选择对话框中)
+ useEffect(() => {
+ if (materialModalVisible) {
+ fetchMaterials(materialSearch)
+ }
+ }, [materialModalVisible, materialSearch])
+
+ // 题目选择对话框打开时获取题目列表
+ useEffect(() => {
+ if (questionModalVisible) {
+ fetchQuestions()
+ // 初始化对话框中的选中状态为当前已选中的题目ID
+ setQuestionModalSelectedIds(
+ selectedQuestions.filter((q) => !q.__isNew && !q.id.startsWith('tmp_')).map((q) => q.id),
+ )
+ }
+ }, [questionModalVisible])
+
+ // 题目筛选条件变化时刷新可选题目(在题目选择对话框中)
+ useEffect(() => {
+ if (questionModalVisible) {
+ fetchQuestions(questionSearch, questionFilterKnowledgeId)
+ }
+ }, [questionModalVisible, questionFilterKnowledgeId, questionSearch])
+
+ // 监听 Modal 打开,设置表单初始值
+ useEffect(() => {
+ if (modalVisible && editingRecord) {
+ // 编辑模式:保留原有的题目,不转换为 Form.List(因为已经确定过了)
+ // 原有题目会显示在预览区域(通过 existingQuestionIds)
+ form.setFieldsValue({
+ title: editingRecord.title,
+ description: editingRecord.description,
+ source: editingRecord.source || '',
+ })
+ // 加载已选中的材料
+ if (editingRecord.materialIds && editingRecord.materialIds.length > 0) {
+ const materialIds = editingRecord.materialIds
+ const loadMaterials = async () => {
+ try {
+ const materialPromises = materialIds.map((id: string) => getMaterial(id))
+ const materialResponses = await Promise.all(materialPromises)
+ const materials: MaterialDraft[] = materialResponses
+ .filter((res) => res.data.code === 200 && res.data.data)
+ .map((res) => ({ ...res.data.data, __isNew: false }))
+ if (materials.length > 0) {
+ setSelectedMaterials(materials)
+ setActiveMaterialKey(materials[0].id)
+ } else {
+ const fallback = [createEmptyMaterialDraft()]
+ setSelectedMaterials(fallback)
+ setActiveMaterialKey(fallback[0].id)
+ }
+ } catch (error: any) {
+ console.error('加载材料详情失败:', error)
+ const fallback = [createEmptyMaterialDraft()]
+ setSelectedMaterials(fallback)
+ setActiveMaterialKey(fallback[0].id)
+ }
+ }
+ loadMaterials()
+ } else {
+ const fallback = [createEmptyMaterialDraft()]
+ setSelectedMaterials(fallback)
+ setActiveMaterialKey(fallback[0].id)
+ }
+ // 加载已选中的题目
+ console.log('编辑模式 - editingRecord:', editingRecord)
+ console.log('编辑模式 - editingRecord.questions:', editingRecord.questions)
+ console.log('编辑模式 - editingRecord.questionIds:', editingRecord.questionIds)
+
+ if (editingRecord.questions && editingRecord.questions.length > 0) {
+ // 将 QuestionInfo 转换为 Question 格式
+ const questions: QuestionDraft[] = editingRecord.questions.map((q, index) => ({
+ id: q.id,
+ type: QuestionType.SUBJECTIVE,
+ content: q.content || '',
+ options: q.options || [],
+ answer: q.answer || '',
+ explanation: q.explanation || '',
+ knowledgeTreeIds: q.knowledgeTreeIds || [],
+ name: `题目${index + 1}`,
+ source: '',
+ createdAt: 0,
+ updatedAt: 0,
+ __isNew: false,
+ }))
+ console.log('编辑模式 - 转换后的 questions:', questions)
+ setSelectedQuestions(questions)
+ setActiveQuestionKey(questions[0]?.id || '')
+ } else if (editingRecord.questionIds && editingRecord.questionIds.length > 0) {
+ // 如果 questions 为空但 questionIds 有值,需要异步加载题目详情
+ console.log('编辑模式 - questions 为空,需要加载题目详情,questionIds:', editingRecord.questionIds)
+ const loadQuestions = async () => {
+ try {
+ const questionPromises = editingRecord.questionIds.map((id: string) => getQuestion(id))
+ const questionResponses = await Promise.all(questionPromises)
+ const questions: QuestionDraft[] = questionResponses
+ .filter((res) => res.data.code === 200 && res.data.data)
+ .map((res, index) => ({
+ ...res.data.data,
+ name: (res.data.data as any)?.name || `题目${index + 1}`,
+ source: (res.data.data as any)?.source || '',
+ __isNew: false,
+ }))
+ console.log('编辑模式 - 异步加载的 questions:', questions)
+ if (questions.length > 0) {
+ setSelectedQuestions(questions)
+ setActiveQuestionKey(questions[0].id)
+ } else {
+ const fallback = [createEmptyQuestionDraft()]
+ setSelectedQuestions(fallback)
+ setActiveQuestionKey(fallback[0].id)
+ }
+ } catch (error: any) {
+ console.error('加载题目详情失败:', error)
+ const fallback = [createEmptyQuestionDraft()]
+ setSelectedQuestions(fallback)
+ setActiveQuestionKey(fallback[0].id)
+ }
+ }
+ loadQuestions()
+ } else {
+ console.log('编辑模式 - 没有题目数据')
+ const fallback = [createEmptyQuestionDraft()]
+ setSelectedQuestions(fallback)
+ setActiveQuestionKey(fallback[0].id)
+ }
+ } else if (modalVisible) {
+ form.resetFields()
+ const mats = [createEmptyMaterialDraft()]
+ const qs = [createEmptyQuestionDraft()]
+ setSelectedMaterials(mats)
+ setSelectedQuestions(qs)
+ setActiveMaterialKey(mats[0].id)
+ setActiveQuestionKey(qs[0].id)
+ }
+ }, [modalVisible, editingRecord, form])
+
+ // 打开新增/编辑弹窗
+ const handleOpenModal = async (record?: Paper) => {
+ if (record) {
+ // 编辑模式:获取完整的试卷信息(包括题目详情)
+ try {
+ const res = await getPaper(record.id)
+ if (res.data.code === 200 && res.data.data) {
+ setEditingRecord(res.data.data)
+ } else {
+ setEditingRecord(record)
+ }
+ } catch (error: any) {
+ console.error('获取试卷详情失败:', error)
+ // 如果获取失败,使用列表中的数据
+ setEditingRecord(record)
+ }
+ } else {
+ setEditingRecord(null)
+ form.resetFields()
+ form.setFieldsValue({
+ materialIds: [],
+ })
+ }
+ setModalVisible(true)
+ }
+
+
+ // 提交表单
+ const handleSubmit = async () => {
+ // 在函数开始就记录状态,确保不会因为异步操作导致状态丢失
+ const currentSelectedQuestions = [...selectedQuestions] // 保存当前选中的题目快照
+ const currentSelectedMaterials = [...selectedMaterials] // 保存当前选中的材料快照
+
+ console.log('=== handleSubmit 开始 ===')
+ console.log('当前 selectedQuestions state:', selectedQuestions)
+ console.log('当前 selectedQuestions.length (state):', selectedQuestions.length)
+ console.log('当前 selectedQuestions (快照):', currentSelectedQuestions)
+ console.log('当前 selectedQuestions.length (快照):', currentSelectedQuestions.length)
+ console.log('当前 selectedMaterials (快照):', currentSelectedMaterials)
+
+ // 如果快照是空的,但预览区域有显示,说明状态可能被清空了
+ if (currentSelectedQuestions.length === 0 && selectedQuestions.length === 0) {
+ console.warn('⚠️ 警告:selectedQuestions 为空!预览区域可能显示的是旧数据')
+ }
+
+ try {
+ const values = await form.validateFields()
+
+ // 基础校验:至少 1 道题,且题干不能为空(按“去掉 html 后的文本”判断)
+ const nonEmptyQuestions = currentSelectedQuestions.filter((q) => hasMeaningfulHtml(q.content || ''))
+ if (nonEmptyQuestions.length === 0) {
+ message.error('请至少填写一道题目的题干内容')
+ return
+ }
+
+ // 先落库材料/题目(新建或更新),拿到最终 ID 列表
+ const materialIdMap = new Map()
+ const questionIdMap = new Map()
+
+ // 材料:允许为空,但有内容的建议保存
+ await Promise.all(
+ currentSelectedMaterials.map(async (m) => {
+ const isNew = m.__isNew || m.id.startsWith('tmp_mat_')
+ if (isNew) {
+ // 空材料(既无标题也无内容)不创建
+ if (!(m.content || '').trim() && !(m.name || '').trim()) return
+ const res = await createMaterial({
+ type: MaterialType.SUBJECTIVE,
+ name: m.name || '',
+ content: m.content || '',
+ createdAt: 0,
+ updatedAt: 0,
+ id: '',
+ } as any)
+ const newId = res.data.data?.id
+ if (newId) materialIdMap.set(m.id, newId)
+ return
+ }
+ await updateMaterial(m as any)
+ }),
+ )
+
+ await Promise.all(
+ currentSelectedQuestions.map(async (q) => {
+ const isNew = q.__isNew || isTempId(q.id)
+ // 跳过空题目(题干没内容就不落库,也不参与提交)
+ if (!hasMeaningfulHtml(q.content || '') && !hasMeaningfulHtml(q.explanation || '')) return
+ if (isNew) {
+ const res = await createQuestion({
+ type: QuestionType.SUBJECTIVE,
+ name: q.name || '',
+ source: q.source || '',
+ materialId: undefined,
+ content: q.content || '',
+ options: [],
+ answer: '',
+ explanation: q.explanation || '',
+ knowledgeTreeIds: Array.isArray(q.knowledgeTreeIds) ? q.knowledgeTreeIds : [],
+ createdAt: 0,
+ updatedAt: 0,
+ id: '',
+ } as any)
+ const newId = res.data.data?.id || res.data.data?.question?.id
+ if (newId) {
+ questionIdMap.set(q.id, newId)
+ } else {
+ // 创建失败必须中断,否则会导致 question_ids 为空
+ throw new Error('创建题目失败:无法获取题目ID')
+ }
+ return
+ }
+ await updateQuestion(q as any)
+ }),
+ )
+
+ const allMaterialIds = Array.from(
+ new Set(
+ currentSelectedMaterials
+ .map((m) => materialIdMap.get(m.id) || (isTempId(m.id) ? '' : m.id))
+ .filter((id) => !!id),
+ ),
+ )
+
+ const allQuestionIds = Array.from(
+ new Set(
+ currentSelectedQuestions
+ .map((q) => questionIdMap.get(q.id) || (isTempId(q.id) ? '' : q.id))
+ .filter((id) => !!id),
+ ),
+ )
+
+ // 验证:题目数量不能为空
+ if (!allQuestionIds || allQuestionIds.length === 0) {
+ message.error('请至少选择一个题目')
+ return
+ }
+
+ // 选中的题目对应的知识树ID(使用快照)
+ const allKnowledgeTreeIds = Array.from(
+ new Set(
+ currentSelectedQuestions.flatMap((q) => q.knowledgeTreeIds || [])
+ )
+ )
+
+ // 调试日志 - 确认预览区域的数据
+ console.log('=== 提交表单调试信息 ===')
+ console.log('快照中的题目数量:', currentSelectedQuestions.length)
+ console.log(
+ '快照中的题目列表:',
+ currentSelectedQuestions.map((q) => ({
+ id: q.id,
+ isTemp: isTempId(q.id),
+ __isNew: q.__isNew,
+ contentTextLen: stripHtmlToText(q.content || '').length,
+ })),
+ )
+ console.log('提取的题目ID:', allQuestionIds)
+ console.log('快照中的材料数量:', currentSelectedMaterials.length)
+ console.log('快照中的材料ID:', currentSelectedMaterials.map((m) => m.id))
+
+ const formData = {
+ title: values.title,
+ description: values.description || '',
+ source: values.source || '',
+ materialIds: allMaterialIds,
+ questionIds: allQuestionIds,
+ knowledgeTreeIds: allKnowledgeTreeIds,
+ }
+
+ console.log('最终提交的数据:', formData)
+ console.log('最终提交的 questionIds:', formData.questionIds)
+ console.log('========================')
+
+ if (editingRecord) {
+ // 编辑:先删除旧题目,再创建新题目
+ // TODO: 这里可以优化,只更新变化的问题
+ await updatePaper({ ...editingRecord, ...formData })
+ message.success('编辑试卷成功')
+ } else {
+ // 新增
+ await createPaper(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 deletePaper(id)
+ message.success('删除成功')
+ fetchData()
+ } catch (error: any) {
+ const errorMsg = error?.message || '删除失败'
+ message.error(errorMsg)
+ }
+ }
+
+ // 搜索
+ const handleSearch = (value: string) => {
+ setQuery(value)
+ setPage(1)
+ }
+
+ // 将知识树转换为 TreeSelect 格式:有下级的节点展开但不可选,仅最低层级(叶子)可选
+ const convertKnowledgeTreeToTreeData = (nodes: KnowledgeTreeNode[]): any[] => {
+ return nodes.map((node) => {
+ const hasChildren = node.children && node.children.length > 0
+ return {
+ title: node.title,
+ value: node.id,
+ key: node.id,
+ disabled: hasChildren, // 有下级则不可选,只有叶子节点可选
+ children: hasChildren && node.children ? convertKnowledgeTreeToTreeData(node.children) : undefined,
+ }
+ })
+ }
+
+ // 处理材料选择对话框的确定按钮
+ const handleMaterialModalOk = () => {
+ // 根据选中的ID,从 materialList 中获取完整的材料对象
+ const selectedFromList = materialList.filter((m) => materialModalSelectedIds.includes(m.id))
+ setSelectedMaterials((prev) => {
+ // 若当前只有占位材料(空槽)且从材料库选了至少一个,则用选中的材料替换,使选中的第一个成为「材料1」
+ const onlyPlaceholders = prev.length > 0 && prev.every(isPlaceholderMaterial)
+ if (onlyPlaceholders && selectedFromList.length > 0) {
+ const merged: MaterialDraft[] = selectedFromList.map((m) => ({ ...m, __isNew: false }))
+ setActiveMaterialKey(merged[0].id)
+ return merged
+ }
+ const existing = new Set(prev.map((x) => x.id))
+ const merged: MaterialDraft[] = [...prev]
+ let lastAddedId = ''
+ selectedFromList.forEach((m) => {
+ if (!existing.has(m.id)) {
+ merged.push({ ...m, __isNew: false })
+ existing.add(m.id)
+ lastAddedId = m.id
+ }
+ })
+ if (lastAddedId) setActiveMaterialKey(lastAddedId)
+ // 确保至少一个 tab
+ if (merged.length === 0) merged.push(createEmptyMaterialDraft())
+ return merged
+ })
+ setMaterialModalVisible(false)
+ message.success(`已添加 ${selectedFromList.length} 个材料`)
+ }
+
+ // 处理材料选择对话框的取消按钮
+ const handleMaterialModalCancel = () => {
+ setMaterialModalVisible(false)
+ // 恢复为当前已选中的材料ID
+ setMaterialModalSelectedIds(selectedMaterials.filter((m) => !m.__isNew && !m.id.startsWith('tmp_')).map((m) => m.id))
+ }
+
+ // 处理题目选择对话框的确定按钮
+ const handleQuestionModalOk = () => {
+ // 根据选中的ID,从 questionList 中获取完整的题目对象
+ const selectedFromList = questionList.filter((q) => questionModalSelectedIds.includes(q.id))
+
+ setSelectedQuestions((prev) => {
+ const existing = new Set(prev.map((x) => x.id))
+ const merged: QuestionDraft[] = [...prev]
+ let lastAddedId = ''
+ selectedFromList.forEach((q, idx) => {
+ if (!existing.has(q.id)) {
+ merged.push({ ...q, __isNew: false, name: q.name || `题目${prev.length + idx + 1}` })
+ lastAddedId = q.id
+ }
+ })
+ if (lastAddedId) setActiveQuestionKey(lastAddedId)
+ // 确保至少一个 tab
+ if (merged.length === 0) merged.push(createEmptyQuestionDraft())
+ return merged
+ })
+ setQuestionModalVisible(false)
+ message.success(`已添加 ${selectedFromList.length} 个题目`)
+ }
+
+ // 处理题目选择对话框的取消按钮
+ const handleQuestionModalCancel = () => {
+ setQuestionModalVisible(false)
+ // 恢复为当前已选中的题目ID
+ setQuestionModalSelectedIds(selectedQuestions.filter((q) => !q.__isNew && !q.id.startsWith('tmp_')).map((q) => q.id))
+ }
+
+ // 题目选择对话框的列定义
+ const questionColumns: ColumnsType = [
+ {
+ title: '题目名称',
+ dataIndex: 'name',
+ key: 'name',
+ width: 200,
+ ellipsis: true,
+ render: (name: string, record: Question) =>
+ name || (record.content || '').replace(/<[^>]+>/g, '').slice(0, 30) || '未命名',
+ },
+ {
+ title: '题目内容',
+ dataIndex: 'content',
+ key: 'content',
+ ellipsis: true,
+ render: (content: string) => {
+ const textContent = content
+ .replace(/
]*>/gi, '[图片]')
+ .replace(/<[^>]+>/g, '')
+ .replace(/\n/g, ' ')
+ .trim()
+ .slice(0, 100)
+ return textContent ? (textContent.length >= 100 ? textContent + '...' : textContent) : '无内容'
+ },
+ },
+ ]
+
+ // 材料选择对话框的列定义
+ const materialColumns: ColumnsType = [
+ {
+ title: '材料名称',
+ dataIndex: 'name',
+ key: 'name',
+ width: 200,
+ ellipsis: true,
+ render: (name: string) => name || 未命名,
+ },
+ {
+ title: '材料内容',
+ dataIndex: 'content',
+ key: 'content',
+ ellipsis: true,
+ render: (content: string) => {
+ const textContent = content
+ .replace(/
]*>/gi, '[图片]')
+ .replace(/<[^>]+>/g, '')
+ .replace(/\n/g, ' ')
+ .trim()
+ .slice(0, 100)
+ return textContent ? (textContent.length >= 100 ? textContent + '...' : textContent) : '无内容'
+ },
+ },
+ ]
+
+ const columns: ColumnsType = [
+ {
+ title: '试卷名称',
+ dataIndex: 'title',
+ key: 'title',
+ width: 200,
+ ellipsis: true,
+ },
+ {
+ title: '试卷出处',
+ dataIndex: 'source',
+ key: 'source',
+ width: 150,
+ ellipsis: true,
+ render: (source: string) => source || 未设置,
+ },
+ {
+ title: '资料数量',
+ dataIndex: 'materialIds',
+ key: 'materialCount',
+ width: 100,
+ render: (materialIds: string[]) => materialIds?.length || 0,
+ },
+ {
+ title: '问题数量',
+ dataIndex: 'questions',
+ key: 'questionCount',
+ width: 100,
+ render: (questions: any[]) => questions?.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) => (
+
+ } onClick={() => handleOpenModal(record)}>
+ 编辑
+
+ handleDelete(record.id)}
+ okText="确定"
+ cancelText="取消"
+ >
+ }>
+ 删除
+
+
+
+ ),
+ },
+ ]
+
+ return (
+
+ {/* 页面标题 */}
+
+
组卷管理
+
+ 管理主观题试卷,将资料和问题组成试卷
+
+
+
+
+
+
+ } onClick={fetchData}>
+ 刷新
+
+ } onClick={() => handleOpenModal()}>
+ 新增试卷
+
+
+
+
+
`共 ${total} 个试卷`,
+ onChange: (page, pageSize) => {
+ setPage(page)
+ setPageSize(pageSize)
+ },
+ }}
+ />
+
+ {
+ setModalVisible(false)
+ setEditingRecord(null)
+ form.resetFields()
+ }}
+ width={1400}
+ okText="确定"
+ cancelText="取消"
+ >
+
+
+
+
+
+
+
+ }
+ >
+ setActiveMaterialKey(key)}
+ onEdit={(targetKey, action) => {
+ if (action === 'add') {
+ setSelectedMaterials((prev) => {
+ const next = [...prev, createEmptyMaterialDraft()]
+ setActiveMaterialKey(next[next.length - 1].id)
+ return next
+ })
+ return
+ }
+ if (action === 'remove') {
+ const key = String(targetKey || '')
+ setSelectedMaterials((prev) => {
+ if (prev.length <= 1) return prev
+ const idx = prev.findIndex((m) => m.id === key)
+ const next = prev.filter((m) => m.id !== key)
+ const nextActive = next[Math.max(0, idx - 1)]?.id || next[0]?.id || ''
+ setActiveMaterialKey(nextActive)
+ return next
+ })
+ }
+ }}
+ items={selectedMaterials.map((m, index) => ({
+ key: m.id,
+ label: `材料${index + 1}`,
+ closable: selectedMaterials.length > 1,
+ children: (
+
+
+ {
+ const v = e.target.value
+ setSelectedMaterials((prev) =>
+ prev.map((x) => (x.id === m.id ? { ...x, name: v } : x)),
+ )
+ }}
+ placeholder="材料标题/备注(仅用于搜索,可选)"
+ />
+
+
{
+ setSelectedMaterials((prev) =>
+ prev.map((x) => (x.id === m.id ? { ...x, content: html } : x)),
+ )
+ }}
+ placeholder="请输入材料内容"
+ rows={6}
+ />
+
+ ),
+ }))}
+ />
+
+
+
+
+
+ }
+ >
+ setActiveQuestionKey(key)}
+ onEdit={(targetKey, action) => {
+ if (action === 'add') {
+ setSelectedQuestions((prev) => {
+ const next = [...prev, createEmptyQuestionDraft()]
+ setActiveQuestionKey(next[next.length - 1].id)
+ return next
+ })
+ return
+ }
+ if (action === 'remove') {
+ const key = String(targetKey || '')
+ setSelectedQuestions((prev) => {
+ if (prev.length <= 1) return prev
+ const idx = prev.findIndex((q) => q.id === key)
+ const next = prev.filter((q) => q.id !== key)
+ const nextActive = next[Math.max(0, idx - 1)]?.id || next[0]?.id || ''
+ setActiveQuestionKey(nextActive)
+ return next
+ })
+ }
+ }}
+ items={selectedQuestions.map((q, index) => ({
+ key: q.id,
+ label: `题目${index + 1}`,
+ closable: selectedQuestions.length > 1,
+ children: (
+
+
+
+ {
+ const v = e.target.value
+ setSelectedQuestions((prev) =>
+ prev.map((x) => (x.id === q.id ? { ...x, name: v } : x)),
+ )
+ }}
+ placeholder="题目名称/备注(仅用于搜索,可选)"
+ />
+
+
+ {
+ const v = e.target.value
+ setSelectedQuestions((prev) =>
+ prev.map((x) => (x.id === q.id ? { ...x, source: v } : x)),
+ )
+ }}
+ placeholder="题目出处(可选)"
+ />
+
+
+
+
+
题干
+
{
+ setSelectedQuestions((prev) =>
+ prev.map((x) => (x.id === q.id ? { ...x, content: html } : x)),
+ )
+ }}
+ placeholder="请输入题干内容"
+ rows={7}
+ />
+
+
+
+
解析(可选)
+
{
+ setSelectedQuestions((prev) =>
+ prev.map((x) => (x.id === q.id ? { ...x, explanation: html } : x)),
+ )
+ }}
+ placeholder="请输入解析内容(可选)"
+ rows={5}
+ />
+
+
+
+
关联题型分类(知识树,可选)
+
{
+ setSelectedQuestions((prev) =>
+ prev.map((x) =>
+ x.id === q.id ? { ...x, knowledgeTreeIds: (value as string[]) || [] } : x,
+ ),
+ )
+ }}
+ />
+
+
+ ),
+ }))}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* 材料选择对话框 */}
+
+
+ setMaterialSearch(value)}
+ onChange={(e) => {
+ if (!e.target.value) {
+ setMaterialSearch('')
+ }
+ }}
+ />
+
+ `共 ${total} 个材料`,
+ }}
+ rowSelection={{
+ selectedRowKeys: materialModalSelectedIds,
+ onChange: (selectedRowKeys) => {
+ setMaterialModalSelectedIds(selectedRowKeys as string[])
+ },
+ type: 'checkbox',
+ }}
+ />
+
+
+ {/* 题目选择对话框 */}
+
+
+
+
+ setQuestionFilterKnowledgeId(value)}
+ />
+
+
+ setQuestionSearch(value)}
+ onChange={(e) => {
+ if (!e.target.value) {
+ setQuestionSearch('')
+ }
+ }}
+ />
+
+
+
+ `共 ${total} 个题目`,
+ }}
+ rowSelection={{
+ selectedRowKeys: questionModalSelectedIds,
+ onChange: (selectedRowKeys) => {
+ setQuestionModalSelectedIds(selectedRowKeys as string[])
+ },
+ type: 'checkbox',
+ }}
+ />
+
+
+ )
+}
+
+export default SubjectivePaperList
diff --git a/src/pages/Subjective/QuestionList.tsx b/src/pages/Subjective/QuestionList.tsx
new file mode 100644
index 0000000..2c8e575
--- /dev/null
+++ b/src/pages/Subjective/QuestionList.tsx
@@ -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([])
+ 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(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 = [
+ {
+ title: '题目名称',
+ dataIndex: 'name',
+ key: 'name',
+ width: 200,
+ ellipsis: true,
+ render: (name: string) => name || 未设置,
+ },
+ {
+ title: '题目出处',
+ dataIndex: 'source',
+ key: 'source',
+ width: 150,
+ ellipsis: true,
+ render: (source: string) => source || 未设置,
+ },
+ {
+ 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) => (
+
+ } onClick={() => handleOpenModal(record)}>
+ 编辑
+
+ handleDelete(record.id)}
+ okText="确定"
+ cancelText="取消"
+ >
+ }>
+ 删除
+
+
+
+ ),
+ },
+ ]
+
+ return (
+
+ {/* 页面标题 */}
+
+
主观题管理 - 题目管理
+
+ 管理主观题题目,可按题目名称、内容搜索,并关联题型分类(知识树)
+
+
+
+
+
+
+ } onClick={fetchData}>
+ 刷新
+
+ } onClick={() => handleOpenModal()}>
+ 新增题目
+
+
+
+
+
`共 ${total} 道题目`,
+ onChange: (page, pageSize) => {
+ setPage(page)
+ setPageSize(pageSize)
+ },
+ }}
+ />
+
+ {
+ setModalVisible(false)
+ setEditingRecord(null)
+ }}
+ onSuccess={handleQuestionSuccess}
+ />
+
+ )
+}
+
+export default SubjectiveQuestionList
diff --git a/src/pages/User/UserList.tsx b/src/pages/User/UserList.tsx
new file mode 100644
index 0000000..7e6fac9
--- /dev/null
+++ b/src/pages/User/UserList.tsx
@@ -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 = [
+ {
+ 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 {role}
+ },
+ },
+ {
+ title: '状态',
+ dataIndex: 'status',
+ key: 'status',
+ render: (status: string) => (
+
+ {status === 'active' ? '启用' : '禁用'}
+
+ ),
+ },
+ {
+ title: '创建时间',
+ dataIndex: 'createdAt',
+ key: 'createdAt',
+ },
+ {
+ title: '操作',
+ key: 'action',
+ render: () => (
+
+ } size="small">
+ 编辑
+
+ } size="small">
+ 删除
+
+
+ ),
+ },
+ ]
+
+ return (
+
+
+
+ }
+ value={searchText}
+ onChange={(e) => setSearchText(e.target.value)}
+ style={{ width: 250 }}
+ />
+
+
+ }>
+ 新增用户
+
+
+
+
`共 ${total} 条`,
+ }}
+ />
+
+ )
+}
+
+export default UserList
+
diff --git a/src/routes/ProtectedRoute.tsx b/src/routes/ProtectedRoute.tsx
new file mode 100644
index 0000000..263adf7
--- /dev/null
+++ b/src/routes/ProtectedRoute.tsx
@@ -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
+ }
+
+ return <>{children}>
+}
+
diff --git a/src/routes/index.tsx b/src/routes/index.tsx
new file mode 100644
index 0000000..4a681e7
--- /dev/null
+++ b/src/routes/index.tsx
@@ -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
+ }
+
+ return <>{children}>
+}
+
+const AppRoutes = () => {
+ const routes = useRoutes([
+ {
+ path: RoutePath.LOGIN,
+ element: ,
+ },
+ {
+ path: RoutePath.HOME,
+ element: (
+
+
+
+ ),
+ children: [
+ {
+ index: true,
+ element: ,
+ },
+ {
+ path: RoutePath.DASHBOARD,
+ element: ,
+ },
+ {
+ path: RoutePath.USER_LIST,
+ element: ,
+ },
+ {
+ path: RoutePath.ROLE_LIST,
+ element: ,
+ },
+ {
+ path: RoutePath.PERMISSION_LIST,
+ element: ,
+ },
+ // 打卡营管理路由
+ {
+ path: RoutePath.CAMP_CATEGORY_LIST,
+ element: ,
+ },
+ {
+ path: RoutePath.CAMP_LIST,
+ element: ,
+ },
+ {
+ path: RoutePath.CAMP_SECTION_LIST,
+ element: ,
+ },
+ {
+ path: RoutePath.CAMP_TASK_LIST,
+ element: ,
+ },
+ {
+ path: RoutePath.CAMP_PROGRESS_LIST,
+ element: ,
+ },
+ {
+ path: RoutePath.CAMP_PENDING_TASK_LIST,
+ element: ,
+ },
+ // 客观题管理路由
+ {
+ path: RoutePath.OBJECTIVE_QUESTION_LIST,
+ element: ,
+ },
+ {
+ path: RoutePath.OBJECTIVE_MATERIAL_LIST,
+ element: ,
+ },
+ {
+ path: RoutePath.OBJECTIVE_PAPER_LIST,
+ element: ,
+ },
+ {
+ path: RoutePath.OBJECTIVE_PAPER_EDIT,
+ element: ,
+ },
+ {
+ path: RoutePath.OBJECTIVE_KNOWLEDGE_TREE,
+ element: ,
+ },
+ // 主观题管理路由
+ {
+ path: RoutePath.SUBJECTIVE_QUESTION_LIST,
+ element: ,
+ },
+ {
+ path: RoutePath.SUBJECTIVE_MATERIAL_LIST,
+ element: ,
+ },
+ {
+ path: RoutePath.SUBJECTIVE_PAPER_LIST,
+ element: ,
+ },
+ {
+ path: RoutePath.SUBJECTIVE_KNOWLEDGE_TREE,
+ element: ,
+ },
+ {
+ path: RoutePath.DOCUMENT_LIST,
+ element: ,
+ },
+ ],
+ },
+ {
+ path: RoutePath.NOT_FOUND,
+ element: ,
+ },
+ ])
+
+ return routes
+}
+
+export default AppRoutes
+
diff --git a/src/store/useUserStore.ts b/src/store/useUserStore.ts
new file mode 100644
index 0000000..d5272f2
--- /dev/null
+++ b/src/store/useUserStore.ts
@@ -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((set, get) => ({
+ userInfo: storage.get(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
+ },
+}))
+
diff --git a/src/types/camp.ts b/src/types/camp.ts
new file mode 100644
index 0000000..d7f9c24
--- /dev/null
+++ b/src/types/camp.ts
@@ -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
+}
+
diff --git a/src/types/index.ts b/src/types/index.ts
new file mode 100644
index 0000000..aa6e26e
--- /dev/null
+++ b/src/types/index.ts
@@ -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 {
+ code: number
+ data: T
+ message: string
+}
+
+// 分页参数接口
+export interface PageParams {
+ page: number
+ pageSize: number
+}
+
+// 分页数据接口
+export interface PageData {
+ list: T[]
+ total: number
+ page: number
+ pageSize: number
+}
+
diff --git a/src/types/question.ts b/src/types/question.ts
new file mode 100644
index 0000000..254160c
--- /dev/null
+++ b/src/types/question.ts
@@ -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
+}
diff --git a/src/utils/permission.ts b/src/utils/permission.ts
new file mode 100644
index 0000000..5c109c6
--- /dev/null
+++ b/src/utils/permission.ts
@@ -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
+}
+
diff --git a/src/utils/request.ts b/src/utils/request.ts
new file mode 100644
index 0000000..19c7226
--- /dev/null
+++ b/src/utils/request.ts
@@ -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) => {
+ 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
+
diff --git a/src/utils/storage.ts b/src/utils/storage.ts
new file mode 100644
index 0000000..38d0c13
--- /dev/null
+++ b/src/utils/storage.ts
@@ -0,0 +1,34 @@
+// 本地存储工具函数
+
+export const storage = {
+ // 获取数据
+ get(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()
+ },
+}
+
diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts
new file mode 100644
index 0000000..ed77210
--- /dev/null
+++ b/src/vite-env.d.ts
@@ -0,0 +1,2 @@
+///
+
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..29da542
--- /dev/null
+++ b/tsconfig.json
@@ -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" }]
+}
+
diff --git a/tsconfig.node.json b/tsconfig.node.json
new file mode 100644
index 0000000..f7c2070
--- /dev/null
+++ b/tsconfig.node.json
@@ -0,0 +1,12 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "skipLibCheck": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowSyntheticDefaultImports": true,
+ "types": ["node"]
+ },
+ "include": ["vite.config.ts"]
+}
+
diff --git a/tsconfig.node.tsbuildinfo b/tsconfig.node.tsbuildinfo
new file mode 100644
index 0000000..562f339
--- /dev/null
+++ b/tsconfig.node.tsbuildinfo
@@ -0,0 +1 @@
+{"fileNames":["./node_modules/typescript/lib/lib.d.ts","./node_modules/typescript/lib/lib.es5.d.ts","./node_modules/typescript/lib/lib.es2015.d.ts","./node_modules/typescript/lib/lib.es2016.d.ts","./node_modules/typescript/lib/lib.es2017.d.ts","./node_modules/typescript/lib/lib.es2018.d.ts","./node_modules/typescript/lib/lib.es2019.d.ts","./node_modules/typescript/lib/lib.es2020.d.ts","./node_modules/typescript/lib/lib.dom.d.ts","./node_modules/typescript/lib/lib.webworker.importscripts.d.ts","./node_modules/typescript/lib/lib.scripthost.d.ts","./node_modules/typescript/lib/lib.es2015.core.d.ts","./node_modules/typescript/lib/lib.es2015.collection.d.ts","./node_modules/typescript/lib/lib.es2015.generator.d.ts","./node_modules/typescript/lib/lib.es2015.iterable.d.ts","./node_modules/typescript/lib/lib.es2015.promise.d.ts","./node_modules/typescript/lib/lib.es2015.proxy.d.ts","./node_modules/typescript/lib/lib.es2015.reflect.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2016.array.include.d.ts","./node_modules/typescript/lib/lib.es2016.intl.d.ts","./node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","./node_modules/typescript/lib/lib.es2017.date.d.ts","./node_modules/typescript/lib/lib.es2017.object.d.ts","./node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2017.string.d.ts","./node_modules/typescript/lib/lib.es2017.intl.d.ts","./node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","./node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","./node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","./node_modules/typescript/lib/lib.es2018.intl.d.ts","./node_modules/typescript/lib/lib.es2018.promise.d.ts","./node_modules/typescript/lib/lib.es2018.regexp.d.ts","./node_modules/typescript/lib/lib.es2019.array.d.ts","./node_modules/typescript/lib/lib.es2019.object.d.ts","./node_modules/typescript/lib/lib.es2019.string.d.ts","./node_modules/typescript/lib/lib.es2019.symbol.d.ts","./node_modules/typescript/lib/lib.es2019.intl.d.ts","./node_modules/typescript/lib/lib.es2020.bigint.d.ts","./node_modules/typescript/lib/lib.es2020.date.d.ts","./node_modules/typescript/lib/lib.es2020.promise.d.ts","./node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2020.string.d.ts","./node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2020.intl.d.ts","./node_modules/typescript/lib/lib.es2020.number.d.ts","./node_modules/typescript/lib/lib.esnext.disposable.d.ts","./node_modules/typescript/lib/lib.esnext.float16.d.ts","./node_modules/typescript/lib/lib.decorators.d.ts","./node_modules/typescript/lib/lib.decorators.legacy.d.ts","./node_modules/@types/node/compatibility/iterators.d.ts","./node_modules/@types/node/globals.typedarray.d.ts","./node_modules/@types/node/buffer.buffer.d.ts","./node_modules/@types/node/globals.d.ts","./node_modules/@types/node/web-globals/abortcontroller.d.ts","./node_modules/@types/node/web-globals/blob.d.ts","./node_modules/@types/node/web-globals/console.d.ts","./node_modules/@types/node/web-globals/crypto.d.ts","./node_modules/@types/node/web-globals/domexception.d.ts","./node_modules/@types/node/web-globals/encoding.d.ts","./node_modules/@types/node/web-globals/events.d.ts","./node_modules/undici-types/utility.d.ts","./node_modules/undici-types/header.d.ts","./node_modules/undici-types/readable.d.ts","./node_modules/undici-types/fetch.d.ts","./node_modules/undici-types/formdata.d.ts","./node_modules/undici-types/connector.d.ts","./node_modules/undici-types/client-stats.d.ts","./node_modules/undici-types/client.d.ts","./node_modules/undici-types/errors.d.ts","./node_modules/undici-types/dispatcher.d.ts","./node_modules/undici-types/global-dispatcher.d.ts","./node_modules/undici-types/global-origin.d.ts","./node_modules/undici-types/pool-stats.d.ts","./node_modules/undici-types/pool.d.ts","./node_modules/undici-types/handlers.d.ts","./node_modules/undici-types/balanced-pool.d.ts","./node_modules/undici-types/h2c-client.d.ts","./node_modules/undici-types/agent.d.ts","./node_modules/undici-types/mock-interceptor.d.ts","./node_modules/undici-types/mock-call-history.d.ts","./node_modules/undici-types/mock-agent.d.ts","./node_modules/undici-types/mock-client.d.ts","./node_modules/undici-types/mock-pool.d.ts","./node_modules/undici-types/snapshot-agent.d.ts","./node_modules/undici-types/mock-errors.d.ts","./node_modules/undici-types/proxy-agent.d.ts","./node_modules/undici-types/env-http-proxy-agent.d.ts","./node_modules/undici-types/retry-handler.d.ts","./node_modules/undici-types/retry-agent.d.ts","./node_modules/undici-types/api.d.ts","./node_modules/undici-types/cache-interceptor.d.ts","./node_modules/undici-types/interceptors.d.ts","./node_modules/undici-types/util.d.ts","./node_modules/undici-types/cookies.d.ts","./node_modules/undici-types/patch.d.ts","./node_modules/undici-types/websocket.d.ts","./node_modules/undici-types/eventsource.d.ts","./node_modules/undici-types/diagnostics-channel.d.ts","./node_modules/undici-types/content-type.d.ts","./node_modules/undici-types/cache.d.ts","./node_modules/undici-types/index.d.ts","./node_modules/@types/node/web-globals/fetch.d.ts","./node_modules/@types/node/web-globals/importmeta.d.ts","./node_modules/@types/node/web-globals/messaging.d.ts","./node_modules/@types/node/web-globals/navigator.d.ts","./node_modules/@types/node/web-globals/performance.d.ts","./node_modules/@types/node/web-globals/storage.d.ts","./node_modules/@types/node/web-globals/streams.d.ts","./node_modules/@types/node/web-globals/timers.d.ts","./node_modules/@types/node/web-globals/url.d.ts","./node_modules/@types/node/assert.d.ts","./node_modules/@types/node/assert/strict.d.ts","./node_modules/@types/node/async_hooks.d.ts","./node_modules/@types/node/buffer.d.ts","./node_modules/@types/node/child_process.d.ts","./node_modules/@types/node/cluster.d.ts","./node_modules/@types/node/console.d.ts","./node_modules/@types/node/constants.d.ts","./node_modules/@types/node/crypto.d.ts","./node_modules/@types/node/dgram.d.ts","./node_modules/@types/node/diagnostics_channel.d.ts","./node_modules/@types/node/dns.d.ts","./node_modules/@types/node/dns/promises.d.ts","./node_modules/@types/node/domain.d.ts","./node_modules/@types/node/events.d.ts","./node_modules/@types/node/fs.d.ts","./node_modules/@types/node/fs/promises.d.ts","./node_modules/@types/node/http.d.ts","./node_modules/@types/node/http2.d.ts","./node_modules/@types/node/https.d.ts","./node_modules/@types/node/inspector.d.ts","./node_modules/@types/node/inspector.generated.d.ts","./node_modules/@types/node/inspector/promises.d.ts","./node_modules/@types/node/module.d.ts","./node_modules/@types/node/net.d.ts","./node_modules/@types/node/os.d.ts","./node_modules/@types/node/path.d.ts","./node_modules/@types/node/path/posix.d.ts","./node_modules/@types/node/path/win32.d.ts","./node_modules/@types/node/perf_hooks.d.ts","./node_modules/@types/node/process.d.ts","./node_modules/@types/node/punycode.d.ts","./node_modules/@types/node/querystring.d.ts","./node_modules/@types/node/quic.d.ts","./node_modules/@types/node/readline.d.ts","./node_modules/@types/node/readline/promises.d.ts","./node_modules/@types/node/repl.d.ts","./node_modules/@types/node/sea.d.ts","./node_modules/@types/node/sqlite.d.ts","./node_modules/@types/node/stream.d.ts","./node_modules/@types/node/stream/consumers.d.ts","./node_modules/@types/node/stream/promises.d.ts","./node_modules/@types/node/stream/web.d.ts","./node_modules/@types/node/string_decoder.d.ts","./node_modules/@types/node/test.d.ts","./node_modules/@types/node/test/reporters.d.ts","./node_modules/@types/node/timers.d.ts","./node_modules/@types/node/timers/promises.d.ts","./node_modules/@types/node/tls.d.ts","./node_modules/@types/node/trace_events.d.ts","./node_modules/@types/node/tty.d.ts","./node_modules/@types/node/url.d.ts","./node_modules/@types/node/util.d.ts","./node_modules/@types/node/util/types.d.ts","./node_modules/@types/node/v8.d.ts","./node_modules/@types/node/vm.d.ts","./node_modules/@types/node/wasi.d.ts","./node_modules/@types/node/worker_threads.d.ts","./node_modules/@types/node/zlib.d.ts","./node_modules/@types/node/index.d.ts","./node_modules/@types/estree/index.d.ts","./node_modules/rollup/dist/rollup.d.ts","./node_modules/rollup/dist/parseast.d.ts","./node_modules/vite/types/hmrpayload.d.ts","./node_modules/vite/types/customevent.d.ts","./node_modules/vite/types/hot.d.ts","./node_modules/vite/dist/node/types.d-agj9qkwt.d.ts","./node_modules/esbuild/lib/main.d.ts","./node_modules/source-map-js/source-map.d.ts","./node_modules/postcss/lib/previous-map.d.ts","./node_modules/postcss/lib/input.d.ts","./node_modules/postcss/lib/css-syntax-error.d.ts","./node_modules/postcss/lib/declaration.d.ts","./node_modules/postcss/lib/root.d.ts","./node_modules/postcss/lib/warning.d.ts","./node_modules/postcss/lib/lazy-result.d.ts","./node_modules/postcss/lib/no-work-result.d.ts","./node_modules/postcss/lib/processor.d.ts","./node_modules/postcss/lib/result.d.ts","./node_modules/postcss/lib/document.d.ts","./node_modules/postcss/lib/rule.d.ts","./node_modules/postcss/lib/node.d.ts","./node_modules/postcss/lib/comment.d.ts","./node_modules/postcss/lib/container.d.ts","./node_modules/postcss/lib/at-rule.d.ts","./node_modules/postcss/lib/list.d.ts","./node_modules/postcss/lib/postcss.d.ts","./node_modules/postcss/lib/postcss.d.mts","./node_modules/vite/dist/node/runtime.d.ts","./node_modules/vite/types/importglob.d.ts","./node_modules/vite/types/metadata.d.ts","./node_modules/vite/dist/node/index.d.ts","./node_modules/@babel/types/lib/index.d.ts","./node_modules/@types/babel__generator/index.d.ts","./node_modules/@babel/parser/typings/babel-parser.d.ts","./node_modules/@types/babel__template/index.d.ts","./node_modules/@types/babel__traverse/index.d.ts","./node_modules/@types/babel__core/index.d.ts","./node_modules/@vitejs/plugin-react/dist/index.d.ts","./vite.config.ts"],"fileIdsList":[[54,116,124,128,131,133,134,135,147,205],[54,116,124,128,131,133,134,135,147],[54,116,124,128,131,133,134,135,147,205,206,207,208,209],[54,116,124,128,131,133,134,135,147,205,207],[54,113,114,116,124,128,131,133,134,135,147],[54,115,116,124,128,131,133,134,135,147],[116,124,128,131,133,134,135,147],[54,116,124,128,131,133,134,135,147,155],[54,116,117,122,124,127,128,131,133,134,135,137,147,152,164],[54,116,117,118,124,127,128,131,133,134,135,147],[54,116,119,124,128,131,133,134,135,147,165],[54,116,120,121,124,128,131,133,134,135,138,147],[54,116,121,124,128,131,133,134,135,147,152,161],[54,116,122,124,127,128,131,133,134,135,137,147],[54,115,116,123,124,128,131,133,134,135,147],[54,116,124,125,128,131,133,134,135,147],[54,116,124,126,127,128,131,133,134,135,147],[54,115,116,124,127,128,131,133,134,135,147],[54,116,124,127,128,129,131,133,134,135,147,152,164],[54,116,124,127,128,129,131,133,134,135,147,152,155],[54,103,116,124,127,128,130,131,133,134,135,137,147,152,164],[54,116,124,127,128,130,131,133,134,135,137,147,152,161,164],[54,116,124,128,130,131,132,133,134,135,147,152,161,164],[52,53,54,55,56,57,58,59,60,61,62,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171],[54,116,124,127,128,131,133,134,135,147],[54,116,124,128,131,133,135,147],[54,116,124,128,131,133,134,135,136,147,164],[54,116,124,127,128,131,133,134,135,137,147,152],[54,116,124,128,131,133,134,135,138,147],[54,116,124,128,131,133,134,135,139,147],[54,116,124,127,128,131,133,134,135,142,147],[54,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171],[54,116,124,128,131,133,134,135,144,147],[54,116,124,128,131,133,134,135,145,147],[54,116,121,124,128,131,133,134,135,137,147,155],[54,116,124,127,128,131,133,134,135,147,148],[54,116,124,128,131,133,134,135,147,149,165,168],[54,116,124,127,128,131,133,134,135,147,152,154,155],[54,116,124,128,131,133,134,135,147,153,155],[54,116,124,128,131,133,134,135,147,155,165],[54,116,124,128,131,133,134,135,147,156],[54,113,116,124,128,131,133,134,135,147,152,158],[54,116,124,128,131,133,134,135,147,152,157],[54,116,124,127,128,131,133,134,135,147,159,160],[54,116,124,128,131,133,134,135,147,159,160],[54,116,121,124,128,131,133,134,135,137,147,152,161],[54,116,124,128,131,133,134,135,147,162],[54,116,124,128,131,133,134,135,137,147,163],[54,116,124,128,130,131,133,134,135,145,147,164],[54,116,124,128,131,133,134,135,147,165,166],[54,116,121,124,128,131,133,134,135,147,166],[54,116,124,128,131,133,134,135,147,152,167],[54,116,124,128,131,133,134,135,136,147,168],[54,116,124,128,131,133,134,135,147,169],[54,116,119,124,128,131,133,134,135,147],[54,116,121,124,128,131,133,134,135,147],[54,116,124,128,131,133,134,135,147,165],[54,103,116,124,128,131,133,134,135,147],[54,116,124,128,131,133,134,135,147,164],[54,116,124,128,131,133,134,135,147,170],[54,116,124,128,131,133,134,135,142,147],[54,116,124,128,131,133,134,135,147,160],[54,103,116,124,127,128,129,131,133,134,135,142,147,152,155,164,167,168,170],[54,116,124,128,131,133,134,135,147,152,171],[54,116,124,128,131,133,134,135,147,204,210],[54,116,124,128,131,133,134,135,147,196],[54,116,124,128,131,133,134,135,147,194,196],[54,116,124,128,131,133,134,135,147,185,193,194,195,197,199],[54,116,124,128,131,133,134,135,147,183],[54,116,124,128,131,133,134,135,147,186,191,196,199],[54,116,124,128,131,133,134,135,147,182,199],[54,116,124,128,131,133,134,135,147,186,187,190,191,192,199],[54,116,124,128,131,133,134,135,147,186,187,188,190,191,199],[54,116,124,128,131,133,134,135,147,183,184,185,186,187,191,192,193,195,196,197,199],[54,116,124,128,131,133,134,135,147,199],[54,116,124,128,131,133,134,135,147,181,183,184,185,186,187,188,190,191,192,193,194,195,196,197,198],[54,116,124,128,131,133,134,135,147,181,199],[54,116,124,128,131,133,134,135,147,186,188,189,191,192,199],[54,116,124,128,131,133,134,135,147,190,199],[54,116,124,128,131,133,134,135,147,191,192,196,199],[54,116,124,128,131,133,134,135,147,184,194],[54,116,124,128,131,133,134,135,147,174,203],[54,116,124,128,131,133,134,135,147,173,174],[54,69,72,75,76,116,124,128,131,133,134,135,147,164],[54,72,116,124,128,131,133,134,135,147,152,164],[54,72,76,116,124,128,131,133,134,135,147,164],[54,116,124,128,131,133,134,135,147,152],[54,66,116,124,128,131,133,134,135,147],[54,70,116,124,128,131,133,134,135,147],[54,68,69,72,116,124,128,131,133,134,135,147,164],[54,116,124,128,131,133,134,135,137,147,161],[54,116,124,128,131,133,134,135,147,172],[54,66,116,124,128,131,133,134,135,147,172],[54,68,72,116,124,128,131,133,134,135,137,147,164],[54,63,64,65,67,71,116,124,127,128,131,133,134,135,147,152,164],[54,72,80,88,116,124,128,131,133,134,135,147],[54,64,70,116,124,128,131,133,134,135,147],[54,72,97,98,116,124,128,131,133,134,135,147],[54,64,67,72,116,124,128,131,133,134,135,147,155,164,172],[54,72,116,124,128,131,133,134,135,147],[54,68,72,116,124,128,131,133,134,135,147,164],[54,63,116,124,128,131,133,134,135,147],[54,66,67,68,70,71,72,73,74,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,98,99,100,101,102,116,124,128,131,133,134,135,147],[54,72,90,93,116,124,128,131,133,134,135,147],[54,72,80,81,82,116,124,128,131,133,134,135,147],[54,70,72,81,83,116,124,128,131,133,134,135,147],[54,71,116,124,128,131,133,134,135,147],[54,64,66,72,116,124,128,131,133,134,135,147],[54,72,76,81,83,116,124,128,131,133,134,135,147],[54,76,116,124,128,131,133,134,135,147],[54,70,72,75,116,124,128,131,133,134,135,147,164],[54,64,68,72,80,116,124,128,131,133,134,135,147],[54,72,90,116,124,128,131,133,134,135,147],[54,83,116,124,128,131,133,134,135,147],[54,66,72,97,116,124,128,131,133,134,135,147,155,170,172],[54,116,124,127,128,130,131,132,133,134,135,137,147,152,161,164,171,172,174,175,176,177,178,179,180,200,201,202,203],[54,116,124,128,131,133,134,135,147,176,177,178,179],[54,116,124,128,131,133,134,135,147,176,177,178],[54,116,124,128,131,133,134,135,147,176],[54,116,124,128,131,133,134,135,147,177],[54,116,124,128,131,133,134,135,147,174],[54,116,124,128,131,133,134,135,147,204,211]],"fileInfos":[{"version":"a7297ff837fcdf174a9524925966429eb8e5feecc2cc55cc06574e6b092c1eaa","impliedFormat":1},{"version":"c430d44666289dae81f30fa7b2edebf186ecc91a2d4c71266ea6ae76388792e1","affectsGlobalScope":true,"impliedFormat":1},{"version":"45b7ab580deca34ae9729e97c13cfd999df04416a79116c3bfb483804f85ded4","impliedFormat":1},{"version":"3facaf05f0c5fc569c5649dd359892c98a85557e3e0c847964caeb67076f4d75","impliedFormat":1},{"version":"e44bb8bbac7f10ecc786703fe0a6a4b952189f908707980ba8f3c8975a760962","impliedFormat":1},{"version":"5e1c4c362065a6b95ff952c0eab010f04dcd2c3494e813b493ecfd4fcb9fc0d8","impliedFormat":1},{"version":"68d73b4a11549f9c0b7d352d10e91e5dca8faa3322bfb77b661839c42b1ddec7","impliedFormat":1},{"version":"5efce4fc3c29ea84e8928f97adec086e3dc876365e0982cc8479a07954a3efd4","impliedFormat":1},{"version":"080941d9f9ff9307f7e27a83bcd888b7c8270716c39af943532438932ec1d0b9","affectsGlobalScope":true,"impliedFormat":1},{"version":"80e18897e5884b6723488d4f5652167e7bb5024f946743134ecc4aa4ee731f89","affectsGlobalScope":true,"impliedFormat":1},{"version":"cd034f499c6cdca722b60c04b5b1b78e058487a7085a8e0d6fb50809947ee573","affectsGlobalScope":true,"impliedFormat":1},{"version":"c57796738e7f83dbc4b8e65132f11a377649c00dd3eee333f672b8f0a6bea671","affectsGlobalScope":true,"impliedFormat":1},{"version":"dc2df20b1bcdc8c2d34af4926e2c3ab15ffe1160a63e58b7e09833f616efff44","affectsGlobalScope":true,"impliedFormat":1},{"version":"515d0b7b9bea2e31ea4ec968e9edd2c39d3eebf4a2d5cbd04e88639819ae3b71","affectsGlobalScope":true,"impliedFormat":1},{"version":"0559b1f683ac7505ae451f9a96ce4c3c92bdc71411651ca6ddb0e88baaaad6a3","affectsGlobalScope":true,"impliedFormat":1},{"version":"0dc1e7ceda9b8b9b455c3a2d67b0412feab00bd2f66656cd8850e8831b08b537","affectsGlobalScope":true,"impliedFormat":1},{"version":"ce691fb9e5c64efb9547083e4a34091bcbe5bdb41027e310ebba8f7d96a98671","affectsGlobalScope":true,"impliedFormat":1},{"version":"8d697a2a929a5fcb38b7a65594020fcef05ec1630804a33748829c5ff53640d0","affectsGlobalScope":true,"impliedFormat":1},{"version":"4ff2a353abf8a80ee399af572debb8faab2d33ad38c4b4474cff7f26e7653b8d","affectsGlobalScope":true,"impliedFormat":1},{"version":"fb0f136d372979348d59b3f5020b4cdb81b5504192b1cacff5d1fbba29378aa1","affectsGlobalScope":true,"impliedFormat":1},{"version":"d15bea3d62cbbdb9797079416b8ac375ae99162a7fba5de2c6c505446486ac0a","affectsGlobalScope":true,"impliedFormat":1},{"version":"68d18b664c9d32a7336a70235958b8997ebc1c3b8505f4f1ae2b7e7753b87618","affectsGlobalScope":true,"impliedFormat":1},{"version":"eb3d66c8327153d8fa7dd03f9c58d351107fe824c79e9b56b462935176cdf12a","affectsGlobalScope":true,"impliedFormat":1},{"version":"38f0219c9e23c915ef9790ab1d680440d95419ad264816fa15009a8851e79119","affectsGlobalScope":true,"impliedFormat":1},{"version":"69ab18c3b76cd9b1be3d188eaf8bba06112ebbe2f47f6c322b5105a6fbc45a2e","affectsGlobalScope":true,"impliedFormat":1},{"version":"a680117f487a4d2f30ea46f1b4b7f58bef1480456e18ba53ee85c2746eeca012","affectsGlobalScope":true,"impliedFormat":1},{"version":"2f11ff796926e0832f9ae148008138ad583bd181899ab7dd768a2666700b1893","affectsGlobalScope":true,"impliedFormat":1},{"version":"4de680d5bb41c17f7f68e0419412ca23c98d5749dcaaea1896172f06435891fc","affectsGlobalScope":true,"impliedFormat":1},{"version":"954296b30da6d508a104a3a0b5d96b76495c709785c1d11610908e63481ee667","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac9538681b19688c8eae65811b329d3744af679e0bdfa5d842d0e32524c73e1c","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a969edff4bd52585473d24995c5ef223f6652d6ef46193309b3921d65dd4376","affectsGlobalScope":true,"impliedFormat":1},{"version":"9e9fbd7030c440b33d021da145d3232984c8bb7916f277e8ffd3dc2e3eae2bdb","affectsGlobalScope":true,"impliedFormat":1},{"version":"811ec78f7fefcabbda4bfa93b3eb67d9ae166ef95f9bff989d964061cbf81a0c","affectsGlobalScope":true,"impliedFormat":1},{"version":"717937616a17072082152a2ef351cb51f98802fb4b2fdabd32399843875974ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"d7e7d9b7b50e5f22c915b525acc5a49a7a6584cf8f62d0569e557c5cfc4b2ac2","affectsGlobalScope":true,"impliedFormat":1},{"version":"71c37f4c9543f31dfced6c7840e068c5a5aacb7b89111a4364b1d5276b852557","affectsGlobalScope":true,"impliedFormat":1},{"version":"576711e016cf4f1804676043e6a0a5414252560eb57de9faceee34d79798c850","affectsGlobalScope":true,"impliedFormat":1},{"version":"89c1b1281ba7b8a96efc676b11b264de7a8374c5ea1e6617f11880a13fc56dc6","affectsGlobalScope":true,"impliedFormat":1},{"version":"74f7fa2d027d5b33eb0471c8e82a6c87216223181ec31247c357a3e8e2fddc5b","affectsGlobalScope":true,"impliedFormat":1},{"version":"d6d7ae4d1f1f3772e2a3cde568ed08991a8ae34a080ff1151af28b7f798e22ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"063600664504610fe3e99b717a1223f8b1900087fab0b4cad1496a114744f8df","affectsGlobalScope":true,"impliedFormat":1},{"version":"934019d7e3c81950f9a8426d093458b65d5aff2c7c1511233c0fd5b941e608ab","affectsGlobalScope":true,"impliedFormat":1},{"version":"52ada8e0b6e0482b728070b7639ee42e83a9b1c22d205992756fe020fd9f4a47","affectsGlobalScope":true,"impliedFormat":1},{"version":"3bdefe1bfd4d6dee0e26f928f93ccc128f1b64d5d501ff4a8cf3c6371200e5e6","affectsGlobalScope":true,"impliedFormat":1},{"version":"59fb2c069260b4ba00b5643b907ef5d5341b167e7d1dbf58dfd895658bda2867","affectsGlobalScope":true,"impliedFormat":1},{"version":"639e512c0dfc3fad96a84caad71b8834d66329a1f28dc95e3946c9b58176c73a","affectsGlobalScope":true,"impliedFormat":1},{"version":"368af93f74c9c932edd84c58883e736c9e3d53cec1fe24c0b0ff451f529ceab1","affectsGlobalScope":true,"impliedFormat":1},{"version":"51ad4c928303041605b4d7ae32e0c1ee387d43a24cd6f1ebf4a2699e1076d4fa","affectsGlobalScope":true,"impliedFormat":1},{"version":"196cb558a13d4533a5163286f30b0509ce0210e4b316c56c38d4c0fd2fb38405","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e7f8264d0fb4c5339605a15daadb037bf238c10b654bb3eee14208f860a32ea","affectsGlobalScope":true,"impliedFormat":1},{"version":"782dec38049b92d4e85c1585fbea5474a219c6984a35b004963b00beb1aab538","affectsGlobalScope":true,"impliedFormat":1},{"version":"d153a11543fd884b596587ccd97aebbeed950b26933ee000f94009f1ab142848","affectsGlobalScope":true,"impliedFormat":1},{"version":"0ccdaa19852d25ecd84eec365c3bfa16e7859cadecf6e9ca6d0dbbbee439743f","affectsGlobalScope":true,"impliedFormat":1},{"version":"438b41419b1df9f1fbe33b5e1b18f5853432be205991d1b19f5b7f351675541e","affectsGlobalScope":true,"impliedFormat":1},{"version":"096116f8fedc1765d5bd6ef360c257b4a9048e5415054b3bf3c41b07f8951b0b","affectsGlobalScope":true,"impliedFormat":1},{"version":"e5e01375c9e124a83b52ee4b3244ed1a4d214a6cfb54ac73e164a823a4a7860a","affectsGlobalScope":true,"impliedFormat":1},{"version":"f90ae2bbce1505e67f2f6502392e318f5714bae82d2d969185c4a6cecc8af2fc","affectsGlobalScope":true,"impliedFormat":1},{"version":"4b58e207b93a8f1c88bbf2a95ddc686ac83962b13830fe8ad3f404ffc7051fb4","affectsGlobalScope":true,"impliedFormat":1},{"version":"1fefabcb2b06736a66d2904074d56268753654805e829989a46a0161cd8412c5","affectsGlobalScope":true,"impliedFormat":1},{"version":"9798340ffb0d067d69b1ae5b32faa17ab31b82466a3fc00d8f2f2df0c8554aaa","affectsGlobalScope":true,"impliedFormat":1},{"version":"c18a99f01eb788d849ad032b31cafd49de0b19e083fe775370834c5675d7df8e","affectsGlobalScope":true,"impliedFormat":1},{"version":"5247874c2a23b9a62d178ae84f2db6a1d54e6c9a2e7e057e178cc5eea13757fc","affectsGlobalScope":true,"impliedFormat":1},{"version":"cdcf9ea426ad970f96ac930cd176d5c69c6c24eebd9fc580e1572d6c6a88f62c","impliedFormat":1},{"version":"23cd712e2ce083d68afe69224587438e5914b457b8acf87073c22494d706a3d0","impliedFormat":1},{"version":"487b694c3de27ddf4ad107d4007ad304d29effccf9800c8ae23c2093638d906a","impliedFormat":1},{"version":"3a80bc85f38526ca3b08007ee80712e7bb0601df178b23fbf0bf87036fce40ce","impliedFormat":1},{"version":"ccf4552357ce3c159ef75f0f0114e80401702228f1898bdc9402214c9499e8c0","impliedFormat":1},{"version":"c6fd2c5a395f2432786c9cb8deb870b9b0e8ff7e22c029954fabdd692bff6195","impliedFormat":1},{"version":"68834d631c8838c715f225509cfc3927913b9cc7a4870460b5b60c8dbdb99baf","impliedFormat":1},{"version":"2931540c47ee0ff8a62860e61782eb17b155615db61e36986e54645ec67f67c2","impliedFormat":1},{"version":"ccab02f3920fc75c01174c47fcf67882a11daf16baf9e81701d0a94636e94556","impliedFormat":1},{"version":"f6faf5f74e4c4cc309a6c6a6c4da02dbb840be5d3e92905a23dcd7b2b0bd1986","impliedFormat":1},{"version":"ea6bc8de8b59f90a7a3960005fd01988f98fd0784e14bc6922dde2e93305ec7d","impliedFormat":1},{"version":"36107995674b29284a115e21a0618c4c2751b32a8766dd4cb3ba740308b16d59","impliedFormat":1},{"version":"914a0ae30d96d71915fc519ccb4efbf2b62c0ddfb3a3fc6129151076bc01dc60","impliedFormat":1},{"version":"33e981bf6376e939f99bd7f89abec757c64897d33c005036b9a10d9587d80187","impliedFormat":1},{"version":"7fd1b31fd35876b0aa650811c25ec2c97a3c6387e5473eb18004bed86cdd76b6","impliedFormat":1},{"version":"b41767d372275c154c7ea6c9d5449d9a741b8ce080f640155cc88ba1763e35b3","impliedFormat":1},{"version":"3bacf516d686d08682751a3bd2519ea3b8041a164bfb4f1d35728993e70a2426","impliedFormat":1},{"version":"7fb266686238369442bd1719bc0d7edd0199da4fb8540354e1ff7f16669b4323","impliedFormat":1},{"version":"0a60a292b89ca7218b8616f78e5bbd1c96b87e048849469cccb4355e98af959a","impliedFormat":1},{"version":"0b6e25234b4eec6ed96ab138d96eb70b135690d7dd01f3dd8a8ab291c35a683a","impliedFormat":1},{"version":"9666f2f84b985b62400d2e5ab0adae9ff44de9b2a34803c2c5bd3c8325b17dc0","impliedFormat":1},{"version":"40cd35c95e9cf22cfa5bd84e96408b6fcbca55295f4ff822390abb11afbc3dca","impliedFormat":1},{"version":"b1616b8959bf557feb16369c6124a97a0e74ed6f49d1df73bb4b9ddf68acf3f3","impliedFormat":1},{"version":"5b03a034c72146b61573aab280f295b015b9168470f2df05f6080a2122f9b4df","impliedFormat":1},{"version":"40b463c6766ca1b689bfcc46d26b5e295954f32ad43e37ee6953c0a677e4ae2b","impliedFormat":1},{"version":"249b9cab7f5d628b71308c7d9bb0a808b50b091e640ba3ed6e2d0516f4a8d91d","impliedFormat":1},{"version":"80aae6afc67faa5ac0b32b5b8bc8cc9f7fa299cff15cf09cc2e11fd28c6ae29e","impliedFormat":1},{"version":"f473cd2288991ff3221165dcf73cd5d24da30391f87e85b3dd4d0450c787a391","impliedFormat":1},{"version":"499e5b055a5aba1e1998f7311a6c441a369831c70905cc565ceac93c28083d53","impliedFormat":1},{"version":"54c3e2371e3d016469ad959697fd257e5621e16296fa67082c2575d0bf8eced0","impliedFormat":1},{"version":"beb8233b2c220cfa0feea31fbe9218d89fa02faa81ef744be8dce5acb89bb1fd","impliedFormat":1},{"version":"c183b931b68ad184bc8e8372bf663f3d33304772fb482f29fb91b3c391031f3e","impliedFormat":1},{"version":"5d0375ca7310efb77e3ef18d068d53784faf62705e0ad04569597ae0e755c401","impliedFormat":1},{"version":"59af37caec41ecf7b2e76059c9672a49e682c1a2aa6f9d7dc78878f53aa284d6","impliedFormat":1},{"version":"addf417b9eb3f938fddf8d81e96393a165e4be0d4a8b6402292f9c634b1cb00d","impliedFormat":1},{"version":"48cc3ec153b50985fb95153258a710782b25975b10dd4ac8a4f3920632d10790","impliedFormat":1},{"version":"adf27937dba6af9f08a68c5b1d3fce0ca7d4b960c57e6d6c844e7d1a8e53adae","impliedFormat":1},{"version":"e1528ca65ac90f6fa0e4a247eb656b4263c470bb22d9033e466463e13395e599","impliedFormat":1},{"version":"2e85db9e6fd73cfa3d7f28e0ab6b55417ea18931423bd47b409a96e4a169e8e6","impliedFormat":1},{"version":"c46e079fe54c76f95c67fb89081b3e399da2c7d109e7dca8e4b58d83e332e605","impliedFormat":1},{"version":"866078923a56d026e39243b4392e282c1c63159723996fa89243140e1388a98d","impliedFormat":1},{"version":"dd0109710de4cd93e245121ab86d8c66d20f3ead80074b68e9c3e349c4f53342","affectsGlobalScope":true,"impliedFormat":1},{"version":"b3275d55fac10b799c9546804126239baf020d220136163f763b55a74e50e750","affectsGlobalScope":true,"impliedFormat":1},{"version":"fa68a0a3b7cb32c00e39ee3cd31f8f15b80cac97dce51b6ee7fc14a1e8deb30b","affectsGlobalScope":true,"impliedFormat":1},{"version":"1cf059eaf468efcc649f8cf6075d3cb98e9a35a0fe9c44419ec3d2f5428d7123","affectsGlobalScope":true,"impliedFormat":1},{"version":"6c36e755bced82df7fb6ce8169265d0a7bb046ab4e2cb6d0da0cb72b22033e89","affectsGlobalScope":true,"impliedFormat":1},{"version":"e7721c4f69f93c91360c26a0a84ee885997d748237ef78ef665b153e622b36c1","affectsGlobalScope":true,"impliedFormat":1},{"version":"7a93de4ff8a63bafe62ba86b89af1df0ccb5e40bb85b0c67d6bbcfdcf96bf3d4","affectsGlobalScope":true,"impliedFormat":1},{"version":"90e85f9bc549dfe2b5749b45fe734144e96cd5d04b38eae244028794e142a77e","affectsGlobalScope":true,"impliedFormat":1},{"version":"e0a5deeb610b2a50a6350bd23df6490036a1773a8a71d70f2f9549ab009e67ee","affectsGlobalScope":true,"impliedFormat":1},{"version":"435b3711465425770ed2ee2f1cf00ce071835265e0851a7dc4600ab4b007550e","impliedFormat":1},{"version":"7e49f52a159435fc8df4de9dc377ef5860732ca2dc9efec1640531d3cf5da7a3","impliedFormat":1},{"version":"dd4bde4bdc2e5394aed6855e98cf135dfdf5dd6468cad842e03116d31bbcc9bc","impliedFormat":1},{"version":"4d4e879009a84a47c05350b8dca823036ba3a29a3038efed1be76c9f81e45edf","affectsGlobalScope":true,"impliedFormat":1},{"version":"cf83d90d5faf27b994c2e79af02e32b555dbfe42cd9bd1571445f2168d1f4e2d","impliedFormat":1},{"version":"9ba13b47cb450a438e3076c4a3f6afb9dc85e17eae50f26d4b2d72c0688c9251","impliedFormat":1},{"version":"b64cd4401633ea4ecadfd700ddc8323a13b63b106ac7127c1d2726f32424622c","impliedFormat":1},{"version":"37c6e5fe5715814412b43cc9b50b24c67a63c4e04e753e0d1305970d65417a60","impliedFormat":1},{"version":"0e28335ac43f4d94dd2fe6d9e6fa6813570640839addd10d309d7985f33a6308","impliedFormat":1},{"version":"ee0e4946247f842c6dd483cbb60a5e6b484fee07996e3a7bc7343dfb68a04c5d","impliedFormat":1},{"version":"ef051f42b7e0ef5ca04552f54c4552eac84099d64b6c5ad0ef4033574b6035b8","impliedFormat":1},{"version":"853a43154f1d01b0173d9cbd74063507ece57170bad7a3b68f3fa1229ad0a92f","impliedFormat":1},{"version":"56231e3c39a031bfb0afb797690b20ed4537670c93c0318b72d5180833d98b72","impliedFormat":1},{"version":"5cc7c39031bfd8b00ad58f32143d59eb6ffc24f5d41a20931269011dccd36c5e","impliedFormat":1},{"version":"b0b69c61b0f0ec8ca15db4c8c41f6e77f4cacb784d42bca948f42dea33e8757e","affectsGlobalScope":true,"impliedFormat":1},{"version":"961cf7535b9c521cd634055b1b6ac49b94d055f0b573ce7fdc4cfaddab080b7c","impliedFormat":1},{"version":"806a8c6daae69e5695e7200d9eca6bc1e4298f38d90edda3ce67a794da31a24f","impliedFormat":1},{"version":"ac86245c2f31335bfd52cbe7fc760f9fc4f165387875869a478a6d9616a95e72","impliedFormat":1},{"version":"01ff95aa1443e3f7248974e5a771f513cb2ac158c8898f470a1792f817bee497","impliedFormat":1},{"version":"9d96a7ce809392ff2cb99691acf7c62e632fe56897356ba013b689277aca3619","impliedFormat":1},{"version":"42a05d8f239f74587d4926aba8cc54792eed8e8a442c7adc9b38b516642aadfe","impliedFormat":1},{"version":"5d21b58d60383cc6ab9ad3d3e265d7d25af24a2c9b506247e0e50b0a884920be","impliedFormat":1},{"version":"101f482fd48cb4c7c0468dcc6d62c843d842977aea6235644b1edd05e81fbf22","impliedFormat":1},{"version":"ae6757460f37078884b1571a3de3ebaf724d827d7e1d53626c02b3c2a408ac63","affectsGlobalScope":true,"impliedFormat":1},{"version":"27c0a08e343c6a0ae17bd13ba6d44a9758236dc904cd5e4b43456996cd51f520","impliedFormat":1},{"version":"3ef397f12387eff17f550bc484ea7c27d21d43816bbe609d495107f44b97e933","impliedFormat":1},{"version":"1023282e2ba810bc07905d3668349fbd37a26411f0c8f94a70ef3c05fe523fcf","impliedFormat":1},{"version":"b214ebcf76c51b115453f69729ee8aa7b7f8eccdae2a922b568a45c2d7ff52f7","impliedFormat":1},{"version":"429c9cdfa7d126255779efd7e6d9057ced2d69c81859bbab32073bad52e9ba76","impliedFormat":1},{"version":"6f80e51ba310608cd71bcdc09a171d7bbfb3b316048601c9ec215ce16a8dcfbc","impliedFormat":1},{"version":"a3bdc774995d56caaac759a424831091bb22450ca3590f34dae53d98323be191","affectsGlobalScope":true,"impliedFormat":1},{"version":"7f2c62938251b45715fd2a9887060ec4fbc8724727029d1cbce373747252bdd7","impliedFormat":1},{"version":"e3ace08b6bbd84655d41e244677b474fd995923ffef7149ddb68af8848b60b05","impliedFormat":1},{"version":"132580b0e86c48fab152bab850fc57a4b74fe915c8958d2ccb052b809a44b61c","impliedFormat":1},{"version":"af4ab0aa8908fc9a655bb833d3bc28e117c4f0e1038c5a891546158beb25accb","impliedFormat":1},{"version":"69c9a5a9392e8564bd81116e1ed93b13205201fb44cb35a7fde8c9f9e21c4b23","impliedFormat":1},{"version":"5f8fc37f8434691ffac1bfd8fc2634647da2c0e84253ab5d2dd19a7718915b35","impliedFormat":1},{"version":"5981c2340fd8b076cae8efbae818d42c11ffc615994cb060b1cd390795f1be2b","impliedFormat":1},{"version":"2ca2bca6845a7234eff5c3d192727a068fca72ac565f3c819c6b04ccc83dadc0","impliedFormat":1},{"version":"ed4f674fc8c0c993cc7e145069ac44129e03519b910c62be206a0cc777bdc60b","affectsGlobalScope":true,"impliedFormat":1},{"version":"0250da3eb85c99624f974e77ef355cdf86f43980251bc371475c2b397ba55bcd","impliedFormat":1},{"version":"f1c93e046fb3d9b7f8249629f4b63dc068dd839b824dd0aa39a5e68476dc9420","impliedFormat":1},{"version":"3d3a5f27ffbc06c885dd4d5f9ee20de61faf877fe2c3a7051c4825903d9a7fdc","impliedFormat":1},{"version":"12806f9f085598ef930edaf2467a5fa1789a878fba077cd27e85dc5851e11834","impliedFormat":1},{"version":"17d06eb5709839c7ce719f0c38ada6f308fb433f2cd6d8c87b35856e07400950","impliedFormat":1},{"version":"a43fe41c33d0a192a0ecaf9b92e87bef3709c9972e6d53c42c49251ccb962d69","impliedFormat":1},{"version":"a177959203c017fad3ecc4f3d96c8757a840957a4959a3ae00dab9d35961ca6c","affectsGlobalScope":true,"impliedFormat":1},{"version":"6fc727ccf9b36e257ff982ea0badeffbfc2c151802f741bddff00c6af3b784cf","impliedFormat":1},{"version":"6c00f77f0335ae0c18bd45a6c7c9c97c9625fb7e5dd6d5936eadf70718bce52e","impliedFormat":1},{"version":"4844a4c9b4b1e812b257676ed8a80b3f3be0e29bf05e742cc2ea9c3c6865e6c6","impliedFormat":1},{"version":"064878a60367e0407c42fb7ba02a2ea4d83257357dc20088e549bd4d89433e9c","impliedFormat":1},{"version":"14d4bd22d1b05824971b98f7e91b2484c90f1a684805c330476641417c3d9735","impliedFormat":1},{"version":"586eaf66bace2e731cee0ddfbfac326ad74a83c1acfeac4afb2db85ad23226c7","impliedFormat":1},{"version":"b484ec11ba00e3a2235562a41898d55372ccabe607986c6fa4f4aba72093749f","impliedFormat":1},{"version":"d1a14d87cedcf4f0b8173720d6eb29cc02878bf2b6dabf9c9d9cee742f275368","impliedFormat":1},{"version":"e60efae9fe48a2955f66bf4cbf0f082516185b877daf50d9c5e2a009660a7714","impliedFormat":1},{"version":"041a7781b9127ab568d2cdcce62c58fdea7c7407f40b8c50045d7866a2727130","impliedFormat":1},{"version":"cd9189eacf0f9143b8830e9d6769335aa6d902c04195f04145bcbf19e7f26fcb","impliedFormat":1},{"version":"e1cb68f3ef3a8dd7b2a9dfb3de482ed6c0f1586ba0db4e7d73c1d2147b6ffc51","impliedFormat":1},{"version":"55cdbeebe76a1fa18bbd7e7bf73350a2173926bd3085bb050cf5a5397025ee4e","impliedFormat":1},{"version":"151ff381ef9ff8da2da9b9663ebf657eac35c4c9a19183420c05728f31a6761d","impliedFormat":1},{"version":"67f0933742a1e547fc31cc52c4183b2be0726ffa9689586b761cef241ca6b251","affectsGlobalScope":true,"impliedFormat":1},{"version":"a660aa95476042d3fdcc1343cf6bb8fdf24772d31712b1db321c5a4dcc325434","impliedFormat":1},{"version":"282f98006ed7fa9bb2cd9bdbe2524595cfc4bcd58a0bb3232e4519f2138df811","impliedFormat":1},{"version":"6222e987b58abfe92597e1273ad7233626285bc2d78409d4a7b113d81a83496b","impliedFormat":1},{"version":"cbe726263ae9a7bf32352380f7e8ab66ee25b3457137e316929269c19e18a2be","impliedFormat":1},{"version":"8b96046bf5fb0a815cba6b0880d9f97b7f3a93cf187e8dcfe8e2792e97f38f87","impliedFormat":99},{"version":"bacf2c84cf448b2cd02c717ad46c3d7fd530e0c91282888c923ad64810a4d511","affectsGlobalScope":true,"impliedFormat":1},{"version":"402e5c534fb2b85fa771170595db3ac0dd532112c8fa44fc23f233bc6967488b","impliedFormat":1},{"version":"8885cf05f3e2abf117590bbb951dcf6359e3e5ac462af1c901cfd24c6a6472e2","impliedFormat":1},{"version":"333caa2bfff7f06017f114de738050dd99a765c7eb16571c6d25a38c0d5365dc","impliedFormat":1},{"version":"e61df3640a38d535fd4bc9f4a53aef17c296b58dc4b6394fd576b808dd2fe5e6","impliedFormat":1},{"version":"459920181700cec8cbdf2a5faca127f3f17fd8dd9d9e577ed3f5f3af5d12a2e4","impliedFormat":1},{"version":"4719c209b9c00b579553859407a7e5dcfaa1c472994bd62aa5dd3cc0757eb077","impliedFormat":1},{"version":"7ec359bbc29b69d4063fe7dad0baaf35f1856f914db16b3f4f6e3e1bca4099fa","impliedFormat":1},{"version":"70790a7f0040993ca66ab8a07a059a0f8256e7bb57d968ae945f696cbff4ac7a","impliedFormat":1},{"version":"d1b9a81e99a0050ca7f2d98d7eedc6cda768f0eb9fa90b602e7107433e64c04c","impliedFormat":1},{"version":"a022503e75d6953d0e82c2c564508a5c7f8556fad5d7f971372d2d40479e4034","impliedFormat":1},{"version":"b215c4f0096f108020f666ffcc1f072c81e9f2f95464e894a5d5f34c5ea2a8b1","impliedFormat":1},{"version":"644491cde678bd462bb922c1d0cfab8f17d626b195ccb7f008612dc31f445d2d","impliedFormat":1},{"version":"dfe54dab1fa4961a6bcfba68c4ca955f8b5bbeb5f2ab3c915aa7adaa2eabc03a","impliedFormat":1},{"version":"1251d53755b03cde02466064260bb88fd83c30006a46395b7d9167340bc59b73","impliedFormat":1},{"version":"47865c5e695a382a916b1eedda1b6523145426e48a2eae4647e96b3b5e52024f","impliedFormat":1},{"version":"4cdf27e29feae6c7826cdd5c91751cc35559125e8304f9e7aed8faef97dcf572","impliedFormat":1},{"version":"331b8f71bfae1df25d564f5ea9ee65a0d847c4a94baa45925b6f38c55c7039bf","impliedFormat":1},{"version":"2a771d907aebf9391ac1f50e4ad37952943515eeea0dcc7e78aa08f508294668","impliedFormat":1},{"version":"0146fd6262c3fd3da51cb0254bb6b9a4e42931eb2f56329edd4c199cb9aaf804","impliedFormat":1},{"version":"183f480885db5caa5a8acb833c2be04f98056bdcc5fb29e969ff86e07efe57ab","impliedFormat":99},{"version":"82e687ebd99518bc63ea04b0c3810fb6e50aa6942decd0ca6f7a56d9b9a212a6","impliedFormat":99},{"version":"7f698624bbbb060ece7c0e51b7236520ebada74b747d7523c7df376453ed6fea","impliedFormat":1},{"version":"8f07f2b6514744ac96e51d7cb8518c0f4de319471237ea10cf688b8d0e9d0225","impliedFormat":1},{"version":"257b83faa134d971c738a6b9e4c47e59bb7b23274719d92197580dd662bfafc3","impliedFormat":99},{"version":"a28ac3e717907284b3910b8e9b3f9844a4e0b0a861bea7b923e5adf90f620330","impliedFormat":1},{"version":"b6d03c9cfe2cf0ba4c673c209fcd7c46c815b2619fd2aad59fc4229aaef2ed43","impliedFormat":1},{"version":"82e5a50e17833a10eb091923b7e429dc846d42f1c6161eb6beeb964288d98a15","impliedFormat":1},{"version":"670a76db379b27c8ff42f1ba927828a22862e2ab0b0908e38b671f0e912cc5ed","impliedFormat":1},{"version":"13b77ab19ef7aadd86a1e54f2f08ea23a6d74e102909e3c00d31f231ed040f62","impliedFormat":1},{"version":"069bebfee29864e3955378107e243508b163e77ab10de6a5ee03ae06939f0bb9","impliedFormat":1},{"version":"26e0ffceb2198feb1ef460d5d14111c69ad07d44c5a67fd4bfeb74c969aa9afb","impliedFormat":99},"194cb67dbc7f814a309ba9436ef1e27c2d1e1a6a0e988a0458d4126f7d10de10"],"root":[212],"options":{"allowSyntheticDefaultImports":true,"composite":true,"module":99,"skipLibCheck":true},"referencedMap":[[207,1],[205,2],[210,3],[206,1],[208,4],[209,1],[173,2],[113,5],[114,5],[115,6],[54,7],[116,8],[117,9],[118,10],[52,2],[119,11],[120,12],[121,13],[122,14],[123,15],[124,16],[125,16],[126,17],[127,18],[128,19],[129,20],[55,2],[53,2],[130,21],[131,22],[132,23],[172,24],[133,25],[134,26],[135,25],[136,27],[137,28],[138,29],[139,30],[140,30],[141,30],[142,31],[143,32],[144,33],[145,34],[146,35],[147,36],[148,36],[149,37],[150,2],[151,2],[152,38],[153,39],[154,38],[155,40],[156,41],[157,42],[158,43],[159,44],[160,45],[161,46],[162,47],[163,48],[164,49],[165,50],[166,51],[167,52],[168,53],[169,54],[56,25],[57,2],[58,55],[59,56],[60,2],[61,57],[62,2],[104,58],[105,59],[106,60],[107,60],[108,61],[109,2],[110,8],[111,62],[112,59],[170,63],[171,64],[211,65],[180,2],[197,66],[195,67],[196,68],[184,69],[185,67],[192,70],[183,71],[188,72],[198,2],[189,73],[194,74],[200,75],[199,76],[182,77],[190,78],[191,79],[186,80],[193,66],[187,81],[175,82],[174,83],[181,2],[1,2],[50,2],[51,2],[9,2],[13,2],[12,2],[3,2],[14,2],[15,2],[16,2],[17,2],[18,2],[19,2],[20,2],[21,2],[4,2],[22,2],[23,2],[5,2],[24,2],[28,2],[25,2],[26,2],[27,2],[29,2],[30,2],[31,2],[6,2],[32,2],[33,2],[34,2],[35,2],[7,2],[39,2],[36,2],[37,2],[38,2],[40,2],[8,2],[41,2],[46,2],[47,2],[42,2],[43,2],[44,2],[45,2],[2,2],[48,2],[49,2],[11,2],[10,2],[80,84],[92,85],[78,86],[93,87],[102,88],[69,89],[70,90],[68,91],[101,92],[96,93],[100,94],[72,95],[89,96],[71,97],[99,98],[66,99],[67,93],[73,100],[74,2],[79,101],[77,100],[64,102],[103,103],[94,104],[83,105],[82,100],[84,106],[87,107],[81,108],[85,109],[97,92],[75,110],[76,111],[88,112],[65,87],[91,113],[90,100],[86,114],[95,2],[63,2],[98,115],[204,116],[201,117],[179,118],[177,119],[176,2],[178,120],[202,2],[203,121],[212,122]],"affectedFilesPendingEmit":[[212,17]],"emitSignatures":[212],"version":"5.9.3"}
\ No newline at end of file
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 0000000..f56ee3c
--- /dev/null
+++ b/vite.config.ts
@@ -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),
+ },
+ }
+})
+