commit 35e4f8f677c5c6c75f62cec4784252c297b72dcf Author: Your Name Date: Fri Jan 2 12:06:09 2026 +0800 初始提交:DesignHub设计师任务管理系统 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e2fd6ba --- /dev/null +++ b/.gitignore @@ -0,0 +1,68 @@ +# Dependencies +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# Build outputs +dist/ +dist-ssr/ +build/ +target/ +*.jar +*.war +*.ear + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ +.DS_Store + +# Environment files +.env +.env.local +.env.*.local +*.log + +# Database +*.sql~ +*.db + +# OS +Thumbs.db +ehthumbs.db + +# Java +*.class +*.log +*.ctxt +.mtj.tmp/ +hs_err_pid* + +# Maven +target/ +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +pom.xml.next +release.properties +dependency-reduced-pom.xml +buildNumber.properties +.mvn/timing.properties +.mvn/wrapper/maven-wrapper.jar + +# Spring Boot +.springBeans +.sts4-cache/ + +# Redis +dump.rdb + +# Logs +logs/ +*.log + diff --git a/DEPLOYMENT_GUIDE.md b/DEPLOYMENT_GUIDE.md new file mode 100644 index 0000000..17e16a8 --- /dev/null +++ b/DEPLOYMENT_GUIDE.md @@ -0,0 +1,850 @@ +# 🚀 DesignHub 部署指南 + +本文档详细说明如何在本地开发环境和生产环境中部署 DesignHub 系统。 + +--- + +## 📋 目录 + +1. [环境要求](#环境要求) +2. [本地开发环境部署](#本地开发环境部署) +3. [生产环境部署](#生产环境部署) +4. [Docker部署](#docker部署) +5. [常见问题](#常见问题) +6. [维护与监控](#维护与监控) + +--- + +## 🔧 环境要求 + +### 开发环境 +| 组件 | 版本要求 | 说明 | +|------|---------|------| +| JDK | 17+ | 建议使用 OpenJDK 17 | +| Node.js | 18+ | 建议使用 LTS 版本 | +| MySQL | 8.0+ | 必须 | +| Redis | 6.0+ | 建议 7.0+ | +| Maven | 3.6+ | 后端构建工具 | +| pnpm | 8.0+ | 前端包管理器(可选npm/yarn) | + +### 生产环境 +| 组件 | 版本要求 | 配置建议 | +|------|---------|---------| +| 服务器 | Linux | CentOS 7+, Ubuntu 20.04+ | +| CPU | 4核+ | 推荐 8核 | +| 内存 | 8GB+ | 推荐 16GB | +| 磁盘 | 50GB+ | SSD推荐 | +| 带宽 | 10Mbps+ | 推荐 100Mbps | + +--- + +## 💻 本地开发环境部署 + +### 1. 克隆项目 + +```bash +# 克隆代码仓库 +cd /Users/lanmu/Desktop/qiangdanxitong +# 或使用 Git +# git clone +``` + +### 2. 配置数据库 + +#### 2.1 安装 MySQL 8.0 + +**macOS**: +```bash +brew install mysql@8.0 +brew services start mysql@8.0 +``` + +**Linux**: +```bash +sudo apt update +sudo apt install mysql-server +sudo systemctl start mysql +sudo systemctl enable mysql +``` + +**Windows**: +- 下载MySQL 8.0安装包 +- 运行安装向导 + +#### 2.2 创建数据库 + +```sql +-- 登录MySQL +mysql -u root -p + +-- 创建数据库 +CREATE DATABASE design_hub CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- 创建用户(可选) +CREATE USER 'designhub'@'localhost' IDENTIFIED BY 'your_password'; +GRANT ALL PRIVILEGES ON design_hub.* TO 'designhub'@'localhost'; +FLUSH PRIVILEGES; +``` + +#### 2.3 导入数据库表 + +```bash +# 执行数据库脚本(如果有SQL文件) +mysql -u root -p design_hub < database/schema.sql +``` + +**注意**:项目使用MyBatis Plus,实体类会自动映射到数据库表。确保在启动前,数据库连接配置正确。 + +### 3. 配置 Redis + +#### 3.1 安装 Redis + +**macOS**: +```bash +brew install redis +brew services start redis +``` + +**Linux**: +```bash +sudo apt install redis-server +sudo systemctl start redis +sudo systemctl enable redis +``` + +**Windows**: +- 下载Redis for Windows +- 解压并运行 `redis-server.exe` + +#### 3.2 验证Redis +```bash +redis-cli ping +# 应返回:PONG +``` + +### 4. 配置后端 + +#### 4.1 编辑配置文件 + +编辑 `/backend/src/main/resources/application.yml`: + +```yaml +spring: + datasource: + url: jdbc:mysql://localhost:3306/design_hub?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai + username: root # 修改为你的MySQL用户名 + password: your_password # 修改为你的MySQL密码 + driver-class-name: com.mysql.cj.jdbc.Driver + + redis: + host: localhost + port: 6379 + password: # 如果Redis没有密码,留空 + database: 0 + timeout: 3000 + +jwt: + secret: your-secret-key-change-this-in-production # 生产环境请修改 + access-token-expiration: 7200000 # 2小时 + refresh-token-expiration: 604800000 # 7天 + +server: + port: 8080 +``` + +#### 4.2 构建并运行后端 + +```bash +cd backend + +# 使用 Maven 构建 +mvn clean install -DskipTests + +# 运行后端 +mvn spring-boot:run + +# 或者使用 JAR 文件 +java -jar target/designhub-backend-1.0.0.jar +``` + +**后端启动成功标志**: +- 控制台输出 `Started DesignHubApplication` +- 访问 http://localhost:8080/swagger-ui.html 查看API文档 + +### 5. 配置前端 + +#### 5.1 安装依赖 + +```bash +cd frontend + +# 使用 pnpm(推荐) +pnpm install + +# 或使用 npm +npm install + +# 或使用 yarn +yarn install +``` + +#### 5.2 配置环境变量 + +创建 `/frontend/.env.development`: + +```env +# 开发环境配置 +VITE_APP_BASE_API=http://localhost:8080/api +VITE_APP_TITLE=DesignHub +VITE_APP_VERSION=6.0 +``` + +#### 5.3 运行前端 + +```bash +# 开发模式 +pnpm dev + +# 或 +npm run dev +``` + +**前端启动成功标志**: +- 控制台输出本地访问地址(默认:http://localhost:5173) +- 浏览器自动打开页面 + +### 6. 访问系统 + +打开浏览器,访问 `http://localhost:5173` + +**默认测试账号**: +- 用户名:`admin` +- 密码:`123456` + +--- + +## 🌐 生产环境部署 + +### 1. 服务器准备 + +#### 1.1 更新系统 +```bash +# CentOS/RHEL +sudo yum update -y + +# Ubuntu/Debian +sudo apt update && sudo apt upgrade -y +``` + +#### 1.2 安装必要软件 +```bash +# 安装 JDK 17 +sudo yum install java-17-openjdk -y + +# 安装 Nginx +sudo yum install nginx -y + +# 安装 Git(如需) +sudo yum install git -y +``` + +### 2. 部署数据库 + +#### 2.1 安装 MySQL 8.0 + +```bash +# 下载MySQL Yum Repository +wget https://dev.mysql.com/get/mysql80-community-release-el7-3.noarch.rpm + +# 安装Repository +sudo rpm -ivh mysql80-community-release-el7-3.noarch.rpm + +# 安装MySQL +sudo yum install mysql-server -y + +# 启动MySQL +sudo systemctl start mysqld +sudo systemctl enable mysqld + +# 获取临时密码 +sudo grep 'temporary password' /var/log/mysqld.log + +# 修改root密码 +mysql_secure_installation +``` + +#### 2.2 创建数据库和用户 + +```sql +CREATE DATABASE design_hub CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +CREATE USER 'designhub'@'%' IDENTIFIED BY 'StrongPassword123!'; +GRANT ALL PRIVILEGES ON design_hub.* TO 'designhub'@'%'; +FLUSH PRIVILEGES; +``` + +#### 2.3 导入数据 + +```bash +mysql -u designhub -p design_hub < /path/to/schema.sql +``` + +### 3. 部署 Redis + +```bash +# 安装Redis +sudo yum install redis -y + +# 配置Redis(可选设置密码) +sudo vi /etc/redis.conf +# 找到 requirepass,设置密码 +requirepass YourStrongPassword + +# 启动Redis +sudo systemctl start redis +sudo systemctl enable redis +``` + +### 4. 部署后端 + +#### 4.1 上传代码 + +```bash +# 创建应用目录 +sudo mkdir -p /opt/designhub/backend +sudo chown -R $USER:$USER /opt/designhub + +# 上传代码(使用scp、rsync或git) +# scp方式 +scp -r backend/* user@server:/opt/designhub/backend/ + +# 或使用git +cd /opt/designhub +git clone +``` + +#### 4.2 配置生产环境 + +编辑 `/opt/designhub/backend/src/main/resources/application-prod.yml`: + +```yaml +spring: + datasource: + url: jdbc:mysql://localhost:3306/design_hub?useSSL=true + username: designhub + password: StrongPassword123! + + redis: + host: localhost + port: 6379 + password: YourStrongPassword + +jwt: + secret: change-this-to-a-very-strong-secret-key-in-production + +logging: + level: + root: INFO + com.designhub: INFO + file: + name: /opt/designhub/logs/application.log +``` + +#### 4.3 构建后端 + +```bash +cd /opt/designhub/backend + +# 构建 +mvn clean package -DskipTests -Pprod + +# 移动JAR文件 +mv target/designhub-backend-1.0.0.jar /opt/designhub/ +``` + +#### 4.4 创建系统服务 + +创建 `/etc/systemd/system/designhub-backend.service`: + +```ini +[Unit] +Description=DesignHub Backend Service +After=network.target + +[Service] +Type=simple +User=designhub +WorkingDirectory=/opt/designhub +ExecStart=/usr/bin/java -jar /opt/designhub/designhub-backend-1.0.0.jar --spring.profiles.active=prod +Restart=on-failure +RestartSec=10 + +[Install] +WantedBy=multi-user.target +``` + +#### 4.5 启动后端服务 + +```bash +# 重新加载systemd +sudo systemctl daemon-reload + +# 启动服务 +sudo systemctl start designhub-backend + +# 设置开机自启 +sudo systemctl enable designhub-backend + +# 查看状态 +sudo systemctl status designhub-backend + +# 查看日志 +sudo journalctl -u designhub-backend -f +``` + +### 5. 部署前端 + +#### 5.1 构建前端 + +```bash +cd /opt/designhub/frontend + +# 安装依赖 +pnpm install --production + +# 创建生产环境配置 +cat > .env.production << EOF +VITE_APP_BASE_API=https://api.yourdomain.com/api +VITE_APP_TITLE=DesignHub +VITE_APP_VERSION=6.0 +EOF + +# 构建 +pnpm build + +# 构建产物在 dist/ 目录 +``` + +#### 5.2 配置 Nginx + +创建 `/etc/nginx/conf.d/designhub.conf`: + +```nginx +server { + listen 80; + server_name yourdomain.com www.yourdomain.com; + + # 前端静态资源 + root /opt/designhub/frontend/dist; + index index.html; + + # 前端路由 + location / { + try_files $uri $uri/ /index.html; + } + + # API代理 + location /api/ { + proxy_pass http://localhost:8080/api/; + proxy_set_header Host $host; + 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; + + # WebSocket支持(如需) + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + + # Gzip压缩 + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; + + # 静态资源缓存 + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ { + expires 30d; + add_header Cache-Control "public, immutable"; + } +} +``` + +#### 5.3 配置 HTTPS (Let's Encrypt) + +```bash +# 安装certbot +sudo yum install certbot python3-certbot-nginx -y + +# 获取SSL证书 +sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com + +# 自动续期测试 +sudo certbot renew --dry-run +``` + +#### 5.4 启动 Nginx + +```bash +# 测试配置 +sudo nginx -t + +# 启动Nginx +sudo systemctl start nginx +sudo systemctl enable nginx + +# 重新加载配置 +sudo systemctl reload nginx +``` + +### 6. 防火墙配置 + +```bash +# CentOS/RHEL (firewalld) +sudo firewall-cmd --permanent --add-service=http +sudo firewall-cmd --permanent --add-service=https +sudo firewall-cmd --reload + +# Ubuntu (ufw) +sudo ufw allow 80/tcp +sudo ufw allow 443/tcp +sudo ufw enable +``` + +--- + +## 🐳 Docker 部署 + +### 1. 后端 Dockerfile + +创建 `/backend/Dockerfile`: + +```dockerfile +FROM openjdk:17-slim + +WORKDIR /app + +# 复制JAR文件 +COPY target/designhub-backend-1.0.0.jar app.jar + +# 设置时区 +ENV TZ=Asia/Shanghai +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone + +# 暴露端口 +EXPOSE 8080 + +# 启动命令 +ENTRYPOINT ["java", "-jar", "app.jar", "--spring.profiles.active=prod"] +``` + +### 2. 前端 Dockerfile + +创建 `/frontend/Dockerfile`: + +```dockerfile +FROM node:18-alpine AS builder + +WORKDIR /app + +# 复制package文件 +COPY package*.json ./ +COPY pnpm-lock.yaml ./ + +# 安装依赖 +RUN npm install -g pnpm +RUN pnpm install + +# 复制源代码 +COPY . . + +# 构建 +RUN pnpm build + +# 生产环境 +FROM nginx:alpine + +# 复制构建产物 +COPY --from=builder /app/dist /usr/share/nginx/html + +# 复制Nginx配置 +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] +``` + +### 3. Docker Compose + +创建 `/docker-compose.yml`: + +```yaml +version: '3.8' + +services: + mysql: + image: mysql:8.0 + container_name: designhub-mysql + environment: + MYSQL_ROOT_PASSWORD: rootpassword + MYSQL_DATABASE: design_hub + MYSQL_USER: designhub + MYSQL_PASSWORD: designhubpass + ports: + - "3306:3306" + volumes: + - mysql_data:/var/lib/mysql + networks: + - designhub-network + + redis: + image: redis:7-alpine + container_name: designhub-redis + command: redis-server --requirepass redispassword + ports: + - "6379:6379" + volumes: + - redis_data:/data + networks: + - designhub-network + + backend: + build: ./backend + container_name: designhub-backend + environment: + SPRING_PROFILES_ACTIVE: prod + SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/design_hub + SPRING_DATASOURCE_USERNAME: designhub + SPRING_DATASOURCE_PASSWORD: designhubpass + SPRING_REDIS_HOST: redis + SPRING_REDIS_PASSWORD: redispassword + ports: + - "8080:8080" + depends_on: + - mysql + - redis + networks: + - designhub-network + + frontend: + build: ./frontend + container_name: designhub-frontend + ports: + - "80:80" + depends_on: + - backend + networks: + - designhub-network + +volumes: + mysql_data: + redis_data: + +networks: + designhub-network: + driver: bridge +``` + +### 4. 启动 Docker Compose + +```bash +# 构建并启动所有服务 +docker-compose up -d + +# 查看日志 +docker-compose logs -f + +# 停止所有服务 +docker-compose down + +# 重启服务 +docker-compose restart +``` + +--- + +## ❓ 常见问题 + +### 1. 后端无法连接数据库 + +**问题**:`java.sql.SQLException: Access denied for user` + +**解决**: +```sql +-- 检查用户权限 +SELECT User, Host FROM mysql.user; + +-- 重新授权 +GRANT ALL PRIVILEGES ON design_hub.* TO 'designhub'@'%' IDENTIFIED BY 'password'; +FLUSH PRIVILEGES; +``` + +### 2. Redis连接失败 + +**问题**:`Could not connect to Redis at localhost:6379` + +**解决**: +```bash +# 检查Redis是否运行 +sudo systemctl status redis + +# 检查端口 +netstat -tuln | grep 6379 + +# 测试连接 +redis-cli -h localhost -p 6379 -a password ping +``` + +### 3. 前端API请求跨域 + +**问题**:`CORS policy: No 'Access-Control-Allow-Origin' header` + +**解决**:后端添加CORS配置(已在项目中配置) + +### 4. JWT Token过期 + +**问题**:频繁退出登录 + +**解决**:调整Token过期时间或实现自动刷新机制(已实现) + +### 5. 文件上传失败 + +**问题**:上传大文件失败 + +**解决**: +```yaml +# application.yml +spring: + servlet: + multipart: + max-file-size: 100MB + max-request-size: 100MB +``` + +--- + +## 🛠️ 维护与监控 + +### 1. 日志管理 + +#### 后端日志 +```bash +# 查看实时日志 +tail -f /opt/designhub/logs/application.log + +# 按日期查看 +less /opt/designhub/logs/application-2025-11-07.log + +# 日志清理(保留30天) +find /opt/designhub/logs -name "*.log" -mtime +30 -delete +``` + +#### Nginx日志 +```bash +# 访问日志 +tail -f /var/log/nginx/access.log + +# 错误日志 +tail -f /var/log/nginx/error.log +``` + +### 2. 数据库备份 + +#### 自动备份脚本 + +创建 `/opt/designhub/scripts/backup_db.sh`: + +```bash +#!/bin/bash + +# 配置 +DB_NAME="design_hub" +DB_USER="designhub" +DB_PASS="password" +BACKUP_DIR="/opt/designhub/backups" +DATE=$(date +%Y%m%d_%H%M%S) + +# 创建备份目录 +mkdir -p $BACKUP_DIR + +# 备份数据库 +mysqldump -u$DB_USER -p$DB_PASS $DB_NAME > $BACKUP_DIR/backup_$DATE.sql + +# 压缩备份 +gzip $BACKUP_DIR/backup_$DATE.sql + +# 删除7天前的备份 +find $BACKUP_DIR -name "backup_*.sql.gz" -mtime +7 -delete + +echo "Backup completed: backup_$DATE.sql.gz" +``` + +#### 设置定时任务 + +```bash +# 编辑crontab +crontab -e + +# 每天凌晨2点备份 +0 2 * * * /opt/designhub/scripts/backup_db.sh +``` + +### 3. 性能监控 + +#### 系统资源监控 +```bash +# CPU和内存 +top + +# 磁盘使用 +df -h + +# 网络连接 +netstat -tuln + +# 进程监控 +ps aux | grep java +``` + +#### 应用监控 + +**Spring Boot Actuator**(已集成): +- 访问 http://yourdomain.com/actuator/health +- 访问 http://yourdomain.com/actuator/metrics + +### 4. 安全加固 + +#### 系统安全 +```bash +# 关闭不必要的服务 +sudo systemctl disable bluetooth +sudo systemctl disable cups + +# 更新安全补丁 +sudo yum update --security -y + +# 配置fail2ban +sudo yum install fail2ban -y +sudo systemctl enable fail2ban +sudo systemctl start fail2ban +``` + +#### 应用安全 +- 定期更新依赖版本 +- 使用强密码 +- 启用HTTPS +- 配置防火墙 +- 定期安全审计 + +--- + +## 📞 技术支持 + +如遇到部署问题,请: +1. 查看本文档"常见问题"部分 +2. 检查日志文件 +3. 联系技术支持团队 + +--- + +**祝您部署顺利!** 🎉 + diff --git a/DEVELOPMENT_GUIDE.md b/DEVELOPMENT_GUIDE.md new file mode 100644 index 0000000..8290836 --- /dev/null +++ b/DEVELOPMENT_GUIDE.md @@ -0,0 +1,418 @@ +# DesignHub 开发启动指南 + +## 📋 目录 + +- [环境准备](#环境准备) +- [项目初始化](#项目初始化) +- [数据库配置](#数据库配置) +- [后端启动](#后端启动) +- [前端启动](#前端启动) +- [开发规范](#开发规范) +- [常见问题](#常见问题) + +## 🛠️ 环境准备 + +### 必备软件 + +确保您的开发环境已安装以下软件: + +| 软件 | 版本要求 | 说明 | +|------|---------|------| +| Java | >= 17 | 后端运行环境 | +| Node.js | >= 18.0 | 前端运行环境 | +| MySQL | >= 8.0 | 数据库 | +| Redis | >= 7.0 | 缓存服务(可选,开发阶段可不启用) | +| Maven | >= 3.8 | Java构建工具 | +| Git | 最新版本 | 版本控制 | + +### 推荐开发工具 + +- **后端开发**: IntelliJ IDEA / Eclipse +- **前端开发**: VS Code +- **数据库管理**: Navicat / DBeaver / MySQL Workbench +- **API测试**: Postman / Apifox +- **Redis管理**: RedisInsight / Another Redis Desktop Manager + +## 🚀 项目初始化 + +### 1. 克隆项目(如果从远程仓库) + +```bash +git clone +cd qiangdanxitong +``` + +### 2. 项目结构说明 + +``` +qiangdanxitong/ +├── frontend/ # 前端项目(Vue 3) +├── backend/ # 后端项目(Spring Boot) +├── database/ # 数据库脚本 +├── docs/ # 文档 +├── PROJECT_REQUIREMENTS.md # 需求文档 +└── README.md # 项目说明 +``` + +## 💾 数据库配置 + +### 1. 创建数据库 + +使用 MySQL 客户端或管理工具执行以下命令: + +```bash +# 方式1:使用命令行 +mysql -u root -p < database/schema.sql +mysql -u root -p < database/init-data.sql + +# 方式2:使用 MySQL 客户端 +mysql -u root -p +source /Users/lanmu/Desktop/qiangdanxitong/database/schema.sql; +source /Users/lanmu/Desktop/qiangdanxitong/database/init-data.sql; +``` + +### 2. 验证数据库 + +```sql +-- 登录MySQL +mysql -u root -p + +-- 切换到designhub数据库 +USE designhub; + +-- 查看所有表 +SHOW TABLES; + +-- 查看部门数据 +SELECT * FROM sys_dept; + +-- 查看任务类型 +SELECT * FROM task_type; +``` + +### 3. 配置数据库连接 + +编辑 `backend/src/main/resources/application-dev.yml`: + +```yaml +spring: + datasource: + url: jdbc:mysql://localhost:3306/designhub?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai + username: root + password: 你的MySQL密码 +``` + +## 🔧 后端启动 + +### 方式1:使用IDE启动(推荐) + +1. 用 IntelliJ IDEA 打开 `backend` 目录 +2. 等待 Maven 自动下载依赖 +3. 找到 `DesignHubApplication.java` 主类 +4. 右键 -> Run 'DesignHubApplication' + +### 方式2:使用命令行启动 + +```bash +cd backend + +# 首次启动需要先编译 +mvn clean install + +# 启动应用 +mvn spring-boot:run + +# 或者直接运行jar包 +java -jar target/designhub-backend-1.0.0.jar +``` + +### 验证后端启动 + +启动成功后,访问以下地址: + +- **API文档**: http://localhost:8080/api/doc.html +- **健康检查**: http://localhost:8080/api/actuator/health(如果配置了actuator) + +控制台应该输出: + +``` +╔═══════════════════════════════════════════════════════╗ +║ ║ +║ 🎨 DesignHub Backend Service Started 🎨 ║ +║ ║ +║ Port: 8080 ║ +║ Swagger UI: http://localhost:8080/api/doc.html ║ +║ Version: 1.0.0 ║ +║ ║ +╚═══════════════════════════════════════════════════════╝ +``` + +## 🎨 前端启动 + +### 1. 安装依赖 + +```bash +cd frontend + +# 使用npm +npm install + +# 或使用pnpm(推荐,更快) +pnpm install + +# 或使用yarn +yarn install +``` + +### 2. 启动开发服务器 + +```bash +# 使用npm +npm run dev + +# 使用pnpm +pnpm dev + +# 使用yarn +yarn dev +``` + +### 3. 访问前端 + +浏览器自动打开或手动访问: + +- **开发地址**: http://localhost:3000 + +控制台应该输出: + +``` +VITE v5.0.0 ready in 500 ms + +➜ Local: http://localhost:3000/ +➜ Network: use --host to expose +➜ press h + enter to show help +``` + +## 📝 开发规范 + +### Git提交规范 + +使用 Conventional Commits 规范: + +```bash +# 功能开发 +git commit -m "feat: 添加任务大厅页面" + +# Bug修复 +git commit -m "fix: 修复任务列表显示问题" + +# 文档更新 +git commit -m "docs: 更新README文档" + +# 样式调整 +git commit -m "style: 调整按钮样式" + +# 代码重构 +git commit -m "refactor: 重构任务服务层代码" + +# 性能优化 +git commit -m "perf: 优化数据库查询性能" + +# 测试相关 +git commit -m "test: 添加任务模块单元测试" +``` + +### 分支策略 + +``` +main/master -> 生产环境分支 +develop -> 开发分支 +feature/* -> 功能分支 +hotfix/* -> 紧急修复分支 +release/* -> 发布分支 +``` + +### 代码风格 + +#### 前端(TypeScript/Vue) + +- 使用 ESLint + Prettier 格式化代码 +- 组件命名使用 PascalCase +- 文件命名使用 kebab-case +- 使用 Composition API + +```bash +# 格式化代码 +npm run format + +# 检查代码 +npm run lint +``` + +#### 后端(Java/Spring Boot) + +- 遵循 阿里巴巴 Java 开发手册 +- 类名使用 PascalCase +- 方法名使用 camelCase +- 常量使用 UPPER_SNAKE_CASE + +## 🔍 常见问题 + +### Q1: 后端启动失败,提示数据库连接错误 + +**A**: 检查以下几点: +1. MySQL 是否已启动 +2. 数据库密码是否正确配置在 `application-dev.yml` +3. 数据库 `designhub` 是否已创建 +4. 数据库端口是否为 3306 + +### Q2: 前端启动后页面空白 + +**A**: +1. 检查浏览器控制台是否有报错 +2. 确认后端API是否正常运行(访问 http://localhost:8080/api/doc.html) +3. 检查前端代理配置(vite.config.ts) + +### Q3: Maven 依赖下载缓慢 + +**A**: 配置国内Maven镜像(阿里云) + +编辑 `~/.m2/settings.xml`: + +```xml + + aliyun + central + Aliyun Maven Mirror + https://maven.aliyun.com/repository/public + +``` + +### Q4: npm 安装依赖失败 + +**A**: 切换npm镜像源 + +```bash +# 临时使用淘宝镜像 +npm install --registry=https://registry.npmmirror.com + +# 永久设置 +npm config set registry https://registry.npmmirror.com + +# 使用 nrm 管理镜像源(推荐) +npm install -g nrm +nrm use taobao +``` + +### Q5: 钉钉登录如何配置 + +**A**: +1. 登录钉钉开放平台 https://open.dingtalk.com +2. 创建应用,获取 AppKey 和 AppSecret +3. 配置到 `application.yml` 的 `dingtalk` 部分 +4. 配置回调地址和权限 + +```yaml +dingtalk: + app-key: your-app-key + app-secret: your-app-secret + agent-id: your-agent-id + corp-id: your-corp-id +``` + +### Q6: Redis 连接失败 + +**A**: 开发阶段可以暂时禁用 Redis + +在 `application-dev.yml` 中注释掉 Redis 配置,或确保 Redis 已启动: + +```bash +# 启动 Redis(Mac) +brew services start redis + +# 启动 Redis(Linux) +redis-server + +# 启动 Redis(Windows) +redis-server.exe + +# 测试连接 +redis-cli ping +# 应该返回 PONG +``` + +## 📚 下一步 + +### 开发优先级 + +1. ✅ **基础架构** - 已完成 + - 项目结构 + - 数据库设计 + - 前后端框架搭建 + +2. 🔄 **核心功能开发** - 进行中 + - [ ] 用户认证模块(Casdoor + 钉钉登录) + - [ ] 任务管理模块 + - [ ] 积分管理模块 + - [ ] 通知系统 + +3. ⏳ **进阶功能** - 待开发 + - [ ] 激励系统 + - [ ] 数据分析 + - [ ] 管理后台 + +### 推荐开发顺序 + +``` +1. 用户认证 → 2. 组织架构 → 3. 任务发起 → 4. 任务大厅 → +5. 我的任务 → 6. 设计师大厅 → 7. 积分系统 → 8. 数据分析 → +9. 激励系统 → 10. 系统配置 → 11. 通知系统 +``` + +## 🤝 团队协作 + +### 每日开发流程 + +1. **早上**:拉取最新代码 + ```bash + git checkout develop + git pull origin develop + ``` + +2. **开发**:创建功能分支 + ```bash + git checkout -b feature/task-hall + # 编码... + git add . + git commit -m "feat: 完成任务大厅页面" + ``` + +3. **提交**:推送到远程并创建PR + ```bash + git push origin feature/task-hall + # 在 GitLab/GitHub 创建 Merge Request / Pull Request + ``` + +4. **审查**:代码审查通过后合并到 develop + +### 代码审查清单 + +- [ ] 代码符合规范 +- [ ] 没有明显的 bug +- [ ] 功能完整 +- [ ] 已添加必要的注释 +- [ ] 通过单元测试 +- [ ] UI 符合 OpenAI 设计规范 + +## 📞 技术支持 + +如有问题,请联系: + +- **技术负责人**: [姓名] - [邮箱] +- **项目文档**: PROJECT_REQUIREMENTS.md +- **API文档**: http://localhost:8080/api/doc.html + +--- + +**祝开发愉快!** 🎉 + diff --git a/Nginx缓存配置说明.md b/Nginx缓存配置说明.md new file mode 100644 index 0000000..1008362 --- /dev/null +++ b/Nginx缓存配置说明.md @@ -0,0 +1,459 @@ +# Nginx缓存配置说明 + +## 📝 配置概述 + +**更新时间**: 2025-12-23 +**服务器**: 公网服务器 156.225.27.52:50232 +**配置文件**: /www/server/panel/vhost/nginx/qd.yydd.ai.conf +**状态**: ✅ 已禁用所有缓存(开发模式) + +--- + +## 🚫 当前缓存设置 + +### 已禁用的缓存功能 + +```nginx +location / { + # 反向代理配置 + proxy_pass http://127.0.0.1:5246; + + # 🚫 禁用缓存 - 开发模式 + proxy_buffering off; # 禁用代理缓冲 + proxy_cache off; # 禁用代理缓存 + proxy_no_cache 1; # 不缓存响应 + proxy_cache_bypass 1; # 绕过缓存检查 + + # 添加缓存控制头 + add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0"; + add_header Pragma "no-cache"; + add_header Expires "0"; +} +``` + +--- + +## 📊 缓存控制说明 + +### Nginx层面缓存控制 + +| 指令 | 作用 | 值 | +|------|------|-----| +| `proxy_buffering` | 代理缓冲 | off(禁用) | +| `proxy_cache` | 代理缓存 | off(禁用) | +| `proxy_no_cache` | 不缓存响应 | 1(启用) | +| `proxy_cache_bypass` | 绕过缓存 | 1(启用) | + +### HTTP响应头 + +| 响应头 | 作用 | 值 | +|--------|------|-----| +| `Cache-Control` | 缓存策略 | no-store, no-cache, must-revalidate | +| `Pragma` | HTTP/1.0兼容 | no-cache | +| `Expires` | 过期时间 | 0(立即过期) | + +--- + +## 🎯 效果说明 + +### 对开发的好处 + +1. **✅ 实时更新** + - 前端代码更新立即生效 + - 无需手动清除浏览器缓存 + - API响应不会被缓存 + +2. **✅ 调试便利** + - 每次请求都是最新内容 + - 便于测试新功能 + - 避免缓存导致的问题 + +3. **✅ 快速迭代** + - 修改代码后立即看到效果 + - 减少"看不到更新"的困惑 + - 提高开发效率 + +--- + +## 🔄 切换到生产模式 + +### 当系统正式上线后 + +**建议**:启用缓存以提高性能和减少服务器负载 + +```nginx +location / { + proxy_pass http://127.0.0.1:5246; + + # 启用缓存(生产模式) + proxy_buffering on; + + # 静态资源缓存(仅示例,根据实际调整) + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ { + proxy_pass http://127.0.0.1:5246; + proxy_cache my_cache; + proxy_cache_valid 200 30d; + proxy_cache_valid 404 1m; + add_header Cache-Control "public, max-age=2592000"; + } + + # API响应不缓存 + location /api/ { + proxy_pass http://127.0.0.1:5246; + proxy_buffering off; + add_header Cache-Control "no-cache, no-store, must-revalidate"; + } +} +``` + +--- + +## 📝 切换步骤 + +### 从开发模式切换到生产模式 + +```bash +# 1. SSH登录公网服务器 +ssh -p 50232 root@156.225.27.52 + +# 2. 编辑Nginx配置 +vi /www/server/panel/vhost/nginx/qd.yydd.ai.conf + +# 3. 修改缓存配置(根据需要) +# 移除或注释 proxy_buffering off 等指令 +# 添加适当的缓存配置 + +# 4. 测试配置 +nginx -t + +# 5. 重载Nginx +nginx -s reload +``` + +--- + +## 🧪 测试缓存配置 + +### 验证缓存已禁用 + +```bash +# 方式1:查看HTTP响应头 +curl -I https://qd.yydd.ai + +# 应该看到: +# Cache-Control: no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0 +# Pragma: no-cache +# Expires: 0 + +# 方式2:在浏览器开发者工具中 +# Network → Headers → Response Headers +# 检查 Cache-Control 等头部 +``` + +### 测试更新是否实时 + +1. **前端更新测试** + ```bash + # 修改前端代码 + cd /Users/lanmu/Desktop/qiangdanxitong/frontend + # 编辑任意文件 + + # 构建并上传 + pnpm build + scp -P 5204 -r dist/* root@156.225.27.52:/opt/designhub/frontend/ + + # 立即刷新浏览器(Ctrl+R,无需Ctrl+Shift+R强制刷新) + # 应该看到更新 + ``` + +2. **API更新测试** + ```bash + # 修改后端代码 + # 重启后端服务 + ssh -p 5204 root@156.225.27.52 + systemctl restart designhub-backend + + # 立即在浏览器中调用API + # 应该返回最新结果 + ``` + +--- + +## ⚠️ 注意事项 + +### 开发模式的影响 + +**优点**: +- ✅ 开发调试方便 +- ✅ 代码更新实时生效 +- ✅ 避免缓存问题 + +**缺点**: +- ⚠️ 服务器负载略高(每次请求都处理) +- ⚠️ 用户体验略差(无缓存加速) +- ⚠️ 带宽消耗略大 + +**建议**: +- 开发阶段:使用当前配置(无缓存) +- 生产阶段:启用适当缓存(性能优化) + +--- + +## 🎯 完整配置文件 + +### 当前配置(开发模式) + +```nginx +# /www/server/panel/vhost/nginx/qd.yydd.ai.conf + +# HTTP自动跳转HTTPS +server { + listen 80; + server_name qd.yydd.ai; + + location /.well-known/acme-challenge/ { + root /www/wwwroot/qd.yydd.ai; + } + + location / { + return 301 https://$server_name$request_uri; + } +} + +# HTTPS配置 +server { + listen 443 ssl http2; + server_name qd.yydd.ai; + + # SSL证书 + ssl_certificate /etc/letsencrypt/live/qd.yydd.ai/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/qd.yydd.ai/privkey.pem; + + # SSL优化 + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_prefer_server_ciphers on; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + + client_max_body_size 100M; + + # 反向代理 - 无缓存模式 + location / { + proxy_pass http://127.0.0.1:5246; + proxy_set_header Host $host; + 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; + + # WebSocket支持 + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + # 超时设置 + proxy_connect_timeout 300; + proxy_send_timeout 300; + proxy_read_timeout 300; + + # 🚫 禁用缓存 - 开发模式 + proxy_buffering off; + proxy_cache off; + proxy_no_cache 1; + proxy_cache_bypass 1; + + # 添加缓存控制头 + add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0"; + add_header Pragma "no-cache"; + add_header Expires "0"; + } + + access_log /www/wwwlogs/qd.yydd.ai.https.access.log; + error_log /www/wwwlogs/qd.yydd.ai.https.error.log; +} +``` + +--- + +## 🔧 常用命令 + +### 修改配置后重载 + +```bash +# SSH登录 +ssh -p 50232 root@156.225.27.52 + +# 编辑配置 +vi /www/server/panel/vhost/nginx/qd.yydd.ai.conf + +# 测试配置 +nginx -t + +# 重载Nginx(不中断服务) +nginx -s reload +``` + +### 查看配置 + +```bash +# 查看当前配置 +ssh -p 50232 root@156.225.27.52 +cat /www/server/panel/vhost/nginx/qd.yydd.ai.conf +``` + +### 验证缓存状态 + +```bash +# 方式1:curl查看响应头 +curl -I https://qd.yydd.ai + +# 方式2:详细查看 +curl -v https://qd.yydd.ai 2>&1 | grep -i cache +``` + +--- + +## 📊 浏览器缓存说明 + +### 浏览器本地缓存 + +**注意**: 即使Nginx禁用了缓存,浏览器仍可能有自己的缓存 + +**开发时的刷新方式**: + +| 刷新方式 | 快捷键 | 效果 | +|----------|--------|------| +| 普通刷新 | F5 或 Ctrl+R | 可能使用缓存 | +| 硬刷新 | Ctrl+Shift+R | 强制不使用缓存 | +| 清空缓存 | DevTools → Network → Disable cache | 开发时建议勾选 | + +**建议**: +- 开发时打开浏览器开发者工具(F12) +- 勾选 Network → Disable cache +- 这样可以完全禁用浏览器缓存 + +--- + +## 🚀 性能对比 + +### 开发模式 vs 生产模式 + +| 指标 | 开发模式(无缓存) | 生产模式(有缓存) | +|------|-------------------|-------------------| +| 首次加载 | 正常 | 正常 | +| 重复访问 | 每次都请求服务器 | 使用缓存,快速 | +| 服务器负载 | 较高 | 较低 | +| 带宽消耗 | 较高 | 较低 | +| 开发便利性 | ⭐⭐⭐⭐⭐ | ⭐⭐ | +| 用户体验 | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | + +--- + +## 💡 最佳实践 + +### 开发阶段(当前) + +✅ **已实施**: +- Nginx层面禁用所有缓存 +- 添加 no-cache 响应头 +- 便于快速迭代开发 + +### 测试阶段(即将上线) + +建议: +- 保持当前配置 +- 测试功能完整性 +- 验证无缓存相关bug + +### 生产阶段(正式上线后) + +建议: +- 启用静态资源缓存(JS/CSS/图片) +- API响应保持不缓存 +- 设置合理的缓存时间 +- 使用CDN加速 + +--- + +## 🔄 快速切换配置 + +### 配置模板 + +**开发模式配置**(当前): +```nginx +# 完全禁用缓存 +proxy_buffering off; +proxy_cache off; +proxy_no_cache 1; +proxy_cache_bypass 1; +add_header Cache-Control "no-store, no-cache, must-revalidate"; +``` + +**生产模式配置**(未来): +```nginx +# 启用适度缓存 +proxy_buffering on; + +# 静态资源缓存7天 +location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ { + add_header Cache-Control "public, max-age=604800"; +} + +# API不缓存 +location /api/ { + add_header Cache-Control "no-cache"; +} +``` + +--- + +## 📞 技术支持 + +### 修改缓存配置 + +如需修改缓存配置,请: + +1. SSH登录公网服务器 +2. 编辑 `/www/server/panel/vhost/nginx/qd.yydd.ai.conf` +3. 测试配置:`nginx -t` +4. 重载Nginx:`nginx -s reload` + +### 验证配置生效 + +```bash +# 查看HTTP响应头 +curl -I https://qd.yydd.ai | grep -i cache + +# 应该看到当前设置的缓存策略 +``` + +--- + +## 📝 更新记录 + +| 日期 | 操作 | 说明 | +|------|------|------| +| 2025-12-23 | 禁用缓存 | 开发模式,便于测试 | +| 待定 | 启用缓存 | 生产模式,性能优化 | + +--- + +## 🎯 总结 + +✅ **当前状态**: 所有缓存已禁用 +✅ **适用场景**: 开发测试阶段 +✅ **效果**: 代码更新立即生效 +✅ **访问地址**: https://qd.yydd.ai + +**开发体验**: +- 修改前端代码 → 构建上传 → 普通刷新(F5)即可看到更新 +- 修改后端代码 → 重启服务 → 立即生效 +- 无需清除浏览器缓存或强制刷新 + +🎉 **现在可以愉快地开发测试了!** + +--- + +**创建时间**: 2025-12-23 +**配置状态**: ✅ 已生效 +**验证方式**: 访问 https://qd.yydd.ai 并查看响应头 diff --git a/PROJECT_REQUIREMENTS.md b/PROJECT_REQUIREMENTS.md new file mode 100644 index 0000000..847b385 --- /dev/null +++ b/PROJECT_REQUIREMENTS.md @@ -0,0 +1,6335 @@ +# 设计师任务抢单管理系统 - 项目需求文档 + +## 📋 项目概述 + +### 1.1 项目背景 +本系统旨在建立一个高效的设计师任务分配与管理平台,通过积分制度和任务大厅机制,实现设计任务的智能分配、进度跟踪和绩效评估。 + +### 1.2 项目目标 +- 提高设计任务分配效率 +- 实现任务进度可视化管理 +- 建立公平的积分激励机制 +- 提供多维度的数据分析和绩效评估 +- 增强团队协作和透明度 + +### 1.3 核心价值 +- **效率提升**:自动化任务分配,减少人工协调成本 +- **透明化管理**:实时查看任务进度和设计师工作负荷 +- **激励机制**:积分制度激发设计师积极性 +- **数据驱动**:多维度数据分析支持决策优化 + +--- + +## 🏗️ 系统架构设计 + +### 2.1 技术栈建议 + +#### 前端技术栈 +- **框架**:Vue 3 + TypeScript / React 18 + TypeScript +- **UI组件库**:Ant Design Vue / Ant Design / Element Plus +- **状态管理**:Pinia / Zustand / Redux Toolkit +- **路由管理**:Vue Router / React Router +- **图表库**:ECharts / Apache ECharts +- **日历组件**:FullCalendar / Vue-Calendar +- **文件上传**:Vue-Upload-Component / react-dropzone +- **HTTP客户端**:Axios + +#### 后端技术栈 +- **主框架**:Spring Boot 3.x / Node.js (NestJS / Express) +- **数据库**:MySQL 8.0 + Redis +- **ORM框架**:MyBatis Plus / Hibernate / TypeORM +- **认证授权**: + - OAuth 2.0 / OpenID Connect + - 推荐开源方案:Keycloak / Auth0 / Casdoor + - JWT Token +- **钉钉集成**:DingTalk Open API SDK +- **文件存储**:阿里云OSS / MinIO / 七牛云 +- **任务调度**:Quartz / XXL-JOB +- **API文档**:Swagger / OpenAPI + +#### 数据库设计 +- **主数据库**:MySQL(业务数据) +- **缓存层**:Redis(会话、热点数据) +- **搜索引擎**:Elasticsearch(可选,用于高级搜索) + +#### 部署方案 +- **容器化**:Docker + Docker Compose +- **CI/CD**:Jenkins / GitLab CI / GitHub Actions +- **服务器**:Nginx(反向代理)+ 应用服务器 + +### 2.2 系统模块划分 + +``` +├── 用户认证模块 +│ ├── 开源认证系统集成 +│ ├── 钉钉登录集成 +│ └── 权限管理 +├── 组织架构模块 +│ ├── 组织架构同步 +│ ├── 部门类型管理(需求部门/接单部门) +│ ├── 团队管理 +│ ├── 部门负责人管理 +│ └── 积分池管理 +├── 任务管理模块 +│ ├── 任务发起 +│ ├── 任务分配 +│ ├── 任务接收 +│ ├── 任务审核 +│ └── 任务调配(管理员) +├── 任务大厅模块 +│ ├── 任务列表展示 +│ ├── 任务搜索筛选 +│ └── 任务抢单 +├── 进度管理模块 +│ ├── 我的任务进度 +│ ├── 日历视图 +│ └── 任务状态追踪 +├── 设计师大厅模块 +│ ├── 设计师列表 +│ ├── 实时进度看板 +│ └── 积分统计 +├── 数据分析模块 +│ ├── 绩效统计 +│ ├── 需求统计展板 +│ ├── 多维度报表 +│ └── 排行榜 +├── 系统配置模块 +│ ├── 需求类型配置 +│ ├── 品牌配置 +│ ├── 类目配置 +│ ├── 平台配置 +│ └── 激励系统配置 +├── 激励系统模块 +│ ├── 成就勋章系统 +│ ├── 实时排名系统 +│ ├── 里程碑提醒 +│ ├── 目标管理 +│ ├── 每日签到 +│ ├── 竞赛活动 +│ ├── 团队PK +│ └── 智能推荐 +├── 激励配置管理模块(管理员) +│ ├── 激励游戏开关 +│ ├── 成就勋章配置 +│ ├── 自动奖励规则配置 +│ ├── 竞赛活动管理 +│ ├── 团队PK管理 +│ ├── 排名规则配置 +│ └── 激励积分池管理 +└── 通知提醒模块 + ├── 站内消息 + ├── 钉钉推送 + └── 邮件通知 +``` + +--- + +## 📝 功能需求详细说明 + +## 一、用户认证与授权模块 + +### 1.1 开源认证系统集成 + +#### 推荐方案 +**Casdoor** - 开源的身份访问管理平台 +- 优势: + - 支持多种身份提供商(钉钉、企业微信等) + - 提供完整的UI界面 + - 易于集成和定制 + - 支持RBAC权限模型 + - 开源免费 + +#### 功能要点 +- 用户注册与登录 +- 单点登录(SSO) +- 会话管理 +- 密码策略管理 +- 安全日志记录 + +### 1.2 钉钉登录集成 + +#### 实现方式 +``` +流程: +1. 用户点击"钉钉登录" +2. 跳转到钉钉授权页面 +3. 用户授权后回调系统 +4. 系统获取钉钉用户信息 +5. 查询并同步组织架构 +6. 匹配或创建系统用户 +7. 生成JWT Token +8. 跳转到系统首页 +``` + +#### 组织架构同步 +- **同步频率**:定时任务(每天凌晨)+ 手动触发 +- **同步内容**: + - 部门层级结构 + - 用户基本信息(姓名、工号、头像) + - 用户部门关系 + - 团队积分池配置 + +#### 权限角色设计 +| 角色 | 权限说明 | +|------|----------| +| 超级管理员 | 系统全部权限,配置管理,任务全局调配 | +| 管理员 | 任务调配、任务转移、查看全部任务数据 | +| 需求部门负责人 | 发起任务、查看本部门数据、设置积分消耗限制、管理部门成员 | +| 接单部门主管 | 任务分配、审核、查看所属团队数据 | +| 设计师(接单部门) | 接收任务、提交交付、查看个人数据 | +| 需求方(需求部门) | 发起任务、审核交付、查看自己发起的任务 | +| 观察者 | 只读权限,查看数据分析 | + +--- + +## 一之二、组织架构与部门管理模块 + +### 1.3 功能概述 +组织架构管理是系统的基础模块,通过部门分类(需求部门/接单部门)实现职责明确的任务流转。需求部门负责发起任务并提供积分,接单部门负责接收和完成任务。 + +### 1.4 部门类型管理 + +#### 1.4.1 部门类型定义 + +**两种部门类型** + +| 部门类型 | 职责 | 权限 | 积分关系 | +|---------|------|------|---------| +| 需求部门 | 发起设计需求 | 只能发布任务,不能接单 | 拥有积分池,发布任务消耗积分 | +| 接单部门 | 接收并完成任务 | 只能接单,不能发起任务 | 完成任务获得积分奖励 | + +**典型场景示例** +``` +需求部门: +- 产品部 +- 市场部 +- 运营部 +- 电商部 + +接单部门: +- 设计部 +- 视觉设计组 +- UI设计组 +- 视频制作组 +``` + +#### 1.4.2 部门类型配置界面 + +**部门列表管理** +``` +┌──────────────────────────────────────────────────┐ +│ 组织架构 - 部门管理 │ +├──────────────────────────────────────────────────┤ +│ 🔄 [同步钉钉组织架构] [+ 新增部门] │ +│ ──────────────────────────────────────────── │ +│ │ +│ 🏢 公司总部 │ +│ │ │ +│ ├─ 📋 产品部 [需求部门] │ +│ │ 负责人:张三 │ +│ │ 成员:15人 | 积分池:5,000 │ +│ │ 消耗限制:500分/周 │ +│ │ [编辑] [查看详情] [设置负责人] │ +│ │ │ +│ ├─ 📋 市场部 [需求部门] │ +│ │ 负责人:李四 │ +│ │ 成员:12人 | 积分池:3,500 │ +│ │ 消耗限制:无限制 │ +│ │ [编辑] [查看详情] [设置负责人] │ +│ │ │ +│ ├─ 🎨 设计部 [接单部门] │ +│ │ 主管:王五 │ +│ │ 成员:25人 | 本月完成:156个任务 │ +│ │ [编辑] [查看详情] [管理成员] │ +│ │ │ +│ └─ 📺 视频制作组 [接单部门] │ +│ 主管:赵六 │ +│ 成员:8人 | 本月完成:45个任务 │ +│ [编辑] [查看详情] [管理成员] │ +│ │ +└──────────────────────────────────────────────────┘ +``` + +**编辑部门表单** +``` +┌───────────────────────────────────┐ +│ 编辑部门 │ +├───────────────────────────────────┤ +│ 部门名称: │ +│ [产品部] │ +│ │ +│ [必填] 部门类型: │ +│ ● 需求部门 │ +│ ○ 接单部门 │ +│ │ +│ 上级部门: │ +│ [公司总部 ▼] │ +│ │ +│ 部门描述: │ +│ ┌─────────────────────────────┐ │ +│ │ 负责产品设计需求... │ │ +│ └─────────────────────────────┘ │ +│ │ +│ === 需求部门专属配置 === │ +│ │ +│ 部门负责人: │ +│ [张三 ▼] │ +│ │ +│ 积分池余额: │ +│ [5000] 分 │ +│ │ +│ 积分消耗限制: │ +│ ☑ 启用消耗限制 │ +│ 周期:[每周 ▼] │ +│ 限制:[500] 分 │ +│ │ +│ 预警设置: │ +│ ☑ 剩余20%时预警 │ +│ ☑ 超过限制时预警 │ +│ │ +│ === 接单部门专属配置 === │ +│ (部门类型为需求部门时隐藏) │ +│ │ +│ 部门主管: │ +│ [王五 ▼] │ +│ │ +│ 可接单的需求类型: │ +│ ☑ 主图设计 │ +│ ☑ 详情页设计 │ +│ ☑ 视频剪辑 │ +│ ☐ 图片修图 │ +│ │ +│ [取消] [保存] │ +└───────────────────────────────────┘ +``` + +#### 1.4.3 部门类型权限控制 + +**权限验证规则** +```javascript +// 发起任务权限验证 +function canPublishTask(user) { + // 1. 检查用户所属部门 + const userDept = user.department; + + // 2. 验证部门类型 + if (userDept.type !== 'DEMAND') { + return { + allowed: false, + message: '您所在的部门为接单部门,无权发起任务' + }; + } + + // 3. 检查积分余额 + if (userDept.scoreBalance < minScore) { + return { + allowed: false, + message: '部门积分余额不足,请联系部门负责人充值' + }; + } + + // 4. 检查消耗限制 + if (userDept.hasLimit && userDept.periodUsed >= userDept.periodLimit) { + return { + allowed: false, + message: `已达到本${userDept.limitPeriod}消耗上限,请等待下一周期或联系负责人调整` + }; + } + + return { allowed: true }; +} + +// 接单权限验证 +function canReceiveTask(user, task) { + // 1. 检查用户所属部门 + const userDept = user.department; + + // 2. 验证部门类型 + if (userDept.type !== 'RECEIVER') { + return { + allowed: false, + message: '您所在的部门为需求部门,无法接收任务' + }; + } + + // 3. 检查技能匹配(可选) + if (!userDept.allowedDemandTypes.includes(task.demandType)) { + return { + allowed: false, + message: '您所在部门不支持该类型的任务' + }; + } + + return { allowed: true }; +} +``` + +**前端权限控制** +``` +需求部门用户登录后: +✓ 显示"发起任务"按钮 +✗ 隐藏"任务大厅"(抢单) +✓ 显示"我发起的任务" +✗ 隐藏"我接收的任务" + +接单部门用户登录后: +✗ 隐藏"发起任务"按钮 +✓ 显示"任务大厅"(抢单) +✗ 隐藏"我发起的任务" +✓ 显示"我接收的任务" +``` + +### 1.5 需求部门负责人管理 + +#### 1.5.1 负责人设置 + +**设置负责人** +``` +┌───────────────────────────────────┐ +│ 设置部门负责人 │ +├───────────────────────────────────┤ +│ 部门:产品部(需求部门) │ +│ 当前负责人:张三 │ +│ │ +│ 更改负责人: │ +│ [搜索部门成员] │ +│ ○ 张三(当前) │ +│ ○ 李四 │ +│ ● 王五 │ +│ ○ 赵六 │ +│ │ +│ 授权权限: │ +│ ☑ 查看部门所有任务 │ +│ ☑ 管理部门积分池 │ +│ ☑ 设置积分消耗限制 │ +│ ☑ 审批部门成员发起的任务(可选)│ +│ ☑ 查看部门数据统计 │ +│ ☑ 管理部门成员 │ +│ │ +│ 生效时间: │ +│ ● 立即生效 │ +│ ○ 指定时间 [日期选择器] │ +│ │ +│ 通知方式: │ +│ ☑ 站内消息 │ +│ ☑ 钉钉通知 │ +│ ☑ 邮件通知 │ +│ │ +│ [取消] [确认] │ +└───────────────────────────────────┘ +``` + +#### 1.5.2 积分消耗限制管理 + +**限制设置界面** +``` +┌──────────────────────────────────────────────┐ +│ 积分消耗限制管理 - 产品部 │ +├──────────────────────────────────────────────┤ +│ 部门负责人:张三 │ +│ 积分池余额:5,000 分 │ +│ ──────────────────────────────────────── │ +│ │ +│ 消耗限制设置: │ +│ ☑ 启用积分消耗限制 │ +│ │ +│ 限制周期: │ +│ ○ 每天 │ +│ ● 每周 │ +│ ○ 每月 │ +│ │ +│ 限制额度: │ +│ [500] 分 / 周 │ +│ │ +│ 本周期统计(2025.11.04 - 2025.11.10): │ +│ ┌────────────────────────────────────┐ │ +│ │ 已消耗:320 分(64%) │ │ +│ │ 剩余额度:180 分 │ │ +│ │ 进度:████████░░░ 64% │ │ +│ │ │ │ +│ │ 本周发起任务:18 个 │ │ +│ │ 待审核:2 个 │ │ +│ │ 进行中:12 个 │ │ +│ │ 已完成:4 个 │ │ +│ └────────────────────────────────────┘ │ +│ │ +│ 预警设置: │ +│ ☑ 达到 80% 时预警(400分) │ +│ ☑ 达到 100% 时禁止发起新任务 │ +│ ☑ 每周一发送消耗报告 │ +│ │ +│ 超限处理: │ +│ ● 禁止发起新任务 │ +│ ○ 仅提醒,不限制 │ +│ ○ 需要管理员审批 │ +│ │ +│ 历史记录: │ +│ ┌────────────────────────────────────┐ │ +│ │ 2025-11-04周:已用420/500(84%) │ │ +│ │ 2025-10-28周:已用480/500(96%) │ │ +│ │ 2025-10-21周:已用350/500(70%) │ │ +│ │ [查看更多] │ │ +│ └────────────────────────────────────┘ │ +│ │ +│ 临时调整: │ +│ [本周期临时增加额度] [暂停限制1周] │ +│ │ +│ [重置为默认] [保存配置] │ +└──────────────────────────────────────────────┘ +``` + +**消耗限制算法** +```javascript +// 检查是否可以发起任务 +function checkScoreLimit(dept, taskScore) { + // 1. 检查是否启用限制 + if (!dept.limitEnabled) { + return { allowed: true }; + } + + // 2. 获取当前周期 + const currentPeriod = getCurrentPeriod(dept.limitPeriod); + + // 3. 计算当前周期已使用 + const periodUsed = calculatePeriodUsage(dept.id, currentPeriod); + + // 4. 计算使用后的总额 + const afterUse = periodUsed + taskScore; + + // 5. 判断是否超限 + if (afterUse > dept.periodLimit) { + return { + allowed: false, + message: `超过本${periodName}消耗限制`, + details: { + limit: dept.periodLimit, + used: periodUsed, + required: taskScore, + remaining: dept.periodLimit - periodUsed + } + }; + } + + // 6. 预警提示 + const usageRate = afterUse / dept.periodLimit; + if (usageRate >= 0.8) { + return { + allowed: true, + warning: `将达到本${periodName}消耗限制的${(usageRate * 100).toFixed(0)}%` + }; + } + + return { allowed: true }; +} + +// 周期计算 +function getCurrentPeriod(periodType) { + const now = new Date(); + + if (periodType === 'DAILY') { + return now.toISOString().split('T')[0]; // 2025-11-07 + } else if (periodType === 'WEEKLY') { + const weekStart = getWeekStart(now); // 本周一 + return weekStart.toISOString().split('T')[0]; + } else if (periodType === 'MONTHLY') { + return `${now.getFullYear()}-${(now.getMonth() + 1).toString().padStart(2, '0')}`; + } +} +``` + +**超限提示** +``` +┌─────────────────────────────────────┐ +│ ⚠️ 积分消耗限制提醒 │ +├─────────────────────────────────────┤ +│ 您的部门本周积分消耗即将达到上限 │ +│ │ +│ 部门:产品部 │ +│ 本周限制:500 分 │ +│ 已使用:420 分(84%) │ +│ 剩余额度:80 分 │ +│ │ +│ 本次任务需要:100 分 │ +│ │ +│ ❌ 无法发起任务 │ +│ │ +│ 建议: │ +│ 1. 等待下周一重置(还剩2天) │ +│ 2. 联系部门负责人临时增加额度 │ +│ 3. 降低任务规模减少积分消耗 │ +│ │ +│ [联系负责人] [知道了] │ +└─────────────────────────────────────┘ +``` + +### 1.6 部门数据权限 + +**数据可见性规则** +``` +需求部门负责人可见: +✓ 本部门所有成员发起的任务 +✓ 本部门积分使用明细 +✓ 本部门消耗统计和趋势 +✓ 本部门成员需求发起排名 +✗ 其他部门的任务和数据 + +接单部门主管可见: +✓ 本部门所有设计师接收的任务 +✓ 本部门设计师的进度和绩效 +✓ 本部门获得的积分统计 +✓ 本部门设计师排行榜 +✗ 其他部门的设计师数据 + +超级管理员/管理员: +✓ 全部部门的所有数据 +``` + +--- + +## 二、任务发起模块 + +### 2.1 功能流程图 + +``` +开始 → 检查用户权限 → 查询团队积分 → 填写任务信息 → 计算所需积分 +→ 积分验证 → 选择发布类型 → 提交审核 → 发送通知 → 结束 +``` + +### 2.2 详细功能点 + +#### 2.2.1 需求类型选择 + +**配置管理(系统设置)** +- 需求类型名称(如:主图设计、详情页设计、视频剪辑等) +- 单价分值(每个类型对应的积分单价) +- 预估耗时(小时/天) +- 类型状态(启用/禁用) +- 类型描述和要求 + +**前端交互** +- 下拉选择框展示已启用的需求类型 +- 快速新增按钮(需管理员权限) +- 显示类型详细信息(单价、耗时) + +**数据示例** +| 需求类型 | 单价分值 | 预估耗时 | 状态 | +|----------|----------|----------|------| +| 主图设计 | 50 | 4小时 | 启用 | +| 详情页设计 | 200 | 2天 | 启用 | +| 视频剪辑 | 150 | 1天 | 启用 | +| 图片修图 | 30 | 2小时 | 启用 | + +#### 2.2.2 数量输入与积分计算 + +**实时计算逻辑** +```javascript +总积分 = 需求类型单价 × 数量 +预估完成时间 = 需求类型耗时 × 数量 +``` + +**积分验证机制** +``` +1. 实时查询用户所属团队的积分池余额 +2. 对比:总积分 vs 团队余额 +3. 不足时: + - 显示红色警告 + - 显示当前余额和所需积分 + - 提示联系管理员充值 + - 禁用提交按钮 +4. 充足时: + - 显示绿色提示 + - 显示扣除后余额 + - 允许提交 +``` + +**优化建议** +- 添加积分预算预警功能(余额<阈值时提前通知) +- 团队积分充值审批流程 +- 积分使用历史记录和统计 + +#### 2.2.3 任务类型选择 +- **新品**:全新产品的设计需求 +- **优化**:对已有设计进行优化改进 +- **改图**:对现有图片进行修改 +- **其它**:自定义说明的其他需求 + +#### 2.2.4 品牌选择 + +**配置管理(系统设置)** +- 品牌名称 +- 品牌LOGO +- 品牌说明 +- 品牌风格指南(可选) +- 状态(启用/禁用) + +**前端展示** +- 下拉选择框 +- 支持搜索过滤 +- 显示品牌LOGO和名称 + +#### 2.2.5 叶子类目选择 + +**配置管理(系统设置)** +- 多级类目树形结构 +- 类目名称 +- 类目层级(一级/二级/三级/叶子类目) +- 关联品牌(可选) +- 状态管理 + +**前端交互** +- 级联选择器 +- 支持搜索 +- 只能选择叶子类目 + +**数据结构示例** +``` +服装 +├── 女装 +│ ├── 连衣裙 [叶子] +│ ├── T恤 [叶子] +│ └── 裤子 [叶子] +└── 男装 + ├── 衬衫 [叶子] + └── 牛仔裤 [叶子] +``` + +#### 2.2.6 SPU信息输入 +- 文本输入框 +- 支持多行 +- 字数限制(如500字) +- 支持复制粘贴 + +#### 2.2.7 平台选择 + +**配置管理(系统设置)** +- 平台名称(淘宝、天猫、京东、拼多多、抖音等) +- 平台图标 +- 平台设计规范说明 +- 状态管理 + +**前端交互** +- 下拉选择或标签选择 +- 支持多选(一个任务可发布到多个平台) + +#### 2.2.8 需求附件上传 + +**⚠️ 重要说明** +- **发布需求时**:只需上传需求表格文件(Excel/Word),无需上传设计源文件(PSD、AI等) +- **设计师交付时**:只需填写素材存储路径,无需上传文件到系统 + +**文件类型支持(仅需求表格)** +- 需求表格:.xls, .xlsx, .doc, .docx +- 参考文档:.pdf +- 压缩包:.zip(如需多个表格时) + +**文件限制** +- 单文件大小:≤ 50MB +- 文件数量:≤ 5个(通常1-2个表格即可) +- 总大小:≤ 100MB + +**功能要求** +- 拖拽上传 +- 上传进度显示 +- 文件预览(支持Excel/Word在线预览) +- 删除功能 +- 下载功能 + +**设计说明** +``` +┌────────────────────────────────────────┐ +│ 上传需求表格 │ +├────────────────────────────────────────┤ +│ │ +│ 📎 已上传文件: │ +│ ┌──────────────────────────────────┐ │ +│ │ 📄 11月主图设计需求表.xlsx │ │ +│ │ 大小:2.3MB 上传时间:10:30 │ │ +│ │ [预览] [下载] [删除] │ │ +│ └──────────────────────────────────┘ │ +│ │ +│ [+ 点击上传] 或 拖拽文件到此处 │ +│ │ +│ 💡 提示: │ +│ • 仅需上传需求说明表格 │ +│ • 支持格式:Excel (.xlsx/.xls) │ +│ • 无需上传设计源文件(PSD/AI等) │ +│ • 设计师交付时仅需填写素材路径 │ +│ │ +└────────────────────────────────────────┘ +``` + +#### 2.2.9 任务发布类型 + +##### A. 指定任务 +- 从设计师列表中选择指定人员 +- 显示该设计师当前任务负荷 +- 支持选择多人(任务拆分) +- 任务直接进入指定设计师的待办列表 + +##### B. 招募任务(任务大厅) +- 任务发布到任务大厅 +- 所有设计师可见 +- 设计师主动抢单 +- 遵循"先到先得"或"评分优先"规则 + +##### C. 默认模式(主管分配) +- 任务进入主管待分配池 +- 主管根据设计师负荷和专长分配 +- 主管可设置任务优先级 +- 分配后通知设计师 + +**优化建议:智能推荐** +- 根据设计师历史任务类型匹配度推荐 +- 根据设计师当前负荷推荐 +- 根据设计师评分推荐 +- AI自动分配模式(可选) + +#### 2.2.10 交付地址信息(素材路径) + +**⚠️ 重要说明** +设计师交付时**无需上传文件**到系统,只需填写或粘贴素材的存储路径即可。 + +**信息收集(由接收任务人填写)** +- 共享盘路径(如:\\\\server\\design\\2025-11\\project01\\) +- 云盘链接(如:https://pan.baidu.com/s/xxxxx) +- 本地服务器路径(如:/mnt/design/2025-11/) +- Git仓库地址(如:https://github.com/xxx/project.git) +- 其他说明(文件夹结构、命名规范等) + +**填写时机** +- 任务接收时可选填写(预计交付位置) +- 任务进行中可修改 +- 任务完成提交时必须填写(实际交付位置) + +**交付路径输入界面** +``` +┌────────────────────────────────────────┐ +│ 填写素材交付路径 │ +├────────────────────────────────────────┤ +│ │ +│ 素材存储路径:* │ +│ ┌──────────────────────────────────┐ │ +│ │ \\server\design\2025-11\main-img│ │ +│ └──────────────────────────────────┘ │ +│ │ +│ 路径类型: │ +│ ● 共享盘路径 ○ 云盘链接 ○ 其他 │ +│ │ +│ 文件夹结构说明: │ +│ ┌──────────────────────────────────┐ │ +│ │ /源文件(PSD、AI等) │ │ +│ │ /导出图(JPG、PNG等) │ │ +│ │ /参考素材 │ │ +│ └──────────────────────────────────┘ │ +│ │ +│ 补充说明: │ +│ ┌──────────────────────────────────┐ │ +│ │ 已按需求表格顺序命名,请查收 │ │ +│ └──────────────────────────────────┘ │ +│ │ +│ 💡 提示: │ +│ • 请确保路径可访问 │ +│ • 建议使用共享盘或云盘链接 │ +│ • 无需上传文件到系统 │ +│ • 确保文件夹权限已设置 │ +│ │ +│ [取消] [确认提交] │ +└────────────────────────────────────────┘ +``` + +**路径验证规则** +- 必填项:至少填写一个有效路径 +- 格式检查:UNC路径、HTTP链接等格式验证 +- 长度限制:单个路径≤500字符 +- 支持多行输入:可填写多个路径 + +### 2.3 任务发起表单设计 + +**表单布局建议** + +``` +┌─────────────────────────────────────┐ +│ 发起任务需求 │ +├─────────────────────────────────────┤ +│ │ +│ 团队积分余额:1,000 分 │ +│ │ +│ [必填] 需求类型: │ +│ [下拉选择 ▼] → [+ 新增] │ +│ 显示:单价50分/个,预估4小时 │ +│ │ +│ [必填] 数量: │ +│ [___] 个 │ +│ ✓ 需消耗:250分 │ +│ ✓ 剩余:750分 │ +│ ✓ 预估完成时间:20小时 │ +│ │ +│ [必填] 任务类型: │ +│ ○ 新品 ○ 优化 ○ 改图 ○ 其它 │ +│ │ +│ [必填] 品牌: │ +│ [下拉选择 ▼] │ +│ │ +│ [必填] 叶子类目: │ +│ [级联选择 ▼] │ +│ │ +│ [必填] SPU信息: │ +│ [多行文本框] │ +│ 0/500 字 │ +│ │ +│ [必填] 平台: │ +│ [多选下拉 ▼] │ +│ │ +│ [必填] 需求附件: │ +│ [拖拽上传区域或点击上传] │ +│ 已上传:design.psd (5.2MB) [×] │ +│ │ +│ [必填] 发布类型: │ +│ ○ 指定任务 [选择设计师 ▼] │ +│ ○ 招募任务(发布到任务大厅) │ +│ ● 默认模式(由主管分配) │ +│ │ +│ 期望完成时间: │ +│ [日期选择器] │ +│ │ +│ 备注说明: │ +│ [文本框] │ +│ │ +│ [取消] [保存草稿] [提交] │ +│ │ +└─────────────────────────────────────┘ +``` + +--- + +## 三、任务大厅模块 + +### 3.1 功能概述 +任务大厅是招募任务的展示平台,设计师可以浏览、筛选和抢单。 + +### 3.2 任务卡片展示 + +**卡片信息结构** +``` +┌──────────────────────────────────────┐ +│ [主图设计] 🔥热门 │ +│ ────────────────────────────────── │ +│ 发布人:张三(产品部) │ +│ 发布时间:2025-11-07 10:30 │ +│ 任务类型:新品 │ +│ 预估积分:250分 │ +│ 任务时效:2天 │ +│ ────────────────────────────────── │ +│ 📦 品牌:Nike │ +│ 📑 类目:服装 > 女装 > 连衣裙 │ +│ 🏷️ SPU:夏季新款连衣裙系列 │ +│ 🌐 平台:淘宝、天猫 │ +│ ────────────────────────────────── │ +│ 数量:5个 | 还剩:2个可抢 │ +│ │ +│ [查看详情] [立即抢单] │ +└──────────────────────────────────────┘ +``` + +**任务状态标识** +- 🔥 热门任务(抢单人数多) +- ⚡ 紧急任务(时效短) +- 💎 高分任务(积分高) +- 🆕 新发布任务(1小时内) + +### 3.3 筛选和搜索功能 + +**筛选条件** +- 任务类型(新品/优化/改图/其它) +- 需求类型(下拉多选) +- 品牌(下拉多选) +- 类目(树形选择) +- 平台(多选) +- 积分范围(滑块) +- 时效范围(今天/3天内/一周内/自定义) +- 发布时间(今天/本周/本月/自定义) + +**搜索功能** +- 关键词搜索(SPU信息、任务描述) +- 发布人搜索 + +**排序方式** +- 最新发布 +- 积分最高 +- 时效最短 +- 最热门 + +### 3.4 抢单机制 + +#### 抢单规则 +1. **资格校验** + - 用户角色为设计师 + - 账号状态正常 + - 当前任务负荷未超限(可配置) + +2. **抢单冲突处理** + - 并发控制:乐观锁/悲观锁 + - 先到先得原则 + - 数量限制(一个任务可被多人抢,每人抢部分数量) + +3. **抢单确认** + - 显示任务详细信息 + - 确认接收数量 + - 填写预计完成时间 + - 填写交付地址信息 + +#### 抢单后流程 +``` +抢单成功 → 任务进入"我的任务" → 开始计时 → 设计师进度大厅更新 +``` + +### 3.5 任务详情页 + +**展示内容** +- 任务基本信息(扩展版卡片信息) +- 需求附件下载 +- 发布人联系方式 +- 任务历史记录(如被抢单记录) +- 相关任务推荐 + +--- + +## 四、我的任务进度模块 + +### 4.1 功能概述 +为发布人和接收人提供任务进度的可视化管理界面。 + +### 4.2 双视角设计 + +#### 视角一:发布人视角 +- 查看自己发起的所有任务 +- 查看每个任务的接收人和进度 +- 审核任务交付 +- 评价任务完成情况 + +#### 视角二:接收人视角 +- 查看自己接收的所有任务 +- 更新任务进度 +- 提交任务交付 +- 查看任务评价 + +### 4.3 任务卡片(增强版) + +**卡片信息结构** +``` +┌──────────────────────────────────────────┐ +│ [主图设计] #T20251107001 │ +│ ──────────────────────────────────── │ +│ 发布人:张三 接收人:李四 │ +│ 发布时间:11-07 10:30 开始:11-07 11:00│ +│ 任务类型:新品 预期:11-09 │ +│ 预估积分:250分 │ +│ ──────────────────────────────────── │ +│ 📦 品牌:Nike │ +│ 📑 类目:服装 > 女装 > 连衣裙 │ +│ 🏷️ SPU:夏季新款连衣裙系列 │ +│ 🌐 平台:淘宝、天猫 │ +│ ──────────────────────────────────── │ +│ 进度:███████████░░░░░ 75% │ +│ 状态:⚙️ 进行中 │ +│ ──────────────────────────────────── │ +│ [查看详情] [更新进度] [提交交付] │ +│ ──────────────────────────────────── │ +│ 💬 备注:需要增加模特图... │ +└──────────────────────────────────────────┘ +``` + +**任务状态流转** +``` +待接收 → 进行中 → 待审核 → [审核通过 / 打回修改] → 已完成 +``` + +| 状态 | 说明 | 可操作 | +|------|------|--------| +| 待接收 | 已分配但未开始 | 接收人:接受/拒绝 | +| 进行中 | 设计师正在处理 | 接收人:更新进度、提交交付 | +| 待审核 | 已提交交付 | 发布人:审核通过、打回修改 | +| 打回修改 | 审核未通过 | 接收人:重新提交 | +| 已完成 | 审核通过 | 发布人:评价 | +| 已取消 | 任务取消 | - | + +### 4.4 审核功能 + +#### 审核通过 +1. 点击"审核通过"按钮 +2. 填写五星评价 + - 质量评分(1-5星) + - 时效评分(1-5星) + - 沟通评分(1-5星) + - 综合评分(自动计算平均值) + - 评价内容(文字,可选) +3. 确认提交 +4. 积分奖励发放给设计师 +5. 任务状态更新为"已完成" + +**评价维度建议** +``` +┌──────────────────────────┐ +│ 任务完成评价 │ +├──────────────────────────┤ +│ 质量评分:★★★★★ │ +│ 时效评分:★★★★☆ │ +│ 沟通评分:★★★★★ │ +│ ────────────────────── │ +│ 综合评分:4.7分 │ +│ ────────────────────── │ +│ 评价内容: │ +│ [文本框] │ +│ 设计质量很高,符合要求... │ +│ │ +│ [取消] [提交] │ +└──────────────────────────┘ +``` + +#### 打回修改 +1. 点击"打回修改"按钮 +2. 填写打回原因(必填) +3. 上传标注图片(可选) +4. 设置最晚交付时间(必填) +5. 确认提交 +6. 发送通知给设计师 +7. 任务状态更新为"打回修改" + +**打回记录** +- 记录每次打回的原因和时间 +- 显示打回次数(过多可能影响评分) +- 保留完整的沟通记录 + +### 4.5 日历视图 + +**功能描述** +以日历的形式展示任务的时间线和进度,类似项目甘特图。 + +**视图类型** +- 月视图 +- 周视图 +- 日视图 + +**展示元素** +``` + 周一 周二 周三 周四 周五 + ┌─────┬─────┬─────┬─────┬─────┐ + 张三 │█████│█████│ │ │ │ 主图设计 + │ │ │░░░░░│░░░░░│ │ 详情页优化 + ├─────┼─────┼─────┼─────┼─────┤ + 李四 │ │█████│█████│█████│ │ 视频剪辑 + │ │ │ │░░░░░│░░░░░│ 新品设计 + └─────┴─────┴─────┴─────┴─────┘ + +图例: +█ 任务执行时间 +░ 任务预留时间 +红色边框:延期任务 +黄色标记:即将到期 +``` + +**交互功能** +- 点击任务条查看详情 +- 拖拽调整任务时间(主管权限) +- 悬浮显示任务摘要 +- 筛选显示(按人员、状态、类型) + +**优化建议** +- 添加时间轴缩放功能 +- 支持导出PDF/图片 +- 支持打印功能 +- 与钉钉日历同步 + +### 4.6 任务详情抽屉/弹窗 + +**详细信息展示** +- 任务完整信息 +- 附件下载 +- 交付地址信息 +- 进度更新记录 +- 沟通留言板 +- 操作日志 + +--- + +## 四之二、管理员任务调配模块 + +### 4.7 功能概述 +管理员任务调配模块是系统的核心管理功能之一,允许管理员对系统中的任务进行灵活调配,包括对进行中的任务进行转移、重新分配,以及对任务池(任务大厅)中的招募任务进行管理和调配。这个功能可以有效应对突发情况、优化资源分配、平衡设计师工作负荷。 + +### 4.8 任务调配权限 + +**权限要求** +- **超级管理员**:可以调配所有任务,无限制 +- **管理员**:可以调配指定部门或团队的任务 +- **主管**:只能调配自己团队内的任务(可选功能) + +**权限控制** +``` +调配权限 = 角色权限 + 部门权限 + 特殊授权 +``` + +### 4.9 进行中任务调配 + +#### 4.9.1 功能入口 + +**调配面板位置** +- 我的任务进度页面(管理员视图) +- 设计师进度大厅页面 +- 任务详情页面(管理员权限可见) + +**展示方式** +``` +┌──────────────────────────────────────────┐ +│ 进行中的任务管理 │ +├──────────────────────────────────────────┤ +│ 🔍 筛选:[部门▼] [设计师▼] [任务状态▼] │ +│ [批量调配] [导出] │ +├──────────────────────────────────────────┤ +│ │ +│ ☑ [进行中] #T20251107001 - 主图设计 │ +│ 接收人:张三 | 进度:60% | 剩余:1天 │ +│ 发布人:产品部-王经理 │ +│ [查看详情] [转移任务] [调整优先级] │ +│ ──────────────────────────────────── │ +│ ☑ [进行中] #T20251107002 - 详情页优化 │ +│ 接收人:李四 | 进度:30% | 剩余:3天 │ +│ 发布人:市场部-刘总监 │ +│ [查看详情] [转移任务] [调整优先级] │ +│ ──────────────────────────────────── │ +│ ☑ [延期] #T20251107003 - 视频剪辑 │ +│ 接收人:王五 | 进度:45% | 已延期2天 │ +│ 发布人:运营部-陈主管 │ +│ [查看详情] [转移任务] [调整优先级] │ +│ │ +│ [1] 2 3 ... 10 │ +└──────────────────────────────────────────┘ +``` + +#### 4.9.2 任务转移功能 + +**转移场景** +1. **设计师请假/离职**:紧急转移任务给其他设计师 +2. **负荷不均衡**:从繁忙设计师转移任务给空闲设计师 +3. **技能匹配**:转移给更擅长该类型的设计师 +4. **任务延期**:转移给效率更高的设计师加急处理 +5. **质量问题**:转移给经验更丰富的设计师 + +**转移操作流程** + +**步骤1:选择任务** +``` +支持单个转移和批量转移 +- 单个:点击任务卡片的"转移任务"按钮 +- 批量:勾选多个任务,点击"批量调配" +``` + +**步骤2:填写转移信息** +``` +┌─────────────────────────────────────┐ +│ 任务转移 │ +├─────────────────────────────────────┤ +│ 当前任务:#T20251107001 - 主图设计 │ +│ 当前接收人:张三(UI设计师) │ +│ 当前进度:60% │ +│ ───────────────────────────────── │ +│ [必填] 转移给: │ +│ [选择设计师 ▼] │ +│ 智能推荐: │ +│ ✓ 李四(负荷:40%,评分4.8) │ +│ ✓ 赵六(负荷:35%,评分4.7) │ +│ │ +│ [必填] 转移原因: │ +│ ○ 原设计师请假/离职 │ +│ ○ 负荷调整 │ +│ ○ 技能不匹配 │ +│ ○ 任务延期 │ +│ ● 其他(请说明) │ +│ │ +│ 详细说明: │ +│ ┌─────────────────────────────┐ │ +│ │ [文本框] │ │ +│ │ 张三因病请假,任务紧急... │ │ +│ └─────────────────────────────┘ │ +│ │ +│ 是否保留原进度: │ +│ ☑ 是(新接收人继续当前进度) │ +│ ☐ 否(任务重置为0%,重新开始) │ +│ │ +│ 调整交付时间: │ +│ 原定:2025-11-09 │ +│ 新定:[日期选择器] │ +│ │ +│ 积分处理: │ +│ ○ 按进度分配(张三60%,李四40%) │ +│ ○ 全部给新接收人 │ +│ ● 由管理员最终确定 │ +│ │ +│ 通知设置: │ +│ ☑ 通知原接收人 │ +│ ☑ 通知新接收人 │ +│ ☑ 通知发布人 │ +│ ☑ 通知主管 │ +│ │ +│ [取消] [确认转移] │ +└─────────────────────────────────────┘ +``` + +**步骤3:系统处理** +``` +1. 权限校验(管理员权限验证) +2. 检查新接收人状态(是否在职、负荷是否过高) +3. 计算原设计师的延误情况和扣分 + - 判断是否存在延误(当前时间 > 原定交付时间) + - 如果延误,计算延误天数/小时 + - 根据系统扣分规则计算扣分值 + - 记录延误数据到原设计师档案 + - 执行积分扣除操作 + - 标注"因任务转移扣分" +4. 重新计算任务时效 + - 评估剩余工作量(根据当前进度) + - 计算新的预计完成时间 + - 考虑新设计师的当前负荷 + - 设置新的交付期限 +5. 更新任务接收人信息 +6. 记录转移日志(原因、操作人、时间、延误扣分情况) +7. 处理积分分配(按进度分配奖励积分,扣除延误扣分) +8. 发送通知给相关人员(包含延误扣分信息) +9. 更新任务状态和时间线 +10. 在任务详情中显示转移记录和延误记录 +``` + +**步骤4:转移记录** +``` +任务操作历史: +──────────────────────────────────── +2025-11-07 14:30 [任务转移] +操作人:管理员-陈经理 +转移:张三 → 李四 +原因:原设计师请假 +进度保留:是(60%) + +⚠️ 延误情况: +- 原定交付时间:2025-11-07 12:00 +- 转移时间:2025-11-07 14:30 +- 延误时长:2小时30分钟 +- 扣分:-5分(按延误扣分规则) +- 延误记录已记入张三档案 + +积分处理: +- 张三获得进度积分:150分(60%) +- 张三延误扣分:-5分 +- 张三实际获得:145分 +- 李四将获得:100分(40%) + +时效重新计算: +- 原定交付时间:2025-11-07 12:00(已延误) +- 剩余工作量:40% +- 预估需要时间:8小时 +- 新交付时间:2025-11-08 10:00 +- 新接收人:李四(当前负荷:40%) + +说明:张三因病请假,任务紧急转移给李四继续完成 +──────────────────────────────────── +2025-11-07 10:00 [任务开始] +接收人:张三 +原定交付时间:2025-11-07 12:00 +──────────────────────────────────── +``` + +#### 4.9.3 延误扣分机制 + +**延误判定标准** +``` +延误判定 = 当前时间 > 原定交付时间 + +延误分类: +- 轻微延误:< 2小时 +- 一般延误:2-8小时 +- 严重延误:8-24小时 +- 重大延误:> 24小时 +``` + +**扣分规则(在系统配置中可调整)** + +| 延误程度 | 延误时长 | 扣分规则 | 示例 | +|---------|---------|---------|------| +| 轻微延误 | < 2小时 | 每小时扣2分 | 延误1.5小时 → 扣3分 | +| 一般延误 | 2-8小时 | 每小时扣3分 | 延误5小时 → 扣15分 | +| 严重延误 | 8-24小时 | 每小时扣5分 | 延误10小时 → 扣50分 | +| 重大延误 | > 24小时 | 基础50分+每天10分 | 延误2天 → 扣70分 | + +**扣分计算公式** +```javascript +function calculateDelayPenalty(delayHours) { + if (delayHours < 0) return 0; // 未延误 + + if (delayHours < 2) { + // 轻微延误 + return Math.ceil(delayHours * 2); + } else if (delayHours < 8) { + // 一般延误 + return Math.ceil(delayHours * 3); + } else if (delayHours < 24) { + // 严重延误 + return Math.ceil(delayHours * 5); + } else { + // 重大延误 + const days = Math.floor(delayHours / 24); + return 50 + days * 10; + } +} + +// 示例 +延误2.5小时 → 扣分 = 2.5 × 3 = 8分 +延误10小时 → 扣分 = 10 × 5 = 50分 +延误48小时 → 扣分 = 50 + 2 × 10 = 70分 +``` + +**延误数据留存** + +**设计师延误档案** +``` +张三 - 延误记录: + +┌──────────────────────────────────────┐ +│ 延误记录 #1 │ +├──────────────────────────────────────┤ +│ 任务:#T20251107001 - 主图设计 │ +│ 原定交付:2025-11-07 12:00 │ +│ 实际情况:任务转移时延误 │ +│ 延误时长:2小时30分钟 │ +│ 延误程度:一般延误 │ +│ 扣分:-5分 │ +│ 原因标注:因病请假导致任务转移 │ +│ 进度完成:60% │ +│ 记录时间:2025-11-07 14:30 │ +│ 操作人:管理员-陈经理 │ +└──────────────────────────────────────┘ + +影响统计: +- 本月延误次数:1次 +- 本月延误扣分:-5分 +- 延误率:4.2%(1/24) +- 影响平均评分:-0.1分 +``` + +**延误记录字段** +- 任务ID和名称 +- 原定交付时间 +- 延误发生时间 +- 延误时长(精确到分钟) +- 延误程度分类 +- 扣分值 +- 原因标注(任务转移/正常延误) +- 当时进度完成情况 +- 是否因特殊原因(病假/事假等) +- 管理员备注 +- 是否申诉 + +**延误扣分通知** + +**通知原设计师** +``` +┌─────────────────────────────────────┐ +│ ⚠️ 延误扣分通知 │ +├─────────────────────────────────────┤ +│ 您的任务因延误产生扣分 │ +│ │ +│ 任务:#T20251107001 - 主图设计 │ +│ 原定交付:2025-11-07 12:00 │ +│ 转移时间:2025-11-07 14:30 │ +│ 延误时长:2小时30分钟 │ +│ │ +│ 扣分详情: │ +│ - 延误扣分:-5分 │ +│ - 进度积分:+150分(60%完成) │ +│ - 实际获得:+145分 │ +│ │ +│ 原因:因病请假导致任务转移 │ +│ 备注:管理员已标注特殊原因 │ +│ │ +│ 您的延误记录: │ +│ - 本月延误次数:1次 │ +│ - 本月延误扣分:-5分 │ +│ │ +│ [查看详情] [申诉] │ +└─────────────────────────────────────┘ +``` + +**延误申诉机制** + +设计师如果认为延误有正当理由,可以提出申诉: +1. 填写申诉原因 +2. 上传证明材料(如病假条、邮件截图等) +3. 提交给管理员审核 +4. 管理员审核通过后可撤销扣分或减少扣分 +5. 记录申诉历史 + +**时效重新计算算法** + +```javascript +function recalculateDeadline(task, newDesigner) { + // 1. 计算剩余工作量 + const remainingWork = 1 - task.progress; // 40% = 0.4 + + // 2. 获取原始预估时长 + const originalEstimatedHours = task.demandType.estimateTime; // 20小时 + + // 3. 计算剩余工作时长 + const remainingHours = originalEstimatedHours * remainingWork; // 8小时 + + // 4. 考虑新设计师的熟练度系数(可选) + const proficiencyFactor = calculateProficiency(newDesigner, task.demandType); + // 0.8 = 很熟练,1.0 = 正常,1.2 = 不太熟练 + + // 5. 考虑新设计师的当前负荷 + const workloadFactor = calculateWorkloadFactor(newDesigner.currentWorkload); + // 负荷高则需要更多时间 + // 负荷<50%: 1.0, 50-70%: 1.1, 70-90%: 1.2, >90%: 1.3 + + // 6. 计算实际需要时间 + const actualHours = remainingHours * proficiencyFactor * workloadFactor; + // 8 × 1.0 × 1.1 = 8.8小时 + + // 7. 计算新的交付时间(考虑工作日历) + const newDeadline = calculateWorkingDeadline( + new Date(), // 从现在开始 + actualHours, // 需要的小时数 + '9:00-18:00' // 工作时间 + ); + + return { + newDeadline: newDeadline, + estimatedHours: actualHours, + proficiencyFactor: proficiencyFactor, + workloadFactor: workloadFactor, + reason: '任务转移,时效重新计算' + }; +} + +// 示例输出 +{ + newDeadline: '2025-11-08 10:00', + estimatedHours: 8.8, + proficiencyFactor: 1.0, + workloadFactor: 1.1, + reason: '任务转移,时效重新计算' +} +``` + +**时效重新计算展示** +``` +┌─────────────────────────────────────┐ +│ 时效重新计算 │ +├─────────────────────────────────────┤ +│ 原定交付时间:2025-11-07 12:00(已过)│ +│ 当前时间:2025-11-07 14:30 │ +│ 已延误:2小时30分钟 │ +│ │ +│ 剩余工作量:40% │ +│ 预估工作时长:8小时 │ +│ │ +│ 新接收人:李四 │ +│ - 熟练度系数:1.0(熟练) │ +│ - 负荷系数:1.1(负荷适中) │ +│ │ +│ 实际需要时间:8.8小时 │ +│ 新交付时间:2025-11-08 10:00 │ +│ │ +│ [确认] [调整] │ +└─────────────────────────────────────┘ +``` + +#### 4.9.4 智能推荐算法 + +**推荐设计师的评分算法** +```javascript +推荐分数 = + 负荷权重 × (1 - 当前负荷率) × 30% + + 技能权重 × 技能匹配度 × 25% + + 评分权重 × 历史平均评分 × 20% + + 效率权重 × 按时完成率 × 15% + + 经验权重 × 同类型任务完成数 × 10% + +按推荐分数降序排列,展示Top 5 +``` + +**推荐标签** +- 🔥 最佳匹配:综合评分最高 +- ⚡ 效率之选:历史效率最高 +- 💎 质量保证:历史评分最高 +- 🆓 空闲优先:当前负荷最低 +- 🎯 经验丰富:同类型任务完成最多 + +#### 4.9.4 批量转移 + +**批量转移场景** +- 设计师长期离职,转移所有在手任务 +- 部门调整,批量转移任务归属 +- 紧急情况,批量分散任务 + +**批量操作界面** +``` +┌─────────────────────────────────────┐ +│ 批量任务转移 (已选择 5 个任务) │ +├─────────────────────────────────────┤ +│ 任务列表: │ +│ ☑ #T001 - 主图设计 (张三, 60%) │ +│ ☑ #T002 - 详情页优化 (张三, 30%) │ +│ ☑ #T005 - 视频剪辑 (张三, 45%) │ +│ ☑ #T008 - 改图 (张三, 80%) │ +│ ☑ #T012 - 新品设计 (张三, 15%) │ +│ ───────────────────────────────── │ +│ 转移策略: │ +│ ○ 统一转移给单人 │ +│ ● 智能分配给多人(推荐) │ +│ ○ 手动指定每个任务的接收人 │ +│ │ +│ 智能分配预览: │ +│ 李四:接收2个任务 (#T001, #T002) │ +│ 王五:接收2个任务 (#T005, #T008) │ +│ 赵六:接收1个任务 (#T012) │ +│ │ +│ 转移原因:[必填] │ +│ 积分处理:[选择策略] │ +│ 通知设置:[勾选] │ +│ │ +│ [取消] [执行转移] │ +└─────────────────────────────────────┘ +``` + +#### 4.9.5 任务优先级调整 + +**优先级管理** +``` +优先级级别: +P0 - 特急(红色):需立即处理,1天内完成 +P1 - 紧急(橙色):优先处理,3天内完成 +P2 - 普通(蓝色):正常处理,1周内完成 +P3 - 低优(灰色):可延后处理,2周内完成 +``` + +**优先级调整界面** +``` +┌───────────────────────────────┐ +│ 调整任务优先级 │ +├───────────────────────────────┤ +│ 任务:#T20251107001 │ +│ 标题:主图设计 │ +│ 当前优先级:P2 (普通) │ +│ ─────────────────────────── │ +│ 调整为: │ +│ ○ P0 - 特急 │ +│ ● P1 - 紧急 │ +│ ○ P2 - 普通 │ +│ ○ P3 - 低优 │ +│ │ +│ 调整原因:[必填] │ +│ ┌─────────────────────────┐ │ +│ │ 客户要求提前上线... │ │ +│ └─────────────────────────┘ │ +│ │ +│ 是否通知接收人: │ +│ ☑ 站内消息 │ +│ ☑ 钉钉推送 │ +│ ☑ 短信提醒(紧急时) │ +│ │ +│ [取消] [确认] │ +└───────────────────────────────┘ +``` + +### 4.10 任务池(任务大厅)调配 + +#### 4.10.1 功能概述 +管理员可以对任务大厅中的招募任务进行管理,包括: +- 查看所有招募任务的状态 +- 指定接收人(将招募任务转为指定任务) +- 撤回任务到待分配池 +- 调整任务信息 +- 关闭/取消任务 + +#### 4.10.2 任务池管理界面 + +**管理入口** +``` +任务大厅 → 管理员视图(切换按钮) +``` + +**界面布局** +``` +┌──────────────────────────────────────────┐ +│ 任务池管理(管理员) │ +├──────────────────────────────────────────┤ +│ 📊 统计: │ +│ 招募中:23个 | 无人抢单:5个 | 今日新增:8个 │ +│ ──────────────────────────────────── │ +│ 🔍 筛选:[状态▼] [部门▼] [发布时间▼] │ +│ [高级筛选] [批量操作▼] [刷新] │ +│ ──────────────────────────────────── │ +│ │ +│ ┌────────────────────────────────────┐ │ +│ │ 🔥 [招募中] #T20251107010 │ │ +│ │ 主图设计 | 250分 | 2天 │ │ +│ │ 发布人:产品部-王经理 │ │ +│ │ 发布时间:2小时前 │ │ +│ │ 浏览:15次 | 收藏:3人 | 抢单:0人│ │ +│ │ ⚠️ 提示:发布2小时无人抢单 │ │ +│ │ │ │ +│ │ [查看详情] [指定接收人] [撤回] │ │ +│ │ [调整] [推广] [关闭] │ │ +│ └────────────────────────────────────┘ │ +│ │ +│ ┌────────────────────────────────────┐ │ +│ │ ✅ [已抢单] #T20251107011 │ │ +│ │ 详情页优化 | 200分 | 1天 │ │ +│ │ 发布人:市场部-刘总监 │ │ +│ │ 接收人:李四 │ │ +│ │ 抢单时间:30分钟前 │ │ +│ │ │ │ +│ │ [查看详情] [转移] [调整] │ │ +│ └────────────────────────────────────┘ │ +│ │ +└──────────────────────────────────────────┘ +``` + +#### 4.10.3 指定接收人功能 + +**使用场景** +- 招募任务长时间无人抢单 +- 任务紧急需要快速分配 +- 特定任务需要特定设计师处理 + +**操作流程** +``` +┌─────────────────────────────────────┐ +│ 为任务指定接收人 │ +├─────────────────────────────────────┤ +│ 任务:#T20251107010 - 主图设计 │ +│ 当前状态:招募中(无人抢单) │ +│ 发布时间:2小时前 │ +│ ───────────────────────────────── │ +│ 选择接收人: │ +│ [搜索设计师] │ +│ │ +│ 智能推荐: │ +│ ┌─────────────────────────────┐ │ +│ │ ○ 李四 🔥 最佳匹配 │ │ +│ │ UI设计师 | 负荷40% | 评分4.8 │ │ +│ │ 同类型完成:28个 │ │ +│ ├─────────────────────────────┤ │ +│ │ ● 王五 ⚡ 效率之选 │ │ +│ │ 视觉设计师 | 负荷35% | 评分4.6│ │ +│ │ 同类型完成:22个 │ │ +│ ├─────────────────────────────┤ │ +│ │ ○ 赵六 🆓 空闲优先 │ │ +│ │ UI设计师 | 负荷20% | 评分4.7 │ │ +│ │ 同类型完成:15个 │ │ +│ └─────────────────────────────┘ │ +│ │ +│ 指定原因: │ +│ ○ 任务紧急 │ +│ ● 长时间无人抢单 │ +│ ○ 需要特定设计师 │ +│ ○ 其他 │ +│ │ +│ 说明:[选填] │ +│ ┌─────────────────────────────┐ │ +│ │ 任务已发布2小时无人抢单... │ │ +│ └─────────────────────────────┘ │ +│ │ +│ 是否通知: │ +│ ☑ 接收人(钉钉+站内) │ +│ ☑ 发布人(站内) │ +│ │ +│ 备注:指定后任务状态将变为"进行中" │ +│ │ +│ [取消] [确认指定] │ +└─────────────────────────────────────┘ +``` + +#### 4.10.4 任务撤回功能 + +**撤回场景** +- 任务信息有误需要修改 +- 任务需求变更 +- 任务取消或暂停 +- 重新评估任务优先级 + +**撤回操作** +``` +┌───────────────────────────────┐ +│ 撤回任务 │ +├───────────────────────────────┤ +│ 任务:#T20251107010 │ +│ 当前状态:招募中 │ +│ ─────────────────────────── │ +│ 撤回后状态: │ +│ ● 待分配(进入主管待办) │ +│ ○ 草稿箱(暂时保存) │ +│ ○ 取消任务(积分退回) │ +│ │ +│ 撤回原因:[必填] │ +│ ┌─────────────────────────┐ │ +│ │ 任务信息需要修改... │ │ +│ └─────────────────────────┘ │ +│ │ +│ 积分处理: │ +│ ☑ 退回到发布人团队积分池 │ +│ │ +│ 通知设置: │ +│ ☑ 通知发布人 │ +│ ☑ 通知关注的设计师 │ +│ │ +│ [取消] [确认撤回] │ +└───────────────────────────────┘ +``` + +#### 4.10.5 任务推广功能 + +**推广场景** +- 重要任务长时间无人接单 +- 紧急任务需要快速响应 +- 高分任务需要更多曝光 + +**推广方式** +``` +┌───────────────────────────────┐ +│ 任务推广 │ +├───────────────────────────────┤ +│ 任务:#T20251107010 │ +│ ─────────────────────────── │ +│ 推广方式: │ +│ ☑ 置顶显示(任务大厅顶部) │ +│ ☑ 标记为热门任务 │ +│ ☑ 钉钉群发通知 │ +│ ☑ 站内消息推送(全体设计师)│ +│ ☐ 短信通知(谨慎使用) │ +│ │ +│ 置顶时长: │ +│ ○ 1小时 ● 3小时 ○ 6小时 │ +│ ○ 12小时 ○ 24小时 │ +│ │ +│ 推广理由: │ +│ ┌─────────────────────────┐ │ +│ │ 紧急任务,需尽快接单... │ │ +│ └─────────────────────────┘ │ +│ │ +│ [取消] [开始推广] │ +└───────────────────────────────┘ +``` + +#### 4.10.6 批量调配任务池 + +**批量操作** +``` +支持的批量操作: +- 批量指定接收人(选择多个任务,分配给不同设计师) +- 批量撤回(将多个任务从大厅撤回) +- 批量调整优先级 +- 批量推广 +- 批量关闭/取消 +``` + +**批量指定界面** +``` +┌─────────────────────────────────────┐ +│ 批量指定接收人 (已选择 3 个任务) │ +├─────────────────────────────────────┤ +│ 任务列表: │ +│ ☑ #T010 - 主图设计 (250分, 2天) │ +│ ☑ #T011 - 详情页优化 (200分, 1天) │ +│ ☑ #T015 - 视频剪辑 (150分, 1天) │ +│ ───────────────────────────────── │ +│ 分配策略: │ +│ ○ 统一分配给单人 │ +│ ● 智能分配(根据技能和负荷) │ +│ ○ 手动指定每个任务 │ +│ │ +│ 智能分配预览: │ +│ ┌─────────────────────────────┐ │ +│ │ #T010 → 李四 │ │ +│ │ 匹配度:95% | 当前负荷:40% │ │ +│ ├─────────────────────────────┤ │ +│ │ #T011 → 王五 │ │ +│ │ 匹配度:88% | 当前负荷:35% │ │ +│ ├─────────────────────────────┤ │ +│ │ #T015 → 赵六 │ │ +│ │ 匹配度:92% | 当前负荷:30% │ │ +│ └─────────────────────────────┘ │ +│ │ +│ [调整分配] [取消] [确认批量指定] │ +└─────────────────────────────────────┘ +``` + +### 4.11 调配监控与统计 + +#### 4.11.1 调配日志 + +**完整记录** +``` +所有调配操作都会记录详细日志: +- 操作时间 +- 操作人 +- 操作类型(转移/指定/撤回/调整等) +- 任务信息 +- 变更详情(从谁到谁) +- 操作原因 +- 处理结果 +``` + +**日志查询界面** +``` +┌──────────────────────────────────────────┐ +│ 任务调配日志 │ +├──────────────────────────────────────────┤ +│ 🔍 [时间范围▼] [操作人▼] [操作类型▼] │ +│ [任务编号] [搜索] │ +│ ──────────────────────────────────── │ +│ 2025-11-07 14:30:25 │ +│ [任务转移] 管理员-陈经理 │ +│ #T20251107001 - 主图设计 │ +│ 张三 → 李四 │ +│ 原因:原设计师请假 │ +│ 积分处理:按进度分配(60%:40%) │ +│ [查看详情] │ +│ ──────────────────────────────────── │ +│ 2025-11-07 13:15:10 │ +│ [指定接收人] 管理员-陈经理 │ +│ #T20251107010 - 主图设计 │ +│ 招募中 → 李四 │ +│ 原因:长时间无人抢单 │ +│ [查看详情] │ +│ ──────────────────────────────────── │ +└──────────────────────────────────────────┘ +``` + +#### 4.11.2 调配统计 + +**统计维度** +``` +管理员工作台 - 任务调配统计 + +本月调配概况: +┌─────────────┐ ┌─────────────┐ ┌─────────────┐ +│ 调配次数 │ │ 转移任务 │ │ 指定任务 │ +│ 156次 │ │ 89个 │ │ 67个 │ +│ ↑ 较上月+12│ │ ↑ 较上月+8 │ │ ↑ 较上月+4 │ +└─────────────┘ └─────────────┘ └─────────────┘ + +调配原因分布(饼图): +- 负荷调整:35% +- 设计师请假/离职:25% +- 任务延期:20% +- 技能不匹配:12% +- 无人抢单:8% + +调配效果分析: +- 调配后按时完成率:94.5% +- 调配后平均评分:4.7⭐ +- 调配后延误率:5.5% +``` + +### 4.12 调配权限审计 + +**审计功能** +``` +系统自动审计调配操作: +1. 记录所有调配行为 +2. 监控异常调配(如频繁转移同一任务) +3. 权限使用统计 +4. 定期生成审计报告 +5. 支持审计日志导出 +``` + +**异常告警** +``` +触发告警的场景: +- 同一任务被转移超过3次 +- 管理员单日调配超过50次 +- 非授权调配尝试 +- 批量调配超过20个任务 +- 积分异常变动 +``` + +### 4.13 移动端调配支持 + +**移动端功能** +``` +管理员可在钉钉工作台或移动App进行: +✓ 查看待调配任务列表 +✓ 快速转移任务 +✓ 指定接收人 +✓ 调整优先级 +✓ 接收调配提醒 +✓ 审批调配申请(可选流程) +``` + +--- + +## 五、设计师进度大厅模块 + +### 5.1 功能概述 +为管理层提供全局视角,实时查看所有设计师的工作状态、负荷和绩效。 + +### 5.2 设计师看板 + +**看板布局** +``` +┌────────────────────────────────────────────────┐ +│ 🔍 筛选:[部门▼] [在职状态▼] [排序:积分▼] │ +├────────────────────────────────────────────────┤ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ 👤 张三 │ │ 👤 李四 │ │ 👤 王五 │ │ +│ │ UI设计师 │ │ 视觉设计师│ │ 视频剪辑师│ │ +│ ├──────────┤ ├──────────┤ ├──────────┤ │ +│ │📊 负荷:●●│ │📊 负荷:●●│ │📊 负荷:●○│ │ +│ │ ●●●○○ │ │ ●●○○○ │ │ ●○○○○ │ │ +│ │ │ │ │ │ │ │ +│ │⚙️ 进行中:│ │⚙️ 进行中:│ │⚙️ 进行中:│ │ +│ │ 3个 │ │ 2个 │ │ 1个 │ │ +│ │ │ │ │ │ │ │ +│ │📅 排队: │ │📅 排队: │ │📅 排队: │ │ +│ │ 2个 │ │ 1个 │ │ 0个 │ │ +│ │ │ │ │ │ │ │ +│ │💎 累计积分│ │💎 累计积分│ │💎 累计积分│ │ +│ │ 1,250 │ │ 980 │ │ 650 │ │ +│ │ │ │ │ │ │ │ +│ │⭐ 平均评分│ │⭐ 平均评分│ │⭐ 平均评分│ │ +│ │ 4.8⭐ │ │ 4.5⭐ │ │ 4.9⭐ │ │ +│ │ │ │ │ │ │ │ +│ │⚠️ 延误: │ │⚠️ 延误: │ │⚠️ 延误: │ │ +│ │ 0次 │ │ 1次 │ │ 0次 │ │ +│ │ │ │ │ │ │ │ +│ │✅ 完成: │ │✅ 完成: │ │✅ 完成: │ │ +│ │ 25个 │ │ 18个 │ │ 12个 │ │ +│ │ │ │ │ │ │ │ +│ │[查看详情]│ │[查看详情]│ │[查看详情]│ │ +│ └──────────┘ └──────────┘ └──────────┘ │ +└────────────────────────────────────────────────┘ +``` + +**卡片信息项** +| 指标 | 说明 | +|------|------| +| 负荷状态 | 用圆点数量表示当前任务饱和度 | +| 进行中任务 | 当前正在执行的任务数量 | +| 排队任务 | 已接收但未开始的任务数量 | +| 累计积分 | 本月/本季度/累计获得的积分 | +| 平均评分 | 历史任务的平均评分 | +| 延误次数 | 本月/累计延误交付的次数 | +| 完成数量 | 本月/累计完成的任务数量 | + +**负荷状态算法** +``` +负荷值 = (进行中任务数 × 1.5 + 排队任务数) / 设计师容量 +负荷等级: +- 0-2分:空闲(○○○○○) +- 3-4分:轻度(●○○○○) +- 5-6分:适中(●●○○○) +- 7-8分:繁忙(●●●○○) +- 9-10分:饱和(●●●●○) +- 10+分:超负荷(●●●●●)红色警告 +``` + +### 5.3 激励提示系统 + +#### 5.3.1 功能概述 +通过实时排名、成就系统、目标追踪等多种激励手段,激发设计师的工作积极性和竞争意识,营造良性竞争氛围。 + +#### 5.3.2 实时排名提示 + +**个人排名卡片** +``` +┌─────────────────────────────────────┐ +│ 🏆 您的实时排名 │ +├─────────────────────────────────────┤ +│ 本月积分排名:#3 / 28 │ +│ ▲ 较上月上升 2 名 │ +│ │ +│ 💪 再完成 2 个任务即可超越李四! │ +│ (当前:980分,李四:1,050分) │ +│ │ +│ 🎯 本月目标:进入前 3 名 │ +│ 进度:██████░░░░ 60% │ +│ 还差:150 分 │ +│ │ +│ [查看详细排名] [设定新目标] │ +└─────────────────────────────────────┘ +``` + +**排名变化实时提醒** +``` +📢 排名变动通知: +"恭喜!您在本月积分榜上升至第3名,超越了赵六!继续加油💪" + +📢 被超越提醒: +"王五刚刚超过了你,当前排名第4。只差50分就能反超!🔥" + +📢 接近目标提醒: +"距离您的目标(前3名)只差150分了,加油!还剩15天⏰" +``` + +#### 5.3.3 成就勋章系统 + +**成就类别** + +| 成就类型 | 成就名称 | 获得条件 | 奖励 | +|---------|---------|---------|------| +| 数量类 | 🥉 百战老兵 | 累计完成100个任务 | +100积分 | +| 数量类 | 🥈 千锤百炼 | 累计完成500个任务 | +500积分 | +| 数量类 | 🥇 万事通 | 累计完成1000个任务 | +1000积分 | +| 质量类 | ⭐ 完美主义 | 连续10个5星好评 | +200积分 | +| 质量类 | 💎 品质保证 | 月均评分≥4.8分 | +150积分 | +| 质量类 | 👑 质量之王 | 季度评分第一 | +500积分 | +| 效率类 | ⚡ 闪电侠 | 单日完成5个任务 | +50积分 | +| 效率类 | 🚀 极速传说 | 连续7天按时交付 | +200积分 | +| 效率类 | ⏰ 守时达人 | 连续30天零延误 | +500积分 | +| 特殊类 | 🌟 新星 | 新人首月完成20个 | +100积分 | +| 特殊类 | 🔥 月度之星 | 月度积分第一 | +300积分 | +| 特殊类 | 🎖️ 全能战士 | 完成所有类型任务 | +200积分 | + +**成就展示** +``` +┌─────────────────────────────────────┐ +│ 🏅 我的成就勋章 │ +├─────────────────────────────────────┤ +│ 已获得:8个 | 总积分奖励:1,500 │ +│ ───────────────────────────────── │ +│ │ +│ ✨ 最新获得 │ +│ 🥉 百战老兵 │ +│ 2025-11-07 获得 │ +│ 完成第100个任务! │ +│ │ +│ 📋 我的勋章墙 │ +│ 🥇🥈🥉 ⭐💎 ⚡🚀⏰ 🌟🔥🎖️ │ +│ 已解锁:●●●●●●●● ○○○○ │ +│ │ +│ 🎯 即将获得(还差一点) │ +│ ⏰ 守时达人 │ +│ 连续按时交付:21/30天 │ +│ ███████░░░ 70% │ +│ │ +│ 💎 品质保证 │ +│ 月均评分:4.75/4.8分 │ +│ ████████░░ 80% │ +│ │ +│ [查看全部成就] [成就排行榜] │ +└─────────────────────────────────────┘ +``` + +**成就获得通知** +``` +🎉 恭喜!您获得新成就! + +┌─────────────────────────────────────┐ +│ 🥉 百战老兵 │ +│ │ +│ 您已累计完成 100 个任务! │ +│ │ +│ 奖励积分:+100 分 │ +│ 获得时间:2025-11-07 │ +│ │ +│ "每一次交付都是成长的印记" │ +│ │ +│ [分享成就] [继续努力] │ +└─────────────────────────────────────┘ +``` + +#### 5.3.4 里程碑提醒 + +**个人里程碑** +``` +📍 里程碑提醒: + +✅ 已达成 +• 本月完成第10个任务 🎯 +• 本月获得500积分 💎 +• 连续7天按时交付 ⚡ + +🎯 即将达成 +• 本月完成20个任务(当前:18个) + 还差:2个 | 预计:2天后达成 + +• 本月获得1000积分(当前:980分) + 还差:20分 | 下个任务即可达成 + +• 累计完成100个任务(当前:98个) + 还差:2个 | 历史性时刻即将到来! + +💪 挑战目标 +• 本月进入积分榜前3(当前:第4) + 还差:70分 | 完成1个大任务即可 + +• 月均评分达到4.8⭐(当前:4.75⭐) + 需要:接下来3个任务都拿5星 +``` + +#### 5.3.5 积分冲刺提示 + +**月末冲刺** +``` +┌─────────────────────────────────────┐ +│ 🔥 本月积分冲刺 │ +├─────────────────────────────────────┤ +│ 距离月底还有:5天 │ +│ ───────────────────────────────── │ +│ │ +│ 您当前排名:#4 │ +│ 本月积分:980 分 │ +│ │ +│ 🎯 冲刺目标:进入前3名 │ +│ 需要超越:李四(1,050分) │ +│ 还需积分:70+ 分 │ +│ │ +│ 💪 冲刺方案建议: │ +│ • 接1个详情页任务(200分)✅ │ +│ • 或接2个主图任务(100分)✅ │ +│ • 或接3个改图任务(90分)✅ │ +│ │ +│ 📊 成功率预测:78% │ +│ (基于您的历史效率和剩余时间) │ +│ │ +│ [查看可接任务] [设定提醒] │ +└─────────────────────────────────────┘ +``` + +**周冲刺提醒** +``` +📢 周冲刺提醒: +"本周还剩2天,再完成1个任务即可达成本周目标(5个任务)! +任务大厅有3个适合您的主图设计任务,快去抢单吧!🚀" +``` + +#### 5.3.6 同事对比(友好竞争) + +**与同事对比卡片** +``` +┌─────────────────────────────────────┐ +│ 📊 与同事对比 │ +├─────────────────────────────────────┤ +│ 对比对象:李四(同部门/同职级) │ +│ ───────────────────────────────── │ +│ │ +│ 本月任务完成数: │ +│ 你:18个 ████████░░ 90% │ +│ 他:20个 ██████████ 100% │ +│ 差距:-2个 │ +│ │ +│ 本月积分: │ +│ 你:980分 ████████░░ 93% │ +│ 他:1,050分 ██████████ 100% │ +│ 差距:-70分 │ +│ │ +│ 平均评分: │ +│ 你:4.75⭐ █████████░ 95% │ +│ 他:4.8⭐ ██████████ 100% │ +│ 差距:-0.05⭐ │ +│ │ +│ 按时完成率: │ +│ 你:95% ██████████░ 95% │ +│ 他:92% █████████░░ 92% │ +│ 领先:+3% 💪 │ +│ │ +│ 💡 超越建议: │ +│ "再完成2个任务并保持5星好评, │ +│ 本月你将在综合排名上超越李四!" │ +│ │ +│ [换个对比对象] [查看详细数据] │ +└─────────────────────────────────────┘ +``` + +#### 5.3.7 每日激励卡片 + +**每日签到激励** +``` +┌─────────────────────────────────────┐ +│ 🌅 早安,张三! │ +├─────────────────────────────────────┤ +│ 今天是 2025-11-07 周四 │ +│ ───────────────────────────────── │ +│ │ +│ 📅 连续签到:21天 🔥 │ +│ 本月签到:21/30天 │ +│ (连续签到30天可获得勋章+50积分) │ +│ │ +│ 📋 今日待办: │ +│ • 主图设计 #T001(截止今天18:00)⚠️ │ +│ • 详情页优化 #T015(截止明天) │ +│ • 改图 #T028(排队中) │ +│ │ +│ 🎯 今日目标建议: │ +│ • 完成1个任务(当前进度:2/5) │ +│ • 保持5星好评(连续5星:4个) │ +│ • 按时交付(连续按时:21天) │ +│ │ +│ 💪 加油语录: │ +│ "每一次精心设计,都是向卓越迈进!" │ +│ │ +│ [开始工作] [查看任务] │ +└─────────────────────────────────────┘ +``` + +#### 5.3.8 即时激励弹窗 + +**完成任务时** +``` +✨ 任务完成! + +┌─────────────────────────────────────┐ +│ 🎉 恭喜完成任务! │ +├─────────────────────────────────────┤ +│ 任务:主图设计 #T001 │ +│ 用时:1.5天(提前0.5天)✅ │ +│ ───────────────────────────────── │ +│ │ +│ 获得积分:+50分 💎 │ +│ 累计积分:1,030分 │ +│ ───────────────────────────────── │ +│ │ +│ 📊 排名变化 │ +│ 本月积分排名:#4 → #3 ▲ │ +│ 超越了:李四 │ +│ │ +│ 🎯 里程碑 │ +│ • 本月第19个任务 ✅ │ +│ • 连续22天按时交付 🔥 │ +│ • 距离"守时达人"勋章还差8天 │ +│ │ +│ 💪 激励 │ +│ "太棒了!你的速度快人一步!" │ +│ "继续保持,月度之星就是你!" │ +│ │ +│ [分享成果] [接下一个任务] │ +└─────────────────────────────────────┘ +``` + +**获得好评时** +``` +⭐ 获得5星好评! + +┌─────────────────────────────────────┐ +│ 🌟 客户给了你5星好评! │ +├─────────────────────────────────────┤ +│ 任务:详情页优化 #T015 │ +│ 评价:"设计质量非常高,超出预期!" │ +│ ───────────────────────────────── │ +│ │ +│ 📊 评分统计 │ +│ • 连续5星好评:5个 🔥 │ +│ • 本月平均评分:4.8⭐ │ +│ • 好评榜排名:#2 ▲ │ +│ │ +│ 🎯 成就进度 │ +│ • "完美主义"勋章:5/10 个5星 │ +│ █████░░░░░ 50% │ +│ │ +│ 💡 提示 │ +│ 再获得5个5星好评即可解锁"完美主义" │ +│ 勋章,奖励200积分! │ +│ │ +│ [查看评价详情] │ +└─────────────────────────────────────┘ +``` + +#### 5.3.9 竞赛活动提示 + +**月度竞赛** +``` +┌─────────────────────────────────────┐ +│ 🏆 本月竞赛活动 │ +├─────────────────────────────────────┤ +│ 活动主题:"设计马拉松" │ +│ 时间:11月1日-11月30日 │ +│ ───────────────────────────────── │ +│ │ +│ 🎯 竞赛规则 │ +│ • 完成任务数量排名 │ +│ • 平均评分≥4.5分 │ +│ • 零延误记录 │ +│ │ +│ 🏅 奖励设置 │ +│ 🥇 第一名:2000积分 + 荣誉证书 │ +│ 🥈 第二名:1500积分 │ +│ 🥉 第三名:1000积分 │ +│ 🎖️ 4-10名:500积分 │ +│ │ +│ 📊 您的当前排名:#5 │ +│ 完成任务:18个 │ +│ 平均评分:4.75⭐ │ +│ 距离前3名:2个任务的差距 │ +│ │ +│ 💪 冲刺建议 │ +│ "距离活动结束还有5天,加油冲刺!" │ +│ "再完成3个高质量任务,进前3有望!" │ +│ │ +│ [查看排行榜] [活动规则] │ +└─────────────────────────────────────┘ +``` + +#### 5.3.10 团队PK提示 + +**部门团队PK** +``` +┌─────────────────────────────────────┐ +│ ⚔️ 部门PK战 │ +├─────────────────────────────────────┤ +│ 设计部 UI组 vs 视觉组 │ +│ ───────────────────────────────── │ +│ │ +│ 📊 实时战况 │ +│ UI组: │ +│ • 完成任务:45个 ████████░░ 82% │ +│ • 团队积分:2,250分 │ +│ • 平均评分:4.7⭐ │ +│ │ +│ 视觉组: │ +│ • 完成任务:55个 ██████████ 100% │ +│ • 团队积分:2,750分 │ +│ • 平均评分:4.8⭐ │ +│ │ +│ 📈 差距分析 │ +│ • 任务数:落后10个 │ +│ • 积分:落后500分 │ +│ • 每人需完成:2个任务 │ +│ │ +│ 💪 团队激励 │ +│ "UI组加油!我们能行!" │ +│ "张三、李四刚刚各完成1个任务!" │ +│ "王五正在冲刺,距离完成还有2小时!" │ +│ │ +│ 🎁 获胜奖励 │ +│ • 团队成员各+300积分 │ +│ • 团队荣誉墙展示 │ +│ • 团队聚餐基金 │ +│ │ +│ [为团队加油] [查看详情] │ +└─────────────────────────────────────┘ +``` + +#### 5.3.11 智能提醒和建议 + +**任务推荐** +``` +💡 智能推荐 + +基于你的历史表现,以下任务很适合你: + +┌─────────────────────────────────────┐ +│ 🎯 推荐任务 #T088 │ +│ 类型:主图设计 │ +│ 积分:50分 | 时效:1天 │ +│ ───────────────────────────────── │ +│ 推荐原因: │ +│ ✓ 你在该类型任务上平均用时0.8天 │ +│ ✓ 该类型任务的平均评分4.9⭐ │ +│ ✓ 完成后可超越李四,升至第3名! │ +│ ✓ 客户是你的老客户(5次合作) │ +│ │ +│ 预期收益: │ +│ • 积分:+50分 │ +│ • 排名:#4 → #3 │ +│ • 里程碑:本月第20个任务达成 │ +│ │ +│ 成功率预测:95% 💪 │ +│ │ +│ [立即抢单] [查看详情] │ +└─────────────────────────────────────┘ +``` + +**休息提醒** +``` +⏰ 适度休息提醒 + +张三,你今天已经连续工作5小时了, +完成了3个任务,表现非常出色!👍 + +建议休息15-20分钟,劳逸结合效率更高! + +你的今日工作数据: +• 完成任务:3个 ✅ +• 获得积分:150分 💎 +• 工作时长:5小时 +• 效率评分:优秀 ⭐ + +"休息是为了走更远的路" 😊 + +[休息一下] [继续工作] +``` + +#### 5.3.12 可视化激励看板 + +**个人激励看板** +``` +┌─────────────────────────────────────────────────┐ +│ ✨ 我的激励看板 │ +├─────────────────────────────────────────────────┤ +│ │ +│ 🏆 本月成就 │ +│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ +│ │ 积分排名 │ │ 完成任务 │ │ 好评数 │ │ +│ │ #3 │ │ 18个 │ │ 15个 │ │ +│ │ ▲ +2 │ │ ▲ +5 │ │ ▲ +3 │ │ +│ └─────────┘ └─────────┘ └─────────┘ │ +│ │ +│ 📈 趋势曲线(近30天) │ +│ 积分 │ ╱ │ +│ 1000│ ╱ │ +│ 800│╱ │ +│ 600└───────────────── │ +│ 1周 2周 3周 4周 │ +│ │ +│ 🎯 目标进度 │ +│ 月度目标:1200积分 │ +│ 当前进度:980分 ████████░░ 82% │ +│ 还需:220分(预计3天可达成) │ +│ │ +│ 🔥 连续记录 │ +│ • 连续签到:21天 🔥🔥🔥 │ +│ • 连续按时:22天 ⚡⚡⚡ │ +│ • 连续5星:5个 ⭐⭐⭐ │ +│ │ +│ 💎 本月收获 │ +│ • 获得积分:980分 │ +│ • 解锁成就:2个 │ +│ • 获得好评:15个 │ +│ • 被感谢:8次 │ +│ │ +└─────────────────────────────────────────────────┘ +``` + +### 5.4 实时进度跟踪 + +**甘特图视图** +- 横轴:时间线(按天/周显示) +- 纵轴:设计师列表 +- 任务条:显示任务名称、进度、状态 +- 颜色标识: + - 绿色:正常进行 + - 黄色:即将到期 + - 红色:已延期 + - 灰色:已完成 + +**时间线视图示例** +``` +设计师 11/7 11/8 11/9 11/10 11/11 +──────────────────────────────────────────── +张三 [████主图设计█████] + [░░░░详情页░░░░░░░░░░░░] +──────────────────────────────────────────── +李四 [██████视频剪辑████] + [░新品░░░] +──────────────────────────────────────────── +王五 [█改图█] + [░░░░░优化设计░░░░░░░] +``` + +### 5.4 数据分析看板 + +#### 5.4.1 总览卡片 + +**设计师团队总览** +``` +┌─────────────┐ ┌─────────────┐ ┌─────────────┐ +│ 👥 团队人数 │ │ 📊 总任务量 │ │ ✅ 完成率 │ +│ │ │ │ │ │ +│ 28人 │ │ 156个 │ │ 92.3% │ +│ │ │ │ │ │ +│ ↑ 较上月+2 │ │ ↑ 较上月+8 │ │ ↓ 较上月-1%│ +└─────────────┘ └─────────────┘ └─────────────┘ + +┌─────────────┐ ┌─────────────┐ ┌─────────────┐ +│ ⭐ 平均评分 │ │ ⚠️ 延误率 │ │ 💎 总积分 │ +│ │ │ │ │ │ +│ 4.6 ⭐ │ │ 5.2% │ │ 28,500 │ +│ │ │ │ │ │ +│ ↑ 较上月+0.2│ │ ↓ 较上月-2%│ │ ↑ 较上月+3K│ +└─────────────┘ └─────────────┘ └─────────────┘ +``` + +#### 5.4.2 多维度分析图表 + +**A. 任务完成趋势图(折线图)** +- X轴:时间(周/月) +- Y轴:任务数量 +- 多条线:发起、完成、延误 + +**B. 需求类型分布(饼图)** +- 各类型需求占比 +- 点击可查看详细数据 + +**C. 设计师积分排名(柱状图)** +- 显示Top 20 +- 对比上月排名变化 + +**D. 任务时效分析(雷达图)** +- 多维度:质量、时效、沟通、复用率、创新度 + +**E. 品牌/类目热力图** +- 哪些品牌/类目的任务最多 +- 用于资源分配参考 + +**F. 设计师负荷热力图** +- 日历视图 +- 颜色深浅代表负荷程度 + +#### 5.4.3 排行榜 + +**好评榜(五星好评榜)** +``` +排名 设计师 五星数 平均评分 完成任务数 +───────────────────────────────────────── +🥇 张三 45 4.9⭐ 48 +🥈 王五 38 4.8⭐ 42 +🥉 李四 35 4.7⭐ 45 +4 赵六 32 4.6⭐ 40 +5 孙七 30 4.7⭐ 38 +``` + +**积分榜** +``` +排名 设计师 本月积分 累计积分 环比增长 +───────────────────────────────────────── +🥇 李四 1,250 12,500 ↑15% +🥈 张三 1,180 15,200 ↑8% +🥉 王五 980 9,800 ↑12% +``` + +**效率榜(按时完成率)** +``` +排名 设计师 完成任务 按时完成 按时率 +───────────────────────────────────────── +🥇 王五 42 42 100% +🥈 张三 48 47 97.9% +🥉 李四 45 43 95.6% +``` + +**勤奋榜(任务数量)** +``` +排名 设计师 本月完成 累计完成 月均完成 +───────────────────────────────────────── +🥇 张三 48 520 43 +🥈 李四 45 480 40 +🥉 王五 42 450 38 +``` + +#### 5.4.4 高级筛选 + +**筛选维度** +- 时间范围:本周/本月/本季度/本年/自定义 +- 部门/团队 +- 设计师 +- 任务类型 +- 需求类型 +- 品牌 +- 状态 + +**导出功能** +- 导出Excel报表 +- 导出PDF报告 +- 定时邮件推送(周报/月报) + +--- + +## 五之二、需求统计展板模块 + +### 5.5 功能概述 +需求统计展板为管理层和各部门提供全局的需求发起数据分析,通过用户维度和部门维度的多角度统计,帮助了解需求分布、消耗趋势和团队协作情况。 + +### 5.6 统计展板首页 + +**整体布局** +``` +┌──────────────────────────────────────────────────┐ +│ 需求统计展板 │ +├──────────────────────────────────────────────────┤ +│ 筛选:[本月▼] [全部部门▼] [导出报表] │ +│ ────────────────────────────────────────────── │ +│ │ +│ 📊 总览数据卡片 │ +│ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │ +│ │发起总数 │ │消耗积分 │ │完成率 │ │平均时效 │ │ +│ │ 256个 │ │ 12,500 │ │ 92.3% │ │ 1.8天 │ │ +│ │↑ +15% │ │↑ +8% │ │↓ -1.2% │ │↓ -0.3天│ │ +│ └────────┘ └────────┘ └────────┘ └────────┘ │ +│ │ +│ ────────────────────────────────────────────── │ +│ [部门维度] [用户维度] [趋势分析] [类型分析] │ +│ ────────────────────────────────────────────── │ +│ │ +│ (下方显示对应的统计内容) │ +│ │ +└──────────────────────────────────────────────────┘ +``` + +### 5.7 部门维度统计 + +**部门积分余额消耗提醒看板** +``` +┌──────────────────────────────────────────────────┐ +│ 💰 部门积分消耗月报(激励消耗) │ +├──────────────────────────────────────────────────┤ +│ 当前月份:2025年11月 | 剩余天数:⏰ 5天 │ +│ ────────────────────────────────────────────── │ +│ │ +│ 🔥 消耗提醒:月底将至,抓紧消耗您的积分额度! │ +│ │ +│ ┌─────────────────────────────────────────┐ │ +│ │ 产品部 - 负责人:张三 │ │ +│ │ ───────────────────────────────────── │ │ +│ │ 月度积分池:10,000 分 │ │ +│ │ 已使用:4,250 分(42.5%) │ │ +│ │ 剩余可用:5,750 分(57.5%) │ │ +│ │ ▓▓▓▓▓▓▓▓░░░░░░░░░░░░ │ │ +│ │ │ │ +│ │ 💡 建议:还有5,750分可用,可发起约115个 │ │ +│ │ 主图设计任务(50分/个) │ │ +│ │ │ │ +│ │ ⚠️ 提醒:月底剩余积分将清零,请合理利用! │ │ +│ │ [立即发起任务] │ │ +│ └─────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────┐ │ +│ │ 市场部 - 负责人:李四 │ │ +│ │ ───────────────────────────────────── │ │ +│ │ 月度积分池:8,000 分 │ │ +│ │ 已使用:3,400 分(42.5%) │ │ +│ │ 剩余可用:4,600 分(57.5%) │ │ +│ │ ▓▓▓▓▓▓▓▓░░░░░░░░░░░░ │ │ +│ │ │ │ +│ │ 💡 建议:还有4,600分可用 │ │ +│ │ [立即发起任务] │ │ +│ └─────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────┐ │ +│ │ 运营部 - 负责人:王五 │ │ +│ │ ───────────────────────────────────── │ │ +│ │ 月度积分池:5,000 分 │ │ +│ │ 已使用:4,800 分(96%) │ │ +│ │ 剩余可用:200 分(4%)🎉 快消耗完了! │ │ +│ │ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░ │ │ +│ │ │ │ +│ │ ✅ 优秀:积分利用率高达96%! │ │ +│ │ [立即发起任务] │ │ +│ └─────────────────────────────────────────┘ │ +│ │ +│ 🏆 本月积分消耗率排行榜 │ +│ 1. 运营部 96% (4,800/5,000) 🥇 第一名 │ +│ 2. 电商部 85% (3,400/4,000) 🥈 第二名 │ +│ 3. 产品部 42% (4,250/10,000) 🥉 第三名 │ +│ 4. 市场部 42% (3,400/8,000) │ +│ │ +│ ⏰ 月底倒计时:还有 5 天 │ +│ 📢 温馨提示:月底未使用的积分将清零,请各部门 │ +│ 负责人合理安排任务发起,充分利用积分资源! │ +│ │ +│ [查看历史消耗] [导出消耗报告] [钉钉提醒] │ +└──────────────────────────────────────────────────┘ +``` + +**积分消耗提醒规则** +- **绿色区域(0-50%)**:🟢 消耗率偏低,提醒"还有XX分可用,请合理安排任务" +- **黄色区域(51-80%)**:🟡 消耗率适中,提醒"进度良好,还可继续使用" +- **橙色区域(81-95%)**:🟠 消耗率较高,提醒"积分利用率高,再接再厉" +- **红色区域(96-100%)**:🔴 即将用完,祝贺"太棒了!积分利用率极高" + +**月底倒计时提醒** +- 距离月底30天:无特殊提醒 +- 距离月底15天:显示"还有15天,请注意积分使用进度" +- 距离月底7天:显示"⚠️ 还有7天,抓紧时间消耗积分" +- 距离月底3天:显示"🔥 紧急提醒:还有3天!快发起任务" +- 距离月底1天:显示"⚡ 最后1天!积分即将清零" + +**智能建议功能** +系统根据剩余积分和常用任务类型,自动计算并推荐可发起的任务数量: +```javascript +// 智能建议算法 +function calculateSuggestion(remainingScore, deptHistory) { + // 获取部门最常发起的任务类型 + const topTaskTypes = getTopTaskTypes(deptHistory, 3); + + const suggestions = topTaskTypes.map(taskType => { + const count = Math.floor(remainingScore / taskType.unitScore); + return { + taskTypeName: taskType.name, + unitScore: taskType.unitScore, + suggestedCount: count, + totalScore: count * taskType.unitScore + }; + }); + + return suggestions; +} +``` + +**部门排行榜** +``` +┌──────────────────────────────────────────────────┐ +│ 需求部门统计排行(本月) │ +├──────────────────────────────────────────────────┤ +│ │ +│ 排名 部门 发起数 消耗积分 完成率 平均评分│ +│ ──────────────────────────────────────────── │ +│ 🥇 产品部 85个 4,250 94.1% 4.8⭐ │ +│ 负责人:张三 │ +│ 本月积分池:10000分 | 已用:4250分(42%) │ +│ 💰 剩余:5750分 🔥 抓紧消耗 │ +│ [查看详情] [查看明细] [立即发起任务] │ +│ ──────────────────────────────────────────── │ +│ 🥈 市场部 68个 3,400 91.2% 4.7⭐ │ +│ 负责人:李四 │ +│ 本月积分池:8000分 | 已用:3400分(42%) │ +│ 💰 剩余:4600分 🔥 抓紧消耗 │ +│ [查看详情] [查看明细] [立即发起任务] │ +│ ──────────────────────────────────────────── │ +│ 🥉 运营部 52个 4,800 89.8% 4.6⭐ │ +│ 负责人:王五 │ +│ 本月积分池:5000分 | 已用:4800分(96%) │ +│ ✅ 剩余:200分 优秀利用率 │ +│ [查看详情] [查看明细] [立即发起任务] │ +│ ──────────────────────────────────────────── │ +│ 4 电商部 41个 3,400 93.7% 4.9⭐ │ +│ 积分池:4000分 | 已用:3400分(85%) 🟠 │ +│ 5 设计部 10个 500 95.0% 4.8⭐ │ +│ 积分池:6000分 | 已用:500分(8%) 🟢 │ +│ │ +│ [查看全部部门] │ +└──────────────────────────────────────────────────┘ +``` + +**部门详情卡片** +``` +┌──────────────────────────────────────────────────┐ +│ 产品部 - 需求统计详情 │ +├──────────────────────────────────────────────────┤ +│ 负责人:张三 | 成员:15人 │ +│ ────────────────────────────────────────────── │ +│ │ +│ 📈 本月概况(2025-11) │ +│ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │ +│ │发起任务 │ │消耗积分 │ │完成任务 │ │进行中 │ │ +│ │ 85个 │ │ 4,250 │ │ 71个 │ │ 12个 │ │ +│ └────────┘ └────────┘ └────────┘ └────────┘ │ +│ │ +│ 💰 积分使用情况 │ +│ 本月限制:2,000 分 │ +│ 实际消耗:4,250 分 ⚠️(超限2,250分) │ +│ 进度:████████████████████░ 212% │ +│ │ +│ ⚠️ 提示:已超出本月限制,请控制需求发起 │ +│ │ +│ 📊 需求类型分布 │ +│ ┌────────────────────────────────────────┐ │ +│ │ ████████ 主图设计 35个 (41%) │ │ +│ │ ██████ 详情页设计 25个 (29%) │ │ +│ │ ████ 视频剪辑 15个 (18%) │ │ +│ │ ██ 改图 10个 (12%) │ │ +│ └────────────────────────────────────────┘ │ +│ │ +│ 📅 每周发起趋势 │ +│ ┌────────────────────────────────────────┐ │ +│ │ 25│ ● │ │ +│ │ 20│ ● ● │ │ +│ │ 15│ ● ● │ │ +│ │ 10│ ● ● │ │ +│ │ 0└───────────────────── │ │ +│ │ W1 W2 W3 W4 W5 │ │ +│ └────────────────────────────────────────┘ │ +│ │ +│ 👥 部门成员发起排名(Top 5) │ +│ 1. 张三 28个 1,400分 ⭐4.9 │ +│ 2. 李四 22个 1,100分 ⭐4.8 │ +│ 3. 王五 18个 900分 ⭐4.7 │ +│ 4. 赵六 12个 600分 ⭐4.6 │ +│ 5. 孙七 5个 250分 ⭐4.8 │ +│ │ +│ [导出明细] [设置消耗限制] [积分充值] │ +└──────────────────────────────────────────────────┘ +``` + +### 5.8 用户维度统计 + +**用户发起排行榜** +``` +┌──────────────────────────────────────────────────┐ +│ 需求发起用户排行(本月) │ +├──────────────────────────────────────────────────┤ +│ 🔍 搜索:[姓名/工号] 筛选:[部门▼] [排序▼] │ +│ ────────────────────────────────────────────── │ +│ │ +│ 排名 用户 部门 发起数 消耗积分 完成率│ +│ ──────────────────────────────────────────── │ +│ 🥇 张三 产品部 28个 1,400 96.4% │ +│ 最常发起:主图设计(12个) │ +│ 平均评分:4.9⭐ │ +│ [查看详情] │ +│ ──────────────────────────────────────────── │ +│ 🥈 李四 市场部 25个 1,250 92.0% │ +│ 最常发起:详情页设计(10个) │ +│ 平均评分:4.8⭐ │ +│ [查看详情] │ +│ ──────────────────────────────────────────── │ +│ 🥉 王五 产品部 22个 1,100 90.9% │ +│ 最常发起:视频剪辑(8个) │ +│ 平均评分:4.7⭐ │ +│ [查看详情] │ +│ ──────────────────────────────────────────── │ +│ 4 赵六 运营部 20个 1,000 95.0% │ +│ 5 孙七 市场部 18个 900 88.9% │ +│ ... │ +│ │ +│ [查看全部] [导出列表] │ +└──────────────────────────────────────────────────┘ +``` + +**用户详情分析** +``` +┌──────────────────────────────────────────────────┐ +│ 张三 - 需求发起详情 │ +├──────────────────────────────────────────────────┤ +│ 👤 产品部 - 产品经理 | 工号:EP001 │ +│ ────────────────────────────────────────────── │ +│ │ +│ 📊 统计概览 │ +│ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │ +│ │本月发起 │ │消耗积分 │ │完成率 │ │平均评分 │ │ +│ │ 28个 │ │ 1,400 │ │ 96.4% │ │ 4.9⭐ │ │ +│ │↑ +5个 │ │↑ +250 │ │↑ +2.1% │ │持平 │ │ +│ └────────┘ └────────┘ └────────┘ └────────┘ │ +│ │ +│ 📈 需求类型偏好 │ +│ ┌────────────────────────────────────────┐ │ +│ │ ████████████ 主图设计 12个 (43%) │ │ +│ │ ████████ 详情页设计 8个 (29%) │ │ +│ │ ████ 视频剪辑 5个 (18%) │ │ +│ │ ██ 改图 3个 (11%) │ │ +│ └────────────────────────────────────────┘ │ +│ │ +│ 🎯 品牌分布 │ +│ Nike: 10个 | Adidas: 8个 | Puma: 6个 | 其他: 4个│ +│ │ +│ 📅 发起时间分布 │ +│ ┌────────────────────────────────────────┐ │ +│ │ 周一: ███ 5个 │ │ +│ │ 周二: ████ 6个 │ │ +│ │ 周三: ████ 6个 │ │ +│ │ 周四: ███ 5个 │ │ +│ │ 周五: ██ 4个 │ │ +│ │ 周六: █ 1个 │ │ +│ │ 周日: █ 1个 │ │ +│ └────────────────────────────────────────┘ │ +│ │ +│ ⏱️ 平均任务时效:1.6天 │ +│ 📍 最常合作设计师:李四(接收8个任务) │ +│ │ +│ 📋 最近发起的任务(Top 5) │ +│ 1. #T001 主图设计 已完成 ⭐5.0 2天 │ +│ 2. #T015 详情页优化 进行中 - - │ +│ 3. #T028 视频剪辑 已完成 ⭐4.8 1.5天 │ +│ 4. #T042 改图 已完成 ⭐5.0 0.5天 │ +│ 5. #T055 新品设计 待接收 - - │ +│ │ +│ [查看全部任务] [导出数据] │ +└──────────────────────────────────────────────────┘ +``` + +### 5.9 趋势分析 + +**时间趋势图** +``` +┌──────────────────────────────────────────────────┐ +│ 需求发起趋势分析 │ +├──────────────────────────────────────────────────┤ +│ 时间范围:[近6个月▼] 对比:[同比▼] │ +│ ────────────────────────────────────────────── │ +│ │ +│ 📈 需求发起数量趋势 │ +│ 300│ │ +│ │ ●● │ +│ 250│ ●● │ +│ │ ●● │ +│ 200│ ●● │ +│ │ ●● │ +│ 150│ ●● │ +│ │ ●● │ +│ 100│ ●● │ +│ └───────────────────────────────── │ +│ 6月 7月 8月 9月 10月 11月 │ +│ │ +│ 💰 积分消耗趋势 │ +│ 15K│ │ +│ │ ●● │ +│ 12K│ ●● │ +│ │ ●● │ +│ 9K│ ●● │ +│ │ ●● │ +│ 6K│ ●● │ +│ │ ●● │ +│ 3K│ ●● │ +│ └───────────────────────────────── │ +│ 6月 7月 8月 9月 10月 11月 │ +│ │ +│ 📊 环比数据 │ +│ 本月 vs 上月: │ +│ - 发起数:256个 vs 223个 (↑14.8%) │ +│ - 消耗积分:12,500 vs 11,580 (↑7.9%) │ +│ - 完成率:92.3% vs 93.5% (↓1.3%) │ +│ │ +└──────────────────────────────────────────────────┘ +``` + +### 5.10 类型分析 + +**需求类型统计** +``` +┌──────────────────────────────────────────────────┐ +│ 需求类型分析 │ +├──────────────────────────────────────────────────┤ +│ │ +│ 📊 类型分布(本月) │ +│ ┌────────────────────────────────────────┐ │ +│ │ │ │ +│ │ ██████████ 主图设计 40% │ │ +│ │ ███████ 详情页设计 28% │ │ +│ │ █████ 视频剪辑 20% │ │ +│ │ ███ 改图 12% │ │ +│ │ │ │ +│ └────────────────────────────────────────┘ │ +│ │ +│ 类型 数量 占比 平均积分 平均时效 完成率 │ +│ ──────────────────────────────────────────── │ +│ 主图 102 40% 50分 1.5天 94% │ +│ 详情 72 28% 200分 2.5天 91% │ +│ 视频 51 20% 150分 2.0天 89% │ +│ 改图 31 12% 30分 0.8天 98% │ +│ │ +│ 📈 类型趋势对比 │ +│ (折线图显示各类型近6个月的变化) │ +│ │ +│ 🏆 最受欢迎:主图设计(102个,4,590分) │ +│ 💎 最高价值:详情页设计(平均200分/个) │ +│ ⚡ 最快完成:改图(平均0.8天) │ +│ │ +└──────────────────────────────────────────────────┘ +``` + +### 5.11 导出与报表 + +**导出功能** +``` +支持导出格式: +✓ Excel(详细明细) +✓ PDF(可视化报表) +✓ CSV(原始数据) +✓ 图片(图表截图) + +导出内容: +✓ 部门统计数据 +✓ 用户发起明细 +✓ 任务完成情况 +✓ 积分消耗记录 +✓ 趋势分析图表 + +定时报表: +✓ 每周一早9:00发送上周统计 +✓ 每月1号早9:00发送上月统计 +✓ 季度/年度报告 +``` + +**报表订阅** +``` +┌───────────────────────────────┐ +│ 统计报表订阅 │ +├───────────────────────────────┤ +│ 订阅类型: │ +│ ☑ 周报(每周一) │ +│ ☑ 月报(每月1号) │ +│ ☐ 季报(每季度首日) │ +│ │ +│ 接收人: │ +│ ☑ 自己 │ +│ ☑ 部门负责人 │ +│ ☑ 上级领导 │ +│ ☐ 其他人员 [添加] │ +│ │ +│ 发送方式: │ +│ ☑ 邮件 │ +│ ☑ 钉钉 │ +│ ☐ 短信 │ +│ │ +│ [取消] [保存] │ +└───────────────────────────────┘ +``` + +--- + +## 六、系统配置模块 + +### 6.1 功能清单 + +#### 6.1.1 需求类型配置 +``` +列表视图: +ID 类型名称 单价分值 预估耗时 状态 操作 +─────────────────────────────────────────────── +1 主图设计 50 4小时 启用 [编辑] [禁用] +2 详情页设计 200 2天 启用 [编辑] [禁用] +3 视频剪辑 150 1天 启用 [编辑] [禁用] +4 图片修图 30 2小时 禁用 [编辑] [启用] + +[+ 新增需求类型] +``` + +**表单字段** +- 类型名称(必填) +- 单价分值(必填,数字) +- 预估耗时(必填,小时/天) +- 类型描述(选填,富文本) +- 交付要求(选填,富文本) +- 排序(数字,用于前端显示顺序) +- 状态(启用/禁用) + +#### 6.1.2 品牌配置 +``` +列表视图(卡片或表格): + +┌──────────┐ ┌──────────┐ ┌──────────┐ +│ [LOGO] │ │ [LOGO] │ │ [LOGO] │ +│ Nike │ │ Adidas │ │ Puma │ +│ │ │ │ │ │ +│ 任务数:25│ │ 任务数:18│ │ 任务数:12│ +│ │ │ │ │ │ +│[编辑][删] │ │[编辑][删] │ │[编辑][删] │ +└──────────┘ └──────────┘ └──────────┘ + +[+ 新增品牌] +``` + +**表单字段** +- 品牌名称(必填) +- 品牌LOGO(图片上传) +- 品牌官网(选填) +- 品牌说明(选填) +- 设计风格指南(选填,富文本或附件) +- 排序 +- 状态 + +#### 6.1.3 类目配置 +``` +树形结构视图: + ++ 服装 + + 女装 + - 连衣裙 [编辑] [删除] [添加子类目] + - T恤 [编辑] [删除] + - 裤子 [编辑] [删除] + + 男装 + - 衬衫 [编辑] [删除] + - 牛仔裤 [编辑] [删除] ++ 鞋类 + + 运动鞋 + - 跑步鞋 [编辑] [删除] + - 篮球鞋 [编辑] [删除] ++ 配饰 + +[+ 新增一级类目] +``` + +**表单字段** +- 类目名称(必填) +- 父级类目(选择,为空则为一级类目) +- 类目描述(选填) +- 是否叶子类目(系统自动判断) +- 排序 +- 状态 + +**功能要求** +- 支持拖拽排序 +- 支持批量导入(Excel) +- 支持批量操作(启用/禁用/删除) +- 删除校验(有关联任务时不允许删除) + +#### 6.1.4 平台配置 +``` +列表视图: + +┌──────────┐ ┌──────────┐ ┌──────────┐ +│ [图标] │ │ [图标] │ │ [图标] │ +│ 淘宝 │ │ 天猫 │ │ 京东 │ +│ │ │ │ │ │ +│ 任务数:88│ │ 任务数:56│ │ 任务数:34│ +│ │ │ │ │ │ +│[编辑][删] │ │[编辑][删] │ │[编辑][删] │ +└──────────┘ └──────────┘ └──────────┘ + +[+ 新增平台] +``` + +**表单字段** +- 平台名称(必填) +- 平台图标(图片上传) +- 平台官网(选填) +- 设计规范说明(选填,富文本或附件) +- 排序 +- 状态 + +#### 6.1.5 积分管理 + +**团队积分池配置** +``` +团队列表: +团队名称 当前积分 已使用 总充值 操作 +───────────────────────────────────────────── +产品一部 2,500 7,500 10,000 [充值] [明细] +产品二部 1,200 3,800 5,000 [充值] [明细] +市场部 800 1,200 2,000 [充值] [明细] + +充值记录:[查看所有充值记录] +``` + +**充值表单** +- 选择团队 +- 充值积分数量 +- 充值原因/说明 +- 审批人(可选) + +**积分明细** +- 时间 +- 操作类型(充值/消耗/退回) +- 积分变动 +- 关联任务(如果是消耗) +- 操作人 +- 余额 + +#### 6.1.6 延误扣分规则配置 + +**延误扣分规则管理** +``` +┌──────────────────────────────────────────────┐ +│ 延误扣分规则配置 │ +├──────────────────────────────────────────────┤ +│ 规则状态:● 启用 ○ 禁用 │ +│ ──────────────────────────────────────── │ +│ 延误等级配置: │ +│ │ +│ ┌─────────────────────────────────────┐ │ +│ │ 轻微延误 │ │ +│ │ 延误时长:< 2 小时 │ │ +│ │ 扣分规则:每小时 [2] 分 │ │ +│ │ 状态:启用 [编辑] [删除] │ │ +│ └─────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────┐ │ +│ │ 一般延误 │ │ +│ │ 延误时长:2-8 小时 │ │ +│ │ 扣分规则:每小时 [3] 分 │ │ +│ │ 状态:启用 [编辑] [删除] │ │ +│ └─────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────┐ │ +│ │ 严重延误 │ │ +│ │ 延误时长:8-24 小时 │ │ +│ │ 扣分规则:每小时 [5] 分 │ │ +│ │ 状态:启用 [编辑] [删除] │ │ +│ └─────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────┐ │ +│ │ 重大延误 │ │ +│ │ 延误时长:> 24 小时 │ │ +│ │ 扣分规则:基础 [50] 分 + 每天 [10] 分│ │ +│ │ 状态:启用 [编辑] [删除] │ │ +│ └─────────────────────────────────────┘ │ +│ │ +│ [+ 新增延误等级] │ +│ ──────────────────────────────────────── │ +│ 其他设置: │ +│ ☑ 任务转移时自动计算延误扣分 │ +│ ☑ 允许设计师申诉延误扣分 │ +│ ☑ 显示延误预警提醒 │ +│ □ 延误扣分翻倍(重复延误) │ +│ │ +│ 申诉审核: │ +│ 审核人:[主管] ▼ │ +│ 审核时限:[48小时] │ +│ │ +│ [重置为默认] [保存配置] │ +└──────────────────────────────────────────────┘ +``` + +**编辑延误等级表单** +``` +┌───────────────────────────────┐ +│ 编辑延误等级 │ +├───────────────────────────────┤ +│ 等级名称: │ +│ [一般延误] │ +│ │ +│ 延误时长范围: │ +│ 最小:[2] 小时 │ +│ 最大:[8] 小时 │ +│ │ +│ 扣分规则: │ +│ ○ 固定扣分 │ +│ ● 按时长扣分 │ +│ ○ 阶梯扣分 │ +│ │ +│ 按时长扣分参数: │ +│ 每小时扣:[3] 分 │ +│ 最高扣分:[24] 分(可选) │ +│ │ +│ 说明: │ +│ ┌─────────────────────────┐ │ +│ │ 一般程度的延误... │ │ +│ └─────────────────────────┘ │ +│ │ +│ 状态:● 启用 ○ 禁用 │ +│ │ +│ [取消] [保存] │ +└───────────────────────────────┘ +``` + +**延误扣分预览测试** +``` +输入延误时长:[10] 小时 + +计算结果: +- 匹配规则:严重延误(8-24小时) +- 扣分规则:每小时 5 分 +- 计算过程:10 × 5 = 50 分 +- 扣分结果:-50 分 + +[重新计算] +``` + +#### 6.1.7 系统参数配置 + +**通用配置** +``` +参数名称 当前值 操作 +─────────────────────────────────────────────── +设计师最大任务并发数 5 [修改] +任务抢单超时时间(分钟) 30 [修改] +任务延期预警提前时间(小时) 24 [修改] +积分预警阈值 500 [修改] +文件上传大小限制(MB) 100 [修改] +任务自动关闭时间(天) 30 [修改] +时效重新计算默认系数 1.0 [修改] +设计师熟练度系数范围 0.8-1.2 [修改] +``` + +**通知配置** +- 站内消息开关 +- 钉钉通知开关 +- 邮件通知开关 +- 延误扣分通知开关 +- 通知模板配置 + +#### 6.1.8 激励系统配置 + +**功能概述** +管理员可以在后台对激励系统进行全面配置,包括: +- **激励游戏开关**:控制各类激励功能的启用/禁用 +- **奖励积分设置**:为不同激励行为配置奖励积分 +- **自动奖励周期**:设置按周/月自动发放奖励 +- **激励规则定制**:自定义成就条件、排名规则等 + +##### 6.1.8.1 激励游戏总开关 + +``` +┌──────────────────────────────────────────────┐ +│ 激励系统总控制面板 │ +├──────────────────────────────────────────────┤ +│ 系统状态:● 全局启用 ○ 全局禁用 │ +│ ──────────────────────────────────────── │ +│ 功能模块开关: │ +│ │ +│ ┌─────────────────────────────────────┐ │ +│ │ 📊 实时排名系统 │ │ +│ │ 状态:● 启用 ○ 禁用 │ │ +│ │ 更新频率:[实时] ▼ │ │ +│ │ 排名范围:[全体] ▼ │ │ +│ │ [高级设置] │ │ +│ └─────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────┐ │ +│ │ 🏅 成就勋章系统 │ │ +│ │ 状态:● 启用 ○ 禁用 │ │ +│ │ 弹窗提醒:☑ 启用 │ │ +│ │ 勋章墙展示:☑ 启用 │ │ +│ │ [成就管理] [奖励设置] │ │ +│ └─────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────┐ │ +│ │ 🎯 里程碑提醒系统 │ │ +│ │ 状态:● 启用 ○ 禁用 │ │ +│ │ 提醒时机:☑ 达成时 ☑ 接近时 │ │ +│ │ 奖励发放:● 自动 ○ 手动 │ │ +│ │ [里程碑管理] │ │ +│ └─────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────┐ │ +│ │ 🚀 积分冲刺提示 │ │ +│ │ 状态:● 启用 ○ 禁用 │ │ +│ │ 提醒周期:[每周一] ▼ │ │ +│ │ 冲刺奖励:● 启用 ○ 禁用 │ │ +│ │ [冲刺规则设置] │ │ +│ └─────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────┐ │ +│ │ 👥 同事对比(友好竞争) │ │ +│ │ 状态:● 启用 ○ 禁用 │ │ +│ │ 对比维度:☑ 任务数 ☑ 积分 ☑ 评分 │ │ +│ │ 显示模式:[匿名对比] ▼ │ │ +│ │ [对比规则] │ │ +│ └─────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────┐ │ +│ │ 📅 每日激励卡片 │ │ +│ │ 状态:● 启用 ○ 禁用 │ │ +│ │ 展示时间:[登录时] ▼ │ │ +│ │ 签到奖励:● 启用 ○ 禁用 │ │ +│ │ [卡片模板] [签到规则] │ │ +│ └─────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────┐ │ +│ │ ⚡ 即时激励弹窗 │ │ +│ │ 状态:● 启用 ○ 禁用 │ │ +│ │ 触发场景:☑ 任务完成 ☑ 好评收到 │ │ +│ │ 弹窗频率:[适中] ▼ │ │ +│ │ [弹窗模板] │ │ +│ └─────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────┐ │ +│ │ 🏆 竞赛活动系统 │ │ +│ │ 状态:● 启用 ○ 禁用 │ │ +│ │ 当前竞赛:[月度设计之星] [管理] │ │ +│ │ 下期竞赛:[未设置] [创建] │ │ +│ │ [竞赛历史] [规则模板] │ │ +│ └─────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────┐ │ +│ │ ⚔️ 团队PK系统 │ │ +│ │ 状态:● 启用 ○ 禁用 │ │ +│ │ PK周期:[每月] ▼ │ │ +│ │ 奖励分配:[胜方额外20%积分] ▼ │ │ +│ │ [PK管理] [历史战况] │ │ +│ └─────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────┐ │ +│ │ 🤖 智能提醒和建议 │ │ +│ │ 状态:● 启用 ○ 禁用 │ │ +│ │ 任务推荐:☑ 启用 │ │ +│ │ 休息提醒:☑ 启用(连续4小时触发) │ │ +│ │ [推荐算法调优] │ │ +│ └─────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────┐ │ +│ │ 📊 可视化激励看板 │ │ +│ │ 状态:● 启用 ○ 禁用 │ │ +│ │ 默认视图:[综合视图] ▼ │ │ +│ │ 数据范围:[近6个月] ▼ │ │ +│ │ [看板布局] │ │ +│ └─────────────────────────────────────┘ │ +│ │ +│ [全部启用] [全部禁用] [保存设置] │ +└──────────────────────────────────────────────┘ +``` + +##### 6.1.8.2 成就勋章配置 + +**成就勋章列表** +``` +┌──────────────────────────────────────────────────────────┐ +│ 成就勋章管理 │ +├──────────────────────────────────────────────────────────┤ +│ 分类:[全部] [数量类] [质量类] [效率类] [特殊类] │ +│ 状态:[全部] [启用] [禁用] [+ 新增成就] │ +│ ────────────────────────────────────────────────────── │ +│ │ +│ 勋章 名称 类别 触发条件 奖励 状态 │ +│ ──────────────────────────────────────────────────── │ +│ 🏆 新手上路 数量 完成首个任务 +10 启用 [编辑]│ +│ 💯 百任达人 数量 累计完成100任务 +100 启用 [编辑]│ +│ ⭐ 五星之光 质量 连续10个五星好评 +50 启用 [编辑]│ +│ 🚀 效率之王 效率 10次提前完成任务 +80 启用 [编辑]│ +│ 🎯 精准狙击 效率 单日完成5个任务 +60 启用 [编辑]│ +│ 🏅 月度冠军 特殊 月度排名第一 +200 启用 [编辑]│ +│ 💪 连续奋进 特殊 连续签到30天 +30 启用 [编辑]│ +│ 🎨 创意大师 质量 获得20个客户表扬 +150 启用 [编辑]│ +│ ⏰ 守时模范 效率 30天零延误 +100 启用 [编辑]│ +│ 🌟 全能选手 综合 各类任务都完成 +120 启用 [编辑]│ +│ 📈 进步之星 特殊 月度排名提升10位 +50 禁用 [编辑]│ +│ │ +└──────────────────────────────────────────────────────────┘ +``` + +**成就编辑表单** +``` +┌───────────────────────────────────────┐ +│ 编辑成就勋章 │ +├───────────────────────────────────────┤ +│ 成就名称: │ +│ [五星之光] │ +│ │ +│ 成就图标: │ +│ [⭐] [选择图标] [上传自定义] │ +│ │ +│ 成就类别: │ +│ ○ 数量类 ● 质量类 ○ 效率类 │ +│ ○ 特殊类 ○ 综合类 │ +│ │ +│ 触发条件类型: │ +│ [连续好评次数] ▼ │ +│ │ +│ 条件参数设置: │ +│ 连续获得:[10] 次 │ +│ 评分要求:[5] 星 │ +│ 时间限制:[不限] ▼ │ +│ │ +│ 成就描述: │ +│ ┌─────────────────────────────────┐ │ +│ │ 连续获得10个五星好评,你就是客户│ │ +│ │ 心中的设计之光! │ │ +│ └─────────────────────────────────┘ │ +│ │ +│ 奖励设置: │ +│ 奖励积分:[50] 分 │ +│ 额外奖励:[称号:五星设计师] │ +│ 奖励有效期:[永久] ▼ │ +│ │ +│ 通知设置: │ +│ ☑ 弹窗通知(即时) │ +│ ☑ 站内消息 │ +│ ☑ 钉钉推送 │ +│ ☑ 勋章墙展示 │ +│ │ +│ 成就等级:(多级成就) │ +│ ○ 单级成就 │ +│ ● 多级成就: │ +│ - 铜牌:5次五星 奖励20分 │ +│ - 银牌:10次五星 奖励50分 │ +│ - 金牌:20次五星 奖励100分 │ +│ [+ 添加等级] │ +│ │ +│ 成就状态: │ +│ ● 启用 ○ 禁用 │ +│ │ +│ 排序:[5] │ +│ │ +│ [取消] [保存] │ +└───────────────────────────────────────┘ +``` + +##### 6.1.8.3 自动奖励规则配置 + +**自动奖励周期设置** +``` +┌──────────────────────────────────────────────┐ +│ 自动奖励规则配置 │ +├──────────────────────────────────────────────┤ +│ 功能状态:● 启用自动奖励 ○ 禁用 │ +│ ──────────────────────────────────────── │ +│ 奖励周期配置: │ +│ │ +│ ┌─────────────────────────────────────┐ │ +│ │ 📅 每日奖励(按天) │ │ +│ │ 状态:● 启用 │ │ +│ │ ───────────────────────────────── │ │ +│ │ • 每日签到奖励:+5 分 │ │ +│ │ • 连续签到3天:+10 分 │ │ +│ │ • 连续签到7天:+30 分 │ │ +│ │ • 连续签到30天:+150 分 │ │ +│ │ • 完成每日目标:+20 分 │ │ +│ │ │ │ +│ │ 发放时间:[自动,完成时即时发放] │ │ +│ │ [编辑规则] │ │ +│ └─────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────┐ │ +│ │ 📊 每周奖励(按周) │ │ +│ │ 状态:● 启用 │ │ +│ │ 发放时间:[每周一 09:00] │ │ +│ │ ───────────────────────────────── │ │ +│ │ 周度排名奖励: │ │ +│ │ • 第1名:+300 分 │ │ +│ │ • 第2名:+200 分 │ │ +│ │ • 第3名:+150 分 │ │ +│ │ • 第4-10名:+100 分 │ │ +│ │ │ │ +│ │ 周度任务量奖励: │ │ +│ │ • 完成≥20个任务:+200 分 │ │ +│ │ • 完成15-19个任务:+150 分 │ │ +│ │ • 完成10-14个任务:+100 分 │ │ +│ │ • 完成5-9个任务:+50 分 │ │ +│ │ │ │ +│ │ 周度好评奖励: │ │ +│ │ • 五星好评率≥95%:+150 分 │ │ +│ │ • 五星好评率≥90%:+100 分 │ │ +│ │ • 五星好评率≥85%:+50 分 │ │ +│ │ │ │ +│ │ 周度零延误奖励: │ │ +│ │ • 全周无延误:+100 分 │ │ +│ │ • 延误≤1次:+50 分 │ │ +│ │ │ │ +│ │ [编辑规则] [测试发放] │ │ +│ └─────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────┐ │ +│ │ 📆 每月奖励(按月) │ │ +│ │ 状态:● 启用 │ │ +│ │ 发放时间:[每月1日 10:00] │ │ +│ │ ───────────────────────────────── │ │ +│ │ 月度排名奖励: │ │ +│ │ • 冠军(第1名):+1000 分 + 🏆 │ │ +│ │ • 亚军(第2名):+800 分 + 🥈 │ │ +│ │ • 季军(第3名):+600 分 + 🥉 │ │ +│ │ • 第4-10名:+400 分 │ │ +│ │ • 第11-20名:+200 分 │ │ +│ │ │ │ +│ │ 月度任务量奖励: │ │ +│ │ • 完成≥80个任务:+800 分 │ │ +│ │ • 完成60-79个任务:+600 分 │ │ +│ │ • 完成40-59个任务:+400 分 │ │ +│ │ • 完成20-39个任务:+200 分 │ │ +│ │ │ │ +│ │ 月度质量奖励: │ │ +│ │ • 五星好评率≥98%:+500 分 │ │ +│ │ • 五星好评率≥95%:+300 分 │ │ +│ │ • 五星好评率≥90%:+200 分 │ │ +│ │ │ │ +│ │ 月度效率奖励: │ │ +│ │ • 平均提前完成≥2天:+400 分 │ │ +│ │ • 平均提前完成≥1天:+300 分 │ │ +│ │ • 准时完成率100%:+200 分 │ │ +│ │ │ │ +│ │ 月度进步奖: │ │ +│ │ • 排名提升≥20位:+300 分 │ │ +│ │ • 排名提升≥10位:+200 分 │ │ +│ │ • 排名提升≥5位:+100 分 │ │ +│ │ │ │ +│ │ 月度全勤奖: │ │ +│ │ • 全月签到:+200 分 │ │ +│ │ • 签到≥25天:+100 分 │ │ +│ │ │ │ +│ │ [编辑规则] [测试发放] [历史记录] │ │ +│ └─────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────┐ │ +│ │ 🎊 季度/年度特殊奖励 │ │ +│ │ 状态:● 启用 │ │ +│ │ ───────────────────────────────── │ │ +│ │ 季度MVP:+3000 分 + 专属称号 │ │ +│ │ 年度最佳设计师:+10000 分 + 实物奖励 │ │ +│ │ 年度进步之星:+5000 分 │ │ +│ │ │ │ +│ │ [编辑规则] │ │ +│ └─────────────────────────────────────┘ │ +│ │ +│ ────────────────────────────────────── │ +│ 奖励发放设置: │ +│ ☑ 自动计算并发放 │ +│ ☑ 发放前管理员审核 │ +│ ☑ 发放后通知接收人 │ +│ ☑ 发放记录留存归档 │ +│ │ +│ 积分来源: │ +│ ● 系统积分池(无限) │ +│ ○ 指定部门积分池:[运营部] ▼ │ +│ │ +│ [重置为默认] [测试奖励计算] [保存配置] │ +└──────────────────────────────────────────────┘ +``` + +**自动奖励发放日志** +``` +┌──────────────────────────────────────────────────────┐ +│ 自动奖励发放记录 │ +├──────────────────────────────────────────────────────┤ +│ 筛选:[本周] [本月] [本季度] [导出记录] │ +│ ──────────────────────────────────────────────── │ +│ │ +│ 发放时间 周期 规则名称 发放人数 总积分│ +│ ────────────────────────────────────────────────── │ +│ 2025-11-04 09:00 每周 周度排名奖励 10人 1,500│ +│ 2025-11-04 09:00 每周 周度任务量奖励 15人 1,800│ +│ 2025-11-04 09:00 每周 周度好评奖励 12人 1,200│ +│ 2025-11-04 09:00 每周 周度零延误奖励 8人 800 │ +│ 2025-11-01 10:00 每月 月度排名奖励 20人 8,000│ +│ 2025-11-01 10:00 每月 月度质量奖励 25人 7,500│ +│ 2025-11-01 10:00 每月 月度效率奖励 18人 5,400│ +│ │ +│ [查看详情] [重新发放] [撤销发放] │ +└──────────────────────────────────────────────────────┘ +``` + +##### 6.1.8.4 竞赛活动配置 + +**竞赛活动管理** +``` +┌──────────────────────────────────────────────┐ +│ 竞赛活动管理 │ +├──────────────────────────────────────────────┤ +│ [+ 创建新竞赛] [竞赛模板] │ +│ ──────────────────────────────────────── │ +│ 当前竞赛: │ +│ │ +│ ┌─────────────────────────────────────┐ │ +│ │ 🏆 11月设计之星月度挑战赛 │ │ +│ │ 状态:● 进行中 │ │ +│ │ 时间:2025-11-01 至 2025-11-30 │ │ +│ │ 参与人数:28人 │ │ +│ │ ───────────────────────────────── │ │ +│ │ 竞赛规则: │ │ +│ │ • 完成任务数 × 2分 │ │ +│ │ • 五星好评 × 10分 │ │ +│ │ • 提前完成 × 5分 │ │ +│ │ • 被表扬 × 15分 │ │ +│ │ │ │ +│ │ 奖励设置: │ │ +│ │ • 第1名:+1500分 + 月度冠军勋章 │ │ +│ │ • 第2名:+1000分 + 月度亚军勋章 │ │ +│ │ • 第3名:+800分 + 月度季军勋章 │ │ +│ │ • 参与奖:完成≥10任务 +100分 │ │ +│ │ │ │ +│ │ 实时排名:[查看] [导出] │ │ +│ │ │ │ +│ │ [编辑] [暂停] [提前结束] [结算奖励] │ │ +│ └─────────────────────────────────────┘ │ +│ │ +│ 即将开始: │ +│ • 无 [创建下期] │ +│ │ +│ 历史竞赛: │ +│ • 10月设计之星(已结束) [查看] │ +│ • 9月效率挑战赛(已结束) [查看] │ +│ • 8月质量月(已结束) [查看] │ +│ │ +└──────────────────────────────────────────────┘ +``` + +**创建竞赛表单** +``` +┌───────────────────────────────────────┐ +│ 创建竞赛活动 │ +├───────────────────────────────────────┤ +│ 竞赛名称: │ +│ [12月年终冲刺赛] │ +│ │ +│ 竞赛类型: │ +│ ● 月度竞赛 ○ 周度竞赛 ○ 专项竞赛 │ +│ │ +│ 竞赛时间: │ +│ 开始:[2025-12-01 00:00] │ +│ 结束:[2025-12-31 23:59] │ +│ │ +│ 参与对象: │ +│ ● 全体设计师 │ +│ ○ 指定部门:[选择部门] ▼ │ +│ ○ 指定人员:[选择人员] │ +│ │ +│ 竞赛维度与权重: │ +│ 任务完成数:[2] 分/个 权重30% │ +│ 五星好评数:[10] 分/个 权重25% │ +│ 提前完成次数:[5] 分/次 权重20% │ +│ 被客户表扬:[15] 分/次 权重15% │ +│ 无延误天数:[2] 分/天 权重10% │ +│ [+ 添加维度] │ +│ │ +│ 排名计算方式: │ +│ ● 综合得分排名 │ +│ ○ 单项排名 │ +│ ○ 混合排名 │ +│ │ +│ 奖励设置: │ +│ ┌─────────────────────────────────┐ │ +│ │ 排名 奖励积分 额外奖励 │ │ +│ │ ───────────────────────────────│ │ +│ │ 第1名 2000 年终冠军勋章 │ │ +│ │ 第2名 1500 年终亚军勋章 │ │ +│ │ 第3名 1000 年终季军勋章 │ │ +│ │ 4-10名 500 优秀设计师称号 │ │ +│ │ [+ 添加奖励档位] │ │ +│ └─────────────────────────────────┘ │ +│ │ +│ 参与奖: │ +│ ☑ 完成≥15个任务:+150分 │ +│ ☑ 参与满30天:+100分 │ +│ │ +│ 通知设置: │ +│ ☑ 竞赛开始通知(钉钉+站内) │ +│ ☑ 每日进度提醒(站内) │ +│ ☑ 排名变化提醒(站内) │ +│ ☑ 竞赛结束通知(钉钉+站内) │ +│ ☑ 奖励发放通知(钉钉+站内) │ +│ │ +│ 竞赛说明: │ +│ ┌─────────────────────────────────┐ │ +│ │ 年终冲刺,冲击年度最佳设计师!│ │ +│ │ 让我们一起用实力说话... │ │ +│ └─────────────────────────────────┘ │ +│ │ +│ 状态: │ +│ ○ 草稿 ● 立即发布 ○ 定时发布 │ +│ │ +│ [保存为模板] [取消] [创建] │ +└───────────────────────────────────────┘ +``` + +##### 6.1.8.5 团队PK配置 + +**团队PK管理** +``` +┌──────────────────────────────────────────────┐ +│ 团队PK管理 │ +├──────────────────────────────────────────────┤ +│ [+ 创建新PK] │ +│ ──────────────────────────────────────── │ +│ 当前PK: │ +│ │ +│ ┌─────────────────────────────────────┐ │ +│ │ ⚔️ 11月部门对抗赛 │ │ +│ │ 状态:● 激战中 │ │ +│ │ 时间:2025-11-01 至 2025-11-30 │ │ +│ │ ───────────────────────────────── │ │ +│ │ 🔵 设计一部(12人)VS 🔴 设计二部(15人)│ │ +│ │ │ │ +│ │ 当前比分: │ │ +│ │ 设计一部:15,200 分 🔥 领先 │ │ +│ │ 设计二部:14,800 分 ⚡ 追赶 │ │ +│ │ 差距:400 分 │ │ +│ │ │ │ +│ │ PK维度: │ │ +│ │ • 任务完成总数(当前:68 vs 72) │ │ +│ │ • 五星好评总数(当前:45 vs 48) │ │ +│ │ • 准时完成率(当前:92% vs 89%) │ │ +│ │ • 平均任务评分(当前:4.7 vs 4.6)│ │ +│ │ │ │ +│ │ 奖励设置: │ │ +│ │ • 胜方团队:每人+200分 │ │ +│ │ • 败方团队:每人+100分(参与奖) │ │ +│ │ • MVP(胜方最高贡献):额外+300分 │ │ +│ │ │ │ +│ │ 实时战况:[查看详情] │ │ +│ │ │ │ +│ │ [编辑] [暂停] [提前结束] [结算] │ │ +│ └─────────────────────────────────────┘ │ +│ │ +│ 历史PK: │ +│ • 10月部门对抗赛(设计一部胜) [查看] │ +│ • 9月新老对决(老将队胜) [查看] │ +│ │ +└──────────────────────────────────────────────┘ +``` + +##### 6.1.8.6 排名规则配置 + +**排名系统配置** +``` +┌──────────────────────────────────────────────┐ +│ 排名系统规则配置 │ +├──────────────────────────────────────────────┤ +│ 排名维度与权重设置: │ +│ │ +│ 综合排名算法: │ +│ ┌─────────────────────────────────────┐ │ +│ │ 维度 权重 计分方式 │ │ +│ │ ───────────────────────────────── │ │ +│ │ 任务完成数 30% 1任务=2分 │ │ +│ │ 五星好评数 25% 1好评=10分 │ │ +│ │ 累计获得积分 20% 1积分=0.1分 │ │ +│ │ 准时完成率 15% 比例×100分 │ │ +│ │ 平均任务评分 10% 评分×20分 │ │ +│ │ [+ 添加维度] [重置权重] │ │ +│ └─────────────────────────────────────┘ │ +│ │ +│ 排名更新频率: │ +│ ● 实时更新(每次任务变化) │ +│ ○ 定时更新:[每小时] ▼ │ +│ ○ 手动更新 │ +│ │ +│ 排名范围设置: │ +│ 显示范围:[全体] [前50名] [前100名] ▼ │ +│ 分组排名:☑ 启用部门内排名 │ +│ ☑ 启用等级内排名 │ +│ │ +│ 排名变化通知: │ +│ ☑ 排名上升≥5位时通知 │ +│ ☑ 进入前10名时通知 │ +│ ☑ 被他人超越时通知 │ +│ ☑ 掉出前10名时通知 │ +│ │ +│ 排名保护机制: │ +│ ☑ 新人保护期:入职[30]天内不显示排名 │ +│ ☑ 休假保护:休假期间排名冻结 │ +│ ☑ 异常保护:任务量<3时不参与排名 │ +│ │ +│ [预览排名结果] [保存配置] │ +└──────────────────────────────────────────────┘ +``` + +##### 6.1.8.7 激励积分池管理 + +**激励专用积分池** +``` +┌──────────────────────────────────────────────┐ +│ 激励系统积分池 │ +├──────────────────────────────────────────────┤ +│ 积分来源: │ +│ ● 系统独立积分池(推荐,不占用团队积分) │ +│ ○ 从指定部门扣除 │ +│ │ +│ ──────────────────────────────────────── │ +│ 系统积分池(当前月): │ +│ │ +│ ┌─────────────────────────────────────┐ │ +│ │ 本月预算:50,000 分 │ │ +│ │ 已发放: 28,500 分(57%) │ │ +│ │ 剩余: 21,500 分(43%) │ │ +│ │ │ │ +│ │ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░░░░░░░░░░░░░░ │ │ +│ │ │ │ +│ │ 预计本月总需求:45,000 分 │ │ +│ │ 状态:✅ 充足 │ │ +│ └─────────────────────────────────────┘ │ +│ │ +│ 月度预算设置: │ +│ 每月预算:[50000] 分 │ +│ 预警阈值:剩余 [20]% 时预警 │ +│ 超支处理: │ +│ ● 自动扩容 │ +│ ○ 暂停发放 │ +│ ○ 降低奖励 │ +│ │ +│ 分项预算分配(参考): │ +│ • 每日签到奖励:约 4,200 分/月 │ +│ • 每周奖励:约 8,000 分/月 │ +│ • 每月奖励:约 15,000 分/月 │ +│ • 成就奖励:约 8,000 分/月 │ +│ • 竞赛奖励:约 10,000 分/月 │ +│ • 团队PK奖励:约 5,000 分/月 │ +│ • 其他奖励:约 3,000 分/月 │ +│ │ +│ 历史统计: │ +│ • 10月发放:48,500 分 │ +│ • 9月发放:46,200 分 │ +│ • 8月发放:51,800 分 │ +│ 平均:48,833 分/月 │ +│ │ +│ [查看详细记录] [导出报表] [调整预算] │ +└──────────────────────────────────────────────┘ +``` + +--- + +## 七、通知提醒模块 + +### 7.1 通知场景 + +| 场景 | 接收人 | 通知方式 | +|------|--------|----------| +| 任务发布(指定) | 指定设计师 | 站内+钉钉 | +| 任务发布(招募) | 所有设计师 | 站内 | +| 任务被抢单 | 发布人 | 站内+钉钉 | +| 任务分配(主管) | 接收设计师 | 站内+钉钉 | +| **任务被转移(原接收人)** | **原接收设计师** | **站内+钉钉** | +| **任务被转移(新接收人)** | **新接收设计师** | **站内+钉钉** | +| **任务被指定接收** | **指定设计师、发布人** | **站内+钉钉** | +| **任务被撤回** | **发布人、关注者** | **站内** | +| **任务优先级调整** | **接收设计师** | **站内+钉钉** | +| **任务被推广** | **全体设计师** | **站内+钉钉** | +| **任务转移延误扣分** | **原接收设计师** | **站内+钉钉** | +| **延误申诉提交** | **管理员、主管** | **站内** | +| **延误申诉审核结果** | **申诉设计师** | **站内+钉钉** | +| **部门消耗限制预警** | **部门负责人、部门成员** | **站内+钉钉** | +| **部门消耗超限** | **部门负责人、发起人** | **站内+钉钉+邮件** | +| **部门消耗周报/月报** | **部门负责人** | **邮件** | +| **部门负责人变更** | **新负责人、旧负责人** | **站内+钉钉** | +| **获得成就勋章** | **设计师本人** | **站内+弹窗** | +| **排名上升** | **设计师本人** | **站内** | +| **排名下降/被超越** | **设计师本人** | **站内** | +| **里程碑达成** | **设计师本人** | **站内+弹窗** | +| **冲刺提醒** | **设计师本人** | **站内** | +| **目标达成** | **设计师本人** | **站内+弹窗** | +| **竞赛开始/结束** | **参赛设计师** | **站内+钉钉** | +| **团队PK进度** | **团队成员** | **站内** | +| **连续记录提醒** | **设计师本人** | **站内** | +| **自动奖励发放(每日/每周/每月)** | **获奖设计师** | **站内+弹窗** | +| **竞赛奖励发放** | **获奖设计师** | **站内+钉钉+弹窗** | +| **团队PK奖励发放** | **团队成员** | **站内+钉钉** | +| **激励积分池预警** | **超级管理员** | **站内+邮件** | +| **激励规则变更通知** | **全体设计师** | **站内** | +| 任务即将到期 | 接收设计师、发布人 | 站内+钉钉 | +| 任务延期 | 接收设计师、发布人、主管 | 站内+钉钉+邮件 | +| 任务提交交付 | 发布人 | 站内+钉钉 | +| 任务审核通过 | 接收设计师 | 站内+钉钉 | +| 任务被打回 | 接收设计师 | 站内+钉钉 | +| 积分不足 | 发布人、主管 | 站内+钉钉 | +| 积分充值 | 团队成员 | 站内 | +| 排行榜更新 | 相关设计师 | 站内(周/月) | + +### 7.2 站内消息 + +**消息中心** +``` +┌──────────────────────────────────────┐ +│ 🔔 消息中心 (5) [全部已读] │ +├──────────────────────────────────────┤ +│ ● [系统通知] │ +│ 您有一个新任务 #T20251107001 │ +│ 2分钟前 │ +│ │ +│ ○ [任务提醒] │ +│ 任务"主图设计"将在2小时后到期 │ +│ 1小时前 │ +│ │ +│ ○ [审核通知] │ +│ 您的任务"详情页优化"审核通过 │ +│ 3小时前 │ +└──────────────────────────────────────┘ +``` + +**消息类型** +- 系统通知(红点) +- 任务提醒(黄点) +- 审核通知(蓝点) +- 积分变动(绿点) +- 调配通知(紫点) + +### 7.3 钉钉通知集成 + +**⚠️ 重要:钉钉通知链接预留** +系统所有站内通知都需预留与钉钉通知的集成接口,确保重要通知能够同步推送到钉钉。 + +**工作通知消息卡片** +``` +┌─────────────────────────────┐ +│ 【新任务通知】 │ +│ │ +│ 任务:主图设计 │ +│ 发布人:张三 │ +│ 预估积分:250分 │ +│ 期望完成:2025-11-09 │ +│ │ +│ [查看详情] [接受任务] │ +└─────────────────────────────┘ +``` + +**钉钉集成技术实现** + +**1. 通知数据结构设计** +所有站内通知消息必须包含钉钉推送所需字段: +```javascript +// 通知消息统一数据结构 +{ + // 基础信息 + id: "notify_20251107_001", + type: "task_assign", // 通知类型 + title: "新任务通知", + content: "您有一个新任务需要处理", + + // 接收人信息 + receiverUserId: "U123456", + receiverDingUserId: "ding_abc123", // 钉钉用户ID(关键) + + // 跳转链接(H5页面或小程序) + link: "https://xxx.com/task/detail?id=T001", + mobileLink: "dingtalk://dingtalkclient/page/link?url=xxx", + + // 时间信息 + createTime: "2025-11-07 10:30:00", + sendTime: "2025-11-07 10:30:05", // 实际发送时间 + + // 钉钉推送相关 + dingNotification: { + enabled: true, // 是否推送钉钉 + messageType: "oa", // 消息类型:text/oa/markdown/actionCard + priority: "normal", // 优先级:normal/urgent + pushed: false, // 是否已推送 + pushTime: null, // 推送时间 + pushResult: null // 推送结果 + }, + + // 业务数据(可选) + businessData: { + taskId: "T001", + taskType: "主图设计", + publisher: "张三", + score: 250 + } +} +``` + +**2. 钉钉消息推送接口封装** +```javascript +// 钉钉通知推送服务 +class DingTalkNotificationService { + + /** + * 发送工作通知消息 + * @param {Object} notification 通知对象 + * @returns {Promise} + */ + async sendWorkNotification(notification) { + // 1. 检查是否需要推送钉钉 + if (!notification.dingNotification.enabled) { + return { success: true, skipped: true }; + } + + // 2. 获取钉钉access_token + const accessToken = await this.getAccessToken(); + + // 3. 构建钉钉消息体 + const message = this.buildDingMessage(notification); + + // 4. 调用钉钉API + const result = await this.callDingTalkAPI({ + url: 'https://oapi.dingtalk.com/topapi/message/corpconversation/asyncsend_v2', + accessToken, + body: { + agent_id: process.env.DING_AGENT_ID, + userid_list: notification.receiverDingUserId, + msg: message + } + }); + + // 5. 更新推送状态 + await this.updatePushStatus(notification.id, result); + + return result; + } + + /** + * 构建不同类型的钉钉消息 + */ + buildDingMessage(notification) { + const { type, title, content, link, businessData } = notification; + + switch (notification.dingNotification.messageType) { + case 'oa': + // OA消息(推荐,样式美观) + return { + msgtype: 'oa', + oa: { + head: { + text: title, + bgcolor: 'FFBBBBBB' + }, + body: { + title: title, + form: [ + { key: '任务类型:', value: businessData.taskType }, + { key: '发布人:', value: businessData.publisher }, + { key: '积分:', value: businessData.score + '分' } + ] + }, + message_url: link, + pc_message_url: link + } + }; + + case 'actionCard': + // 卡片消息(支持多按钮) + return { + msgtype: 'action_card', + action_card: { + title: title, + markdown: content, + single_title: '查看详情', + single_url: link + } + }; + + case 'markdown': + // Markdown消息 + return { + msgtype: 'markdown', + markdown: { + title: title, + text: this.formatMarkdown(notification) + } + }; + + default: + // 文本消息(默认) + return { + msgtype: 'text', + text: { + content: `${title}\n${content}` + } + }; + } + } + + /** + * 格式化Markdown消息 + */ + formatMarkdown(notification) { + const { title, businessData } = notification; + return `### ${title}\n` + + `- **任务类型**:${businessData.taskType}\n` + + `- **发布人**:${businessData.publisher}\n` + + `- **积分**:${businessData.score}分\n` + + `\n[点击查看详情](${notification.link})`; + } +} +``` + +**3. 通知发送流程** +``` +站内消息生成 → 保存到数据库 → 检查是否需要钉钉推送 +→ 异步推送到钉钉 → 更新推送状态 → 记录推送日志 +``` + +**4. 通知队列机制(推荐)** +```javascript +// 使用消息队列异步处理钉钉推送 +// 避免同步推送影响主业务性能 + +// 发送通知时 +async function sendNotification(notification) { + // 1. 保存站内消息 + await saveNotification(notification); + + // 2. 如需钉钉推送,加入队列 + if (notification.dingNotification.enabled) { + await notificationQueue.add('ding-push', { + notificationId: notification.id, + priority: notification.dingNotification.priority + }); + } +} + +// 队列处理器 +notificationQueue.process('ding-push', async (job) => { + const { notificationId } = job.data; + const notification = await getNotificationById(notificationId); + + // 调用钉钉推送服务 + await dingTalkService.sendWorkNotification(notification); +}); +``` + +**5. 数据库表设计(通知表扩展字段)** +```sql +-- 通知消息表 +CREATE TABLE `sys_notification` ( + `id` bigint PRIMARY KEY, + `type` varchar(50) COMMENT '通知类型', + `title` varchar(200) COMMENT '标题', + `content` text COMMENT '内容', + `receiver_user_id` bigint COMMENT '接收人用户ID', + `receiver_ding_user_id` varchar(100) COMMENT '接收人钉钉UserID', + `link_url` varchar(500) COMMENT '跳转链接', + `mobile_link_url` varchar(500) COMMENT '移动端跳转链接', + + -- 钉钉推送相关字段 + `ding_enabled` tinyint DEFAULT 0 COMMENT '是否推送钉钉: 0-否, 1-是', + `ding_message_type` varchar(20) COMMENT '钉钉消息类型: text/oa/markdown/actionCard', + `ding_priority` varchar(20) DEFAULT 'normal' COMMENT '优先级: normal/urgent', + `ding_pushed` tinyint DEFAULT 0 COMMENT '是否已推送: 0-未推送, 1-已推送', + `ding_push_time` datetime COMMENT '推送时间', + `ding_task_id` varchar(100) COMMENT '钉钉任务ID', + `ding_push_result` text COMMENT '推送结果JSON', + + `business_type` varchar(50) COMMENT '业务类型: task/score/achievement等', + `business_id` varchar(100) COMMENT '业务ID', + `business_data` json COMMENT '业务数据JSON', + + `is_read` tinyint DEFAULT 0 COMMENT '是否已读', + `read_time` datetime COMMENT '阅读时间', + + `create_time` datetime DEFAULT CURRENT_TIMESTAMP, + `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + INDEX `idx_receiver` (`receiver_user_id`, `is_read`), + INDEX `idx_ding_push` (`ding_enabled`, `ding_pushed`), + INDEX `idx_create_time` (`create_time`) +) COMMENT '系统通知表'; +``` + +**通知策略** +- 工作时间(9:00-18:00)实时推送 +- 非工作时间汇总推送(可配置) +- 紧急任务不限时间 +- 用户可自定义免打扰时段 +- 支持消息撤回和重推 +- 推送失败自动重试(最多3次) + +**钉钉消息类型选择建议** +- **OA消息**:适用于任务通知、审核通知(推荐) +- **ActionCard**:适用于需要操作按钮的场景 +- **Markdown**:适用于内容丰富的通知 +- **Text**:适用于简单文本通知 + +### 7.4 邮件通知 + +**周报/月报** +- 收件人:设计师本人、主管 +- 内容:任务统计、积分统计、排行榜、评价汇总 +- 发送时间:每周一早9:00 / 每月1号早9:00 + +**紧急通知** +- 任务严重延期 +- 系统异常告警 + +--- + +## 八、数据库设计要点 + +### 8.1 核心表结构 + +#### 用户表(sys_user) +- id, username, password, name, avatar, phone, email +- ding_user_id, ding_union_id, dept_id +- role, status, create_time, update_time + +#### 部门表(sys_dept) +- id, dept_name, parent_id, ding_dept_id +- dept_type(部门类型:DEMAND需求部门/RECEIVER接单部门) +- level, sort, leader_user_id, score_balance +- limit_enabled, limit_period(每天/每周/每月), period_limit +- alert_threshold, over_limit_action +- allowed_demand_types(接单部门可接的需求类型,JSON) +- description, status +- create_time, update_time + +#### 部门消耗限制记录表(dept_limit_record) +- id, dept_id, period_type, period_start, period_end +- period_limit, period_used, period_remaining +- task_count, is_over_limit +- create_time, update_time + +#### 任务表(task) +- id, task_no, title, description +- demand_type_id, demand_quantity, demand_score +- task_type, brand_id, category_id, spu_info, platform_ids +- publish_type, publish_user_id, publish_dept_id +- assign_user_id, assign_time +- expect_finish_time, actual_start_time, actual_finish_time +- status, progress, delivery_info +- review_score, review_comment +- create_time, update_time + +#### 需求类型表(demand_type) +- id, type_name, unit_score, estimate_time +- description, requirement, sort, status +- create_time, update_time + +#### 品牌表(brand) +- id, brand_name, logo_url, website +- description, style_guide, sort, status +- create_time, update_time + +#### 类目表(category) +- id, category_name, parent_id, level +- is_leaf, sort, status +- create_time, update_time + +#### 平台表(platform) +- id, platform_name, icon_url, website +- design_spec, sort, status +- create_time, update_time + +#### 任务附件表(task_attachment) +- id, task_id, file_name, file_url, file_size +- file_type, upload_user_id, upload_time + +#### 任务日志表(task_log) +- id, task_id, operation_type, operation_desc +- operator_id, create_time + +#### 任务调配记录表(task_transfer_log) +- id, task_id, transfer_type(转移/指定/撤回) +- from_user_id, to_user_id, operator_id +- reason, reason_detail, progress_keep +- score_allocation, old_deadline, new_deadline +- delay_hours, delay_penalty_score(延误时长、延误扣分) +- notify_settings, create_time + +#### 延误扣分记录表(delay_penalty_record) +- id, task_id, user_id, task_transfer_log_id +- original_deadline, actual_time(原定时间、实际时间) +- delay_hours, delay_minutes(延误小时、延误分钟) +- delay_level(延误等级:轻微/一般/严重/重大) +- penalty_score(扣分值) +- penalty_reason(原因:任务转移/正常延误) +- is_special_case(是否特殊情况:病假/事假等) +- admin_note(管理员备注) +- is_appealed, appeal_status(是否申诉、申诉状态) +- appeal_reason, appeal_result(申诉原因、申诉结果) +- create_time, update_time + +#### 延误扣分规则表(delay_penalty_rule) +- id, rule_name, level_name +- min_hours, max_hours(时长范围) +- penalty_type(扣分类型:固定/按时长/阶梯) +- penalty_rate(扣分比率,如每小时N分) +- base_penalty, daily_penalty(基础扣分、每天扣分,用于重大延误) +- max_penalty(最高扣分限制,可选) +- description, sort, status +- create_time, update_time + +#### 积分记录表(score_record) +- id, dept_id, user_id, score_change +- change_type, task_id, balance_after +- delay_penalty_record_id(关联延误扣分记录) +- remark, create_time + +#### 评价表(task_review) +- id, task_id, reviewer_id, reviewee_id +- quality_score, timeliness_score, communication_score +- avg_score, comment, review_time + +#### 成就勋章表(achievement) +- id, achievement_name, achievement_icon, achievement_desc +- achievement_type(数量类/质量类/效率类/特殊类) +- condition_type, condition_value(获得条件) +- reward_score(奖励积分) +- sort, status +- create_time, update_time + +#### 用户成就记录表(user_achievement) +- id, user_id, achievement_id +- achieve_time, is_shared +- create_time + +#### 用户目标表(user_goal) +- id, user_id, goal_type +- goal_name, goal_target, goal_period +- current_progress, is_completed +- start_time, complete_time +- create_time, update_time + +#### 用户签到记录表(user_check_in) +- id, user_id, check_in_date +- continuous_days, month_count +- create_time + +#### 激励系统配置表(incentive_config) +- id, config_key(配置键:ranking_enabled, achievement_enabled等) +- config_value(配置值) +- config_type(配置类型:boolean, string, number, json) +- module_name(模块名称:排名系统、成就系统等) +- description(配置说明) +- status(启用/禁用) +- create_time, update_time + +#### 自动奖励规则表(auto_reward_rule) +- id, rule_name, rule_type(每日/每周/每月/季度/年度) +- period_type(daily, weekly, monthly, quarterly, yearly) +- trigger_condition(触发条件:JSON格式) +- reward_score(奖励积分) +- extra_reward(额外奖励:JSON格式,如勋章、称号等) +- target_users(目标用户:all全体/部门/指定用户) +- schedule_time(定时发放时间) +- is_auto_issue(是否自动发放) +- need_approval(是否需要审核) +- description, status +- create_time, update_time + +#### 自动奖励发放记录表(auto_reward_log) +- id, rule_id, period_start, period_end +- issue_time(发放时间) +- user_count(获奖人数) +- total_score(总积分) +- status(pending待发放/issued已发放/cancelled已取消) +- operator_id(操作人) +- remark +- create_time, update_time + +#### 自动奖励明细表(auto_reward_detail) +- id, log_id, rule_id, user_id +- reward_score(奖励积分) +- extra_reward(额外奖励:JSON) +- trigger_data(触发数据:如完成任务数、好评数等) +- score_record_id(关联积分记录ID) +- status(issued已发放/failed失败) +- create_time + +#### 竞赛活动表(competition) +- id, competition_name, competition_type(月度/周度/专项) +- start_time, end_time +- participant_scope(参与范围:all/dept/users) +- participant_json(参与者JSON:如部门ID列表、用户ID列表) +- rule_dimensions(竞赛维度:JSON,包含权重) +- ranking_method(排名方式:综合得分/单项排名/混合排名) +- reward_config(奖励配置:JSON) +- participation_reward(参与奖配置:JSON) +- notify_settings(通知设置:JSON) +- description +- status(draft草稿/ongoing进行中/ended已结束/cancelled已取消) +- creator_id, create_time, update_time + +#### 竞赛排名表(competition_ranking) +- id, competition_id, user_id +- total_score(总得分) +- dimension_scores(各维度得分:JSON) +- final_rank(最终排名) +- reward_score(奖励积分) +- extra_reward(额外奖励:JSON) +- is_rewarded(是否已发放奖励) +- update_time + +#### 团队PK表(team_pk) +- id, pk_name, pk_type(部门对抗/分组对抗) +- start_time, end_time +- team_a_id, team_a_name, team_a_score +- team_b_id, team_b_name, team_b_score +- pk_dimensions(PK维度:JSON) +- reward_config(奖励配置:JSON) +- winner_team(获胜队伍:team_a/team_b/draw平局) +- status(ongoing进行中/ended已结束/cancelled已取消) +- creator_id, create_time, update_time + +#### 团队PK成员表(team_pk_member) +- id, pk_id, team_side(team_a/team_b) +- user_id, contribution_score(个人贡献分) +- task_count, good_review_count +- is_mvp(是否MVP) +- reward_score(获得积分) +- create_time + +#### 排名规则配置表(ranking_rule) +- id, rule_name, dimension_name(维度名称) +- dimension_weight(权重) +- score_formula(计分公式) +- dimension_order(排序) +- status +- create_time, update_time + +#### 激励积分池表(incentive_pool) +- id, period_month(周期月份:YYYY-MM) +- monthly_budget(月度预算) +- issued_score(已发放积分) +- remaining_score(剩余积分) +- alert_threshold(预警阈值) +- alert_status(预警状态:normal正常/warning预警/critical严重) +- daily_issued, weekly_issued, monthly_issued(各周期发放统计) +- achievement_issued, competition_issued, team_pk_issued(各模块发放统计) +- create_time, update_time + +### 8.2 索引设计 +- task表:task_no、publish_user_id、assign_user_id、status、publish_dept_id +- 复合索引:(status, publish_user_id)、(status, assign_user_id) +- 全文索引:task.description、task.spu_info(如需高级搜索) + +### 8.3 数据字典 + +**任务状态(task_status)** +- 0: 待接收 +- 1: 进行中 +- 2: 待审核 +- 3: 打回修改 +- 4: 已完成 +- 5: 已取消 + +**任务类型(task_type)** +- 1: 新品 +- 2: 优化 +- 3: 改图 +- 4: 其它 + +**发布类型(publish_type)** +- 1: 指定任务 +- 2: 招募任务 +- 3: 默认模式(主管分配) + +**积分变动类型(score_change_type)** +- 1: 充值 +- 2: 任务消耗 +- 3: 任务退回 +- 4: 任务奖励 +- 5: 调整 +- 6: 延误扣分 +- 7: 延误申诉返还 + +**部门类型(dept_type)** +- DEMAND: 需求部门(只能发起任务) +- RECEIVER: 接单部门(只能接收任务) + +**消耗限制周期(limit_period)** +- DAILY: 每天 +- WEEKLY: 每周 +- MONTHLY: 每月 + +**激励配置类型(incentive_config_type)** +- boolean: 布尔值(开关) +- string: 字符串 +- number: 数字 +- json: JSON对象 + +**奖励规则周期类型(reward_period_type)** +- daily: 每日 +- weekly: 每周 +- monthly: 每月 +- quarterly: 每季度 +- yearly: 每年 + +**自动奖励发放状态(reward_log_status)** +- pending: 待发放 +- issued: 已发放 +- cancelled: 已取消 +- failed: 发放失败 + +**竞赛状态(competition_status)** +- draft: 草稿 +- ongoing: 进行中 +- ended: 已结束 +- cancelled: 已取消 + +**团队PK状态(team_pk_status)** +- ongoing: 进行中 +- ended: 已结束 +- cancelled: 已取消 + +**积分池预警状态(pool_alert_status)** +- normal: 正常 +- warning: 预警 +- critical: 严重(紧急) + +--- + +## 九、接口设计要点 + +### 9.1 RESTful API 规范 + +**基础路径** +``` +/api/v1 +``` + +**认证方式** +``` +Authorization: Bearer {JWT_TOKEN} +``` + +### 9.2 核心接口列表 + +#### 认证模块 +``` +POST /auth/login # 用户名密码登录 +POST /auth/ding-login # 钉钉登录 +POST /auth/refresh-token # 刷新Token +POST /auth/logout # 登出 +GET /auth/user-info # 获取当前用户信息 +``` + +#### 任务模块 +``` +POST /tasks # 创建任务 +GET /tasks # 任务列表(分页、筛选) +GET /tasks/:id # 任务详情 +PUT /tasks/:id # 更新任务 +DELETE /tasks/:id # 删除任务 +POST /tasks/:id/grab # 抢单 +POST /tasks/:id/assign # 分配任务 +PUT /tasks/:id/progress # 更新进度 +POST /tasks/:id/submit # 提交交付 +POST /tasks/:id/review # 审核任务 +POST /tasks/:id/reject # 打回任务 +GET /tasks/my-published # 我发布的任务 +GET /tasks/my-received # 我接收的任务 +GET /tasks/hall # 任务大厅列表 +GET /tasks/calendar # 日历视图数据 + +# 任务调配(管理员) +POST /tasks/:id/transfer # 转移任务 +POST /tasks/batch-transfer # 批量转移任务 +POST /tasks/:id/designate # 指定接收人(从任务池) +POST /tasks/batch-designate # 批量指定接收人 +POST /tasks/:id/withdraw # 撤回任务 +POST /tasks/batch-withdraw # 批量撤回任务 +PUT /tasks/:id/priority # 调整任务优先级 +POST /tasks/:id/promote # 推广任务 +GET /tasks/admin-view # 管理员任务视图 +GET /tasks/pool # 任务池管理(管理员) +GET /tasks/transfer-logs # 调配日志 +GET /tasks/transfer-stats # 调配统计 +POST /tasks/:id/recommend # 获取推荐设计师列表 +``` + +#### 设计师模块 +``` +GET /designers # 设计师列表 +GET /designers/:id # 设计师详情 +GET /designers/:id/tasks # 设计师任务列表 +GET /designers/:id/stats # 设计师统计数据 +GET /designers/:id/delays # 设计师延误记录 +GET /designers/leaderboard # 排行榜 +``` + +#### 激励系统模块 +``` +# 成就勋章 +GET /achievements # 获取成就列表 +GET /achievements/:id # 获取成就详情 +GET /user/achievements # 我的成就勋章 +GET /user/achievements/progress # 成就进度 +POST /user/achievements/:id/share # 分享成就 +GET /achievements/leaderboard # 成就排行榜 + +# 排名和对比 +GET /user/ranking # 我的实时排名 +GET /user/ranking/trend # 排名趋势 +GET /user/compare/:userId # 与同事对比 +GET /user/milestones # 我的里程碑 +GET /user/sprint-status # 冲刺状态 + +# 目标管理 +GET /user/goals # 我的目标列表 +POST /user/goals # 设定新目标 +PUT /user/goals/:id # 更新目标 +DELETE /user/goals/:id # 删除目标 +GET /user/goals/:id/progress # 目标进度 + +# 签到和激励 +POST /user/check-in # 每日签到 +GET /user/check-in/records # 签到记录 +GET /user/daily-motivation # 每日激励卡片 +GET /user/motivation-board # 激励看板 + +# 竞赛活动 +GET /competitions # 竞赛活动列表 +GET /competitions/:id # 竞赛详情 +GET /competitions/:id/ranking # 竞赛排名 +POST /competitions/:id/join # 参加竞赛 + +# 团队PK +GET /team-pk # 团队PK列表 +GET /team-pk/:id # PK详情 +GET /team-pk/:id/realtime # 实时战况 +POST /team-pk/:id/cheer # 为团队加油 + +# 智能推荐 +GET /recommendations/tasks # 推荐任务 +GET /recommendations/rest # 休息提醒 +``` + +#### 激励配置管理模块(管理员) +``` +# 激励总开关配置 +GET /admin/incentive/config # 获取激励系统总配置 +PUT /admin/incentive/config # 更新激励系统总配置 +PUT /admin/incentive/config/modules/:module # 更新指定模块开关(ranking/achievement等) +POST /admin/incentive/config/batch-enable # 批量启用模块 +POST /admin/incentive/config/batch-disable # 批量禁用模块 + +# 成就勋章配置 +GET /admin/achievements # 获取成就配置列表 +POST /admin/achievements # 创建新成就 +PUT /admin/achievements/:id # 更新成就配置 +DELETE /admin/achievements/:id # 删除成就 +PUT /admin/achievements/:id/status # 启用/禁用成就 +POST /admin/achievements/batch-update # 批量更新成就 +GET /admin/achievements/:id/statistics # 成就获得统计 + +# 自动奖励规则配置 +GET /admin/reward-rules # 获取自动奖励规则列表 +POST /admin/reward-rules # 创建新规则 +PUT /admin/reward-rules/:id # 更新规则 +DELETE /admin/reward-rules/:id # 删除规则 +PUT /admin/reward-rules/:id/status # 启用/禁用规则 +POST /admin/reward-rules/:id/test # 测试规则(计算预估奖励) +POST /admin/reward-rules/:id/execute # 手动触发执行规则 + +# 自动奖励发放记录 +GET /admin/reward-logs # 获取发放记录列表 +GET /admin/reward-logs/:id # 获取发放记录详情 +GET /admin/reward-logs/:id/details # 获取发放明细 +POST /admin/reward-logs/:id/reissue # 重新发放 +POST /admin/reward-logs/:id/cancel # 取消发放 +GET /admin/reward-logs/statistics # 发放统计数据 +POST /admin/reward-logs/export # 导出发放记录 + +# 竞赛活动管理 +GET /admin/competitions # 获取竞赛列表 +POST /admin/competitions # 创建新竞赛 +PUT /admin/competitions/:id # 更新竞赛 +DELETE /admin/competitions/:id # 删除竞赛 +PUT /admin/competitions/:id/status # 更改竞赛状态(开始/暂停/结束) +POST /admin/competitions/:id/settle # 结算竞赛奖励 +GET /admin/competitions/:id/ranking # 查看竞赛排名 +POST /admin/competitions/:id/ranking/refresh # 刷新排名 +GET /admin/competitions/templates # 获取竞赛模板 +POST /admin/competitions/from-template # 从模板创建竞赛 + +# 团队PK管理 +GET /admin/team-pk # 获取PK列表 +POST /admin/team-pk # 创建新PK +PUT /admin/team-pk/:id # 更新PK +DELETE /admin/team-pk/:id # 删除PK +PUT /admin/team-pk/:id/status # 更改PK状态 +POST /admin/team-pk/:id/settle # 结算PK奖励 +GET /admin/team-pk/:id/realtime # 实时战况(详细数据) +POST /admin/team-pk/:id/adjust # 调整PK参数(如延长时间) + +# 排名规则配置 +GET /admin/ranking-rules # 获取排名规则配置 +PUT /admin/ranking-rules # 更新排名规则 +POST /admin/ranking-rules/preview # 预览规则效果 +POST /admin/ranking-rules/reset # 重置为默认规则 +POST /admin/ranking/refresh # 刷新全局排名 + +# 激励积分池管理 +GET /admin/incentive-pool # 获取积分池状态 +GET /admin/incentive-pool/:month # 获取指定月份积分池 +PUT /admin/incentive-pool/:month/budget # 调整月度预算 +GET /admin/incentive-pool/statistics # 积分池统计数据 +GET /admin/incentive-pool/forecast # 积分池预测(基于历史数据) +POST /admin/incentive-pool/export # 导出积分池报表 + +# 激励数据分析 +GET /admin/incentive/analytics/overview # 激励系统总览 +GET /admin/incentive/analytics/effectiveness # 激励效果分析 +GET /admin/incentive/analytics/participation # 参与度分析 +GET /admin/incentive/analytics/trends # 趋势分析 +POST /admin/incentive/analytics/export # 导出分析报告 +``` + +#### 延误扣分模块 +``` +GET /delay-penalties # 延误扣分记录列表 +GET /delay-penalties/:id # 延误扣分记录详情 +POST /delay-penalties/:id/appeal # 提交延误申诉 +PUT /delay-penalties/:id/appeal # 审核延误申诉 +GET /delay-penalties/my-records # 我的延误记录 +GET /delay-penalties/stats # 延误统计数据 +POST /delay-penalties/calculate # 计算延误扣分(用于预览) +``` + +#### 部门管理模块 +``` +GET /departments # 获取部门列表(树形结构) +GET /departments/:id # 获取部门详情 +POST /departments # 创建部门 +PUT /departments/:id # 更新部门 +DELETE /departments/:id # 删除部门 +POST /departments/sync-dingtalk # 同步钉钉组织架构 +PUT /departments/:id/leader # 设置部门负责人 +PUT /departments/:id/limit # 设置积分消耗限制 +GET /departments/:id/limit-usage # 获取部门消耗情况 +POST /departments/:id/limit-adjust # 临时调整消耗限制 +GET /departments/:id/members # 获取部门成员 +GET /departments/:id/tasks # 获取部门任务列表 +GET /departments/:id/stats # 获取部门统计数据 +``` + +#### 需求统计模块 +``` +GET /demand-stats/overview # 需求统计总览 +GET /demand-stats/departments # 部门维度统计 +GET /demand-stats/departments/:id # 指定部门详情统计 +GET /demand-stats/users # 用户维度统计 +GET /demand-stats/users/:id # 指定用户详情统计 +GET /demand-stats/trends # 需求发起趋势 +GET /demand-stats/types # 需求类型分析 +GET /demand-stats/export # 导出统计报表 +POST /demand-stats/subscribe # 订阅定时报表 +``` + +#### 配置模块 +``` +# 需求类型 +GET /config/demand-types +POST /config/demand-types +PUT /config/demand-types/:id +DELETE /config/demand-types/:id + +# 品牌 +GET /config/brands +POST /config/brands +PUT /config/brands/:id +DELETE /config/brands/:id + +# 类目 +GET /config/categories/tree +POST /config/categories +PUT /config/categories/:id +DELETE /config/categories/:id + +# 平台 +GET /config/platforms +POST /config/platforms +PUT /config/platforms/:id +DELETE /config/platforms/:id + +# 延误扣分规则 +GET /config/delay-penalty-rules # 获取延误扣分规则列表 +POST /config/delay-penalty-rules # 创建延误扣分规则 +PUT /config/delay-penalty-rules/:id # 更新延误扣分规则 +DELETE /config/delay-penalty-rules/:id # 删除延误扣分规则 +POST /config/delay-penalty-rules/test # 测试延误扣分计算 +``` + +#### 积分模块 +``` +GET /scores/dept/:id # 部门积分信息 +POST /scores/recharge # 积分充值 +GET /scores/records # 积分记录 +``` + +#### 数据分析模块 +``` +GET /stats/overview # 总览数据 +GET /stats/trends # 趋势数据 +GET /stats/distribution # 分布数据 +GET /stats/leaderboard # 排行榜数据 +``` + +#### 通知模块 +``` +GET /notifications # 消息列表 +PUT /notifications/:id/read # 标记已读 +PUT /notifications/read-all # 全部已读 +``` + +#### 文件上传 +``` +POST /upload/file # 文件上传 +POST /upload/image # 图片上传 +``` + +### 9.3 响应格式 + +**成功响应** +```json +{ + "code": 200, + "message": "success", + "data": { + // 业务数据 + }, + "timestamp": 1699344000000 +} +``` + +**失败响应** +```json +{ + "code": 400, + "message": "参数错误", + "data": null, + "timestamp": 1699344000000 +} +``` + +**分页响应** +```json +{ + "code": 200, + "message": "success", + "data": { + "list": [], + "total": 100, + "page": 1, + "page_size": 20 + } +} +``` + +--- + +## 十、UI/UX 设计规范 + +### ⚠️ 重要声明:严格遵循 OpenAI 官方 UI 设计规范 + +本项目页面外观开发**必须严格按照 OpenAI 官方 UI 设计规范**进行设计和实现,以确保界面的现代性、简洁性和用户体验的一致性。 + +### 10.1 OpenAI 风格设计规范 + +#### 10.1.1 核心设计原则 + +**1. 极简主义(Minimalism)** +- 去除一切不必要的视觉元素 +- 专注于内容和功能本身 +- 留白充足,让界面呼吸感更强 +- 避免过度装饰和复杂渐变 + +**2. 现代化与科技感(Modern & Tech-forward)** +- 使用柔和的深色模式和明亮的浅色模式 +- 流畅的动画过渡 +- 微妙的交互反馈 +- 前沿的视觉设计语言 + +**3. 清晰的信息层次(Clear Hierarchy)** +- 使用字重、大小、颜色来建立视觉层级 +- 重要信息突出显示 +- 次要信息适当弱化 +- 引导用户视线流动 + +**4. 一致性(Consistency)** +- 统一的间距系统 +- 统一的圆角规范 +- 统一的阴影样式 +- 统一的动效规则 + +#### 10.1.2 配色方案(参考 OpenAI) + +**深色模式(主推荐,符合OpenAI风格)** +``` +背景色系: +- 主背景:#0D0D0D / #171717(极深黑) +- 次背景:#202020 / #2A2A2A(深灰) +- 卡片背景:#2A2A2A / #353535(中深灰) +- 悬浮背景:#353535 / #404040(灰) + +文字色系: +- 主文字:#ECECEC / #FFFFFF(接近白色) +- 次文字:#AFAFAF / #B4B4B4(浅灰) +- 辅助文字:#737373 / #808080(中灰) +- 禁用文字:#4A4A4A(深灰) + +品牌色(参考OpenAI): +- 主品牌色:#10A37F(翠绿色,OpenAI经典色) +- 悬浮状态:#1A7F64(深翠绿) +- 按下状态:#0D8B6D(更深翠绿) + +功能色: +- 成功:#10A37F(翠绿,与品牌色统一) +- 警告:#FFA500(橙色) +- 错误:#EF4444(红色) +- 信息:#3B82F6(蓝色) + +边框色: +- 主边框:#3A3A3A / #404040(深灰边框) +- 次边框:#2A2A2A(更深边框) +- 分割线:#262626(极细分割线) +``` + +**浅色模式(可选)** +``` +背景色系: +- 主背景:#FFFFFF(纯白) +- 次背景:#F7F7F8(极浅灰) +- 卡片背景:#FFFFFF(纯白) +- 悬浮背景:#F0F0F0(浅灰) + +文字色系: +- 主文字:#0D0D0D(极深黑) +- 次文字:#666666(深灰) +- 辅助文字:#8E8E8E(中灰) + +品牌色与功能色:同深色模式 +``` + +#### 10.1.3 字体规范(参考 OpenAI) + +**字体家族** +```css +/* 主字体(参考OpenAI使用的字体) */ +font-family: + "Söhne", + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + "Helvetica Neue", + Arial, + "Noto Sans", + sans-serif, + "Apple Color Emoji", + "Segoe UI Emoji"; + +/* 中文字体 */ +font-family: + -apple-system, + BlinkMacSystemFont, + "PingFang SC", + "Microsoft YaHei", + "Helvetica Neue", + Helvetica, + Arial, + sans-serif; + +/* 等宽字体(用于代码、路径等) */ +font-family: + "SF Mono", + "Monaco", + "Cascadia Code", + "Roboto Mono", + "Consolas", + monospace; +``` + +**字体大小系统** +``` +超大标题:32px / 2rem(页面主标题) +大标题: 24px / 1.5rem(区块标题) +中标题: 20px / 1.25rem(卡片标题) +小标题: 16px / 1rem(列表项标题) +正文: 14px / 0.875rem(主要内容) +辅助文字:12px / 0.75rem(次要信息) +小字: 11px / 0.6875rem(标签、时间戳) +``` + +**字重规范** +``` +Bold(700): 重要标题、强调文字 +Semibold(600):次级标题 +Medium(500): 按钮文字、标签 +Regular(400):正文 +Light(300): 辅助说明 +``` + +#### 10.1.4 间距系统(8px 基准) + +``` +XXS:2px - 细微间距 +XS: 4px - 极小间距 +SM: 8px - 小间距(基准单位) +MD: 16px - 中间距 +LG: 24px - 大间距 +XL: 32px - 特大间距 +XXL:48px - 超大间距 +XXXL:64px - 巨大间距 + +内边距: +- 紧凑卡片:16px +- 标准卡片:24px +- 宽松卡片:32px + +外边距: +- 组件间隔:16px +- 区块间隔:24px +- 页面边距:32px +``` + +#### 10.1.5 圆角规范(参考 OpenAI) + +``` +微圆角: 4px - 按钮、输入框、标签 +小圆角: 8px - 小卡片、下拉菜单 +中圆角: 12px - 标准卡片 +大圆角: 16px - 大型容器 +特大圆角:24px - 模态框、抽屉 + +完全圆角:50% - 头像、图标按钮 +``` + +#### 10.1.6 阴影系统 + +```css +/* 轻微阴影(悬浮卡片) */ +box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12); + +/* 标准阴影(卡片) */ +box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + +/* 浮起阴影(悬浮状态) */ +box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2); + +/* 强阴影(模态框) */ +box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); + +/* 深色模式阴影(更柔和) */ +box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4); +``` + +#### 10.1.7 动画与过渡(参考 OpenAI 流畅体验) + +```css +/* 标准过渡时间 */ +--transition-fast: 150ms; +--transition-base: 250ms; +--transition-slow: 350ms; + +/* 缓动函数(OpenAI风格) */ +--ease-out: cubic-bezier(0.16, 1, 0.3, 1); +--ease-in-out: cubic-bezier(0.45, 0, 0.55, 1); + +/* 常用过渡 */ +transition: all 250ms cubic-bezier(0.16, 1, 0.3, 1); +``` + +**动效原则** +- 所有交互都应有微妙的动效反馈 +- 避免突兀的变化 +- 状态切换使用淡入淡出 +- 悬浮效果:轻微上浮 + 阴影增强 +- 按钮点击:微缩放 + 颜色变化 +- 加载状态:优雅的骨架屏或脉冲动画 + +#### 10.1.8 组件设计规范 + +**按钮(参考 OpenAI)** +```css +/* 主按钮(品牌色) */ +.btn-primary { + background: #10A37F; + color: #FFFFFF; + border: none; + border-radius: 8px; + padding: 10px 20px; + font-size: 14px; + font-weight: 500; + transition: all 250ms ease; +} +.btn-primary:hover { + background: #1A7F64; + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(16, 163, 127, 0.3); +} + +/* 次按钮(轮廓) */ +.btn-secondary { + background: transparent; + color: #ECECEC; + border: 1px solid #3A3A3A; + border-radius: 8px; + padding: 10px 20px; +} +.btn-secondary:hover { + border-color: #10A37F; + color: #10A37F; +} + +/* 文字按钮 */ +.btn-text { + background: transparent; + color: #AFAFAF; + border: none; + padding: 8px 12px; +} +.btn-text:hover { + color: #10A37F; +} +``` + +**输入框(参考 OpenAI)** +```css +.input { + background: #2A2A2A; + border: 1px solid #3A3A3A; + border-radius: 8px; + color: #ECECEC; + padding: 10px 16px; + font-size: 14px; + transition: all 250ms ease; +} +.input:focus { + border-color: #10A37F; + outline: none; + box-shadow: 0 0 0 3px rgba(16, 163, 127, 0.1); +} +.input::placeholder { + color: #737373; +} +``` + +**卡片(参考 OpenAI)** +```css +.card { + background: #2A2A2A; + border: 1px solid #3A3A3A; + border-radius: 12px; + padding: 24px; + transition: all 250ms ease; +} +.card:hover { + border-color: #404040; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2); + transform: translateY(-2px); +} +``` + +### 10.2 首页 UI 布局设计(重点) + +#### 10.2.1 首页整体布局 + +**布局结构(深色模式,OpenAI风格)** +``` +┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +┃ ┌─────────────────────────────────────────────────┐ ┃ +┃ │ 🎨 Logo 工作台 任务大厅 我的任务 数据分析 │👤│ ┃ ← 顶部导航栏 +┃ └─────────────────────────────────────────────────┘ ┃ (高度: 64px) +┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +┃ ┃ +┃ ┌────────────────────────────────────────────┐ ┃ +┃ │ 👋 欢迎回来,张三 │ ┃ ← 欢迎区域 +┃ │ 今天是2025年11月7日 星期四 │ ┃ (Hero Section) +┃ └────────────────────────────────────────────┘ ┃ +┃ ┃ +┃ ┌───────┐ ┌───────┐ ┌───────┐ ┌───────┐ ┃ +┃ │ 📊 │ │ ✅ │ │ ⏰ │ │ 💎 │ ┃ ← 数据卡片区 +┃ │ 8个 │ │ 12个 │ │ 2个 │ │ 1,250│ ┃ (Data Cards) +┃ │进行中 │ │已完成 │ │待审核 │ │ 积分 │ ┃ +┃ └───────┘ └───────┘ └───────┘ └───────┘ ┃ +┃ ┃ +┃ ┌─────────────────────┐ ┌──────────────────┐ ┃ +┃ │ 📋 今日待办 │ │ 📈 本周数据 │ ┃ +┃ │ ───────────────── │ │ ────────────── │ ┃ ← 主要内容区 +┃ │ • 主图设计 [紧急] │ │ [图表] │ ┃ (Main Content) +┃ │ • 详情页优化 │ │ 任务完成趋势 │ ┃ +┃ │ • 视频剪辑 │ │ │ ┃ +┃ │ │ │ │ ┃ +┃ │ [查看全部任务] │ │ [查看详情] │ ┃ +┃ └─────────────────────┘ └──────────────────┘ ┃ +┃ ┃ +┃ ┌───────────────────────────────────────────┐ ┃ +┃ │ 🏆 设计师排行榜 │ ┃ ← 排行榜区域 +┃ │ 🥇 李四 28个任务 4.9⭐ │ ┃ (Leaderboard) +┃ │ 🥈 王五 22个任务 4.8⭐ │ ┃ +┃ │ 🥉 赵六 18个任务 4.7⭐ │ ┃ +┃ └───────────────────────────────────────────┘ ┃ +┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ +``` + +#### 10.2.2 顶部导航栏详细设计 + +**导航栏规范** +``` +┌─────────────────────────────────────────────────────────┐ +│ │ +│ 🎨 DesignHub 工作台 任务大厅 我的任务 数据分析 │ +│ [Logo + 名称] [导航菜单] │ +│ │ +│ 🔔(3) ⚙️ 👤 张三 │ +│ [通知] [设置] [用户] │ +└─────────────────────────────────────────────────────────┘ +``` + +**详细样式说明** +```css +/* 导航栏容器 */ +.navbar { + height: 64px; + background: #171717; /* 深色背景 */ + border-bottom: 1px solid #262626; /* 极细分割线 */ + padding: 0 32px; + display: flex; + align-items: center; + justify-content: space-between; + position: sticky; + top: 0; + z-index: 1000; + backdrop-filter: blur(12px); /* 毛玻璃效果 */ +} + +/* Logo 区域 */ +.navbar-logo { + display: flex; + align-items: center; + gap: 12px; + font-size: 20px; + font-weight: 600; + color: #ECECEC; +} +.navbar-logo-icon { + width: 32px; + height: 32px; + border-radius: 8px; + background: linear-gradient(135deg, #10A37F 0%, #1A7F64 100%); +} + +/* 导航菜单 */ +.navbar-menu { + display: flex; + gap: 8px; +} +.nav-item { + padding: 8px 16px; + border-radius: 8px; + font-size: 14px; + font-weight: 400; + color: #AFAFAF; + cursor: pointer; + transition: all 250ms cubic-bezier(0.16, 1, 0.3, 1); +} +.nav-item:hover { + color: #ECECEC; + background: #2A2A2A; +} +.nav-item.active { + color: #10A37F; + background: rgba(16, 163, 127, 0.1); +} + +/* 右侧操作区 */ +.navbar-actions { + display: flex; + align-items: center; + gap: 16px; +} +.navbar-icon { + width: 40px; + height: 40px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: #AFAFAF; + cursor: pointer; + position: relative; + transition: all 250ms ease; +} +.navbar-icon:hover { + background: #2A2A2A; + color: #ECECEC; +} +.navbar-icon .badge { + position: absolute; + top: 6px; + right: 6px; + width: 18px; + height: 18px; + background: #EF4444; + border-radius: 50%; + font-size: 11px; + color: white; + display: flex; + align-items: center; + justify-content: center; +} +``` + +#### 10.2.3 欢迎区域(Hero Section) + +``` +┌───────────────────────────────────────────────────────┐ +│ │ +│ 👋 欢迎回来,张三 │ +│ 今天是2025年11月7日 星期四 ☀️ │ +│ │ +│ 您的部门积分余额:5,750 分 [充值] │ +│ 💡 本月还剩5天,快抓紧时间发起任务吧! │ +│ │ +│ [+ 发起新任务] [查看任务进度] │ +│ │ +└───────────────────────────────────────────────────────┘ +``` + +**样式实现** +```css +.hero-section { + margin: 32px auto; + max-width: 1200px; + padding: 48px; + background: linear-gradient(135deg, #2A2A2A 0%, #1F1F1F 100%); + border: 1px solid #3A3A3A; + border-radius: 16px; + position: relative; + overflow: hidden; +} + +/* 装饰性背景 */ +.hero-section::before { + content: ''; + position: absolute; + top: -50%; + right: -10%; + width: 500px; + height: 500px; + background: radial-gradient(circle, rgba(16, 163, 127, 0.05) 0%, transparent 70%); + border-radius: 50%; +} + +.hero-title { + font-size: 32px; + font-weight: 700; + color: #ECECEC; + margin-bottom: 8px; +} + +.hero-subtitle { + font-size: 16px; + color: #AFAFAF; + margin-bottom: 24px; +} + +.hero-info { + display: flex; + align-items: center; + gap: 16px; + padding: 16px; + background: rgba(16, 163, 127, 0.1); + border: 1px solid rgba(16, 163, 127, 0.2); + border-radius: 12px; + margin-bottom: 24px; +} + +.hero-actions { + display: flex; + gap: 16px; +} +``` + +#### 10.2.4 数据卡片区域(Data Cards) + +``` +┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ +│ 📊 │ │ ✅ │ │ ⏰ │ │ 💎 │ +│ │ │ │ │ │ │ │ +│ 8 个 │ │ 12 个 │ │ 2 个 │ │ 1,250 │ +│ 进行中 │ │ 已完成 │ │ 待审核 │ │ 积分 │ +│ │ │ │ │ │ │ │ +│ ↑ +2 今日 │ │ ↑ +3 今日 │ │ ⚠️ 紧急 │ │ ↑ +150 │ +└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ +``` + +**样式实现** +```css +.data-cards { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 24px; + margin: 32px auto; + max-width: 1200px; +} + +.data-card { + background: #2A2A2A; + border: 1px solid #3A3A3A; + border-radius: 12px; + padding: 24px; + transition: all 250ms cubic-bezier(0.16, 1, 0.3, 1); + cursor: pointer; +} + +.data-card:hover { + border-color: #10A37F; + transform: translateY(-4px); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); +} + +.data-card-icon { + font-size: 32px; + margin-bottom: 16px; +} + +.data-card-value { + font-size: 36px; + font-weight: 700; + color: #ECECEC; + margin-bottom: 4px; +} + +.data-card-label { + font-size: 14px; + color: #AFAFAF; + margin-bottom: 12px; +} + +.data-card-trend { + font-size: 12px; + color: #10A37F; + display: flex; + align-items: center; + gap: 4px; +} + +.data-card-trend.down { + color: #EF4444; +} +``` + +#### 10.2.5 主要内容区域(双列布局) + +``` +┌──────────────────────────┐ ┌─────────────────────────┐ +│ 📋 今日待办 [全部3个] │ │ 📈 本周数据 │ +│ ─────────────────────── │ │ ────────────────────── │ +│ │ │ │ +│ ┌────────────────────┐ │ │ [折线图] │ +│ │ ⏰ 主图设计 │ │ │ 任务完成趋势 │ +│ │ 截止:今天 18:00 │ │ │ │ +│ │ 🔥 紧急 │ │ │ 本周完成:15个 │ +│ │ [查看详情] │ │ │ 上周:12个 ↑25% │ +│ └────────────────────┘ │ │ │ +│ │ │ │ +│ ┌────────────────────┐ │ │ ┌───────────────────┐ │ +│ │ 📝 详情页优化 │ │ │ │ 🎯 本周目标 │ │ +│ │ 截止:明天 12:00 │ │ │ │ 完成20个任务 │ │ +│ │ [查看详情] │ │ │ │ ████░░░░ 75% │ │ +│ └────────────────────┘ │ │ └───────────────────┘ │ +│ │ │ │ +│ [查看全部任务 →] │ │ [查看详细报表 →] │ +└──────────────────────────┘ └─────────────────────────┘ +``` + +**样式实现** +```css +.main-content { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; + margin: 32px auto; + max-width: 1200px; +} + +.content-card { + background: #2A2A2A; + border: 1px solid #3A3A3A; + border-radius: 12px; + padding: 24px; + min-height: 400px; +} + +.content-card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; + padding-bottom: 16px; + border-bottom: 1px solid #3A3A3A; +} + +.content-card-title { + font-size: 18px; + font-weight: 600; + color: #ECECEC; + display: flex; + align-items: center; + gap: 8px; +} + +.task-item { + background: #1F1F1F; + border: 1px solid #3A3A3A; + border-radius: 8px; + padding: 16px; + margin-bottom: 12px; + transition: all 250ms ease; +} + +.task-item:hover { + border-color: #10A37F; + background: #252525; +} + +.task-item-title { + font-size: 16px; + font-weight: 500; + color: #ECECEC; + margin-bottom: 8px; +} + +.task-item-meta { + display: flex; + gap: 16px; + font-size: 12px; + color: #AFAFAF; +} + +.task-urgent { + display: inline-flex; + align-items: center; + padding: 4px 8px; + background: rgba(239, 68, 68, 0.1); + border: 1px solid rgba(239, 68, 68, 0.3); + border-radius: 4px; + color: #EF4444; + font-size: 11px; + font-weight: 500; +} +``` + +#### 10.2.6 排行榜区域 + +``` +┌─────────────────────────────────────────────────────┐ +│ 🏆 本月设计师排行榜 [查看全部] │ +│ ──────────────────────────────────────────────────│ +│ │ +│ 🥇 李四 28个任务 4.9⭐ +1,400分 │ +│ UI设计师 本月第一 │ +│ ────────────────────────────────────────────────│ +│ 🥈 王五 22个任务 4.8⭐ +1,100分 │ +│ 视觉设计师 本月第二 │ +│ ────────────────────────────────────────────────│ +│ 🥉 赵六 18个任务 4.7⭐ +900分 │ +│ 平面设计师 本月第三 │ +│ ────────────────────────────────────────────────│ +│ │ +│ 你当前排名第8位,再完成2个任务即可进入前5! │ +│ │ +└─────────────────────────────────────────────────────┘ +``` + +**样式实现** +```css +.leaderboard { + background: #2A2A2A; + border: 1px solid #3A3A3A; + border-radius: 12px; + padding: 24px; + margin: 32px auto; + max-width: 1200px; +} + +.leaderboard-item { + display: flex; + align-items: center; + gap: 16px; + padding: 16px; + background: #1F1F1F; + border: 1px solid #3A3A3A; + border-radius: 8px; + margin-bottom: 12px; + transition: all 250ms ease; +} + +.leaderboard-item:hover { + background: #252525; + border-color: #10A37F; + transform: translateX(4px); +} + +.leaderboard-rank { + font-size: 32px; + min-width: 48px; + text-align: center; +} + +.leaderboard-info { + flex: 1; +} + +.leaderboard-name { + font-size: 18px; + font-weight: 600; + color: #ECECEC; + margin-bottom: 4px; +} + +.leaderboard-role { + font-size: 12px; + color: #737373; +} + +.leaderboard-stats { + display: flex; + gap: 24px; + font-size: 14px; + color: #AFAFAF; +} + +.leaderboard-highlight { + padding: 16px; + background: rgba(16, 163, 127, 0.1); + border: 1px solid rgba(16, 163, 127, 0.2); + border-radius: 8px; + text-align: center; + color: #10A37F; + font-size: 14px; + margin-top: 16px; +} +``` + +#### 10.2.7 响应式适配 + +**桌面端(≥1200px)** +- 数据卡片:4列 +- 主要内容:2列 +- 最大宽度:1200px + +**平板端(768px - 1199px)** +- 数据卡片:2列 +- 主要内容:1列 +- 侧边距:24px + +**移动端(<768px)** +- 数据卡片:1列 +- 主要内容:1列 +- 侧边距:16px +- 导航栏折叠为汉堡菜单 + +```css +@media (max-width: 1199px) { + .data-cards { + grid-template-columns: repeat(2, 1fr); + } + .main-content { + grid-template-columns: 1fr; + } +} + +@media (max-width: 767px) { + .data-cards { + grid-template-columns: 1fr; + } + .navbar { + padding: 0 16px; + } + .hero-section { + padding: 24px; + } +} +``` + +### 10.3 响应式设计 +- 支持桌面端(主要) +- 支持平板端 +- 支持移动端(查看为主) + +### 10.4 交互动效 +- 页面切换:淡入淡出 +- 卡片悬浮:轻微上浮 + 阴影加深 +- 按钮点击:涟漪效果 +- 加载状态:骨架屏/加载动画 +- 数据更新:数字滚动动效 + +### 10.4 关键页面布局 + +#### 任务大厅 +``` +┌──────────────────────────────────────┐ +│ Logo 任务大厅 我的任务 设计师大厅 │ 顶部导航栏 +│ 系统配置 [通知] [头像]│ +├──────────────────────────────────────┤ +│ ┌──────────────┐ │ +│ │ 筛选条件 │ 任务列表(卡片流) │ +│ │ │ ┌────┐ ┌────┐ │ +│ │ 任务类型 │ │卡片│ │卡片│ │ +│ │ ☑ 新品 │ └────┘ └────┘ │ +│ │ ☑ 优化 │ ┌────┐ ┌────┐ │ +│ │ │ │卡片│ │卡片│ │ +│ │ 品牌 │ └────┘ └────┘ │ +│ │ [搜索框] │ │ +│ │ │ [加载更多] │ +│ │ 积分范围 │ │ +│ │ [滑块] │ │ +│ └──────────────┘ │ +└──────────────────────────────────────┘ +``` + +#### 我的任务(日历视图) +``` +┌──────────────────────────────────────┐ +│ Logo 任务大厅 我的任务 设计师大厅 │ +│ 系统配置 [通知] [头像]│ +├──────────────────────────────────────┤ +│ ┌列表视图┐ ┌日历视图┐ │ +│ └────────┘ └────────┘ │ +│ │ +│ 2025年11月 < > │ +│ │ +│ 日 一 二 三 四 五 六 │ +│ 1 2 3 4 │ +│ 5 6 7 8 9 10 11 │ +│ 12 13 14 15 16 17 18 │ +│ │ +│ 当日任务: │ +│ ┌────────────────────┐ │ +│ │ 主图设计 [进行中] │ │ +│ │ 进度:75% │ │ +│ └────────────────────┘ │ +└──────────────────────────────────────┘ +``` + +### 10.5 无障碍设计 +- 支持键盘快捷键 +- 色盲友好的颜色组合 +- 合理的对比度(符合WCAG AA标准) +- 图片ALT文本 + +--- + +## 十一、安全性设计 + +### 11.1 认证与授权 +- JWT Token机制 +- Token过期和刷新策略 +- 权限控制(RBAC) +- 接口权限校验 + +### 11.2 数据安全 +- 密码加密存储(BCrypt) +- 敏感信息脱敏 +- SQL注入防护(参数化查询) +- XSS攻击防护 +- CSRF防护 + +### 11.3 文件安全 +- 文件类型校验 +- 文件大小限制 +- 恶意文件扫描 +- 文件访问权限控制 + +### 11.4 日志与审计 +- 操作日志记录 +- 异常日志记录 +- 登录日志 +- 敏感操作审计 + +--- + +## 十二、性能优化 + +### 12.1 前端优化 +- 代码分割(Code Splitting) +- 懒加载 +- 图片懒加载和压缩 +- CDN加速 +- 浏览器缓存 +- Gzip压缩 + +### 12.2 后端优化 +- 数据库索引优化 +- Redis缓存热点数据 +- 接口响应时间监控 +- SQL查询优化 +- 连接池配置 + +### 12.3 数据库优化 +- 读写分离 +- 分库分表(数据量大时) +- 慢查询监控 +- 定期数据归档 + +--- + +## 十三、测试策略 + +### 13.1 测试类型 +- 单元测试 +- 集成测试 +- 接口测试 +- 前端E2E测试 +- 性能测试 +- 安全测试 + +### 13.2 测试覆盖率 +- 核心业务逻辑:>80% +- 工具类方法:>90% + +### 13.3 测试环境 +- 开发环境(Dev) +- 测试环境(Test) +- 预发布环境(Staging) +- 生产环境(Production) + +--- + +## 十四、部署与运维 + +### 14.1 部署方案 +``` +前端:Nginx静态资源服务 +后端:Docker容器 + Kubernetes(可选) +数据库:主从复制 + 定期备份 +缓存:Redis集群 +``` + +### 14.2 监控告警 +- 应用性能监控(APM) +- 服务器资源监控 +- 数据库监控 +- 日志监控 +- 告警通知(钉钉群/邮件/短信) + +### 14.3 备份策略 +- 数据库:每日全量备份 + 实时binlog +- 文件:定期备份到对象存储 +- 配置:版本控制 + +--- + +## 十五、项目计划 + +### 15.1 开发阶段 + +#### 第一阶段:基础框架搭建(2周) +- 技术选型确认 +- 项目初始化 +- 数据库设计 +- 认证系统集成(开源方案) +- 钉钉登录集成 +- 组织架构同步 + +#### 第二阶段:核心功能开发(4周) +- 任务发起模块 +- 任务大厅模块 +- 任务分配和接收 +- 进度跟踪 +- 审核评价 + +#### 第三阶段:配置与管理(2周) +- 系统配置模块 +- 积分管理 +- 权限管理 +- 通知模块 + +#### 第四阶段:数据分析与展示(2周) +- 设计师进度大厅 +- 数据统计 +- 图表展示 +- 排行榜 + +#### 第五阶段:优化与测试(2周) +- UI/UX优化 +- 性能优化 +- 全面测试 +- Bug修复 + +#### 第六阶段:部署上线(1周) +- 部署环境准备 +- 数据迁移 +- 上线部署 +- 培训和文档 + +### 15.2 时间线 +总计:**13周**(约3个月) + +--- + +## 十六、风险与挑战 + +### 16.1 技术风险 +| 风险 | 影响 | 应对措施 | +|------|------|----------| +| 钉钉API变更 | 登录和同步失败 | 关注官方文档,及时适配 | +| 并发抢单冲突 | 超卖问题 | 使用锁机制,充分测试 | +| 大文件上传 | 性能问题 | 分片上传,限制大小 | +| 数据库性能 | 响应慢 | 索引优化,缓存策略 | + +### 16.2 业务风险 +| 风险 | 影响 | 应对措施 | +|------|------|----------| +| 积分滥用 | 激励失效 | 审批机制,日志审计 | +| 恶意抢单 | 资源浪费 | 限制规则,信用体系 | +| 任务延期 | 影响业务 | 预警机制,责任追溯 | + +### 16.3 团队风险 +| 风险 | 影响 | 应对措施 | +|------|------|----------| +| 人员变动 | 进度延误 | 代码规范,文档完善 | +| 需求变更 | 开发返工 | 敏捷开发,小步迭代 | + +--- + +## 十七、后续优化方向 + +### 17.1 功能增强 +- **AI智能分配**:基于历史数据,AI推荐最合适的设计师 +- **任务拆分**:大任务自动拆分为子任务 +- **协同设计**:多人协作完成同一任务 +- **版本管理**:设计稿版本控制和对比 +- **移动端App**:独立的移动应用 +- **消息即时通讯**:任务内置聊天功能 +- **积分商城**:积分兑换奖品 +- **成长体系**:设计师等级和勋章系统 + +### 17.2 技术优化 +- **微服务架构**:拆分为多个独立服务 +- **GraphQL**:更灵活的API查询 +- **WebSocket**:实时消息推送 +- **大数据分析**:更深入的数据挖掘 +- **机器学习**:任务时长预测、质量评估 + +### 17.3 集成扩展 +- **集成更多设计工具**:Figma、Sketch等 +- **集成项目管理工具**:Jira、Trello等 +- **对接财务系统**:积分与奖金挂钩 +- **对接HR系统**:绩效考核数据同步 + +--- + +## 十八、参考资料 + +### 18.1 开源认证系统推荐 +1. **Casdoor** + - 官网:https://casdoor.org/ + - 特点:UI友好,易于集成,支持多种身份提供商 + +2. **Keycloak** + - 官网:https://www.keycloak.org/ + - 特点:功能强大,企业级,支持复杂场景 + +3. **Auth0**(部分开源) + - 官网:https://auth0.com/ + - 特点:云服务,集成简单 + +### 18.2 技术文档 +- Vue 3官方文档:https://vuejs.org/ +- React官方文档:https://react.dev/ +- Spring Boot官方文档:https://spring.io/projects/spring-boot +- 钉钉开放平台:https://open.dingtalk.com/ +- Ant Design:https://ant.design/ + +### 18.3 最佳实践 +- RESTful API设计规范 +- 前端工程化实践 +- 数据库设计范式 +- 安全编码规范 + +--- + +## 十九、附录 + +### 19.1 术语表 +| 术语 | 说明 | +|------|------| +| SPU | Standard Product Unit,标准产品单位 | +| 叶子类目 | 类目树中最底层、不可再分的类目 | +| 抢单 | 设计师主动接受任务大厅中的招募任务 | +| 积分池 | 每个团队拥有的可用积分总额 | +| 甘特图 | 项目进度的条形图表示 | +| 任务调配 | 管理员对进行中任务和任务池任务进行转移、指定、撤回等管理操作 | +| 任务转移 | 将进行中的任务从一个设计师转移给另一个设计师 | +| 指定接收人 | 管理员为任务池中的招募任务指定具体接收设计师 | +| 任务撤回 | 将任务从任务大厅撤回到待分配状态 | +| 任务池 | 任务大厅中所有招募任务的集合 | +| 智能推荐 | 基于设计师负荷、技能、评分等维度推荐最合适的设计师 | +| 延误扣分 | 当任务转移时如果存在延误,根据延误时长对原设计师进行积分扣除 | +| 时效重新计算 | 任务转移给新设计师后,根据剩余工作量和新设计师情况重新计算交付时间 | +| 延误等级 | 将延误按时长分为轻微、一般、严重、重大四个等级 | +| 延误申诉 | 设计师对延误扣分有异议时,可提交申诉由管理员审核 | +| 熟练度系数 | 设计师对特定类型任务的熟练程度系数,影响时效计算(0.8-1.2) | +| 负荷系数 | 设计师当前工作负荷对新任务完成时间的影响系数(1.0-1.3) | +| 需求部门 | 只能发起任务、不能接单的部门,拥有积分池消耗积分 | +| 接单部门 | 只能接单、不能发起任务的部门,完成任务获得积分奖励 | +| 部门负责人 | 需求部门的管理者,可设置积分消耗限制和管理部门成员 | +| 积分消耗限制 | 对需求部门按天/周/月设置的积分使用上限,用于控制资源合理使用 | +| 需求统计展板 | 展示需求发起数据的可视化看板,包含部门和用户维度的多角度分析 | +| 成就勋章 | 设计师达成特定条件后获得的虚拟勋章,附带积分奖励 | +| 里程碑 | 设计师在工作过程中达成的重要节点(如完成100个任务) | +| 实时排名 | 根据积分、任务数等维度实时更新的设计师排名 | +| 冲刺提示 | 在周末或月末提醒设计师完成目标的激励信息 | +| 同事对比 | 将个人数据与同部门同事进行对比,激发竞争意识 | +| 竞赛活动 | 系统组织的短期比赛活动,如"设计马拉松" | +| 团队PK | 不同部门或小组之间的团队对抗赛 | +| 连续记录 | 设计师连续达成某个目标的天数(如连续21天按时交付) | +| 激励看板 | 综合展示设计师成就、排名、目标等激励信息的可视化界面 | +| 激励游戏开关 | 管理员控制各种激励功能(排名、成就等)的启用/禁用开关 | +| 自动奖励规则 | 系统按周期(每日/周/月)自动计算并发放奖励的规则配置 | +| 奖励积分配置 | 为各种激励行为(签到、完成任务、好评等)设置的积分奖励值 | +| 激励积分池 | 专门用于激励系统奖励发放的独立积分池,不占用团队积分 | +| 竞赛模板 | 预设的竞赛活动配置模板,可快速创建新竞赛 | +| 排名规则 | 综合排名的计算规则,包含各维度的权重和计分公式 | +| 自动发放 | 系统根据设定规则自动计算并发放奖励,无需人工干预 | +| 奖励审核 | 管理员对系统自动计算的奖励进行审核后再发放 | + +### 19.2 联系方式 +- 项目负责人:[姓名] +- 技术负责人:[姓名] +- 产品负责人:[姓名] + +--- + +## 📌 总结 + +本系统是一个**设计师任务抢单管理平台**,核心目标是: +1. ✅ 提高任务分配效率 +2. ✅ 实现进度透明化管理 +3. ✅ 建立公平的积分激励机制 +4. ✅ 提供多维度的数据分析 + +**核心亮点**: +- 🎯 灵活的任务发布方式(指定/招募/分配) +- 🔄 **强大的任务调配功能(管理员可灵活调配进行中任务和任务池)** +- 🏢 **清晰的部门职责划分(需求部门 vs 接单部门)** +- 💰 **智能积分消耗控制(按天/周/月限制,防止资源滥用)** +- 📊 **多维度需求统计展板(部门/用户视角,数据可视化)** +- 🎮 **游戏化激励系统(成就勋章、排名竞赛、团队PK)** +- ⚙️ **完善的激励配置后台(自动奖励、积分配置、游戏开关)** +- 💎 积分制度激励设计师 +- 📈 可视化的进度管理(日历/甘特图) +- 🏆 多维度排行榜和数据分析 +- 🤖 智能推荐算法(任务转移时推荐最合适设计师) +- ⚠️ 延误扣分与申诉机制(公平透明) +- 🔔 完善的通知提醒机制 +- 🔐 企业级的认证授权体系 +- 📝 完整的调配日志和审计功能 + +**技术特点**: +- 前后端分离架构 +- 开源认证系统集成 +- 钉钉生态深度集成 +- 现代化UI/UX设计 +- 高性能和高可用性 + +--- + +## 📝 文档版本 + +- **版本**:v1.7 +- **日期**:2025-11-07 +- **状态**:待评审 + +### 更新记录 + +**v1.7 (2025-11-07)** +- 🎨 新增:OpenAI 官方 UI 设计规范(10.1章节) + - 明确要求严格遵循 OpenAI 官方 UI 设计规范 + - 核心设计原则(极简主义、现代化、清晰层次、一致性) + - 完整配色方案(深色模式为主,参考OpenAI经典翠绿色#10A37F) + - 字体规范(Söhne字体家族、字体大小系统、字重规范) + - 间距系统(8px基准、统一内外边距) + - 圆角规范(4px-24px分级圆角) + - 阴影系统(4级阴影深度) + - 动画与过渡(OpenAI流畅体验、标准缓动函数) + - 组件设计规范(按钮、输入框、卡片等完整CSS实现) +- 🏠 新增:首页 UI 布局详细设计(10.2章节) + - 首页整体布局(导航栏+欢迎区+数据卡片+内容区+排行榜) + - 顶部导航栏详细设计(含完整CSS实现、毛玻璃效果) + - 欢迎区域(Hero Section)设计(含积分余额提醒) + - 数据卡片区域(4列网格、悬浮动效) + - 主要内容区域(双列布局、今日待办+本周数据) + - 排行榜区域(本月设计师Top3展示) + - 响应式适配方案(桌面/平板/移动端) +- 📐 完善:所有UI组件提供完整的CSS代码示例 +- 🎯 特色:深色模式优先、OpenAI经典翠绿色品牌色、流畅动效体验 + +**v1.6 (2025-11-07)** +- 💰 新增:部门积分余额消耗提醒看板(5.7章节) + - 部门积分消耗月报展示(月底倒计时、消耗率排行) + - 智能建议功能(根据剩余积分推荐可发起任务数) + - 积分消耗提醒规则(分级提醒:绿/黄/橙/红) + - 月底倒计时提醒(30天/15天/7天/3天/1天分级提醒) + - 部门排行榜增加积分余额和消耗率展示 + - 目的:激发需求部门在月底前充分利用积分资源 +- 📎 优化:文件上传和交付方式说明(2.2.8、2.2.10章节) + - 明确发布需求时仅需上传需求表格(Excel/Word) + - 明确设计师交付时无需上传文件,仅填写素材路径 + - 添加交付路径输入界面设计 + - 支持共享盘路径、云盘链接、服务器路径等多种方式 + - 添加路径验证规则和格式检查 +- 🔔 完善:钉钉通知集成技术方案(7.3章节) + - 详细的通知数据结构设计(包含钉钉推送所需字段) + - 钉钉消息推送接口封装(DingTalkNotificationService类) + - 支持多种钉钉消息类型(OA/ActionCard/Markdown/Text) + - 通知队列机制(异步处理,避免影响主业务性能) + - 通知表数据库设计(sys_notification表,含钉钉推送字段) + - 推送策略和失败重试机制 + - 确保所有站内通知都预留钉钉推送接口 + +**v1.5 (2025-11-07)** +- ⚙️ 新增:激励系统后台配置管理功能(6.1.8章节) + - 激励游戏总开关(11个独立模块开关控制) + - 成就勋章配置(创建、编辑、多级成就、奖励设置) + - 自动奖励规则配置(每日/周/月/季度/年度,多种奖励类型) + - 竞赛活动配置(创建竞赛、设置规则、奖励结算、模板管理) + - 团队PK配置(对抗赛创建、实时战况、结算管理) + - 排名规则配置(维度权重、更新频率、保护机制) + - 激励积分池管理(预算设置、实时监控、趋势预测) +- 📝 更新:数据库设计(增加10张配置管理表) + - 激励系统配置表(incentive_config) + - 自动奖励规则表(auto_reward_rule) + - 自动奖励发放记录表(auto_reward_log) + - 自动奖励明细表(auto_reward_detail) + - 竞赛活动表(competition) + - 竞赛排名表(competition_ranking) + - 团队PK表(team_pk) + - 团队PK成员表(team_pk_member) + - 排名规则配置表(ranking_rule) + - 激励积分池表(incentive_pool) +- 📝 更新:接口设计 + - 新增:激励配置管理模块API(62个管理接口) + - 涵盖:总开关管理、成就配置、自动奖励、竞赛管理、团队PK、排名规则、积分池、数据分析 +- 📝 更新:通知场景(增加5个自动奖励通知) +- 📝 更新:数据字典(增加7个配置相关枚举类型) +- 📝 更新:系统模块划分(增加激励配置管理模块) +- 📝 更新:核心亮点(增加完善的激励配置后台) +- 📝 更新:术语表(增加8个配置相关术语) +- 💡 特色:管理员可灵活配置激励规则,系统自动按周期发放奖励,激励游戏可独立开关控制 + +**v1.4 (2025-11-07)** +- ✨ 新增:设计师激励提示系统(5.3章节) + - 实时排名提示(排名变化、超越提醒) + - 成就勋章系统(12种成就,积分奖励) + - 里程碑提醒(已达成/即将达成/挑战目标) + - 积分冲刺提示(月末冲刺、周冲刺) + - 同事对比功能(友好竞争) + - 每日激励卡片(签到、待办、加油语录) + - 即时激励弹窗(完成任务、获得好评) + - 竞赛活动提示(月度竞赛) + - 团队PK提示(部门对抗赛) + - 智能提醒建议(任务推荐、休息提醒) + - 可视化激励看板(趋势曲线、目标进度) +- 📝 更新:数据库设计 + - 新增:成就勋章表(achievement) + - 新增:用户成就记录表(user_achievement) + - 新增:用户目标表(user_goal) + - 新增:用户签到记录表(user_check_in) +- 📝 更新:接口设计 + - 新增:激励系统模块API(28个接口) + - 涵盖:成就、排名、目标、签到、竞赛、团队PK、智能推荐 +- 📝 更新:通知场景(增加9个激励相关通知) +- 💡 特色:通过游戏化设计激发设计师积极性和竞争意识 + +**v1.3 (2025-11-07)** +- ✨ 新增:组织架构与部门管理模块(一之二章节) + - 部门类型管理(需求部门/接单部门) + - 部门权限控制(发起/接单分离) + - 需求部门负责人管理 + - 积分消耗限制功能(按天/周/月) + - 部门数据权限隔离 +- ✨ 新增:需求统计展板模块(五之二章节) + - 部门维度统计和排行榜 + - 用户维度统计和排行榜 + - 需求发起趋势分析 + - 需求类型分析 + - 数据导出和报表订阅 +- 📝 更新:权限角色设计 + - 新增:需求部门负责人角色 + - 区分:接单部门主管 vs 需求部门负责人 + - 细化:设计师和需求方的部门归属 +- 📝 更新:数据库设计 + - 更新:部门表(增加部门类型、消耗限制等字段) + - 新增:部门消耗限制记录表(dept_limit_record) +- 📝 更新:接口设计 + - 新增:部门管理模块API(12个接口) + - 新增:需求统计模块API(9个接口) +- 📝 更新:数据字典 + - 新增:部门类型枚举(DEMAND/RECEIVER) + - 新增:消耗限制周期枚举(DAILY/WEEKLY/MONTHLY) +- 📝 更新:通知场景(增加4个部门管理相关通知) +- 📝 更新:术语表(增加5个术语) +- 📝 更新:核心亮点(突出部门管理和统计功能) + +**v1.2 (2025-11-07)** +- ✨ 新增:延误扣分机制(4.9.3章节) + - 延误判定标准(4级延误分类) + - 可配置的扣分规则(轻微/一般/严重/重大) + - 延误扣分计算公式和算法 + - 设计师延误档案和数据留存 + - 延误扣分通知系统 + - 延误申诉机制 +- ✨ 新增:时效重新计算算法 + - 剩余工作量计算 + - 设计师熟练度系数 + - 设计师负荷系数 + - 智能计算新交付时间 +- ✨ 新增:延误扣分规则配置(6.1.6章节) + - 可视化规则管理界面 + - 延误等级配置(可增删改) + - 扣分规则测试工具 + - 申诉审核配置 +- 📝 更新:任务转移流程(增加延误检测和扣分步骤) +- 📝 更新:数据库设计 + - 新增:延误扣分记录表(delay_penalty_record) + - 新增:延误扣分规则表(delay_penalty_rule) + - 更新:任务调配记录表(增加延误字段) + - 更新:积分记录表(增加延误扣分关联) +- 📝 更新:接口设计 + - 新增:延误扣分模块API(7个接口) + - 新增:延误扣分规则配置API(5个接口) + - 更新:设计师模块API(增加延误记录查询) +- 📝 更新:积分变动类型(增加延误扣分和申诉返还) +- 📝 更新:通知场景(增加3个延误相关通知) +- 📝 更新:系统参数配置(增加时效计算相关参数) + +**v1.1 (2025-11-07)** +- ✨ 新增:管理员任务调配模块(四之二章节) +- ✨ 新增:进行中任务转移功能(单个/批量) +- ✨ 新增:任务池管理功能(指定接收人、撤回、推广) +- ✨ 新增:智能推荐算法(推荐最合适的设计师) +- ✨ 新增:任务优先级调整功能 +- ✨ 新增:调配日志和统计功能 +- ✨ 新增:调配权限审计功能 +- 📝 更新:权限角色设计(增加管理员角色) +- 📝 更新:系统模块划分(增加任务调配模块) +- 📝 更新:接口设计(增加13个调配相关API) +- 📝 更新:数据库设计(增加任务调配记录表) +- 📝 更新:通知场景(增加6个调配相关通知) +- 📝 更新:术语表(增加调配相关术语) + +**v1.0 (2025-11-07)** +- 🎉 初始版本发布 +- 完整的需求分析和功能设计 +- 技术架构和实施方案 + +--- + +**祝项目开发顺利!** 🎉 + diff --git a/README.md b/README.md new file mode 100644 index 0000000..b837e06 --- /dev/null +++ b/README.md @@ -0,0 +1,362 @@ +# 🎨 DesignHub - 设计师任务管理平台 + +
+ +![Version](https://img.shields.io/badge/version-6.0-blue.svg) +![Status](https://img.shields.io/badge/status-completed-success.svg) +![License](https://img.shields.io/badge/license-MIT-green.svg) + +**现代化的设计师任务抢单与管理系统** + +[功能特性](#-核心功能) • [技术栈](#-技术栈) • [快速开始](#-快速开始) • [文档](#-项目文档) • [截图](#-系统截图) + +
+ +--- + +## 📖 项目简介 + +DesignHub是一个功能完善的设计师任务管理平台,旨在帮助企业高效管理设计任务的发布、接收、执行和评审全流程。系统采用积分制管理,配备完整的激励机制和数据分析功能,大幅提升设计团队的工作效率和积极性。 + +### 🎯 核心价值 + +- **任务流转高效化**:从发布到完成,全程可视化管理 +- **积分控制精准化**:多维度积分管理,合理分配资源 +- **激励机制完善化**:签到、成就、排行榜,激发团队活力 +- **数据分析智能化**:多维度数据统计,辅助决策 +- **消息通知及时化**:实时推送,确保信息触达 + +--- + +## ✨ 核心功能 + +### 🔐 用户认证与权限 +- JWT双令牌认证机制 +- RBAC权限模型(7种角色) +- 钉钉登录预留接口 +- 细粒度权限控制 + +### 📋 任务管理 +- 任务发起(配置化类型、品牌、平台) +- 任务抢单(自由抢单/指定分配/招募) +- 任务执行(进度跟踪、时效计算) +- 任务评审(五星评分、质量评价) +- 管理员调配(分配、转移、优先级调整) + +### 💰 积分管理 +- 团队积分池 +- 积分充值与审批 +- 周期限额管理(日/周/月) +- 积分消耗与奖励 +- 延误扣分机制 + +### 🏆 激励系统 +- **签到系统**:每日签到、连续追踪、奖励递增 +- **成就系统**:10个默认成就、自动解锁、奖励发放 +- **排行榜**:好评排行、积分排行、效率排行 + +### 🔔 通知系统 +- 站内消息(4种类型) +- 通知分类与筛选 +- 已读/未读管理 +- 钉钉推送预留接口 + +### 📊 数据分析 +- 需求统计(总览/趋势/类型分布) +- 用户排行(需求量/完成率/满意度) +- 部门统计(积分使用/预警机制) +- 设计师统计(任务量/积分/好评率) +- ECharts可视化图表 + +### 🏢 部门管理 +- 部门CRUD操作 +- 部门类型(需求/接单) +- 部门层级结构 +- 积分限额配置 + +### ⚙️ 配置管理 +- 需求类型配置 +- 品牌/平台/类目配置 +- 系统参数配置 +- 延误扣分规则 + +--- + +## 🛠️ 技术栈 + +### 前端技术 +| 技术 | 版本 | 说明 | +|------|------|------| +| Vue | 3.x | 渐进式JavaScript框架 | +| TypeScript | 5.x | 类型安全 | +| Ant Design Vue | 4.x | UI组件库 | +| Pinia | 2.x | 状态管理 | +| Vue Router | 4.x | 路由管理 | +| Axios | 1.x | HTTP客户端 | +| ECharts | 5.x | 数据可视化 | +| SCSS | - | CSS预处理器 | + +### 后端技术 +| 技术 | 版本 | 说明 | +|------|------|------| +| Spring Boot | 3.x | Java应用框架 | +| MySQL | 8.0 | 关系型数据库 | +| Redis | 7.x | 缓存数据库 | +| MyBatis Plus | 3.x | ORM框架 | +| JWT | - | Token认证 | +| Swagger | 3.x | API文档 | +| Quartz | 2.x | 任务调度 | +| Apache POI | 5.x | Excel操作 | + +### 开发工具 +- IntelliJ IDEA - 后端开发 +- VS Code - 前端开发 +- Maven - 后端构建 +- pnpm - 前端包管理 + +--- + +## 🚀 快速开始 + +### 环境要求 + +- JDK 17+ +- Node.js 18+ +- MySQL 8.0+ +- Redis 6.0+ +- Maven 3.6+ +- pnpm 8.0+ + +### 1. 克隆项目 + +```bash +git clone +cd qiangdanxitong +``` + +### 2. 配置数据库 + +```sql +-- 创建数据库 +CREATE DATABASE design_hub CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- 创建用户 +CREATE USER 'designhub'@'localhost' IDENTIFIED BY 'your_password'; +GRANT ALL PRIVILEGES ON design_hub.* TO 'designhub'@'localhost'; +FLUSH PRIVILEGES; +``` + +### 3. 启动后端 + +```bash +cd backend + +# 修改配置文件 +vi src/main/resources/application.yml + +# 构建并运行 +mvn clean install -DskipTests +mvn spring-boot:run +``` + +后端启动成功后,访问:http://localhost:8080/swagger-ui.html + +### 4. 启动前端 + +```bash +cd frontend + +# 安装依赖 +pnpm install + +# 启动开发服务器 +pnpm dev +``` + +前端启动成功后,访问:http://localhost:5173 + +### 5. 登录系统 + +**测试账号**: +- 用户名:`admin` +- 密码:`123456` + +--- + +## 📚 项目文档 + +| 文档名称 | 说明 | 链接 | +|---------|------|------| +| 需求文档 | 详细的功能需求和设计规范 | [PROJECT_REQUIREMENTS.md](./PROJECT_REQUIREMENTS.md) | +| 项目总结 | 项目概述、功能、技术亮点 | [PROJECT_SUMMARY.md](./PROJECT_SUMMARY.md) | +| 部署指南 | 本地和生产环境部署指南 | [DEPLOYMENT_GUIDE.md](./DEPLOYMENT_GUIDE.md) | +| 当前状态 | 开发进度和模块完成情况 | [CURRENT_STATUS.md](./CURRENT_STATUS.md) | +| 阶段报告 | 各开发阶段的完成报告 | PHASE1-6_COMPLETED.md | + +--- + +## 📸 系统截图 + +### 登录页面 +渐变背景、玻璃拟态设计、流畅动画效果 + +### 首页工作台 +数据卡片、快速操作、待办任务、本周统计 + +### 任务大厅 +任务列表、筛选排序、抢单操作 + +### 我的任务 +双视图(发布/接收)、进度追踪、甘特图 + +### 设计师大厅 +实时状态、进度展示、好评排行榜 + +### 数据分析 +ECharts图表、多维度统计、趋势分析 + +### 通知中心 +消息列表、分类筛选、已读管理 + +### 签到打卡 +签到状态、奖励规则、历史记录 + +### 成就中心 +徽章展示、等级标识、奖励领取 + +--- + +## 📊 项目数据 + +### 代码统计 +- 后端Java文件:67个 +- 前端Vue/TS文件:36个 +- API接口:118+个 +- 数据库表:29张 +- 完整页面:20+个 +- 总代码行数:13000+行 + +### 功能模块 +- 核心业务模块:16个 +- 前端页面组件:20+个 +- 公共组件:10+个 +- 工具类:5个 + +--- + +## 🎨 UI设计规范 + +项目严格遵循**OpenAI官方UI设计规范**: + +- ✅ 极简主义设计 +- ✅ 现代科技感 +- ✅ 清晰的层级结构 +- ✅ 一致的交互体验 +- ✅ 深色模式优先 + +### 配色方案 +- Primary: `#1890ff` (蓝色) +- Success: `#52c41a` (绿色) +- Warning: `#faad14` (橙色) +- Error: `#f5222d` (红色) + +--- + +## 📈 项目进度 + +**当前版本**:v6.0 +**完成度**:100% ✅ + +### 开发阶段 +- ✅ 阶段1:基础配置与核心功能(完成) +- ✅ 阶段2:部门管理与数据分析(完成) +- ✅ 阶段3:数据导出与系统配置(完成) +- ✅ 阶段4:JWT Token与参数配置(完成) +- ✅ 阶段5:通知、激励、管理员调配(完成) +- ✅ 阶段6:最终收尾与文档完善(完成) + +--- + +## 🔮 未来规划 + +### 短期(1周内) +- 系统测试(功能/性能/兼容性) +- Bug修复与优化 +- 用户操作手册 + +### 中期(1-3月) +- 钉钉深度集成 +- 移动端适配 +- AI推荐功能 +- 性能优化 + +### 长期(3-6月) +- 微服务化架构 +- 容器化部署 +- 数据大屏展示 +- 第三方API开放 + +--- + +## 🤝 贡献指南 + +欢迎贡献代码、报告问题或提出建议! + +1. Fork本项目 +2. 创建特性分支 (`git checkout -b feature/AmazingFeature`) +3. 提交更改 (`git commit -m 'Add some AmazingFeature'`) +4. 推送到分支 (`git push origin feature/AmazingFeature`) +5. 提交Pull Request + +--- + +## ❓ 常见问题 + +### 如何重置密码? +管理员可在用户管理模块重置用户密码。 + +### 如何配置钉钉登录? +参考 `DEPLOYMENT_GUIDE.md` 中的钉钉集成部分。 + +### 如何备份数据? +参考 `DEPLOYMENT_GUIDE.md` 中的数据库备份部分。 + +### 更多问题 +请查看 [DEPLOYMENT_GUIDE.md](./DEPLOYMENT_GUIDE.md) 的"常见问题"章节。 + +--- + +## 📄 开源协议 + +本项目采用 [MIT](LICENSE) 协议开源。 + +--- + +## 🙏 致谢 + +感谢以下开源项目: + +- [Vue.js](https://vuejs.org/) +- [Spring Boot](https://spring.io/projects/spring-boot) +- [Ant Design](https://ant.design/) +- [ECharts](https://echarts.apache.org/) +- [MyBatis Plus](https://baomidou.com/) + +--- + +## 📞 联系方式 + +- **项目名称**:DesignHub +- **当前版本**:v6.0 +- **完成度**:100% +- **开发时间**:2025年11月7日 + +--- + +
+ +**⭐ 如果这个项目对您有帮助,请给个Star支持一下!⭐** + +Made with ❤️ by DesignHub Team + +
diff --git a/S3文件存储系统实现说明.md b/S3文件存储系统实现说明.md new file mode 100644 index 0000000..28e2675 --- /dev/null +++ b/S3文件存储系统实现说明.md @@ -0,0 +1,331 @@ +# S3文件存储系统实现说明 + +## 📋 概述 + +本系统实现了基于S3兼容存储(MinIO)的文件上传、下载和管理功能,包括: + +1. **S3配置管理**:支持多配置管理和动态切换 +2. **文件上传**:支持多文件上传到S3存储 +3. **文件下载**:带登录验证的文件下载 +4. **文件记录**:完整的文件上传记录和关联管理 + +--- + +## 🗄️ 数据库设计 + +### 1. S3配置表 (`sys_s3_config`) + +```sql +CREATE TABLE `sys_s3_config` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `endpoint` varchar(255) NOT NULL COMMENT 'S3服务端点', + `bucket` varchar(100) NOT NULL COMMENT '存储桶名称', + `access_key_id` varchar(255) NOT NULL, + `secret_access_key` varchar(255) NOT NULL, + `region` varchar(50) DEFAULT 'us-east-1', + `public_domain` varchar(255), + `enable_path_style` tinyint(1) DEFAULT 1, + `file_path` varchar(255) DEFAULT '/', + `is_active` tinyint(1) DEFAULT 1, + `create_time` datetime DEFAULT CURRENT_TIMESTAMP, + `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted` tinyint(1) DEFAULT 0, + PRIMARY KEY (`id`) +); +``` + +### 2. 文件上传记录表 (`sys_file_upload`) + +```sql +CREATE TABLE `sys_file_upload` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `original_name` varchar(255) NOT NULL, + `stored_name` varchar(255) NOT NULL, + `file_path` varchar(500) NOT NULL, + `file_url` varchar(500) NOT NULL, + `file_size` bigint NOT NULL, + `file_type` varchar(100), + `uploader_id` bigint NOT NULL, + `uploader_name` varchar(100), + `related_type` varchar(50), + `related_id` bigint, + `create_time` datetime DEFAULT CURRENT_TIMESTAMP, + `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted` tinyint(1) DEFAULT 0, + PRIMARY KEY (`id`), + KEY `idx_uploader` (`uploader_id`), + KEY `idx_related` (`related_type`, `related_id`) +); +``` + +--- + +## 🔧 后端实现 + +### 1. Maven依赖 + +在 `pom.xml` 中添加: + +```xml + + + software.amazon.awssdk + s3 + 2.20.26 + + + + + commons-io + commons-io + 2.11.0 + +``` + +### 2. 核心服务 + +#### S3Service +- `uploadFile()`: 上传文件到S3 +- `getFileUploadById()`: 获取文件记录 +- `getFilesByRelated()`: 获取关联文件列表 + +#### S3ConfigService +- `getActiveConfig()`: 获取激活的配置 +- `createConfig()`: 创建新配置 +- `updateConfig()`: 更新配置 +- `activateConfig()`: 激活指定配置 + +### 3. API接口 + +#### S3配置管理 (`/api/s3/config`) + +- `GET /active` - 获取激活的配置 +- `GET /list` - 获取所有配置 +- `GET /{id}` - 获取指定配置 +- `POST /` - 创建配置 +- `PUT /{id}` - 更新配置 +- `DELETE /{id}` - 删除配置 +- `PUT /{id}/activate` - 激活配置 + +#### 文件管理 (`/api/file`) + +- `POST /upload` - 上传文件(需要登录) + - 参数:`files` (MultipartFile[]), `relatedType`, `relatedId` + - 返回:文件URL列表 + +- `GET /download/{id}` - 下载文件(需要登录,验证token) + - 自动重定向到S3文件URL + +- `GET /{id}` - 获取文件信息 +- `GET /list` - 获取关联文件列表 + +--- + +## 🎨 前端实现 + +### 1. S3配置管理页面 + +在 `Settings.vue` 中添加S3配置管理标签页: + +```vue + +``` + +### 2. 文件上传组件 + +在 `CreateTask.vue` 中修改文件上传逻辑: + +```typescript +// 使用新的S3上传接口 +const handleUpload = async (file: File) => { + const formData = new FormData(); + formData.append('files', file); + formData.append('relatedType', 'task'); + + const response = await axios.post('/api/file/upload', formData, { + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'multipart/form-data' + } + }); + + return response.data.data; // 返回文件URL数组 +}; +``` + +### 3. 文件下载 + +```typescript +const downloadFile = (fileId: number) => { + const token = localStorage.getItem('token'); + window.open(`/api/file/download/${fileId}`, '_blank'); + // 浏览器会自动携带token(如果在请求头中) +}; +``` + +--- + +## 🔐 安全特性 + +### 1. 登录验证 +- 所有文件上传需要有效的JWT token +- 文件下载需要验证登录状态 +- token验证失败返回401错误 + +### 2. 敏感信息保护 +- API返回时自动隐藏`secretAccessKey` +- 前端更新时支持保留原密钥 + +### 3. 权限控制 +- 只有管理员可以配置S3 +- 普通用户只能上传和下载自己有权限的文件 + +--- + +## 📝 使用流程 + +### 1. 配置S3存储 + +1. 登录系统,进入**系统设置** > **S3存储配置** +2. 填写S3配置信息: + - 服务端点:`https://s3.1080p.hk` + - 存储桶:`jsxiangche` + - AccessKey和SecretKey + - 公共域名:`https://s3.1080p.hk/jsxiangche` + - 文件路径前缀:`由壹点AI/` +3. 点击保存并激活 + +### 2. 上传文件 + +1. 在创建任务页面选择文件 +2. 文件自动上传到S3 +3. 系统生成文件URL并保存到数据库 +4. 任务中显示附件链接 + +### 3. 下载文件 + +1. 在任务卡片或详情中点击附件链接 +2. 系统验证登录状态 +3. 自动跳转到S3文件下载 + +--- + +## 🚀 部署步骤 + +### 1. 执行数据库脚本 + +```bash +mysql -uroot -p designhub < database/create_s3_config.sql +``` + +### 2. 更新后端依赖 + +```bash +cd backend +mvn clean install +``` + +### 3. 重启后端服务 + +```bash +systemctl restart designhub-backend +``` + +### 4. 更新前端 + +```bash +cd frontend +pnpm build +# 部署到服务器 +``` + +--- + +## 🔍 测试验证 + +### 1. 测试S3配置 + +```bash +curl -X GET http://localhost:8080/api/s3/config/active \ + -H "Authorization: Bearer YOUR_TOKEN" +``` + +### 2. 测试文件上传 + +```bash +curl -X POST http://localhost:8080/api/file/upload \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -F "files=@test.pdf" \ + -F "relatedType=task" \ + -F "relatedId=1" +``` + +### 3. 测试文件下载 + +```bash +curl -X GET http://localhost:8080/api/file/download/1 \ + -H "Authorization: Bearer YOUR_TOKEN" +``` + +--- + +## 📌 注意事项 + +1. **S3配置安全**: + - AccessKey和SecretKey存储在数据库中,需要加密存储(可选) + - 管理界面只对管理员开放 + +2. **文件大小限制**: + - 需要在Nginx和Spring Boot中配置文件大小限制 + - 建议单文件不超过100MB + +3. **文件命名**: + - 使用时间戳+UUID避免文件名冲突 + - 保留原始文件名用于显示 + +4. **错误处理**: + - S3服务不可用时给出友好提示 + - 上传失败后清理临时文件 + +5. **性能优化**: + - 使用S3的预签名URL实现直传(可选) + - 大文件使用分片上传(可选) + +--- + +## 🎯 后续优化建议 + +1. **直传优化**:前端直接上传到S3,后端只生成签名 +2. **CDN加速**:配置CloudFlare等CDN加速文件访问 +3. **图片处理**:集成图片压缩和格式转换 +4. **文件预览**:支持PDF、图片等文件在线预览 +5. **批量操作**:支持批量删除和批量下载 +6. **存储统计**:显示存储空间使用情况 + +--- + +## ✅ 完成清单 + +- [x] 数据库表设计 +- [x] 后端实体类和Mapper +- [x] S3Service核心服务 +- [x] S3ConfigService配置管理 +- [x] FileUploadController上传下载 +- [x] S3ConfigController配置管理 +- [ ] 前端S3配置管理页面 +- [ ] 前端文件上传组件改造 +- [ ] 系统测试和部署 diff --git a/S3部署说明.md b/S3部署说明.md new file mode 100644 index 0000000..1b22ec6 --- /dev/null +++ b/S3部署说明.md @@ -0,0 +1,195 @@ +# S3文件存储系统 - 部署说明 + +## 📋 部署步骤 + +### 第一步:上传文件到服务器 + +在**本地Mac**上执行以下命令,将所有新文件上传到服务器: + +```bash +# 设置变量 +SERVER="root@192.168.150.22" +BACKEND_DIR="/root/designhub-backend" # 请根据实际情况修改 + +# 1. 上传实体类 +scp backend/src/main/java/com/designhub/entity/S3Config.java $SERVER:$BACKEND_DIR/src/main/java/com/designhub/entity/ +scp backend/src/main/java/com/designhub/entity/FileUpload.java $SERVER:$BACKEND_DIR/src/main/java/com/designhub/entity/ + +# 2. 上传Mapper +scp backend/src/main/java/com/designhub/mapper/S3ConfigMapper.java $SERVER:$BACKEND_DIR/src/main/java/com/designhub/mapper/ +scp backend/src/main/java/com/designhub/mapper/FileUploadMapper.java $SERVER:$BACKEND_DIR/src/main/java/com/designhub/mapper/ + +# 3. 上传Service +scp backend/src/main/java/com/designhub/service/S3Service.java $SERVER:$BACKEND_DIR/src/main/java/com/designhub/service/ +scp backend/src/main/java/com/designhub/service/S3ConfigService.java $SERVER:$BACKEND_DIR/src/main/java/com/designhub/service/ + +# 4. 上传Controller +scp backend/src/main/java/com/designhub/controller/S3ConfigController.java $SERVER:$BACKEND_DIR/src/main/java/com/designhub/controller/ +scp backend/src/main/java/com/designhub/controller/FileUploadController.java $SERVER:$BACKEND_DIR/src/main/java/com/designhub/controller/ + +# 5. 上传pom.xml(已包含AWS SDK依赖) +scp backend/pom.xml $SERVER:$BACKEND_DIR/ +``` + +### 第二步:在服务器上执行数据库脚本 + +```bash +# SSH登录到服务器 +ssh root@192.168.150.22 + +# 执行数据库脚本 +mysql -uroot -p'Abc123456' designhub < /path/to/create_s3_config.sql + +# 或者直接执行SQL(如果文件已上传) +mysql -uroot -p'Abc123456' designhub << EOF +-- 执行 create_s3_config.sql 中的内容 +EOF +``` + +### 第三步:编译后端 + +```bash +# 在服务器上 +cd /root/designhub-backend # 根据实际路径修改 + +# 清理并编译 +mvn clean package -DskipTests +``` + +### 第四步:部署和重启 + +```bash +# 1. 备份当前JAR +cp /path/to/designhub-backend.jar /path/to/designhub-backend.jar.backup + +# 2. 复制新JAR(根据实际路径) +cp target/designhub-backend-*.jar /path/to/designhub-backend.jar + +# 3. 重启服务 +systemctl restart designhub-backend + +# 4. 检查状态 +systemctl status designhub-backend + +# 5. 查看日志 +journalctl -u designhub-backend -f +``` + +--- + +## 🔍 验证部署 + +### 1. 检查数据库表 + +```sql +-- 检查S3配置表 +SELECT * FROM sys_s3_config; + +-- 检查文件上传表 +SHOW CREATE TABLE sys_file_upload; +``` + +### 2. 测试API + +```bash +# 获取激活的S3配置(需要token) +curl -X GET http://localhost:8080/api/s3/config/active \ + -H "Authorization: Bearer YOUR_TOKEN" + +# 上传文件测试(需要token) +curl -X POST http://localhost:8080/api/file/upload \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -F "files=@test.pdf" \ + -F "relatedType=task" +``` + +--- + +## 📝 文件清单 + +需要上传的文件: + +1. **实体类** (Entity) + - `S3Config.java` + - `FileUpload.java` + +2. **Mapper接口** + - `S3ConfigMapper.java` + - `FileUploadMapper.java` + +3. **服务类** (Service) + - `S3Service.java` + - `S3ConfigService.java` + +4. **控制器** (Controller) + - `S3ConfigController.java` + - `FileUploadController.java` + +5. **配置文件** + - `pom.xml` (已添加AWS SDK依赖) + +6. **数据库脚本** + - `database/create_s3_config.sql` + +--- + +## ⚠️ 注意事项 + +1. **路径确认**:请确认服务器上的后端代码路径,可能需要修改 `BACKEND_DIR` 变量 + +2. **数据库密码**:如果数据库密码不是 `Abc123456`,请修改脚本中的密码 + +3. **Maven依赖**:确保 `pom.xml` 已包含以下依赖: + ```xml + + software.amazon.awssdk + s3 + 2.20.26 + + + commons-io + commons-io + 2.11.0 + + ``` + +4. **JwtUtil注入**:`FileUploadController` 需要注入 `JwtUtil`,确保 `JwtUtil` 是 `@Component` + +5. **S3配置**:部署后需要在数据库中激活S3配置,默认配置已插入但需要确认 + +--- + +## 🐛 常见问题 + +### 问题1:编译失败 - 找不到AWS SDK类 + +**解决**:检查 `pom.xml` 是否包含AWS SDK依赖,然后执行 `mvn clean install` + +### 问题2:运行时错误 - S3配置未激活 + +**解决**:检查数据库中的 `sys_s3_config` 表,确保有一条 `is_active=1` 的记录 + +### 问题3:文件上传失败 - 连接S3超时 + +**解决**: +- 检查S3服务端点是否可访问 +- 检查AccessKey和SecretKey是否正确 +- 检查网络连接和防火墙设置 + +--- + +## ✅ 部署完成检查清单 + +- [ ] 所有Java文件已上传到服务器 +- [ ] `pom.xml` 已更新并包含AWS SDK依赖 +- [ ] 数据库脚本已执行,表已创建 +- [ ] 后端编译成功,无错误 +- [ ] JAR文件已部署到正确位置 +- [ ] 服务已重启 +- [ ] 服务状态正常(`systemctl status`) +- [ ] 日志无错误信息 +- [ ] API测试通过 + +--- + +部署完成后,请告诉我,我将继续完成前端的S3配置管理界面! diff --git a/backend/backend-src.tar.gz b/backend/backend-src.tar.gz new file mode 100644 index 0000000..cd5e916 Binary files /dev/null and b/backend/backend-src.tar.gz differ diff --git a/backend/backend_src.tar.gz b/backend/backend_src.tar.gz new file mode 100644 index 0000000..181dd24 Binary files /dev/null and b/backend/backend_src.tar.gz differ diff --git a/backend/pom-s3-dependency.xml b/backend/pom-s3-dependency.xml new file mode 100644 index 0000000..d73fb52 --- /dev/null +++ b/backend/pom-s3-dependency.xml @@ -0,0 +1,15 @@ + + + + + software.amazon.awssdk + s3 + 2.20.26 + + + + + commons-io + commons-io + 2.11.0 + diff --git a/backend/pom.xml b/backend/pom.xml new file mode 100644 index 0000000..71fdaa7 --- /dev/null +++ b/backend/pom.xml @@ -0,0 +1,205 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.1.6 + + + + com.designhub + designhub-backend + 1.0.0 + DesignHub Backend + 设计师任务抢单管理系统 - 后端服务 + + + 17 + 17 + 17 + UTF-8 + 3.5.7 + 4.3.0 + 0.12.3 + 5.8.24 + + + + + + org.springframework.boot + spring-boot-starter-web + + + + + org.springframework.boot + spring-boot-starter-validation + + + + + org.springframework.boot + spring-boot-starter-aop + + + + + org.springframework.boot + spring-boot-starter-data-redis + + + + + com.mysql + mysql-connector-j + runtime + + + + + com.baomidou + mybatis-plus-boot-starter + ${mybatis-plus.version} + + + + + io.jsonwebtoken + jjwt-api + ${jwt.version} + + + io.jsonwebtoken + jjwt-impl + ${jwt.version} + runtime + + + io.jsonwebtoken + jjwt-jackson + ${jwt.version} + runtime + + + + + org.springframework.security + spring-security-crypto + + + + + com.github.xiaoymin + knife4j-openapi3-jakarta-spring-boot-starter + ${knife4j.version} + + + + + cn.hutool + hutool-all + ${hutool.version} + + + + + org.projectlombok + lombok + true + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + com.aliyun + dingtalk + 2.1.67 + + + + + org.springframework.boot + spring-boot-starter-quartz + + + + + org.apache.commons + commons-lang3 + + + + + org.apache.commons + commons-collections4 + 4.4 + + + + + org.apache.poi + poi-ooxml + 5.2.5 + + + + + software.amazon.awssdk + s3 + 2.20.26 + + + + + commons-io + commons-io + 2.11.0 + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + 17 + 17 + + + org.projectlombok + lombok + 1.18.34 + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + + diff --git a/backend/src/main/java/com/designhub/DesignHubApplication.java b/backend/src/main/java/com/designhub/DesignHubApplication.java new file mode 100644 index 0000000..1ff0922 --- /dev/null +++ b/backend/src/main/java/com/designhub/DesignHubApplication.java @@ -0,0 +1,36 @@ +package com.designhub; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; + +/** + * DesignHub 主应用类 + * 设计师任务抢单管理系统 + * + * @author DesignHub Team + * @version 1.0.0 + */ +@SpringBootApplication +@MapperScan("com.designhub.mapper") +@EnableScheduling +public class DesignHubApplication { + + public static void main(String[] args) { + SpringApplication.run(DesignHubApplication.class, args); + System.out.println(""" + + ╔═══════════════════════════════════════════════════════╗ + ║ ║ + ║ 🎨 DesignHub Backend Service Started 🎨 ║ + ║ ║ + ║ Port: 8080 ║ + ║ Swagger UI: http://localhost:8080/api/doc.html ║ + ║ Version: 1.0.0 ║ + ║ ║ + ╚═══════════════════════════════════════════════════════╝ + """); + } +} + diff --git a/backend/src/main/java/com/designhub/annotation/RequirePermission.java b/backend/src/main/java/com/designhub/annotation/RequirePermission.java new file mode 100644 index 0000000..29a8b60 --- /dev/null +++ b/backend/src/main/java/com/designhub/annotation/RequirePermission.java @@ -0,0 +1,30 @@ +package com.designhub.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 权限验证注解 + * 用于Controller方法上,标记需要的权限 + */ +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface RequirePermission { + + /** + * 权限代码 + */ + String[] value() default {}; + + /** + * 权限匹配模式:AND-需要所有权限,OR-需要任一权限 + */ + LogicalMode mode() default LogicalMode.AND; + + enum LogicalMode { + AND, OR + } +} + diff --git a/backend/src/main/java/com/designhub/annotation/RequireRole.java b/backend/src/main/java/com/designhub/annotation/RequireRole.java new file mode 100644 index 0000000..7ab287f --- /dev/null +++ b/backend/src/main/java/com/designhub/annotation/RequireRole.java @@ -0,0 +1,30 @@ +package com.designhub.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 角色验证注解 + * 用于Controller方法上,标记需要的角色 + */ +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface RequireRole { + + /** + * 角色代码 + */ + String[] value() default {}; + + /** + * 角色匹配模式:AND-需要所有角色,OR-需要任一角色 + */ + LogicalMode mode() default LogicalMode.OR; + + enum LogicalMode { + AND, OR + } +} + diff --git a/backend/src/main/java/com/designhub/common/Result.java b/backend/src/main/java/com/designhub/common/Result.java new file mode 100644 index 0000000..36e2ef1 --- /dev/null +++ b/backend/src/main/java/com/designhub/common/Result.java @@ -0,0 +1,110 @@ +package com.designhub.common; + +import lombok.Data; +import java.io.Serializable; + +/** + * 统一响应结果类 + * + * @param 数据类型 + */ +@Data +public class Result implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 响应码 + */ + private Integer code; + + /** + * 响应消息 + */ + private String message; + + /** + * 响应数据 + */ + private T data; + + /** + * 时间戳 + */ + private Long timestamp; + + public Result() { + this.timestamp = System.currentTimeMillis(); + } + + public Result(Integer code, String message, T data) { + this.code = code; + this.message = message; + this.data = data; + this.timestamp = System.currentTimeMillis(); + } + + /** + * 成功响应 + */ + public static Result success() { + return new Result<>(ResultCode.SUCCESS.getCode(), ResultCode.SUCCESS.getMessage(), null); + } + + /** + * 成功响应(带数据) + */ + public static Result success(T data) { + return new Result<>(ResultCode.SUCCESS.getCode(), ResultCode.SUCCESS.getMessage(), data); + } + + /** + * 成功响应(自定义消息) + */ + public static Result success(String message, T data) { + return new Result<>(ResultCode.SUCCESS.getCode(), message, data); + } + + /** + * 失败响应 + */ + public static Result error() { + return new Result<>(ResultCode.ERROR.getCode(), ResultCode.ERROR.getMessage(), null); + } + + /** + * 失败响应(自定义消息) + */ + public static Result error(String message) { + return new Result<>(ResultCode.ERROR.getCode(), message, null); + } + + /** + * 失败响应(自定义码和消息) + */ + public static Result error(Integer code, String message) { + return new Result<>(code, message, null); + } + + /** + * 失败响应(使用ResultCode) + */ + public static Result error(ResultCode resultCode) { + return new Result<>(resultCode.getCode(), resultCode.getMessage(), null); + } + + /** + * 失败响应(自定义码、消息和数据) + */ + public static Result error(Integer code, String message, T data) { + return new Result<>(code, message, data); + } + + /** + * 失败响应(别名方法) + */ + public static Result fail(String message) { + return error(message); + } +} + diff --git a/backend/src/main/java/com/designhub/common/ResultCode.java b/backend/src/main/java/com/designhub/common/ResultCode.java new file mode 100644 index 0000000..62b9604 --- /dev/null +++ b/backend/src/main/java/com/designhub/common/ResultCode.java @@ -0,0 +1,59 @@ +package com.designhub.common; + +import lombok.Getter; + +/** + * 响应状态码枚举 + */ +@Getter +public enum ResultCode { + + // 成功 + SUCCESS(200, "操作成功"), + + // 客户端错误 4xx + BAD_REQUEST(400, "请求参数错误"), + PARAM_ERROR(400, "参数错误"), + UNAUTHORIZED(401, "未授权,请登录"), + FORBIDDEN(403, "没有权限访问该资源"), + NOT_FOUND(404, "请求的资源不存在"), + METHOD_NOT_ALLOWED(405, "请求方法不支持"), + CONFLICT(409, "数据冲突"), + + // 服务器错误 5xx + ERROR(500, "服务器内部错误"), + SERVICE_UNAVAILABLE(503, "服务暂时不可用"), + + // 业务错误 1xxx + USER_NOT_EXIST(1001, "用户不存在"), + USER_ALREADY_EXIST(1002, "用户已存在"), + PASSWORD_ERROR(1003, "密码错误"), + USER_DISABLED(1004, "用户已被禁用"), + TOKEN_EXPIRED(1005, "Token已过期"), + TOKEN_INVALID(1006, "Token无效"), + + TASK_NOT_EXIST(1101, "任务不存在"), + TASK_ALREADY_TAKEN(1102, "任务已被抢单"), + INSUFFICIENT_SCORE(1103, "积分不足"), + TASK_STATUS_ERROR(1104, "任务状态错误"), + + DEPT_NOT_EXIST(1201, "部门不存在"), + DEPT_TYPE_ERROR(1202, "部门类型错误"), + SCORE_LIMIT_EXCEEDED(1203, "超出积分消耗限制"), + + FILE_UPLOAD_ERROR(1301, "文件上传失败"), + FILE_TYPE_ERROR(1302, "文件类型不支持"), + FILE_SIZE_ERROR(1303, "文件大小超出限制"), + + DINGTALK_AUTH_ERROR(1401, "钉钉认证失败"), + DINGTALK_API_ERROR(1402, "钉钉接口调用失败"); + + private final Integer code; + private final String message; + + ResultCode(Integer code, String message) { + this.code = code; + this.message = message; + } +} + diff --git a/backend/src/main/java/com/designhub/config/CorsConfig.java b/backend/src/main/java/com/designhub/config/CorsConfig.java new file mode 100644 index 0000000..2234fb2 --- /dev/null +++ b/backend/src/main/java/com/designhub/config/CorsConfig.java @@ -0,0 +1,40 @@ +package com.designhub.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.filter.CorsFilter; + +/** + * 跨域配置 + */ +@Configuration +public class CorsConfig { + + @Bean + public CorsFilter corsFilter() { + CorsConfiguration config = new CorsConfiguration(); + + // 允许所有域名进行跨域调用(生产环境需要具体指定) + config.addAllowedOriginPattern("*"); + + // 允许所有请求头 + config.addAllowedHeader("*"); + + // 允许所有请求方法 + config.addAllowedMethod("*"); + + // 允许携带凭证 + config.setAllowCredentials(true); + + // 暴露哪些头信息 + config.addExposedHeader("Authorization"); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + + return new CorsFilter(source); + } +} + diff --git a/backend/src/main/java/com/designhub/config/Knife4jConfig.java b/backend/src/main/java/com/designhub/config/Knife4jConfig.java new file mode 100644 index 0000000..534997f --- /dev/null +++ b/backend/src/main/java/com/designhub/config/Knife4jConfig.java @@ -0,0 +1,32 @@ +package com.designhub.config; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Contact; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.info.License; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Knife4j (Swagger) 配置 + * 访问地址:http://localhost:8080/api/doc.html + */ +@Configuration +public class Knife4jConfig { + + @Bean + public OpenAPI customOpenAPI() { + return new OpenAPI() + .info(new Info() + .title("DesignHub API 文档") + .description("设计师任务抢单管理系统 RESTful API 接口文档") + .version("v1.0.0") + .contact(new Contact() + .name("DesignHub Team") + .email("support@designhub.com")) + .license(new License() + .name("MIT License") + .url("https://opensource.org/licenses/MIT"))); + } +} + diff --git a/backend/src/main/java/com/designhub/config/MybatisPlusConfig.java b/backend/src/main/java/com/designhub/config/MybatisPlusConfig.java new file mode 100644 index 0000000..ec551c7 --- /dev/null +++ b/backend/src/main/java/com/designhub/config/MybatisPlusConfig.java @@ -0,0 +1,29 @@ +package com.designhub.config; + +import com.baomidou.mybatisplus.annotation.DbType; +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * MyBatis Plus 配置类 + */ +@Configuration +public class MybatisPlusConfig { + + /** + * MyBatis Plus 分页插件配置 + */ + @Bean + public MybatisPlusInterceptor mybatisPlusInterceptor() { + MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); + // 添加分页插件 + PaginationInnerInterceptor paginationInnerInterceptor = new PaginationInnerInterceptor(DbType.MYSQL); + // 设置最大单页限制数量,默认 500 条,-1 不受限制 + paginationInnerInterceptor.setMaxLimit(1000L); + interceptor.addInnerInterceptor(paginationInnerInterceptor); + return interceptor; + } +} + diff --git a/backend/src/main/java/com/designhub/config/SystemConfigInitializer.java b/backend/src/main/java/com/designhub/config/SystemConfigInitializer.java new file mode 100644 index 0000000..52ffe3c --- /dev/null +++ b/backend/src/main/java/com/designhub/config/SystemConfigInitializer.java @@ -0,0 +1,31 @@ +package com.designhub.config; + +import com.designhub.service.SystemConfigService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; + +/** + * 系统配置初始化器 + * 在应用启动时自动初始化默认配置 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class SystemConfigInitializer implements ApplicationRunner { + + private final SystemConfigService systemConfigService; + + @Override + public void run(ApplicationArguments args) { + try { + log.info("开始初始化系统默认配置..."); + systemConfigService.initDefaultConfigs(); + log.info("系统默认配置初始化完成"); + } catch (Exception e) { + log.error("系统默认配置初始化失败", e); + } + } +} diff --git a/backend/src/main/java/com/designhub/config/WebConfig.java b/backend/src/main/java/com/designhub/config/WebConfig.java new file mode 100644 index 0000000..a1deaf2 --- /dev/null +++ b/backend/src/main/java/com/designhub/config/WebConfig.java @@ -0,0 +1,38 @@ +package com.designhub.config; + +import com.designhub.interceptor.PermissionInterceptor; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * Web配置类 + */ +@Configuration +@RequiredArgsConstructor +public class WebConfig implements WebMvcConfigurer { + + private final PermissionInterceptor permissionInterceptor; + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(permissionInterceptor) + .addPathPatterns("/api/**") // 拦截所有API请求 + .excludePathPatterns( + "/api/auth/**", // 排除认证相关接口(包含注册) + "/api/departments", // 排除部门列表接口(注册页面需要) + "/api/hello", // 排除测试接口 + "/api/system/health", // 排除健康检查接口 + "/api/system-config/group/site", // 排除公开的网站设置API(登录页面需要) + "/api/system-config/key/site.name", // 排除公开的网站设置API(按key获取) + "/api/system-config/key/site.logo", // 排除公开的网站设置API(按key获取) + "/api/system-config/map/group/site", // 排除公开的网站设置API(Map格式) + "/doc.html", // 排除Swagger文档 + "/swagger-ui/**", + "/v3/api-docs/**", + "/webjars/**" + ); + } +} + diff --git a/backend/src/main/java/com/designhub/controller/AchievementController.java b/backend/src/main/java/com/designhub/controller/AchievementController.java new file mode 100644 index 0000000..7284e91 --- /dev/null +++ b/backend/src/main/java/com/designhub/controller/AchievementController.java @@ -0,0 +1,53 @@ +package com.designhub.controller; + +import com.designhub.common.Result; +import com.designhub.entity.Achievement; +import com.designhub.entity.UserAchievement; +import com.designhub.service.AchievementService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 成就系统Controller + */ +@Tag(name = "成就系统", description = "成就徽章相关API") +@RestController +@RequestMapping("/api/achievements") +@RequiredArgsConstructor +public class AchievementController { + + private final AchievementService achievementService; + + @Operation(summary = "获取所有成就列表") + @GetMapping + public Result> getAllAchievements() { + List achievements = achievementService.getAllAchievements(); + return Result.success(achievements); + } + + @Operation(summary = "获取用户已达成的成就") + @GetMapping("/user/{userId}") + public Result> getUserAchievements(@PathVariable Long userId) { + List achievements = achievementService.getUserAchievements(userId); + return Result.success(achievements); + } + + @Operation(summary = "领取成就奖励") + @PostMapping("/{achievementId}/claim") + public Result claimReward(@PathVariable Long achievementId, @RequestParam Long userId) { + boolean success = achievementService.claimAchievementReward(userId, achievementId); + return Result.success(success); + } + + @Operation(summary = "初始化默认成就") + @PostMapping("/init") + public Result initDefaultAchievements() { + achievementService.initDefaultAchievements(); + return Result.success(null); + } +} + diff --git a/backend/src/main/java/com/designhub/controller/AdminTaskController.java b/backend/src/main/java/com/designhub/controller/AdminTaskController.java new file mode 100644 index 0000000..3c25e04 --- /dev/null +++ b/backend/src/main/java/com/designhub/controller/AdminTaskController.java @@ -0,0 +1,155 @@ +package com.designhub.controller; + +import com.designhub.common.Result; +import com.designhub.entity.Task; +import com.designhub.service.AdminTaskService; +import com.designhub.util.JwtUtil; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 管理员任务调配Controller + */ +@Tag(name = "管理员任务管理", description = "管理员任务调配相关API") +@RestController +@RequestMapping("/api/admin/tasks") +@RequiredArgsConstructor +public class AdminTaskController { + + private final AdminTaskService adminTaskService; + private final JwtUtil jwtUtil; + + @Operation(summary = "分配任务") + @PostMapping("/{taskId}/assign") + public Result assignTask( + @PathVariable Long taskId, + @RequestParam Long designerId, + @RequestHeader("Authorization") String authHeader + ) { + String token = jwtUtil.extractTokenFromHeader(authHeader); + Long adminId = jwtUtil.getUserIdFromToken(token); + Task task = adminTaskService.assignTask(taskId, designerId, adminId); + return Result.success(task); + } + + @Operation(summary = "转移任务") + @PostMapping("/{taskId}/transfer") + public Result transferTask( + @PathVariable Long taskId, + @RequestBody TransferRequest request, + @RequestHeader("Authorization") String authHeader + ) { + String token = jwtUtil.extractTokenFromHeader(authHeader); + Long adminId = jwtUtil.getUserIdFromToken(token); + Task task = adminTaskService.transferTask( + taskId, + request.getNewDesignerId(), + adminId, + request.getReason() + ); + return Result.success(task); + } + + @Operation(summary = "批量分配任务") + @PostMapping("/batch-assign") + public Result batchAssignTasks( + @RequestBody BatchAssignRequest request, + @RequestHeader("Authorization") String authHeader + ) { + String token = jwtUtil.extractTokenFromHeader(authHeader); + Long adminId = jwtUtil.getUserIdFromToken(token); + int count = adminTaskService.batchAssignTasks( + request.getTaskIds(), + request.getDesignerId(), + adminId + ); + return Result.success(count); + } + + @Operation(summary = "调整任务优先级") + @PutMapping("/{taskId}/priority") + public Result adjustTaskPriority( + @PathVariable Long taskId, + @RequestParam Integer priority, + @RequestHeader("Authorization") String authHeader + ) { + String token = jwtUtil.extractTokenFromHeader(authHeader); + Long adminId = jwtUtil.getUserIdFromToken(token); + Task task = adminTaskService.adjustTaskPriority(taskId, priority, adminId); + return Result.success(task); + } + + @Operation(summary = "获取任务池列表") + @GetMapping("/pool") + public Result> getTaskPool() { + List tasks = adminTaskService.getTaskPool(); + return Result.success(tasks); + } + + @Operation(summary = "获取所有进行中的任务") + @GetMapping("/in-progress") + public Result> getInProgressTasks() { + List tasks = adminTaskService.getInProgressTasks(); + return Result.success(tasks); + } + + @Operation(summary = "强制完成任务") + @PostMapping("/{taskId}/force-complete") + public Result forceCompleteTask( + @PathVariable Long taskId, + @RequestBody AdminActionRequest request, + @RequestHeader("Authorization") String authHeader + ) { + String token = jwtUtil.extractTokenFromHeader(authHeader); + Long adminId = jwtUtil.getUserIdFromToken(token); + Task task = adminTaskService.forceCompleteTask(taskId, adminId, request.getReason()); + return Result.success(task); + } + + @Operation(summary = "取消任务") + @PostMapping("/{taskId}/cancel") + public Result cancelTask( + @PathVariable Long taskId, + @RequestBody AdminActionRequest request, + @RequestHeader("Authorization") String authHeader + ) { + String token = jwtUtil.extractTokenFromHeader(authHeader); + Long adminId = jwtUtil.getUserIdFromToken(token); + Task task = adminTaskService.cancelTask(taskId, adminId, request.getReason()); + return Result.success(task); + } + + // DTO类 + public static class TransferRequest { + private Long newDesignerId; + private String reason; + + public Long getNewDesignerId() { return newDesignerId; } + public void setNewDesignerId(Long newDesignerId) { this.newDesignerId = newDesignerId; } + public String getReason() { return reason; } + public void setReason(String reason) { this.reason = reason; } + } + + public static class BatchAssignRequest { + private List taskIds; + private Long designerId; + + public List getTaskIds() { return taskIds; } + public void setTaskIds(List taskIds) { this.taskIds = taskIds; } + public Long getDesignerId() { return designerId; } + public void setDesignerId(Long designerId) { this.designerId = designerId; } + } + + public static class AdminActionRequest { + private String reason; + + public String getReason() { return reason; } + public void setReason(String reason) { this.reason = reason; } + } +} + diff --git a/backend/src/main/java/com/designhub/controller/AnalyticsController.java b/backend/src/main/java/com/designhub/controller/AnalyticsController.java new file mode 100644 index 0000000..8f998e1 --- /dev/null +++ b/backend/src/main/java/com/designhub/controller/AnalyticsController.java @@ -0,0 +1,44 @@ +package com.designhub.controller; + +import com.designhub.common.Result; +import com.designhub.service.AnalyticsService; +import com.designhub.util.JwtUtil; +import com.designhub.vo.DemandStatisticsVO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; + +/** + * 数据分析Controller + */ +@Slf4j +@Tag(name = "数据分析", description = "数据分析相关API") +@RestController +@RequestMapping("/api/analytics") +@RequiredArgsConstructor +public class AnalyticsController { + + private final AnalyticsService analyticsService; + private final JwtUtil jwtUtil; + + @Operation(summary = "获取需求统计数据(根据角色返回不同维度的数据)") + @GetMapping("/demand-statistics") + public Result getDemandStatistics( + @RequestHeader("Authorization") String authHeader, + @RequestParam(required = false, defaultValue = "month") String dateRange, + @RequestParam(required = false) Long deptId, + @RequestParam(required = false) Long userId + ) { + // 从token获取当前用户ID + String token = jwtUtil.extractTokenFromHeader(authHeader); + Long currentUserId = jwtUtil.getUserIdFromToken(token); + + log.info("获取需求统计数据: currentUserId={}, dateRange={}, deptId={}, userId={}", + currentUserId, dateRange, deptId, userId); + + return Result.success(analyticsService.getDemandStatistics(currentUserId, dateRange, deptId, userId)); + } +} + diff --git a/backend/src/main/java/com/designhub/controller/AuthController.java b/backend/src/main/java/com/designhub/controller/AuthController.java new file mode 100644 index 0000000..a54fe03 --- /dev/null +++ b/backend/src/main/java/com/designhub/controller/AuthController.java @@ -0,0 +1,130 @@ +package com.designhub.controller; + +import com.designhub.common.Result; +import com.designhub.dto.LoginDTO; +import com.designhub.dto.RegisterDTO; +import com.designhub.entity.User; +import com.designhub.exception.BusinessException; +import com.designhub.service.UserService; +import com.designhub.util.JwtUtil; +import com.designhub.vo.LoginVO; +import com.designhub.vo.UserInfoVO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.Map; + +/** + * 认证控制器 + */ +@Slf4j +@Tag(name = "用户认证", description = "用户登录、登出、刷新令牌等接口") +@RestController +@RequestMapping("/api/auth") +@RequiredArgsConstructor +public class AuthController { + + private final UserService userService; + private final JwtUtil jwtUtil; + + @Operation(summary = "用户注册", description = "新用户注册") + @PostMapping("/register") + public Result register(@Valid @RequestBody RegisterDTO registerDTO) { + log.info("用户注册请求: {}", registerDTO.getUsername()); + User user = userService.register(registerDTO); + return Result.success(user); + } + + @Operation(summary = "用户登录", description = "支持密码登录、钉钉登录、Casdoor登录") + @PostMapping("/login") + public Result login(@Valid @RequestBody LoginDTO loginDTO) { + log.info("用户登录请求: {}", loginDTO.getUsername()); + LoginVO loginVO = userService.login(loginDTO); + return Result.success(loginVO); + } + + @Operation(summary = "获取当前用户信息", description = "根据Token获取当前登录用户信息") + @GetMapping("/userinfo") + public Result getUserInfo(@RequestHeader(value = "Authorization", required = false) String authHeader) { + log.info("获取用户信息"); + String token = jwtUtil.extractTokenFromHeader(authHeader); + UserInfoVO userInfo = userService.getUserInfoByToken(token); + return Result.success(userInfo); + } + + @Operation(summary = "用户登出", description = "退出登录,清除Token") + @PostMapping("/logout") + public Result logout(@RequestHeader(value = "Authorization", required = false) String authHeader) { + log.info("用户登出"); + // Token是无状态的,前端删除即可 + // 如果需要黑名单机制,可以在Redis中记录 + return Result.success("登出成功"); + } + + @Operation(summary = "刷新令牌", description = "使用刷新令牌获取新的访问令牌") + @PostMapping("/refresh") + public Result refresh(@RequestHeader("Authorization") String authHeader) { + log.info("刷新令牌"); + LoginVO loginVO = userService.refreshToken(authHeader); + return Result.success(loginVO); + } + + @Operation(summary = "钉钉扫码登录回调", description = "接收钉钉扫码登录的回调 - 使用OAuth 2.0流程") + @GetMapping("/dingtalk/callback") + @SuppressWarnings("unchecked") + public Result dingtalkCallback( + @RequestParam String code, + @RequestParam(required = false) String state) { + log.info("钉钉登录回调 - code: {}, state: {}", + code.substring(0, Math.min(10, code.length())) + "...", state); + + // 使用新的OAuth 2.0流程登录 + LoginDTO loginDTO = new LoginDTO(); + loginDTO.setLoginType("dingtalk"); + loginDTO.setAuthCode(code); + + try { + LoginVO loginVO = userService.login(loginDTO); + log.info("钉钉登录成功: {}", loginVO.getUsername()); + return Result.success(loginVO); + } catch (BusinessException e) { + // 检查是否需要选择部门 + if (e.getMessage() != null && e.getMessage().startsWith("NEED_SELECT_DEPT:")) { + String[] parts = e.getMessage().split(":"); + Map data = new HashMap<>(); + data.put("needSelectDept", true); + data.put("userId", parts[1]); + data.put("dingDepartments", parts.length > 2 ? parts[2] : ""); + Result> result = Result.error(1901, "需要选择部门", data); + return (Result) (Result) result; + } + log.error("钉钉登录失败", e); + return Result.error(e.getMessage()); + } catch (Exception e) { + log.error("钉钉登录失败", e); + return Result.error("钉钉登录失败: " + e.getMessage()); + } + } + + @Operation(summary = "钉钉用户选择部门", description = "首次登录时选择系统部门") + @PostMapping("/dingtalk/select-department") + public Result selectDepartment( + @RequestParam Long userId, + @RequestParam Long deptId) { + log.info("钉钉用户选择部门: userId={}, deptId={}", userId, deptId); + + try { + LoginVO loginVO = userService.completeDingTalkUserSetup(userId, deptId); + return Result.success(loginVO); + } catch (Exception e) { + log.error("选择部门失败", e); + return Result.error("选择部门失败: " + e.getMessage()); + } + } +} + diff --git a/backend/src/main/java/com/designhub/controller/CheckInController.java b/backend/src/main/java/com/designhub/controller/CheckInController.java new file mode 100644 index 0000000..f77ea9c --- /dev/null +++ b/backend/src/main/java/com/designhub/controller/CheckInController.java @@ -0,0 +1,84 @@ +package com.designhub.controller; + +import com.designhub.common.Result; +import com.designhub.entity.CheckIn; +import com.designhub.service.CheckInService; +import com.designhub.util.JwtUtil; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 签到Controller + */ +@Slf4j +@Tag(name = "签到系统", description = "每日签到相关API") +@RestController +@RequestMapping("/api/check-in") +@RequiredArgsConstructor +public class CheckInController { + + private final CheckInService checkInService; + private final JwtUtil jwtUtil; + + @Operation(summary = "用户签到") + @PostMapping + public Result checkIn(@RequestHeader("Authorization") String authHeader) { + String token = jwtUtil.extractTokenFromHeader(authHeader); + Long userId = jwtUtil.getUserIdFromToken(token); + if (userId == null) { + return Result.error(401, "未登录或登录已过期"); + } + CheckIn checkIn = checkInService.checkIn(userId); + return Result.success(checkIn); + } + + @Operation(summary = "检查今天是否已签到") + @GetMapping("/today") + public Result hasCheckedInToday(@RequestHeader("Authorization") String authHeader) { + String token = jwtUtil.extractTokenFromHeader(authHeader); + Long userId = jwtUtil.getUserIdFromToken(token); + if (userId == null) { + return Result.error(401, "未登录或登录已过期"); + } + boolean checked = checkInService.hasCheckedInToday(userId); + return Result.success(checked); + } + + @Operation(summary = "获取签到历史记录") + @GetMapping("/history") + public Result> getCheckInHistory( + @RequestHeader("Authorization") String authHeader, + @RequestParam(defaultValue = "30") Integer limit + ) { + String token = jwtUtil.extractTokenFromHeader(authHeader); + Long userId = jwtUtil.getUserIdFromToken(token); + if (userId == null) { + return Result.error(401, "未登录或登录已过期"); + } + List history = checkInService.getUserCheckInHistory(userId, limit); + return Result.success(history); + } + + @Operation(summary = "获取签到统计信息") + @GetMapping("/stats") + public Result> getCheckInStats(@RequestHeader("Authorization") String authHeader) { + String token = jwtUtil.extractTokenFromHeader(authHeader); + Long userId = jwtUtil.getUserIdFromToken(token); + if (userId == null) { + return Result.error(401, "未登录或登录已过期"); + } + Map stats = new HashMap<>(); + stats.put("continuousDays", checkInService.getContinuousDays(userId)); + stats.put("totalDays", checkInService.getTotalCheckInDays(userId)); + stats.put("hasCheckedInToday", checkInService.hasCheckedInToday(userId)); + return Result.success(stats); + } +} + diff --git a/backend/src/main/java/com/designhub/controller/ConfigController.java b/backend/src/main/java/com/designhub/controller/ConfigController.java new file mode 100644 index 0000000..9e4835d --- /dev/null +++ b/backend/src/main/java/com/designhub/controller/ConfigController.java @@ -0,0 +1,271 @@ +package com.designhub.controller; + +import com.designhub.common.Result; +import com.designhub.entity.*; +import com.designhub.service.ConfigService; +import com.designhub.service.DemandTypeDepartmentService; +import com.designhub.service.DepartmentService; +import com.designhub.service.TeamScorePoolService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 配置管理Controller + */ +@Tag(name = "配置管理", description = "系统配置相关API") +@RestController +@RequestMapping("/api/config") +@RequiredArgsConstructor +public class ConfigController { + + private final ConfigService configService; + private final DemandTypeDepartmentService demandTypeDepartmentService; + private final TeamScorePoolService teamScorePoolService; + private final DepartmentService departmentService; + + // ============ 需求类型管理 ============ + + @Operation(summary = "获取所有需求类型(包括禁用的,用于管理页面)") + @GetMapping("/demand-types") + public Result> getAllDemandTypes() { + return Result.success(configService.getAllDemandTypes()); + } + + @Operation(summary = "获取所有启用的需求类型(用于下拉选择)") + @GetMapping("/demand-types/enabled") + public Result> getEnabledDemandTypes() { + return Result.success(configService.getEnabledDemandTypes()); + } + + @Operation(summary = "根据ID获取需求类型") + @GetMapping("/demand-types/{id:\\d+}") + public Result getDemandTypeById(@PathVariable Long id) { + return Result.success(configService.getDemandTypeById(id)); + } + + @Operation(summary = "创建需求类型") + @PostMapping("/demand-types") + public Result createDemandType(@RequestBody DemandType demandType) { + return Result.success(configService.createDemandType(demandType)); + } + + @Operation(summary = "更新需求类型") + @PutMapping("/demand-types/{id:\\d+}") + public Result updateDemandType(@PathVariable Long id, @RequestBody DemandType demandType) { + demandType.setId(id); + return Result.success(configService.updateDemandType(demandType)); + } + + @Operation(summary = "删除需求类型") + @DeleteMapping("/demand-types/{id:\\d+}") + public Result deleteDemandType(@PathVariable Long id) { + return Result.success(configService.deleteDemandType(id)); + } + + @Operation(summary = "设置需求类型的可接单部门") + @PutMapping("/demand-types/{id:\\d+}/departments") + public Result setDemandTypeDepartments( + @PathVariable Long id, + @RequestBody List departmentIds) { + return Result.success(demandTypeDepartmentService.setDemandTypeDepartments(id, departmentIds)); + } + + @Operation(summary = "获取需求类型的可接单部门列表") + @GetMapping("/demand-types/{id:\\d+}/departments") + public Result> getDemandTypeDepartments(@PathVariable Long id) { + return Result.success(demandTypeDepartmentService.getDepartmentIdsByDemandType(id)); + } + + // ============ 品牌管理 ============ + + @Operation(summary = "获取所有品牌(包括禁用的,用于管理页面)") + @GetMapping("/brands") + public Result> getAllBrands() { + return Result.success(configService.getAllBrands()); + } + + @Operation(summary = "获取所有启用的品牌(用于下拉选择)") + @GetMapping("/brands/enabled") + public Result> getEnabledBrands() { + return Result.success(configService.getEnabledBrands()); + } + + @Operation(summary = "根据ID获取品牌") + @GetMapping("/brands/{id:\\d+}") + public Result getBrandById(@PathVariable Long id) { + return Result.success(configService.getBrandById(id)); + } + + @Operation(summary = "创建品牌") + @PostMapping("/brands") + public Result createBrand(@RequestBody Brand brand) { + return Result.success(configService.createBrand(brand)); + } + + @Operation(summary = "更新品牌") + @PutMapping("/brands/{id:\\d+}") + public Result updateBrand(@PathVariable Long id, @RequestBody Brand brand) { + brand.setId(id); + return Result.success(configService.updateBrand(brand)); + } + + @Operation(summary = "删除品牌") + @DeleteMapping("/brands/{id:\\d+}") + public Result deleteBrand(@PathVariable Long id) { + return Result.success(configService.deleteBrand(id)); + } + + // ============ 平台管理 ============ + + @Operation(summary = "获取所有平台(包括禁用的,用于管理页面)") + @GetMapping("/platforms") + public Result> getAllPlatforms() { + return Result.success(configService.getAllPlatforms()); + } + + @Operation(summary = "获取所有启用的平台(用于下拉选择)") + @GetMapping("/platforms/enabled") + public Result> getEnabledPlatforms() { + return Result.success(configService.getEnabledPlatforms()); + } + + @Operation(summary = "根据ID获取平台") + @GetMapping("/platforms/{id:\\d+}") + public Result getPlatformById(@PathVariable Long id) { + return Result.success(configService.getPlatformById(id)); + } + + @Operation(summary = "创建平台") + @PostMapping("/platforms") + public Result createPlatform(@RequestBody Platform platform) { + return Result.success(configService.createPlatform(platform)); + } + + @Operation(summary = "更新平台") + @PutMapping("/platforms/{id:\\d+}") + public Result updatePlatform(@PathVariable Long id, @RequestBody Platform platform) { + platform.setId(id); + return Result.success(configService.updatePlatform(platform)); + } + + @Operation(summary = "删除平台") + @DeleteMapping("/platforms/{id:\\d+}") + public Result deletePlatform(@PathVariable Long id) { + return Result.success(configService.deletePlatform(id)); + } + + // ============ 类目管理 ============ + + @Operation(summary = "获取所有叶子类目(包括禁用的,用于管理页面)") + @GetMapping("/categories/leaf") + public Result> getAllLeafCategories() { + return Result.success(configService.getAllLeafCategories()); + } + + @Operation(summary = "获取所有启用的叶子类目(用于下拉选择)") + @GetMapping("/categories/leaf/enabled") + public Result> getEnabledLeafCategories() { + return Result.success(configService.getEnabledLeafCategories()); + } + + @Operation(summary = "获取所有类目") + @GetMapping("/categories") + public Result> getAllCategories() { + return Result.success(configService.getAllCategories()); + } + + @Operation(summary = "根据父ID获取子类目") + @GetMapping("/categories/parent/{parentId}") + public Result> getCategoriesByParentId(@PathVariable Long parentId) { + return Result.success(configService.getCategoriesByParentId(parentId)); + } + + @Operation(summary = "根据ID获取类目") + @GetMapping("/categories/{id:\\d+}") + public Result getCategoryById(@PathVariable Long id) { + return Result.success(configService.getCategoryById(id)); + } + + @Operation(summary = "创建类目") + @PostMapping("/categories") + public Result createCategory(@RequestBody Category category) { + return Result.success(configService.createCategory(category)); + } + + @Operation(summary = "更新类目") + @PutMapping("/categories/{id:\\d+}") + public Result updateCategory(@PathVariable Long id, @RequestBody Category category) { + category.setId(id); + return Result.success(configService.updateCategory(category)); + } + + @Operation(summary = "删除类目") + @DeleteMapping("/categories/{id:\\d+}") + public Result deleteCategory(@PathVariable Long id) { + return Result.success(configService.deleteCategory(id)); + } + + // ============ 团队积分池管理 ============ + + @Operation(summary = "根据部门ID获取积分池") + @GetMapping("/score-pool/dept/{deptId}") + public Result getScorePoolByDeptId(@PathVariable Long deptId) { + TeamScorePool pool = teamScorePoolService.getByDeptId(deptId); + // 如果积分池不存在,自动创建 + if (pool == null) { + var department = departmentService.getDepartmentById(deptId); + if (department != null) { + pool = teamScorePoolService.upsertPool(deptId, department.getDeptName()); + } else { + // 部门不存在,返回一个默认的积分池对象(余额为0) + pool = new TeamScorePool(); + pool.setDeptId(deptId); + pool.setBalance(0); + } + } + return Result.success(pool); + } + + @Operation(summary = "检查积分余额是否足够") + @GetMapping("/score-pool/check-balance") + public Result checkBalance(@RequestParam Long deptId, @RequestParam Integer score) { + return Result.success(teamScorePoolService.checkBalance(deptId, score)); + } + + @Operation(summary = "检查是否超过周期限制") + @GetMapping("/score-pool/check-limit") + public Result checkPeriodLimit(@RequestParam Long deptId, @RequestParam Integer score) { + return Result.success(teamScorePoolService.checkPeriodLimit(deptId, score)); + } + + @Operation(summary = "充值积分") + @PostMapping("/score-pool/recharge") + public Result rechargeScore(@RequestParam Long deptId, @RequestParam Integer score) { + teamScorePoolService.rechargeScore(deptId, score); + return Result.success(); + } + + @Operation(summary = "创建积分池") + @PostMapping("/score-pool") + public Result createScorePool(@RequestBody TeamScorePool pool) { + return Result.success(teamScorePoolService.createPool(pool)); + } + + @Operation(summary = "更新积分池配置") + @PutMapping("/score-pool/{id}") + public Result updateScorePool(@PathVariable Long id, @RequestBody TeamScorePool pool) { + pool.setId(id); + return Result.success(teamScorePoolService.updatePool(pool)); + } + + @Operation(summary = "检查是否需要预警") + @GetMapping("/score-pool/alert/{deptId}") + public Result isAlert(@PathVariable Long deptId) { + return Result.success(teamScorePoolService.isAlert(deptId)); + } +} + diff --git a/backend/src/main/java/com/designhub/controller/ContestController.java b/backend/src/main/java/com/designhub/controller/ContestController.java new file mode 100644 index 0000000..990191c --- /dev/null +++ b/backend/src/main/java/com/designhub/controller/ContestController.java @@ -0,0 +1,122 @@ +package com.designhub.controller; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.designhub.common.Result; +import com.designhub.entity.Contest; +import com.designhub.entity.ContestParticipant; +import com.designhub.service.ContestService; +import com.designhub.util.JwtUtil; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 竞赛活动Controller + */ +@Tag(name = "竞赛活动", description = "竞赛活动管理相关API") +@RestController +@RequestMapping("/api/contests") +@RequiredArgsConstructor +public class ContestController { + + private final ContestService contestService; + private final JwtUtil jwtUtil; + + @Operation(summary = "创建竞赛") + @PostMapping + public Result createContest( + @RequestBody Contest contest, + @RequestHeader("Authorization") String authHeader) { + String token = jwtUtil.extractTokenFromHeader(authHeader); + Long userId = jwtUtil.getUserIdFromToken(token); + contest.setCreatorId(userId); + return Result.success(contestService.createContest(contest)); + } + + @Operation(summary = "更新竞赛") + @PutMapping("/{id}") + public Result updateContest(@PathVariable Long id, @RequestBody Contest contest) { + return Result.success(contestService.updateContest(id, contest)); + } + + @Operation(summary = "发布竞赛") + @PostMapping("/{id}/publish") + public Result publishContest(@PathVariable Long id) { + return Result.success(contestService.publishContest(id)); + } + + @Operation(summary = "结束竞赛") + @PostMapping("/{id}/end") + public Result endContest(@PathVariable Long id) { + contestService.endContest(id); + return Result.success(); + } + + @Operation(summary = "取消竞赛") + @PostMapping("/{id}/cancel") + public Result cancelContest(@PathVariable Long id) { + contestService.cancelContest(id); + return Result.success(); + } + + @Operation(summary = "删除竞赛") + @DeleteMapping("/{id}") + public Result deleteContest(@PathVariable Long id) { + contestService.deleteContest(id); + return Result.success(); + } + + @Operation(summary = "获取竞赛列表") + @GetMapping + public Result> getContestList( + @RequestParam(defaultValue = "1") Integer page, + @RequestParam(defaultValue = "10") Integer pageSize, + @RequestParam(required = false) String status) { + return Result.success(contestService.getContestList(page, pageSize, status)); + } + + @Operation(summary = "获取竞赛详情") + @GetMapping("/{id}") + public Result getContestById(@PathVariable Long id) { + return Result.success(contestService.getContestById(id)); + } + + @Operation(summary = "获取竞赛排行榜") + @GetMapping("/{id}/leaderboard") + public Result> getLeaderboard( + @PathVariable Long id, + @RequestParam(required = false, defaultValue = "50") Integer limit) { + return Result.success(contestService.getLeaderboard(id, limit)); + } + + @Operation(summary = "获取用户在竞赛中的数据") + @GetMapping("/{contestId}/user/{userId}") + public Result getUserContestData( + @PathVariable Long contestId, + @PathVariable Long userId) { + return Result.success(contestService.getUserContestData(contestId, userId)); + } + + @Operation(summary = "获取我的竞赛数据") + @GetMapping("/{contestId}/my") + public Result getMyContestData( + @PathVariable Long contestId, + @RequestHeader("Authorization") String authHeader) { + String token = jwtUtil.extractTokenFromHeader(authHeader); + Long userId = jwtUtil.getUserIdFromToken(token); + return Result.success(contestService.getUserContestData(contestId, userId)); + } + + @Operation(summary = "领取竞赛奖励") + @PostMapping("/{contestId}/claim-reward") + public Result claimReward( + @PathVariable Long contestId, + @RequestHeader("Authorization") String authHeader) { + String token = jwtUtil.extractTokenFromHeader(authHeader); + Long userId = jwtUtil.getUserIdFromToken(token); + return Result.success(contestService.claimReward(contestId, userId)); + } +} diff --git a/backend/src/main/java/com/designhub/controller/DelayPenaltyController.java b/backend/src/main/java/com/designhub/controller/DelayPenaltyController.java new file mode 100644 index 0000000..0423787 --- /dev/null +++ b/backend/src/main/java/com/designhub/controller/DelayPenaltyController.java @@ -0,0 +1,72 @@ +package com.designhub.controller; + +import com.designhub.common.Result; +import com.designhub.entity.DelayPenaltyRecord; +import com.designhub.service.DelayPenaltyService; +import com.designhub.util.JwtUtil; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 延误扣分Controller + */ +@Slf4j +@Tag(name = "延误扣分管理", description = "延误扣分和申诉相关API") +@RestController +@RequestMapping("/delay-penalty") +@RequiredArgsConstructor +public class DelayPenaltyController { + + private final DelayPenaltyService delayPenaltyService; + private final JwtUtil jwtUtil; + + @Operation(summary = "根据任务ID查询延误记录") + @GetMapping("/task/{taskId}") + public Result> getByTaskId(@PathVariable Long taskId) { + return Result.success(delayPenaltyService.getDelayRecordsByTaskId(taskId)); + } + + @Operation(summary = "根据设计师ID查询延误记录") + @GetMapping("/designer/{designerId}") + public Result> getByDesignerId(@PathVariable Long designerId) { + return Result.success(delayPenaltyService.getDelayRecordsByDesignerId(designerId)); + } + + @Operation(summary = "统计设计师的延误扣分总数") + @GetMapping("/designer/{designerId}/total") + public Result getTotalPenaltyScore(@PathVariable Long designerId) { + return Result.success(delayPenaltyService.getTotalPenaltyScoreByDesignerId(designerId)); + } + + @Operation(summary = "申诉延误扣分") + @PostMapping("/{recordId}/appeal") + public Result appealDelayPenalty( + @PathVariable Long recordId, + @RequestParam String appealReason, + @RequestHeader("Authorization") String authHeader) { + String token = jwtUtil.extractTokenFromHeader(authHeader); + Long userId = jwtUtil.getUserIdFromToken(token); + delayPenaltyService.appealDelayPenalty(recordId, userId, appealReason); + return Result.success("申诉提交成功"); + } + + @Operation(summary = "管理员审批申诉") + @PostMapping("/{recordId}/approve") + public Result approveAppeal( + @PathVariable Long recordId, + @RequestParam Boolean approved, + @RequestParam(required = false) String appealResult, + @RequestHeader("Authorization") String authHeader) { + String token = jwtUtil.extractTokenFromHeader(authHeader); + Long approverId = jwtUtil.getUserIdFromToken(token); + delayPenaltyService.approveAppeal(recordId, approverId, approved, + appealResult != null ? appealResult : (approved ? "申诉通过" : "申诉驳回")); + return Result.success(approved ? "申诉已通过" : "申诉已驳回"); + } +} + diff --git a/backend/src/main/java/com/designhub/controller/DepartmentController.java b/backend/src/main/java/com/designhub/controller/DepartmentController.java new file mode 100644 index 0000000..3921788 --- /dev/null +++ b/backend/src/main/java/com/designhub/controller/DepartmentController.java @@ -0,0 +1,114 @@ +package com.designhub.controller; + +import com.designhub.common.Result; +import com.designhub.dto.DepartmentCreateRequest; +import com.designhub.dto.DepartmentLimitSettingsRequest; +import com.designhub.dto.DepartmentScoreAdjustRequest; +import com.designhub.dto.DepartmentUpdateRequest; +import com.designhub.entity.Department; +import com.designhub.entity.TeamScorePool; +import com.designhub.service.DepartmentService; +import com.designhub.service.TeamScorePoolService; +import com.designhub.vo.DepartmentOverviewVO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 部门管理Controller + */ +@Tag(name = "部门管理", description = "部门管理相关API") +@RestController +@RequestMapping("/api/departments") +@RequiredArgsConstructor +public class DepartmentController { + + private final DepartmentService departmentService; + private final TeamScorePoolService teamScorePoolService; + + @Operation(summary = "获取所有部门列表") + @GetMapping + public Result> getAllDepartments() { + return Result.success(departmentService.getAllDepartments()); + } + + @Operation(summary = "部门概览(含积分信息)") + @GetMapping("/overview") + public Result> getDepartmentOverview() { + return Result.success(departmentService.getDepartmentOverviewList()); + } + + @Operation(summary = "获取部门树形结构") + @GetMapping("/tree") + public Result> getDepartmentTree() { + return Result.success(departmentService.getDepartmentTree()); + } + + @Operation(summary = "根据类型获取部门列表") + @GetMapping("/type/{deptType}") + public Result> getDepartmentsByType(@PathVariable String deptType) { + return Result.success(departmentService.getDepartmentsByType(deptType)); + } + + @Operation(summary = "根据ID获取部门") + @GetMapping("/{id}") + public Result getDepartmentById(@PathVariable Long id) { + return Result.success(departmentService.getDepartmentById(id)); + } + + @Operation(summary = "创建部门") + @PostMapping + public Result createDepartment(@Valid @RequestBody DepartmentCreateRequest request) { + return Result.success(departmentService.createDepartment(request)); + } + + @Operation(summary = "更新部门") + @PutMapping("/{id}") + public Result updateDepartment(@PathVariable Long id, + @Valid @RequestBody DepartmentUpdateRequest request) { + return Result.success(departmentService.updateDepartment(id, request)); + } + + @Operation(summary = "更新部门积分限制配置") + @PutMapping("/{id}/limit") + public Result updateLimitSettings(@PathVariable Long id, + @Valid @RequestBody DepartmentLimitSettingsRequest request) { + return Result.success(departmentService.updateLimitSettings(id, request)); + } + + @Operation(summary = "删除部门") + @DeleteMapping("/{id}") + public Result deleteDepartment(@PathVariable Long id) { + return Result.success(departmentService.deleteDepartment(id)); + } + + @Operation(summary = "设置部门负责人/主管") + @PostMapping("/{deptId}/head") + public Result setDepartmentHead( + @PathVariable Long deptId, + @RequestParam Long userId, + @RequestParam String type + ) { + return Result.success(departmentService.setDepartmentHead(deptId, userId, type)); + } + + @Operation(summary = "调整部门积分") + @PostMapping("/{deptId}/score-adjust") + public Result adjustScore(@PathVariable Long deptId, + @Valid @RequestBody DepartmentScoreAdjustRequest request) { + departmentService.adjustScore(deptId, request); + return Result.success(); + } + + @Operation(summary = "获取部门积分池详情") + @GetMapping("/{deptId}/score-pool") + public Result getScorePool(@PathVariable Long deptId) { + return Result.success(teamScorePoolService.getByDeptId(deptId)); + } +} + + diff --git a/backend/src/main/java/com/designhub/controller/DesignerController.java b/backend/src/main/java/com/designhub/controller/DesignerController.java new file mode 100644 index 0000000..10094ec --- /dev/null +++ b/backend/src/main/java/com/designhub/controller/DesignerController.java @@ -0,0 +1,54 @@ +package com.designhub.controller; + +import com.designhub.common.Result; +import com.designhub.entity.Task; +import com.designhub.service.DesignerService; +import com.designhub.vo.DesignerStatsVO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 设计师统计Controller + */ +@Tag(name = "设计师管理", description = "设计师统计和排行榜相关API") +@RestController +@RequestMapping("/api/designers") +@RequiredArgsConstructor +public class DesignerController { + + private final DesignerService designerService; + + @Operation(summary = "获取所有设计师统计信息") + @GetMapping("/stats") + public Result> getAllDesignerStats() { + return Result.success(designerService.getAllDesignerStats()); + } + + @Operation(summary = "获取单个设计师统计信息") + @GetMapping("/stats/{userId}") + public Result getDesignerStats(@PathVariable Long userId) { + return Result.success(designerService.getDesignerStats(userId)); + } + + @Operation(summary = "获取排行榜") + @GetMapping("/leaderboard") + public Result> getLeaderboard( + @RequestParam(required = false, defaultValue = "score") String type, + @RequestParam(required = false, defaultValue = "10") Integer limit, + @Parameter(description = "月份,格式:YYYY-MM,不传则查询当月") @RequestParam(required = false) String month + ) { + return Result.success(designerService.getLeaderboard(type, limit, month)); + } + + @Operation(summary = "获取设计师进行中的任务列表") + @GetMapping("/{userId}/in-progress-tasks") + public Result> getDesignerInProgressTasks(@PathVariable Long userId) { + return Result.success(designerService.getDesignerInProgressTasks(userId)); + } +} + diff --git a/backend/src/main/java/com/designhub/controller/DingTalkController.java b/backend/src/main/java/com/designhub/controller/DingTalkController.java new file mode 100644 index 0000000..275af9f --- /dev/null +++ b/backend/src/main/java/com/designhub/controller/DingTalkController.java @@ -0,0 +1,174 @@ +package com.designhub.controller; + +import com.designhub.common.Result; +import com.designhub.entity.DingTalkConfig; +import com.designhub.service.DingTalkService; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 钉钉集成 Controller + */ +@Tag(name = "钉钉集成", description = "钉钉集成管理API") +@RestController +@RequestMapping("/api/dingtalk") +@RequiredArgsConstructor +public class DingTalkController { + + private final DingTalkService dingTalkService; + private final ObjectMapper objectMapper; + + @Operation(summary = "获取钉钉配置") + @GetMapping("/config") + public Result getConfig() { + DingTalkConfig config = dingTalkService.getConfig(); + + // 如果没有配置,返回默认配置(仅用于前端显示) + if (config == null) { + config = new DingTalkConfig(); + config.setName("默认配置"); + config.setEnabled(0); + } + + // 隐藏敏感信息 + if (config.getAppSecret() != null && !config.getAppSecret().isEmpty()) { + config.setAppSecret("******"); + } + if (config.getRobotSecret() != null && !config.getRobotSecret().isEmpty()) { + config.setRobotSecret("******"); + } + config.setAccessToken(null); + + return Result.success(config); + } + + @Operation(summary = "保存钉钉配置") + @PostMapping("/config") + public Result saveConfig(@RequestBody Map configMap) { + try { + // 处理enabledFeatures字段 + Object enabledFeaturesObj = configMap.get("enabledFeatures"); + String enabledFeaturesJson = null; + if (enabledFeaturesObj != null) { + if (enabledFeaturesObj instanceof String) { + enabledFeaturesJson = (String) enabledFeaturesObj; + } else { + // 转换为JSON字符串 + enabledFeaturesJson = objectMapper.writeValueAsString(enabledFeaturesObj); + } + } + + // 构建配置对象 + DingTalkConfig config = new DingTalkConfig(); + config.setId(configMap.get("id") != null ? ((Number) configMap.get("id")).longValue() : null); + config.setName((String) configMap.get("name")); + config.setAgentId((String) configMap.get("agentId")); + config.setAppKey((String) configMap.get("appKey")); + config.setAppSecret((String) configMap.get("appSecret")); + config.setCorpId((String) configMap.get("corpId")); + config.setRobotWebhook((String) configMap.get("robotWebhook")); + config.setRobotSecret((String) configMap.get("robotSecret")); + config.setEnabled(configMap.get("enabled") != null ? ((Number) configMap.get("enabled")).intValue() : 1); + config.setEnabledFeatures(enabledFeaturesJson); + + // 如果前端传来的appSecret是占位符,则保留原有的secret + if ("******".equals(config.getAppSecret())) { + DingTalkConfig existingConfig = dingTalkService.getConfig(); + if (existingConfig != null) { + config.setAppSecret(existingConfig.getAppSecret()); + } + } + if ("******".equals(config.getRobotSecret())) { + DingTalkConfig existingConfig = dingTalkService.getConfig(); + if (existingConfig != null) { + config.setRobotSecret(existingConfig.getRobotSecret()); + } + } + + DingTalkConfig saved = dingTalkService.saveConfig(config); + + // 返回时隐藏敏感信息 + if (saved.getAppSecret() != null && !saved.getAppSecret().isEmpty()) { + saved.setAppSecret("******"); + } + if (saved.getRobotSecret() != null && !saved.getRobotSecret().isEmpty()) { + saved.setRobotSecret("******"); + } + saved.setAccessToken(null); + + return Result.success(saved); + } catch (Exception e) { + return Result.error("保存配置失败: " + e.getMessage()); + } + } + + @Operation(summary = "测试钉钉连接") + @PostMapping("/test") + public Result testConnection(@RequestBody DingTalkConfig config) { + return Result.success(dingTalkService.testConnection(config)); + } + + @Operation(summary = "发送测试消息") + @PostMapping("/test-message") + public Result sendTestMessage(@RequestBody Map params) { + String title = params.getOrDefault("title", "测试消息"); + String content = params.getOrDefault("content", "这是一条来自DesignHub的测试消息"); + return Result.success(dingTalkService.sendRobotMessage(title, content)); + } + + @Operation(summary = "获取钉钉部门列表") + @GetMapping("/departments") + public Result>> getDepartmentList() { + return Result.success(dingTalkService.getDepartmentList()); + } + + @Operation(summary = "同步钉钉用户") + @PostMapping("/sync-users") + public Result syncUsers() { + return Result.success(dingTalkService.syncUsers()); + } + + @Operation(summary = "检查功能状态") + @GetMapping("/feature/{feature}") + public Result isFeatureEnabled(@PathVariable String feature) { + return Result.success(dingTalkService.isFeatureEnabled(feature)); + } + + @Operation(summary = "钉钉OAuth登录发起", description = "生成钉钉登录URL") + @GetMapping("/oauth/login") + public Result> initiateLogin(@RequestParam(required = false) String redirectUrl) { + if (!dingTalkService.isConfigured()) { + return Result.error("钉钉登录未配置或未启用,请联系管理员在系统设置中配置"); + } + + DingTalkConfig config = dingTalkService.getConfig(); + + // 构建回调URL + String callbackUrl = redirectUrl != null ? redirectUrl : "http://localhost:3000/auth/dingtalk/callback"; + + // 构建钉钉OAuth URL + String authUrl = String.format( + "https://login.dingtalk.com/oauth2/auth?redirect_uri=%s&response_type=code&client_id=%s&scope=openid&state=STATE&prompt=consent", + java.net.URLEncoder.encode(callbackUrl, java.nio.charset.StandardCharsets.UTF_8), + config.getAppKey() + ); + + Map result = new HashMap<>(); + result.put("authUrl", authUrl); + result.put("appKey", config.getAppKey()); + return Result.success(result); + } + + @Operation(summary = "检查钉钉登录是否已配置") + @GetMapping("/is-configured") + public Result isConfigured() { + return Result.success(dingTalkService.isConfigured()); + } +} diff --git a/backend/src/main/java/com/designhub/controller/ExportController.java b/backend/src/main/java/com/designhub/controller/ExportController.java new file mode 100644 index 0000000..5f3610c --- /dev/null +++ b/backend/src/main/java/com/designhub/controller/ExportController.java @@ -0,0 +1,55 @@ +package com.designhub.controller; + +import com.designhub.service.ExportService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.io.IOException; + +/** + * 数据导出Controller + */ +@Tag(name = "数据导出", description = "数据导出相关API") +@RestController +@RequestMapping("/api/export") +@RequiredArgsConstructor +public class ExportController { + + private final ExportService exportService; + + @Operation(summary = "导出任务列表") + @GetMapping("/tasks") + public void exportTasks( + HttpServletResponse response, + @RequestParam(required = false) String status, + @RequestParam(required = false) Long publisherId + ) throws IOException { + exportService.exportTasks(response, status, publisherId); + } + + @Operation(summary = "导出用户列表") + @GetMapping("/users") + public void exportUsers( + HttpServletResponse response, + @RequestParam(required = false) Long deptId, + @RequestParam(required = false) Integer status + ) throws IOException { + exportService.exportUsers(response, deptId, status); + } + + @Operation(summary = "导出部门列表") + @GetMapping("/departments") + public void exportDepartments(HttpServletResponse response) throws IOException { + exportService.exportDepartments(response); + } + + @Operation(summary = "导出设计师统计") + @GetMapping("/designer-stats") + public void exportDesignerStats(HttpServletResponse response) throws IOException { + exportService.exportDesignerStats(response); + } +} + diff --git a/backend/src/main/java/com/designhub/controller/FileUploadController.java b/backend/src/main/java/com/designhub/controller/FileUploadController.java new file mode 100644 index 0000000..2ee3c63 --- /dev/null +++ b/backend/src/main/java/com/designhub/controller/FileUploadController.java @@ -0,0 +1,184 @@ +package com.designhub.controller; + +import com.designhub.entity.FileUpload; +import com.designhub.entity.User; +import com.designhub.service.S3Service; +import com.designhub.service.UserService; +import com.designhub.util.JwtUtil; +import com.designhub.common.Result; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 文件上传下载Controller + */ +@RestController +@RequestMapping("/api/file") +public class FileUploadController { + + @Autowired + private S3Service s3Service; + + @Autowired + private UserService userService; + + @Autowired + private JwtUtil jwtUtil; + + /** + * 上传文件 + * + * @param files 文件列表 + * @param relatedType 关联类型(可选) + * @param relatedId 关联ID(可选) + * @param request HttpServletRequest + * @return 文件URL列表 + */ + @PostMapping("/upload") + public Result> uploadFiles( + @RequestParam("files") MultipartFile[] files, + @RequestParam(value = "relatedType", required = false) String relatedType, + @RequestParam(value = "relatedId", required = false) Long relatedId, + HttpServletRequest request) { + + try { + // 从token获取用户信息 + String token = request.getHeader("Authorization"); + if (token == null || !token.startsWith("Bearer ")) { + return Result.error("未登录或token无效"); + } + token = token.substring(7); + + String username = jwtUtil.getUsernameFromToken(token); + if (username == null) { + return Result.error("token无效"); + } + + User user = userService.getUserByUsername(username); + if (user == null) { + return Result.error("用户不存在"); + } + + // 上传文件 + List fileUrls = new ArrayList<>(); + for (MultipartFile file : files) { + if (file.isEmpty()) { + continue; + } + + String fileUrl = s3Service.uploadFile( + file, + user.getId(), + user.getRealName() != null ? user.getRealName() : user.getUsername(), + relatedType, + relatedId + ); + fileUrls.add(fileUrl); + } + + return Result.success(fileUrls); + + } catch (Exception e) { + return Result.error("文件上传失败: " + e.getMessage()); + } + } + + /** + * 下载文件(需要登录) + * + * @param id 文件ID + * @param request HttpServletRequest + * @param response HttpServletResponse + */ + @GetMapping("/download/{id}") + public void downloadFile( + @PathVariable Long id, + HttpServletRequest request, + HttpServletResponse response) { + + try { + // 验证登录状态 + String token = request.getHeader("Authorization"); + if (token == null || !token.startsWith("Bearer ")) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.getWriter().write("未登录,请先登录"); + return; + } + token = token.substring(7); + + String username = jwtUtil.getUsernameFromToken(token); + if (username == null) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.getWriter().write("token无效"); + return; + } + + // 获取文件信息 + FileUpload fileUpload = s3Service.getFileUploadById(id); + if (fileUpload == null) { + response.setStatus(HttpServletResponse.SC_NOT_FOUND); + response.getWriter().write("文件不存在"); + return; + } + + // 重定向到文件URL(S3提供的公开URL) + response.sendRedirect(fileUpload.getFileUrl()); + + } catch (IOException e) { + try { + response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + response.getWriter().write("文件下载失败: " + e.getMessage()); + } catch (IOException ex) { + // ignore + } + } + } + + /** + * 获取文件信息 + * + * @param id 文件ID + * @return 文件信息 + */ + @GetMapping("/{id}") + public Result getFileInfo(@PathVariable Long id) { + try { + FileUpload fileUpload = s3Service.getFileUploadById(id); + if (fileUpload == null) { + return Result.error("文件不存在"); + } + return Result.success(fileUpload); + } catch (Exception e) { + return Result.error("获取文件信息失败: " + e.getMessage()); + } + } + + /** + * 根据关联信息获取文件列表 + * + * @param relatedType 关联类型 + * @param relatedId 关联ID + * @return 文件列表 + */ + @GetMapping("/list") + public Result> getFilesByRelated( + @RequestParam String relatedType, + @RequestParam Long relatedId) { + + try { + List files = s3Service.getFilesByRelated(relatedType, relatedId); + return Result.success(files); + } catch (Exception e) { + return Result.error("获取文件列表失败: " + e.getMessage()); + } + } +} diff --git a/backend/src/main/java/com/designhub/controller/HelloController.java b/backend/src/main/java/com/designhub/controller/HelloController.java new file mode 100644 index 0000000..7f1a9a8 --- /dev/null +++ b/backend/src/main/java/com/designhub/controller/HelloController.java @@ -0,0 +1,50 @@ +package com.designhub.controller; + +import com.designhub.common.Result; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; + +/** + * 测试控制器 + */ +@Tag(name = "测试接口", description = "用于测试系统是否正常运行") +@RestController +@RequestMapping("/hello") +public class HelloController { + + @Operation(summary = "Hello World", description = "测试接口是否可访问") + @GetMapping + public Result hello() { + return Result.success("Hello, DesignHub! 系统运行正常 ✅"); + } + + @Operation(summary = "系统信息", description = "获取系统基本信息") + @GetMapping("/info") + public Result> info() { + Map info = new HashMap<>(); + info.put("name", "DesignHub - 设计师任务管理平台"); + info.put("version", "1.0.0"); + info.put("time", LocalDateTime.now()); + info.put("status", "running"); + info.put("description", "基于 Spring Boot 3.x + Vue 3 开发"); + return Result.success(info); + } + + @Operation(summary = "健康检查", description = "检查系统健康状态") + @GetMapping("/health") + public Result> health() { + Map health = new HashMap<>(); + health.put("status", "UP"); + health.put("database", "OK"); + health.put("api", "OK"); + return Result.success(health); + } +} + diff --git a/backend/src/main/java/com/designhub/controller/NotificationController.java b/backend/src/main/java/com/designhub/controller/NotificationController.java new file mode 100644 index 0000000..742abc4 --- /dev/null +++ b/backend/src/main/java/com/designhub/controller/NotificationController.java @@ -0,0 +1,70 @@ +package com.designhub.controller; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.designhub.common.Result; +import com.designhub.entity.Notification; +import com.designhub.service.NotificationService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +/** + * 通知消息Controller + */ +@Tag(name = "通知消息", description = "通知消息相关API") +@RestController +@RequestMapping("/api/notifications") +@RequiredArgsConstructor +public class NotificationController { + + private final NotificationService notificationService; + + @Operation(summary = "获取用户通知列表(分页)") + @GetMapping + public Result> getUserNotifications( + @RequestParam Long receiverId, + @RequestParam(defaultValue = "1") Integer pageNum, + @RequestParam(defaultValue = "20") Integer pageSize, + @RequestParam(required = false) Boolean unreadOnly + ) { + Page page = notificationService.getUserNotifications(receiverId, pageNum, pageSize, unreadOnly); + return Result.success(page); + } + + @Operation(summary = "获取未读数量") + @GetMapping("/unread-count") + public Result getUnreadCount(@RequestParam Long receiverId) { + long count = notificationService.getUnreadCount(receiverId); + return Result.success(count); + } + + @Operation(summary = "标记通知为已读") + @PutMapping("/{id}/read") + public Result markAsRead(@PathVariable Long id, @RequestParam Long receiverId) { + boolean success = notificationService.markAsRead(id, receiverId); + return Result.success(success); + } + + @Operation(summary = "批量标记所有通知为已读") + @PutMapping("/read-all") + public Result markAllAsRead(@RequestParam Long receiverId) { + int count = notificationService.markAllAsRead(receiverId); + return Result.success(count); + } + + @Operation(summary = "删除通知") + @DeleteMapping("/{id}") + public Result deleteNotification(@PathVariable Long id, @RequestParam Long receiverId) { + boolean success = notificationService.deleteNotification(id, receiverId); + return Result.success(success); + } + + @Operation(summary = "发送通知(管理员)") + @PostMapping + public Result sendNotification(@RequestBody Notification notification) { + Notification result = notificationService.sendNotification(notification); + return Result.success(result); + } +} + diff --git a/backend/src/main/java/com/designhub/controller/PermissionController.java b/backend/src/main/java/com/designhub/controller/PermissionController.java new file mode 100644 index 0000000..738ce48 --- /dev/null +++ b/backend/src/main/java/com/designhub/controller/PermissionController.java @@ -0,0 +1,73 @@ +package com.designhub.controller; + +import com.designhub.common.Result; +import com.designhub.entity.Permission; +import com.designhub.service.PermissionService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 权限管理Controller + */ +@Tag(name = "权限管理", description = "权限管理相关API") +@RestController +@RequestMapping("/api/permissions") +@RequiredArgsConstructor +public class PermissionController { + + private final PermissionService permissionService; + + @Operation(summary = "获取所有权限列表") + @GetMapping + public Result> getAllPermissions() { + return Result.success(permissionService.getAllPermissions()); + } + + @Operation(summary = "获取权限树形结构") + @GetMapping("/tree") + public Result> getPermissionTree() { + return Result.success(permissionService.getPermissionTree()); + } + + @Operation(summary = "根据ID获取权限") + @GetMapping("/{id}") + public Result getPermissionById(@PathVariable Long id) { + return Result.success(permissionService.getPermissionById(id)); + } + + @Operation(summary = "根据用户ID获取权限列表") + @GetMapping("/user/{userId}") + public Result> getPermissionsByUserId(@PathVariable Long userId) { + return Result.success(permissionService.getPermissionsByUserId(userId)); + } + + @Operation(summary = "根据用户ID获取权限代码列表") + @GetMapping("/user/{userId}/codes") + public Result> getPermissionCodesByUserId(@PathVariable Long userId) { + return Result.success(permissionService.getPermissionCodesByUserId(userId)); + } + + @Operation(summary = "创建权限") + @PostMapping + public Result createPermission(@RequestBody Permission permission) { + return Result.success(permissionService.createPermission(permission)); + } + + @Operation(summary = "更新权限") + @PutMapping("/{id}") + public Result updatePermission(@PathVariable Long id, @RequestBody Permission permission) { + permission.setId(id); + return Result.success(permissionService.updatePermission(permission)); + } + + @Operation(summary = "删除权限") + @DeleteMapping("/{id}") + public Result deletePermission(@PathVariable Long id) { + return Result.success(permissionService.deletePermission(id)); + } +} + diff --git a/backend/src/main/java/com/designhub/controller/RewardRuleController.java b/backend/src/main/java/com/designhub/controller/RewardRuleController.java new file mode 100644 index 0000000..b25db21 --- /dev/null +++ b/backend/src/main/java/com/designhub/controller/RewardRuleController.java @@ -0,0 +1,92 @@ +package com.designhub.controller; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.designhub.common.Result; +import com.designhub.entity.RewardRule; +import com.designhub.service.RewardRuleService; +import com.designhub.util.JwtUtil; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 自动奖励规则 Controller + */ +@Tag(name = "自动奖励规则", description = "自动奖励规则管理API") +@RestController +@RequestMapping("/api/reward-rules") +@RequiredArgsConstructor +public class RewardRuleController { + + private final RewardRuleService rewardRuleService; + private final JwtUtil jwtUtil; + + @Operation(summary = "创建奖励规则") + @PostMapping + public Result createRule( + @RequestBody RewardRule rule, + @RequestHeader("Authorization") String authHeader) { + String token = jwtUtil.extractTokenFromHeader(authHeader); + Long userId = jwtUtil.getUserIdFromToken(token); + rule.setCreatorId(userId); + return Result.success(rewardRuleService.createRule(rule)); + } + + @Operation(summary = "更新奖励规则") + @PutMapping("/{id}") + public Result updateRule(@PathVariable Long id, @RequestBody RewardRule rule) { + return Result.success(rewardRuleService.updateRule(id, rule)); + } + + @Operation(summary = "启用规则") + @PostMapping("/{id}/enable") + public Result enableRule(@PathVariable Long id) { + rewardRuleService.toggleRule(id, true); + return Result.success(); + } + + @Operation(summary = "禁用规则") + @PostMapping("/{id}/disable") + public Result disableRule(@PathVariable Long id) { + rewardRuleService.toggleRule(id, false); + return Result.success(); + } + + @Operation(summary = "删除规则") + @DeleteMapping("/{id}") + public Result deleteRule(@PathVariable Long id) { + rewardRuleService.deleteRule(id); + return Result.success(); + } + + @Operation(summary = "获取规则列表") + @GetMapping + public Result> getRuleList( + @RequestParam(defaultValue = "1") Integer page, + @RequestParam(defaultValue = "10") Integer pageSize, + @RequestParam(required = false) String ruleType) { + return Result.success(rewardRuleService.getRuleList(page, pageSize, ruleType)); + } + + @Operation(summary = "获取规则详情") + @GetMapping("/{id}") + public Result getRuleById(@PathVariable Long id) { + return Result.success(rewardRuleService.getRuleById(id)); + } + + @Operation(summary = "获取所有启用的规则") + @GetMapping("/enabled") + public Result> getEnabledRules() { + return Result.success(rewardRuleService.getEnabledRules()); + } + + @Operation(summary = "初始化默认规则") + @PostMapping("/init") + public Result initDefaultRules() { + rewardRuleService.initDefaultRules(); + return Result.success(); + } +} diff --git a/backend/src/main/java/com/designhub/controller/RoleController.java b/backend/src/main/java/com/designhub/controller/RoleController.java new file mode 100644 index 0000000..2118355 --- /dev/null +++ b/backend/src/main/java/com/designhub/controller/RoleController.java @@ -0,0 +1,79 @@ +package com.designhub.controller; + +import com.designhub.common.Result; +import com.designhub.entity.Role; +import com.designhub.service.RoleService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 角色管理Controller + */ +@Tag(name = "角色管理", description = "角色管理相关API") +@RestController +@RequestMapping("/api/roles") +@RequiredArgsConstructor +public class RoleController { + + private final RoleService roleService; + + @Operation(summary = "获取所有角色列表") + @GetMapping + public Result> getAllRoles() { + return Result.success(roleService.getAllRoles()); + } + + @Operation(summary = "根据ID获取角色") + @GetMapping("/{id}") + public Result getRoleById(@PathVariable Long id) { + return Result.success(roleService.getRoleById(id)); + } + + @Operation(summary = "创建角色") + @PostMapping + public Result createRole(@RequestBody Role role) { + return Result.success(roleService.createRole(role)); + } + + @Operation(summary = "更新角色") + @PutMapping("/{id}") + public Result updateRole(@PathVariable Long id, @RequestBody Role role) { + role.setId(id); + return Result.success(roleService.updateRole(role)); + } + + @Operation(summary = "删除角色") + @DeleteMapping("/{id}") + public Result deleteRole(@PathVariable Long id) { + return Result.success(roleService.deleteRole(id)); + } + + @Operation(summary = "为角色分配权限") + @PostMapping("/{id}/permissions") + public Result assignPermissions( + @PathVariable Long id, + @RequestBody List permissionIds + ) { + return Result.success(roleService.assignPermissions(id, permissionIds)); + } + + @Operation(summary = "获取角色的权限ID列表") + @GetMapping("/{id}/permissions") + public Result> getRolePermissionIds(@PathVariable Long id) { + return Result.success(roleService.getRolePermissionIds(id)); + } + + @Operation(summary = "启用/禁用角色") + @PutMapping("/{id}/status") + public Result toggleRoleStatus( + @PathVariable Long id, + @RequestParam Integer status + ) { + return Result.success(roleService.toggleRoleStatus(id, status)); + } +} + diff --git a/backend/src/main/java/com/designhub/controller/S3ConfigController.java b/backend/src/main/java/com/designhub/controller/S3ConfigController.java new file mode 100644 index 0000000..bed8d26 --- /dev/null +++ b/backend/src/main/java/com/designhub/controller/S3ConfigController.java @@ -0,0 +1,132 @@ +package com.designhub.controller; + +import com.designhub.entity.S3Config; +import com.designhub.service.S3ConfigService; +import com.designhub.common.Result; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * S3配置管理Controller + */ +@RestController +@RequestMapping("/api/s3/config") +public class S3ConfigController { + + @Autowired + private S3ConfigService s3ConfigService; + + /** + * 获取激活的S3配置 + */ + @GetMapping("/active") + public Result getActiveConfig() { + try { + S3Config config = s3ConfigService.getActiveConfig(); + // 隐藏敏感信息 + if (config != null) { + config.setSecretAccessKey("******"); + } + return Result.success(config); + } catch (Exception e) { + return Result.error("获取S3配置失败: " + e.getMessage()); + } + } + + /** + * 获取所有S3配置 + */ + @GetMapping("/list") + public Result> getAllConfigs() { + try { + List configs = s3ConfigService.getAllConfigs(); + // 隐藏敏感信息 + configs.forEach(config -> config.setSecretAccessKey("******")); + return Result.success(configs); + } catch (Exception e) { + return Result.error("获取S3配置列表失败: " + e.getMessage()); + } + } + + /** + * 根据ID获取S3配置 + */ + @GetMapping("/{id}") + public Result getConfigById(@PathVariable Long id) { + try { + S3Config config = s3ConfigService.getConfigById(id); + if (config == null) { + return Result.error("S3配置不存在"); + } + // 隐藏敏感信息 + config.setSecretAccessKey("******"); + return Result.success(config); + } catch (Exception e) { + return Result.error("获取S3配置失败: " + e.getMessage()); + } + } + + /** + * 创建S3配置 + */ + @PostMapping + public Result createConfig(@RequestBody S3Config config) { + try { + S3Config created = s3ConfigService.createConfig(config); + created.setSecretAccessKey("******"); + return Result.success(created); + } catch (Exception e) { + return Result.error("创建S3配置失败: " + e.getMessage()); + } + } + + /** + * 更新S3配置 + */ + @PutMapping("/{id}") + public Result updateConfig(@PathVariable Long id, @RequestBody S3Config config) { + try { + // 如果secretAccessKey为"******",说明没有修改,从数据库中获取原值 + if ("******".equals(config.getSecretAccessKey())) { + S3Config existingConfig = s3ConfigService.getConfigById(id); + if (existingConfig != null) { + config.setSecretAccessKey(existingConfig.getSecretAccessKey()); + } + } + + S3Config updated = s3ConfigService.updateConfig(id, config); + updated.setSecretAccessKey("******"); + return Result.success(updated); + } catch (Exception e) { + return Result.error("更新S3配置失败: " + e.getMessage()); + } + } + + /** + * 删除S3配置 + */ + @DeleteMapping("/{id}") + public Result deleteConfig(@PathVariable Long id) { + try { + s3ConfigService.deleteConfig(id); + return Result.success(); + } catch (Exception e) { + return Result.error("删除S3配置失败: " + e.getMessage()); + } + } + + /** + * 激活指定的S3配置 + */ + @PutMapping("/{id}/activate") + public Result activateConfig(@PathVariable Long id) { + try { + s3ConfigService.activateConfig(id); + return Result.success(); + } catch (Exception e) { + return Result.error("激活S3配置失败: " + e.getMessage()); + } + } +} diff --git a/backend/src/main/java/com/designhub/controller/ScorePoolController.java b/backend/src/main/java/com/designhub/controller/ScorePoolController.java new file mode 100644 index 0000000..9cad04e --- /dev/null +++ b/backend/src/main/java/com/designhub/controller/ScorePoolController.java @@ -0,0 +1,102 @@ +package com.designhub.controller; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.designhub.common.Result; +import com.designhub.entity.ScorePool; +import com.designhub.entity.ScorePoolRecord; +import com.designhub.service.ScorePoolService; +import com.designhub.util.JwtUtil; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +/** + * 激励积分池 Controller + */ +@Tag(name = "激励积分池", description = "激励积分池管理API") +@RestController +@RequestMapping("/api/score-pools") +@RequiredArgsConstructor +public class ScorePoolController { + + private final ScorePoolService scorePoolService; + private final JwtUtil jwtUtil; + + @Operation(summary = "创建积分池") + @PostMapping + public Result createPool( + @RequestBody ScorePool pool, + @RequestHeader("Authorization") String authHeader) { + String token = jwtUtil.extractTokenFromHeader(authHeader); + Long userId = jwtUtil.getUserIdFromToken(token); + pool.setCreatorId(userId); + return Result.success(scorePoolService.createPool(pool)); + } + + @Operation(summary = "更新积分池") + @PutMapping("/{id}") + public Result updatePool(@PathVariable Long id, @RequestBody ScorePool pool) { + return Result.success(scorePoolService.updatePool(id, pool)); + } + + @Operation(summary = "获取积分池列表") + @GetMapping + public Result> getPoolList( + @RequestParam(defaultValue = "1") Integer page, + @RequestParam(defaultValue = "10") Integer pageSize, + @RequestParam(required = false) String poolType, + @RequestParam(required = false) Integer year) { + return Result.success(scorePoolService.getPoolList(page, pageSize, poolType, year)); + } + + @Operation(summary = "获取积分池详情") + @GetMapping("/{id}") + public Result getPoolById(@PathVariable Long id) { + return Result.success(scorePoolService.getPoolById(id)); + } + + @Operation(summary = "获取当前活跃积分池") + @GetMapping("/current") + public Result getCurrentActivePool() { + return Result.success(scorePoolService.getCurrentActivePool()); + } + + @Operation(summary = "获取积分池统计信息") + @GetMapping("/{id}/statistics") + public Result> getPoolStatistics(@PathVariable Long id) { + return Result.success(scorePoolService.getPoolStatistics(id)); + } + + @Operation(summary = "获取积分池使用记录") + @GetMapping("/{id}/records") + public Result> getPoolRecords( + @PathVariable Long id, + @RequestParam(defaultValue = "1") Integer page, + @RequestParam(defaultValue = "20") Integer pageSize) { + return Result.success(scorePoolService.getPoolRecords(id, page, pageSize)); + } + + @Operation(summary = "关闭积分池") + @PostMapping("/{id}/close") + public Result closePool(@PathVariable Long id) { + scorePoolService.closePool(id); + return Result.success(); + } + + @Operation(summary = "结算积分池") + @PostMapping("/{id}/settle") + public Result settlePool(@PathVariable Long id) { + scorePoolService.settlePool(id); + return Result.success(); + } + + @Operation(summary = "删除积分池") + @DeleteMapping("/{id}") + public Result deletePool(@PathVariable Long id) { + scorePoolService.deletePool(id); + return Result.success(); + } +} diff --git a/backend/src/main/java/com/designhub/controller/SystemConfigController.java b/backend/src/main/java/com/designhub/controller/SystemConfigController.java new file mode 100644 index 0000000..8d2f44a --- /dev/null +++ b/backend/src/main/java/com/designhub/controller/SystemConfigController.java @@ -0,0 +1,87 @@ +package com.designhub.controller; + +import com.designhub.common.Result; +import com.designhub.entity.SystemConfig; +import com.designhub.service.SystemConfigService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +/** + * 系统配置Controller + */ +@Tag(name = "系统配置", description = "系统配置相关API") +@RestController +@RequestMapping("/api/system-config") +@RequiredArgsConstructor +public class SystemConfigController { + + private final SystemConfigService systemConfigService; + + @Operation(summary = "获取所有配置列表") + @GetMapping + public Result> getAllConfigs() { + return Result.success(systemConfigService.getAllConfigs()); + } + + @Operation(summary = "根据分组获取配置列表") + @GetMapping("/group/{group}") + public Result> getConfigsByGroup(@PathVariable String group) { + return Result.success(systemConfigService.getConfigsByGroup(group)); + } + + @Operation(summary = "根据键获取配置值") + @GetMapping("/key/{key}") + public Result getConfigValue(@PathVariable String key) { + return Result.success(systemConfigService.getConfigValue(key)); + } + + @Operation(summary = "获取所有配置的Map") + @GetMapping("/map") + public Result> getAllConfigsMap() { + return Result.success(systemConfigService.getAllConfigsMap()); + } + + @Operation(summary = "根据分组获取配置Map") + @GetMapping("/map/group/{group}") + public Result> getConfigsMapByGroup(@PathVariable String group) { + return Result.success(systemConfigService.getConfigsMapByGroup(group)); + } + + @Operation(summary = "创建配置") + @PostMapping + public Result createConfig(@RequestBody SystemConfig config) { + return Result.success(systemConfigService.createConfig(config)); + } + + @Operation(summary = "更新配置") + @PutMapping("/{id}") + public Result updateConfig(@PathVariable Long id, @RequestBody SystemConfig config) { + config.setId(id); + return Result.success(systemConfigService.updateConfig(config)); + } + + @Operation(summary = "批量更新配置值") + @PutMapping("/batch") + public Result batchUpdateValues(@RequestBody Map configMap) { + return Result.success(systemConfigService.batchUpdateValues(configMap)); + } + + @Operation(summary = "删除配置") + @DeleteMapping("/{id}") + public Result deleteConfig(@PathVariable Long id) { + return Result.success(systemConfigService.deleteConfig(id)); + } + + @Operation(summary = "初始化默认配置") + @PostMapping("/init") + public Result initDefaultConfigs() { + systemConfigService.initDefaultConfigs(); + return Result.success(null); + } +} + diff --git a/backend/src/main/java/com/designhub/controller/SystemHealthController.java b/backend/src/main/java/com/designhub/controller/SystemHealthController.java new file mode 100644 index 0000000..42be246 --- /dev/null +++ b/backend/src/main/java/com/designhub/controller/SystemHealthController.java @@ -0,0 +1,30 @@ +package com.designhub.controller; + +import com.designhub.common.Result; +import com.designhub.service.SystemHealthService; +import com.designhub.vo.SystemHealthVO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 系统健康检查控制器 + */ +@Tag(name = "系统健康", description = "系统自检与健康检查接口") +@RestController +@RequestMapping("/api/system") +@RequiredArgsConstructor +public class SystemHealthController { + + private final SystemHealthService systemHealthService; + + @Operation(summary = "健康检查", description = "用于部署后快速检测数据库与关键表状态") + @GetMapping("/health") + public Result health() { + return Result.success(systemHealthService.check()); + } +} + diff --git a/backend/src/main/java/com/designhub/controller/TaskApprovalController.java b/backend/src/main/java/com/designhub/controller/TaskApprovalController.java new file mode 100644 index 0000000..7832732 --- /dev/null +++ b/backend/src/main/java/com/designhub/controller/TaskApprovalController.java @@ -0,0 +1,93 @@ +package com.designhub.controller; + +import com.designhub.common.Result; +import com.designhub.dto.TaskApprovalDTO; +import com.designhub.entity.Task; +import com.designhub.service.TaskApprovalService; +import com.designhub.util.JwtUtil; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +/** + * 任务审核控制器 + */ +@Tag(name = "任务审核管理") +@RestController +@RequestMapping("/api/tasks") +@RequiredArgsConstructor +public class TaskApprovalController { + + private final TaskApprovalService taskApprovalService; + private final JwtUtil jwtUtil; + + @Operation(summary = "需求部门审核通过") + @PostMapping("/{id}/approve-demand") + public Result approveDemandLevel( + @PathVariable Long id, + @RequestBody TaskApprovalDTO dto, + @RequestHeader("Authorization") String authHeader + ) { + String token = jwtUtil.extractTokenFromHeader(authHeader); + Long approverId = jwtUtil.getUserIdFromToken(token); + taskApprovalService.approveDemandLevel(id, approverId, dto); + return Result.success(); + } + + @Operation(summary = "需求部门驳回") + @PostMapping("/{id}/reject-demand") + public Result rejectByDemandLeader( + @PathVariable Long id, + @RequestBody Map params, + @RequestHeader("Authorization") String authHeader + ) { + String token = jwtUtil.extractTokenFromHeader(authHeader); + Long approverId = jwtUtil.getUserIdFromToken(token); + String reason = params.getOrDefault("reason", ""); + taskApprovalService.rejectByDemandLeader(id, approverId, reason); + return Result.success(); + } + + @Operation(summary = "接单部门审核通过") + @PostMapping("/{id}/approve-receiving") + public Result approveReceivingLevel( + @PathVariable Long id, + @RequestBody TaskApprovalDTO dto, + @RequestHeader("Authorization") String authHeader + ) { + String token = jwtUtil.extractTokenFromHeader(authHeader); + Long approverId = jwtUtil.getUserIdFromToken(token); + taskApprovalService.approveReceivingLevel(id, approverId, dto); + return Result.success(); + } + + @Operation(summary = "接单部门驳回") + @PostMapping("/{id}/reject-receiving") + public Result rejectByReceivingLeader( + @PathVariable Long id, + @RequestBody Map params, + @RequestHeader("Authorization") String authHeader + ) { + String token = jwtUtil.extractTokenFromHeader(authHeader); + Long approverId = jwtUtil.getUserIdFromToken(token); + String reason = params.getOrDefault("reason", ""); + taskApprovalService.rejectByReceivingLeader(id, approverId, reason); + return Result.success(); + } + + @Operation(summary = "获取待审核任务列表") + @GetMapping("/pending-approval") + public Result> getPendingApprovalTasks( + @RequestHeader("Authorization") String authHeader + ) { + String token = jwtUtil.extractTokenFromHeader(authHeader); + Long userId = jwtUtil.getUserIdFromToken(token); + List tasks = taskApprovalService.getPendingApprovalTasks(userId); + return Result.success(tasks); + } +} diff --git a/backend/src/main/java/com/designhub/controller/TaskController.java b/backend/src/main/java/com/designhub/controller/TaskController.java new file mode 100644 index 0000000..4eea057 --- /dev/null +++ b/backend/src/main/java/com/designhub/controller/TaskController.java @@ -0,0 +1,400 @@ +package com.designhub.controller; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.designhub.common.Result; +import com.designhub.dto.TaskAssignRequest; +import com.designhub.dto.TaskAuditRequest; +import com.designhub.entity.Task; +import com.designhub.service.TaskService; +import com.designhub.util.JwtUtil; +import com.designhub.vo.DashboardStatsVO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; + +/** + * 任务管理控制器 + */ +@Slf4j +@Tag(name = "任务管理", description = "任务的增删改查、抢单、审核等接口") +@RestController +@RequestMapping("/api/tasks") +@RequiredArgsConstructor +public class TaskController { + + private final TaskService taskService; + private final JwtUtil jwtUtil; + + @Operation(summary = "创建任务", description = "发起新的设计任务") + @PostMapping + public Result createTask(@RequestBody Task task, @RequestHeader("Authorization") String authHeader) { + log.info("创建任务: {}", task.getTitle()); + String token = jwtUtil.extractTokenFromHeader(authHeader); + Long userId = jwtUtil.getUserIdFromToken(token); + task.setPublisherId(userId); + Long taskId = taskService.createTask(task); + return Result.success(taskId); + } + + @Operation(summary = "创建流水线任务", description = "创建包含多个子任务的流水线任务") + @PostMapping("/pipeline") + public Result createPipelineTask( + @RequestBody com.designhub.dto.PipelineTaskDTO dto, + @RequestHeader("Authorization") String authHeader) { + + log.info("创建流水线任务: {}", dto.getTitle()); + String token = jwtUtil.extractTokenFromHeader(authHeader); + Long userId = jwtUtil.getUserIdFromToken(token); + Long taskId = taskService.createPipelineTask(dto, userId); + return Result.success("流水线任务创建成功", taskId); + } + + @Operation(summary = "获取任务列表", description = "分页查询任务列表,支持多种筛选条件") + @GetMapping + public Result> getTaskList( + @Parameter(description = "任务状态") @RequestParam(required = false) String status, + @Parameter(description = "任务类型ID") @RequestParam(required = false) Long taskTypeId, + @Parameter(description = "页码") @RequestParam(defaultValue = "1") Integer page, + @Parameter(description = "每页数量") @RequestParam(defaultValue = "20") Integer pageSize) { + + log.info("查询任务列表: status={}, taskTypeId={}, page={}, pageSize={}", + status, taskTypeId, page, pageSize); + + IPage taskPage = taskService.getTaskList(page, pageSize, status, taskTypeId); + return Result.success(taskPage); + } + + @Operation(summary = "获取所有待审核任务", description = "获取所有处于审批流程中的任务(包括需求部门审核和接单部门审核)") + @GetMapping("/pending-approval-all") + public Result> getAllPendingApprovalTasks( + @Parameter(description = "任务类型ID") @RequestParam(required = false) Long taskTypeId, + @Parameter(description = "页码") @RequestParam(defaultValue = "1") Integer page, + @Parameter(description = "每页数量") @RequestParam(defaultValue = "20") Integer pageSize) { + + log.info("查询所有待审核任务: taskTypeId={}, page={}, pageSize={}", + taskTypeId, page, pageSize); + + IPage taskPage = taskService.getAllPendingApprovalTasks(page, pageSize, taskTypeId); + return Result.success(taskPage); + } + + @Operation(summary = "获取任务详情", description = "根据任务ID获取详细信息") + @GetMapping("/{id}") + public Result getTaskById(@PathVariable Long id) { + log.info("查询任务详情: {}", id); + Task task = taskService.getTaskById(id); + return Result.success(task); + } + + @Operation(summary = "更新任务", description = "更新任务信息") + @PutMapping("/{id}") + public Result updateTask(@PathVariable Long id, @RequestBody Task task) { + log.info("更新任务: {}", id); + taskService.updateTask(id, task); + return Result.success("更新成功"); + } + + @Operation(summary = "抢单", description = "设计师抢单") + @PostMapping("/{id}/grab") + public Result grabTask(@PathVariable Long id, @RequestHeader("Authorization") String authHeader) { + log.info("抢单: {}", id); + String token = jwtUtil.extractTokenFromHeader(authHeader); + Long userId = jwtUtil.getUserIdFromToken(token); + taskService.grabTask(id, userId); + return Result.success("抢单成功"); + } + + @Operation(summary = "提交任务", description = "设计师提交完成的任务") + @PostMapping("/{id}/submit") + public Result submitTask( + @PathVariable Long id, + @RequestParam String deliveryPath, + @RequestHeader("Authorization") String authHeader) { + + log.info("提交任务: {}, 交付路径: {}", id, deliveryPath); + String token = jwtUtil.extractTokenFromHeader(authHeader); + Long userId = jwtUtil.getUserIdFromToken(token); + taskService.submitTask(id, userId, deliveryPath); + return Result.success("提交成功"); + } + + @Operation(summary = "审核任务", description = "需求方审核任务") + @PostMapping("/{id}/review") + public Result reviewTask( + @PathVariable Long id, + @RequestParam Boolean approved, + @RequestParam(required = false) String comment, + @RequestParam(required = false) Integer rating, + @RequestHeader("Authorization") String authHeader) { + + log.info("审核任务: {}, 通过: {}, 评分: {}", id, approved, rating); + String token = jwtUtil.extractTokenFromHeader(authHeader); + Long userId = jwtUtil.getUserIdFromToken(token); + taskService.reviewTask(id, userId, approved, comment, rating); + return Result.success(approved ? "审核通过" : "已驳回"); + } + + @Operation(summary = "取消任务", description = "发布人取消任务") + @PostMapping("/{id}/cancel") + public Result cancelTask( + @PathVariable Long id, + @RequestParam String reason, + @RequestHeader("Authorization") String authHeader) { + + log.info("取消任务: {}, 原因: {}", id, reason); + String token = jwtUtil.extractTokenFromHeader(authHeader); + Long userId = jwtUtil.getUserIdFromToken(token); + taskService.cancelTask(id, userId, reason); + return Result.success("取消成功"); + } + + @Operation(summary = "获取已完成任务列表", description = "根据用户角色返回不同范围的已完成任务") + @GetMapping("/completed") + public Result> getCompletedTasks( + @Parameter(description = "页码") @RequestParam(defaultValue = "1") Integer page, + @Parameter(description = "每页数量") @RequestParam(defaultValue = "12") Integer pageSize, + @Parameter(description = "关键词") @RequestParam(required = false) String keyword, + @Parameter(description = "排序方式") @RequestParam(required = false) String sortBy, + @RequestHeader("Authorization") String authHeader) { + + log.info("查询已完成任务: page={}, pageSize={}, keyword={}, sortBy={}", + page, pageSize, keyword, sortBy); + + String token = jwtUtil.extractTokenFromHeader(authHeader); + Long userId = jwtUtil.getUserIdFromToken(token); + + IPage taskPage = taskService.getCompletedTasks(page, pageSize, userId, keyword, sortBy); + return Result.success(taskPage); + } + + @Operation(summary = "任务大厅", description = "获取可接单的任务列表,支持多种筛选、搜索和排序") + @GetMapping("/hall") + public Result> getTaskHall( + @Parameter(description = "任务类型ID") @RequestParam(required = false) Long taskTypeId, + @Parameter(description = "需求类型ID") @RequestParam(required = false) Long demandTypeId, + @Parameter(description = "品牌ID") @RequestParam(required = false) Long brandId, + @Parameter(description = "类目ID") @RequestParam(required = false) Long categoryId, + @Parameter(description = "平台ID") @RequestParam(required = false) Long platformId, + @Parameter(description = "任务子类型") @RequestParam(required = false) String taskSubType, + @Parameter(description = "最小积分") @RequestParam(required = false) Integer minScore, + @Parameter(description = "最大积分") @RequestParam(required = false) Integer maxScore, + @Parameter(description = "关键词搜索") @RequestParam(required = false) String keyword, + @Parameter(description = "发布人姓名") @RequestParam(required = false) String publisherName, + @Parameter(description = "发布时间范围:today/week/month") @RequestParam(required = false) String timeRange, + @Parameter(description = "排序方式:latest/score/deadline/hot") @RequestParam(required = false) String sortBy, + @Parameter(description = "页码") @RequestParam(defaultValue = "1") Integer page, + @Parameter(description = "每页数量") @RequestParam(defaultValue = "20") Integer pageSize) { + + log.info("查询任务大厅: taskTypeId={}, demandTypeId={}, brandId={}, categoryId={}, platformId={}, " + + "taskSubType={}, minScore={}, maxScore={}, keyword={}, publisherName={}, timeRange={}, " + + "sortBy={}, page={}, pageSize={}", + taskTypeId, demandTypeId, brandId, categoryId, platformId, taskSubType, + minScore, maxScore, keyword, publisherName, timeRange, sortBy, page, pageSize); + + IPage taskPage = taskService.getTaskHall(page, pageSize, taskTypeId, demandTypeId, + brandId, categoryId, platformId, taskSubType, minScore, maxScore, keyword, + publisherName, timeRange, sortBy); + return Result.success(taskPage); + } + + @Operation(summary = "我的任务", description = "获取当前用户的任务列表") + @GetMapping("/my") + public Result> getMyTasks( + @RequestParam(required = false) String role, // publisher 或 receiver + @RequestParam(required = false) String status, + @RequestParam(defaultValue = "1") Integer page, + @RequestParam(defaultValue = "20") Integer pageSize, + @RequestHeader("Authorization") String authHeader) { + + log.info("查询我的任务: role={}, status={}, page={}, pageSize={}", + role, status, page, pageSize); + + String token = jwtUtil.extractTokenFromHeader(authHeader); + Long userId = jwtUtil.getUserIdFromToken(token); + + IPage taskPage; + if ("publisher".equals(role)) { + taskPage = taskService.getMyPublishedTasks(page, pageSize, userId, status); + } else { + taskPage = taskService.getMyReceivedTasks(page, pageSize, userId, status); + } + + return Result.success(taskPage); + } + + @Operation(summary = "提交任务审核", description = "任务发起人提交任务给部门负责人审核") + @PostMapping("/{id}/submit-audit") + public Result submitTaskForAudit( + @PathVariable Long id, + @RequestHeader("Authorization") String authHeader) { + log.info("提交任务审核: taskId={}", id); + String token = jwtUtil.extractTokenFromHeader(authHeader); + Long userId = jwtUtil.getUserIdFromToken(token); + return Result.success(taskService.submitTaskForAudit(id, userId)); + } + + @Operation(summary = "审核任务", description = "部门负责人审核任务(通过或驳回)") + @PostMapping("/audit") + public Result auditTask( + @Valid @RequestBody TaskAuditRequest request, + @RequestHeader("Authorization") String authHeader) { + log.info("审核任务: request={}", request); + String token = jwtUtil.extractTokenFromHeader(authHeader); + Long auditorId = jwtUtil.getUserIdFromToken(token); + return Result.success(taskService.auditTask(request, auditorId)); + } + + @Operation(summary = "强制指派任务", description = "部门负责人强制指派任务给部门成员") + @PostMapping("/assign") + public Result assignTask( + @Valid @RequestBody TaskAssignRequest request, + @RequestHeader("Authorization") String authHeader) { + log.info("强制指派任务: request={}", request); + String token = jwtUtil.extractTokenFromHeader(authHeader); + Long assignerId = jwtUtil.getUserIdFromToken(token); + return Result.success(taskService.assignTask(request, assignerId)); + } + + @Operation(summary = "负责人任务列表", description = "需求部门负责人查看部门内所有任务") + @GetMapping("/leader/demand") + public Result> getLeaderTaskList( + @RequestParam(required = false) String auditStatus, + @RequestParam(defaultValue = "1") Integer page, + @RequestParam(defaultValue = "20") Integer pageSize, + @RequestHeader("Authorization") String authHeader) { + log.info("查询负责人任务列表: auditStatus={}, page={}, pageSize={}", + auditStatus, page, pageSize); + String token = jwtUtil.extractTokenFromHeader(authHeader); + Long userId = jwtUtil.getUserIdFromToken(token); + return Result.success(taskService.getLeaderTaskList(userId, page, pageSize, auditStatus)); + } + + @Operation(summary = "接单部门负责人视图", description = "接单部门负责人查看部门成员接单明细") + @GetMapping("/leader/receiving") + public Result> getLeaderReceivingTaskList( + @RequestParam(required = false) String status, + @RequestParam(defaultValue = "1") Integer page, + @RequestParam(defaultValue = "20") Integer pageSize, + @RequestHeader("Authorization") String authHeader) { + log.info("查询接单部门负责人视图: status={}, page={}, pageSize={}", + status, page, pageSize); + String token = jwtUtil.extractTokenFromHeader(authHeader); + Long userId = jwtUtil.getUserIdFromToken(token); + return Result.success(taskService.getLeaderReceivingTaskList(userId, page, pageSize, status)); + } + + @Operation(summary = "获取工作台统计数据") + @GetMapping("/dashboard/stats") + public Result getDashboardStats( + @Parameter(description = "月份,格式:YYYY-MM,不传则查询当月") @RequestParam(required = false) String month, + @RequestHeader("Authorization") String authHeader) { + String token = jwtUtil.extractTokenFromHeader(authHeader); + Long userId = jwtUtil.getUserIdFromToken(token); + log.info("获取工作台统计数据: userId={}, month={}", userId, month); + return Result.success(taskService.getDashboardStats(userId, month)); + } + + @Operation(summary = "获取我的进行中任务") + @GetMapping("/my-in-progress") + public Result> getMyInProgressTasks(@RequestHeader("Authorization") String authHeader) { + String token = jwtUtil.extractTokenFromHeader(authHeader); + Long userId = jwtUtil.getUserIdFromToken(token); + return Result.success(taskService.getMyInProgressTasks(userId)); + } + + @Operation(summary = "批量更新任务进度") + @PostMapping("/batch-update-progress") + public Result batchUpdateProgress( + @RequestBody java.util.Map payload, + @RequestHeader("Authorization") String authHeader) { + + String token = jwtUtil.extractTokenFromHeader(authHeader); + Long userId = jwtUtil.getUserIdFromToken(token); + + @SuppressWarnings("unchecked") + java.util.List> updates = + (java.util.List>) payload.get("updates"); + + taskService.batchUpdateProgress(userId, updates); + return Result.success("进度更新成功"); + } + + @Operation(summary = "更新任务进度", description = "接单人更新任务进度") + @PostMapping("/{id}/progress") + public Result updateTaskProgress( + @PathVariable Long id, + @RequestBody java.util.Map payload, + @RequestHeader("Authorization") String authHeader) { + + log.info("更新任务进度: taskId={}", id); + String token = jwtUtil.extractTokenFromHeader(authHeader); + Long userId = jwtUtil.getUserIdFromToken(token); + + Integer progress = (Integer) payload.get("progress"); + if (progress == null) { + return Result.error("进度值不能为空"); + } + + taskService.updateTaskProgress(id, userId, progress); + return Result.success("进度更新成功"); + } + + @Operation(summary = "完结任务", description = "接单人完结任务并提交交付路径") + @PostMapping("/{id}/complete") + public Result completeTask( + @PathVariable Long id, + @RequestBody java.util.Map payload, + @RequestHeader("Authorization") String authHeader) { + + log.info("完结任务: taskId={}", id); + String token = jwtUtil.extractTokenFromHeader(authHeader); + Long userId = jwtUtil.getUserIdFromToken(token); + + String deliveryPath = payload.get("deliveryPath"); + if (deliveryPath == null || deliveryPath.trim().isEmpty()) { + return Result.error("交付路径不能为空"); + } + + taskService.completeTask(id, userId, deliveryPath); + return Result.success("任务已提交审核"); + } + + @Operation(summary = "审核通过任务", description = "发起人或部门负责人审核通过任务") + @PostMapping("/{id}/approve") + public Result approveTask( + @PathVariable Long id, + @RequestHeader("Authorization") String authHeader) { + + log.info("审核通过任务: taskId={}", id); + String token = jwtUtil.extractTokenFromHeader(authHeader); + Long userId = jwtUtil.getUserIdFromToken(token); + + taskService.approveTask(id, userId); + return Result.success("审核通过"); + } + + @Operation(summary = "驳回任务", description = "发起人或部门负责人驳回任务") + @PostMapping("/{id}/reject") + public Result rejectTask( + @PathVariable Long id, + @RequestBody java.util.Map payload, + @RequestHeader("Authorization") String authHeader) { + + log.info("驳回任务: taskId={}", id); + String token = jwtUtil.extractTokenFromHeader(authHeader); + Long userId = jwtUtil.getUserIdFromToken(token); + + String reason = payload.get("reason"); + if (reason == null || reason.trim().isEmpty()) { + return Result.error("驳回原因不能为空"); + } + + taskService.rejectTask(id, userId, reason); + return Result.success("任务已驳回"); + } +} + diff --git a/backend/src/main/java/com/designhub/controller/TaskItemController.java b/backend/src/main/java/com/designhub/controller/TaskItemController.java new file mode 100644 index 0000000..9a28174 --- /dev/null +++ b/backend/src/main/java/com/designhub/controller/TaskItemController.java @@ -0,0 +1,174 @@ +package com.designhub.controller; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.designhub.common.Result; +import com.designhub.entity.TaskItem; +import com.designhub.service.TaskItemService; +import com.designhub.util.JwtUtil; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +/** + * 任务子项控制器(流水线任务) + */ +@Tag(name = "流水线任务管理", description = "流水线任务子项相关接口") +@RestController +@RequestMapping("/api/tasks/items") +@RequiredArgsConstructor +public class TaskItemController { + + private final TaskItemService taskItemService; + private final JwtUtil jwtUtil; + + /** + * 获取可接单的子任务列表 + */ + @Operation(summary = "获取可接单的子任务列表") + @GetMapping("/available") + public Result> getAvailableItems( + @Parameter(description = "页码") @RequestParam(defaultValue = "1") Integer page, + @Parameter(description = "每页数量") @RequestParam(defaultValue = "10") Integer pageSize, + @Parameter(description = "任务类型ID") @RequestParam(required = false) Long taskTypeId, + @Parameter(description = "搜索关键词") @RequestParam(required = false) String keyword) { + + IPage items = taskItemService.getAvailableItems(page, pageSize, taskTypeId, keyword); + return Result.success(items); + } + + /** + * 获取进行中的子任务列表 + */ + @Operation(summary = "获取进行中的子任务列表") + @GetMapping("/in-progress") + public Result> getInProgressItems( + @Parameter(description = "页码") @RequestParam(defaultValue = "1") Integer page, + @Parameter(description = "每页数量") @RequestParam(defaultValue = "10") Integer pageSize, + @RequestHeader("Authorization") String token) { + + String actualToken = token.replace("Bearer ", ""); + Long userId = jwtUtil.getUserIdFromToken(actualToken); + IPage items = taskItemService.getInProgressItems(page, pageSize, userId); + return Result.success(items); + } + + /** + * 获取已完成的子任务列表 + */ + @Operation(summary = "获取已完成的子任务列表") + @GetMapping("/completed") + public Result> getCompletedItems( + @Parameter(description = "页码") @RequestParam(defaultValue = "1") Integer page, + @Parameter(description = "每页数量") @RequestParam(defaultValue = "10") Integer pageSize, + @RequestHeader("Authorization") String token) { + + String actualToken = token.replace("Bearer ", ""); + Long userId = jwtUtil.getUserIdFromToken(actualToken); + IPage items = taskItemService.getCompletedItems(page, pageSize, userId); + return Result.success(items); + } + + /** + * 根据主任务ID获取所有子任务 + */ + @Operation(summary = "根据主任务ID获取所有子任务") + @GetMapping("/task/{taskId}") + public Result> getItemsByTaskId( + @Parameter(description = "主任务ID") @PathVariable Long taskId) { + + List items = taskItemService.getItemsByTaskId(taskId); + return Result.success(items); + } + + /** + * 获取子任务详情 + */ + @Operation(summary = "获取子任务详情") + @GetMapping("/{itemId}") + public Result getItemById( + @Parameter(description = "子任务ID") @PathVariable Long itemId) { + + TaskItem item = taskItemService.getItemById(itemId); + return Result.success(item); + } + + /** + * 抢单子任务 + */ + @Operation(summary = "抢单子任务") + @PostMapping("/{itemId}/grab") + public Result grabItem( + @Parameter(description = "子任务ID") @PathVariable Long itemId, + @RequestHeader("Authorization") String token) { + + String actualToken = token.replace("Bearer ", ""); + Long userId = jwtUtil.getUserIdFromToken(actualToken); + taskItemService.grabItem(itemId, userId); + return Result.success("抢单成功"); + } + + /** + * 更新子任务进度 + */ + @Operation(summary = "更新子任务进度") + @PostMapping("/{itemId}/progress") + public Result updateProgress( + @Parameter(description = "子任务ID") @PathVariable Long itemId, + @Parameter(description = "进度(0-100)") @RequestParam Integer progress, + @RequestHeader("Authorization") String token) { + + String actualToken = token.replace("Bearer ", ""); + Long userId = jwtUtil.getUserIdFromToken(actualToken); + taskItemService.updateProgress(itemId, userId, progress); + return Result.success("进度更新成功"); + } + + /** + * 完结子任务 + */ + @Operation(summary = "完结子任务") + @PostMapping("/{itemId}/complete") + public Result> completeItem( + @Parameter(description = "子任务ID") @PathVariable Long itemId, + @Parameter(description = "交付路径") @RequestBody Map request, + @RequestHeader("Authorization") String token) { + + String actualToken = token.replace("Bearer ", ""); + Long userId = jwtUtil.getUserIdFromToken(actualToken); + String deliveryPath = request.get("deliveryPath"); + + Map result = taskItemService.completeItem(itemId, userId, deliveryPath); + return Result.success("子任务完成成功", result); + } + + /** + * 驳回子任务 + */ + @Operation(summary = "驳回子任务") + @PostMapping("/{itemId}/reject") + public Result rejectItem( + @Parameter(description = "子任务ID") @PathVariable Long itemId, + @Parameter(description = "驳回原因") @RequestBody Map request) { + + String reason = request.get("reason"); + taskItemService.rejectItem(itemId, reason); + return Result.success("子任务已驳回"); + } + + /** + * 获取主任务进度统计 + */ + @Operation(summary = "获取主任务进度统计") + @GetMapping("/task/{taskId}/progress") + public Result> getTaskProgress( + @Parameter(description = "主任务ID") @PathVariable Long taskId) { + + Map progress = taskItemService.getTaskProgress(taskId); + return Result.success(progress); + } +} diff --git a/backend/src/main/java/com/designhub/controller/TaskSubTypeController.java b/backend/src/main/java/com/designhub/controller/TaskSubTypeController.java new file mode 100644 index 0000000..b03bef0 --- /dev/null +++ b/backend/src/main/java/com/designhub/controller/TaskSubTypeController.java @@ -0,0 +1,81 @@ +package com.designhub.controller; + +import com.designhub.common.Result; +import com.designhub.entity.TaskSubType; +import com.designhub.service.TaskSubTypeService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 任务子类型控制器 + */ +@Tag(name = "任务子类型管理") +@RestController +@RequestMapping("/api/task-sub-types") +@RequiredArgsConstructor +public class TaskSubTypeController { + + private final TaskSubTypeService taskSubTypeService; + + @Operation(summary = "获取所有子类型") + @GetMapping + public Result> getAllSubTypes() { + return Result.success(taskSubTypeService.getAllSubTypes()); + } + + @Operation(summary = "获取启用的子类型") + @GetMapping("/enabled") + public Result> getEnabledSubTypes() { + return Result.success(taskSubTypeService.getEnabledSubTypes()); + } + + @Operation(summary = "根据ID获取子类型") + @GetMapping("/{id}") + public Result getById(@PathVariable Long id) { + return Result.success(taskSubTypeService.getById(id)); + } + + @Operation(summary = "创建子类型") + @PostMapping + public Result createSubType(@RequestBody TaskSubType subType) { + return Result.success(taskSubTypeService.createSubType(subType)); + } + + @Operation(summary = "更新子类型") + @PutMapping("/{id}") + public Result updateSubType( + @PathVariable Long id, + @RequestBody TaskSubType subType + ) { + return Result.success(taskSubTypeService.updateSubType(id, subType)); + } + + @Operation(summary = "删除子类型") + @DeleteMapping("/{id}") + public Result deleteSubType(@PathVariable Long id) { + taskSubTypeService.deleteSubType(id); + return Result.success(); + } + + @Operation(summary = "切换启用状态") + @PostMapping("/{id}/toggle-enabled") + public Result toggleEnabled(@PathVariable Long id) { + taskSubTypeService.toggleEnabled(id); + return Result.success(); + } + + @Operation(summary = "更新排序") + @PostMapping("/{id}/sort") + public Result updateSortOrder( + @PathVariable Long id, + @Parameter(description = "排序值") @RequestParam Integer sortOrder + ) { + taskSubTypeService.updateSortOrder(id, sortOrder); + return Result.success(); + } +} diff --git a/backend/src/main/java/com/designhub/controller/TeamPKController.java b/backend/src/main/java/com/designhub/controller/TeamPKController.java new file mode 100644 index 0000000..0b8c061 --- /dev/null +++ b/backend/src/main/java/com/designhub/controller/TeamPKController.java @@ -0,0 +1,85 @@ +package com.designhub.controller; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.designhub.common.Result; +import com.designhub.entity.TeamPK; +import com.designhub.service.TeamPKService; +import com.designhub.util.JwtUtil; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +/** + * 团队PK Controller + */ +@Tag(name = "团队PK", description = "团队PK管理相关API") +@RestController +@RequestMapping("/api/team-pk") +@RequiredArgsConstructor +public class TeamPKController { + + private final TeamPKService teamPKService; + private final JwtUtil jwtUtil; + + @Operation(summary = "创建团队PK") + @PostMapping + public Result createTeamPK( + @RequestBody TeamPK teamPK, + @RequestHeader("Authorization") String authHeader) { + String token = jwtUtil.extractTokenFromHeader(authHeader); + Long userId = jwtUtil.getUserIdFromToken(token); + teamPK.setCreatorId(userId); + return Result.success(teamPKService.createTeamPK(teamPK)); + } + + @Operation(summary = "发布团队PK") + @PostMapping("/{id}/publish") + public Result publishTeamPK(@PathVariable Long id) { + return Result.success(teamPKService.publishTeamPK(id)); + } + + @Operation(summary = "结束团队PK") + @PostMapping("/{id}/end") + public Result endTeamPK(@PathVariable Long id) { + teamPKService.endTeamPK(id); + return Result.success(); + } + + @Operation(summary = "取消团队PK") + @PostMapping("/{id}/cancel") + public Result cancelTeamPK(@PathVariable Long id) { + teamPKService.cancelTeamPK(id); + return Result.success(); + } + + @Operation(summary = "删除团队PK") + @DeleteMapping("/{id}") + public Result deleteTeamPK(@PathVariable Long id) { + teamPKService.deleteTeamPK(id); + return Result.success(); + } + + @Operation(summary = "获取团队PK列表") + @GetMapping + public Result> getPKList( + @RequestParam(defaultValue = "1") Integer page, + @RequestParam(defaultValue = "10") Integer pageSize, + @RequestParam(required = false) String status) { + return Result.success(teamPKService.getPKList(page, pageSize, status)); + } + + @Operation(summary = "获取团队PK详情") + @GetMapping("/{id}") + public Result getPKById(@PathVariable Long id) { + return Result.success(teamPKService.getPKById(id)); + } + + @Operation(summary = "刷新PK数据") + @PostMapping("/{id}/refresh") + public Result refreshPKStats(@PathVariable Long id) { + TeamPK pk = teamPKService.getPKById(id); + teamPKService.refreshPKStats(pk); + return Result.success(teamPKService.getPKById(id)); + } +} diff --git a/backend/src/main/java/com/designhub/controller/ThirdPartyCallbackController.java b/backend/src/main/java/com/designhub/controller/ThirdPartyCallbackController.java new file mode 100644 index 0000000..821cab6 --- /dev/null +++ b/backend/src/main/java/com/designhub/controller/ThirdPartyCallbackController.java @@ -0,0 +1,72 @@ +package com.designhub.controller; + +import com.designhub.common.Result; +import com.designhub.service.ThirdPartyCallbackService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +/** + * 第三方平台回调控制器 + */ +@Slf4j +@Tag(name = "第三方回调", description = "接收第三方平台的任务完成回调") +@RestController +@RequestMapping("/api/third-party") +@RequiredArgsConstructor +public class ThirdPartyCallbackController { + + private final ThirdPartyCallbackService callbackService; + + @Operation(summary = "任务完成回调", description = "第三方平台通知任务已完成") + @PostMapping("/task-complete") + public Result taskComplete(@RequestBody Map payload) { + log.info("收到第三方任务完成回调: {}", payload); + + try { + String thirdPartyTaskNo = (String) payload.get("thirdPartyTaskNo"); + if (thirdPartyTaskNo == null || thirdPartyTaskNo.isEmpty()) { + return Result.fail("缺少必需参数: thirdPartyTaskNo"); + } + + callbackService.handleTaskCompleteCallback(thirdPartyTaskNo, payload); + return Result.success("回调处理成功"); + } catch (Exception e) { + log.error("处理任务完成回调失败", e); + return Result.fail("处理失败: " + e.getMessage()); + } + } + + @Operation(summary = "任务状态更新回调", description = "第三方平台通知任务状态变更") + @PostMapping("/task-status-update") + public Result taskStatusUpdate(@RequestBody Map payload) { + log.info("收到第三方任务状态更新回调: {}", payload); + + try { + String thirdPartyTaskNo = (String) payload.get("thirdPartyTaskNo"); + String status = (String) payload.get("status"); + + if (thirdPartyTaskNo == null || thirdPartyTaskNo.isEmpty()) { + return Result.fail("缺少必需参数: thirdPartyTaskNo"); + } + + callbackService.handleTaskStatusUpdateCallback(thirdPartyTaskNo, status, payload); + return Result.success("回调处理成功"); + } catch (Exception e) { + log.error("处理任务状态更新回调失败", e); + return Result.fail("处理失败: " + e.getMessage()); + } + } + + @Operation(summary = "查询回调记录", description = "根据第三方任务编号查询回调记录") + @GetMapping("/callback-records/{thirdPartyTaskNo}") + public Result getCallbackRecords( + @Parameter(description = "第三方任务编号") @PathVariable String thirdPartyTaskNo) { + return Result.success(callbackService.getCallbackRecords(thirdPartyTaskNo)); + } +} diff --git a/backend/src/main/java/com/designhub/controller/UploadController.java b/backend/src/main/java/com/designhub/controller/UploadController.java new file mode 100644 index 0000000..172d7a8 --- /dev/null +++ b/backend/src/main/java/com/designhub/controller/UploadController.java @@ -0,0 +1,171 @@ +package com.designhub.controller; + +import com.designhub.common.Result; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import jakarta.annotation.PostConstruct; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; + +/** + * 文件上传Controller + */ +@Slf4j +@RestController +@RequestMapping("/api/upload") +@Tag(name = "文件上传", description = "文件上传相关API") +public class UploadController { + + @Value("${file.upload.path:./uploads}") + private String uploadPath; + + @Value("${file.upload.max-size:52428800}") + private long maxSize; + + @Value("${file.upload.allowed-extensions:.xls,.xlsx,.doc,.docx,.pdf,.zip}") + private String allowedExtensions; + + private static final Set ALLOWED_EXTENSIONS_SET = new HashSet<>(); + + @PostConstruct + public void init() { + String[] extensions = allowedExtensions.split(","); + for (String ext : extensions) { + ALLOWED_EXTENSIONS_SET.add(ext.trim().toLowerCase()); + } + + // 确保上传目录存在 + try { + Path path = Paths.get(uploadPath); + if (!Files.exists(path)) { + Files.createDirectories(path); + log.info("创建上传目录: {}", uploadPath); + } + } catch (IOException e) { + log.error("创建上传目录失败: {}", e.getMessage()); + } + } + + /** + * 上传文件 + */ + @PostMapping("/file") + @Operation(summary = "上传文件", description = "支持Excel、Word、PDF、ZIP等格式") + public Result> uploadFile(@RequestParam("file") MultipartFile file) { + try { + // 验证文件 + if (file == null || file.isEmpty()) { + return Result.error(400, "文件不能为空"); + } + + // 验证文件大小 + if (file.getSize() > maxSize) { + return Result.error(400, String.format("文件大小不能超过 %d MB", maxSize / 1024 / 1024)); + } + + // 验证文件扩展名 + String originalFilename = file.getOriginalFilename(); + if (originalFilename == null || originalFilename.isEmpty()) { + return Result.error(400, "文件名不能为空"); + } + + String extension = getFileExtension(originalFilename); + if (!ALLOWED_EXTENSIONS_SET.contains(extension.toLowerCase())) { + return Result.error(400, String.format("不支持的文件格式,支持的格式:%s", allowedExtensions)); + } + + // 生成文件存储路径 + String dateDir = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd")); + String fileName = generateFileName(originalFilename); + String relativePath = dateDir + "/" + fileName; + String fullPath = uploadPath + "/" + relativePath; + + // 确保目录存在 + Path targetPath = Paths.get(fullPath); + Files.createDirectories(targetPath.getParent()); + + // 保存文件 + file.transferTo(targetPath.toFile()); + + log.info("文件上传成功: originalName={}, savedPath={}, size={}", + originalFilename, fullPath, file.getSize()); + + // 构建返回数据 + Map data = new HashMap<>(); + data.put("fileName", originalFilename); + data.put("filePath", relativePath); + data.put("fileUrl", "/api/upload/download?path=" + relativePath); + data.put("fileSize", file.getSize()); + data.put("fileType", extension); + + return Result.success(data); + } catch (IOException e) { + log.error("文件上传失败: {}", e.getMessage(), e); + return Result.error(500, "文件上传失败: " + e.getMessage()); + } catch (Exception e) { + log.error("文件上传异常: {}", e.getMessage(), e); + return Result.error(500, "文件上传异常: " + e.getMessage()); + } + } + + /** + * 下载文件 + */ + @GetMapping("/download") + @Operation(summary = "下载文件", description = "根据文件路径下载文件") + public org.springframework.http.ResponseEntity downloadFile( + @RequestParam("path") String filePath) { + try { + String fullPath = uploadPath + "/" + filePath; + Path path = Paths.get(fullPath); + + if (!Files.exists(path)) { + return org.springframework.http.ResponseEntity.notFound().build(); + } + + org.springframework.core.io.Resource resource = + new org.springframework.core.io.UrlResource(path.toUri()); + + return org.springframework.http.ResponseEntity.ok() + .header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename=\"" + resource.getFilename() + "\"") + .body(resource); + } catch (Exception e) { + log.error("文件下载失败: {}", e.getMessage(), e); + return org.springframework.http.ResponseEntity.internalServerError().build(); + } + } + + /** + * 获取文件扩展名 + */ + private String getFileExtension(String filename) { + int lastDotIndex = filename.lastIndexOf('.'); + if (lastDotIndex > 0 && lastDotIndex < filename.length() - 1) { + return filename.substring(lastDotIndex); + } + return ""; + } + + /** + * 生成唯一文件名 + */ + private String generateFileName(String originalFilename) { + String extension = getFileExtension(originalFilename); + String nameWithoutExt = originalFilename.substring(0, originalFilename.lastIndexOf('.')); + String timestamp = String.valueOf(System.currentTimeMillis()); + String random = UUID.randomUUID().toString().substring(0, 8); + return nameWithoutExt + "_" + timestamp + "_" + random + extension; + } +} + diff --git a/backend/src/main/java/com/designhub/controller/UserController.java b/backend/src/main/java/com/designhub/controller/UserController.java new file mode 100644 index 0000000..1440f72 --- /dev/null +++ b/backend/src/main/java/com/designhub/controller/UserController.java @@ -0,0 +1,129 @@ +package com.designhub.controller; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.designhub.common.Result; +import com.designhub.entity.User; +import com.designhub.service.UserService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +/** + * 用户管理Controller + */ +@Tag(name = "用户管理", description = "用户管理相关API") +@RestController +@RequestMapping("/api/users") +@RequiredArgsConstructor +@Slf4j +public class UserController { + + private final UserService userService; + + @Operation(summary = "分页查询用户列表") + @GetMapping + public Result> getUserList( + @RequestParam(defaultValue = "1") int pageNum, + @RequestParam(defaultValue = "10") int pageSize, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) Long deptId, + @RequestParam(required = false) Integer status + ) { + return Result.success(userService.getUserList(pageNum, pageSize, keyword, deptId, status)); + } + + @Operation(summary = "获取全部用户列表") + @GetMapping("/all") + public Result> getAllUsers() { + return Result.success(userService.getAllUsers()); + } + + @Operation(summary = "根据ID获取用户") + @GetMapping("/{id}") + public Result getUserById(@PathVariable Long id) { + return Result.success(userService.getUserById(id)); + } + + @Operation(summary = "创建用户") + @PostMapping + public Result createUser(@RequestBody User user) { + return Result.success(userService.createUser(user)); + } + + @Operation(summary = "更新用户") + @PutMapping("/{id}") + public Result updateUser(@PathVariable Long id, @RequestBody User user) { + user.setId(id); + return Result.success(userService.updateUser(user)); + } + + @Operation(summary = "删除用户") + @DeleteMapping("/{id}") + public Result deleteUser(@PathVariable Long id) { + return Result.success(userService.deleteUser(id)); + } + + @Operation(summary = "重置密码") + @PostMapping("/{id}/reset-password") + public Result resetPassword( + @PathVariable Long id, + @RequestBody Map params + ) { + String newPassword = params.getOrDefault("newPassword", "123456"); + return Result.success(userService.resetPassword(id, newPassword)); + } + + @Operation(summary = "修改密码") + @PostMapping("/{id}/change-password") + public Result changePassword( + @PathVariable Long id, + @RequestBody Map params + ) { + String oldPassword = params.get("oldPassword"); + String newPassword = params.get("newPassword"); + return Result.success(userService.changePassword(id, oldPassword, newPassword)); + } + + @Operation(summary = "为用户分配角色") + @PostMapping("/{id}/roles") + public Result assignRoles( + @PathVariable Long id, + @RequestBody List roleIds + ) { + return Result.success(userService.assignRoles(id, roleIds)); + } + + @Operation(summary = "获取用户的角色ID列表") + @GetMapping("/{id}/roles") + public Result> getUserRoleIds(@PathVariable Long id) { + return Result.success(userService.getUserRoleIds(id)); + } + + @Operation(summary = "启用/禁用用户") + @PutMapping("/{id}/status") + public Result toggleUserStatus( + @PathVariable Long id, + @RequestParam Integer status + ) { + return Result.success(userService.toggleUserStatus(id, status)); + } + + @Operation(summary = "调整用户所属部门") + @PutMapping("/{id}/department") + public Result updateUserDepartment( + @PathVariable Long id, + @RequestBody Map params + ) { + Long deptId = params.get("deptId"); + log.info("调整用户部门请求: userId={}, deptId={}, params={}", id, deptId, params); + boolean result = userService.updateUserDepartment(id, deptId); + log.info("调整用户部门结果: userId={}, deptId={}, result={}", id, deptId, result); + return Result.success(result); + } +} + diff --git a/backend/src/main/java/com/designhub/controller/UserGoalController.java b/backend/src/main/java/com/designhub/controller/UserGoalController.java new file mode 100644 index 0000000..fd7a4e0 --- /dev/null +++ b/backend/src/main/java/com/designhub/controller/UserGoalController.java @@ -0,0 +1,117 @@ +package com.designhub.controller; + +import com.designhub.common.Result; +import com.designhub.entity.UserGoal; +import com.designhub.service.UserGoalService; +import com.designhub.util.JwtUtil; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 用户目标Controller + */ +@Tag(name = "目标管理", description = "用户目标管理相关API") +@RestController +@RequestMapping("/api/goals") +@RequiredArgsConstructor +public class UserGoalController { + + private final UserGoalService userGoalService; + private final JwtUtil jwtUtil; + + @Operation(summary = "创建目标") + @PostMapping + public Result createGoal( + @RequestBody UserGoal goal, + @RequestHeader("Authorization") String authHeader) { + String token = jwtUtil.extractTokenFromHeader(authHeader); + Long userId = jwtUtil.getUserIdFromToken(token); + goal.setUserId(userId); + return Result.success(userGoalService.createGoal(goal)); + } + + @Operation(summary = "创建预设目标") + @PostMapping("/preset/{type}") + public Result createPresetGoal( + @PathVariable String type, + @RequestHeader("Authorization") String authHeader) { + String token = jwtUtil.extractTokenFromHeader(authHeader); + Long userId = jwtUtil.getUserIdFromToken(token); + return Result.success(userGoalService.createPresetGoal(userId, type)); + } + + @Operation(summary = "更新目标") + @PutMapping("/{id}") + public Result updateGoal(@PathVariable Long id, @RequestBody UserGoal goal) { + return Result.success(userGoalService.updateGoal(id, goal)); + } + + @Operation(summary = "更新目标进度") + @PutMapping("/{id}/progress") + public Result updateProgress( + @PathVariable Long id, + @RequestParam Integer progressValue) { + return Result.success(userGoalService.updateProgress(id, progressValue)); + } + + @Operation(summary = "获取我的目标列表") + @GetMapping("/my") + public Result> getMyGoals( + @RequestParam(required = false) String status, + @RequestHeader("Authorization") String authHeader) { + String token = jwtUtil.extractTokenFromHeader(authHeader); + Long userId = jwtUtil.getUserIdFromToken(token); + return Result.success(userGoalService.getUserGoals(userId, status)); + } + + @Operation(summary = "获取我的活跃目标") + @GetMapping("/my/active") + public Result> getMyActiveGoals( + @RequestHeader("Authorization") String authHeader) { + String token = jwtUtil.extractTokenFromHeader(authHeader); + Long userId = jwtUtil.getUserIdFromToken(token); + return Result.success(userGoalService.getActiveGoals(userId)); + } + + @Operation(summary = "获取目标详情") + @GetMapping("/{id}") + public Result getGoalById(@PathVariable Long id) { + return Result.success(userGoalService.getGoalById(id)); + } + + @Operation(summary = "领取目标奖励") + @PostMapping("/{id}/claim-reward") + public Result claimReward( + @PathVariable Long id, + @RequestHeader("Authorization") String authHeader) { + String token = jwtUtil.extractTokenFromHeader(authHeader); + Long userId = jwtUtil.getUserIdFromToken(token); + return Result.success(userGoalService.claimReward(id, userId)); + } + + @Operation(summary = "取消目标") + @PostMapping("/{id}/cancel") + public Result cancelGoal( + @PathVariable Long id, + @RequestHeader("Authorization") String authHeader) { + String token = jwtUtil.extractTokenFromHeader(authHeader); + Long userId = jwtUtil.getUserIdFromToken(token); + userGoalService.cancelGoal(id, userId); + return Result.success(); + } + + @Operation(summary = "删除目标") + @DeleteMapping("/{id}") + public Result deleteGoal( + @PathVariable Long id, + @RequestHeader("Authorization") String authHeader) { + String token = jwtUtil.extractTokenFromHeader(authHeader); + Long userId = jwtUtil.getUserIdFromToken(token); + userGoalService.deleteGoal(id, userId); + return Result.success(); + } +} diff --git a/backend/src/main/java/com/designhub/dto/DepartmentCreateRequest.java b/backend/src/main/java/com/designhub/dto/DepartmentCreateRequest.java new file mode 100644 index 0000000..3227e93 --- /dev/null +++ b/backend/src/main/java/com/designhub/dto/DepartmentCreateRequest.java @@ -0,0 +1,86 @@ +package com.designhub.dto; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.List; + +/** + * 部门创建请求 + */ +@Data +public class DepartmentCreateRequest { + + @NotBlank(message = "部门名称不能为空") + private String deptName; + + /** + * 部门编码,可选 + */ + private String deptCode; + + @NotBlank(message = "部门类型不能为空") + private String deptType; // demand / receiving + + private Long parentId; + + /** + * 部门负责人 + */ + private Long leaderId; + + /** + * 部门描述 + */ + private String description; + + /** + * 排序 + */ + @Min(value = 0, message = "排序必须大于等于0") + private Integer sortOrder = 0; + + /** + * 状态:0-禁用 1-启用 + */ + private Integer status = 1; + + /** + * 接单部门允许的需求类型 + */ + private List allowedDemandTypes; + + /** + * 是否启用积分限制(仅需求部门) + */ + private Integer limitEnabled; + + /** + * 限制周期:DAILY/WEEKLY/MONTHLY + */ + private String limitPeriod; + + /** + * 周期限额 + */ + private Integer periodLimit; + + /** + * 预警阈值(百分比) + */ + private Integer alertThreshold; + + /** + * 超限动作:alert/block + */ + private String overLimitAction; + + /** + * 初始积分余额(仅需求部门) + */ + @Min(value = 0, message = "初始积分不能为负数") + private Integer initialScoreBalance; +} + diff --git a/backend/src/main/java/com/designhub/dto/DepartmentLimitSettingsRequest.java b/backend/src/main/java/com/designhub/dto/DepartmentLimitSettingsRequest.java new file mode 100644 index 0000000..5962017 --- /dev/null +++ b/backend/src/main/java/com/designhub/dto/DepartmentLimitSettingsRequest.java @@ -0,0 +1,37 @@ +package com.designhub.dto; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * 部门积分限制配置请求 + */ +@Data +public class DepartmentLimitSettingsRequest { + + @NotNull(message = "是否启用限制不能为空") + private Integer limitEnabled; + + /** + * 限制周期:DAILY/WEEKLY/MONTHLY + */ + private String limitPeriod; + + /** + * 周期限额 + */ + @Min(value = 0, message = "限制额度不能为负数") + private Integer periodLimit; + + /** + * 预警阈值 + */ + private Integer alertThreshold; + + /** + * 超限动作 + */ + private String overLimitAction; +} + diff --git a/backend/src/main/java/com/designhub/dto/DepartmentScoreAdjustRequest.java b/backend/src/main/java/com/designhub/dto/DepartmentScoreAdjustRequest.java new file mode 100644 index 0000000..fee6cb1 --- /dev/null +++ b/backend/src/main/java/com/designhub/dto/DepartmentScoreAdjustRequest.java @@ -0,0 +1,21 @@ +package com.designhub.dto; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; + +public class DepartmentScoreAdjustRequest { + + @NotBlank(message = "操作类型不能为空") + private String action; + @Min(value = 1, message = "调整积分必须大于0") + private Integer amount; + private String remark; + + public String getAction() { return action; } + public void setAction(String action) { this.action = action; } + public Integer getAmount() { return amount; } + public void setAmount(Integer amount) { this.amount = amount; } + public String getRemark() { return remark; } + public void setRemark(String remark) { this.remark = remark; } +} + diff --git a/backend/src/main/java/com/designhub/dto/DepartmentUpdateRequest.java b/backend/src/main/java/com/designhub/dto/DepartmentUpdateRequest.java new file mode 100644 index 0000000..2d413dd --- /dev/null +++ b/backend/src/main/java/com/designhub/dto/DepartmentUpdateRequest.java @@ -0,0 +1,53 @@ +package com.designhub.dto; + +import jakarta.validation.constraints.Min; +import java.util.List; + +public class DepartmentUpdateRequest { + + private String deptName; + private String deptCode; + private String deptType; + private Long parentId; + private Long leaderId; + private String description; + @Min(value = 0, message = "排序必须大于等于0") + private Integer sortOrder; + private Integer status; + private List allowedDemandTypes; + private Integer limitEnabled; + private String limitPeriod; + private Integer periodLimit; + private Integer alertThreshold; + private String overLimitAction; + + public String getDeptName() { return deptName; } + public void setDeptName(String deptName) { this.deptName = deptName; } + public String getDeptCode() { return deptCode; } + public void setDeptCode(String deptCode) { this.deptCode = deptCode; } + public String getDeptType() { return deptType; } + public void setDeptType(String deptType) { this.deptType = deptType; } + public Long getParentId() { return parentId; } + public void setParentId(Long parentId) { this.parentId = parentId; } + public Long getLeaderId() { return leaderId; } + public void setLeaderId(Long leaderId) { this.leaderId = leaderId; } + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + public Integer getSortOrder() { return sortOrder; } + public void setSortOrder(Integer sortOrder) { this.sortOrder = sortOrder; } + public Integer getStatus() { return status; } + public void setStatus(Integer status) { this.status = status; } + public List getAllowedDemandTypes() { return allowedDemandTypes; } + public void setAllowedDemandTypes(List allowedDemandTypes) { this.allowedDemandTypes = allowedDemandTypes; } + public Integer getLimitEnabled() { return limitEnabled; } + public void setLimitEnabled(Integer limitEnabled) { this.limitEnabled = limitEnabled; } + public String getLimitPeriod() { return limitPeriod; } + public void setLimitPeriod(String limitPeriod) { this.limitPeriod = limitPeriod; } + public Integer getPeriodLimit() { return periodLimit; } + public void setPeriodLimit(Integer periodLimit) { this.periodLimit = periodLimit; } + public Integer getAlertThreshold() { return alertThreshold; } + public void setAlertThreshold(Integer alertThreshold) { this.alertThreshold = alertThreshold; } + public String getOverLimitAction() { return overLimitAction; } + public void setOverLimitAction(String overLimitAction) { this.overLimitAction = overLimitAction; } +} + diff --git a/backend/src/main/java/com/designhub/dto/LoginDTO.java b/backend/src/main/java/com/designhub/dto/LoginDTO.java new file mode 100644 index 0000000..987af2f --- /dev/null +++ b/backend/src/main/java/com/designhub/dto/LoginDTO.java @@ -0,0 +1,34 @@ +package com.designhub.dto; + +import lombok.Data; +import jakarta.validation.constraints.NotBlank; + +/** + * 登录请求DTO + */ +@Data +public class LoginDTO { + + /** + * 用户名 + */ + @NotBlank(message = "用户名不能为空") + private String username; + + /** + * 密码 + */ + @NotBlank(message = "密码不能为空") + private String password; + + /** + * 登录类型:password-密码登录 dingtalk-钉钉登录 casdoor-Casdoor登录 + */ + private String loginType = "password"; + + /** + * 钉钉授权码(钉钉登录时使用) + */ + private String authCode; +} + diff --git a/backend/src/main/java/com/designhub/dto/PipelineTaskDTO.java b/backend/src/main/java/com/designhub/dto/PipelineTaskDTO.java new file mode 100644 index 0000000..b7ecb71 --- /dev/null +++ b/backend/src/main/java/com/designhub/dto/PipelineTaskDTO.java @@ -0,0 +1,78 @@ +package com.designhub.dto; + +import lombok.Data; +import java.time.LocalDateTime; +import java.util.List; + +/** + * 流水线任务创建请求DTO + */ +@Data +public class PipelineTaskDTO { + + /** + * 任务标题 + */ + private String title; + + /** + * 是否流水线任务 + */ + private Boolean isPipeline; + + /** + * 流水线模式:sequential(顺序)/parallel(并行) + */ + private String pipelineMode; + + /** + * 子任务列表 + */ + private List items; + + /** + * 任务其他字段(与普通任务相同) + */ + private Long brandId; + private Long categoryId; + private Long platformId; + private String spuInfo; + private String taskSubType; + private String attachmentUrl; + private String remark; + private Integer priority; + private LocalDateTime deadline; // 截止时间 + private String releaseType; // 发布类型:default/recruitment/assigned + private Long receiverId; // 指定接收人ID(assigned模式使用) + + /** + * 子任务项DTO + */ + @Data + public static class TaskItemDTO { + /** + * 任务类型ID + */ + private Long taskTypeId; + + /** + * 数量 + */ + private Integer quantity; + + /** + * 单价积分 + */ + private Integer unitScore; + + /** + * 预估工时 + */ + private Double estimatedHours; + + /** + * 排序 + */ + private Integer sortOrder; + } +} diff --git a/backend/src/main/java/com/designhub/dto/RegisterDTO.java b/backend/src/main/java/com/designhub/dto/RegisterDTO.java new file mode 100644 index 0000000..7eb9544 --- /dev/null +++ b/backend/src/main/java/com/designhub/dto/RegisterDTO.java @@ -0,0 +1,52 @@ +package com.designhub.dto; + +import lombok.Data; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +/** + * 注册请求DTO + */ +@Data +public class RegisterDTO { + + /** + * 用户名 + */ + @NotBlank(message = "用户名不能为空") + private String username; + + /** + * 密码 + */ + @NotBlank(message = "密码不能为空") + private String password; + + /** + * 真实姓名 + */ + @NotBlank(message = "真实姓名不能为空") + private String realName; + + /** + * 角色ID(5-设计师DESIGNER, 6-需求方REQUESTER) + */ + @NotNull(message = "请选择角色") + private Long roleId; + + /** + * 部门ID + */ + @NotNull(message = "请选择部门") + private Long deptId; + + /** + * 邮箱(可选) + */ + private String email; + + /** + * 手机号(可选) + */ + private String phone; +} diff --git a/backend/src/main/java/com/designhub/dto/TaskApprovalDTO.java b/backend/src/main/java/com/designhub/dto/TaskApprovalDTO.java new file mode 100644 index 0000000..74943c3 --- /dev/null +++ b/backend/src/main/java/com/designhub/dto/TaskApprovalDTO.java @@ -0,0 +1,36 @@ +package com.designhub.dto; + +import lombok.Data; + +/** + * 任务审核DTO + */ +@Data +public class TaskApprovalDTO { + /** + * 审核意见 + */ + private String comment; + + /** + * 任务更新数据(审核时可修改任务) + */ + private TaskUpdateDTO taskUpdates; + + // Getters and Setters + public String getComment() { + return comment; + } + + public void setComment(String comment) { + this.comment = comment; + } + + public TaskUpdateDTO getTaskUpdates() { + return taskUpdates; + } + + public void setTaskUpdates(TaskUpdateDTO taskUpdates) { + this.taskUpdates = taskUpdates; + } +} diff --git a/backend/src/main/java/com/designhub/dto/TaskAssignRequest.java b/backend/src/main/java/com/designhub/dto/TaskAssignRequest.java new file mode 100644 index 0000000..68ae11c --- /dev/null +++ b/backend/src/main/java/com/designhub/dto/TaskAssignRequest.java @@ -0,0 +1,33 @@ +package com.designhub.dto; + +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * 任务指派请求DTO + */ +@Data +public class TaskAssignRequest { + + /** + * 任务ID + */ + @NotNull(message = "任务ID不能为空") + private Long taskId; + + /** + * 被指派人ID + */ + @NotNull(message = "被指派人ID不能为空") + private Long assigneeId; + + /** + * 指派类型:force-强制指派 recommend-推荐 + */ + private String assignType = "force"; + + /** + * 指派说明 + */ + private String comment; +} diff --git a/backend/src/main/java/com/designhub/dto/TaskAuditRequest.java b/backend/src/main/java/com/designhub/dto/TaskAuditRequest.java new file mode 100644 index 0000000..a94af23 --- /dev/null +++ b/backend/src/main/java/com/designhub/dto/TaskAuditRequest.java @@ -0,0 +1,19 @@ +package com.designhub.dto; + +import jakarta.validation.constraints.NotNull; + +public class TaskAuditRequest { + + @NotNull(message = "任务ID不能为空") + private Long taskId; + @NotNull(message = "审核结果不能为空") + private String result; + private String comment; + + public Long getTaskId() { return taskId; } + public void setTaskId(Long taskId) { this.taskId = taskId; } + public String getResult() { return result; } + public void setResult(String result) { this.result = result; } + public String getComment() { return comment; } + public void setComment(String comment) { this.comment = comment; } +} diff --git a/backend/src/main/java/com/designhub/dto/TaskUpdateDTO.java b/backend/src/main/java/com/designhub/dto/TaskUpdateDTO.java new file mode 100644 index 0000000..a72ffa9 --- /dev/null +++ b/backend/src/main/java/com/designhub/dto/TaskUpdateDTO.java @@ -0,0 +1,116 @@ +package com.designhub.dto; + +import lombok.Data; + +import java.util.List; + +/** + * 任务更新DTO(用于审核时修改任务) + */ +@Data +public class TaskUpdateDTO { + private String title; + private String taskSubType; + private Long brandId; + private Long platformId; + private Long categoryId; + private String spuInfo; + private String remark; + private String attachmentUrl; + private List taskTypes; + + @Data + public static class TaskTypeUpdateDTO { + private Long taskTypeId; + private Integer quantity; + + public Long getTaskTypeId() { + return taskTypeId; + } + + public void setTaskTypeId(Long taskTypeId) { + this.taskTypeId = taskTypeId; + } + + public Integer getQuantity() { + return quantity; + } + + public void setQuantity(Integer quantity) { + this.quantity = quantity; + } + } + + // Getters and Setters + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getTaskSubType() { + return taskSubType; + } + + public void setTaskSubType(String taskSubType) { + this.taskSubType = taskSubType; + } + + public Long getBrandId() { + return brandId; + } + + public void setBrandId(Long brandId) { + this.brandId = brandId; + } + + public Long getPlatformId() { + return platformId; + } + + public void setPlatformId(Long platformId) { + this.platformId = platformId; + } + + public Long getCategoryId() { + return categoryId; + } + + public void setCategoryId(Long categoryId) { + this.categoryId = categoryId; + } + + public String getSpuInfo() { + return spuInfo; + } + + public void setSpuInfo(String spuInfo) { + this.spuInfo = spuInfo; + } + + public String getRemark() { + return remark; + } + + public void setRemark(String remark) { + this.remark = remark; + } + + public String getAttachmentUrl() { + return attachmentUrl; + } + + public void setAttachmentUrl(String attachmentUrl) { + this.attachmentUrl = attachmentUrl; + } + + public List getTaskTypes() { + return taskTypes; + } + + public void setTaskTypes(List taskTypes) { + this.taskTypes = taskTypes; + } +} diff --git a/backend/src/main/java/com/designhub/entity/Achievement.java b/backend/src/main/java/com/designhub/entity/Achievement.java new file mode 100644 index 0000000..2fcfe42 --- /dev/null +++ b/backend/src/main/java/com/designhub/entity/Achievement.java @@ -0,0 +1,95 @@ +package com.designhub.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 成就徽章实体类 + */ +@Data +@TableName("achievement") +public class Achievement implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 主键ID + */ + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** + * 成就代码(唯一标识) + */ + private String achievementCode; + + /** + * 成就名称 + */ + private String achievementName; + + /** + * 成就描述 + */ + private String description; + + /** + * 成就图标URL + */ + private String iconUrl; + + /** + * 成就类型:task-任务类, score-积分类, quality-质量类, speed-速度类 + */ + private String achievementType; + + /** + * 达成条件(JSON格式) + * 例如:{"type":"task_count","value":100} + */ + @TableField("`condition`") + private String condition; + + /** + * 奖励积分 + */ + private Integer rewardScore; + + /** + * 成就等级:bronze-铜牌, silver-银牌, gold-金牌, diamond-钻石 + */ + private String level; + + /** + * 排序号 + */ + private Integer sortOrder; + + /** + * 状态:0-禁用,1-启用 + */ + @TableField("enabled") + private Integer status; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + + /** + * 删除标志:0-未删除,1-已删除 + */ + @TableLogic + private Integer deleted; +} + diff --git a/backend/src/main/java/com/designhub/entity/Brand.java b/backend/src/main/java/com/designhub/entity/Brand.java new file mode 100644 index 0000000..2864d96 --- /dev/null +++ b/backend/src/main/java/com/designhub/entity/Brand.java @@ -0,0 +1,69 @@ +package com.designhub.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 品牌实体类 + */ +@Data +@TableName("brand") +public class Brand implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 主键ID + */ + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** + * 品牌名称 + */ + @TableField("brand_name") + private String name; + + /** + * 品牌编码 + */ + @TableField("brand_code") + private String brandCode; + + /** + * 品牌Logo URL + */ + @TableField("logo") + private String logoUrl; + + /** + * 品牌描述 + */ + private String description; + + /** + * 排序号 + */ + private Integer sortOrder; + + /** + * 是否启用:0-禁用,1-启用 + */ + @TableField("status") + private Integer isEnabled; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; +} + diff --git a/backend/src/main/java/com/designhub/entity/Category.java b/backend/src/main/java/com/designhub/entity/Category.java new file mode 100644 index 0000000..06fda36 --- /dev/null +++ b/backend/src/main/java/com/designhub/entity/Category.java @@ -0,0 +1,84 @@ +package com.designhub.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 类目实体类 + */ +@Data +@TableName("category") +public class Category implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 主键ID + */ + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** + * 父类目ID,0表示一级类目 + */ + private Long parentId; + + /** + * 类目名称 + */ + @TableField("category_name") + private String name; + + /** + * 类目编码 + */ + @TableField("category_code") + private String categoryCode; + + /** + * 类目层级:1-一级,2-二级,3-三级 + */ + private Integer level; + + /** + * 是否叶子类目:0-否,1-是 + */ + private Integer isLeaf; + + /** + * 类目路径,用/分隔,如:/1/12/123(数据库不存在此字段) + */ + @TableField(exist = false) + private String path; + + /** + * 类目描述 + */ + private String description; + + /** + * 排序号 + */ + private Integer sortOrder; + + /** + * 是否启用:0-禁用,1-启用 + */ + @TableField("status") + private Integer isEnabled; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; +} + diff --git a/backend/src/main/java/com/designhub/entity/CheckIn.java b/backend/src/main/java/com/designhub/entity/CheckIn.java new file mode 100644 index 0000000..a1e50e3 --- /dev/null +++ b/backend/src/main/java/com/designhub/entity/CheckIn.java @@ -0,0 +1,68 @@ +package com.designhub.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * 签到记录实体类 + */ +@Data +@TableName("user_check_in") +public class CheckIn implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 主键ID + */ + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** + * 用户ID + */ + private Long userId; + + /** + * 签到日期 + */ + private LocalDate checkInDate; + + /** + * 连续签到天数 + */ + private Integer continuousDays; + + /** + * 奖励积分 + */ + private Integer rewardScore; + + /** + * 签到备注 + */ + private String remark; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + + /** + * 删除标志:0-未删除,1-已删除 + */ + @TableLogic + private Integer deleted; +} + diff --git a/backend/src/main/java/com/designhub/entity/Contest.java b/backend/src/main/java/com/designhub/entity/Contest.java new file mode 100644 index 0000000..6929de5 --- /dev/null +++ b/backend/src/main/java/com/designhub/entity/Contest.java @@ -0,0 +1,110 @@ +package com.designhub.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 竞赛活动实体 + */ +@Data +@TableName("contest") +public class Contest implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + + /** + * 竞赛名称 + */ + private String name; + + /** + * 竞赛描述 + */ + private String description; + + /** + * 竞赛类型:monthly-月度竞赛, weekly-周度竞赛, special-专项竞赛 + */ + private String contestType; + + /** + * 开始时间 + */ + private LocalDateTime startTime; + + /** + * 结束时间 + */ + private LocalDateTime endTime; + + /** + * 参与范围:all-全体设计师, department-指定部门, user-指定人员 + */ + private String participantScope; + + /** + * 指定部门ID列表(JSON数组) + */ + private String departmentIds; + + /** + * 指定用户ID列表(JSON数组) + */ + private String userIds; + + /** + * 竞赛规则配置(JSON对象) + * 例如:{"taskCompleted":{"score":2,"weight":30},"fiveStarReview":{"score":10,"weight":25}} + */ + private String rules; + + /** + * 奖励配置(JSON数组) + * 例如:[{"rank":1,"score":1500,"badge":"月度冠军"},{"rank":2,"score":1000}] + */ + private String rewards; + + /** + * 参与奖配置(JSON对象) + */ + private String participationReward; + + /** + * 竞赛状态:draft-草稿, scheduled-待开始, active-进行中, ended-已结束, cancelled-已取消 + */ + private String status; + + /** + * 参与人数 + */ + private Integer participantCount; + + /** + * 创建人ID + */ + private Long creatorId; + + /** + * 是否删除:0-否,1-是 + */ + @TableLogic + private Integer deleted; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; +} diff --git a/backend/src/main/java/com/designhub/entity/ContestParticipant.java b/backend/src/main/java/com/designhub/entity/ContestParticipant.java new file mode 100644 index 0000000..5ba3091 --- /dev/null +++ b/backend/src/main/java/com/designhub/entity/ContestParticipant.java @@ -0,0 +1,107 @@ +package com.designhub.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 竞赛参与者实体 + */ +@Data +@TableName("contest_participant") +public class ContestParticipant implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + + /** + * 竞赛ID + */ + private Long contestId; + + /** + * 用户ID + */ + private Long userId; + + /** + * 用户姓名(冗余) + */ + private String userName; + + /** + * 部门ID + */ + private Long deptId; + + /** + * 部门名称(冗余) + */ + private String deptName; + + /** + * 完成任务数 + */ + private Integer taskCompleted; + + /** + * 五星好评数 + */ + private Integer fiveStarCount; + + /** + * 提前完成次数 + */ + private Integer earlyCompletionCount; + + /** + * 被表扬次数 + */ + private Integer praiseCount; + + /** + * 无延误天数 + */ + private Integer noDelayDays; + + /** + * 总得分 + */ + private Integer totalScore; + + /** + * 当前排名 + */ + private Integer currentRank; + + /** + * 是否已领取奖励:0-否,1-是 + */ + private Integer rewardClaimed; + + /** + * 奖励积分 + */ + private Integer rewardScore; + + /** + * 奖励领取时间 + */ + private LocalDateTime rewardClaimTime; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; +} diff --git a/backend/src/main/java/com/designhub/entity/DelayPenaltyRecord.java b/backend/src/main/java/com/designhub/entity/DelayPenaltyRecord.java new file mode 100644 index 0000000..8e12bf5 --- /dev/null +++ b/backend/src/main/java/com/designhub/entity/DelayPenaltyRecord.java @@ -0,0 +1,106 @@ +package com.designhub.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 延误扣分记录实体类 + */ +@Data +@TableName("delay_penalty_record") +public class DelayPenaltyRecord implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 主键ID + */ + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** + * 任务ID + */ + private Long taskId; + + /** + * 设计师ID + */ + private Long designerId; + + /** + * 延误天数 + */ + private Integer delayDays; + + /** + * 延误小时数 + */ + private Long delayHours; + + /** + * 延误等级:light-轻微 general-一般 severe-严重 major-重大 + */ + private String delayLevel; + + /** + * 扣除积分 + */ + private Integer penaltyScore; + + /** + * 原截止时间 + */ + private LocalDateTime originalDeadline; + + /** + * 实际完成/转移时间 + */ + private LocalDateTime actualTime; + + /** + * 状态:confirmed-已确认 appealed-已申诉 approved-申诉通过 rejected-申诉驳回 + */ + private String status; + + /** + * 申诉原因 + */ + private String appealReason; + + /** + * 申诉时间 + */ + private LocalDateTime appealTime; + + /** + * 申诉结果 + */ + private String appealResult; + + /** + * 审批人ID + */ + private Long approverId; + + /** + * 审批时间 + */ + private LocalDateTime approveTime; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; +} + diff --git a/backend/src/main/java/com/designhub/entity/DelayPenaltyRule.java b/backend/src/main/java/com/designhub/entity/DelayPenaltyRule.java new file mode 100644 index 0000000..524b023 --- /dev/null +++ b/backend/src/main/java/com/designhub/entity/DelayPenaltyRule.java @@ -0,0 +1,86 @@ +package com.designhub.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 延误扣分规则实体类 + */ +@Data +@TableName("delay_penalty_rule") +public class DelayPenaltyRule implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 主键ID + */ + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** + * 等级名称:light-轻微 general-一般 severe-严重 major-重大 + */ + private String levelName; + + /** + * 最小延误天数(实际使用小时数) + */ + private Integer minDays; + + /** + * 最小延误小时数 + */ + private Integer minHours; + + /** + * 最大延误天数(实际使用小时数,NULL表示无上限) + */ + private Integer maxDays; + + /** + * 最大延误小时数(NULL表示无上限) + */ + private Integer maxHours; + + /** + * 基础扣分 + */ + private Integer baseScore; + + /** + * 每天递增扣分 + */ + private Integer perDayScore; + + /** + * 每小时扣分(用于按小时计算的等级) + */ + private Integer perHourScore; + + /** + * 规则描述 + */ + private String description; + + /** + * 是否启用:0-否 1-是 + */ + private Integer enabled; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; +} + diff --git a/backend/src/main/java/com/designhub/entity/DemandType.java b/backend/src/main/java/com/designhub/entity/DemandType.java new file mode 100644 index 0000000..3fb72ff --- /dev/null +++ b/backend/src/main/java/com/designhub/entity/DemandType.java @@ -0,0 +1,75 @@ +package com.designhub.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 需求类型实体类 + */ +@Data +@TableName("task_type") +public class DemandType implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 主键ID + */ + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** + * 类型名称 + */ + @TableField("type_name") + private String name; + + /** + * 单价(积分) + */ + @TableField("unit_score") + private Integer unitScore; + + /** + * 预估工时(小时) + */ + @TableField("estimated_hours") + private Integer estimatedHours; + + /** + * 类型描述 + */ + private String description; + + /** + * 排序号 + */ + private Integer sortOrder; + + /** + * 是否启用:0-禁用,1-启用 + */ + @TableField("status") + private Integer isEnabled; + + /** + * 可接单部门ID列表(逗号分隔),为空表示所有部门都可接单 + */ + @TableField("allowed_dept_ids") + private String allowedDeptIds; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; +} + diff --git a/backend/src/main/java/com/designhub/entity/DemandTypeDepartment.java b/backend/src/main/java/com/designhub/entity/DemandTypeDepartment.java new file mode 100644 index 0000000..867b9aa --- /dev/null +++ b/backend/src/main/java/com/designhub/entity/DemandTypeDepartment.java @@ -0,0 +1,38 @@ +package com.designhub.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 需求类型-接单部门关联表实体类 + */ +@Data +@TableName("demand_type_department") +public class DemandTypeDepartment implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 关联ID + */ + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** + * 需求类型ID + */ + private Long demandTypeId; + + /** + * 接单部门ID + */ + private Long departmentId; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; +} diff --git a/backend/src/main/java/com/designhub/entity/Department.java b/backend/src/main/java/com/designhub/entity/Department.java new file mode 100644 index 0000000..301949f --- /dev/null +++ b/backend/src/main/java/com/designhub/entity/Department.java @@ -0,0 +1,235 @@ +package com.designhub.entity; + +import com.baomidou.mybatisplus.annotation.*; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 部门实体类 + * 注意:已移除@Data注解,改用手动getter/setter以解决Lombok编译问题 + */ +@TableName("sys_dept") +public class Department implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 主键ID + */ + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** + * 父部门ID,0表示顶级部门 + */ + private Long parentId; + + /** + * 部门名称 + */ + private String deptName; + + /** + * 部门编码 + */ + private String deptCode; + + /** + * 钉钉部门ID + */ + private String dingDeptId; + + /** + * 部门类型:demand-需求部门,receiving-接单部门 + */ + private String deptType; + + /** + * 部门负责人ID + */ + @TableField("leader_id") + private Long leaderId; + + /** + * 部门积分余额(冗余字段,用于快速展示) + */ + @TableField("score_balance") + private Integer scoreBalance; + + /** + * 是否启用积分消耗限制:0-否,1-是 + */ + @TableField("limit_enabled") + private Integer limitEnabled; + + /** + * 限制周期:DAILY/WEEKLY/MONTHLY + */ + @TableField("limit_period") + private String limitPeriod; + + /** + * 周期限额 + */ + @TableField("period_limit") + private Integer periodLimit; + + /** + * 预警阈值(百分比) + */ + @TableField("alert_threshold") + private Integer alertThreshold; + + /** + * 超限后的动作:alert-仅提醒,block-禁止发起 + */ + @TableField("over_limit_action") + private String overLimitAction; + + /** + * 允许的需求类型(接单部门使用,逗号分隔) + */ + @TableField("allowed_demand_types") + private String allowedDemandTypes; + + /** + * 部门主管ID(接单部门)- 数据库不存在此字段 + */ + @TableField(exist = false) + private Long supervisorId; + + /** + * 部门描述 - 数据库不存在此字段 + */ + @TableField(exist = false) + private String description; + + /** + * 部门层级 - 数据库不存在此字段 + */ + @TableField(exist = false) + private Integer level; + + /** + * 部门路径 - 数据库不存在此字段 + */ + @TableField(exist = false) + private String path; + + /** + * 排序号 + */ + private Integer sortOrder; + + /** + * 状态:0-禁用,1-启用 + */ + private Integer status; + + /** + * 创建人ID - 数据库不存在此字段 + */ + @TableField(exist = false) + private Long createBy; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + /** + * 更新人ID - 数据库不存在此字段 + */ + @TableField(exist = false) + private Long updateBy; + + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + + /** + * 删除标志:0-未删除,1-已删除 + */ + @TableLogic + private Integer deleted; + + // Getters and Setters (手动添加以解决Lombok编译问题) + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + + public Long getParentId() { return parentId; } + public void setParentId(Long parentId) { this.parentId = parentId; } + + public String getDeptName() { return deptName; } + public void setDeptName(String deptName) { this.deptName = deptName; } + + public String getDeptCode() { return deptCode; } + public void setDeptCode(String deptCode) { this.deptCode = deptCode; } + + public String getDingDeptId() { return dingDeptId; } + public void setDingDeptId(String dingDeptId) { this.dingDeptId = dingDeptId; } + + public String getDeptType() { return deptType; } + public void setDeptType(String deptType) { this.deptType = deptType; } + + public Long getLeaderId() { return leaderId; } + public void setLeaderId(Long leaderId) { this.leaderId = leaderId; } + + public Integer getScoreBalance() { return scoreBalance; } + public void setScoreBalance(Integer scoreBalance) { this.scoreBalance = scoreBalance; } + + public Integer getLimitEnabled() { return limitEnabled; } + public void setLimitEnabled(Integer limitEnabled) { this.limitEnabled = limitEnabled; } + + public String getLimitPeriod() { return limitPeriod; } + public void setLimitPeriod(String limitPeriod) { this.limitPeriod = limitPeriod; } + + public Integer getPeriodLimit() { return periodLimit; } + public void setPeriodLimit(Integer periodLimit) { this.periodLimit = periodLimit; } + + public Integer getAlertThreshold() { return alertThreshold; } + public void setAlertThreshold(Integer alertThreshold) { this.alertThreshold = alertThreshold; } + + public String getOverLimitAction() { return overLimitAction; } + public void setOverLimitAction(String overLimitAction) { this.overLimitAction = overLimitAction; } + + public String getAllowedDemandTypes() { return allowedDemandTypes; } + public void setAllowedDemandTypes(String allowedDemandTypes) { this.allowedDemandTypes = allowedDemandTypes; } + + public Long getSupervisorId() { return supervisorId; } + public void setSupervisorId(Long supervisorId) { this.supervisorId = supervisorId; } + + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + + public Integer getLevel() { return level; } + public void setLevel(Integer level) { this.level = level; } + + public String getPath() { return path; } + public void setPath(String path) { this.path = path; } + + public Integer getSortOrder() { return sortOrder; } + public void setSortOrder(Integer sortOrder) { this.sortOrder = sortOrder; } + + public Integer getStatus() { return status; } + public void setStatus(Integer status) { this.status = status; } + + public Long getCreateBy() { return createBy; } + public void setCreateBy(Long createBy) { this.createBy = createBy; } + + public LocalDateTime getCreateTime() { return createTime; } + public void setCreateTime(LocalDateTime createTime) { this.createTime = createTime; } + + public Long getUpdateBy() { return updateBy; } + public void setUpdateBy(Long updateBy) { this.updateBy = updateBy; } + + public LocalDateTime getUpdateTime() { return updateTime; } + public void setUpdateTime(LocalDateTime updateTime) { this.updateTime = updateTime; } + + public Integer getDeleted() { return deleted; } + public void setDeleted(Integer deleted) { this.deleted = deleted; } +} + diff --git a/backend/src/main/java/com/designhub/entity/DingTalkConfig.java b/backend/src/main/java/com/designhub/entity/DingTalkConfig.java new file mode 100644 index 0000000..a65f497 --- /dev/null +++ b/backend/src/main/java/com/designhub/entity/DingTalkConfig.java @@ -0,0 +1,88 @@ +package com.designhub.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 钉钉配置实体 + */ +@Data +@TableName("dingtalk_config") +public class DingTalkConfig implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + + /** + * 配置名称 + */ + private String name; + + /** + * 应用AgentId + */ + private String agentId; + + /** + * 应用AppKey + */ + private String appKey; + + /** + * 应用AppSecret (加密存储) + */ + private String appSecret; + + /** + * 企业ID (CorpId) + */ + private String corpId; + + /** + * 机器人Webhook地址 + */ + private String robotWebhook; + + /** + * 机器人密钥 + */ + private String robotSecret; + + /** + * 是否启用:0-禁用,1-启用 + */ + private Integer enabled; + + /** + * 启用功能配置(JSON) + * 例如:{"notification":true,"login":false,"attendance":false} + */ + private String enabledFeatures; + + /** + * Access Token(缓存) + */ + private String accessToken; + + /** + * Access Token过期时间 + */ + private LocalDateTime tokenExpireTime; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; +} diff --git a/backend/src/main/java/com/designhub/entity/FileUpload.java b/backend/src/main/java/com/designhub/entity/FileUpload.java new file mode 100644 index 0000000..70920cf --- /dev/null +++ b/backend/src/main/java/com/designhub/entity/FileUpload.java @@ -0,0 +1,200 @@ +package com.designhub.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.time.LocalDateTime; + +/** + * 文件上传记录实体类 + */ +@Data +@TableName("sys_file_upload") +public class FileUpload { + + /** + * 主键ID + */ + @TableId(type = IdType.AUTO) + private Long id; + + /** + * 原始文件名 + */ + private String originalName; + + /** + * 存储文件名 + */ + private String storedName; + + /** + * 文件路径 + */ + private String filePath; + + /** + * 文件访问URL + */ + private String fileUrl; + + /** + * 文件大小(字节) + */ + private Long fileSize; + + /** + * 文件类型 + */ + private String fileType; + + /** + * 上传者ID + */ + private Long uploaderId; + + /** + * 上传者姓名 + */ + private String uploaderName; + + /** + * 关联类型:task-任务, user-用户等 + */ + private String relatedType; + + /** + * 关联ID + */ + private Long relatedId; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + + /** + * 删除标志:0-未删除,1-已删除 + */ + @TableLogic + private Integer deleted; + + // Getters and Setters + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getOriginalName() { + return originalName; + } + + public void setOriginalName(String originalName) { + this.originalName = originalName; + } + + public String getStoredName() { + return storedName; + } + + public void setStoredName(String storedName) { + this.storedName = storedName; + } + + public String getFilePath() { + return filePath; + } + + public void setFilePath(String filePath) { + this.filePath = filePath; + } + + public String getFileUrl() { + return fileUrl; + } + + public void setFileUrl(String fileUrl) { + this.fileUrl = fileUrl; + } + + public Long getFileSize() { + return fileSize; + } + + public void setFileSize(Long fileSize) { + this.fileSize = fileSize; + } + + public String getFileType() { + return fileType; + } + + public void setFileType(String fileType) { + this.fileType = fileType; + } + + public Long getUploaderId() { + return uploaderId; + } + + public void setUploaderId(Long uploaderId) { + this.uploaderId = uploaderId; + } + + public String getUploaderName() { + return uploaderName; + } + + public void setUploaderName(String uploaderName) { + this.uploaderName = uploaderName; + } + + public String getRelatedType() { + return relatedType; + } + + public void setRelatedType(String relatedType) { + this.relatedType = relatedType; + } + + public Long getRelatedId() { + return relatedId; + } + + public void setRelatedId(Long relatedId) { + this.relatedId = relatedId; + } + + public LocalDateTime getCreateTime() { + return createTime; + } + + public void setCreateTime(LocalDateTime createTime) { + this.createTime = createTime; + } + + public LocalDateTime getUpdateTime() { + return updateTime; + } + + public void setUpdateTime(LocalDateTime updateTime) { + this.updateTime = updateTime; + } + + public Integer getDeleted() { + return deleted; + } + + public void setDeleted(Integer deleted) { + this.deleted = deleted; + } +} diff --git a/backend/src/main/java/com/designhub/entity/Notification.java b/backend/src/main/java/com/designhub/entity/Notification.java new file mode 100644 index 0000000..0905c40 --- /dev/null +++ b/backend/src/main/java/com/designhub/entity/Notification.java @@ -0,0 +1,268 @@ +package com.designhub.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 通知消息实体类 + */ +@Data +@TableName("sys_notification") +public class Notification implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 主键ID + */ + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** + * 通知类型:system-系统通知, task-任务通知, score-积分通知, review-评审通知 + */ + @TableField("type") + private String notificationType; + + /** + * 接收用户ID + */ + @TableField("receiver_id") + private Long receiverId; + + /** + * 发送用户ID(可为空,系统通知为空) + */ + @TableField(exist = false) + private Long senderId; + + /** + * 通知标题 + */ + private String title; + + /** + * 通知内容 + */ + private String content; + + /** + * 关联业务ID(任务ID、积分记录ID等) + */ + @TableField("related_task_id") + private Long relatedId; + + /** + * 关联业务类型:task, score, review, system + */ + @TableField(exist = false) + private String relatedType; + + /** + * 跳转链接 + */ + @TableField("link") + private String linkUrl; + + /** + * 是否已读:0-未读,1-已读 + */ + @TableField("is_read") + private Integer isRead; + + /** + * 阅读时间 + */ + private LocalDateTime readTime; + + /** + * 是否推送到钉钉:0-否,1-是 + */ + @TableField("ding_enabled") + private Integer pushDingtalk; + + /** + * 钉钉推送状态:pending-待推送, success-成功, failed-失败 + */ + @TableField(exist = false) + private String dingtalkStatus; + + /** + * 钉钉推送时间 + */ + @TableField("ding_push_time") + private LocalDateTime dingtalkPushTime; + + /** + * 优先级:low-低, normal-普通, high-高, urgent-紧急 + */ + @TableField("ding_priority") + private Integer priority; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + + /** + * 删除标志:0-未删除,1-已删除 + */ + @TableLogic + private Integer deleted; + + // Getters and Setters (手动添加以解决Lombok编译问题) + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getNotificationType() { + return notificationType; + } + + public void setNotificationType(String notificationType) { + this.notificationType = notificationType; + } + + public Long getReceiverId() { + return receiverId; + } + + public void setReceiverId(Long receiverId) { + this.receiverId = receiverId; + } + + public Long getSenderId() { + return senderId; + } + + public void setSenderId(Long senderId) { + this.senderId = senderId; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getContent() { + return content; + } + + public void setContent(String content) { + this.content = content; + } + + public Long getRelatedId() { + return relatedId; + } + + public void setRelatedId(Long relatedId) { + this.relatedId = relatedId; + } + + public String getRelatedType() { + return relatedType; + } + + public void setRelatedType(String relatedType) { + this.relatedType = relatedType; + } + + public String getLinkUrl() { + return linkUrl; + } + + public void setLinkUrl(String linkUrl) { + this.linkUrl = linkUrl; + } + + public Integer getIsRead() { + return isRead; + } + + public void setIsRead(Integer isRead) { + this.isRead = isRead; + } + + public LocalDateTime getReadTime() { + return readTime; + } + + public void setReadTime(LocalDateTime readTime) { + this.readTime = readTime; + } + + public Integer getPushDingtalk() { + return pushDingtalk; + } + + public void setPushDingtalk(Integer pushDingtalk) { + this.pushDingtalk = pushDingtalk; + } + + public String getDingtalkStatus() { + return dingtalkStatus; + } + + public void setDingtalkStatus(String dingtalkStatus) { + this.dingtalkStatus = dingtalkStatus; + } + + public LocalDateTime getDingtalkPushTime() { + return dingtalkPushTime; + } + + public void setDingtalkPushTime(LocalDateTime dingtalkPushTime) { + this.dingtalkPushTime = dingtalkPushTime; + } + + public Integer getPriority() { + return priority; + } + + public void setPriority(Integer priority) { + this.priority = priority; + } + + public LocalDateTime getCreateTime() { + return createTime; + } + + public void setCreateTime(LocalDateTime createTime) { + this.createTime = createTime; + } + + public LocalDateTime getUpdateTime() { + return updateTime; + } + + public void setUpdateTime(LocalDateTime updateTime) { + this.updateTime = updateTime; + } + + public Integer getDeleted() { + return deleted; + } + + public void setDeleted(Integer deleted) { + this.deleted = deleted; + } +} + diff --git a/backend/src/main/java/com/designhub/entity/Permission.java b/backend/src/main/java/com/designhub/entity/Permission.java new file mode 100644 index 0000000..2c879d9 --- /dev/null +++ b/backend/src/main/java/com/designhub/entity/Permission.java @@ -0,0 +1,96 @@ +package com.designhub.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 权限实体类 + */ +@Data +@TableName("sys_permission") +public class Permission implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 主键ID + */ + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** + * 父权限ID,0表示顶级权限 + */ + private Long parentId; + + /** + * 权限编码 + */ + private String permissionCode; + + /** + * 权限名称 + */ + private String permissionName; + + /** + * 权限类型:menu-菜单,button-按钮,api-接口 + */ + private String type; + + /** + * 权限路径/接口路径 + */ + private String path; + + /** + * 请求方法:GET/POST/PUT/DELETE等 + */ + private String method; + + /** + * 图标 + */ + private String icon; + + /** + * 组件路径 + */ + private String component; + + /** + * 权限描述 + */ + private String description; + + /** + * 排序号 + */ + private Integer sortOrder; + + /** + * 状态:0-禁用,1-启用 + */ + private Integer status; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + + /** + * 删除标志:0-未删除,1-已删除 + */ + @TableLogic + private Integer deleted; +} + diff --git a/backend/src/main/java/com/designhub/entity/Platform.java b/backend/src/main/java/com/designhub/entity/Platform.java new file mode 100644 index 0000000..f1aa524 --- /dev/null +++ b/backend/src/main/java/com/designhub/entity/Platform.java @@ -0,0 +1,69 @@ +package com.designhub.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 平台实体类 + */ +@Data +@TableName("platform") +public class Platform implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 主键ID + */ + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** + * 平台名称 + */ + @TableField("platform_name") + private String name; + + /** + * 平台编码 + */ + @TableField("platform_code") + private String platformCode; + + /** + * 平台图标 URL + */ + @TableField("icon") + private String iconUrl; + + /** + * 平台描述 + */ + private String description; + + /** + * 排序号 + */ + private Integer sortOrder; + + /** + * 是否启用:0-禁用,1-启用 + */ + @TableField("status") + private Integer isEnabled; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; +} + diff --git a/backend/src/main/java/com/designhub/entity/RewardRule.java b/backend/src/main/java/com/designhub/entity/RewardRule.java new file mode 100644 index 0000000..5815b09 --- /dev/null +++ b/backend/src/main/java/com/designhub/entity/RewardRule.java @@ -0,0 +1,109 @@ +package com.designhub.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 自动奖励规则实体 + */ +@Data +@TableName("reward_rule") +public class RewardRule implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + + /** + * 规则名称 + */ + private String name; + + /** + * 规则描述 + */ + private String description; + + /** + * 规则类型:daily-每日奖励, weekly-每周奖励, monthly-每月奖励, task-任务奖励, rating-好评奖励 + */ + private String ruleType; + + /** + * 触发条件(JSON对象) + * 例如:{"type":"task_count","operator":">=","value":10} + */ + private String triggerCondition; + + /** + * 奖励类型:score-积分, badge-徽章, both-两者 + */ + private String rewardType; + + /** + * 奖励积分数 + */ + private Integer rewardScore; + + /** + * 奖励徽章(JSON对象) + */ + private String rewardBadge; + + /** + * 适用范围:all-全员, designer-设计师, department-指定部门 + */ + private String scope; + + /** + * 指定部门ID列表(JSON数组) + */ + private String scopeDeptIds; + + /** + * 是否启用:0-禁用,1-启用 + */ + private Integer enabled; + + /** + * 执行周期(cron 表达式,仅周期性奖励使用) + */ + private String cronExpression; + + /** + * 上次执行时间 + */ + private LocalDateTime lastExecuteTime; + + /** + * 优先级(数值越大优先级越高) + */ + private Integer priority; + + /** + * 是否删除:0-否,1-是 + */ + @TableLogic + private Integer deleted; + + /** + * 创建人ID + */ + private Long creatorId; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; +} diff --git a/backend/src/main/java/com/designhub/entity/Role.java b/backend/src/main/java/com/designhub/entity/Role.java new file mode 100644 index 0000000..38b8b7f --- /dev/null +++ b/backend/src/main/java/com/designhub/entity/Role.java @@ -0,0 +1,103 @@ +package com.designhub.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 角色实体类 + */ +@Data +@TableName("sys_role") +public class Role implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 主键ID + */ + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** + * 角色编码 + */ + private String roleCode; + + /** + * 角色名称 + */ + private String roleName; + + /** + * 角色描述 + */ + private String description; + + /** + * 角色级别:1-最高权限,数字越大权限越低 + * 当前数据库表中无对应字段,标记为非持久化,避免 SQL 报错 + */ + @TableField(exist = false) + private Integer level; + + /** + * 数据权限范围:all-全部数据,dept-本部门,self-本人 + * 数据库当前未存储该字段 + */ + @TableField(exist = false) + private String dataScope; + + /** + * 排序号 + * 数据库当前未存储该字段 + */ + @TableField(exist = false) + private Integer sortOrder; + + /** + * 状态:0-禁用,1-启用 + */ + private Integer status; + + /** + * 是否内置角色:0-否,1-是(内置角色不可删除) + * 数据库当前未存储该字段 + */ + @TableField(exist = false) + private Integer builtIn; + + /** + * 创建人ID + * 数据库当前未存储该字段 + */ + @TableField(exist = false) + private Long createBy; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + /** + * 更新人ID + * 数据库当前未存储该字段 + */ + @TableField(exist = false) + private Long updateBy; + + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + + /** + * 删除标志:0-未删除,1-已删除 + */ + @TableLogic + private Integer deleted; +} + diff --git a/backend/src/main/java/com/designhub/entity/RolePermission.java b/backend/src/main/java/com/designhub/entity/RolePermission.java new file mode 100644 index 0000000..dff6a34 --- /dev/null +++ b/backend/src/main/java/com/designhub/entity/RolePermission.java @@ -0,0 +1,39 @@ +package com.designhub.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 角色权限关联实体类 + */ +@Data +@TableName("sys_role_permission") +public class RolePermission implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 主键ID + */ + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** + * 角色ID + */ + private Long roleId; + + /** + * 权限ID + */ + private Long permissionId; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; +} + diff --git a/backend/src/main/java/com/designhub/entity/S3Config.java b/backend/src/main/java/com/designhub/entity/S3Config.java new file mode 100644 index 0000000..5e23d27 --- /dev/null +++ b/backend/src/main/java/com/designhub/entity/S3Config.java @@ -0,0 +1,187 @@ +package com.designhub.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.time.LocalDateTime; + +/** + * S3存储配置实体类 + */ +@Data +@TableName("sys_s3_config") +public class S3Config { + + /** + * 主键ID + */ + @TableId(type = IdType.AUTO) + private Long id; + + /** + * S3服务端点 + */ + private String endpoint; + + /** + * 存储桶名称 + */ + private String bucket; + + /** + * 访问密钥ID + */ + private String accessKeyId; + + /** + * 访问密钥 + */ + private String secretAccessKey; + + /** + * 区域 + */ + private String region; + + /** + * 公共访问域名 + */ + private String publicDomain; + + /** + * 是否启用路径样式访问 + */ + private Boolean enablePathStyle; + + /** + * 文件存储路径前缀 + */ + private String filePath; + + /** + * 是否激活:0-未激活,1-激活 + */ + private Boolean isActive; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + + /** + * 删除标志:0-未删除,1-已删除 + */ + @TableLogic + private Integer deleted; + + // Getters and Setters + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getEndpoint() { + return endpoint; + } + + public void setEndpoint(String endpoint) { + this.endpoint = endpoint; + } + + public String getBucket() { + return bucket; + } + + public void setBucket(String bucket) { + this.bucket = bucket; + } + + public String getAccessKeyId() { + return accessKeyId; + } + + public void setAccessKeyId(String accessKeyId) { + this.accessKeyId = accessKeyId; + } + + public String getSecretAccessKey() { + return secretAccessKey; + } + + public void setSecretAccessKey(String secretAccessKey) { + this.secretAccessKey = secretAccessKey; + } + + public String getRegion() { + return region; + } + + public void setRegion(String region) { + this.region = region; + } + + public String getPublicDomain() { + return publicDomain; + } + + public void setPublicDomain(String publicDomain) { + this.publicDomain = publicDomain; + } + + public Boolean getEnablePathStyle() { + return enablePathStyle; + } + + public void setEnablePathStyle(Boolean enablePathStyle) { + this.enablePathStyle = enablePathStyle; + } + + public String getFilePath() { + return filePath; + } + + public void setFilePath(String filePath) { + this.filePath = filePath; + } + + public Boolean getIsActive() { + return isActive; + } + + public void setIsActive(Boolean isActive) { + this.isActive = isActive; + } + + public LocalDateTime getCreateTime() { + return createTime; + } + + public void setCreateTime(LocalDateTime createTime) { + this.createTime = createTime; + } + + public LocalDateTime getUpdateTime() { + return updateTime; + } + + public void setUpdateTime(LocalDateTime updateTime) { + this.updateTime = updateTime; + } + + public Integer getDeleted() { + return deleted; + } + + public void setDeleted(Integer deleted) { + this.deleted = deleted; + } +} diff --git a/backend/src/main/java/com/designhub/entity/ScorePool.java b/backend/src/main/java/com/designhub/entity/ScorePool.java new file mode 100644 index 0000000..95b4397 --- /dev/null +++ b/backend/src/main/java/com/designhub/entity/ScorePool.java @@ -0,0 +1,148 @@ +package com.designhub.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 激励积分池实体 + */ +@Data +@TableName("score_pool") +public class ScorePool implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + + /** + * 积分池名称 + */ + private String name; + + /** + * 积分池类型:monthly-月度池, quarterly-季度池, yearly-年度池, special-专项池 + */ + private String poolType; + + /** + * 周期年份 + */ + private Integer year; + + /** + * 周期月份(月度池使用) + */ + private Integer month; + + /** + * 周期季度(季度池使用,1-4) + */ + private Integer quarter; + + /** + * 总预算积分 + */ + private Integer totalBudget; + + /** + * 已使用积分 + */ + private Integer usedScore; + + /** + * 剩余积分 + */ + private Integer remainingScore; + + /** + * 任务完成奖励预算 + */ + private Integer taskRewardBudget; + + /** + * 任务完成奖励已使用 + */ + private Integer taskRewardUsed; + + /** + * 好评奖励预算 + */ + private Integer ratingRewardBudget; + + /** + * 好评奖励已使用 + */ + private Integer ratingRewardUsed; + + /** + * 竞赛奖励预算 + */ + private Integer contestRewardBudget; + + /** + * 竞赛奖励已使用 + */ + private Integer contestRewardUsed; + + /** + * 签到奖励预算 + */ + private Integer checkinRewardBudget; + + /** + * 签到奖励已使用 + */ + private Integer checkinRewardUsed; + + /** + * 其他奖励预算 + */ + private Integer otherRewardBudget; + + /** + * 其他奖励已使用 + */ + private Integer otherRewardUsed; + + /** + * 超支处理策略:stop-停止发放, alert-提醒继续, auto_increase-自动增加预算 + */ + private String overflowStrategy; + + /** + * 超支提醒阈值(百分比,如80表示使用80%时提醒) + */ + private Integer alertThreshold; + + /** + * 状态:active-进行中, closed-已关闭, settled-已结算 + */ + private String status; + + /** + * 创建人ID + */ + private Long creatorId; + + /** + * 是否删除:0-否,1-是 + */ + @TableLogic + private Integer deleted; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; +} diff --git a/backend/src/main/java/com/designhub/entity/ScorePoolRecord.java b/backend/src/main/java/com/designhub/entity/ScorePoolRecord.java new file mode 100644 index 0000000..8f6dfe1 --- /dev/null +++ b/backend/src/main/java/com/designhub/entity/ScorePoolRecord.java @@ -0,0 +1,76 @@ +package com.designhub.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 积分池使用记录实体 + */ +@Data +@TableName("score_pool_record") +public class ScorePoolRecord implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + + /** + * 积分池ID + */ + private Long poolId; + + /** + * 用户ID + */ + private Long userId; + + /** + * 用户姓名 + */ + private String userName; + + /** + * 奖励类型:task-任务, rating-好评, contest-竞赛, checkin-签到, goal-目标, other-其他 + */ + private String rewardType; + + /** + * 积分变动(正数为发放,负数为扣除) + */ + private Integer scoreChange; + + /** + * 变动前池剩余 + */ + private Integer beforeRemaining; + + /** + * 变动后池剩余 + */ + private Integer afterRemaining; + + /** + * 关联业务类型 + */ + private String bizType; + + /** + * 关联业务ID + */ + private Long bizId; + + /** + * 备注 + */ + private String remark; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; +} diff --git a/backend/src/main/java/com/designhub/entity/SystemConfig.java b/backend/src/main/java/com/designhub/entity/SystemConfig.java new file mode 100644 index 0000000..e89b606 --- /dev/null +++ b/backend/src/main/java/com/designhub/entity/SystemConfig.java @@ -0,0 +1,81 @@ +package com.designhub.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 系统配置实体类 + */ +@Data +@TableName("sys_config") +public class SystemConfig implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 主键ID + */ + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** + * 配置键 + */ + private String configKey; + + /** + * 配置值 + */ + private String configValue; + + /** + * 配置分组 + */ + private String configGroup; + + /** + * 配置名称 + */ + private String configName; + + /** + * 配置描述 + */ + private String description; + + /** + * 值类型:string/number/boolean/json + */ + private String valueType; + + /** + * 是否系统内置:0-否,1-是 + */ + private Integer builtIn; + + /** + * 排序号 + */ + private Integer sortOrder; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + + /** + * 删除标志:0-未删除,1-已删除 + */ + @TableLogic + private Integer deleted; +} + diff --git a/backend/src/main/java/com/designhub/entity/Task.java b/backend/src/main/java/com/designhub/entity/Task.java new file mode 100644 index 0000000..3ba2b60 --- /dev/null +++ b/backend/src/main/java/com/designhub/entity/Task.java @@ -0,0 +1,517 @@ +package com.designhub.entity; + +import com.baomidou.mybatisplus.annotation.*; +import java.io.Serializable; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 任务实体类 + * 注意:已移除@Data注解,改用手动getter/setter以解决Lombok编译问题 + */ +@TableName("task") +public class Task implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 任务ID + */ + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** + * 任务编号 + */ + private String taskNo; + + /** + * 第三方平台任务编号 + */ + private String thirdPartyTaskNo; + + /** + * 任务标题 + */ + private String title; + + /** + * 任务类型ID(单个,兼容旧数据) + */ + private Long taskTypeId; + + /** + * 任务类型ID列表(多个,逗号分隔,支持多类型) + */ + private String taskTypeIds; + + /** + * 任务子类型:new-新品 optimize-优化 redesign-改图 other-其他 + */ + private String taskSubType; + + /** + * 数量 + */ + private Integer quantity; + + /** + * 预估积分 + */ + private Integer score; + + /** + * 实际积分奖励 + */ + private BigDecimal scoreReward; + + /** + * 预估工时(小时) + */ + private BigDecimal estimatedHours; + + /** + * 预估时长(小时) + */ + private Integer estimatedDuration; + + /** + * 需求类型ID + */ + private Long demandTypeId; + + /** + * 品牌ID + */ + private Long brandId; + + /** + * 叶子类目ID + */ + private Long categoryId; + + /** + * SPU信息 + */ + private String spuInfo; + + /** + * 平台ID + */ + private Long platformId; + + /** + * 附件URL(多个用逗号分隔) + */ + private String attachmentUrl; + + /** + * 交付路径 + */ + private String deliveryPath; + + /** + * 发布人ID + */ + private Long publisherId; + + /** + * 发布人部门ID + */ + private Long publisherDeptId; + + /** + * 接收人ID + */ + private Long receiverId; + + /** + * 接收人部门ID + */ + private Long receiverDeptId; + + /** + * 发布类型:assigned-指定 recruitment-招募 default-默认 + */ + private String releaseType; + + /** + * 状态:pending-待接单 in_progress-进行中 review-待审核 rejected-已驳回 completed-已完成 cancelled-已取消 + */ + private String status; + + /** + * 审核状态:draft-草稿 pending_audit-待审核 audit_pass-审核通过 audit_reject-审核驳回 + */ + private String auditStatus; + + /** + * 审核人ID + */ + private Long auditorId; + + /** + * 审核时间 + */ + private LocalDateTime auditTime; + + /** + * 审核意见 + */ + private String auditComment; + + /** + * 完成进度(0-100) + */ + private Integer progress; + + /** + * 开始时间 + */ + private LocalDateTime startTime; + + /** + * 实际开始时间 + */ + private LocalDateTime actualStartTime; + + /** + * 截止时间 + */ + private LocalDateTime deadline; + + /** + * 完成时间 + */ + private LocalDateTime completeTime; + + /** + * 提交审核时间 + */ + private LocalDateTime submitTime; + + /** + * 实际结束时间 + */ + private LocalDateTime actualEndTime; + + /** + * 优先级:0-低 1-普通 2-高 3-紧急 + */ + private Integer priority; + + /** + * 备注信息 + */ + private String remark; + + /** + * 评分(1-5星) + */ + private Integer rating; + + /** + * 审核评论 + */ + private String reviewComment; + + /** + * 驳回原因 + */ + private String rejectReason; + + /** + * 驳回后的最晚交付时间 + */ + private LocalDateTime rejectDeadline; + + /** + * 是否紧急:0-否 1-是 + */ + private Integer isUrgent; + + /** + * 是否延误:0-否 1-是 + */ + private Integer isDelayed; + + /** + * 延误天数 + */ + private Integer delayDays; + + /** + * 是否流水线任务:0-否,1-是 + */ + private Integer isPipeline; + + /** + * 流水线模式:sequential(顺序)/parallel(并行) + */ + private String pipelineMode; + + /** + * 审核状态 + */ + private String approvalStatus; + + /** + * 当前审核人ID + */ + private Long currentApproverId; + + /** + * 当前审核人角色 + */ + private String currentApproverRole; + + /** + * 需求部门审核时间 + */ + private java.time.LocalDateTime demandApprovalTime; + + /** + * 接单部门审核时间 + */ + private java.time.LocalDateTime receivingApprovalTime; + + /** + * 逻辑删除:0-否 1-是 + */ + @TableLogic + private Integer deleted; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + + /** + * 关联字段(查询时返回,不映射到数据库) + */ + @TableField(exist = false) + private String brandName; + + @TableField(exist = false) + private String categoryName; + + @TableField(exist = false) + private String platformName; + + @TableField(exist = false) + private String publisherName; + + @TableField(exist = false) + private String publisherDeptName; + + @TableField(exist = false) + private String receiverName; + + @TableField(exist = false) + private String auditorName; + + /** + * 当前审核人姓名(查询时返回,不映射到数据库) + */ + @TableField(exist = false) + private String currentApproverName; + + // ==================== Getters and Setters (手动添加以解决Lombok编译问题) ==================== + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + + public String getTaskNo() { return taskNo; } + public void setTaskNo(String taskNo) { this.taskNo = taskNo; } + + public String getThirdPartyTaskNo() { return thirdPartyTaskNo; } + public void setThirdPartyTaskNo(String thirdPartyTaskNo) { this.thirdPartyTaskNo = thirdPartyTaskNo; } + + public String getTitle() { return title; } + public void setTitle(String title) { this.title = title; } + + public Long getTaskTypeId() { return taskTypeId; } + public void setTaskTypeId(Long taskTypeId) { this.taskTypeId = taskTypeId; } + + public String getTaskTypeIds() { return taskTypeIds; } + public void setTaskTypeIds(String taskTypeIds) { this.taskTypeIds = taskTypeIds; } + + public String getTaskSubType() { return taskSubType; } + public void setTaskSubType(String taskSubType) { this.taskSubType = taskSubType; } + + public Integer getQuantity() { return quantity; } + public void setQuantity(Integer quantity) { this.quantity = quantity; } + + public Integer getScore() { return score; } + public void setScore(Integer score) { this.score = score; } + + public BigDecimal getScoreReward() { return scoreReward; } + public void setScoreReward(BigDecimal scoreReward) { this.scoreReward = scoreReward; } + + public BigDecimal getEstimatedHours() { return estimatedHours; } + public void setEstimatedHours(BigDecimal estimatedHours) { this.estimatedHours = estimatedHours; } + + public Integer getEstimatedDuration() { return estimatedDuration; } + public void setEstimatedDuration(Integer estimatedDuration) { this.estimatedDuration = estimatedDuration; } + + public Long getDemandTypeId() { return demandTypeId; } + public void setDemandTypeId(Long demandTypeId) { this.demandTypeId = demandTypeId; } + + public Long getBrandId() { return brandId; } + public void setBrandId(Long brandId) { this.brandId = brandId; } + + public Long getCategoryId() { return categoryId; } + public void setCategoryId(Long categoryId) { this.categoryId = categoryId; } + + public String getSpuInfo() { return spuInfo; } + public void setSpuInfo(String spuInfo) { this.spuInfo = spuInfo; } + + public Long getPlatformId() { return platformId; } + public void setPlatformId(Long platformId) { this.platformId = platformId; } + + public String getAttachmentUrl() { return attachmentUrl; } + public void setAttachmentUrl(String attachmentUrl) { this.attachmentUrl = attachmentUrl; } + + public String getDeliveryPath() { return deliveryPath; } + public void setDeliveryPath(String deliveryPath) { this.deliveryPath = deliveryPath; } + + public Long getPublisherId() { return publisherId; } + public void setPublisherId(Long publisherId) { this.publisherId = publisherId; } + + public Long getPublisherDeptId() { return publisherDeptId; } + public void setPublisherDeptId(Long publisherDeptId) { this.publisherDeptId = publisherDeptId; } + + public Long getReceiverId() { return receiverId; } + public void setReceiverId(Long receiverId) { this.receiverId = receiverId; } + + public Long getReceiverDeptId() { return receiverDeptId; } + public void setReceiverDeptId(Long receiverDeptId) { this.receiverDeptId = receiverDeptId; } + + public String getReleaseType() { return releaseType; } + public void setReleaseType(String releaseType) { this.releaseType = releaseType; } + + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + + public String getAuditStatus() { return auditStatus; } + public void setAuditStatus(String auditStatus) { this.auditStatus = auditStatus; } + + public Long getAuditorId() { return auditorId; } + public void setAuditorId(Long auditorId) { this.auditorId = auditorId; } + + public LocalDateTime getAuditTime() { return auditTime; } + public void setAuditTime(LocalDateTime auditTime) { this.auditTime = auditTime; } + + public String getAuditComment() { return auditComment; } + public void setAuditComment(String auditComment) { this.auditComment = auditComment; } + + public Integer getProgress() { return progress; } + public void setProgress(Integer progress) { this.progress = progress; } + + public LocalDateTime getStartTime() { return startTime; } + public void setStartTime(LocalDateTime startTime) { this.startTime = startTime; } + + public LocalDateTime getActualStartTime() { return actualStartTime; } + public void setActualStartTime(LocalDateTime actualStartTime) { this.actualStartTime = actualStartTime; } + + public LocalDateTime getDeadline() { return deadline; } + public void setDeadline(LocalDateTime deadline) { this.deadline = deadline; } + + public LocalDateTime getCompleteTime() { return completeTime; } + public void setCompleteTime(LocalDateTime completeTime) { this.completeTime = completeTime; } + + public LocalDateTime getSubmitTime() { return submitTime; } + public void setSubmitTime(LocalDateTime submitTime) { this.submitTime = submitTime; } + + public LocalDateTime getActualEndTime() { return actualEndTime; } + public void setActualEndTime(LocalDateTime actualEndTime) { this.actualEndTime = actualEndTime; } + + public Integer getPriority() { return priority; } + public void setPriority(Integer priority) { this.priority = priority; } + + public String getRemark() { return remark; } + public void setRemark(String remark) { this.remark = remark; } + + public Integer getRating() { return rating; } + public void setRating(Integer rating) { this.rating = rating; } + + public String getReviewComment() { return reviewComment; } + public void setReviewComment(String reviewComment) { this.reviewComment = reviewComment; } + + public String getRejectReason() { return rejectReason; } + public void setRejectReason(String rejectReason) { this.rejectReason = rejectReason; } + + public LocalDateTime getRejectDeadline() { return rejectDeadline; } + public void setRejectDeadline(LocalDateTime rejectDeadline) { this.rejectDeadline = rejectDeadline; } + + public Integer getIsUrgent() { return isUrgent; } + public void setIsUrgent(Integer isUrgent) { this.isUrgent = isUrgent; } + + public Integer getIsDelayed() { return isDelayed; } + public void setIsDelayed(Integer isDelayed) { this.isDelayed = isDelayed; } + + public Integer getDelayDays() { return delayDays; } + public void setDelayDays(Integer delayDays) { this.delayDays = delayDays; } + + public Integer getIsPipeline() { return isPipeline; } + public void setIsPipeline(Integer isPipeline) { this.isPipeline = isPipeline; } + + public String getPipelineMode() { return pipelineMode; } + public void setPipelineMode(String pipelineMode) { this.pipelineMode = pipelineMode; } + + public String getApprovalStatus() { return approvalStatus; } + public void setApprovalStatus(String approvalStatus) { this.approvalStatus = approvalStatus; } + + public Long getCurrentApproverId() { return currentApproverId; } + public void setCurrentApproverId(Long currentApproverId) { this.currentApproverId = currentApproverId; } + + public String getCurrentApproverRole() { return currentApproverRole; } + public void setCurrentApproverRole(String currentApproverRole) { this.currentApproverRole = currentApproverRole; } + + public LocalDateTime getDemandApprovalTime() { return demandApprovalTime; } + public void setDemandApprovalTime(LocalDateTime demandApprovalTime) { this.demandApprovalTime = demandApprovalTime; } + + public LocalDateTime getReceivingApprovalTime() { return receivingApprovalTime; } + public void setReceivingApprovalTime(LocalDateTime receivingApprovalTime) { this.receivingApprovalTime = receivingApprovalTime; } + + public Integer getDeleted() { return deleted; } + public void setDeleted(Integer deleted) { this.deleted = deleted; } + + public LocalDateTime getCreateTime() { return createTime; } + public void setCreateTime(LocalDateTime createTime) { this.createTime = createTime; } + + public LocalDateTime getUpdateTime() { return updateTime; } + public void setUpdateTime(LocalDateTime updateTime) { this.updateTime = updateTime; } + + public String getBrandName() { return brandName; } + public void setBrandName(String brandName) { this.brandName = brandName; } + + public String getCategoryName() { return categoryName; } + public void setCategoryName(String categoryName) { this.categoryName = categoryName; } + + public String getPlatformName() { return platformName; } + public void setPlatformName(String platformName) { this.platformName = platformName; } + + public String getPublisherName() { return publisherName; } + public void setPublisherName(String publisherName) { this.publisherName = publisherName; } + + public String getPublisherDeptName() { return publisherDeptName; } + public void setPublisherDeptName(String publisherDeptName) { this.publisherDeptName = publisherDeptName; } + + public String getReceiverName() { return receiverName; } + public void setReceiverName(String receiverName) { this.receiverName = receiverName; } + + public String getAuditorName() { return auditorName; } + public void setAuditorName(String auditorName) { this.auditorName = auditorName; } + + public String getCurrentApproverName() { return currentApproverName; } + public void setCurrentApproverName(String currentApproverName) { this.currentApproverName = currentApproverName; } +} + diff --git a/backend/src/main/java/com/designhub/entity/TaskApprovalLog.java b/backend/src/main/java/com/designhub/entity/TaskApprovalLog.java new file mode 100644 index 0000000..4a3dbcd --- /dev/null +++ b/backend/src/main/java/com/designhub/entity/TaskApprovalLog.java @@ -0,0 +1,136 @@ +package com.designhub.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 任务审核记录实体 + */ +@Data +@TableName("task_approval_log") +public class TaskApprovalLog implements Serializable { + private static final long serialVersionUID = 1L; + + /** + * 主键 + */ + @TableId(type = IdType.AUTO) + private Long id; + + /** + * 任务ID + */ + private Long taskId; + + /** + * 审核级别:1-需求部门,2-接单部门 + */ + private Integer approvalLevel; + + /** + * 审核人ID + */ + private Long approverId; + + /** + * 审核人姓名 + */ + private String approverName; + + /** + * 操作:approve/reject/modify + */ + private String action; + + /** + * 审核意见 + */ + private String comment; + + /** + * 修改的内容(JSON格式) + */ + private String changes; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + // Getters and Setters + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getTaskId() { + return taskId; + } + + public void setTaskId(Long taskId) { + this.taskId = taskId; + } + + public Integer getApprovalLevel() { + return approvalLevel; + } + + public void setApprovalLevel(Integer approvalLevel) { + this.approvalLevel = approvalLevel; + } + + public Long getApproverId() { + return approverId; + } + + public void setApproverId(Long approverId) { + this.approverId = approverId; + } + + public String getApproverName() { + return approverName; + } + + public void setApproverName(String approverName) { + this.approverName = approverName; + } + + public String getAction() { + return action; + } + + public void setAction(String action) { + this.action = action; + } + + public String getComment() { + return comment; + } + + public void setComment(String comment) { + this.comment = comment; + } + + public String getChanges() { + return changes; + } + + public void setChanges(String changes) { + this.changes = changes; + } + + public LocalDateTime getCreateTime() { + return createTime; + } + + public void setCreateTime(LocalDateTime createTime) { + this.createTime = createTime; + } +} diff --git a/backend/src/main/java/com/designhub/entity/TaskAssignLog.java b/backend/src/main/java/com/designhub/entity/TaskAssignLog.java new file mode 100644 index 0000000..4d4e62f --- /dev/null +++ b/backend/src/main/java/com/designhub/entity/TaskAssignLog.java @@ -0,0 +1,53 @@ +package com.designhub.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 任务指派记录实体类 + */ +@Data +@TableName("task_assign_log") +public class TaskAssignLog implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 记录ID + */ + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** + * 任务ID + */ + private Long taskId; + + /** + * 指派人ID(部门负责人) + */ + private Long assignerId; + + /** + * 被指派人ID + */ + private Long assigneeId; + + /** + * 指派类型:force-强制指派 recommend-推荐 + */ + private String assignType; + + /** + * 指派说明 + */ + private String comment; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; +} diff --git a/backend/src/main/java/com/designhub/entity/TaskAuditLog.java b/backend/src/main/java/com/designhub/entity/TaskAuditLog.java new file mode 100644 index 0000000..9105437 --- /dev/null +++ b/backend/src/main/java/com/designhub/entity/TaskAuditLog.java @@ -0,0 +1,53 @@ +package com.designhub.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 任务审核日志实体类 + */ +@Data +@TableName("task_audit_log") +public class TaskAuditLog implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 日志ID + */ + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** + * 任务ID + */ + private Long taskId; + + /** + * 审核人ID + */ + private Long auditorId; + + /** + * 审核人姓名 + */ + private String auditorName; + + /** + * 操作:submit-提交审核 pass-审核通过 reject-审核驳回 + */ + private String action; + + /** + * 审核意见 + */ + private String comment; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; +} diff --git a/backend/src/main/java/com/designhub/entity/TaskItem.java b/backend/src/main/java/com/designhub/entity/TaskItem.java new file mode 100644 index 0000000..d49a76b --- /dev/null +++ b/backend/src/main/java/com/designhub/entity/TaskItem.java @@ -0,0 +1,197 @@ +package com.designhub.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.io.Serializable; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 任务子项实体类(流水线任务) + */ +@Data +@TableName("task_item") +public class TaskItem implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 主键 + */ + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** + * 主任务ID + */ + private Long taskId; + + /** + * 子项编号 + */ + private String itemNo; + + /** + * 子任务组号(同组任务由同一接单部门处理) + */ + private Integer groupNo; + + /** + * 任务类型ID + */ + private Long taskTypeId; + + /** + * 数量 + */ + private Integer quantity; + + /** + * 单价积分 + */ + private Integer unitScore; + + /** + * 总积分 + */ + private Integer totalScore; + + /** + * 预估工时 + */ + private BigDecimal estimatedHours; + + /** + * 接单人ID + */ + private Long receiverId; + + /** + * 接单人姓名 + */ + private String receiverName; + + /** + * 状态:locked/pending/in_progress/completed/rejected + */ + private String status; + + /** + * 进度0-100 + */ + private Integer progress; + + /** + * 是否锁定:0-否,1-是(顺序流水线中使用) + */ + private Integer isLocked; + + /** + * 前置子任务ID(顺序流水线中使用) + */ + private Long prevItemId; + + /** + * 交付路径 + */ + private String deliveryPath; + + /** + * 提交时间 + */ + private LocalDateTime submitTime; + + /** + * 完成时间 + */ + private LocalDateTime completeTime; + + /** + * 排序 + */ + private Integer sortOrder; + + /** + * 创建时间 + */ + private LocalDateTime createTime; + + /** + * 更新时间 + */ + private LocalDateTime updateTime; + + // ========== 非数据库字段 ========== + + /** + * 任务类型名称(非数据库字段) + */ + @TableField(exist = false) + private String taskTypeName; + + /** + * 主任务信息(非数据库字段) + */ + @TableField(exist = false) + private Task mainTask; + + /** + * 主任务标题(非数据库字段) + */ + @TableField(exist = false) + private String mainTaskTitle; + + /** + * 流水线模式(非数据库字段) + */ + @TableField(exist = false) + private String pipelineMode; + + /** + * 总子任务数(非数据库字段) + */ + @TableField(exist = false) + private Integer totalItems; + + /** + * 品牌名称(非数据库字段) + */ + @TableField(exist = false) + private String brandName; + + /** + * 类目名称(非数据库字段) + */ + @TableField(exist = false) + private String categoryName; + + /** + * 平台名称(非数据库字段) + */ + @TableField(exist = false) + private String platformName; + + /** + * 发布人姓名(非数据库字段) + */ + @TableField(exist = false) + private String publisherName; + + /** + * SPU信息(非数据库字段) + */ + @TableField(exist = false) + private String spuInfo; + + /** + * 任务子类型(非数据库字段) + */ + @TableField(exist = false) + private String taskSubType; + + /** + * 附件URL(非数据库字段) + */ + @TableField(exist = false) + private String attachmentUrl; +} diff --git a/backend/src/main/java/com/designhub/entity/TaskProgressUpdateLog.java b/backend/src/main/java/com/designhub/entity/TaskProgressUpdateLog.java new file mode 100644 index 0000000..9464ef9 --- /dev/null +++ b/backend/src/main/java/com/designhub/entity/TaskProgressUpdateLog.java @@ -0,0 +1,99 @@ +package com.designhub.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.io.Serializable; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * 任务进度更新记录实体类 + */ +@Data +@TableName("task_progress_update_log") +public class TaskProgressUpdateLog implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 记录ID + */ + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** + * 任务ID + */ + private Long taskId; + + /** + * 任务编号 + */ + private String taskNo; + + /** + * 任务标题 + */ + private String taskTitle; + + /** + * 接单人ID + */ + private Long receiverId; + + /** + * 接单人姓名 + */ + private String receiverName; + + /** + * 检查日期 + */ + private LocalDate checkDate; + + /** + * 当天是否更新过进度:0-未更新 1-已更新 + */ + private Integer isUpdated; + + /** + * 最后更新时间 + */ + private LocalDateTime lastUpdateTime; + + /** + * 检查前进度 + */ + private Integer progressBefore; + + /** + * 检查后进度(如果更新了) + */ + private Integer progressAfter; + + /** + * 是否已发送通知:0-未发送 1-已发送 + */ + private Integer notificationSent; + + /** + * 通知发送时间 + */ + private LocalDateTime notificationTime; + + /** + * 任务状态 + */ + private String taskStatus; + + /** + * 备注 + */ + private String remark; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; +} diff --git a/backend/src/main/java/com/designhub/entity/TaskReview.java b/backend/src/main/java/com/designhub/entity/TaskReview.java new file mode 100644 index 0000000..70c7167 --- /dev/null +++ b/backend/src/main/java/com/designhub/entity/TaskReview.java @@ -0,0 +1,65 @@ +package com.designhub.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 任务评价实体类 + */ +@Data +@TableName("task_review") +public class TaskReview implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 主键ID + */ + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** + * 任务ID + */ + private Long taskId; + + /** + * 评价人ID(发布者) + */ + private Long reviewerId; + + /** + * 被评价人ID(接收者) + */ + private Long receiverId; + + /** + * 评分(1-5星) + */ + private Integer rating; + + /** + * 评价内容 + */ + private String comment; + + /** + * 评价标签(JSON数组) + */ + private String tags; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; +} + diff --git a/backend/src/main/java/com/designhub/entity/TaskSubType.java b/backend/src/main/java/com/designhub/entity/TaskSubType.java new file mode 100644 index 0000000..c90ecd9 --- /dev/null +++ b/backend/src/main/java/com/designhub/entity/TaskSubType.java @@ -0,0 +1,124 @@ +package com.designhub.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 任务子类型实体 + */ +@Data +@TableName("task_sub_type") +public class TaskSubType implements Serializable { + private static final long serialVersionUID = 1L; + + /** + * 主键 + */ + @TableId(type = IdType.AUTO) + private Long id; + + /** + * 子类型名称 + */ + private String name; + + /** + * 子类型编码 + */ + private String code; + + /** + * 描述 + */ + private String description; + + /** + * 排序 + */ + private Integer sortOrder; + + /** + * 是否启用:0-否,1-是 + */ + private Integer isEnabled; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + + // Getters and Setters + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public Integer getSortOrder() { + return sortOrder; + } + + public void setSortOrder(Integer sortOrder) { + this.sortOrder = sortOrder; + } + + public Integer getIsEnabled() { + return isEnabled; + } + + public void setIsEnabled(Integer isEnabled) { + this.isEnabled = isEnabled; + } + + public LocalDateTime getCreateTime() { + return createTime; + } + + public void setCreateTime(LocalDateTime createTime) { + this.createTime = createTime; + } + + public LocalDateTime getUpdateTime() { + return updateTime; + } + + public void setUpdateTime(LocalDateTime updateTime) { + this.updateTime = updateTime; + } +} diff --git a/backend/src/main/java/com/designhub/entity/TeamPK.java b/backend/src/main/java/com/designhub/entity/TeamPK.java new file mode 100644 index 0000000..8d97374 --- /dev/null +++ b/backend/src/main/java/com/designhub/entity/TeamPK.java @@ -0,0 +1,359 @@ +package com.designhub.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 团队PK实体 + */ +@Data +@TableName("team_pk") +public class TeamPK implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + + /** + * PK名称 + */ + private String name; + + /** + * PK描述 + */ + private String description; + + /** + * 甲方部门ID + */ + private Long teamADeptId; + + /** + * 甲方部门名称 + */ + private String teamAName; + + /** + * 乙方部门ID + */ + private Long teamBDeptId; + + /** + * 乙方部门名称 + */ + private String teamBName; + + /** + * 开始时间 + */ + private LocalDateTime startTime; + + /** + * 结束时间 + */ + private LocalDateTime endTime; + + /** + * PK维度配置(JSON对象) + * 例如:{"taskCount":{"weight":30},"fiveStarCount":{"weight":25},"onTimeRate":{"weight":25},"avgRating":{"weight":20}} + */ + private String dimensions; + + /** + * 甲方当前得分 + */ + private Integer teamAScore; + + /** + * 乙方当前得分 + */ + private Integer teamBScore; + + /** + * 甲方完成任务数 + */ + private Integer teamATaskCount; + + /** + * 乙方完成任务数 + */ + private Integer teamBTaskCount; + + /** + * 甲方五星好评数 + */ + private Integer teamAFiveStarCount; + + /** + * 乙方五星好评数 + */ + private Integer teamBFiveStarCount; + + /** + * 甲方准时完成率(百分比*100) + */ + private Integer teamAOnTimeRate; + + /** + * 乙方准时完成率(百分比*100) + */ + private Integer teamBOnTimeRate; + + /** + * 胜利奖励配置(JSON对象) + * 例如:{"winnerReward":200,"loserReward":100,"mvpReward":300} + */ + private String rewards; + + /** + * PK状态:draft-草稿, scheduled-待开始, active-进行中, ended-已结束, cancelled-已取消 + */ + private String status; + + /** + * 获胜方:A-甲方, B-乙方, draw-平局, null-未结束 + */ + private String winner; + + /** + * MVP用户ID + */ + private Long mvpUserId; + + /** + * 创建人ID + */ + private Long creatorId; + + /** + * 是否删除:0-否,1-是 + */ + @TableLogic + private Integer deleted; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + + // Getters and Setters (手动添加以解决Lombok编译问题) + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public Long getTeamADeptId() { + return teamADeptId; + } + + public void setTeamADeptId(Long teamADeptId) { + this.teamADeptId = teamADeptId; + } + + public String getTeamAName() { + return teamAName; + } + + public void setTeamAName(String teamAName) { + this.teamAName = teamAName; + } + + public Long getTeamBDeptId() { + return teamBDeptId; + } + + public void setTeamBDeptId(Long teamBDeptId) { + this.teamBDeptId = teamBDeptId; + } + + public String getTeamBName() { + return teamBName; + } + + public void setTeamBName(String teamBName) { + this.teamBName = teamBName; + } + + public LocalDateTime getStartTime() { + return startTime; + } + + public void setStartTime(LocalDateTime startTime) { + this.startTime = startTime; + } + + public LocalDateTime getEndTime() { + return endTime; + } + + public void setEndTime(LocalDateTime endTime) { + this.endTime = endTime; + } + + public String getDimensions() { + return dimensions; + } + + public void setDimensions(String dimensions) { + this.dimensions = dimensions; + } + + public Integer getTeamAScore() { + return teamAScore; + } + + public void setTeamAScore(Integer teamAScore) { + this.teamAScore = teamAScore; + } + + public Integer getTeamBScore() { + return teamBScore; + } + + public void setTeamBScore(Integer teamBScore) { + this.teamBScore = teamBScore; + } + + public Integer getTeamATaskCount() { + return teamATaskCount; + } + + public void setTeamATaskCount(Integer teamATaskCount) { + this.teamATaskCount = teamATaskCount; + } + + public Integer getTeamBTaskCount() { + return teamBTaskCount; + } + + public void setTeamBTaskCount(Integer teamBTaskCount) { + this.teamBTaskCount = teamBTaskCount; + } + + public Integer getTeamAFiveStarCount() { + return teamAFiveStarCount; + } + + public void setTeamAFiveStarCount(Integer teamAFiveStarCount) { + this.teamAFiveStarCount = teamAFiveStarCount; + } + + public Integer getTeamBFiveStarCount() { + return teamBFiveStarCount; + } + + public void setTeamBFiveStarCount(Integer teamBFiveStarCount) { + this.teamBFiveStarCount = teamBFiveStarCount; + } + + public Integer getTeamAOnTimeRate() { + return teamAOnTimeRate; + } + + public void setTeamAOnTimeRate(Integer teamAOnTimeRate) { + this.teamAOnTimeRate = teamAOnTimeRate; + } + + public Integer getTeamBOnTimeRate() { + return teamBOnTimeRate; + } + + public void setTeamBOnTimeRate(Integer teamBOnTimeRate) { + this.teamBOnTimeRate = teamBOnTimeRate; + } + + public String getRewards() { + return rewards; + } + + public void setRewards(String rewards) { + this.rewards = rewards; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getWinner() { + return winner; + } + + public void setWinner(String winner) { + this.winner = winner; + } + + public Long getMvpUserId() { + return mvpUserId; + } + + public void setMvpUserId(Long mvpUserId) { + this.mvpUserId = mvpUserId; + } + + public Long getCreatorId() { + return creatorId; + } + + public void setCreatorId(Long creatorId) { + this.creatorId = creatorId; + } + + public Integer getDeleted() { + return deleted; + } + + public void setDeleted(Integer deleted) { + this.deleted = deleted; + } + + public LocalDateTime getCreateTime() { + return createTime; + } + + public void setCreateTime(LocalDateTime createTime) { + this.createTime = createTime; + } + + public LocalDateTime getUpdateTime() { + return updateTime; + } + + public void setUpdateTime(LocalDateTime updateTime) { + this.updateTime = updateTime; + } +} diff --git a/backend/src/main/java/com/designhub/entity/TeamScorePool.java b/backend/src/main/java/com/designhub/entity/TeamScorePool.java new file mode 100644 index 0000000..57e60d8 --- /dev/null +++ b/backend/src/main/java/com/designhub/entity/TeamScorePool.java @@ -0,0 +1,135 @@ +package com.designhub.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 团队积分池实体类 + */ +@Data +@TableName("team_score_pool") +public class TeamScorePool implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 主键ID + */ + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** + * 部门ID + */ + private Long deptId; + + /** + * 部门名称 + */ + private String deptName; + + /** + * 积分余额 + */ + private Integer balance; + + /** + * 累计充值积分 + */ + private Integer totalRecharge; + + /** + * 累计消耗积分 + */ + private Integer totalConsume; + + /** + * 累计获得积分(接单部门) + */ + private Integer totalEarn; + + /** + * 总积分额度 + */ + private Integer totalScore; + + /** + * 剩余积分 + */ + private Integer remainingScore; + + /** + * 是否启用消耗限制:0-否,1-是 + */ + private Integer limitEnabled; + + /** + * 限制周期:DAILY-每天,WEEKLY-每周,MONTHLY-每月 + */ + private String limitPeriod; + + /** + * 周期限制额度 + */ + private Integer periodLimit; + + /** + * 当前周期已消耗 + */ + private Integer periodUsed; + + /** + * 当前周期开始时间 + */ + private LocalDateTime periodStartTime; + + /** + * 每日限额 + */ + private Integer dailyLimit; + + /** + * 每周限额 + */ + private Integer weeklyLimit; + + /** + * 每月限额 + */ + private Integer monthlyLimit; + + /** + * 每日已使用 + */ + private Integer dailyUsed; + + /** + * 每周已使用 + */ + private Integer weeklyUsed; + + /** + * 每月已使用 + */ + private Integer monthlyUsed; + + /** + * 预警阈值(百分比,如80表示80%) + */ + private Integer alertThreshold; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; +} + diff --git a/backend/src/main/java/com/designhub/entity/ThirdPartyCallback.java b/backend/src/main/java/com/designhub/entity/ThirdPartyCallback.java new file mode 100644 index 0000000..41f28c4 --- /dev/null +++ b/backend/src/main/java/com/designhub/entity/ThirdPartyCallback.java @@ -0,0 +1,76 @@ +package com.designhub.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.time.LocalDateTime; + +/** + * 第三方回调记录实体类 + */ +@Data +@TableName("third_party_callback") +public class ThirdPartyCallback { + + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** + * 关联的任务ID + */ + private Long taskId; + + /** + * 第三方平台任务编号 + */ + private String thirdPartyTaskNo; + + /** + * 回调类型:complete-任务完成, status_update-状态更新 + */ + private String callbackType; + + /** + * 回调处理状态:pending-待处理, success-成功, failed-失败 + */ + private String callbackStatus; + + /** + * 回调请求数据(JSON格式) + */ + private String requestData; + + /** + * 响应数据(JSON格式) + */ + private String responseData; + + /** + * 错误信息 + */ + private String errorMessage; + + /** + * 重试次数 + */ + private Integer retryCount; + + /** + * 回调接收时间 + */ + private LocalDateTime callbackTime; + + /** + * 处理完成时间 + */ + private LocalDateTime processedTime; + + /** + * 创建时间 + */ + private LocalDateTime createTime; + + /** + * 更新时间 + */ + private LocalDateTime updateTime; +} diff --git a/backend/src/main/java/com/designhub/entity/User.java b/backend/src/main/java/com/designhub/entity/User.java new file mode 100644 index 0000000..8bc35e6 --- /dev/null +++ b/backend/src/main/java/com/designhub/entity/User.java @@ -0,0 +1,101 @@ +package com.designhub.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 用户实体类 + */ +@Data +@TableName("sys_user") +public class User implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 用户ID + */ + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** + * 用户名 + */ + private String username; + + /** + * 密码 + */ + private String password; + + /** + * 真实姓名 + */ + private String realName; + + /** + * 邮箱 + */ + private String email; + + /** + * 手机号 + */ + private String phone; + + /** + * 头像URL + */ + private String avatar; + + /** + * 钉钉用户ID + */ + private String dingUserId; + + /** + * 部门ID + */ + private Long deptId; + + /** + * 部门名称 + */ + private String deptName; + + /** + * 角色ID列表(逗号分隔) + */ + private String roleIds; + + /** + * 状态:0-禁用 1-启用 + */ + private Integer status; + + /** + * 用户积分 + */ + private Integer score; + + /** + * 逻辑删除:0-否 1-是 + */ + @TableLogic + private Integer deleted; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; +} + diff --git a/backend/src/main/java/com/designhub/entity/UserAchievement.java b/backend/src/main/java/com/designhub/entity/UserAchievement.java new file mode 100644 index 0000000..46ef0f6 --- /dev/null +++ b/backend/src/main/java/com/designhub/entity/UserAchievement.java @@ -0,0 +1,72 @@ +package com.designhub.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 用户成就关联实体类 + */ +@Data +@TableName("user_achievement") +public class UserAchievement implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 主键ID + */ + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** + * 用户ID + */ + private Long userId; + + /** + * 成就ID + */ + private Long achievementId; + + /** + * 达成时间 + */ + private LocalDateTime achievedTime; + + /** + * 当前进度值 + */ + private Integer progressValue; + + /** + * 是否已领取奖励 + */ + private Integer rewardClaimed; + + /** + * 领取奖励时间 + */ + private LocalDateTime claimTime; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + + /** + * 删除标志:0-未删除,1-已删除 + */ + @TableLogic + private Integer deleted; +} + diff --git a/backend/src/main/java/com/designhub/entity/UserGoal.java b/backend/src/main/java/com/designhub/entity/UserGoal.java new file mode 100644 index 0000000..2ffe4ca --- /dev/null +++ b/backend/src/main/java/com/designhub/entity/UserGoal.java @@ -0,0 +1,108 @@ +package com.designhub.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 用户目标实体 + */ +@Data +@TableName("user_goal") +public class UserGoal implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + + /** + * 用户ID + */ + private Long userId; + + /** + * 目标类型:task_count-任务数量, score-积分, rank-排名, rating-评分 + */ + private String goalType; + + /** + * 目标名称 + */ + private String goalName; + + /** + * 目标描述 + */ + private String description; + + /** + * 目标值 + */ + private Integer targetValue; + + /** + * 当前进度值 + */ + private Integer currentValue; + + /** + * 目标周期:daily-每日, weekly-每周, monthly-每月, quarterly-每季度, yearly-每年, custom-自定义 + */ + private String period; + + /** + * 周期开始时间 + */ + private LocalDateTime periodStart; + + /** + * 周期结束时间 + */ + private LocalDateTime periodEnd; + + /** + * 目标状态:active-进行中, completed-已完成, failed-未完成, cancelled-已取消 + */ + private String status; + + /** + * 完成时间 + */ + private LocalDateTime completedTime; + + /** + * 奖励积分(达成目标后) + */ + private Integer rewardScore; + + /** + * 是否已领取奖励:0-否,1-是 + */ + private Integer rewardClaimed; + + /** + * 提醒设置:none-不提醒, daily-每天, before_deadline-截止前 + */ + private String reminderType; + + /** + * 是否删除:0-否,1-是 + */ + @TableLogic + private Integer deleted; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; +} diff --git a/backend/src/main/java/com/designhub/entity/UserRole.java b/backend/src/main/java/com/designhub/entity/UserRole.java new file mode 100644 index 0000000..e5c01e4 --- /dev/null +++ b/backend/src/main/java/com/designhub/entity/UserRole.java @@ -0,0 +1,39 @@ +package com.designhub.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 用户角色关联实体类 + */ +@Data +@TableName("sys_user_role") +public class UserRole implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 主键ID + */ + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** + * 用户ID + */ + private Long userId; + + /** + * 角色ID + */ + private Long roleId; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; +} + diff --git a/backend/src/main/java/com/designhub/exception/BusinessException.java b/backend/src/main/java/com/designhub/exception/BusinessException.java new file mode 100644 index 0000000..e6af95a --- /dev/null +++ b/backend/src/main/java/com/designhub/exception/BusinessException.java @@ -0,0 +1,50 @@ +package com.designhub.exception; + +import com.designhub.common.ResultCode; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 业务异常类 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class BusinessException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + /** + * 错误码 + */ + private Integer code; + + /** + * 错误信息 + */ + private String message; + + public BusinessException(String message) { + super(message); + this.code = 500; + this.message = message; + } + + public BusinessException(Integer code, String message) { + super(message); + this.code = code; + this.message = message; + } + + public BusinessException(ResultCode resultCode) { + super(resultCode.getMessage()); + this.code = resultCode.getCode(); + this.message = resultCode.getMessage(); + } + + public BusinessException(ResultCode resultCode, String message) { + super(message); + this.code = resultCode.getCode(); + this.message = message; + } +} + diff --git a/backend/src/main/java/com/designhub/exception/GlobalExceptionHandler.java b/backend/src/main/java/com/designhub/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..b61b01d --- /dev/null +++ b/backend/src/main/java/com/designhub/exception/GlobalExceptionHandler.java @@ -0,0 +1,84 @@ +package com.designhub.exception; + +import com.designhub.common.Result; +import com.designhub.common.ResultCode; +import lombok.extern.slf4j.Slf4j; +import org.springframework.validation.BindException; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; + +import java.util.stream.Collectors; + +/** + * 全局异常处理器 + */ +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + /** + * 处理业务异常 + */ + @ExceptionHandler(BusinessException.class) + public Result handleBusinessException(BusinessException e) { + log.error("业务异常:{}", e.getMessage()); + return Result.error(e.getCode(), e.getMessage()); + } + + /** + * 处理参数校验异常 + */ + @ExceptionHandler(MethodArgumentNotValidException.class) + public Result handleValidException(MethodArgumentNotValidException e) { + String message = e.getBindingResult().getFieldErrors() + .stream() + .map(FieldError::getDefaultMessage) + .collect(Collectors.joining(", ")); + log.error("参数校验失败:{}", message); + return Result.error(ResultCode.BAD_REQUEST.getCode(), message); + } + + /** + * 处理绑定异常 + */ + @ExceptionHandler(BindException.class) + public Result handleBindException(BindException e) { + String message = e.getFieldErrors() + .stream() + .map(FieldError::getDefaultMessage) + .collect(Collectors.joining(", ")); + log.error("参数绑定失败:{}", message); + return Result.error(ResultCode.BAD_REQUEST.getCode(), message); + } + + /** + * 处理参数类型不匹配异常 + */ + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public Result handleTypeMismatchException(MethodArgumentTypeMismatchException e) { + log.error("参数类型不匹配:{}", e.getMessage()); + return Result.error(ResultCode.BAD_REQUEST.getCode(), "参数类型错误"); + } + + /** + * 处理IllegalArgumentException + */ + @ExceptionHandler(IllegalArgumentException.class) + public Result handleIllegalArgumentException(IllegalArgumentException e) { + log.error("非法参数异常:{}", e.getMessage()); + return Result.error(ResultCode.BAD_REQUEST.getCode(), e.getMessage()); + } + + /** + * 处理其他所有异常 + */ + @ExceptionHandler(Exception.class) + public Result handleException(Exception e) { + log.error("系统异常:", e); + return Result.error(ResultCode.ERROR.getCode(), "系统异常,请联系管理员"); + } +} + diff --git a/backend/src/main/java/com/designhub/interceptor/PermissionInterceptor.java b/backend/src/main/java/com/designhub/interceptor/PermissionInterceptor.java new file mode 100644 index 0000000..83de279 --- /dev/null +++ b/backend/src/main/java/com/designhub/interceptor/PermissionInterceptor.java @@ -0,0 +1,166 @@ +package com.designhub.interceptor; + +import com.designhub.annotation.RequirePermission; +import com.designhub.annotation.RequireRole; +import com.designhub.entity.Role; +import com.designhub.exception.BusinessException; +import com.designhub.service.PermissionService; +import com.designhub.service.RoleService; +import com.designhub.util.JwtUtil; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerInterceptor; + +import java.util.Arrays; +import java.util.List; + +/** + * 权限拦截器 + * 拦截带有@RequirePermission或@RequireRole注解的接口 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class PermissionInterceptor implements HandlerInterceptor { + + private final PermissionService permissionService; + private final RoleService roleService; + private final JwtUtil jwtUtil; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { + if (!(handler instanceof HandlerMethod)) { + return true; + } + + HandlerMethod handlerMethod = (HandlerMethod) handler; + + // 检查权限注解 + RequirePermission requirePermission = handlerMethod.getMethodAnnotation(RequirePermission.class); + if (requirePermission == null) { + requirePermission = handlerMethod.getBeanType().getAnnotation(RequirePermission.class); + } + + // 检查角色注解 + RequireRole requireRole = handlerMethod.getMethodAnnotation(RequireRole.class); + if (requireRole == null) { + requireRole = handlerMethod.getBeanType().getAnnotation(RequireRole.class); + } + + // 获取当前登录用户ID(从JWT Token或Session中获取) + Long userId = getCurrentUserId(request); + + // 如果方法或类上有权限或角色注解,则需要验证用户身份和权限 + if (requirePermission != null || requireRole != null) { + if (userId == null) { + throw new BusinessException("未登录或登录已过期"); + } + + // 检查权限 + if (requirePermission != null) { + checkPermissions(userId, requirePermission); + } + + // 检查角色 + if (requireRole != null) { + checkRoles(userId, requireRole); + } + } + // 如果没有权限或角色注解,允许访问(公开接口) + // 具体的权限控制由各个Controller方法自己决定 + + return true; + } + + /** + * 检查权限 + */ + private void checkPermissions(Long userId, RequirePermission requirePermission) { + String[] requiredPermissions = requirePermission.value(); + if (requiredPermissions.length == 0) { + return; + } + + // 获取用户的所有权限代码 + List userPermissions = permissionService.getPermissionCodesByUserId(userId); + + boolean hasPermission; + if (requirePermission.mode() == RequirePermission.LogicalMode.AND) { + // AND模式:需要拥有所有权限 + hasPermission = userPermissions.containsAll(Arrays.asList(requiredPermissions)); + } else { + // OR模式:需要拥有任一权限 + hasPermission = Arrays.stream(requiredPermissions) + .anyMatch(userPermissions::contains); + } + + if (!hasPermission) { + throw new BusinessException("权限不足"); + } + } + + /** + * 检查角色 + */ + private void checkRoles(Long userId, RequireRole requireRole) { + String[] requiredRoles = requireRole.value(); + if (requiredRoles.length == 0) { + return; + } + + // 获取用户的所有角色代码 + List userRoles = roleService.getRolesByUserId(userId); + List userRoleCodes = userRoles.stream() + .map(Role::getRoleCode) + .toList(); + + boolean hasRole; + if (requireRole.mode() == RequireRole.LogicalMode.AND) { + // AND模式:需要拥有所有角色 + hasRole = userRoleCodes.containsAll(Arrays.asList(requiredRoles)); + } else { + // OR模式:需要拥有任一角色 + hasRole = Arrays.stream(requiredRoles) + .anyMatch(userRoleCodes::contains); + } + + if (!hasRole) { + throw new BusinessException("角色权限不足"); + } + } + + /** + * 获取当前登录用户ID + * 从JWT Token中解析用户ID + */ + private Long getCurrentUserId(HttpServletRequest request) { + // 从Authorization请求头中获取Token + String authHeader = request.getHeader("Authorization"); + if (authHeader != null && authHeader.startsWith("Bearer ")) { + String token = authHeader.substring(7); + if (jwtUtil.validateToken(token)) { + Long userId = jwtUtil.getUserIdFromToken(token); + if (userId != null) { + return userId; + } + } + } + + // 向后兼容:从请求头中获取用户ID(临时实现) + String userIdStr = request.getHeader("X-User-Id"); + if (userIdStr != null) { + try { + return Long.parseLong(userIdStr); + } catch (NumberFormatException e) { + log.warn("Invalid user ID: {}", userIdStr); + } + } + + return null; + } +} + diff --git a/backend/src/main/java/com/designhub/mapper/AchievementMapper.java b/backend/src/main/java/com/designhub/mapper/AchievementMapper.java new file mode 100644 index 0000000..b213693 --- /dev/null +++ b/backend/src/main/java/com/designhub/mapper/AchievementMapper.java @@ -0,0 +1,13 @@ +package com.designhub.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.designhub.entity.Achievement; +import org.apache.ibatis.annotations.Mapper; + +/** + * 成就徽章Mapper接口 + */ +@Mapper +public interface AchievementMapper extends BaseMapper { +} + diff --git a/backend/src/main/java/com/designhub/mapper/BrandMapper.java b/backend/src/main/java/com/designhub/mapper/BrandMapper.java new file mode 100644 index 0000000..6c65281 --- /dev/null +++ b/backend/src/main/java/com/designhub/mapper/BrandMapper.java @@ -0,0 +1,13 @@ +package com.designhub.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.designhub.entity.Brand; +import org.apache.ibatis.annotations.Mapper; + +/** + * 品牌Mapper接口 + */ +@Mapper +public interface BrandMapper extends BaseMapper { +} + diff --git a/backend/src/main/java/com/designhub/mapper/CategoryMapper.java b/backend/src/main/java/com/designhub/mapper/CategoryMapper.java new file mode 100644 index 0000000..2676d47 --- /dev/null +++ b/backend/src/main/java/com/designhub/mapper/CategoryMapper.java @@ -0,0 +1,13 @@ +package com.designhub.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.designhub.entity.Category; +import org.apache.ibatis.annotations.Mapper; + +/** + * 类目Mapper接口 + */ +@Mapper +public interface CategoryMapper extends BaseMapper { +} + diff --git a/backend/src/main/java/com/designhub/mapper/CheckInMapper.java b/backend/src/main/java/com/designhub/mapper/CheckInMapper.java new file mode 100644 index 0000000..2f0c0a4 --- /dev/null +++ b/backend/src/main/java/com/designhub/mapper/CheckInMapper.java @@ -0,0 +1,29 @@ +package com.designhub.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.designhub.entity.CheckIn; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.time.LocalDate; + +/** + * 签到记录Mapper接口 + */ +@Mapper +public interface CheckInMapper extends BaseMapper { + + /** + * 查询用户今天是否已签到 + */ + @Select("SELECT COUNT(*) FROM user_check_in WHERE user_id = #{userId} AND check_in_date = #{date} AND deleted = 0") + int countTodayCheckIn(@Param("userId") Long userId, @Param("date") LocalDate date); + + /** + * 获取用户最近一次签到记录 + */ + @Select("SELECT * FROM user_check_in WHERE user_id = #{userId} AND deleted = 0 ORDER BY check_in_date DESC LIMIT 1") + CheckIn getLatestCheckIn(@Param("userId") Long userId); +} + diff --git a/backend/src/main/java/com/designhub/mapper/ContestMapper.java b/backend/src/main/java/com/designhub/mapper/ContestMapper.java new file mode 100644 index 0000000..a0f230a --- /dev/null +++ b/backend/src/main/java/com/designhub/mapper/ContestMapper.java @@ -0,0 +1,12 @@ +package com.designhub.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.designhub.entity.Contest; +import org.apache.ibatis.annotations.Mapper; + +/** + * 竞赛活动Mapper + */ +@Mapper +public interface ContestMapper extends BaseMapper { +} diff --git a/backend/src/main/java/com/designhub/mapper/ContestParticipantMapper.java b/backend/src/main/java/com/designhub/mapper/ContestParticipantMapper.java new file mode 100644 index 0000000..56e4b56 --- /dev/null +++ b/backend/src/main/java/com/designhub/mapper/ContestParticipantMapper.java @@ -0,0 +1,28 @@ +package com.designhub.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.designhub.entity.ContestParticipant; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +/** + * 竞赛参与者Mapper + */ +@Mapper +public interface ContestParticipantMapper extends BaseMapper { + + /** + * 获取竞赛排行榜 + */ + @Select("SELECT * FROM contest_participant WHERE contest_id = #{contestId} ORDER BY total_score DESC, task_completed DESC") + List getLeaderboard(@Param("contestId") Long contestId); + + /** + * 获取用户在竞赛中的排名 + */ + @Select("SELECT COUNT(*) + 1 FROM contest_participant WHERE contest_id = #{contestId} AND total_score > (SELECT total_score FROM contest_participant WHERE contest_id = #{contestId} AND user_id = #{userId})") + Integer getUserRank(@Param("contestId") Long contestId, @Param("userId") Long userId); +} diff --git a/backend/src/main/java/com/designhub/mapper/DelayPenaltyRecordMapper.java b/backend/src/main/java/com/designhub/mapper/DelayPenaltyRecordMapper.java new file mode 100644 index 0000000..137189b --- /dev/null +++ b/backend/src/main/java/com/designhub/mapper/DelayPenaltyRecordMapper.java @@ -0,0 +1,35 @@ +package com.designhub.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.designhub.entity.DelayPenaltyRecord; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +/** + * 延误扣分记录Mapper接口 + */ +@Mapper +public interface DelayPenaltyRecordMapper extends BaseMapper { + + /** + * 根据任务ID查询延误记录 + */ + @Select("SELECT * FROM delay_penalty_record WHERE task_id = #{taskId} ORDER BY create_time DESC") + List findByTaskId(@Param("taskId") Long taskId); + + /** + * 根据设计师ID查询延误记录 + */ + @Select("SELECT * FROM delay_penalty_record WHERE designer_id = #{designerId} ORDER BY create_time DESC") + List findByDesignerId(@Param("designerId") Long designerId); + + /** + * 统计设计师的延误扣分总数 + */ + @Select("SELECT COALESCE(SUM(penalty_score), 0) FROM delay_penalty_record WHERE designer_id = #{designerId} AND status = 'confirmed'") + Integer sumPenaltyScoreByDesignerId(@Param("designerId") Long designerId); +} + diff --git a/backend/src/main/java/com/designhub/mapper/DelayPenaltyRuleMapper.java b/backend/src/main/java/com/designhub/mapper/DelayPenaltyRuleMapper.java new file mode 100644 index 0000000..b127994 --- /dev/null +++ b/backend/src/main/java/com/designhub/mapper/DelayPenaltyRuleMapper.java @@ -0,0 +1,22 @@ +package com.designhub.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.designhub.entity.DelayPenaltyRule; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +/** + * 延误扣分规则Mapper接口 + */ +@Mapper +public interface DelayPenaltyRuleMapper extends BaseMapper { + + /** + * 查询所有启用的规则,按最小小时数排序 + */ + @Select("SELECT * FROM delay_penalty_rule WHERE enabled = 1 ORDER BY min_hours ASC") + List findAllEnabled(); +} + diff --git a/backend/src/main/java/com/designhub/mapper/DemandTypeDepartmentMapper.java b/backend/src/main/java/com/designhub/mapper/DemandTypeDepartmentMapper.java new file mode 100644 index 0000000..98f4346 --- /dev/null +++ b/backend/src/main/java/com/designhub/mapper/DemandTypeDepartmentMapper.java @@ -0,0 +1,28 @@ +package com.designhub.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.designhub.entity.DemandTypeDepartment; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +/** + * 需求类型-接单部门关联Mapper + */ +@Mapper +public interface DemandTypeDepartmentMapper extends BaseMapper { + + /** + * 根据需求类型ID获取可接单的部门ID列表 + */ + @Select("SELECT department_id FROM demand_type_department WHERE demand_type_id = #{demandTypeId}") + List selectDepartmentIdsByDemandTypeId(@Param("demandTypeId") Long demandTypeId); + + /** + * 根据部门ID获取可接的需求类型ID列表 + */ + @Select("SELECT demand_type_id FROM demand_type_department WHERE department_id = #{departmentId}") + List selectDemandTypeIdsByDepartmentId(@Param("departmentId") Long departmentId); +} diff --git a/backend/src/main/java/com/designhub/mapper/DemandTypeMapper.java b/backend/src/main/java/com/designhub/mapper/DemandTypeMapper.java new file mode 100644 index 0000000..e04d4e7 --- /dev/null +++ b/backend/src/main/java/com/designhub/mapper/DemandTypeMapper.java @@ -0,0 +1,13 @@ +package com.designhub.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.designhub.entity.DemandType; +import org.apache.ibatis.annotations.Mapper; + +/** + * 需求类型Mapper接口 + */ +@Mapper +public interface DemandTypeMapper extends BaseMapper { +} + diff --git a/backend/src/main/java/com/designhub/mapper/DepartmentMapper.java b/backend/src/main/java/com/designhub/mapper/DepartmentMapper.java new file mode 100644 index 0000000..1fae49b --- /dev/null +++ b/backend/src/main/java/com/designhub/mapper/DepartmentMapper.java @@ -0,0 +1,22 @@ +package com.designhub.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.designhub.entity.Department; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +/** + * 部门Mapper接口 + */ +@Mapper +public interface DepartmentMapper extends BaseMapper { + + /** + * 查询部门树形结构 + */ + @Select("SELECT * FROM sys_dept WHERE deleted = 0 ORDER BY sort_order ASC") + List selectDeptTree(); +} + diff --git a/backend/src/main/java/com/designhub/mapper/DingTalkConfigMapper.java b/backend/src/main/java/com/designhub/mapper/DingTalkConfigMapper.java new file mode 100644 index 0000000..bd56c36 --- /dev/null +++ b/backend/src/main/java/com/designhub/mapper/DingTalkConfigMapper.java @@ -0,0 +1,12 @@ +package com.designhub.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.designhub.entity.DingTalkConfig; +import org.apache.ibatis.annotations.Mapper; + +/** + * 钉钉配置 Mapper + */ +@Mapper +public interface DingTalkConfigMapper extends BaseMapper { +} diff --git a/backend/src/main/java/com/designhub/mapper/FileUploadMapper.java b/backend/src/main/java/com/designhub/mapper/FileUploadMapper.java new file mode 100644 index 0000000..779d54f --- /dev/null +++ b/backend/src/main/java/com/designhub/mapper/FileUploadMapper.java @@ -0,0 +1,12 @@ +package com.designhub.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.designhub.entity.FileUpload; +import org.apache.ibatis.annotations.Mapper; + +/** + * 文件上传记录Mapper + */ +@Mapper +public interface FileUploadMapper extends BaseMapper { +} diff --git a/backend/src/main/java/com/designhub/mapper/NotificationMapper.java b/backend/src/main/java/com/designhub/mapper/NotificationMapper.java new file mode 100644 index 0000000..a3d2168 --- /dev/null +++ b/backend/src/main/java/com/designhub/mapper/NotificationMapper.java @@ -0,0 +1,26 @@ +package com.designhub.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.designhub.entity.Notification; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Update; + +/** + * 通知消息Mapper接口 + */ +@Mapper +public interface NotificationMapper extends BaseMapper { + + /** + * 批量标记为已读 + */ + @Update("UPDATE notification SET is_read = 1, read_time = NOW() WHERE receiver_id = #{receiverId} AND is_read = 0 AND deleted = 0") + int markAllAsRead(@Param("receiverId") Long receiverId); + + /** + * 获取未读数量 + */ + long countUnread(@Param("receiverId") Long receiverId); +} + diff --git a/backend/src/main/java/com/designhub/mapper/PermissionMapper.java b/backend/src/main/java/com/designhub/mapper/PermissionMapper.java new file mode 100644 index 0000000..72b14a3 --- /dev/null +++ b/backend/src/main/java/com/designhub/mapper/PermissionMapper.java @@ -0,0 +1,33 @@ +package com.designhub.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.designhub.entity.Permission; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +/** + * 权限Mapper接口 + */ +@Mapper +public interface PermissionMapper extends BaseMapper { + + /** + * 根据角色ID查询权限列表 + */ + @Select("SELECT p.* FROM sys_permission p " + + "INNER JOIN sys_role_permission rp ON p.id = rp.permission_id " + + "WHERE rp.role_id = #{roleId} AND p.deleted = 0 AND p.status = 1") + List selectPermissionsByRoleId(Long roleId); + + /** + * 根据用户ID查询权限列表 + */ + @Select("SELECT DISTINCT p.* FROM sys_permission p " + + "INNER JOIN sys_role_permission rp ON p.id = rp.permission_id " + + "INNER JOIN sys_user_role ur ON rp.role_id = ur.role_id " + + "WHERE ur.user_id = #{userId} AND p.deleted = 0 AND p.status = 1") + List selectPermissionsByUserId(Long userId); +} + diff --git a/backend/src/main/java/com/designhub/mapper/PlatformMapper.java b/backend/src/main/java/com/designhub/mapper/PlatformMapper.java new file mode 100644 index 0000000..54d0c72 --- /dev/null +++ b/backend/src/main/java/com/designhub/mapper/PlatformMapper.java @@ -0,0 +1,13 @@ +package com.designhub.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.designhub.entity.Platform; +import org.apache.ibatis.annotations.Mapper; + +/** + * 平台Mapper接口 + */ +@Mapper +public interface PlatformMapper extends BaseMapper { +} + diff --git a/backend/src/main/java/com/designhub/mapper/RewardRuleMapper.java b/backend/src/main/java/com/designhub/mapper/RewardRuleMapper.java new file mode 100644 index 0000000..5546fda --- /dev/null +++ b/backend/src/main/java/com/designhub/mapper/RewardRuleMapper.java @@ -0,0 +1,12 @@ +package com.designhub.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.designhub.entity.RewardRule; +import org.apache.ibatis.annotations.Mapper; + +/** + * 自动奖励规则 Mapper + */ +@Mapper +public interface RewardRuleMapper extends BaseMapper { +} diff --git a/backend/src/main/java/com/designhub/mapper/RoleMapper.java b/backend/src/main/java/com/designhub/mapper/RoleMapper.java new file mode 100644 index 0000000..4473601 --- /dev/null +++ b/backend/src/main/java/com/designhub/mapper/RoleMapper.java @@ -0,0 +1,25 @@ +package com.designhub.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.designhub.entity.Role; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +/** + * 角色Mapper接口 + */ +@Mapper +public interface RoleMapper extends BaseMapper { + + /** + * 根据用户ID查询角色列表 + * 从sys_user表的role_ids字段解析角色ID,然后查询角色信息 + */ + @Select("SELECT r.* FROM sys_role r " + + "WHERE FIND_IN_SET(r.id, (SELECT role_ids FROM sys_user WHERE id = #{userId})) > 0 " + + "AND r.deleted = 0 AND r.status = 1") + List selectRolesByUserId(Long userId); +} + diff --git a/backend/src/main/java/com/designhub/mapper/RolePermissionMapper.java b/backend/src/main/java/com/designhub/mapper/RolePermissionMapper.java new file mode 100644 index 0000000..dc489db --- /dev/null +++ b/backend/src/main/java/com/designhub/mapper/RolePermissionMapper.java @@ -0,0 +1,13 @@ +package com.designhub.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.designhub.entity.RolePermission; +import org.apache.ibatis.annotations.Mapper; + +/** + * 角色权限关联Mapper接口 + */ +@Mapper +public interface RolePermissionMapper extends BaseMapper { +} + diff --git a/backend/src/main/java/com/designhub/mapper/S3ConfigMapper.java b/backend/src/main/java/com/designhub/mapper/S3ConfigMapper.java new file mode 100644 index 0000000..6e0001f --- /dev/null +++ b/backend/src/main/java/com/designhub/mapper/S3ConfigMapper.java @@ -0,0 +1,19 @@ +package com.designhub.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.designhub.entity.S3Config; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Select; + +/** + * S3配置Mapper + */ +@Mapper +public interface S3ConfigMapper extends BaseMapper { + + /** + * 获取激活的S3配置 + */ + @Select("SELECT * FROM sys_s3_config WHERE is_active = 1 AND deleted = 0 LIMIT 1") + S3Config getActiveConfig(); +} diff --git a/backend/src/main/java/com/designhub/mapper/ScorePoolMapper.java b/backend/src/main/java/com/designhub/mapper/ScorePoolMapper.java new file mode 100644 index 0000000..b3d18d9 --- /dev/null +++ b/backend/src/main/java/com/designhub/mapper/ScorePoolMapper.java @@ -0,0 +1,12 @@ +package com.designhub.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.designhub.entity.ScorePool; +import org.apache.ibatis.annotations.Mapper; + +/** + * 激励积分池 Mapper + */ +@Mapper +public interface ScorePoolMapper extends BaseMapper { +} diff --git a/backend/src/main/java/com/designhub/mapper/ScorePoolRecordMapper.java b/backend/src/main/java/com/designhub/mapper/ScorePoolRecordMapper.java new file mode 100644 index 0000000..109e472 --- /dev/null +++ b/backend/src/main/java/com/designhub/mapper/ScorePoolRecordMapper.java @@ -0,0 +1,12 @@ +package com.designhub.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.designhub.entity.ScorePoolRecord; +import org.apache.ibatis.annotations.Mapper; + +/** + * 积分池使用记录 Mapper + */ +@Mapper +public interface ScorePoolRecordMapper extends BaseMapper { +} diff --git a/backend/src/main/java/com/designhub/mapper/SystemConfigMapper.java b/backend/src/main/java/com/designhub/mapper/SystemConfigMapper.java new file mode 100644 index 0000000..b81ffea --- /dev/null +++ b/backend/src/main/java/com/designhub/mapper/SystemConfigMapper.java @@ -0,0 +1,13 @@ +package com.designhub.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.designhub.entity.SystemConfig; +import org.apache.ibatis.annotations.Mapper; + +/** + * 系统配置Mapper接口 + */ +@Mapper +public interface SystemConfigMapper extends BaseMapper { +} + diff --git a/backend/src/main/java/com/designhub/mapper/TaskApprovalLogMapper.java b/backend/src/main/java/com/designhub/mapper/TaskApprovalLogMapper.java new file mode 100644 index 0000000..85910c7 --- /dev/null +++ b/backend/src/main/java/com/designhub/mapper/TaskApprovalLogMapper.java @@ -0,0 +1,12 @@ +package com.designhub.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.designhub.entity.TaskApprovalLog; +import org.apache.ibatis.annotations.Mapper; + +/** + * 任务审核记录 Mapper + */ +@Mapper +public interface TaskApprovalLogMapper extends BaseMapper { +} diff --git a/backend/src/main/java/com/designhub/mapper/TaskAssignLogMapper.java b/backend/src/main/java/com/designhub/mapper/TaskAssignLogMapper.java new file mode 100644 index 0000000..72582e9 --- /dev/null +++ b/backend/src/main/java/com/designhub/mapper/TaskAssignLogMapper.java @@ -0,0 +1,12 @@ +package com.designhub.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.designhub.entity.TaskAssignLog; +import org.apache.ibatis.annotations.Mapper; + +/** + * 任务指派记录Mapper + */ +@Mapper +public interface TaskAssignLogMapper extends BaseMapper { +} diff --git a/backend/src/main/java/com/designhub/mapper/TaskAuditLogMapper.java b/backend/src/main/java/com/designhub/mapper/TaskAuditLogMapper.java new file mode 100644 index 0000000..4e68e12 --- /dev/null +++ b/backend/src/main/java/com/designhub/mapper/TaskAuditLogMapper.java @@ -0,0 +1,12 @@ +package com.designhub.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.designhub.entity.TaskAuditLog; +import org.apache.ibatis.annotations.Mapper; + +/** + * 任务审核日志Mapper + */ +@Mapper +public interface TaskAuditLogMapper extends BaseMapper { +} diff --git a/backend/src/main/java/com/designhub/mapper/TaskItemMapper.java b/backend/src/main/java/com/designhub/mapper/TaskItemMapper.java new file mode 100644 index 0000000..034044e --- /dev/null +++ b/backend/src/main/java/com/designhub/mapper/TaskItemMapper.java @@ -0,0 +1,177 @@ +package com.designhub.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.designhub.entity.TaskItem; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 任务子项 Mapper 接口 + */ +@Mapper +public interface TaskItemMapper extends BaseMapper { + + /** + * 查询可接单的子任务列表(带主任务信息) + */ + @Select("") + IPage selectAvailableItems(Page page, + @Param("taskTypeId") Long taskTypeId, + @Param("keyword") String keyword); + + /** + * 查询进行中的子任务列表(按接单人) + */ + @Select("") + IPage selectInProgressItems(Page page, + @Param("receiverId") Long receiverId); + + /** + * 查询已完成的子任务列表(按接单人) + */ + @Select("") + IPage selectCompletedItems(Page page, + @Param("receiverId") Long receiverId); + + /** + * 根据任务ID查询所有子任务 + */ + @Select("SELECT ti.*, " + + " dt.type_name AS taskTypeName " + + "FROM task_item ti " + + "LEFT JOIN demand_type dt ON ti.task_type_id = dt.id " + + "WHERE ti.task_id = #{taskId} " + + "ORDER BY ti.sort_order ASC") + List selectByTaskId(@Param("taskId") Long taskId); + + /** + * 查询下一个待解锁的子任务(顺序流水线) + */ + @Select("SELECT * FROM task_item " + + "WHERE task_id = #{taskId} " + + " AND prev_item_id = #{prevItemId} " + + " AND status = 'locked' " + + " AND is_locked = 1 " + + "LIMIT 1") + TaskItem selectNextLockedItem(@Param("taskId") Long taskId, + @Param("prevItemId") Long prevItemId); + + /** + * 抢单子任务(乐观锁) + */ + @Update("UPDATE task_item SET " + + "receiver_id = #{receiverId}, " + + "receiver_name = #{receiverName}, " + + "status = 'in_progress', " + + "update_time = NOW() " + + "WHERE id = #{itemId} " + + "AND status = 'pending' " + + "AND is_locked = 0 " + + "AND receiver_id IS NULL") + int grabItem(@Param("itemId") Long itemId, + @Param("receiverId") Long receiverId, + @Param("receiverName") String receiverName); + + /** + * 解锁子任务 + */ + @Update("UPDATE task_item SET " + + "status = 'pending', " + + "is_locked = 0, " + + "update_time = NOW() " + + "WHERE id = #{itemId}") + int unlockItem(@Param("itemId") Long itemId); + + /** + * 统计任务的完成子项数量 + */ + @Select("SELECT COUNT(*) FROM task_item " + + "WHERE task_id = #{taskId} AND status = 'completed'") + int countCompletedItems(@Param("taskId") Long taskId); + + /** + * 统计任务的总子项数量 + */ + @Select("SELECT COUNT(*) FROM task_item WHERE task_id = #{taskId}") + int countTotalItems(@Param("taskId") Long taskId); +} diff --git a/backend/src/main/java/com/designhub/mapper/TaskMapper.java b/backend/src/main/java/com/designhub/mapper/TaskMapper.java new file mode 100644 index 0000000..de44a7e --- /dev/null +++ b/backend/src/main/java/com/designhub/mapper/TaskMapper.java @@ -0,0 +1,206 @@ +package com.designhub.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.designhub.entity.Task; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 任务 Mapper 接口 + */ +@Mapper +public interface TaskMapper extends BaseMapper { + + /** + * 查询任务大厅(待接单的任务) + */ + @Select("") + IPage selectTaskHall(Page page, + @Param("taskTypeId") Long taskTypeId, + @Param("demandTypeId") Long demandTypeId, + @Param("brandId") Long brandId, + @Param("categoryId") Long categoryId, + @Param("platformId") Long platformId, + @Param("taskSubType") String taskSubType, + @Param("minScore") Integer minScore, + @Param("maxScore") Integer maxScore, + @Param("keyword") String keyword, + @Param("publisherName") String publisherName, + @Param("timeRange") String timeRange, + @Param("sortBy") String sortBy); + + /** + * 查询我发布的任务 + */ + @Select("") + IPage selectMyPublishedTasks(Page page, + @Param("userId") Long userId, + @Param("status") String status); + + /** + * 查询我接收的任务 + */ + @Select("") + IPage selectMyReceivedTasks(Page page, + @Param("userId") Long userId, + @Param("status") String status); + + /** + * 根据任务编号查询 + */ + @Select("SELECT * FROM task WHERE task_no = #{taskNo} AND deleted = 0") + Task selectByTaskNo(@Param("taskNo") String taskNo); + + /** + * 查询设计师当前进行中的任务数量 + */ + @Select("SELECT COUNT(*) FROM task WHERE receiver_id = #{designerId} " + + "AND status IN ('in_progress', 'review') AND deleted = 0") + int countInProgressTasks(@Param("designerId") Long designerId); + + /** + * 抢单时基于条件更新任务,避免并发 + */ + @Update("UPDATE task SET " + + "receiver_id = #{receiverId}, " + + "receiver_dept_id = #{receiverDeptId}, " + + "status = 'in_progress', " + + "start_time = #{startTime}, " + + "actual_start_time = #{startTime}, " + + "deadline = CASE WHEN #{deadline} IS NOT NULL THEN #{deadline} ELSE deadline END, " + + "update_time = NOW() " + + "WHERE id = #{taskId} " + + "AND deleted = 0 " + + "AND status = 'pending' " + + "AND release_type = 'recruitment' " + + "AND receiver_id IS NULL") + int grabTask(@Param("taskId") Long taskId, + @Param("receiverId") Long receiverId, + @Param("receiverDeptId") Long receiverDeptId, + @Param("startTime") LocalDateTime startTime, + @Param("deadline") LocalDateTime deadline); + + /** + * 查询需求部门负责人的任务列表(部门内所有成员发起的任务) + */ + @Select("") + IPage selectLeaderTaskPage(Page page, + @Param("deptId") Long deptId, + @Param("auditStatus") String auditStatus); + + /** + * 查询接单部门负责人视图(部门成员接单的所有任务) + */ + @Select("") + IPage selectDeptReceivingTaskPage(Page page, + @Param("deptId") Long deptId, + @Param("status") String status); +} + diff --git a/backend/src/main/java/com/designhub/mapper/TaskProgressUpdateLogMapper.java b/backend/src/main/java/com/designhub/mapper/TaskProgressUpdateLogMapper.java new file mode 100644 index 0000000..c443d3c --- /dev/null +++ b/backend/src/main/java/com/designhub/mapper/TaskProgressUpdateLogMapper.java @@ -0,0 +1,12 @@ +package com.designhub.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.designhub.entity.TaskProgressUpdateLog; +import org.apache.ibatis.annotations.Mapper; + +/** + * 任务进度更新记录Mapper + */ +@Mapper +public interface TaskProgressUpdateLogMapper extends BaseMapper { +} diff --git a/backend/src/main/java/com/designhub/mapper/TaskReviewMapper.java b/backend/src/main/java/com/designhub/mapper/TaskReviewMapper.java new file mode 100644 index 0000000..7da0716 --- /dev/null +++ b/backend/src/main/java/com/designhub/mapper/TaskReviewMapper.java @@ -0,0 +1,13 @@ +package com.designhub.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.designhub.entity.TaskReview; +import org.apache.ibatis.annotations.Mapper; + +/** + * 任务评价Mapper接口 + */ +@Mapper +public interface TaskReviewMapper extends BaseMapper { +} + diff --git a/backend/src/main/java/com/designhub/mapper/TaskSubTypeMapper.java b/backend/src/main/java/com/designhub/mapper/TaskSubTypeMapper.java new file mode 100644 index 0000000..ea7f51d --- /dev/null +++ b/backend/src/main/java/com/designhub/mapper/TaskSubTypeMapper.java @@ -0,0 +1,12 @@ +package com.designhub.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.designhub.entity.TaskSubType; +import org.apache.ibatis.annotations.Mapper; + +/** + * 任务子类型 Mapper + */ +@Mapper +public interface TaskSubTypeMapper extends BaseMapper { +} diff --git a/backend/src/main/java/com/designhub/mapper/TeamPKMapper.java b/backend/src/main/java/com/designhub/mapper/TeamPKMapper.java new file mode 100644 index 0000000..2f6ca1f --- /dev/null +++ b/backend/src/main/java/com/designhub/mapper/TeamPKMapper.java @@ -0,0 +1,12 @@ +package com.designhub.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.designhub.entity.TeamPK; +import org.apache.ibatis.annotations.Mapper; + +/** + * 团队PK Mapper + */ +@Mapper +public interface TeamPKMapper extends BaseMapper { +} diff --git a/backend/src/main/java/com/designhub/mapper/TeamScorePoolMapper.java b/backend/src/main/java/com/designhub/mapper/TeamScorePoolMapper.java new file mode 100644 index 0000000..ffd9c43 --- /dev/null +++ b/backend/src/main/java/com/designhub/mapper/TeamScorePoolMapper.java @@ -0,0 +1,13 @@ +package com.designhub.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.designhub.entity.TeamScorePool; +import org.apache.ibatis.annotations.Mapper; + +/** + * 团队积分池Mapper接口 + */ +@Mapper +public interface TeamScorePoolMapper extends BaseMapper { +} + diff --git a/backend/src/main/java/com/designhub/mapper/ThirdPartyCallbackMapper.java b/backend/src/main/java/com/designhub/mapper/ThirdPartyCallbackMapper.java new file mode 100644 index 0000000..0bf372c --- /dev/null +++ b/backend/src/main/java/com/designhub/mapper/ThirdPartyCallbackMapper.java @@ -0,0 +1,12 @@ +package com.designhub.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.designhub.entity.ThirdPartyCallback; +import org.apache.ibatis.annotations.Mapper; + +/** + * 第三方回调记录Mapper + */ +@Mapper +public interface ThirdPartyCallbackMapper extends BaseMapper { +} diff --git a/backend/src/main/java/com/designhub/mapper/UserAchievementMapper.java b/backend/src/main/java/com/designhub/mapper/UserAchievementMapper.java new file mode 100644 index 0000000..4ac466e --- /dev/null +++ b/backend/src/main/java/com/designhub/mapper/UserAchievementMapper.java @@ -0,0 +1,13 @@ +package com.designhub.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.designhub.entity.UserAchievement; +import org.apache.ibatis.annotations.Mapper; + +/** + * 用户成就关联Mapper接口 + */ +@Mapper +public interface UserAchievementMapper extends BaseMapper { +} + diff --git a/backend/src/main/java/com/designhub/mapper/UserGoalMapper.java b/backend/src/main/java/com/designhub/mapper/UserGoalMapper.java new file mode 100644 index 0000000..4c9c7ac --- /dev/null +++ b/backend/src/main/java/com/designhub/mapper/UserGoalMapper.java @@ -0,0 +1,12 @@ +package com.designhub.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.designhub.entity.UserGoal; +import org.apache.ibatis.annotations.Mapper; + +/** + * 用户目标Mapper + */ +@Mapper +public interface UserGoalMapper extends BaseMapper { +} diff --git a/backend/src/main/java/com/designhub/mapper/UserMapper.java b/backend/src/main/java/com/designhub/mapper/UserMapper.java new file mode 100644 index 0000000..e11e541 --- /dev/null +++ b/backend/src/main/java/com/designhub/mapper/UserMapper.java @@ -0,0 +1,12 @@ +package com.designhub.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.designhub.entity.User; +import org.apache.ibatis.annotations.Mapper; + +/** + * 用户Mapper接口 + */ +@Mapper +public interface UserMapper extends BaseMapper { +} diff --git a/backend/src/main/java/com/designhub/mapper/UserRoleMapper.java b/backend/src/main/java/com/designhub/mapper/UserRoleMapper.java new file mode 100644 index 0000000..942cf33 --- /dev/null +++ b/backend/src/main/java/com/designhub/mapper/UserRoleMapper.java @@ -0,0 +1,13 @@ +package com.designhub.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.designhub.entity.UserRole; +import org.apache.ibatis.annotations.Mapper; + +/** + * 用户角色关联Mapper接口 + */ +@Mapper +public interface UserRoleMapper extends BaseMapper { +} + diff --git a/backend/src/main/java/com/designhub/service/AchievementService.java b/backend/src/main/java/com/designhub/service/AchievementService.java new file mode 100644 index 0000000..4cadbc2 --- /dev/null +++ b/backend/src/main/java/com/designhub/service/AchievementService.java @@ -0,0 +1,208 @@ +package com.designhub.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.designhub.entity.Achievement; +import com.designhub.entity.UserAchievement; +import com.designhub.mapper.AchievementMapper; +import com.designhub.mapper.UserAchievementMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * 成就系统Service + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AchievementService { + + private final AchievementMapper achievementMapper; + private final UserAchievementMapper userAchievementMapper; + private final NotificationService notificationService; + + /** + * 获取所有成就列表 + */ + public List getAllAchievements() { + return achievementMapper.selectList( + new LambdaQueryWrapper() + .eq(Achievement::getStatus, 1) + .orderByAsc(Achievement::getSortOrder) + ); + } + + /** + * 获取用户已达成的成就列表 + */ + public List getUserAchievements(Long userId) { + return userAchievementMapper.selectList( + new LambdaQueryWrapper() + .eq(UserAchievement::getUserId, userId) + .orderByDesc(UserAchievement::getAchievedTime) + ); + } + + /** + * 检查并解锁成就 + */ + @Transactional(rollbackFor = Exception.class) + public List checkAndUnlockAchievements(Long userId, String achievementType, Integer currentValue) { + List unlockedAchievements = new ArrayList<>(); + + // 获取该类型的所有成就 + List achievements = achievementMapper.selectList( + new LambdaQueryWrapper() + .eq(Achievement::getAchievementType, achievementType) + .eq(Achievement::getStatus, 1) + ); + + for (Achievement achievement : achievements) { + // 检查用户是否已获得该成就 + long count = userAchievementMapper.selectCount( + new LambdaQueryWrapper() + .eq(UserAchievement::getUserId, userId) + .eq(UserAchievement::getAchievementId, achievement.getId()) + ); + + if (count == 0) { + // 简单的条件判断(实际应该解析JSON条件) + // 这里假设condition格式为:{"type":"task_count","value":100} + if (checkAchievementCondition(achievement, currentValue)) { + // 解锁成就 + UserAchievement userAchievement = new UserAchievement(); + userAchievement.setUserId(userId); + userAchievement.setAchievementId(achievement.getId()); + userAchievement.setAchievedTime(LocalDateTime.now()); + userAchievement.setProgressValue(currentValue); + userAchievement.setRewardClaimed(0); + userAchievementMapper.insert(userAchievement); + + unlockedAchievements.add(achievement); + + // 发送通知 + notificationService.sendSystemNotification( + userId, + "🎉 恭喜解锁新成就", + "您已解锁成就:" + achievement.getAchievementName() + + ",奖励积分:" + achievement.getRewardScore(), + "high" + ); + + log.info("用户解锁成就: userId={}, achievement={}", userId, achievement.getAchievementName()); + } + } + } + + return unlockedAchievements; + } + + /** + * 领取成就奖励 + */ + @Transactional(rollbackFor = Exception.class) + public boolean claimAchievementReward(Long userId, Long achievementId) { + // 查询用户成就记录 + UserAchievement userAchievement = userAchievementMapper.selectOne( + new LambdaQueryWrapper() + .eq(UserAchievement::getUserId, userId) + .eq(UserAchievement::getAchievementId, achievementId) + ); + + if (userAchievement != null && userAchievement.getRewardClaimed() == 0) { + // 标记为已领取 + userAchievement.setRewardClaimed(1); + userAchievement.setClaimTime(LocalDateTime.now()); + userAchievementMapper.updateById(userAchievement); + + // TODO: 实际发放积分奖励 + Achievement achievement = achievementMapper.selectById(achievementId); + if (achievement != null && achievement.getRewardScore() != null && achievement.getRewardScore() > 0) { + // 这里应该调用积分服务发放奖励 + log.info("发放成就奖励: userId={}, score={}", userId, achievement.getRewardScore()); + } + + return true; + } + + return false; + } + + /** + * 初始化默认成就 + */ + @Transactional(rollbackFor = Exception.class) + public void initDefaultAchievements() { + // 任务类成就 + createAchievementIfNotExists("TASK_10", "初出茅庐", "完成10个任务", "task", 10, 50, "bronze"); + createAchievementIfNotExists("TASK_50", "驾轻就熟", "完成50个任务", "task", 50, 200, "silver"); + createAchievementIfNotExists("TASK_100", "百里挑一", "完成100个任务", "task", 100, 500, "gold"); + createAchievementIfNotExists("TASK_500", "业界大拿", "完成500个任务", "task", 500, 2000, "diamond"); + + // 积分类成就 + createAchievementIfNotExists("SCORE_1000", "积分新秀", "累计获得1000积分", "score", 1000, 100, "bronze"); + createAchievementIfNotExists("SCORE_5000", "积分高手", "累计获得5000积分", "score", 5000, 500, "silver"); + createAchievementIfNotExists("SCORE_10000", "积分大师", "累计获得10000积分", "score", 10000, 1000, "gold"); + + // 质量类成就 + createAchievementIfNotExists("QUALITY_10", "好评如潮", "获得10个五星好评", "quality", 10, 100, "silver"); + createAchievementIfNotExists("QUALITY_50", "完美主义", "获得50个五星好评", "quality", 50, 500, "gold"); + + // 速度类成就 + createAchievementIfNotExists("SPEED_10", "闪电侠", "提前完成任务10次", "speed", 10, 100, "silver"); + + log.info("默认成就初始化完成"); + } + + /** + * 如果不存在则创建成就 + */ + private void createAchievementIfNotExists(String code, String name, String description, + String type, Integer conditionValue, + Integer rewardScore, String level) { + long count = achievementMapper.selectCount( + new LambdaQueryWrapper() + .eq(Achievement::getAchievementCode, code) + ); + + if (count == 0) { + Achievement achievement = new Achievement(); + achievement.setAchievementCode(code); + achievement.setAchievementName(name); + achievement.setDescription(description); + achievement.setAchievementType(type); + achievement.setCondition("{\"type\":\"" + type + "_count\",\"value\":" + conditionValue + "}"); + achievement.setRewardScore(rewardScore); + achievement.setLevel(level); + achievement.setSortOrder(0); + achievement.setStatus(1); + achievementMapper.insert(achievement); + } + } + + /** + * 检查成就条件是否满足 + */ + private boolean checkAchievementCondition(Achievement achievement, Integer currentValue) { + // 简化版本:从condition中提取value进行比较 + // 实际应该解析JSON + String condition = achievement.getCondition(); + if (condition != null && condition.contains("\"value\":")) { + try { + String valueStr = condition.substring(condition.indexOf("\"value\":") + 8); + valueStr = valueStr.substring(0, valueStr.indexOf("}")); + int requiredValue = Integer.parseInt(valueStr.trim()); + return currentValue >= requiredValue; + } catch (Exception e) { + log.error("解析成就条件失败: {}", condition, e); + } + } + return false; + } +} + diff --git a/backend/src/main/java/com/designhub/service/AdminTaskService.java b/backend/src/main/java/com/designhub/service/AdminTaskService.java new file mode 100644 index 0000000..d96ac1a --- /dev/null +++ b/backend/src/main/java/com/designhub/service/AdminTaskService.java @@ -0,0 +1,425 @@ +package com.designhub.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.designhub.common.ResultCode; +import com.designhub.entity.Department; +import com.designhub.entity.Task; +import com.designhub.entity.User; +import com.designhub.exception.BusinessException; +import com.designhub.mapper.TaskMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.List; + +/** + * 管理员任务调配Service + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AdminTaskService { + + private final TaskMapper taskMapper; + private final NotificationService notificationService; + private final UserService userService; + private final DepartmentService departmentService; + private final RoleService roleService; + + /** + * 管理员分配任务 + */ + @Transactional(rollbackFor = Exception.class) + public Task assignTask(Long taskId, Long designerId, Long adminId) { + // 验证管理员权限 + if (!isAdminUser(adminId)) { + throw new BusinessException(ResultCode.FORBIDDEN, "只有管理员可以分配任务"); + } + + // 验证设计师 + User designer = userService.getUserById(designerId); + if (designer == null) { + throw new BusinessException(ResultCode.PARAM_ERROR, "设计师不存在"); + } + + // 验证设计师部门类型(必须是接单部门) + if (designer.getDeptId() != null) { + Department dept = departmentService.getDepartmentById(designer.getDeptId()); + if (dept == null || !"receiving".equals(dept.getDeptType())) { + throw new BusinessException(ResultCode.FORBIDDEN, "只能分配给接单部门的设计师"); + } + } + + Task task = taskMapper.selectById(taskId); + if (task == null) { + throw new BusinessException(ResultCode.TASK_NOT_EXIST); + } + + if (!"pending".equals(task.getStatus()) && !"recruitment".equals(task.getReleaseType())) { + throw new BusinessException(ResultCode.TASK_STATUS_ERROR, "只能分配待接单的任务"); + } + + // 验证任务需求类型是否允许该部门接单 + if (task.getDemandTypeId() != null && designer.getDeptId() != null) { + Department dept = departmentService.getDepartmentById(designer.getDeptId()); + if (dept != null && dept.getAllowedDemandTypes() != null && !dept.getAllowedDemandTypes().isEmpty()) { + String[] allowedTypes = dept.getAllowedDemandTypes().split(","); + boolean allowed = false; + for (String typeId : allowedTypes) { + if (task.getDemandTypeId().toString().equals(typeId.trim())) { + allowed = true; + break; + } + } + if (!allowed) { + throw new BusinessException(ResultCode.FORBIDDEN, "该设计师所在部门不支持此需求类型"); + } + } + } + + // 记录原接收人(如果有) + Long originalReceiverId = task.getReceiverId(); + + // 分配任务 + task.setReceiverId(designerId); + task.setReceiverDeptId(designer.getDeptId()); + task.setStatus("in_progress"); + task.setStartTime(LocalDateTime.now()); + task.setActualStartTime(LocalDateTime.now()); + + // 重新计算截止时间 + if (task.getEstimatedHours() != null) { + int hours = task.getEstimatedHours().intValue(); + if (hours > 0) { + task.setDeadline(LocalDateTime.now().plusHours(hours)); + } + } else if (task.getEstimatedDuration() != null) { + task.setDeadline(LocalDateTime.now().plusHours(task.getEstimatedDuration())); + } + + task.setUpdateTime(LocalDateTime.now()); + taskMapper.updateById(task); + + // 记录操作日志 + log.info("管理员分配任务: taskId={}, designerId={}, adminId={}", taskId, designerId, adminId); + + // 发送通知给设计师 + notificationService.sendTaskNotification( + designerId, + taskId, + "📋 新任务分配", + "管理员为您分配了新任务:" + task.getTitle() + ); + + // 如果有原接收人,通知他任务已被重新分配 + if (originalReceiverId != null && !originalReceiverId.equals(designerId)) { + notificationService.sendTaskNotification( + originalReceiverId, + taskId, + "⚠️ 任务重新分配", + "您的任务已被管理员重新分配" + ); + } + + return task; + } + + /** + * 管理员转移任务 + */ + @Transactional(rollbackFor = Exception.class) + public Task transferTask(Long taskId, Long newDesignerId, Long adminId, String reason) { + // 验证管理员权限 + if (!isAdminUser(adminId)) { + throw new BusinessException(ResultCode.FORBIDDEN, "只有管理员可以转移任务"); + } + + // 验证新设计师 + User newDesigner = userService.getUserById(newDesignerId); + if (newDesigner == null) { + throw new BusinessException(ResultCode.PARAM_ERROR, "新设计师不存在"); + } + + // 验证新设计师部门类型 + if (newDesigner.getDeptId() != null) { + Department dept = departmentService.getDepartmentById(newDesigner.getDeptId()); + if (dept == null || !"receiving".equals(dept.getDeptType())) { + throw new BusinessException(ResultCode.FORBIDDEN, "只能转移给接单部门的设计师"); + } + } + + Task task = taskMapper.selectById(taskId); + if (task == null) { + throw new BusinessException(ResultCode.TASK_NOT_EXIST); + } + + if (!"in_progress".equals(task.getStatus()) && !"review".equals(task.getStatus())) { + throw new BusinessException(ResultCode.TASK_STATUS_ERROR, "只能转移进行中或待审核的任务"); + } + + Long originalDesignerId = task.getReceiverId(); + if (originalDesignerId == null) { + throw new BusinessException("任务没有接收人"); + } + + if (originalDesignerId.equals(newDesignerId)) { + throw new BusinessException("新接收人与原接收人相同"); + } + + // 计算已用时长 + LocalDateTime startTime = task.getActualStartTime(); + if (startTime != null) { + long hoursUsed = ChronoUnit.HOURS.between(startTime, LocalDateTime.now()); + + // 检查是否已延误 + LocalDateTime originalDeadline = task.getDeadline(); + if (originalDeadline != null && LocalDateTime.now().isAfter(originalDeadline)) { + // 原设计师已延误,记录延误信息 + long delayHours = ChronoUnit.HOURS.between(originalDeadline, LocalDateTime.now()); + log.warn("任务转移时发现延误: taskId={}, originalDesignerId={}, delayHours={}", + taskId, originalDesignerId, delayHours); + + // TODO: 记录延误扣分信息 + // TODO: 更新原设计师的延误统计 + } + } + + // 转移任务 + task.setReceiverId(newDesignerId); + task.setReceiverDeptId(newDesigner.getDeptId()); + task.setActualStartTime(LocalDateTime.now()); // 重新开始计时 + + // 如果任务状态是 review,转回 in_progress + if ("review".equals(task.getStatus())) { + task.setStatus("in_progress"); + } + + // 重新计算截止时间 + if (task.getEstimatedHours() != null) { + int hours = task.getEstimatedHours().intValue(); + if (hours > 0) { + task.setDeadline(LocalDateTime.now().plusHours(hours)); + } + } else if (task.getEstimatedDuration() != null) { + task.setDeadline(LocalDateTime.now().plusHours(task.getEstimatedDuration())); + } + + task.setUpdateTime(LocalDateTime.now()); + + // 在备注中记录转移信息 + String transferNote = String.format("[%s] 管理员转移任务:从设计师%d转移到设计师%d,原因:%s", + LocalDateTime.now().toString(), originalDesignerId, newDesignerId, reason); + String existingRemark = task.getRemark(); + task.setRemark(existingRemark != null ? existingRemark + "\n" + transferNote : transferNote); + + taskMapper.updateById(task); + + log.info("管理员转移任务: taskId={}, from={}, to={}, adminId={}, reason={}", + taskId, originalDesignerId, newDesignerId, adminId, reason); + + // 通知新设计师 + notificationService.sendTaskNotification( + newDesignerId, + taskId, + "📋 新任务转移", + "管理员为您转移了任务:" + task.getTitle() + ",原因:" + reason + ); + + // 通知原设计师 + notificationService.sendTaskNotification( + originalDesignerId, + taskId, + "⚠️ 任务已转移", + "您的任务已被管理员转移,原因:" + reason + ); + + return task; + } + + /** + * 批量分配任务 + */ + @Transactional(rollbackFor = Exception.class) + public int batchAssignTasks(List taskIds, Long designerId, Long adminId) { + int successCount = 0; + for (Long taskId : taskIds) { + try { + assignTask(taskId, designerId, adminId); + successCount++; + } catch (Exception e) { + log.error("批量分配任务失败: taskId={}, error={}", taskId, e.getMessage()); + } + } + log.info("批量分配任务完成: total={}, success={}, adminId={}", taskIds.size(), successCount, adminId); + return successCount; + } + + /** + * 调整任务优先级 + */ + @Transactional(rollbackFor = Exception.class) + public Task adjustTaskPriority(Long taskId, Integer newPriority, Long adminId) { + Task task = taskMapper.selectById(taskId); + if (task == null) { + throw new BusinessException("任务不存在"); + } + + Integer oldPriority = task.getPriority(); + task.setPriority(newPriority); + task.setUpdateTime(LocalDateTime.now()); + taskMapper.updateById(task); + + log.info("管理员调整任务优先级: taskId={}, old={}, new={}, adminId={}", + taskId, oldPriority, newPriority, adminId); + + // 如果任务有接收人,发送通知 + if (task.getReceiverId() != null) { + String priorityText = getPriorityText(newPriority); + notificationService.sendTaskNotification( + task.getReceiverId(), + taskId, + "⚡ 任务优先级调整", + "任务优先级已调整为:" + priorityText + ); + } + + return task; + } + + /** + * 获取任务池列表(待分配的任务) + */ + public List getTaskPool() { + return taskMapper.selectList( + new LambdaQueryWrapper() + .in(Task::getStatus, "pending", "pool") + .orderByDesc(Task::getCreateTime) + ); + } + + /** + * 获取所有进行中的任务 + */ + public List getInProgressTasks() { + return taskMapper.selectList( + new LambdaQueryWrapper() + .eq(Task::getStatus, "in_progress") + .orderByAsc(Task::getDeadline) + ); + } + + /** + * 强制完成任务(管理员操作) + */ + @Transactional(rollbackFor = Exception.class) + public Task forceCompleteTask(Long taskId, Long adminId, String reason) { + Task task = taskMapper.selectById(taskId); + if (task == null) { + throw new BusinessException("任务不存在"); + } + + task.setStatus("completed"); + task.setActualEndTime(LocalDateTime.now()); + task.setUpdateTime(LocalDateTime.now()); + + String completeNote = String.format("[%s] 管理员强制完成,原因:%s", + LocalDateTime.now().toString(), reason); + String existingRemark = task.getRemark(); + task.setRemark(existingRemark != null ? existingRemark + "\n" + completeNote : completeNote); + + taskMapper.updateById(task); + + log.info("管理员强制完成任务: taskId={}, adminId={}, reason={}", taskId, adminId, reason); + + // 通知相关人员 + if (task.getReceiverId() != null) { + notificationService.sendTaskNotification( + task.getReceiverId(), + taskId, + "✅ 任务已完成", + "管理员已将您的任务标记为完成,原因:" + reason + ); + } + + return task; + } + + /** + * 取消任务(管理员操作) + */ + @Transactional(rollbackFor = Exception.class) + public Task cancelTask(Long taskId, Long adminId, String reason) { + Task task = taskMapper.selectById(taskId); + if (task == null) { + throw new BusinessException("任务不存在"); + } + + task.setStatus("cancelled"); + task.setUpdateTime(LocalDateTime.now()); + + String cancelNote = String.format("[%s] 管理员取消任务,原因:%s", + LocalDateTime.now().toString(), reason); + String existingRemark = task.getRemark(); + task.setRemark(existingRemark != null ? existingRemark + "\n" + cancelNote : cancelNote); + + taskMapper.updateById(task); + + log.info("管理员取消任务: taskId={}, adminId={}, reason={}", taskId, adminId, reason); + + // 通知相关人员 + if (task.getReceiverId() != null) { + notificationService.sendTaskNotification( + task.getReceiverId(), + taskId, + "❌ 任务已取消", + "管理员已取消该任务,原因:" + reason + ); + } + + if (task.getPublisherId() != null) { + notificationService.sendTaskNotification( + task.getPublisherId(), + taskId, + "❌ 任务已取消", + "您发布的任务已被取消,原因:" + reason + ); + } + + return task; + } + + /** + * 获取优先级文本 + */ + private String getPriorityText(Integer priority) { + if (priority == null) return "未设置"; + return switch (priority) { + case 0 -> "低"; + case 1 -> "普通"; + case 2 -> "高"; + case 3 -> "紧急"; + default -> String.valueOf(priority); + }; + } + + /** + * 检查用户是否是管理员(ADMIN或SUPER_ADMIN) + */ + private boolean isAdminUser(Long userId) { + try { + var roles = roleService.getRolesByUserId(userId); + return roles.stream().anyMatch(role -> + "ADMIN".equals(role.getRoleCode()) || + "SUPER_ADMIN".equals(role.getRoleCode()) + ); + } catch (Exception e) { + log.warn("检查用户角色失败: userId={}, error={}", userId, e.getMessage()); + return false; + } + } +} + diff --git a/backend/src/main/java/com/designhub/service/AnalyticsService.java b/backend/src/main/java/com/designhub/service/AnalyticsService.java new file mode 100644 index 0000000..e8f29ed --- /dev/null +++ b/backend/src/main/java/com/designhub/service/AnalyticsService.java @@ -0,0 +1,514 @@ +package com.designhub.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.designhub.entity.*; +import com.designhub.mapper.*; +import com.designhub.vo.DemandStatisticsVO; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; +import java.util.stream.Collectors; + +/** + * 数据分析Service + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AnalyticsService { + + private final TaskMapper taskMapper; + private final UserMapper userMapper; + private final DepartmentMapper departmentMapper; + private final TeamScorePoolMapper teamScorePoolMapper; + private final TaskReviewMapper taskReviewMapper; + private final RoleService roleService; + + /** + * 获取需求统计数据(根据角色返回不同维度的数据) + * @param currentUserId 当前登录用户ID + * @param dateRange 时间范围 + * @param deptId 部门ID(可选,用于管理员查看特定部门) + * @param userId 用户ID(可选,用于管理员查看特定用户) + */ + public DemandStatisticsVO getDemandStatistics(Long currentUserId, String dateRange, Long deptId, Long userId) { + // 获取当前用户角色和部门信息 + List roles = roleService.getRolesByUserId(currentUserId); + User currentUser = userMapper.selectById(currentUserId); + + boolean isAdmin = roles.stream() + .anyMatch(role -> "ADMIN".equals(role.getRoleCode()) || "SUPER_ADMIN".equals(role.getRoleCode())); + + boolean isDeptHead = roles.stream() + .anyMatch(role -> "DEMAND_DEPT_HEAD".equals(role.getRoleCode()) || + "RECEIVING_DEPT_SUPERVISOR".equals(role.getRoleCode())); + + log.info("获取需求统计数据: currentUserId={}, isAdmin={}, isDeptHead={}, userDeptId={}, dateRange={}, deptId={}, userId={}", + currentUserId, isAdmin, isDeptHead, currentUser != null ? currentUser.getDeptId() : null, dateRange, deptId, userId); + + // 计算时间范围 + LocalDateTime startTime = calculateStartTime(dateRange); + LocalDateTime endTime = LocalDateTime.now(); + + // 构建查询条件 + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper() + .ge(startTime != null, Task::getCreateTime, startTime) + .le(Task::getCreateTime, endTime); + + // 根据角色和参数决定查询范围 + if (isAdmin) { + // 管理员:可以通过参数查看特定部门或用户的数据,否则查看所有数据 + if (deptId != null) { + queryWrapper.eq(Task::getPublisherDeptId, deptId); + log.info("管理员查看部门数据: deptId={}", deptId); + } else if (userId != null) { + queryWrapper.eq(Task::getPublisherId, userId); + log.info("管理员查看用户数据: userId={}", userId); + } else { + log.info("管理员查看全部数据"); + } + } else if (isDeptHead && currentUser != null && currentUser.getDeptId() != null) { + // 部门负责人:查看本部门的所有任务 + queryWrapper.eq(Task::getPublisherDeptId, currentUser.getDeptId()); + log.info("部门负责人查看部门数据: deptId={}", currentUser.getDeptId()); + } else { + // 普通用户:只能查看自己发布的任务 + queryWrapper.eq(Task::getPublisherId, currentUserId); + log.info("普通用户查看个人数据: userId={}", currentUserId); + } + + List tasks = taskMapper.selectList(queryWrapper); + log.info("查询到任务数量: {}", tasks.size()); + + return DemandStatisticsVO.builder() + .userStats(buildUserStats(tasks)) + .deptStats(buildDeptStats(tasks)) + .trendData(buildTrendData(tasks, dateRange)) + .typeDistribution(buildTypeDistribution(tasks)) + .overview(buildOverview(tasks)) + .build(); + } + + /** + * 构建用户维度统计 + */ + private List buildUserStats(List tasks) { + Map> tasksByUser = tasks.stream() + .collect(Collectors.groupingBy(Task::getPublisherId)); + + List stats = new ArrayList<>(); + + for (Map.Entry> entry : tasksByUser.entrySet()) { + Long userId = entry.getKey(); + List userTasks = entry.getValue(); + + User user = userMapper.selectById(userId); + if (user == null) continue; + + int totalDemands = userTasks.size(); + int completedDemands = (int) userTasks.stream() + .filter(t -> "completed".equals(t.getStatus())) + .count(); + int pendingDemands = (int) userTasks.stream() + .filter(t -> Arrays.asList("pending", "in_progress", "submitted").contains(t.getStatus())) + .count(); + + BigDecimal totalScore = userTasks.stream() + .map(Task::getScoreReward) + .filter(Objects::nonNull) + .reduce(BigDecimal.ZERO, BigDecimal::add); + + BigDecimal avgScore = totalDemands > 0 + ? totalScore.divide(BigDecimal.valueOf(totalDemands), 2, RoundingMode.HALF_UP) + : BigDecimal.ZERO; + + double completionRate = totalDemands > 0 + ? (double) completedDemands / totalDemands * 100 + : 0.0; + + // 计算满意度(五星好评率) + double satisfactionRate = calculateSatisfactionRate(userTasks); + + Department dept = departmentMapper.selectById(user.getDeptId()); + + stats.add(DemandStatisticsVO.UserDemandStat.builder() + .userId(userId) + .userName(user.getUsername()) + .deptName(dept != null ? dept.getDeptName() : "") + .totalDemands(totalDemands) + .completedDemands(completedDemands) + .pendingDemands(pendingDemands) + .totalScore(totalScore) + .avgScore(avgScore) + .completionRate(completionRate) + .satisfactionRate(satisfactionRate) + .build()); + } + + // 按总需求数降序排序 + stats.sort((a, b) -> b.getTotalDemands().compareTo(a.getTotalDemands())); + + return stats; + } + + /** + * 构建部门维度统计 + */ + private List buildDeptStats(List tasks) { + Map> tasksByDept = tasks.stream() + .filter(t -> t.getPublisherDeptId() != null) + .collect(Collectors.groupingBy(Task::getPublisherDeptId)); + + List stats = new ArrayList<>(); + + for (Map.Entry> entry : tasksByDept.entrySet()) { + Long deptId = entry.getKey(); + List deptTasks = entry.getValue(); + + Department dept = departmentMapper.selectById(deptId); + if (dept == null) continue; + + TeamScorePool scorePool = teamScorePoolMapper.selectOne( + new LambdaQueryWrapper().eq(TeamScorePool::getDeptId, deptId) + ); + + int totalDemands = deptTasks.size(); + int completedDemands = (int) deptTasks.stream() + .filter(t -> "completed".equals(t.getStatus())) + .count(); + int pendingDemands = (int) deptTasks.stream() + .filter(t -> Arrays.asList("pending", "in_progress", "submitted").contains(t.getStatus())) + .count(); + + BigDecimal totalScore = deptTasks.stream() + .map(Task::getScoreReward) + .filter(Objects::nonNull) + .reduce(BigDecimal.ZERO, BigDecimal::add); + + // 计算使用率和预警级别 + double utilizationRate = 0.0; + String alertLevel = "safe"; + + if (scorePool != null) { + BigDecimal totalAvailable = BigDecimal.valueOf(scorePool.getTotalScore() != null ? scorePool.getTotalScore() : 0); + if (totalAvailable.compareTo(BigDecimal.ZERO) > 0) { + utilizationRate = totalScore.divide(totalAvailable, 4, RoundingMode.HALF_UP) + .multiply(BigDecimal.valueOf(100)) + .doubleValue(); + } + + // 计算预警级别 + BigDecimal remainingScore = BigDecimal.valueOf(scorePool.getRemainingScore() != null ? scorePool.getRemainingScore() : 0); + if (totalAvailable.compareTo(BigDecimal.ZERO) > 0) { + double remainingRate = remainingScore.divide(totalAvailable, 4, RoundingMode.HALF_UP) + .multiply(BigDecimal.valueOf(100)) + .doubleValue(); + + if (remainingRate < 10) { + alertLevel = "danger"; + } else if (remainingRate < 30) { + alertLevel = "warning"; + } + } + + stats.add(DemandStatisticsVO.DeptDemandStat.builder() + .deptId(deptId) + .deptName(dept.getDeptName()) + .totalDemands(totalDemands) + .completedDemands(completedDemands) + .pendingDemands(pendingDemands) + .totalScore(totalScore) + .remainingScore(BigDecimal.valueOf(scorePool.getRemainingScore() != null ? scorePool.getRemainingScore() : 0)) + .dailyLimit(BigDecimal.valueOf(scorePool.getDailyLimit() != null ? scorePool.getDailyLimit() : 0)) + .weeklyLimit(BigDecimal.valueOf(scorePool.getWeeklyLimit() != null ? scorePool.getWeeklyLimit() : 0)) + .monthlyLimit(BigDecimal.valueOf(scorePool.getMonthlyLimit() != null ? scorePool.getMonthlyLimit() : 0)) + .dailyUsed(BigDecimal.valueOf(scorePool.getDailyUsed() != null ? scorePool.getDailyUsed() : 0)) + .weeklyUsed(BigDecimal.valueOf(scorePool.getWeeklyUsed() != null ? scorePool.getWeeklyUsed() : 0)) + .monthlyUsed(BigDecimal.valueOf(scorePool.getMonthlyUsed() != null ? scorePool.getMonthlyUsed() : 0)) + .utilizationRate(utilizationRate) + .alertLevel(alertLevel) + .build()); + } + } + + // 按总需求数降序排序 + stats.sort((a, b) -> b.getTotalDemands().compareTo(a.getTotalDemands())); + + return stats; + } + + /** + * 构建趋势数据 + */ + private List buildTrendData(List tasks, String dateRange) { + Map> tasksByDate = new LinkedHashMap<>(); + + // 根据dateRange生成日期范围 + int days = "week".equals(dateRange) ? 7 : "month".equals(dateRange) ? 30 : 90; + LocalDate endDate = LocalDate.now(); + + for (int i = days - 1; i >= 0; i--) { + LocalDate date = endDate.minusDays(i); + tasksByDate.put(date.format(DateTimeFormatter.ISO_LOCAL_DATE), new ArrayList<>()); + } + + // 将任务按日期分组 + for (Task task : tasks) { + String dateKey = task.getCreateTime().toLocalDate().format(DateTimeFormatter.ISO_LOCAL_DATE); + if (tasksByDate.containsKey(dateKey)) { + tasksByDate.get(dateKey).add(task); + } + } + + List trendData = new ArrayList<>(); + for (Map.Entry> entry : tasksByDate.entrySet()) { + List dateTasks = entry.getValue(); + + int demandCount = dateTasks.size(); + int completedCount = (int) dateTasks.stream() + .filter(t -> "completed".equals(t.getStatus())) + .count(); + + BigDecimal scoreUsed = dateTasks.stream() + .map(Task::getScoreReward) + .filter(Objects::nonNull) + .reduce(BigDecimal.ZERO, BigDecimal::add); + + trendData.add(DemandStatisticsVO.TrendData.builder() + .date(entry.getKey()) + .demandCount(demandCount) + .completedCount(completedCount) + .scoreUsed(scoreUsed) + .build()); + } + + return trendData; + } + + /** + * 构建类型分布 + */ + private List buildTypeDistribution(List tasks) { + Map> tasksByType = tasks.stream() + .filter(t -> t.getDemandTypeId() != null) + .collect(Collectors.groupingBy(t -> String.valueOf(t.getDemandTypeId()))); + + int totalTasks = tasks.size(); + List distribution = new ArrayList<>(); + + for (Map.Entry> entry : tasksByType.entrySet()) { + List typeTasks = entry.getValue(); + int count = typeTasks.size(); + double percentage = totalTasks > 0 ? (double) count / totalTasks * 100 : 0.0; + + BigDecimal totalScore = typeTasks.stream() + .map(Task::getScoreReward) + .filter(Objects::nonNull) + .reduce(BigDecimal.ZERO, BigDecimal::add); + + distribution.add(DemandStatisticsVO.TypeDistribution.builder() + .typeName("类型" + entry.getKey()) + .count(count) + .percentage(percentage) + .totalScore(totalScore) + .build()); + } + + // 按数量降序排序 + distribution.sort((a, b) -> b.getCount().compareTo(a.getCount())); + + return distribution; + } + + /** + * 构建总览数据 + */ + private DemandStatisticsVO.OverviewData buildOverview(List tasks) { + LocalDateTime now = LocalDateTime.now(); + LocalDate today = now.toLocalDate(); + LocalDate weekStart = today.minusDays(today.getDayOfWeek().getValue() - 1); + LocalDate monthStart = today.withDayOfMonth(1); + + int totalDemands = tasks.size(); + + int todayDemands = (int) tasks.stream() + .filter(t -> t.getCreateTime().toLocalDate().equals(today)) + .count(); + + int weekDemands = (int) tasks.stream() + .filter(t -> !t.getCreateTime().toLocalDate().isBefore(weekStart)) + .count(); + + int monthDemands = (int) tasks.stream() + .filter(t -> !t.getCreateTime().toLocalDate().isBefore(monthStart)) + .count(); + + int completedDemands = (int) tasks.stream() + .filter(t -> "completed".equals(t.getStatus())) + .count(); + + int pendingDemands = (int) tasks.stream() + .filter(t -> Arrays.asList("pending", "in_progress", "submitted").contains(t.getStatus())) + .count(); + + int canceledDemands = (int) tasks.stream() + .filter(t -> "cancelled".equals(t.getStatus())) + .count(); + + BigDecimal totalScoreUsed = tasks.stream() + .map(Task::getScoreReward) + .filter(Objects::nonNull) + .reduce(BigDecimal.ZERO, BigDecimal::add); + + BigDecimal todayScoreUsed = tasks.stream() + .filter(t -> t.getCreateTime().toLocalDate().equals(today)) + .map(Task::getScoreReward) + .filter(Objects::nonNull) + .reduce(BigDecimal.ZERO, BigDecimal::add); + + BigDecimal weekScoreUsed = tasks.stream() + .filter(t -> !t.getCreateTime().toLocalDate().isBefore(weekStart)) + .map(Task::getScoreReward) + .filter(Objects::nonNull) + .reduce(BigDecimal.ZERO, BigDecimal::add); + + BigDecimal monthScoreUsed = tasks.stream() + .filter(t -> !t.getCreateTime().toLocalDate().isBefore(monthStart)) + .map(Task::getScoreReward) + .filter(Objects::nonNull) + .reduce(BigDecimal.ZERO, BigDecimal::add); + + // 计算平均完成时长 + double avgCompletionTime = calculateAvgCompletionTime(tasks); + + // 计算平均满意度 + double avgSatisfaction = calculateAvgSatisfaction(tasks); + + return DemandStatisticsVO.OverviewData.builder() + .totalDemands(totalDemands) + .todayDemands(todayDemands) + .weekDemands(weekDemands) + .monthDemands(monthDemands) + .completedDemands(completedDemands) + .pendingDemands(pendingDemands) + .canceledDemands(canceledDemands) + .totalScoreUsed(totalScoreUsed) + .todayScoreUsed(todayScoreUsed) + .weekScoreUsed(weekScoreUsed) + .monthScoreUsed(monthScoreUsed) + .avgCompletionTime(avgCompletionTime) + .avgSatisfaction(avgSatisfaction) + .build(); + } + + /** + * 计算时间范围起始时间 + */ + private LocalDateTime calculateStartTime(String dateRange) { + LocalDateTime now = LocalDateTime.now(); + switch (dateRange) { + case "week": + return now.minusWeeks(1); + case "month": + return now.minusMonths(1); + case "quarter": + return now.minusMonths(3); + case "year": + return now.minusYears(1); + default: + return null; // 全部 + } + } + + /** + * 计算满意度(五星好评率) + */ + private double calculateSatisfactionRate(List tasks) { + try { + List completedTaskIds = tasks.stream() + .filter(t -> "completed".equals(t.getStatus())) + .map(Task::getId) + .collect(Collectors.toList()); + + if (completedTaskIds.isEmpty()) { + return 0.0; + } + + long fiveStarCount = taskReviewMapper.selectCount( + new LambdaQueryWrapper() + .in(TaskReview::getTaskId, completedTaskIds) + .eq(TaskReview::getRating, 5) + ); + + return (double) fiveStarCount / completedTaskIds.size() * 100; + } catch (Exception e) { + log.warn("计算满意度失败(可能task_review表不存在): {}", e.getMessage()); + return 0.0; + } + } + + /** + * 计算平均完成时长(小时) + */ + private double calculateAvgCompletionTime(List tasks) { + List completedTasks = tasks.stream() + .filter(t -> "completed".equals(t.getStatus()) && t.getActualEndTime() != null) + .collect(Collectors.toList()); + + if (completedTasks.isEmpty()) { + return 0.0; + } + + long totalHours = completedTasks.stream() + .mapToLong(t -> { + LocalDateTime start = t.getActualStartTime() != null ? t.getActualStartTime() : t.getCreateTime(); + LocalDateTime end = t.getActualEndTime(); + return java.time.Duration.between(start, end).toHours(); + }) + .sum(); + + return (double) totalHours / completedTasks.size(); + } + + /** + * 计算平均满意度 + */ + private double calculateAvgSatisfaction(List tasks) { + try { + List completedTaskIds = tasks.stream() + .filter(t -> "completed".equals(t.getStatus())) + .map(Task::getId) + .collect(Collectors.toList()); + + if (completedTaskIds.isEmpty()) { + return 0.0; + } + + List reviews = taskReviewMapper.selectList( + new LambdaQueryWrapper() + .in(TaskReview::getTaskId, completedTaskIds) + ); + + if (reviews.isEmpty()) { + return 0.0; + } + + double totalRating = reviews.stream() + .mapToInt(TaskReview::getRating) + .sum(); + + return totalRating / reviews.size(); + } catch (Exception e) { + log.warn("计算平均满意度失败(可能task_review表不存在): {}", e.getMessage()); + return 0.0; + } + } +} + diff --git a/backend/src/main/java/com/designhub/service/CheckInService.java b/backend/src/main/java/com/designhub/service/CheckInService.java new file mode 100644 index 0000000..7403e1e --- /dev/null +++ b/backend/src/main/java/com/designhub/service/CheckInService.java @@ -0,0 +1,164 @@ +package com.designhub.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.designhub.entity.CheckIn; +import com.designhub.exception.BusinessException; +import com.designhub.mapper.CheckInMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.List; + +/** + * 签到Service + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class CheckInService { + + private final CheckInMapper checkInMapper; + private final NotificationService notificationService; + private final SystemConfigService systemConfigService; + + /** + * 用户签到 + */ + @Transactional(rollbackFor = Exception.class) + public CheckIn checkIn(Long userId) { + LocalDate today = LocalDate.now(); + + // 检查今天是否已签到 + int count = checkInMapper.countTodayCheckIn(userId, today); + if (count > 0) { + throw new BusinessException("今天已经签到过了"); + } + + // 获取最近一次签到记录 + CheckIn lastCheckIn = checkInMapper.getLatestCheckIn(userId); + + int continuousDays = 1; + if (lastCheckIn != null) { + LocalDate lastDate = lastCheckIn.getCheckInDate(); + long daysBetween = ChronoUnit.DAYS.between(lastDate, today); + + if (daysBetween == 1) { + // 连续签到 + continuousDays = lastCheckIn.getContinuousDays() + 1; + } else if (daysBetween > 1) { + // 中断了,重新开始 + continuousDays = 1; + } + } + + // 计算奖励积分(连续签到越多,奖励越高) + int baseScore = systemConfigService.getIntValue("incentive.daily_checkin_score", 5); + int rewardScore = calculateRewardScore(baseScore, continuousDays); + + // 创建签到记录 + CheckIn checkIn = new CheckIn(); + checkIn.setUserId(userId); + checkIn.setCheckInDate(today); + checkIn.setContinuousDays(continuousDays); + checkIn.setRewardScore(rewardScore); + checkIn.setRemark("连续签到" + continuousDays + "天"); + checkInMapper.insert(checkIn); + + // TODO: 实际发放积分奖励 + log.info("用户签到成功: userId={}, continuousDays={}, rewardScore={}", userId, continuousDays, rewardScore); + + // 发送通知 + String message = String.format("签到成功!连续签到%d天,获得%d积分", continuousDays, rewardScore); + if (continuousDays >= 7) { + message += "。坚持得真棒!🎉"; + } + notificationService.sendSystemNotification(userId, "每日签到", message, "normal"); + + // 检查签到成就 + if (continuousDays == 7 || continuousDays == 30 || continuousDays == 100) { + notificationService.sendSystemNotification( + userId, + "🏆 签到成就达成", + String.format("恭喜您达成连续签到%d天的成就!", continuousDays), + "high" + ); + } + + return checkIn; + } + + /** + * 检查用户今天是否已签到 + */ + public boolean hasCheckedInToday(Long userId) { + LocalDate today = LocalDate.now(); + return checkInMapper.countTodayCheckIn(userId, today) > 0; + } + + /** + * 获取用户签到记录 + */ + public List getUserCheckInHistory(Long userId, Integer limit) { + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.eq(CheckIn::getUserId, userId); + queryWrapper.orderByDesc(CheckIn::getCheckInDate); + if (limit != null && limit > 0) { + queryWrapper.last("LIMIT " + limit); + } + return checkInMapper.selectList(queryWrapper); + } + + /** + * 获取用户连续签到天数 + */ + public int getContinuousDays(Long userId) { + CheckIn lastCheckIn = checkInMapper.getLatestCheckIn(userId); + if (lastCheckIn != null) { + LocalDate today = LocalDate.now(); + LocalDate lastDate = lastCheckIn.getCheckInDate(); + long daysBetween = ChronoUnit.DAYS.between(lastDate, today); + + if (daysBetween == 0) { + // 今天已签到 + return lastCheckIn.getContinuousDays(); + } else if (daysBetween == 1) { + // 今天还没签到,但昨天签了 + return lastCheckIn.getContinuousDays(); + } + } + return 0; + } + + /** + * 获取用户总签到天数 + */ + public long getTotalCheckInDays(Long userId) { + return checkInMapper.selectCount( + new LambdaQueryWrapper() + .eq(CheckIn::getUserId, userId) + ); + } + + /** + * 计算奖励积分 + * 基础分值 + 连续签到加成 + */ + private int calculateRewardScore(int baseScore, int continuousDays) { + if (continuousDays >= 30) { + return baseScore * 3; // 30天以上,3倍奖励 + } else if (continuousDays >= 14) { + return (int) (baseScore * 2.5); // 14天以上,2.5倍奖励 + } else if (continuousDays >= 7) { + return baseScore * 2; // 7天以上,2倍奖励 + } else if (continuousDays >= 3) { + return (int) (baseScore * 1.5); // 3天以上,1.5倍奖励 + } else { + return baseScore; // 基础奖励 + } + } +} + diff --git a/backend/src/main/java/com/designhub/service/ConfigService.java b/backend/src/main/java/com/designhub/service/ConfigService.java new file mode 100644 index 0000000..a3ee383 --- /dev/null +++ b/backend/src/main/java/com/designhub/service/ConfigService.java @@ -0,0 +1,266 @@ +package com.designhub.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.designhub.entity.*; +import com.designhub.mapper.*; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** + * 配置管理Service + */ +@Service +@RequiredArgsConstructor +public class ConfigService { + + private final DemandTypeMapper demandTypeMapper; + private final BrandMapper brandMapper; + private final PlatformMapper platformMapper; + private final CategoryMapper categoryMapper; + + // ============ 需求类型管理 ============ + + /** + * 获取所有需求类型(包括禁用的,用于管理页面) + */ + public List getAllDemandTypes() { + return demandTypeMapper.selectList( + new LambdaQueryWrapper() + .orderByAsc(DemandType::getSortOrder) + ); + } + + /** + * 获取所有启用的需求类型(用于下拉选择) + */ + public List getEnabledDemandTypes() { + return demandTypeMapper.selectList( + new LambdaQueryWrapper() + .eq(DemandType::getIsEnabled, 1) + .orderByAsc(DemandType::getSortOrder) + ); + } + + /** + * 根据ID获取需求类型 + */ + public DemandType getDemandTypeById(Long id) { + return demandTypeMapper.selectById(id); + } + + /** + * 创建需求类型 + */ + public DemandType createDemandType(DemandType demandType) { + demandTypeMapper.insert(demandType); + return demandType; + } + + /** + * 更新需求类型 + */ + public boolean updateDemandType(DemandType demandType) { + return demandTypeMapper.updateById(demandType) > 0; + } + + /** + * 删除需求类型 + */ + public boolean deleteDemandType(Long id) { + return demandTypeMapper.deleteById(id) > 0; + } + + // ============ 品牌管理 ============ + + /** + * 获取所有品牌(包括禁用的,用于管理页面) + */ + public List getAllBrands() { + return brandMapper.selectList( + new LambdaQueryWrapper() + .orderByAsc(Brand::getSortOrder) + ); + } + + /** + * 获取所有启用的品牌(用于下拉选择) + */ + public List getEnabledBrands() { + return brandMapper.selectList( + new LambdaQueryWrapper() + .eq(Brand::getIsEnabled, 1) + .orderByAsc(Brand::getSortOrder) + ); + } + + /** + * 根据ID获取品牌 + */ + public Brand getBrandById(Long id) { + return brandMapper.selectById(id); + } + + /** + * 创建品牌 + */ + public Brand createBrand(Brand brand) { + brandMapper.insert(brand); + return brand; + } + + /** + * 更新品牌 + */ + public boolean updateBrand(Brand brand) { + return brandMapper.updateById(brand) > 0; + } + + /** + * 删除品牌 + */ + public boolean deleteBrand(Long id) { + return brandMapper.deleteById(id) > 0; + } + + // ============ 平台管理 ============ + + /** + * 获取所有平台(包括禁用的,用于管理页面) + */ + public List getAllPlatforms() { + return platformMapper.selectList( + new LambdaQueryWrapper() + .orderByAsc(Platform::getSortOrder) + ); + } + + /** + * 获取所有启用的平台(用于下拉选择) + */ + public List getEnabledPlatforms() { + return platformMapper.selectList( + new LambdaQueryWrapper() + .eq(Platform::getIsEnabled, 1) + .orderByAsc(Platform::getSortOrder) + ); + } + + /** + * 根据ID获取平台 + */ + public Platform getPlatformById(Long id) { + return platformMapper.selectById(id); + } + + /** + * 创建平台 + */ + public Platform createPlatform(Platform platform) { + platformMapper.insert(platform); + return platform; + } + + /** + * 更新平台 + */ + public boolean updatePlatform(Platform platform) { + return platformMapper.updateById(platform) > 0; + } + + /** + * 删除平台 + */ + public boolean deletePlatform(Long id) { + return platformMapper.deleteById(id) > 0; + } + + // ============ 类目管理 ============ + + /** + * 获取所有叶子类目(包括禁用的,用于管理页面) + */ + public List getAllLeafCategories() { + return categoryMapper.selectList( + new LambdaQueryWrapper() + .eq(Category::getIsLeaf, 1) + .orderByAsc(Category::getSortOrder) + ); + } + + /** + * 获取所有类目(包括禁用的,用于管理页面) + */ + public List getAllCategories() { + return categoryMapper.selectList( + new LambdaQueryWrapper() + .orderByAsc(Category::getSortOrder) + ); + } + + /** + * 获取所有启用的类目(树形结构) + */ + public List getEnabledCategories() { + return categoryMapper.selectList( + new LambdaQueryWrapper() + .eq(Category::getIsEnabled, 1) + .orderByAsc(Category::getSortOrder) + ); + } + + /** + * 获取所有启用的叶子类目(用于下拉选择) + */ + public List getEnabledLeafCategories() { + return categoryMapper.selectList( + new LambdaQueryWrapper() + .eq(Category::getIsEnabled, 1) + .eq(Category::getIsLeaf, 1) + .orderByAsc(Category::getSortOrder) + ); + } + + /** + * 根据父ID获取子类目 + */ + public List getCategoriesByParentId(Long parentId) { + return categoryMapper.selectList( + new LambdaQueryWrapper() + .eq(Category::getParentId, parentId) + .eq(Category::getIsEnabled, 1) + .orderByAsc(Category::getSortOrder) + ); + } + + /** + * 根据ID获取类目 + */ + public Category getCategoryById(Long id) { + return categoryMapper.selectById(id); + } + + /** + * 创建类目 + */ + public Category createCategory(Category category) { + categoryMapper.insert(category); + return category; + } + + /** + * 更新类目 + */ + public boolean updateCategory(Category category) { + return categoryMapper.updateById(category) > 0; + } + + /** + * 删除类目 + */ + public boolean deleteCategory(Long id) { + return categoryMapper.deleteById(id) > 0; + } +} + diff --git a/backend/src/main/java/com/designhub/service/ContestService.java b/backend/src/main/java/com/designhub/service/ContestService.java new file mode 100644 index 0000000..3d39983 --- /dev/null +++ b/backend/src/main/java/com/designhub/service/ContestService.java @@ -0,0 +1,483 @@ +package com.designhub.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.designhub.common.ResultCode; +import com.designhub.entity.Contest; +import com.designhub.entity.ContestParticipant; +import com.designhub.entity.User; +import com.designhub.exception.BusinessException; +import com.designhub.mapper.ContestMapper; +import com.designhub.mapper.ContestParticipantMapper; +import com.designhub.mapper.UserMapper; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +/** + * 竞赛活动Service + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ContestService { + + private final ContestMapper contestMapper; + private final ContestParticipantMapper participantMapper; + private final UserMapper userMapper; + private final NotificationService notificationService; + private final ObjectMapper objectMapper; + + /** + * 创建竞赛 + */ + @Transactional + public Contest createContest(Contest contest) { + // 验证时间 + if (contest.getStartTime().isAfter(contest.getEndTime())) { + throw new BusinessException(ResultCode.PARAM_ERROR, "开始时间不能晚于结束时间"); + } + + contest.setStatus("draft"); + contest.setParticipantCount(0); + contest.setDeleted(0); + contestMapper.insert(contest); + + log.info("创建竞赛成功: id={}, name={}", contest.getId(), contest.getName()); + return contest; + } + + /** + * 更新竞赛 + */ + @Transactional + public Contest updateContest(Long id, Contest contest) { + Contest existing = contestMapper.selectById(id); + if (existing == null) { + throw new BusinessException(ResultCode.NOT_FOUND, "竞赛不存在"); + } + + // 进行中的竞赛不能修改规则 + if ("active".equals(existing.getStatus()) && contest.getRules() != null) { + throw new BusinessException(ResultCode.PARAM_ERROR, "进行中的竞赛不能修改规则"); + } + + contest.setId(id); + contestMapper.updateById(contest); + + return contestMapper.selectById(id); + } + + /** + * 发布竞赛(从草稿变为待开始或进行中) + */ + @Transactional + public Contest publishContest(Long id) { + Contest contest = contestMapper.selectById(id); + if (contest == null) { + throw new BusinessException(ResultCode.NOT_FOUND, "竞赛不存在"); + } + + if (!"draft".equals(contest.getStatus())) { + throw new BusinessException(ResultCode.PARAM_ERROR, "只有草稿状态的竞赛可以发布"); + } + + LocalDateTime now = LocalDateTime.now(); + if (contest.getStartTime().isBefore(now)) { + contest.setStatus("active"); + } else { + contest.setStatus("scheduled"); + } + + contestMapper.updateById(contest); + + // 初始化参与者 + initParticipants(contest); + + // 发送通知 + notifyContestStart(contest); + + return contest; + } + + /** + * 初始化竞赛参与者 + */ + private void initParticipants(Contest contest) { + List users; + + if ("all".equals(contest.getParticipantScope())) { + // 全体设计师 + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(User::getDeleted, 0); + users = userMapper.selectList(wrapper); + } else if ("department".equals(contest.getParticipantScope())) { + // 指定部门 + try { + List deptIds = objectMapper.readValue(contest.getDepartmentIds(), + new TypeReference>() {}); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.in(User::getDeptId, deptIds).eq(User::getDeleted, 0); + users = userMapper.selectList(wrapper); + } catch (JsonProcessingException e) { + throw new BusinessException(ResultCode.PARAM_ERROR, "部门ID格式错误"); + } + } else { + // 指定用户 + try { + List userIds = objectMapper.readValue(contest.getUserIds(), + new TypeReference>() {}); + users = userMapper.selectBatchIds(userIds); + } catch (JsonProcessingException e) { + throw new BusinessException(ResultCode.PARAM_ERROR, "用户ID格式错误"); + } + } + + for (User user : users) { + ContestParticipant participant = new ContestParticipant(); + participant.setContestId(contest.getId()); + participant.setUserId(user.getId()); + participant.setUserName(user.getRealName()); + participant.setDeptId(user.getDeptId()); + participant.setDeptName(user.getDeptName()); + participant.setTaskCompleted(0); + participant.setFiveStarCount(0); + participant.setEarlyCompletionCount(0); + participant.setPraiseCount(0); + participant.setNoDelayDays(0); + participant.setTotalScore(0); + participant.setCurrentRank(0); + participant.setRewardClaimed(0); + participant.setRewardScore(0); + participantMapper.insert(participant); + } + + // 更新参与人数 + contest.setParticipantCount(users.size()); + contestMapper.updateById(contest); + } + + /** + * 发送竞赛开始通知 + */ + private void notifyContestStart(Contest contest) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(ContestParticipant::getContestId, contest.getId()); + List participants = participantMapper.selectList(wrapper); + + for (ContestParticipant p : participants) { + notificationService.sendNotification( + p.getUserId(), + "竞赛活动开始", + String.format("竞赛「%s」已开始!快来参与吧!", contest.getName()), + "contest", + contest.getId() + ); + } + } + + /** + * 获取竞赛列表 + */ + public IPage getContestList(Integer page, Integer pageSize, String status) { + Page pageParam = new Page<>(page, pageSize); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + + if (status != null && !status.isEmpty()) { + wrapper.eq(Contest::getStatus, status); + } + + wrapper.orderByDesc(Contest::getCreateTime); + return contestMapper.selectPage(pageParam, wrapper); + } + + /** + * 获取竞赛详情 + */ + public Contest getContestById(Long id) { + Contest contest = contestMapper.selectById(id); + if (contest == null) { + throw new BusinessException(ResultCode.NOT_FOUND, "竞赛不存在"); + } + return contest; + } + + /** + * 获取竞赛排行榜 + */ + public List getLeaderboard(Long contestId, Integer limit) { + List list = participantMapper.getLeaderboard(contestId); + if (limit != null && list.size() > limit) { + return list.subList(0, limit); + } + return list; + } + + /** + * 获取用户在竞赛中的排名和数据 + */ + public ContestParticipant getUserContestData(Long contestId, Long userId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(ContestParticipant::getContestId, contestId) + .eq(ContestParticipant::getUserId, userId); + ContestParticipant participant = participantMapper.selectOne(wrapper); + + if (participant != null) { + // 计算排名 + Integer rank = participantMapper.getUserRank(contestId, userId); + participant.setCurrentRank(rank); + } + + return participant; + } + + /** + * 更新参与者数据(当任务完成时调用) + */ + @Transactional + public void updateParticipantStats(Long userId, boolean fiveStar, boolean earlyCompletion) { + // 获取用户参与的所有进行中的竞赛 + LocalDateTime now = LocalDateTime.now(); + LambdaQueryWrapper contestWrapper = new LambdaQueryWrapper<>(); + contestWrapper.eq(Contest::getStatus, "active"); + List activeContests = contestMapper.selectList(contestWrapper); + + for (Contest contest : activeContests) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(ContestParticipant::getContestId, contest.getId()) + .eq(ContestParticipant::getUserId, userId); + ContestParticipant participant = participantMapper.selectOne(wrapper); + + if (participant != null) { + participant.setTaskCompleted(participant.getTaskCompleted() + 1); + if (fiveStar) { + participant.setFiveStarCount(participant.getFiveStarCount() + 1); + } + if (earlyCompletion) { + participant.setEarlyCompletionCount(participant.getEarlyCompletionCount() + 1); + } + + // 重新计算总分 + int totalScore = calculateScore(contest, participant); + participant.setTotalScore(totalScore); + + participantMapper.updateById(participant); + } + } + } + + /** + * 计算参与者得分 + */ + private int calculateScore(Contest contest, ContestParticipant participant) { + try { + Map> rules = objectMapper.readValue( + contest.getRules(), new TypeReference>>() {}); + + int score = 0; + + if (rules.containsKey("taskCompleted")) { + score += participant.getTaskCompleted() * rules.get("taskCompleted").getOrDefault("score", 2); + } + if (rules.containsKey("fiveStarReview")) { + score += participant.getFiveStarCount() * rules.get("fiveStarReview").getOrDefault("score", 10); + } + if (rules.containsKey("earlyCompletion")) { + score += participant.getEarlyCompletionCount() * rules.get("earlyCompletion").getOrDefault("score", 5); + } + if (rules.containsKey("praise")) { + score += participant.getPraiseCount() * rules.get("praise").getOrDefault("score", 15); + } + + return score; + } catch (JsonProcessingException e) { + log.error("解析竞赛规则失败", e); + return 0; + } + } + + /** + * 结束竞赛并发放奖励 + */ + @Transactional + public void endContest(Long id) { + Contest contest = contestMapper.selectById(id); + if (contest == null) { + throw new BusinessException(ResultCode.NOT_FOUND, "竞赛不存在"); + } + + if (!"active".equals(contest.getStatus())) { + throw new BusinessException(ResultCode.PARAM_ERROR, "只有进行中的竞赛可以结束"); + } + + contest.setStatus("ended"); + contestMapper.updateById(contest); + + // 发放奖励 + distributeRewards(contest); + + log.info("竞赛结束: id={}, name={}", contest.getId(), contest.getName()); + } + + /** + * 发放竞赛奖励 + */ + private void distributeRewards(Contest contest) { + try { + List> rewards = objectMapper.readValue( + contest.getRewards(), new TypeReference>>() {}); + + List leaderboard = participantMapper.getLeaderboard(contest.getId()); + + for (int i = 0; i < leaderboard.size(); i++) { + ContestParticipant participant = leaderboard.get(i); + int rank = i + 1; + participant.setCurrentRank(rank); + + // 查找对应排名的奖励 + for (Map reward : rewards) { + int rewardRank = (Integer) reward.get("rank"); + if (rank == rewardRank) { + int rewardScore = (Integer) reward.get("score"); + participant.setRewardScore(rewardScore); + + // 发送通知 + notificationService.sendNotification( + participant.getUserId(), + "竞赛获奖通知", + String.format("恭喜!您在竞赛「%s」中获得第%d名,奖励%d积分!", + contest.getName(), rank, rewardScore), + "contest", + contest.getId() + ); + break; + } + } + + participantMapper.updateById(participant); + } + } catch (JsonProcessingException e) { + log.error("解析竞赛奖励配置失败", e); + } + } + + /** + * 领取竞赛奖励 + */ + @Transactional + public boolean claimReward(Long contestId, Long userId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(ContestParticipant::getContestId, contestId) + .eq(ContestParticipant::getUserId, userId); + ContestParticipant participant = participantMapper.selectOne(wrapper); + + if (participant == null) { + throw new BusinessException(ResultCode.NOT_FOUND, "您未参与此竞赛"); + } + + if (participant.getRewardClaimed() == 1) { + throw new BusinessException(ResultCode.PARAM_ERROR, "奖励已领取"); + } + + if (participant.getRewardScore() == null || participant.getRewardScore() <= 0) { + throw new BusinessException(ResultCode.PARAM_ERROR, "暂无奖励可领取"); + } + + // 更新用户积分 + User user = userMapper.selectById(userId); + int currentScore = user.getScore() != null ? user.getScore() : 0; + user.setScore(currentScore + participant.getRewardScore()); + userMapper.updateById(user); + + // 标记已领取 + participant.setRewardClaimed(1); + participant.setRewardClaimTime(LocalDateTime.now()); + participantMapper.updateById(participant); + + return true; + } + + /** + * 定时任务:自动开始和结束竞赛 + */ + @Scheduled(fixedRate = 60000) // 每分钟检查一次 + @Transactional + public void checkContestStatus() { + LocalDateTime now = LocalDateTime.now(); + + // 自动开始待开始的竞赛 + LambdaQueryWrapper startWrapper = new LambdaQueryWrapper<>(); + startWrapper.eq(Contest::getStatus, "scheduled") + .le(Contest::getStartTime, now); + List toStart = contestMapper.selectList(startWrapper); + + for (Contest contest : toStart) { + contest.setStatus("active"); + contestMapper.updateById(contest); + notifyContestStart(contest); + log.info("竞赛自动开始: id={}, name={}", contest.getId(), contest.getName()); + } + + // 自动结束已到期的竞赛 + LambdaQueryWrapper endWrapper = new LambdaQueryWrapper<>(); + endWrapper.eq(Contest::getStatus, "active") + .le(Contest::getEndTime, now); + List toEnd = contestMapper.selectList(endWrapper); + + for (Contest contest : toEnd) { + endContest(contest.getId()); + } + } + + /** + * 取消竞赛 + */ + @Transactional + public void cancelContest(Long id) { + Contest contest = contestMapper.selectById(id); + if (contest == null) { + throw new BusinessException(ResultCode.NOT_FOUND, "竞赛不存在"); + } + + if ("ended".equals(contest.getStatus())) { + throw new BusinessException(ResultCode.PARAM_ERROR, "已结束的竞赛不能取消"); + } + + contest.setStatus("cancelled"); + contestMapper.updateById(contest); + + log.info("竞赛已取消: id={}, name={}", contest.getId(), contest.getName()); + } + + /** + * 删除竞赛 + */ + @Transactional + public void deleteContest(Long id) { + Contest contest = contestMapper.selectById(id); + if (contest == null) { + throw new BusinessException(ResultCode.NOT_FOUND, "竞赛不存在"); + } + + if ("active".equals(contest.getStatus())) { + throw new BusinessException(ResultCode.PARAM_ERROR, "进行中的竞赛不能删除"); + } + + contestMapper.deleteById(id); + + // 删除参与者数据 + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(ContestParticipant::getContestId, id); + participantMapper.delete(wrapper); + } +} diff --git a/backend/src/main/java/com/designhub/service/DelayPenaltyService.java b/backend/src/main/java/com/designhub/service/DelayPenaltyService.java new file mode 100644 index 0000000..18808f7 --- /dev/null +++ b/backend/src/main/java/com/designhub/service/DelayPenaltyService.java @@ -0,0 +1,183 @@ +package com.designhub.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.designhub.entity.DelayPenaltyRecord; +import com.designhub.entity.DelayPenaltyRule; +import com.designhub.mapper.DelayPenaltyRecordMapper; +import com.designhub.mapper.DelayPenaltyRuleMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.List; + +/** + * 延误扣分Service + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class DelayPenaltyService { + + private final DelayPenaltyRecordMapper delayPenaltyRecordMapper; + private final DelayPenaltyRuleMapper delayPenaltyRuleMapper; + + /** + * 计算延误扣分 + * 根据需求文档的规则: + * - 轻微延误(< 2小时):每小时扣2分 + * - 一般延误(2-8小时):每小时扣3分 + * - 严重延误(8-24小时):每小时扣5分 + * - 重大延误(> 24小时):基础50分+每天10分 + */ + public DelayPenaltyRecord calculateDelayPenalty(Long taskId, Long designerId, + LocalDateTime deadline, + LocalDateTime actualTime) { + if (deadline == null || actualTime == null) { + return null; + } + + // 如果实际时间早于截止时间,未延误 + if (actualTime.isBefore(deadline) || actualTime.isEqual(deadline)) { + return null; + } + + // 计算延误小时数 + long delayHours = ChronoUnit.HOURS.between(deadline, actualTime); + long delayDays = ChronoUnit.DAYS.between(deadline, actualTime); + + // 确定延误等级和扣分 + String delayLevel; + Integer penaltyScore; + + if (delayHours < 2) { + // 轻微延误:每小时扣2分 + delayLevel = "light"; + penaltyScore = (int) Math.ceil(delayHours * 2.0); + } else if (delayHours < 8) { + // 一般延误:每小时扣3分 + delayLevel = "general"; + penaltyScore = (int) Math.ceil(delayHours * 3.0); + } else if (delayHours < 24) { + // 严重延误:每小时扣5分 + delayLevel = "severe"; + penaltyScore = (int) Math.ceil(delayHours * 5.0); + } else { + // 重大延误:基础50分+每天10分 + delayLevel = "major"; + penaltyScore = 50 + (int) delayDays * 10; + } + + // 创建延误扣分记录 + DelayPenaltyRecord record = new DelayPenaltyRecord(); + record.setTaskId(taskId); + record.setDesignerId(designerId); + record.setDelayDays((int) delayDays); + record.setDelayHours(delayHours); + record.setDelayLevel(delayLevel); + record.setPenaltyScore(penaltyScore); + record.setOriginalDeadline(deadline); + record.setActualTime(actualTime); + record.setStatus("confirmed"); + + log.info("计算延误扣分: taskId={}, designerId={}, delayHours={}, delayLevel={}, penaltyScore={}", + taskId, designerId, delayHours, delayLevel, penaltyScore); + + return record; + } + + /** + * 保存延误扣分记录 + */ + @Transactional(rollbackFor = Exception.class) + public DelayPenaltyRecord saveDelayPenaltyRecord(DelayPenaltyRecord record) { + if (record == null) { + return null; + } + delayPenaltyRecordMapper.insert(record); + log.info("保存延误扣分记录: recordId={}, taskId={}, penaltyScore={}", + record.getId(), record.getTaskId(), record.getPenaltyScore()); + return record; + } + + /** + * 根据任务ID查询延误记录 + */ + public List getDelayRecordsByTaskId(Long taskId) { + return delayPenaltyRecordMapper.findByTaskId(taskId); + } + + /** + * 根据设计师ID查询延误记录 + */ + public List getDelayRecordsByDesignerId(Long designerId) { + return delayPenaltyRecordMapper.findByDesignerId(designerId); + } + + /** + * 统计设计师的延误扣分总数 + */ + public Integer getTotalPenaltyScoreByDesignerId(Long designerId) { + Integer total = delayPenaltyRecordMapper.sumPenaltyScoreByDesignerId(designerId); + return total != null ? total : 0; + } + + /** + * 申诉延误扣分 + */ + @Transactional(rollbackFor = Exception.class) + public void appealDelayPenalty(Long recordId, Long designerId, String appealReason) { + DelayPenaltyRecord record = delayPenaltyRecordMapper.selectById(recordId); + if (record == null) { + throw new RuntimeException("延误扣分记录不存在"); + } + + if (!record.getDesignerId().equals(designerId)) { + throw new RuntimeException("无权申诉此记录"); + } + + if (!"confirmed".equals(record.getStatus())) { + throw new RuntimeException("该记录已申诉或已处理"); + } + + record.setStatus("appealed"); + record.setAppealReason(appealReason); + record.setAppealTime(LocalDateTime.now()); + delayPenaltyRecordMapper.updateById(record); + + log.info("设计师申诉延误扣分: recordId={}, designerId={}, reason={}", + recordId, designerId, appealReason); + } + + /** + * 管理员审批申诉 + */ + @Transactional(rollbackFor = Exception.class) + public void approveAppeal(Long recordId, Long approverId, Boolean approved, String appealResult) { + DelayPenaltyRecord record = delayPenaltyRecordMapper.selectById(recordId); + if (record == null) { + throw new RuntimeException("延误扣分记录不存在"); + } + + if (!"appealed".equals(record.getStatus())) { + throw new RuntimeException("该记录不是申诉状态"); + } + + if (approved) { + record.setStatus("approved"); + } else { + record.setStatus("rejected"); + } + record.setAppealResult(appealResult); + record.setApproverId(approverId); + record.setApproveTime(LocalDateTime.now()); + delayPenaltyRecordMapper.updateById(record); + + log.info("管理员审批申诉: recordId={}, approverId={}, approved={}", + recordId, approverId, approved); + } +} + diff --git a/backend/src/main/java/com/designhub/service/DemandTypeDepartmentService.java b/backend/src/main/java/com/designhub/service/DemandTypeDepartmentService.java new file mode 100644 index 0000000..8eaeffb --- /dev/null +++ b/backend/src/main/java/com/designhub/service/DemandTypeDepartmentService.java @@ -0,0 +1,74 @@ +package com.designhub.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.designhub.entity.DemandTypeDepartment; +import com.designhub.mapper.DemandTypeDepartmentMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * 需求类型-接单部门关联服务 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class DemandTypeDepartmentService { + + private final DemandTypeDepartmentMapper demandTypeDepartmentMapper; + + /** + * 设置需求类型的可接单部门列表 + */ + @Transactional(rollbackFor = Exception.class) + public boolean setDemandTypeDepartments(Long demandTypeId, List departmentIds) { + log.info("设置需求类型可接单部门: demandTypeId={}, departmentIds={}", demandTypeId, departmentIds); + + // 删除旧的关联 + demandTypeDepartmentMapper.delete( + new LambdaQueryWrapper() + .eq(DemandTypeDepartment::getDemandTypeId, demandTypeId) + ); + + // 添加新的关联 + if (departmentIds != null && !departmentIds.isEmpty()) { + for (Long deptId : departmentIds) { + DemandTypeDepartment relation = new DemandTypeDepartment(); + relation.setDemandTypeId(demandTypeId); + relation.setDepartmentId(deptId); + demandTypeDepartmentMapper.insert(relation); + } + } + + return true; + } + + /** + * 获取需求类型的可接单部门ID列表 + */ + public List getDepartmentIdsByDemandType(Long demandTypeId) { + return demandTypeDepartmentMapper.selectDepartmentIdsByDemandTypeId(demandTypeId); + } + + /** + * 获取部门可接的需求类型ID列表 + */ + public List getDemandTypeIdsByDepartment(Long departmentId) { + return demandTypeDepartmentMapper.selectDemandTypeIdsByDepartmentId(departmentId); + } + + /** + * 检查部门是否可以接指定类型的任务 + */ + public boolean canDepartmentReceiveDemandType(Long departmentId, Long demandTypeId) { + List allowedDemandTypes = getDemandTypeIdsByDepartment(departmentId); + // 如果没有配置,表示可以接所有类型 + if (allowedDemandTypes == null || allowedDemandTypes.isEmpty()) { + return true; + } + return allowedDemandTypes.contains(demandTypeId); + } +} diff --git a/backend/src/main/java/com/designhub/service/DepartmentService.java b/backend/src/main/java/com/designhub/service/DepartmentService.java new file mode 100644 index 0000000..d25546a --- /dev/null +++ b/backend/src/main/java/com/designhub/service/DepartmentService.java @@ -0,0 +1,459 @@ +package com.designhub.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.designhub.dto.DepartmentCreateRequest; +import com.designhub.dto.DepartmentLimitSettingsRequest; +import com.designhub.dto.DepartmentScoreAdjustRequest; +import com.designhub.dto.DepartmentUpdateRequest; +import com.designhub.entity.Department; +import com.designhub.entity.User; +import com.designhub.exception.BusinessException; +import com.designhub.mapper.DepartmentMapper; +import com.designhub.mapper.UserMapper; +import com.designhub.vo.DepartmentOverviewVO; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.CollectionUtils; +import org.springframework.util.StringUtils; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +/** + * 部门管理Service + */ +@Service +@RequiredArgsConstructor +public class DepartmentService { + + private final DepartmentMapper departmentMapper; + private final TeamScorePoolService teamScorePoolService; + private final UserMapper userMapper; + + /** + * 获取所有部门列表 + */ + public List getAllDepartments() { + return departmentMapper.selectList( + new LambdaQueryWrapper() + .eq(Department::getStatus, 1) + .orderByAsc(Department::getSortOrder) + ); + } + + /** + * 获取部门树形结构 + */ + public List getDepartmentTree() { + List allDepts = departmentMapper.selectDeptTree(); + return buildTree(allDepts, 0L); + } + + /** + * 根据类型获取部门列表 + */ + public List getDepartmentsByType(String deptType) { + return departmentMapper.selectList( + new LambdaQueryWrapper() + .eq(Department::getDeptType, deptType) + .eq(Department::getStatus, 1) + .orderByAsc(Department::getSortOrder) + ); + } + + /** + * 根据ID获取部门 + */ + public Department getDepartmentById(Long id) { + return departmentMapper.selectById(id); + } + + /** + * 部门概览列表 + */ + public List getDepartmentOverviewList() { + List departments = getAllDepartments(); + Map memberCountMap = loadMemberCountMap(); + Map leaderNameMap = loadLeaderNames(departments); + return departments.stream() + .map(dept -> buildOverviewVO(dept, memberCountMap, leaderNameMap)) + .collect(Collectors.toList()); + } + + /** + * 获取单个部门概览 + */ + public DepartmentOverviewVO getDepartmentOverview(Long deptId) { + Department department = getDepartmentById(deptId); + if (department == null) { + throw new BusinessException("部门不存在"); + } + Map memberCountMap = loadMemberCountMap(); + Map leaderNameMap = loadLeaderNames(Collections.singletonList(department)); + return buildOverviewVO(department, memberCountMap, leaderNameMap); + } + + /** + * 创建部门 + */ + @Transactional(rollbackFor = Exception.class) + public DepartmentOverviewVO createDepartment(DepartmentCreateRequest request) { + validateDeptType(request.getDeptType()); + + Department department = new Department(); + department.setDeptName(request.getDeptName()); + department.setDeptCode(request.getDeptCode()); + department.setDeptType(request.getDeptType()); + department.setParentId(request.getParentId() != null ? request.getParentId() : 0L); + department.setLeaderId(request.getLeaderId()); + department.setSortOrder(request.getSortOrder()); + department.setStatus(request.getStatus()); + department.setAllowedDemandTypes(joinAllowedTypes(request.getAllowedDemandTypes())); + + departmentMapper.insert(department); + + // 初始化积分池 + teamScorePoolService.upsertPool(department.getId(), department.getDeptName()); + if ("demand".equals(request.getDeptType())) { + if (request.getInitialScoreBalance() != null) { + teamScorePoolService.setBalance(department.getId(), request.getInitialScoreBalance()); + department.setScoreBalance(request.getInitialScoreBalance()); + } + teamScorePoolService.updateLimitConfig( + department.getId(), + defaultLimitEnabled(request.getLimitEnabled()), + defaultLimitPeriod(request.getLimitPeriod()), + request.getPeriodLimit(), + request.getAlertThreshold() + ); + department.setLimitEnabled(defaultLimitEnabled(request.getLimitEnabled())); + department.setLimitPeriod(defaultLimitPeriod(request.getLimitPeriod())); + department.setPeriodLimit(request.getPeriodLimit()); + department.setAlertThreshold(request.getAlertThreshold()); + department.setOverLimitAction( + StringUtils.hasText(request.getOverLimitAction()) ? request.getOverLimitAction() : "alert" + ); + } + + return getDepartmentOverview(department.getId()); + } + + /** + * 更新部门 + */ + @Transactional(rollbackFor = Exception.class) + public boolean updateDepartment(Long id, DepartmentUpdateRequest request) { + Department existDept = getDepartmentById(id); + if (existDept == null) { + throw new BusinessException("部门不存在"); + } + + if (StringUtils.hasText(request.getDeptName())) { + existDept.setDeptName(request.getDeptName()); + } + if (StringUtils.hasText(request.getDeptCode())) { + existDept.setDeptCode(request.getDeptCode()); + } + if (StringUtils.hasText(request.getDeptType())) { + validateDeptType(request.getDeptType()); + existDept.setDeptType(request.getDeptType()); + } + if (request.getParentId() != null) { + existDept.setParentId(request.getParentId()); + } + if (request.getLeaderId() != null) { + existDept.setLeaderId(request.getLeaderId()); + } + if (request.getSortOrder() != null) { + existDept.setSortOrder(request.getSortOrder()); + } + if (request.getStatus() != null) { + existDept.setStatus(request.getStatus()); + } + if (request.getAllowedDemandTypes() != null) { + existDept.setAllowedDemandTypes(joinAllowedTypes(request.getAllowedDemandTypes())); + } + if (request.getLimitEnabled() != null) { + existDept.setLimitEnabled(request.getLimitEnabled()); + } + if (request.getLimitPeriod() != null) { + existDept.setLimitPeriod(request.getLimitPeriod()); + } + if (request.getPeriodLimit() != null) { + existDept.setPeriodLimit(request.getPeriodLimit()); + } + if (request.getAlertThreshold() != null) { + existDept.setAlertThreshold(request.getAlertThreshold()); + } + if (request.getOverLimitAction() != null) { + existDept.setOverLimitAction(request.getOverLimitAction()); + } + + departmentMapper.updateById(existDept); + + // 同步积分池 + teamScorePoolService.upsertPool(existDept.getId(), existDept.getDeptName()); + if ("demand".equals(existDept.getDeptType()) && + (request.getLimitEnabled() != null || request.getLimitPeriod() != null || + request.getPeriodLimit() != null || request.getAlertThreshold() != null)) { + teamScorePoolService.updateLimitConfig( + existDept.getId(), + request.getLimitEnabled(), + request.getLimitPeriod(), + request.getPeriodLimit(), + request.getAlertThreshold() + ); + } + + return true; + } + + /** + * 更新部门限制配置 + */ + @Transactional(rollbackFor = Exception.class) + public boolean updateLimitSettings(Long deptId, DepartmentLimitSettingsRequest request) { + Department department = getDepartmentById(deptId); + if (department == null) { + throw new BusinessException("部门不存在"); + } + if (!"demand".equals(department.getDeptType())) { + throw new BusinessException("只有需求部门需要配置积分限制"); + } + + department.setLimitEnabled(request.getLimitEnabled()); + department.setLimitPeriod(request.getLimitPeriod()); + department.setPeriodLimit(request.getPeriodLimit()); + department.setAlertThreshold(request.getAlertThreshold()); + department.setOverLimitAction(request.getOverLimitAction()); + departmentMapper.updateById(department); + + teamScorePoolService.updateLimitConfig( + deptId, + request.getLimitEnabled(), + request.getLimitPeriod(), + request.getPeriodLimit(), + request.getAlertThreshold() + ); + return true; + } + + /** + * 删除部门(逻辑删除) + */ + @Transactional(rollbackFor = Exception.class) + public boolean deleteDepartment(Long id) { + long childCount = departmentMapper.selectCount( + new LambdaQueryWrapper() + .eq(Department::getParentId, id) + ); + if (childCount > 0) { + throw new BusinessException("该部门下还有子部门,无法删除"); + } + + return departmentMapper.deleteById(id) > 0; + } + + /** + * 设置部门负责人/主管 + */ + @Transactional(rollbackFor = Exception.class) + public boolean setDepartmentHead(Long deptId, Long userId, String type) { + Department department = getDepartmentById(deptId); + if (department == null) { + throw new BusinessException("部门不存在"); + } + + if ("head".equals(type)) { + if (!"demand".equals(department.getDeptType())) { + throw new BusinessException("只有需求部门可以设置负责人"); + } + department.setLeaderId(userId); + } else if ("supervisor".equals(type)) { + if (!"receiving".equals(department.getDeptType())) { + throw new BusinessException("只有接单部门可以设置主管"); + } + department.setSupervisorId(userId); + } else { + throw new BusinessException("类型参数错误"); + } + + return departmentMapper.updateById(department) > 0; + } + + /** + * 调整部门积分 + */ + @Transactional(rollbackFor = Exception.class) + public void adjustScore(Long deptId, DepartmentScoreAdjustRequest request) { + Department department = getDepartmentById(deptId); + if (department == null) { + throw new BusinessException("部门不存在"); + } + if (!"demand".equals(department.getDeptType())) { + throw new BusinessException("只有需求部门可以调整积分"); + } + if (request.getAmount() == null || request.getAmount() <= 0) { + throw new BusinessException("调整积分必须大于0"); + } + + String action = request.getAction().toLowerCase(); + switch (action) { + case "recharge" -> teamScorePoolService.rechargeScore(deptId, request.getAmount()); + case "deduct" -> teamScorePoolService.deductScore(deptId, request.getAmount()); + default -> throw new BusinessException("不支持的积分操作类型"); + } + } + + private void validateDeptType(String deptType) { + if (!"demand".equals(deptType) && !"receiving".equals(deptType)) { + throw new BusinessException("部门类型必须是 demand 或 receiving"); + } + } + + private Integer defaultLimitEnabled(Integer limitEnabled) { + return limitEnabled != null ? limitEnabled : 0; + } + + private String defaultLimitPeriod(String limitPeriod) { + return StringUtils.hasText(limitPeriod) ? limitPeriod : "MONTHLY"; + } + + private String joinAllowedTypes(List allowedTypes) { + if (CollectionUtils.isEmpty(allowedTypes)) { + return null; + } + return allowedTypes.stream() + .filter(StringUtils::hasText) + .collect(Collectors.joining(",")); + } + + private List splitAllowedTypes(String allowedTypes) { + if (allowedTypes == null || allowedTypes.trim().isEmpty()) { + return Collections.emptyList(); + } + return Arrays.stream(allowedTypes.split(",")) + .map(String::trim) + .filter(StringUtils::hasText) + .collect(Collectors.toList()); + } + + private Map loadMemberCountMap() { + return userMapper.selectList(null).stream() + .filter(user -> user.getDeptId() != null) + .collect(Collectors.groupingBy( + User::getDeptId, + Collectors.counting() + )); + } + + private Map loadLeaderNames(List departments) { + List leaderIds = departments.stream() + .map(Department::getLeaderId) + .filter(Objects::nonNull) + .distinct() + .collect(Collectors.toList()); + if (leaderIds.isEmpty()) { + return Collections.emptyMap(); + } + return userMapper.selectBatchIds(leaderIds).stream() + .collect(Collectors.toMap( + User::getId, + user -> StringUtils.hasText(user.getRealName()) ? user.getRealName() : user.getUsername() + )); + } + + private DepartmentOverviewVO buildOverviewVO(Department department, + Map memberCountMap, + Map leaderNameMap) { + DepartmentOverviewVO vo = new DepartmentOverviewVO(); + vo.setId(department.getId()); + vo.setDeptName(department.getDeptName()); + vo.setDeptCode(department.getDeptCode()); + vo.setDeptType(department.getDeptType()); + vo.setParentId(department.getParentId()); + vo.setLeaderId(department.getLeaderId()); + if (department.getLeaderId() != null && leaderNameMap != null) { + vo.setLeaderName(leaderNameMap.get(department.getLeaderId())); + } + vo.setDescription(department.getDescription()); + vo.setSortOrder(department.getSortOrder()); + vo.setStatus(department.getStatus()); + vo.setAllowedDemandTypes(splitAllowedTypes(department.getAllowedDemandTypes())); + + var pool = teamScorePoolService.getByDeptId(department.getId()); + if (pool != null) { + vo.setScoreBalance(pool.getBalance()); + vo.setTotalRecharge(pool.getTotalRecharge()); + vo.setTotalConsume(pool.getTotalConsume()); + vo.setTotalEarn(pool.getTotalEarn()); + vo.setLimitEnabled(pool.getLimitEnabled()); + vo.setLimitPeriod(pool.getLimitPeriod()); + vo.setPeriodLimit(pool.getPeriodLimit()); + vo.setPeriodUsed(pool.getPeriodUsed()); + vo.setAlertThreshold(pool.getAlertThreshold()); + vo.setLimitAlert(teamScorePoolService.isAlert(department.getId())); + } else { + vo.setScoreBalance(department.getScoreBalance()); + vo.setLimitEnabled(department.getLimitEnabled()); + vo.setLimitPeriod(department.getLimitPeriod()); + vo.setPeriodLimit(department.getPeriodLimit()); + vo.setAlertThreshold(department.getAlertThreshold()); + vo.setLimitAlert(false); + } + vo.setOverLimitAction(department.getOverLimitAction()); + + if (memberCountMap != null && memberCountMap.containsKey(department.getId())) { + vo.setMemberCount(memberCountMap.get(department.getId()).intValue()); + } else { + vo.setMemberCount(0); + } + return vo; + } + + /** + * 构建树形结构 + */ + private List buildTree(List allDepts, Long parentId) { + List children = allDepts.stream() + .filter(dept -> { + Long deptParentId = dept.getParentId(); + if (deptParentId == null) { + return parentId == null || parentId == 0L; + } + return deptParentId.equals(parentId); + }) + .collect(Collectors.toList()); + + for (Department child : children) { + buildTree(allDepts, child.getId()); + } + + return children; + } + + /** + * 检查用户是否为任何部门的负责人 + */ + public boolean isUserDepartmentLeader(Long userId) { + if (userId == null) { + return false; + } + + // 查询是否有部门的 leader_id 等于该用户ID + Long count = departmentMapper.selectCount( + new LambdaQueryWrapper() + .eq(Department::getLeaderId, userId) + .eq(Department::getStatus, 1) + ); + + return count != null && count > 0; + } +} + diff --git a/backend/src/main/java/com/designhub/service/DesignerService.java b/backend/src/main/java/com/designhub/service/DesignerService.java new file mode 100644 index 0000000..0e2a9e2 --- /dev/null +++ b/backend/src/main/java/com/designhub/service/DesignerService.java @@ -0,0 +1,417 @@ +package com.designhub.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.designhub.entity.Department; +import com.designhub.entity.Task; +import com.designhub.entity.User; +import com.designhub.mapper.DepartmentMapper; +import com.designhub.mapper.TaskMapper; +import com.designhub.mapper.UserMapper; +import com.designhub.vo.DesignerStatsVO; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 设计师统计Service + */ +@Service +@RequiredArgsConstructor +public class DesignerService { + + private final UserMapper userMapper; + private final TaskMapper taskMapper; + private final DepartmentMapper departmentMapper; + + /** + * 获取所有设计师的统计信息 + */ + public List getAllDesignerStats() { + // 仅获取接单部门(designer/receiving)的用户 + List receivingDeptIds = departmentMapper.selectList( + new LambdaQueryWrapper() + .eq(Department::getDeptType, "receiving") + .eq(Department::getStatus, 1) + ).stream() + .map(Department::getId) + .collect(Collectors.toList()); + + if (receivingDeptIds.isEmpty()) { + return new ArrayList<>(); + } + + List designers = userMapper.selectList( + new LambdaQueryWrapper() + .eq(User::getStatus, 1) + .in(User::getDeptId, receivingDeptIds) + ); + + return designers.stream() + .map(this::buildDesignerStats) + .collect(Collectors.toList()); + } + + /** + * 获取单个设计师的统计信息 + */ + public DesignerStatsVO getDesignerStats(Long userId) { + User user = userMapper.selectById(userId); + if (user == null) { + return null; + } + return buildDesignerStats(user); + } + + /** + * 获取排行榜数据(支持按月查询) + */ + public List getLeaderboard(String type, Integer limit, String month) { + List stats; + + // 如果指定了月份,则查询该月份的数据 + if (month != null && !month.isEmpty()) { + stats = getDesignerStatsByMonth(month); + } else { + // 否则查询全部数据 + stats = getAllDesignerStats(); + } + + // 根据类型排序 + switch (type) { + case "score": + // 积分榜 + stats.sort((a, b) -> Integer.compare( + b.getTotalScore() != null ? b.getTotalScore() : 0, + a.getTotalScore() != null ? a.getTotalScore() : 0 + )); + break; + case "efficiency": + // 效率榜(完成率) + stats.sort((a, b) -> Double.compare( + b.getCompletionRate() != null ? b.getCompletionRate() : 0.0, + a.getCompletionRate() != null ? a.getCompletionRate() : 0.0 + )); + break; + case "quality": + // 好评榜 + stats.sort((a, b) -> { + int countCompare = Integer.compare( + b.getGoodReviewCount() != null ? b.getGoodReviewCount() : 0, + a.getGoodReviewCount() != null ? a.getGoodReviewCount() : 0 + ); + if (countCompare == 0) { + // 好评数相同时,比较平均评分 + return Double.compare( + b.getAvgRating() != null ? b.getAvgRating() : 0.0, + a.getAvgRating() != null ? a.getAvgRating() : 0.0 + ); + } + return countCompare; + }); + break; + case "diligence": + // 勤勉榜(总任务数) + stats.sort((a, b) -> Integer.compare( + b.getTotalTaskCount() != null ? b.getTotalTaskCount() : 0, + a.getTotalTaskCount() != null ? a.getTotalTaskCount() : 0 + )); + break; + default: + // 默认按积分排序 + stats.sort((a, b) -> Integer.compare( + b.getTotalScore() != null ? b.getTotalScore() : 0, + a.getTotalScore() != null ? a.getTotalScore() : 0 + )); + } + + // 限制返回数量 + if (limit != null && limit > 0 && stats.size() > limit) { + return stats.subList(0, limit); + } + + return stats; + } + + /** + * 构建设计师统计信息 + */ + private DesignerStatsVO buildDesignerStats(User user) { + DesignerStatsVO stats = new DesignerStatsVO(); + + // 基本信息 + stats.setUserId(user.getId()); + stats.setUserName(user.getRealName()); + stats.setAvatar(user.getAvatar()); + stats.setDeptName(user.getDeptName()); + + // 查询任务统计 + Long userId = user.getId(); + + // 总接收任务数 + Long totalCountLong = taskMapper.selectCount( + new LambdaQueryWrapper() + .eq(Task::getReceiverId, userId) + ); + Integer totalCount = totalCountLong.intValue(); + stats.setTotalTaskCount(totalCount); + + // 进行中任务数 + Long inProgressCountLong = taskMapper.selectCount( + new LambdaQueryWrapper() + .eq(Task::getReceiverId, userId) + .eq(Task::getStatus, "in_progress") + ); + Integer inProgressCount = inProgressCountLong.intValue(); + stats.setInProgressCount(inProgressCount); + + // 待接单任务数(队列)- 这里应该是用户已接收但还未开始的任务,实际上应该是0 + // 因为一旦接收任务,状态就会变成 in_progress + // 队列数可以理解为用户当前可以接的任务数(这个需要从任务大厅查询,这里暂时设为0) + stats.setQueueCount(0); + + // 已完成任务数 + Long completedCountLong = taskMapper.selectCount( + new LambdaQueryWrapper() + .eq(Task::getReceiverId, userId) + .eq(Task::getStatus, "completed") + ); + Integer completedCount = completedCountLong.intValue(); + stats.setCompletedCount(completedCount); + + // 延误任务数 + Long delayedCountLong = taskMapper.selectCount( + new LambdaQueryWrapper() + .eq(Task::getReceiverId, userId) + .eq(Task::getIsDelayed, 1) + ); + Integer delayedCount = delayedCountLong.intValue(); + stats.setDelayedCount(delayedCount); + + // 计算完成率 + if (totalCount > 0) { + stats.setCompletionRate(completedCount * 100.0 / totalCount); + } else { + stats.setCompletionRate(0.0); + } + + // 计算准时率 + if (completedCount > 0) { + Integer ontimeCount = completedCount - delayedCount; + stats.setOntimeRate(ontimeCount * 100.0 / completedCount); + } else { + stats.setOntimeRate(0.0); + } + + // 查询已完成任务的详细信息(用于计算积分和评分) + List completedTasks = taskMapper.selectList( + new LambdaQueryWrapper() + .eq(Task::getReceiverId, userId) + .eq(Task::getStatus, "completed") + ); + + // 累计获得积分 + Integer totalScore = completedTasks.stream() + .mapToInt(task -> task.getScore() != null ? task.getScore() : 0) + .sum(); + stats.setTotalScore(totalScore); + + // 平均评分 + List ratings = completedTasks.stream() + .map(Task::getRating) + .filter(rating -> rating != null && rating > 0) + .collect(Collectors.toList()); + + if (!ratings.isEmpty()) { + double avgRating = ratings.stream() + .mapToInt(Integer::intValue) + .average() + .orElse(0.0); + stats.setAvgRating(avgRating); + + // 好评数(5星) + long goodReviewCount = ratings.stream() + .filter(rating -> rating == 5) + .count(); + stats.setGoodReviewCount((int) goodReviewCount); + } else { + stats.setAvgRating(0.0); + stats.setGoodReviewCount(0); + } + + // 在线状态(简化处理,实际应该通过WebSocket或心跳机制维护) + stats.setOnlineStatus(1); + stats.setLastActiveTime("刚刚"); + + return stats; + } + + /** + * 获取指定月份的设计师统计信息 + */ + private List getDesignerStatsByMonth(String month) { + // 解析月份 (YYYY-MM) + String[] parts = month.split("-"); + if (parts.length != 2) { + return getAllDesignerStats(); // 格式错误时返回全部数据 + } + + int year = Integer.parseInt(parts[0]); + int monthNum = Integer.parseInt(parts[1]); + + // 计算月份的起始和结束时间 + String startDate = String.format("%d-%02d-01 00:00:00", year, monthNum); + String endDate; + if (monthNum == 12) { + endDate = String.format("%d-01-01 00:00:00", year + 1); + } else { + endDate = String.format("%d-%02d-01 00:00:00", year, monthNum + 1); + } + + // 获取接单部门的设计师列表 + List receivingDeptIds = departmentMapper.selectList( + new LambdaQueryWrapper() + .eq(Department::getDeptType, "receiving") + .eq(Department::getStatus, 1) + ).stream() + .map(Department::getId) + .collect(Collectors.toList()); + + if (receivingDeptIds.isEmpty()) { + return new ArrayList<>(); + } + + List designers = userMapper.selectList( + new LambdaQueryWrapper() + .eq(User::getStatus, 1) + .in(User::getDeptId, receivingDeptIds) + ); + + // 构建每个设计师的月度统计 + return designers.stream() + .map(user -> buildDesignerMonthStats(user, startDate, endDate)) + .collect(Collectors.toList()); + } + + /** + * 构建设计师指定月份的统计信息 + */ + private DesignerStatsVO buildDesignerMonthStats(User user, String startDate, String endDate) { + DesignerStatsVO stats = new DesignerStatsVO(); + + // 基本信息 + stats.setUserId(user.getId()); + stats.setUserName(user.getRealName()); + stats.setAvatar(user.getAvatar()); + stats.setDeptName(user.getDeptName()); + + Long userId = user.getId(); + + // 当月总接收任务数 + Long totalCountLong = taskMapper.selectCount( + new LambdaQueryWrapper() + .eq(Task::getReceiverId, userId) + .ge(Task::getCreateTime, startDate) + .lt(Task::getCreateTime, endDate) + ); + stats.setTotalTaskCount(totalCountLong.intValue()); + + // 当月进行中任务数(实际在月份查询时应该是0,因为查历史月份) + Long inProgressCountLong = taskMapper.selectCount( + new LambdaQueryWrapper() + .eq(Task::getReceiverId, userId) + .eq(Task::getStatus, "in_progress") + .ge(Task::getCreateTime, startDate) + .lt(Task::getCreateTime, endDate) + ); + stats.setInProgressCount(inProgressCountLong.intValue()); + + stats.setQueueCount(0); + + // 当月已完成任务数 + Long completedCountLong = taskMapper.selectCount( + new LambdaQueryWrapper() + .eq(Task::getReceiverId, userId) + .eq(Task::getStatus, "completed") + .ge(Task::getCompleteTime, startDate) + .lt(Task::getCompleteTime, endDate) + ); + stats.setCompletedCount(completedCountLong.intValue()); + + // 当月总积分(已完成任务的积分之和) + List completedTasks = taskMapper.selectList( + new LambdaQueryWrapper() + .eq(Task::getReceiverId, userId) + .eq(Task::getStatus, "completed") + .ge(Task::getCompleteTime, startDate) + .lt(Task::getCompleteTime, endDate) + ); + + Integer totalScore = completedTasks.stream() + .mapToInt(task -> task.getScore() != null ? task.getScore() : 0) + .sum(); + stats.setTotalScore(totalScore); + + // 当月延期任务数 + Long delayedCountLong = taskMapper.selectCount( + new LambdaQueryWrapper() + .eq(Task::getReceiverId, userId) + .eq(Task::getIsDelayed, 1) + .ge(Task::getCompleteTime, startDate) + .lt(Task::getCompleteTime, endDate) + ); + stats.setDelayedCount(delayedCountLong.intValue()); + + // 计算当月完成率 + if (totalCountLong > 0) { + double completionRate = (completedCountLong.doubleValue() / totalCountLong.doubleValue()) * 100; + stats.setCompletionRate(completionRate); + } else { + stats.setCompletionRate(0.0); + } + + // 计算当月按时完成率 + if (completedCountLong > 0) { + long ontimeCount = completedCountLong - delayedCountLong; + double ontimeRate = (ontimeCount / (double)completedCountLong) * 100; + stats.setOntimeRate(ontimeRate); + } else { + stats.setOntimeRate(0.0); + } + + // 当月平均评分 + Double avgRating = completedTasks.stream() + .filter(task -> task.getRating() != null && task.getRating() > 0) + .mapToInt(Task::getRating) + .average() + .orElse(0.0); + stats.setAvgRating(avgRating); + + // 当月好评数(评分>=4) + long goodReviewCount = completedTasks.stream() + .filter(task -> task.getRating() != null && task.getRating() >= 4) + .count(); + stats.setGoodReviewCount((int)goodReviewCount); + + // 在线状态(历史月份时设为离线) + stats.setOnlineStatus(0); + stats.setLastActiveTime(null); + + return stats; + } + + /** + * 获取设计师进行中的任务列表 + */ + public List getDesignerInProgressTasks(Long userId) { + return taskMapper.selectList( + new LambdaQueryWrapper() + .eq(Task::getReceiverId, userId) + .eq(Task::getStatus, "in_progress") + .orderByDesc(Task::getCreateTime) + ); + } +} + diff --git a/backend/src/main/java/com/designhub/service/DingTalkService.java b/backend/src/main/java/com/designhub/service/DingTalkService.java new file mode 100644 index 0000000..b96ceef --- /dev/null +++ b/backend/src/main/java/com/designhub/service/DingTalkService.java @@ -0,0 +1,507 @@ +package com.designhub.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.designhub.common.ResultCode; +import com.designhub.entity.DingTalkConfig; +import com.designhub.exception.BusinessException; +import com.designhub.mapper.DingTalkConfigMapper; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.client.RestTemplate; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.*; + +/** + * 钉钉集成 Service + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class DingTalkService { + + private final DingTalkConfigMapper configMapper; + private final ObjectMapper objectMapper; + private final RestTemplate restTemplate = new RestTemplate(); + + private static final String DINGTALK_API_BASE = "https://oapi.dingtalk.com"; + + /** + * 获取钉钉配置(优先获取启用的配置,如果没有则获取最新的配置) + */ + public DingTalkConfig getConfig() { + // 1. 优先获取启用的配置 + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(DingTalkConfig::getEnabled, 1) + .orderByDesc(DingTalkConfig::getUpdateTime) + .last("LIMIT 1"); + DingTalkConfig config = configMapper.selectOne(wrapper); + + // 2. 如果没有启用的配置,获取最新的配置(用于管理界面显示) + if (config == null) { + wrapper = new LambdaQueryWrapper<>(); + wrapper.orderByDesc(DingTalkConfig::getUpdateTime) + .last("LIMIT 1"); + config = configMapper.selectOne(wrapper); + } + + return config; + } + + /** + * 检查钉钉是否已配置且启用 + */ + public boolean isConfigured() { + DingTalkConfig config = getConfig(); + return config != null + && config.getEnabled() == 1 + && config.getAppKey() != null + && !config.getAppKey().isEmpty() + && config.getAppSecret() != null + && !config.getAppSecret().isEmpty(); + } + + /** + * 保存钉钉配置 + */ + @Transactional + public DingTalkConfig saveConfig(DingTalkConfig config) { + // 如果enabledFeatures不是字符串格式,转换为JSON字符串 + if (config.getEnabledFeatures() != null && !config.getEnabledFeatures().trim().startsWith("{")) { + // 已经是JSON字符串,不需要处理 + } + + if (config.getId() != null) { + configMapper.updateById(config); + } else { + config.setEnabled(1); + configMapper.insert(config); + } + return config; + } + + /** + * 测试钉钉连接 + */ + public boolean testConnection(DingTalkConfig config) { + try { + String accessToken = getAccessToken(config); + return accessToken != null && !accessToken.isEmpty(); + } catch (Exception e) { + log.error("钉钉连接测试失败", e); + return false; + } + } + + /** + * 获取 Access Token + */ + public String getAccessToken(DingTalkConfig config) { + // 检查缓存的token是否有效 + if (config.getAccessToken() != null && config.getTokenExpireTime() != null + && config.getTokenExpireTime().isAfter(LocalDateTime.now())) { + return config.getAccessToken(); + } + + try { + String url = String.format("%s/gettoken?appkey=%s&appsecret=%s", + DINGTALK_API_BASE, config.getAppKey(), config.getAppSecret()); + + ResponseEntity response = restTemplate.getForEntity(url, Map.class); + Map body = response.getBody(); + + if (body != null && body.get("errcode").equals(0)) { + String accessToken = (String) body.get("access_token"); + int expiresIn = (Integer) body.get("expires_in"); + + // 缓存token + config.setAccessToken(accessToken); + config.setTokenExpireTime(LocalDateTime.now().plusSeconds(expiresIn - 300)); // 提前5分钟过期 + configMapper.updateById(config); + + return accessToken; + } else { + log.error("获取钉钉AccessToken失败: {}", body); + return null; + } + } catch (Exception e) { + log.error("获取钉钉AccessToken异常", e); + return null; + } + } + + /** + * 发送工作通知 + */ + public boolean sendWorkNotification(List userIds, String title, String content) { + DingTalkConfig config = getConfig(); + if (config == null) { + log.warn("钉钉未配置,跳过发送通知"); + return false; + } + + try { + String accessToken = getAccessToken(config); + if (accessToken == null) { + return false; + } + + String url = String.format("%s/topapi/message/corpconversation/asyncsend_v2?access_token=%s", + DINGTALK_API_BASE, accessToken); + + Map msg = new HashMap<>(); + msg.put("msgtype", "action_card"); + Map actionCard = new HashMap<>(); + actionCard.put("title", title); + actionCard.put("markdown", content); + actionCard.put("single_title", "查看详情"); + actionCard.put("single_url", "dingtalk://dingtalkclient/page/link?url=" + + URLEncoder.encode("https://your-domain.com", StandardCharsets.UTF_8.toString())); + msg.put("action_card", actionCard); + + Map request = new HashMap<>(); + request.put("agent_id", config.getAgentId()); + request.put("userid_list", String.join(",", userIds)); + request.put("msg", msg); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity> entity = new HttpEntity<>(request, headers); + + ResponseEntity response = restTemplate.postForEntity(url, entity, Map.class); + Map body = response.getBody(); + + if (body != null && body.get("errcode").equals(0)) { + log.info("钉钉工作通知发送成功: userIds={}", userIds); + return true; + } else { + log.error("钉钉工作通知发送失败: {}", body); + return false; + } + } catch (Exception e) { + log.error("发送钉钉工作通知异常", e); + return false; + } + } + + /** + * 发送机器人消息 + */ + public boolean sendRobotMessage(String title, String content) { + DingTalkConfig config = getConfig(); + if (config == null || config.getRobotWebhook() == null) { + log.warn("钉钉机器人未配置"); + return false; + } + + try { + String webhook = config.getRobotWebhook(); + + // 如果有密钥,需要签名 + if (config.getRobotSecret() != null && !config.getRobotSecret().isEmpty()) { + long timestamp = System.currentTimeMillis(); + String sign = generateSign(timestamp, config.getRobotSecret()); + webhook = webhook + "×tamp=" + timestamp + "&sign=" + sign; + } + + Map message = new HashMap<>(); + message.put("msgtype", "markdown"); + Map markdown = new HashMap<>(); + markdown.put("title", title); + markdown.put("text", "### " + title + "\n\n" + content); + message.put("markdown", markdown); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity> entity = new HttpEntity<>(message, headers); + + ResponseEntity response = restTemplate.postForEntity(webhook, entity, Map.class); + Map body = response.getBody(); + + if (body != null && body.get("errcode").equals(0)) { + log.info("钉钉机器人消息发送成功: title={}", title); + return true; + } else { + log.error("钉钉机器人消息发送失败: {}", body); + return false; + } + } catch (Exception e) { + log.error("发送钉钉机器人消息异常", e); + return false; + } + } + + /** + * 生成签名 + */ + private String generateSign(long timestamp, String secret) throws Exception { + String stringToSign = timestamp + "\n" + secret; + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + byte[] signData = mac.doFinal(stringToSign.getBytes(StandardCharsets.UTF_8)); + return URLEncoder.encode(Base64.getEncoder().encodeToString(signData), StandardCharsets.UTF_8.toString()); + } + + /** + * 获取部门列表 + */ + public List> getDepartmentList() { + DingTalkConfig config = getConfig(); + if (config == null) { + return Collections.emptyList(); + } + + try { + String accessToken = getAccessToken(config); + if (accessToken == null) { + return Collections.emptyList(); + } + + String url = String.format("%s/topapi/v2/department/listsub?access_token=%s", + DINGTALK_API_BASE, accessToken); + + Map request = new HashMap<>(); + request.put("dept_id", 1); // 根部门 + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity> entity = new HttpEntity<>(request, headers); + + ResponseEntity response = restTemplate.postForEntity(url, entity, Map.class); + Map body = response.getBody(); + + if (body != null && body.get("errcode").equals(0)) { + return (List>) body.get("result"); + } + return Collections.emptyList(); + } catch (Exception e) { + log.error("获取钉钉部门列表异常", e); + return Collections.emptyList(); + } + } + + /** + * 钉钉OAuth登录 - 获取用户访问令牌 + * 参考:https://open.dingtalk.com/document/orgapp/obtain-user-token + */ + public Map getUserAccessToken(String code) { + DingTalkConfig config = getConfig(); + if (!isConfigured()) { + throw new BusinessException(ResultCode.PARAM_ERROR, "钉钉登录未配置或未启用,请联系管理员在系统设置中配置"); + } + + try { + String url = "https://api.dingtalk.com/v1.0/oauth2/userAccessToken"; + + Map request = new HashMap<>(); + request.put("clientId", config.getAppKey()); + request.put("clientSecret", config.getAppSecret()); + request.put("code", code); + request.put("grantType", "authorization_code"); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity> entity = new HttpEntity<>(request, headers); + + ResponseEntity response = restTemplate.postForEntity(url, entity, Map.class); + Map body = response.getBody(); + + log.info("获取用户访问令牌响应: {}", body); + return body; + } catch (Exception e) { + log.error("获取用户访问令牌失败", e); + throw new BusinessException(ResultCode.DINGTALK_API_ERROR, "获取钉钉用户令牌失败: " + e.getMessage()); + } + } + + /** + * 获取用户unionId + * 参考:https://open.dingtalk.com/document/orgapp/obtain-the-user-information-based-on-the-sns-temporary-authorization + */ + public Map getUserInfo(String accessToken) { + try { + String url = "https://api.dingtalk.com/v1.0/contact/users/me"; + + HttpHeaders headers = new HttpHeaders(); + headers.set("x-acs-dingtalk-access-token", accessToken); + HttpEntity entity = new HttpEntity<>(headers); + + ResponseEntity response = restTemplate.exchange(url, HttpMethod.GET, entity, Map.class); + Map body = response.getBody(); + + log.info("获取用户信息响应: {}", body); + return body; + } catch (Exception e) { + log.error("获取用户信息失败", e); + throw new BusinessException(ResultCode.DINGTALK_API_ERROR, "获取钉钉用户信息失败: " + e.getMessage()); + } + } + + /** + * 根据unionId获取userId + */ + public String getUserIdByUnionId(String unionId) { + DingTalkConfig config = getConfig(); + String accessToken = getAccessToken(config); + + try { + String url = String.format("%s/topapi/user/getbyunionid?access_token=%s", + DINGTALK_API_BASE, accessToken); + + Map request = new HashMap<>(); + request.put("unionid", unionId); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity> entity = new HttpEntity<>(request, headers); + + ResponseEntity response = restTemplate.postForEntity(url, entity, Map.class); + Map body = response.getBody(); + + if (body != null && body.get("errcode").equals(0)) { + Map result = (Map) body.get("result"); + return (String) result.get("userid"); + } + return null; + } catch (Exception e) { + log.error("根据unionId获取userId失败", e); + return null; + } + } + + /** + * 根据userId获取详细用户信息(包含部门信息) + */ + public Map getDetailedUserInfo(String userId) { + DingTalkConfig config = getConfig(); + String accessToken = getAccessToken(config); + + try { + String url = String.format("%s/topapi/v2/user/get?access_token=%s", + DINGTALK_API_BASE, accessToken); + + Map request = new HashMap<>(); + request.put("userid", userId); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity> entity = new HttpEntity<>(request, headers); + + ResponseEntity response = restTemplate.postForEntity(url, entity, Map.class); + Map body = response.getBody(); + + if (body != null && body.get("errcode").equals(0)) { + Map result = (Map) body.get("result"); + + // 获取部门信息 + List deptIdList = (List) result.get("dept_id_list"); + if (deptIdList != null && !deptIdList.isEmpty()) { + List> departments = new ArrayList<>(); + for (Long deptId : deptIdList) { + Map deptInfo = getDepartmentInfo(deptId.toString()); + if (deptInfo != null) { + departments.add(deptInfo); + } + } + result.put("departments", departments); + log.info("获取到用户部门信息: userId={}, departments={}", userId, departments.size()); + } + + return result; + } + return null; + } catch (Exception e) { + log.error("获取详细用户信息失败", e); + return null; + } + } + + /** + * 获取钉钉部门信息 + */ + public Map getDepartmentInfo(String deptId) { + DingTalkConfig config = getConfig(); + String accessToken = getAccessToken(config); + + try { + String url = String.format("%s/topapi/v2/department/get?access_token=%s", + DINGTALK_API_BASE, accessToken); + + Map request = new HashMap<>(); + request.put("dept_id", Long.parseLong(deptId)); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity> entity = new HttpEntity<>(request, headers); + + ResponseEntity response = restTemplate.postForEntity(url, entity, Map.class); + Map body = response.getBody(); + + if (body != null && body.get("errcode").equals(0)) { + return (Map) body.get("result"); + } + return null; + } catch (Exception e) { + log.error("获取钉钉部门信息失败: deptId={}", deptId, e); + return null; + } + } + + /** + * 同步钉钉用户 + */ + public int syncUsers() { + DingTalkConfig config = getConfig(); + if (config == null) { + throw new BusinessException(ResultCode.PARAM_ERROR, "钉钉未配置"); + } + + // 获取用户列表并同步到系统 + // 这里需要根据实际需求实现用户同步逻辑 + log.info("开始同步钉钉用户..."); + + // TODO: 实现用户同步逻辑 + + return 0; + } + + /** + * 检查功能是否启用 + */ + public boolean isFeatureEnabled(String feature) { + DingTalkConfig config = getConfig(); + if (config == null || config.getEnabledFeatures() == null) { + return false; + } + + try { + Map features = objectMapper.readValue( + config.getEnabledFeatures(), new TypeReference>() {}); + return features.getOrDefault(feature, false); + } catch (Exception e) { + return false; + } + } + + /** + * 发送任务通知(如果启用钉钉通知) + */ + public void sendTaskNotification(String dingTalkUserId, String taskTitle, String action) { + if (!isFeatureEnabled("notification") || dingTalkUserId == null) { + return; + } + + String title = "任务通知"; + String content = String.format("您的任务「%s」%s", taskTitle, action); + sendWorkNotification(Collections.singletonList(dingTalkUserId), title, content); + } +} diff --git a/backend/src/main/java/com/designhub/service/ExportService.java b/backend/src/main/java/com/designhub/service/ExportService.java new file mode 100644 index 0000000..cdffec1 --- /dev/null +++ b/backend/src/main/java/com/designhub/service/ExportService.java @@ -0,0 +1,156 @@ +package com.designhub.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.designhub.entity.*; +import com.designhub.mapper.*; +import com.designhub.util.ExcelExportUtil; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 数据导出Service + */ +@Service +@RequiredArgsConstructor +public class ExportService { + + private final TaskMapper taskMapper; + private final UserMapper userMapper; + private final DepartmentMapper departmentMapper; + private final DesignerService designerService; + + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + /** + * 导出任务列表 + */ + public void exportTasks(HttpServletResponse response, String status, Long publisherId) throws IOException { + // 查询任务数据 + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + if (status != null && !status.isEmpty()) { + queryWrapper.eq(Task::getStatus, status); + } + if (publisherId != null) { + queryWrapper.eq(Task::getPublisherId, publisherId); + } + queryWrapper.orderByDesc(Task::getCreateTime); + + List tasks = taskMapper.selectList(queryWrapper); + + // 定义表头 + String[] headers = { + "任务ID", "任务标题", "发布人", "接收人", "任务类型", "任务状态", + "预估积分", "任务时效(h)", "开始时间", "截止时间", "创建时间" + }; + + // 定义字段名 + String[] fieldNames = { + "id", "title", "publisherId", "receiverId", "demandTypeId", "status", + "scoreReward", "estimatedDuration", "actualStartTime", "deadline", "createTime" + }; + + // 生成文件名 + String fileName = "任务列表_" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")); + + // 导出Excel + ExcelExportUtil.exportExcel(response, fileName, "任务列表", headers, fieldNames, tasks); + } + + /** + * 导出用户列表 + */ + public void exportUsers(HttpServletResponse response, Long deptId, Integer status) throws IOException { + // 查询用户数据 + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + if (deptId != null) { + queryWrapper.eq(User::getDeptId, deptId); + } + if (status != null) { + queryWrapper.eq(User::getStatus, status); + } + queryWrapper.orderByDesc(User::getCreateTime); + + List users = userMapper.selectList(queryWrapper); + + // 定义表头 + String[] headers = { + "用户ID", "用户名", "真实姓名", "手机号", "邮箱", "部门ID", + "状态", "创建时间" + }; + + // 定义字段名 + String[] fieldNames = { + "id", "username", "realName", "phone", "email", "deptId", + "status", "createTime" + }; + + // 生成文件名 + String fileName = "用户列表_" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")); + + // 导出Excel + ExcelExportUtil.exportExcel(response, fileName, "用户列表", headers, fieldNames, users); + } + + /** + * 导出部门列表 + */ + public void exportDepartments(HttpServletResponse response) throws IOException { + // 查询部门数据 + List departments = departmentMapper.selectList( + new LambdaQueryWrapper() + .orderByAsc(Department::getSortOrder) + ); + + // 定义表头 + String[] headers = { + "部门ID", "部门名称", "部门编码", "部门类型", "父部门ID", + "描述", "排序", "状态", "创建时间" + }; + + // 定义字段名 + String[] fieldNames = { + "id", "deptName", "deptCode", "deptType", "parentId", + "description", "sortOrder", "status", "createTime" + }; + + // 生成文件名 + String fileName = "部门列表_" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")); + + // 导出Excel + ExcelExportUtil.exportExcel(response, fileName, "部门列表", headers, fieldNames, departments); + } + + /** + * 导出设计师统计数据 + */ + public void exportDesignerStats(HttpServletResponse response) throws IOException { + // 获取设计师统计数据 + var stats = designerService.getAllDesignerStats(); + + // 定义表头 + String[] headers = { + "设计师ID", "设计师姓名", "当前任务数", "排队任务数", "已完成任务数", + "累计积分", "延误数量", "好评数", "好评率(%)" + }; + + // 定义字段名 + String[] fieldNames = { + "designerId", "designerName", "currentTasks", "queueTasks", "completedTasks", + "totalScore", "delayCount", "goodReviews", "goodReviewRate" + }; + + // 生成文件名 + String fileName = "设计师统计_" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")); + + // 导出Excel + ExcelExportUtil.exportExcel(response, fileName, "设计师统计", headers, fieldNames, stats); + } +} + diff --git a/backend/src/main/java/com/designhub/service/NotificationService.java b/backend/src/main/java/com/designhub/service/NotificationService.java new file mode 100644 index 0000000..5effbd8 --- /dev/null +++ b/backend/src/main/java/com/designhub/service/NotificationService.java @@ -0,0 +1,240 @@ +package com.designhub.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.designhub.entity.Notification; +import com.designhub.mapper.NotificationMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 通知消息Service + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class NotificationService { + + private final NotificationMapper notificationMapper; + + /** + * 将优先级字符串转换为整数 + * low -> 0, normal -> 1, high -> 2, urgent -> 3 + */ + private Integer convertPriorityToInt(String priority) { + if (priority == null) return 1; // 默认普通 + return switch (priority.toLowerCase()) { + case "low" -> 0; + case "normal" -> 1; + case "high" -> 2; + case "urgent" -> 3; + default -> 1; // 默认普通 + }; + } + + /** + * 发送通知 + */ + @Transactional(rollbackFor = Exception.class) + public Notification sendNotification(Notification notification) { + if (notification.getIsRead() == null) { + notification.setIsRead(0); + } + if (notification.getPriority() == null) { + notification.setPriority(1); // 默认普通 + } + if (notification.getPushDingtalk() == null) { + notification.setPushDingtalk(0); + } + + notificationMapper.insert(notification); + log.info("发送通知成功: receiverId={}, title={}", notification.getReceiverId(), notification.getTitle()); + + // TODO: 如果开启了钉钉推送,这里可以异步推送 + if (notification.getPushDingtalk() == 1) { + asyncPushToDingtalk(notification); + } + + return notification; + } + + /** + * 批量发送通知 + */ + @Transactional(rollbackFor = Exception.class) + public void batchSendNotification(List receiverIds, String title, String content, + String notificationType, String relatedType, Long relatedId) { + for (Long receiverId : receiverIds) { + Notification notification = new Notification(); + notification.setReceiverId(receiverId); + notification.setTitle(title); + notification.setContent(content); + notification.setNotificationType(notificationType); + notification.setRelatedType(relatedType); + notification.setRelatedId(relatedId); + notification.setIsRead(0); + notification.setPriority(1); // 普通 + notificationMapper.insert(notification); + } + log.info("批量发送通知成功: count={}", receiverIds.size()); + } + + /** + * 获取用户通知列表(分页) + */ + public Page getUserNotifications(Long receiverId, Integer pageNum, Integer pageSize, Boolean unreadOnly) { + Page page = new Page<>(pageNum, pageSize); + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.eq(Notification::getReceiverId, receiverId); + + if (unreadOnly != null && unreadOnly) { + queryWrapper.eq(Notification::getIsRead, 0); + } + + queryWrapper.orderByDesc(Notification::getCreateTime); + return notificationMapper.selectPage(page, queryWrapper); + } + + /** + * 标记为已读 + */ + @Transactional(rollbackFor = Exception.class) + public boolean markAsRead(Long notificationId, Long receiverId) { + Notification notification = notificationMapper.selectById(notificationId); + if (notification != null && notification.getReceiverId().equals(receiverId)) { + notification.setIsRead(1); + notification.setReadTime(LocalDateTime.now()); + notificationMapper.updateById(notification); + log.info("标记通知已读: id={}", notificationId); + return true; + } + return false; + } + + /** + * 批量标记为已读 + */ + @Transactional(rollbackFor = Exception.class) + public int markAllAsRead(Long receiverId) { + int count = notificationMapper.markAllAsRead(receiverId); + log.info("批量标记通知已读: receiverId={}, count={}", receiverId, count); + return count; + } + + /** + * 获取未读数量 + */ + public long getUnreadCount(Long receiverId) { + return notificationMapper.selectCount( + new LambdaQueryWrapper() + .eq(Notification::getReceiverId, receiverId) + .eq(Notification::getIsRead, 0) + ); + } + + /** + * 删除通知 + */ + @Transactional(rollbackFor = Exception.class) + public boolean deleteNotification(Long notificationId, Long receiverId) { + Notification notification = notificationMapper.selectById(notificationId); + if (notification != null && notification.getReceiverId().equals(receiverId)) { + notificationMapper.deleteById(notificationId); + log.info("删除通知: id={}", notificationId); + return true; + } + return false; + } + + /** + * 异步推送到钉钉(预留接口) + */ + private void asyncPushToDingtalk(Notification notification) { + // TODO: 实现钉钉推送逻辑 + log.info("钉钉推送功能待实现: notificationId={}", notification.getId()); + } + + // ==================== 便捷方法 ==================== + + /** + * 发送任务通知 + */ + public void sendTaskNotification(Long receiverId, Long taskId, String title, String content) { + Notification notification = new Notification(); + notification.setReceiverId(receiverId); + notification.setNotificationType("task"); + notification.setRelatedType("task"); + notification.setRelatedId(taskId); + notification.setTitle(title); + notification.setContent(content); + notification.setLinkUrl("/tasks/" + taskId); + notification.setPushDingtalk(1); // 任务通知默认推送钉钉 + sendNotification(notification); + } + + /** + * 发送积分通知 + */ + public void sendScoreNotification(Long receiverId, String title, String content) { + Notification notification = new Notification(); + notification.setReceiverId(receiverId); + notification.setNotificationType("score"); + notification.setRelatedType("score"); + notification.setTitle(title); + notification.setContent(content); + notification.setLinkUrl("/my-score"); + sendNotification(notification); + } + + /** + * 发送评审通知 + */ + public void sendReviewNotification(Long receiverId, Long taskId, String title, String content) { + Notification notification = new Notification(); + notification.setReceiverId(receiverId); + notification.setNotificationType("review"); + notification.setRelatedType("task"); + notification.setRelatedId(taskId); + notification.setTitle(title); + notification.setContent(content); + notification.setLinkUrl("/tasks/" + taskId); + notification.setPriority(2); // 高 + notification.setPushDingtalk(1); + sendNotification(notification); + } + + /** + * 发送系统通知 + */ + public void sendSystemNotification(Long receiverId, String title, String content, String priority) { + Notification notification = new Notification(); + notification.setReceiverId(receiverId); + notification.setNotificationType("system"); + notification.setRelatedType("system"); + notification.setTitle(title); + notification.setContent(content); + notification.setPriority(priority != null ? convertPriorityToInt(priority) : 1); // 默认普通 + sendNotification(notification); + } + + /** + * 发送通用通知(便捷方法) + */ + public void sendNotification(Long receiverId, String title, String content, String relatedType, Long relatedId) { + Notification notification = new Notification(); + notification.setReceiverId(receiverId); + notification.setNotificationType(relatedType); + notification.setRelatedType(relatedType); + notification.setRelatedId(relatedId); + notification.setTitle(title); + notification.setContent(content); + notification.setPriority(1); // 普通 + sendNotification(notification); + } +} + diff --git a/backend/src/main/java/com/designhub/service/PermissionService.java b/backend/src/main/java/com/designhub/service/PermissionService.java new file mode 100644 index 0000000..cc3b827 --- /dev/null +++ b/backend/src/main/java/com/designhub/service/PermissionService.java @@ -0,0 +1,190 @@ +package com.designhub.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.designhub.entity.Permission; +import com.designhub.entity.RolePermission; +import com.designhub.exception.BusinessException; +import com.designhub.mapper.PermissionMapper; +import com.designhub.mapper.RolePermissionMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 权限管理Service + */ +@Service +@RequiredArgsConstructor +public class PermissionService { + + private final PermissionMapper permissionMapper; + private final RolePermissionMapper rolePermissionMapper; + + /** + * 获取所有权限列表 + */ + public List getAllPermissions() { + return permissionMapper.selectList( + new LambdaQueryWrapper() + .eq(Permission::getStatus, 1) + .orderByAsc(Permission::getSortOrder) + ); + } + + /** + * 获取权限树形结构 + */ + public List getPermissionTree() { + List allPermissions = permissionMapper.selectList( + new LambdaQueryWrapper() + .orderByAsc(Permission::getSortOrder) + ); + return buildTree(allPermissions, 0L); + } + + /** + * 根据ID获取权限 + */ + public Permission getPermissionById(Long id) { + return permissionMapper.selectById(id); + } + + /** + * 根据角色ID获取权限列表 + */ + public List getPermissionsByRoleId(Long roleId) { + return permissionMapper.selectPermissionsByRoleId(roleId); + } + + /** + * 根据用户ID获取权限列表 + */ + public List getPermissionsByUserId(Long userId) { + return permissionMapper.selectPermissionsByUserId(userId); + } + + /** + * 根据用户ID获取权限代码列表 + */ + public List getPermissionCodesByUserId(Long userId) { + List permissions = getPermissionsByUserId(userId); + return permissions.stream() + .map(Permission::getPermissionCode) + .collect(Collectors.toList()); + } + + /** + * 创建权限 + */ + @Transactional(rollbackFor = Exception.class) + public Permission createPermission(Permission permission) { + // 验证权限编码唯一性 + long count = permissionMapper.selectCount( + new LambdaQueryWrapper() + .eq(Permission::getPermissionCode, permission.getPermissionCode()) + ); + if (count > 0) { + throw new BusinessException("权限编码已存在"); + } + + // 设置默认值 + if (permission.getStatus() == null) { + permission.setStatus(1); + } + if (permission.getParentId() == null) { + permission.setParentId(0L); + } + + permissionMapper.insert(permission); + return permission; + } + + /** + * 更新权限 + */ + @Transactional(rollbackFor = Exception.class) + public boolean updatePermission(Permission permission) { + Permission existPermission = getPermissionById(permission.getId()); + if (existPermission == null) { + throw new BusinessException("权限不存在"); + } + + // 不允许将父ID设置为自己 + if (permission.getParentId() != null && permission.getParentId().equals(permission.getId())) { + throw new BusinessException("不能将父权限设置为自己"); + } + + return permissionMapper.updateById(permission) > 0; + } + + /** + * 删除权限 + */ + @Transactional(rollbackFor = Exception.class) + public boolean deletePermission(Long id) { + Permission permission = getPermissionById(id); + if (permission == null) { + throw new BusinessException("权限不存在"); + } + + // 检查是否有子权限 + long childCount = permissionMapper.selectCount( + new LambdaQueryWrapper() + .eq(Permission::getParentId, id) + ); + if (childCount > 0) { + throw new BusinessException("该权限下还有子权限,无法删除"); + } + + // 检查是否有角色使用此权限 + long roleCount = rolePermissionMapper.selectCount( + new LambdaQueryWrapper() + .eq(RolePermission::getPermissionId, id) + ); + if (roleCount > 0) { + throw new BusinessException("该权限已分配给角色,无法删除"); + } + + return permissionMapper.deleteById(id) > 0; + } + + /** + * 构建树形结构 + */ + private List buildTree(List allPermissions, Long parentId) { + List children = allPermissions.stream() + .filter(permission -> permission.getParentId().equals(parentId)) + .collect(Collectors.toList()); + + for (Permission child : children) { + List subChildren = buildTree(allPermissions, child.getId()); + // 可以将子权限存储到一个临时字段中,或者返回VO对象 + } + + return children; + } + + /** + * 根据用户ID检查是否有指定权限 + */ + public boolean hasPermission(Long userId, String permissionCode) { + List permissionCodes = getPermissionCodesByUserId(userId); + return permissionCodes.contains(permissionCode); + } + + /** + * 根据用户ID检查是否有访问接口的权限 + */ + public boolean hasApiPermission(Long userId, String path, String method) { + List permissions = getPermissionsByUserId(userId); + return permissions.stream() + .anyMatch(p -> "api".equals(p.getType()) && + path.equals(p.getPath()) && + (method == null || method.equals(p.getMethod()))); + } +} + diff --git a/backend/src/main/java/com/designhub/service/RewardRuleService.java b/backend/src/main/java/com/designhub/service/RewardRuleService.java new file mode 100644 index 0000000..35f67a0 --- /dev/null +++ b/backend/src/main/java/com/designhub/service/RewardRuleService.java @@ -0,0 +1,471 @@ +package com.designhub.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.designhub.common.ResultCode; +import com.designhub.entity.RewardRule; +import com.designhub.entity.Task; +import com.designhub.entity.User; +import com.designhub.exception.BusinessException; +import com.designhub.mapper.RewardRuleMapper; +import com.designhub.mapper.TaskMapper; +import com.designhub.mapper.UserMapper; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.time.temporal.TemporalAdjusters; +import java.util.List; +import java.util.Map; + +/** + * 自动奖励规则 Service + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class RewardRuleService { + + private final RewardRuleMapper rewardRuleMapper; + private final UserMapper userMapper; + private final TaskMapper taskMapper; + private final NotificationService notificationService; + private final ObjectMapper objectMapper; + + /** + * 创建奖励规则 + */ + @Transactional + public RewardRule createRule(RewardRule rule) { + rule.setEnabled(1); + rule.setDeleted(0); + rewardRuleMapper.insert(rule); + log.info("创建奖励规则成功: id={}, name={}", rule.getId(), rule.getName()); + return rule; + } + + /** + * 更新奖励规则 + */ + @Transactional + public RewardRule updateRule(Long id, RewardRule rule) { + RewardRule existing = rewardRuleMapper.selectById(id); + if (existing == null) { + throw new BusinessException(ResultCode.NOT_FOUND, "规则不存在"); + } + rule.setId(id); + rewardRuleMapper.updateById(rule); + return rewardRuleMapper.selectById(id); + } + + /** + * 启用/禁用规则 + */ + @Transactional + public void toggleRule(Long id, boolean enabled) { + RewardRule rule = rewardRuleMapper.selectById(id); + if (rule == null) { + throw new BusinessException(ResultCode.NOT_FOUND, "规则不存在"); + } + rule.setEnabled(enabled ? 1 : 0); + rewardRuleMapper.updateById(rule); + } + + /** + * 删除规则 + */ + @Transactional + public void deleteRule(Long id) { + rewardRuleMapper.deleteById(id); + } + + /** + * 获取规则列表 + */ + public IPage getRuleList(Integer page, Integer pageSize, String ruleType) { + Page pageParam = new Page<>(page, pageSize); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + + if (ruleType != null && !ruleType.isEmpty()) { + wrapper.eq(RewardRule::getRuleType, ruleType); + } + + wrapper.orderByDesc(RewardRule::getPriority) + .orderByDesc(RewardRule::getCreateTime); + return rewardRuleMapper.selectPage(pageParam, wrapper); + } + + /** + * 获取规则详情 + */ + public RewardRule getRuleById(Long id) { + RewardRule rule = rewardRuleMapper.selectById(id); + if (rule == null) { + throw new BusinessException(ResultCode.NOT_FOUND, "规则不存在"); + } + return rule; + } + + /** + * 获取所有启用的规则 + */ + public List getEnabledRules() { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(RewardRule::getEnabled, 1); + return rewardRuleMapper.selectList(wrapper); + } + + /** + * 任务完成时触发检查 + */ + @Transactional + public void onTaskCompleted(Task task, User user) { + // 获取任务相关的奖励规则 + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(RewardRule::getEnabled, 1) + .eq(RewardRule::getRuleType, "task"); + List rules = rewardRuleMapper.selectList(wrapper); + + for (RewardRule rule : rules) { + if (checkRuleCondition(rule, user, task)) { + applyReward(rule, user); + } + } + } + + /** + * 好评时触发检查 + */ + @Transactional + public void onTaskRated(Task task, User user, Integer rating) { + if (rating == null || rating < 5) return; + + // 获取好评相关的奖励规则 + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(RewardRule::getEnabled, 1) + .eq(RewardRule::getRuleType, "rating"); + List rules = rewardRuleMapper.selectList(wrapper); + + for (RewardRule rule : rules) { + if (checkRuleCondition(rule, user, task)) { + applyReward(rule, user); + } + } + } + + /** + * 检查规则条件是否满足 + */ + private boolean checkRuleCondition(RewardRule rule, User user, Task task) { + try { + Map condition = objectMapper.readValue( + rule.getTriggerCondition(), new TypeReference>() {}); + + String type = (String) condition.get("type"); + String operator = (String) condition.get("operator"); + int targetValue = ((Number) condition.get("value")).intValue(); + + int actualValue = 0; + + switch (type) { + case "task_count": + // 统计用户完成任务数 + actualValue = countUserTasks(user.getId(), "completed"); + break; + case "rating": + // 任务评分 + actualValue = task.getRating() != null ? task.getRating() : 0; + break; + case "five_star_count": + // 统计五星好评数 + actualValue = countUserFiveStarTasks(user.getId()); + break; + default: + return false; + } + + return compareValue(actualValue, operator, targetValue); + } catch (JsonProcessingException e) { + log.error("解析规则条件失败", e); + return false; + } + } + + /** + * 值比较 + */ + private boolean compareValue(int actual, String operator, int target) { + switch (operator) { + case ">=": return actual >= target; + case ">": return actual > target; + case "<=": return actual <= target; + case "<": return actual < target; + case "==": return actual == target; + default: return false; + } + } + + /** + * 统计用户任务数 + */ + private int countUserTasks(Long userId, String status) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(Task::getReceiverId, userId) + .eq(Task::getStatus, status); + Long count = taskMapper.selectCount(wrapper); + return count != null ? count.intValue() : 0; + } + + /** + * 统计用户五星好评数 + */ + private int countUserFiveStarTasks(Long userId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(Task::getReceiverId, userId) + .eq(Task::getStatus, "completed") + .ge(Task::getRating, 5); + Long count = taskMapper.selectCount(wrapper); + return count != null ? count.intValue() : 0; + } + + /** + * 应用奖励 + */ + @Transactional + public void applyReward(RewardRule rule, User user) { + if (rule.getRewardScore() != null && rule.getRewardScore() > 0) { + int currentScore = user.getScore() != null ? user.getScore() : 0; + user.setScore(currentScore + rule.getRewardScore()); + userMapper.updateById(user); + + // 发送通知 + notificationService.sendNotification( + user.getId(), + "获得奖励", + String.format("恭喜!根据「%s」规则,您获得 %d 积分奖励!", rule.getName(), rule.getRewardScore()), + "reward", + rule.getId() + ); + + log.info("用户获得自动奖励: userId={}, ruleId={}, score={}", + user.getId(), rule.getId(), rule.getRewardScore()); + } + } + + /** + * 定时任务:执行每日奖励规则 + */ + @Scheduled(cron = "0 0 23 * * ?") // 每天23点执行 + @Transactional + public void executeDailyRewards() { + log.info("开始执行每日自动奖励规则"); + executePeriodicRewards("daily"); + } + + /** + * 定时任务:执行每周奖励规则 + */ + @Scheduled(cron = "0 0 22 ? * SUN") // 每周日22点执行 + @Transactional + public void executeWeeklyRewards() { + log.info("开始执行每周自动奖励规则"); + executePeriodicRewards("weekly"); + } + + /** + * 定时任务:执行每月奖励规则 + */ + @Scheduled(cron = "0 0 21 L * ?") // 每月最后一天21点执行 + @Transactional + public void executeMonthlyRewards() { + log.info("开始执行每月自动奖励规则"); + executePeriodicRewards("monthly"); + } + + /** + * 执行周期性奖励 + */ + private void executePeriodicRewards(String ruleType) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(RewardRule::getEnabled, 1) + .eq(RewardRule::getRuleType, ruleType); + List rules = rewardRuleMapper.selectList(wrapper); + + for (RewardRule rule : rules) { + try { + executePeriodicRule(rule); + rule.setLastExecuteTime(LocalDateTime.now()); + rewardRuleMapper.updateById(rule); + } catch (Exception e) { + log.error("执行奖励规则失败: ruleId={}", rule.getId(), e); + } + } + } + + /** + * 执行单个周期性规则 + */ + private void executePeriodicRule(RewardRule rule) { + // 获取符合范围的用户 + List users = getUsersByScope(rule); + + LocalDateTime startTime = getStartTimeByRuleType(rule.getRuleType()); + LocalDateTime endTime = LocalDateTime.now(); + + for (User user : users) { + // 检查用户在该周期内是否满足条件 + if (checkPeriodicCondition(rule, user, startTime, endTime)) { + applyReward(rule, user); + } + } + } + + /** + * 根据范围获取用户列表 + */ + private List getUsersByScope(RewardRule rule) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(User::getDeleted, 0); + + if ("designer".equals(rule.getScope())) { + // 获取设计师角色的用户 + // 简化处理,获取所有用户 + } else if ("department".equals(rule.getScope()) && rule.getScopeDeptIds() != null) { + try { + List deptIds = objectMapper.readValue( + rule.getScopeDeptIds(), new TypeReference>() {}); + wrapper.in(User::getDeptId, deptIds); + } catch (JsonProcessingException e) { + log.error("解析部门ID失败", e); + } + } + + return userMapper.selectList(wrapper); + } + + /** + * 获取周期开始时间 + */ + private LocalDateTime getStartTimeByRuleType(String ruleType) { + LocalDateTime now = LocalDateTime.now(); + switch (ruleType) { + case "daily": + return now.toLocalDate().atStartOfDay(); + case "weekly": + return now.with(TemporalAdjusters.previousOrSame(java.time.DayOfWeek.MONDAY)) + .toLocalDate().atStartOfDay(); + case "monthly": + return now.with(TemporalAdjusters.firstDayOfMonth()) + .toLocalDate().atStartOfDay(); + default: + return now.minusDays(1); + } + } + + /** + * 检查周期性条件 + */ + private boolean checkPeriodicCondition(RewardRule rule, User user, + LocalDateTime startTime, LocalDateTime endTime) { + try { + Map condition = objectMapper.readValue( + rule.getTriggerCondition(), new TypeReference>() {}); + + String type = (String) condition.get("type"); + String operator = (String) condition.get("operator"); + int targetValue = ((Number) condition.get("value")).intValue(); + + int actualValue = 0; + + switch (type) { + case "task_count": + // 统计周期内完成任务数 + LambdaQueryWrapper taskWrapper = new LambdaQueryWrapper<>(); + taskWrapper.eq(Task::getReceiverId, user.getId()) + .eq(Task::getStatus, "completed") + .ge(Task::getCompleteTime, startTime) + .le(Task::getCompleteTime, endTime); + Long taskCount = taskMapper.selectCount(taskWrapper); + actualValue = taskCount != null ? taskCount.intValue() : 0; + break; + case "five_star_count": + // 统计周期内五星好评数 + LambdaQueryWrapper fiveStarWrapper = new LambdaQueryWrapper<>(); + fiveStarWrapper.eq(Task::getReceiverId, user.getId()) + .eq(Task::getStatus, "completed") + .ge(Task::getRating, 5) + .ge(Task::getCompleteTime, startTime) + .le(Task::getCompleteTime, endTime); + Long fiveStarCount = taskMapper.selectCount(fiveStarWrapper); + actualValue = fiveStarCount != null ? fiveStarCount.intValue() : 0; + break; + default: + return false; + } + + return compareValue(actualValue, operator, targetValue); + } catch (JsonProcessingException e) { + log.error("解析规则条件失败", e); + return false; + } + } + + /** + * 初始化默认奖励规则 + */ + @Transactional + public void initDefaultRules() { + // 检查是否已有规则 + if (rewardRuleMapper.selectCount(null) > 0) { + return; + } + + // 每日完成5个任务奖励 + RewardRule dailyRule = new RewardRule(); + dailyRule.setName("每日任务达人"); + dailyRule.setDescription("每天完成5个任务,额外奖励20积分"); + dailyRule.setRuleType("daily"); + dailyRule.setTriggerCondition("{\"type\":\"task_count\",\"operator\":\">=\",\"value\":5}"); + dailyRule.setRewardType("score"); + dailyRule.setRewardScore(20); + dailyRule.setScope("all"); + dailyRule.setEnabled(1); + dailyRule.setPriority(100); + rewardRuleMapper.insert(dailyRule); + + // 每周完成20个任务奖励 + RewardRule weeklyRule = new RewardRule(); + weeklyRule.setName("每周效率之星"); + weeklyRule.setDescription("每周完成20个任务,额外奖励100积分"); + weeklyRule.setRuleType("weekly"); + weeklyRule.setTriggerCondition("{\"type\":\"task_count\",\"operator\":\">=\",\"value\":20}"); + weeklyRule.setRewardType("score"); + weeklyRule.setRewardScore(100); + weeklyRule.setScope("all"); + weeklyRule.setEnabled(1); + weeklyRule.setPriority(90); + rewardRuleMapper.insert(weeklyRule); + + // 五星好评奖励 + RewardRule ratingRule = new RewardRule(); + ratingRule.setName("五星好评奖励"); + ratingRule.setDescription("获得五星好评,奖励10积分"); + ratingRule.setRuleType("rating"); + ratingRule.setTriggerCondition("{\"type\":\"rating\",\"operator\":\">=\",\"value\":5}"); + ratingRule.setRewardType("score"); + ratingRule.setRewardScore(10); + ratingRule.setScope("all"); + ratingRule.setEnabled(1); + ratingRule.setPriority(80); + rewardRuleMapper.insert(ratingRule); + + log.info("初始化默认奖励规则完成"); + } +} diff --git a/backend/src/main/java/com/designhub/service/RoleService.java b/backend/src/main/java/com/designhub/service/RoleService.java new file mode 100644 index 0000000..35cba05 --- /dev/null +++ b/backend/src/main/java/com/designhub/service/RoleService.java @@ -0,0 +1,198 @@ +package com.designhub.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.designhub.entity.Role; +import com.designhub.entity.RolePermission; +import com.designhub.entity.UserRole; +import com.designhub.exception.BusinessException; +import com.designhub.mapper.RoleMapper; +import com.designhub.mapper.RolePermissionMapper; +import com.designhub.mapper.UserRoleMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * 角色管理Service + */ +@Service +@RequiredArgsConstructor +public class RoleService { + + private final RoleMapper roleMapper; + private final RolePermissionMapper rolePermissionMapper; + private final UserRoleMapper userRoleMapper; + + /** + * 获取所有角色列表 + */ + public List getAllRoles() { + return roleMapper.selectList( + new LambdaQueryWrapper() + .eq(Role::getStatus, 1) + .orderByAsc(Role::getSortOrder) + ); + } + + /** + * 根据ID获取角色 + */ + public Role getRoleById(Long id) { + return roleMapper.selectById(id); + } + + /** + * 根据用户ID获取角色列表 + */ + public List getRolesByUserId(Long userId) { + return roleMapper.selectRolesByUserId(userId); + } + + /** + * 创建角色 + */ + @Transactional(rollbackFor = Exception.class) + public Role createRole(Role role) { + // 验证角色编码唯一性 + long count = roleMapper.selectCount( + new LambdaQueryWrapper() + .eq(Role::getRoleCode, role.getRoleCode()) + ); + if (count > 0) { + throw new BusinessException("角色编码已存在"); + } + + // 设置默认值 + if (role.getStatus() == null) { + role.setStatus(1); + } + if (role.getBuiltIn() == null) { + role.setBuiltIn(0); + } + if (role.getDataScope() == null) { + role.setDataScope("self"); + } + + roleMapper.insert(role); + return role; + } + + /** + * 更新角色 + */ + @Transactional(rollbackFor = Exception.class) + public boolean updateRole(Role role) { + Role existRole = getRoleById(role.getId()); + if (existRole == null) { + throw new BusinessException("角色不存在"); + } + + // 内置角色不允许修改编码和级别 + if (existRole.getBuiltIn() == 1) { + role.setRoleCode(null); + role.setLevel(null); + role.setBuiltIn(null); + } + + return roleMapper.updateById(role) > 0; + } + + /** + * 删除角色 + */ + @Transactional(rollbackFor = Exception.class) + public boolean deleteRole(Long id) { + Role role = getRoleById(id); + if (role == null) { + throw new BusinessException("角色不存在"); + } + + // 内置角色不允许删除 + if (role.getBuiltIn() == 1) { + throw new BusinessException("内置角色不允许删除"); + } + + // 检查是否有用户使用此角色 + long userCount = userRoleMapper.selectCount( + new LambdaQueryWrapper() + .eq(UserRole::getRoleId, id) + ); + if (userCount > 0) { + throw new BusinessException("该角色下还有用户,无法删除"); + } + + // 删除角色权限关联 + rolePermissionMapper.delete( + new LambdaQueryWrapper() + .eq(RolePermission::getRoleId, id) + ); + + return roleMapper.deleteById(id) > 0; + } + + /** + * 为角色分配权限 + */ + @Transactional(rollbackFor = Exception.class) + public boolean assignPermissions(Long roleId, List permissionIds) { + Role role = getRoleById(roleId); + if (role == null) { + throw new BusinessException("角色不存在"); + } + + // 删除原有权限 + rolePermissionMapper.delete( + new LambdaQueryWrapper() + .eq(RolePermission::getRoleId, roleId) + ); + + // 分配新权限 + if (permissionIds != null && !permissionIds.isEmpty()) { + for (Long permissionId : permissionIds) { + RolePermission rolePermission = new RolePermission(); + rolePermission.setRoleId(roleId); + rolePermission.setPermissionId(permissionId); + rolePermissionMapper.insert(rolePermission); + } + } + + return true; + } + + /** + * 获取角色的权限ID列表 + */ + public List getRolePermissionIds(Long roleId) { + List rolePermissions = rolePermissionMapper.selectList( + new LambdaQueryWrapper() + .eq(RolePermission::getRoleId, roleId) + ); + return rolePermissions.stream() + .map(RolePermission::getPermissionId) + .toList(); + } + + /** + * 启用/禁用角色 + */ + @Transactional(rollbackFor = Exception.class) + public boolean toggleRoleStatus(Long id, Integer status) { + Role role = getRoleById(id); + if (role == null) { + throw new BusinessException("角色不存在"); + } + + // 内置角色不允许禁用 + if (role.getBuiltIn() == 1 && status == 0) { + throw new BusinessException("内置角色不允许禁用"); + } + + Role updateRole = new Role(); + updateRole.setId(id); + updateRole.setStatus(status); + return roleMapper.updateById(updateRole) > 0; + } +} + diff --git a/backend/src/main/java/com/designhub/service/S3ConfigService.java b/backend/src/main/java/com/designhub/service/S3ConfigService.java new file mode 100644 index 0000000..f55f52b --- /dev/null +++ b/backend/src/main/java/com/designhub/service/S3ConfigService.java @@ -0,0 +1,120 @@ +package com.designhub.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.designhub.entity.S3Config; +import com.designhub.mapper.S3ConfigMapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * S3配置服务 + */ +@Service +public class S3ConfigService { + + @Autowired + private S3ConfigMapper s3ConfigMapper; + + /** + * 获取激活的S3配置 + */ + public S3Config getActiveConfig() { + return s3ConfigMapper.getActiveConfig(); + } + + /** + * 获取所有S3配置 + */ + public List getAllConfigs() { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.orderByDesc(S3Config::getIsActive) + .orderByDesc(S3Config::getCreateTime); + return s3ConfigMapper.selectList(wrapper); + } + + /** + * 根据ID获取S3配置 + */ + public S3Config getConfigById(Long id) { + return s3ConfigMapper.selectById(id); + } + + /** + * 创建S3配置 + */ + @Transactional + public S3Config createConfig(S3Config config) { + // 如果新配置设置为激活,则将其他配置设为未激活 + if (config.getIsActive() != null && config.getIsActive()) { + deactivateAllConfigs(); + } + s3ConfigMapper.insert(config); + return config; + } + + /** + * 更新S3配置 + */ + @Transactional + public S3Config updateConfig(Long id, S3Config config) { + S3Config existingConfig = s3ConfigMapper.selectById(id); + if (existingConfig == null) { + throw new RuntimeException("S3配置不存在"); + } + + // 如果新配置设置为激活,则将其他配置设为未激活 + if (config.getIsActive() != null && config.getIsActive()) { + deactivateAllConfigs(); + } + + config.setId(id); + s3ConfigMapper.updateById(config); + return s3ConfigMapper.selectById(id); + } + + /** + * 删除S3配置 + */ + public void deleteConfig(Long id) { + S3Config config = s3ConfigMapper.selectById(id); + if (config != null && config.getIsActive()) { + throw new RuntimeException("无法删除激活的配置,请先激活其他配置"); + } + s3ConfigMapper.deleteById(id); + } + + /** + * 激活指定的S3配置 + */ + @Transactional + public void activateConfig(Long id) { + S3Config config = s3ConfigMapper.selectById(id); + if (config == null) { + throw new RuntimeException("S3配置不存在"); + } + + // 将其他配置设为未激活 + deactivateAllConfigs(); + + // 激活当前配置 + config.setIsActive(true); + s3ConfigMapper.updateById(config); + } + + /** + * 将所有配置设为未激活 + */ + private void deactivateAllConfigs() { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(S3Config::getIsActive, true); + List activeConfigs = s3ConfigMapper.selectList(wrapper); + + for (S3Config activeConfig : activeConfigs) { + activeConfig.setIsActive(false); + s3ConfigMapper.updateById(activeConfig); + } + } +} diff --git a/backend/src/main/java/com/designhub/service/S3Service.java b/backend/src/main/java/com/designhub/service/S3Service.java new file mode 100644 index 0000000..946f9b7 --- /dev/null +++ b/backend/src/main/java/com/designhub/service/S3Service.java @@ -0,0 +1,146 @@ +package com.designhub.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.designhub.entity.FileUpload; +import com.designhub.entity.S3Config; +import com.designhub.mapper.FileUploadMapper; +import com.designhub.mapper.S3ConfigMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.S3Configuration; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +import java.io.IOException; +import java.net.URI; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.UUID; + +/** + * S3文件存储服务 + */ +@Service +public class S3Service { + + private static final Logger log = LoggerFactory.getLogger(S3Service.class); + + @Autowired + private S3ConfigMapper s3ConfigMapper; + + @Autowired + private FileUploadMapper fileUploadMapper; + + /** + * 上传文件到S3 + * + * @param file 文件 + * @param uploaderId 上传者ID + * @param uploaderName 上传者姓名 + * @param relatedType 关联类型 + * @param relatedId 关联ID + * @return 文件URL + */ + public String uploadFile(MultipartFile file, Long uploaderId, String uploaderName, + String relatedType, Long relatedId) throws IOException { + // 获取激活的S3配置 + S3Config config = s3ConfigMapper.getActiveConfig(); + if (config == null) { + throw new RuntimeException("S3配置未激活,请先配置S3存储"); + } + + // 生成唯一文件名 + String originalFilename = file.getOriginalFilename(); + String fileExtension = originalFilename.substring(originalFilename.lastIndexOf(".")); + String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")); + String uuid = UUID.randomUUID().toString().replace("-", "").substring(0, 8); + String storedName = timestamp + "_" + uuid + fileExtension; + + // 构建文件路径 + String filePath = config.getFilePath() + storedName; + + try { + // 创建S3客户端 + S3Client s3Client = createS3Client(config); + + // 上传文件 + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(config.getBucket()) + .key(filePath) + .contentType(file.getContentType()) + .build(); + + s3Client.putObject(putObjectRequest, RequestBody.fromInputStream(file.getInputStream(), file.getSize())); + + // 生成文件URL + String fileUrl = config.getPublicDomain() + "/" + filePath; + + // 保存文件上传记录 + FileUpload fileUpload = new FileUpload(); + fileUpload.setOriginalName(originalFilename); + fileUpload.setStoredName(storedName); + fileUpload.setFilePath(filePath); + fileUpload.setFileUrl(fileUrl); + fileUpload.setFileSize(file.getSize()); + fileUpload.setFileType(file.getContentType()); + fileUpload.setUploaderId(uploaderId); + fileUpload.setUploaderName(uploaderName); + fileUpload.setRelatedType(relatedType); + fileUpload.setRelatedId(relatedId); + fileUploadMapper.insert(fileUpload); + + log.info("文件上传成功: {} -> {}", originalFilename, fileUrl); + return fileUrl; + + } catch (Exception e) { + log.error("文件上传失败: {}", originalFilename, e); + throw new RuntimeException("文件上传失败: " + e.getMessage()); + } + } + + /** + * 创建S3客户端 + */ + private S3Client createS3Client(S3Config config) { + AwsBasicCredentials credentials = AwsBasicCredentials.create( + config.getAccessKeyId(), + config.getSecretAccessKey() + ); + + S3Configuration s3Config = S3Configuration.builder() + .pathStyleAccessEnabled(config.getEnablePathStyle()) + .build(); + + return S3Client.builder() + .region(Region.of(config.getRegion())) + .credentialsProvider(StaticCredentialsProvider.create(credentials)) + .endpointOverride(URI.create(config.getEndpoint())) + .serviceConfiguration(s3Config) + .build(); + } + + /** + * 获取文件上传记录 + */ + public FileUpload getFileUploadById(Long id) { + return fileUploadMapper.selectById(id); + } + + /** + * 根据关联信息获取文件列表 + */ + public java.util.List getFilesByRelated(String relatedType, Long relatedId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(FileUpload::getRelatedType, relatedType) + .eq(FileUpload::getRelatedId, relatedId) + .orderByDesc(FileUpload::getCreateTime); + return fileUploadMapper.selectList(wrapper); + } +} diff --git a/backend/src/main/java/com/designhub/service/ScorePoolService.java b/backend/src/main/java/com/designhub/service/ScorePoolService.java new file mode 100644 index 0000000..b34985b --- /dev/null +++ b/backend/src/main/java/com/designhub/service/ScorePoolService.java @@ -0,0 +1,419 @@ +package com.designhub.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.designhub.common.ResultCode; +import com.designhub.entity.ScorePool; +import com.designhub.entity.ScorePoolRecord; +import com.designhub.entity.User; +import com.designhub.exception.BusinessException; +import com.designhub.mapper.ScorePoolMapper; +import com.designhub.mapper.ScorePoolRecordMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.time.YearMonth; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 激励积分池 Service + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ScorePoolService { + + private final ScorePoolMapper scorePoolMapper; + private final ScorePoolRecordMapper recordMapper; + private final NotificationService notificationService; + + /** + * 创建积分池 + */ + @Transactional + public ScorePool createPool(ScorePool pool) { + // 检查是否已存在相同周期的积分池 + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(ScorePool::getPoolType, pool.getPoolType()) + .eq(ScorePool::getYear, pool.getYear()); + + if ("monthly".equals(pool.getPoolType())) { + wrapper.eq(ScorePool::getMonth, pool.getMonth()); + } else if ("quarterly".equals(pool.getPoolType())) { + wrapper.eq(ScorePool::getQuarter, pool.getQuarter()); + } + + if (scorePoolMapper.selectCount(wrapper) > 0) { + throw new BusinessException(ResultCode.PARAM_ERROR, "该周期已存在积分池"); + } + + // 计算各项预算默认分配 + if (pool.getTaskRewardBudget() == null) { + pool.setTaskRewardBudget((int)(pool.getTotalBudget() * 0.4)); // 40% + } + if (pool.getRatingRewardBudget() == null) { + pool.setRatingRewardBudget((int)(pool.getTotalBudget() * 0.2)); // 20% + } + if (pool.getContestRewardBudget() == null) { + pool.setContestRewardBudget((int)(pool.getTotalBudget() * 0.15)); // 15% + } + if (pool.getCheckinRewardBudget() == null) { + pool.setCheckinRewardBudget((int)(pool.getTotalBudget() * 0.1)); // 10% + } + if (pool.getOtherRewardBudget() == null) { + pool.setOtherRewardBudget((int)(pool.getTotalBudget() * 0.15)); // 15% + } + + pool.setUsedScore(0); + pool.setRemainingScore(pool.getTotalBudget()); + pool.setTaskRewardUsed(0); + pool.setRatingRewardUsed(0); + pool.setContestRewardUsed(0); + pool.setCheckinRewardUsed(0); + pool.setOtherRewardUsed(0); + pool.setStatus("active"); + pool.setDeleted(0); + + scorePoolMapper.insert(pool); + log.info("创建积分池成功: id={}, name={}", pool.getId(), pool.getName()); + return pool; + } + + /** + * 更新积分池 + */ + @Transactional + public ScorePool updatePool(Long id, ScorePool pool) { + ScorePool existing = scorePoolMapper.selectById(id); + if (existing == null) { + throw new BusinessException(ResultCode.NOT_FOUND, "积分池不存在"); + } + + // 如果增加了总预算,同步增加剩余积分 + if (pool.getTotalBudget() != null && pool.getTotalBudget() > existing.getTotalBudget()) { + int increase = pool.getTotalBudget() - existing.getTotalBudget(); + pool.setRemainingScore(existing.getRemainingScore() + increase); + } + + pool.setId(id); + scorePoolMapper.updateById(pool); + return scorePoolMapper.selectById(id); + } + + /** + * 获取积分池列表 + */ + public IPage getPoolList(Integer page, Integer pageSize, String poolType, Integer year) { + Page pageParam = new Page<>(page, pageSize); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + + if (poolType != null && !poolType.isEmpty()) { + wrapper.eq(ScorePool::getPoolType, poolType); + } + if (year != null) { + wrapper.eq(ScorePool::getYear, year); + } + + wrapper.orderByDesc(ScorePool::getYear) + .orderByDesc(ScorePool::getMonth) + .orderByDesc(ScorePool::getCreateTime); + return scorePoolMapper.selectPage(pageParam, wrapper); + } + + /** + * 获取积分池详情 + */ + public ScorePool getPoolById(Long id) { + ScorePool pool = scorePoolMapper.selectById(id); + if (pool == null) { + throw new BusinessException(ResultCode.NOT_FOUND, "积分池不存在"); + } + return pool; + } + + /** + * 获取当前活跃的积分池 + */ + public ScorePool getCurrentActivePool() { + LocalDateTime now = LocalDateTime.now(); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(ScorePool::getStatus, "active") + .eq(ScorePool::getPoolType, "monthly") + .eq(ScorePool::getYear, now.getYear()) + .eq(ScorePool::getMonth, now.getMonthValue()); + + ScorePool pool = scorePoolMapper.selectOne(wrapper); + if (pool == null) { + // 自动创建当月积分池 + pool = autoCreateMonthlyPool(now.getYear(), now.getMonthValue()); + } + return pool; + } + + /** + * 自动创建月度积分池 + */ + @Transactional + public ScorePool autoCreateMonthlyPool(int year, int month) { + ScorePool pool = new ScorePool(); + pool.setName(String.format("%d年%d月激励积分池", year, month)); + pool.setPoolType("monthly"); + pool.setYear(year); + pool.setMonth(month); + pool.setTotalBudget(100000); // 默认10万积分预算 + pool.setOverflowStrategy("alert"); + pool.setAlertThreshold(80); + + return createPool(pool); + } + + /** + * 从积分池发放积分 + */ + @Transactional + public boolean distributeScore(User user, int score, String rewardType, String bizType, Long bizId, String remark) { + ScorePool pool = getCurrentActivePool(); + + // 检查是否超支 + if (pool.getRemainingScore() < score) { + if ("stop".equals(pool.getOverflowStrategy())) { + log.warn("积分池已耗尽,停止发放: poolId={}", pool.getId()); + return false; + } else if ("alert".equals(pool.getOverflowStrategy())) { + // 发送提醒但继续发放 + notificationService.sendNotification( + pool.getCreatorId(), + "积分池预算不足提醒", + String.format("积分池「%s」剩余积分不足,当前剩余 %d,请及时补充预算!", + pool.getName(), pool.getRemainingScore()), + "score_pool", + pool.getId() + ); + } + // auto_increase 策略自动增加预算,不做限制 + } + + // 检查分项预算 + int categoryUsed = getCategoryUsed(pool, rewardType); + int categoryBudget = getCategoryBudget(pool, rewardType); + + if (categoryUsed + score > categoryBudget && "stop".equals(pool.getOverflowStrategy())) { + log.warn("分项预算已用完: poolId={}, type={}", pool.getId(), rewardType); + return false; + } + + // 记录变动前状态 + int beforeRemaining = pool.getRemainingScore(); + + // 扣减积分池 + pool.setUsedScore(pool.getUsedScore() + score); + pool.setRemainingScore(pool.getRemainingScore() - score); + updateCategoryUsed(pool, rewardType, score); + + scorePoolMapper.updateById(pool); + + // 创建记录 + ScorePoolRecord record = new ScorePoolRecord(); + record.setPoolId(pool.getId()); + record.setUserId(user.getId()); + record.setUserName(user.getRealName()); + record.setRewardType(rewardType); + record.setScoreChange(score); + record.setBeforeRemaining(beforeRemaining); + record.setAfterRemaining(pool.getRemainingScore()); + record.setBizType(bizType); + record.setBizId(bizId); + record.setRemark(remark); + recordMapper.insert(record); + + // 检查是否需要发送预警 + checkAndSendAlert(pool); + + log.info("积分池发放成功: poolId={}, userId={}, score={}, type={}", + pool.getId(), user.getId(), score, rewardType); + return true; + } + + /** + * 获取分项已使用积分 + */ + private int getCategoryUsed(ScorePool pool, String rewardType) { + switch (rewardType) { + case "task": return pool.getTaskRewardUsed(); + case "rating": return pool.getRatingRewardUsed(); + case "contest": return pool.getContestRewardUsed(); + case "checkin": return pool.getCheckinRewardUsed(); + default: return pool.getOtherRewardUsed(); + } + } + + /** + * 获取分项预算 + */ + private int getCategoryBudget(ScorePool pool, String rewardType) { + switch (rewardType) { + case "task": return pool.getTaskRewardBudget(); + case "rating": return pool.getRatingRewardBudget(); + case "contest": return pool.getContestRewardBudget(); + case "checkin": return pool.getCheckinRewardBudget(); + default: return pool.getOtherRewardBudget(); + } + } + + /** + * 更新分项已使用积分 + */ + private void updateCategoryUsed(ScorePool pool, String rewardType, int score) { + switch (rewardType) { + case "task": + pool.setTaskRewardUsed(pool.getTaskRewardUsed() + score); + break; + case "rating": + pool.setRatingRewardUsed(pool.getRatingRewardUsed() + score); + break; + case "contest": + pool.setContestRewardUsed(pool.getContestRewardUsed() + score); + break; + case "checkin": + pool.setCheckinRewardUsed(pool.getCheckinRewardUsed() + score); + break; + default: + pool.setOtherRewardUsed(pool.getOtherRewardUsed() + score); + } + } + + /** + * 检查并发送预警 + */ + private void checkAndSendAlert(ScorePool pool) { + if (pool.getAlertThreshold() == null) return; + + int usagePercent = (int)((double) pool.getUsedScore() / pool.getTotalBudget() * 100); + if (usagePercent >= pool.getAlertThreshold()) { + notificationService.sendNotification( + pool.getCreatorId(), + "积分池使用预警", + String.format("积分池「%s」使用率已达 %d%%,请关注!", pool.getName(), usagePercent), + "score_pool", + pool.getId() + ); + } + } + + /** + * 获取积分池使用记录 + */ + public IPage getPoolRecords(Long poolId, Integer page, Integer pageSize) { + Page pageParam = new Page<>(page, pageSize); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(ScorePoolRecord::getPoolId, poolId) + .orderByDesc(ScorePoolRecord::getCreateTime); + return recordMapper.selectPage(pageParam, wrapper); + } + + /** + * 获取积分池统计信息 + */ + public Map getPoolStatistics(Long poolId) { + ScorePool pool = getPoolById(poolId); + Map stats = new HashMap<>(); + + stats.put("totalBudget", pool.getTotalBudget()); + stats.put("usedScore", pool.getUsedScore()); + stats.put("remainingScore", pool.getRemainingScore()); + stats.put("usagePercent", (double) pool.getUsedScore() / pool.getTotalBudget() * 100); + + // 分项统计 + Map categories = new HashMap<>(); + categories.put("task", Map.of("budget", pool.getTaskRewardBudget(), "used", pool.getTaskRewardUsed())); + categories.put("rating", Map.of("budget", pool.getRatingRewardBudget(), "used", pool.getRatingRewardUsed())); + categories.put("contest", Map.of("budget", pool.getContestRewardBudget(), "used", pool.getContestRewardUsed())); + categories.put("checkin", Map.of("budget", pool.getCheckinRewardBudget(), "used", pool.getCheckinRewardUsed())); + categories.put("other", Map.of("budget", pool.getOtherRewardBudget(), "used", pool.getOtherRewardUsed())); + stats.put("categories", categories); + + return stats; + } + + /** + * 关闭积分池 + */ + @Transactional + public void closePool(Long id) { + ScorePool pool = getPoolById(id); + pool.setStatus("closed"); + scorePoolMapper.updateById(pool); + log.info("积分池已关闭: id={}", id); + } + + /** + * 结算积分池 + */ + @Transactional + public void settlePool(Long id) { + ScorePool pool = getPoolById(id); + if (!"closed".equals(pool.getStatus())) { + throw new BusinessException(ResultCode.PARAM_ERROR, "只有已关闭的积分池才能结算"); + } + pool.setStatus("settled"); + scorePoolMapper.updateById(pool); + log.info("积分池已结算: id={}, used={}, remaining={}", + id, pool.getUsedScore(), pool.getRemainingScore()); + } + + /** + * 定时任务:每月1日自动创建新的月度积分池 + */ + @Scheduled(cron = "0 0 0 1 * ?") // 每月1日0点执行 + @Transactional + public void autoCreateNewMonthPool() { + LocalDateTime now = LocalDateTime.now(); + int year = now.getYear(); + int month = now.getMonthValue(); + + // 检查是否已存在 + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(ScorePool::getPoolType, "monthly") + .eq(ScorePool::getYear, year) + .eq(ScorePool::getMonth, month); + + if (scorePoolMapper.selectCount(wrapper) == 0) { + autoCreateMonthlyPool(year, month); + log.info("自动创建{}年{}月积分池", year, month); + } + + // 关闭上个月的积分池 + YearMonth lastMonth = YearMonth.of(year, month).minusMonths(1); + LambdaQueryWrapper lastWrapper = new LambdaQueryWrapper<>(); + lastWrapper.eq(ScorePool::getPoolType, "monthly") + .eq(ScorePool::getYear, lastMonth.getYear()) + .eq(ScorePool::getMonth, lastMonth.getMonthValue()) + .eq(ScorePool::getStatus, "active"); + + List lastPools = scorePoolMapper.selectList(lastWrapper); + for (ScorePool lastPool : lastPools) { + lastPool.setStatus("closed"); + scorePoolMapper.updateById(lastPool); + log.info("自动关闭上月积分池: id={}", lastPool.getId()); + } + } + + /** + * 删除积分池 + */ + @Transactional + public void deletePool(Long id) { + ScorePool pool = getPoolById(id); + if ("active".equals(pool.getStatus())) { + throw new BusinessException(ResultCode.PARAM_ERROR, "进行中的积分池不能删除"); + } + scorePoolMapper.deleteById(id); + } +} diff --git a/backend/src/main/java/com/designhub/service/SystemConfigService.java b/backend/src/main/java/com/designhub/service/SystemConfigService.java new file mode 100644 index 0000000..d761bcd --- /dev/null +++ b/backend/src/main/java/com/designhub/service/SystemConfigService.java @@ -0,0 +1,257 @@ +package com.designhub.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.designhub.entity.SystemConfig; +import com.designhub.exception.BusinessException; +import com.designhub.mapper.SystemConfigMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 系统配置Service + */ +@Service +@RequiredArgsConstructor +public class SystemConfigService { + + private final SystemConfigMapper systemConfigMapper; + + /** + * 获取所有配置列表 + */ + public List getAllConfigs() { + return systemConfigMapper.selectList( + new LambdaQueryWrapper() + .orderByAsc(SystemConfig::getConfigGroup, SystemConfig::getSortOrder) + ); + } + + /** + * 根据分组获取配置列表 + */ + public List getConfigsByGroup(String group) { + return systemConfigMapper.selectList( + new LambdaQueryWrapper() + .eq(SystemConfig::getConfigGroup, group) + .orderByAsc(SystemConfig::getSortOrder) + ); + } + + /** + * 根据键获取配置值 + */ + public String getConfigValue(String key) { + SystemConfig config = systemConfigMapper.selectOne( + new LambdaQueryWrapper() + .eq(SystemConfig::getConfigKey, key) + ); + return config != null ? config.getConfigValue() : null; + } + + /** + * 根据键获取配置值(带默认值) + */ + public String getConfigValue(String key, String defaultValue) { + String value = getConfigValue(key); + return value != null ? value : defaultValue; + } + + /** + * 获取整型配置值 + */ + public Integer getIntValue(String key, Integer defaultValue) { + String value = getConfigValue(key); + if (value != null) { + try { + return Integer.parseInt(value); + } catch (NumberFormatException e) { + return defaultValue; + } + } + return defaultValue; + } + + /** + * 获取布尔型配置值 + */ + public Boolean getBooleanValue(String key, Boolean defaultValue) { + String value = getConfigValue(key); + if (value != null) { + return Boolean.parseBoolean(value); + } + return defaultValue; + } + + /** + * 获取所有配置的Map + */ + public Map getAllConfigsMap() { + List configs = getAllConfigs(); + return configs.stream() + .collect(Collectors.toMap( + SystemConfig::getConfigKey, + SystemConfig::getConfigValue, + (v1, v2) -> v2 + )); + } + + /** + * 根据分组获取配置Map + */ + public Map getConfigsMapByGroup(String group) { + List configs = getConfigsByGroup(group); + return configs.stream() + .collect(Collectors.toMap( + SystemConfig::getConfigKey, + SystemConfig::getConfigValue, + (v1, v2) -> v2 + )); + } + + /** + * 创建配置 + */ + @Transactional(rollbackFor = Exception.class) + public SystemConfig createConfig(SystemConfig config) { + // 验证键的唯一性 + long count = systemConfigMapper.selectCount( + new LambdaQueryWrapper() + .eq(SystemConfig::getConfigKey, config.getConfigKey()) + ); + if (count > 0) { + throw new BusinessException("配置键已存在"); + } + + // 设置默认值 + if (config.getBuiltIn() == null) { + config.setBuiltIn(0); + } + if (config.getSortOrder() == null) { + config.setSortOrder(0); + } + + systemConfigMapper.insert(config); + return config; + } + + /** + * 更新配置 + */ + @Transactional(rollbackFor = Exception.class) + public boolean updateConfig(SystemConfig config) { + SystemConfig existConfig = systemConfigMapper.selectById(config.getId()); + if (existConfig == null) { + throw new BusinessException("配置不存在"); + } + + // 内置配置不允许修改键和分组 + if (existConfig.getBuiltIn() == 1) { + config.setConfigKey(null); + config.setConfigGroup(null); + config.setBuiltIn(null); + } + + return systemConfigMapper.updateById(config) > 0; + } + + /** + * 批量更新配置值 + */ + @Transactional(rollbackFor = Exception.class) + public boolean batchUpdateValues(Map configMap) { + for (Map.Entry entry : configMap.entrySet()) { + SystemConfig config = systemConfigMapper.selectOne( + new LambdaQueryWrapper() + .eq(SystemConfig::getConfigKey, entry.getKey()) + ); + if (config != null) { + config.setConfigValue(entry.getValue()); + systemConfigMapper.updateById(config); + } + } + return true; + } + + /** + * 删除配置 + */ + @Transactional(rollbackFor = Exception.class) + public boolean deleteConfig(Long id) { + SystemConfig config = systemConfigMapper.selectById(id); + if (config == null) { + throw new BusinessException("配置不存在"); + } + + // 内置配置不允许删除 + if (config.getBuiltIn() == 1) { + throw new BusinessException("内置配置不允许删除"); + } + + return systemConfigMapper.deleteById(id) > 0; + } + + /** + * 初始化默认配置 + */ + @Transactional(rollbackFor = Exception.class) + public void initDefaultConfigs() { + // 任务相关配置 + createConfigIfNotExists("task.max_concurrent", "5", "task", "最大并发任务数", "设计师同时进行的最大任务数", "number", 1); + createConfigIfNotExists("task.grab_timeout", "30", "task", "抢单超时时间(分钟)", "任务发布后多久无人抢单则自动关闭", "number", 1); + createConfigIfNotExists("task.auto_close_days", "7", "task", "自动关闭天数", "未完成的任务多少天后自动关闭", "number", 1); + + // 积分相关配置 + createConfigIfNotExists("score.low_balance_alert", "100", "score", "积分余额预警阈值", "部门积分低于此值时发出预警", "number", 1); + createConfigIfNotExists("score.delay_penalty_rate", "10", "score", "延误扣分比例(%)", "任务延误时的扣分比例", "number", 1); + + // 通知相关配置 + createConfigIfNotExists("notification.dingtalk_enabled", "false", "notification", "启用钉钉通知", "是否启用钉钉消息推送", "boolean", 1); + createConfigIfNotExists("notification.email_enabled", "false", "notification", "启用邮件通知", "是否启用邮件通知", "boolean", 1); + + // 激励系统配置 + createConfigIfNotExists("incentive.enabled", "true", "incentive", "启用激励系统", "是否启用激励功能", "boolean", 1); + createConfigIfNotExists("incentive.daily_checkin_score", "5", "incentive", "每日签到奖励积分", "设计师每日签到可获得的积分", "number", 1); + + // 导航栏配置 - 主菜单 + createConfigIfNotExists("navbar.menu.home", "true", "navbar", "显示工作台菜单", "是否在导航栏显示工作台菜单", "boolean", 1); + createConfigIfNotExists("navbar.menu.tasks", "true", "navbar", "显示任务大厅菜单", "是否在导航栏显示任务大厅菜单", "boolean", 1); + createConfigIfNotExists("navbar.menu.designer_hall", "true", "navbar", "显示设计师大厅菜单", "是否在导航栏显示设计师大厅菜单", "boolean", 1); + createConfigIfNotExists("navbar.menu.incentive", "true", "navbar", "显示激励中心菜单", "是否在导航栏显示激励中心菜单", "boolean", 1); + createConfigIfNotExists("navbar.menu.analytics", "true", "navbar", "显示数据分析菜单", "是否在导航栏显示数据分析菜单", "boolean", 1); + + // 导航栏配置 - 功能按钮 + createConfigIfNotExists("navbar.action.notification", "true", "navbar", "显示通知按钮", "是否在导航栏显示通知铃铛按钮", "boolean", 1); + createConfigIfNotExists("navbar.action.checkin", "true", "navbar", "显示签到按钮", "是否在导航栏显示每日签到按钮", "boolean", 1); + createConfigIfNotExists("navbar.action.achievement", "true", "navbar", "显示成就按钮", "是否在导航栏显示成就中心按钮", "boolean", 1); + createConfigIfNotExists("navbar.action.settings", "true", "navbar", "显示设置按钮", "是否在导航栏显示设置按钮", "boolean", 1); + } + + /** + * 如果不存在则创建配置 + */ + private void createConfigIfNotExists(String key, String value, String group, String name, String description, String valueType, Integer builtIn) { + long count = systemConfigMapper.selectCount( + new LambdaQueryWrapper() + .eq(SystemConfig::getConfigKey, key) + ); + if (count == 0) { + SystemConfig config = new SystemConfig(); + config.setConfigKey(key); + config.setConfigValue(value); + config.setConfigGroup(group); + config.setConfigName(name); + config.setDescription(description); + config.setValueType(valueType); + config.setBuiltIn(builtIn); + config.setSortOrder(0); + systemConfigMapper.insert(config); + } + } +} + diff --git a/backend/src/main/java/com/designhub/service/SystemHealthService.java b/backend/src/main/java/com/designhub/service/SystemHealthService.java new file mode 100644 index 0000000..20279a8 --- /dev/null +++ b/backend/src/main/java/com/designhub/service/SystemHealthService.java @@ -0,0 +1,63 @@ +package com.designhub.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.designhub.entity.Role; +import com.designhub.mapper.DepartmentMapper; +import com.designhub.mapper.RoleMapper; +import com.designhub.mapper.TaskMapper; +import com.designhub.mapper.UserMapper; +import com.designhub.vo.SystemHealthVO; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** + * 系统健康检查服务 + */ +@Service +@RequiredArgsConstructor +public class SystemHealthService { + + private final UserMapper userMapper; + private final TaskMapper taskMapper; + private final RoleMapper roleMapper; + private final DepartmentMapper departmentMapper; + + @Value("${spring.profiles.active:default}") + private String activeProfile; + + /** + * 执行健康检查 + */ + public SystemHealthVO check() { + SystemHealthVO health = new SystemHealthVO(); + health.setProfile(activeProfile); + + try { + health.setUserCount(userMapper.selectCount(null)); + health.setTaskCount(taskMapper.selectCount(null)); + health.setDepartmentCount(departmentMapper.selectCount(null)); + health.setRoleCount(roleMapper.selectCount(new LambdaQueryWrapper().select(Role::getId))); + health.setDatabaseUp(true); + } catch (Exception ex) { + health.setDatabaseUp(false); + health.getChecks().add(new SystemHealthVO.CheckItem("database", false, ex.getMessage())); + return health; + } + + // 校验 sys_role 表结构是否与实体一致(只查询存在的列) + try { + roleMapper.selectList(new LambdaQueryWrapper() + .select(Role::getId, Role::getRoleCode, Role::getRoleName, Role::getStatus) + .last("LIMIT 1")); + health.getChecks().add(new SystemHealthVO.CheckItem("sys_role_schema", true, "Role columns verified")); + } catch (Exception ex) { + health.getChecks().add(new SystemHealthVO.CheckItem("sys_role_schema", false, ex.getMessage())); + } + + return health; + } +} + diff --git a/backend/src/main/java/com/designhub/service/TaskApprovalService.java b/backend/src/main/java/com/designhub/service/TaskApprovalService.java new file mode 100644 index 0000000..47f287a --- /dev/null +++ b/backend/src/main/java/com/designhub/service/TaskApprovalService.java @@ -0,0 +1,518 @@ +package com.designhub.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.designhub.dto.PipelineTaskDTO; +import com.designhub.dto.TaskApprovalDTO; +import com.designhub.dto.TaskUpdateDTO; +import com.designhub.entity.*; +import com.designhub.exception.BusinessException; +import com.designhub.mapper.*; +import com.designhub.common.ResultCode; +import com.designhub.entity.Brand; +import com.designhub.entity.Category; +import com.designhub.entity.Platform; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 任务审核服务 + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class TaskApprovalService { + + private final TaskMapper taskMapper; + private final TaskApprovalLogMapper approvalLogMapper; + private final DepartmentMapper departmentMapper; + private final UserMapper userMapper; + private final BrandMapper brandMapper; + private final CategoryMapper categoryMapper; + private final PlatformMapper platformMapper; + private final NotificationService notificationService; + private final TeamScorePoolService teamScorePoolService; + private final TaskService taskService; + private final ConfigService configService; + private final ObjectMapper objectMapper; + + /** + * 需求部门负责人审核通过 + */ + @Transactional(rollbackFor = Exception.class) + public void approveDemandLevel(Long taskId, Long approverId, TaskApprovalDTO dto) { + // 1. 验证任务和权限 + Task task = getTaskForApproval(taskId, "pending_demand_approval"); + validateApprover(task, approverId, "demand_leader"); + + // 2. 应用任务修改(如果有) + if (dto.getTaskUpdates() != null) { + applyTaskUpdates(task, dto.getTaskUpdates()); + logApproval(taskId, approverId, 1, "modify", dto.getComment()); + } + + // 3. 根据发布类型决定是否需要接单部门负责人审核 + String releaseType = task.getReleaseType(); + boolean needReceivingApproval = "default".equals(releaseType); + + if (needReceivingApproval) { + // default模式:需要接单部门负责人审核 + Long receivingLeaderId = determineReceivingLeader(task); + if (receivingLeaderId == null) { + throw new BusinessException(ResultCode.PARAM_ERROR, "无法确定接单部门负责人,请在系统设置中为接单部门配置负责人"); + } + + // 更新任务状态为待接单部门审核 + task.setApprovalStatus("pending_receiving_approval"); + task.setDemandApprovalTime(LocalDateTime.now()); + task.setCurrentApproverId(receivingLeaderId); + task.setCurrentApproverRole("receiving_leader"); + taskMapper.updateById(task); + + // 记录审核日志 + logApproval(taskId, approverId, 1, "approve", dto.getComment()); + + // 发送通知给接单部门负责人 + User receivingLeader = userMapper.selectById(receivingLeaderId); + if (receivingLeader != null) { + notificationService.sendNotification( + receivingLeaderId, + "新任务待审核", + String.format("任务【%s】需要您审核并分配设计师", task.getTitle()), + "task_approval", + taskId + ); + } + + log.info("需求部门负责人审核通过,任务ID:{},审核人:{},等待接单部门负责人审核", taskId, approverId); + } else { + // recruitment或assigned模式:不需要接单部门负责人审核,直接发布 + task.setDemandApprovalTime(LocalDateTime.now()); + + // 判断是否为流水线任务 + if (task.getIsPipeline() != null && task.getIsPipeline() == 1) { + // 流水线任务:拆分子任务 + splitIntoPipelineSubTasks(task); + // splitIntoPipelineSubTasks方法会根据releaseType设置任务状态 + } else { + // 普通任务处理 + if ("assigned".equals(releaseType) && task.getReceiverId() != null) { + // assigned模式:如果指定了接收人,直接分配给该设计师 + User receiver = userMapper.selectById(task.getReceiverId()); + if (receiver != null && receiver.getDeptId() != null) { + task.setReceiverDeptId(receiver.getDeptId()); + task.setStatus("in_progress"); + task.setStartTime(LocalDateTime.now()); + task.setActualStartTime(LocalDateTime.now()); + + // 重新计算截止时间 + if (task.getEstimatedHours() != null) { + int hours = task.getEstimatedHours().intValue(); + if (hours > 0) { + task.setDeadline(LocalDateTime.now().plusHours(hours)); + } + } + + // 发送通知给被指定的设计师 + notificationService.sendNotification( + task.getReceiverId(), + "新任务分配", + String.format("任务【%s】已分配给您", task.getTitle()), + "task_assigned", + taskId + ); + + log.info("assigned模式任务已直接分配给设计师:taskId={}, receiverId={}", taskId, task.getReceiverId()); + } else { + throw new BusinessException(ResultCode.PARAM_ERROR, "指定的接收人不存在或部门信息不完整"); + } + } else { + // recruitment模式:发布到任务大厅 + task.setStatus("pending"); + } + } + + // 更新审核状态为已通过 + task.setApprovalStatus("approved"); + task.setCurrentApproverId(null); + task.setCurrentApproverRole(null); + taskMapper.updateById(task); + + // 扣除部门积分 + try { + teamScorePoolService.deductScore(task.getPublisherDeptId(), task.getScore()); + } catch (Exception e) { + log.error("扣除部门积分失败", e); + throw new BusinessException(ResultCode.ERROR, "扣除部门积分失败:" + e.getMessage()); + } + + // 记录审核日志 + logApproval(taskId, approverId, 1, "approve", dto.getComment()); + + log.info("需求部门负责人审核通过,任务ID:{},审核人:{},发布类型:{},已直接发布", taskId, approverId, releaseType); + } + } + + /** + * 需求部门负责人驳回 + */ + @Transactional(rollbackFor = Exception.class) + public void rejectByDemandLeader(Long taskId, Long approverId, String reason) { + Task task = getTaskForApproval(taskId, "pending_demand_approval"); + validateApprover(task, approverId, "demand_leader"); + + task.setApprovalStatus("rejected_by_demand"); + task.setStatus("draft"); + task.setCurrentApproverId(null); + task.setCurrentApproverRole(null); + taskMapper.updateById(task); + + logApproval(taskId, approverId, 1, "reject", reason); + + // 通知发起人 + notificationService.sendNotification( + task.getPublisherId(), + "任务已被驳回", + String.format("您的任务【%s】被需求部门负责人驳回,原因:%s", task.getTitle(), reason), + "task_reject", + taskId + ); + + log.info("需求部门负责人驳回任务,任务ID:{},审核人:{}", taskId, approverId); + } + + /** + * 接单部门负责人审核通过 + */ + @Transactional(rollbackFor = Exception.class) + public void approveReceivingLevel(Long taskId, Long approverId, TaskApprovalDTO dto) { + Task task = getTaskForApproval(taskId, "pending_receiving_approval"); + validateApprover(task, approverId, "receiving_leader"); + + // 应用任务修改(如果有) + if (dto.getTaskUpdates() != null) { + applyTaskUpdates(task, dto.getTaskUpdates()); + logApproval(taskId, approverId, 2, "modify", dto.getComment()); + } + + // 判断是否为流水线任务 + if (task.getIsPipeline() != null && task.getIsPipeline() == 1) { + // 流水线任务:拆分子任务 + splitIntoPipelineSubTasks(task); + } else { + // 普通任务:直接发布到任务大厅 + task.setStatus("pending"); + } + + // 更新审核状态 + task.setApprovalStatus("approved"); + task.setReceivingApprovalTime(LocalDateTime.now()); + task.setCurrentApproverId(null); + task.setCurrentApproverRole(null); + taskMapper.updateById(task); + + // 扣除部门积分 + try { + teamScorePoolService.deductScore(task.getPublisherDeptId(), task.getScore()); + } catch (Exception e) { + log.error("扣除部门积分失败", e); + throw new BusinessException(ResultCode.ERROR, "扣除部门积分失败:" + e.getMessage()); + } + + // 记录审核日志 + logApproval(taskId, approverId, 2, "approve", dto.getComment()); + + // TODO: 通知任务大厅(所有接单部门成员) + // 暂时不实现群发通知功能,后续可以通过WebSocket或轮询方式实现 + log.info("任务【{}】已发布到任务大厅", task.getTitle()); + + log.info("接单部门负责人审核通过,任务ID:{},审核人:{}", taskId, approverId); + } + + /** + * 接单部门负责人驳回 + */ + @Transactional(rollbackFor = Exception.class) + public void rejectByReceivingLeader(Long taskId, Long approverId, String reason) { + Task task = getTaskForApproval(taskId, "pending_receiving_approval"); + validateApprover(task, approverId, "receiving_leader"); + + // 退回给需求部门负责人 + Department demandDept = departmentMapper.selectById(task.getPublisherDeptId()); + if (demandDept == null || demandDept.getLeaderId() == null) { + throw new BusinessException(ResultCode.PARAM_ERROR, "需求部门负责人不存在"); + } + + task.setApprovalStatus("rejected_by_receiving"); + task.setStatus("draft"); + task.setCurrentApproverId(demandDept.getLeaderId()); + task.setCurrentApproverRole("demand_leader"); + taskMapper.updateById(task); + + logApproval(taskId, approverId, 2, "reject", reason); + + // 通知需求部门负责人 + notificationService.sendNotification( + demandDept.getLeaderId(), + "任务已被驳回", + String.format("任务【%s】被接单部门负责人驳回,原因:%s", task.getTitle(), reason), + "task_reject", + taskId + ); + + log.info("接单部门负责人驳回任务,任务ID:{},审核人:{}", taskId, approverId); + } + + /** + * 获取待审核任务列表 + */ + public List getPendingApprovalTasks(Long userId) { + List tasks = taskMapper.selectList( + new LambdaQueryWrapper() + .eq(Task::getCurrentApproverId, userId) + .in(Task::getApprovalStatus, "pending_demand_approval", "pending_receiving_approval") + .orderByDesc(Task::getCreateTime) + ); + + // 填充关联信息 + for (Task task : tasks) { + // 填充发起人姓名 + if (task.getPublisherId() != null) { + User publisher = userMapper.selectById(task.getPublisherId()); + if (publisher != null) { + task.setPublisherName(publisher.getUsername()); + } + } + + // 填充发起部门名称 + if (task.getPublisherDeptId() != null) { + Department dept = departmentMapper.selectById(task.getPublisherDeptId()); + if (dept != null) { + task.setPublisherDeptName(dept.getDeptName()); + } + } + + // 填充品牌名称 + if (task.getBrandId() != null) { + Brand brand = brandMapper.selectById(task.getBrandId()); + if (brand != null) { + // Brand实体类中name字段映射到brand_name,使用getName() + task.setBrandName(brand.getName()); + } + } + + // 填充类目名称 + if (task.getCategoryId() != null) { + Category category = categoryMapper.selectById(task.getCategoryId()); + if (category != null) { + // Category实体类中name字段映射到category_name,使用getName() + task.setCategoryName(category.getName()); + } + } + + // 填充平台名称 + if (task.getPlatformId() != null) { + Platform platform = platformMapper.selectById(task.getPlatformId()); + if (platform != null) { + // Platform实体类中name字段映射到platform_name,使用getName() + task.setPlatformName(platform.getName()); + } + } + + // 填充当前审核人姓名 + if (task.getCurrentApproverId() != null) { + User approver = userMapper.selectById(task.getCurrentApproverId()); + if (approver != null) { + task.setCurrentApproverName(approver.getRealName() != null ? approver.getRealName() : approver.getUsername()); + } + } + } + + return tasks; + } + + /** + * 获取任务并验证审核状态 + */ + private Task getTaskForApproval(Long taskId, String expectedStatus) { + Task task = taskMapper.selectById(taskId); + if (task == null) { + throw new BusinessException(ResultCode.NOT_FOUND, "任务不存在"); + } + if (!expectedStatus.equals(task.getApprovalStatus())) { + throw new BusinessException(ResultCode.TASK_STATUS_ERROR, + "任务当前状态不允许此操作,当前状态:" + task.getApprovalStatus()); + } + return task; + } + + /** + * 验证审核人权限 + */ + private void validateApprover(Task task, Long approverId, String expectedRole) { + if (!approverId.equals(task.getCurrentApproverId())) { + throw new BusinessException(ResultCode.FORBIDDEN, "您无权审核此任务"); + } + if (!expectedRole.equals(task.getCurrentApproverRole())) { + throw new BusinessException(ResultCode.FORBIDDEN, "审核角色不匹配"); + } + } + + /** + * 确定接单部门负责人 + */ + private Long determineReceivingLeader(Task task) { + // 根据任务类型的allowedDeptIds确定接单部门 + // 简化版本:取第一个任务类型的第一个允许部门 + String taskTypeIds = task.getTaskTypeIds(); + if (taskTypeIds == null || taskTypeIds.isEmpty()) { + return null; + } + + String[] typeIdArray = taskTypeIds.split(","); + for (String typeIdStr : typeIdArray) { + try { + Long typeId = Long.parseLong(typeIdStr.trim()); + DemandType demandType = configService.getDemandTypeById(typeId); + if (demandType != null && demandType.getAllowedDeptIds() != null) { + String[] deptIds = demandType.getAllowedDeptIds().split(","); + if (deptIds.length > 0) { + Long deptId = Long.parseLong(deptIds[0].trim()); + Department dept = departmentMapper.selectById(deptId); + if (dept != null && dept.getLeaderId() != null) { + return dept.getLeaderId(); + } + } + } + } catch (Exception e) { + log.error("解析任务类型或部门ID失败", e); + } + } + return null; + } + + /** + * 应用任务更新 + */ + private void applyTaskUpdates(Task task, TaskUpdateDTO updates) { + if (updates.getTitle() != null) { + task.setTitle(updates.getTitle()); + } + if (updates.getTaskSubType() != null) { + task.setTaskSubType(updates.getTaskSubType()); + } + if (updates.getBrandId() != null) { + task.setBrandId(updates.getBrandId()); + } + if (updates.getPlatformId() != null) { + task.setPlatformId(updates.getPlatformId()); + } + if (updates.getCategoryId() != null) { + task.setCategoryId(updates.getCategoryId()); + } + if (updates.getSpuInfo() != null) { + task.setSpuInfo(updates.getSpuInfo()); + } + if (updates.getRemark() != null) { + task.setRemark(updates.getRemark()); + } + if (updates.getAttachmentUrl() != null) { + task.setAttachmentUrl(updates.getAttachmentUrl()); + } + // 如果修改了任务类型和数量,需要重新计算积分 + if (updates.getTaskTypes() != null && !updates.getTaskTypes().isEmpty()) { + String taskTypeIds = updates.getTaskTypes().stream() + .map(t -> String.valueOf(t.getTaskTypeId())) + .collect(Collectors.joining(",")); + task.setTaskTypeIds(taskTypeIds); + + // 如果只有一个任务类型,更新quantity字段 + if (updates.getTaskTypes().size() == 1) { + task.setQuantity(updates.getTaskTypes().get(0).getQuantity()); + } + + // 重新计算积分 + int totalScore = taskService.calculateTotalScore(updates.getTaskTypes()); + task.setScore(totalScore); + } + } + + /** + * 拆分为流水线子任务 + */ + private void splitIntoPipelineSubTasks(Task task) { + try { + // 构建流水线任务DTO + PipelineTaskDTO dto = new PipelineTaskDTO(); + dto.setTitle(task.getTitle()); + dto.setTaskSubType(task.getTaskSubType()); + dto.setBrandId(task.getBrandId()); + dto.setPlatformId(task.getPlatformId()); + dto.setCategoryId(task.getCategoryId()); + dto.setSpuInfo(task.getSpuInfo()); + dto.setRemark(task.getRemark()); + dto.setAttachmentUrl(task.getAttachmentUrl()); + dto.setPipelineMode(task.getPipelineMode()); + dto.setReleaseType(task.getReleaseType()); // 传递发布类型 + + // 解析任务类型 + String[] taskTypeIds = task.getTaskTypeIds().split(","); + List items = new java.util.ArrayList<>(); + + // 简化版本:所有任务类型使用相同的数量(task.quantity) + int quantity = task.getQuantity() != null ? task.getQuantity() : 1; + + for (String taskTypeIdStr : taskTypeIds) { + PipelineTaskDTO.TaskItemDTO item = new PipelineTaskDTO.TaskItemDTO(); + item.setTaskTypeId(Long.parseLong(taskTypeIdStr.trim())); + item.setQuantity(quantity); + items.add(item); + } + dto.setItems(items); + + // 调用TaskService创建流水线子任务 + taskService.createPipelineTaskItems(task, dto); + + // 根据发布类型设置主任务状态 + if ("recruitment".equals(task.getReleaseType())) { + // recruitment模式:发布到任务大厅 + task.setStatus("pending"); + } else if ("assigned".equals(task.getReleaseType()) && task.getReceiverId() != null) { + // assigned模式:如果指定了接收人,状态已经是in_progress(在approveDemandLevel中已设置) + // 这里不需要再次设置 + } + + } catch (Exception e) { + log.error("拆分流水线任务失败", e); + throw new BusinessException(ResultCode.ERROR, "拆分流水线任务失败:" + e.getMessage()); + } + } + + /** + * 记录审核日志 + */ + private void logApproval(Long taskId, Long approverId, Integer level, String action, String comment) { + try { + User approver = userMapper.selectById(approverId); + + TaskApprovalLog log = new TaskApprovalLog(); + log.setTaskId(taskId); + log.setApprovalLevel(level); + log.setApproverId(approverId); + log.setApproverName(approver != null ? approver.getRealName() : null); + log.setAction(action); + log.setComment(comment); + log.setCreateTime(LocalDateTime.now()); + + approvalLogMapper.insert(log); + } catch (Exception e) { + this.log.error("记录审核日志失败", e); + } + } +} diff --git a/backend/src/main/java/com/designhub/service/TaskItemService.java b/backend/src/main/java/com/designhub/service/TaskItemService.java new file mode 100644 index 0000000..62efa35 --- /dev/null +++ b/backend/src/main/java/com/designhub/service/TaskItemService.java @@ -0,0 +1,365 @@ +package com.designhub.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.designhub.common.ResultCode; +import com.designhub.entity.Task; +import com.designhub.entity.TaskItem; +import com.designhub.entity.Notification; +import com.designhub.exception.BusinessException; +import com.designhub.mapper.TaskItemMapper; +import com.designhub.mapper.TaskMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 任务子项服务类(流水线任务) + */ +@Service +@RequiredArgsConstructor +public class TaskItemService { + private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(TaskItemService.class); + + private final TaskItemMapper taskItemMapper; + private final TaskMapper taskMapper; + private final UserService userService; + private final NotificationService notificationService; + + /** + * 获取可接单的子任务列表 + */ + public IPage getAvailableItems(Integer page, Integer pageSize, Long taskTypeId, String keyword) { + Page pageParam = new Page<>(page, pageSize); + return taskItemMapper.selectAvailableItems(pageParam, taskTypeId, keyword); + } + + /** + * 获取进行中的子任务列表 + */ + public IPage getInProgressItems(Integer page, Integer pageSize, Long receiverId) { + Page pageParam = new Page<>(page, pageSize); + return taskItemMapper.selectInProgressItems(pageParam, receiverId); + } + + /** + * 获取已完成的子任务列表 + */ + public IPage getCompletedItems(Integer page, Integer pageSize, Long receiverId) { + Page pageParam = new Page<>(page, pageSize); + return taskItemMapper.selectCompletedItems(pageParam, receiverId); + } + + /** + * 根据任务ID获取所有子任务 + */ + public List getItemsByTaskId(Long taskId) { + return taskItemMapper.selectByTaskId(taskId); + } + + /** + * 获取子任务详情 + */ + public TaskItem getItemById(Long itemId) { + TaskItem item = taskItemMapper.selectById(itemId); + if (item == null) { + throw new BusinessException(ResultCode.NOT_FOUND, "子任务不存在"); + } + + // 加载主任务信息 + Task mainTask = taskMapper.selectById(item.getTaskId()); + if (mainTask != null) { + item.setMainTaskTitle(mainTask.getTitle()); + item.setPipelineMode(mainTask.getPipelineMode()); + item.setBrandName(mainTask.getBrandName()); + item.setCategoryName(mainTask.getCategoryName()); + item.setPlatformName(mainTask.getPlatformName()); + item.setPublisherName(mainTask.getPublisherName()); + item.setSpuInfo(mainTask.getSpuInfo()); + item.setTaskSubType(mainTask.getTaskSubType()); + item.setAttachmentUrl(mainTask.getAttachmentUrl()); + } + + return item; + } + + /** + * 抢单子任务 + */ + @Transactional(rollbackFor = Exception.class) + public void grabItem(Long itemId, Long userId) { + log.info("设计师 {} 抢单子任务 {}", userId, itemId); + + // 获取用户信息 + var user = userService.getUserById(userId); + if (user == null) { + throw new BusinessException(ResultCode.FORBIDDEN, "用户不存在"); + } + + // 获取子任务信息 + TaskItem item = taskItemMapper.selectById(itemId); + if (item == null) { + throw new BusinessException(ResultCode.NOT_FOUND, "子任务不存在"); + } + + // 验证状态 + if (!"pending".equals(item.getStatus())) { + throw new BusinessException(ResultCode.TASK_STATUS_ERROR, "该子任务不可接单"); + } + + if (item.getIsLocked() != null && item.getIsLocked() == 1) { + throw new BusinessException(ResultCode.TASK_STATUS_ERROR, "该子任务已被锁定,请等待前序任务完成"); + } + + if (item.getReceiverId() != null) { + throw new BusinessException(ResultCode.TASK_ALREADY_TAKEN, "该子任务已被接单"); + } + + // 乐观锁抢单 + int result = taskItemMapper.grabItem(itemId, userId, user.getRealName()); + if (result == 0) { + throw new BusinessException(ResultCode.TASK_ALREADY_TAKEN, "抢单失败,该子任务可能已被他人接单"); + } + + log.info("设计师 {} 成功抢单子任务 {}", userId, itemId); + + // 发送通知给任务发布者 + Task mainTask = taskMapper.selectById(item.getTaskId()); + if (mainTask != null && mainTask.getPublisherId() != null) { + Notification notification = new Notification(); + notification.setReceiverId(mainTask.getPublisherId()); + notification.setNotificationType("task"); + notification.setTitle("子任务已被接单"); + notification.setContent(user.getRealName() + " 接单了子任务:" + item.getItemNo()); + notification.setRelatedId(itemId); + notification.setRelatedType("task_item"); + notification.setPriority(1); // 普通 + notificationService.sendNotification(notification); + } + } + + /** + * 更新子任务进度 + */ + @Transactional(rollbackFor = Exception.class) + public void updateProgress(Long itemId, Long userId, Integer progress) { + log.info("设计师 {} 更新子任务 {} 进度为 {}%", userId, itemId, progress); + + TaskItem item = taskItemMapper.selectById(itemId); + if (item == null) { + throw new BusinessException(ResultCode.NOT_FOUND, "子任务不存在"); + } + + // 验证权限 + if (!userId.equals(item.getReceiverId())) { + throw new BusinessException(ResultCode.FORBIDDEN, "无权更新该子任务"); + } + + // 验证状态 + if (!"in_progress".equals(item.getStatus())) { + throw new BusinessException(ResultCode.TASK_STATUS_ERROR, "只能更新进行中的子任务"); + } + + // 验证进度值 + if (progress < 0 || progress > 100) { + throw new BusinessException(ResultCode.PARAM_ERROR, "进度值必须在0-100之间"); + } + + // 更新进度 + item.setProgress(progress); + item.setUpdateTime(LocalDateTime.now()); + taskItemMapper.updateById(item); + + log.info("子任务 {} 进度已更新为 {}%", itemId, progress); + } + + /** + * 完结子任务(关键方法:包含自动解锁逻辑) + */ + @Transactional(rollbackFor = Exception.class) + public Map completeItem(Long itemId, Long userId, String deliveryPath) { + log.info("设计师 {} 完结子任务 {}", userId, itemId); + + // 1. 获取子任务信息 + TaskItem item = taskItemMapper.selectById(itemId); + if (item == null) { + throw new BusinessException(ResultCode.NOT_FOUND, "子任务不存在"); + } + + // 2. 验证权限 + if (!userId.equals(item.getReceiverId())) { + throw new BusinessException(ResultCode.FORBIDDEN, "无权完结该子任务"); + } + + // 3. 验证状态 + if (!"in_progress".equals(item.getStatus())) { + throw new BusinessException(ResultCode.TASK_STATUS_ERROR, "只能完结进行中的子任务"); + } + + // 4. 更新子任务状态 + item.setStatus("completed"); + item.setProgress(100); + item.setDeliveryPath(deliveryPath); + item.setCompleteTime(LocalDateTime.now()); + item.setUpdateTime(LocalDateTime.now()); + taskItemMapper.updateById(item); + + log.info("子任务 {} 已完成", itemId); + + // 5. 设计师立即获得积分(如果需要,可以调用积分服务) + // scoreService.addScore(userId, item.getTotalScore()); + + Map result = new HashMap<>(); + result.put("completedItem", item); + + // 6. 🔥 关键步骤:检查是否是顺序流水线,基于group_no解锁下一组 + Task mainTask = taskMapper.selectById(item.getTaskId()); + if (mainTask != null && "sequential".equals(mainTask.getPipelineMode())) { + // 获取当前子任务的分组号 + Integer currentGroupNo = item.getGroupNo(); + if (currentGroupNo == null) { + currentGroupNo = 1; // 兼容旧数据 + } + + // 检查当前组的所有子任务是否都已完成 + List currentGroupItems = taskItemMapper.selectList( + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .eq(TaskItem::getTaskId, item.getTaskId()) + .eq(TaskItem::getGroupNo, currentGroupNo) + ); + + boolean allGroupCompleted = currentGroupItems.stream() + .allMatch(i -> "completed".equals(i.getStatus())); + + if (allGroupCompleted) { + log.info("✅ 第 {} 组所有子任务已完成,准备解锁下一组", currentGroupNo); + + // 解锁下一组的所有子任务 + Integer nextGroupNo = currentGroupNo + 1; + List nextGroupItems = taskItemMapper.selectList( + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .eq(TaskItem::getTaskId, item.getTaskId()) + .eq(TaskItem::getGroupNo, nextGroupNo) + .eq(TaskItem::getStatus, "locked") + ); + + if (!nextGroupItems.isEmpty()) { + // 批量解锁下一组的所有子任务 + for (TaskItem nextItem : nextGroupItems) { + nextItem.setStatus("pending"); + nextItem.setIsLocked(0); + nextItem.setUpdateTime(LocalDateTime.now()); + taskItemMapper.updateById(nextItem); + + log.info("✅ 解锁子任务: {} (组号: {}, ID: {})", + nextItem.getItemNo(), nextGroupNo, nextItem.getId()); + } + + result.put("unlockedGroupNo", nextGroupNo); + result.put("unlockedCount", nextGroupItems.size()); + + // 发送通知:新的子任务组已开放 + Notification notification = new Notification(); + notification.setNotificationType("task"); + notification.setTitle("新子任务组已开放"); + notification.setContent("第 " + nextGroupNo + " 组共 " + nextGroupItems.size() + " 个子任务已解锁,可以接单了!"); + notification.setRelatedId(item.getTaskId()); + notification.setRelatedType("task"); + notification.setPriority(1); // 普通 + // 可以通知所有接单部门的成员(这里暂时省略,可以扩展) + + log.info("第 {} 组 {} 个子任务已进入可接任务池", nextGroupNo, nextGroupItems.size()); + } else { + log.info("没有更多子任务组需要解锁"); + } + } else { + log.info("当前组还有子任务未完成,暂不解锁下一组"); + } + } + + // 7. 检查主任务是否所有子任务都已完成 + int completedCount = taskItemMapper.countCompletedItems(item.getTaskId()); + int totalCount = taskItemMapper.countTotalItems(item.getTaskId()); + + if (completedCount == totalCount) { + // 所有子任务完成,更新主任务状态 + mainTask.setStatus("completed"); + mainTask.setCompleteTime(LocalDateTime.now()); + mainTask.setUpdateTime(LocalDateTime.now()); + taskMapper.updateById(mainTask); + + log.info("🎉 主任务 {} 所有子任务已完成,主任务标记为完成", mainTask.getId()); + + // 发送通知给发布者 + if (mainTask.getPublisherId() != null) { + Notification notification = new Notification(); + notification.setReceiverId(mainTask.getPublisherId()); + notification.setNotificationType("task"); + notification.setTitle("流水线任务已完成"); + notification.setContent("您的任务《" + mainTask.getTitle() + "》所有子任务已完成!"); + notification.setRelatedId(mainTask.getId()); + notification.setRelatedType("task"); + notification.setPriority(1); // 普通 + notificationService.sendNotification(notification); + } + + result.put("mainTaskCompleted", true); + } else { + result.put("mainTaskCompleted", false); + result.put("progress", completedCount + "/" + totalCount); + } + + return result; + } + + /** + * 驳回子任务 + */ + @Transactional(rollbackFor = Exception.class) + public void rejectItem(Long itemId, String reason) { + TaskItem item = taskItemMapper.selectById(itemId); + if (item == null) { + throw new BusinessException(ResultCode.NOT_FOUND, "子任务不存在"); + } + + item.setStatus("rejected"); + item.setUpdateTime(LocalDateTime.now()); + taskItemMapper.updateById(item); + + // 发送通知给接单人 + if (item.getReceiverId() != null) { + Notification notification = new Notification(); + notification.setReceiverId(item.getReceiverId()); + notification.setNotificationType("task"); + notification.setTitle("子任务被驳回"); + notification.setContent("您的子任务 " + item.getItemNo() + " 被驳回。原因:" + reason); + notification.setRelatedId(itemId); + notification.setRelatedType("task_item"); + notification.setPriority(1); // 普通 + notificationService.sendNotification(notification); + } + + log.info("子任务 {} 已驳回", itemId); + } + + /** + * 获取主任务的进度统计 + */ + public Map getTaskProgress(Long taskId) { + int completedCount = taskItemMapper.countCompletedItems(taskId); + int totalCount = taskItemMapper.countTotalItems(taskId); + + Map progress = new HashMap<>(); + progress.put("completedCount", completedCount); + progress.put("totalCount", totalCount); + progress.put("percentage", totalCount > 0 ? (completedCount * 100 / totalCount) : 0); + + return progress; + } +} diff --git a/backend/src/main/java/com/designhub/service/TaskProgressCheckService.java b/backend/src/main/java/com/designhub/service/TaskProgressCheckService.java new file mode 100644 index 0000000..d05762b --- /dev/null +++ b/backend/src/main/java/com/designhub/service/TaskProgressCheckService.java @@ -0,0 +1,186 @@ +package com.designhub.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.designhub.entity.Task; +import com.designhub.entity.TaskProgressUpdateLog; +import com.designhub.mapper.TaskMapper; +import com.designhub.mapper.TaskProgressUpdateLogMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.List; + +/** + * 任务进度检查服务 + * 每日18:00检查任务进度是否更新,并发送通知 + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class TaskProgressCheckService { + + private final TaskMapper taskMapper; + private final TaskProgressUpdateLogMapper progressUpdateLogMapper; + private final NotificationService notificationService; + private final UserService userService; + + /** + * 每日18:00执行进度更新检查 + * cron: 秒 分 时 日 月 周 + */ + @Scheduled(cron = "0 0 18 * * ?") + @Transactional + public void checkDailyProgressUpdate() { + log.info("=== 开始执行每日进度更新检查任务 ==="); + + LocalDate today = LocalDate.now(); + LocalDateTime todayStart = LocalDateTime.of(today, LocalTime.MIN); + LocalDateTime now = LocalDateTime.now(); + + // 查询所有进行中的任务 + List inProgressTasks = taskMapper.selectList( + new LambdaQueryWrapper() + .eq(Task::getStatus, "in_progress") + .isNotNull(Task::getReceiverId) + ); + + log.info("当前进行中的任务数量: {}", inProgressTasks.size()); + + int notUpdatedCount = 0; + int updatedCount = 0; + int notificationSentCount = 0; + + for (Task task : inProgressTasks) { + try { + // 检查今天是否已经有记录 + TaskProgressUpdateLog existingLog = progressUpdateLogMapper.selectOne( + new LambdaQueryWrapper() + .eq(TaskProgressUpdateLog::getTaskId, task.getId()) + .eq(TaskProgressUpdateLog::getCheckDate, today) + ); + + if (existingLog != null) { + log.debug("任务 {} 今天已检查过,跳过", task.getTaskNo()); + continue; + } + + // 检查任务进度是否在今天更新过 + boolean updatedToday = task.getUpdateTime() != null + && task.getUpdateTime().isAfter(todayStart); + + // 创建检查记录 + // 获取接单人姓名 + String receiverName = null; + if (task.getReceiverId() != null) { + try { + var receiver = userService.getUserById(task.getReceiverId()); + if (receiver != null) { + receiverName = receiver.getRealName() != null ? receiver.getRealName() : receiver.getUsername(); + } + } catch (Exception e) { + log.warn("获取接单人姓名失败: receiverId={}", task.getReceiverId()); + } + } + + TaskProgressUpdateLog log = new TaskProgressUpdateLog(); + log.setTaskId(task.getId()); + log.setTaskNo(task.getTaskNo()); + log.setTaskTitle(task.getTitle()); + log.setReceiverId(task.getReceiverId()); + log.setReceiverName(receiverName); + log.setCheckDate(today); + log.setIsUpdated(updatedToday ? 1 : 0); + log.setLastUpdateTime(task.getUpdateTime()); + log.setProgressBefore(task.getProgress()); + log.setTaskStatus(task.getStatus()); + log.setCreateTime(now); + + if (updatedToday) { + updatedCount++; + log.setProgressAfter(task.getProgress()); + log.setRemark("今日已更新进度"); + log.setNotificationSent(0); // 已更新,不需要发送通知 + } else { + notUpdatedCount++; + log.setRemark("今日未更新进度,已发送提醒通知"); + + // 发送通知给接单人 + if (task.getReceiverId() != null) { + try { + notificationService.sendTaskNotification( + task.getReceiverId(), + task.getId(), + String.format("任务【%s】需要更新进度", task.getTitle()), + String.format("您接收的任务【%s】(编号:%s)今天还未更新进度,请及时更新任务进展情况。", + task.getTitle(), task.getTaskNo()) + ); + log.setNotificationSent(1); + log.setNotificationTime(LocalDateTime.now()); + notificationSentCount++; + this.log.debug("已向用户 {} 发送进度更新提醒", task.getReceiverId()); + } catch (Exception e) { + this.log.error("发送进度更新通知失败: taskId={}, receiverId={}", + task.getId(), task.getReceiverId(), e); + log.setNotificationSent(0); + log.setRemark("今日未更新进度,通知发送失败: " + e.getMessage()); + } + } + } + + // 保存检查记录 + progressUpdateLogMapper.insert(log); + + } catch (Exception e) { + this.log.error("检查任务进度失败: taskId={}, taskNo={}", + task.getId(), task.getTaskNo(), e); + } + } + + log.info("=== 每日进度更新检查完成 ==="); + log.info("总任务数: {}, 已更新: {}, 未更新: {}, 通知已发送: {}", + inProgressTasks.size(), updatedCount, notUpdatedCount, notificationSentCount); + } + + /** + * 手动触发进度检查(用于测试或管理员手动触发) + */ + public void manualCheckProgressUpdate() { + log.info("手动触发进度更新检查"); + checkDailyProgressUpdate(); + } + + /** + * 获取指定日期的进度更新记录 + */ + public List getProgressUpdateLogs(LocalDate date, Integer isUpdated) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(TaskProgressUpdateLog::getCheckDate, date); + if (isUpdated != null) { + wrapper.eq(TaskProgressUpdateLog::getIsUpdated, isUpdated); + } + wrapper.orderByDesc(TaskProgressUpdateLog::getCreateTime); + return progressUpdateLogMapper.selectList(wrapper); + } + + /** + * 获取某个用户的进度更新记录 + */ + public List getUserProgressUpdateLogs(Long userId, LocalDate startDate, LocalDate endDate) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(TaskProgressUpdateLog::getReceiverId, userId); + if (startDate != null) { + wrapper.ge(TaskProgressUpdateLog::getCheckDate, startDate); + } + if (endDate != null) { + wrapper.le(TaskProgressUpdateLog::getCheckDate, endDate); + } + wrapper.orderByDesc(TaskProgressUpdateLog::getCheckDate); + return progressUpdateLogMapper.selectList(wrapper); + } +} diff --git a/backend/src/main/java/com/designhub/service/TaskService.java b/backend/src/main/java/com/designhub/service/TaskService.java new file mode 100644 index 0000000..b3bb651 --- /dev/null +++ b/backend/src/main/java/com/designhub/service/TaskService.java @@ -0,0 +1,1723 @@ +package com.designhub.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.designhub.common.ResultCode; +import com.designhub.dto.TaskAuditRequest; +import com.designhub.dto.TaskAssignRequest; +import com.designhub.dto.TaskUpdateDTO; +import com.designhub.entity.*; +import com.designhub.exception.BusinessException; +import com.designhub.mapper.TaskAuditLogMapper; +import com.designhub.mapper.TaskAssignLogMapper; +import com.designhub.mapper.TaskMapper; +import com.designhub.vo.DashboardStatsVO; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.format.DateTimeFormatter; +import java.util.Arrays; +import java.util.List; +import java.util.Random; +import java.util.Set; +import java.util.stream.Collectors; +import org.springframework.util.StringUtils; + +/** + * 任务服务类 + */ +@Service +@RequiredArgsConstructor +public class TaskService { + private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(TaskService.class); + + private final TaskMapper taskMapper; + private final TaskAuditLogMapper taskAuditLogMapper; + private final TaskAssignLogMapper taskAssignLogMapper; + private final TeamScorePoolService teamScorePoolService; + private final ConfigService configService; + private final DelayPenaltyService delayPenaltyService; + private final UserService userService; + private final DepartmentService departmentService; + private final RoleService roleService; + private final NotificationService notificationService; + private final com.designhub.mapper.TaskItemMapper taskItemMapper; + + /** + * 创建任务 + */ + @Transactional(rollbackFor = Exception.class) + public Long createTask(Task task) { + log.info("创建任务: {}", task.getTitle()); + + // 验证用户权限:只有需求部门可以发起任务(管理员除外) + if (task.getPublisherId() != null) { + var user = userService.getUserById(task.getPublisherId()); + if (user == null) { + throw new BusinessException(ResultCode.FORBIDDEN, "用户不存在"); + } + + // 检查是否是管理员(管理员可以同时拥有需求部门和接单部门权限) + boolean isAdmin = isAdminUser(user.getId()); + + if (!isAdmin) { + if (user.getDeptId() == null) { + throw new BusinessException(ResultCode.FORBIDDEN, "用户部门信息不完整"); + } + + var department = departmentService.getDepartmentById(user.getDeptId()); + if (department == null) { + throw new BusinessException(ResultCode.FORBIDDEN, "用户部门不存在"); + } + + if (!"demand".equals(department.getDeptType())) { + throw new BusinessException(ResultCode.FORBIDDEN, "只有需求部门可以发起任务,您所在的部门是接单部门"); + } + } + + // 设置发布人部门ID + if (task.getPublisherDeptId() == null && user.getDeptId() != null) { + task.setPublisherDeptId(user.getDeptId()); + } + } + + // 验证任务类型并计算积分(支持多类型) + if (task.getQuantity() != null) { + // 优先处理多类型 + if (task.getTaskTypeIds() != null && !task.getTaskTypeIds().isEmpty()) { + String[] typeIds = task.getTaskTypeIds().split(","); + + // 验证所有类型ID有效 + for (String typeIdStr : typeIds) { + Long typeId = Long.parseLong(typeIdStr.trim()); + var demandType = configService.getDemandTypeById(typeId); + if (demandType == null) { + throw new BusinessException(ResultCode.PARAM_ERROR, "任务类型ID无效: " + typeId); + } + } + + // 计算总积分和工时:取第一个类型的单价 + Long firstTypeId = Long.parseLong(typeIds[0].trim()); + var firstType = configService.getDemandTypeById(firstTypeId); + int totalScore = firstType.getUnitScore() * task.getQuantity(); + int totalHours = firstType.getEstimatedHours() * task.getQuantity(); + + task.setScore(totalScore); + task.setEstimatedHours(BigDecimal.valueOf(totalHours)); + + // 兼容性:同时保存第一个类型到task_type_id + task.setTaskTypeId(firstTypeId); + + log.info("创建多类型任务: typeIds={}, score={}", task.getTaskTypeIds(), totalScore); + } + // 兼容单类型模式 + else if (task.getTaskTypeId() != null) { + var demandType = configService.getDemandTypeById(task.getTaskTypeId()); + if (demandType == null) { + throw new BusinessException(ResultCode.PARAM_ERROR, "任务类型不存在"); + } + + // 计算总积分和工时 + int totalScore = demandType.getUnitScore() * task.getQuantity(); + int totalHours = demandType.getEstimatedHours() * task.getQuantity(); + + task.setScore(totalScore); + task.setEstimatedHours(BigDecimal.valueOf(totalHours)); + + // 自动填充taskTypeIds字段 + task.setTaskTypeIds(String.valueOf(task.getTaskTypeId())); + + log.info("创建单类型任务: typeId={}, score={}", task.getTaskTypeId(), totalScore); + } else { + throw new BusinessException(ResultCode.PARAM_ERROR, "请选择任务类型"); + } + } + + // 检查部门积分是否充足 + if (task.getPublisherDeptId() != null && task.getScore() != null) { + if (!teamScorePoolService.checkBalance(task.getPublisherDeptId(), task.getScore())) { + throw new BusinessException(ResultCode.INSUFFICIENT_SCORE, "部门积分余额不足"); + } + + // 检查周期消耗限制 + if (!teamScorePoolService.checkPeriodLimit(task.getPublisherDeptId(), task.getScore())) { + throw new BusinessException(ResultCode.SCORE_LIMIT_EXCEEDED, "超过本周期积分消耗限制"); + } + } + + // 生成任务编号 + task.setTaskNo(generateTaskNo()); + + // 生成第三方平台任务编号 + task.setThirdPartyTaskNo(generateThirdPartyTaskNo()); + + // 设置任务状态:默认为草稿,需要审核 + task.setStatus("draft"); + + // 设置审核状态:初始为草稿状态 + task.setAuditStatus("draft"); + + // 设置进度为0 + task.setProgress(0); + + // 设置紧急标识默认值 + if (task.getIsUrgent() == null) { + task.setIsUrgent(0); + } + + // 设置延误标识默认值 + if (task.getIsDelayed() == null) { + task.setIsDelayed(0); + } + + // 【两级审核流程】初始化审核状态 + initApprovalStatus(task); + + // 保存任务(草稿状态不扣除积分) + taskMapper.insert(task); + + // 注意:草稿状态不扣除积分,只有审核通过后才扣除 + // 积分扣除将在审核通过时进行 + + // 【两级审核流程】发送审核通知 + sendApprovalNotification(task); + + return task.getId(); + } + + /** + * 创建流水线任务 + */ + @Transactional(rollbackFor = Exception.class) + public Long createPipelineTask(com.designhub.dto.PipelineTaskDTO dto, Long publisherId) { + log.info("创建流水线任务: {}, 模式: {}", dto.getTitle(), dto.getPipelineMode()); + + // 验证用户权限 + var user = userService.getUserById(publisherId); + if (user == null) { + throw new BusinessException(ResultCode.FORBIDDEN, "用户不存在"); + } + + // 检查是否是管理员 + boolean isAdmin = isAdminUser(user.getId()); + + if (!isAdmin) { + if (user.getDeptId() == null) { + throw new BusinessException(ResultCode.FORBIDDEN, "用户部门信息不完整"); + } + + var department = departmentService.getDepartmentById(user.getDeptId()); + if (department == null) { + throw new BusinessException(ResultCode.FORBIDDEN, "用户部门不存在"); + } + + if (!"demand".equals(department.getDeptType())) { + throw new BusinessException(ResultCode.FORBIDDEN, "只有需求部门可以发起任务"); + } + } + + // 验证流水线模式 + if (!"sequential".equals(dto.getPipelineMode()) && !"parallel".equals(dto.getPipelineMode())) { + throw new BusinessException(ResultCode.PARAM_ERROR, "流水线模式必须是 sequential 或 parallel"); + } + + // 验证子任务列表 + if (dto.getItems() == null || dto.getItems().isEmpty()) { + throw new BusinessException(ResultCode.PARAM_ERROR, "流水线任务至少需要一个子任务"); + } + + // 计算总积分和工时 + int totalScore = 0; + double totalHours = 0; + for (com.designhub.dto.PipelineTaskDTO.TaskItemDTO itemDto : dto.getItems()) { + var demandType = configService.getDemandTypeById(itemDto.getTaskTypeId()); + if (demandType == null) { + throw new BusinessException(ResultCode.PARAM_ERROR, "任务类型ID无效: " + itemDto.getTaskTypeId()); + } + + int itemScore = demandType.getUnitScore() * itemDto.getQuantity(); + double itemHours = demandType.getEstimatedHours() * itemDto.getQuantity(); + + totalScore += itemScore; + totalHours += itemHours; + } + + // 检查部门积分是否充足 + if (user.getDeptId() != null) { + if (!teamScorePoolService.checkBalance(user.getDeptId(), totalScore)) { + throw new BusinessException(ResultCode.INSUFFICIENT_SCORE, "部门积分余额不足"); + } + + if (!teamScorePoolService.checkPeriodLimit(user.getDeptId(), totalScore)) { + throw new BusinessException(ResultCode.SCORE_LIMIT_EXCEEDED, "超过本周期积分消耗限制"); + } + } + + // 创建主任务 + Task task = new Task(); + task.setTaskNo(generateTaskNo()); + task.setThirdPartyTaskNo(generateThirdPartyTaskNo()); + task.setTitle(dto.getTitle()); + task.setPublisherId(publisherId); + task.setPublisherDeptId(user.getDeptId()); + task.setIsPipeline(1); + task.setPipelineMode(dto.getPipelineMode()); + task.setScore(totalScore); + task.setEstimatedHours(BigDecimal.valueOf(totalHours)); + task.setBrandId(dto.getBrandId()); + task.setCategoryId(dto.getCategoryId()); + task.setPlatformId(dto.getPlatformId()); + task.setSpuInfo(dto.getSpuInfo()); + task.setTaskSubType(dto.getTaskSubType()); + task.setAttachmentUrl(dto.getAttachmentUrl()); + task.setRemark(dto.getRemark()); + task.setPriority(dto.getPriority() != null ? dto.getPriority() : 1); // 默认优先级:1-普通 + + // 设置时间字段 + task.setCreateTime(LocalDateTime.now()); + // 如果前端没有传递截止时间,默认为创建时间+7天 + if (dto.getDeadline() != null) { + task.setDeadline(dto.getDeadline()); + } else { + task.setDeadline(LocalDateTime.now().plusDays(7)); + } + + // 设置任务类型ID(使用第一个任务类型作为主任务类型) + if (dto.getItems() != null && !dto.getItems().isEmpty()) { + task.setTaskTypeId(dto.getItems().get(0).getTaskTypeId()); + // 设置所有任务类型ID(逗号分隔) + String taskTypeIds = dto.getItems().stream() + .map(item -> String.valueOf(item.getTaskTypeId())) + .collect(Collectors.joining(",")); + task.setTaskTypeIds(taskTypeIds); + } + + task.setStatus("draft"); + task.setAuditStatus("draft"); + task.setProgress(0); + task.setIsUrgent(0); + task.setIsDelayed(0); + // 设置发布类型(从前端传递,默认为recruitment) + task.setReleaseType(dto.getReleaseType() != null ? dto.getReleaseType() : "recruitment"); + // 如果是assigned模式,设置接收人 + if ("assigned".equals(dto.getReleaseType()) && dto.getReceiverId() != null) { + task.setReceiverId(dto.getReceiverId()); + // 获取接收人的部门ID + User receiver = userService.getUserById(dto.getReceiverId()); + if (receiver != null && receiver.getDeptId() != null) { + task.setReceiverDeptId(receiver.getDeptId()); + } + } + + // 【两级审核流程】初始化审核状态 + initApprovalStatus(task); + + taskMapper.insert(task); + log.info("主任务创建成功,ID: {}, 任务编号: {}", task.getId(), task.getTaskNo()); + + // **步骤1:按接单部门自动分组** + // 遍历任务类型列表,根据接单部门分组 + int currentGroupNo = 1; + String lastAllowedDeptIds = null; + + for (int i = 0; i < dto.getItems().size(); i++) { + com.designhub.dto.PipelineTaskDTO.TaskItemDTO itemDto = dto.getItems().get(i); + var demandType = configService.getDemandTypeById(itemDto.getTaskTypeId()); + + // 获取当前任务类型的接单部门配置 + String currentAllowedDeptIds = demandType.getAllowedDeptIds(); + + // 判断是否需要创建新分组 + boolean needNewGroup = false; + if (i > 0) { + // 如果当前或上一个的 allowed_dept_ids 为 null,则必须分组 + // 因为 null 表示所有部门可接单,无法确定具体接单部门 + if (currentAllowedDeptIds == null || lastAllowedDeptIds == null) { + needNewGroup = true; + } + // 如果两者都不为 null,则比较是否相同 + else if (!currentAllowedDeptIds.equals(lastAllowedDeptIds)) { + needNewGroup = true; + } + } + + if (needNewGroup) { + currentGroupNo++; + log.info("检测到接单部门变化,创建新分组: groupNo={}, 前一部门={}, 当前部门={}", + currentGroupNo, lastAllowedDeptIds, currentAllowedDeptIds); + } + + // 记录当前分组号(临时存储在sortOrder字段,稍后会恢复) + itemDto.setSortOrder(currentGroupNo * 1000 + i); // 高位存group_no,低位存原始顺序 + lastAllowedDeptIds = currentAllowedDeptIds; + } + + log.info("分组完成,共 {} 个子任务组", currentGroupNo); + + // **步骤2:创建子任务,按分组设置状态** + for (int i = 0; i < dto.getItems().size(); i++) { + com.designhub.dto.PipelineTaskDTO.TaskItemDTO itemDto = dto.getItems().get(i); + + var demandType = configService.getDemandTypeById(itemDto.getTaskTypeId()); + + // 解析分组号 + int itemGroupNo = itemDto.getSortOrder() / 1000; + int itemIndex = itemDto.getSortOrder() % 1000; + + com.designhub.entity.TaskItem item = new com.designhub.entity.TaskItem(); + item.setTaskId(task.getId()); + item.setItemNo(generateTaskItemNo(task.getTaskNo(), i + 1)); + item.setGroupNo(itemGroupNo); + item.setTaskTypeId(itemDto.getTaskTypeId()); + item.setQuantity(itemDto.getQuantity()); + item.setUnitScore(demandType.getUnitScore()); + item.setTotalScore(demandType.getUnitScore() * itemDto.getQuantity()); + item.setEstimatedHours(BigDecimal.valueOf(demandType.getEstimatedHours() * itemDto.getQuantity())); + item.setSortOrder(i + 1); // 恢复真实的排序号 + item.setProgress(0); + + // 关键:根据流水线模式和分组设置初始状态 + if ("sequential".equals(dto.getPipelineMode())) { + if (itemGroupNo == 1) { + // 顺序流水线:第一组的所有子任务都是 pending(可接单) + item.setStatus("pending"); + item.setIsLocked(0); + item.setPrevItemId(null); + } else { + // 其他组的子任务都是 locked(锁定) + item.setStatus("locked"); + item.setIsLocked(1); + } + } else { + // 并行流水线:所有子任务都是 pending(可接单) + item.setStatus("pending"); + item.setIsLocked(0); + } + + taskItemMapper.insert(item); + log.info("子任务创建成功,ID: {}, 编号: {}, 组号: {}, 状态: {}", + item.getId(), item.getItemNo(), item.getGroupNo(), item.getStatus()); + } + + log.info("流水线任务创建完成,主任务ID: {}, 子任务数: {}, 分组数: {}", + task.getId(), dto.getItems().size(), currentGroupNo); + + return task.getId(); + } + + /** + * 生成子任务编号 + */ + private String generateTaskItemNo(String taskNo, int itemIndex) { + return taskNo + "-" + String.format("%02d", itemIndex); + } + + /** + * 生成任务编号 + */ + private String generateTaskNo() { + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMdd"); + String date = LocalDateTime.now().format(formatter); + String random = String.format("%04d", new Random().nextInt(10000)); + return "TASK" + date + random; + } + + /** + * 生成第三方平台任务编号 + * 格式:THIRD-yyyyMMddHHmmss-随机数 + */ + private String generateThirdPartyTaskNo() { + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); + String datetime = LocalDateTime.now().format(formatter); + String random = String.format("%06d", new Random().nextInt(1000000)); + return "THIRD-" + datetime + "-" + random; + } + + /** + * 获取任务列表 + */ + public IPage getTaskList(Integer page, Integer pageSize, String status, Long taskTypeId) { + Page pageParam = new Page<>(page, pageSize); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + + // 根据状态筛选 + if (StringUtils.hasText(status)) { + wrapper.eq(Task::getStatus, status); + } + + // 根据任务类型筛选 + if (taskTypeId != null) { + wrapper.eq(Task::getTaskTypeId, taskTypeId); + } + + // 按更新时间倒序 + wrapper.orderByDesc(Task::getUpdateTime); + + return taskMapper.selectPage(pageParam, wrapper); + } + + /** + * 获取所有待审核任务(处于审批流程中的任务) + * 包括:pending_demand_approval 和 pending_receiving_approval 状态的任务 + */ + public IPage getAllPendingApprovalTasks(Integer page, Integer pageSize, Long taskTypeId) { + Page pageParam = new Page<>(page, pageSize); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + + // 查询处于审批流程中的任务 + wrapper.in(Task::getApprovalStatus, "pending_demand_approval", "pending_receiving_approval"); + + // 根据任务类型筛选 + if (taskTypeId != null) { + wrapper.eq(Task::getTaskTypeId, taskTypeId); + } + + // 未删除的任务 + wrapper.eq(Task::getDeleted, 0); + + // 按创建时间倒序 + wrapper.orderByDesc(Task::getCreateTime); + + IPage result = taskMapper.selectPage(pageParam, wrapper); + + // 填充关联信息 + for (Task task : result.getRecords()) { + // 填充发布人名字 + if (task.getPublisherId() != null) { + User publisher = userService.getUserById(task.getPublisherId()); + if (publisher != null) { + task.setPublisherName(publisher.getRealName() != null ? publisher.getRealName() : publisher.getUsername()); + } + } + // 填充接收人名字 + if (task.getReceiverId() != null) { + User receiver = userService.getUserById(task.getReceiverId()); + if (receiver != null) { + task.setReceiverName(receiver.getRealName() != null ? receiver.getRealName() : receiver.getUsername()); + } + } + // 填充当前审核人姓名 + if (task.getCurrentApproverId() != null) { + User approver = userService.getUserById(task.getCurrentApproverId()); + if (approver != null) { + task.setCurrentApproverName(approver.getRealName() != null ? approver.getRealName() : approver.getUsername()); + } + } + } + + return result; + } + + /** + * 获取已完成任务列表(根据角色权限) + * - 个人用户:只看自己接的已完成任务 + * - 部门负责人:看本部门所有已完成任务 + * - 管理员:看所有已完成任务 + */ + public IPage getCompletedTasks(Integer page, Integer pageSize, + Long userId, String keyword, String sortBy) { + // 1. 获取用户信息和角色 + User user = userService.getUserById(userId); + if (user == null) { + throw new BusinessException("用户不存在"); + } + + boolean isAdmin = isAdminUser(userId); + boolean isDeptHead = isDeptHeadUser(userId); + + // 2. 构建查询条件 + Page pageParam = new Page<>(page, pageSize); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + + // 状态必须是已完成 + wrapper.eq(Task::getStatus, "completed"); + + // 3. 根据角色筛选数据范围 + if (isAdmin) { + // 管理员:查看所有 + log.info("管理员查看所有已完成任务"); + } else if (isDeptHead) { + // 部门负责人:查看本部门的 + wrapper.and(w -> w + .eq(Task::getPublisherDeptId, user.getDeptId()) // 本部门发起的 + .or() + .eq(Task::getReceiverDeptId, user.getDeptId()) // 或本部门接的 + ); + log.info("部门负责人查看部门已完成任务: deptId={}", user.getDeptId()); + } else { + // 普通用户:只看自己接的 + wrapper.eq(Task::getReceiverId, userId); + log.info("个人用户查看个人已完成任务: userId={}", userId); + } + + // 4. 关键词搜索 + if (org.springframework.util.StringUtils.hasText(keyword)) { + wrapper.and(w -> w + .like(Task::getTitle, keyword) + .or() + .like(Task::getTaskNo, keyword) + ); + } + + // 5. 排序 + if ("score".equals(sortBy)) { + wrapper.orderByDesc(Task::getScore); + } else { + wrapper.orderByDesc(Task::getCompleteTime); // 默认按完成时间降序 + } + + // 6. 查询并填充用户名 + IPage result = taskMapper.selectPage(pageParam, wrapper); + for (Task task : result.getRecords()) { + // 填充发布人名字 + if (task.getPublisherId() != null) { + User publisher = userService.getUserById(task.getPublisherId()); + if (publisher != null) { + task.setPublisherName(publisher.getRealName()); + } + } + // 填充接收人名字 + if (task.getReceiverId() != null) { + User receiver = userService.getUserById(task.getReceiverId()); + if (receiver != null) { + task.setReceiverName(receiver.getRealName()); + } + } + // 填充当前审核人姓名 + if (task.getCurrentApproverId() != null) { + User approver = userService.getUserById(task.getCurrentApproverId()); + if (approver != null) { + task.setCurrentApproverName(approver.getRealName() != null ? approver.getRealName() : approver.getUsername()); + } + } + } + return result; + } + + /** + * 获取任务大厅 + */ + public IPage getTaskHall(Integer page, Integer pageSize, Long taskTypeId, Long demandTypeId, + Long brandId, Long categoryId, Long platformId, String taskSubType, + Integer minScore, Integer maxScore, String keyword, String publisherName, + String timeRange, String sortBy) { + Page pageParam = new Page<>(page, pageSize); + return taskMapper.selectTaskHall(pageParam, taskTypeId, demandTypeId, brandId, categoryId, + platformId, taskSubType, minScore, maxScore, keyword, + publisherName, timeRange, sortBy); + } + + /** + * 获取我发布的任务 + */ + public IPage getMyPublishedTasks(Integer page, Integer pageSize, Long userId, String status) { + Page pageParam = new Page<>(page, pageSize); + return taskMapper.selectMyPublishedTasks(pageParam, userId, status); + } + + /** + * 获取我接收的任务 + */ + public IPage getMyReceivedTasks(Integer page, Integer pageSize, Long userId, String status) { + Page pageParam = new Page<>(page, pageSize); + return taskMapper.selectMyReceivedTasks(pageParam, userId, status); + } + + /** + * 获取任务详情 + */ + public Task getTaskById(Long id) { + Task task = taskMapper.selectById(id); + if (task == null) { + throw new BusinessException(ResultCode.TASK_NOT_EXIST); + } + return task; + } + + /** + * 更新任务 + */ + @Transactional(rollbackFor = Exception.class) + public void updateTask(Long id, Task task) { + Task existTask = getTaskById(id); + // TODO: 验证权限 + task.setId(id); + taskMapper.updateById(task); + } + + /** + * 抢单 + */ + @Transactional(rollbackFor = Exception.class) + public void grabTask(Long taskId, Long userId) { + log.info("用户 {} 抢单任务 {}", userId, taskId); + + Task task = getTaskById(taskId); + + if (!"pending".equals(task.getStatus())) { + throw new BusinessException(ResultCode.TASK_STATUS_ERROR, "任务不是待接单状态"); + } + if (task.getReceiverId() != null) { + throw new BusinessException(ResultCode.TASK_ALREADY_TAKEN); + } + if (!"recruitment".equalsIgnoreCase(task.getReleaseType())) { + throw new BusinessException(ResultCode.FORBIDDEN, "该任务不支持抢单"); + } + + // 验证用户权限:只有接单部门可以接任务(管理员除外) + var user = userService.getUserById(userId); + if (user == null) { + throw new BusinessException(ResultCode.FORBIDDEN, "用户不存在"); + } + + // 检查是否是管理员(管理员可以同时拥有需求部门和接单部门权限) + boolean isAdmin = isAdminUser(userId); + + Long deptId = user.getDeptId(); + Department department = deptId != null ? departmentService.getDepartmentById(deptId) : null; + if (department == null) { + throw new BusinessException(ResultCode.FORBIDDEN, "未找到用户所属部门,请联系管理员"); + } + if (!"receiving".equals(department.getDeptType())) { + throw new BusinessException(ResultCode.FORBIDDEN, "只有接单部门可以接收任务"); + } + if (!isAdmin && deptId == null) { + throw new BusinessException(ResultCode.FORBIDDEN, "用户部门信息不完整"); + } + if (task.getDemandTypeId() != null) { + validateAllowedDemandTypes(department, task.getDemandTypeId()); + } + + LocalDateTime now = LocalDateTime.now(); + LocalDateTime deadline = task.getDeadline(); + if (deadline == null && task.getEstimatedHours() != null) { + int hours = task.getEstimatedHours().intValue(); + if (hours > 0) { + deadline = now.plusHours(hours); + } + } + + int updated = taskMapper.grabTask(taskId, userId, deptId, now, deadline); + if (updated == 0) { + throw new BusinessException(ResultCode.TASK_ALREADY_TAKEN, "任务已被其他设计师抢走"); + } + + // TODO: 发送通知 + } + + /** + * 提交任务 + */ + @Transactional(rollbackFor = Exception.class) + public void submitTask(Long taskId, Long userId, String deliveryPath) { + log.info("用户 {} 提交任务 {}", userId, taskId); + + Task task = getTaskById(taskId); + + // 验证权限(必须是任务接收人) + if (!userId.equals(task.getReceiverId())) { + throw new BusinessException(ResultCode.FORBIDDEN, "只有任务接收人可以提交"); + } + + // 验证任务状态:允许 in_progress 或 rejected(驳回后重新提交) + if (!"in_progress".equals(task.getStatus()) && !"rejected".equals(task.getStatus())) { + throw new BusinessException(ResultCode.TASK_STATUS_ERROR, "任务状态不允许提交,当前状态:" + task.getStatus()); + } + + // 更新任务 + task.setDeliveryPath(deliveryPath); + task.setStatus("review"); + task.setCompleteTime(LocalDateTime.now()); + task.setProgress(100); + + taskMapper.updateById(task); + + // TODO: 发送通知给发布人 + } + + /** + * 审核任务 + */ + @Transactional(rollbackFor = Exception.class) + public void reviewTask(Long taskId, Long userId, Boolean approved, String comment, Integer rating) { + log.info("用户 {} 审核任务 {}, 结果: {}", userId, taskId, approved); + + Task task = getTaskById(taskId); + + // 验证权限(必须是任务发布人) + if (!userId.equals(task.getPublisherId())) { + throw new BusinessException(ResultCode.FORBIDDEN, "只有任务发布人可以审核"); + } + + // 验证任务状态 + if (!"review".equals(task.getStatus())) { + throw new BusinessException(ResultCode.TASK_STATUS_ERROR, "任务不是待审核状态"); + } + + if (approved) { + // 审核通过 + task.setStatus("completed"); + task.setReviewComment(comment); + task.setRating(rating); + + // 设置完成时间(如果还没有设置) + if (task.getCompleteTime() == null) { + task.setCompleteTime(LocalDateTime.now()); + } + + // 检查是否延误并计算扣分 + if (task.getDeadline() != null && task.getCompleteTime() != null + && task.getCompleteTime().isAfter(task.getDeadline())) { + task.setIsDelayed(1); + + // 计算延误天数 + long delayDays = java.time.temporal.ChronoUnit.DAYS.between( + task.getDeadline(), task.getCompleteTime()); + task.setDelayDays((int) delayDays); + } + + // 计算延误扣分(移到外层作用域) + DelayPenaltyRecord delayRecord = null; + if (task.getCompleteTime() != null && task.getDeadline() != null + && task.getCompleteTime().isAfter(task.getDeadline()) + && task.getReceiverId() != null) { + delayRecord = delayPenaltyService.calculateDelayPenalty( + taskId, + task.getReceiverId(), + task.getDeadline(), + task.getCompleteTime() + ); + + if (delayRecord != null) { + // 保存延误扣分记录 + delayPenaltyService.saveDelayPenaltyRecord(delayRecord); + log.info("任务延误扣分: taskId={}, designerId={}, penaltyScore={}", + taskId, task.getReceiverId(), delayRecord.getPenaltyScore()); + } + } + + // 奖励设计师所在部门积分(任务奖励) + if (task.getReceiverDeptId() != null && task.getScore() != null) { + teamScorePoolService.addScore(task.getReceiverDeptId(), task.getScore()); + + // 如果有延误扣分,从积分池中扣除 + if (delayRecord != null && delayRecord.getPenaltyScore() > 0 + && "confirmed".equals(delayRecord.getStatus())) { + try { + teamScorePoolService.deductScore(task.getReceiverDeptId(), delayRecord.getPenaltyScore()); + log.info("已扣除延误扣分: deptId={}, penaltyScore={}", + task.getReceiverDeptId(), delayRecord.getPenaltyScore()); + } catch (Exception e) { + log.error("扣除延误扣分失败: {}", e.getMessage()); + } + } + } + } else { + // 审核驳回 + task.setStatus("rejected"); + task.setRejectReason(comment); + // 驳回后设计师可以重新提交,无需更改积分 + } + + taskMapper.updateById(task); + + // TODO: 发送通知 + } + + /** + * 取消任务 + */ + @Transactional(rollbackFor = Exception.class) + public void cancelTask(Long taskId, Long userId, String reason) { + log.info("用户 {} 取消任务 {}", userId, taskId); + + Task task = getTaskById(taskId); + + // 验证权限(必须是任务发布人) + if (!userId.equals(task.getPublisherId())) { + throw new BusinessException(ResultCode.FORBIDDEN, "只有任务发布人可以取消"); + } + + // 验证任务状态(不能是已完成) + if ("completed".equals(task.getStatus())) { + throw new BusinessException(ResultCode.TASK_STATUS_ERROR, "已完成的任务不能取消"); + } + + // 更新任务状态 + task.setStatus("cancelled"); + task.setRejectReason(reason); + + taskMapper.updateById(task); + + // 返还积分(任务取消时返还给发布部门) + if (task.getPublisherDeptId() != null && task.getScore() != null) { + teamScorePoolService.addScore(task.getPublisherDeptId(), task.getScore()); + } + + // TODO: 发送通知 + } + + /** + * 检查用户是否是管理员(ADMIN或SUPER_ADMIN) + */ + private boolean isAdminUser(Long userId) { + try { + var roles = roleService.getRolesByUserId(userId); + return roles.stream().anyMatch(role -> + "ADMIN".equals(role.getRoleCode()) || + "SUPER_ADMIN".equals(role.getRoleCode()) + ); + } catch (Exception e) { + log.warn("检查用户角色失败: userId={}, error={}", userId, e.getMessage()); + return false; + } + } + + /** + * 检查用户是否是部门负责人 + */ + private boolean isDeptHeadUser(Long userId) { + try { + User user = userService.getUserById(userId); + if (user == null || user.getDeptId() == null) { + return false; + } + + Department dept = departmentService.getDepartmentById(user.getDeptId()); + if (dept == null) { + return false; + } + + // 检查是否是部门负责人 + return userId.equals(dept.getLeaderId()); + } catch (Exception e) { + log.warn("检查部门负责人失败: userId={}, error={}", userId, e.getMessage()); + return false; + } + } + + private void validateAllowedDemandTypes(Department department, Long demandTypeId) { + if (department == null || !StringUtils.hasText(department.getAllowedDemandTypes()) || demandTypeId == null) { + return; + } + Set allowed = Arrays.stream(department.getAllowedDemandTypes().split(",")) + .map(String::trim) + .filter(str -> !str.isEmpty()) + .map(Long::valueOf) + .collect(Collectors.toSet()); + if (!allowed.isEmpty() && !allowed.contains(demandTypeId)) { + throw new BusinessException(ResultCode.FORBIDDEN, "您所在部门不支持该需求类型的任务"); + } + } + + /** + * 提交任务审核 + */ + @Transactional(rollbackFor = Exception.class) + public boolean submitTaskForAudit(Long taskId, Long userId) { + log.info("提交任务审核: taskId={}, userId={}", taskId, userId); + + Task task = taskMapper.selectById(taskId); + if (task == null) { + throw new BusinessException("任务不存在"); + } + + // 检查是否是任务发布人 + if (!task.getPublisherId().equals(userId)) { + throw new BusinessException("只有任务发布人才能提交审核"); + } + + // 检查任务状态 + if (!"draft".equals(task.getAuditStatus())) { + throw new BusinessException("只有草稿状态的任务才能提交审核"); + } + + // 更新审核状态 + task.setAuditStatus("pending_audit"); + taskMapper.updateById(task); + + // 记录审核日志 + TaskAuditLog auditLog = new TaskAuditLog(); + auditLog.setTaskId(taskId); + auditLog.setAuditorId(userId); + auditLog.setAction("submit"); + auditLog.setComment("提交审核"); + taskAuditLogMapper.insert(auditLog); + + // 发送通知给部门负责人 + Department department = departmentService.getDepartmentById(task.getPublisherDeptId()); + if (department != null && department.getLeaderId() != null) { + User publisher = userService.getUserById(userId); + String publisherName = publisher != null ? + (publisher.getRealName() != null ? publisher.getRealName() : publisher.getUsername()) + : "未知用户"; + + notificationService.sendTaskNotification( + department.getLeaderId(), + taskId, + "【待审核】新任务提交审核", + String.format("%s 提交了任务《%s》,请及时审核", publisherName, task.getTitle()) + ); + log.info("已发送审核通知给负责人: leaderId={}, taskId={}", department.getLeaderId(), taskId); + } + + return true; + } + + /** + * 审核任务 + */ + @Transactional(rollbackFor = Exception.class) + public boolean auditTask(TaskAuditRequest request, Long auditorId) { + log.info("审核任务: taskId={}, result={}, auditorId={}", + request.getTaskId(), request.getResult(), auditorId); + + Task task = taskMapper.selectById(request.getTaskId()); + if (task == null) { + throw new BusinessException("任务不存在"); + } + + // 检查是否是部门负责人 + User auditor = userService.getUserById(auditorId); + if (auditor == null) { + throw new BusinessException("用户不存在"); + } + + Department publisherDept = departmentService.getDepartmentById(task.getPublisherDeptId()); + if (publisherDept == null || !auditorId.equals(publisherDept.getLeaderId())) { + throw new BusinessException("只有发起部门的负责人才能审核任务"); + } + + // 检查任务状态 + if (!"pending_audit".equals(task.getAuditStatus())) { + throw new BusinessException("只有待审核状态的任务才能审核"); + } + + // 更新审核状态 + if ("pass".equals(request.getResult())) { + task.setAuditStatus("audit_pass"); + task.setStatus("pending"); // 审核通过后变为待接单状态 + + // 审核通过后扣除部门积分 + if (task.getPublisherDeptId() != null && task.getScore() != null) { + // 检查积分是否充足 + if (!teamScorePoolService.checkBalance(task.getPublisherDeptId(), task.getScore())) { + throw new BusinessException("部门积分余额不足,无法通过审核"); + } + + // 扣除积分 + teamScorePoolService.consumeScore(task.getPublisherDeptId(), task.getScore()); + log.info("审核通过,扣除部门积分: deptId={}, score={}", task.getPublisherDeptId(), task.getScore()); + } + } else if ("reject".equals(request.getResult())) { + task.setAuditStatus("audit_reject"); + task.setStatus("draft"); // 审核驳回后回到草稿状态 + } else { + throw new BusinessException("审核结果参数错误"); + } + + task.setAuditorId(auditorId); + task.setAuditTime(LocalDateTime.now()); + task.setAuditComment(request.getComment()); + taskMapper.updateById(task); + + // 记录审核日志 + TaskAuditLog auditLog = new TaskAuditLog(); + auditLog.setTaskId(task.getId()); + auditLog.setAuditorId(auditorId); + auditLog.setAuditorName(auditor.getRealName() != null ? auditor.getRealName() : auditor.getUsername()); + auditLog.setAction(request.getResult()); + auditLog.setComment(request.getComment()); + taskAuditLogMapper.insert(auditLog); + + // 发送审核结果通知给任务发起人 + if (task.getPublisherId() != null) { + String auditorName = auditor.getRealName() != null ? auditor.getRealName() : auditor.getUsername(); + String resultText = "pass".equals(request.getResult()) ? "通过" : "驳回"; + String title = String.format("【审核%s】任务《%s》", resultText, task.getTitle()); + String content; + + if ("pass".equals(request.getResult())) { + content = String.format("%s 审核通过了您的任务《%s》,任务已发布到任务大厅", + auditorName, task.getTitle()); + if (request.getComment() != null && !request.getComment().isEmpty()) { + content += "。审核意见:" + request.getComment(); + } + } else { + content = String.format("%s 驳回了您的任务《%s》", auditorName, task.getTitle()); + if (request.getComment() != null && !request.getComment().isEmpty()) { + content += "。驳回原因:" + request.getComment(); + } + } + + notificationService.sendTaskNotification( + task.getPublisherId(), + task.getId(), + title, + content + ); + log.info("已发送审核结果通知: publisherId={}, result={}, taskId={}", + task.getPublisherId(), request.getResult(), task.getId()); + } + + return true; + } + + /** + * 强制指派任务给部门成员 + */ + @Transactional(rollbackFor = Exception.class) + public boolean assignTask(TaskAssignRequest request, Long assignerId) { + log.info("强制指派任务: taskId={}, assigneeId={}, assignerId={}", + request.getTaskId(), request.getAssigneeId(), assignerId); + + Task task = taskMapper.selectById(request.getTaskId()); + if (task == null) { + throw new BusinessException("任务不存在"); + } + + // 检查任务状态(只有待接单或招募中的任务可以指派) + if (!"pending".equals(task.getStatus())) { + throw new BusinessException("只有待接单状态的任务才能指派"); + } + + // 检查是否已有人接单 + if (task.getReceiverId() != null) { + throw new BusinessException("该任务已被接单"); + } + + // 检查指派人是否是部门负责人 + User assigner = userService.getUserById(assignerId); + if (assigner == null || assigner.getDeptId() == null) { + throw new BusinessException("用户不存在或未分配部门"); + } + + Department assignerDept = departmentService.getDepartmentById(assigner.getDeptId()); + if (assignerDept == null || !assignerId.equals(assignerDept.getLeaderId())) { + throw new BusinessException("只有部门负责人才能指派任务"); + } + + // 检查被指派人是否在该部门 + User assignee = userService.getUserById(request.getAssigneeId()); + if (assignee == null || !assigner.getDeptId().equals(assignee.getDeptId())) { + throw new BusinessException("只能指派给本部门成员"); + } + + // 更新任务 + task.setReceiverId(request.getAssigneeId()); + task.setReceiverDeptId(assignee.getDeptId()); + task.setStatus("in_progress"); + task.setStartTime(LocalDateTime.now()); + taskMapper.updateById(task); + + // 记录指派日志 + TaskAssignLog log = new TaskAssignLog(); + log.setTaskId(task.getId()); + log.setAssignerId(assignerId); + log.setAssigneeId(request.getAssigneeId()); + log.setAssignType(request.getAssignType()); + log.setComment(request.getComment()); + taskAssignLogMapper.insert(log); + + return true; + } + + /** + * 获取部门负责人的任务列表(需求部门负责人视图) + */ + public IPage getLeaderTaskList(Long userId, Integer page, Integer pageSize, String auditStatus) { + log.info("查询负责人任务列表: userId={}, auditStatus={}", userId, auditStatus); + + User user = userService.getUserById(userId); + if (user == null || user.getDeptId() == null) { + throw new BusinessException("用户不存在或未分配部门"); + } + + Department dept = departmentService.getDepartmentById(user.getDeptId()); + if (dept == null) { + throw new BusinessException("部门不存在"); + } + + // 检查是否是部门负责人 + if (!userId.equals(dept.getLeaderId())) { + throw new BusinessException("只有部门负责人才能查看该视图"); + } + + // 查询部门内所有任务 + Page pageParam = new Page<>(page, pageSize); + return taskMapper.selectLeaderTaskPage(pageParam, user.getDeptId(), auditStatus); + } + + /** + * 获取接单部门负责人的接单明细(接单部门负责人视图) + */ + public IPage getLeaderReceivingTaskList(Long userId, Integer page, Integer pageSize, String status) { + log.info("查询接单部门负责人视图: userId={}, status={}", userId, status); + + User user = userService.getUserById(userId); + if (user == null || user.getDeptId() == null) { + throw new BusinessException("用户不存在或未分配部门"); + } + + Department dept = departmentService.getDepartmentById(user.getDeptId()); + if (dept == null) { + throw new BusinessException("部门不存在"); + } + + // 检查是否是部门负责人 + if (!userId.equals(dept.getLeaderId())) { + throw new BusinessException("只有部门负责人才能查看该视图"); + } + + // 查询部门成员接单的所有任务 + Page pageParam = new Page<>(page, pageSize); + return taskMapper.selectDeptReceivingTaskPage(pageParam, user.getDeptId(), status); + } + + /** + * 获取工作台统计数据 + */ + public DashboardStatsVO getDashboardStats(Long userId, String month) { + try { + // 获取用户角色,判断是否为管理员 + List roles = roleService.getRolesByUserId(userId); + boolean isAdmin = roles.stream() + .anyMatch(role -> "ADMIN".equals(role.getRoleCode()) || "SUPER_ADMIN".equals(role.getRoleCode())); + + log.info("获取工作台统计数据: userId={}, isAdmin={}, month={}", userId, isAdmin, month); + + // 解析月份,如果不传则使用当前月份 + LocalDate targetMonth; + if (month != null && !month.isEmpty()) { + try { + targetMonth = LocalDate.parse(month + "-01", java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd")); + } catch (Exception e) { + log.warn("月份格式错误: {}, 使用当前月份", month); + targetMonth = LocalDate.now().withDayOfMonth(1); + } + } else { + targetMonth = LocalDate.now().withDayOfMonth(1); + } + + // 计算月份的开始和结束时间 + LocalDateTime monthStart = LocalDateTime.of(targetMonth, LocalTime.MIN); + LocalDateTime monthEnd = monthStart.plusMonths(1).minusSeconds(1); + + // 当前日期范围 + LocalDateTime todayStart = LocalDateTime.of(LocalDate.now(), LocalTime.MIN); + LocalDateTime todayEnd = LocalDateTime.of(LocalDate.now(), LocalTime.MAX); + + // 本周范围 + LocalDate now = LocalDate.now(); + LocalDate weekStart = now.minusDays(now.getDayOfWeek().getValue() - 1); + LocalDate weekEnd = weekStart.plusDays(6); + LocalDateTime weekStartTime = LocalDateTime.of(weekStart, LocalTime.MIN); + LocalDateTime weekEndTime = LocalDateTime.of(weekEnd, LocalTime.MAX); + + // 上周范围 + LocalDate lastWeekStart = weekStart.minusDays(7); + LocalDate lastWeekEnd = lastWeekStart.plusDays(6); + LocalDateTime lastWeekStartTime = LocalDateTime.of(lastWeekStart, LocalTime.MIN); + LocalDateTime lastWeekEndTime = LocalDateTime.of(lastWeekEnd, LocalTime.MAX); + + // 进行中的任务数 + LambdaQueryWrapper inProgressQuery = new LambdaQueryWrapper() + .eq(Task::getStatus, "in_progress"); + if (!isAdmin) { + inProgressQuery.eq(Task::getReceiverId, userId); + } + Long inProgressCount = taskMapper.selectCount(inProgressQuery); + + // 已完成的任务数 + LambdaQueryWrapper completedQuery = new LambdaQueryWrapper() + .eq(Task::getStatus, "completed"); + if (!isAdmin) { + completedQuery.eq(Task::getReceiverId, userId); + } + Long completedCount = taskMapper.selectCount(completedQuery); + + // 待审核的任务数 + LambdaQueryWrapper reviewQuery = new LambdaQueryWrapper() + .eq(Task::getStatus, "review"); + if (!isAdmin) { + reviewQuery.eq(Task::getReceiverId, userId); + } + Long reviewCount = taskMapper.selectCount(reviewQuery); + + // 今日开始的任务数 + LambdaQueryWrapper todayStartedQuery = new LambdaQueryWrapper() + .eq(Task::getStatus, "in_progress") + .ge(Task::getStartTime, todayStart) + .le(Task::getStartTime, todayEnd); + if (!isAdmin) { + todayStartedQuery.eq(Task::getReceiverId, userId); + } + Long todayStartedCount = taskMapper.selectCount(todayStartedQuery); + + // 今日完成的任务数 + LambdaQueryWrapper todayCompletedQuery = new LambdaQueryWrapper() + .eq(Task::getStatus, "completed") + .ge(Task::getCompleteTime, todayStart) + .le(Task::getCompleteTime, todayEnd); + if (!isAdmin) { + todayCompletedQuery.eq(Task::getReceiverId, userId); + } + Long todayCompletedCount = taskMapper.selectCount(todayCompletedQuery); + + // 本周完成的任务数 + LambdaQueryWrapper weekCompletedQuery = new LambdaQueryWrapper() + .eq(Task::getStatus, "completed") + .ge(Task::getCompleteTime, weekStartTime) + .le(Task::getCompleteTime, weekEndTime); + if (!isAdmin) { + weekCompletedQuery.eq(Task::getReceiverId, userId); + } + Long weekCompletedCount = taskMapper.selectCount(weekCompletedQuery); + + // 上周完成的任务数 + LambdaQueryWrapper lastWeekCompletedQuery = new LambdaQueryWrapper() + .eq(Task::getStatus, "completed") + .ge(Task::getCompleteTime, lastWeekStartTime) + .le(Task::getCompleteTime, lastWeekEndTime); + if (!isAdmin) { + lastWeekCompletedQuery.eq(Task::getReceiverId, userId); + } + Long lastWeekCompletedCount = taskMapper.selectCount(lastWeekCompletedQuery); + + // 可接任务数(状态为pending的任务) + LambdaQueryWrapper availableQuery = new LambdaQueryWrapper() + .eq(Task::getStatus, "pending"); + // 可接任务对所有人都是全局的,不需要过滤用户 + Long availableCount = taskMapper.selectCount(availableQuery); + + // 当日新增任务数 + LambdaQueryWrapper todayNewQuery = new LambdaQueryWrapper() + .ge(Task::getCreateTime, todayStart) + .le(Task::getCreateTime, todayEnd); + if (!isAdmin) { + // 普通用户看自己相关的任务(作为发起人或接单人) + todayNewQuery.and(wrapper -> wrapper + .eq(Task::getPublisherId, userId) + .or() + .eq(Task::getReceiverId, userId)); + } + Long todayNewCount = taskMapper.selectCount(todayNewQuery); + + // 获取总积分 + LambdaQueryWrapper scoreQuery = new LambdaQueryWrapper() + .eq(Task::getStatus, "completed") + .select(Task::getScoreReward); + if (!isAdmin) { + scoreQuery.eq(Task::getReceiverId, userId); + } + List completedTasks = taskMapper.selectList(scoreQuery); + + int totalScore = completedTasks.stream() + .filter(t -> t.getScoreReward() != null) + .mapToInt(t -> t.getScoreReward().intValue()) + .sum(); + + // 获取当前用户当月发布的任务数 + LambdaQueryWrapper publishedQuery = new LambdaQueryWrapper() + .eq(Task::getPublisherId, userId) + .ge(Task::getCreateTime, monthStart) + .le(Task::getCreateTime, monthEnd) + .eq(Task::getDeleted, 0); + Long publishedCount = taskMapper.selectCount(publishedQuery); + + DashboardStatsVO result = DashboardStatsVO.builder() + .availableCount(availableCount.intValue()) + .publishedCount(publishedCount.intValue()) + .inProgressCount(inProgressCount.intValue()) + .completedCount(completedCount.intValue()) + .reviewCount(reviewCount.intValue()) + .todayNewCount(todayNewCount.intValue()) + .totalScore(totalScore) + .todayStartedCount(todayStartedCount.intValue()) + .todayCompletedCount(todayCompletedCount.intValue()) + .weekCompletedCount(weekCompletedCount.intValue()) + .lastWeekCompletedCount(lastWeekCompletedCount.intValue()) + .build(); + + log.info("工作台统计数据查询完成: userId={}, isAdmin={}, result={}", userId, isAdmin, result); + return result; + + } catch (Exception e) { + log.error("获取工作台统计数据失败: userId={}", userId, e); + // 返回空数据而不是抛异常 + return DashboardStatsVO.builder() + .availableCount(0) + .publishedCount(0) + .inProgressCount(0) + .completedCount(0) + .reviewCount(0) + .todayNewCount(0) + .totalScore(0) + .todayStartedCount(0) + .todayCompletedCount(0) + .weekCompletedCount(0) + .lastWeekCompletedCount(0) + .build(); + } + } + + /** + * 获取我的进行中任务列表 + */ + public List getMyInProgressTasks(Long userId) { + return taskMapper.selectList( + new LambdaQueryWrapper() + .eq(Task::getReceiverId, userId) + .eq(Task::getStatus, "in_progress") + .orderByDesc(Task::getStartTime) + ); + } + + /** + * 批量更新任务进度 + */ + @Transactional(rollbackFor = Exception.class) + public void batchUpdateProgress(Long userId, List> updates) { + for (java.util.Map update : updates) { + Long taskId = ((Number) update.get("taskId")).longValue(); + Integer progress = ((Number) update.get("progress")).intValue(); + String remark = (String) update.get("remark"); + + // 验证任务归属 + Task task = taskMapper.selectById(taskId); + if (task == null) { + throw new BusinessException("任务不存在: taskId=" + taskId); + } + + if (!task.getReceiverId().equals(userId)) { + throw new BusinessException("无权更新此任务进度: taskId=" + taskId); + } + + if (!"in_progress".equals(task.getStatus())) { + throw new BusinessException("只能更新进行中的任务进度: taskId=" + taskId); + } + + // 验证进度值 + if (progress < 0 || progress > 100) { + throw new BusinessException("进度值必须在0-100之间: taskId=" + taskId); + } + + Integer oldProgress = task.getProgress() != null ? task.getProgress() : 0; + if (progress < oldProgress) { + throw new BusinessException("新进度不能小于当前进度: taskId=" + taskId); + } + + // 更新进度 + Task updateTask = new Task(); + updateTask.setId(taskId); + updateTask.setProgress(progress); + updateTask.setUpdateTime(LocalDateTime.now()); + + // 如果进度达到100%,自动标记为待审核 + if (progress == 100) { + updateTask.setStatus("pending_review"); + } + + taskMapper.updateById(updateTask); + + log.info("任务进度更新成功: taskId={}, userId={}, oldProgress={}, newProgress={}, remark={}", + taskId, userId, oldProgress, progress, remark); + } + } + + /** + * 更新任务进度 + */ + @Transactional + public void updateTaskProgress(Long taskId, Long userId, Integer progress) { + log.info("更新任务进度: taskId={}, userId={}, progress={}", taskId, userId, progress); + + // 验证进度值 + if (progress < 0 || progress > 100) { + throw new BusinessException("进度值必须在0-100之间"); + } + + Task task = taskMapper.selectById(taskId); + if (task == null) { + throw new BusinessException("任务不存在"); + } + + // 验证是接单人 + if (!userId.equals(task.getReceiverId())) { + throw new BusinessException("只有接单人才能更新任务进度"); + } + + // 验证任务状态(只有进行中的任务才能更新进度) + if (!"in_progress".equals(task.getStatus())) { + throw new BusinessException("只有进行中的任务才能更新进度"); + } + + // 更新进度 + task.setProgress(progress); + task.setUpdateTime(LocalDateTime.now()); + taskMapper.updateById(task); + + log.info("任务进度更新成功: taskId={}, progress={}", taskId, progress); + } + + /** + * 完结任务(提交交付路径) + */ + @Transactional + public void completeTask(Long taskId, Long userId, String deliveryPath) { + log.info("完结任务: taskId={}, userId={}, deliveryPath={}", taskId, userId, deliveryPath); + + Task task = taskMapper.selectById(taskId); + if (task == null) { + throw new BusinessException("任务不存在"); + } + + // 验证是接单人 + if (!userId.equals(task.getReceiverId())) { + throw new BusinessException("只有接单人才能完结任务"); + } + + // 验证任务状态(只有进行中的任务才能完结) + if (!"in_progress".equals(task.getStatus())) { + throw new BusinessException("只有进行中的任务才能完结"); + } + + // 更新任务状态为待审核 + task.setStatus("review"); + task.setDeliveryPath(deliveryPath); + task.setProgress(100); // 完结时进度设为100% + task.setSubmitTime(LocalDateTime.now()); + task.setUpdateTime(LocalDateTime.now()); + taskMapper.updateById(task); + + // 发送通知给发起人 + try { + Notification notification = new Notification(); + notification.setReceiverId(task.getPublisherId()); + notification.setTitle("任务提交审核"); + notification.setContent("任务【" + task.getTitle() + "】已提交审核,请查看"); + notification.setNotificationType("TASK_REVIEW"); + notification.setRelatedType("task"); + notification.setRelatedId(taskId); + notificationService.sendNotification(notification); + } catch (Exception e) { + log.error("发送审核通知失败", e); + } + + log.info("任务已提交审核: taskId={}", taskId); + } + + /** + * 审核通过任务 + */ + @Transactional + public void approveTask(Long taskId, Long userId) { + log.info("审核通过任务: taskId={}, userId={}", taskId, userId); + + Task task = taskMapper.selectById(taskId); + if (task == null) { + throw new BusinessException("任务不存在"); + } + + // 验证权限(发起人或部门负责人) + User user = userService.getUserById(userId); + if (user == null) { + throw new BusinessException("用户不存在"); + } + + boolean isPublisher = userId.equals(task.getPublisherId()); + boolean isDeptLeader = false; + + if (user.getDeptId() != null) { + Department dept = departmentService.getDepartmentById(user.getDeptId()); + if (dept != null && userId.equals(dept.getLeaderId())) { + isDeptLeader = true; + } + } + + if (!isPublisher && !isDeptLeader) { + throw new BusinessException("只有任务发起人或部门负责人才能审核任务"); + } + + // 验证任务状态 + if (!"review".equals(task.getStatus())) { + throw new BusinessException("只有待审核的任务才能审核"); + } + + // 更新任务状态为已完成 + task.setStatus("completed"); + task.setCompleteTime(LocalDateTime.now()); + task.setUpdateTime(LocalDateTime.now()); + task.setRating(5); // 默认5分 + + // 计算积分奖励 + if (task.getScore() != null) { + task.setScoreReward(BigDecimal.valueOf(task.getScore())); + } + + taskMapper.updateById(task); + + // 发送通知给接单人 + try { + Notification notification = new Notification(); + notification.setReceiverId(task.getReceiverId()); + notification.setTitle("任务审核通过"); + notification.setContent("任务【" + task.getTitle() + "】审核通过,积分已到账"); + notification.setNotificationType("TASK_COMPLETED"); + notification.setRelatedType("task"); + notification.setRelatedId(taskId); + notificationService.sendNotification(notification); + } catch (Exception e) { + log.error("发送完成通知失败", e); + } + + log.info("任务审核通过: taskId={}", taskId); + } + + /** + * 驳回任务 + */ + @Transactional + public void rejectTask(Long taskId, Long userId, String reason) { + log.info("驳回任务: taskId={}, userId={}, reason={}", taskId, userId, reason); + + Task task = taskMapper.selectById(taskId); + if (task == null) { + throw new BusinessException("任务不存在"); + } + + // 验证权限(发起人或部门负责人) + User user = userService.getUserById(userId); + if (user == null) { + throw new BusinessException("用户不存在"); + } + + boolean isPublisher = userId.equals(task.getPublisherId()); + boolean isDeptLeader = false; + + if (user.getDeptId() != null) { + Department dept = departmentService.getDepartmentById(user.getDeptId()); + if (dept != null && userId.equals(dept.getLeaderId())) { + isDeptLeader = true; + } + } + + if (!isPublisher && !isDeptLeader) { + throw new BusinessException("只有任务发起人或部门负责人才能驳回任务"); + } + + // 验证任务状态 + if (!"review".equals(task.getStatus())) { + throw new BusinessException("只有待审核的任务才能驳回"); + } + + // 更新任务状态为进行中(驳回后重新修改) + task.setStatus("in_progress"); + task.setRejectReason(reason); + task.setUpdateTime(LocalDateTime.now()); + taskMapper.updateById(task); + + // 发送通知给接单人 + try { + Notification notification = new Notification(); + notification.setReceiverId(task.getReceiverId()); + notification.setTitle("任务被驳回"); + notification.setContent("任务【" + task.getTitle() + "】被驳回,原因:" + reason); + notification.setNotificationType("TASK_REJECTED"); + notification.setRelatedType("task"); + notification.setRelatedId(taskId); + notificationService.sendNotification(notification); + } catch (Exception e) { + log.error("发送驳回通知失败", e); + } + + log.info("任务已驳回: taskId={}, reason={}", taskId, reason); + } + + /** + * 初始化审核状态(两级审核流程) + */ + private void initApprovalStatus(Task task) { + try { + // 设置审核状态为待需求部门审核 + task.setApprovalStatus("pending_demand_approval"); + + // 确定需求部门负责人 + if (task.getPublisherDeptId() != null) { + Department demandDept = departmentService.getDepartmentById(task.getPublisherDeptId()); + if (demandDept != null && demandDept.getLeaderId() != null) { + task.setCurrentApproverId(demandDept.getLeaderId()); + task.setCurrentApproverRole("demand_leader"); + log.info("任务已设置待审核状态,等待需求部门负责人审核:leaderId={}", demandDept.getLeaderId()); + } else { + log.warn("需求部门未配置负责人,任务将直接发布:deptId={}", task.getPublisherDeptId()); + // 如果没有配置负责人,跳过审核直接发布 + task.setApprovalStatus("approved"); + task.setStatus("pending"); + } + } + } catch (Exception e) { + log.error("初始化审核状态失败", e); + // 失败时跳过审核直接发布 + task.setApprovalStatus("approved"); + task.setStatus("pending"); + } + } + + /** + * 发送审核通知(两级审核流程) + */ + private void sendApprovalNotification(Task task) { + try { + if (task.getCurrentApproverId() != null) { + User approver = userService.getUserById(task.getCurrentApproverId()); + if (approver != null) { + Notification notification = new Notification(); + notification.setReceiverId(task.getCurrentApproverId()); + notification.setTitle("新任务待审核"); + notification.setContent(String.format("任务【%s】需要您审核", task.getTitle())); + notification.setNotificationType("TASK_APPROVAL"); + notification.setRelatedType("task"); + notification.setRelatedId(task.getId()); + notificationService.sendNotification(notification); + log.info("已发送审核通知给用户:userId={}, taskId={}", task.getCurrentApproverId(), task.getId()); + } + } + } catch (Exception e) { + log.error("发送审核通知失败", e); + } + } + + /** + * 计算任务总积分(用于审核时修改) + */ + public int calculateTotalScore(List taskTypes) { + int totalScore = 0; + for (TaskUpdateDTO.TaskTypeUpdateDTO taskType : taskTypes) { + DemandType demandType = configService.getDemandTypeById(taskType.getTaskTypeId()); + if (demandType != null) { + totalScore += demandType.getUnitScore() * taskType.getQuantity(); + } + } + return totalScore; + } + + /** + * 创建流水线任务的子任务项(用于审核通过后拆分) + */ + public void createPipelineTaskItems(Task mainTask, com.designhub.dto.PipelineTaskDTO dto) { + // 委托给已有的createPipelineTask逻辑中的子任务创建部分 + // 这里简化处理,实际应该提取createPipelineTask中的子任务创建逻辑 + log.info("开始拆分流水线子任务:taskId={}, mode={}", mainTask.getId(), dto.getPipelineMode()); + + // TODO: 实现流水线子任务拆分逻辑 + // 可以参考createPipelineTask方法中的实现 + } +} + diff --git a/backend/src/main/java/com/designhub/service/TaskSubTypeService.java b/backend/src/main/java/com/designhub/service/TaskSubTypeService.java new file mode 100644 index 0000000..74cb851 --- /dev/null +++ b/backend/src/main/java/com/designhub/service/TaskSubTypeService.java @@ -0,0 +1,145 @@ +package com.designhub.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.designhub.entity.TaskSubType; +import com.designhub.exception.BusinessException; +import com.designhub.mapper.TaskSubTypeMapper; +import com.designhub.common.ResultCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * 任务子类型服务 + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class TaskSubTypeService { + + private final TaskSubTypeMapper taskSubTypeMapper; + + /** + * 获取所有子类型 + */ + public List getAllSubTypes() { + return taskSubTypeMapper.selectList( + new LambdaQueryWrapper() + .orderByAsc(TaskSubType::getSortOrder) + ); + } + + /** + * 获取启用的子类型 + */ + public List getEnabledSubTypes() { + return taskSubTypeMapper.selectList( + new LambdaQueryWrapper() + .eq(TaskSubType::getIsEnabled, 1) + .orderByAsc(TaskSubType::getSortOrder) + ); + } + + /** + * 根据ID获取子类型 + */ + public TaskSubType getById(Long id) { + TaskSubType subType = taskSubTypeMapper.selectById(id); + if (subType == null) { + throw new BusinessException(ResultCode.NOT_FOUND, "子类型不存在"); + } + return subType; + } + + /** + * 根据编码获取子类型 + */ + public TaskSubType getByCode(String code) { + return taskSubTypeMapper.selectOne( + new LambdaQueryWrapper() + .eq(TaskSubType::getCode, code) + ); + } + + /** + * 创建子类型 + */ + @Transactional(rollbackFor = Exception.class) + public TaskSubType createSubType(TaskSubType subType) { + // 验证编码唯一性 + TaskSubType existing = getByCode(subType.getCode()); + if (existing != null) { + throw new BusinessException(ResultCode.PARAM_ERROR, "子类型编码已存在"); + } + + // 设置默认值 + if (subType.getIsEnabled() == null) { + subType.setIsEnabled(1); + } + if (subType.getSortOrder() == null) { + subType.setSortOrder(0); + } + + taskSubTypeMapper.insert(subType); + log.info("创建任务子类型:{}", subType.getName()); + return subType; + } + + /** + * 更新子类型 + */ + @Transactional(rollbackFor = Exception.class) + public TaskSubType updateSubType(Long id, TaskSubType subType) { + TaskSubType existing = getById(id); + + // 如果修改了编码,验证新编码的唯一性 + if (!existing.getCode().equals(subType.getCode())) { + TaskSubType duplicate = getByCode(subType.getCode()); + if (duplicate != null && !duplicate.getId().equals(id)) { + throw new BusinessException(ResultCode.PARAM_ERROR, "子类型编码已存在"); + } + } + + subType.setId(id); + taskSubTypeMapper.updateById(subType); + log.info("更新任务子类型:{}", subType.getName()); + return getById(id); + } + + /** + * 删除子类型 + */ + @Transactional(rollbackFor = Exception.class) + public void deleteSubType(Long id) { + TaskSubType subType = getById(id); + + // TODO: 可以添加检查,确保没有任务使用此子类型 + + taskSubTypeMapper.deleteById(id); + log.info("删除任务子类型:{}", subType.getName()); + } + + /** + * 切换启用状态 + */ + @Transactional(rollbackFor = Exception.class) + public void toggleEnabled(Long id) { + TaskSubType subType = getById(id); + subType.setIsEnabled(subType.getIsEnabled() == 1 ? 0 : 1); + taskSubTypeMapper.updateById(subType); + log.info("切换任务子类型启用状态:{} -> {}", subType.getName(), subType.getIsEnabled()); + } + + /** + * 更新排序 + */ + @Transactional(rollbackFor = Exception.class) + public void updateSortOrder(Long id, Integer sortOrder) { + TaskSubType subType = getById(id); + subType.setSortOrder(sortOrder); + taskSubTypeMapper.updateById(subType); + } +} diff --git a/backend/src/main/java/com/designhub/service/TeamPKService.java b/backend/src/main/java/com/designhub/service/TeamPKService.java new file mode 100644 index 0000000..1e7c5ad --- /dev/null +++ b/backend/src/main/java/com/designhub/service/TeamPKService.java @@ -0,0 +1,455 @@ +package com.designhub.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.designhub.common.ResultCode; +import com.designhub.entity.Task; +import com.designhub.entity.TeamPK; +import com.designhub.entity.User; +import com.designhub.exception.BusinessException; +import com.designhub.mapper.TaskMapper; +import com.designhub.mapper.TeamPKMapper; +import com.designhub.mapper.UserMapper; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +/** + * 团队PK Service + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class TeamPKService { + + // 手动添加Logger以解决Lombok编译问题 + private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(TeamPKService.class); + + private final TeamPKMapper teamPKMapper; + private final UserMapper userMapper; + private final TaskMapper taskMapper; + private final NotificationService notificationService; + private final ObjectMapper objectMapper; + + /** + * 创建团队PK + */ + @Transactional + public TeamPK createTeamPK(TeamPK teamPK) { + // 验证时间 + if (teamPK.getStartTime().isAfter(teamPK.getEndTime())) { + throw new BusinessException(ResultCode.PARAM_ERROR, "开始时间不能晚于结束时间"); + } + + // 验证部门 + if (teamPK.getTeamADeptId().equals(teamPK.getTeamBDeptId())) { + throw new BusinessException(ResultCode.PARAM_ERROR, "不能与自己PK"); + } + + teamPK.setStatus("draft"); + teamPK.setTeamAScore(0); + teamPK.setTeamBScore(0); + teamPK.setTeamATaskCount(0); + teamPK.setTeamBTaskCount(0); + teamPK.setTeamAFiveStarCount(0); + teamPK.setTeamBFiveStarCount(0); + teamPK.setTeamAOnTimeRate(0); + teamPK.setTeamBOnTimeRate(0); + teamPK.setDeleted(0); + + teamPKMapper.insert(teamPK); + + log.info("创建团队PK成功: id={}, name={}", teamPK.getId(), teamPK.getName()); + return teamPK; + } + + /** + * 发布团队PK + */ + @Transactional + public TeamPK publishTeamPK(Long id) { + TeamPK teamPK = teamPKMapper.selectById(id); + if (teamPK == null) { + throw new BusinessException(ResultCode.NOT_FOUND, "PK不存在"); + } + + if (!"draft".equals(teamPK.getStatus())) { + throw new BusinessException(ResultCode.PARAM_ERROR, "只有草稿状态的PK可以发布"); + } + + LocalDateTime now = LocalDateTime.now(); + if (teamPK.getStartTime().isBefore(now)) { + teamPK.setStatus("active"); + } else { + teamPK.setStatus("scheduled"); + } + + teamPKMapper.updateById(teamPK); + + // 发送通知给双方部门成员 + notifyTeamPKStart(teamPK); + + return teamPK; + } + + /** + * 发送PK开始通知 + */ + private void notifyTeamPKStart(TeamPK teamPK) { + // 获取甲方成员 + LambdaQueryWrapper wrapperA = new LambdaQueryWrapper<>(); + wrapperA.eq(User::getDeptId, teamPK.getTeamADeptId()).eq(User::getDeleted, 0); + List teamA = userMapper.selectList(wrapperA); + + // 获取乙方成员 + LambdaQueryWrapper wrapperB = new LambdaQueryWrapper<>(); + wrapperB.eq(User::getDeptId, teamPK.getTeamBDeptId()).eq(User::getDeleted, 0); + List teamB = userMapper.selectList(wrapperB); + + String message = String.format("团队PK「%s」已开始!%s VS %s,为团队荣誉而战!", + teamPK.getName(), teamPK.getTeamAName(), teamPK.getTeamBName()); + + for (User user : teamA) { + notificationService.sendNotification(user.getId(), "团队PK开始", message, "team_pk", teamPK.getId()); + } + for (User user : teamB) { + notificationService.sendNotification(user.getId(), "团队PK开始", message, "team_pk", teamPK.getId()); + } + } + + /** + * 更新PK数据 + */ + @Transactional + public void updatePKStats(Long deptId) { + LocalDateTime now = LocalDateTime.now(); + + // 查找包含该部门的进行中的PK + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(TeamPK::getStatus, "active") + .and(w -> w.eq(TeamPK::getTeamADeptId, deptId).or().eq(TeamPK::getTeamBDeptId, deptId)); + + List activePKs = teamPKMapper.selectList(wrapper); + + for (TeamPK pk : activePKs) { + refreshPKStats(pk); + } + } + + /** + * 刷新PK统计数据 + */ + @Transactional + public void refreshPKStats(TeamPK pk) { + LocalDateTime startTime = pk.getStartTime(); + LocalDateTime endTime = pk.getEndTime().isBefore(LocalDateTime.now()) ? pk.getEndTime() : LocalDateTime.now(); + + // 统计甲方数据 + LambdaQueryWrapper wrapperA = new LambdaQueryWrapper<>(); + wrapperA.eq(Task::getStatus, "completed") + .ge(Task::getCompleteTime, startTime) + .le(Task::getCompleteTime, endTime); + // 这里需要根据接收人的部门来统计,简化处理 + + // 获取甲方成员 + LambdaQueryWrapper userWrapperA = new LambdaQueryWrapper<>(); + userWrapperA.eq(User::getDeptId, pk.getTeamADeptId()).eq(User::getDeleted, 0); + List teamAUsers = userMapper.selectList(userWrapperA); + + int teamATaskCount = 0; + int teamAFiveStarCount = 0; + int teamAOnTimeCount = 0; + int teamATotalCount = 0; + + for (User user : teamAUsers) { + LambdaQueryWrapper taskWrapper = new LambdaQueryWrapper<>(); + taskWrapper.eq(Task::getReceiverId, user.getId()) + .eq(Task::getStatus, "completed") + .ge(Task::getCompleteTime, startTime) + .le(Task::getCompleteTime, endTime); + List tasks = taskMapper.selectList(taskWrapper); + + teamATotalCount += tasks.size(); + for (Task task : tasks) { + teamATaskCount++; + if (task.getRating() != null && task.getRating() >= 5) { + teamAFiveStarCount++; + } + if (task.getCompleteTime() != null && task.getDeadline() != null + && !task.getCompleteTime().isAfter(task.getDeadline())) { + teamAOnTimeCount++; + } + } + } + + // 获取乙方成员 + LambdaQueryWrapper userWrapperB = new LambdaQueryWrapper<>(); + userWrapperB.eq(User::getDeptId, pk.getTeamBDeptId()).eq(User::getDeleted, 0); + List teamBUsers = userMapper.selectList(userWrapperB); + + int teamBTaskCount = 0; + int teamBFiveStarCount = 0; + int teamBOnTimeCount = 0; + int teamBTotalCount = 0; + + for (User user : teamBUsers) { + LambdaQueryWrapper taskWrapper = new LambdaQueryWrapper<>(); + taskWrapper.eq(Task::getReceiverId, user.getId()) + .eq(Task::getStatus, "completed") + .ge(Task::getCompleteTime, startTime) + .le(Task::getCompleteTime, endTime); + List tasks = taskMapper.selectList(taskWrapper); + + teamBTotalCount += tasks.size(); + for (Task task : tasks) { + teamBTaskCount++; + if (task.getRating() != null && task.getRating() >= 5) { + teamBFiveStarCount++; + } + if (task.getCompleteTime() != null && task.getDeadline() != null + && !task.getCompleteTime().isAfter(task.getDeadline())) { + teamBOnTimeCount++; + } + } + } + + pk.setTeamATaskCount(teamATaskCount); + pk.setTeamBTaskCount(teamBTaskCount); + pk.setTeamAFiveStarCount(teamAFiveStarCount); + pk.setTeamBFiveStarCount(teamBFiveStarCount); + pk.setTeamAOnTimeRate(teamATotalCount > 0 ? (teamAOnTimeCount * 100 / teamATotalCount) : 0); + pk.setTeamBOnTimeRate(teamBTotalCount > 0 ? (teamBOnTimeCount * 100 / teamBTotalCount) : 0); + + // 计算总分 + pk.setTeamAScore(calculateTeamScore(pk, true)); + pk.setTeamBScore(calculateTeamScore(pk, false)); + + teamPKMapper.updateById(pk); + } + + /** + * 计算团队得分 + */ + private int calculateTeamScore(TeamPK pk, boolean isTeamA) { + try { + Map> dimensions = objectMapper.readValue( + pk.getDimensions(), new TypeReference>>() {}); + + int score = 0; + + if (dimensions.containsKey("taskCount")) { + int weight = dimensions.get("taskCount").getOrDefault("weight", 30); + int value = isTeamA ? pk.getTeamATaskCount() : pk.getTeamBTaskCount(); + score += value * weight / 10; + } + if (dimensions.containsKey("fiveStarCount")) { + int weight = dimensions.get("fiveStarCount").getOrDefault("weight", 25); + int value = isTeamA ? pk.getTeamAFiveStarCount() : pk.getTeamBFiveStarCount(); + score += value * weight / 5; + } + if (dimensions.containsKey("onTimeRate")) { + int weight = dimensions.get("onTimeRate").getOrDefault("weight", 25); + int value = isTeamA ? pk.getTeamAOnTimeRate() : pk.getTeamBOnTimeRate(); + score += value * weight / 10; + } + + return score; + } catch (JsonProcessingException e) { + log.error("解析PK维度配置失败", e); + return 0; + } + } + + /** + * 获取PK列表 + */ + public IPage getPKList(Integer page, Integer pageSize, String status) { + Page pageParam = new Page<>(page, pageSize); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + + if (status != null && !status.isEmpty()) { + wrapper.eq(TeamPK::getStatus, status); + } + + wrapper.orderByDesc(TeamPK::getCreateTime); + return teamPKMapper.selectPage(pageParam, wrapper); + } + + /** + * 获取PK详情 + */ + public TeamPK getPKById(Long id) { + TeamPK pk = teamPKMapper.selectById(id); + if (pk == null) { + throw new BusinessException(ResultCode.NOT_FOUND, "PK不存在"); + } + + // 如果进行中,刷新数据 + if ("active".equals(pk.getStatus())) { + refreshPKStats(pk); + } + + return pk; + } + + /** + * 结束PK并发放奖励 + */ + @Transactional + public void endTeamPK(Long id) { + TeamPK pk = teamPKMapper.selectById(id); + if (pk == null) { + throw new BusinessException(ResultCode.NOT_FOUND, "PK不存在"); + } + + if (!"active".equals(pk.getStatus())) { + throw new BusinessException(ResultCode.PARAM_ERROR, "只有进行中的PK可以结束"); + } + + // 最后刷新一次数据 + refreshPKStats(pk); + + // 判断胜负 + if (pk.getTeamAScore() > pk.getTeamBScore()) { + pk.setWinner("A"); + } else if (pk.getTeamBScore() > pk.getTeamAScore()) { + pk.setWinner("B"); + } else { + pk.setWinner("draw"); + } + + pk.setStatus("ended"); + teamPKMapper.updateById(pk); + + // 发放奖励 + distributeRewards(pk); + + log.info("团队PK结束: id={}, winner={}", pk.getId(), pk.getWinner()); + } + + /** + * 发放PK奖励 + */ + private void distributeRewards(TeamPK pk) { + try { + Map rewards = objectMapper.readValue( + pk.getRewards(), new TypeReference>() {}); + + int winnerReward = rewards.getOrDefault("winnerReward", 200); + int loserReward = rewards.getOrDefault("loserReward", 100); + + Long winnerDeptId = "A".equals(pk.getWinner()) ? pk.getTeamADeptId() : pk.getTeamBDeptId(); + Long loserDeptId = "A".equals(pk.getWinner()) ? pk.getTeamBDeptId() : pk.getTeamADeptId(); + String winnerName = "A".equals(pk.getWinner()) ? pk.getTeamAName() : pk.getTeamBName(); + + if ("draw".equals(pk.getWinner())) { + // 平局,双方都获得参与奖 + distributeToTeam(pk.getTeamADeptId(), loserReward, pk, "平局"); + distributeToTeam(pk.getTeamBDeptId(), loserReward, pk, "平局"); + } else { + distributeToTeam(winnerDeptId, winnerReward, pk, "胜利"); + distributeToTeam(loserDeptId, loserReward, pk, "参与"); + } + + } catch (JsonProcessingException e) { + log.error("解析PK奖励配置失败", e); + } + } + + /** + * 向团队成员发放奖励 + */ + private void distributeToTeam(Long deptId, int rewardScore, TeamPK pk, String result) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(User::getDeptId, deptId).eq(User::getDeleted, 0); + List users = userMapper.selectList(wrapper); + + for (User user : users) { + int currentScore = user.getScore() != null ? user.getScore() : 0; + user.setScore(currentScore + rewardScore); + userMapper.updateById(user); + + notificationService.sendNotification( + user.getId(), + "团队PK奖励", + String.format("团队PK「%s」已结束,您的团队%s!获得%d积分奖励。", + pk.getName(), result, rewardScore), + "team_pk", + pk.getId() + ); + } + } + + /** + * 定时任务:自动开始和结束PK + */ + @Scheduled(fixedRate = 60000) // 每分钟检查一次 + @Transactional + public void checkPKStatus() { + LocalDateTime now = LocalDateTime.now(); + + // 自动开始 + LambdaQueryWrapper startWrapper = new LambdaQueryWrapper<>(); + startWrapper.eq(TeamPK::getStatus, "scheduled") + .le(TeamPK::getStartTime, now); + List toStart = teamPKMapper.selectList(startWrapper); + + for (TeamPK pk : toStart) { + pk.setStatus("active"); + teamPKMapper.updateById(pk); + notifyTeamPKStart(pk); + log.info("团队PK自动开始: id={}", pk.getId()); + } + + // 自动结束 + LambdaQueryWrapper endWrapper = new LambdaQueryWrapper<>(); + endWrapper.eq(TeamPK::getStatus, "active") + .le(TeamPK::getEndTime, now); + List toEnd = teamPKMapper.selectList(endWrapper); + + for (TeamPK pk : toEnd) { + endTeamPK(pk.getId()); + } + } + + /** + * 取消PK + */ + @Transactional + public void cancelTeamPK(Long id) { + TeamPK pk = teamPKMapper.selectById(id); + if (pk == null) { + throw new BusinessException(ResultCode.NOT_FOUND, "PK不存在"); + } + + pk.setStatus("cancelled"); + teamPKMapper.updateById(pk); + } + + /** + * 删除PK + */ + @Transactional + public void deleteTeamPK(Long id) { + TeamPK pk = teamPKMapper.selectById(id); + if (pk == null) { + throw new BusinessException(ResultCode.NOT_FOUND, "PK不存在"); + } + + if ("active".equals(pk.getStatus())) { + throw new BusinessException(ResultCode.PARAM_ERROR, "进行中的PK不能删除"); + } + + teamPKMapper.deleteById(id); + } +} diff --git a/backend/src/main/java/com/designhub/service/TeamScorePoolService.java b/backend/src/main/java/com/designhub/service/TeamScorePoolService.java new file mode 100644 index 0000000..36899bb --- /dev/null +++ b/backend/src/main/java/com/designhub/service/TeamScorePoolService.java @@ -0,0 +1,307 @@ +package com.designhub.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.designhub.entity.Department; +import com.designhub.entity.TeamScorePool; +import com.designhub.exception.BusinessException; +import com.designhub.mapper.DepartmentMapper; +import com.designhub.mapper.TeamScorePoolMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +/** + * 团队积分池Service + */ +@Service +@RequiredArgsConstructor +public class TeamScorePoolService { + + private final TeamScorePoolMapper teamScorePoolMapper; + private final DepartmentMapper departmentMapper; + + /** + * 根据部门ID获取积分池 + */ + public TeamScorePool getByDeptId(Long deptId) { + return teamScorePoolMapper.selectOne( + new LambdaQueryWrapper() + .eq(TeamScorePool::getDeptId, deptId) + ); + } + + /** + * 检查积分是否足够 + */ + public boolean checkBalance(Long deptId, Integer score) { + TeamScorePool pool = getByDeptId(deptId); + // 如果积分池不存在,自动创建 + if (pool == null) { + Department department = departmentMapper.selectById(deptId); + if (department != null) { + pool = upsertPool(deptId, department.getDeptName()); + } else { + // 部门不存在,返回false + return false; + } + } + return pool.getBalance() >= score; + } + + /** + * 检查是否超过周期限制 + */ + public boolean checkPeriodLimit(Long deptId, Integer score) { + TeamScorePool pool = getByDeptId(deptId); + // 如果积分池不存在,自动创建 + if (pool == null) { + Department department = departmentMapper.selectById(deptId); + if (department != null) { + pool = upsertPool(deptId, department.getDeptName()); + } else { + // 部门不存在,返回true(允许通过) + return true; + } + } + if (pool.getLimitEnabled() == 0) { + return true; // 没有限制或未启用限制 + } + + // 检查周期是否需要重置 + resetPeriodIfNeeded(pool); + + // 检查本周期是否超限 + Integer periodUsed = pool.getPeriodUsed() != null ? pool.getPeriodUsed() : 0; + Integer periodLimit = pool.getPeriodLimit() != null ? pool.getPeriodLimit() : 0; + + return periodUsed + score <= periodLimit; + } + + /** + * 消耗积分 + */ + @Transactional(rollbackFor = Exception.class) + public void consumeScore(Long deptId, Integer score) { + TeamScorePool pool = getByDeptId(deptId); + // 如果积分池不存在,自动创建 + if (pool == null) { + Department department = departmentMapper.selectById(deptId); + if (department != null) { + pool = upsertPool(deptId, department.getDeptName()); + } else { + throw new BusinessException("部门不存在"); + } + } + + // 检查余额 + if (pool.getBalance() < score) { + throw new BusinessException("积分余额不足"); + } + + // 检查周期限制 + if (!checkPeriodLimit(deptId, score)) { + throw new BusinessException("超过本周期积分消耗限制"); + } + + // 更新余额和消耗 + pool.setBalance(pool.getBalance() - score); + pool.setTotalConsume((pool.getTotalConsume() != null ? pool.getTotalConsume() : 0) + score); + + // 更新周期使用 + if (pool.getLimitEnabled() == 1) { + pool.setPeriodUsed((pool.getPeriodUsed() != null ? pool.getPeriodUsed() : 0) + score); + } + + teamScorePoolMapper.updateById(pool); + } + + /** + * 增加积分(任务取消或完成奖励) + */ + @Transactional(rollbackFor = Exception.class) + public void addScore(Long deptId, Integer score) { + TeamScorePool pool = getByDeptId(deptId); + if (pool == null) { + throw new BusinessException("部门积分池不存在"); + } + + pool.setBalance(pool.getBalance() + score); + pool.setTotalEarn((pool.getTotalEarn() != null ? pool.getTotalEarn() : 0) + score); + + teamScorePoolMapper.updateById(pool); + } + + /** + * 扣除积分(用于延误扣分等特殊情况,不检查周期限制) + */ + @Transactional(rollbackFor = Exception.class) + public void deductScore(Long deptId, Integer score) { + TeamScorePool pool = getByDeptId(deptId); + if (pool == null) { + throw new BusinessException("部门积分池不存在"); + } + + if (pool.getBalance() < score) { + // 如果余额不足,只扣除现有余额 + score = pool.getBalance(); + } + + pool.setBalance(pool.getBalance() - score); + pool.setTotalConsume((pool.getTotalConsume() != null ? pool.getTotalConsume() : 0) + score); + + teamScorePoolMapper.updateById(pool); + } + + /** + * 充值积分 + */ + @Transactional(rollbackFor = Exception.class) + public void rechargeScore(Long deptId, Integer score) { + TeamScorePool pool = getByDeptId(deptId); + if (pool == null) { + throw new BusinessException("部门积分池不存在"); + } + + pool.setBalance(pool.getBalance() + score); + pool.setTotalRecharge((pool.getTotalRecharge() != null ? pool.getTotalRecharge() : 0) + score); + + teamScorePoolMapper.updateById(pool); + } + + /** + * 检查周期是否需要重置 + */ + private void resetPeriodIfNeeded(TeamScorePool pool) { + if (pool.getLimitEnabled() == 0 || pool.getPeriodStartTime() == null) { + return; + } + + LocalDateTime now = LocalDateTime.now(); + boolean needReset = false; + + switch (pool.getLimitPeriod()) { + case "DAILY": + // 如果不是同一天,需要重置 + if (!now.toLocalDate().equals(pool.getPeriodStartTime().toLocalDate())) { + needReset = true; + } + break; + case "WEEKLY": + // 如果不是同一周,需要重置 + if (now.minusWeeks(1).isAfter(pool.getPeriodStartTime())) { + needReset = true; + } + break; + case "MONTHLY": + // 如果不是同一月,需要重置 + if (now.getMonthValue() != pool.getPeriodStartTime().getMonthValue() || + now.getYear() != pool.getPeriodStartTime().getYear()) { + needReset = true; + } + break; + } + + if (needReset) { + pool.setPeriodUsed(0); + pool.setPeriodStartTime(now); + teamScorePoolMapper.updateById(pool); + } + } + + /** + * 创建积分池 + */ + public TeamScorePool createPool(TeamScorePool pool) { + teamScorePoolMapper.insert(pool); + return pool; + } + + /** + * 更新或创建积分池 + */ + @Transactional(rollbackFor = Exception.class) + public TeamScorePool upsertPool(Long deptId, String deptName) { + TeamScorePool pool = getByDeptId(deptId); + if (pool == null) { + pool = new TeamScorePool(); + pool.setDeptId(deptId); + pool.setDeptName(deptName); + pool.setBalance(0); + pool.setTotalRecharge(0); + pool.setTotalConsume(0); + pool.setTotalEarn(0); + pool.setLimitEnabled(0); + pool.setAlertThreshold(80); + pool.setPeriodUsed(0); + pool.setPeriodLimit(0); + pool.setPeriodStartTime(LocalDateTime.now()); + createPool(pool); + } else if (deptName != null && !deptName.equals(pool.getDeptName())) { + pool.setDeptName(deptName); + teamScorePoolMapper.updateById(pool); + } + return pool; + } + + /** + * 更新积分池配置 + */ + public boolean updatePool(TeamScorePool pool) { + return teamScorePoolMapper.updateById(pool) > 0; + } + + /** + * 更新积分池的限制配置 + */ + @Transactional(rollbackFor = Exception.class) + public void updateLimitConfig(Long deptId, Integer limitEnabled, String limitPeriod, + Integer periodLimit, Integer alertThreshold) { + TeamScorePool pool = upsertPool(deptId, null); + if (limitEnabled != null) { + pool.setLimitEnabled(limitEnabled); + } + if (limitPeriod != null) { + pool.setLimitPeriod(limitPeriod); + } + if (periodLimit != null) { + pool.setPeriodLimit(periodLimit); + } + if (alertThreshold != null) { + pool.setAlertThreshold(alertThreshold); + } + if (pool.getPeriodStartTime() == null) { + pool.setPeriodStartTime(LocalDateTime.now()); + } + teamScorePoolMapper.updateById(pool); + } + + /** + * 直接设置积分余额(用于初始化或同步) + */ + @Transactional(rollbackFor = Exception.class) + public void setBalance(Long deptId, Integer balance) { + TeamScorePool pool = upsertPool(deptId, null); + pool.setBalance(balance != null ? balance : 0); + teamScorePoolMapper.updateById(pool); + } + + /** + * 计算预警状态 + */ + public boolean isAlert(Long deptId) { + TeamScorePool pool = getByDeptId(deptId); + if (pool == null || pool.getLimitEnabled() == 0) { + return false; + } + + Integer periodUsed = pool.getPeriodUsed() != null ? pool.getPeriodUsed() : 0; + Integer periodLimit = pool.getPeriodLimit() != null ? pool.getPeriodLimit() : 1; + Integer threshold = pool.getAlertThreshold() != null ? pool.getAlertThreshold() : 80; + + double usageRate = (double) periodUsed / periodLimit * 100; + return usageRate >= threshold; + } +} + diff --git a/backend/src/main/java/com/designhub/service/ThirdPartyCallbackService.java b/backend/src/main/java/com/designhub/service/ThirdPartyCallbackService.java new file mode 100644 index 0000000..e8804d0 --- /dev/null +++ b/backend/src/main/java/com/designhub/service/ThirdPartyCallbackService.java @@ -0,0 +1,173 @@ +package com.designhub.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.designhub.entity.Task; +import com.designhub.entity.ThirdPartyCallback; +import com.designhub.exception.BusinessException; +import com.designhub.mapper.TaskMapper; +import com.designhub.mapper.ThirdPartyCallbackMapper; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +/** + * 第三方回调处理Service + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ThirdPartyCallbackService { + + private final ThirdPartyCallbackMapper callbackMapper; + private final TaskMapper taskMapper; + private final ObjectMapper objectMapper; + + /** + * 处理任务完成回调 + */ + @Transactional(rollbackFor = Exception.class) + public void handleTaskCompleteCallback(String thirdPartyTaskNo, Map payload) { + log.info("处理任务完成回调: thirdPartyTaskNo={}", thirdPartyTaskNo); + + // 查找对应的任务 + Task task = taskMapper.selectOne( + new LambdaQueryWrapper() + .eq(Task::getThirdPartyTaskNo, thirdPartyTaskNo) + ); + + if (task == null) { + log.error("未找到对应的任务: thirdPartyTaskNo={}", thirdPartyTaskNo); + throw new BusinessException("任务不存在"); + } + + // 创建回调记录 + ThirdPartyCallback callback = new ThirdPartyCallback(); + callback.setTaskId(task.getId()); + callback.setThirdPartyTaskNo(thirdPartyTaskNo); + callback.setCallbackType("complete"); + callback.setCallbackStatus("pending"); + callback.setCallbackTime(LocalDateTime.now()); + callback.setRetryCount(0); + + try { + callback.setRequestData(objectMapper.writeValueAsString(payload)); + } catch (JsonProcessingException e) { + log.warn("序列化请求数据失败", e); + } + + callbackMapper.insert(callback); + + // 处理回调逻辑 + try { + // 更新任务状态为已完成 + Task updateTask = new Task(); + updateTask.setId(task.getId()); + updateTask.setStatus("completed"); + updateTask.setProgress(100); + updateTask.setCompleteTime(LocalDateTime.now()); + updateTask.setUpdateTime(LocalDateTime.now()); + + taskMapper.updateById(updateTask); + + // 更新回调记录状态 + callback.setCallbackStatus("success"); + callback.setProcessedTime(LocalDateTime.now()); + callback.setResponseData("{\"status\":\"success\"}"); + callbackMapper.updateById(callback); + + log.info("任务完成回调处理成功: taskId={}, thirdPartyTaskNo={}", task.getId(), thirdPartyTaskNo); + + } catch (Exception e) { + log.error("处理任务完成回调失败", e); + + // 更新回调记录状态 + callback.setCallbackStatus("failed"); + callback.setErrorMessage(e.getMessage()); + callback.setProcessedTime(LocalDateTime.now()); + callbackMapper.updateById(callback); + + throw new BusinessException("处理回调失败: " + e.getMessage()); + } + } + + /** + * 处理任务状态更新回调 + */ + @Transactional(rollbackFor = Exception.class) + public void handleTaskStatusUpdateCallback(String thirdPartyTaskNo, String status, Map payload) { + log.info("处理任务状态更新回调: thirdPartyTaskNo={}, status={}", thirdPartyTaskNo, status); + + // 查找对应的任务 + Task task = taskMapper.selectOne( + new LambdaQueryWrapper() + .eq(Task::getThirdPartyTaskNo, thirdPartyTaskNo) + ); + + if (task == null) { + log.error("未找到对应的任务: thirdPartyTaskNo={}", thirdPartyTaskNo); + throw new BusinessException("任务不存在"); + } + + // 创建回调记录 + ThirdPartyCallback callback = new ThirdPartyCallback(); + callback.setTaskId(task.getId()); + callback.setThirdPartyTaskNo(thirdPartyTaskNo); + callback.setCallbackType("status_update"); + callback.setCallbackStatus("pending"); + callback.setCallbackTime(LocalDateTime.now()); + callback.setRetryCount(0); + + try { + callback.setRequestData(objectMapper.writeValueAsString(payload)); + } catch (JsonProcessingException e) { + log.warn("序列化请求数据失败", e); + } + + callbackMapper.insert(callback); + + // 处理回调逻辑(根据第三方状态更新本地状态) + try { + // 这里可以根据第三方的status做相应的状态映射 + // 示例:status可能是 "submitted", "processing", "completed", "cancelled" 等 + + log.info("第三方任务状态: {}", status); + + // 更新回调记录状态 + callback.setCallbackStatus("success"); + callback.setProcessedTime(LocalDateTime.now()); + callback.setResponseData("{\"status\":\"success\",\"message\":\"状态已记录\"}"); + callbackMapper.updateById(callback); + + log.info("任务状态更新回调处理成功: taskId={}, thirdPartyTaskNo={}, status={}", + task.getId(), thirdPartyTaskNo, status); + + } catch (Exception e) { + log.error("处理任务状态更新回调失败", e); + + callback.setCallbackStatus("failed"); + callback.setErrorMessage(e.getMessage()); + callback.setProcessedTime(LocalDateTime.now()); + callbackMapper.updateById(callback); + + throw new BusinessException("处理回调失败: " + e.getMessage()); + } + } + + /** + * 查询回调记录 + */ + public List getCallbackRecords(String thirdPartyTaskNo) { + return callbackMapper.selectList( + new LambdaQueryWrapper() + .eq(ThirdPartyCallback::getThirdPartyTaskNo, thirdPartyTaskNo) + .orderByDesc(ThirdPartyCallback::getCallbackTime) + ); + } +} diff --git a/backend/src/main/java/com/designhub/service/UserGoalService.java b/backend/src/main/java/com/designhub/service/UserGoalService.java new file mode 100644 index 0000000..80d64b5 --- /dev/null +++ b/backend/src/main/java/com/designhub/service/UserGoalService.java @@ -0,0 +1,358 @@ +package com.designhub.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.designhub.common.ResultCode; +import com.designhub.entity.User; +import com.designhub.entity.UserGoal; +import com.designhub.exception.BusinessException; +import com.designhub.mapper.UserGoalMapper; +import com.designhub.mapper.UserMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.time.temporal.TemporalAdjusters; +import java.util.List; + +/** + * 用户目标Service + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class UserGoalService { + + private final UserGoalMapper userGoalMapper; + private final UserMapper userMapper; + private final NotificationService notificationService; + + /** + * 创建目标 + */ + @Transactional + public UserGoal createGoal(UserGoal goal) { + // 计算周期时间 + calculatePeriodTime(goal); + + goal.setCurrentValue(0); + goal.setStatus("active"); + goal.setRewardClaimed(0); + goal.setDeleted(0); + + userGoalMapper.insert(goal); + + log.info("创建目标成功: userId={}, goalName={}", goal.getUserId(), goal.getGoalName()); + return goal; + } + + /** + * 计算周期开始和结束时间 + */ + private void calculatePeriodTime(UserGoal goal) { + LocalDateTime now = LocalDateTime.now(); + + switch (goal.getPeriod()) { + case "daily": + goal.setPeriodStart(now.toLocalDate().atStartOfDay()); + goal.setPeriodEnd(now.toLocalDate().atTime(23, 59, 59)); + break; + case "weekly": + goal.setPeriodStart(now.with(TemporalAdjusters.previousOrSame(java.time.DayOfWeek.MONDAY)).toLocalDate().atStartOfDay()); + goal.setPeriodEnd(now.with(TemporalAdjusters.nextOrSame(java.time.DayOfWeek.SUNDAY)).toLocalDate().atTime(23, 59, 59)); + break; + case "monthly": + goal.setPeriodStart(now.with(TemporalAdjusters.firstDayOfMonth()).toLocalDate().atStartOfDay()); + goal.setPeriodEnd(now.with(TemporalAdjusters.lastDayOfMonth()).toLocalDate().atTime(23, 59, 59)); + break; + case "quarterly": + int quarter = (now.getMonthValue() - 1) / 3; + LocalDateTime quarterStart = LocalDateTime.of(now.getYear(), quarter * 3 + 1, 1, 0, 0); + goal.setPeriodStart(quarterStart); + goal.setPeriodEnd(quarterStart.plusMonths(3).minusSeconds(1)); + break; + case "yearly": + goal.setPeriodStart(now.with(TemporalAdjusters.firstDayOfYear()).toLocalDate().atStartOfDay()); + goal.setPeriodEnd(now.with(TemporalAdjusters.lastDayOfYear()).toLocalDate().atTime(23, 59, 59)); + break; + case "custom": + // 自定义周期,使用传入的时间 + if (goal.getPeriodStart() == null || goal.getPeriodEnd() == null) { + throw new BusinessException(ResultCode.PARAM_ERROR, "自定义周期必须指定开始和结束时间"); + } + break; + default: + goal.setPeriodStart(now); + goal.setPeriodEnd(now.plusMonths(1)); + } + } + + /** + * 更新目标 + */ + @Transactional + public UserGoal updateGoal(Long id, UserGoal goal) { + UserGoal existing = userGoalMapper.selectById(id); + if (existing == null) { + throw new BusinessException(ResultCode.NOT_FOUND, "目标不存在"); + } + + goal.setId(id); + userGoalMapper.updateById(goal); + + return userGoalMapper.selectById(id); + } + + /** + * 更新目标进度 + */ + @Transactional + public UserGoal updateProgress(Long goalId, Integer progressValue) { + UserGoal goal = userGoalMapper.selectById(goalId); + if (goal == null) { + throw new BusinessException(ResultCode.NOT_FOUND, "目标不存在"); + } + + goal.setCurrentValue(progressValue); + + // 检查是否完成 + if (progressValue >= goal.getTargetValue() && !"completed".equals(goal.getStatus())) { + goal.setStatus("completed"); + goal.setCompletedTime(LocalDateTime.now()); + + // 发送完成通知 + notificationService.sendNotification( + goal.getUserId(), + "目标达成", + String.format("恭喜!您的目标「%s」已完成!", goal.getGoalName()), + "goal", + goal.getId() + ); + } + + userGoalMapper.updateById(goal); + return goal; + } + + /** + * 增量更新目标进度 + */ + @Transactional + public void incrementProgress(Long userId, String goalType, int increment) { + // 获取用户当前进行中的该类型目标 + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(UserGoal::getUserId, userId) + .eq(UserGoal::getGoalType, goalType) + .eq(UserGoal::getStatus, "active") + .le(UserGoal::getPeriodStart, LocalDateTime.now()) + .ge(UserGoal::getPeriodEnd, LocalDateTime.now()); + + List goals = userGoalMapper.selectList(wrapper); + + for (UserGoal goal : goals) { + int newValue = goal.getCurrentValue() + increment; + updateProgress(goal.getId(), newValue); + } + } + + /** + * 获取用户目标列表 + */ + public List getUserGoals(Long userId, String status) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(UserGoal::getUserId, userId); + + if (status != null && !status.isEmpty()) { + wrapper.eq(UserGoal::getStatus, status); + } + + wrapper.orderByDesc(UserGoal::getCreateTime); + return userGoalMapper.selectList(wrapper); + } + + /** + * 获取用户当前活跃目标 + */ + public List getActiveGoals(Long userId) { + LocalDateTime now = LocalDateTime.now(); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(UserGoal::getUserId, userId) + .eq(UserGoal::getStatus, "active") + .le(UserGoal::getPeriodStart, now) + .ge(UserGoal::getPeriodEnd, now) + .orderByAsc(UserGoal::getPeriodEnd); + + return userGoalMapper.selectList(wrapper); + } + + /** + * 获取目标详情 + */ + public UserGoal getGoalById(Long id) { + UserGoal goal = userGoalMapper.selectById(id); + if (goal == null) { + throw new BusinessException(ResultCode.NOT_FOUND, "目标不存在"); + } + return goal; + } + + /** + * 领取目标奖励 + */ + @Transactional + public boolean claimReward(Long goalId, Long userId) { + UserGoal goal = userGoalMapper.selectById(goalId); + if (goal == null) { + throw new BusinessException(ResultCode.NOT_FOUND, "目标不存在"); + } + + if (!goal.getUserId().equals(userId)) { + throw new BusinessException(ResultCode.FORBIDDEN, "无权操作此目标"); + } + + if (!"completed".equals(goal.getStatus())) { + throw new BusinessException(ResultCode.PARAM_ERROR, "目标未完成,无法领取奖励"); + } + + if (goal.getRewardClaimed() == 1) { + throw new BusinessException(ResultCode.PARAM_ERROR, "奖励已领取"); + } + + if (goal.getRewardScore() == null || goal.getRewardScore() <= 0) { + throw new BusinessException(ResultCode.PARAM_ERROR, "此目标无积分奖励"); + } + + // 更新用户积分 + User user = userMapper.selectById(userId); + int currentScore = user.getScore() != null ? user.getScore() : 0; + user.setScore(currentScore + goal.getRewardScore()); + userMapper.updateById(user); + + // 标记已领取 + goal.setRewardClaimed(1); + userGoalMapper.updateById(goal); + + return true; + } + + /** + * 取消目标 + */ + @Transactional + public void cancelGoal(Long id, Long userId) { + UserGoal goal = userGoalMapper.selectById(id); + if (goal == null) { + throw new BusinessException(ResultCode.NOT_FOUND, "目标不存在"); + } + + if (!goal.getUserId().equals(userId)) { + throw new BusinessException(ResultCode.FORBIDDEN, "无权操作此目标"); + } + + goal.setStatus("cancelled"); + userGoalMapper.updateById(goal); + } + + /** + * 删除目标 + */ + @Transactional + public void deleteGoal(Long id, Long userId) { + UserGoal goal = userGoalMapper.selectById(id); + if (goal == null) { + throw new BusinessException(ResultCode.NOT_FOUND, "目标不存在"); + } + + if (!goal.getUserId().equals(userId)) { + throw new BusinessException(ResultCode.FORBIDDEN, "无权操作此目标"); + } + + userGoalMapper.deleteById(id); + } + + /** + * 定时任务:检查过期目标 + */ + @Scheduled(cron = "0 0 0 * * ?") // 每天凌晨执行 + @Transactional + public void checkExpiredGoals() { + LocalDateTime now = LocalDateTime.now(); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(UserGoal::getStatus, "active") + .lt(UserGoal::getPeriodEnd, now); + + List expiredGoals = userGoalMapper.selectList(wrapper); + + for (UserGoal goal : expiredGoals) { + if (goal.getCurrentValue() >= goal.getTargetValue()) { + goal.setStatus("completed"); + goal.setCompletedTime(now); + } else { + goal.setStatus("failed"); + + // 发送未完成通知 + notificationService.sendNotification( + goal.getUserId(), + "目标未完成", + String.format("很遗憾,您的目标「%s」未能在期限内完成。继续努力!", goal.getGoalName()), + "goal", + goal.getId() + ); + } + userGoalMapper.updateById(goal); + } + + log.info("检查过期目标完成,处理了{}个目标", expiredGoals.size()); + } + + /** + * 创建预设目标(系统推荐) + */ + @Transactional + public UserGoal createPresetGoal(Long userId, String presetType) { + UserGoal goal = new UserGoal(); + goal.setUserId(userId); + + switch (presetType) { + case "weekly_task": + goal.setGoalType("task_count"); + goal.setGoalName("本周完成10个任务"); + goal.setDescription("在本周内完成10个设计任务"); + goal.setTargetValue(10); + goal.setPeriod("weekly"); + goal.setRewardScore(50); + break; + case "monthly_task": + goal.setGoalType("task_count"); + goal.setGoalName("本月完成40个任务"); + goal.setDescription("在本月内完成40个设计任务"); + goal.setTargetValue(40); + goal.setPeriod("monthly"); + goal.setRewardScore(200); + break; + case "monthly_score": + goal.setGoalType("score"); + goal.setGoalName("本月获得1000积分"); + goal.setDescription("在本月内累计获得1000积分"); + goal.setTargetValue(1000); + goal.setPeriod("monthly"); + goal.setRewardScore(100); + break; + case "top_10": + goal.setGoalType("rank"); + goal.setGoalName("进入排行榜前10"); + goal.setDescription("在本月排行榜中进入前10名"); + goal.setTargetValue(10); + goal.setPeriod("monthly"); + goal.setRewardScore(150); + break; + default: + throw new BusinessException(ResultCode.PARAM_ERROR, "未知的预设目标类型"); + } + + return createGoal(goal); + } +} diff --git a/backend/src/main/java/com/designhub/service/UserService.java b/backend/src/main/java/com/designhub/service/UserService.java new file mode 100644 index 0000000..1befdfa --- /dev/null +++ b/backend/src/main/java/com/designhub/service/UserService.java @@ -0,0 +1,857 @@ +package com.designhub.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.designhub.dto.LoginDTO; +import com.designhub.dto.RegisterDTO; +import com.designhub.entity.Department; +import com.designhub.entity.User; +import com.designhub.exception.BusinessException; +import com.designhub.mapper.UserMapper; +import com.designhub.mapper.DepartmentMapper; +import com.designhub.common.ResultCode; +import com.designhub.util.JwtUtil; +import com.designhub.vo.LoginVO; +import com.designhub.vo.UserInfoVO; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; + +/** + * 用户管理Service + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class UserService { + + private final UserMapper userMapper; + private final DepartmentMapper departmentMapper; + private final TeamScorePoolService teamScorePoolService; + private final RoleService roleService; + private final DingTalkService dingTalkService; + private final DepartmentService departmentService; + private final JwtUtil jwtUtil; + private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); + + private Department getDepartmentOrThrow(Long deptId) { + if (deptId == null) { + return null; + } + Department department = departmentMapper.selectById(deptId); + if (department == null) { + throw new BusinessException("部门不存在"); + } + if (department.getStatus() != null && department.getStatus() == 0) { + throw new BusinessException("该部门已被禁用"); + } + return department; + } + + /** + * 分页查询用户列表 + */ + public Page getUserList(int pageNum, int pageSize, String keyword, Long deptId, Integer status) { + Page page = new Page<>(pageNum, pageSize); + + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + if (StringUtils.hasText(keyword)) { + queryWrapper.and(wrapper -> wrapper + .like(User::getUsername, keyword) + .or() + .like(User::getRealName, keyword) + .or() + .like(User::getPhone, keyword) + .or() + .like(User::getEmail, keyword) + ); + } + if (deptId != null) { + queryWrapper.eq(User::getDeptId, deptId); + } + if (status != null) { + queryWrapper.eq(User::getStatus, status); + } + queryWrapper.orderByDesc(User::getCreateTime); + + return userMapper.selectPage(page, queryWrapper); + } + + /** + * 根据ID获取用户 + */ + public User getUserById(Long id) { + User user = userMapper.selectById(id); + if (user != null) { + // 不返回密码 + user.setPassword(null); + } + return user; + } + + /** + * 根据用户名获取用户 + */ + public User getUserByUsername(String username) { + return userMapper.selectOne( + new LambdaQueryWrapper() + .eq(User::getUsername, username) + ); + } + + /** + * 用户注册 + */ + @Transactional(rollbackFor = Exception.class) + public User register(RegisterDTO registerDTO) { + log.info("用户注册: username={}, roleId={}, deptId={}", + registerDTO.getUsername(), registerDTO.getRoleId(), registerDTO.getDeptId()); + + // 验证用户名唯一性 + User existUser = getUserByUsername(registerDTO.getUsername()); + if (existUser != null) { + throw new BusinessException("用户名已存在"); + } + + // 验证角色ID(只允许注册为设计师或需求方) + if (registerDTO.getRoleId() != 5L && registerDTO.getRoleId() != 6L) { + throw new BusinessException("无效的角色选择"); + } + + // 验证部门存在 + Department department = departmentMapper.selectById(registerDTO.getDeptId()); + if (department == null) { + throw new BusinessException("部门不存在"); + } + + // 验证角色和部门类型匹配 + String roleCode = registerDTO.getRoleId() == 5L ? "DESIGNER" : "REQUESTER"; + String expectedDeptType = roleCode.equals("DESIGNER") ? "receiving" : "demand"; + if (!expectedDeptType.equals(department.getDeptType())) { + throw new BusinessException("角色与部门类型不匹配"); + } + + // 创建用户 + User user = new User(); + user.setUsername(registerDTO.getUsername()); + user.setPassword(passwordEncoder.encode(registerDTO.getPassword())); + user.setRealName(registerDTO.getRealName()); + user.setEmail(registerDTO.getEmail()); + user.setPhone(registerDTO.getPhone()); + user.setDeptId(registerDTO.getDeptId()); + user.setDeptName(department.getDeptName()); + user.setRoleIds(String.valueOf(registerDTO.getRoleId())); + user.setStatus(1); + user.setDeleted(0); + + userMapper.insert(user); + log.info("用户注册成功: userId={}, username={}", user.getId(), user.getUsername()); + + // 不返回密码 + user.setPassword(null); + return user; + } + + /** + * 创建用户 + */ + @Transactional(rollbackFor = Exception.class) + public User createUser(User user) { + // 验证用户名唯一性 + User existUser = getUserByUsername(user.getUsername()); + if (existUser != null) { + throw new BusinessException("用户名已存在"); + } + + // 验证手机号唯一性 + if (StringUtils.hasText(user.getPhone())) { + long count = userMapper.selectCount( + new LambdaQueryWrapper() + .eq(User::getPhone, user.getPhone()) + ); + if (count > 0) { + throw new BusinessException("手机号已存在"); + } + } + + // 验证邮箱唯一性 + if (StringUtils.hasText(user.getEmail())) { + long count = userMapper.selectCount( + new LambdaQueryWrapper() + .eq(User::getEmail, user.getEmail()) + ); + if (count > 0) { + throw new BusinessException("邮箱已存在"); + } + } + + // 加密密码 + if (StringUtils.hasText(user.getPassword())) { + user.setPassword(passwordEncoder.encode(user.getPassword())); + } else { + // 默认密码 + user.setPassword(passwordEncoder.encode("123456")); + } + + // 设置部门名称 + if (user.getDeptId() != null) { + Department department = getDepartmentOrThrow(user.getDeptId()); + user.setDeptName(department.getDeptName()); + } + + // 设置默认状态 + if (user.getStatus() == null) { + user.setStatus(1); + } + + userMapper.insert(user); + return user; + } + + /** + * 更新用户 + */ + @Transactional(rollbackFor = Exception.class) + public boolean updateUser(User user) { + User existUser = getUserById(user.getId()); + if (existUser == null) { + throw new BusinessException("用户不存在"); + } + + // 如果修改了用户名,验证唯一性 + if (StringUtils.hasText(user.getUsername()) && + !user.getUsername().equals(existUser.getUsername())) { + User sameUsernameUser = getUserByUsername(user.getUsername()); + if (sameUsernameUser != null) { + throw new BusinessException("用户名已存在"); + } + } + + // 如果更新了部门 + if (user.getDeptId() != null) { + Department department = getDepartmentOrThrow(user.getDeptId()); + user.setDeptName(department != null ? department.getDeptName() : null); + } + + // 不允许通过此接口修改密码 + user.setPassword(null); + + return userMapper.updateById(user) > 0; + } + + /** + * 删除用户(逻辑删除) + */ + @Transactional(rollbackFor = Exception.class) + public boolean deleteUser(Long id) { + User user = getUserById(id); + if (user == null) { + throw new BusinessException("用户不存在"); + } + + // 不允许删除自己 + // TODO: 获取当前登录用户ID + // if (id.equals(currentUserId)) { + // throw new BusinessException("不能删除自己"); + // } + + // 注意:已移除删除用户角色关联的代码,因为sys_user_role表不存在 + // 系统使用sys_user表中的role_id字段管理角色,不使用关联表 + + return userMapper.deleteById(id) > 0; + } + + /** + * 重置密码 + */ + @Transactional(rollbackFor = Exception.class) + public boolean resetPassword(Long id, String newPassword) { + User user = getUserById(id); + if (user == null) { + throw new BusinessException("用户不存在"); + } + + user.setPassword(passwordEncoder.encode(newPassword)); + return userMapper.updateById(user) > 0; + } + + /** + * 修改密码 + */ + @Transactional(rollbackFor = Exception.class) + public boolean changePassword(Long id, String oldPassword, String newPassword) { + User user = userMapper.selectById(id); + if (user == null) { + throw new BusinessException("用户不存在"); + } + + // 验证旧密码 + if (!passwordEncoder.matches(oldPassword, user.getPassword())) { + throw new BusinessException("原密码不正确"); + } + + user.setPassword(passwordEncoder.encode(newPassword)); + return userMapper.updateById(user) > 0; + } + + /** + * 为用户分配角色 + */ + @Transactional(rollbackFor = Exception.class) + public boolean assignRoles(Long userId, List roleIds) { + User user = getUserById(userId); + if (user == null) { + throw new BusinessException("用户不存在"); + } + + // 将角色ID列表转换为逗号分隔的字符串 + if (roleIds != null && !roleIds.isEmpty()) { + String roleIdsStr = roleIds.stream() + .map(String::valueOf) + .reduce((a, b) -> a + "," + b) + .orElse(""); + user.setRoleIds(roleIdsStr); + userMapper.updateById(user); + } else { + user.setRoleIds(null); + userMapper.updateById(user); + } + + return true; + } + + /** + * 获取用户的角色ID列表 + */ + public List getUserRoleIds(Long userId) { + User user = getUserById(userId); + if (user == null || user.getRoleIds() == null || user.getRoleIds().isEmpty()) { + return List.of(); + } + + // 将逗号分隔的字符串转换为Long列表 + return java.util.Arrays.stream(user.getRoleIds().split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .map(Long::parseLong) + .toList(); + } + + /** + * 启用/禁用用户 + */ + @Transactional(rollbackFor = Exception.class) + public boolean toggleUserStatus(Long id, Integer status) { + User user = new User(); + user.setId(id); + user.setStatus(status); + return userMapper.updateById(user) > 0; + } + + /** + * 获取全部用户列表(不分页) + */ + public List getAllUsers() { + return userMapper.selectList( + new LambdaQueryWrapper() + .orderByAsc(User::getDeptId) + .orderByDesc(User::getCreateTime) + ); + } + + /** + * 调整用户所属部门 + */ + @Transactional(rollbackFor = Exception.class) + public boolean updateUserDepartment(Long userId, Long deptId) { + log.info("开始调整用户部门: userId={}, 新deptId={}", userId, deptId); + + User user = getUserById(userId); + if (user == null) { + log.error("用户不存在: userId={}", userId); + throw new BusinessException("用户不存在"); + } + log.info("当前用户信息: username={}, 原deptId={}, 原deptName={}", + user.getUsername(), user.getDeptId(), user.getDeptName()); + + // 使用 LambdaUpdateWrapper 强制更新,包括 null 值 + LambdaUpdateWrapper updateWrapper = new LambdaUpdateWrapper<>(); + updateWrapper.eq(User::getId, userId); + + if (deptId == null || deptId == 0) { + // 清除部门 + log.info("清除用户部门: userId={}", userId); + updateWrapper.set(User::getDeptId, null); + updateWrapper.set(User::getDeptName, null); + } else { + Department department = getDepartmentOrThrow(deptId); + log.info("设置新部门: deptId={}, deptName={}", department.getId(), department.getDeptName()); + updateWrapper.set(User::getDeptId, department.getId()); + updateWrapper.set(User::getDeptName, department.getDeptName()); + + // 确保新部门有积分池(如果不存在则自动创建) + teamScorePoolService.upsertPool(department.getId(), department.getDeptName()); + log.info("已确保部门积分池存在: deptId={}, deptName={}", department.getId(), department.getDeptName()); + } + + int rows = userMapper.update(null, updateWrapper); + log.info("更新结果: 影响行数={}", rows); + return rows > 0; + } + + /** + * 尝试匹配系统部门(根据钉钉部门名称) + */ + private Long matchSystemDepartment(List> dingDepartments) { + if (dingDepartments == null || dingDepartments.isEmpty()) { + return null; + } + + // 获取所有系统部门 + List systemDepts = departmentMapper.selectList( + new LambdaQueryWrapper() + .eq(Department::getStatus, 1) + .eq(Department::getDeleted, 0) + ); + + for (Map dingDept : dingDepartments) { + String dingDeptName = (String) dingDept.get("name"); + Object dingDeptIdObj = dingDept.get("dept_id"); + String dingDeptId = dingDeptIdObj != null ? dingDeptIdObj.toString() : null; + + // 1. 优先通过钉钉部门ID精确匹配 + if (dingDeptId != null) { + Department matched = systemDepts.stream() + .filter(d -> dingDeptId.equals(d.getDingDeptId())) + .findFirst() + .orElse(null); + if (matched != null) { + log.info("通过钉钉部门ID精确匹配: {} -> {}", dingDeptId, matched.getDeptName()); + return matched.getId(); + } + } + + // 2. 通过部门名称模糊匹配 + if (dingDeptName != null) { + Department matched = systemDepts.stream() + .filter(d -> d.getDeptName().contains(dingDeptName) || + dingDeptName.contains(d.getDeptName())) + .findFirst() + .orElse(null); + if (matched != null) { + log.info("通过部门名称模糊匹配: {} -> {}", dingDeptName, matched.getDeptName()); + return matched.getId(); + } + } + } + + return null; + } + + /** + * 同步钉钉用户信息到系统 + */ + private void syncUserFromDingTalk(User user, Map detailedInfo, + String dingtalkUserId, List> dingDepartments) { + boolean needUpdate = false; + + // 1. 同步基本信息 + if (detailedInfo != null) { + if (!StringUtils.hasText(user.getRealName()) && detailedInfo.get("name") != null) { + user.setRealName((String) detailedInfo.get("name")); + needUpdate = true; + } + if (!StringUtils.hasText(user.getAvatar()) && detailedInfo.get("avatar") != null) { + user.setAvatar((String) detailedInfo.get("avatar")); + needUpdate = true; + } + if (!StringUtils.hasText(user.getPhone()) && detailedInfo.get("mobile") != null) { + user.setPhone((String) detailedInfo.get("mobile")); + needUpdate = true; + } + } + + // 2. 同步钉钉userId + if (!StringUtils.hasText(user.getDingUserId()) && dingtalkUserId != null) { + user.setDingUserId(dingtalkUserId); + needUpdate = true; + } + + // 3. 检查部门是否需要更新 + if (user.getDeptId() != null && dingDepartments != null && !dingDepartments.isEmpty()) { + Department currentDept = departmentMapper.selectById(user.getDeptId()); + if (currentDept != null && StringUtils.hasText(currentDept.getDingDeptId())) { + // 检查用户是否还在原部门 + boolean stillInDept = dingDepartments.stream() + .anyMatch(d -> currentDept.getDingDeptId().equals(d.get("dept_id").toString())); + + if (!stillInDept) { + String newDeptNames = dingDepartments.stream() + .map(d -> String.valueOf(d.get("name"))) + .collect(Collectors.joining(",")); + log.warn("用户钉钉部门已变化: userId={}, 原部门={}, 钉钉部门={}", + user.getId(), currentDept.getDeptName(), newDeptNames); + + // 尝试重新匹配部门 + Long newDeptId = matchSystemDepartment(dingDepartments); + if (newDeptId != null && !newDeptId.equals(user.getDeptId())) { + Department newDept = departmentMapper.selectById(newDeptId); + user.setDeptId(newDeptId); + user.setDeptName(newDept.getDeptName()); + needUpdate = true; + log.info("自动同步到新部门: userId={}, 新部门={}", + user.getId(), newDept.getDeptName()); + } + } + } + } + + if (needUpdate) { + userMapper.updateById(user); + log.info("钉钉用户信息已同步: userId={}", user.getId()); + } + } + + /** + * 处理钉钉OAuth登录 + */ + @Transactional(rollbackFor = Exception.class) + private LoginVO handleDingTalkLogin(String code) { + log.info("开始钉钉OAuth登录流程, code: {}...", code.substring(0, Math.min(10, code.length()))); + + try { + // 1. 获取用户访问令牌 + Map tokenData = dingTalkService.getUserAccessToken(code); + String accessToken = (String) tokenData.get("accessToken"); + if (accessToken == null) { + throw new BusinessException(ResultCode.DINGTALK_AUTH_ERROR, "获取钉钉访问令牌失败"); + } + + // 2. 获取用户信息(包含unionId) + Map userMeData = dingTalkService.getUserInfo(accessToken); + String unionId = (String) userMeData.get("unionId"); + String nick = (String) userMeData.get("nick"); + String avatarUrl = (String) userMeData.get("avatarUrl"); + + if (unionId == null) { + throw new BusinessException(ResultCode.DINGTALK_AUTH_ERROR, "获取钉钉用户unionId失败"); + } + + log.info("钉钉用户信息: unionId={}, nick={}", unionId, nick); + + // 3. 根据unionId获取userId(用于获取更详细信息) + String dingtalkUserId = dingTalkService.getUserIdByUnionId(unionId); + Map detailedInfo = null; + List> dingDepartments = new ArrayList<>(); + + if (dingtalkUserId != null) { + detailedInfo = dingTalkService.getDetailedUserInfo(dingtalkUserId); + if (detailedInfo != null) { + log.info("获取到详细用户信息: userId={}", dingtalkUserId); + // 获取钉钉部门信息 + dingDepartments = (List>) detailedInfo.get("departments"); + if (dingDepartments != null && !dingDepartments.isEmpty()) { + String deptNames = dingDepartments.stream() + .map(d -> String.valueOf(d.get("name"))) + .collect(Collectors.joining(", ")); + log.info("钉钉用户所属部门: {}", deptNames); + } + } + } + + // 4. 查找或创建用户 + // 使用unionId作为唯一标识 + String email = "dingtalk_" + unionId + "@dingtalk.local"; + User user = userMapper.selectOne( + new LambdaQueryWrapper() + .eq(User::getEmail, email) + ); + + if (user == null) { + // 首次登录:创建用户但不分配部门 + // 需要用户手动选择部门类型和具体部门 + log.info("钉钉用户首次登录,创建临时账号: email={}", email); + + // 尝试匹配系统部门 + Long matchedDeptId = matchSystemDepartment(dingDepartments); + String username = detailedInfo != null && detailedInfo.get("job_number") != null + ? (String) detailedInfo.get("job_number") : "DT_" + unionId.substring(0, 8); + + user = new User(); + user.setUsername(username); + user.setEmail(email); + user.setPassword(passwordEncoder.encode(UUID.randomUUID().toString())); + user.setRealName(detailedInfo != null && detailedInfo.get("name") != null + ? (String) detailedInfo.get("name") : nick); + user.setAvatar(detailedInfo != null && detailedInfo.get("avatar") != null + ? (String) detailedInfo.get("avatar") : avatarUrl); + user.setPhone(detailedInfo != null ? (String) detailedInfo.get("mobile") : null); + user.setDingUserId(dingtalkUserId); + user.setStatus(1); + user.setDeleted(0); + + if (matchedDeptId != null) { + // 自动匹配到部门 + Department dept = departmentMapper.selectById(matchedDeptId); + user.setDeptId(matchedDeptId); + user.setDeptName(dept.getDeptName()); + // 根据部门类型设置默认角色 + user.setRoleIds("receiving".equals(dept.getDeptType()) ? "5" : "6"); + log.info("自动匹配到系统部门: deptId={}, deptName={}, type={}", + matchedDeptId, dept.getDeptName(), dept.getDeptType()); + } else { + // 无法自动匹配,需要用户手动选择 + String deptNamesStr = dingDepartments.stream() + .map(d -> String.valueOf(d.get("name"))) + .collect(Collectors.joining(", ")); + log.warn("无法自动匹配系统部门,钉钉部门: {}", deptNamesStr); + user.setDeptId(null); + user.setDeptName(null); + user.setRoleIds(null); + user.setStatus(0); // 暂时禁用,待选择部门后启用 + } + + userMapper.insert(user); + log.info("钉钉用户创建成功: userId={}, username={}, needSelectDept={}", + user.getId(), user.getUsername(), matchedDeptId == null); + + // 如果需要选择部门,抛出特殊异常 + if (matchedDeptId == null) { + String deptInfo = dingDepartments.isEmpty() ? "无钉钉部门信息" : + dingDepartments.stream() + .map(d -> String.valueOf(d.get("name"))) + .collect(Collectors.joining(",")); + throw new BusinessException(ResultCode.PARAM_ERROR, + "NEED_SELECT_DEPT:" + user.getId() + ":" + deptInfo); + } + } else { + // 已有用户:同步更新信息 + log.info("钉钉用户已存在,同步更新: userId={}", user.getId()); + syncUserFromDingTalk(user, detailedInfo, dingtalkUserId, dingDepartments); + } + + // 5. 生成Token + String token = jwtUtil.generateToken(user.getId(), user.getUsername()); + String refreshToken = jwtUtil.generateRefreshToken(user.getId(), user.getUsername()); + + return LoginVO.builder() + .token(token) + .refreshToken(refreshToken) + .userId(user.getId()) + .username(user.getUsername()) + .realName(user.getRealName()) + .avatar(user.getAvatar()) + .roleIds(user.getRoleIds()) + .build(); + + } catch (BusinessException e) { + throw e; + } catch (Exception e) { + log.error("钉钉登录失败", e); + throw new BusinessException(ResultCode.DINGTALK_AUTH_ERROR, "钉钉登录失败: " + e.getMessage()); + } + } + + /** + * 完成钉钉用户设置(选择部门) + */ + @Transactional(rollbackFor = Exception.class) + public LoginVO completeDingTalkUserSetup(Long userId, Long deptId) { + log.info("完成钉钉用户设置: userId={}, deptId={}", userId, deptId); + + // 1. 获取用户 + User user = userMapper.selectById(userId); + if (user == null) { + throw new BusinessException(ResultCode.USER_NOT_EXIST); + } + + // 2. 获取部门 + Department dept = departmentMapper.selectById(deptId); + if (dept == null) { + throw new BusinessException(ResultCode.DEPT_NOT_EXIST); + } + + // 3. 更新用户部门和角色 + user.setDeptId(deptId); + user.setDeptName(dept.getDeptName()); + // 根据部门类型设置角色 + user.setRoleIds("receiving".equals(dept.getDeptType()) ? "5" : "6"); + user.setStatus(1); // 启用用户 + + userMapper.updateById(user); + log.info("钉钉用户部门设置完成: userId={}, deptName={}, roleIds={}", + userId, dept.getDeptName(), user.getRoleIds()); + + // 4. 生成Token并返回 + String token = jwtUtil.generateToken(user.getId(), user.getUsername()); + String refreshToken = jwtUtil.generateRefreshToken(user.getId(), user.getUsername()); + + return LoginVO.builder() + .token(token) + .refreshToken(refreshToken) + .userId(user.getId()) + .username(user.getUsername()) + .realName(user.getRealName()) + .avatar(user.getAvatar()) + .roleIds(user.getRoleIds()) + .build(); + } + + /** + * 用户登录 + */ + public LoginVO login(LoginDTO loginDTO) { + String loginType = loginDTO.getLoginType(); + + if ("password".equals(loginType)) { + // 用户名密码登录 + User user = getUserByUsername(loginDTO.getUsername()); + if (user == null) { + throw new BusinessException(ResultCode.USER_NOT_EXIST); + } + + if (!passwordEncoder.matches(loginDTO.getPassword(), user.getPassword())) { + throw new BusinessException(ResultCode.PASSWORD_ERROR); + } + + if (user.getStatus() == 0) { + throw new BusinessException(ResultCode.USER_DISABLED); + } + + // 生成Token + String token = jwtUtil.generateToken(user.getId(), user.getUsername()); + String refreshToken = jwtUtil.generateRefreshToken(user.getId(), user.getUsername()); + + return LoginVO.builder() + .token(token) + .refreshToken(refreshToken) + .userId(user.getId()) + .username(user.getUsername()) + .realName(user.getRealName()) + .avatar(user.getAvatar()) + .roleIds(user.getRoleIds()) + .build(); + } else if ("dingtalk".equals(loginType)) { + // 钉钉OAuth 2.0登录 + return handleDingTalkLogin(loginDTO.getAuthCode()); + } else if ("casdoor".equals(loginType)) { + // Casdoor登录 - 暂时返回错误,待后续实现 + throw new BusinessException(ResultCode.PARAM_ERROR, "Casdoor登录功能尚未开放"); + } else { + throw new BusinessException(ResultCode.PARAM_ERROR, "不支持的登录类型"); + } + } + + /** + * 根据Token获取用户信息 + */ + public UserInfoVO getUserInfoByToken(String token) { + if (!StringUtils.hasText(token)) { + throw new BusinessException(ResultCode.UNAUTHORIZED); + } + + if (!jwtUtil.validateToken(token)) { + throw new BusinessException(ResultCode.UNAUTHORIZED, "Token无效或已过期"); + } + + String username = jwtUtil.getUsernameFromToken(token); + log.info("getUserInfoByToken - username: {}", username); + + User user = getUserByUsername(username); + if (user == null) { + throw new BusinessException(ResultCode.USER_NOT_EXIST); + } + + log.info("getUserInfoByToken - 用户: id={}, username={}, deptId={}, deptName={}", + user.getId(), user.getUsername(), user.getDeptId(), user.getDeptName()); + + // 获取用户角色代码列表 + List roleCodes = List.of(); + try { + var roles = roleService.getRolesByUserId(user.getId()); + roleCodes = roles.stream() + .map(role -> role.getRoleCode()) + .toList(); + log.info("getUserInfoByToken - 用户角色: {}", roleCodes); + } catch (Exception e) { + log.warn("获取用户角色失败: userId={}, error={}", user.getId(), e.getMessage()); + } + + // 检查是否为部门负责人 + Integer isDepartmentLeader = 0; + try { + boolean isLeader = departmentService.isUserDepartmentLeader(user.getId()); + isDepartmentLeader = isLeader ? 1 : 0; + log.info("getUserInfoByToken - 是否为部门负责人: userId={}, isLeader={}", user.getId(), isLeader); + } catch (Exception e) { + log.warn("检查部门负责人状态失败: userId={}, error={}", user.getId(), e.getMessage()); + } + + UserInfoVO userInfoVO = UserInfoVO.builder() + .id(user.getId()) + .username(user.getUsername()) + .realName(user.getRealName()) + .email(user.getEmail()) + .phone(user.getPhone()) + .avatar(user.getAvatar()) + .deptId(user.getDeptId()) + .deptName(user.getDeptName()) + .roleIds(user.getRoleIds()) + .roles(roleCodes) + .status(user.getStatus()) + .isDepartmentLeader(isDepartmentLeader) + .build(); + + log.info("getUserInfoByToken - 返回VO: id={}, deptId={}, deptName={}, roles={}, isDepartmentLeader={}", + userInfoVO.getId(), userInfoVO.getDeptId(), userInfoVO.getDeptName(), roleCodes, isDepartmentLeader); + + return userInfoVO; + } + + /** + * 刷新Token + */ + public LoginVO refreshToken(String authHeader) { + String token = jwtUtil.extractTokenFromHeader(authHeader); + + if (!StringUtils.hasText(token)) { + throw new BusinessException(ResultCode.UNAUTHORIZED); + } + + if (!jwtUtil.validateToken(token)) { + throw new BusinessException(ResultCode.UNAUTHORIZED, "Token无效或已过期"); + } + + String username = jwtUtil.getUsernameFromToken(token); + Long userId = jwtUtil.getUserIdFromToken(token); + + User user = getUserById(userId); + if (user == null) { + throw new BusinessException(ResultCode.USER_NOT_EXIST); + } + + // 生成新的Token + String newToken = jwtUtil.generateToken(userId, username); + String newRefreshToken = jwtUtil.generateRefreshToken(userId, username); + + return LoginVO.builder() + .token(newToken) + .refreshToken(newRefreshToken) + .userId(user.getId()) + .username(user.getUsername()) + .realName(user.getRealName()) + .avatar(user.getAvatar()) + .roleIds(user.getRoleIds()) + .build(); + } +} diff --git a/backend/src/main/java/com/designhub/util/ExcelExportUtil.java b/backend/src/main/java/com/designhub/util/ExcelExportUtil.java new file mode 100644 index 0000000..61b0a0c --- /dev/null +++ b/backend/src/main/java/com/designhub/util/ExcelExportUtil.java @@ -0,0 +1,190 @@ +package com.designhub.util; + +import jakarta.servlet.http.HttpServletResponse; +import org.apache.poi.ss.usermodel.*; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; + +import java.io.IOException; +import java.io.OutputStream; +import java.lang.reflect.Field; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; + +/** + * Excel导出工具类 + */ +public class ExcelExportUtil { + + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + /** + * 导出Excel到HTTP响应 + * + * @param response HTTP响应 + * @param fileName 文件名(不包含扩展名) + * @param sheetName 工作表名称 + * @param headers 表头数组 + * @param fieldNames 字段名数组(与表头对应) + * @param dataList 数据列表 + */ + public static void exportExcel( + HttpServletResponse response, + String fileName, + String sheetName, + String[] headers, + String[] fieldNames, + List dataList + ) throws IOException { + + // 创建工作簿 + Workbook workbook = new XSSFWorkbook(); + Sheet sheet = workbook.createSheet(sheetName); + + // 创建表头样式 + CellStyle headerStyle = createHeaderStyle(workbook); + CellStyle dataStyle = createDataStyle(workbook); + + // 创建表头行 + Row headerRow = sheet.createRow(0); + for (int i = 0; i < headers.length; i++) { + Cell cell = headerRow.createCell(i); + cell.setCellValue(headers[i]); + cell.setCellStyle(headerStyle); + sheet.setColumnWidth(i, 4000); // 设置列宽 + } + + // 填充数据 + for (int i = 0; i < dataList.size(); i++) { + Row row = sheet.createRow(i + 1); + T data = dataList.get(i); + + for (int j = 0; j < fieldNames.length; j++) { + Cell cell = row.createCell(j); + Object value = getFieldValue(data, fieldNames[j]); + setCellValue(cell, value); + cell.setCellStyle(dataStyle); + } + } + + // 设置响应头 + response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + response.setCharacterEncoding("UTF-8"); + String encodedFileName = URLEncoder.encode(fileName, StandardCharsets.UTF_8).replace("+", "%20"); + response.setHeader("Content-Disposition", "attachment; filename=" + encodedFileName + ".xlsx"); + + // 输出到响应流 + try (OutputStream out = response.getOutputStream()) { + workbook.write(out); + out.flush(); + } finally { + workbook.close(); + } + } + + /** + * 创建表头样式 + */ + private static CellStyle createHeaderStyle(Workbook workbook) { + CellStyle style = workbook.createCellStyle(); + + // 设置背景色 + style.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex()); + style.setFillPattern(FillPatternType.SOLID_FOREGROUND); + + // 设置边框 + style.setBorderTop(BorderStyle.THIN); + style.setBorderBottom(BorderStyle.THIN); + style.setBorderLeft(BorderStyle.THIN); + style.setBorderRight(BorderStyle.THIN); + + // 设置字体 + Font font = workbook.createFont(); + font.setBold(true); + font.setFontHeightInPoints((short) 12); + style.setFont(font); + + // 设置对齐 + style.setAlignment(HorizontalAlignment.CENTER); + style.setVerticalAlignment(VerticalAlignment.CENTER); + + return style; + } + + /** + * 创建数据样式 + */ + private static CellStyle createDataStyle(Workbook workbook) { + CellStyle style = workbook.createCellStyle(); + + // 设置边框 + style.setBorderTop(BorderStyle.THIN); + style.setBorderBottom(BorderStyle.THIN); + style.setBorderLeft(BorderStyle.THIN); + style.setBorderRight(BorderStyle.THIN); + + // 设置对齐 + style.setAlignment(HorizontalAlignment.LEFT); + style.setVerticalAlignment(VerticalAlignment.CENTER); + + // 设置自动换行 + style.setWrapText(false); + + return style; + } + + /** + * 通过反射获取字段值 + */ + private static Object getFieldValue(Object obj, String fieldName) { + try { + Field field = findField(obj.getClass(), fieldName); + if (field != null) { + field.setAccessible(true); + return field.get(obj); + } + } catch (Exception e) { + // 忽略异常 + } + return null; + } + + /** + * 查找字段(包括父类) + */ + private static Field findField(Class clazz, String fieldName) { + try { + return clazz.getDeclaredField(fieldName); + } catch (NoSuchFieldException e) { + if (clazz.getSuperclass() != null) { + return findField(clazz.getSuperclass(), fieldName); + } + } + return null; + } + + /** + * 设置单元格值 + */ + private static void setCellValue(Cell cell, Object value) { + if (value == null) { + cell.setCellValue(""); + return; + } + + if (value instanceof String) { + cell.setCellValue((String) value); + } else if (value instanceof Number) { + cell.setCellValue(((Number) value).doubleValue()); + } else if (value instanceof Boolean) { + cell.setCellValue((Boolean) value); + } else if (value instanceof LocalDateTime) { + cell.setCellValue(((LocalDateTime) value).format(DATE_FORMATTER)); + } else { + cell.setCellValue(value.toString()); + } + } +} + diff --git a/backend/src/main/java/com/designhub/util/JwtUtil.java b/backend/src/main/java/com/designhub/util/JwtUtil.java new file mode 100644 index 0000000..419e253 --- /dev/null +++ b/backend/src/main/java/com/designhub/util/JwtUtil.java @@ -0,0 +1,184 @@ +package com.designhub.util; + +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +/** + * JWT Token工具类 + */ +@Component +public class JwtUtil { + + // Token过期时间:7天 + private static final long EXPIRATION_TIME = 7 * 24 * 60 * 60 * 1000; + + // Refresh Token过期时间:30天 + private static final long REFRESH_EXPIRATION_TIME = 30 * 24 * 60 * 60 * 1000; + + @Value("${jwt.secret:designhub-secret-key-for-jwt-token-generation-please-change-in-production}") + private String secret; + + /** + * 生成密钥 + */ + private SecretKey getSecretKey() { + return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + } + + /** + * 生成Access Token + */ + public String generateToken(Long userId, String username) { + Map claims = new HashMap<>(); + claims.put("userId", userId); + claims.put("username", username); + claims.put("type", "access"); + + return Jwts.builder() + .setClaims(claims) + .setSubject(username) + .setIssuedAt(new Date()) + .setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME)) + .signWith(getSecretKey(), SignatureAlgorithm.HS256) + .compact(); + } + + /** + * 生成Refresh Token + */ + public String generateRefreshToken(Long userId, String username) { + Map claims = new HashMap<>(); + claims.put("userId", userId); + claims.put("username", username); + claims.put("type", "refresh"); + + return Jwts.builder() + .setClaims(claims) + .setSubject(username) + .setIssuedAt(new Date()) + .setExpiration(new Date(System.currentTimeMillis() + REFRESH_EXPIRATION_TIME)) + .signWith(getSecretKey(), SignatureAlgorithm.HS256) + .compact(); + } + + /** + * 从Token中获取用户ID + */ + public Long getUserIdFromToken(String token) { + Claims claims = getClaimsFromToken(token); + if (claims != null) { + Object userId = claims.get("userId"); + if (userId instanceof Integer) { + return ((Integer) userId).longValue(); + } else if (userId instanceof Long) { + return (Long) userId; + } + } + return null; + } + + /** + * 从Token中获取用户名 + */ + public String getUsernameFromToken(String token) { + Claims claims = getClaimsFromToken(token); + return claims != null ? (String) claims.get("username") : null; + } + + /** + * 从Token中获取类型 + */ + public String getTypeFromToken(String token) { + Claims claims = getClaimsFromToken(token); + return claims != null ? (String) claims.get("type") : null; + } + + /** + * 从Token中获取Claims + */ + private Claims getClaimsFromToken(String token) { + try { + return Jwts.parser() + .setSigningKey(getSecretKey()) + .build() + .parseClaimsJws(token) + .getBody(); + } catch (Exception e) { + return null; + } + } + + /** + * 验证Token是否有效 + */ + public boolean validateToken(String token) { + try { + Jwts.parser() + .setSigningKey(getSecretKey()) + .build() + .parseClaimsJws(token); + return true; + } catch (JwtException | IllegalArgumentException e) { + return false; + } + } + + /** + * 验证Token是否过期 + */ + public boolean isTokenExpired(String token) { + try { + Claims claims = getClaimsFromToken(token); + if (claims != null) { + Date expiration = claims.getExpiration(); + return expiration.before(new Date()); + } + } catch (Exception e) { + return true; + } + return true; + } + + /** + * 刷新Token(使用Refresh Token生成新的Access Token) + */ + public String refreshAccessToken(String refreshToken) { + if (validateToken(refreshToken)) { + String type = getTypeFromToken(refreshToken); + if ("refresh".equals(type)) { + Long userId = getUserIdFromToken(refreshToken); + String username = getUsernameFromToken(refreshToken); + if (userId != null && username != null) { + return generateToken(userId, username); + } + } + } + return null; + } + + /** + * 从Authorization请求头中解析Token + */ + public String parseTokenFromHeader(String authHeader) { + if (authHeader != null && authHeader.startsWith("Bearer ")) { + return authHeader.substring(7); + } + return null; + } + + /** + * 从Authorization请求头中提取Token(别名方法) + */ + public String extractTokenFromHeader(String authHeader) { + return parseTokenFromHeader(authHeader); + } +} + diff --git a/backend/src/main/java/com/designhub/vo/DashboardStatsVO.java b/backend/src/main/java/com/designhub/vo/DashboardStatsVO.java new file mode 100644 index 0000000..2be8d9f --- /dev/null +++ b/backend/src/main/java/com/designhub/vo/DashboardStatsVO.java @@ -0,0 +1,67 @@ +package com.designhub.vo; + +import lombok.Data; +import lombok.Builder; + +/** + * 工作台统计数据VO + */ +@Data +@Builder +public class DashboardStatsVO { + + /** + * 可接任务数 + */ + private Integer availableCount; + + /** + * 发布的任务数(当前用户当月发布的任务总数) + */ + private Integer publishedCount; + + /** + * 进行中任务数 + */ + private Integer inProgressCount; + + /** + * 已完成任务数 + */ + private Integer completedCount; + + /** + * 待审核任务数 + */ + private Integer reviewCount; + + /** + * 当日新增任务数 + */ + private Integer todayNewCount; + + /** + * 总积分 + */ + private Integer totalScore; + + /** + * 今日开始任务数 + */ + private Integer todayStartedCount; + + /** + * 今日完成任务数 + */ + private Integer todayCompletedCount; + + /** + * 本周完成任务数 + */ + private Integer weekCompletedCount; + + /** + * 上周完成任务数 + */ + private Integer lastWeekCompletedCount; +} diff --git a/backend/src/main/java/com/designhub/vo/DemandStatisticsVO.java b/backend/src/main/java/com/designhub/vo/DemandStatisticsVO.java new file mode 100644 index 0000000..1330c24 --- /dev/null +++ b/backend/src/main/java/com/designhub/vo/DemandStatisticsVO.java @@ -0,0 +1,141 @@ +package com.designhub.vo; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.util.List; + +/** + * 需求统计数据VO + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class DemandStatisticsVO { + + /** + * 用户维度统计 + */ + private List userStats; + + /** + * 部门维度统计 + */ + private List deptStats; + + /** + * 趋势数据 + */ + private List trendData; + + /** + * 类型分布 + */ + private List typeDistribution; + + /** + * 总览数据 + */ + private OverviewData overview; + + /** + * 用户需求统计 + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class UserDemandStat { + private Long userId; + private String userName; + private String deptName; + private Integer totalDemands; // 总需求数 + private Integer completedDemands; // 已完成数 + private Integer pendingDemands; // 进行中数 + private BigDecimal totalScore; // 总消耗积分 + private BigDecimal avgScore; // 平均积分 + private Double completionRate; // 完成率 + private Double satisfactionRate; // 满意度(五星好评率) + } + + /** + * 部门需求统计 + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class DeptDemandStat { + private Long deptId; + private String deptName; + private Integer totalDemands; // 总需求数 + private Integer completedDemands; // 已完成数 + private Integer pendingDemands; // 进行中数 + private BigDecimal totalScore; // 总消耗积分 + private BigDecimal remainingScore; // 剩余积分 + private BigDecimal dailyLimit; // 日限额 + private BigDecimal weeklyLimit; // 周限额 + private BigDecimal monthlyLimit; // 月限额 + private BigDecimal dailyUsed; // 今日已用 + private BigDecimal weeklyUsed; // 本周已用 + private BigDecimal monthlyUsed; // 本月已用 + private Double utilizationRate; // 积分使用率 + private String alertLevel; // 预警级别:safe/warning/danger + } + + /** + * 趋势数据 + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class TrendData { + private String date; // 日期 + private Integer demandCount; // 需求数量 + private Integer completedCount; // 完成数量 + private BigDecimal scoreUsed; // 积分消耗 + } + + /** + * 类型分布 + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class TypeDistribution { + private String typeName; // 类型名称 + private Integer count; // 数量 + private Double percentage; // 占比 + private BigDecimal totalScore; // 总积分 + } + + /** + * 总览数据 + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class OverviewData { + private Integer totalDemands; // 总需求数 + private Integer todayDemands; // 今日需求数 + private Integer weekDemands; // 本周需求数 + private Integer monthDemands; // 本月需求数 + private Integer completedDemands; // 已完成数 + private Integer pendingDemands; // 进行中数 + private Integer canceledDemands; // 已取消数 + private BigDecimal totalScoreUsed; // 总积分消耗 + private BigDecimal todayScoreUsed; // 今日积分消耗 + private BigDecimal weekScoreUsed; // 本周积分消耗 + private BigDecimal monthScoreUsed; // 本月积分消耗 + private Double avgCompletionTime; // 平均完成时长(小时) + private Double avgSatisfaction; // 平均满意度 + } +} + diff --git a/backend/src/main/java/com/designhub/vo/DepartmentOverviewVO.java b/backend/src/main/java/com/designhub/vo/DepartmentOverviewVO.java new file mode 100644 index 0000000..377daba --- /dev/null +++ b/backend/src/main/java/com/designhub/vo/DepartmentOverviewVO.java @@ -0,0 +1,36 @@ +package com.designhub.vo; + +import lombok.Data; + +import java.util.List; + +/** + * 部门概览 VO + */ +@Data +public class DepartmentOverviewVO { + private Long id; + private String deptName; + private String deptCode; + private String deptType; + private Long parentId; + private Long leaderId; + private String leaderName; + private String description; + private Integer sortOrder; + private Integer status; + private Integer scoreBalance; + private Integer totalRecharge; + private Integer totalConsume; + private Integer totalEarn; + private Integer limitEnabled; + private String limitPeriod; + private Integer periodLimit; + private Integer periodUsed; + private Integer alertThreshold; + private String overLimitAction; + private Boolean limitAlert; + private List allowedDemandTypes; + private Integer memberCount; +} + diff --git a/backend/src/main/java/com/designhub/vo/DesignerStatsVO.java b/backend/src/main/java/com/designhub/vo/DesignerStatsVO.java new file mode 100644 index 0000000..08847ee --- /dev/null +++ b/backend/src/main/java/com/designhub/vo/DesignerStatsVO.java @@ -0,0 +1,138 @@ +package com.designhub.vo; + +/** + * 设计师统计信息VO + * 注意:已移除@Data注解,改用手动getter/setter以解决Lombok编译问题 + */ +public class DesignerStatsVO { + + /** + * 设计师ID + */ + private Long userId; + + /** + * 设计师姓名 + */ + private String userName; + + /** + * 设计师头像 + */ + private String avatar; + + /** + * 部门名称 + */ + private String deptName; + + /** + * 当前进行中任务数 + */ + private Integer inProgressCount; + + /** + * 任务队列数(待处理) + */ + private Integer queueCount; + + /** + * 累计获得积分 + */ + private Integer totalScore; + + /** + * 延误任务数 + */ + private Integer delayedCount; + + /** + * 总接收任务数 + */ + private Integer totalTaskCount; + + /** + * 已完成任务数 + */ + private Integer completedCount; + + /** + * 平均评分 + */ + private Double avgRating; + + /** + * 好评数(5星) + */ + private Integer goodReviewCount; + + /** + * 完成率(百分比) + */ + private Double completionRate; + + /** + * 准时率(百分比) + */ + private Double ontimeRate; + + /** + * 在线状态:0-离线,1-在线 + */ + private Integer onlineStatus; + + /** + * 最后活跃时间 + */ + private String lastActiveTime; + + // Getters and Setters (手动添加以解决Lombok编译问题) + public Long getUserId() { return userId; } + public void setUserId(Long userId) { this.userId = userId; } + + public String getUserName() { return userName; } + public void setUserName(String userName) { this.userName = userName; } + + public String getAvatar() { return avatar; } + public void setAvatar(String avatar) { this.avatar = avatar; } + + public String getDeptName() { return deptName; } + public void setDeptName(String deptName) { this.deptName = deptName; } + + public Integer getInProgressCount() { return inProgressCount; } + public void setInProgressCount(Integer inProgressCount) { this.inProgressCount = inProgressCount; } + + public Integer getQueueCount() { return queueCount; } + public void setQueueCount(Integer queueCount) { this.queueCount = queueCount; } + + public Integer getTotalScore() { return totalScore; } + public void setTotalScore(Integer totalScore) { this.totalScore = totalScore; } + + public Integer getDelayedCount() { return delayedCount; } + public void setDelayedCount(Integer delayedCount) { this.delayedCount = delayedCount; } + + public Integer getTotalTaskCount() { return totalTaskCount; } + public void setTotalTaskCount(Integer totalTaskCount) { this.totalTaskCount = totalTaskCount; } + + public Integer getCompletedCount() { return completedCount; } + public void setCompletedCount(Integer completedCount) { this.completedCount = completedCount; } + + public Double getAvgRating() { return avgRating; } + public void setAvgRating(Double avgRating) { this.avgRating = avgRating; } + + public Integer getGoodReviewCount() { return goodReviewCount; } + public void setGoodReviewCount(Integer goodReviewCount) { this.goodReviewCount = goodReviewCount; } + + public Double getCompletionRate() { return completionRate; } + public void setCompletionRate(Double completionRate) { this.completionRate = completionRate; } + + public Double getOntimeRate() { return ontimeRate; } + public void setOntimeRate(Double ontimeRate) { this.ontimeRate = ontimeRate; } + + public Integer getOnlineStatus() { return onlineStatus; } + public void setOnlineStatus(Integer onlineStatus) { this.onlineStatus = onlineStatus; } + + public String getLastActiveTime() { return lastActiveTime; } + public void setLastActiveTime(String lastActiveTime) { this.lastActiveTime = lastActiveTime; } +} + diff --git a/backend/src/main/java/com/designhub/vo/LoginVO.java b/backend/src/main/java/com/designhub/vo/LoginVO.java new file mode 100644 index 0000000..364a20e --- /dev/null +++ b/backend/src/main/java/com/designhub/vo/LoginVO.java @@ -0,0 +1,64 @@ +package com.designhub.vo; + +import lombok.Data; +import lombok.Builder; + +/** + * 登录响应VO + */ +@Data +@Builder +public class LoginVO { + + /** + * 访问令牌 + */ + private String token; + + /** + * 刷新令牌 + */ + private String refreshToken; + + /** + * 令牌类型 + */ + @Builder.Default + private String tokenType = "Bearer"; + + /** + * 过期时间(毫秒) + */ + private Long expiresIn; + + /** + * 用户ID + */ + private Long userId; + + /** + * 用户名 + */ + private String username; + + /** + * 真实姓名 + */ + private String realName; + + /** + * 头像 + */ + private String avatar; + + /** + * 角色ID列表 + */ + private String roleIds; + + /** + * 用户信息(可选,兼容旧版) + */ + private UserInfoVO userInfo; +} + diff --git a/backend/src/main/java/com/designhub/vo/SystemHealthVO.java b/backend/src/main/java/com/designhub/vo/SystemHealthVO.java new file mode 100644 index 0000000..4c5a736 --- /dev/null +++ b/backend/src/main/java/com/designhub/vo/SystemHealthVO.java @@ -0,0 +1,73 @@ +package com.designhub.vo; + +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * 系统健康检查结果 + */ +@Data +public class SystemHealthVO { + + /** + * 当前运行环境 + */ + private String profile; + + /** + * 后端服务可用状态 + */ + private boolean backendUp = true; + + /** + * 数据库连接状态 + */ + private boolean databaseUp; + + /** + * 用户总数 + */ + private Long userCount; + + /** + * 任务总数 + */ + private Long taskCount; + + /** + * 角色总数 + */ + private Long roleCount; + + /** + * 部门总数 + */ + private Long departmentCount; + + /** + * 检查项列表 + */ + private List checks = new ArrayList<>(); + + /** + * 检查时间 + */ + private LocalDateTime checkedAt = LocalDateTime.now(); + + @Data + public static class CheckItem { + private String name; + private boolean success; + private String message; + + public CheckItem(String name, boolean success, String message) { + this.name = name; + this.success = success; + this.message = message; + } + } +} + diff --git a/backend/src/main/java/com/designhub/vo/UserInfoVO.java b/backend/src/main/java/com/designhub/vo/UserInfoVO.java new file mode 100644 index 0000000..a6767b3 --- /dev/null +++ b/backend/src/main/java/com/designhub/vo/UserInfoVO.java @@ -0,0 +1,79 @@ +package com.designhub.vo; + +import lombok.Data; +import lombok.Builder; +import java.util.List; + +/** + * 用户信息VO + */ +@Data +@Builder +public class UserInfoVO { + + /** + * 用户ID + */ + private Long id; + + /** + * 用户名 + */ + private String username; + + /** + * 真实姓名 + */ + private String realName; + + /** + * 邮箱 + */ + private String email; + + /** + * 手机号 + */ + private String phone; + + /** + * 头像URL + */ + private String avatar; + + /** + * 部门ID + */ + private Long deptId; + + /** + * 部门名称 + */ + private String deptName; + + /** + * 角色ID列表(逗号分隔) + */ + private String roleIds; + + /** + * 角色列表 + */ + private List roles; + + /** + * 权限列表 + */ + private List permissions; + + /** + * 用户状态:0-禁用 1-启用 + */ + private Integer status; + + /** + * 是否为部门负责人:0-否 1-是 + */ + private Integer isDepartmentLeader; +} + diff --git a/backend/src/main/resources/application-dev.yml b/backend/src/main/resources/application-dev.yml new file mode 100644 index 0000000..3aaec7e --- /dev/null +++ b/backend/src/main/resources/application-dev.yml @@ -0,0 +1,94 @@ +# ================================ +# 开发环境配置 +# ================================ + +spring: + # 数据源配置 + datasource: + url: jdbc:mysql://localhost:3306/design_hub?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false + username: root + password: 123456 # 请修改为您的本地 MySQL 密码 + driver-class-name: com.mysql.cj.jdbc.Driver + + # 连接池配置 + hikari: + minimum-idle: 5 + maximum-pool-size: 20 + connection-timeout: 30000 + idle-timeout: 600000 + max-lifetime: 1800000 + + # Redis 配置 + redis: + host: localhost + port: 6379 + password: # 本地开发如果没有密码,留空 + database: 0 + timeout: 3000 + lettuce: + pool: + max-active: 8 + max-wait: -1 + max-idle: 8 + min-idle: 0 + + # 文件上传配置 + servlet: + multipart: + enabled: true + max-file-size: 50MB + max-request-size: 100MB + +# JWT 配置 +jwt: + secret: DesignHub_Dev_Secret_Key_2025 # 开发环境密钥 + access-token-expiration: 7200000 # 2小时 + refresh-token-expiration: 604800000 # 7天 + +# 服务器配置 +server: + port: 8080 + servlet: + context-path: / + encoding: + charset: UTF-8 + enabled: true + force: true + +# 日志配置 +logging: + level: + root: INFO + com.designhub: DEBUG # 开发环境显示详细日志 + org.springframework.web: DEBUG + org.springframework.security: DEBUG + pattern: + console: '%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n' + +# 文件存储配置 +file: + upload: + # 本地开发路径(项目目录下) + path: ./uploads + max-size: 52428800 # 50MB + allowed-extensions: .xls,.xlsx,.doc,.docx,.pdf,.zip + +# MyBatis Plus 配置 +mybatis-plus: + configuration: + log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 打印 SQL + map-underscore-to-camel-case: true # 开启下划线转驼峰映射 + global-config: + db-config: + # 禁用逻辑删除功能 + logic-delete-field: "" + logic-delete-value: 1 + logic-not-delete-value: 0 + +# Swagger 配置(开发环境启用) +springdoc: + swagger-ui: + enabled: true + path: /swagger-ui.html + api-docs: + enabled: true diff --git a/backend/src/main/resources/application-prod.yml b/backend/src/main/resources/application-prod.yml new file mode 100644 index 0000000..b27f0e9 --- /dev/null +++ b/backend/src/main/resources/application-prod.yml @@ -0,0 +1,101 @@ +# ================================ +# 生产环境配置 +# ================================ + +spring: + # 数据源配置 + datasource: + url: jdbc:mysql://localhost:3306/designhub?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true + username: designhub + password: DesignHub@2025_DB # 生产环境数据库密码(建议修改) + driver-class-name: com.mysql.cj.jdbc.Driver + + # 连接池配置(生产环境优化) + hikari: + minimum-idle: 10 + maximum-pool-size: 50 + connection-timeout: 30000 + idle-timeout: 600000 + max-lifetime: 1800000 + + # Redis 配置 + redis: + host: localhost + port: 6379 + password: DesignHub@2025_Redis # Redis 密码 + database: 0 + timeout: 3000 + lettuce: + pool: + max-active: 20 + max-wait: -1 + max-idle: 10 + min-idle: 5 + + # 文件上传配置 + servlet: + multipart: + enabled: true + max-file-size: 100MB + max-request-size: 100MB + +# JWT 配置 +jwt: + # ⚠️ 重要:生产环境必须修改为强密钥 + secret: DesignHub_Prod_Secret_Key_2025_Very_Strong_Change_This_In_Production + access-token-expiration: 7200000 # 2小时 + refresh-token-expiration: 604800000 # 7天 + +# 服务器配置 +server: + port: 5123 + servlet: + context-path: / + encoding: + charset: UTF-8 + enabled: true + force: true + # 生产环境压缩 + compression: + enabled: true + mime-types: text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json + +# 日志配置 +logging: + level: + root: INFO + com.designhub: INFO # 生产环境只显示 INFO 级别 + file: + name: /opt/designhub/logs/application.log + max-size: 10MB + max-history: 30 + pattern: + file: '%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n' + +# 文件存储配置 +file: + upload: + # 生产环境路径 + path: /opt/designhub/uploads + max-size: 104857600 # 100MB + allowed-extensions: .xls,.xlsx,.doc,.docx,.pdf,.zip + +# MyBatis Plus 配置 +mybatis-plus: + configuration: + log-impl: # 生产环境不打印 SQL + map-underscore-to-camel-case: true # 开启下划线转驼峰映射 + global-config: + db-config: + # 禁用逻辑删除功能 + logic-delete-field: "" + logic-delete-value: 1 + logic-not-delete-value: 0 + +# Swagger 配置(生产环境禁用) +springdoc: + swagger-ui: + enabled: false + api-docs: + enabled: false + diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml new file mode 100644 index 0000000..9d43713 --- /dev/null +++ b/backend/src/main/resources/application.yml @@ -0,0 +1,32 @@ +# ================================ +# 主配置文件 +# ================================ + +spring: + application: + name: designhub-backend + + # 激活的配置文件 + # 开发环境使用 dev,生产环境使用 prod + profiles: + active: dev + + # Jackson 配置 + jackson: + date-format: yyyy-MM-dd HH:mm:ss + time-zone: GMT+8 + default-property-inclusion: non_null + serialization: + write-dates-as-timestamps: false + + # 国际化配置 + messages: + basename: i18n/messages + encoding: UTF-8 + +# 应用信息 +info: + app: + name: DesignHub + description: 设计师任务管理平台 + version: 6.0 diff --git a/backend/src/main/resources/db/migration/V8__add_incentive_tables.sql b/backend/src/main/resources/db/migration/V8__add_incentive_tables.sql new file mode 100644 index 0000000..785632a --- /dev/null +++ b/backend/src/main/resources/db/migration/V8__add_incentive_tables.sql @@ -0,0 +1,235 @@ +-- 竞赛活动表 +CREATE TABLE IF NOT EXISTS `contest` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', + `name` VARCHAR(100) NOT NULL COMMENT '竞赛名称', + `description` TEXT COMMENT '竞赛描述', + `contest_type` VARCHAR(20) NOT NULL DEFAULT 'monthly' COMMENT '竞赛类型:monthly-月度,weekly-周度,special-专项', + `start_time` DATETIME NOT NULL COMMENT '开始时间', + `end_time` DATETIME NOT NULL COMMENT '结束时间', + `participant_scope` VARCHAR(20) NOT NULL DEFAULT 'all' COMMENT '参与范围:all-全体,department-部门,user-指定', + `department_ids` JSON COMMENT '指定部门ID列表', + `user_ids` JSON COMMENT '指定用户ID列表', + `rules` JSON COMMENT '竞赛规则配置', + `rewards` JSON COMMENT '奖励配置', + `participation_reward` JSON COMMENT '参与奖配置', + `status` VARCHAR(20) NOT NULL DEFAULT 'draft' COMMENT '状态:draft-草稿,scheduled-待开始,active-进行中,ended-已结束,cancelled-已取消', + `participant_count` INT NOT NULL DEFAULT 0 COMMENT '参与人数', + `creator_id` BIGINT COMMENT '创建人ID', + `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '是否删除', + `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + INDEX `idx_status` (`status`), + INDEX `idx_start_time` (`start_time`), + INDEX `idx_end_time` (`end_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='竞赛活动表'; + +-- 竞赛参与者表 +CREATE TABLE IF NOT EXISTS `contest_participant` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', + `contest_id` BIGINT NOT NULL COMMENT '竞赛ID', + `user_id` BIGINT NOT NULL COMMENT '用户ID', + `user_name` VARCHAR(50) COMMENT '用户姓名', + `dept_id` BIGINT COMMENT '部门ID', + `dept_name` VARCHAR(100) COMMENT '部门名称', + `task_completed` INT NOT NULL DEFAULT 0 COMMENT '完成任务数', + `five_star_count` INT NOT NULL DEFAULT 0 COMMENT '五星好评数', + `early_completion_count` INT NOT NULL DEFAULT 0 COMMENT '提前完成次数', + `praise_count` INT NOT NULL DEFAULT 0 COMMENT '被表扬次数', + `no_delay_days` INT NOT NULL DEFAULT 0 COMMENT '无延误天数', + `total_score` INT NOT NULL DEFAULT 0 COMMENT '总得分', + `current_rank` INT NOT NULL DEFAULT 0 COMMENT '当前排名', + `reward_claimed` TINYINT NOT NULL DEFAULT 0 COMMENT '是否已领取奖励', + `reward_score` INT DEFAULT 0 COMMENT '奖励积分', + `reward_claim_time` DATETIME COMMENT '奖励领取时间', + `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE INDEX `uk_contest_user` (`contest_id`, `user_id`), + INDEX `idx_contest_id` (`contest_id`), + INDEX `idx_user_id` (`user_id`), + INDEX `idx_total_score` (`total_score`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='竞赛参与者表'; + +-- 用户目标表(如果不存在则创建,如果存在则添加缺失字段) +CREATE TABLE IF NOT EXISTS `user_goal` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', + `user_id` BIGINT NOT NULL COMMENT '用户ID', + `goal_type` VARCHAR(30) NOT NULL COMMENT '目标类型:task_count-任务数,score-积分,rank-排名,rating-评分', + `goal_name` VARCHAR(100) NOT NULL COMMENT '目标名称', + `description` VARCHAR(500) COMMENT '目标描述', + `target_value` INT NOT NULL COMMENT '目标值', + `current_value` INT NOT NULL DEFAULT 0 COMMENT '当前进度值', + `period` VARCHAR(20) NOT NULL DEFAULT 'monthly' COMMENT '周期:daily,weekly,monthly,quarterly,yearly,custom', + `period_start` DATETIME COMMENT '周期开始时间', + `period_end` DATETIME COMMENT '周期结束时间', + `status` VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT '状态:active-进行中,completed-已完成,failed-未完成,cancelled-已取消', + `completed_time` DATETIME COMMENT '完成时间', + `reward_score` INT DEFAULT 0 COMMENT '奖励积分', + `reward_claimed` TINYINT NOT NULL DEFAULT 0 COMMENT '是否已领取奖励', + `reminder_type` VARCHAR(20) DEFAULT 'none' COMMENT '提醒设置:none,daily,before_deadline', + `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '是否删除', + `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + INDEX `idx_user_id` (`user_id`), + INDEX `idx_status` (`status`), + INDEX `idx_period_end` (`period_end`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户目标表'; + +-- 团队PK表 +CREATE TABLE IF NOT EXISTS `team_pk` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', + `name` VARCHAR(100) NOT NULL COMMENT 'PK名称', + `description` TEXT COMMENT 'PK描述', + `team_a_dept_id` BIGINT NOT NULL COMMENT '甲方部门ID', + `team_a_name` VARCHAR(100) COMMENT '甲方部门名称', + `team_b_dept_id` BIGINT NOT NULL COMMENT '乙方部门ID', + `team_b_name` VARCHAR(100) COMMENT '乙方部门名称', + `start_time` DATETIME NOT NULL COMMENT '开始时间', + `end_time` DATETIME NOT NULL COMMENT '结束时间', + `dimensions` JSON COMMENT 'PK维度配置', + `team_a_score` INT NOT NULL DEFAULT 0 COMMENT '甲方得分', + `team_b_score` INT NOT NULL DEFAULT 0 COMMENT '乙方得分', + `team_a_task_count` INT NOT NULL DEFAULT 0 COMMENT '甲方完成任务数', + `team_b_task_count` INT NOT NULL DEFAULT 0 COMMENT '乙方完成任务数', + `team_a_five_star_count` INT NOT NULL DEFAULT 0 COMMENT '甲方五星好评数', + `team_b_five_star_count` INT NOT NULL DEFAULT 0 COMMENT '乙方五星好评数', + `team_a_on_time_rate` INT NOT NULL DEFAULT 0 COMMENT '甲方准时完成率', + `team_b_on_time_rate` INT NOT NULL DEFAULT 0 COMMENT '乙方准时完成率', + `rewards` JSON COMMENT '奖励配置', + `status` VARCHAR(20) NOT NULL DEFAULT 'draft' COMMENT '状态:draft-草稿,scheduled-待开始,active-进行中,ended-已结束,cancelled-已取消', + `winner` VARCHAR(10) COMMENT '获胜方:A-甲方,B-乙方,draw-平局', + `mvp_user_id` BIGINT COMMENT 'MVP用户ID', + `creator_id` BIGINT COMMENT '创建人ID', + `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '是否删除', + `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + INDEX `idx_status` (`status`), + INDEX `idx_team_a` (`team_a_dept_id`), + INDEX `idx_team_b` (`team_b_dept_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='团队PK表'; + +-- 自动奖励规则表 +CREATE TABLE IF NOT EXISTS `reward_rule` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', + `name` VARCHAR(100) NOT NULL COMMENT '规则名称', + `description` VARCHAR(500) COMMENT '规则描述', + `rule_type` VARCHAR(20) NOT NULL COMMENT '规则类型:daily-每日,weekly-每周,monthly-每月,task-任务,rating-好评', + `trigger_condition` JSON NOT NULL COMMENT '触发条件', + `reward_type` VARCHAR(20) NOT NULL DEFAULT 'score' COMMENT '奖励类型:score-积分,badge-徽章,both-两者', + `reward_score` INT DEFAULT 0 COMMENT '奖励积分数', + `reward_badge` JSON COMMENT '奖励徽章', + `scope` VARCHAR(20) NOT NULL DEFAULT 'all' COMMENT '适用范围:all-全员,designer-设计师,department-指定部门', + `scope_dept_ids` JSON COMMENT '指定部门ID列表', + `enabled` TINYINT NOT NULL DEFAULT 1 COMMENT '是否启用', + `cron_expression` VARCHAR(50) COMMENT '执行周期cron表达式', + `last_execute_time` DATETIME COMMENT '上次执行时间', + `priority` INT NOT NULL DEFAULT 0 COMMENT '优先级', + `creator_id` BIGINT COMMENT '创建人ID', + `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '是否删除', + `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + INDEX `idx_rule_type` (`rule_type`), + INDEX `idx_enabled` (`enabled`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='自动奖励规则表'; + +-- 激励积分池表 +CREATE TABLE IF NOT EXISTS `score_pool` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', + `name` VARCHAR(100) NOT NULL COMMENT '积分池名称', + `pool_type` VARCHAR(20) NOT NULL COMMENT '积分池类型:monthly-月度,quarterly-季度,yearly-年度,special-专项', + `year` INT NOT NULL COMMENT '周期年份', + `month` INT COMMENT '周期月份', + `quarter` INT COMMENT '周期季度', + `total_budget` INT NOT NULL DEFAULT 0 COMMENT '总预算积分', + `used_score` INT NOT NULL DEFAULT 0 COMMENT '已使用积分', + `remaining_score` INT NOT NULL DEFAULT 0 COMMENT '剩余积分', + `task_reward_budget` INT NOT NULL DEFAULT 0 COMMENT '任务奖励预算', + `task_reward_used` INT NOT NULL DEFAULT 0 COMMENT '任务奖励已使用', + `rating_reward_budget` INT NOT NULL DEFAULT 0 COMMENT '好评奖励预算', + `rating_reward_used` INT NOT NULL DEFAULT 0 COMMENT '好评奖励已使用', + `contest_reward_budget` INT NOT NULL DEFAULT 0 COMMENT '竞赛奖励预算', + `contest_reward_used` INT NOT NULL DEFAULT 0 COMMENT '竞赛奖励已使用', + `checkin_reward_budget` INT NOT NULL DEFAULT 0 COMMENT '签到奖励预算', + `checkin_reward_used` INT NOT NULL DEFAULT 0 COMMENT '签到奖励已使用', + `other_reward_budget` INT NOT NULL DEFAULT 0 COMMENT '其他奖励预算', + `other_reward_used` INT NOT NULL DEFAULT 0 COMMENT '其他奖励已使用', + `overflow_strategy` VARCHAR(20) NOT NULL DEFAULT 'alert' COMMENT '超支策略:stop-停止,alert-提醒,auto_increase-自动增加', + `alert_threshold` INT DEFAULT 80 COMMENT '预警阈值百分比', + `status` VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT '状态:active-进行中,closed-已关闭,settled-已结算', + `creator_id` BIGINT COMMENT '创建人ID', + `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '是否删除', + `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + INDEX `idx_pool_type` (`pool_type`), + INDEX `idx_year_month` (`year`, `month`), + INDEX `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='激励积分池表'; + +-- 积分池使用记录表 +CREATE TABLE IF NOT EXISTS `score_pool_record` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', + `pool_id` BIGINT NOT NULL COMMENT '积分池ID', + `user_id` BIGINT NOT NULL COMMENT '用户ID', + `user_name` VARCHAR(50) COMMENT '用户姓名', + `reward_type` VARCHAR(20) NOT NULL COMMENT '奖励类型:task,rating,contest,checkin,goal,other', + `score_change` INT NOT NULL COMMENT '积分变动', + `before_remaining` INT COMMENT '变动前剩余', + `after_remaining` INT COMMENT '变动后剩余', + `biz_type` VARCHAR(50) COMMENT '关联业务类型', + `biz_id` BIGINT COMMENT '关联业务ID', + `remark` VARCHAR(500) COMMENT '备注', + `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`), + INDEX `idx_pool_id` (`pool_id`), + INDEX `idx_user_id` (`user_id`), + INDEX `idx_reward_type` (`reward_type`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='积分池使用记录表'; + +-- 钉钉配置表 +CREATE TABLE IF NOT EXISTS `dingtalk_config` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', + `name` VARCHAR(100) NOT NULL DEFAULT '默认配置' COMMENT '配置名称', + `agent_id` VARCHAR(100) COMMENT '应用AgentId', + `app_key` VARCHAR(100) COMMENT '应用AppKey', + `app_secret` VARCHAR(200) COMMENT '应用AppSecret', + `corp_id` VARCHAR(100) COMMENT '企业ID', + `robot_webhook` VARCHAR(500) COMMENT '机器人Webhook', + `robot_secret` VARCHAR(200) COMMENT '机器人密钥', + `enabled` TINYINT NOT NULL DEFAULT 0 COMMENT '是否启用', + `enabled_features` JSON COMMENT '启用功能配置', + `access_token` VARCHAR(500) COMMENT 'AccessToken缓存', + `token_expire_time` DATETIME COMMENT 'Token过期时间', + `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='钉钉配置表'; + +-- 插入当月积分池 +INSERT INTO `score_pool` (`name`, `pool_type`, `year`, `month`, `total_budget`, `remaining_score`, + `task_reward_budget`, `rating_reward_budget`, `contest_reward_budget`, `checkin_reward_budget`, `other_reward_budget`, + `overflow_strategy`, `alert_threshold`, `status`) VALUES +(CONCAT(YEAR(NOW()), '年', MONTH(NOW()), '月激励积分池'), 'monthly', YEAR(NOW()), MONTH(NOW()), 100000, 100000, + 40000, 20000, 15000, 10000, 15000, 'alert', 80, 'active'); + +-- 插入默认奖励规则 +INSERT INTO `reward_rule` (`name`, `description`, `rule_type`, `trigger_condition`, `reward_type`, `reward_score`, `scope`, `enabled`, `priority`) VALUES +('每日任务达人', '每天完成5个任务,额外奖励20积分', 'daily', '{"type":"task_count","operator":">=","value":5}', 'score', 20, 'all', 1, 100), +('每周效率之星', '每周完成20个任务,额外奖励100积分', 'weekly', '{"type":"task_count","operator":">=","value":20}', 'score', 100, 'all', 1, 90), +('每月MVP', '每月完成80个任务,额外奖励500积分', 'monthly', '{"type":"task_count","operator":">=","value":80}', 'score', 500, 'all', 1, 80), +('五星好评奖励', '获得五星好评,奖励10积分', 'rating', '{"type":"rating","operator":">=","value":5}', 'score', 10, 'all', 1, 70), +('好评达人', '累计获得10个五星好评,奖励50积分', 'task', '{"type":"five_star_count","operator":">=","value":10}', 'score', 50, 'all', 1, 60); + +-- 插入示例竞赛数据 +INSERT INTO `contest` (`name`, `description`, `contest_type`, `start_time`, `end_time`, `participant_scope`, `rules`, `rewards`, `status`, `participant_count`) VALUES +('12月设计之星挑战赛', '月度设计竞赛,争夺月度设计之星称号!', 'monthly', + DATE_FORMAT(CONCAT(YEAR(NOW()), '-', MONTH(NOW()), '-01'), '%Y-%m-%d 00:00:00'), + LAST_DAY(NOW()), + 'all', + '{"taskCompleted":{"score":2,"weight":30},"fiveStarReview":{"score":10,"weight":25},"earlyCompletion":{"score":5,"weight":20},"praise":{"score":15,"weight":15}}', + '[{"rank":1,"score":1500,"badge":"月度冠军"},{"rank":2,"score":1000,"badge":"月度亚军"},{"rank":3,"score":800,"badge":"月度季军"},{"rank":10,"score":300,"badge":"优秀设计师"}]', + 'active', 0); diff --git a/database/.gitkeep_if_needed b/database/.gitkeep_if_needed new file mode 100644 index 0000000..e69de29 diff --git a/database/README_DATABASE_SYNC.md b/database/README_DATABASE_SYNC.md new file mode 100644 index 0000000..60b3139 --- /dev/null +++ b/database/README_DATABASE_SYNC.md @@ -0,0 +1,170 @@ +# 数据库结构同步方案 + +## 问题说明 + +每次部署数据库时,经常会遇到字段缺失的问题,导致应用报错。这是因为: +1. 数据库脚本分散在多个文件中 +2. 没有统一的数据库初始化流程 +3. 缺少自动化的数据库同步机制 + +## 解决方案 + +我们提供了两种数据库同步方案: + +### 方案一:使用同步脚本(推荐) + +**适用场景**:已有数据库,需要添加缺失的字段 + +**使用方法**: + +```bash +# 1. 设置数据库连接信息(可选,默认使用配置的默认值) +export DB_HOST=localhost +export DB_USER=root +export DB_PASS=DesignHub@2025_Root +export DB_NAME=designhub + +# 2. 执行同步脚本 +cd database +./sync_database.sh +``` + +**脚本功能**: +- ✅ 自动检测每个字段是否存在 +- ✅ 只添加缺失的字段,不影响已有数据 +- ✅ 可以安全地重复执行 +- ✅ 提供详细的执行日志 + +### 方案二:使用SQL脚本 + +**适用场景**:全新安装或需要完整重建数据库 + +**使用方法**: + +```bash +# 1. 执行完整的schema脚本 +mysql -uroot -p designhub < database/schema.sql + +# 2. 执行初始化数据脚本 +mysql -uroot -p designhub < database/init-data.sql + +# 3. 执行同步脚本(添加缺失字段) +mysql -uroot -p designhub < database/sync_database_structure.sql +``` + +**注意**:`sync_database_structure.sql` 使用了存储过程,适用于MySQL 5.7+版本。 + +## 部署集成 + +### 在部署脚本中自动执行 + +修改部署脚本(如 `deploy_to_public_server.sh`),在部署后端之前执行数据库同步: + +```bash +# 在部署脚本中添加 +echo "同步数据库结构..." +sshpass -p 'PASSWORD' ssh root@SERVER_IP "cd /opt/designhub/database && bash sync_database.sh" +``` + +### 在应用启动时检查(可选) + +可以在Spring Boot应用启动时添加数据库检查逻辑,但这会增加启动时间,不推荐。 + +## 维护说明 + +### 添加新字段时 + +1. **修改实体类**:在对应的Entity类中添加字段 +2. **更新同步脚本**:在 `sync_database.sh` 中添加对应的 `sync_column` 调用 +3. **更新SQL脚本**(可选):在 `schema.sql` 中添加字段定义 +4. **测试**:在测试环境执行同步脚本,验证无误后再部署到生产环境 + +### 示例:添加新字段到task表 + +```bash +# 在 sync_database.sh 中添加 +sync_column "task" "new_field" "VARCHAR(255) NULL COMMENT '新字段说明'" "existing_field" +``` + +## 字段列表 + +当前同步脚本会检查并添加以下字段: + +### sys_user 表 +- `dept_name` - 部门名称 +- `score` - 用户积分 + +### task 表 +- `third_party_task_no` - 第三方平台任务编号 +- `task_type_ids` - 任务类型ID列表 +- `score_reward` - 实际积分奖励 +- `estimated_duration` - 预估时长 +- `demand_type_id` - 需求类型ID +- `actual_start_time` - 实际开始时间 +- `submit_time` - 提交审核时间 +- `actual_end_time` - 实际结束时间 +- `priority` - 优先级 +- `remark` - 备注信息 +- `is_pipeline` - 是否流水线任务 +- `pipeline_mode` - 流水线模式 +- `approval_status` - 审核状态 +- `current_approver_id` - 当前审核人ID +- `current_approver_role` - 当前审核人角色 +- `demand_approval_time` - 需求部门审核时间 +- `receiving_approval_time` - 接单部门审核时间 + +### task_type 表 +- `allowed_dept_ids` - 可接单部门ID列表 + +### category 表 +- `description` - 描述 + +### team_score_pool 表 +- `dept_id` - 部门ID +- `total_recharge` - 累计充值积分 +- `total_consume` - 累计消耗积分 +- `total_earn` - 累计获得积分 +- `period_limit` - 周期限额 +- `period_used` - 当前周期已消耗 +- `period_start_time` - 当前周期开始时间 +- `alert_threshold` - 预警阈值 +- `limit_enabled` - 是否启用限制 +- `limit_period` - 限制周期 + +### achievement 表 +- `achievement_code` - 成就代码 +- `achievement_name` - 成就名称 + +### user_achievement 表 +- `progress_value` - 当前进度值 +- `reward_claimed` - 奖励是否已领取 +- `claim_time` - 领取时间 + +## 故障排查 + +### 问题:脚本执行失败,提示权限错误 + +**解决**:确保MySQL用户有ALTER TABLE权限 + +```sql +GRANT ALTER ON designhub.* TO 'your_user'@'localhost'; +FLUSH PRIVILEGES; +``` + +### 问题:字段添加失败,提示字段已存在 + +**解决**:这是正常情况,脚本会跳过已存在的字段 + +### 问题:需要添加的字段不在列表中 + +**解决**: +1. 在 `sync_database.sh` 中添加对应的 `sync_column` 调用 +2. 或者在MySQL中手动执行ALTER TABLE语句 + +## 最佳实践 + +1. **定期同步**:每次部署前执行同步脚本 +2. **版本控制**:将同步脚本纳入版本控制 +3. **测试先行**:在测试环境验证后再部署到生产环境 +4. **备份数据**:在生产环境执行前先备份数据库 +5. **文档同步**:添加新字段时及时更新本文档 diff --git a/database/init-data.sql b/database/init-data.sql new file mode 100644 index 0000000..27f32b8 --- /dev/null +++ b/database/init-data.sql @@ -0,0 +1,162 @@ +-- DesignHub 初始化数据 +-- 版本: v1.0 + +USE `designhub`; + +-- 1. 初始化角色数据 +INSERT INTO `sys_role` (`role_name`, `role_code`, `description`) VALUES +('超级管理员', 'SUPER_ADMIN', '拥有系统所有权限'), +('管理员', 'ADMIN', '任务调配和系统配置权限'), +('需求部门负责人', 'DEMAND_DEPT_HEAD', '部门积分管理和需求统计查看'), +('接单部门主管', 'RECEIVING_DEPT_SUPERVISOR', '任务分配和团队管理'), +('设计师', 'DESIGNER', '任务接收和完成'), +('需求方', 'REQUESTER', '任务发起'), +('观察者', 'OBSERVER', '只读权限'); + +-- 2. 初始化部门数据 +INSERT INTO `sys_dept` (`dept_name`, `dept_type`, `score_balance`, `limit_enabled`, `period_limit`) VALUES +('产品部', 'demand', 10000, 1, 5000), +('市场部', 'demand', 8000, 1, 4000), +('运营部', 'demand', 6000, 1, 3000), +('设计部', 'receiving', 0, 0, 0), +('创意部', 'receiving', 0, 0, 0); + +-- 3. 初始化任务类型 +INSERT INTO `task_type` (`type_name`, `unit_score`, `estimated_hours`, `description`) VALUES +('主图设计', 100, 2.0, '产品主图设计'), +('详情页设计', 200, 4.0, '详情页设计'), +('Banner设计', 150, 3.0, 'Banner海报设计'), +('视频剪辑', 300, 6.0, '短视频剪辑制作'), +('3D建模', 500, 10.0, '3D产品建模'), +('图片优化', 50, 1.0, '图片优化调整'); + +-- 4. 初始化品牌数据 +INSERT INTO `brand` (`brand_name`, `brand_code`, `description`) VALUES +('品牌A', 'BRAND_A', '品牌A描述'), +('品牌B', 'BRAND_B', '品牌B描述'), +('品牌C', 'BRAND_C', '品牌C描述'); + +-- 5. 初始化类目数据 +INSERT INTO `category` (`parent_id`, `category_name`, `level`, `is_leaf`) VALUES +(0, '服装', 1, 0), +(1, '男装', 2, 0), +(2, 'T恤', 3, 1), +(2, '衬衫', 3, 1), +(0, '数码', 1, 0), +(5, '手机', 2, 1), +(5, '电脑', 2, 1); + +-- 6. 初始化平台数据 +INSERT INTO `platform` (`platform_name`, `platform_code`, `description`) VALUES +('淘宝', 'TAOBAO', '淘宝平台'), +('天猫', 'TMALL', '天猫平台'), +('京东', 'JD', '京东平台'), +('拼多多', 'PDD', '拼多多平台'), +('抖音', 'DOUYIN', '抖音平台'); + +-- 7. 初始化延误扣分规则 +INSERT INTO `delay_penalty_rule` (`level_name`, `min_days`, `max_days`, `base_score`, `per_day_score`, `description`) VALUES +('light', 0, 1, 10, 5, '轻微延误:0-1天'), +('general', 2, 3, 30, 10, '一般延误:2-3天'), +('severe', 4, 7, 60, 15, '严重延误:4-7天'), +('major', 8, NULL, 100, 20, '重大延误:8天以上'); + +-- 8. 初始化成就勋章 +INSERT INTO `achievement` (`name`, `category`, `description`, `condition_json`, `reward_score`, `level`) VALUES +('新手上路', 'quantity', '完成第1个任务', '{"task_count": 1}', 10, 1), +('初露锋芒', 'quantity', '完成10个任务', '{"task_count": 10}', 50, 2), +('熟能生巧', 'quantity', '完成50个任务', '{"task_count": 50}', 200, 3), +('业界精英', 'quantity', '完成100个任务', '{"task_count": 100}', 500, 4), +('完美主义', 'quality', '连续10个5星好评', '{"consecutive_5_star": 10}', 100, 1), +('质量之星', 'quality', '平均评分4.8以上', '{"avg_rating": 4.8}', 150, 1), +('速度之王', 'efficiency', '连续5个任务提前完成', '{"early_completion": 5}', 100, 1), +('零延误', 'efficiency', '本月无延误记录', '{"no_delay_month": 1}', 50, 1); + +-- 9. 初始化激励配置 +INSERT INTO `incentive_config` (`config_key`, `config_value`, `description`, `enabled`) VALUES +('ranking_enabled', 'true', '实时排名功能开关', 1), +('achievement_enabled', 'true', '成就勋章功能开关', 1), +('milestone_enabled', 'true', '里程碑提醒开关', 1), +('sprint_enabled', 'true', '积分冲刺提示开关', 1), +('comparison_enabled', 'true', '同事对比功能开关', 1), +('daily_card_enabled', 'true', '每日激励卡片开关', 1), +('instant_feedback_enabled', 'true', '即时激励弹窗开关', 1), +('competition_enabled', 'true', '竞赛活动功能开关', 1), +('team_pk_enabled', 'true', '团队PK功能开关', 1), +('smart_reminder_enabled', 'true', '智能提醒功能开关', 1), +('dashboard_enabled', 'true', '可视化激励看板开关', 1); + +-- 10. 初始化自动奖励规则 +INSERT INTO `auto_reward_rule` (`rule_name`, `reward_period`, `rank_limit`, `task_threshold`, `rating_threshold`, `no_delay_required`, `reward_score`, `auto_issue`) VALUES +('周冠军奖励', 'weekly', 1, NULL, NULL, 0, 500, 1), +('周前三名', 'weekly', 3, NULL, 4.5, 0, 200, 1), +('月冠军奖励', 'monthly', 1, NULL, NULL, 0, 2000, 1), +('月前五名', 'monthly', 5, NULL, 4.5, 0, 500, 1), +('月度优秀', 'monthly', NULL, 30, 4.8, 1, 1000, 1); + +-- 11. 初始化测试用户(密码:123456,MD5加密) +INSERT INTO `sys_user` (`username`, `password`, `real_name`, `email`, `phone`, `dept_id`, `role_ids`, `status`) VALUES +('admin', 'e10adc3949ba59abbe56e057f20f883e', '管理员', 'admin@designhub.com', '13800138000', 4, '1', 1), +('designer1', 'e10adc3949ba59abbe56e057f20f883e', '设计师张三', 'designer1@designhub.com', '13800138001', 4, '5', 1), +('designer2', 'e10adc3949ba59abbe56e057f20f883e', '设计师李四', 'designer2@designhub.com', '13800138002', 4, '5', 1), +('requester1', 'e10adc3949ba59abbe56e057f20f883e', '需求方王五', 'requester1@designhub.com', '13800138003', 1, '6', 1), +('requester2', 'e10adc3949ba59abbe56e057f20f883e', '需求方赵六', 'requester2@designhub.com', '13800138004', 2, '6', 1); + +-- ================================= +-- 新增配置数据(使用新表结构) +-- ================================= + +-- 配置数据:需求类型 +INSERT INTO `config_demand_type` (name, unit_score, estimated_hours, description, sort_order, is_enabled, create_time, update_time) VALUES +('主图设计', 50, 2, '商品主图设计', 1, 1, NOW(), NOW()), +('详情页设计', 100, 4, '商品详情页设计', 2, 1, NOW(), NOW()), +('Banner设计', 80, 3, '活动Banner设计', 3, 1, NOW(), NOW()), +('视频剪辑', 150, 6, '产品视频剪辑', 4, 1, NOW(), NOW()), +('3D建模', 200, 8, '产品3D建模', 5, 1, NOW(), NOW()), +('图片优化', 30, 1, '图片优化处理', 6, 1, NOW(), NOW()); + +-- 配置数据:品牌 +INSERT INTO `config_brand` (name, logo_url, description, sort_order, is_enabled, create_time, update_time) VALUES +('品牌A', '', '品牌A', 1, 1, NOW(), NOW()), +('品牌B', '', '品牌B', 2, 1, NOW(), NOW()), +('品牌C', '', '品牌C', 3, 1, NOW(), NOW()), +('品牌D', '', '品牌D', 4, 1, NOW(), NOW()); + +-- 配置数据:平台 +INSERT INTO `config_platform` (name, icon_url, description, sort_order, is_enabled, create_time, update_time) VALUES +('淘宝', '', '淘宝平台', 1, 1, NOW(), NOW()), +('天猫', '', '天猫平台', 2, 1, NOW(), NOW()), +('京东', '', '京东平台', 3, 1, NOW(), NOW()), +('拼多多', '', '拼多多平台', 4, 1, NOW(), NOW()), +('抖音', '', '抖音电商', 5, 1, NOW(), NOW()), +('快手', '', '快手电商', 6, 1, NOW(), NOW()); + +-- 配置数据:类目 +INSERT INTO `config_category` (parent_id, name, level, is_leaf, path, description, sort_order, is_enabled, create_time, update_time) VALUES +-- 一级类目 +(0, '女装', 1, 0, '/1', '女装类目', 1, 1, NOW(), NOW()), +(0, '男装', 1, 0, '/2', '男装类目', 2, 1, NOW(), NOW()), +(0, '家居', 1, 0, '/3', '家居类目', 3, 1, NOW(), NOW()), +-- 二级类目(女装) +(1, '连衣裙', 2, 1, '/1/4', '女装-连衣裙', 1, 1, NOW(), NOW()), +(1, 'T恤', 2, 1, '/1/5', '女装-T恤', 2, 1, NOW(), NOW()), +-- 二级类目(男装) +(2, 'T恤', 2, 1, '/2/6', '男装-T恤', 1, 1, NOW(), NOW()), +(2, '衬衫', 2, 1, '/2/7', '男装-衬衫', 2, 1, NOW(), NOW()), +-- 二级类目(家居) +(3, '床上用品', 2, 1, '/3/8', '家居-床上用品', 1, 1, NOW(), NOW()), +(3, '家纺', 2, 1, '/3/9', '家居-家纺', 2, 1, NOW(), NOW()); + +-- 配置数据:团队积分池 +INSERT INTO `team_score_pool` (dept_id, dept_name, balance, total_recharge, total_consume, total_earn, limit_enabled, limit_period, period_limit, period_used, period_start_time, alert_threshold, create_time, update_time) VALUES +-- 需求部门积分池 +(1, '产品部', 10000, 10000, 0, 0, 1, 'WEEKLY', 500, 0, NOW(), 80, NOW(), NOW()), +(2, '市场部', 8000, 8000, 0, 0, 1, 'WEEKLY', 400, 0, NOW(), 80, NOW(), NOW()), +(3, '运营部', 12000, 12000, 0, 0, 1, 'MONTHLY', 2000, 0, NOW(), 80, NOW(), NOW()), +-- 接单部门积分池 +(4, '设计部', 0, 0, 0, 0, 0, NULL, NULL, NULL, NULL, NULL, NOW(), NOW()), +(5, '视频组', 0, 0, 0, 0, 0, NULL, NULL, NULL, NULL, NULL, NOW(), NOW()); + +-- 初始化完成 +SELECT '初始化数据插入完成!' AS message; + diff --git a/database/schema.sql b/database/schema.sql new file mode 100644 index 0000000..2db0484 --- /dev/null +++ b/database/schema.sql @@ -0,0 +1,432 @@ +-- DesignHub 设计师任务抢单管理系统 数据库Schema +-- 版本: v1.7 +-- 创建时间: 2025-11-07 + +-- 创建数据库 +CREATE DATABASE IF NOT EXISTS `designhub` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +USE `designhub`; + +-- ============================== +-- 一、用户与组织架构模块 +-- ============================== + +-- 1. 用户表 +CREATE TABLE `sys_user` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '用户ID', + `username` VARCHAR(50) NOT NULL COMMENT '用户名', + `password` VARCHAR(255) NULL COMMENT '密码(如果使用Casdoor可为空)', + `real_name` VARCHAR(50) NOT NULL COMMENT '真实姓名', + `email` VARCHAR(100) NULL COMMENT '邮箱', + `phone` VARCHAR(20) NULL COMMENT '手机号', + `avatar` VARCHAR(255) NULL COMMENT '头像URL', + `ding_user_id` VARCHAR(100) NULL COMMENT '钉钉用户ID', + `dept_id` BIGINT NULL COMMENT '部门ID', + `role_ids` VARCHAR(255) NULL COMMENT '角色ID列表(逗号分隔)', + `status` TINYINT DEFAULT 1 COMMENT '状态:0-禁用 1-启用', + `deleted` TINYINT DEFAULT 0 COMMENT '逻辑删除:0-否 1-是', + `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_username` (`username`), + UNIQUE KEY `uk_ding_user_id` (`ding_user_id`), + KEY `idx_dept_id` (`dept_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表'; + +-- 2. 部门表 +CREATE TABLE `sys_dept` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '部门ID', + `parent_id` BIGINT DEFAULT 0 COMMENT '父部门ID', + `dept_name` VARCHAR(100) NOT NULL COMMENT '部门名称', + `dept_code` VARCHAR(50) NULL COMMENT '部门编码', + `dept_type` VARCHAR(20) DEFAULT 'receiving' COMMENT '部门类型:demand-需求部门 receiving-接单部门', + `ding_dept_id` VARCHAR(100) NULL COMMENT '钉钉部门ID', + `leader_id` BIGINT NULL COMMENT '部门负责人ID', + `score_balance` INT DEFAULT 0 COMMENT '部门积分余额', + `limit_enabled` TINYINT DEFAULT 0 COMMENT '是否启用消耗限制:0-否 1-是', + `limit_period` VARCHAR(20) DEFAULT 'month' COMMENT '限制周期:day-每日 week-每周 month-每月', + `period_limit` INT DEFAULT 0 COMMENT '周期限额', + `alert_threshold` INT DEFAULT 80 COMMENT '预警阈值(百分比)', + `over_limit_action` VARCHAR(20) DEFAULT 'alert' COMMENT '超限操作:alert-仅提醒 block-禁止发起', + `allowed_demand_types` VARCHAR(255) NULL COMMENT '允许的需求类型(逗号分隔)', + `sort_order` INT DEFAULT 0 COMMENT '排序', + `status` TINYINT DEFAULT 1 COMMENT '状态:0-禁用 1-启用', + `deleted` TINYINT DEFAULT 0 COMMENT '逻辑删除:0-否 1-是', + `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_parent_id` (`parent_id`), + KEY `idx_dept_type` (`dept_type`), + KEY `idx_leader_id` (`leader_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='部门表'; + +-- 3. 角色表 +CREATE TABLE `sys_role` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '角色ID', + `role_name` VARCHAR(50) NOT NULL COMMENT '角色名称', + `role_code` VARCHAR(50) NOT NULL COMMENT '角色编码', + `description` VARCHAR(255) NULL COMMENT '角色描述', + `permissions` TEXT NULL COMMENT '权限列表(JSON)', + `status` TINYINT DEFAULT 1 COMMENT '状态:0-禁用 1-启用', + `deleted` TINYINT DEFAULT 0 COMMENT '逻辑删除:0-否 1-是', + `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_role_code` (`role_code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='角色表'; + +-- 4. 部门消耗限制记录表 +CREATE TABLE `dept_limit_record` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '记录ID', + `dept_id` BIGINT NOT NULL COMMENT '部门ID', + `period_type` VARCHAR(20) NOT NULL COMMENT '周期类型:day/week/month', + `period_start` DATE NOT NULL COMMENT '周期开始日期', + `period_end` DATE NOT NULL COMMENT '周期结束日期', + `period_limit` INT NOT NULL COMMENT '周期限额', + `consumed` INT DEFAULT 0 COMMENT '已消耗积分', + `remaining` INT DEFAULT 0 COMMENT '剩余积分', + `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_dept_period` (`dept_id`, `period_type`, `period_start`), + KEY `idx_period` (`period_start`, `period_end`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='部门消耗限制记录表'; + +-- ============================== +-- 二、任务管理模块 +-- ============================== + +-- 5. 任务表 +CREATE TABLE `task` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '任务ID', + `task_no` VARCHAR(50) NOT NULL COMMENT '任务编号', + `title` VARCHAR(200) NOT NULL COMMENT '任务标题', + `task_type_id` BIGINT NOT NULL COMMENT '任务类型ID', + `task_sub_type` VARCHAR(50) NULL COMMENT '任务子类型:new-新品 optimize-优化 redesign-改图 other-其他', + `quantity` INT DEFAULT 1 COMMENT '数量', + `score` INT NOT NULL COMMENT '预估积分', + `estimated_hours` DECIMAL(5,2) NULL COMMENT '预估工时(小时)', + `brand_id` BIGINT NULL COMMENT '品牌ID', + `category_id` BIGINT NULL COMMENT '叶子类目ID', + `spu_info` VARCHAR(255) NULL COMMENT 'SPU信息', + `platform_id` BIGINT NULL COMMENT '平台ID', + `attachment_url` TEXT NULL COMMENT '附件URL(多个用逗号分隔)', + `delivery_path` TEXT NULL COMMENT '交付路径', + `publisher_id` BIGINT NOT NULL COMMENT '发布人ID', + `publisher_dept_id` BIGINT NOT NULL COMMENT '发布人部门ID', + `receiver_id` BIGINT NULL COMMENT '接收人ID', + `receiver_dept_id` BIGINT NULL COMMENT '接收人部门ID', + `release_type` VARCHAR(20) NOT NULL COMMENT '发布类型:assigned-指定 recruitment-招募 default-默认', + `status` VARCHAR(20) DEFAULT 'pending' COMMENT '状态:pending-待接单 in_progress-进行中 review-待审核 rejected-已驳回 completed-已完成 cancelled-已取消', + `progress` INT DEFAULT 0 COMMENT '完成进度(0-100)', + `start_time` DATETIME NULL COMMENT '开始时间', + `deadline` DATETIME NULL COMMENT '截止时间', + `complete_time` DATETIME NULL COMMENT '完成时间', + `rating` INT NULL COMMENT '评分(1-5星)', + `review_comment` TEXT NULL COMMENT '审核评论', + `reject_reason` TEXT NULL COMMENT '驳回原因', + `reject_deadline` DATETIME NULL COMMENT '驳回后的最晚交付时间', + `is_urgent` TINYINT DEFAULT 0 COMMENT '是否紧急:0-否 1-是', + `is_delayed` TINYINT DEFAULT 0 COMMENT '是否延误:0-否 1-是', + `delay_days` INT DEFAULT 0 COMMENT '延误天数', + `deleted` TINYINT DEFAULT 0 COMMENT '逻辑删除:0-否 1-是', + `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_task_no` (`task_no`), + KEY `idx_publisher_id` (`publisher_id`), + KEY `idx_receiver_id` (`receiver_id`), + KEY `idx_status` (`status`), + KEY `idx_create_time` (`create_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='任务表'; + +-- 6. 任务类型表 +CREATE TABLE `task_type` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '类型ID', + `type_name` VARCHAR(50) NOT NULL COMMENT '类型名称', + `unit_score` INT NOT NULL COMMENT '单价积分', + `estimated_hours` DECIMAL(5,2) NULL COMMENT '预估工时(小时)', + `description` VARCHAR(255) NULL COMMENT '描述', + `sort_order` INT DEFAULT 0 COMMENT '排序', + `status` TINYINT DEFAULT 1 COMMENT '状态:0-禁用 1-启用', + `deleted` TINYINT DEFAULT 0 COMMENT '逻辑删除:0-否 1-是', + `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='任务类型表'; + +-- 7. 任务调配记录表 +CREATE TABLE `task_transfer_log` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '记录ID', + `task_id` BIGINT NOT NULL COMMENT '任务ID', + `transfer_type` VARCHAR(20) NOT NULL COMMENT '调配类型:transfer-转移 assign-指定 withdraw-撤回 priority-优先级调整 promote-推广', + `from_user_id` BIGINT NULL COMMENT '原接收人ID', + `to_user_id` BIGINT NULL COMMENT '新接收人ID', + `operator_id` BIGINT NOT NULL COMMENT '操作人ID', + `reason` VARCHAR(500) NULL COMMENT '调配原因', + `old_deadline` DATETIME NULL COMMENT '原截止时间', + `new_deadline` DATETIME NULL COMMENT '新截止时间', + `old_priority` INT NULL COMMENT '原优先级', + `new_priority` INT NULL COMMENT '新优先级', + `delay_days` INT NULL COMMENT '延误天数', + `penalty_score` INT NULL COMMENT '扣除积分', + `remark` TEXT NULL COMMENT '备注', + `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`), + KEY `idx_task_id` (`task_id`), + KEY `idx_from_user` (`from_user_id`), + KEY `idx_to_user` (`to_user_id`), + KEY `idx_operator` (`operator_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='任务调配记录表'; + +-- ============================== +-- 三、积分管理模块 +-- ============================== + +-- 8. 积分记录表 +CREATE TABLE `score_record` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '记录ID', + `user_id` BIGINT NULL COMMENT '用户ID', + `dept_id` BIGINT NULL COMMENT '部门ID', + `task_id` BIGINT NULL COMMENT '关联任务ID', + `change_type` VARCHAR(50) NOT NULL COMMENT '变动类型:task_consume-任务消耗 task_reward-任务奖励 manual_adjust-手动调整 delay_penalty-延误扣分 incentive_reward-激励奖励 appeal_return-申诉返还', + `change_amount` INT NOT NULL COMMENT '变动积分(正数为增加,负数为减少)', + `before_amount` INT NULL COMMENT '变动前积分', + `after_amount` INT NULL COMMENT '变动后积分', + `reason` VARCHAR(500) NULL COMMENT '变动原因', + `operator_id` BIGINT NULL COMMENT '操作人ID', + `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`), + KEY `idx_user_id` (`user_id`), + KEY `idx_dept_id` (`dept_id`), + KEY `idx_task_id` (`task_id`), + KEY `idx_change_type` (`change_type`), + KEY `idx_create_time` (`create_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='积分记录表'; + +-- ============================== +-- 四、延误扣分模块 +-- ============================== + +-- 9. 延误扣分记录表 +CREATE TABLE `delay_penalty_record` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '记录ID', + `task_id` BIGINT NOT NULL COMMENT '任务ID', + `designer_id` BIGINT NOT NULL COMMENT '设计师ID', + `delay_days` INT NOT NULL COMMENT '延误天数', + `delay_level` VARCHAR(20) NOT NULL COMMENT '延误等级:light-轻微 general-一般 severe-严重 major-重大', + `penalty_score` INT NOT NULL COMMENT '扣除积分', + `original_deadline` DATETIME NOT NULL COMMENT '原截止时间', + `actual_time` DATETIME NOT NULL COMMENT '实际完成/转移时间', + `status` VARCHAR(20) DEFAULT 'confirmed' COMMENT '状态:confirmed-已确认 appealed-已申诉 approved-申诉通过 rejected-申诉驳回', + `appeal_reason` TEXT NULL COMMENT '申诉原因', + `appeal_time` DATETIME NULL COMMENT '申诉时间', + `appeal_result` VARCHAR(255) NULL COMMENT '申诉结果', + `approver_id` BIGINT NULL COMMENT '审批人ID', + `approve_time` DATETIME NULL COMMENT '审批时间', + `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_task_id` (`task_id`), + KEY `idx_designer_id` (`designer_id`), + KEY `idx_delay_level` (`delay_level`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='延误扣分记录表'; + +-- 10. 延误扣分规则表 +CREATE TABLE `delay_penalty_rule` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '规则ID', + `level_name` VARCHAR(20) NOT NULL COMMENT '等级名称:light-轻微 general-一般 severe-严重 major-重大', + `min_days` INT NOT NULL COMMENT '最小延误天数', + `max_days` INT NULL COMMENT '最大延误天数(NULL表示无上限)', + `base_score` INT NOT NULL COMMENT '基础扣分', + `per_day_score` INT NOT NULL COMMENT '每天递增扣分', + `description` VARCHAR(255) NULL COMMENT '规则描述', + `enabled` TINYINT DEFAULT 1 COMMENT '是否启用:0-否 1-是', + `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_level_name` (`level_name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='延误扣分规则表'; + +-- ============================== +-- 五、激励系统模块 +-- ============================== + +-- 11. 成就勋章表 +CREATE TABLE `achievement` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '成就ID', + `name` VARCHAR(100) NOT NULL COMMENT '成就名称', + `category` VARCHAR(50) NOT NULL COMMENT '成就类别:quantity-数量 quality-质量 efficiency-效率 special-特殊', + `icon` VARCHAR(255) NULL COMMENT '图标', + `description` VARCHAR(500) NULL COMMENT '描述', + `condition_json` TEXT NULL COMMENT '达成条件(JSON)', + `reward_score` INT DEFAULT 0 COMMENT '奖励积分', + `level` INT DEFAULT 1 COMMENT '成就等级', + `sort_order` INT DEFAULT 0 COMMENT '排序', + `enabled` TINYINT DEFAULT 1 COMMENT '是否启用:0-否 1-是', + `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_category` (`category`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='成就勋章表'; + +-- 12. 用户成就记录表 +CREATE TABLE `user_achievement` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '记录ID', + `user_id` BIGINT NOT NULL COMMENT '用户ID', + `achievement_id` BIGINT NOT NULL COMMENT '成就ID', + `achieved_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '获得时间', + `is_displayed` TINYINT DEFAULT 1 COMMENT '是否展示:0-否 1-是', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_user_achievement` (`user_id`, `achievement_id`), + KEY `idx_user_id` (`user_id`), + KEY `idx_achievement_id` (`achievement_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户成就记录表'; + +-- 13. 用户目标表 +CREATE TABLE `user_goal` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '目标ID', + `user_id` BIGINT NOT NULL COMMENT '用户ID', + `goal_type` VARCHAR(50) NOT NULL COMMENT '目标类型:daily-每日 weekly-每周 monthly-每月 custom-自定义', + `target_value` INT NOT NULL COMMENT '目标值', + `current_value` INT DEFAULT 0 COMMENT '当前值', + `start_date` DATE NOT NULL COMMENT '开始日期', + `end_date` DATE NOT NULL COMMENT '结束日期', + `status` VARCHAR(20) DEFAULT 'active' COMMENT '状态:active-进行中 completed-已完成 failed-未完成', + `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_user_id` (`user_id`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户目标表'; + +-- 14. 用户签到记录表 +CREATE TABLE `user_check_in` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '记录ID', + `user_id` BIGINT NOT NULL COMMENT '用户ID', + `check_in_date` DATE NOT NULL COMMENT '签到日期', + `continuous_days` INT DEFAULT 1 COMMENT '连续天数', + `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_user_date` (`user_id`, `check_in_date`), + KEY `idx_user_id` (`user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户签到记录表'; + +-- 15. 激励配置表 +CREATE TABLE `incentive_config` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '配置ID', + `config_key` VARCHAR(100) NOT NULL COMMENT '配置键', + `config_value` TEXT NULL COMMENT '配置值(JSON)', + `description` VARCHAR(500) NULL COMMENT '描述', + `enabled` TINYINT DEFAULT 1 COMMENT '是否启用:0-否 1-是', + `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_config_key` (`config_key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='激励配置表'; + +-- 16. 自动奖励规则表 +CREATE TABLE `auto_reward_rule` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '规则ID', + `rule_name` VARCHAR(100) NOT NULL COMMENT '规则名称', + `reward_period` VARCHAR(20) NOT NULL COMMENT '奖励周期:daily-每日 weekly-每周 monthly-每月 quarterly-季度 yearly-年度', + `rank_limit` INT NULL COMMENT '排名限制(前N名)', + `task_threshold` INT NULL COMMENT '任务数量阈值', + `rating_threshold` DECIMAL(3,2) NULL COMMENT '评分阈值', + `no_delay_required` TINYINT DEFAULT 0 COMMENT '是否要求零延误:0-否 1-是', + `reward_score` INT NOT NULL COMMENT '奖励积分', + `auto_issue` TINYINT DEFAULT 1 COMMENT '是否自动发放:0-否 1-是', + `enabled` TINYINT DEFAULT 1 COMMENT '是否启用:0-否 1-是', + `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='自动奖励规则表'; + +-- ============================== +-- 六、系统配置模块 +-- ============================== + +-- 17. 品牌表 +CREATE TABLE `brand` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '品牌ID', + `brand_name` VARCHAR(100) NOT NULL COMMENT '品牌名称', + `brand_code` VARCHAR(50) NULL COMMENT '品牌编码', + `logo` VARCHAR(255) NULL COMMENT '品牌Logo', + `description` VARCHAR(500) NULL COMMENT '描述', + `sort_order` INT DEFAULT 0 COMMENT '排序', + `status` TINYINT DEFAULT 1 COMMENT '状态:0-禁用 1-启用', + `deleted` TINYINT DEFAULT 0 COMMENT '逻辑删除:0-否 1-是', + `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='品牌表'; + +-- 18. 类目表 +CREATE TABLE `category` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '类目ID', + `parent_id` BIGINT DEFAULT 0 COMMENT '父类目ID', + `category_name` VARCHAR(100) NOT NULL COMMENT '类目名称', + `category_code` VARCHAR(50) NULL COMMENT '类目编码', + `level` INT DEFAULT 1 COMMENT '层级', + `is_leaf` TINYINT DEFAULT 1 COMMENT '是否叶子节点:0-否 1-是', + `sort_order` INT DEFAULT 0 COMMENT '排序', + `status` TINYINT DEFAULT 1 COMMENT '状态:0-禁用 1-启用', + `deleted` TINYINT DEFAULT 0 COMMENT '逻辑删除:0-否 1-是', + `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_parent_id` (`parent_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='类目表'; + +-- 19. 平台表 +CREATE TABLE `platform` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '平台ID', + `platform_name` VARCHAR(100) NOT NULL COMMENT '平台名称', + `platform_code` VARCHAR(50) NULL COMMENT '平台编码', + `icon` VARCHAR(255) NULL COMMENT '图标', + `description` VARCHAR(500) NULL COMMENT '描述', + `sort_order` INT DEFAULT 0 COMMENT '排序', + `status` TINYINT DEFAULT 1 COMMENT '状态:0-禁用 1-启用', + `deleted` TINYINT DEFAULT 0 COMMENT '逻辑删除:0-否 1-是', + `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='平台表'; + +-- ============================== +-- 七、通知模块 +-- ============================== + +-- 20. 通知表 +CREATE TABLE `sys_notification` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '通知ID', + `receiver_id` BIGINT NOT NULL COMMENT '接收人ID', + `receiver_ding_user_id` VARCHAR(100) NULL COMMENT '接收人钉钉ID', + `type` VARCHAR(50) NOT NULL COMMENT '通知类型', + `title` VARCHAR(200) NOT NULL COMMENT '通知标题', + `content` TEXT NOT NULL COMMENT '通知内容', + `link` VARCHAR(500) NULL COMMENT '链接地址', + `mobile_link` VARCHAR(500) NULL COMMENT '移动端链接', + `related_task_id` BIGINT NULL COMMENT '关联任务ID', + `is_read` TINYINT DEFAULT 0 COMMENT '是否已读:0-否 1-是', + `read_time` DATETIME NULL COMMENT '阅读时间', + `ding_enabled` TINYINT DEFAULT 1 COMMENT '是否发送钉钉:0-否 1-是', + `ding_message_type` VARCHAR(20) DEFAULT 'text' COMMENT '钉钉消息类型:text/markdown/oa/actionCard', + `ding_priority` INT DEFAULT 0 COMMENT '钉钉优先级', + `ding_pushed` TINYINT DEFAULT 0 COMMENT '钉钉是否已推送:0-否 1-是', + `ding_push_time` DATETIME NULL COMMENT '钉钉推送时间', + `ding_push_result` TEXT NULL COMMENT '钉钉推送结果(JSON)', + `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`), + KEY `idx_receiver_id` (`receiver_id`), + KEY `idx_is_read` (`is_read`), + KEY `idx_type` (`type`), + KEY `idx_create_time` (`create_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='通知表'; + +-- 创建索引以优化查询性能 +CREATE INDEX idx_task_status_time ON `task` (`status`, `create_time`); +CREATE INDEX idx_score_record_time ON `score_record` (`create_time`, `change_type`); +CREATE INDEX idx_user_dept ON `sys_user` (`dept_id`, `status`); + diff --git a/database/sync_database.sh b/database/sync_database.sh new file mode 100755 index 0000000..28109cb --- /dev/null +++ b/database/sync_database.sh @@ -0,0 +1,154 @@ +#!/bin/bash +# ======================================== +# DesignHub 数据库结构同步脚本 +# 用途: 自动检测并添加缺失的表和字段 +# 版本: v2.0 +# ======================================== + +set -e + +# 配置 +DB_HOST="${DB_HOST:-localhost}" +DB_USER="${DB_USER:-root}" +DB_PASS="${DB_PASS:-DesignHub@2025_Root}" +DB_NAME="${DB_NAME:-designhub}" + +# 颜色输出 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +echo -e "${GREEN}========================================${NC}" +echo -e "${GREEN}DesignHub 数据库结构同步工具${NC}" +echo -e "${GREEN}========================================${NC}" +echo "" + +# 检查字段是否存在的函数 +check_column_exists() { + local table=$1 + local column=$2 + mysql -h"$DB_HOST" -u"$DB_USER" -p"$DB_PASS" "$DB_NAME" -sN -e " + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA='$DB_NAME' + AND TABLE_NAME='$table' + AND COLUMN_NAME='$column' + " 2>/dev/null +} + +# 添加字段的函数 +add_column() { + local table=$1 + local column=$2 + local definition=$3 + local after=$4 + + if [ -n "$after" ]; then + local sql="ALTER TABLE \`$table\` ADD COLUMN \`$column\` $definition AFTER \`$after\`;" + else + local sql="ALTER TABLE \`$table\` ADD COLUMN \`$column\` $definition;" + fi + + echo -e "${YELLOW} 添加字段: ${table}.${column}${NC}" + mysql -h"$DB_HOST" -u"$DB_USER" -p"$DB_PASS" "$DB_NAME" -e "$sql" 2>/dev/null +} + +# 同步字段的主函数 +sync_column() { + local table=$1 + local column=$2 + local definition=$3 + local after=$4 + + if [ $(check_column_exists "$table" "$column") -eq 0 ]; then + add_column "$table" "$column" "$definition" "$after" + else + echo -e "${GREEN} ✓ 字段已存在: ${table}.${column}${NC}" + fi +} + +echo -e "${GREEN}开始同步数据库结构...${NC}" +echo "" + +# ======================================== +# 同步 sys_user 表 +# ======================================== +echo -e "${GREEN}[1/7] 同步 sys_user 表${NC}" +sync_column "sys_user" "dept_name" "VARCHAR(100) NULL COMMENT '部门名称'" "dept_id" +sync_column "sys_user" "score" "INT DEFAULT 0 COMMENT '用户积分'" "status" +echo "" + +# ======================================== +# 同步 task 表 +# ======================================== +echo -e "${GREEN}[2/7] 同步 task 表${NC}" +sync_column "task" "third_party_task_no" "VARCHAR(100) NULL COMMENT '第三方平台任务编号'" "task_no" +sync_column "task" "task_type_ids" "VARCHAR(255) NULL COMMENT '任务类型ID列表(逗号分隔)'" "task_type_id" +sync_column "task" "score_reward" "INT DEFAULT 0 COMMENT '实际积分奖励'" "score" +sync_column "task" "estimated_duration" "INT NULL COMMENT '预估时长(小时)'" "estimated_hours" +sync_column "task" "demand_type_id" "BIGINT NULL COMMENT '需求类型ID'" "estimated_duration" +sync_column "task" "actual_start_time" "DATETIME NULL COMMENT '实际开始时间'" "start_time" +sync_column "task" "submit_time" "DATETIME NULL COMMENT '提交审核时间'" "complete_time" +sync_column "task" "actual_end_time" "DATETIME NULL COMMENT '实际结束时间'" "submit_time" +sync_column "task" "priority" "INT DEFAULT 0 COMMENT '优先级:0-低 1-普通 2-高 3-紧急'" "actual_end_time" +sync_column "task" "remark" "VARCHAR(500) NULL COMMENT '备注信息'" "priority" +sync_column "task" "is_pipeline" "TINYINT DEFAULT 0 COMMENT '是否流水线任务:0-否,1-是'" "delay_days" +sync_column "task" "pipeline_mode" "VARCHAR(20) NULL COMMENT '流水线模式:sequential(顺序)/parallel(并行)'" "is_pipeline" +sync_column "task" "approval_status" "VARCHAR(20) DEFAULT 'draft' COMMENT '审核状态:draft-草稿 pending_demand_approval-待需求审核 pending_receiving_approval-待接单审核 approved-已通过 rejected-已驳回'" "audit_status" +sync_column "task" "current_approver_id" "BIGINT NULL COMMENT '当前审核人ID'" "approval_status" +sync_column "task" "current_approver_role" "VARCHAR(50) NULL COMMENT '当前审核人角色'" "current_approver_id" +sync_column "task" "demand_approval_time" "DATETIME NULL COMMENT '需求部门审核时间'" "current_approver_role" +sync_column "task" "receiving_approval_time" "DATETIME NULL COMMENT '接单部门审核时间'" "demand_approval_time" +echo "" + +# ======================================== +# 同步 task_type 表 +# ======================================== +echo -e "${GREEN}[3/7] 同步 task_type 表${NC}" +sync_column "task_type" "allowed_dept_ids" "VARCHAR(255) NULL COMMENT '可接单部门ID列表(逗号分隔)'" "description" +echo "" + +# ======================================== +# 同步 category 表 +# ======================================== +echo -e "${GREEN}[4/7] 同步 category 表${NC}" +sync_column "category" "description" "VARCHAR(500) NULL COMMENT '描述'" "is_leaf" +echo "" + +# ======================================== +# 同步 team_score_pool 表 +# ======================================== +echo -e "${GREEN}[5/7] 同步 team_score_pool 表${NC}" +sync_column "team_score_pool" "dept_id" "BIGINT NULL COMMENT '部门ID(与team_id相同)'" "id" +sync_column "team_score_pool" "total_recharge" "INT DEFAULT 0 COMMENT '累计充值积分'" "balance" +sync_column "team_score_pool" "total_consume" "INT DEFAULT 0 COMMENT '累计消耗积分'" "total_recharge" +sync_column "team_score_pool" "total_earn" "INT DEFAULT 0 COMMENT '累计获得积分'" "total_consume" +sync_column "team_score_pool" "period_limit" "INT DEFAULT 0 COMMENT '周期限额'" "monthly_used" +sync_column "team_score_pool" "period_used" "INT DEFAULT 0 COMMENT '当前周期已消耗'" "period_limit" +sync_column "team_score_pool" "period_start_time" "DATETIME NULL COMMENT '当前周期开始时间'" "period_used" +sync_column "team_score_pool" "alert_threshold" "INT DEFAULT 80 COMMENT '预警阈值(百分比)'" "monthly_used" +sync_column "team_score_pool" "limit_enabled" "INT DEFAULT 0 COMMENT '是否启用限制:0-否 1-是'" "alert_threshold" +sync_column "team_score_pool" "limit_period" "VARCHAR(20) DEFAULT 'month' COMMENT '限制周期:day/week/month'" "limit_enabled" +echo "" + +# ======================================== +# 同步 achievement 表 +# ======================================== +echo -e "${GREEN}[6/7] 同步 achievement 表${NC}" +sync_column "achievement" "achievement_code" "VARCHAR(50) NULL COMMENT '成就代码'" "id" +sync_column "achievement" "achievement_name" "VARCHAR(100) NULL COMMENT '成就名称'" "achievement_code" +echo "" + +# ======================================== +# 同步 user_achievement 表 +# ======================================== +echo -e "${GREEN}[7/7] 同步 user_achievement 表${NC}" +sync_column "user_achievement" "progress_value" "INT NULL COMMENT '当前进度值'" "achievement_id" +sync_column "user_achievement" "reward_claimed" "INT DEFAULT 0 COMMENT '奖励是否已领取:0-否 1-是'" "progress_value" +sync_column "user_achievement" "claim_time" "DATETIME NULL COMMENT '领取时间'" "reward_claimed" +echo "" + +echo -e "${GREEN}========================================${NC}" +echo -e "${GREEN}数据库结构同步完成!${NC}" +echo -e "${GREEN}========================================${NC}" diff --git a/database/sync_database_structure.sql b/database/sync_database_structure.sql new file mode 100644 index 0000000..b8f5d3a --- /dev/null +++ b/database/sync_database_structure.sql @@ -0,0 +1,203 @@ +-- ======================================== +-- DesignHub 数据库结构同步脚本 +-- 用途: 自动检测并添加缺失的表和字段 +-- 版本: v2.0 +-- 生成时间: 2026-01-02 +-- ======================================== +-- +-- 使用说明: +-- 1. 此脚本会自动检测并创建缺失的表 +-- 2. 自动检测并添加缺失的字段 +-- 3. 可以安全地重复执行,不会影响已有数据 +-- +-- 执行方式: +-- mysql -uroot -p designhub < sync_database_structure.sql +-- 或者在MySQL客户端中执行: source sync_database_structure.sql +-- + +USE `designhub`; + +-- ======================================== +-- 第一部分: 确保所有必需的表存在 +-- ======================================== + +-- 如果表不存在,则创建。如果存在,则跳过。 +-- 注意:MySQL不支持 CREATE TABLE IF NOT EXISTS 的完整字段检查, +-- 所以这里只创建表的基本结构,字段的添加在第二部分完成 + +-- ======================================== +-- 第二部分: 为每个表添加缺失的字段 +-- ======================================== + +-- 1. sys_user 表字段补全 +ALTER TABLE `sys_user` + ADD COLUMN IF NOT EXISTS `dept_name` VARCHAR(50) NULL COMMENT '部门名称' AFTER `dept_id`, + ADD COLUMN IF NOT EXISTS `score` INT DEFAULT 0 COMMENT '用户积分' AFTER `status`; + +-- 2. task 表字段补全(所有缺失的字段) +ALTER TABLE `task` + ADD COLUMN IF NOT EXISTS `third_party_task_no` VARCHAR(100) NULL COMMENT '第三方平台任务编号' AFTER `task_no`, + ADD COLUMN IF NOT EXISTS `task_type_ids` VARCHAR(255) NULL COMMENT '任务类型ID列表(逗号分隔)' AFTER `task_type_id`, + ADD COLUMN IF NOT EXISTS `score_reward` DECIMAL(10,2) NULL COMMENT '实际积分奖励' AFTER `score`, + ADD COLUMN IF NOT EXISTS `estimated_duration` INT NULL COMMENT '预估时长(小时)' AFTER `estimated_hours`, + ADD COLUMN IF NOT EXISTS `demand_type_id` BIGINT NULL COMMENT '需求类型ID' AFTER `estimated_duration`, + ADD COLUMN IF NOT EXISTS `actual_start_time` DATETIME NULL COMMENT '实际开始时间' AFTER `start_time`, + ADD COLUMN IF NOT EXISTS `submit_time` DATETIME NULL COMMENT '提交审核时间' AFTER `complete_time`, + ADD COLUMN IF NOT EXISTS `actual_end_time` DATETIME NULL COMMENT '实际结束时间' AFTER `submit_time`, + ADD COLUMN IF NOT EXISTS `priority` INT DEFAULT 1 COMMENT '优先级:0-低 1-普通 2-高 3-紧急' AFTER `actual_end_time`, + ADD COLUMN IF NOT EXISTS `remark` VARCHAR(500) NULL COMMENT '备注信息' AFTER `priority`, + ADD COLUMN IF NOT EXISTS `is_pipeline` TINYINT DEFAULT 0 COMMENT '是否流水线任务:0-否,1-是' AFTER `delay_days`, + ADD COLUMN IF NOT EXISTS `pipeline_mode` VARCHAR(20) NULL COMMENT '流水线模式:sequential(顺序)/parallel(并行)' AFTER `is_pipeline`, + ADD COLUMN IF NOT EXISTS `approval_status` VARCHAR(20) DEFAULT 'draft' COMMENT '审核状态:draft-草稿 pending_demand_approval-待需求审核 pending_receiving_approval-待接单审核 approved-已通过 rejected-已驳回' AFTER `audit_status`, + ADD COLUMN IF NOT EXISTS `current_approver_id` BIGINT NULL COMMENT '当前审核人ID' AFTER `approval_status`, + ADD COLUMN IF NOT EXISTS `current_approver_role` VARCHAR(50) NULL COMMENT '当前审核人角色' AFTER `current_approver_id`, + ADD COLUMN IF NOT EXISTS `demand_approval_time` DATETIME NULL COMMENT '需求部门审核时间' AFTER `current_approver_role`, + ADD COLUMN IF NOT EXISTS `receiving_approval_time` DATETIME NULL COMMENT '接单部门审核时间' AFTER `demand_approval_time`; + +-- 3. task_type 表字段补全 +ALTER TABLE `task_type` + ADD COLUMN IF NOT EXISTS `allowed_dept_ids` VARCHAR(255) NULL COMMENT '可接单部门ID列表(逗号分隔)' AFTER `description`; + +-- 4. category 表字段补全 +ALTER TABLE `category` + ADD COLUMN IF NOT EXISTS `description` VARCHAR(500) NULL COMMENT '描述' AFTER `is_leaf`; + +-- 5. task_sub_type 表字段重命名和补全 +-- 注意:如果字段名不一致,需要先检查并重命名 +-- 这里使用存储过程来处理字段重命名(如果存在旧字段名) +ALTER TABLE `task_sub_type` + ADD COLUMN IF NOT EXISTS `name` VARCHAR(100) NULL COMMENT '子类型名称' AFTER `id`, + ADD COLUMN IF NOT EXISTS `code` VARCHAR(50) NULL COMMENT '子类型编码' AFTER `name`, + ADD COLUMN IF NOT EXISTS `is_enabled` TINYINT DEFAULT 1 COMMENT '是否启用:0-禁用 1-启用' AFTER `code`; + +-- 6. team_score_pool 表字段补全 +ALTER TABLE `team_score_pool` + ADD COLUMN IF NOT EXISTS `dept_id` BIGINT NULL COMMENT '部门ID(与team_id相同)' AFTER `id`, + ADD COLUMN IF NOT EXISTS `total_recharge` INT DEFAULT 0 COMMENT '累计充值积分' AFTER `balance`, + ADD COLUMN IF NOT EXISTS `total_consume` INT DEFAULT 0 COMMENT '累计消耗积分' AFTER `total_recharge`, + ADD COLUMN IF NOT EXISTS `total_earn` INT DEFAULT 0 COMMENT '累计获得积分' AFTER `total_consume`, + ADD COLUMN IF NOT EXISTS `period_limit` INT DEFAULT 0 COMMENT '周期限额' AFTER `monthly_used`, + ADD COLUMN IF NOT EXISTS `period_used` INT DEFAULT 0 COMMENT '当前周期已消耗' AFTER `period_limit`, + ADD COLUMN IF NOT EXISTS `period_start_time` DATETIME NULL COMMENT '当前周期开始时间' AFTER `period_used`, + ADD COLUMN IF NOT EXISTS `alert_threshold` INT DEFAULT 80 COMMENT '预警阈值(百分比)' AFTER `monthly_used`, + ADD COLUMN IF NOT EXISTS `limit_enabled` TINYINT DEFAULT 0 COMMENT '是否启用限制:0-否 1-是' AFTER `alert_threshold`, + ADD COLUMN IF NOT EXISTS `limit_period` VARCHAR(20) DEFAULT 'month' COMMENT '限制周期:day/week/month' AFTER `limit_enabled`; + +-- 7. achievement 表字段补全 +ALTER TABLE `achievement` + ADD COLUMN IF NOT EXISTS `achievement_code` VARCHAR(50) NULL COMMENT '成就代码' AFTER `id`, + ADD COLUMN IF NOT EXISTS `achievement_name` VARCHAR(100) NULL COMMENT '成就名称' AFTER `achievement_code`; + +-- 8. user_achievement 表字段补全 +ALTER TABLE `user_achievement` + ADD COLUMN IF NOT EXISTS `progress_value` INT NULL COMMENT '当前进度值' AFTER `achievement_id`, + ADD COLUMN IF NOT EXISTS `reward_claimed` TINYINT DEFAULT 0 COMMENT '奖励是否已领取:0-否 1-是' AFTER `progress_value`, + ADD COLUMN IF NOT EXISTS `claim_time` DATETIME NULL COMMENT '领取时间' AFTER `reward_claimed`; + +-- ======================================== +-- 第三部分: 创建缺失的表(如果不存在) +-- ======================================== + +-- 这里使用存储过程来检查表是否存在,如果不存在则创建 +-- 由于MySQL的限制,我们需要使用不同的方法 + +-- 注意:由于MySQL不支持 ALTER TABLE ADD COLUMN IF NOT EXISTS 和 CREATE TABLE IF NOT EXISTS 的完整功能, +-- 上面的脚本在实际执行时可能会报错。我们需要使用存储过程或应用程序来处理这个问题。 + +-- ======================================== +-- 第四部分: 创建存储过程来安全地添加字段 +-- ======================================== + +DELIMITER $$ + +-- 存储过程:安全地添加列(如果不存在) +DROP PROCEDURE IF EXISTS AddColumnIfNotExists $$ +CREATE PROCEDURE AddColumnIfNotExists( + IN tableName VARCHAR(64), + IN columnName VARCHAR(64), + IN columnDefinition TEXT +) +BEGIN + DECLARE columnExists INT DEFAULT 0; + + SELECT COUNT(*) INTO columnExists + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = tableName + AND COLUMN_NAME = columnName; + + IF columnExists = 0 THEN + SET @sql = CONCAT('ALTER TABLE `', tableName, '` ADD COLUMN `', columnName, '` ', columnDefinition); + PREPARE stmt FROM @sql; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END IF; +END$$ + +DELIMITER ; + +-- ======================================== +-- 第五部分: 使用存储过程添加所有缺失的字段 +-- ======================================== + +-- sys_user 表 +CALL AddColumnIfNotExists('sys_user', 'dept_name', 'VARCHAR(50) NULL COMMENT ''部门名称'' AFTER `dept_id`'); +CALL AddColumnIfNotExists('sys_user', 'score', 'INT DEFAULT 0 COMMENT ''用户积分'' AFTER `status`'); + +-- task 表 +CALL AddColumnIfNotExists('task', 'third_party_task_no', 'VARCHAR(100) NULL COMMENT ''第三方平台任务编号'' AFTER `task_no`'); +CALL AddColumnIfNotExists('task', 'task_type_ids', 'VARCHAR(255) NULL COMMENT ''任务类型ID列表(逗号分隔)'' AFTER `task_type_id`'); +CALL AddColumnIfNotExists('task', 'score_reward', 'DECIMAL(10,2) NULL COMMENT ''实际积分奖励'' AFTER `score`'); +CALL AddColumnIfNotExists('task', 'estimated_duration', 'INT NULL COMMENT ''预估时长(小时)'' AFTER `estimated_hours`'); +CALL AddColumnIfNotExists('task', 'demand_type_id', 'BIGINT NULL COMMENT ''需求类型ID'' AFTER `estimated_duration`'); +CALL AddColumnIfNotExists('task', 'actual_start_time', 'DATETIME NULL COMMENT ''实际开始时间'' AFTER `start_time`'); +CALL AddColumnIfNotExists('task', 'submit_time', 'DATETIME NULL COMMENT ''提交审核时间'' AFTER `complete_time`'); +CALL AddColumnIfNotExists('task', 'actual_end_time', 'DATETIME NULL COMMENT ''实际结束时间'' AFTER `submit_time`'); +CALL AddColumnIfNotExists('task', 'priority', 'INT DEFAULT 1 COMMENT ''优先级:0-低 1-普通 2-高 3-紧急'' AFTER `actual_end_time`'); +CALL AddColumnIfNotExists('task', 'remark', 'VARCHAR(500) NULL COMMENT ''备注信息'' AFTER `priority`'); +CALL AddColumnIfNotExists('task', 'is_pipeline', 'TINYINT DEFAULT 0 COMMENT ''是否流水线任务:0-否,1-是'' AFTER `delay_days`'); +CALL AddColumnIfNotExists('task', 'pipeline_mode', 'VARCHAR(20) NULL COMMENT ''流水线模式:sequential(顺序)/parallel(并行)'' AFTER `is_pipeline`'); +CALL AddColumnIfNotExists('task', 'approval_status', 'VARCHAR(20) DEFAULT ''draft'' COMMENT ''审核状态:draft-草稿 pending_demand_approval-待需求审核 pending_receiving_approval-待接单审核 approved-已通过 rejected-已驳回'' AFTER `audit_status`'); +CALL AddColumnIfNotExists('task', 'current_approver_id', 'BIGINT NULL COMMENT ''当前审核人ID'' AFTER `approval_status`'); +CALL AddColumnIfNotExists('task', 'current_approver_role', 'VARCHAR(50) NULL COMMENT ''当前审核人角色'' AFTER `current_approver_id`'); +CALL AddColumnIfNotExists('task', 'demand_approval_time', 'DATETIME NULL COMMENT ''需求部门审核时间'' AFTER `current_approver_role`'); +CALL AddColumnIfNotExists('task', 'receiving_approval_time', 'DATETIME NULL COMMENT ''接单部门审核时间'' AFTER `demand_approval_time`'); + +-- task_type 表 +CALL AddColumnIfNotExists('task_type', 'allowed_dept_ids', 'VARCHAR(255) NULL COMMENT ''可接单部门ID列表(逗号分隔)'' AFTER `description`'); + +-- category 表 +CALL AddColumnIfNotExists('category', 'description', 'VARCHAR(500) NULL COMMENT ''描述'' AFTER `is_leaf`'); + +-- team_score_pool 表 +CALL AddColumnIfNotExists('team_score_pool', 'dept_id', 'BIGINT NULL COMMENT ''部门ID(与team_id相同)'' AFTER `id`'); +CALL AddColumnIfNotExists('team_score_pool', 'total_recharge', 'INT DEFAULT 0 COMMENT ''累计充值积分'' AFTER `balance`'); +CALL AddColumnIfNotExists('team_score_pool', 'total_consume', 'INT DEFAULT 0 COMMENT ''累计消耗积分'' AFTER `total_recharge`'); +CALL AddColumnIfNotExists('team_score_pool', 'total_earn', 'INT DEFAULT 0 COMMENT ''累计获得积分'' AFTER `total_consume`'); +CALL AddColumnIfNotExists('team_score_pool', 'period_limit', 'INT DEFAULT 0 COMMENT ''周期限额'' AFTER `monthly_used`'); +CALL AddColumnIfNotExists('team_score_pool', 'period_used', 'INT DEFAULT 0 COMMENT ''当前周期已消耗'' AFTER `period_limit`'); +CALL AddColumnIfNotExists('team_score_pool', 'period_start_time', 'DATETIME NULL COMMENT ''当前周期开始时间'' AFTER `period_used`'); +CALL AddColumnIfNotExists('team_score_pool', 'alert_threshold', 'INT DEFAULT 80 COMMENT ''预警阈值(百分比)'' AFTER `monthly_used`'); +CALL AddColumnIfNotExists('team_score_pool', 'limit_enabled', 'TINYINT DEFAULT 0 COMMENT ''是否启用限制:0-否 1-是'' AFTER `alert_threshold`'); +CALL AddColumnIfNotExists('team_score_pool', 'limit_period', 'VARCHAR(20) DEFAULT ''month'' COMMENT ''限制周期:day/week/month'' AFTER `limit_enabled`'); + +-- achievement 表 +CALL AddColumnIfNotExists('achievement', 'achievement_code', 'VARCHAR(50) NULL COMMENT ''成就代码'' AFTER `id`'); +CALL AddColumnIfNotExists('achievement', 'achievement_name', 'VARCHAR(100) NULL COMMENT ''成就名称'' AFTER `achievement_code`'); + +-- user_achievement 表 +CALL AddColumnIfNotExists('user_achievement', 'progress_value', 'INT NULL COMMENT ''当前进度值'' AFTER `achievement_id`'); +CALL AddColumnIfNotExists('user_achievement', 'reward_claimed', 'TINYINT DEFAULT 0 COMMENT ''奖励是否已领取:0-否 1-是'' AFTER `progress_value`'); +CALL AddColumnIfNotExists('user_achievement', 'claim_time', 'DATETIME NULL COMMENT ''领取时间'' AFTER `reward_claimed`'); + +-- ======================================== +-- 第六部分: 清理存储过程 +-- ======================================== + +DROP PROCEDURE IF EXISTS AddColumnIfNotExists; + +-- ======================================== +-- 完成提示 +-- ======================================== + +SELECT '数据库结构同步完成!' AS message; diff --git a/deploy_to_public_server.sh b/deploy_to_public_server.sh new file mode 100755 index 0000000..3cb36fb --- /dev/null +++ b/deploy_to_public_server.sh @@ -0,0 +1,505 @@ +#!/bin/bash + +############################################################################### +# DesignHub 公网服务器自动化部署脚本 +# 服务器: 45.192.99.48 +# 使用方法: bash deploy_to_public_server.sh +############################################################################### + +set -e + +# 颜色定义 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +# 服务器配置 +SERVER_IP="45.192.99.48" +SERVER_PORT="22" +SERVER_USER="root" +SERVER_PASSWORD="1blZllMIp7" + +# 应用配置 +APP_NAME="designhub" +APP_DIR="/opt/${APP_NAME}" +DB_NAME="designhub" +DB_USER="designhub" +DB_PASSWORD="DesignHub@2025_DB" +REDIS_PASSWORD="DesignHub@2025_Redis" +MYSQL_ROOT_PASSWORD="DesignHub@2025_Root" + +echo "========================================" +echo -e "${GREEN} DesignHub 公网服务器部署脚本${NC}" +echo "========================================" +echo "" +echo "服务器信息:" +echo " IP: ${SERVER_IP}" +echo " 端口: ${SERVER_PORT}" +echo " 用户: ${SERVER_USER}" +echo "" + +# 检查 sshpass 是否安装 +check_sshpass() { + if ! command -v sshpass &> /dev/null; then + echo -e "${YELLOW}正在安装 sshpass...${NC}" + if [[ "$OSTYPE" == "darwin"* ]]; then + brew install hudochenkov/sshpass/sshpass 2>/dev/null || { + echo -e "${RED}请先安装 sshpass: brew install hudochenkov/sshpass/sshpass${NC}" + exit 1 + } + else + sudo apt-get install -y sshpass 2>/dev/null || sudo yum install -y sshpass 2>/dev/null + fi + fi + echo -e "${GREEN}✓ sshpass 已就绪${NC}" +} + +# SSH 命令封装 +ssh_cmd() { + sshpass -p "${SERVER_PASSWORD}" ssh -p ${SERVER_PORT} \ + -o StrictHostKeyChecking=no \ + -o UserKnownHostsFile=/dev/null \ + -o ConnectTimeout=30 \ + ${SERVER_USER}@${SERVER_IP} "$@" +} + +# SCP 命令封装 +scp_cmd() { + sshpass -p "${SERVER_PASSWORD}" scp -P ${SERVER_PORT} \ + -o StrictHostKeyChecking=no \ + -o UserKnownHostsFile=/dev/null \ + "$@" +} + +# rsync 命令封装 +rsync_cmd() { + sshpass -p "${SERVER_PASSWORD}" rsync -avz \ + -e "ssh -p ${SERVER_PORT} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" \ + "$@" +} + +# 测试连接 +test_connection() { + echo -e "${BLUE}[0/10] 测试服务器连接...${NC}" + if ssh_cmd "echo 'Connection successful'" 2>/dev/null; then + echo -e "${GREEN}✓ 服务器连接成功${NC}" + else + echo -e "${RED}✗ 无法连接到服务器${NC}" + exit 1 + fi + echo "" +} + +# 步骤1:安装系统依赖 +install_dependencies() { + echo -e "${BLUE}[1/10] 安装系统依赖...${NC}" + + ssh_cmd << 'ENDSSH' +set -e + +# 安装基础工具 +echo "安装基础工具..." +dnf install -y -q curl wget git unzip + +# 检查并安装 Java 17 +if ! command -v java &> /dev/null || ! java -version 2>&1 | grep -q "17"; then + echo "安装 Java 17..." + dnf install -y -q java-17-openjdk java-17-openjdk-devel +fi + +# 检查并安装 Maven +if ! command -v mvn &> /dev/null; then + echo "安装 Maven..." + dnf install -y -q maven +fi + +# 检查并安装 MySQL +if ! command -v mysql &> /dev/null; then + echo "安装 MySQL..." + dnf install -y -q mysql-server mysql + systemctl enable mysqld + systemctl start mysqld + sleep 3 + # 设置root密码 + mysqladmin -u root password "${MYSQL_ROOT_PASSWORD}" 2>/dev/null || mysqladmin -u root password "${MYSQL_ROOT_PASSWORD}" +fi + +# 检查并安装 Redis +if ! command -v redis-cli &> /dev/null; then + echo "安装 Redis..." + dnf install -y -q redis + systemctl enable redis + systemctl start redis +fi + +# Nginx 已安装,检查状态 +systemctl enable nginx || true +systemctl start nginx || true + +# Node.js 和 pnpm 已安装,跳过 + +echo "✓ 系统依赖安装完成" +ENDSSH + + echo -e "${GREEN}✓ 系统依赖安装完成${NC}" + echo "" +} + +# 步骤2:创建目录结构 +create_directories() { + echo -e "${BLUE}[2/10] 创建目录结构...${NC}" + + ssh_cmd << ENDSSH +set -e +mkdir -p ${APP_DIR}/{backend,frontend,logs,uploads} +mkdir -p /etc/nginx/conf.d +echo "✓ 目录创建完成" +ENDSSH + + echo -e "${GREEN}✓ 目录结构创建完成${NC}" + echo "" +} + +# 步骤3:配置 MySQL +setup_mysql() { + echo -e "${BLUE}[3/10] 配置 MySQL 数据库...${NC}" + + ssh_cmd << ENDSSH +set -e + +# 启动 MySQL (CentOS使用mysqld) +systemctl start mysqld || true +systemctl enable mysqld || true + +# 等待MySQL启动 +sleep 5 + +# 创建数据库和用户 +mysql -uroot -p${MYSQL_ROOT_PASSWORD} << 'EOSQL' 2>/dev/null || mysql -uroot -p${MYSQL_ROOT_PASSWORD} << 'EOSQL' +CREATE DATABASE IF NOT EXISTS ${DB_NAME} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +CREATE USER IF NOT EXISTS '${DB_USER}'@'localhost' IDENTIFIED BY '${DB_PASSWORD}'; +GRANT ALL PRIVILEGES ON ${DB_NAME}.* TO '${DB_USER}'@'localhost'; +FLUSH PRIVILEGES; +EOSQL + +echo "✓ MySQL 配置完成" +ENDSSH + + echo -e "${GREEN}✓ MySQL 配置完成${NC}" + echo "" +} + +# 步骤4:配置 Redis +setup_redis() { + echo -e "${BLUE}[4/10] 配置 Redis...${NC}" + + ssh_cmd << ENDSSH +set -e + +# 启动 Redis (CentOS使用redis服务) +systemctl start redis || true +systemctl enable redis || true + +# 设置密码(如果还没设置) +redis-cli CONFIG SET requirepass "${REDIS_PASSWORD}" 2>/dev/null || true + +echo "✓ Redis 配置完成" +ENDSSH + + echo -e "${GREEN}✓ Redis 配置完成${NC}" + echo "" +} + +# 步骤5:上传后端代码 +upload_backend() { + echo -e "${BLUE}[5/10] 上传后端代码...${NC}" + + # 上传整个backend目录 + rsync_cmd --exclude='target' --exclude='*.class' \ + backend/ ${SERVER_USER}@${SERVER_IP}:${APP_DIR}/backend/ + + echo -e "${GREEN}✓ 后端代码上传完成${NC}" + echo "" +} + +# 步骤6:上传前端代码 +upload_frontend() { + echo -e "${BLUE}[6/10] 上传前端代码...${NC}" + + # 上传整个frontend目录(不包括node_modules) + rsync_cmd --exclude='node_modules' --exclude='dist' \ + frontend/ ${SERVER_USER}@${SERVER_IP}:${APP_DIR}/frontend/ + + echo -e "${GREEN}✓ 前端代码上传完成${NC}" + echo "" +} + +# 步骤7:配置后端 +setup_backend() { + echo -e "${BLUE}[7/10] 配置并构建后端...${NC}" + + ssh_cmd << ENDSSH +set -e +cd ${APP_DIR}/backend + +# 创建生产环境配置文件 +cat > src/main/resources/application-prod.yml << EOF +spring: + datasource: + url: jdbc:mysql://localhost:3306/${DB_NAME}?useUnicode=true&characterEncoding=utf8mb4&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true + username: ${DB_USER} + password: ${DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + hikari: + minimum-idle: 10 + maximum-pool-size: 50 + connection-timeout: 30000 + idle-timeout: 600000 + max-lifetime: 1800000 + + redis: + host: localhost + port: 6379 + password: ${REDIS_PASSWORD} + database: 0 + timeout: 3000 + +jwt: + secret: DesignHub_Prod_Secret_Key_2025_Very_Strong + access-token-expiration: 7200000 + refresh-token-expiration: 604800000 + +server: + port: 5123 + +logging: + level: + root: INFO + com.designhub: INFO + file: + name: ${APP_DIR}/logs/application.log + max-size: 10MB + max-history: 30 + +file: + upload: + path: ${APP_DIR}/uploads + max-size: 52428800 +EOF + +# 构建项目 +echo "开始构建后端项目..." +mvn clean package -DskipTests 2>&1 | tail -20 + +# 复制 JAR 文件 +cp target/designhub-backend-1.0.0.jar ${APP_DIR}/designhub.jar + +echo "✓ 后端构建完成" +ENDSSH + + echo -e "${GREEN}✓ 后端配置完成${NC}" + echo "" +} + +# 步骤8:配置前端 +setup_frontend() { + echo -e "${BLUE}[8/10] 配置并构建前端...${NC}" + + ssh_cmd << ENDSSH +set -e +cd ${APP_DIR}/frontend + +# 创建生产环境配置 +cat > .env.production << EOF +VITE_APP_BASE_API=http://${SERVER_IP}/api +VITE_APP_TITLE=DesignHub +VITE_APP_VERSION=6.0 +EOF + +# 安装依赖 +echo "安装前端依赖..." +pnpm install + +# 构建项目 +echo "构建前端项目..." +pnpm build + +echo "✓ 前端构建完成" +ENDSSH + + echo -e "${GREEN}✓ 前端配置完成${NC}" + echo "" +} + +# 步骤9:配置系统服务 +setup_services() { + echo -e "${BLUE}[9/10] 配置系统服务...${NC}" + + ssh_cmd << ENDSSH +set -e + +# 配置防火墙开放5123端口 +if command -v firewall-cmd &> /dev/null; then + firewall-cmd --permanent --add-port=5123/tcp + firewall-cmd --reload + echo "✓ 防火墙端口5123已开放" +elif command -v ufw &> /dev/null; then + ufw allow 5123/tcp + echo "✓ 防火墙端口5123已开放" +else + echo "未找到防火墙工具,请手动开放端口5123" +fi + +# 创建后端服务 +cat > /etc/systemd/system/designhub-backend.service << EOF +[Unit] +Description=DesignHub Backend Service +After=network.target mysqld.service redis.service + +[Service] +Type=simple +User=root +WorkingDirectory=${APP_DIR} +ExecStart=/usr/bin/java -jar -Xms512m -Xmx1024m ${APP_DIR}/designhub.jar --spring.profiles.active=prod +Restart=on-failure +RestartSec=10 + +StandardOutput=append:${APP_DIR}/logs/backend.log +StandardError=append:${APP_DIR}/logs/backend-error.log + +[Install] +WantedBy=multi-user.target +EOF + +# 创建 Nginx 配置 +cat > /etc/nginx/conf.d/designhub.conf << EOF +server { + listen 80; + server_name ${SERVER_IP}; + + root ${APP_DIR}/frontend/dist; + index index.html; + + access_log ${APP_DIR}/logs/nginx-access.log; + error_log ${APP_DIR}/logs/nginx-error.log; + + # 前端路由 + location / { + try_files \$uri \$uri/ /index.html; + } + + # API 代理 + location /api/ { + proxy_pass http://localhost:5123/api/; + proxy_set_header Host \$host; + 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; + + proxy_connect_timeout 600; + proxy_send_timeout 600; + proxy_read_timeout 600; + } + + # 静态资源缓存 + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 30d; + add_header Cache-Control "public, immutable"; + } + + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml; + gzip_min_length 1000; +} +EOF + +# 重新加载 systemd +systemctl daemon-reload + +# 测试 Nginx 配置 +nginx -t + +# 重启 Nginx +systemctl restart nginx +systemctl enable nginx + +echo "✓ 系统服务配置完成" +ENDSSH + + echo -e "${GREEN}✓ 系统服务配置完成${NC}" + echo "" +} + +# 步骤10:启动服务 +start_services() { + echo -e "${BLUE}[10/10] 启动服务...${NC}" + + ssh_cmd << ENDSSH +set -e + +# 启动后端服务 +systemctl start designhub-backend +systemctl enable designhub-backend + +# 等待服务启动 +sleep 5 + +# 检查服务状态 +echo "" +echo "=== 服务状态 ===" +systemctl status designhub-backend --no-pager -l | head -15 + +echo "" +echo "✓ 服务启动完成" +ENDSSH + + echo -e "${GREEN}✓ 服务启动完成${NC}" + echo "" +} + +# 显示部署信息 +show_deployment_info() { + echo "" + echo "========================================" + echo -e "${GREEN} 部署完成!${NC}" + echo "========================================" + echo "" + echo "访问地址:" + echo " http://${SERVER_IP}" + echo "" + echo "API 地址:" + echo " http://${SERVER_IP}/api" + echo "" + echo "服务管理:" + echo " 查看后端状态: ssh ${SERVER_USER}@${SERVER_IP} 'systemctl status designhub-backend'" + echo " 查看后端日志: ssh ${SERVER_USER}@${SERVER_IP} 'tail -f ${APP_DIR}/logs/application.log'" + echo " 重启后端: ssh ${SERVER_USER}@${SERVER_IP} 'systemctl restart designhub-backend'" + echo "" + echo "数据库信息:" + echo " 数据库名: ${DB_NAME}" + echo " 用户名: ${DB_USER}" + echo " 密码: ${DB_PASSWORD}" + echo "" +} + +# 主执行流程 +main() { + check_sshpass + test_connection + install_dependencies + create_directories + setup_mysql + setup_redis + upload_backend + upload_frontend + setup_backend + setup_frontend + setup_services + start_services + show_deployment_info +} + +# 执行主函数 +main diff --git a/frontend/.env.development b/frontend/.env.development new file mode 100644 index 0000000..1e20cad --- /dev/null +++ b/frontend/.env.development @@ -0,0 +1,18 @@ +# ================================ +# 开发环境配置 +# ================================ + +# API 基础路径(本地开发) +VITE_APP_BASE_API=http://localhost:8080/api + +# 应用标题 +VITE_APP_TITLE=DesignHub + +# 应用版本 +VITE_APP_VERSION=6.0 + +# 是否开启 Mock 数据(可选) +VITE_APP_USE_MOCK=false + +# 是否显示调试信息 +VITE_APP_DEBUG=true diff --git a/frontend/.env.production b/frontend/.env.production new file mode 100644 index 0000000..ce659a0 --- /dev/null +++ b/frontend/.env.production @@ -0,0 +1,3 @@ +VITE_APP_BASE_API=http://38.246.244.140:8888/api +VITE_APP_TITLE=DesignHub +VITE_APP_VERSION=6.0 diff --git a/frontend/.eslintrc-auto-import.json b/frontend/.eslintrc-auto-import.json new file mode 100644 index 0000000..181d852 --- /dev/null +++ b/frontend/.eslintrc-auto-import.json @@ -0,0 +1,91 @@ +{ + "globals": { + "Component": true, + "ComponentPublicInstance": true, + "ComputedRef": true, + "DirectiveBinding": true, + "EffectScope": true, + "ExtractDefaultPropTypes": true, + "ExtractPropTypes": true, + "ExtractPublicPropTypes": true, + "InjectionKey": true, + "MaybeRef": true, + "MaybeRefOrGetter": true, + "PropType": true, + "Ref": true, + "VNode": true, + "WritableComputedRef": true, + "acceptHMRUpdate": true, + "computed": true, + "createApp": true, + "createPinia": true, + "customRef": true, + "defineAsyncComponent": true, + "defineComponent": true, + "defineStore": true, + "effectScope": true, + "getActivePinia": true, + "getCurrentInstance": true, + "getCurrentScope": true, + "h": true, + "inject": true, + "isProxy": true, + "isReactive": true, + "isReadonly": true, + "isRef": true, + "mapActions": true, + "mapGetters": true, + "mapState": true, + "mapStores": true, + "mapWritableState": true, + "markRaw": true, + "nextTick": true, + "onActivated": true, + "onBeforeMount": true, + "onBeforeRouteLeave": true, + "onBeforeRouteUpdate": true, + "onBeforeUnmount": true, + "onBeforeUpdate": true, + "onDeactivated": true, + "onErrorCaptured": true, + "onMounted": true, + "onRenderTracked": true, + "onRenderTriggered": true, + "onScopeDispose": true, + "onServerPrefetch": true, + "onUnmounted": true, + "onUpdated": true, + "onWatcherCleanup": true, + "provide": true, + "reactive": true, + "readonly": true, + "ref": true, + "resolveComponent": true, + "setActivePinia": true, + "setMapStoreSuffix": true, + "shallowReactive": true, + "shallowReadonly": true, + "shallowRef": true, + "storeToRefs": true, + "toRaw": true, + "toRef": true, + "toRefs": true, + "toValue": true, + "triggerRef": true, + "unref": true, + "useAttrs": true, + "useCssModule": true, + "useCssVars": true, + "useId": true, + "useLink": true, + "useModel": true, + "useRoute": true, + "useRouter": true, + "useSlots": true, + "useTemplateRef": true, + "watch": true, + "watchEffect": true, + "watchPostEffect": true, + "watchSyncEffect": true + } +} diff --git a/frontend/dist.tar.gz b/frontend/dist.tar.gz new file mode 100644 index 0000000..f56fe6d Binary files /dev/null and b/frontend/dist.tar.gz differ diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..17515c1 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,15 @@ + + + + + + + + DesignHub - 设计师任务管理平台 + + +
+ + + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..6fcb647 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,4793 @@ +{ + "name": "designhub-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "designhub-frontend", + "version": "1.0.0", + "dependencies": { + "@ant-design/icons-vue": "^7.0.1", + "ant-design-vue": "^4.1.0", + "axios": "^1.6.2", + "dayjs": "^1.11.19", + "echarts": "^5.4.3", + "lodash-es": "^4.17.21", + "pinia": "^2.1.7", + "vue": "^3.4.0", + "vue-router": "^4.2.5" + }, + "devDependencies": { + "@types/lodash-es": "^4.17.12", + "@types/node": "^20.10.5", + "@typescript-eslint/eslint-plugin": "^6.15.0", + "@typescript-eslint/parser": "^6.15.0", + "@vitejs/plugin-vue": "^5.0.0", + "@vue/tsconfig": "^0.5.0", + "eslint": "^8.55.0", + "eslint-plugin-vue": "^9.19.2", + "prettier": "^3.1.1", + "sass": "^1.69.5", + "typescript": "~5.3.0", + "unplugin-auto-import": "^0.17.2", + "unplugin-vue-components": "^0.26.0", + "vite": "^5.0.0", + "vue-tsc": "^1.8.27" + } + }, + "node_modules/@ant-design/colors": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-6.0.0.tgz", + "integrity": "sha512-qAZRvPzfdWHtfameEGP2Qvuf838NhergR35o+EuVyB5XvSA98xod5r4utvi4TJ3ywmevm290g9nsCG5MryrdWQ==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^3.4.0" + } + }, + "node_modules/@ant-design/icons-svg": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.4.2.tgz", + "integrity": "sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA==", + "license": "MIT" + }, + "node_modules/@ant-design/icons-vue": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@ant-design/icons-vue/-/icons-vue-7.0.1.tgz", + "integrity": "sha512-eCqY2unfZK6Fe02AwFlDHLfoyEFreP6rBwAZMIJ1LugmfMiVgwWDYlp1YsRugaPtICYOabV1iWxXdP12u9U43Q==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^6.0.0", + "@ant-design/icons-svg": "^4.2.1" + }, + "peerDependencies": { + "vue": ">=3.0.3" + } + }, + "node_modules/@antfu/utils": { + "version": "0.7.10", + "resolved": "https://registry.npmjs.org/@antfu/utils/-/utils-0.7.10.tgz", + "integrity": "sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz", + "integrity": "sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "license": "MIT" + }, + "node_modules/@emotion/unitless": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.8.1.tgz", + "integrity": "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@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/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@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/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@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/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@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/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@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/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@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/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@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/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@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/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@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/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@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/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@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/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@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/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@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/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@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/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@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/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@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/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@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/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@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/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@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/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@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/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@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/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@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/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@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/@eslint-community/eslint-utils": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "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.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "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", + "resolved": "https://registry.npmjs.org/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/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/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/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "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", + "resolved": "https://registry.npmjs.org/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/@humanwhocodes/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/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/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "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", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "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", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", + "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^1.0.3", + "is-glob": "^4.0.3", + "micromatch": "^4.0.5", + "node-addon-api": "^7.0.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.1", + "@parcel/watcher-darwin-arm64": "2.5.1", + "@parcel/watcher-darwin-x64": "2.5.1", + "@parcel/watcher-freebsd-x64": "2.5.1", + "@parcel/watcher-linux-arm-glibc": "2.5.1", + "@parcel/watcher-linux-arm-musl": "2.5.1", + "@parcel/watcher-linux-arm64-glibc": "2.5.1", + "@parcel/watcher-linux-arm64-musl": "2.5.1", + "@parcel/watcher-linux-x64-glibc": "2.5.1", + "@parcel/watcher-linux-x64-musl": "2.5.1", + "@parcel/watcher-win32-arm64": "2.5.1", + "@parcel/watcher-win32-ia32": "2.5.1", + "@parcel/watcher-win32-x64": "2.5.1" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz", + "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", + "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz", + "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz", + "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz", + "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz", + "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz", + "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz", + "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz", + "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz", + "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz", + "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz", + "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz", + "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.5.tgz", + "integrity": "sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.5.tgz", + "integrity": "sha512-mQGfsIEFcu21mvqkEKKu2dYmtuSZOBMmAl5CFlPGLY94Vlcm+zWApK7F/eocsNzp8tKmbeBP8yXyAbx0XHsFNA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.5.tgz", + "integrity": "sha512-takF3CR71mCAGA+v794QUZ0b6ZSrgJkArC+gUiG6LB6TQty9T0Mqh3m2ImRBOxS2IeYBo4lKWIieSvnEk2OQWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.5.tgz", + "integrity": "sha512-W901Pla8Ya95WpxDn//VF9K9u2JbocwV/v75TE0YIHNTbhqUTv9w4VuQ9MaWlNOkkEfFwkdNhXgcLqPSmHy0fA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.5.tgz", + "integrity": "sha512-QofO7i7JycsYOWxe0GFqhLmF6l1TqBswJMvICnRUjqCx8b47MTo46W8AoeQwiokAx3zVryVnxtBMcGcnX12LvA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.5.tgz", + "integrity": "sha512-jr21b/99ew8ujZubPo9skbrItHEIE50WdV86cdSoRkKtmWa+DDr6fu2c/xyRT0F/WazZpam6kk7IHBerSL7LDQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.5.tgz", + "integrity": "sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.5.tgz", + "integrity": "sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.5.tgz", + "integrity": "sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.5.tgz", + "integrity": "sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.5.tgz", + "integrity": "sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.5.tgz", + "integrity": "sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.5.tgz", + "integrity": "sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.5.tgz", + "integrity": "sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.5.tgz", + "integrity": "sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.5.tgz", + "integrity": "sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.5.tgz", + "integrity": "sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.5.tgz", + "integrity": "sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.5.tgz", + "integrity": "sha512-w0cDWVR6MlTstla1cIfOGyl8+qb93FlAVutcor14Gf5Md5ap5ySfQ7R9S/NjNaMLSFdUnKGEasmVnu3lCMqB7w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.5.tgz", + "integrity": "sha512-Aufdpzp7DpOTULJCuvzqcItSGDH73pF3ko/f+ckJhxQyHtp67rHw3HMNxoIdDMUITJESNE6a8uh4Lo4SLouOUg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.5.tgz", + "integrity": "sha512-UGBUGPFp1vkj6p8wCRraqNhqwX/4kNQPS57BCFc8wYh0g94iVIW33wJtQAx3G7vrjjNtRaxiMUylM0ktp/TRSQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.5.tgz", + "integrity": "sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@simonwep/pickr": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/@simonwep/pickr/-/pickr-1.8.2.tgz", + "integrity": "sha512-/l5w8BIkrpP6n1xsetx9MWPWlU6OblN5YgZZphxan0Tq4BByTCETL6lyIeY8lagalS2Nbt4F2W034KHLIiunKA==", + "license": "MIT", + "dependencies": { + "core-js": "^3.15.1", + "nanopop": "^2.1.0" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.20", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.20.tgz", + "integrity": "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/node": { + "version": "20.19.24", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.24.tgz", + "integrity": "sha512-FE5u0ezmi6y9OZEzlJfg37mqqf6ZDSF2V/NLjUyGrR9uTZ7Sb9F7bLNZ03S4XVUNRWGA7Ck4c1kK+YnuWjl+DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@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", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz", + "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", + "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.npmjs.org/@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", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.21.0.tgz", + "integrity": "sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==", + "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", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz", + "integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==", + "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", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz", + "integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==", + "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", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.21.0.tgz", + "integrity": "sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==", + "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", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz", + "integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==", + "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", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true, + "license": "ISC" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-1.11.1.tgz", + "integrity": "sha512-dOcNn3i9GgZAcJt43wuaEykSluAuOkQgzni1cuxLxTV0nJKanQztp7FxyswdRILaKH+P2XZMPRp2S4MV/pElCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "1.11.1" + } + }, + "node_modules/@volar/source-map": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-1.11.1.tgz", + "integrity": "sha512-hJnOnwZ4+WT5iupLRnuzbULZ42L7BWWPMmruzwtLhJfpDVoZLjNBxHDi2sY2bgZXCKlpU5XcsMFoYrsQmPhfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "muggle-string": "^0.3.1" + } + }, + "node_modules/@volar/typescript": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-1.11.1.tgz", + "integrity": "sha512-iU+t2mas/4lYierSnoFOeRFQUhAEMgsFuQxoxvwn5EdQopw43j+J27a4lt9LMInx1gLJBC6qL14WYGlgymaSMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "1.11.1", + "path-browserify": "^1.0.1" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.24", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.24.tgz", + "integrity": "sha512-eDl5H57AOpNakGNAkFDH+y7kTqrQpJkZFXhWZQGyx/5Wh7B1uQYvcWkvZi11BDhscPgj8N7XV3oRwiPnx1Vrig==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/shared": "3.5.24", + "entities": "^4.5.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.24", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.24.tgz", + "integrity": "sha512-1QHGAvs53gXkWdd3ZMGYuvQFXHW4ksKWPG8HP8/2BscrbZ0brw183q2oNWjMrSWImYLHxHrx1ItBQr50I/q2zw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.24", + "@vue/shared": "3.5.24" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.24", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.24.tgz", + "integrity": "sha512-8EG5YPRgmTB+YxYBM3VXy8zHD9SWHUJLIGPhDovo3Z8VOgvP+O7UP5vl0J4BBPWYD9vxtBabzW1EuEZ+Cqs14g==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/compiler-core": "3.5.24", + "@vue/compiler-dom": "3.5.24", + "@vue/compiler-ssr": "3.5.24", + "@vue/shared": "3.5.24", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.24", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.24.tgz", + "integrity": "sha512-trOvMWNBMQ/odMRHW7Ae1CdfYx+7MuiQu62Jtu36gMLXcaoqKvAyh+P73sYG9ll+6jLB6QPovqoKGGZROzkFFg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.24", + "@vue/shared": "3.5.24" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/language-core": { + "version": "1.8.27", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-1.8.27.tgz", + "integrity": "sha512-L8Kc27VdQserNaCUNiSFdDl9LWT24ly8Hpwf1ECy3aFb9m6bDhBGQYOujDm21N7EW3moKIOKEanQwe1q5BK+mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "~1.11.1", + "@volar/source-map": "~1.11.1", + "@vue/compiler-dom": "^3.3.0", + "@vue/shared": "^3.3.0", + "computeds": "^0.0.1", + "minimatch": "^9.0.3", + "muggle-string": "^0.3.1", + "path-browserify": "^1.0.1", + "vue-template-compiler": "^2.7.14" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.24", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.24.tgz", + "integrity": "sha512-BM8kBhtlkkbnyl4q+HiF5R5BL0ycDPfihowulm02q3WYp2vxgPcJuZO866qa/0u3idbMntKEtVNuAUp5bw4teg==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.24" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.24", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.24.tgz", + "integrity": "sha512-RYP/byyKDgNIqfX/gNb2PB55dJmM97jc9wyF3jK7QUInYKypK2exmZMNwnjueWwGceEkP6NChd3D2ZVEp9undQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.24", + "@vue/shared": "3.5.24" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.24", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.24.tgz", + "integrity": "sha512-Z8ANhr/i0XIluonHVjbUkjvn+CyrxbXRIxR7wn7+X7xlcb7dJsfITZbkVOeJZdP8VZwfrWRsWdShH6pngMxRjw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.24", + "@vue/runtime-core": "3.5.24", + "@vue/shared": "3.5.24", + "csstype": "^3.1.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.24", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.24.tgz", + "integrity": "sha512-Yh2j2Y4G/0/4z/xJ1Bad4mxaAk++C2v4kaa8oSYTMJBJ00/ndPuxCnWeot0/7/qafQFLh5pr6xeV6SdMcE/G1w==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.24", + "@vue/shared": "3.5.24" + }, + "peerDependencies": { + "vue": "3.5.24" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.24", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.24.tgz", + "integrity": "sha512-9cwHL2EsJBdi8NY22pngYYWzkTDhld6fAD6jlaeloNGciNSJL6bLpbxVgXl96X00Jtc6YWQv96YA/0sxex/k1A==", + "license": "MIT" + }, + "node_modules/@vue/tsconfig": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@vue/tsconfig/-/tsconfig-0.5.1.tgz", + "integrity": "sha512-VcZK7MvpjuTPx2w6blwnwZAu5/LgBUtejFOi3pPGQFXQN5Ela03FUtd2Qtg4yWGGissVL0dr6Ro1LfOFh+PCuQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "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", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "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/ant-design-vue": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/ant-design-vue/-/ant-design-vue-4.2.6.tgz", + "integrity": "sha512-t7eX13Yj3i9+i5g9lqFyYneoIb3OzTvQjq9Tts1i+eiOd3Eva/6GagxBSXM1fOCjqemIu0FYVE1ByZ/38epR3Q==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^6.0.0", + "@ant-design/icons-vue": "^7.0.0", + "@babel/runtime": "^7.10.5", + "@ctrl/tinycolor": "^3.5.0", + "@emotion/hash": "^0.9.0", + "@emotion/unitless": "^0.8.0", + "@simonwep/pickr": "~1.8.0", + "array-tree-filter": "^2.1.0", + "async-validator": "^4.0.0", + "csstype": "^3.1.1", + "dayjs": "^1.10.5", + "dom-align": "^1.12.1", + "dom-scroll-into-view": "^2.0.0", + "lodash": "^4.17.21", + "lodash-es": "^4.17.15", + "resize-observer-polyfill": "^1.5.1", + "scroll-into-view-if-needed": "^2.2.25", + "shallow-equal": "^1.0.0", + "stylis": "^4.1.3", + "throttle-debounce": "^5.0.0", + "vue-types": "^3.0.0", + "warning": "^4.0.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ant-design-vue" + }, + "peerDependencies": { + "vue": ">=3.2.0" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-tree-filter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-tree-filter/-/array-tree-filter-2.1.0.tgz", + "integrity": "sha512-4ROwICNlNw/Hqa9v+rk5h22KjmzB1JGTMVKP2AKJBOCgb0yL0ASf0+YvCcLNNwquOHNX48jkeZIJ3a+oOQqKcw==", + "license": "MIT" + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "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", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "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/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/compute-scroll-into-view": { + "version": "1.0.20", + "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz", + "integrity": "sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==", + "license": "MIT" + }, + "node_modules/computeds": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/computeds/-/computeds-0.0.1.tgz", + "integrity": "sha512-7CEBgcMjVmitjYo5q8JTJVra6X5mQ20uTThdK+0kR7UEaDrAWEQcRiBtWJzga4eRpP6afNwwLsX2SET2JhVB1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/core-js": { + "version": "3.46.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.46.0.tgz", + "integrity": "sha512-vDMm9B0xnqqZ8uSBpZ8sNtRtOdmfShrvT6h2TuQGLs0Is+cR0DYbj/KWP6ALVNbWPpqA/qPLoOuppJN07humpA==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.19", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", + "license": "MIT" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/dom-align": { + "version": "1.12.4", + "resolved": "https://registry.npmjs.org/dom-align/-/dom-align-1.12.4.tgz", + "integrity": "sha512-R8LUSEay/68zE5c8/3BDxiTEvgb4xZTF0RKmAHfiEVN3klfIpXfi2/QCoiWPccVQ0J/ZGdz9OjzL4uJEP/MRAw==", + "license": "MIT" + }, + "node_modules/dom-scroll-into-view": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/dom-scroll-into-view/-/dom-scroll-into-view-2.0.1.tgz", + "integrity": "sha512-bvVTQe1lfaUr1oFzZX80ce9KLDlZ3iU+XGNE/bz9HnGdklTieqsbmsLHe+rT2XWqopvL0PckkYqN7ksmm5pe3w==", + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "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/echarts": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz", + "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "5.6.1" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/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", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "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", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "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/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/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-vue": { + "version": "9.33.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-9.33.0.tgz", + "integrity": "sha512-174lJKuNsuDIlLpjeXc5E2Tss8P44uIimAfGD0b90k0NoirJqpG7stLuU9Vp/9ioTOrQdWVREc4mRd1BD+CvGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "globals": "^13.24.0", + "natural-compare": "^1.4.0", + "nth-check": "^2.1.1", + "postcss-selector-parser": "^6.0.15", + "semver": "^7.6.3", + "vue-eslint-parser": "^9.4.3", + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": "^14.17.0 || >=16.0.0" + }, + "peerDependencies": { + "eslint": "^6.2.0 || ^7.0.0 || ^8.0.0 || ^9.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "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", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "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.npmjs.org/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.npmjs.org/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", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "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", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/exsolve": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.7.tgz", + "integrity": "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "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", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "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", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "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", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "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", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "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", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "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", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/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", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "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", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "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", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "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", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "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", + "resolved": "https://registry.npmjs.org/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/glob/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/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/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "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", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "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", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "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", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/immutable": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz", + "integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==", + "dev": true, + "license": "MIT" + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "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", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-plain-object": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-3.0.1.tgz", + "integrity": "sha512-Xnpx182SBMrr/aBik8y+GuR4U1L9FqMSojwDQwPMmxyC6bvEqly9UBCxhauBF5vNh2gwWJNX6oDV7O+OM4z34g==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/local-pkg": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz", + "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mlly": "^1.7.3", + "pkg-types": "^1.2.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "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.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/loose-envify/node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "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", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "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/mlly": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", + "integrity": "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.15.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.1" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/muggle-string": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.3.1.tgz", + "integrity": "sha512-ckmWDJjphvd/FvZawgygcUeQCxzvohjFO5RxTjj4eq8kw359gFF3E1brjfI+viLMxss5JrHTDRHZvu2/tuy0Qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "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/nanopop": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/nanopop/-/nanopop-2.4.2.tgz", + "integrity": "sha512-NzOgmMQ+elxxHeIha+OG/Pv3Oc3p4RU2aBhwWwAqDpXrdTbtRylbRLQztLy8dMMwfl6pclznBdfUhccEn9ZIzw==", + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "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/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "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", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "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/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/quansync": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", + "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/antfu" + }, + { + "type": "individual", + "url": "https://github.com/sponsors/sxzz" + } + ], + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "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/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", + "license": "MIT" + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "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.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.5.tgz", + "integrity": "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==", + "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.5", + "@rollup/rollup-android-arm64": "4.52.5", + "@rollup/rollup-darwin-arm64": "4.52.5", + "@rollup/rollup-darwin-x64": "4.52.5", + "@rollup/rollup-freebsd-arm64": "4.52.5", + "@rollup/rollup-freebsd-x64": "4.52.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.52.5", + "@rollup/rollup-linux-arm-musleabihf": "4.52.5", + "@rollup/rollup-linux-arm64-gnu": "4.52.5", + "@rollup/rollup-linux-arm64-musl": "4.52.5", + "@rollup/rollup-linux-loong64-gnu": "4.52.5", + "@rollup/rollup-linux-ppc64-gnu": "4.52.5", + "@rollup/rollup-linux-riscv64-gnu": "4.52.5", + "@rollup/rollup-linux-riscv64-musl": "4.52.5", + "@rollup/rollup-linux-s390x-gnu": "4.52.5", + "@rollup/rollup-linux-x64-gnu": "4.52.5", + "@rollup/rollup-linux-x64-musl": "4.52.5", + "@rollup/rollup-openharmony-arm64": "4.52.5", + "@rollup/rollup-win32-arm64-msvc": "4.52.5", + "@rollup/rollup-win32-ia32-msvc": "4.52.5", + "@rollup/rollup-win32-x64-gnu": "4.52.5", + "@rollup/rollup-win32-x64-msvc": "4.52.5", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "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/sass": { + "version": "1.93.3", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.93.3.tgz", + "integrity": "sha512-elOcIZRTM76dvxNAjqYrucTSI0teAF/L2Lv0s6f6b7FOwcwIuA357bIE871580AjHJuSvLIRUosgV+lIWx6Rgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.0", + "immutable": "^5.0.2", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, + "node_modules/scroll-into-view-if-needed": { + "version": "2.2.31", + "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.31.tgz", + "integrity": "sha512-dGCXy99wZQivjmjIqihaBQNjryrz5rueJY7eHfTdyWEiR4ttYpsajb14rn9s5d4DY4EcY6+4+U/maARBXJedkA==", + "license": "MIT", + "dependencies": { + "compute-scroll-into-view": "^1.0.20" + } + }, + "node_modules/scule": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz", + "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shallow-equal": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/shallow-equal/-/shallow-equal-1.2.1.tgz", + "integrity": "sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-literal": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.1.tgz", + "integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/stylis": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz", + "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==", + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/throttle-debounce": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz", + "integrity": "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==", + "license": "MIT", + "engines": { + "node": ">=12.22" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", + "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, + "node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", + "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz", + "integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unimport": { + "version": "3.14.6", + "resolved": "https://registry.npmjs.org/unimport/-/unimport-3.14.6.tgz", + "integrity": "sha512-CYvbDaTT04Rh8bmD8jz3WPmHYZRG/NnvYVzwD6V1YAlvvKROlAeNDUBhkBGzNav2RKaeuXvlWYaa1V4Lfi/O0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.1.4", + "acorn": "^8.14.0", + "escape-string-regexp": "^5.0.0", + "estree-walker": "^3.0.3", + "fast-glob": "^3.3.3", + "local-pkg": "^1.0.0", + "magic-string": "^0.30.17", + "mlly": "^1.7.4", + "pathe": "^2.0.1", + "picomatch": "^4.0.2", + "pkg-types": "^1.3.0", + "scule": "^1.3.0", + "strip-literal": "^2.1.1", + "unplugin": "^1.16.1" + } + }, + "node_modules/unimport/node_modules/confbox": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz", + "integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unimport/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/unimport/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/unimport/node_modules/local-pkg": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz", + "integrity": "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mlly": "^1.7.4", + "pkg-types": "^2.3.0", + "quansync": "^0.2.11" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/unimport/node_modules/local-pkg/node_modules/pkg-types": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", + "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.2.2", + "exsolve": "^1.0.7", + "pathe": "^2.0.3" + } + }, + "node_modules/unimport/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/unplugin": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-1.16.1.tgz", + "integrity": "sha512-4/u/j4FrCKdi17jaxuJA0jClGxB1AvU2hw/IuayPc4ay1XGaJs/rbb4v5WKwAjNifjmXK9PIFyuPiaK8azyR9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.14.0", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/unplugin-auto-import": { + "version": "0.17.8", + "resolved": "https://registry.npmjs.org/unplugin-auto-import/-/unplugin-auto-import-0.17.8.tgz", + "integrity": "sha512-CHryj6HzJ+n4ASjzwHruD8arhbdl+UXvhuAIlHDs15Y/IMecG3wrf7FVg4pVH/DIysbq/n0phIjNHAjl7TG7Iw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@antfu/utils": "^0.7.10", + "@rollup/pluginutils": "^5.1.0", + "fast-glob": "^3.3.2", + "local-pkg": "^0.5.0", + "magic-string": "^0.30.10", + "minimatch": "^9.0.4", + "unimport": "^3.7.2", + "unplugin": "^1.11.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@nuxt/kit": "^3.2.2", + "@vueuse/core": "*" + }, + "peerDependenciesMeta": { + "@nuxt/kit": { + "optional": true + }, + "@vueuse/core": { + "optional": true + } + } + }, + "node_modules/unplugin-auto-import/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "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/unplugin-vue-components": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/unplugin-vue-components/-/unplugin-vue-components-0.26.0.tgz", + "integrity": "sha512-s7IdPDlnOvPamjunVxw8kNgKNK8A5KM1YpK5j/p97jEKTjlPNrA0nZBiSfAKKlK1gWZuyWXlKL5dk3EDw874LQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@antfu/utils": "^0.7.6", + "@rollup/pluginutils": "^5.0.4", + "chokidar": "^3.5.3", + "debug": "^4.3.4", + "fast-glob": "^3.3.1", + "local-pkg": "^0.4.3", + "magic-string": "^0.30.3", + "minimatch": "^9.0.3", + "resolve": "^1.22.4", + "unplugin": "^1.4.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@babel/parser": "^7.15.8", + "@nuxt/kit": "^3.2.2", + "vue": "2 || 3" + }, + "peerDependenciesMeta": { + "@babel/parser": { + "optional": true + }, + "@nuxt/kit": { + "optional": true + } + } + }, + "node_modules/unplugin-vue-components/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/unplugin-vue-components/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/unplugin-vue-components/node_modules/local-pkg": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.4.3.tgz", + "integrity": "sha512-SFppqq5p42fe2qcZQqqEOiVRXl+WCP1MdT6k7BDEW1j++sp5fIY+/fdRQitvKgB5BrBcmrs5m/L0v2FrU5MY1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/unplugin-vue-components/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "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/vue": { + "version": "3.5.24", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.24.tgz", + "integrity": "sha512-uTHDOpVQTMjcGgrqFPSb8iO2m1DUvo+WbGqoXQz8Y1CeBYQ0FXf2z1gLRaBtHjlRz7zZUBHxjVB5VTLzYkvftg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.24", + "@vue/compiler-sfc": "3.5.24", + "@vue/runtime-dom": "3.5.24", + "@vue/server-renderer": "3.5.24", + "@vue/shared": "3.5.24" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-eslint-parser": { + "version": "9.4.3", + "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.4.3.tgz", + "integrity": "sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "eslint-scope": "^7.1.1", + "eslint-visitor-keys": "^3.3.0", + "espree": "^9.3.1", + "esquery": "^1.4.0", + "lodash": "^4.17.21", + "semver": "^7.3.6" + }, + "engines": { + "node": "^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + }, + "peerDependencies": { + "eslint": ">=6.0.0" + } + }, + "node_modules/vue-router": { + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.3.tgz", + "integrity": "sha512-ARBedLm9YlbvQomnmq91Os7ck6efydTSpRP3nuOKCvgJOHNrhRoJDSKtee8kcL1Vf7nz6U+PMBL+hTvR3bTVQg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-template-compiler": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/vue-template-compiler/-/vue-template-compiler-2.7.16.tgz", + "integrity": "sha512-AYbUWAJHLGGQM7+cNTELw+KsOG9nl2CnSv467WobS5Cv9uk3wFcnr1Etsz2sEIHEZvw1U+o9mRlEO6QbZvUPGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/vue-tsc": { + "version": "1.8.27", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-1.8.27.tgz", + "integrity": "sha512-WesKCAZCRAbmmhuGl3+VrdWItEvfoFIPXOvUJkjULi+x+6G/Dy69yO3TBRJDr9eUlmsNAwVmxsNZxvHKzbkKdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "~1.11.1", + "@vue/language-core": "1.8.27", + "semver": "^7.5.4" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": "*" + } + }, + "node_modules/vue-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/vue-types/-/vue-types-3.0.2.tgz", + "integrity": "sha512-IwUC0Aq2zwaXqy74h4WCvFCUtoV0iSWr0snWnE9TnU18S66GAQyqQbRf2qfJtUuiFsBf6qp0MEwdonlwznlcrw==", + "license": "MIT", + "dependencies": { + "is-plain-object": "3.0.1" + }, + "engines": { + "node": ">=10.15.0" + }, + "peerDependencies": { + "vue": "^3.0.0" + } + }, + "node_modules/warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "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", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/xml-name-validator": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", + "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zrender": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz", + "integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..eddc04b --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,43 @@ +{ + "name": "designhub-frontend", + "version": "1.0.0", + "description": "设计师任务抢单管理系统 - 前端", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "build:check": "vue-tsc && vite build", + "preview": "vite preview", + "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore", + "format": "prettier --write src/" + }, + "dependencies": { + "@ant-design/icons-vue": "^7.0.1", + "@heroicons/vue": "^2.2.0", + "ant-design-vue": "^4.1.0", + "axios": "^1.6.2", + "dayjs": "^1.11.19", + "echarts": "^5.4.3", + "lodash-es": "^4.17.21", + "pinia": "^2.1.7", + "vue": "^3.4.0", + "vue-router": "^4.2.5" + }, + "devDependencies": { + "@types/lodash-es": "^4.17.12", + "@types/node": "^20.10.5", + "@typescript-eslint/eslint-plugin": "^6.15.0", + "@typescript-eslint/parser": "^6.15.0", + "@vitejs/plugin-vue": "^4.6.0", + "@vue/tsconfig": "^0.5.0", + "eslint": "^8.55.0", + "eslint-plugin-vue": "^9.19.2", + "prettier": "^3.1.1", + "sass": "^1.69.5", + "typescript": "~5.3.0", + "unplugin-auto-import": "^0.17.2", + "unplugin-vue-components": "^0.26.0", + "vite": "^4.5.3", + "vue-tsc": "^1.8.27" + } +} diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml new file mode 100644 index 0000000..0a6dd26 --- /dev/null +++ b/frontend/pnpm-lock.yaml @@ -0,0 +1,3030 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@ant-design/icons-vue': + specifier: ^7.0.1 + version: 7.0.1(vue@3.5.24(typescript@5.3.3)) + '@heroicons/vue': + specifier: ^2.2.0 + version: 2.2.0(vue@3.5.24(typescript@5.3.3)) + ant-design-vue: + specifier: ^4.1.0 + version: 4.2.6(vue@3.5.24(typescript@5.3.3)) + axios: + specifier: ^1.6.2 + version: 1.13.2 + dayjs: + specifier: ^1.11.19 + version: 1.11.19 + echarts: + specifier: ^5.4.3 + version: 5.6.0 + lodash-es: + specifier: ^4.17.21 + version: 4.17.21 + pinia: + specifier: ^2.1.7 + version: 2.3.1(typescript@5.3.3)(vue@3.5.24(typescript@5.3.3)) + vue: + specifier: ^3.4.0 + version: 3.5.24(typescript@5.3.3) + vue-router: + specifier: ^4.2.5 + version: 4.6.3(vue@3.5.24(typescript@5.3.3)) + devDependencies: + '@types/lodash-es': + specifier: ^4.17.12 + version: 4.17.12 + '@types/node': + specifier: ^20.10.5 + version: 20.19.24 + '@typescript-eslint/eslint-plugin': + specifier: ^6.15.0 + version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.3.3))(eslint@8.57.1)(typescript@5.3.3) + '@typescript-eslint/parser': + specifier: ^6.15.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.3.3) + '@vitejs/plugin-vue': + specifier: ^5.0.0 + version: 5.2.4(vite@5.4.21(@types/node@20.19.24)(sass@1.93.3))(vue@3.5.24(typescript@5.3.3)) + '@vue/tsconfig': + specifier: ^0.5.0 + version: 0.5.1 + eslint: + specifier: ^8.55.0 + version: 8.57.1 + eslint-plugin-vue: + specifier: ^9.19.2 + version: 9.33.0(eslint@8.57.1) + prettier: + specifier: ^3.1.1 + version: 3.6.2 + sass: + specifier: ^1.69.5 + version: 1.93.3 + typescript: + specifier: ~5.3.0 + version: 5.3.3 + unplugin-auto-import: + specifier: ^0.17.2 + version: 0.17.8(rollup@4.53.1) + unplugin-vue-components: + specifier: ^0.26.0 + version: 0.26.0(@babel/parser@7.28.5)(rollup@4.53.1)(vue@3.5.24(typescript@5.3.3)) + vite: + specifier: ^5.0.0 + version: 5.4.21(@types/node@20.19.24)(sass@1.93.3) + vue-tsc: + specifier: ^1.8.27 + version: 1.8.27(typescript@5.3.3) + +packages: + + '@ant-design/colors@6.0.0': + resolution: {integrity: sha512-qAZRvPzfdWHtfameEGP2Qvuf838NhergR35o+EuVyB5XvSA98xod5r4utvi4TJ3ywmevm290g9nsCG5MryrdWQ==} + + '@ant-design/icons-svg@4.4.2': + resolution: {integrity: sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA==} + + '@ant-design/icons-vue@7.0.1': + resolution: {integrity: sha512-eCqY2unfZK6Fe02AwFlDHLfoyEFreP6rBwAZMIJ1LugmfMiVgwWDYlp1YsRugaPtICYOabV1iWxXdP12u9U43Q==} + peerDependencies: + vue: '>=3.0.3' + + '@antfu/utils@0.7.10': + resolution: {integrity: sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.28.5': + resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/runtime@7.28.4': + resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.28.5': + resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} + engines: {node: '>=6.9.0'} + + '@ctrl/tinycolor@3.6.1': + resolution: {integrity: sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==} + engines: {node: '>=10'} + + '@emotion/hash@0.9.2': + resolution: {integrity: sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==} + + '@emotion/unitless@0.8.1': + resolution: {integrity: sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==} + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.9.0': + resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/eslintrc@2.1.4': + resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@eslint/js@8.57.1': + resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@heroicons/vue@2.2.0': + resolution: {integrity: sha512-G3dbSxoeEKqbi/DFalhRxJU4mTXJn7GwZ7ae8NuEQzd1bqdd0jAbdaBZlHPcvPD2xI1iGzNVB4k20Un2AguYPw==} + peerDependencies: + vue: '>= 3' + + '@humanwhocodes/config-array@0.13.0': + resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} + engines: {node: '>=10.10.0'} + deprecated: Use @eslint/config-array instead + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/object-schema@2.0.3': + resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} + deprecated: Use @eslint/object-schema instead + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@parcel/watcher-android-arm64@2.5.1': + resolution: {integrity: sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [android] + + '@parcel/watcher-darwin-arm64@2.5.1': + resolution: {integrity: sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [darwin] + + '@parcel/watcher-darwin-x64@2.5.1': + resolution: {integrity: sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [darwin] + + '@parcel/watcher-freebsd-x64@2.5.1': + resolution: {integrity: sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [freebsd] + + '@parcel/watcher-linux-arm-glibc@2.5.1': + resolution: {integrity: sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + + '@parcel/watcher-linux-arm-musl@2.5.1': + resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + + '@parcel/watcher-linux-arm64-glibc@2.5.1': + resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + + '@parcel/watcher-linux-arm64-musl@2.5.1': + resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + + '@parcel/watcher-linux-x64-glibc@2.5.1': + resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + + '@parcel/watcher-linux-x64-musl@2.5.1': + resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + + '@parcel/watcher-win32-arm64@2.5.1': + resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [win32] + + '@parcel/watcher-win32-ia32@2.5.1': + resolution: {integrity: sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==} + engines: {node: '>= 10.0.0'} + cpu: [ia32] + os: [win32] + + '@parcel/watcher-win32-x64@2.5.1': + resolution: {integrity: sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [win32] + + '@parcel/watcher@2.5.1': + resolution: {integrity: sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==} + engines: {node: '>= 10.0.0'} + + '@rollup/pluginutils@5.3.0': + resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/rollup-android-arm-eabi@4.53.1': + resolution: {integrity: sha512-bxZtughE4VNVJlL1RdoSE545kc4JxL7op57KKoi59/gwuU5rV6jLWFXXc8jwgFoT6vtj+ZjO+Z2C5nrY0Cl6wA==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.53.1': + resolution: {integrity: sha512-44a1hreb02cAAfAKmZfXVercPFaDjqXCK+iKeVOlJ9ltvnO6QqsBHgKVPTu+MJHSLLeMEUbeG2qiDYgbFPU48g==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.53.1': + resolution: {integrity: sha512-usmzIgD0rf1syoOZ2WZvy8YpXK5G1V3btm3QZddoGSa6mOgfXWkkv+642bfUUldomgrbiLQGrPryb7DXLovPWQ==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.53.1': + resolution: {integrity: sha512-is3r/k4vig2Gt8mKtTlzzyaSQ+hd87kDxiN3uDSDwggJLUV56Umli6OoL+/YZa/KvtdrdyNfMKHzL/P4siOOmg==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.53.1': + resolution: {integrity: sha512-QJ1ksgp/bDJkZB4daldVmHaEQkG4r8PUXitCOC2WRmRaSaHx5RwPoI3DHVfXKwDkB+Sk6auFI/+JHacTekPRSw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.53.1': + resolution: {integrity: sha512-J6ma5xgAzvqsnU6a0+jgGX/gvoGokqpkx6zY4cWizRrm0ffhHDpJKQgC8dtDb3+MqfZDIqs64REbfHDMzxLMqQ==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.53.1': + resolution: {integrity: sha512-JzWRR41o2U3/KMNKRuZNsDUAcAVUYhsPuMlx5RUldw0E4lvSIXFUwejtYz1HJXohUmqs/M6BBJAUBzKXZVddbg==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.53.1': + resolution: {integrity: sha512-L8kRIrnfMrEoHLHtHn+4uYA52fiLDEDyezgxZtGUTiII/yb04Krq+vk3P2Try+Vya9LeCE9ZHU8CXD6J9EhzHQ==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.53.1': + resolution: {integrity: sha512-ysAc0MFRV+WtQ8li8hi3EoFi7us6d1UzaS/+Dp7FYZfg3NdDljGMoVyiIp6Ucz7uhlYDBZ/zt6XI0YEZbUO11Q==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.53.1': + resolution: {integrity: sha512-UV6l9MJpDbDZZ/fJvqNcvO1PcivGEf1AvKuTcHoLjVZVFeAMygnamCTDikCVMRnA+qJe+B3pSbgX2+lBMqgBhA==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.53.1': + resolution: {integrity: sha512-UDUtelEprkA85g95Q+nj3Xf0M4hHa4DiJ+3P3h4BuGliY4NReYYqwlc0Y8ICLjN4+uIgCEvaygYlpf0hUj90Yg==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.53.1': + resolution: {integrity: sha512-vrRn+BYhEtNOte/zbc2wAUQReJXxEx2URfTol6OEfY2zFEUK92pkFBSXRylDM7aHi+YqEPJt9/ABYzmcrS4SgQ==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.53.1': + resolution: {integrity: sha512-gto/1CxHyi4A7YqZZNznQYrVlPSaodOBPKM+6xcDSCMVZN/Fzb4K+AIkNz/1yAYz9h3Ng+e2fY9H6bgawVq17w==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.53.1': + resolution: {integrity: sha512-KZ6Vx7jAw3aLNjFR8eYVcQVdFa/cvBzDNRFM3z7XhNNunWjA03eUrEwJYPk0G8V7Gs08IThFKcAPS4WY/ybIrQ==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.53.1': + resolution: {integrity: sha512-HvEixy2s/rWNgpwyKpXJcHmE7om1M89hxBTBi9Fs6zVuLU4gOrEMQNbNsN/tBVIMbLyysz/iwNiGtMOpLAOlvA==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.53.1': + resolution: {integrity: sha512-E/n8x2MSjAQgjj9IixO4UeEUeqXLtiA7pyoXCFYLuXpBA/t2hnbIdxHfA7kK9BFsYAoNU4st1rHYdldl8dTqGA==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.53.1': + resolution: {integrity: sha512-IhJ087PbLOQXCN6Ui/3FUkI9pWNZe/Z7rEIVOzMsOs1/HSAECCvSZ7PkIbkNqL/AZn6WbZvnoVZw/qwqYMo4/w==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openharmony-arm64@4.53.1': + resolution: {integrity: sha512-0++oPNgLJHBblreu0SFM7b3mAsBJBTY0Ksrmu9N6ZVrPiTkRgda52mWR7TKhHAsUb9noCjFvAw9l6ZO1yzaVbA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.53.1': + resolution: {integrity: sha512-VJXivz61c5uVdbmitLkDlbcTk9Or43YC2QVLRkqp86QoeFSqI81bNgjhttqhKNMKnQMWnecOCm7lZz4s+WLGpQ==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.53.1': + resolution: {integrity: sha512-NmZPVTUOitCXUH6erJDzTQ/jotYw4CnkMDjCYRxNHVD9bNyfrGoIse684F9okwzKCV4AIHRbUkeTBc9F2OOH5Q==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.53.1': + resolution: {integrity: sha512-2SNj7COIdAf6yliSpLdLG8BEsp5lgzRehgfkP0Av8zKfQFKku6JcvbobvHASPJu4f3BFxej5g+HuQPvqPhHvpQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.53.1': + resolution: {integrity: sha512-rLarc1Ofcs3DHtgSzFO31pZsCh8g05R2azN1q3fF+H423Co87My0R+tazOEvYVKXSLh8C4LerMK41/K7wlklcg==} + cpu: [x64] + os: [win32] + + '@simonwep/pickr@1.8.2': + resolution: {integrity: sha512-/l5w8BIkrpP6n1xsetx9MWPWlU6OblN5YgZZphxan0Tq4BByTCETL6lyIeY8lagalS2Nbt4F2W034KHLIiunKA==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/lodash-es@4.17.12': + resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==} + + '@types/lodash@4.17.20': + resolution: {integrity: sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==} + + '@types/node@20.19.24': + resolution: {integrity: sha512-FE5u0ezmi6y9OZEzlJfg37mqqf6ZDSF2V/NLjUyGrR9uTZ7Sb9F7bLNZ03S4XVUNRWGA7Ck4c1kK+YnuWjl+DA==} + + '@types/semver@7.7.1': + resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} + + '@typescript-eslint/eslint-plugin@6.21.0': + resolution: {integrity: sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + '@typescript-eslint/parser': ^6.0.0 || ^6.0.0-alpha + eslint: ^7.0.0 || ^8.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/parser@6.21.0': + resolution: {integrity: sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + eslint: ^7.0.0 || ^8.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/scope-manager@6.21.0': + resolution: {integrity: sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==} + engines: {node: ^16.0.0 || >=18.0.0} + + '@typescript-eslint/type-utils@6.21.0': + resolution: {integrity: sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + eslint: ^7.0.0 || ^8.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/types@6.21.0': + resolution: {integrity: sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==} + engines: {node: ^16.0.0 || >=18.0.0} + + '@typescript-eslint/typescript-estree@6.21.0': + resolution: {integrity: sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/utils@6.21.0': + resolution: {integrity: sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + eslint: ^7.0.0 || ^8.0.0 + + '@typescript-eslint/visitor-keys@6.21.0': + resolution: {integrity: sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==} + engines: {node: ^16.0.0 || >=18.0.0} + + '@ungap/structured-clone@1.3.0': + resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + + '@vitejs/plugin-vue@5.2.4': + resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==} + engines: {node: ^18.0.0 || >=20.0.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 + vue: ^3.2.25 + + '@volar/language-core@1.11.1': + resolution: {integrity: sha512-dOcNn3i9GgZAcJt43wuaEykSluAuOkQgzni1cuxLxTV0nJKanQztp7FxyswdRILaKH+P2XZMPRp2S4MV/pElCw==} + + '@volar/source-map@1.11.1': + resolution: {integrity: sha512-hJnOnwZ4+WT5iupLRnuzbULZ42L7BWWPMmruzwtLhJfpDVoZLjNBxHDi2sY2bgZXCKlpU5XcsMFoYrsQmPhfZg==} + + '@volar/typescript@1.11.1': + resolution: {integrity: sha512-iU+t2mas/4lYierSnoFOeRFQUhAEMgsFuQxoxvwn5EdQopw43j+J27a4lt9LMInx1gLJBC6qL14WYGlgymaSMQ==} + + '@vue/compiler-core@3.5.24': + resolution: {integrity: sha512-eDl5H57AOpNakGNAkFDH+y7kTqrQpJkZFXhWZQGyx/5Wh7B1uQYvcWkvZi11BDhscPgj8N7XV3oRwiPnx1Vrig==} + + '@vue/compiler-dom@3.5.24': + resolution: {integrity: sha512-1QHGAvs53gXkWdd3ZMGYuvQFXHW4ksKWPG8HP8/2BscrbZ0brw183q2oNWjMrSWImYLHxHrx1ItBQr50I/q2zw==} + + '@vue/compiler-sfc@3.5.24': + resolution: {integrity: sha512-8EG5YPRgmTB+YxYBM3VXy8zHD9SWHUJLIGPhDovo3Z8VOgvP+O7UP5vl0J4BBPWYD9vxtBabzW1EuEZ+Cqs14g==} + + '@vue/compiler-ssr@3.5.24': + resolution: {integrity: sha512-trOvMWNBMQ/odMRHW7Ae1CdfYx+7MuiQu62Jtu36gMLXcaoqKvAyh+P73sYG9ll+6jLB6QPovqoKGGZROzkFFg==} + + '@vue/devtools-api@6.6.4': + resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==} + + '@vue/language-core@1.8.27': + resolution: {integrity: sha512-L8Kc27VdQserNaCUNiSFdDl9LWT24ly8Hpwf1ECy3aFb9m6bDhBGQYOujDm21N7EW3moKIOKEanQwe1q5BK+mA==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@vue/reactivity@3.5.24': + resolution: {integrity: sha512-BM8kBhtlkkbnyl4q+HiF5R5BL0ycDPfihowulm02q3WYp2vxgPcJuZO866qa/0u3idbMntKEtVNuAUp5bw4teg==} + + '@vue/runtime-core@3.5.24': + resolution: {integrity: sha512-RYP/byyKDgNIqfX/gNb2PB55dJmM97jc9wyF3jK7QUInYKypK2exmZMNwnjueWwGceEkP6NChd3D2ZVEp9undQ==} + + '@vue/runtime-dom@3.5.24': + resolution: {integrity: sha512-Z8ANhr/i0XIluonHVjbUkjvn+CyrxbXRIxR7wn7+X7xlcb7dJsfITZbkVOeJZdP8VZwfrWRsWdShH6pngMxRjw==} + + '@vue/server-renderer@3.5.24': + resolution: {integrity: sha512-Yh2j2Y4G/0/4z/xJ1Bad4mxaAk++C2v4kaa8oSYTMJBJ00/ndPuxCnWeot0/7/qafQFLh5pr6xeV6SdMcE/G1w==} + peerDependencies: + vue: 3.5.24 + + '@vue/shared@3.5.24': + resolution: {integrity: sha512-9cwHL2EsJBdi8NY22pngYYWzkTDhld6fAD6jlaeloNGciNSJL6bLpbxVgXl96X00Jtc6YWQv96YA/0sxex/k1A==} + + '@vue/tsconfig@0.5.1': + resolution: {integrity: sha512-VcZK7MvpjuTPx2w6blwnwZAu5/LgBUtejFOi3pPGQFXQN5Ela03FUtd2Qtg4yWGGissVL0dr6Ro1LfOFh+PCuQ==} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.15.0: + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.12.6: + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ant-design-vue@4.2.6: + resolution: {integrity: sha512-t7eX13Yj3i9+i5g9lqFyYneoIb3OzTvQjq9Tts1i+eiOd3Eva/6GagxBSXM1fOCjqemIu0FYVE1ByZ/38epR3Q==} + engines: {node: '>=12.22.0'} + peerDependencies: + vue: '>=3.2.0' + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + array-tree-filter@2.1.0: + resolution: {integrity: sha512-4ROwICNlNw/Hqa9v+rk5h22KjmzB1JGTMVKP2AKJBOCgb0yL0ASf0+YvCcLNNwquOHNX48jkeZIJ3a+oOQqKcw==} + + array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + + async-validator@4.2.5: + resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + axios@1.13.2: + resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + compute-scroll-into-view@1.0.20: + resolution: {integrity: sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==} + + computeds@0.0.1: + resolution: {integrity: sha512-7CEBgcMjVmitjYo5q8JTJVra6X5mQ20uTThdK+0kR7UEaDrAWEQcRiBtWJzga4eRpP6afNwwLsX2SET2JhVB1Q==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + confbox@0.2.2: + resolution: {integrity: sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==} + + core-js@3.46.0: + resolution: {integrity: sha512-vDMm9B0xnqqZ8uSBpZ8sNtRtOdmfShrvT6h2TuQGLs0Is+cR0DYbj/KWP6ALVNbWPpqA/qPLoOuppJN07humpA==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csstype@3.1.3: + resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + + dayjs@1.11.19: + resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==} + + de-indent@1.0.2: + resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + detect-libc@1.0.3: + resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==} + engines: {node: '>=0.10'} + hasBin: true + + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + + doctrine@3.0.0: + resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} + engines: {node: '>=6.0.0'} + + dom-align@1.12.4: + resolution: {integrity: sha512-R8LUSEay/68zE5c8/3BDxiTEvgb4xZTF0RKmAHfiEVN3klfIpXfi2/QCoiWPccVQ0J/ZGdz9OjzL4uJEP/MRAw==} + + dom-scroll-into-view@2.0.1: + resolution: {integrity: sha512-bvVTQe1lfaUr1oFzZX80ce9KLDlZ3iU+XGNE/bz9HnGdklTieqsbmsLHe+rT2XWqopvL0PckkYqN7ksmm5pe3w==} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + echarts@5.6.0: + resolution: {integrity: sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + + eslint-plugin-vue@9.33.0: + resolution: {integrity: sha512-174lJKuNsuDIlLpjeXc5E2Tss8P44uIimAfGD0b90k0NoirJqpG7stLuU9Vp/9ioTOrQdWVREc4mRd1BD+CvGw==} + engines: {node: ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.2.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 + + eslint-scope@7.2.2: + resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint@8.57.1: + resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. + hasBin: true + + espree@9.6.1: + resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + esquery@1.6.0: + resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + exsolve@1.0.7: + resolution: {integrity: sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fastq@1.19.1: + resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} + + file-entry-cache@6.0.1: + resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} + engines: {node: ^10.12.0 || >=12.0.0} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@3.2.0: + resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} + engines: {node: ^10.12.0 || >=12.0.0} + + flatted@3.3.3: + resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + + follow-redirects@1.15.11: + resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + form-data@4.0.4: + resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} + engines: {node: '>= 6'} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Glob versions prior to v9 are no longer supported + + globals@13.24.0: + resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} + engines: {node: '>=8'} + + globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graphemer@1.4.0: + resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + he@1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} + hasBin: true + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + immutable@5.1.4: + resolution: {integrity: sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-path-inside@3.0.3: + resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} + engines: {node: '>=8'} + + is-plain-object@3.0.1: + resolution: {integrity: sha512-Xnpx182SBMrr/aBik8y+GuR4U1L9FqMSojwDQwPMmxyC6bvEqly9UBCxhauBF5vNh2gwWJNX6oDV7O+OM4z34g==} + engines: {node: '>=0.10.0'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + js-yaml@4.1.0: + resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + local-pkg@0.4.3: + resolution: {integrity: sha512-SFppqq5p42fe2qcZQqqEOiVRXl+WCP1MdT6k7BDEW1j++sp5fIY+/fdRQitvKgB5BrBcmrs5m/L0v2FrU5MY1g==} + engines: {node: '>=14'} + + local-pkg@0.5.1: + resolution: {integrity: sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==} + engines: {node: '>=14'} + + local-pkg@1.1.2: + resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==} + engines: {node: '>=14'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash-es@4.17.21: + resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + + minimatch@9.0.3: + resolution: {integrity: sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==} + engines: {node: '>=16 || 14 >=14.17'} + + minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} + + mlly@1.8.0: + resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + muggle-string@0.3.1: + resolution: {integrity: sha512-ckmWDJjphvd/FvZawgygcUeQCxzvohjFO5RxTjj4eq8kw359gFF3E1brjfI+viLMxss5JrHTDRHZvu2/tuy0Qg==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + nanopop@2.4.2: + resolution: {integrity: sha512-NzOgmMQ+elxxHeIha+OG/Pv3Oc3p4RU2aBhwWwAqDpXrdTbtRylbRLQztLy8dMMwfl6pclznBdfUhccEn9ZIzw==} + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + pinia@2.3.1: + resolution: {integrity: sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==} + peerDependencies: + typescript: '>=4.4.4' + vue: ^2.7.0 || ^3.5.11 + peerDependenciesMeta: + typescript: + optional: true + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + pkg-types@2.3.0: + resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + + postcss-selector-parser@6.1.2: + resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} + engines: {node: '>=4'} + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier@3.6.2: + resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==} + engines: {node: '>=14'} + hasBin: true + + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + quansync@0.2.11: + resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + resize-observer-polyfill@1.5.1: + resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve@1.22.11: + resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} + engines: {node: '>= 0.4'} + hasBin: true + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rimraf@3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + rollup@4.53.1: + resolution: {integrity: sha512-n2I0V0lN3E9cxxMqBCT3opWOiQBzRN7UG60z/WDKqdX2zHUS/39lezBcsckZFsV6fUTSnfqI7kHf60jDAPGKug==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + sass@1.93.3: + resolution: {integrity: sha512-elOcIZRTM76dvxNAjqYrucTSI0teAF/L2Lv0s6f6b7FOwcwIuA357bIE871580AjHJuSvLIRUosgV+lIWx6Rgg==} + engines: {node: '>=14.0.0'} + hasBin: true + + scroll-into-view-if-needed@2.2.31: + resolution: {integrity: sha512-dGCXy99wZQivjmjIqihaBQNjryrz5rueJY7eHfTdyWEiR4ttYpsajb14rn9s5d4DY4EcY6+4+U/maARBXJedkA==} + + scule@1.3.0: + resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==} + + semver@7.7.3: + resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + engines: {node: '>=10'} + hasBin: true + + shallow-equal@1.2.1: + resolution: {integrity: sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + strip-literal@2.1.1: + resolution: {integrity: sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==} + + stylis@4.3.6: + resolution: {integrity: sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + text-table@0.2.0: + resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} + + throttle-debounce@5.0.2: + resolution: {integrity: sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==} + engines: {node: '>=12.22'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + ts-api-utils@1.4.3: + resolution: {integrity: sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==} + engines: {node: '>=16'} + peerDependencies: + typescript: '>=4.2.0' + + tslib@2.3.0: + resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-fest@0.20.2: + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} + engines: {node: '>=10'} + + typescript@5.3.3: + resolution: {integrity: sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.1: + resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + unimport@3.14.6: + resolution: {integrity: sha512-CYvbDaTT04Rh8bmD8jz3WPmHYZRG/NnvYVzwD6V1YAlvvKROlAeNDUBhkBGzNav2RKaeuXvlWYaa1V4Lfi/O0g==} + + unplugin-auto-import@0.17.8: + resolution: {integrity: sha512-CHryj6HzJ+n4ASjzwHruD8arhbdl+UXvhuAIlHDs15Y/IMecG3wrf7FVg4pVH/DIysbq/n0phIjNHAjl7TG7Iw==} + engines: {node: '>=14'} + peerDependencies: + '@nuxt/kit': ^3.2.2 + '@vueuse/core': '*' + peerDependenciesMeta: + '@nuxt/kit': + optional: true + '@vueuse/core': + optional: true + + unplugin-vue-components@0.26.0: + resolution: {integrity: sha512-s7IdPDlnOvPamjunVxw8kNgKNK8A5KM1YpK5j/p97jEKTjlPNrA0nZBiSfAKKlK1gWZuyWXlKL5dk3EDw874LQ==} + engines: {node: '>=14'} + peerDependencies: + '@babel/parser': ^7.15.8 + '@nuxt/kit': ^3.2.2 + vue: 2 || 3 + peerDependenciesMeta: + '@babel/parser': + optional: true + '@nuxt/kit': + optional: true + + unplugin@1.16.1: + resolution: {integrity: sha512-4/u/j4FrCKdi17jaxuJA0jClGxB1AvU2hw/IuayPc4ay1XGaJs/rbb4v5WKwAjNifjmXK9PIFyuPiaK8azyR9w==} + engines: {node: '>=14.0.0'} + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + 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 + + vue-demi@0.14.10: + resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} + engines: {node: '>=12'} + hasBin: true + peerDependencies: + '@vue/composition-api': ^1.0.0-rc.1 + vue: ^3.0.0-0 || ^2.6.0 + peerDependenciesMeta: + '@vue/composition-api': + optional: true + + vue-eslint-parser@9.4.3: + resolution: {integrity: sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==} + engines: {node: ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: '>=6.0.0' + + vue-router@4.6.3: + resolution: {integrity: sha512-ARBedLm9YlbvQomnmq91Os7ck6efydTSpRP3nuOKCvgJOHNrhRoJDSKtee8kcL1Vf7nz6U+PMBL+hTvR3bTVQg==} + peerDependencies: + vue: ^3.5.0 + + vue-template-compiler@2.7.16: + resolution: {integrity: sha512-AYbUWAJHLGGQM7+cNTELw+KsOG9nl2CnSv467WobS5Cv9uk3wFcnr1Etsz2sEIHEZvw1U+o9mRlEO6QbZvUPGQ==} + + vue-tsc@1.8.27: + resolution: {integrity: sha512-WesKCAZCRAbmmhuGl3+VrdWItEvfoFIPXOvUJkjULi+x+6G/Dy69yO3TBRJDr9eUlmsNAwVmxsNZxvHKzbkKdg==} + hasBin: true + peerDependencies: + typescript: '*' + + vue-types@3.0.2: + resolution: {integrity: sha512-IwUC0Aq2zwaXqy74h4WCvFCUtoV0iSWr0snWnE9TnU18S66GAQyqQbRf2qfJtUuiFsBf6qp0MEwdonlwznlcrw==} + engines: {node: '>=10.15.0'} + peerDependencies: + vue: ^3.0.0 + + vue@3.5.24: + resolution: {integrity: sha512-uTHDOpVQTMjcGgrqFPSb8iO2m1DUvo+WbGqoXQz8Y1CeBYQ0FXf2z1gLRaBtHjlRz7zZUBHxjVB5VTLzYkvftg==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + warning@4.0.3: + resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==} + + webpack-virtual-modules@0.6.2: + resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + xml-name-validator@4.0.0: + resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} + engines: {node: '>=12'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zrender@5.6.1: + resolution: {integrity: sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==} + +snapshots: + + '@ant-design/colors@6.0.0': + dependencies: + '@ctrl/tinycolor': 3.6.1 + + '@ant-design/icons-svg@4.4.2': {} + + '@ant-design/icons-vue@7.0.1(vue@3.5.24(typescript@5.3.3))': + dependencies: + '@ant-design/colors': 6.0.0 + '@ant-design/icons-svg': 4.4.2 + vue: 3.5.24(typescript@5.3.3) + + '@antfu/utils@0.7.10': {} + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/parser@7.28.5': + dependencies: + '@babel/types': 7.28.5 + + '@babel/runtime@7.28.4': {} + + '@babel/types@7.28.5': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@ctrl/tinycolor@3.6.1': {} + + '@emotion/hash@0.9.2': {} + + '@emotion/unitless@0.8.1': {} + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@eslint-community/eslint-utils@4.9.0(eslint@8.57.1)': + dependencies: + eslint: 8.57.1 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/eslintrc@2.1.4': + dependencies: + ajv: 6.12.6 + debug: 4.4.3 + espree: 9.6.1 + globals: 13.24.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.0 + minimatch: 3.1.2 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@8.57.1': {} + + '@heroicons/vue@2.2.0(vue@3.5.24(typescript@5.3.3))': + dependencies: + vue: 3.5.24(typescript@5.3.3) + + '@humanwhocodes/config-array@0.13.0': + dependencies: + '@humanwhocodes/object-schema': 2.0.3 + debug: 4.4.3 + minimatch: 3.1.2 + transitivePeerDependencies: + - supports-color + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/object-schema@2.0.3': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.19.1 + + '@parcel/watcher-android-arm64@2.5.1': + optional: true + + '@parcel/watcher-darwin-arm64@2.5.1': + optional: true + + '@parcel/watcher-darwin-x64@2.5.1': + optional: true + + '@parcel/watcher-freebsd-x64@2.5.1': + optional: true + + '@parcel/watcher-linux-arm-glibc@2.5.1': + optional: true + + '@parcel/watcher-linux-arm-musl@2.5.1': + optional: true + + '@parcel/watcher-linux-arm64-glibc@2.5.1': + optional: true + + '@parcel/watcher-linux-arm64-musl@2.5.1': + optional: true + + '@parcel/watcher-linux-x64-glibc@2.5.1': + optional: true + + '@parcel/watcher-linux-x64-musl@2.5.1': + optional: true + + '@parcel/watcher-win32-arm64@2.5.1': + optional: true + + '@parcel/watcher-win32-ia32@2.5.1': + optional: true + + '@parcel/watcher-win32-x64@2.5.1': + optional: true + + '@parcel/watcher@2.5.1': + dependencies: + detect-libc: 1.0.3 + is-glob: 4.0.3 + micromatch: 4.0.8 + node-addon-api: 7.1.1 + optionalDependencies: + '@parcel/watcher-android-arm64': 2.5.1 + '@parcel/watcher-darwin-arm64': 2.5.1 + '@parcel/watcher-darwin-x64': 2.5.1 + '@parcel/watcher-freebsd-x64': 2.5.1 + '@parcel/watcher-linux-arm-glibc': 2.5.1 + '@parcel/watcher-linux-arm-musl': 2.5.1 + '@parcel/watcher-linux-arm64-glibc': 2.5.1 + '@parcel/watcher-linux-arm64-musl': 2.5.1 + '@parcel/watcher-linux-x64-glibc': 2.5.1 + '@parcel/watcher-linux-x64-musl': 2.5.1 + '@parcel/watcher-win32-arm64': 2.5.1 + '@parcel/watcher-win32-ia32': 2.5.1 + '@parcel/watcher-win32-x64': 2.5.1 + optional: true + + '@rollup/pluginutils@5.3.0(rollup@4.53.1)': + dependencies: + '@types/estree': 1.0.8 + estree-walker: 2.0.2 + picomatch: 4.0.3 + optionalDependencies: + rollup: 4.53.1 + + '@rollup/rollup-android-arm-eabi@4.53.1': + optional: true + + '@rollup/rollup-android-arm64@4.53.1': + optional: true + + '@rollup/rollup-darwin-arm64@4.53.1': + optional: true + + '@rollup/rollup-darwin-x64@4.53.1': + optional: true + + '@rollup/rollup-freebsd-arm64@4.53.1': + optional: true + + '@rollup/rollup-freebsd-x64@4.53.1': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.53.1': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.53.1': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.53.1': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.53.1': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.53.1': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.53.1': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.53.1': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.53.1': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.53.1': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.53.1': + optional: true + + '@rollup/rollup-linux-x64-musl@4.53.1': + optional: true + + '@rollup/rollup-openharmony-arm64@4.53.1': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.53.1': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.53.1': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.53.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.53.1': + optional: true + + '@simonwep/pickr@1.8.2': + dependencies: + core-js: 3.46.0 + nanopop: 2.4.2 + + '@types/estree@1.0.8': {} + + '@types/json-schema@7.0.15': {} + + '@types/lodash-es@4.17.12': + dependencies: + '@types/lodash': 4.17.20 + + '@types/lodash@4.17.20': {} + + '@types/node@20.19.24': + dependencies: + undici-types: 6.21.0 + + '@types/semver@7.7.1': {} + + '@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.3.3))(eslint@8.57.1)(typescript@5.3.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 6.21.0(eslint@8.57.1)(typescript@5.3.3) + '@typescript-eslint/scope-manager': 6.21.0 + '@typescript-eslint/type-utils': 6.21.0(eslint@8.57.1)(typescript@5.3.3) + '@typescript-eslint/utils': 6.21.0(eslint@8.57.1)(typescript@5.3.3) + '@typescript-eslint/visitor-keys': 6.21.0 + debug: 4.4.3 + eslint: 8.57.1 + graphemer: 1.4.0 + ignore: 5.3.2 + natural-compare: 1.4.0 + semver: 7.7.3 + ts-api-utils: 1.4.3(typescript@5.3.3) + optionalDependencies: + typescript: 5.3.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.3.3)': + dependencies: + '@typescript-eslint/scope-manager': 6.21.0 + '@typescript-eslint/types': 6.21.0 + '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.3.3) + '@typescript-eslint/visitor-keys': 6.21.0 + debug: 4.4.3 + eslint: 8.57.1 + optionalDependencies: + typescript: 5.3.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@6.21.0': + dependencies: + '@typescript-eslint/types': 6.21.0 + '@typescript-eslint/visitor-keys': 6.21.0 + + '@typescript-eslint/type-utils@6.21.0(eslint@8.57.1)(typescript@5.3.3)': + dependencies: + '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.3.3) + '@typescript-eslint/utils': 6.21.0(eslint@8.57.1)(typescript@5.3.3) + debug: 4.4.3 + eslint: 8.57.1 + ts-api-utils: 1.4.3(typescript@5.3.3) + optionalDependencies: + typescript: 5.3.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@6.21.0': {} + + '@typescript-eslint/typescript-estree@6.21.0(typescript@5.3.3)': + dependencies: + '@typescript-eslint/types': 6.21.0 + '@typescript-eslint/visitor-keys': 6.21.0 + debug: 4.4.3 + globby: 11.1.0 + is-glob: 4.0.3 + minimatch: 9.0.3 + semver: 7.7.3 + ts-api-utils: 1.4.3(typescript@5.3.3) + optionalDependencies: + typescript: 5.3.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@6.21.0(eslint@8.57.1)(typescript@5.3.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.0(eslint@8.57.1) + '@types/json-schema': 7.0.15 + '@types/semver': 7.7.1 + '@typescript-eslint/scope-manager': 6.21.0 + '@typescript-eslint/types': 6.21.0 + '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.3.3) + eslint: 8.57.1 + semver: 7.7.3 + transitivePeerDependencies: + - supports-color + - typescript + + '@typescript-eslint/visitor-keys@6.21.0': + dependencies: + '@typescript-eslint/types': 6.21.0 + eslint-visitor-keys: 3.4.3 + + '@ungap/structured-clone@1.3.0': {} + + '@vitejs/plugin-vue@5.2.4(vite@5.4.21(@types/node@20.19.24)(sass@1.93.3))(vue@3.5.24(typescript@5.3.3))': + dependencies: + vite: 5.4.21(@types/node@20.19.24)(sass@1.93.3) + vue: 3.5.24(typescript@5.3.3) + + '@volar/language-core@1.11.1': + dependencies: + '@volar/source-map': 1.11.1 + + '@volar/source-map@1.11.1': + dependencies: + muggle-string: 0.3.1 + + '@volar/typescript@1.11.1': + dependencies: + '@volar/language-core': 1.11.1 + path-browserify: 1.0.1 + + '@vue/compiler-core@3.5.24': + dependencies: + '@babel/parser': 7.28.5 + '@vue/shared': 3.5.24 + entities: 4.5.0 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.24': + dependencies: + '@vue/compiler-core': 3.5.24 + '@vue/shared': 3.5.24 + + '@vue/compiler-sfc@3.5.24': + dependencies: + '@babel/parser': 7.28.5 + '@vue/compiler-core': 3.5.24 + '@vue/compiler-dom': 3.5.24 + '@vue/compiler-ssr': 3.5.24 + '@vue/shared': 3.5.24 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.6 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.24': + dependencies: + '@vue/compiler-dom': 3.5.24 + '@vue/shared': 3.5.24 + + '@vue/devtools-api@6.6.4': {} + + '@vue/language-core@1.8.27(typescript@5.3.3)': + dependencies: + '@volar/language-core': 1.11.1 + '@volar/source-map': 1.11.1 + '@vue/compiler-dom': 3.5.24 + '@vue/shared': 3.5.24 + computeds: 0.0.1 + minimatch: 9.0.5 + muggle-string: 0.3.1 + path-browserify: 1.0.1 + vue-template-compiler: 2.7.16 + optionalDependencies: + typescript: 5.3.3 + + '@vue/reactivity@3.5.24': + dependencies: + '@vue/shared': 3.5.24 + + '@vue/runtime-core@3.5.24': + dependencies: + '@vue/reactivity': 3.5.24 + '@vue/shared': 3.5.24 + + '@vue/runtime-dom@3.5.24': + dependencies: + '@vue/reactivity': 3.5.24 + '@vue/runtime-core': 3.5.24 + '@vue/shared': 3.5.24 + csstype: 3.1.3 + + '@vue/server-renderer@3.5.24(vue@3.5.24(typescript@5.3.3))': + dependencies: + '@vue/compiler-ssr': 3.5.24 + '@vue/shared': 3.5.24 + vue: 3.5.24(typescript@5.3.3) + + '@vue/shared@3.5.24': {} + + '@vue/tsconfig@0.5.1': {} + + acorn-jsx@5.3.2(acorn@8.15.0): + dependencies: + acorn: 8.15.0 + + acorn@8.15.0: {} + + ajv@6.12.6: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ant-design-vue@4.2.6(vue@3.5.24(typescript@5.3.3)): + dependencies: + '@ant-design/colors': 6.0.0 + '@ant-design/icons-vue': 7.0.1(vue@3.5.24(typescript@5.3.3)) + '@babel/runtime': 7.28.4 + '@ctrl/tinycolor': 3.6.1 + '@emotion/hash': 0.9.2 + '@emotion/unitless': 0.8.1 + '@simonwep/pickr': 1.8.2 + array-tree-filter: 2.1.0 + async-validator: 4.2.5 + csstype: 3.1.3 + dayjs: 1.11.19 + dom-align: 1.12.4 + dom-scroll-into-view: 2.0.1 + lodash: 4.17.21 + lodash-es: 4.17.21 + resize-observer-polyfill: 1.5.1 + scroll-into-view-if-needed: 2.2.31 + shallow-equal: 1.2.1 + stylis: 4.3.6 + throttle-debounce: 5.0.2 + vue: 3.5.24(typescript@5.3.3) + vue-types: 3.0.2(vue@3.5.24(typescript@5.3.3)) + warning: 4.0.3 + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + + argparse@2.0.1: {} + + array-tree-filter@2.1.0: {} + + array-union@2.1.0: {} + + async-validator@4.2.5: {} + + asynckit@0.4.0: {} + + axios@1.13.2: + dependencies: + follow-redirects: 1.15.11 + form-data: 4.0.4 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + + balanced-match@1.0.2: {} + + binary-extensions@2.3.0: {} + + boolbase@1.0.0: {} + + brace-expansion@1.1.12: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.0.2: + dependencies: + balanced-match: 1.0.2 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + callsites@3.1.0: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + compute-scroll-into-view@1.0.20: {} + + computeds@0.0.1: {} + + concat-map@0.0.1: {} + + confbox@0.1.8: {} + + confbox@0.2.2: {} + + core-js@3.46.0: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + cssesc@3.0.0: {} + + csstype@3.1.3: {} + + dayjs@1.11.19: {} + + de-indent@1.0.2: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-is@0.1.4: {} + + delayed-stream@1.0.0: {} + + detect-libc@1.0.3: + optional: true + + dir-glob@3.0.1: + dependencies: + path-type: 4.0.0 + + doctrine@3.0.0: + dependencies: + esutils: 2.0.3 + + dom-align@1.12.4: {} + + dom-scroll-into-view@2.0.1: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + echarts@5.6.0: + dependencies: + tslib: 2.3.0 + zrender: 5.6.1 + + entities@4.5.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + esbuild@0.21.5: + 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 + + escape-string-regexp@4.0.0: {} + + escape-string-regexp@5.0.0: {} + + eslint-plugin-vue@9.33.0(eslint@8.57.1): + dependencies: + '@eslint-community/eslint-utils': 4.9.0(eslint@8.57.1) + eslint: 8.57.1 + globals: 13.24.0 + natural-compare: 1.4.0 + nth-check: 2.1.1 + postcss-selector-parser: 6.1.2 + semver: 7.7.3 + vue-eslint-parser: 9.4.3(eslint@8.57.1) + xml-name-validator: 4.0.0 + transitivePeerDependencies: + - supports-color + + eslint-scope@7.2.2: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint@8.57.1: + dependencies: + '@eslint-community/eslint-utils': 4.9.0(eslint@8.57.1) + '@eslint-community/regexpp': 4.12.2 + '@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.3.0 + ajv: 6.12.6 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + 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.6.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 6.0.1 + find-up: 5.0.0 + glob-parent: 6.0.2 + globals: 13.24.0 + graphemer: 1.4.0 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + 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.4 + strip-ansi: 6.0.1 + text-table: 0.2.0 + transitivePeerDependencies: + - supports-color + + espree@9.6.1: + dependencies: + acorn: 8.15.0 + acorn-jsx: 5.3.2(acorn@8.15.0) + eslint-visitor-keys: 3.4.3 + + esquery@1.6.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@2.0.2: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + esutils@2.0.3: {} + + exsolve@1.0.7: {} + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fastq@1.19.1: + dependencies: + reusify: 1.1.0 + + file-entry-cache@6.0.1: + dependencies: + flat-cache: 3.2.0 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@3.2.0: + dependencies: + flatted: 3.3.3 + keyv: 4.5.4 + rimraf: 3.0.2 + + flatted@3.3.3: {} + + follow-redirects@1.15.11: {} + + form-data@4.0.4: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + + fs.realpath@1.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + 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 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + + globals@13.24.0: + dependencies: + type-fest: 0.20.2 + + globby@11.1.0: + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.3 + ignore: 5.3.2 + merge2: 1.4.1 + slash: 3.0.0 + + gopd@1.2.0: {} + + graphemer@1.4.0: {} + + has-flag@4.0.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + he@1.2.0: {} + + ignore@5.3.2: {} + + immutable@5.1.4: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-core-module@2.16.1: + dependencies: + hasown: 2.0.2 + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + + is-path-inside@3.0.3: {} + + is-plain-object@3.0.1: {} + + isexe@2.0.0: {} + + js-tokens@4.0.0: {} + + js-tokens@9.0.1: {} + + js-yaml@4.1.0: + dependencies: + argparse: 2.0.1 + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + local-pkg@0.4.3: {} + + local-pkg@0.5.1: + dependencies: + mlly: 1.8.0 + pkg-types: 1.3.1 + + local-pkg@1.1.2: + dependencies: + mlly: 1.8.0 + pkg-types: 2.3.0 + quansync: 0.2.11 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash-es@4.17.21: {} + + lodash.merge@4.6.2: {} + + lodash@4.17.21: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + math-intrinsics@1.1.0: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + minimatch@3.1.2: + dependencies: + brace-expansion: 1.1.12 + + minimatch@9.0.3: + dependencies: + brace-expansion: 2.0.2 + + minimatch@9.0.5: + dependencies: + brace-expansion: 2.0.2 + + mlly@1.8.0: + dependencies: + acorn: 8.15.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.1 + + ms@2.1.3: {} + + muggle-string@0.3.1: {} + + nanoid@3.3.11: {} + + nanopop@2.4.2: {} + + natural-compare@1.4.0: {} + + node-addon-api@7.1.1: + optional: true + + normalize-path@3.0.0: {} + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + path-browserify@1.0.1: {} + + path-exists@4.0.0: {} + + path-is-absolute@1.0.1: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + path-type@4.0.0: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@2.3.1: {} + + picomatch@4.0.3: {} + + pinia@2.3.1(typescript@5.3.3)(vue@3.5.24(typescript@5.3.3)): + dependencies: + '@vue/devtools-api': 6.6.4 + vue: 3.5.24(typescript@5.3.3) + vue-demi: 0.14.10(vue@3.5.24(typescript@5.3.3)) + optionalDependencies: + typescript: 5.3.3 + transitivePeerDependencies: + - '@vue/composition-api' + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.0 + pathe: 2.0.3 + + pkg-types@2.3.0: + dependencies: + confbox: 0.2.2 + exsolve: 1.0.7 + pathe: 2.0.3 + + postcss-selector-parser@6.1.2: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + prettier@3.6.2: {} + + proxy-from-env@1.1.0: {} + + punycode@2.3.1: {} + + quansync@0.2.11: {} + + queue-microtask@1.2.3: {} + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.1 + + readdirp@4.1.2: {} + + resize-observer-polyfill@1.5.1: {} + + resolve-from@4.0.0: {} + + resolve@1.22.11: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + reusify@1.1.0: {} + + rimraf@3.0.2: + dependencies: + glob: 7.2.3 + + rollup@4.53.1: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.53.1 + '@rollup/rollup-android-arm64': 4.53.1 + '@rollup/rollup-darwin-arm64': 4.53.1 + '@rollup/rollup-darwin-x64': 4.53.1 + '@rollup/rollup-freebsd-arm64': 4.53.1 + '@rollup/rollup-freebsd-x64': 4.53.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.53.1 + '@rollup/rollup-linux-arm-musleabihf': 4.53.1 + '@rollup/rollup-linux-arm64-gnu': 4.53.1 + '@rollup/rollup-linux-arm64-musl': 4.53.1 + '@rollup/rollup-linux-loong64-gnu': 4.53.1 + '@rollup/rollup-linux-ppc64-gnu': 4.53.1 + '@rollup/rollup-linux-riscv64-gnu': 4.53.1 + '@rollup/rollup-linux-riscv64-musl': 4.53.1 + '@rollup/rollup-linux-s390x-gnu': 4.53.1 + '@rollup/rollup-linux-x64-gnu': 4.53.1 + '@rollup/rollup-linux-x64-musl': 4.53.1 + '@rollup/rollup-openharmony-arm64': 4.53.1 + '@rollup/rollup-win32-arm64-msvc': 4.53.1 + '@rollup/rollup-win32-ia32-msvc': 4.53.1 + '@rollup/rollup-win32-x64-gnu': 4.53.1 + '@rollup/rollup-win32-x64-msvc': 4.53.1 + fsevents: 2.3.3 + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + sass@1.93.3: + dependencies: + chokidar: 4.0.3 + immutable: 5.1.4 + source-map-js: 1.2.1 + optionalDependencies: + '@parcel/watcher': 2.5.1 + + scroll-into-view-if-needed@2.2.31: + dependencies: + compute-scroll-into-view: 1.0.20 + + scule@1.3.0: {} + + semver@7.7.3: {} + + shallow-equal@1.2.1: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + slash@3.0.0: {} + + source-map-js@1.2.1: {} + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-json-comments@3.1.1: {} + + strip-literal@2.1.1: + dependencies: + js-tokens: 9.0.1 + + stylis@4.3.6: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + text-table@0.2.0: {} + + throttle-debounce@5.0.2: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + ts-api-utils@1.4.3(typescript@5.3.3): + dependencies: + typescript: 5.3.3 + + tslib@2.3.0: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-fest@0.20.2: {} + + typescript@5.3.3: {} + + ufo@1.6.1: {} + + undici-types@6.21.0: {} + + unimport@3.14.6(rollup@4.53.1): + dependencies: + '@rollup/pluginutils': 5.3.0(rollup@4.53.1) + acorn: 8.15.0 + escape-string-regexp: 5.0.0 + estree-walker: 3.0.3 + fast-glob: 3.3.3 + local-pkg: 1.1.2 + magic-string: 0.30.21 + mlly: 1.8.0 + pathe: 2.0.3 + picomatch: 4.0.3 + pkg-types: 1.3.1 + scule: 1.3.0 + strip-literal: 2.1.1 + unplugin: 1.16.1 + transitivePeerDependencies: + - rollup + + unplugin-auto-import@0.17.8(rollup@4.53.1): + dependencies: + '@antfu/utils': 0.7.10 + '@rollup/pluginutils': 5.3.0(rollup@4.53.1) + fast-glob: 3.3.3 + local-pkg: 0.5.1 + magic-string: 0.30.21 + minimatch: 9.0.5 + unimport: 3.14.6(rollup@4.53.1) + unplugin: 1.16.1 + transitivePeerDependencies: + - rollup + + unplugin-vue-components@0.26.0(@babel/parser@7.28.5)(rollup@4.53.1)(vue@3.5.24(typescript@5.3.3)): + dependencies: + '@antfu/utils': 0.7.10 + '@rollup/pluginutils': 5.3.0(rollup@4.53.1) + chokidar: 3.6.0 + debug: 4.4.3 + fast-glob: 3.3.3 + local-pkg: 0.4.3 + magic-string: 0.30.21 + minimatch: 9.0.5 + resolve: 1.22.11 + unplugin: 1.16.1 + vue: 3.5.24(typescript@5.3.3) + optionalDependencies: + '@babel/parser': 7.28.5 + transitivePeerDependencies: + - rollup + - supports-color + + unplugin@1.16.1: + dependencies: + acorn: 8.15.0 + webpack-virtual-modules: 0.6.2 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + util-deprecate@1.0.2: {} + + vite@5.4.21(@types/node@20.19.24)(sass@1.93.3): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.6 + rollup: 4.53.1 + optionalDependencies: + '@types/node': 20.19.24 + fsevents: 2.3.3 + sass: 1.93.3 + + vue-demi@0.14.10(vue@3.5.24(typescript@5.3.3)): + dependencies: + vue: 3.5.24(typescript@5.3.3) + + vue-eslint-parser@9.4.3(eslint@8.57.1): + dependencies: + debug: 4.4.3 + eslint: 8.57.1 + eslint-scope: 7.2.2 + eslint-visitor-keys: 3.4.3 + espree: 9.6.1 + esquery: 1.6.0 + lodash: 4.17.21 + semver: 7.7.3 + transitivePeerDependencies: + - supports-color + + vue-router@4.6.3(vue@3.5.24(typescript@5.3.3)): + dependencies: + '@vue/devtools-api': 6.6.4 + vue: 3.5.24(typescript@5.3.3) + + vue-template-compiler@2.7.16: + dependencies: + de-indent: 1.0.2 + he: 1.2.0 + + vue-tsc@1.8.27(typescript@5.3.3): + dependencies: + '@volar/typescript': 1.11.1 + '@vue/language-core': 1.8.27(typescript@5.3.3) + semver: 7.7.3 + typescript: 5.3.3 + + vue-types@3.0.2(vue@3.5.24(typescript@5.3.3)): + dependencies: + is-plain-object: 3.0.1 + vue: 3.5.24(typescript@5.3.3) + + vue@3.5.24(typescript@5.3.3): + dependencies: + '@vue/compiler-dom': 3.5.24 + '@vue/compiler-sfc': 3.5.24 + '@vue/runtime-dom': 3.5.24 + '@vue/server-renderer': 3.5.24(vue@3.5.24(typescript@5.3.3)) + '@vue/shared': 3.5.24 + optionalDependencies: + typescript: 5.3.3 + + warning@4.0.3: + dependencies: + loose-envify: 1.4.0 + + webpack-virtual-modules@0.6.2: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + word-wrap@1.2.5: {} + + wrappy@1.0.2: {} + + xml-name-validator@4.0.0: {} + + yocto-queue@0.1.0: {} + + zrender@5.6.1: + dependencies: + tslib: 2.3.0 diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..3f5806f --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,46 @@ + + + + + + diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts new file mode 100644 index 0000000..c18d037 --- /dev/null +++ b/frontend/src/api/admin.ts @@ -0,0 +1,73 @@ +import request from '@/utils/request' +import type { Task } from './task' + +/** + * 管理员分配任务 + */ +export const assignTask = (taskId: number, designerId: number) => { + return request.post(`/admin/tasks/${taskId}/assign`, null, { + params: { designerId } + }) +} + +/** + * 管理员转移任务 + */ +export const transferTask = (taskId: number, newDesignerId: number, reason: string) => { + return request.post(`/admin/tasks/${taskId}/transfer`, { + newDesignerId, + reason + }) +} + +/** + * 批量分配任务 + */ +export const batchAssignTasks = (taskIds: number[], designerId: number) => { + return request.post('/admin/tasks/batch-assign', { + taskIds, + designerId + }) +} + +/** + * 调整任务优先级 + */ +export const adjustTaskPriority = (taskId: number, priority: string) => { + return request.put(`/admin/tasks/${taskId}/priority`, null, { + params: { priority } + }) +} + +/** + * 获取任务池列表(待分配的任务) + */ +export const getTaskPool = () => { + return request.get('/admin/tasks/pool') +} + +/** + * 获取所有进行中的任务 + */ +export const getInProgressTasks = () => { + return request.get('/admin/tasks/in-progress') +} + +/** + * 强制完成任务 + */ +export const forceCompleteTask = (taskId: number, reason: string) => { + return request.post(`/admin/tasks/${taskId}/force-complete`, { + reason + }) +} + +/** + * 管理员取消任务 + */ +export const cancelTask = (taskId: number, reason: string) => { + return request.post(`/admin/tasks/${taskId}/cancel`, { + reason + }) +} + diff --git a/frontend/src/api/analytics.ts b/frontend/src/api/analytics.ts new file mode 100644 index 0000000..ca4e5d3 --- /dev/null +++ b/frontend/src/api/analytics.ts @@ -0,0 +1,131 @@ +import request from '@/utils/request'; + +// 需求统计数据类型定义 +export interface UserDemandStat { + userId: number; + userName: string; + deptName: string; + totalDemands: number; + completedDemands: number; + pendingDemands: number; + totalScore: number; + avgScore: number; + completionRate: number; + satisfactionRate: number; +} + +export interface DeptDemandStat { + deptId: number; + deptName: string; + totalDemands: number; + completedDemands: number; + pendingDemands: number; + totalScore: number; + remainingScore: number; + dailyLimit: number; + weeklyLimit: number; + monthlyLimit: number; + dailyUsed: number; + weeklyUsed: number; + monthlyUsed: number; + utilizationRate: number; + alertLevel: string; +} + +export interface TrendData { + date: string; + demandCount: number; + completedCount: number; + scoreUsed: number; +} + +export interface TypeDistribution { + typeName: string; + count: number; + percentage: number; + totalScore: number; +} + +export interface OverviewData { + totalDemands: number; + todayDemands: number; + weekDemands: number; + monthDemands: number; + completedDemands: number; + pendingDemands: number; + canceledDemands: number; + totalScoreUsed: number; + todayScoreUsed: number; + weekScoreUsed: number; + monthScoreUsed: number; + avgCompletionTime: number; + avgSatisfaction: number; +} + +export interface DemandStatistics { + userStats: UserDemandStat[]; + deptStats: DeptDemandStat[]; + trendData: TrendData[]; + typeDistribution: TypeDistribution[]; + overview: OverviewData; +} + +/** + * 获取需求统计数据 + */ +export const getDemandStatistics = async ( + dateRange: string = 'month', + deptId?: number, + userId?: number +): Promise => { + const params: any = { dateRange }; + if (deptId) params.deptId = deptId; + if (userId) params.userId = userId; + + // 使用统一的 request 工具,它会自动处理 Result 包装 + // 注意:baseURL 已经包含 /api 前缀,这里不要再写 /api 开头,避免变成 /api/api/... + const response = await request.get('/analytics/demand-statistics', { params }); + + // 添加调试日志 + console.log('Analytics API Response:', response); + + // request 拦截器已经处理了错误情况,这里 response 应该是 Result 对象 + // 如果 response 是 Result 对象,取 data 字段;否则直接返回 + if (response && typeof response === 'object') { + // 检查是否是 Result 格式 { code, data, message } + if ('data' in response && response.data !== undefined && response.data !== null) { + console.log('Returning response.data:', response.data); + return response.data as DemandStatistics; + } + // 如果 response 本身就是数据对象(包含 overview 或 trendData),直接返回 + if ('overview' in response || 'trendData' in response) { + console.log('Returning response directly:', response); + return response as unknown as DemandStatistics; + } + } + + // 如果都不匹配,返回默认数据结构 + console.warn('Invalid response format, returning default structure'); + return { + overview: { + totalDemands: 0, + todayDemands: 0, + weekDemands: 0, + monthDemands: 0, + completedDemands: 0, + pendingDemands: 0, + canceledDemands: 0, + totalScoreUsed: 0, + todayScoreUsed: 0, + weekScoreUsed: 0, + monthScoreUsed: 0, + avgCompletionTime: 0, + avgSatisfaction: 0 + }, + trendData: [], + typeDistribution: [], + userStats: [], + deptStats: [] + }; +}; + diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts new file mode 100644 index 0000000..9f3c7a0 --- /dev/null +++ b/frontend/src/api/auth.ts @@ -0,0 +1,96 @@ +import request from '@/utils/request' + +const API_BASE = '' + +export interface LoginParams { + username: string + password: string + loginType?: string + authCode?: string +} + +// 注册参数 +export interface RegisterParams { + username: string + password: string + realName: string + roleId: number + deptId: number + email?: string + phone?: string +} + +export interface LoginResponse { + token: string // 后端返回的是 token + accessToken?: string // 兼容旧版 + refreshToken?: string + tokenType?: string + expiresIn?: number + userId?: number + username?: string + realName?: string + avatar?: string + roleIds?: string + userInfo?: UserInfo // 可选,后端可能不返回 +} + +export interface UserInfo { + id: number + username: string + realName: string + email: string + phone: string + avatar: string + deptId: number + deptName: string + roles: string[] + permissions: string[] +} + +/** + * 用户登录 + */ +export const login = (data: LoginParams) => { + return request.post( + `/auth/login`, + data + ) +} + +/** + * 用户注册 + */ +export const register = (data: RegisterParams) => { + return request.post( + `/auth/register`, + data + ) +} + +/** + * 获取用户信息 + */ +export const getUserInfo = () => { + return request.get( + `/auth/userinfo` + ) +} + +/** + * 退出登录 + */ +export const logout = () => { + return request.post( + `/auth/logout` + ) +} + +/** + * 刷新Token + */ +export const refreshToken = () => { + return request.post( + `/auth/refresh` + ) +} + diff --git a/frontend/src/api/config.ts b/frontend/src/api/config.ts new file mode 100644 index 0000000..eea7764 --- /dev/null +++ b/frontend/src/api/config.ts @@ -0,0 +1,313 @@ +import request from '@/utils/request' + +export interface DemandType { + id: number + name: string + unitScore: number + estimatedHours: number + description?: string + sortOrder?: number + isEnabled?: number + allowedDeptIds?: string // 可接单部门ID列表(逗号分隔) + departmentIds?: number[] // 前端使用的部门ID数组 +} + +export interface Brand { + id: number + name: string + brandCode?: string + logoUrl?: string + description?: string + sortOrder?: number + isEnabled?: number +} + +export interface Platform { + id: number + name: string + platformCode?: string + iconUrl?: string + description?: string + sortOrder?: number + isEnabled?: number +} + +export interface Category { + id: number + parentId?: number + name: string + categoryCode?: string + level?: number + isLeaf?: number + path?: string + description?: string + sortOrder?: number + isEnabled?: number +} + +export interface TeamScorePool { + id: number + deptId: number + deptName: string + balance: number + totalRecharge?: number + totalConsume?: number + totalEarn?: number + limitEnabled?: number + limitPeriod?: string + periodLimit?: number + periodUsed?: number + periodStartTime?: string + alertThreshold?: number +} + +export interface TaskSubType { + id: number + name: string + code: string + description?: string + sortOrder?: number + isEnabled?: number + createTime?: string + updateTime?: string +} + +/** + * 获取所有需求类型(包括禁用的,用于管理页面) + */ +export const getAllDemandTypes = () => { + return request.get('/config/demand-types') +} + +/** + * 获取所有启用的需求类型(用于下拉选择) + */ +export const getEnabledDemandTypes = () => { + return request.get('/config/demand-types/enabled') +} + +/** + * 获取所有品牌(包括禁用的,用于管理页面) + */ +export const getAllBrands = () => { + return request.get('/config/brands') +} + +/** + * 获取所有启用的品牌(用于下拉选择) + */ +export const getEnabledBrands = () => { + return request.get('/config/brands/enabled') +} + +/** + * 获取所有平台(包括禁用的,用于管理页面) + */ +export const getAllPlatforms = () => { + return request.get('/config/platforms') +} + +/** + * 获取所有启用的平台(用于下拉选择) + */ +export const getEnabledPlatforms = () => { + return request.get('/config/platforms/enabled') +} + +/** + * 获取所有叶子类目(包括禁用的,用于管理页面) + */ +export const getAllLeafCategories = () => { + return request.get('/config/categories/leaf') +} + +/** + * 获取所有启用的叶子类目(用于下拉选择) + */ +export const getEnabledLeafCategories = () => { + return request.get('/config/categories/leaf/enabled') +} + +/** + * 根据部门ID获取积分池 + */ +export const getScorePoolByDeptId = (deptId: number) => { + return request.get(`/config/score-pool/dept/${deptId}`) +} + +/** + * 检查积分余额是否足够 + */ +export const checkBalance = (deptId: number, score: number) => { + return request.get('/config/score-pool/check-balance', { + params: { deptId, score } + }) +} + +/** + * 检查是否超过周期限制 + */ +export const checkPeriodLimit = (deptId: number, score: number) => { + return request.get('/config/score-pool/check-limit', { + params: { deptId, score } + }) +} + +// ========== 需求类型 CRUD ========== + +/** + * 获取所有需求类型(别名) + */ +export const getDemandTypes = getAllDemandTypes + +/** + * 创建需求类型 + */ +export const createDemandType = (data: Partial) => { + return request.post('/config/demand-types', data) +} + +/** + * 更新需求类型 + */ +export const updateDemandType = (id: number, data: Partial) => { + return request.put(`/config/demand-types/${id}`, data) +} + +/** + * 删除需求类型 + */ +export const deleteDemandType = (id: number) => { + return request.delete(`/config/demand-types/${id}`) +} + +// ========== 品牌 CRUD ========== + +/** + * 获取所有品牌(别名) + */ +export const getBrands = getAllBrands + +/** + * 创建品牌 + */ +export const createBrand = (data: Partial) => { + return request.post('/config/brands', data) +} + +/** + * 更新品牌 + */ +export const updateBrand = (id: number, data: Partial) => { + return request.put(`/config/brands/${id}`, data) +} + +/** + * 删除品牌 + */ +export const deleteBrand = (id: number) => { + return request.delete(`/config/brands/${id}`) +} + +// ========== 平台 CRUD ========== + +/** + * 获取所有平台(别名) + */ +export const getPlatforms = getAllPlatforms + +/** + * 创建平台 + */ +export const createPlatform = (data: Partial) => { + return request.post('/config/platforms', data) +} + +/** + * 更新平台 + */ +export const updatePlatform = (id: number, data: Partial) => { + return request.put(`/config/platforms/${id}`, data) +} + +/** + * 删除平台 + */ +export const deletePlatform = (id: number) => { + return request.delete(`/config/platforms/${id}`) +} + +// ========== 类目 CRUD ========== + +/** + * 获取所有类目(包括非叶子节点) + */ +export const getCategories = () => { + return request.get('/config/categories') +} + +/** + * 创建类目 + */ +export const createCategory = (data: Partial) => { + return request.post('/config/categories', data) +} + +/** + * 更新类目 + */ +export const updateCategory = (id: number, data: Partial) => { + return request.put(`/config/categories/${id}`, data) +} + +/** + * 删除类目 + */ +export const deleteCategory = (id: number) => { + return request.delete(`/config/categories/${id}`) +} + +// ========== 任务子类型 CRUD ========== + +/** + * 获取所有任务子类型 + */ +export const getAllTaskSubTypes = () => { + return request.get('/task-sub-types') +} + +/** + * 获取启用的任务子类型 + */ +export const getEnabledTaskSubTypes = () => { + return request.get('/task-sub-types/enabled') +} + +/** + * 创建任务子类型 + */ +export const createTaskSubType = (data: Partial) => { + return request.post('/task-sub-types', data) +} + +/** + * 更新任务子类型 + */ +export const updateTaskSubType = (id: number, data: Partial) => { + return request.put(`/task-sub-types/${id}`, data) +} + +/** + * 删除任务子类型 + */ +export const deleteTaskSubType = (id: number) => { + return request.delete(`/task-sub-types/${id}`) +} + +/** + * 切换任务子类型启用状态 + */ +export const toggleTaskSubTypeEnabled = (id: number) => { + return request.post(`/task-sub-types/${id}/toggle-enabled`) +} + diff --git a/frontend/src/api/contest.ts b/frontend/src/api/contest.ts new file mode 100644 index 0000000..716b193 --- /dev/null +++ b/frontend/src/api/contest.ts @@ -0,0 +1,153 @@ +import request from '@/utils/request' +import type { Result, PageResult } from '@/api/types' + +// 竞赛活动接口类型 +export interface Contest { + id: number + name: string + description: string + contestType: 'monthly' | 'weekly' | 'special' + startTime: string + endTime: string + participantScope: 'all' | 'department' | 'user' + departmentIds?: number[] + userIds?: number[] + rules: Record + rewards: Array<{ rank: number; score: number; badge?: string }> + participationReward?: { minTasks: number; score: number } + status: 'draft' | 'scheduled' | 'active' | 'ended' | 'cancelled' + participantCount: number + creatorId: number + createTime: string + updateTime: string +} + +export interface ContestParticipant { + id: number + contestId: number + userId: number + userName: string + deptId: number + deptName: string + taskCompleted: number + fiveStarCount: number + earlyCompletionCount: number + praiseCount: number + noDelayDays: number + totalScore: number + currentRank: number + rewardClaimed: number + rewardScore: number + rewardClaimTime?: string + createTime: string + updateTime: string +} + +export interface ContestCreateRequest { + name: string + description?: string + contestType: string + startTime: string + endTime: string + participantScope: string + departmentIds?: number[] + userIds?: number[] + rules: string + rewards: string + participationReward?: string +} + +// ==================== 竞赛活动 API ==================== + +/** + * 创建竞赛 + */ +export function createContest(data: ContestCreateRequest): Promise { + return request.post>('/contests', data) + .then(res => res.data) +} + +/** + * 更新竞赛 + */ +export function updateContest(id: number, data: Partial): Promise { + return request.put>(`/contests/${id}`, data) + .then(res => res.data) +} + +/** + * 发布竞赛 + */ +export function publishContest(id: number): Promise { + return request.post>(`/contests/${id}/publish`) + .then(res => res.data) +} + +/** + * 结束竞赛 + */ +export function endContest(id: number): Promise { + return request.post>(`/contests/${id}/end`) + .then(res => res.data) +} + +/** + * 取消竞赛 + */ +export function cancelContest(id: number): Promise { + return request.post>(`/contests/${id}/cancel`) + .then(res => res.data) +} + +/** + * 删除竞赛 + */ +export function deleteContest(id: number): Promise { + return request.delete>(`/contests/${id}`) + .then(res => res.data) +} + +/** + * 获取竞赛列表 + */ +export function getContestList(params: { + page?: number + pageSize?: number + status?: string +}): Promise> { + return request.get>>('/contests', { params }) + .then(res => res.data) +} + +/** + * 获取竞赛详情 + */ +export function getContestById(id: number): Promise { + return request.get>(`/contests/${id}`) + .then(res => res.data) +} + +/** + * 获取竞赛排行榜 + */ +export function getContestLeaderboard(id: number, limit?: number): Promise { + return request.get>(`/contests/${id}/leaderboard`, { + params: { limit } + }).then(res => res.data) +} + +/** + * 获取我的竞赛数据 + */ +export function getMyContestData(contestId: number): Promise { + return request.get>(`/contests/${contestId}/my`) + .then(res => res.data) +} + +/** + * 领取竞赛奖励 + */ +export function claimContestReward(contestId: number): Promise { + return request.post>(`/contests/${contestId}/claim-reward`) + .then(res => res.data) +} diff --git a/frontend/src/api/delayPenalty.ts b/frontend/src/api/delayPenalty.ts new file mode 100644 index 0000000..1c54185 --- /dev/null +++ b/frontend/src/api/delayPenalty.ts @@ -0,0 +1,61 @@ +import request from './request' + +export interface DelayPenaltyRecord { + id: number + taskId: number + designerId: number + delayDays: number + delayHours: number + delayLevel: 'light' | 'general' | 'severe' | 'major' + penaltyScore: number + originalDeadline: string + actualTime: string + status: 'confirmed' | 'appealed' | 'approved' | 'rejected' + appealReason?: string + appealTime?: string + appealResult?: string + approverId?: number + approveTime?: string + createTime: string + updateTime: string +} + +/** + * 根据任务ID查询延误记录 + */ +export const getDelayRecordsByTaskId = (taskId: number) => { + return request.get(`/delay-penalty/task/${taskId}`) +} + +/** + * 根据设计师ID查询延误记录 + */ +export const getDelayRecordsByDesignerId = (designerId: number) => { + return request.get(`/delay-penalty/designer/${designerId}`) +} + +/** + * 统计设计师的延误扣分总数 + */ +export const getTotalPenaltyScore = (designerId: number) => { + return request.get(`/delay-penalty/designer/${designerId}/total`) +} + +/** + * 申诉延误扣分 + */ +export const appealDelayPenalty = (recordId: number, appealReason: string) => { + return request.post(`/delay-penalty/${recordId}/appeal`, null, { + params: { appealReason } + }) +} + +/** + * 管理员审批申诉 + */ +export const approveAppeal = (recordId: number, approved: boolean, appealResult?: string) => { + return request.post(`/delay-penalty/${recordId}/approve`, null, { + params: { approved, appealResult } + }) +} + diff --git a/frontend/src/api/department.ts b/frontend/src/api/department.ts new file mode 100644 index 0000000..69b0e0d --- /dev/null +++ b/frontend/src/api/department.ts @@ -0,0 +1,99 @@ +import request from '@/utils/request' + +export type DeptType = 'demand' | 'receiving' + +export interface DepartmentOverview { + id: number + deptName: string + deptCode?: string + deptType: DeptType + parentId?: number + leaderId?: number + leaderName?: string + description?: string + sortOrder?: number + status?: number + scoreBalance?: number + totalRecharge?: number + totalConsume?: number + totalEarn?: number + limitEnabled?: number + limitPeriod?: 'DAILY' | 'WEEKLY' | 'MONTHLY' + periodLimit?: number + periodUsed?: number + alertThreshold?: number + overLimitAction?: 'alert' | 'block' + limitAlert?: boolean + allowedDemandTypes?: string[] + memberCount?: number +} + +export interface DepartmentPayload { + deptName: string + deptCode?: string + deptType: DeptType + parentId?: number | null + leaderId?: number | null + sortOrder?: number + status?: number + allowedDemandTypes?: string[] + limitEnabled?: number + limitPeriod?: 'DAILY' | 'WEEKLY' | 'MONTHLY' + periodLimit?: number + alertThreshold?: number + overLimitAction?: 'alert' | 'block' + initialScoreBalance?: number +} + +export interface DepartmentUpdatePayload extends Partial {} + +export interface LimitSettingsPayload { + limitEnabled: number + limitPeriod?: 'DAILY' | 'WEEKLY' | 'MONTHLY' + periodLimit?: number + alertThreshold?: number + overLimitAction?: 'alert' | 'block' +} + +export interface ScoreAdjustPayload { + action: 'recharge' | 'deduct' + amount: number + remark?: string +} + +export const fetchDepartmentOverview = () => { + return request.get('/departments/overview') +} + +export const createDepartment = (data: DepartmentPayload) => { + return request.post('/departments', data) +} + +export const updateDepartment = (id: number, data: DepartmentUpdatePayload) => { + return request.put(`/departments/${id}`, data) +} + +export const updateLimitSettings = (id: number, data: LimitSettingsPayload) => { + return request.put(`/departments/${id}/limit`, data) +} + +export const deleteDepartment = (id: number) => { + return request.delete(`/departments/${id}`) +} + +export const adjustDepartmentScore = (id: number, data: ScoreAdjustPayload) => { + return request.post(`/departments/${id}/score-adjust`, data) +} + +export const getDepartmentScorePool = (id: number) => { + return request.get(`/departments/${id}/score-pool`) +} + +export const getDepartmentTree = () => { + return request.get('/departments/tree') +} + +export const getDepartmentList = (params?: any) => { + return request.get('/departments', { params }) +} + diff --git a/frontend/src/api/designer.ts b/frontend/src/api/designer.ts new file mode 100644 index 0000000..e278afc --- /dev/null +++ b/frontend/src/api/designer.ts @@ -0,0 +1,63 @@ +import request from '@/utils/request' + +export interface DesignerStats { + userId: number + userName: string + avatar?: string + deptName?: string + inProgressCount: number + queueCount: number + totalScore: number + delayedCount: number + totalTaskCount: number + completedCount: number + avgRating: number + goodReviewCount: number + completionRate: number + ontimeRate: number + onlineStatus: number + lastActiveTime: string + // 前端扩展字段 + showTasks?: boolean + loadingTasks?: boolean + inProgressTasks?: any[] +} + +/** + * 获取所有设计师统计信息 + */ +export const getAllDesignerStats = () => { + return request.get('/designers/stats') +} + +/** + * 获取单个设计师统计信息 + */ +export const getDesignerStats = (userId: number) => { + return request.get(`/designers/stats/${userId}`) +} + +/** + * 获取排行榜 + * @param type 类型:score-积分榜,efficiency-效率榜,quality-好评榜,diligence-勤勉榜 + * @param limit 返回数量 + * @param month 月份:YYYY-MM格式,可选,不传则查询当月 + */ +export const getLeaderboard = (type: string = 'score', limit: number = 10, month?: string) => { + const params: any = { type, limit } + if (month) { + params.month = month + } + return request.get('/designers/leaderboard', { + params + }) +} + +/** + * 获取设计师进行中的任务列表 + * @param userId 设计师ID + */ +export const getDesignerInProgressTasks = (userId: number) => { + return request.get(`/designers/${userId}/in-progress-tasks`) +} + diff --git a/frontend/src/api/dingtalk.ts b/frontend/src/api/dingtalk.ts new file mode 100644 index 0000000..d968839 --- /dev/null +++ b/frontend/src/api/dingtalk.ts @@ -0,0 +1,81 @@ +import request from '@/utils/request' +import type { Result } from '@/api/types' + +// 钉钉配置接口类型 +export interface DingTalkConfig { + id?: number + name: string + agentId?: string + appKey?: string + appSecret?: string + corpId?: string + robotWebhook?: string + robotSecret?: string + enabled: number + enabledFeatures?: { + notification?: boolean + login?: boolean + attendance?: boolean + sync?: boolean + } + createTime?: string + updateTime?: string +} + +// ==================== 钉钉集成 API ==================== + +/** + * 获取钉钉配置 + */ +export function getDingTalkConfig(): Promise { + return request.get>('/dingtalk/config') + .then(res => res.data) +} + +/** + * 保存钉钉配置 + */ +export function saveDingTalkConfig(config: DingTalkConfig): Promise { + return request.post>('/dingtalk/config', config) + .then(res => res.data) +} + +/** + * 测试钉钉连接 + */ +export function testDingTalkConnection(config: DingTalkConfig): Promise { + return request.post>('/dingtalk/test', config) + .then(res => res.data) +} + +/** + * 发送测试消息 + */ +export function sendTestMessage(title: string, content: string): Promise { + return request.post>('/dingtalk/test-message', { title, content }) + .then(res => res.data) +} + +/** + * 获取钉钉部门列表 + */ +export function getDingTalkDepartments(): Promise { + return request.get>('/dingtalk/departments') + .then(res => res.data) +} + +/** + * 同步钉钉用户 + */ +export function syncDingTalkUsers(): Promise { + return request.post>('/dingtalk/sync-users') + .then(res => res.data) +} + +/** + * 检查功能是否启用 + */ +export function isDingTalkFeatureEnabled(feature: string): Promise { + return request.get>(`/dingtalk/feature/${feature}`) + .then(res => res.data) +} diff --git a/frontend/src/api/file.ts b/frontend/src/api/file.ts new file mode 100644 index 0000000..4a65b7e --- /dev/null +++ b/frontend/src/api/file.ts @@ -0,0 +1,176 @@ +import axios from 'axios' +import { message } from 'ant-design-vue' + +const API_BASE = (import.meta as any).env?.VITE_API_BASE_URL || '/api' + +export interface FileUpload { + id: number + originalName: string + storedName: string + filePath: string + fileUrl: string + fileSize: number + fileType: string + uploaderId: number + uploaderName: string + relatedType?: string + relatedId?: number + createTime: string +} + +/** + * 上传文件到S3 + * @param files 文件列表 + * @param relatedType 关联类型(可选,如 'task') + * @param relatedId 关联ID(可选) + * @param onProgress 上传进度回调 + */ +export const uploadFilesToS3 = async ( + files: File[], + relatedType?: string, + relatedId?: number, + onProgress?: (progress: number) => void +): Promise => { + console.log('[uploadFilesToS3] 开始上传,文件数量:', files.length, 'relatedType:', relatedType, 'relatedId:', relatedId) + + const formData = new FormData() + files.forEach((file, index) => { + console.log(`[uploadFilesToS3] 添加文件 ${index + 1}:`, file.name, file.size, file.type) + formData.append('files', file) + }) + if (relatedType) { + formData.append('relatedType', relatedType) + } + if (relatedId) { + formData.append('relatedId', relatedId.toString()) + } + + const token = localStorage.getItem('token') + console.log('[uploadFilesToS3] Token存在:', !!token) + console.log('[uploadFilesToS3] 请求URL:', `${API_BASE}/file/upload`) + + return new Promise((resolve, reject) => { + console.log('[uploadFilesToS3] 发送POST请求到:', `${API_BASE}/file/upload`) + axios.post<{ code: number; message: string; data: string[] }>( + `${API_BASE}/file/upload`, + formData, + { + headers: { + 'Content-Type': 'multipart/form-data', + 'Authorization': token ? `Bearer ${token}` : '' + }, + onUploadProgress: (progressEvent) => { + if (onProgress && progressEvent.total) { + const percent = Math.round((progressEvent.loaded * 100) / progressEvent.total) + onProgress(percent) + } + } + } + ) + .then(response => { + console.log('[uploadFilesToS3] 上传响应:', response.data) + if (response.data.code === 200 && response.data.data) { + console.log('[uploadFilesToS3] 上传成功,文件URLs:', response.data.data) + resolve(response.data.data) + } else { + console.error('[uploadFilesToS3] 上传失败,响应:', response.data) + message.error(response.data.message || '上传失败') + reject(new Error(response.data.message || '上传失败')) + } + }) + .catch(error => { + console.error('[uploadFilesToS3] 上传异常:', error) + console.error('[uploadFilesToS3] 错误详情:', { + message: error.message, + response: error.response?.data, + status: error.response?.status + }) + const errorMsg = error.response?.data?.message || error.message || '上传失败' + message.error(errorMsg) + reject(new Error(errorMsg)) + }) + }) +} + +/** + * 下载文件(需要登录) + * @param fileId 文件ID + */ +export const downloadFile = (fileId: number) => { + const token = localStorage.getItem('token') + const url = `${API_BASE}/file/download/${fileId}` + + // 创建隐藏的a标签进行下载 + const link = document.createElement('a') + link.href = url + link.style.display = 'none' + + // 添加Authorization header(通过fetch实现) + fetch(url, { + headers: { + 'Authorization': token ? `Bearer ${token}` : '' + } + }) + .then(response => { + if (response.ok) { + return response.blob() + } else { + throw new Error('下载失败') + } + }) + .then(blob => { + const downloadUrl = window.URL.createObjectURL(blob) + link.href = downloadUrl + link.download = '' + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + window.URL.revokeObjectURL(downloadUrl) + }) + .catch(error => { + message.error('文件下载失败: ' + error.message) + }) +} + +/** + * 获取文件信息 + */ +export const getFileInfo = (fileId: number): Promise => { + return axios.get(`${API_BASE}/file/${fileId}`, { + headers: { + 'Authorization': `Bearer ${localStorage.getItem('token')}` + } + }).then(response => { + if (response.data.code === 200) { + return response.data.data + } else { + throw new Error(response.data.message || '获取文件信息失败') + } + }) +} + +/** + * 根据关联信息获取文件列表 + */ +export const getFilesByRelated = (relatedType: string, relatedId: number): Promise => { + return axios.get(`${API_BASE}/file/list`, { + params: { relatedType, relatedId }, + headers: { + 'Authorization': `Bearer ${localStorage.getItem('token')}` + } + }).then(response => { + if (response.data.code === 200) { + return response.data.data + } else { + throw new Error(response.data.message || '获取文件列表失败') + } + }) +} + +/** + * 直接打开文件URL(用于S3公开URL) + * @param fileUrl 文件URL + */ +export const openFileUrl = (fileUrl: string) => { + window.open(fileUrl, '_blank') +} diff --git a/frontend/src/api/goal.ts b/frontend/src/api/goal.ts new file mode 100644 index 0000000..e3643aa --- /dev/null +++ b/frontend/src/api/goal.ts @@ -0,0 +1,127 @@ +import request from '@/utils/request' +import type { Result } from '@/api/types' + +// 用户目标接口类型 +export interface UserGoal { + id: number + userId: number + goalType: 'task_count' | 'score' | 'rank' | 'rating' + goalName: string + description?: string + targetValue: number + currentValue: number + period: 'daily' | 'weekly' | 'monthly' | 'quarterly' | 'yearly' | 'custom' + periodStart: string + periodEnd: string + status: 'active' | 'completed' | 'failed' | 'cancelled' + completedTime?: string + rewardScore: number + rewardClaimed: number + reminderType: 'none' | 'daily' | 'before_deadline' + createTime: string + updateTime: string +} + +export interface GoalCreateRequest { + goalType: string + goalName: string + description?: string + targetValue: number + period: string + periodStart?: string + periodEnd?: string + rewardScore?: number + reminderType?: string +} + +// ==================== 目标管理 API ==================== + +/** + * 创建目标 + */ +export function createGoal(data: GoalCreateRequest): Promise { + return request.post>('/goals', data) + .then(res => res.data) +} + +/** + * 创建预设目标 + */ +export function createPresetGoal(type: string): Promise { + return request.post>(`/goals/preset/${type}`) + .then(res => res.data) +} + +/** + * 更新目标 + */ +export function updateGoal(id: number, data: Partial): Promise { + return request.put>(`/goals/${id}`, data) + .then(res => res.data) +} + +/** + * 更新目标进度 + */ +export function updateGoalProgress(id: number, progressValue: number): Promise { + return request.put>(`/goals/${id}/progress`, null, { + params: { progressValue } + }).then(res => res.data) +} + +/** + * 获取我的目标列表 + */ +export function getMyGoals(status?: string): Promise { + return request.get>('/goals/my', { + params: { status } + }).then(res => res.data) +} + +/** + * 获取我的活跃目标 + */ +export function getMyActiveGoals(): Promise { + return request.get>('/goals/my/active') + .then(res => res.data) +} + +/** + * 获取目标详情 + */ +export function getGoalById(id: number): Promise { + return request.get>(`/goals/${id}`) + .then(res => res.data) +} + +/** + * 领取目标奖励 + */ +export function claimGoalReward(id: number): Promise { + return request.post>(`/goals/${id}/claim-reward`) + .then(res => res.data) +} + +/** + * 取消目标 + */ +export function cancelGoal(id: number): Promise { + return request.post>(`/goals/${id}/cancel`) + .then(res => res.data) +} + +/** + * 删除目标 + */ +export function deleteGoal(id: number): Promise { + return request.delete>(`/goals/${id}`) + .then(res => res.data) +} + +// 预设目标类型 +export const PRESET_GOAL_TYPES = [ + { type: 'weekly_task', name: '本周完成10个任务', description: '在本周内完成10个设计任务', reward: 50 }, + { type: 'monthly_task', name: '本月完成40个任务', description: '在本月内完成40个设计任务', reward: 200 }, + { type: 'monthly_score', name: '本月获得1000积分', description: '在本月内累计获得1000积分', reward: 100 }, + { type: 'top_10', name: '进入排行榜前10', description: '在本月排行榜中进入前10名', reward: 150 }, +] diff --git a/frontend/src/api/incentive.ts b/frontend/src/api/incentive.ts new file mode 100644 index 0000000..4351882 --- /dev/null +++ b/frontend/src/api/incentive.ts @@ -0,0 +1,116 @@ +import request from '@/utils/request' +import type { Result } from '@/api/types' + +// 成就接口类型定义 +export interface Achievement { + id: number + achievementCode: string + achievementName: string + description: string + iconUrl?: string + achievementType: string + condition: string + rewardScore: number + level: string + sortOrder: number + status: number + createTime: string + updateTime: string +} + +export interface UserAchievement { + id: number + userId: number + achievementId: number + achievedTime: string + progressValue: number + rewardClaimed: number + claimTime?: string + createTime: string + updateTime: string +} + +// 签到接口类型定义 +export interface CheckIn { + id: number + userId: number + checkInDate: string + continuousDays: number + rewardScore: number + remark?: string + createTime: string +} + +export interface CheckInStats { + continuousDays: number + totalDays: number + hasCheckedInToday: boolean +} + +// ==================== 成就相关API ==================== + +/** + * 获取所有成就列表 + */ +export function getAllAchievements(): Promise { + return request.get>('/achievements') + .then(res => res.data) +} + +/** + * 获取用户已达成的成就 + */ +export function getUserAchievements(userId: number): Promise { + return request.get>(`/achievements/user/${userId}`) + .then(res => res.data) +} + +/** + * 领取成就奖励 + */ +export function claimAchievementReward(achievementId: number, userId: number): Promise { + return request.post>(`/achievements/${achievementId}/claim`, null, { + params: { userId } + }).then(res => res.data) +} + +/** + * 初始化默认成就 + */ +export function initDefaultAchievements(): Promise { + return request.post>('/achievements/init') + .then(res => res.data) +} + +// ==================== 签到相关API ==================== + +/** + * 用户签到 + */ +export function checkIn(): Promise { + return request.post>('/check-in').then(res => res.data) +} + +/** + * 检查今天是否已签到 + */ +export function hasCheckedInToday(): Promise { + return request.get>('/check-in/today').then(res => res.data) +} + +/** + * 获取签到历史记录 + */ +export function getCheckInHistory(limit: number = 30): Promise { + return request.get>('/check-in/history', { + params: { limit } + }).then(res => res.data) +} + +/** + * 获取签到统计信息 + */ +export function getCheckInStats(): Promise { + return request.get>('/check-in/stats').then(res => res.data) +} + diff --git a/frontend/src/api/notification.ts b/frontend/src/api/notification.ts new file mode 100644 index 0000000..f7b349b --- /dev/null +++ b/frontend/src/api/notification.ts @@ -0,0 +1,81 @@ +import request from '@/utils/request' +import type { Result } from '@/api/types' + +// 通知接口类型定义 +export interface Notification { + id: number + notificationType: string + receiverId: number + senderId?: number + title: string + content: string + relatedId?: number + relatedType?: string + linkUrl?: string + isRead: number + readTime?: string + pushDingtalk?: number + dingtalkStatus?: string + dingtalkPushTime?: string + priority: string + createTime: string + updateTime: string +} + +export interface PageResult { + records: T[] + total: number + size: number + current: number + pages: number +} + +/** + * 获取用户通知列表(分页) + */ +export function getNotifications(params: { + receiverId: number + pageNum?: number + pageSize?: number + unreadOnly?: boolean +}): Promise> { + return request.get>>('/notifications', { params }) + .then(res => res.data) +} + +/** + * 获取未读数量 + */ +export function getUnreadCount(receiverId: number): Promise { + return request.get>('/notifications/unread-count', { + params: { receiverId } + }).then(res => res.data) +} + +/** + * 标记为已读 + */ +export function markAsRead(id: number, receiverId: number): Promise { + return request.put>(`/notifications/${id}/read`, null, { + params: { receiverId } + }).then(res => res.data) +} + +/** + * 批量标记所有为已读 + */ +export function markAllAsRead(receiverId: number): Promise { + return request.put>('/notifications/read-all', null, { + params: { receiverId } + }).then(res => res.data) +} + +/** + * 删除通知 + */ +export function deleteNotification(id: number, receiverId: number): Promise { + return request.delete>(`/notifications/${id}`, { + params: { receiverId } + }).then(res => res.data) +} + diff --git a/frontend/src/api/permission.ts b/frontend/src/api/permission.ts new file mode 100644 index 0000000..da993dc --- /dev/null +++ b/frontend/src/api/permission.ts @@ -0,0 +1,87 @@ +import axios from 'axios'; + +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || ''; + +// 权限相关类型定义 +export interface Permission { + id?: number; + parentId?: number; + permissionCode: string; + permissionName: string; + type?: string; + path?: string; + method?: string; + icon?: string; + component?: string; + description?: string; + sortOrder?: number; + status?: number; + createTime?: string; + updateTime?: string; + children?: Permission[]; +} + +/** + * 获取所有权限列表 + */ +export const getAllPermissions = async (): Promise => { + const response = await axios.get(`${API_BASE_URL}/api/permissions`); + return response.data.data; +}; + +/** + * 获取权限树形结构 + */ +export const getPermissionTree = async (): Promise => { + const response = await axios.get(`${API_BASE_URL}/api/permissions/tree`); + return response.data.data; +}; + +/** + * 根据ID获取权限 + */ +export const getPermissionById = async (id: number): Promise => { + const response = await axios.get(`${API_BASE_URL}/api/permissions/${id}`); + return response.data.data; +}; + +/** + * 根据用户ID获取权限列表 + */ +export const getPermissionsByUserId = async (userId: number): Promise => { + const response = await axios.get(`${API_BASE_URL}/api/permissions/user/${userId}`); + return response.data.data; +}; + +/** + * 根据用户ID获取权限代码列表 + */ +export const getPermissionCodesByUserId = async (userId: number): Promise => { + const response = await axios.get(`${API_BASE_URL}/api/permissions/user/${userId}/codes`); + return response.data.data; +}; + +/** + * 创建权限 + */ +export const createPermission = async (permission: Permission): Promise => { + const response = await axios.post(`${API_BASE_URL}/api/permissions`, permission); + return response.data.data; +}; + +/** + * 更新权限 + */ +export const updatePermission = async (id: number, permission: Partial): Promise => { + const response = await axios.put(`${API_BASE_URL}/api/permissions/${id}`, permission); + return response.data.data; +}; + +/** + * 删除权限 + */ +export const deletePermission = async (id: number): Promise => { + const response = await axios.delete(`${API_BASE_URL}/api/permissions/${id}`); + return response.data.data; +}; + diff --git a/frontend/src/api/rewardRule.ts b/frontend/src/api/rewardRule.ts new file mode 100644 index 0000000..6139a15 --- /dev/null +++ b/frontend/src/api/rewardRule.ts @@ -0,0 +1,142 @@ +import request from '@/utils/request' +import type { Result, PageResult } from '@/api/types' + +// 奖励规则接口类型 +export interface RewardRule { + id: number + name: string + description: string + ruleType: 'daily' | 'weekly' | 'monthly' | 'task' | 'rating' + triggerCondition: { + type: string + operator: string + value: number + } + rewardType: 'score' | 'badge' | 'both' + rewardScore: number + rewardBadge?: any + scope: 'all' | 'designer' | 'department' + scopeDeptIds?: number[] + enabled: number + cronExpression?: string + lastExecuteTime?: string + priority: number + creatorId: number + createTime: string + updateTime: string +} + +export interface RewardRuleCreateRequest { + name: string + description?: string + ruleType: string + triggerCondition: string + rewardType: string + rewardScore: number + scope: string + scopeDeptIds?: string + priority?: number +} + +// ==================== 奖励规则 API ==================== + +/** + * 创建奖励规则 + */ +export function createRewardRule(data: RewardRuleCreateRequest): Promise { + return request.post>('/reward-rules', data) + .then(res => res.data) +} + +/** + * 更新奖励规则 + */ +export function updateRewardRule(id: number, data: Partial): Promise { + return request.put>(`/reward-rules/${id}`, data) + .then(res => res.data) +} + +/** + * 启用规则 + */ +export function enableRewardRule(id: number): Promise { + return request.post>(`/reward-rules/${id}/enable`) + .then(res => res.data) +} + +/** + * 禁用规则 + */ +export function disableRewardRule(id: number): Promise { + return request.post>(`/reward-rules/${id}/disable`) + .then(res => res.data) +} + +/** + * 删除规则 + */ +export function deleteRewardRule(id: number): Promise { + return request.delete>(`/reward-rules/${id}`) + .then(res => res.data) +} + +/** + * 获取规则列表 + */ +export function getRewardRuleList(params: { + page?: number + pageSize?: number + ruleType?: string +}): Promise> { + return request.get>>('/reward-rules', { params }) + .then(res => res.data) +} + +/** + * 获取规则详情 + */ +export function getRewardRuleById(id: number): Promise { + return request.get>(`/reward-rules/${id}`) + .then(res => res.data) +} + +/** + * 获取启用的规则 + */ +export function getEnabledRewardRules(): Promise { + return request.get>('/reward-rules/enabled') + .then(res => res.data) +} + +/** + * 初始化默认规则 + */ +export function initDefaultRewardRules(): Promise { + return request.post>('/reward-rules/init') + .then(res => res.data) +} + +// 规则类型选项 +export const RULE_TYPE_OPTIONS = [ + { value: 'daily', label: '每日奖励' }, + { value: 'weekly', label: '每周奖励' }, + { value: 'monthly', label: '每月奖励' }, + { value: 'task', label: '任务奖励' }, + { value: 'rating', label: '好评奖励' }, +] + +// 触发条件类型 +export const TRIGGER_TYPE_OPTIONS = [ + { value: 'task_count', label: '完成任务数' }, + { value: 'five_star_count', label: '五星好评数' }, + { value: 'rating', label: '任务评分' }, +] + +// 比较运算符 +export const OPERATOR_OPTIONS = [ + { value: '>=', label: '大于等于' }, + { value: '>', label: '大于' }, + { value: '==', label: '等于' }, + { value: '<=', label: '小于等于' }, + { value: '<', label: '小于' }, +] diff --git a/frontend/src/api/role.ts b/frontend/src/api/role.ts new file mode 100644 index 0000000..ac7c524 --- /dev/null +++ b/frontend/src/api/role.ts @@ -0,0 +1,85 @@ +import axios from 'axios'; + +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || ''; + +// 角色相关类型定义 +export interface Role { + id?: number; + roleCode: string; + roleName: string; + description?: string; + level?: number; + dataScope?: string; + sortOrder?: number; + status?: number; + builtIn?: number; + createTime?: string; + updateTime?: string; +} + +/** + * 获取所有角色列表 + */ +export const getAllRoles = async (): Promise => { + const response = await axios.get(`${API_BASE_URL}/api/roles`); + return response.data.data; +}; + +/** + * 根据ID获取角色 + */ +export const getRoleById = async (id: number): Promise => { + const response = await axios.get(`${API_BASE_URL}/api/roles/${id}`); + return response.data.data; +}; + +/** + * 创建角色 + */ +export const createRole = async (role: Role): Promise => { + const response = await axios.post(`${API_BASE_URL}/api/roles`, role); + return response.data.data; +}; + +/** + * 更新角色 + */ +export const updateRole = async (id: number, role: Partial): Promise => { + const response = await axios.put(`${API_BASE_URL}/api/roles/${id}`, role); + return response.data.data; +}; + +/** + * 删除角色 + */ +export const deleteRole = async (id: number): Promise => { + const response = await axios.delete(`${API_BASE_URL}/api/roles/${id}`); + return response.data.data; +}; + +/** + * 为角色分配权限 + */ +export const assignRolePermissions = async (roleId: number, permissionIds: number[]): Promise => { + const response = await axios.post(`${API_BASE_URL}/api/roles/${roleId}/permissions`, permissionIds); + return response.data.data; +}; + +/** + * 获取角色的权限ID列表 + */ +export const getRolePermissionIds = async (roleId: number): Promise => { + const response = await axios.get(`${API_BASE_URL}/api/roles/${roleId}/permissions`); + return response.data.data; +}; + +/** + * 启用/禁用角色 + */ +export const toggleRoleStatus = async (id: number, status: number): Promise => { + const response = await axios.put(`${API_BASE_URL}/api/roles/${id}/status`, null, { + params: { status } + }); + return response.data.data; +}; + diff --git a/frontend/src/api/s3.ts b/frontend/src/api/s3.ts new file mode 100644 index 0000000..1429a36 --- /dev/null +++ b/frontend/src/api/s3.ts @@ -0,0 +1,70 @@ +import request from '@/utils/request' + +export interface S3Config { + id?: number + endpoint: string + bucket: string + accessKeyId: string + secretAccessKey: string + region: string + publicDomain?: string + enablePathStyle?: boolean + filePath?: string + isActive?: boolean + createTime?: string + updateTime?: string +} + +/** + * 获取激活的S3配置 + */ +export const getActiveS3Config = async (): Promise => { + const response = await request.get('/s3/config/active') + return response?.data || null +} + +/** + * 获取所有S3配置 + */ +export const getAllS3Configs = async (): Promise => { + const response = await request.get('/s3/config/list') + return response?.data || [] +} + +/** + * 根据ID获取S3配置 + */ +export const getS3ConfigById = async (id: number): Promise => { + const response = await request.get(`/s3/config/${id}`) + return response?.data +} + +/** + * 创建S3配置 + */ +export const createS3Config = async (config: S3Config): Promise => { + const response = await request.post('/s3/config', config) + return response?.data +} + +/** + * 更新S3配置 + */ +export const updateS3Config = async (id: number, config: S3Config): Promise => { + const response = await request.put(`/s3/config/${id}`, config) + return response?.data +} + +/** + * 删除S3配置 + */ +export const deleteS3Config = async (id: number): Promise => { + await request.delete(`/s3/config/${id}`) +} + +/** + * 激活指定的S3配置 + */ +export const activateS3Config = async (id: number): Promise => { + await request.put(`/s3/config/${id}/activate`) +} diff --git a/frontend/src/api/scorePool.ts b/frontend/src/api/scorePool.ts new file mode 100644 index 0000000..27c5280 --- /dev/null +++ b/frontend/src/api/scorePool.ts @@ -0,0 +1,181 @@ +import request from '@/utils/request' +import type { Result, PageResult } from '@/api/types' + +// 积分池接口类型 +export interface ScorePool { + id: number + name: string + poolType: 'monthly' | 'quarterly' | 'yearly' | 'special' + year: number + month?: number + quarter?: number + totalBudget: number + usedScore: number + remainingScore: number + taskRewardBudget: number + taskRewardUsed: number + ratingRewardBudget: number + ratingRewardUsed: number + contestRewardBudget: number + contestRewardUsed: number + checkinRewardBudget: number + checkinRewardUsed: number + otherRewardBudget: number + otherRewardUsed: number + overflowStrategy: 'stop' | 'alert' | 'auto_increase' + alertThreshold: number + status: 'active' | 'closed' | 'settled' + creatorId: number + createTime: string + updateTime: string +} + +export interface ScorePoolRecord { + id: number + poolId: number + userId: number + userName: string + rewardType: string + scoreChange: number + beforeRemaining: number + afterRemaining: number + bizType?: string + bizId?: number + remark?: string + createTime: string +} + +export interface ScorePoolCreateRequest { + name: string + poolType: string + year: number + month?: number + quarter?: number + totalBudget: number + taskRewardBudget?: number + ratingRewardBudget?: number + contestRewardBudget?: number + checkinRewardBudget?: number + otherRewardBudget?: number + overflowStrategy?: string + alertThreshold?: number +} + +export interface PoolStatistics { + totalBudget: number + usedScore: number + remainingScore: number + usagePercent: number + categories: { + task: { budget: number; used: number } + rating: { budget: number; used: number } + contest: { budget: number; used: number } + checkin: { budget: number; used: number } + other: { budget: number; used: number } + } +} + +// ==================== 积分池 API ==================== + +/** + * 创建积分池 + */ +export function createScorePool(data: ScorePoolCreateRequest): Promise { + return request.post>('/score-pools', data) + .then(res => res.data) +} + +/** + * 更新积分池 + */ +export function updateScorePool(id: number, data: Partial): Promise { + return request.put>(`/score-pools/${id}`, data) + .then(res => res.data) +} + +/** + * 获取积分池列表 + */ +export function getScorePoolList(params: { + page?: number + pageSize?: number + poolType?: string + year?: number +}): Promise> { + return request.get>>('/score-pools', { params }) + .then(res => res.data) +} + +/** + * 获取积分池详情 + */ +export function getScorePoolById(id: number): Promise { + return request.get>(`/score-pools/${id}`) + .then(res => res.data) +} + +/** + * 获取当前活跃积分池 + */ +export function getCurrentActivePool(): Promise { + return request.get>('/score-pools/current') + .then(res => res.data) +} + +/** + * 获取积分池统计信息 + */ +export function getPoolStatistics(id: number): Promise { + return request.get>(`/score-pools/${id}/statistics`) + .then(res => res.data) +} + +/** + * 获取积分池使用记录 + */ +export function getPoolRecords(id: number, params: { + page?: number + pageSize?: number +}): Promise> { + return request.get>>(`/score-pools/${id}/records`, { params }) + .then(res => res.data) +} + +/** + * 关闭积分池 + */ +export function closeScorePool(id: number): Promise { + return request.post>(`/score-pools/${id}/close`) + .then(res => res.data) +} + +/** + * 结算积分池 + */ +export function settleScorePool(id: number): Promise { + return request.post>(`/score-pools/${id}/settle`) + .then(res => res.data) +} + +/** + * 删除积分池 + */ +export function deleteScorePool(id: number): Promise { + return request.delete>(`/score-pools/${id}`) + .then(res => res.data) +} + +// 积分池类型选项 +export const POOL_TYPE_OPTIONS = [ + { value: 'monthly', label: '月度池' }, + { value: 'quarterly', label: '季度池' }, + { value: 'yearly', label: '年度池' }, + { value: 'special', label: '专项池' }, +] + +// 超支策略选项 +export const OVERFLOW_STRATEGY_OPTIONS = [ + { value: 'stop', label: '停止发放' }, + { value: 'alert', label: '提醒继续' }, + { value: 'auto_increase', label: '自动增加预算' }, +] diff --git a/frontend/src/api/system.ts b/frontend/src/api/system.ts new file mode 100644 index 0000000..11d5694 --- /dev/null +++ b/frontend/src/api/system.ts @@ -0,0 +1,99 @@ +import request from '@/utils/request' + +export interface SystemConfig { + id?: number + configKey: string + configValue: string + configGroup?: string + configName?: string + description?: string + valueType?: string + builtIn?: number + sortOrder?: number +} + +/** + * 获取系统配置值 + */ +export const getSystemConfig = (key: string) => { + return request.get(`/system-config/key/${key}`) +} + +/** + * 获取所有系统配置 + */ +export const getAllSystemConfigs = () => { + return request.get('/system-config') +} + +/** + * 根据分组获取系统配置 + */ +export const getSystemConfigsByGroup = (group: string) => { + return request.get(`/system-config/group/${group}`) +} + +/** + * 获取所有系统配置的Map + */ +export const getAllSystemConfigsMap = () => { + return request.get>('/system-config/map') +} + +/** + * 根据分组获取系统配置Map + */ +export const getSystemConfigsMapByGroup = (group: string) => { + return request.get>(`/system-config/map/group/${group}`) +} + +/** + * 创建系统配置 + */ +export const createSystemConfig = (config: { + configKey: string + configValue: string + configGroup?: string + configName?: string + description?: string + valueType?: string + builtIn?: number + sortOrder?: number +}) => { + return request.post('/system-config', config) +} + +/** + * 更新系统配置 + */ +export const updateSystemConfig = (id: number, config: { + configValue?: string + configName?: string + description?: string + valueType?: string + sortOrder?: number +}) => { + return request.put(`/system-config/${id}`, config) +} + +/** + * 批量更新系统配置值 + */ +export const batchUpdateSystemConfigs = (configMap: Record) => { + return request.put('/system-config/batch', configMap) +} + +/** + * 删除系统配置 + */ +export const deleteSystemConfig = (id: number) => { + return request.delete(`/system-config/${id}`) +} + +/** + * 初始化默认配置 + */ +export const initDefaultConfigs = () => { + return request.post('/system-config/init') +} + diff --git a/frontend/src/api/task.ts b/frontend/src/api/task.ts new file mode 100644 index 0000000..0a3e088 --- /dev/null +++ b/frontend/src/api/task.ts @@ -0,0 +1,516 @@ +import request from '@/utils/request' + +export interface Task { + id?: number + taskNo?: string + title: string + taskTypeId: number + taskSubType?: string + quantity?: number + score?: number + estimatedHours?: number + brandId?: number + categoryId?: number + spuInfo?: string + platformId?: number + attachmentUrl?: string + deliveryPath?: string + publisherId?: number + publisherDeptId?: number + receiverId?: number + receiverDeptId?: number + receiverName?: string + receiverDeptName?: string + releaseType: string + status?: string + auditStatus?: string + auditorId?: number + auditorName?: string + auditTime?: string + auditComment?: string + progress?: number + startTime?: string + deadline?: string + completeTime?: string + rating?: number + reviewComment?: string + rejectReason?: string + isUrgent?: number + isDelayed?: number + createTime?: string + updateTime?: string + remark?: string + // 流水线任务字段 + isPipeline?: number + pipelineMode?: 'sequential' | 'parallel' + // 两级审核字段 + approvalStatus?: string // pending_demand_approval/pending_receiving_approval/approved/rejected_by_demand/rejected_by_receiving + currentApproverId?: number + currentApproverRole?: string // demand_leader/receiving_leader + currentApproverName?: string // 当前审核人姓名 + demandApprovalTime?: string + receivingApprovalTime?: string + // 关联字段(从后端查询返回) + brandName?: string + categoryName?: string + platformName?: string + publisherName?: string + publisherDeptName?: string +} + +// 任务子项(流水线任务) +export interface TaskItem { + id?: number + taskId: number + itemNo?: string + taskTypeId: number + taskTypeName?: string + quantity: number + unitScore: number + totalScore: number + estimatedHours?: number + receiverId?: number + receiverName?: string + status: string // locked/pending/in_progress/completed/rejected + progress?: number + isLocked?: number + prevItemId?: number + deliveryPath?: string + submitTime?: string + completeTime?: string + sortOrder: number + createTime?: string + updateTime?: string + // 主任务信息 + mainTaskTitle?: string + pipelineMode?: 'sequential' | 'parallel' + totalItems?: number + // 其他关联字段 + brandName?: string + categoryName?: string + platformName?: string + publisherName?: string + spuInfo?: string + taskSubType?: string + attachmentUrl?: string +} + +// 创建流水线任务请求 +export interface PipelineTaskRequest { + title: string + isPipeline: boolean + pipelineMode: 'sequential' | 'parallel' + items: Array<{ + taskTypeId: number + quantity: number + sortOrder: number + }> + brandId?: number + categoryId?: number + platformId?: number + spuInfo?: string + taskSubType?: string + attachmentUrl?: string + remark?: string + priority?: string +} + +export interface TaskAuditRequest { + taskId: number + result: 'pass' | 'reject' + comment?: string +} + +export interface TaskListParams { + status?: string + taskTypeId?: number + page?: number + pageSize?: number +} + +export interface TaskHallParams { + taskTypeId?: number + demandTypeId?: number + brandId?: number + categoryId?: number + platformId?: number + taskSubType?: string + minScore?: number + maxScore?: number + keyword?: string + publisherName?: string + timeRange?: 'today' | 'week' | 'month' + sortBy?: 'latest' | 'score' | 'deadline' | 'hot' + page?: number + pageSize?: number +} + +export interface MyTasksParams { + role?: 'publisher' | 'receiver' + status?: string + page?: number + pageSize?: number +} + +/** + * 创建任务 + */ +export const createTask = (data: Task) => { + return request.post('/tasks', data) +} + +/** + * 获取任务列表 + */ +export const getTaskList = (params: TaskListParams) => { + return request.get('/tasks', { params }) +} + +/** + * 获取任务详情 + */ +export const getTaskById = (id: number) => { + return request.get(`/tasks/${id}`) +} + +/** + * 更新任务 + */ +export const updateTask = (id: number, data: Partial) => { + return request.put(`/tasks/${id}`, data) +} + +/** + * 抢单 + */ +export const grabTask = (id: number) => { + return request.post(`/tasks/${id}/grab`) +} + +/** + * 提交任务 + */ +export const submitTask = (id: number, deliveryPath: string) => { + return request.post(`/tasks/${id}/submit`, null, { + params: { deliveryPath } + }) +} + +/** + * 审核任务 + */ +export const reviewTask = ( + id: number, + approved: boolean, + comment?: string, + rating?: number +) => { + return request.post(`/tasks/${id}/review`, null, { + params: { approved, comment, rating } + }) +} + +/** + * 取消任务 + */ +export const cancelTask = (id: number, reason: string) => { + return request.post(`/tasks/${id}/cancel`, null, { + params: { reason } + }) +} + +/** + * 获取任务大厅 + */ +export const getTaskHall = (params: TaskHallParams) => { + return request.get('/tasks/hall', { params }) +} + +/** + * 获取已完成任务列表(根据角色权限) + */ +export const getCompletedTasks = (params: any) => { + return request.get('/tasks/completed', { params }) +} + +/** + * 获取我的进行中任务列表 + */ +export const getMyInProgressTasks = () => { + return request.get('/tasks/my-in-progress') +} + +/** + * 批量更新任务进度 + */ +export const batchUpdateProgress = (updates: Array<{taskId: number, progress: number, remark?: string}>) => { + return request.post('/tasks/batch-update-progress', { updates }) +} + +/** + * 获取我的任务 + */ +export const getMyTasks = (params: MyTasksParams) => { + return request.get('/tasks/my', { params }) +} + +/** + * 提交任务审核 + */ +export const submitTaskAudit = (id: number) => { + return request.post(`/tasks/${id}/submit-audit`) +} + +/** + * 审核任务(部门负责人) + */ +export const auditTask = (data: TaskAuditRequest) => { + return request.post('/tasks/audit', data) +} + +/** + * 强制指派任务 + */ +export const assignTask = (taskId: number, assigneeId: number, comment?: string) => { + return request.post('/tasks/assign', { + taskId, + assigneeId, + assignType: 'force', + comment + }) +} + +/** + * 获取负责人任务列表(需求部门负责人) + */ +export const getLeaderTaskList = (params: { auditStatus?: string; page?: number; pageSize?: number }) => { + return request.get('/tasks/leader/demand', { params }) +} + +/** + * 获取接单部门负责人视图 + */ +export const getLeaderReceivingTaskList = (params: { status?: string; page?: number; pageSize?: number }) => { + return request.get('/tasks/leader/receiving', { params }) +} + +/** + * 工作台统计数据接口 + */ +export interface DashboardStats { + availableCount?: number + publishedCount?: number // 发布的任务数(当前用户当月发布的任务总数) + inProgressCount: number + completedCount: number + reviewCount: number + totalScore: number + todayStartedCount: number + todayCompletedCount: number + weekCompletedCount: number + lastWeekCompletedCount: number +} + +/** + * 获取工作台统计数据 + */ +export const getDashboardStats = (params?: { month?: string }) => { + return request.get('/tasks/dashboard/stats', { params }) +} + +/** + * 通用获取任务列表(支持状态筛选) + */ +export const getTasks = (params: any) => { + return request.get('/tasks', { params }) +} + +/** + * 更新任务进度 + */ +export const updateTaskProgress = (taskId: number, progress: number) => { + return request.post(`/tasks/${taskId}/progress`, { progress }) +} + +/** + * 完结任务(提交审核) + */ +export const completeTask = (taskId: number, data: { deliveryPath: string }) => { + return request.post(`/tasks/${taskId}/complete`, data) +} + +/** + * 审核通过任务 + */ +export const approveTask = (taskId: number) => { + return request.post(`/tasks/${taskId}/approve`) +} + +/** + * 驳回任务 + */ +export const rejectTask = (taskId: number, data: { reason: string }) => { + return request.post(`/tasks/${taskId}/reject`, data) +} + +// ==================== 流水线任务相关 API ==================== + +/** + * 创建流水线任务 + */ +export const createPipelineTask = (data: PipelineTaskRequest) => { + return request.post('/tasks/pipeline', data) +} + +/** + * 获取可接单的子任务列表 + */ +export const getAvailableTaskItems = (params: { + page?: number + pageSize?: number + taskTypeId?: number + keyword?: string +}) => { + return request.get('/tasks/items/available', { params }) +} + +/** + * 获取进行中的子任务列表 + */ +export const getInProgressTaskItems = (params: { + page?: number + pageSize?: number +}) => { + return request.get('/tasks/items/in-progress', { params }) +} + +/** + * 获取已完成的子任务列表 + */ +export const getCompletedTaskItems = (params: { + page?: number + pageSize?: number +}) => { + return request.get('/tasks/items/completed', { params }) +} + +/** + * 根据主任务ID获取所有子任务 + */ +export const getTaskItemsByTaskId = (taskId: number) => { + return request.get(`/tasks/items/task/${taskId}`) +} + +/** + * 获取子任务详情 + */ +export const getTaskItemById = (itemId: number) => { + return request.get(`/tasks/items/${itemId}`) +} + +/** + * 抢单子任务 + */ +export const grabTaskItem = (itemId: number) => { + return request.post(`/tasks/items/${itemId}/grab`) +} + +/** + * 更新子任务进度 + */ +export const updateTaskItemProgress = (itemId: number, progress: number) => { + return request.post(`/tasks/items/${itemId}/progress`, null, { + params: { progress } + }) +} + +/** + * 完结子任务 + */ +export const completeTaskItem = (itemId: number, data: { deliveryPath: string }) => { + return request.post(`/tasks/items/${itemId}/complete`, data) +} + +/** + * 驳回子任务 + */ +export const rejectTaskItem = (itemId: number, data: { reason: string }) => { + return request.post(`/tasks/items/${itemId}/reject`, data) +} + +/** + * 获取主任务进度统计 + */ +export const getTaskItemsProgress = (taskId: number) => { + return request.get(`/tasks/items/task/${taskId}/progress`) +} + +// ==================== 两级审核API ==================== + +/** + * 任务审核DTO + */ +export interface TaskApprovalDTO { + comment?: string // 审核意见 + taskUpdates?: TaskUpdateDTO // 任务修改(可选) +} + +/** + * 任务更新DTO + */ +export interface TaskUpdateDTO { + title?: string + taskSubType?: string + brandId?: number + platformId?: number + categoryId?: number + spuInfo?: string + remark?: string + attachmentUrl?: string + taskTypes?: Array<{ + taskTypeId: number + quantity: number + }> +} + +/** + * 获取待审核任务列表 + */ +export const getPendingApprovalTasks = () => { + return request.get('/tasks/pending-approval') +} + +/** + * 获取所有待审核任务(处于审批流程中的任务) + */ +export const getAllPendingApprovalTasks = (params?: { taskTypeId?: number; page?: number; pageSize?: number }) => { + return request.get('/tasks/pending-approval-all', { params }) +} + +/** + * 需求部门负责人审核通过 + */ +export const approveDemandLevel = (taskId: number, data: TaskApprovalDTO) => { + return request.post(`/tasks/${taskId}/approve-demand`, data) +} + +/** + * 需求部门负责人驳回 + */ +export const rejectByDemandLeader = (taskId: number, reason: string) => { + return request.post(`/tasks/${taskId}/reject-demand`, { reason }) +} + +/** + * 接单部门负责人审核通过 + */ +export const approveReceivingLevel = (taskId: number, data: TaskApprovalDTO) => { + return request.post(`/tasks/${taskId}/approve-receiving`, data) +} + +/** + * 接单部门负责人驳回 + */ +export const rejectByReceivingLeader = (taskId: number, reason: string) => { + return request.post(`/tasks/${taskId}/reject-receiving`, { reason }) +} + diff --git a/frontend/src/api/teamPK.ts b/frontend/src/api/teamPK.ts new file mode 100644 index 0000000..3cde509 --- /dev/null +++ b/frontend/src/api/teamPK.ts @@ -0,0 +1,114 @@ +import request from '@/utils/request' +import type { Result, PageResult } from '@/api/types' + +// 团队PK接口类型 +export interface TeamPK { + id: number + name: string + description: string + teamADeptId: number + teamAName: string + teamBDeptId: number + teamBName: string + startTime: string + endTime: string + dimensions: Record + teamAScore: number + teamBScore: number + teamATaskCount: number + teamBTaskCount: number + teamAFiveStarCount: number + teamBFiveStarCount: number + teamAOnTimeRate: number + teamBOnTimeRate: number + rewards: { winnerReward: number; loserReward: number; mvpReward?: number } + status: 'draft' | 'scheduled' | 'active' | 'ended' | 'cancelled' + winner?: 'A' | 'B' | 'draw' + mvpUserId?: number + creatorId: number + createTime: string + updateTime: string +} + +export interface TeamPKCreateRequest { + name: string + description?: string + teamADeptId: number + teamAName: string + teamBDeptId: number + teamBName: string + startTime: string + endTime: string + dimensions: string + rewards: string +} + +// ==================== 团队PK API ==================== + +/** + * 创建团队PK + */ +export function createTeamPK(data: TeamPKCreateRequest): Promise { + return request.post>('/team-pk', data) + .then(res => res.data) +} + +/** + * 发布团队PK + */ +export function publishTeamPK(id: number): Promise { + return request.post>(`/team-pk/${id}/publish`) + .then(res => res.data) +} + +/** + * 结束团队PK + */ +export function endTeamPK(id: number): Promise { + return request.post>(`/team-pk/${id}/end`) + .then(res => res.data) +} + +/** + * 取消团队PK + */ +export function cancelTeamPK(id: number): Promise { + return request.post>(`/team-pk/${id}/cancel`) + .then(res => res.data) +} + +/** + * 删除团队PK + */ +export function deleteTeamPK(id: number): Promise { + return request.delete>(`/team-pk/${id}`) + .then(res => res.data) +} + +/** + * 获取团队PK列表 + */ +export function getTeamPKList(params: { + page?: number + pageSize?: number + status?: string +}): Promise> { + return request.get>>('/team-pk', { params }) + .then(res => res.data) +} + +/** + * 获取团队PK详情 + */ +export function getTeamPKById(id: number): Promise { + return request.get>(`/team-pk/${id}`) + .then(res => res.data) +} + +/** + * 刷新PK数据 + */ +export function refreshTeamPK(id: number): Promise { + return request.post>(`/team-pk/${id}/refresh`) + .then(res => res.data) +} diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts new file mode 100644 index 0000000..4f8ef5a --- /dev/null +++ b/frontend/src/api/types.ts @@ -0,0 +1,29 @@ +// API 通用类型定义 + +/** + * API 响应结果 + */ +export interface Result { + code: number + message: string + data: T +} + +/** + * 分页结果 + */ +export interface PageResult { + records: T[] + total: number + size: number + current: number + pages: number +} + +/** + * 分页请求参数 + */ +export interface PageParams { + page?: number + pageSize?: number +} diff --git a/frontend/src/api/upload.ts b/frontend/src/api/upload.ts new file mode 100644 index 0000000..ca49b06 --- /dev/null +++ b/frontend/src/api/upload.ts @@ -0,0 +1,63 @@ +import axios from 'axios' +import { message } from 'ant-design-vue' + +const API_BASE = (import.meta as any).env?.VITE_API_BASE_URL || '/api' + +export interface UploadResponse { + fileName: string + filePath: string + fileUrl: string + fileSize: number + fileType: string +} + +/** + * 上传文件 + */ +export const uploadFile = (file: File, onProgress?: (progress: number) => void): Promise => { + const formData = new FormData() + formData.append('file', file) + + const token = localStorage.getItem('token') + + return new Promise((resolve, reject) => { + axios.post<{ code: number; message: string; data: UploadResponse }>( + `${API_BASE}/upload/file`, + formData, + { + headers: { + 'Content-Type': 'multipart/form-data', + 'Authorization': token ? `Bearer ${token}` : '' + }, + onUploadProgress: (progressEvent) => { + if (onProgress && progressEvent.total) { + const percent = Math.round((progressEvent.loaded * 100) / progressEvent.total) + onProgress(percent) + } + } + } + ) + .then(response => { + if (response.data.code === 200 && response.data.data) { + resolve(response.data.data) + } else { + message.error(response.data.message || '上传失败') + reject(new Error(response.data.message || '上传失败')) + } + }) + .catch(error => { + const errorMsg = error.response?.data?.message || error.message || '上传失败' + message.error(errorMsg) + reject(new Error(errorMsg)) + }) + }) +} + +/** + * 下载文件 + */ +export const downloadFile = (filePath: string) => { + const url = `${API_BASE}/upload/download?path=${encodeURIComponent(filePath)}` + window.open(url, '_blank') +} + diff --git a/frontend/src/api/user.ts b/frontend/src/api/user.ts new file mode 100644 index 0000000..23cda73 --- /dev/null +++ b/frontend/src/api/user.ts @@ -0,0 +1,141 @@ +import request from '@/utils/request'; + +// 用户相关类型定义 +export interface User { + id?: number; + username: string; + realName?: string; + phone?: string; + email?: string; + deptId?: number; + deptName?: string; + status?: number; + avatar?: string; + createTime?: string; + updateTime?: string; +} + +export interface PageResult { + records: T[]; + total: number; + size: number; + current: number; + pages: number; +} + +/** + * 分页查询用户列表 + */ +export const getUserList = async ( + pageNum: number = 1, + pageSize: number = 10, + keyword?: string, + deptId?: number, + status?: number +): Promise> => { + const params: any = { pageNum, pageSize }; + if (keyword) params.keyword = keyword; + if (deptId) params.deptId = deptId; + if (status !== undefined) params.status = status; + + const response = await request.get('/users', { params }); + return response.data; +}; + +/** + * 获取全部用户列表 + */ +export const getAllUsers = async (): Promise => { + const response = await request.get('/users/all'); + return response.data || []; +}; + +/** + * 根据ID获取用户 + */ +export const getUserById = async (id: number): Promise => { + const response = await request.get(`/users/${id}`); + return response.data; +}; + +/** + * 创建用户 + */ +export const createUser = async (user: User): Promise => { + const response = await request.post('/users', user); + return response.data; +}; + +/** + * 更新用户 + */ +export const updateUser = async (id: number, user: Partial): Promise => { + const response = await request.put(`/users/${id}`, user); + return response.data; +}; + +/** + * 删除用户 + */ +export const deleteUser = async (id: number): Promise => { + const response = await request.delete(`/users/${id}`); + return response.data; +}; + +/** + * 重置密码 + */ +export const resetPassword = async (id: number, newPassword: string = '123456'): Promise => { + const response = await request.post(`/users/${id}/reset-password`, { newPassword }); + return response.data; +}; + +/** + * 修改密码 + */ +export const changePassword = async ( + id: number, + oldPassword: string, + newPassword: string +): Promise => { + const response = await request.post(`/users/${id}/change-password`, { + oldPassword, + newPassword + }); + return response.data; +}; + +/** + * 为用户分配角色 + */ +export const assignUserRoles = async (userId: number, roleIds: number[]): Promise => { + const response = await request.post(`/users/${userId}/roles`, roleIds); + return response.data; +}; + +/** + * 获取用户的角色ID列表 + */ +export const getUserRoleIds = async (userId: number): Promise => { + const response = await request.get(`/users/${userId}/roles`); + return response.data; +}; + +/** + * 启用/禁用用户 + */ +export const toggleUserStatus = async (id: number, status: number): Promise => { + const response = await request.put(`/users/${id}/status`, null, { + params: { status } + }); + return response.data; +}; + +/** + * 调整用户所属部门 + */ +export const updateUserDepartment = async (id: number, deptId: number | null): Promise => { + const response = await request.put(`/users/${id}/department`, { deptId }); + return response.data; +}; + diff --git a/frontend/src/components/DailyProgressReminder.vue b/frontend/src/components/DailyProgressReminder.vue new file mode 100644 index 0000000..46c80ec --- /dev/null +++ b/frontend/src/components/DailyProgressReminder.vue @@ -0,0 +1,391 @@ + + + + + diff --git a/frontend/src/components/Icon.vue b/frontend/src/components/Icon.vue new file mode 100644 index 0000000..7fc1336 --- /dev/null +++ b/frontend/src/components/Icon.vue @@ -0,0 +1,219 @@ + + + + + + diff --git a/frontend/src/components/IncentiveModal.vue b/frontend/src/components/IncentiveModal.vue new file mode 100644 index 0000000..07a5636 --- /dev/null +++ b/frontend/src/components/IncentiveModal.vue @@ -0,0 +1,281 @@ + + + + + + diff --git a/frontend/src/components/MilestoneCard.vue b/frontend/src/components/MilestoneCard.vue new file mode 100644 index 0000000..25c20af --- /dev/null +++ b/frontend/src/components/MilestoneCard.vue @@ -0,0 +1,390 @@ + + + + + + diff --git a/frontend/src/components/Navbar.vue b/frontend/src/components/Navbar.vue new file mode 100644 index 0000000..b60a055 --- /dev/null +++ b/frontend/src/components/Navbar.vue @@ -0,0 +1,438 @@ + + + + + + diff --git a/frontend/src/components/NotificationBell.vue b/frontend/src/components/NotificationBell.vue new file mode 100644 index 0000000..85ead6d --- /dev/null +++ b/frontend/src/components/NotificationBell.vue @@ -0,0 +1,410 @@ + + + + + + diff --git a/frontend/src/components/RankingCard.vue b/frontend/src/components/RankingCard.vue new file mode 100644 index 0000000..e8c6a23 --- /dev/null +++ b/frontend/src/components/RankingCard.vue @@ -0,0 +1,293 @@ + + + + + + diff --git a/frontend/src/components/TaskCard.vue b/frontend/src/components/TaskCard.vue new file mode 100644 index 0000000..5569632 --- /dev/null +++ b/frontend/src/components/TaskCard.vue @@ -0,0 +1,442 @@ + + + + + diff --git a/frontend/src/layouts/DefaultLayout.vue b/frontend/src/layouts/DefaultLayout.vue new file mode 100644 index 0000000..f393d4d --- /dev/null +++ b/frontend/src/layouts/DefaultLayout.vue @@ -0,0 +1,25 @@ + + + + + + diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..d778cd0 --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,17 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import router from './router' +import App from './App.vue' + +// 引入 Ant Design Vue 样式 +import 'ant-design-vue/dist/reset.css' +// 引入全局样式 +import './styles/index.scss' + +const app = createApp(App) + +app.use(createPinia()) +app.use(router) + +app.mount('#app') + diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts new file mode 100644 index 0000000..a203ffe --- /dev/null +++ b/frontend/src/router/index.ts @@ -0,0 +1,248 @@ +import { createRouter, createWebHistory } from 'vue-router' +import type { RouteRecordRaw } from 'vue-router' +import { useAuthStore } from '@/stores/auth' + +const routes: RouteRecordRaw[] = [ + { + path: '/', + name: 'Home', + redirect: '/tasks' + }, + { + path: '/tasks', + name: 'TaskHall', + component: () => import('@/views/TaskHall.vue'), + meta: { + title: '任务大厅' + } + }, + { + path: '/tasks/create', + name: 'CreateTask', + component: () => import('@/views/CreateTask.vue'), + meta: { + title: '发起任务' + } + }, + { + path: '/my-tasks', + name: 'MyTasks', + component: () => import('@/views/MyTasks.vue'), + meta: { + title: '我的任务' + } + }, + { + path: '/leader-workbench', + name: 'LeaderWorkbench', + component: () => import('@/views/LeaderWorkbench.vue'), + meta: { + title: '负责人工作台' + } + }, + { + path: '/designer-hall', + name: 'DesignerHall', + component: () => import('@/views/DesignerHall.vue'), + meta: { + title: '设计师大厅' + } + }, + { + path: '/analytics', + name: 'Analytics', + component: () => import('@/views/Analytics.vue'), + meta: { + title: '数据分析' + } + }, + { + path: '/settings', + name: 'Settings', + component: () => import('@/views/Settings.vue'), + meta: { + title: '系统设置' + } + }, + { + path: '/users', + name: 'UserManagement', + component: () => import('@/views/UserManagement.vue'), + meta: { + title: '用户管理' + } + }, + { + path: '/roles', + name: 'RoleManagement', + component: () => import('@/views/RoleManagement.vue'), + meta: { + title: '角色管理' + } + }, + { + path: '/notifications', + name: 'Notifications', + component: () => import('@/views/Notifications.vue'), + meta: { + title: '消息中心' + } + }, + { + path: '/checkin', + name: 'CheckIn', + component: () => import('@/views/CheckIn.vue'), + meta: { + title: '每日签到' + } + }, + { + path: '/achievements', + name: 'Achievements', + component: () => import('@/views/Achievements.vue'), + meta: { + title: '成就中心' + } + }, + { + path: '/contests', + name: 'Contests', + component: () => import('@/views/Contests.vue'), + meta: { + title: '竞赛活动' + } + }, + { + path: '/goals', + name: 'Goals', + component: () => import('@/views/Goals.vue'), + meta: { + title: '我的目标' + } + }, + { + path: '/team-pk', + name: 'TeamPK', + component: () => import('@/views/TeamPK.vue'), + meta: { + title: '团队PK' + } + }, + { + path: '/incentive', + name: 'IncentiveCenter', + component: () => import('@/views/IncentiveCenter.vue'), + meta: { + title: '激励中心' + } + }, + { + path: '/admin/tasks', + name: 'AdminTaskManagement', + component: () => import('@/views/admin/TaskManagement.vue'), + meta: { + title: '任务调配管理' + } + }, + { + path: '/admin/reward-rules', + name: 'RewardRules', + component: () => import('@/views/admin/RewardRules.vue'), + meta: { + title: '自动奖励规则' + } + }, + { + path: '/admin/score-pool', + name: 'ScorePoolManagement', + component: () => import('@/views/admin/ScorePoolManagement.vue'), + meta: { + title: '激励积分池管理' + } + }, + { + path: '/login', + name: 'Login', + component: () => import('@/views/Login.vue'), + meta: { + title: '登录', + layout: 'blank' + } + }, + { + path: '/:pathMatch(.*)*', + name: 'NotFound', + component: () => import('@/views/NotFound.vue'), + meta: { + title: '页面未找到', + layout: 'blank' + } + } +] + +const router = createRouter({ + history: createWebHistory(import.meta.env.BASE_URL), + routes +}) + +// 路由守卫 +router.beforeEach(async (to, from, next) => { + // 设置页面标题 + document.title = `${to.meta.title || ''} - DesignHub` + + // 身份验证逻辑 + const authStore = useAuthStore() + const token = localStorage.getItem('token') + + // 如果访问登录页 + if (to.path === '/login') { + // 如果已登录,跳转到首页 + if (token && authStore.isLoggedIn) { + next('/') + return + } + next() + return + } + + // 如果未登录,跳转到登录页 + if (!token) { + next('/login') + return + } + + // 如果有token但authStore中没有用户信息,尝试恢复登录状态 + if (token && !authStore.userInfo) { + try { + // 恢复token到authStore + authStore.token = token + + // 尝试获取用户信息验证token有效性 + await authStore.fetchUserInfo() + + // 如果获取用户信息失败,说明token无效,清除并跳转登录页 + if (!authStore.userInfo) { + localStorage.removeItem('token') + localStorage.removeItem('userInfo') + authStore.token = '' + next('/login') + return + } + } catch (error) { + // 如果获取用户信息失败,清除token并跳转登录页 + console.error('验证登录状态失败:', error) + localStorage.removeItem('token') + localStorage.removeItem('userInfo') + authStore.token = '' + authStore.userInfo = null + next('/login') + return + } + } + + // 确保异步操作完成后再继续导航 + next() +}) + +export default router + diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts new file mode 100644 index 0000000..78baec2 --- /dev/null +++ b/frontend/src/stores/auth.ts @@ -0,0 +1,167 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import * as authApi from '@/api/auth' +import type { UserInfo } from '@/api/auth' +import { message } from 'ant-design-vue' +import { useRouter } from 'vue-router' + +export const useAuthStore = defineStore('auth', () => { + const router = useRouter() + + // State + const token = ref(localStorage.getItem('token') || '') + + // 安全地解析 userInfo + const getUserInfoFromStorage = (): UserInfo | null => { + try { + const stored = localStorage.getItem('userInfo') + if (!stored || stored === 'undefined' || stored === 'null') { + return null + } + return JSON.parse(stored) + } catch (error) { + console.error('解析 userInfo 失败:', error) + return null + } + } + + const userInfo = ref(getUserInfoFromStorage()) + + // Getters + const isLoggedIn = computed(() => !!token.value) + const userName = computed(() => userInfo.value?.realName || userInfo.value?.username || '') + const userRoles = computed(() => userInfo.value?.roles || []) + + // Actions + /** + * 登录 + */ + const login = async (username: string, password: string) => { + try { + const response = await authApi.login({ + username, + password, + loginType: 'password' + }) + + const loginData = response.data + + // 保存 token + const accessToken = loginData.token || loginData.accessToken + token.value = accessToken + localStorage.setItem('token', accessToken) + + // 登录成功后立即获取完整用户信息(包括deptId) + try { + await fetchUserInfo() + } catch (error) { + console.error('获取用户信息失败:', error) + // 即使获取失败也不影响登录,使用基本信息 + const userInfoData = { + id: loginData.userId, + username: loginData.username, + realName: loginData.realName, + avatar: loginData.avatar, + email: '', + phone: '', + deptId: null, + deptName: '', + roles: loginData.roles || [], + permissions: [] + } + userInfo.value = userInfoData + localStorage.setItem('userInfo', JSON.stringify(userInfoData)) + } + + message.success('登录成功') + return true + } catch (error: any) { + console.error('登录失败:', error) + message.error(error.message || '登录失败') + return false + } + } + + /** + * 退出登录 + */ + const logout = async () => { + try { + await authApi.logout() + } catch (error) { + console.error('退出登录失败:', error) + } finally { + // 清除本地数据 + token.value = '' + userInfo.value = null + localStorage.removeItem('token') + localStorage.removeItem('userInfo') + + // 跳转到登录页 + router.push('/login') + message.success('已退出登录') + } + } + + /** + * 获取用户信息 + */ + const fetchUserInfo = async () => { + try { + const response = await authApi.getUserInfo() + const userData = response.data + userInfo.value = userData + localStorage.setItem('userInfo', JSON.stringify(userData)) + return userData + } catch (error) { + console.error('获取用户信息失败:', error) + // 如果获取用户信息失败,清除登录状态 + token.value = '' + userInfo.value = null + localStorage.removeItem('token') + localStorage.removeItem('userInfo') + throw error + } + } + + /** + * 刷新Token + */ + const refreshToken = async () => { + try { + const response = await authApi.refreshToken() + if (response.data.code === 200) { + const loginData = response.data.data + // 后端返回的是 token,不是 accessToken + const accessToken = loginData.token || loginData.accessToken + token.value = accessToken + localStorage.setItem('token', accessToken) + + // 如果返回了用户信息,也更新 + if (loginData.userInfo) { + userInfo.value = loginData.userInfo + localStorage.setItem('userInfo', JSON.stringify(loginData.userInfo)) + } + + return true + } + return false + } catch (error) { + console.error('刷新Token失败:', error) + return false + } + } + + return { + token, + userInfo, + isLoggedIn, + userName, + userRoles, + login, + logout, + fetchUserInfo, + refreshToken + } +}) + diff --git a/frontend/src/stores/config.ts b/frontend/src/stores/config.ts new file mode 100644 index 0000000..fb7b68f --- /dev/null +++ b/frontend/src/stores/config.ts @@ -0,0 +1,132 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import * as configApi from '@/api/config' +import type { DemandType, Brand, Platform, Category } from '@/api/config' + +/** + * 全局配置数据Store + * 用于管理需求类型、品牌、平台、类目等配置数据 + * 确保数据在整个项目中同步 + */ +export const useConfigStore = defineStore('config', () => { + // 需求类型(只存储启用的,用于下拉选择) + const demandTypes = ref([]) + + // 品牌(只存储启用的) + const brands = ref([]) + + // 平台(只存储启用的) + const platforms = ref([]) + + // 类目(只存储叶子类目,用于下拉选择) + const categories = ref([]) + + /** + * 加载启用的需求类型 + */ + const loadDemandTypes = async () => { + try { + const response = await configApi.getEnabledDemandTypes() + if (response && response.data) { + demandTypes.value = response.data + } + } catch (error) { + console.error('加载需求类型失败:', error) + } + } + + /** + * 加载启用的品牌 + */ + const loadBrands = async () => { + try { + const response = await configApi.getEnabledBrands() + if (response && response.data) { + brands.value = response.data + } + } catch (error) { + console.error('加载品牌失败:', error) + } + } + + /** + * 加载启用的平台 + */ + const loadPlatforms = async () => { + try { + const response = await configApi.getEnabledPlatforms() + if (response && response.data) { + platforms.value = response.data + } + } catch (error) { + console.error('加载平台失败:', error) + } + } + + /** + * 加载启用的叶子类目 + */ + const loadCategories = async () => { + try { + const response = await configApi.getEnabledLeafCategories() + if (response && response.data) { + categories.value = response.data + } + } catch (error) { + console.error('加载类目失败:', error) + } + } + + /** + * 加载所有配置数据 + */ + const loadAllConfigs = async () => { + await Promise.all([ + loadDemandTypes(), + loadBrands(), + loadPlatforms(), + loadCategories() + ]) + } + + /** + * 刷新配置数据(当系统设置页面更新后调用) + */ + const refreshConfigs = async (type?: 'demandType' | 'brand' | 'platform' | 'category') => { + switch (type) { + case 'demandType': + await loadDemandTypes() + break + case 'brand': + await loadBrands() + break + case 'platform': + await loadPlatforms() + break + case 'category': + await loadCategories() + break + default: + await loadAllConfigs() + } + + // 触发自定义事件,通知其他组件更新 + window.dispatchEvent(new CustomEvent('configUpdated', { + detail: { type } + })) + } + + return { + demandTypes, + brands, + platforms, + categories, + loadDemandTypes, + loadBrands, + loadPlatforms, + loadCategories, + loadAllConfigs, + refreshConfigs + } +}) + diff --git a/frontend/src/stores/navbarConfig.ts b/frontend/src/stores/navbarConfig.ts new file mode 100644 index 0000000..790ef35 --- /dev/null +++ b/frontend/src/stores/navbarConfig.ts @@ -0,0 +1,144 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import request from '@/utils/request' + +/** + * 导航栏配置数据类型 + */ +export interface NavbarConfig { + // 主菜单显示配置 + menuItems: { + tasks: boolean + designer_hall: boolean + incentive: boolean + analytics: boolean + } + // 功能按钮显示配置 + actions: { + notification: boolean + checkin: boolean + achievement: boolean + settings: boolean + } +} + +/** + * 导航栏配置Store + * 用于管理导航栏菜单项和功能按钮的显示/隐藏 + */ +export const useNavbarConfigStore = defineStore('navbarConfig', () => { + // 导航栏配置 + const config = ref({ + menuItems: { + tasks: true, + designer_hall: true, + incentive: true, + analytics: true + }, + actions: { + notification: true, + checkin: true, + achievement: true, + settings: true + } + }) + + /** + * 从后端加载导航栏配置 + */ + const loadConfig = async () => { + try { + const response = await request.get>('/system-config/map/group/navbar') + if (response.data && Object.keys(response.data).length > 0) { + const data = response.data + + // 解析主菜单配置(如果没有配置则默认为 true) + config.value.menuItems = { + tasks: data['navbar.menu.tasks'] !== 'false', + designer_hall: data['navbar.menu.designer_hall'] !== 'false', + incentive: data['navbar.menu.incentive'] !== 'false', + analytics: data['navbar.menu.analytics'] !== 'false' + } + + // 解析功能按钮配置(如果没有配置则默认为 true) + config.value.actions = { + notification: data['navbar.action.notification'] !== 'false', + checkin: data['navbar.action.checkin'] !== 'false', + achievement: data['navbar.action.achievement'] !== 'false', + settings: data['navbar.action.settings'] !== 'false' + } + + console.log('[NavbarConfig] 配置加载成功:', config.value) + } else { + console.log('[NavbarConfig] 数据库中没有配置,使用默认配置(全部显示)') + // 数据库中没有配置,使用默认配置(全部显示) + resetToDefault() + } + } catch (error) { + console.error('[NavbarConfig] 加载配置失败:', error) + // 失败时使用默认配置(全部显示) + resetToDefault() + } + } + + /** + * 保存导航栏配置到后端 + */ + const saveConfig = async (newConfig: NavbarConfig) => { + try { + // 将配置转换为 key-value 格式 + const configMap: Record = { + 'navbar.menu.tasks': String(newConfig.menuItems.tasks), + 'navbar.menu.designer_hall': String(newConfig.menuItems.designer_hall), + 'navbar.menu.incentive': String(newConfig.menuItems.incentive), + 'navbar.menu.analytics': String(newConfig.menuItems.analytics), + 'navbar.action.notification': String(newConfig.actions.notification), + 'navbar.action.checkin': String(newConfig.actions.checkin), + 'navbar.action.achievement': String(newConfig.actions.achievement), + 'navbar.action.settings': String(newConfig.actions.settings) + } + + await request.put('/system-config/batch', configMap) + + // 保存成功后更新本地配置 + config.value = { ...newConfig } + + console.log('[NavbarConfig] 配置保存成功') + + // 触发自定义事件,通知导航栏组件更新 + window.dispatchEvent(new CustomEvent('navbarConfigUpdated')) + + return true + } catch (error) { + console.error('[NavbarConfig] 保存配置失败:', error) + throw error + } + } + + /** + * 重置为默认配置(全部显示) + */ + const resetToDefault = () => { + config.value = { + menuItems: { + tasks: true, + designer_hall: true, + incentive: true, + analytics: true + }, + actions: { + notification: true, + checkin: true, + achievement: true, + settings: true + } + } + } + + return { + config, + loadConfig, + saveConfig, + resetToDefault + } +}) diff --git a/frontend/src/stores/siteSettings.ts b/frontend/src/stores/siteSettings.ts new file mode 100644 index 0000000..cb5c920 --- /dev/null +++ b/frontend/src/stores/siteSettings.ts @@ -0,0 +1,126 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { getSystemConfigsByGroup } from '@/api/system' + +export const useSiteSettingsStore = defineStore('siteSettings', () => { + const siteName = ref('DesignHub') + const logoUrl = ref('') + + // 加载网站设置 + const loadSiteSettings = async () => { + try { + const response = await getSystemConfigsByGroup('site') + const configs = response.data || [] + + const siteNameConfig = configs.find((c: any) => c.configKey === 'site.name') + const logoConfig = configs.find((c: any) => c.configKey === 'site.logo') + + if (siteNameConfig) { + siteName.value = siteNameConfig.configValue || 'DesignHub' + } else { + siteName.value = localStorage.getItem('siteName') || 'DesignHub' + } + + if (logoConfig) { + logoUrl.value = logoConfig.configValue || '' + } else { + logoUrl.value = localStorage.getItem('logoUrl') || '' + } + + // 更新浏览器标题和favicon + updateBrowserInfo() + } catch (error) { + console.error('加载网站设置失败:', error) + // 如果API失败,从localStorage读取(向后兼容) + siteName.value = localStorage.getItem('siteName') || 'DesignHub' + logoUrl.value = localStorage.getItem('logoUrl') || '' + updateBrowserInfo() + } + } + + // 更新网站设置 + const updateSiteSettings = (newSiteName: string, newLogoUrl: string) => { + siteName.value = newSiteName || 'DesignHub' + logoUrl.value = newLogoUrl || '' + + // 保存到localStorage + localStorage.setItem('siteName', siteName.value) + if (logoUrl.value) { + localStorage.setItem('logoUrl', logoUrl.value) + } else { + localStorage.removeItem('logoUrl') + } + + // 更新浏览器标题和favicon + updateBrowserInfo() + + // 触发自定义事件,通知其他组件更新 + window.dispatchEvent(new CustomEvent('siteSettingsUpdated', { + detail: { + siteName: siteName.value, + logoUrl: logoUrl.value + } + })) + } + + // 更新浏览器标题和favicon + const updateBrowserInfo = () => { + // 更新浏览器标题 + document.title = siteName.value || 'DesignHub' + + // 更新favicon(如果有logo) + if (logoUrl.value) { + // 移除所有现有的favicon链接 + const existingLinks = document.querySelectorAll("link[rel*='icon']") + existingLinks.forEach(link => link.remove()) + + // 创建新的favicon链接 + const link = document.createElement('link') + link.rel = 'icon' + + // 处理base64和普通URL + if (logoUrl.value.startsWith('data:')) { + // base64格式 + const mimeMatch = logoUrl.value.match(/data:([^;]+)/) + link.type = mimeMatch?.[1] || 'image/png' + link.href = logoUrl.value + } else { + // 普通URL,添加时间戳强制刷新 + link.type = 'image/x-icon' + link.href = `${logoUrl.value}?t=${Date.now()}` + } + + // 添加到head + document.getElementsByTagName('head')[0].appendChild(link) + + // 同时添加apple-touch-icon(移动端支持) + const appleLink = document.createElement('link') + appleLink.rel = 'apple-touch-icon' + if (logoUrl.value.startsWith('data:')) { + appleLink.href = logoUrl.value + } else { + appleLink.href = `${logoUrl.value}?t=${Date.now()}` + } + document.getElementsByTagName('head')[0].appendChild(appleLink) + } else { + // 如果没有logo,移除所有favicon链接 + const existingLinks = document.querySelectorAll("link[rel*='icon']") + existingLinks.forEach(link => { + // 保留默认的vite.svg favicon(如果存在) + const href = (link as HTMLLinkElement).href + if (!href.includes('vite.svg')) { + link.remove() + } + }) + } + } + + return { + siteName, + logoUrl, + loadSiteSettings, + updateSiteSettings, + updateBrowserInfo + } +}) + diff --git a/frontend/src/styles/index.scss b/frontend/src/styles/index.scss new file mode 100644 index 0000000..4aabfe3 --- /dev/null +++ b/frontend/src/styles/index.scss @@ -0,0 +1,1440 @@ +/** + * 全局样式入口 + * Cursor 风格设计系统 + * 参考 Cursor 官方网站 (cursor.com) 的设计规范 + */ + +@import './variables.scss'; + +// ===== CSS 变量(用于 JavaScript 访问)===== +:root { + // 背景色 + --bg-primary: #{$bg-primary}; + --bg-secondary: #{$bg-secondary}; + --bg-tertiary: #{$bg-tertiary}; + --bg-card: #{$bg-card}; + --bg-card-hover: #{$bg-card-hover}; + --bg-elevated: #{$bg-elevated}; + + // 文字色 + --text-primary: #{$text-primary}; + --text-secondary: #{$text-secondary}; + --text-tertiary: #{$text-tertiary}; + --text-disabled: #{$text-disabled}; + + // 品牌色 + --brand-primary: #{$brand-primary}; + --brand-hover: #{$brand-hover}; + --brand-pressed: #{$brand-pressed}; + + // 功能色 + --color-success: #{$color-success}; + --color-warning: #{$color-warning}; + --color-error: #{$color-error}; + --color-info: #{$color-info}; + + // 边框色 + --border-primary: #{$border-primary}; + --border-secondary: #{$border-secondary}; + --border-divider: #{$border-divider}; + + // 间距 + --space-sm: #{$space-sm}; + --space-md: #{$space-md}; + --space-lg: #{$space-lg}; + --space-xl: #{$space-xl}; + + // 圆角 + --radius-small: #{$radius-small}; + --radius-medium: #{$radius-medium}; + --radius-large: #{$radius-large}; + + // 过渡 + --transition-fast: #{$transition-fast}; + --transition-base: #{$transition-base}; + --transition-slow: #{$transition-slow}; + --ease-out: #{$ease-out}; + + // 布局 + --navbar-height: #{$navbar-height}; + --container-max-width: #{$container-max-width}; +} + +// ===== 全局重置 ===== +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + font-size: 16px; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; +} + +body { + margin: 0; + font-family: $font-family-zh; + font-size: $font-size-base; + line-height: 1.5; + color: $text-primary; + background: $bg-primary; + overflow-x: hidden; +} + +// ===== 链接样式 ===== +a { + color: $brand-primary; + text-decoration: none; + transition: color $transition-base $ease-out; + + &:hover { + color: $brand-hover; + } +} + +// ===== 滚动条样式(Cursor 风格:更细腻的滚动条)===== +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: $border-primary; + border-radius: $radius-full; + + &:hover { + background: $border-color; + } +} + +// ===== 通用工具类 ===== + +// 容器 +.container { + max-width: $container-max-width; + margin: 0 auto; + padding: 0 $space-xl; + + @media (max-width: $breakpoint-tablet) { + padding: 0 $space-lg; + } + + @media (max-width: $breakpoint-mobile) { + padding: 0 $space-md; + } +} + +// 卡片(Cursor 风格:更简洁的卡片) +.card { + background: $bg-card; + border: 1px solid $border-primary; + border-radius: $radius-md; + padding: $space-lg; + transition: all $transition-base $ease-out; + + &:hover { + border-color: $border-color; + background: $bg-card-hover; + box-shadow: $shadow-base; + } +} + +// 按钮样式(Cursor 风格:更柔和的按钮) +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: $space-sm; + padding: 10px 20px; + font-size: $font-size-base; + font-weight: $font-weight-medium; + line-height: 1.5; + border-radius: $radius-md; + border: none; + cursor: pointer; + transition: all $transition-base $ease-out; + white-space: nowrap; + user-select: none; + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +.btn-primary { + background: $brand-primary; + color: #000000 !important; // 黑色文字 + + &:hover:not(:disabled) { + background: $brand-hover; + color: #000000 !important; // 黑色文字 + box-shadow: 0 2px 8px rgba($brand-primary, 0.25); + } + + &:active:not(:disabled) { + background: $brand-pressed; + color: #000000 !important; // 黑色文字 + transform: scale(0.98); + } + + // 确保按钮内的所有文字和图标都是黑色 + span, + .anticon { + color: #000000 !important; + } +} + +.btn-secondary { + background: $bg-tertiary; + color: $text-primary; + border: 1px solid $border-primary; + + &:hover:not(:disabled) { + background: $bg-card; + border-color: $border-color; + color: $text-primary; + } +} + +.btn-text { + background: transparent; + color: $text-secondary; + padding: $space-sm $space-md; + + &:hover:not(:disabled) { + color: $brand-primary; + } +} + +// 输入框(Cursor 风格:更柔和的输入框) +.input { + width: 100%; + padding: 10px 16px; + font-size: $font-size-base; + line-height: 1.5; + color: $text-primary; + background: $bg-tertiary; + border: 1px solid $border-primary; + border-radius: $radius-md; + transition: all $transition-base $ease-out; + + &:focus { + outline: none; + border-color: $brand-primary; + background: $bg-card; + box-shadow: 0 0 0 2px rgba($brand-primary, 0.08); + } + + &::placeholder { + color: $text-tertiary; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +// ===== 全局 Ant Design 输入框样式统一 ===== +// 统一所有页面的输入框样式(与登录页面一致) + +// 基础输入框 +.ant-input, +.ant-input-affix-wrapper { + background: $bg-secondary !important; + border: 1px solid $border-color !important; + border-radius: $radius-md !important; + color: $text-white !important; + font-size: $font-size-base; + transition: all 0.2s ease; + height: 48px !important; + line-height: 48px !important; + padding: 0 12px !important; + box-sizing: border-box; + + &::placeholder { + color: $text-tertiary !important; + line-height: normal; + } + + &:hover { + background: $bg-secondary !important; + border-color: rgba($brand-primary, 0.5) !important; + color: $text-white !important; + } + + &:focus, + &.ant-input-focused, + &:focus-visible { + background: $bg-secondary !important; + border-color: $brand-primary !important; + box-shadow: 0 0 0 2px rgba($brand-primary, 0.1) !important; + color: $text-white !important; + outline: none !important; + } + + &:disabled { + background: $bg-tertiary !important; + border-color: $border-primary !important; + color: $text-disabled !important; + cursor: not-allowed; + } +} + +// 输入框大小变体 +.ant-input-sm { + height: 32px !important; + line-height: 32px !important; + font-size: $font-size-sm !important; + padding: 0 8px !important; +} + +.ant-input-lg { + height: 48px !important; + line-height: 48px !important; + font-size: $font-size-base !important; + padding: 0 12px !important; +} + +// 搜索框 +.ant-input-search { + .ant-input { + border-radius: $radius-md 0 0 $radius-md !important; + } + + .ant-input-search-button { + background: $bg-secondary !important; + border: 1px solid $border-color !important; + border-left: none !important; + color: $text-white !important; + height: 48px !important; + border-radius: 0 $radius-md $radius-md 0 !important; + + &:hover { + background: $bg-tertiary !important; + border-color: rgba($brand-primary, 0.5) !important; + color: $text-white !important; + } + } + + &:hover .ant-input, + &.ant-input-search-focused .ant-input { + border-color: rgba($brand-primary, 0.5) !important; + } + + &.ant-input-search-focused .ant-input { + border-color: $brand-primary !important; + box-shadow: 0 0 0 2px rgba($brand-primary, 0.1) !important; + } +} + +// 密码输入框 +.ant-input-password { + background: $bg-secondary !important; + border: 1px solid $border-color !important; + border-radius: $radius-md !important; + height: 48px !important; + width: 100% !important; + box-shadow: none !important; + transition: all 0.2s ease; + padding: 0 !important; + display: flex; + align-items: center; + + .ant-input { + background: transparent !important; + border: none !important; + box-shadow: none !important; + color: $text-white !important; + height: 48px; + line-height: 48px; + font-size: $font-size-base; + padding: 0 40px 0 12px !important; + text-align: left; + flex: 1; + width: 100%; + box-sizing: border-box; + + &::placeholder { + color: $text-tertiary !important; + line-height: normal; + } + + &:hover { + background: transparent !important; + color: $text-white !important; + } + + &:focus, + &:focus-visible { + background: transparent !important; + color: $text-white !important; + outline: none !important; + box-shadow: none !important; + } + } + + .ant-input-password-icon { + color: $text-secondary; + right: 12px; + + &:hover { + color: $text-primary; + } + } + + &:hover { + background: $bg-secondary !important; + border-color: rgba($brand-primary, 0.5) !important; + } + + &.ant-input-password-focused, + &:focus, + &:focus-visible { + background: $bg-secondary !important; + border-color: $brand-primary !important; + box-shadow: 0 0 0 2px rgba($brand-primary, 0.1) !important; + outline: none !important; + } +} + +// 下拉选择框 +.ant-select { + .ant-select-selector { + background: $bg-secondary !important; + border: 1px solid $border-color !important; + border-radius: $radius-md !important; + color: $text-white !important; + height: 48px !important; + transition: all 0.2s ease; + + .ant-select-selection-item, + .ant-select-selection-placeholder { + color: $text-white !important; + line-height: 46px !important; + } + + .ant-select-selection-placeholder { + color: $text-tertiary !important; + } + } + + &:hover .ant-select-selector { + border-color: rgba($brand-primary, 0.5) !important; + } + + &.ant-select-focused .ant-select-selector { + border-color: $brand-primary !important; + box-shadow: 0 0 0 2px rgba($brand-primary, 0.1) !important; + } + + .ant-select-arrow { + color: $text-secondary !important; + } + + // 大小变体 + &.ant-select-sm .ant-select-selector { + height: 32px !important; + + .ant-select-selection-item, + .ant-select-selection-placeholder { + line-height: 30px !important; + } + } + + &.ant-select-lg .ant-select-selector { + height: 48px !important; + + .ant-select-selection-item, + .ant-select-selection-placeholder { + line-height: 46px !important; + } + } +} + +// 下拉菜单样式 +.ant-select-dropdown { + background: $bg-card !important; + border: 1px solid $border-color !important; + border-radius: $radius-md !important; + box-shadow: $shadow-elevated !important; + + .ant-select-item { + color: $text-white !important; + background: transparent !important; + + &:hover { + background: $bg-tertiary !important; + } + + &.ant-select-item-option-selected { + background: rgba($brand-primary, 0.1) !important; + color: $brand-primary !important; + } + + &.ant-select-item-option-active { + background: $bg-tertiary !important; + } + } +} + +// 文本域(Textarea) +.ant-textarea, +textarea.ant-input { + background: $bg-secondary !important; + border: 1px solid $border-color !important; + border-radius: $radius-md !important; + color: $text-white !important; + font-size: $font-size-base !important; + transition: all 0.2s ease !important; + min-height: auto !important; + padding: 12px !important; + line-height: 1.5 !important; + height: auto !important; + resize: vertical !important; + + &::placeholder { + color: $text-tertiary !important; + } + + &:hover { + background: $bg-secondary !important; + border-color: rgba($brand-primary, 0.5) !important; + } + + &:focus, + &:focus-visible { + background: $bg-secondary !important; + border-color: $brand-primary !important; + box-shadow: 0 0 0 2px rgba($brand-primary, 0.1) !important; + outline: none !important; + } +} + +// Textarea 包装器 +.ant-input-textarea { + .ant-input { + background: $bg-secondary !important; + border: 1px solid $border-color !important; + + &:hover { + border-color: rgba($brand-primary, 0.5) !important; + } + + &:focus { + border-color: $brand-primary !important; + box-shadow: 0 0 0 2px rgba($brand-primary, 0.1) !important; + } + } +} + +// 数字输入框 +.ant-input-number { + width: 100%; + background: $bg-secondary !important; + border: 1px solid $border-color !important; + border-radius: $radius-md !important; + height: 48px !important; + + .ant-input-number-input { + background: transparent !important; + border: none !important; + color: $text-white !important; + height: 46px !important; + line-height: 46px !important; + padding: 0 12px !important; + + &::placeholder { + color: $text-tertiary !important; + } + } + + &:hover { + border-color: rgba($brand-primary, 0.5) !important; + } + + &.ant-input-number-focused { + border-color: $brand-primary !important; + box-shadow: 0 0 0 2px rgba($brand-primary, 0.1) !important; + } +} + +// ===== 全局 Ant Design 按钮样式统一 ===== +// 统一所有页面的主要按钮(绿色按钮)文字颜色为黑色 + +// 确保所有 primary 按钮都是绿色(覆盖 Ant Design 默认蓝色) +.ant-btn-primary, +.ant-btn[type="primary"], +button.ant-btn-primary, +a.ant-btn-primary { + background: $brand-primary !important; + border-color: $brand-primary !important; + color: #000000 !important; // 黑色文字 + + &:hover:not(:disabled), + &:hover:not(.ant-btn-loading) { + background: $brand-hover !important; + border-color: $brand-hover !important; + color: #000000 !important; // 黑色文字 + } + + &:active:not(:disabled), + &:focus:not(:disabled) { + background: $brand-pressed !important; + border-color: $brand-pressed !important; + color: #000000 !important; // 黑色文字 + } + + &:disabled, + &.ant-btn-loading { + background: $bg-tertiary !important; + border-color: $bg-tertiary !important; + color: $text-disabled !important; + opacity: 0.5; + } + + // 确保按钮内的所有文字和图标都是黑色 + span, + .anticon, + .ant-btn-loading-icon, + .anticon-loading { + color: #000000 !important; + } + + // 确保加载状态下的图标也是黑色 + &.ant-btn-loading { + .anticon-loading { + color: #000000 !important; + } + } +} + +// 确保登录页面的按钮也是黑色文字 +.login-button { + color: #000000 !important; + + span, + .anticon { + color: #000000 !important; + } +} + +// 单选按钮组(Radio Button Group)- 现代化独立按钮设计 +.ant-radio-group { + display: flex; + gap: 8px; // 按钮之间的间距 + align-items: center; // 垂直居中对齐 + + .ant-radio-button-wrapper { + background: $bg-tertiary !important; + border: 1px solid transparent !important; // 默认透明边框 + border-radius: 8px !important; // 圆角 + color: $text-secondary !important; + padding: 6px 16px !important; // 增加垂直padding + font-size: 13px !important; + line-height: 20px !important; // 固定行高,确保文字居中 + height: 32px !important; // 固定高度 + display: inline-flex !important; // 使用flex布局 + align-items: center !important; // 垂直居中 + justify-content: center !important; // 水平居中 + transition: all 0.2s ease !important; + margin: 0 !important; // 移除默认 margin + + // 移除默认的连接样式 + &::before { + display: none !important; // 隐藏分隔线 + } + + &:first-child { + border-radius: 8px !important; // 确保第一个按钮也是圆角 + } + + &:last-child { + border-radius: 8px !important; // 确保最后一个按钮也是圆角 + } + + &:hover:not(.ant-radio-button-wrapper-checked) { + background: rgba($brand-primary, 0.05) !important; + border-color: rgba($brand-primary, 0.3) !important; + color: $brand-primary !important; + transform: translateY(-1px); + } + + &.ant-radio-button-wrapper-checked { + background: rgba($brand-primary, 0.12) !important; + border-color: $brand-primary !important; + color: $brand-primary !important; + font-weight: 500 !important; + box-shadow: 0 0 0 2px rgba($brand-primary, 0.1) !important; + + &:hover { + background: rgba($brand-primary, 0.15) !important; + border-color: $brand-primary !important; + color: $brand-primary !important; + } + + &:active { + background: rgba($brand-primary, 0.2) !important; + border-color: $brand-primary !important; + color: $brand-primary !important; + } + } + + &:focus-within { + box-shadow: 0 0 0 2px rgba($brand-primary, 0.1) !important; + } + + // 确保内部文字也垂直居中 + > span { + display: inline-flex; + align-items: center; + line-height: 20px; + } + } +} + +// 普通单选按钮(Radio) +.ant-radio-wrapper { + color: $text-primary !important; + + .ant-radio { + .ant-radio-inner { + background: $bg-secondary !important; + border-color: $border-primary !important; + } + + &:hover .ant-radio-inner { + border-color: $brand-primary !important; + } + + &.ant-radio-checked .ant-radio-inner { + background: $brand-primary !important; + border-color: $brand-primary !important; + + &::after { + background: #000000 !important; // 选中时内部圆点为黑色 + } + } + } +} + +// Switch 开关 - 全局统一样式 +.ant-switch { + background: $bg-tertiary !important; + border-color: $border-primary !important; + color: $text-primary !important; + + // 未选中状态的文字 + .ant-switch-inner { + color: $text-primary !important; + } + + &.ant-switch-checked { + background: $brand-primary !important; + border-color: $brand-primary !important; + + // 选中状态的文字(checked-children)必须是黑色 + .ant-switch-inner { + color: #000000 !important; + } + + // 选中状态的文字内容也要是黑色 + .ant-switch-inner-checked { + color: #000000 !important; + } + } + + .ant-switch-handle { + &::before { + background: $text-primary !important; + } + } + + &.ant-switch-checked .ant-switch-handle { + &::before { + background: #000000 !important; // 选中时手柄为黑色 + } + } +} + +// 额外确保所有选中的 Switch 文字都是黑色(最高优先级) +.ant-switch.ant-switch-checked { + .ant-switch-inner, + .ant-switch-inner-checked { + color: #000000 !important; + } +} + +// 日期选择器 - 选中日期的文字必须是黑色 +.ant-picker-cell-selected { + .ant-picker-cell-inner { + background: $brand-primary !important; + color: #000000 !important; // 选中日期文字为黑色 + } +} + +// 日期选择器下拉面板中的选中日期 +.ant-picker-dropdown { + .ant-picker-cell-selected .ant-picker-cell-inner { + background: $brand-primary !important; + color: #000000 !important; // 选中日期文字为黑色 + } +} + +// Tag 标签 - 绿色 Tag 的文字必须是黑色 +.ant-tag-green, +.ant-tag[class*="green"] { + background: $brand-primary !important; + border-color: $brand-primary !important; + color: #000000 !important; // 绿色 Tag 文字为黑色 +} + +// 确保所有带有绿色背景的 Tag 文字都是黑色 +.ant-tag { + &.ant-tag-green { + background: $brand-primary !important; + border-color: $brand-primary !important; + color: #000000 !important; + } +} + +// 徽章 +.badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 20px; + height: 20px; + padding: 0 6px; + font-size: $font-size-xs; + font-weight: $font-weight-medium; + line-height: 1; + color: $text-white; + background: $color-error; + border-radius: $radius-full; +} + +// 标签 +.tag { + display: inline-flex; + align-items: center; + gap: $space-xs; + padding: 4px 12px; + font-size: $font-size-sm; + font-weight: $font-weight-medium; + border-radius: $radius-small; + + &.tag-success { + color: $color-success; + background: rgba($color-success, 0.1); + border: 1px solid rgba($color-success, 0.3); + } + + &.tag-warning { + color: $color-warning; + background: rgba($color-warning, 0.1); + border: 1px solid rgba($color-warning, 0.3); + } + + &.tag-error { + color: $color-error; + background: rgba($color-error, 0.1); + border: 1px solid rgba($color-error, 0.3); + } + + &.tag-info { + color: $color-info; + background: rgba($color-info, 0.1); + border: 1px solid rgba($color-info, 0.3); + } +} + +// 文本工具类 +.text-primary { + color: $text-primary; +} + +.text-secondary { + color: $text-secondary; +} + +.text-tertiary { + color: $text-tertiary; +} + +.text-brand { + color: $brand-primary; +} + +.text-success { + color: $color-success; +} + +.text-warning { + color: $color-warning; +} + +.text-error { + color: $color-error; +} + +// 间距工具类 +.mt-sm { margin-top: $space-sm; } +.mt-md { margin-top: $space-md; } +.mt-lg { margin-top: $space-lg; } +.mt-xl { margin-top: $space-xl; } + +.mb-sm { margin-bottom: $space-sm; } +.mb-md { margin-bottom: $space-md; } +.mb-lg { margin-bottom: $space-lg; } +.mb-xl { margin-bottom: $space-xl; } + +.ml-sm { margin-left: $space-sm; } +.ml-md { margin-left: $space-md; } +.ml-lg { margin-left: $space-lg; } + +.mr-sm { margin-right: $space-sm; } +.mr-md { margin-right: $space-md; } +.mr-lg { margin-right: $space-lg; } + +.p-sm { padding: $space-sm; } +.p-md { padding: $space-md; } +.p-lg { padding: $space-lg; } +.p-xl { padding: $space-xl; } + +// Flex 布局 +.flex { + display: flex; +} + +.flex-center { + display: flex; + align-items: center; + justify-content: center; +} + +.flex-between { + display: flex; + align-items: center; + justify-content: space-between; +} + +.flex-col { + display: flex; + flex-direction: column; +} + +.gap-sm { gap: $space-sm; } +.gap-md { gap: $space-md; } +.gap-lg { gap: $space-lg; } + +// 隐藏/显示 +.hidden { + display: none !important; +} + +@media (max-width: $breakpoint-mobile) { + .hidden-mobile { + display: none !important; + } +} + +@media (min-width: calc(#{$breakpoint-mobile} + 1px)) { + .show-mobile { + display: none !important; + } +} + +// 加载动画 +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.loading-spinner { + width: 20px; + height: 20px; + border: 2px solid $border-primary; + border-top-color: $brand-primary; + border-radius: $radius-full; + animation: spin 0.8s linear infinite; +} + +// ===== 全局 Ant Design 表格样式统一 ===== +// 统一所有页面的表格样式(深色主题,避免悬停闪白) +:deep(.ant-table) { + // 表头统一样式 + .ant-table-thead > tr > th { + background: $bg-tertiary !important; + border-color: $border-primary !important; + color: $text-primary !important; + font-weight: $font-weight-semibold !important; + font-size: 14px !important; + padding: 16px 12px !important; + text-align: left !important; + white-space: nowrap !important; + + // 第一个表头单元格 + &:first-child { + padding-left: 20px !important; + border-top-left-radius: $radius-md !important; + } + + // 最后一个表头单元格(通常是操作列) + &:last-child { + padding-right: 20px !important; + text-align: center !important; + border-top-right-radius: $radius-md !important; + } + + // 固定列样式 + &.ant-table-cell-fix-right, + &.ant-table-cell-fix-left { + background: $bg-tertiary !important; + } + } + + .ant-table-tbody > tr { + background: $bg-card !important; + transition: none !important; // 移除过渡避免闪烁 + + > td { + background: $bg-card !important; + transition: none !important; + } + + &:hover { + background: $bg-card-hover !important; + transition: none !important; + + > td { + background: $bg-card-hover !important; + transition: none !important; + } + } + } + + // 覆盖 Ant Design 默认样式 + .ant-table-tbody > tr.ant-table-row { + background: $bg-card !important; + transition: none !important; + + > td { + background: $bg-card !important; + transition: none !important; + } + + &:hover { + background: $bg-card-hover !important; + transition: none !important; + + > td { + background: $bg-card-hover !important; + transition: none !important; + } + } + } + + // 确保所有悬停状态都是深色 + .ant-table-tbody > tr:hover > td, + .ant-table-tbody > tr.ant-table-row:hover > td { + background: $bg-card-hover !important; + transition: none !important; + } +} + +// ===== 全局 Ant Design 分页样式统一 ===== +// 统一所有页面的分页组件样式(深色主题) +:deep(.ant-pagination) { + .ant-pagination-item { + background: $bg-tertiary !important; + border-color: $border-primary !important; + + a { + color: $text-primary !important; + } + + &:hover:not(.ant-pagination-item-active) { + border-color: $brand-primary !important; + + a { + color: $brand-primary !important; + } + } + + &.ant-pagination-item-active { + background: $brand-primary !important; + border-color: $brand-primary !important; + + a { + color: #000000 !important; // 当前页文字为黑色 + } + + &:hover { + background: $brand-hover !important; + border-color: $brand-hover !important; + + a { + color: #000000 !important; + } + } + } + } + + .ant-pagination-prev, + .ant-pagination-next { + .ant-pagination-item-link { + background: $bg-tertiary !important; + border-color: $border-primary !important; + color: $text-primary !important; + + &:hover:not(:disabled) { + border-color: $brand-primary !important; + color: $brand-primary !important; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + } + } + + .ant-pagination-jump-prev, + .ant-pagination-jump-next { + .ant-pagination-item-link { + color: $text-primary !important; + + &:hover { + color: $brand-primary !important; + } + } + } + + .ant-pagination-options { + .ant-select { + .ant-select-selector { + background: $bg-tertiary !important; + border-color: $border-primary !important; + color: $text-primary !important; + } + } + + .ant-pagination-options-size-changer { + color: $text-primary !important; + } + } + + .ant-pagination-total-text { + color: $text-primary !important; + } + + // 表格内分页样式 + &.ant-pagination { + .ant-pagination-item { + background: $bg-tertiary !important; + border-color: $border-primary !important; + + a { + color: $text-primary !important; + } + + &.ant-pagination-item-active { + background: $brand-primary !important; + border-color: $brand-primary !important; + + a { + color: #000000 !important; // 当前页文字为黑色 + } + } + } + } +} + +// 表格分页容器 +:deep(.ant-table-pagination) { + .ant-pagination { + .ant-pagination-item { + background: $bg-tertiary !important; + border-color: $border-primary !important; + + a { + color: $text-primary !important; + } + + &.ant-pagination-item-active { + background: $brand-primary !important; + border-color: $brand-primary !important; + + a { + color: #000000 !important; // 当前页文字为黑色 + } + } + } + } +} + +// ===== 全局 Ant Design Modal 弹窗样式统一 ===== +// 统一所有页面的弹窗样式(深色主题) +.ant-modal { + .ant-modal-content { + background: $bg-card !important; + border: 1px solid $border-primary !important; + border-radius: $radius-lg !important; + box-shadow: $shadow-elevated !important; + } + + .ant-modal-header { + background: $bg-card !important; + border-bottom: 1px solid $border-primary !important; + padding: $space-lg $space-xl !important; + + .ant-modal-title { + color: $text-primary !important; + font-size: $font-size-xl !important; + font-weight: $font-weight-semibold !important; + } + } + + .ant-modal-body { + background: $bg-card !important; + padding: $space-xl !important; + color: $text-primary !important; + } + + .ant-modal-footer { + background: $bg-card !important; + border-top: 1px solid $border-primary !important; + padding: $space-md $space-xl !important; + + .ant-btn { + min-width: 80px; + } + } + + .ant-modal-close { + color: $text-secondary !important; + + &:hover { + color: $text-primary !important; + } + } +} + +// Modal 确认框样式 +.ant-modal-confirm { + .ant-modal-confirm-title { + color: $text-primary !important; + } + + .ant-modal-confirm-content { + color: $text-secondary !important; + } +} + +// ===== 全局 Ant Design Drawer 抽屉样式统一 ===== +.ant-drawer { + .ant-drawer-content { + background: $bg-card !important; + } + + .ant-drawer-header { + background: $bg-card !important; + border-bottom: 1px solid $border-primary !important; + padding: $space-lg $space-xl !important; + + .ant-drawer-title { + color: $text-primary !important; + font-size: $font-size-xl !important; + font-weight: $font-weight-semibold !important; + } + + .ant-drawer-close { + color: $text-secondary !important; + + &:hover { + color: $text-primary !important; + } + } + } + + .ant-drawer-body { + background: $bg-card !important; + padding: $space-xl !important; + color: $text-primary !important; + } + + .ant-drawer-footer { + background: $bg-card !important; + border-top: 1px solid $border-primary !important; + padding: $space-md $space-xl !important; + } +} + +// ===== 全局 Ant Design Form 表单样式统一 ===== +.ant-form { + .ant-form-item-label > label { + color: $text-primary !important; + font-weight: $font-weight-medium; + } + + .ant-form-item-required::before { + color: $color-error !important; + } + + .ant-form-item-explain-error { + color: $color-error !important; + font-size: $font-size-sm; + } +} + +// ===== 全局 Ant Design Descriptions 描述列表样式统一 ===== +.ant-descriptions { + .ant-descriptions-header { + .ant-descriptions-title { + color: $text-primary !important; + } + } + + .ant-descriptions-view { + border-color: $border-primary !important; + } + + .ant-descriptions-item-label { + background: $bg-tertiary !important; + color: $text-secondary !important; + border-color: $border-primary !important; + } + + .ant-descriptions-item-content { + background: $bg-card !important; + color: $text-primary !important; + border-color: $border-primary !important; + } + + &.ant-descriptions-bordered { + .ant-descriptions-item-label, + .ant-descriptions-item-content { + border-color: $border-primary !important; + } + } +} + +// ===== 全局 Ant Design Checkbox 复选框样式统一 ===== +.ant-checkbox-wrapper { + color: $text-primary !important; + + .ant-checkbox { + .ant-checkbox-inner { + background: $bg-secondary !important; + border-color: $border-primary !important; + } + + &:hover .ant-checkbox-inner { + border-color: $brand-primary !important; + } + + &.ant-checkbox-checked .ant-checkbox-inner { + background: $brand-primary !important; + border-color: $brand-primary !important; + + &::after { + border-color: #000000 !important; // 勾选标记为黑色 + } + } + } +} + +// ===== 全局 Ant Design Menu 菜单样式统一 ===== +.ant-dropdown-menu { + background: $bg-card !important; + border: 1px solid $border-primary !important; + border-radius: $radius-md !important; + box-shadow: $shadow-elevated !important; + + .ant-dropdown-menu-item { + color: $text-primary !important; + + &:hover { + background: $bg-tertiary !important; + } + + &.ant-dropdown-menu-item-danger { + color: $color-error !important; + + &:hover { + background: rgba($color-error, 0.1) !important; + } + } + } +} + +// ===== 全局 Ant Design Popconfirm 确认弹出框样式统一 ===== +.ant-popconfirm { + .ant-popover-inner { + background: $bg-card !important; + border: 1px solid $border-primary !important; + border-radius: $radius-md !important; + } + + .ant-popconfirm-message-title { + color: $text-primary !important; + } + + .ant-popconfirm-description { + color: $text-secondary !important; + } +} + +// ===== 全局 Ant Design Tooltip 提示框样式统一 ===== +.ant-tooltip { + .ant-tooltip-inner { + background: $bg-elevated !important; + color: $text-primary !important; + border-radius: $radius-sm !important; + } + + .ant-tooltip-arrow-content { + background: $bg-elevated !important; + } +} + +// ===== 全局 Ant Design Message 消息提示样式统一 ===== +.ant-message { + .ant-message-notice-content { + background: $bg-card !important; + border: 1px solid $border-primary !important; + border-radius: $radius-md !important; + box-shadow: $shadow-elevated !important; + color: $text-primary !important; + } +} + +// ===== 全局 Ant Design Spin 加载组件样式统一 ===== +.ant-spin { + .ant-spin-dot-item { + background: $brand-primary !important; + } +} + +// ===== 全局 Ant Design Empty 空状态样式统一 ===== +.ant-empty { + .ant-empty-description { + color: $text-secondary !important; + } +} + diff --git a/frontend/src/styles/variables.scss b/frontend/src/styles/variables.scss new file mode 100644 index 0000000..829c34c --- /dev/null +++ b/frontend/src/styles/variables.scss @@ -0,0 +1,126 @@ +/** + * Cursor 风格设计系统变量 + * 参考 Cursor 官方网站 (cursor.com) 的设计规范 + */ + +// ===== 配色方案 ===== + +// 深色模式 - 背景色系(Cursor 风格:更柔和的深色) +$bg-primary: #0A0A0A; // 主背景 - 接近纯黑但更柔和 +$bg-secondary: #141414; // 次要背景 - 卡片和容器 +$bg-tertiary: #1A1A1A; // 三级背景 - 嵌套容器 +$bg-card: #1E1E1E; // 卡片背景 - 更柔和的灰色 +$bg-card-hover: #252525; // 卡片悬停 - 轻微提亮 +$bg-elevated: #2A2A2A; // 提升背景 - 模态框等 + +// 深色模式 - 文字色系(Cursor 风格:高对比度但不过于刺眼) +$text-primary: #E5E5E5; // 主文字 - 柔和的白色 +$text-secondary: #A0A0A0; // 次要文字 - 中等灰色 +$text-tertiary: #6B6B6B; // 三级文字 - 较暗灰色 +$text-disabled: #404040; // 禁用文字 +$text-white: #FFFFFF; // 纯白色 + +// 品牌色(标准绿色) +$brand-primary: #31f08b; // 主品牌色 - 标准绿色 +$primary-color: #31f08b; // 别名,与 $brand-primary 相同 +$brand-hover: #28d977; // 悬停色 - 深绿色 +$brand-pressed: #1fc266; // 按下色 - 更深绿色 + +// 功能色(统一使用绿色系,保持简洁) +$color-success: #31f08b; // 成功色 - 使用品牌色 +$color-warning: #F59E0B; // 警告色 - 橙色(仅用于警告场景) +$color-error: #EF4444; // 错误色 - 红色(仅用于错误场景) +$color-info: #31f08b; // 信息色 - 品牌色 + +// 边框色(Cursor 风格:更细腻的边框) +$border-primary: #2A2A2A; // 主边框 - 更柔和 +$border-secondary: #1E1E1E; // 次要边框 +$border-divider: #1A1A1A; // 分割线 +$border-color: #2A2A2A; // 别名,与 $border-primary 相同 + +// ===== 间距系统(8px 基准)===== +$space-xxs: 2px; +$space-xs: 4px; +$space-sm: 8px; +$space-md: 16px; +$space-lg: 24px; +$space-xl: 32px; +$space-xxl: 48px; +$space-2xl: 48px; // 别名,与 $space-xxl 相同 +$space-xxxl: 64px; +$space-3xl: 64px; // 别名,与 $space-xxxl 相同 + +// ===== 圆角规范(Cursor 风格:更圆润的边角)===== +$radius-micro: 4px; +$radius-small: 6px; // 更小的圆角 +$radius-sm: 6px; // 别名 +$radius-base: 8px; // 基础圆角 +$radius-medium: 10px; // 中等圆角 +$radius-md: 10px; // 别名 +$radius-large: 12px; // 大圆角 +$radius-lg: 12px; // 别名 +$radius-xlarge: 16px; // 超大圆角 +$radius-xl: 16px; // 别名 +$radius-full: 50%; + +// ===== 字体系统(Cursor 风格:现代无衬线字体)===== +$font-family-base: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", + "Helvetica Neue", Arial, "Noto Sans", sans-serif; +$font-family-zh: "Inter", -apple-system, BlinkMacSystemFont, "PingFang SC", + "Microsoft YaHei", "Helvetica Neue", Helvetica, Arial, sans-serif; +$font-family-mono: "SF Mono", "Monaco", "Cascadia Code", "Roboto Mono", + "Consolas", "Fira Code", monospace; + +// 字体大小 +$font-size-xs: 11px; // 0.6875rem +$font-size-sm: 12px; // 0.75rem +$font-size-base: 14px; // 0.875rem +$font-size-lg: 16px; // 1rem +$font-size-xl: 20px; // 1.25rem +$font-size-2xl: 24px; // 1.5rem +$font-size-3xl: 32px; // 2rem +$font-size-4xl: 40px; // 2.5rem + +// 字重 +$font-weight-light: 300; +$font-weight-regular: 400; +$font-weight-normal: 400; // 别名,与 $font-weight-regular 相同 +$font-weight-medium: 500; +$font-weight-semibold: 600; +$font-weight-bold: 700; + +// ===== 阴影系统(Cursor 风格:更细腻的阴影)===== +$shadow-light: 0 1px 2px rgba(0, 0, 0, 0.1); +$shadow-base: 0 2px 8px rgba(0, 0, 0, 0.12); +$shadow-elevated: 0 4px 16px rgba(0, 0, 0, 0.15); +$shadow-strong: 0 8px 24px rgba(0, 0, 0, 0.2); +$shadow-dark: 0 2px 8px rgba(0, 0, 0, 0.25); + +// ===== 过渡动画 ===== +$transition-fast: 150ms; +$transition-base: 250ms; +$transition-slow: 350ms; + +// 缓动函数(OpenAI 风格) +$ease-out: cubic-bezier(0.16, 1, 0.3, 1); +$ease-in-out: cubic-bezier(0.45, 0, 0.55, 1); + +// ===== Z-index 层级 ===== +$z-index-dropdown: 1000; +$z-index-modal: 1050; +$z-index-popover: 1100; +$z-index-tooltip: 1200; +$z-index-notification: 1300; + +// ===== 布局 ===== +$navbar-height: 64px; +$sidebar-width: 240px; +$sidebar-collapsed-width: 64px; +$container-max-width: 1200px; + +// ===== 断点 ===== +$breakpoint-mobile: 768px; +$breakpoint-tablet: 1024px; +$breakpoint-desktop: 1200px; +$breakpoint-wide: 1440px; + diff --git a/frontend/src/types/auto-imports.d.ts b/frontend/src/types/auto-imports.d.ts new file mode 100644 index 0000000..a606bd4 --- /dev/null +++ b/frontend/src/types/auto-imports.d.ts @@ -0,0 +1,87 @@ +/* eslint-disable */ +/* prettier-ignore */ +// @ts-nocheck +// noinspection JSUnusedGlobalSymbols +// Generated by unplugin-auto-import +export {} +declare global { + const EffectScope: typeof import('vue')['EffectScope'] + const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate'] + const computed: typeof import('vue')['computed'] + const createApp: typeof import('vue')['createApp'] + const createPinia: typeof import('pinia')['createPinia'] + const customRef: typeof import('vue')['customRef'] + const defineAsyncComponent: typeof import('vue')['defineAsyncComponent'] + const defineComponent: typeof import('vue')['defineComponent'] + const defineStore: typeof import('pinia')['defineStore'] + const effectScope: typeof import('vue')['effectScope'] + const getActivePinia: typeof import('pinia')['getActivePinia'] + const getCurrentInstance: typeof import('vue')['getCurrentInstance'] + const getCurrentScope: typeof import('vue')['getCurrentScope'] + const h: typeof import('vue')['h'] + const inject: typeof import('vue')['inject'] + const isProxy: typeof import('vue')['isProxy'] + const isReactive: typeof import('vue')['isReactive'] + const isReadonly: typeof import('vue')['isReadonly'] + const isRef: typeof import('vue')['isRef'] + const mapActions: typeof import('pinia')['mapActions'] + const mapGetters: typeof import('pinia')['mapGetters'] + const mapState: typeof import('pinia')['mapState'] + const mapStores: typeof import('pinia')['mapStores'] + const mapWritableState: typeof import('pinia')['mapWritableState'] + const markRaw: typeof import('vue')['markRaw'] + const nextTick: typeof import('vue')['nextTick'] + const onActivated: typeof import('vue')['onActivated'] + const onBeforeMount: typeof import('vue')['onBeforeMount'] + const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave'] + const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate'] + const onBeforeUnmount: typeof import('vue')['onBeforeUnmount'] + const onBeforeUpdate: typeof import('vue')['onBeforeUpdate'] + const onDeactivated: typeof import('vue')['onDeactivated'] + const onErrorCaptured: typeof import('vue')['onErrorCaptured'] + const onMounted: typeof import('vue')['onMounted'] + const onRenderTracked: typeof import('vue')['onRenderTracked'] + const onRenderTriggered: typeof import('vue')['onRenderTriggered'] + const onScopeDispose: typeof import('vue')['onScopeDispose'] + const onServerPrefetch: typeof import('vue')['onServerPrefetch'] + const onUnmounted: typeof import('vue')['onUnmounted'] + const onUpdated: typeof import('vue')['onUpdated'] + const onWatcherCleanup: typeof import('vue')['onWatcherCleanup'] + const provide: typeof import('vue')['provide'] + const reactive: typeof import('vue')['reactive'] + const readonly: typeof import('vue')['readonly'] + const ref: typeof import('vue')['ref'] + const resolveComponent: typeof import('vue')['resolveComponent'] + const setActivePinia: typeof import('pinia')['setActivePinia'] + const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix'] + const shallowReactive: typeof import('vue')['shallowReactive'] + const shallowReadonly: typeof import('vue')['shallowReadonly'] + const shallowRef: typeof import('vue')['shallowRef'] + const storeToRefs: typeof import('pinia')['storeToRefs'] + const toRaw: typeof import('vue')['toRaw'] + const toRef: typeof import('vue')['toRef'] + const toRefs: typeof import('vue')['toRefs'] + const toValue: typeof import('vue')['toValue'] + const triggerRef: typeof import('vue')['triggerRef'] + const unref: typeof import('vue')['unref'] + const useAttrs: typeof import('vue')['useAttrs'] + const useCssModule: typeof import('vue')['useCssModule'] + const useCssVars: typeof import('vue')['useCssVars'] + const useId: typeof import('vue')['useId'] + const useLink: typeof import('vue-router')['useLink'] + const useModel: typeof import('vue')['useModel'] + const useRoute: typeof import('vue-router')['useRoute'] + const useRouter: typeof import('vue-router')['useRouter'] + const useSlots: typeof import('vue')['useSlots'] + const useTemplateRef: typeof import('vue')['useTemplateRef'] + const watch: typeof import('vue')['watch'] + const watchEffect: typeof import('vue')['watchEffect'] + const watchPostEffect: typeof import('vue')['watchPostEffect'] + const watchSyncEffect: typeof import('vue')['watchSyncEffect'] +} +// for type re-export +declare global { + // @ts-ignore + export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue' + import('vue') +} diff --git a/frontend/src/types/components.d.ts b/frontend/src/types/components.d.ts new file mode 100644 index 0000000..ffbc4c3 --- /dev/null +++ b/frontend/src/types/components.d.ts @@ -0,0 +1,67 @@ +/* eslint-disable */ +/* prettier-ignore */ +// @ts-nocheck +// Generated by unplugin-vue-components +// Read more: https://github.com/vuejs/core/pull/3399 +export {} + +declare module 'vue' { + export interface GlobalComponents { + AAlert: typeof import('ant-design-vue/es')['Alert'] + ABadge: typeof import('ant-design-vue/es')['Badge'] + AButton: typeof import('ant-design-vue/es')['Button'] + ACalendar: typeof import('ant-design-vue/es')['Calendar'] + ACheckbox: typeof import('ant-design-vue/es')['Checkbox'] + ACheckboxGroup: typeof import('ant-design-vue/es')['CheckboxGroup'] + ACol: typeof import('ant-design-vue/es')['Col'] + ADatePicker: typeof import('ant-design-vue/es')['DatePicker'] + ADescriptions: typeof import('ant-design-vue/es')['Descriptions'] + ADescriptionsItem: typeof import('ant-design-vue/es')['DescriptionsItem'] + ADivider: typeof import('ant-design-vue/es')['Divider'] + ADrawer: typeof import('ant-design-vue/es')['Drawer'] + ADropdown: typeof import('ant-design-vue/es')['Dropdown'] + AEmpty: typeof import('ant-design-vue/es')['Empty'] + AForm: typeof import('ant-design-vue/es')['Form'] + AFormItem: typeof import('ant-design-vue/es')['FormItem'] + AInput: typeof import('ant-design-vue/es')['Input'] + AInputNumber: typeof import('ant-design-vue/es')['InputNumber'] + AInputPassword: typeof import('ant-design-vue/es')['InputPassword'] + AInputSearch: typeof import('ant-design-vue/es')['InputSearch'] + AMenu: typeof import('ant-design-vue/es')['Menu'] + AMenuDivider: typeof import('ant-design-vue/es')['MenuDivider'] + AMenuItem: typeof import('ant-design-vue/es')['MenuItem'] + AModal: typeof import('ant-design-vue/es')['Modal'] + AMonthPicker: typeof import('ant-design-vue/es')['MonthPicker'] + APagination: typeof import('ant-design-vue/es')['Pagination'] + APopconfirm: typeof import('ant-design-vue/es')['Popconfirm'] + AProgress: typeof import('ant-design-vue/es')['Progress'] + ARadio: typeof import('ant-design-vue/es')['Radio'] + ARadioButton: typeof import('ant-design-vue/es')['RadioButton'] + ARadioGroup: typeof import('ant-design-vue/es')['RadioGroup'] + ARate: typeof import('ant-design-vue/es')['Rate'] + ARow: typeof import('ant-design-vue/es')['Row'] + ASelect: typeof import('ant-design-vue/es')['Select'] + ASelectOption: typeof import('ant-design-vue/es')['SelectOption'] + ASlider: typeof import('ant-design-vue/es')['Slider'] + ASpace: typeof import('ant-design-vue/es')['Space'] + ASpin: typeof import('ant-design-vue/es')['Spin'] + ASwitch: typeof import('ant-design-vue/es')['Switch'] + ATable: typeof import('ant-design-vue/es')['Table'] + ATabPane: typeof import('ant-design-vue/es')['TabPane'] + ATabs: typeof import('ant-design-vue/es')['Tabs'] + ATag: typeof import('ant-design-vue/es')['Tag'] + ATextarea: typeof import('ant-design-vue/es')['Textarea'] + ATree: typeof import('ant-design-vue/es')['Tree'] + AUpload: typeof import('ant-design-vue/es')['Upload'] + DailyProgressReminder: typeof import('./../components/DailyProgressReminder.vue')['default'] + Icon: typeof import('./../components/Icon.vue')['default'] + IncentiveModal: typeof import('./../components/IncentiveModal.vue')['default'] + MilestoneCard: typeof import('./../components/MilestoneCard.vue')['default'] + Navbar: typeof import('./../components/Navbar.vue')['default'] + NotificationBell: typeof import('./../components/NotificationBell.vue')['default'] + RankingCard: typeof import('./../components/RankingCard.vue')['default'] + RouterLink: typeof import('vue-router')['RouterLink'] + RouterView: typeof import('vue-router')['RouterView'] + TaskCard: typeof import('./../components/TaskCard.vue')['default'] + } +} diff --git a/frontend/src/utils/request.ts b/frontend/src/utils/request.ts new file mode 100644 index 0000000..152d692 --- /dev/null +++ b/frontend/src/utils/request.ts @@ -0,0 +1,116 @@ +import axios from 'axios' +import type { AxiosInstance, AxiosResponse } from 'axios' +import { message } from 'ant-design-vue' + +// 创建 axios 实例 +const service: AxiosInstance = axios.create({ + baseURL: (import.meta as any).env?.VITE_API_BASE_URL || '/api', + timeout: 15000 +}) + +// 请求拦截器 +service.interceptors.request.use( + (config) => { + const base = (config.baseURL || '') as string + let url = (config.url || '') as string + + // 统一去重:如果 baseURL 末尾带 /api,url 又以 /api/ 开头,则自动去掉 url 前面的 /api + // 例如 baseURL = '/api' 且 url = '/api/analytics/xxx',最终会发到 '/api/analytics/xxx' 而不是 '/api/api/analytics/xxx' + if (base.endsWith('/api') && url.startsWith('/api/')) { + url = url.replace(/^\/api\//, '/') + config.url = url + } + + // 调试:打印最终请求地址,帮助排查 404 / 重复前缀问题(生产环境可按需注释) + try { + // eslint-disable-next-line no-console + console.debug('[HTTP] request:', { + baseURL: config.baseURL, + url: config.url, + method: config.method + }) + } catch (e) { + // ignore + } + // 调试:打印最终请求地址,帮助排查 404 / 重复前缀问题(生产环境可按需注释) + try { + // eslint-disable-next-line no-console + console.debug('[HTTP] request:', { + baseURL: config.baseURL, + url: config.url, + method: config.method + }) + } catch (e) { + // ignore + } + + // 从 localStorage 获取 token + const token = localStorage.getItem('token') + if (token) { + config.headers['Authorization'] = `Bearer ${token}` + } + return config + }, + (error) => { + console.error('请求错误:', error) + return Promise.reject(error) + } +) + +// 响应拦截器 +service.interceptors.response.use( + (response: AxiosResponse) => { + const res = response.data + + // 如果返回的状态码不是 200,说明接口报错了 + if (res.code && res.code !== 200) { + message.error(res.message || '请求失败') + + // 401: 未授权 + if (res.code === 401) { + // 清除 token + localStorage.removeItem('token') + localStorage.removeItem('userInfo') + // 跳转到登录页 + window.location.href = '/login' + } + + return Promise.reject(new Error(res.message || '请求失败')) + } + + return res + }, + (error) => { + console.error('响应错误:', error) + + if (error.response) { + const status = error.response.status + switch (status) { + case 401: + message.error('未授权,请登录') + localStorage.removeItem('token') + localStorage.removeItem('userInfo') + window.location.href = '/login' + break + case 403: + message.error('拒绝访问') + break + case 404: + message.error('请求的资源不存在') + break + case 500: + message.error('服务器内部错误') + break + default: + message.error(error.response.data.message || '请求失败') + } + } else { + message.error('网络错误,请检查您的网络连接') + } + + return Promise.reject(error) + } +) + +export default service + diff --git a/frontend/src/views/Achievements.vue b/frontend/src/views/Achievements.vue new file mode 100644 index 0000000..e2599c9 --- /dev/null +++ b/frontend/src/views/Achievements.vue @@ -0,0 +1,533 @@ + + + + + + diff --git a/frontend/src/views/Analytics.vue b/frontend/src/views/Analytics.vue new file mode 100644 index 0000000..fdbae33 --- /dev/null +++ b/frontend/src/views/Analytics.vue @@ -0,0 +1,887 @@ + + + + + + diff --git a/frontend/src/views/CheckIn.vue b/frontend/src/views/CheckIn.vue new file mode 100644 index 0000000..f155f7f --- /dev/null +++ b/frontend/src/views/CheckIn.vue @@ -0,0 +1,559 @@ + + + + + + diff --git a/frontend/src/views/Contests.vue b/frontend/src/views/Contests.vue new file mode 100644 index 0000000..81c124c --- /dev/null +++ b/frontend/src/views/Contests.vue @@ -0,0 +1,734 @@ + + + + + diff --git a/frontend/src/views/CreateTask.vue b/frontend/src/views/CreateTask.vue new file mode 100644 index 0000000..9957a60 --- /dev/null +++ b/frontend/src/views/CreateTask.vue @@ -0,0 +1,1482 @@ + + + + + + diff --git a/frontend/src/views/DesignerHall.vue b/frontend/src/views/DesignerHall.vue new file mode 100644 index 0000000..7f00f60 --- /dev/null +++ b/frontend/src/views/DesignerHall.vue @@ -0,0 +1,944 @@ + + + + + diff --git a/frontend/src/views/Goals.vue b/frontend/src/views/Goals.vue new file mode 100644 index 0000000..7e651fa --- /dev/null +++ b/frontend/src/views/Goals.vue @@ -0,0 +1,650 @@ + + + + + diff --git a/frontend/src/views/IncentiveCenter.vue b/frontend/src/views/IncentiveCenter.vue new file mode 100644 index 0000000..2e823f2 --- /dev/null +++ b/frontend/src/views/IncentiveCenter.vue @@ -0,0 +1,507 @@ + + + + + diff --git a/frontend/src/views/LeaderWorkbench.vue b/frontend/src/views/LeaderWorkbench.vue new file mode 100644 index 0000000..b4a1e9c --- /dev/null +++ b/frontend/src/views/LeaderWorkbench.vue @@ -0,0 +1,697 @@ + + + + + diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue new file mode 100644 index 0000000..367e7a3 --- /dev/null +++ b/frontend/src/views/Login.vue @@ -0,0 +1,846 @@ + + + + + + diff --git a/frontend/src/views/MyTasks.vue b/frontend/src/views/MyTasks.vue new file mode 100644 index 0000000..1c28a56 --- /dev/null +++ b/frontend/src/views/MyTasks.vue @@ -0,0 +1,1785 @@ + + + + + + diff --git a/frontend/src/views/NotFound.vue b/frontend/src/views/NotFound.vue new file mode 100644 index 0000000..dfd85b7 --- /dev/null +++ b/frontend/src/views/NotFound.vue @@ -0,0 +1,283 @@ + + + + + diff --git a/frontend/src/views/Notifications.vue b/frontend/src/views/Notifications.vue new file mode 100644 index 0000000..4750d08 --- /dev/null +++ b/frontend/src/views/Notifications.vue @@ -0,0 +1,491 @@ + + + + + + diff --git a/frontend/src/views/RoleManagement.vue b/frontend/src/views/RoleManagement.vue new file mode 100644 index 0000000..a69b90a --- /dev/null +++ b/frontend/src/views/RoleManagement.vue @@ -0,0 +1,280 @@ + + + + + + diff --git a/frontend/src/views/Settings.vue b/frontend/src/views/Settings.vue new file mode 100644 index 0000000..9f357b7 --- /dev/null +++ b/frontend/src/views/Settings.vue @@ -0,0 +1,3715 @@ + + + + + + + diff --git a/frontend/src/views/TaskHall.vue b/frontend/src/views/TaskHall.vue new file mode 100644 index 0000000..0323993 --- /dev/null +++ b/frontend/src/views/TaskHall.vue @@ -0,0 +1,3626 @@ + + + + + + diff --git a/frontend/src/views/TaskHall.vue.backup b/frontend/src/views/TaskHall.vue.backup new file mode 100644 index 0000000..81b047c --- /dev/null +++ b/frontend/src/views/TaskHall.vue.backup @@ -0,0 +1,2821 @@ + + + + + + diff --git a/frontend/src/views/TeamPK.vue b/frontend/src/views/TeamPK.vue new file mode 100644 index 0000000..6a81527 --- /dev/null +++ b/frontend/src/views/TeamPK.vue @@ -0,0 +1,675 @@ + + + + + diff --git a/frontend/src/views/UserManagement.vue b/frontend/src/views/UserManagement.vue new file mode 100644 index 0000000..b35feff --- /dev/null +++ b/frontend/src/views/UserManagement.vue @@ -0,0 +1,624 @@ + + + + + + diff --git a/frontend/src/views/admin/RewardRules.vue b/frontend/src/views/admin/RewardRules.vue new file mode 100644 index 0000000..574fe41 --- /dev/null +++ b/frontend/src/views/admin/RewardRules.vue @@ -0,0 +1,543 @@ + + + + + diff --git a/frontend/src/views/admin/ScorePoolManagement.vue b/frontend/src/views/admin/ScorePoolManagement.vue new file mode 100644 index 0000000..c489813 --- /dev/null +++ b/frontend/src/views/admin/ScorePoolManagement.vue @@ -0,0 +1,797 @@ + + + + + diff --git a/frontend/src/views/admin/TaskManagement.vue b/frontend/src/views/admin/TaskManagement.vue new file mode 100644 index 0000000..6945985 --- /dev/null +++ b/frontend/src/views/admin/TaskManagement.vue @@ -0,0 +1,472 @@ + + + + + + diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..cb18f57 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,37 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "preserve", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + + /* Path mapping */ + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, + + /* Additional */ + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "allowSyntheticDefaultImports": true + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"], + "references": [{ "path": "./tsconfig.node.json" }] +} + diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..e428d50 --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} + diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..fb16d4c --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,63 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import { fileURLToPath, URL } from 'node:url' +import AutoImport from 'unplugin-auto-import/vite' +import Components from 'unplugin-vue-components/vite' +import { AntDesignVueResolver } from 'unplugin-vue-components/resolvers' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [ + vue(), + // 自动导入 Vue 相关函数 + AutoImport({ + imports: ['vue', 'vue-router', 'pinia'], + dts: 'src/types/auto-imports.d.ts', + eslintrc: { + enabled: true + } + }), + // 自动导入组件 + Components({ + resolvers: [ + AntDesignVueResolver({ + importStyle: false // 使用 CSS 而不是 less + }) + ], + dts: 'src/types/components.d.ts' + }) + ], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)) + } + }, + server: { + port: 3000, + proxy: { + '/api': { + target: 'http://localhost:8080', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api/, '') + } + } + }, + build: { + rollupOptions: { + output: { + // 为每次构建生成唯一的文件名,避免缓存问题 + entryFileNames: `assets/[name]-[hash]-${Date.now()}.js`, + chunkFileNames: `assets/[name]-[hash]-${Date.now()}.js`, + assetFileNames: `assets/[name]-[hash]-${Date.now()}.[ext]` + } + } + }, + css: { + preprocessorOptions: { + scss: { + additionalData: '@import "@/styles/variables.scss";' + } + } + } +}) + diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..5e28189 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,47 @@ +server { + listen 8888; + server_name 38.246.244.140; + + root /opt/designhub/frontend/dist; + index index.html; + + access_log /opt/designhub/logs/nginx-access.log; + error_log /opt/designhub/logs/nginx-error.log; + + # 前端路由 + location / { + try_files $uri $uri/ /index.html; + + # 禁止缓存 HTML 文件 + if ($request_filename ~* .*\.(?:htm|html)$) { + add_header Cache-Control "private, no-store, no-cache, must-revalidate, proxy-revalidate"; + } + } + + # API 代理 + location /api/ { + proxy_pass http://localhost:8080/api/; + proxy_set_header Host $host; + 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; + + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + proxy_connect_timeout 600; + proxy_send_timeout 600; + proxy_read_timeout 600; + } + + # 静态资源缓存(JS/CSS等) + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 30d; + add_header Cache-Control "public, immutable"; + } + + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml; + gzip_min_length 1000; +} diff --git a/scripts/health-check.sh b/scripts/health-check.sh new file mode 100644 index 0000000..f2f0bee --- /dev/null +++ b/scripts/health-check.sh @@ -0,0 +1,30 @@ +#!/bin/bash + +# 简易健康检查脚本 +# Usage: ./scripts/health-check.sh http://localhost:8080/api + +API_BASE=${1:-"http://localhost:8080/api"} +HEALTH_URL="${API_BASE%/}/system/health" + +echo "Checking backend health from ${HEALTH_URL} ..." + +response=$(curl -s -w "\n%{http_code}" "${HEALTH_URL}") +body=$(echo "$response" | head -n -1) +status=$(echo "$response" | tail -n 1) + +if [ "$status" != "200" ]; then + echo "❌ Health check request failed, status code: $status" + echo "Response: $body" + exit 1 +fi + +db_up=$(echo "$body" | jq -r '.data.databaseUp' 2>/dev/null) + +if [ "$db_up" = "true" ]; then + echo "✅ Database connection OK" +else + echo "❌ Database check failed" +fi + +echo "$body" | jq '.' 2>/dev/null || echo "$body" + diff --git a/公网服务器部署指南.md b/公网服务器部署指南.md new file mode 100644 index 0000000..c7b9826 --- /dev/null +++ b/公网服务器部署指南.md @@ -0,0 +1,407 @@ +# DesignHub 公网服务器部署指南 + +**服务器信息:** +- IP: 45.192.99.48 +- 端口: 22 +- 用户名: root +- 密码: 1blZllMIp7 +- 系统: CentOS Stream 9 + +--- + +## 快速部署脚本 + +已创建自动化部署脚本:`deploy_to_public_server.sh` + +执行方式: +```bash +cd /Users/lanmu/Desktop/qiangdanxitong +bash deploy_to_public_server.sh +``` + +--- + +## 手动部署步骤(如果脚本失败) + +### 第一步:连接服务器 + +```bash +ssh root@45.192.99.48 +# 密码: 1blZllMIp7 +``` + +### 第二步:安装系统依赖 + +```bash +# 安装基础工具 +dnf install -y curl wget git unzip + +# 安装 Java 17 +dnf install -y java-17-openjdk java-17-openjdk-devel + +# 安装 Maven +dnf install -y maven + +# 安装 MySQL +dnf install -y mysql-server mysql +systemctl enable mysqld +systemctl start mysqld + +# 设置 MySQL root 密码 +mysqladmin -u root password "DesignHub@2025_Root" + +# 安装 Redis +dnf install -y redis +systemctl enable redis +systemctl start redis + +# Nginx 和 Node.js/pnpm 已安装,跳过 +``` + +### 第三步:创建目录结构 + +```bash +mkdir -p /opt/designhub/{backend,frontend,logs,uploads} +``` + +### 第四步:配置 MySQL 数据库 + +```bash +mysql -uroot -pDesignHub@2025_Root << 'EOF' +CREATE DATABASE IF NOT EXISTS designhub CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +CREATE USER IF NOT EXISTS 'designhub'@'localhost' IDENTIFIED BY 'DesignHub@2025_DB'; +GRANT ALL PRIVILEGES ON designhub.* TO 'designhub'@'localhost'; +FLUSH PRIVILEGES; +EOF +``` + +### 第五步:上传代码 + +**方式1:使用 rsync(推荐)** + +在本地执行: +```bash +# 安装 sshpass(Mac) +brew install hudochenkov/sshpass/sshpass + +# 上传后端代码 +sshpass -p '1blZllMIp7' rsync -avz \ + --exclude='target' --exclude='*.class' \ + -e "ssh -o StrictHostKeyChecking=no" \ + backend/ root@45.192.99.48:/opt/designhub/backend/ + +# 上传前端代码 +sshpass -p '1blZllMIp7' rsync -avz \ + --exclude='node_modules' --exclude='dist' \ + -e "ssh -o StrictHostKeyChecking=no" \ + frontend/ root@45.192.99.48:/opt/designhub/frontend/ + +# 上传数据库SQL文件(如果有) +sshpass -p '1blZllMIp7' scp -o StrictHostKeyChecking=no \ + database/*.sql root@45.192.99.48:/tmp/ +``` + +**方式2:使用 scp** + +```bash +# 上传整个项目目录 +sshpass -p '1blZllMIp7' scp -r \ + -o StrictHostKeyChecking=no \ + backend root@45.192.99.48:/opt/designhub/ + +sshpass -p '1blZllMIp7' scp -r \ + -o StrictHostKeyChecking=no \ + frontend root@45.192.99.48:/opt/designhub/ +``` + +### 第六步:导入数据库(如果需要) + +```bash +# 在服务器上执行 +cd /tmp +mysql -uroot -pDesignHub@2025_Root designhub < create_s3_config.sql +# 导入其他SQL文件... +``` + +### 第七步:配置后端 + +```bash +cd /opt/designhub/backend + +# 创建生产环境配置 +cat > src/main/resources/application-prod.yml << 'EOF' +spring: + datasource: + url: jdbc:mysql://localhost:3306/designhub?useUnicode=true&characterEncoding=utf8mb4&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true + username: designhub + password: DesignHub@2025_DB + driver-class-name: com.mysql.cj.jdbc.Driver + hikari: + minimum-idle: 10 + maximum-pool-size: 50 + connection-timeout: 30000 + idle-timeout: 600000 + max-lifetime: 1800000 + + redis: + host: localhost + port: 6379 + password: DesignHub@2025_Redis + database: 0 + timeout: 3000 + +jwt: + secret: DesignHub_Prod_Secret_Key_2025_Very_Strong + access-token-expiration: 7200000 + refresh-token-expiration: 604800000 + +server: + port: 8080 + +logging: + level: + root: INFO + com.designhub: INFO + file: + name: /opt/designhub/logs/application.log + max-size: 10MB + max-history: 30 + +file: + upload: + path: /opt/designhub/uploads + max-size: 52428800 +EOF + +# 构建后端 +mvn clean package -DskipTests + +# 复制JAR文件 +cp target/designhub-backend-1.0.0.jar /opt/designhub/designhub.jar +``` + +### 第八步:配置前端 + +```bash +cd /opt/designhub/frontend + +# 创建生产环境配置 +cat > .env.production << 'EOF' +VITE_APP_BASE_API=http://45.192.99.48/api +VITE_APP_TITLE=DesignHub +VITE_APP_VERSION=6.0 +EOF + +# 安装依赖 +pnpm install + +# 构建 +pnpm build +``` + +### 第九步:配置系统服务 + +```bash +# 创建后端服务 +cat > /etc/systemd/system/designhub-backend.service << 'EOF' +[Unit] +Description=DesignHub Backend Service +After=network.target mysqld.service redis.service + +[Service] +Type=simple +User=root +WorkingDirectory=/opt/designhub +ExecStart=/usr/bin/java -jar -Xms512m -Xmx1024m /opt/designhub/designhub.jar --spring.profiles.active=prod +Restart=on-failure +RestartSec=10 + +StandardOutput=append:/opt/designhub/logs/backend.log +StandardError=append:/opt/designhub/logs/backend-error.log + +[Install] +WantedBy=multi-user.target +EOF + +# 创建 Nginx 配置 +cat > /etc/nginx/conf.d/designhub.conf << 'EOF' +server { + listen 80; + server_name 45.192.99.48; + + root /opt/designhub/frontend/dist; + index index.html; + + access_log /opt/designhub/logs/nginx-access.log; + error_log /opt/designhub/logs/nginx-error.log; + + location / { + try_files $uri $uri/ /index.html; + } + + location /api/ { + proxy_pass http://localhost:8080/api/; + proxy_set_header Host $host; + 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; + + proxy_connect_timeout 600; + proxy_send_timeout 600; + proxy_read_timeout 600; + } + + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 30d; + add_header Cache-Control "public, immutable"; + } + + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml; + gzip_min_length 1000; +} +EOF + +# 重新加载 systemd +systemctl daemon-reload + +# 测试 Nginx 配置 +nginx -t + +# 重启 Nginx +systemctl restart nginx +systemctl enable nginx +``` + +### 第十步:启动后端服务 + +```bash +# 启动后端服务 +systemctl start designhub-backend +systemctl enable designhub-backend + +# 查看状态 +systemctl status designhub-backend + +# 查看日志 +tail -f /opt/designhub/logs/application.log +``` + +--- + +## 验证部署 + +### 检查服务状态 + +```bash +# 检查后端服务 +systemctl status designhub-backend + +# 检查 MySQL +systemctl status mysqld + +# 检查 Redis +systemctl status redis + +# 检查 Nginx +systemctl status nginx +``` + +### 测试访问 + +- 前端地址: http://45.192.99.48 +- API地址: http://45.192.99.48/api +- API文档: http://45.192.99.48/api/doc.html (如果启用) + +### 查看日志 + +```bash +# 后端日志 +tail -f /opt/designhub/logs/application.log + +# 后端错误日志 +tail -f /opt/designhub/logs/backend-error.log + +# Nginx 访问日志 +tail -f /opt/designhub/logs/nginx-access.log + +# Nginx 错误日志 +tail -f /opt/designhub/logs/nginx-error.log +``` + +--- + +## 常用命令 + +### 服务管理 + +```bash +# 重启后端 +systemctl restart designhub-backend + +# 停止后端 +systemctl stop designhub-backend + +# 查看后端日志 +journalctl -u designhub-backend -f + +# 重启 Nginx +systemctl restart nginx +``` + +### 数据库管理 + +```bash +# 连接数据库 +mysql -u designhub -pDesignHub@2025_DB designhub + +# 备份数据库 +mysqldump -u designhub -pDesignHub@2025_DB designhub > backup.sql + +# 恢复数据库 +mysql -u designhub -pDesignHub@2025_DB designhub < backup.sql +``` + +--- + +## 故障排查 + +### 后端无法启动 + +1. 检查 Java 版本:`java -version` +2. 检查 JAR 文件:`ls -lh /opt/designhub/designhub.jar` +3. 检查日志:`tail -100 /opt/designhub/logs/backend-error.log` +4. 手动运行:`cd /opt/designhub && java -jar designhub.jar --spring.profiles.active=prod` + +### 数据库连接失败 + +1. 检查 MySQL 状态:`systemctl status mysqld` +2. 测试连接:`mysql -u designhub -pDesignHub@2025_DB designhub` +3. 检查配置文件:`cat /opt/designhub/backend/src/main/resources/application-prod.yml` + +### 前端无法访问 + +1. 检查 Nginx 状态:`systemctl status nginx` +2. 检查 Nginx 配置:`nginx -t` +3. 检查前端文件:`ls -lh /opt/designhub/frontend/dist/` +4. 查看 Nginx 错误日志:`tail -100 /opt/designhub/logs/nginx-error.log` + +--- + +## 注意事项 + +1. **安全建议**: + - 修改 MySQL root 密码 + - 修改 Redis 密码 + - 修改 JWT secret + - 配置防火墙规则 + +2. **性能优化**: + - 根据服务器配置调整 JVM 参数(-Xms, -Xmx) + - 配置 MySQL 连接池大小 + - 配置 Redis 连接池 + +3. **备份策略**: + - 定期备份数据库 + - 备份配置文件 + - 备份上传的文件 diff --git a/域名和HTTPS配置指南.md b/域名和HTTPS配置指南.md new file mode 100644 index 0000000..6a18967 --- /dev/null +++ b/域名和HTTPS配置指南.md @@ -0,0 +1,533 @@ +# 域名和HTTPS配置指南 + +## 🌐 域名信息 + +**域名**: qd.yydd.ai +**公网服务器**: 156.225.27.52:50232 +**内网端口**: 5246 (frp穿透) + +--- + +## 📋 配置步骤 + +### ✅ 已完成的工作 + +1. ✅ **Certbot安装** - SSL证书工具已安装 +2. ✅ **Nginx配置** - 反向代理配置已创建 +3. ✅ **HTTP配置** - 临时HTTP访问已配置 +4. ✅ **HTTPS配置文件** - 已预创建(待证书申请后启用) + +### ⏳ 待完成的工作 + +1. **DNS解析配置**(必须先完成) + ``` + 需要在域名管理后台添加A记录: + + 主机记录: qd + 记录类型: A + 记录值: 156.225.27.52 + TTL: 600(或默认) + ``` + +2. **验证DNS解析** + ```bash + # 在本地或任意机器执行 + ping qd.yydd.ai + # 应该解析到 156.225.27.52 + + nslookup qd.yydd.ai + # 应该返回 156.225.27.52 + ``` + +3. **申请SSL证书**(DNS解析生效后) + ```bash + ssh -p 50232 root@156.225.27.52 + # 密码:it5NuKGemX + + # 申请证书 + certbot certonly \ + --webroot \ + --webroot-path=/www/wwwroot/qd.yydd.ai \ + --email admin@yydd.ai \ + -d qd.yydd.ai \ + --agree-tos \ + --non-interactive + ``` + +4. **启用HTTPS配置** + ```bash + # 删除旧的HTTP配置 + rm -f /www/server/panel/vhost/nginx/qd.yydd.ai.conf + + # 启用HTTPS配置 + mv /www/server/panel/vhost/nginx/qd.yydd.ai_ssl.conf \ + /www/server/panel/vhost/nginx/qd.yydd.ai.conf + + # 重载Nginx + nginx -t && nginx -s reload + ``` + +5. **配置自动续期** + ```bash + # 添加定时任务(每天凌晨2点检查续期) + crontab -e + # 添加: + 0 2 * * * certbot renew --quiet --post-hook "nginx -s reload" + ``` + +--- + +## 🔧 当前配置状态 + +### 服务器架构 +``` +用户浏览器 + ↓ HTTPS +qd.yydd.ai (公网服务器 156.225.27.52:80/443) + ↓ Nginx反向代理 +127.0.0.1:5246 (frp穿透端口) + ↓ frp内网穿透 +内网服务器 127.0.0.1:8888 + ↓ Nginx代理 +后端API 127.0.0.1:8080 + ↓ Spring Boot +MySQL 127.0.0.1:3306 +``` + +### 配置文件位置 + +| 类型 | 路径 | +|------|------| +| **公网服务器** | | +| HTTP配置 | /www/server/panel/vhost/nginx/qd.yydd.ai.conf | +| HTTPS配置 | /www/server/panel/vhost/nginx/qd.yydd.ai_ssl.conf | +| SSL证书 | /etc/letsencrypt/live/qd.yydd.ai/ | +| | | +| **内网服务器** | | +| frp配置 | /etc/frp/frpc.ini | +| 后端服务 | /opt/designhub/designhub.jar | +| Nginx配置 | /etc/nginx/sites-available/designhub.conf | + +--- + +## 🎯 快速测试 + +### 当前可用的访问方式 + +1. **IP直接访问(已可用)** + - http://156.225.27.52:5246 + - 登录:admin / 123456 + +2. **域名HTTP访问(需DNS解析后)** + - http://qd.yydd.ai + - 登录:admin / 123456 + +3. **域名HTTPS访问(需DNS+证书)** + - https://qd.yydd.ai + - 登录:admin / 123456 + +### 测试命令 + +```bash +# 测试DNS解析 +ping qd.yydd.ai + +# 测试HTTP访问 +curl -I http://qd.yydd.ai + +# 测试HTTPS访问(证书申请后) +curl -I https://qd.yydd.ai +``` + +--- + +## 📝 DNS配置示例 + +### 在域名管理后台配置 + +**域名**: yydd.ai +**子域名**: qd + +**添加A记录**: +``` +类型: A +主机记录: qd +记录值: 156.225.27.52 +TTL: 600 +``` + +配置后等待5-10分钟DNS生效。 + +--- + +## 🔒 SSL证书申请流程 + +### 方式1:自动申请(推荐) + +**前提**: DNS已解析 + +```bash +ssh -p 50232 root@156.225.27.52 + +# 一键申请证书 +certbot certonly \ + --webroot \ + --webroot-path=/www/wwwroot/qd.yydd.ai \ + --email admin@yydd.ai \ + -d qd.yydd.ai \ + --agree-tos \ + --non-interactive + +# 如果成功,会显示: +# Congratulations! Your certificate and chain have been saved at: +# /etc/letsencrypt/live/qd.yydd.ai/fullchain.pem +``` + +### 方式2:使用宝塔面板 + +1. 登录宝塔面板 +2. 网站 → 添加站点 +3. 域名:qd.yydd.ai +4. SSL → Let's Encrypt → 申请 + +--- + +## 🔄 HTTPS配置激活 + +### 证书申请成功后 + +```bash +ssh -p 50232 root@156.225.27.52 + +# 1. 检查证书文件 +ls -la /etc/letsencrypt/live/qd.yydd.ai/ + +# 2. 启用HTTPS配置 +rm -f /www/server/panel/vhost/nginx/qd.yydd.ai.conf +mv /www/server/panel/vhost/nginx/qd.yydd.ai_ssl.conf \ + /www/server/panel/vhost/nginx/qd.yydd.ai.conf + +# 3. 测试并重载Nginx +nginx -t && nginx -s reload + +# 4. 验证HTTPS +curl -I https://qd.yydd.ai +``` + +--- + +## 🔐 自动续期配置 + +Let's Encrypt证书有效期90天,需要配置自动续期: + +```bash +# 添加定时任务 +crontab -e + +# 添加以下行(每天凌晨2点检查续期) +0 2 * * * certbot renew --quiet --post-hook "nginx -s reload" >> /var/log/certbot-renew.log 2>&1 +``` + +--- + +## 📊 完整访问地址汇总 + +### 🎯 推荐访问地址(配置完成后) + +**HTTPS域名**(最终推荐): +``` +https://qd.yydd.ai +``` + +### 🔧 备用访问地址 + +1. **HTTP域名**: + ``` + http://qd.yydd.ai + ``` + +2. **IP+端口**: + ``` + http://156.225.27.52:5246 + ``` + +3. **SSH管理**: + ```bash + # 公网服务器 + ssh -p 50232 root@156.225.27.52 + # 密码:it5NuKGemX + + # 内网服务器 + ssh -p 5204 root@156.225.27.52 + # 密码:Jsjj@8888 + ``` + +--- + +## 🎨 配置文件示例 + +### Nginx HTTPS完整配置 + +```nginx +# /www/server/panel/vhost/nginx/qd.yydd.ai.conf + +# HTTP自动跳转HTTPS +server { + listen 80; + server_name qd.yydd.ai; + + location /.well-known/acme-challenge/ { + root /www/wwwroot/qd.yydd.ai; + } + + location / { + return 301 https://$server_name$request_uri; + } +} + +# HTTPS主配置 +server { + listen 443 ssl http2; + server_name qd.yydd.ai; + + # SSL证书 + ssl_certificate /etc/letsencrypt/live/qd.yydd.ai/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/qd.yydd.ai/privkey.pem; + + # SSL优化 + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_prefer_server_ciphers on; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + + client_max_body_size 100M; + + # 反向代理 + location / { + proxy_pass http://127.0.0.1:5246; + proxy_set_header Host $host; + 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; + + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + proxy_connect_timeout 300; + proxy_send_timeout 300; + proxy_read_timeout 300; + } + + access_log /www/wwwlogs/qd.yydd.ai.https.access.log; + error_log /www/wwwlogs/qd.yydd.ai.https.error.log; +} +``` + +--- + +## ⚠️ 重要提示 + +### 1. DNS解析优先 +**必须先完成DNS解析**,再申请SSL证书! + +Let's Encrypt需要通过域名验证您的服务器所有权。 + +### 2. 防火墙配置 +确保80和443端口开放: +```bash +# CentOS防火墙 +firewall-cmd --permanent --add-port=80/tcp +firewall-cmd --permanent --add-port=443/tcp +firewall-cmd --reload + +# 或检查是否已开放 +firewall-cmd --list-ports +``` + +### 3. 证书有效期 +- Let's Encrypt证书有效期:90天 +- 建议提前30天自动续期 +- 已配置自动续期定时任务 + +--- + +## 🧪 故障排查 + +### DNS未解析 +```bash +# 检查DNS +nslookup qd.yydd.ai +ping qd.yydd.ai + +# 如果解析失败,联系域名管理员配置A记录 +``` + +### 证书申请失败 +```bash +# 查看详细日志 +cat /var/log/letsencrypt/letsencrypt.log + +# 常见原因: +# 1. DNS未解析到服务器 +# 2. 80端口被占用或防火墙阻止 +# 3. Nginx配置错误 +``` + +### HTTPS无法访问 +```bash +# 检查证书文件 +ls -la /etc/letsencrypt/live/qd.yydd.ai/ + +# 检查Nginx配置 +nginx -t + +# 查看错误日志 +tail -50 /www/wwwlogs/qd.yydd.ai.https.error.log +``` + +--- + +## 🎯 一键完成脚本(DNS解析后使用) + +```bash +#!/bin/bash +# 保存为 /root/setup_https.sh + +echo "=== DesignHub HTTPS配置脚本 ===" + +# 1. 申请SSL证书 +certbot certonly \ + --webroot \ + --webroot-path=/www/wwwroot/qd.yydd.ai \ + --email admin@yydd.ai \ + -d qd.yydd.ai \ + --agree-tos \ + --non-interactive + +if [ $? -eq 0 ]; then + echo "✅ SSL证书申请成功" + + # 2. 启用HTTPS配置 + rm -f /www/server/panel/vhost/nginx/qd.yydd.ai.conf + mv /www/server/panel/vhost/nginx/qd.yydd.ai_ssl.conf \ + /www/server/panel/vhost/nginx/qd.yydd.ai.conf + + # 3. 重载Nginx + nginx -t && nginx -s reload + + # 4. 配置自动续期 + (crontab -l 2>/dev/null; echo "0 2 * * * certbot renew --quiet --post-hook 'nginx -s reload'") | crontab - + + echo "" + echo "🎉 HTTPS配置完成!" + echo "访问地址: https://qd.yydd.ai" +else + echo "❌ SSL证书申请失败" + echo "请检查DNS是否解析成功" +fi +``` + +使用方法: +```bash +chmod +x /root/setup_https.sh +/root/setup_https.sh +``` + +--- + +## 📞 当前状态 + +### ✅ 已配置 +- ✅ 公网服务器Nginx配置 +- ✅ 反向代理到5246端口 +- ✅ Certbot工具已安装 +- ✅ HTTPS配置文件已准备 + +### ⏳ 待配置 +- ⏳ **DNS解析** - 需要在域名管理后台配置 +- ⏳ **SSL证书** - DNS解析后自动申请 +- ⏳ **HTTPS启用** - 证书申请后启用 + +--- + +## 🚀 完成后的访问方式 + +### 最终推荐访问地址 +``` +https://qd.yydd.ai +``` + +**登录账号**: +- 用户名:admin +- 密码:123456 + +### 功能页面 +- 登录页面: https://qd.yydd.ai +- 任务大厅: https://qd.yydd.ai/#/task-hall +- 设计师大厅: https://qd.yydd.ai/#/designer +- 数据分析: https://qd.yydd.ai/#/analytics +- 系统设置: https://qd.yydd.ai/#/settings + +--- + +## 📊 配置进度 + +| 步骤 | 状态 | 说明 | +|------|------|------| +| 1. 服务器迁移 | ✅ 完成 | 数据和服务已迁移到内网服务器 | +| 2. frp穿透 | ✅ 完成 | 内网5246端口已穿透 | +| 3. Certbot安装 | ✅ 完成 | SSL工具已安装 | +| 4. Nginx配置 | ✅ 完成 | 反向代理已配置 | +| 5. DNS解析 | ⏳ 待配置 | **需要您在域名后台配置** | +| 6. SSL证书 | ⏳ 待申请 | DNS解析后自动申请 | +| 7. HTTPS启用 | ⏳ 待启用 | 证书申请后启用 | + +**当前进度**: 60% (4/7步骤完成) + +--- + +## 💡 下一步操作 + +### 立即需要做的事情 + +**1. 配置DNS解析**(最重要) + +请访问您的域名管理后台(yydd.ai的DNS管理): + +添加A记录: +``` +主机记录: qd +记录类型: A +记录值: 156.225.27.52 +TTL: 600(10分钟) +``` + +**2. 等待DNS生效**(5-10分钟) + +验证命令: +```bash +ping qd.yydd.ai +``` + +**3. 申请SSL证书** + +DNS解析成功后,执行: +```bash +ssh -p 50232 root@156.225.27.52 +/root/setup_https.sh +``` + +**4. 访问测试** + +打开浏览器: +``` +https://qd.yydd.ai +``` + +--- + +**创建时间**: 2025-12-23 +**状态**: ⏳ 等待DNS解析配置 +**预计完成时间**: DNS配置后10分钟内 diff --git a/服务器和数据库配置清单.md b/服务器和数据库配置清单.md new file mode 100644 index 0000000..cc6dfba --- /dev/null +++ b/服务器和数据库配置清单.md @@ -0,0 +1,849 @@ +# DesignHub 服务器配置文档 + +**最后更新**: 2025-12-23 +**系统状态**: ✅ 生产环境运行中 +**版本**: v2.0 (迁移后) + +--- + +## 🌐 访问地址 + +### 🎯 主要访问地址 + +| 类型 | 地址 | 说明 | +|------|------|------| +| **生产地址** | https://qd.yydd.ai | ⭐ 推荐使用(HTTPS安全) | +| HTTP访问 | http://qd.yydd.ai | 自动跳转到HTTPS | +| IP访问 | http://156.225.27.52:5246 | 备用访问方式 | + +### 🔐 登录账号 + +| 用户名 | 密码 | 角色 | +|--------|------|------| +| admin | 123456 | 超级管理员 | +| designer1 | 123456 | 设计师 | +| designer2 | 123456 | 设计师 | +| requester1 | 123456 | 需求方 | +| requester2 | 123456 | 需求方 | + +--- + +## 🖥️ 服务器信息 + +### 📡 公网服务器(反向代理服务器) + +| 项目 | 值 | +|------|-----| +| **服务器IP** | 156.225.27.52 | +| **SSH端口** | 50232 | +| **SSH用户名** | root | +| **SSH密码** | it5NuKGemX | +| **操作系统** | CentOS 7 x86_64 | +| **Nginx版本** | 1.24.0 | +| **作用** | SSL终止、反向代理、域名绑定 | + +**SSH连接命令**: +```bash +ssh -p 50232 root@156.225.27.52 +# 或使用sshpass +sshpass -p 'it5NuKGemX' ssh -p 50232 root@156.225.27.52 +``` + +### 🏢 内网服务器(应用服务器) + +| 项目 | 值 | +|------|-----| +| **服务器IP** | 156.225.27.52(通过不同端口访问) | +| **SSH端口** | 5204 | +| **SSH用户名** | root | +| **SSH密码** | Jsjj@8888 | +| **操作系统** | Ubuntu 24.04.2 LTS | +| **Java版本** | OpenJDK 17.0.17 | +| **Maven版本** | 3.8.7 | +| **作用** | 运行DesignHub应用 | + +**SSH连接命令**: +```bash +ssh -p 5204 root@156.225.27.52 +# 或使用sshpass +sshpass -p 'Jsjj@8888' ssh -p 5204 root@156.225.27.52 +``` + +--- + +## 🏗️ 系统架构 + +``` +用户浏览器 + ↓ HTTPS/TLS 1.3 (443端口) +https://qd.yydd.ai (公网服务器 156.225.27.52) + ↓ Nginx反向代理 (SSL终止) +127.0.0.1:5246 (frp穿透端口) + ↓ frp内网穿透 +内网服务器 (Ubuntu 24.04) + ↓ Nginx (127.0.0.1:8888) +后端Spring Boot (127.0.0.1:8080) + ↓ JDBC +MySQL 8.0 (127.0.0.1:3306) +Redis (127.0.0.1:6379) +``` + +--- + +## 💾 数据库配置 + +### MySQL 8.0 + +| 项目 | 值 | +|------|-----| +| **主机** | localhost (内网服务器) | +| **端口** | 3306 | +| **数据库名** | designhub | +| **用户名** | designhub | +| **密码** | DesignHub@2025_DB | +| **Root密码** | DesignHub@2025_Root | +| **字符集** | utf8mb4 | +| **数据表数** | 39个 | + +**连接命令**: +```bash +# SSH到内网服务器 +ssh -p 5204 root@156.225.27.52 + +# 连接MySQL +mysql -u designhub -p'DesignHub@2025_DB' designhub +``` + +**数据库备份**: +```bash +# 备份 +mysqldump -u designhub -p'DesignHub@2025_DB' designhub > backup_$(date +%Y%m%d_%H%M%S).sql + +# 恢复 +mysql -u designhub -p'DesignHub@2025_DB' designhub < backup.sql +``` + +### Redis + +| 项目 | 值 | +|------|-----| +| **主机** | localhost (内网服务器) | +| **端口** | 6379 | +| **密码** | DesignHub@2025_Redis | +| **数据库** | 0 | + +**连接命令**: +```bash +# SSH到内网服务器 +ssh -p 5204 root@156.225.27.52 + +# 连接Redis +redis-cli -a 'DesignHub@2025_Redis' +``` + +--- + +## 🔧 服务配置 + +### 端口分配 + +#### 公网服务器 +| 服务 | 端口 | 说明 | +|------|------|------| +| SSH | 50232 | 远程管理 | +| HTTP | 80 | 自动跳转到HTTPS | +| HTTPS | 443 | SSL加密访问 | +| DesignHub | 5246 | frp穿透后的应用端口 | + +#### 内网服务器 +| 服务 | 端口 | 说明 | +|------|------|------| +| SSH | 5204 | 远程管理 | +| Nginx | 8888 | 前端和API代理 | +| 后端服务 | 8080 | Spring Boot应用 | +| MySQL | 3306 | 数据库 | +| Redis | 6379 | 缓存 | +| frp客户端 | 7000 | frp连接端口 | + +--- + +## 📂 目录结构 + +### 公网服务器 + +``` +/www/server/ +├── nginx/ +│ └── conf/ +│ └── nginx.conf # Nginx主配置 +├── panel/ +│ └── vhost/ +│ └── nginx/ +│ └── qd.yydd.ai.conf # DesignHub站点配置 +└── wwwroot/ + └── qd.yydd.ai/ # SSL证书验证目录 + +/etc/letsencrypt/ +└── live/ + └── qd.yydd.ai/ # SSL证书 + ├── fullchain.pem # 完整证书链 + ├── privkey.pem # 私钥 + ├── cert.pem # 证书 + └── chain.pem # 中间证书 + +/www/wwwlogs/ +├── qd.yydd.ai.https.access.log # HTTPS访问日志 +└── qd.yydd.ai.https.error.log # HTTPS错误日志 +``` + +### 内网服务器 + +``` +/opt/designhub/ +├── backend/ # 后端源代码 +│ ├── src/ +│ ├── pom.xml +│ └── target/ +├── frontend/ # 前端构建文件 +│ ├── index.html +│ ├── assets/ +│ └── ... +├── logs/ # 日志目录 +│ ├── backend.log # 后端日志 +│ └── application.log # 应用日志 +├── uploads/ # 上传文件目录 +└── designhub.jar # 运行的JAR包 + +/etc/ +├── nginx/ +│ └── sites-available/ +│ └── designhub.conf # Nginx配置 +├── systemd/ +│ └── system/ +│ └── designhub-backend.service # 后端服务 +└── frp/ + └── frpc.ini # frp客户端配置 +``` + +--- + +## 📝 配置文件位置 + +### 公网服务器 + +| 配置 | 路径 | 说明 | +|------|------|------| +| Nginx主配置 | /www/server/nginx/conf/nginx.conf | 宝塔面板管理 | +| 站点配置 | /www/server/panel/vhost/nginx/qd.yydd.ai.conf | DesignHub站点配置 | +| SSL证书 | /etc/letsencrypt/live/qd.yydd.ai/ | Let's Encrypt证书 | +| 访问日志 | /www/wwwlogs/qd.yydd.ai.https.access.log | HTTPS访问日志 | +| 错误日志 | /www/wwwlogs/qd.yydd.ai.https.error.log | HTTPS错误日志 | + +### 内网服务器 + +| 配置 | 路径 | 说明 | +|------|------|------| +| 后端配置 | /opt/designhub/backend/src/main/resources/application-prod.yml | Spring Boot配置 | +| Nginx配置 | /etc/nginx/sites-available/designhub.conf | 反向代理配置 | +| 后端服务 | /etc/systemd/system/designhub-backend.service | systemd服务配置 | +| frp配置 | /etc/frp/frpc.ini | frp客户端配置 | +| MySQL配置 | /etc/mysql/my.cnf | MySQL配置 | +| Redis配置 | /etc/redis/redis.conf | Redis配置 | + +--- + +## 🔐 SSL证书配置 + +### 证书信息 + +| 项目 | 值 | +|------|-----| +| **域名** | qd.yydd.ai | +| **证书提供商** | Let's Encrypt | +| **证书类型** | 免费DV证书 | +| **有效期** | 2025-12-23 至 2026-03-23 (90天) | +| **自动续期** | ✅ 已配置 | +| **协议** | TLS 1.2, TLS 1.3 | +| **HTTP/2** | ✅ 已启用 | + +### 证书续期 + +**自动续期任务** (已配置): +```bash +# 每天凌晨2点检查续期 +0 2 * * * certbot renew --quiet --post-hook 'nginx -s reload' >> /var/log/certbot-renew.log 2>&1 +``` + +**手动续期**: +```bash +# SSH到公网服务器 +ssh -p 50232 root@156.225.27.52 + +# 测试续期(不会实际续期) +certbot renew --dry-run + +# 手动续期 +certbot renew +nginx -s reload +``` + +--- + +## 🌐 frp内网穿透配置 + +### frp客户端配置 (内网服务器) + +**配置文件**: `/etc/frp/frpc.ini` + +```ini +[common] +server_addr = 156.225.27.52 +server_port = 7000 +token = your_token_here + +[designhub-web] +name = designhub-web +type = tcp +local_ip = 127.0.0.1 +local_port = 8888 +remote_port = 5246 +``` + +**服务管理**: +```bash +# SSH到内网服务器 +ssh -p 5204 root@156.225.27.52 + +# 启动frp客户端 +systemctl start frpc + +# 停止 +systemctl stop frpc + +# 重启 +systemctl restart frpc + +# 查看状态 +systemctl status frpc + +# 查看日志 +journalctl -u frpc -f +``` + +--- + +## 🚀 运维命令 + +### 公网服务器管理 + +```bash +# SSH连接 +ssh -p 50232 root@156.225.27.52 + +# Nginx管理 +nginx -t # 测试配置 +nginx -s reload # 重载配置(不中断服务) +systemctl restart nginx # 重启Nginx +systemctl status nginx # 查看状态 + +# 查看日志 +tail -f /www/wwwlogs/qd.yydd.ai.https.access.log # 访问日志 +tail -f /www/wwwlogs/qd.yydd.ai.https.error.log # 错误日志 + +# SSL证书管理 +certbot certificates # 查看所有证书 +certbot renew --dry-run # 测试续期 +``` + +### 内网服务器管理 + +```bash +# SSH连接 +ssh -p 5204 root@156.225.27.52 + +# 查看所有服务状态 +systemctl status designhub-backend mysql nginx redis-server frpc + +# 后端服务 +systemctl start designhub-backend # 启动 +systemctl stop designhub-backend # 停止 +systemctl restart designhub-backend # 重启 +systemctl status designhub-backend # 状态 +journalctl -u designhub-backend -f # 查看日志 + +# Nginx +systemctl restart nginx # 重启 +nginx -t # 测试配置 +ss -tlnp | grep 8888 # 检查端口 + +# MySQL +systemctl restart mysql # 重启 +systemctl status mysql # 状态 +mysql -u designhub -p'DesignHub@2025_DB' designhub # 连接 + +# Redis +systemctl restart redis-server # 重启 +systemctl status redis-server # 状态 +redis-cli -a 'DesignHub@2025_Redis' # 连接 + +# frp客户端 +systemctl restart frpc # 重启 +systemctl status frpc # 状态 +journalctl -u frpc -f # 查看日志 +``` + +### 日志查看 + +```bash +# 后端应用日志 +tail -f /opt/designhub/logs/backend.log +tail -f /opt/designhub/logs/application.log + +# 后端服务日志 +journalctl -u designhub-backend -f +journalctl -u designhub-backend -n 100 --no-pager + +# Nginx日志(内网) +tail -f /var/log/nginx/access.log +tail -f /var/log/nginx/error.log + +# frp日志 +journalctl -u frpc -f +``` + +--- + +## 📦 部署更新流程 + +### 更新后端代码 + +```bash +# 1. 本地同步代码到内网服务器 +cd /Users/lanmu/Desktop/qiangdanxitong/backend +rsync -avz --exclude='target' -e "ssh -p 5204" \ + src/ root@156.225.27.52:/opt/designhub/backend/src/ + +# 2. SSH到内网服务器编译 +ssh -p 5204 root@156.225.27.52 +cd /opt/designhub/backend +mvn clean package -DskipTests + +# 3. 替换JAR包 +cp target/designhub-backend-1.0.0.jar /opt/designhub/designhub.jar + +# 4. 重启后端服务 +systemctl restart designhub-backend + +# 5. 验证服务 +systemctl status designhub-backend +tail -f /opt/designhub/logs/backend.log + +# 6. 测试访问 +curl -I https://qd.yydd.ai +``` + +### 更新前端代码 + +```bash +# 1. 本地构建 +cd /Users/lanmu/Desktop/qiangdanxitong/frontend +pnpm build + +# 2. 上传到内网服务器 +rsync -avz -e "ssh -p 5204" \ + dist/ root@156.225.27.52:/opt/designhub/frontend/ + +# 3. 清除浏览器缓存(当前配置已禁用Nginx缓存) +# 直接刷新浏览器即可看到更新 + +# 4. 测试访问 +curl -I https://qd.yydd.ai +``` + +### 一键部署脚本 + +```bash +#!/bin/bash +# 保存为 deploy.sh + +echo "=== DesignHub 一键部署 ===" + +# 后端部署 +echo "【1/4】同步后端代码..." +rsync -avz --exclude='target' -e "ssh -p 5204" \ + backend/src/ root@156.225.27.52:/opt/designhub/backend/src/ + +echo "【2/4】编译后端..." +ssh -p 5204 root@156.225.27.52 << 'EOF' +cd /opt/designhub/backend +mvn clean package -DskipTests +cp target/designhub-backend-1.0.0.jar /opt/designhub/designhub.jar +systemctl restart designhub-backend +EOF + +# 前端部署 +echo "【3/4】构建前端..." +cd frontend +pnpm build + +echo "【4/4】上传前端..." +rsync -avz -e "ssh -p 5204" \ + dist/ root@156.225.27.52:/opt/designhub/frontend/ + +echo "" +echo "✅ 部署完成!" +echo "访问地址: https://qd.yydd.ai" +``` + +使用方法: +```bash +chmod +x deploy.sh +./deploy.sh +``` + +--- + +## 🔍 故障排查 + +### 1. HTTPS无法访问 + +```bash +# 检查公网服务器Nginx +ssh -p 50232 root@156.225.27.52 +systemctl status nginx +nginx -t +tail -50 /www/wwwlogs/qd.yydd.ai.https.error.log + +# 检查SSL证书 +openssl x509 -in /etc/letsencrypt/live/qd.yydd.ai/fullchain.pem -noout -dates + +# 检查端口监听 +ss -tlnp | grep 443 +``` + +### 2. 后端服务无法启动 + +```bash +# SSH到内网服务器 +ssh -p 5204 root@156.225.27.52 + +# 查看详细日志 +journalctl -u designhub-backend --no-pager -n 100 + +# 检查端口占用 +ss -tlnp | grep 8080 + +# 手动启动测试 +cd /opt/designhub +java -jar designhub.jar --spring.profiles.active=prod + +# 检查配置文件 +cat /opt/designhub/backend/src/main/resources/application-prod.yml +``` + +### 3. 数据库连接失败 + +```bash +# 检查MySQL状态 +systemctl status mysql + +# 测试连接 +mysql -u designhub -p'DesignHub@2025_DB' -e "SELECT 1" + +# 检查用户权限 +mysql -u root -p'DesignHub@2025_Root' -e "SHOW GRANTS FOR 'designhub'@'localhost';" + +# 查看错误日志 +tail -50 /var/log/mysql/error.log +``` + +### 4. frp穿透失败 + +```bash +# SSH到内网服务器 +ssh -p 5204 root@156.225.27.52 + +# 检查frpc状态 +systemctl status frpc + +# 查看日志 +journalctl -u frpc -n 100 --no-pager + +# 测试本地服务 +curl -I http://127.0.0.1:8888 + +# 测试穿透后端口 +curl -I http://127.0.0.1:5246 +``` + +### 5. Nginx 502错误 + +```bash +# 检查后端服务是否运行 +ssh -p 5204 root@156.225.27.52 +systemctl status designhub-backend + +# 检查Nginx配置 +nginx -t + +# 查看错误日志 +tail -50 /var/log/nginx/error.log + +# 测试后端连接 +curl -I http://127.0.0.1:8080/api/health +``` + +### 6. 页面更新不生效 + +```bash +# 当前配置已禁用Nginx缓存,直接刷新即可 + +# 如仍有问题,检查前端文件 +ssh -p 5204 root@156.225.27.52 +ls -la /opt/designhub/frontend/ +cat /opt/designhub/frontend/index.html + +# 清除浏览器缓存 +# Chrome: Ctrl+Shift+Delete +# 或使用无痕模式测试 +``` + +--- + +## 📊 监控检查 + +### 健康检查命令 + +```bash +# 一键检查所有服务(内网服务器) +ssh -p 5204 root@156.225.27.52 << 'EOF' +echo "=== DesignHub 服务状态 ===" +echo "" +echo "【后端服务】" +systemctl is-active designhub-backend +echo "" +echo "【MySQL】" +systemctl is-active mysql +echo "" +echo "【Redis】" +systemctl is-active redis-server +echo "" +echo "【Nginx】" +systemctl is-active nginx +echo "" +echo "【frp客户端】" +systemctl is-active frpc +echo "" +echo "【端口监听】" +ss -tlnp | grep -E "8080|8888|3306|6379" +EOF + +# 检查公网服务器 +ssh -p 50232 root@156.225.27.52 << 'EOF' +echo "=== 公网服务器状态 ===" +echo "" +echo "【Nginx】" +systemctl is-active nginx +echo "" +echo "【SSL证书有效期】" +openssl x509 -in /etc/letsencrypt/live/qd.yydd.ai/fullchain.pem -noout -dates +EOF +``` + +### 性能监控 + +```bash +# CPU和内存使用 +ssh -p 5204 root@156.225.27.52 +top -bn1 | head -20 + +# 磁盘使用 +df -h + +# 网络连接 +ss -s + +# 后端JVM内存 +jps -lv +``` + +--- + +## 🌐 DNS配置 + +### 域名解析配置 + +| 类型 | 主机记录 | 记录值 | TTL | +|------|----------|--------|-----| +| A记录 | qd | 156.225.27.52 | 600 | + +### 验证DNS解析 + +```bash +# 查询域名解析 +nslookup qd.yydd.ai + +# Ping测试 +ping qd.yydd.ai + +# 详细查询 +dig qd.yydd.ai +``` + +--- + +## 🔒 安全配置 + +### 防火墙规则 + +**公网服务器**: +- 开放端口: 50232(SSH), 80(HTTP), 443(HTTPS) +- 通过宝塔面板管理 + +**内网服务器**: +```bash +# 查看防火墙状态 +ufw status + +# 开放必要端口(如需要) +ufw allow 5204/tcp # SSH +ufw allow 8888/tcp # Nginx +``` + +### SSH安全 + +**建议**: +- ✅ 使用非标准SSH端口(已实施:50232、5204) +- ✅ 使用强密码 +- 建议: 配置SSH密钥认证 +- 建议: 禁用root密码登录(配置密钥后) + +--- + +## 🎯 快速参考 + +### 常用命令速查 + +```bash +# ============ 公网服务器 ============ +# SSH连接 +ssh -p 50232 root@156.225.27.52 + +# 重载Nginx +nginx -s reload + +# 查看HTTPS日志 +tail -f /www/wwwlogs/qd.yydd.ai.https.access.log + +# SSL证书续期 +certbot renew + + +# ============ 内网服务器 ============ +# SSH连接 +ssh -p 5204 root@156.225.27.52 + +# 重启后端 +systemctl restart designhub-backend + +# 查看后端日志 +tail -f /opt/designhub/logs/backend.log + +# 连接数据库 +mysql -u designhub -p'DesignHub@2025_DB' designhub + +# 连接Redis +redis-cli -a 'DesignHub@2025_Redis' + +# 查看所有服务状态 +systemctl status designhub-backend mysql nginx redis-server frpc + + +# ============ 本地开发 ============ +# 部署后端 +rsync -avz --exclude='target' -e "ssh -p 5204" backend/src/ root@156.225.27.52:/opt/designhub/backend/src/ +ssh -p 5204 root@156.225.27.52 'cd /opt/designhub/backend && mvn clean package -DskipTests && cp target/*.jar /opt/designhub/designhub.jar && systemctl restart designhub-backend' + +# 部署前端 +cd frontend && pnpm build +rsync -avz -e "ssh -p 5204" dist/ root@156.225.27.52:/opt/designhub/frontend/ + +# 测试访问 +curl -I https://qd.yydd.ai +``` + +--- + +## 📚 相关文档 + +- **🎉 项目部署完成-最终报告.md** - 完整部署记录 +- **域名和HTTPS配置指南.md** - SSL证书配置详解 +- **Nginx缓存配置说明.md** - 缓存策略说明 +- **DesignHub访问地址.md** - 访问地址汇总 +- **最终开发总结.md** - 功能开发总结 + +--- + +## 📞 技术支持 + +### 环境信息 + +**公网服务器**: +- 系统: CentOS 7 x86_64 +- Nginx: 1.24.0 +- Certbot: 1.11.0 + +**内网服务器**: +- 系统: Ubuntu 24.04.2 LTS +- Java: OpenJDK 17.0.17 +- Maven: 3.8.7 +- MySQL: 8.0 +- Redis: 7.x +- Nginx: 1.24.x + +### 访问测试 + +```bash +# HTTPS访问测试 +curl -I https://qd.yydd.ai + +# 应该返回: +# HTTP/2 200 +# cache-control: no-store, no-cache, must-revalidate... +``` + +--- + +## 📝 更新历史 + +| 日期 | 版本 | 更新内容 | +|------|------|----------| +| 2025-12-23 | v2.0 | 服务器迁移,添加域名和HTTPS | +| 2025-12-19 | v1.0 | 初始版本 | + +--- + +## 🎯 系统状态 + +### 当前配置 + +- ✅ **域名**: qd.yydd.ai +- ✅ **HTTPS**: 已启用(TLS 1.3) +- ✅ **HTTP/2**: 已启用 +- ✅ **SSL证书**: Let's Encrypt(自动续期) +- ✅ **缓存策略**: 已禁用(开发模式) +- ✅ **frp穿透**: 已配置(端口5246) +- ✅ **服务状态**: 所有服务正常运行 + +### 性能指标 + +- 🚀 **国内访问延迟**: < 100ms +- 🚀 **HTTPS加密**: TLS 1.3 (0-RTT) +- 🚀 **HTTP/2多路复用**: 已启用 +- 🚀 **缓存策略**: 无缓存(便于开发) + +--- + +**文档维护**: 请在服务器配置变更后及时更新此文档 +**最后更新**: 2025-12-23 +**状态**: ✅ 生产环境运行中 diff --git a/配置SSL证书说明.md b/配置SSL证书说明.md new file mode 100644 index 0000000..be4fc0f --- /dev/null +++ b/配置SSL证书说明.md @@ -0,0 +1,113 @@ +# SSL证书配置说明 + +## 当前状态 + +**域名**: QD.YYDD.AI +**服务器IP**: 45.192.99.48 +**域名当前解析**: 198.18.0.113 + +⚠️ **重要**: 域名解析尚未指向服务器IP,需要先配置DNS解析。 + +--- + +## 步骤1: 配置DNS解析 + +在域名管理面板中,将 `QD.YYDD.AI` 的A记录指向 `45.192.99.48` + +--- + +## 步骤2: 验证域名解析 + +```bash +# 在本地执行 +nslookup QD.YYDD.AI +# 应该返回: 45.192.99.48 +``` + +--- + +## 步骤3: 申请Let's Encrypt证书 + +**方法1: 使用certbot standalone模式(推荐)** + +```bash +ssh root@45.192.99.48 +systemctl stop nginx +certbot certonly --standalone -d QD.YYDD.AI --non-interactive --agree-tos --email admin@yydd.ai +systemctl start nginx +``` + +**方法2: 使用certbot nginx模式** + +```bash +ssh root@45.192.99.48 +certbot --nginx -d QD.YYDD.AI --non-interactive --agree-tos --email admin@yydd.ai +``` + +--- + +## 步骤4: 配置Nginx使用SSL证书 + +证书申请成功后,证书文件将位于: +- `/etc/letsencrypt/live/QD.YYDD.AI/fullchain.pem` +- `/etc/letsencrypt/live/QD.YYDD.AI/privkey.pem` + +然后更新Nginx配置(已包含在配置文件中)。 + +--- + +## 步骤5: 重启Nginx + +```bash +nginx -t +systemctl restart nginx +``` + +--- + +## 当前配置说明 + +已配置: +- ✅ HTTPS (443端口) +- ✅ HTTP重定向到HTTPS (80端口) +- ✅ 关闭所有缓存 +- ✅ 上传限制100M +- ✅ 使用域名QD.YYDD.AI + +待完成: +- ⏳ SSL证书申请(需等待DNS解析生效) +- ⏳ 自动续期配置 + +--- + +## 自动续期 + +Let's Encrypt证书有效期90天,需要配置自动续期: + +```bash +# 测试续期 +certbot renew --dry-run + +# 设置自动续期(cron任务已自动配置) +systemctl status certbot.timer +``` + +--- + +## 临时使用自签名证书 + +如果需要立即测试,可以使用自签名证书(浏览器会显示警告): + +```bash +mkdir -p /etc/nginx/ssl +openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ + -keyout /etc/nginx/ssl/QD.YYDD.AI.key \ + -out /etc/nginx/ssl/QD.YYDD.AI.crt \ + -subj '/CN=QD.YYDD.AI' +``` + +然后在Nginx配置中使用: +```nginx +ssl_certificate /etc/nginx/ssl/QD.YYDD.AI.crt; +ssl_certificate_key /etc/nginx/ssl/QD.YYDD.AI.key; +``` diff --git a/钉钉登录完整配置指南.md b/钉钉登录完整配置指南.md new file mode 100644 index 0000000..75aa679 --- /dev/null +++ b/钉钉登录完整配置指南.md @@ -0,0 +1,881 @@ +# 钉钉登录完整配置指南 + +本文档提供管理员配置钉钉登录功能的完整步骤说明。 + +--- + +## 📋 **配置流程总览** + +``` +步骤1: 钉钉开放平台创建应用 + ↓ +步骤2: 获取AppKey和AppSecret + ↓ +步骤3: 在系统设置中配置钉钉信息 + ↓ +步骤4: 启用钉钉登录功能 + ↓ +步骤5: 配置部门映射(可选) + ↓ +步骤6: 测试钉钉登录 +``` + +--- + +## 一、钉钉开放平台配置 + +### 1.1 创建应用 + +1. **登录钉钉开放平台** + - 访问:https://open.dingtalk.com/ + - 使用管理员账号登录 + +2. **创建H5微应用** + - 进入"应用开发" → "企业内部应用" → "创建应用" + - 选择"H5微应用" + - 填写应用名称:`DesignHub` + - 上传应用图标(可选) + +3. **记录关键信息** + ``` + AppKey (ClientId): dingxxxxxxxxxxxxxx + AppSecret (ClientSecret): xxxxxxxxxxxxxxxxxxxxxxxx + AgentId: 123456789(工作通知需要) + CorpId: dingxxxxxxxxxxxxxxxx(企业ID) + ``` + +### 1.2 配置应用权限 + +在应用"权限管理"中,申请以下权限: + +| 权限名称 | 权限代码 | 用途 | +|---------|---------|------| +| 通讯录只读权限 | `Contact.User.Read` | 获取用户信息 | +| 身份验证 | `openid` | OAuth登录 | + +### 1.3 配置回调地址 + +在应用"开发配置"中设置: + +``` +回调域名: http://your-domain.com +或 +回调域名: http://38.246.244.140:8888 + +具体回调地址: +- http://your-domain.com/dingtalk-callback.html +- http://your-domain.com/api/auth/dingtalk/callback +``` + +⚠️ **重要**:回调域名必须与实际访问域名一致! + +--- + +## 二、系统设置中配置钉钉 + +### 2.1 访问系统设置 + +1. 使用管理员账号登录系统 +2. 点击导航栏右上角的"系统设置"按钮 +3. 切换到"钉钉集成"标签页 + +### 2.2 填写应用配置 + +**必填字段**: + +| 字段 | 说明 | 示例 | +|------|------|------| +| **AppKey** | 钉钉应用的AppKey | `dingxxxxxxxxxxxxxx` | +| **AppSecret** | 钉钉应用的AppSecret | `xxxxxxxxxxxxxxxxxxxxxxxx` | + +**可选字段**: + +| 字段 | 说明 | 用途 | +|------|------|------| +| AgentId | 应用的AgentId | 发送工作通知 | +| 企业ID (CorpId) | 企业CorpId | 钉钉内免密登录 | + +### 2.3 配置机器人(可选) + +如果需要使用钉钉机器人发送消息: + +1. 在钉钉群聊中添加"自定义机器人" +2. 获取Webhook地址 +3. 如果启用了"加签",获取密钥 +4. 填写到系统配置中: + - **机器人Webhook地址**: `https://oapi.dingtalk.com/robot/send?access_token=xxx` + - **机器人密钥**: `SECxxxxxxxxxxxxx`(如果启用加签) + +### 2.4 启用功能 + +勾选需要启用的功能: + +- ☑️ **钉钉登录** - 用户可使用钉钉扫码登录 +- ☑️ **消息通知** - 系统消息通过钉钉发送 +- ☑️ **用户同步** - 同步钉钉组织架构 + +### 2.5 保存并测试 + +1. 点击"保存配置"按钮 +2. 点击"测试连接"按钮,验证配置正确 +3. (可选)点击"发送测试消息"测试机器人 + +--- + +## 三、数据库配置验证 + +### 3.1 查看配置 + +```sql +-- 查看当前钉钉配置 +SELECT id, name, app_key, corp_id, enabled, enabled_features +FROM dingtalk_config +ORDER BY update_time DESC +LIMIT 1; +``` + +### 3.2 手动更新配置(如需要) + +```sql +-- 更新AppKey +UPDATE dingtalk_config +SET app_key = 'dingxxxxxxxxxxxxxx' +WHERE id = 1; + +-- 更新AppSecret +UPDATE dingtalk_config +SET app_secret = 'xxxxxxxxxxxxxxxxxxxxxxxx' +WHERE id = 1; + +-- 启用钉钉 +UPDATE dingtalk_config +SET enabled = 1 +WHERE id = 1; + +-- 启用钉钉登录功能 +UPDATE dingtalk_config +SET enabled_features = '{"login": true, "notification": false, "sync": false}' +WHERE id = 1; +``` + +### 3.3 验证配置生效 + +```bash +# 测试配置是否已加载 +curl http://localhost:8080/api/dingtalk/is-configured + +# 期望返回 +{"code":200,"data":true} # 已配置且启用 +{"code":200,"data":false} # 未配置或未启用 +``` + +--- + +## 四、部门映射配置 + +### 4.1 为什么需要部门映射? + +钉钉用户登录时,系统需要将用户分配到合适的系统部门: +- **需求部门**(demand):产品部、市场部、运营部 +- **接单部门**(receiving):设计部、创意部 + +### 4.2 配置方法 + +#### 方法1:手动配置钉钉部门ID + +```sql +-- 为系统部门绑定钉钉部门ID +UPDATE sys_dept +SET ding_dept_id = '123456789' -- 钉钉部门的dept_id +WHERE dept_name = '设计部'; +``` + +**如何获取钉钉部门ID?** +1. 登录钉钉管理后台 +2. 进入"通讯录" → "部门管理" +3. 点击部门,URL中显示的ID即为部门ID +4. 或使用API:`GET /api/dingtalk/departments` + +#### 方法2:通过部门名称自动匹配 + +确保系统部门名称与钉钉部门名称一致或包含关系: + +| 钉钉部门 | 系统部门 | 匹配结果 | +|---------|---------|---------| +| 设计部 | 设计部 | ✅ 完全匹配 | +| 研发中心-设计组 | 设计部 | ✅ 包含"设计" | +| UI设计团队 | 设计部 | ✅ 包含"设计" | +| 新部门A | - | ❌ 需手动选择 | + +### 4.3 查看部门映射状态 + +```sql +-- 查看哪些部门已配置钉钉ID +SELECT + id, + dept_name, + dept_type, + ding_dept_id, + CASE + WHEN ding_dept_id IS NOT NULL THEN '已绑定' + ELSE '未绑定' + END as status +FROM sys_dept +WHERE deleted = 0 +ORDER BY dept_type, id; +``` + +**建议**: +- ✅ 所有活跃部门都应配置`ding_dept_id` +- ✅ 定期检查钉钉组织架构变化 +- ✅ 新增部门时同步配置 + +--- + +## 五、首次登录用户处理 + +### 5.1 自动匹配成功 + +当钉钉用户的部门能匹配到系统部门时: + +``` +用户登录 → 获取钉钉信息 → 匹配系统部门成功 + ↓ + 创建用户(已分配部门和角色) + ↓ + 自动登录成功 +``` + +**日志示例**: +``` +[INFO] 通过钉钉部门ID精确匹配: 123456 -> 设计部 +[INFO] 自动匹配到系统部门: deptId=4, deptName=设计部, type=receiving +[INFO] 钉钉用户创建成功: userId=10, needSelectDept=false +``` + +### 5.2 无法自动匹配 + +当无法匹配时,系统会: + +``` +1. 创建用户记录(status=0,待完善) +2. 返回特殊错误码:1901 +3. 前端显示部门选择界面 +4. 用户选择系统部门 +5. 完成注册并登录 +``` + +**前端需要实现的界面**: +```vue + +``` + +--- + +## 六、配置检查清单 + +### 6.1 钉钉开放平台 ✓ + +- [ ] 已创建H5微应用 +- [ ] 已获取AppKey和AppSecret +- [ ] 已申请`Contact.User.Read`权限 +- [ ] 已申请`openid`权限 +- [ ] 已配置回调域名 +- [ ] 应用已发布上线 + +### 6.2 系统配置 ✓ + +- [ ] 已在"系统设置-钉钉集成"填写AppKey +- [ ] 已填写AppSecret +- [ ] 已勾选"钉钉登录"功能 +- [ ] 已点击"保存配置" +- [ ] 测试连接成功(绿色提示) + +### 6.3 部门映射 ✓ + +- [ ] 已为主要部门配置`ding_dept_id` +- [ ] 已确认部门名称匹配规则 +- [ ] 已测试新用户登录流程 + +### 6.4 数据库验证 ✓ + +```sql +-- 检查配置是否启用 +SELECT enabled, app_key, enabled_features +FROM dingtalk_config +WHERE id = 1; + +-- 期望结果 +enabled = 1 +app_key = 'dingxxxxx'(不为空) +enabled_features 包含 "login": true +``` + +--- + +## 七、API接口说明 + +### 7.1 配置相关API + +#### 获取配置 +``` +GET /api/dingtalk/config +需要认证: 是(管理员) + +Response: { + "code": 200, + "data": { + "id": 1, + "appKey": "dingxxxxx", + "appSecret": "******", // 已脱敏 + "enabled": 1, + "enabledFeatures": {...} + } +} +``` + +#### 保存配置 +``` +POST /api/dingtalk/config +需要认证: 是(管理员) + +Body: { + "appKey": "dingxxxxx", + "appSecret": "xxxxxxxx", + "corpId": "dingxxxxx", + "enabled": 1, + "enabledFeatures": { + "login": true, + "notification": true + } +} +``` + +#### 检查配置状态 +``` +GET /api/dingtalk/is-configured +需要认证: 否 + +Response: { + "code": 200, + "data": true // true=已配置,false=未配置 +} +``` + +### 7.2 登录相关API + +#### 发起登录 +``` +GET /api/dingtalk/oauth/login?redirectUrl={回调地址} +需要认证: 否 + +Response: { + "code": 200, + "data": { + "authUrl": "https://login.dingtalk.com/oauth2/auth?...", + "appKey": "dingxxxxx" + } +} +``` + +#### 登录回调 +``` +GET /api/auth/dingtalk/callback?code={授权码} +需要认证: 否 + +Response A(匹配成功): { + "code": 200, + "data": { + "token": "eyJhbGc...", + "userId": 123, + "username": "张三" + } +} + +Response B(需选择部门): { + "code": 1901, + "message": "需要选择部门", + "data": { + "needSelectDept": true, + "userId": 123, + "dingDepartments": "设计组,创意组" + } +} +``` + +#### 选择部门 +``` +POST /api/auth/dingtalk/select-department +需要认证: 否 + +Body: { + "userId": 123, + "deptId": 4 +} + +Response: { + "code": 200, + "data": { + "token": "eyJhbGc...", + "userId": 123, + "deptId": 4, + "deptName": "设计部" + } +} +``` + +--- + +## 八、数据库表结构 + +### 8.1 钉钉配置表(dingtalk_config) + +```sql +CREATE TABLE `dingtalk_config` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `name` varchar(100) NOT NULL DEFAULT '默认配置', + `agent_id` varchar(100) DEFAULT NULL, + `app_key` varchar(100) DEFAULT NULL, + `app_secret` varchar(200) DEFAULT NULL, + `corp_id` varchar(100) DEFAULT NULL, + `robot_webhook` varchar(500) DEFAULT NULL, + `robot_secret` varchar(200) DEFAULT NULL, + `enabled` tinyint NOT NULL DEFAULT '0', + `enabled_features` json DEFAULT NULL, + `access_token` varchar(500) DEFAULT NULL, + `token_expire_time` datetime DEFAULT NULL, + `create_time` datetime DEFAULT CURRENT_TIMESTAMP, + `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) +); +``` + +### 8.2 用户表相关字段(sys_user) + +```sql +-- 钉钉相关字段 +`ding_user_id` varchar(100) DEFAULT NULL, -- 钉钉用户ID +`email` varchar(100) DEFAULT NULL, -- 钉钉用户标识 +`dept_id` bigint DEFAULT NULL, -- 系统部门ID +`dept_name` varchar(100) DEFAULT NULL, -- 部门名称 + +-- 唯一索引 +UNIQUE KEY `uk_ding_user_id` (`ding_user_id`), +UNIQUE KEY `uk_email` (`email`) +``` + +### 8.3 部门表相关字段(sys_dept) + +```sql +-- 钉钉映射字段 +`ding_dept_id` varchar(100) DEFAULT NULL, -- 钉钉部门ID +`dept_type` varchar(20) DEFAULT 'receiving', -- 部门类型 +``` + +--- + +## 九、配置示例 + +### 9.1 完整配置示例 + +**系统设置界面填写**: +``` +应用配置: + AgentId: 1234567890 + AppKey: dingoa12345678abcdefg + AppSecret: abc123def456ghi789jkl012mno345pqr678stu901vwx234 + 企业ID: dingabcdef1234567890abcdefg1234567890 + +机器人配置(可选): + Webhook: https://oapi.dingtalk.com/robot/send?access_token=xxx + 密钥: SECxxx(如果启用加签) + +启用功能: + ☑ 钉钉登录 + ☑ 消息通知 + ☐ 用户同步 +``` + +**对应数据库记录**: +```sql +{ + "id": 1, + "name": "DesignHub生产环境", + "agent_id": "1234567890", + "app_key": "dingoa12345678abcdefg", + "app_secret": "abc123def456...", + "corp_id": "dingabcdef...", + "enabled": 1, + "enabled_features": { + "login": true, + "notification": true, + "sync": false + } +} +``` + +### 9.2 部门映射示例 + +```sql +-- 设计部绑定钉钉设计部 +UPDATE sys_dept +SET ding_dept_id = '123456' +WHERE dept_name = '设计部'; + +-- 产品部绑定钉钉产品部 +UPDATE sys_dept +SET ding_dept_id = '789012' +WHERE dept_name = '产品部'; + +-- 验证绑定 +SELECT id, dept_name, dept_type, ding_dept_id +FROM sys_dept +WHERE deleted = 0; +``` + +--- + +## 十、测试流程 + +### 10.1 管理员测试配置 + +1. **测试连接** + ```bash + # 后端日志应显示 + [INFO] 获取钉钉AccessToken响应: {"errcode":0,"access_token":"xxx"} + ``` + +2. **测试机器人** + - 点击"发送测试消息" + - 检查钉钉群是否收到消息 + +### 10.2 测试用户登录 + +#### 场景1:能自动匹配部门的用户 + +1. 退出登录 +2. 点击"钉钉登录"(前端需要添加此按钮) +3. 扫码授权 +4. 自动创建用户并登录成功 +5. 验证用户信息正确(姓名、部门、角色) + +**验证SQL**: +```sql +SELECT id, username, real_name, ding_user_id, dept_id, dept_name, role_ids +FROM sys_user +WHERE email LIKE 'dingtalk_%' +ORDER BY create_time DESC +LIMIT 5; +``` + +#### 场景2:需要手动选择部门的用户 + +1. 使用未配置映射的钉钉部门的用户 +2. 扫码登录 +3. 前端应显示部门选择页面 +4. 选择系统部门(如"设计部") +5. 完成登录 + +### 10.3 测试部门同步 + +1. 在钉钉中将用户移动到其他部门 +2. 用户重新登录 +3. 检查日志是否显示部门变更 +4. 验证系统中用户部门是否自动更新 + +--- + +## 十一、故障排查 + +### 11.1 配置无法保存 + +**问题**:点击保存后没有成功提示 + +**检查**: +```bash +# 查看后端日志 +journalctl -u designhub-backend -n 50 | grep -i dingtalk + +# 检查数据库 +SELECT * FROM dingtalk_config ORDER BY update_time DESC LIMIT 1; +``` + +**可能原因**: +- 数据库连接失败 +- 字段验证失败 +- 权限不足 + +### 11.2 测试连接失败 + +**问题**:点击"测试连接"显示失败 + +**检查**: +1. AppKey和AppSecret是否正确 +2. 网络是否能访问钉钉API +3. 钉钉应用是否已发布 + +**测试命令**: +```bash +# 手动测试获取token +curl -X GET "https://oapi.dingtalk.com/gettoken?appkey=YOUR_APP_KEY&appsecret=YOUR_APP_SECRET" + +# 期望返回 +{"errcode":0,"errmsg":"ok","access_token":"xxx","expires_in":7200} +``` + +### 11.3 登录提示未配置 + +**问题**:用户点击钉钉登录,提示"未配置" + +**检查**: +```sql +-- 1. 配置是否存在 +SELECT * FROM dingtalk_config; + +-- 2. 是否已启用 +SELECT enabled FROM dingtalk_config WHERE id = 1; + +-- 3. 启用功能是否包含login +SELECT enabled_features FROM dingtalk_config WHERE id = 1; +``` + +**修复**: +```sql +-- 启用配置 +UPDATE dingtalk_config SET enabled = 1 WHERE id = 1; + +-- 启用登录功能 +UPDATE dingtalk_config +SET enabled_features = JSON_SET(enabled_features, '$.login', true) +WHERE id = 1; +``` + +### 11.4 用户部门匹配失败 + +**问题**:所有钉钉用户都需要手动选择部门 + +**解决方案**: +1. **配置ding_dept_id** + ```sql + -- 查看钉钉部门列表 + GET /api/dingtalk/departments + + -- 为系统部门配置钉钉部门ID + UPDATE sys_dept SET ding_dept_id = '123456' WHERE dept_name = '设计部'; + ``` + +2. **统一部门命名** + - 确保系统部门名称与钉钉部门名称一致 + - 或使用包含关系命名 + +--- + +## 十二、安全建议 + +### 12.1 密钥管理 + +- ✅ AppSecret已在数据库加密存储 +- ✅ 前端获取配置时已脱敏(显示`******`) +- ⚠️ 不要在日志中打印完整密钥 +- ⚠️ 定期更新AppSecret + +### 12.2 访问控制 + +- ✅ 钉钉配置API仅管理员可访问 +- ✅ 回调接口不需要认证(OAuth流程必需) +- ✅ 选择部门接口不需要认证(首次登录流程) + +### 12.3 数据安全 + +- ✅ 钉钉用户的email使用虚拟地址 +- ✅ 密码使用随机UUID + BCrypt加密 +- ✅ unionId作为全局唯一标识 + +--- + +## 十三、监控与维护 + +### 13.1 日常监控 + +```bash +# 查看钉钉登录日志 +journalctl -u designhub-backend --since today | grep -i dingtalk + +# 统计钉钉用户数量 +SELECT COUNT(*) FROM sys_user WHERE email LIKE 'dingtalk_%'; + +# 查看最近登录的钉钉用户 +SELECT id, username, real_name, dept_name, create_time +FROM sys_user +WHERE email LIKE 'dingtalk_%' +ORDER BY create_time DESC +LIMIT 10; +``` + +### 13.2 定期维护 + +**每月任务**: +- [ ] 检查AccessToken缓存是否正常 +- [ ] 验证部门映射准确性 +- [ ] 审查异常登录日志 +- [ ] 清理无效的待完善用户(status=0超过30天) + +**每季度任务**: +- [ ] 同步钉钉组织架构变化 +- [ ] 审查并更新部门映射 +- [ ] 检查钉钉应用权限状态 +- [ ] 更新AppSecret(如需要) + +--- + +## 十四、配置流程图 + +``` +┌─────────────────────────────────────────┐ +│ 1. 钉钉开放平台 │ +│ - 创建应用 │ +│ - 获取AppKey/AppSecret │ +│ - 配置回调地址 │ +│ - 申请权限 │ +└──────────────┬──────────────────────────┘ + ↓ +┌─────────────────────────────────────────┐ +│ 2. 系统设置-钉钉集成 │ +│ - 填写AppKey │ +│ - 填写AppSecret │ +│ - 勾选"钉钉登录" │ +│ - 点击"保存配置" │ +└──────────────┬──────────────────────────┘ + ↓ +┌─────────────────────────────────────────┐ +│ 3. 配置自动同步到数据库 │ +│ - dingtalk_config表 │ +│ - enabled = 1 │ +│ - enabled_features.login = true │ +└──────────────┬──────────────────────────┘ + ↓ +┌─────────────────────────────────────────┐ +│ 4. 后端自动从数据库读取 │ +│ - DingTalkService.getConfig() │ +│ - 缓存AccessToken │ +│ - 提供OAuth登录服务 │ +└──────────────┬──────────────────────────┘ + ↓ +┌─────────────────────────────────────────┐ +│ 5. 前端显示钉钉登录按钮 │ +│ - 检查 /api/dingtalk/is-configured │ +│ - 显示登录按钮 │ +└──────────────┬──────────────────────────┘ + ↓ +┌─────────────────────────────────────────┐ +│ 6. 用户可以使用钉钉登录 │ +│ - 扫码授权 │ +│ - 自动创建账号 │ +│ - 部门智能匹配 │ +└─────────────────────────────────────────┘ +``` + +--- + +## 十五、常见错误代码 + +| 错误码 | 错误信息 | 原因 | 解决方案 | +|-------|---------|------|----------| +| 1401 | 钉钉认证失败 | AppKey/AppSecret错误 | 检查配置 | +| 1402 | 钉钉接口调用失败 | 网络或权限问题 | 检查网络和权限 | +| 1901 | 需要选择部门 | 无法自动匹配部门 | 前端显示选择界面 | +| 400 | 参数错误 | code参数缺失或无效 | 重新发起登录 | + +--- + +## 十六、快速配置命令 + +### 16.1 一键配置测试环境 + +```sql +-- 配置钉钉应用(替换为实际值) +UPDATE dingtalk_config SET + app_key = 'YOUR_APP_KEY', + app_secret = 'YOUR_APP_SECRET', + corp_id = 'YOUR_CORP_ID', + agent_id = 'YOUR_AGENT_ID', + enabled = 1, + enabled_features = '{"login": true, "notification": true}' +WHERE id = 1; + +-- 配置部门映射 +UPDATE sys_dept SET ding_dept_id = '123456' WHERE dept_name = '设计部'; +UPDATE sys_dept SET ding_dept_id = '789012' WHERE dept_name = '产品部'; +``` + +### 16.2 验证配置 + +```bash +# 1. 检查配置状态 +curl http://localhost:8080/api/dingtalk/is-configured + +# 2. 获取登录URL +curl http://localhost:8080/api/dingtalk/oauth/login + +# 3. 查看配置(需要token) +curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:8080/api/dingtalk/config +``` + +--- + +## 十七、配置完成验证 + +### ✓ 配置完成的标志 + +- [ ] `/api/dingtalk/is-configured` 返回 `true` +- [ ] `/api/dingtalk/oauth/login` 能返回authUrl +- [ ] 系统设置界面显示钉钉配置信息 +- [ ] 测试连接显示成功 +- [ ] 至少一个部门配置了`ding_dept_id` + +### ✓ 功能正常的标志 + +- [ ] 新钉钉用户能成功登录 +- [ ] 用户信息正确保存到数据库 +- [ ] 部门自动匹配或手动选择正常 +- [ ] 用户角色自动分配正确 +- [ ] 后续登录无需重新选择部门 + +--- + +**配置完成时间**: 2025-12-22 +**配置状态**: ✅ 数据库表结构完整,配置流程就绪 +**后续步骤**: 管理员在系统设置中填写钉钉应用信息 + +--- + +**技术支持**: +- 钉钉开放平台:https://open.dingtalk.com/ +- 系统文档:`/钉钉OAuth登录配置文档.md` +- 同步机制:`/钉钉用户部门同步机制.md`