commit 7800b7786d4cb13a073556d284464e07b8728751 Author: 王伟志 <790727372@qq.com> Date: Sun Nov 23 14:04:20 2025 +0800 feat: init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..71f8e07 --- /dev/null +++ b/.gitignore @@ -0,0 +1,46 @@ +# Dependencies +node_modules/ +*/node_modules/ +.pnpm-store/ + +# Build outputs +dist/ +*/dist/ +build/ +*/build/ + +# pnpm +.pnpm-debug.log* + +# Environment variables +.env +.env.local +.env.*.local +*/.env +*/.env.local + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Testing +coverage/ +.nyc_output/ + +# Prisma +backend/prisma/migrations/ + diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..17024a7 --- /dev/null +++ b/.npmrc @@ -0,0 +1,8 @@ +# pnpm 配置 +shamefully-hoist=true +strict-peer-dependencies=false +auto-install-peers=true + +# 使用国内镜像(可选,根据需要取消注释) +# registry=https://registry.npmmirror.com + diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..32d3dea --- /dev/null +++ b/.nvmrc @@ -0,0 +1,2 @@ +18.0.0 + diff --git a/DYNAMIC_MENU_GUIDE.md b/DYNAMIC_MENU_GUIDE.md new file mode 100644 index 0000000..03d5507 --- /dev/null +++ b/DYNAMIC_MENU_GUIDE.md @@ -0,0 +1,280 @@ +# 动态菜单系统实现指南 + +## 📋 概述 + +已成功将菜单管理模块扩展为**完全动态化的菜单生成系统**。系统现在支持: + +- ✅ 从数据库动态加载菜单 +- ✅ 根据用户权限自动过滤菜单 +- ✅ 动态生成路由配置 +- ✅ 动态生成侧边栏菜单 +- ✅ 支持多级菜单结构 +- ✅ 菜单级别的权限控制 + +## 🏗️ 架构设计 + +### 后端架构 + +``` +数据库 (Menu表) + ↓ +MenusService.findUserMenus() + ↓ +根据用户权限过滤菜单 + ↓ +返回树形菜单结构 +``` + +### 前端架构 + +``` +用户登录 + ↓ +获取用户菜单 (API) + ↓ +存储到 Auth Store + ↓ +转换为路由配置 (convertMenusToRoutes) + ↓ +动态注册路由 (router.addRoute) + ↓ +转换为菜单项 (convertMenusToMenuItems) + ↓ +渲染到侧边栏 +``` + +## 🔧 实现细节 + +### 1. 数据库 Schema 更新 + +在 `Menu` 模型中添加了 `permission` 字段: + +```prisma +model Menu { + // ... 其他字段 + permission String? /// 权限编码(用于控制菜单显示,如:menu:read) + // ... 其他字段 +} +``` + +**需要运行数据库迁移:** + +```bash +cd backend +npx prisma migrate dev --name add_menu_permission +``` + +### 2. 后端 API + +#### 新增接口:`GET /menus/user-menus` + +获取当前用户的菜单(根据权限自动过滤) + +**响应示例:** + +```json +[ + { + "id": 1, + "name": "系统管理", + "path": "/system", + "icon": "SettingOutlined", + "permission": null, + "children": [ + { + "id": 2, + "name": "用户管理", + "path": "/system/users", + "component": "system/users/Index", + "permission": "user:read" + } + ] + } +] +``` + +### 3. 前端工具函数 + +创建了 `frontend/src/utils/menu.ts`,包含: + +- `convertMenusToMenuItems()`: 将数据库菜单转换为 Ant Design Vue Menu 格式 +- `convertMenusToRoutes()`: 将数据库菜单转换为 Vue Router 路由配置 +- `getIconComponent()`: 动态加载图标组件 +- `flattenMenus()`: 扁平化菜单树 + +### 4. Auth Store 扩展 + +在 `auth.ts` 中添加了: + +- `menus`: 存储用户菜单数据 +- `fetchUserMenus()`: 获取用户菜单 +- 登录时自动获取菜单 +- 登出时清空菜单 + +### 5. 路由动态注册 + +在 `router/index.ts` 中: + +- 基础路由(登录页、404等)保持静态 +- 业务路由从数据库动态加载 +- 登录后自动注册动态路由 +- 支持路由权限检查 + +### 6. 布局组件更新 + +`BasicLayout.vue` 现在: + +- 从 `authStore.menus` 读取菜单数据 +- 使用 `convertMenusToMenuItems()` 转换菜单 +- 自动展开包含当前路径的父菜单 + +## 📝 使用方法 + +### 1. 创建菜单 + +在菜单管理界面创建菜单时,需要填写: + +- **菜单名称**: 显示名称 +- **路由路径**: 如 `/system/users` +- **图标**: Ant Design Icons 名称,如 `UserOutlined` +- **组件路径**: Vue 组件路径,如 `system/users/Index`(相对于 `@/views/`) +- **权限编码**: 可选,如 `user:read`(留空则所有用户可见) +- **父菜单**: 可选,用于创建多级菜单 +- **排序**: 数字,控制显示顺序 + +### 2. 权限控制 + +#### 菜单级别权限 + +在菜单的"权限编码"字段设置权限码,如: + +- `user:read` - 需要用户查看权限 +- `role:create` - 需要角色创建权限 +- 留空 - 所有用户可见 + +#### 路由级别权限 + +菜单的权限会自动添加到路由的 `meta.permissions` 中,路由守卫会自动检查。 + +### 3. 组件路径规则 + +组件路径应该相对于 `frontend/src/views/` 目录: + +- ✅ `system/users/Index` → `@/views/system/users/Index.vue` +- ✅ `dashboard/Index` → `@/views/dashboard/Index.vue` +- ❌ `@/views/system/users/Index` (不需要 `@/views/` 前缀) + +## 🔄 工作流程 + +### 用户登录流程 + +``` +1. 用户输入账号密码 + ↓ +2. 调用 login API + ↓ +3. 获取 token 和用户信息 + ↓ +4. 调用 fetchUserMenus() 获取菜单 + ↓ +5. 菜单数据存储到 authStore.menus + ↓ +6. 动态路由注册 (addDynamicRoutes) + ↓ +7. 跳转到首页 +``` + +### 页面访问流程 + +``` +1. 用户访问 /system/users + ↓ +2. 路由守卫检查认证 + ↓ +3. 检查路由权限 (meta.permissions) + ↓ +4. 如果通过,渲染组件 + ↓ +5. BasicLayout 显示侧边栏菜单 +``` + +## 🎯 示例场景 + +### 场景1: 创建带权限的菜单 + +1. 进入"菜单管理" +2. 点击"新增菜单" +3. 填写: + - 名称: 用户管理 + - 路径: `/system/users` + - 图标: `UserOutlined` + - 组件路径: `system/users/Index` + - 权限编码: `user:read` +4. 保存 + +结果:只有拥有 `user:read` 权限的用户才能看到此菜单。 + +### 场景2: 创建多级菜单 + +1. 创建父菜单: + - 名称: 系统管理 + - 路径: `/system` + - 图标: `SettingOutlined` + - (不填组件路径和权限编码) + +2. 创建子菜单: + - 名称: 用户管理 + - 路径: `/system/users` + - 父菜单: 选择"系统管理" + - 组件路径: `system/users/Index` + +结果:侧边栏显示为: + +``` +系统管理 + └─ 用户管理 +``` + +## ⚠️ 注意事项 + +1. **数据库迁移**: 添加 `permission` 字段后,需要运行 Prisma 迁移 +2. **组件路径**: 确保组件文件存在,否则路由会失败 +3. **权限编码**: 必须与权限系统中定义的权限码一致 +4. **路由名称**: 自动生成,基于路径(如 `/system/users` → `SystemUsers`) +5. **动态路由**: 只在登录后添加一次,刷新页面不会重复添加 + +## 🐛 故障排除 + +### 菜单不显示 + +1. 检查菜单的 `validState` 是否为 1(有效) +2. 检查用户是否有菜单对应的权限 +3. 检查浏览器控制台是否有错误 + +### 路由404 + +1. 检查组件路径是否正确 +2. 检查组件文件是否存在 +3. 检查路由是否已动态注册(查看 Vue DevTools) + +### 图标不显示 + +1. 检查图标名称是否正确(Ant Design Icons) +2. 检查图标是否已导入到项目中 + +## 📚 相关文件 + +- 后端菜单服务: `backend/src/menus/menus.service.ts` +- 后端菜单控制器: `backend/src/menus/menus.controller.ts` +- 前端菜单工具: `frontend/src/utils/menu.ts` +- 前端路由配置: `frontend/src/router/index.ts` +- 前端布局组件: `frontend/src/layouts/BasicLayout.vue` +- 前端菜单管理: `frontend/src/views/system/menus/Index.vue` + +## 🚀 下一步优化建议 + +1. **菜单缓存**: 可以缓存菜单数据,减少API调用 +2. **菜单刷新**: 提供手动刷新菜单的功能 +3. **菜单搜索**: 在侧边栏添加菜单搜索功能 +4. **菜单收藏**: 允许用户收藏常用菜单 +5. **菜单拖拽排序**: 在管理界面支持拖拽排序 diff --git a/README.md b/README.md new file mode 100644 index 0000000..c2327b9 --- /dev/null +++ b/README.md @@ -0,0 +1,303 @@ +# 比赛管理系统 + +一个基于 Vue 3 + NestJS 的现代化比赛管理系统,支持用户管理、角色权限、菜单管理、数据字典、系统配置和日志记录等核心功能。 + +## 技术栈 + +### 前端 +- **框架**: Vue 3 + TypeScript +- **构建工具**: Vite +- **UI 组件库**: Ant Design Vue +- **样式方案**: Tailwind CSS + SCSS + CSS Modules +- **状态管理**: Pinia +- **路由**: Vue Router 4 +- **HTTP 客户端**: Axios +- **表单验证**: VeeValidate + Zod + +### 后端 +- **框架**: NestJS + TypeScript +- **数据库**: MySQL 8.0 +- **ORM**: Prisma +- **认证授权**: JWT + RBAC (基于角色的访问控制) + +## 项目结构 + +``` +competition-management-system/ +├── frontend/ # 前端项目 +│ ├── src/ +│ │ ├── api/ # API 接口 +│ │ ├── assets/ # 静态资源 +│ │ ├── components/# 公共组件 +│ │ ├── layouts/ # 布局组件 +│ │ ├── router/ # 路由配置 +│ │ ├── stores/ # Pinia 状态管理 +│ │ ├── styles/ # 样式文件 +│ │ ├── types/ # TypeScript 类型定义 +│ │ ├── utils/ # 工具函数 +│ │ └── views/ # 页面组件 +│ └── package.json +│ +└── backend/ # 后端项目 + ├── prisma/ # Prisma 配置 + │ └── schema.prisma + ├── src/ + │ ├── auth/ # 认证模块 + │ ├── users/ # 用户管理 + │ ├── roles/ # 角色管理 + │ ├── menus/ # 菜单管理 + │ ├── dict/ # 数据字典 + │ ├── config/ # 系统配置 + │ ├── logs/ # 日志记录 + │ └── prisma/ # Prisma 服务 + └── package.json +``` + +## 快速开始 + +### 环境要求 + +- Node.js >= 18.0.0 +- pnpm >= 8.0.0 +- MySQL >= 8.0 + +### 安装 pnpm + +如果还没有安装 pnpm,可以通过以下方式安装: + +```bash +# 使用 npm 安装 +npm install -g pnpm + +# 或使用 corepack(Node.js 16.13+) +corepack enable +corepack prepare pnpm@latest --activate +``` + +### 快速安装(推荐) + +在项目根目录执行: + +```bash +# 安装所有依赖(前端 + 后端) +pnpm install + +# 或分别安装 +pnpm --filter frontend install +pnpm --filter backend install +``` + +### 后端设置 + +1. 进入后端目录: +```bash +cd backend +``` + +2. 安装依赖(如果未在根目录安装): +```bash +pnpm install +``` + +3. 配置环境变量,创建 `.env` 文件: +```env +DATABASE_URL="mysql://user:password@localhost:3306/competition_management?schema=public" +JWT_SECRET="your-secret-key-change-in-production" +PORT=3001 +``` + +4. 初始化数据库: +```bash +# 生成 Prisma Client +pnpm prisma:generate + +# 运行数据库迁移 +pnpm prisma:migrate +``` + +5. 启动开发服务器: +```bash +# 方式1:在后端目录 +pnpm start:dev + +# 方式2:在根目录 +pnpm dev:backend +``` + +后端服务将在 `http://localhost:3001` 启动。 + +### 前端设置 + +1. 进入前端目录: +```bash +cd frontend +``` + +2. 安装依赖(如果未在根目录安装): +```bash +pnpm install +``` + +3. 启动开发服务器: +```bash +# 方式1:在前端目录 +pnpm dev + +# 方式2:在根目录 +pnpm dev:frontend +``` + +前端应用将在 `http://localhost:3000` 启动。 + +### 同时启动前后端 + +在项目根目录执行: + +```bash +pnpm dev +``` + +这将同时启动前端和后端开发服务器。 + +## 核心功能 + +### 1. 用户管理 +- 用户列表查询(分页) +- 用户创建、编辑、删除 +- 用户角色分配 + +### 2. 角色权限 (RBAC) +- 角色管理(创建、编辑、删除) +- 权限分配 +- 基于角色的访问控制 + +### 3. 菜单管理 +- 菜单树形结构管理 +- 菜单权限配置 +- 动态路由生成 + +### 4. 数据字典 +- 字典类型管理 +- 字典项管理 +- 字典数据查询 + +### 5. 系统配置 +- 系统参数配置 +- 配置项管理 + +### 6. 日志记录 +- 操作日志记录 +- 日志查询和统计 + +## API 文档 + +### 认证接口 + +- `POST /api/auth/login` - 用户登录 +- `GET /api/auth/user-info` - 获取当前用户信息 +- `POST /api/auth/logout` - 用户登出 + +### 用户管理 + +- `GET /api/users` - 获取用户列表 +- `GET /api/users/:id` - 获取用户详情 +- `POST /api/users` - 创建用户 +- `PATCH /api/users/:id` - 更新用户 +- `DELETE /api/users/:id` - 删除用户 + +### 角色管理 + +- `GET /api/roles` - 获取角色列表 +- `GET /api/roles/:id` - 获取角色详情 +- `POST /api/roles` - 创建角色 +- `PATCH /api/roles/:id` - 更新角色 +- `DELETE /api/roles/:id` - 删除角色 + +### 菜单管理 + +- `GET /api/menus` - 获取菜单列表(树形结构) +- `GET /api/menus/:id` - 获取菜单详情 +- `POST /api/menus` - 创建菜单 +- `PATCH /api/menus/:id` - 更新菜单 +- `DELETE /api/menus/:id` - 删除菜单 + +### 数据字典 + +- `GET /api/dict` - 获取字典列表 +- `GET /api/dict/code/:code` - 根据代码获取字典 +- `GET /api/dict/:id` - 获取字典详情 +- `POST /api/dict` - 创建字典 +- `PATCH /api/dict/:id` - 更新字典 +- `DELETE /api/dict/:id` - 删除字典 + +### 系统配置 + +- `GET /api/config` - 获取配置列表 +- `GET /api/config/key/:key` - 根据键获取配置 +- `GET /api/config/:id` - 获取配置详情 +- `POST /api/config` - 创建配置 +- `PATCH /api/config/:id` - 更新配置 +- `DELETE /api/config/:id` - 删除配置 + +### 日志记录 + +- `GET /api/logs` - 获取日志列表 +- `GET /api/logs/:id` - 获取日志详情 +- `POST /api/logs` - 创建日志 + +## 开发规范 + +### 代码风格 +- 使用 ESLint 和 Prettier 进行代码格式化 +- 遵循 TypeScript 严格模式 +- 使用语义化的提交信息 + +### 提交规范 +- `feat`: 新功能 +- `fix`: 修复 bug +- `docs`: 文档更新 +- `style`: 代码格式调整 +- `refactor`: 代码重构 +- `test`: 测试相关 +- `chore`: 构建/工具相关 + +## 部署 + +### 前端构建 +```bash +# 方式1:在前端目录 +cd frontend +pnpm build + +# 方式2:在根目录 +pnpm build:frontend +``` + +构建产物在 `frontend/dist` 目录。 + +### 后端构建 +```bash +# 方式1:在后端目录 +cd backend +pnpm build +pnpm start:prod + +# 方式2:在根目录 +pnpm build:backend +cd backend +pnpm start:prod +``` + +### 同时构建前后端 +```bash +pnpm build +``` + +## 许可证 + +MIT License + +## 贡献 + +欢迎提交 Issue 和 Pull Request! + diff --git a/TENANT_IMPLEMENTATION.md b/TENANT_IMPLEMENTATION.md new file mode 100644 index 0000000..2c6b82c --- /dev/null +++ b/TENANT_IMPLEMENTATION.md @@ -0,0 +1,222 @@ +# 多租户系统实现方案 + +## 实现概述 + +已成功实现完整的多租户系统,包括以下核心功能: + +1. ✅ **租户管理模块**:创建、查看、更新、删除租户 +2. ✅ **数据隔离**:用户、角色、权限、菜单等数据按租户隔离 +3. ✅ **租户识别**:支持多种方式识别租户(请求头、子域名、JWT Token) +4. ✅ **超级租户**:可以创建租户并分配菜单 +5. ✅ **菜单分配**:超级租户可以为租户分配菜单 + +## 核心变更 + +### 1. 数据库Schema变更 + +- 新增 `Tenant` 表(租户表) +- 新增 `TenantMenu` 表(租户菜单关联表) +- 在以下表添加 `tenantId` 字段: + - `User` + - `Role` + - `Permission` + - `Dict` + - `Config` +- 调整唯一性约束:从全局唯一改为租户内唯一 + +### 2. 新增模块 + +- **TenantsModule**: 租户管理模块 + - `TenantsController`: 租户CRUD接口 + - `TenantsService`: 租户业务逻辑 + - `TenantGuard`: 租户识别守卫(可选,当前未全局启用) + - `Tenant`/`TenantId` 装饰器:获取当前租户信息 + +### 3. 修改的模块 + +- **AuthModule**: + - 登录时支持租户识别 + - JWT Token中包含租户ID + - 用户验证时检查租户匹配 + +- **UsersModule**: + - 所有操作自动添加租户过滤 + - 创建用户时自动关联租户 + +- **RolesModule**: + - 所有操作自动添加租户过滤 + - 创建角色时自动关联租户 + +- **PermissionsModule**: + - 所有操作自动添加租户过滤 + - 创建权限时自动关联租户 + +- **MenusModule**: + - 用户菜单查询基于租户分配的菜单 + - 菜单管理仍为全局(超级租户管理) + +## 使用步骤 + +### 1. 数据库迁移 + +```bash +cd backend +npm run prisma:migrate:dev -- --name add_tenant_support +``` + +### 2. 初始化超级租户 + +```bash +npm run init:super-tenant +``` + +这将创建: +- 超级租户(code: `super`) +- 超级管理员(username: `admin`, password: `admin123`) +- 基础权限 + +### 3. 创建租户 + +使用超级管理员登录后,通过API创建租户: + +```bash +POST /api/tenants +Headers: + Authorization: Bearer + X-Tenant-Code: super +Body: +{ + "name": "租户A", + "code": "tenant-a", + "menuIds": [1, 2, 3] +} +``` + +### 4. 租户用户登录 + +```bash +POST /api/auth/login +Headers: + X-Tenant-Code: tenant-a +Body: +{ + "username": "user1", + "password": "password123" +} +``` + +## 租户识别方式 + +系统支持以下方式识别租户(按优先级): + +1. **请求头 `X-Tenant-Id`**: 直接指定租户ID +2. **请求头 `X-Tenant-Code`**: 通过租户编码识别 +3. **子域名**: 从Host头提取子域名匹配 +4. **JWT Token**: Token中包含的tenantId + +## 数据隔离机制 + +所有数据查询都会自动添加租户过滤条件: + +```typescript +// 示例:查询用户 +const where = tenantId ? { tenantId } : {}; +const users = await prisma.user.findMany({ where }); +``` + +确保: +- 每个租户只能看到自己的数据 +- 不同租户的数据完全隔离 +- 超级租户可以管理所有租户 + +## 菜单分配机制 + +- 菜单是全局的(由超级租户管理) +- 通过 `TenantMenu` 表关联租户和菜单 +- 用户只能看到分配给其租户的菜单 +- 超级租户可以为租户分配/取消分配菜单 + +## API接口 + +### 租户管理 + +- `POST /api/tenants` - 创建租户 +- `GET /api/tenants` - 获取租户列表 +- `GET /api/tenants/:id` - 获取租户详情 +- `PATCH /api/tenants/:id` - 更新租户(包括菜单分配) +- `DELETE /api/tenants/:id` - 删除租户 +- `GET /api/tenants/:id/menus` - 获取租户菜单树 + +### 其他接口 + +所有其他接口(用户、角色、权限等)都支持租户隔离,会自动根据请求中的租户信息过滤数据。 + +## 前端集成 + +### 1. 请求拦截器添加租户信息 + +```typescript +service.interceptors.request.use((config) => { + const tenantCode = localStorage.getItem('tenantCode'); + if (tenantCode) { + config.headers['X-Tenant-Code'] = tenantCode; + } + return config; +}); +``` + +### 2. 登录后保存租户信息 + +```typescript +// 登录成功后 +localStorage.setItem('tenantCode', response.data.user.tenantCode); +localStorage.setItem('tenantId', response.data.user.tenantId); +``` + +## 注意事项 + +1. **数据迁移**: 如果现有系统已有数据,需要将现有数据关联到超级租户 +2. **唯一性**: 用户名、邮箱等在租户内唯一,不同租户可以有相同的用户名 +3. **超级租户**: 超级租户不能被删除,且拥有所有权限 +4. **菜单管理**: 菜单是全局的,但通过分配机制实现租户级别的菜单显示 + +## 文件清单 + +### 新增文件 + +- `backend/src/tenants/` - 租户模块 + - `tenants.controller.ts` + - `tenants.service.ts` + - `tenants.module.ts` + - `dto/create-tenant.dto.ts` + - `dto/update-tenant.dto.ts` + - `guards/tenant.guard.ts` + - `decorators/tenant.decorator.ts` +- `backend/scripts/init-super-tenant.ts` - 初始化超级租户脚本 +- `backend/docs/TENANT_GUIDE.md` - 详细使用指南 + +### 修改文件 + +- `backend/prisma/schema.prisma` - 数据库Schema +- `backend/src/app.module.ts` - 添加TenantsModule +- `backend/src/auth/` - 认证相关修改 +- `backend/src/users/` - 用户服务修改 +- `backend/src/roles/` - 角色服务修改 +- `backend/src/permissions/` - 权限服务修改 +- `backend/src/menus/` - 菜单服务修改 + +## 后续优化建议 + +1. **租户守卫**: 可以全局启用TenantGuard,自动识别租户 +2. **租户配置**: 支持租户级别的系统配置 +3. **租户统计**: 添加租户使用统计功能 +4. **数据导出**: 支持租户数据导出和备份 +5. **租户主题**: 支持租户级别的UI主题定制 + +## 测试建议 + +1. 测试租户数据隔离:确保不同租户的数据不会互相访问 +2. 测试菜单分配:验证租户只能看到分配的菜单 +3. 测试超级租户权限:验证超级租户可以管理所有租户 +4. 测试租户识别:验证各种租户识别方式都能正常工作 + diff --git a/backend/.eslintrc.js b/backend/.eslintrc.js new file mode 100644 index 0000000..a1cd83f --- /dev/null +++ b/backend/.eslintrc.js @@ -0,0 +1,26 @@ +module.exports = { + parser: '@typescript-eslint/parser', + parserOptions: { + project: 'tsconfig.json', + tsconfigRootDir: __dirname, + sourceType: 'module', + }, + plugins: ['@typescript-eslint/eslint-plugin'], + extends: [ + 'plugin:@typescript-eslint/recommended', + 'plugin:prettier/recommended', + ], + root: true, + env: { + node: true, + jest: true, + }, + ignorePatterns: ['.eslintrc.js'], + rules: { + '@typescript-eslint/interface-name-prefix': 'off', + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/no-explicit-any': 'off', + }, +}; + diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..62bdd70 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,47 @@ +# compiled output +/dist +/node_modules + +# Logs +logs +*.log +npm-debug.log* +pnpm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# OS +.DS_Store + +# Tests +/coverage +/.nyc_output + +# IDEs and editors +/.idea +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# IDE - VSCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +# Environment +.env +.env.local +.env.*.local +.env.development +.env.production +.env.test +.env.staging +# 保留示例文件 +!.env*.example + diff --git a/backend/.npmrc b/backend/.npmrc new file mode 100644 index 0000000..216cc21 --- /dev/null +++ b/backend/.npmrc @@ -0,0 +1,4 @@ +# 后端 pnpm 配置 +shamefully-hoist=true +strict-peer-dependencies=false + diff --git a/backend/.prettierrc b/backend/.prettierrc new file mode 100644 index 0000000..68ce4d0 --- /dev/null +++ b/backend/.prettierrc @@ -0,0 +1,5 @@ +{ + "singleQuote": true, + "trailingComma": "all" +} + diff --git a/backend/docs/ADMIN_ACCOUNT.md b/backend/docs/ADMIN_ACCOUNT.md new file mode 100644 index 0000000..3be60d3 --- /dev/null +++ b/backend/docs/ADMIN_ACCOUNT.md @@ -0,0 +1,184 @@ +# 超级管理员账号说明 + +## 📋 账号信息 + +### 登录凭据 + +- **用户名**: `admin` +- **密码**: `cms@admin` +- **昵称**: 超级管理员 +- **邮箱**: admin@example.com +- **角色**: super_admin (超级管理员) + +## 🔐 权限说明 + +超级管理员拥有系统所有权限,共 **27 个权限**: + +### 用户管理权限 + +- `user:create` - 创建用户 +- `user:read` - 查看用户 +- `user:update` - 更新用户 +- `user:delete` - 删除用户 + +### 角色管理权限 + +- `role:create` - 创建角色 +- `role:read` - 查看角色 +- `role:update` - 更新角色 +- `role:delete` - 删除角色 +- `role:assign` - 分配角色 + +### 权限管理权限 + +- `permission:create` - 创建权限 +- `permission:read` - 查看权限 +- `permission:update` - 更新权限 +- `permission:delete` - 删除权限 + +### 菜单管理权限 + +- `menu:create` - 创建菜单 +- `menu:read` - 查看菜单 +- `menu:update` - 更新菜单 +- `menu:delete` - 删除菜单 + +### 数据字典权限 + +- `dict:create` - 创建字典 +- `dict:read` - 查看字典 +- `dict:update` - 更新字典 +- `dict:delete` - 删除字典 + +### 系统配置权限 + +- `config:create` - 创建配置 +- `config:read` - 查看配置 +- `config:update` - 更新配置 +- `config:delete` - 删除配置 + +### 日志管理权限 + +- `log:read` - 查看日志 +- `log:delete` - 删除日志 + +## 🚀 使用方法 + +### 1. 登录系统 + +使用以下 API 登录: + +```bash +POST /api/auth/login +Content-Type: application/json + +{ + "username": "admin", + "password": "cms@admin" +} +``` + +### 2. 响应示例 + +```json +{ + "code": 200, + "message": "success", + "data": { + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "user": { + "id": 1, + "username": "admin", + "nickname": "超级管理员", + "email": "admin@example.com", + "avatar": null, + "roles": ["super_admin"], + "permissions": [ + "user:create", + "user:read", + "user:update", + "user:delete" + // ... 所有 27 个权限 + ] + } + } +} +``` + +### 3. 使用 Token 访问 API + +```bash +GET /api/users +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... +``` + +## 🔄 重新初始化 + +如果需要重新初始化超级管理员账号,可以运行: + +```bash +cd backend +pnpm init:admin +``` + +脚本会: + +- ✅ 创建/更新所有基础权限(27个) +- ✅ 创建/更新超级管理员角色 +- ✅ 创建/更新 admin 用户 +- ✅ 分配角色给用户 + +**注意**: 如果用户已存在,密码会被重置为 `cms@admin` + +## 🔍 验证账号 + +验证超级管理员账号是否创建成功: + +```bash +cd backend +node scripts/verify-admin.js +``` + +## ⚠️ 安全建议 + +1. **首次登录后立即修改密码** +2. **生产环境使用强密码** +3. **定期更换密码** +4. **不要将密码提交到版本控制** + +## 📝 修改密码 + +可以通过以下方式修改密码: + +### 方式一:通过 API + +```bash +PATCH /api/users/1 +Authorization: Bearer +Content-Type: application/json + +{ + "password": "new_strong_password" +} +``` + +### 方式二:通过数据库 + +```sql +-- 需要先使用 bcrypt 加密密码 +UPDATE users +SET password = '' +WHERE username = 'admin'; +``` + +### 方式三:通过脚本 + +可以修改 `scripts/init-admin.ts` 中的密码,然后重新运行脚本。 + +## 🎯 下一步 + +1. ✅ 使用 admin 账号登录系统 +2. ✅ 创建其他角色(如:编辑、查看者等) +3. ✅ 创建其他用户并分配角色 +4. ✅ 配置菜单权限 +5. ✅ 开始使用系统 diff --git a/backend/docs/DATABASE_SETUP.md b/backend/docs/DATABASE_SETUP.md new file mode 100644 index 0000000..da2d21d --- /dev/null +++ b/backend/docs/DATABASE_SETUP.md @@ -0,0 +1,183 @@ +# 数据库配置指南 + +## 1. 创建数据库 + +首先需要在 MySQL 中创建数据库: + +```sql +CREATE DATABASE db_competition_management CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +``` + +## 2. 配置环境变量 + +### 方式一:复制示例文件 + +```bash +cd backend +cp .env.example .env +``` + +### 方式二:手动创建 .env 文件 + +在 `backend` 目录下创建 `.env` 文件,内容如下: + +```env +DATABASE_URL="mysql://root:password@localhost:3306/competition_management?schema=public" +JWT_SECRET="your-secret-key-change-in-production" +PORT=3001 +NODE_ENV=development +``` + +## 3. 配置说明 + +### DATABASE_URL 格式 + +``` +mysql://用户名:密码@主机:端口/数据库名?参数 +``` + +**示例:** + +- 本地 MySQL,默认端口: + + ``` + DATABASE_URL="mysql://root:password@localhost:3306/competition_management?schema=public" + ``` + +- 远程 MySQL: + + ``` + DATABASE_URL="mysql://user:password@192.168.1.100:3306/competition_management?schema=public" + ``` + +- 使用 SSL: + + ``` + DATABASE_URL="mysql://user:password@localhost:3306/competition_management?schema=public&sslmode=require" + ``` + +- 包含特殊字符的密码(需要 URL 编码): + ``` + DATABASE_URL="mysql://user:p%40ssw0rd@localhost:3306/competition_management?schema=public" + ``` + +### JWT_SECRET + +用于 JWT token 签名的密钥,生产环境必须使用强随机字符串。 + +**生成方式:** + +```bash +# 使用 Node.js +node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" + +# 或使用 openssl +openssl rand -hex 32 +``` + +## 4. 初始化数据库 + +配置好 `.env` 文件后,执行以下命令初始化数据库: + +```bash +# 生成 Prisma Client +pnpm prisma:generate + +# 运行数据库迁移(创建表结构) +pnpm prisma:migrate + +# 或使用开发模式(会提示输入迁移名称) +pnpm prisma:migrate dev +``` + +## 5. 验证连接 + +### 方式一:使用 Prisma Studio + +```bash +pnpm prisma:studio +``` + +这会打开一个可视化界面,可以在浏览器中查看和管理数据库。 + +### 方式二:测试连接 + +启动后端服务: + +```bash +pnpm start:dev +``` + +如果连接成功,服务会正常启动;如果失败,会显示具体的错误信息。 + +## 6. 常见问题 + +### 问题 1: 连接被拒绝 + +**错误信息:** `Can't reach database server` + +**解决方案:** + +- 检查 MySQL 服务是否启动 +- 检查主机和端口是否正确 +- 检查防火墙设置 + +### 问题 2: 认证失败 + +**错误信息:** `Access denied for user` + +**解决方案:** + +- 检查用户名和密码是否正确 +- 确认用户有访问该数据库的权限 +- 如果密码包含特殊字符,需要进行 URL 编码 + +### 问题 3: 数据库不存在 + +**错误信息:** `Unknown database` + +**解决方案:** + +- 先创建数据库(见步骤 1) +- 检查数据库名称是否正确 + +### 问题 4: 字符集问题 + +**解决方案:** +创建数据库时指定字符集: + +```sql +CREATE DATABASE competition_management CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +``` + +## 7. 生产环境配置 + +生产环境建议: + +1. **使用环境变量管理工具**(如 AWS Secrets Manager、Azure Key Vault) +2. **使用连接池**(Prisma 默认已配置) +3. **启用 SSL 连接** +4. **定期备份数据库** +5. **使用强密码和 JWT_SECRET** + +## 8. 数据库迁移 + +### 创建新迁移 + +```bash +pnpm prisma:migrate dev --name migration_name +``` + +### 应用迁移(生产环境) + +```bash +pnpm prisma:migrate deploy +``` + +### 重置数据库(开发环境) + +```bash +pnpm prisma:migrate reset +``` + +**注意:** 这会删除所有数据,仅用于开发环境! diff --git a/backend/docs/DATABASE_URL_SOURCE.md b/backend/docs/DATABASE_URL_SOURCE.md new file mode 100644 index 0000000..243cdd3 --- /dev/null +++ b/backend/docs/DATABASE_URL_SOURCE.md @@ -0,0 +1,165 @@ +# DATABASE_URL 来源说明 + +## 📍 定义位置 + +`DATABASE_URL` 在 `schema.prisma` 中定义: + +```prisma +datasource db { + provider = "mysql" + url = env("DATABASE_URL") // ← 从这里读取环境变量 +} +``` + +## 🔄 加载流程 + +### 1. 配置文件定义 + +`DATABASE_URL` 定义在环境配置文件中: + +**当前配置**:`.development.env` 文件 +```env +DATABASE_URL="mysql://root:woshimima@localhost:3306/db_competition_management?schema=public" +``` + +### 2. NestJS ConfigModule 加载 + +在 `app.module.ts` 中配置: + +```typescript +ConfigModule.forRoot({ + isGlobal: true, + envFilePath: ['.development.env'], // ← 从这里加载环境变量 +}) +``` + +**加载顺序**: +1. NestJS ConfigModule 读取 `.development.env` 文件 +2. 将文件中的 `DATABASE_URL` 加载到 `process.env.DATABASE_URL` +3. 应用启动时,所有模块都可以通过 `ConfigService` 访问 + +### 3. Prisma 读取 + +Prisma 在以下时机读取 `DATABASE_URL`: + +1. **生成 Prisma Client 时**: + ```bash + npx prisma generate + ``` + - 读取 `process.env.DATABASE_URL` + - 生成类型定义(不连接数据库) + +2. **运行迁移时**: + ```bash + npx prisma migrate dev + npx prisma migrate deploy + ``` + - 读取 `process.env.DATABASE_URL` + - 连接到数据库执行迁移 + +3. **应用运行时**: + - `PrismaService` 初始化时读取 `process.env.DATABASE_URL` + - 建立数据库连接 + +## 📂 配置文件优先级 + +根据 `app.module.ts` 的配置: + +```typescript +envFilePath: ['.development.env'] +``` + +**当前配置**: +- ✅ 优先加载:`.development.env` +- ⚠️ 注意:如果设置了 `ignoreEnvFile: true`,则不会加载文件,只使用系统环境变量 + +## 🔍 验证 DATABASE_URL 来源 + +### 方法 1:查看环境变量(应用运行时) + +```bash +# 启动应用后,访问配置验证接口 +curl http://localhost:3001/api/config-verification/env-info +``` + +### 方法 2:查看启动日志 + +应用启动时会在控制台显示: +``` +=== 环境配置验证 === +DATABASE_URL: 已设置 mysql://root:woshimima@localhost:3306/db_competition_management?schema=public +``` + +### 方法 3:检查配置文件 + +```bash +cd backend +cat .development.env | grep DATABASE_URL +``` + +### 方法 4:在代码中验证 + +```typescript +// 在任何服务中 +constructor(private configService: ConfigService) {} + +const dbUrl = this.configService.get('DATABASE_URL'); +console.log('DATABASE_URL:', dbUrl); +``` + +## 🔐 环境变量来源优先级 + +Prisma 读取 `DATABASE_URL` 的优先级: + +1. **系统环境变量**(最高优先级) + ```bash + export DATABASE_URL="mysql://..." + ``` + +2. **.env 文件**(通过 ConfigModule 加载) + - `.development.env` + - `.env` + +3. **默认值**(如果都没有设置,Prisma 会报错) + +## 📝 DATABASE_URL 格式 + +``` +mysql://用户名:密码@主机:端口/数据库名?参数 +``` + +**示例**: +```env +# 本地数据库 +DATABASE_URL="mysql://root:password@localhost:3306/db_competition_management?schema=public" + +# 远程数据库 +DATABASE_URL="mysql://user:pass@192.168.1.100:3306/db_name?schema=public" + +# 带 SSL +DATABASE_URL="mysql://user:pass@host:3306/db_name?schema=public&sslmode=require" +``` + +## ⚠️ 注意事项 + +1. **密码包含特殊字符**:需要进行 URL 编码 + ```env + # 密码: p@ssw0rd + DATABASE_URL="mysql://user:p%40ssw0rd@localhost:3306/db" + ``` + +2. **配置文件安全**: + - `.development.env` 不应提交到 Git + - 生产环境使用环境变量或密钥管理服务 + +3. **Prisma 读取时机**: + - Prisma 直接读取 `process.env.DATABASE_URL` + - 不依赖 NestJS ConfigModule(但 ConfigModule 会将文件内容加载到 `process.env`) + +## 🔧 当前配置总结 + +- **配置文件**:`.development.env` +- **配置项**:`DATABASE_URL="mysql://root:woshimima@localhost:3306/db_competition_management?schema=public"` +- **加载方式**:NestJS ConfigModule → `process.env` → Prisma +- **验证方式**:启动日志或 `/api/config-verification/env-info` 接口 + diff --git a/backend/docs/ENVIRONMENT_CONFIG.md b/backend/docs/ENVIRONMENT_CONFIG.md new file mode 100644 index 0000000..0a61970 --- /dev/null +++ b/backend/docs/ENVIRONMENT_CONFIG.md @@ -0,0 +1,290 @@ +# 环境配置指南 + +## 环境区分方案 + +项目支持通过 `NODE_ENV` 环境变量和不同的 `.env` 文件来区分开发和生产环境。 + +## 配置文件结构 + +``` +backend/ +├── .env # 默认配置(可选,作为后备) +├── .env.development # 开发环境配置 +├── .env.production # 生产环境配置 +└── .env.test # 测试环境配置(可选) +``` + +## 配置优先级 + +配置文件按以下优先级加载: + +1. `.env.${NODE_ENV}` - 根据当前环境加载(最高优先级) +2. `.env` - 默认配置文件(后备) + +例如: +- `NODE_ENV=development` → 加载 `.env.development` +- `NODE_ENV=production` → 加载 `.env.production` +- 未设置 `NODE_ENV` → 默认加载 `.env.development`,然后 `.env` + +## 开发环境配置 + +### 创建 `.env.development` 文件 + +```env +# 开发环境配置 +NODE_ENV=development + +# 开发数据库(本地数据库) +DATABASE_URL="mysql://root:password@localhost:3306/competition_management_dev?schema=public" + +# JWT 密钥(开发环境可以使用简单密钥) +JWT_SECRET="dev-secret-key-not-for-production" + +# 服务器端口 +PORT=3001 + +# 日志级别 +LOG_LEVEL=debug + +# CORS 配置(开发环境允许所有来源) +CORS_ORIGIN=* +``` + +### 开发环境数据库命名建议 + +- 数据库名:`competition_management_dev` +- 便于区分:开发和生产使用不同的数据库 +- 安全:避免误操作生产数据 + +## 生产环境配置 + +### 创建 `.env.production` 文件 + +```env +# 生产环境配置 +NODE_ENV=production + +# 生产数据库(远程或云数据库) +DATABASE_URL="mysql://prod_user:strong_password@prod-db-host:3306/competition_management?schema=public&sslmode=require" + +# JWT 密钥(必须使用强随机字符串) +# 生成方式: openssl rand -hex 32 +JWT_SECRET="your-production-secret-key-must-be-strong-and-random-64-chars" + +# 服务器端口 +PORT=3001 + +# 日志级别 +LOG_LEVEL=error + +# CORS 配置(生产环境指定具体域名) +CORS_ORIGIN=https://yourdomain.com + +# 数据库连接池配置 +DB_POOL_MIN=2 +DB_POOL_MAX=10 + +# SSL/TLS 配置 +SSL_ENABLED=true +``` + +### 生产环境数据库配置要点 + +1. **使用独立的数据库服务器** +2. **启用 SSL 连接**(`sslmode=require`) +3. **使用强密码** +4. **限制数据库用户权限**(最小权限原则) +5. **定期备份** + +## 使用方法 + +### 开发环境 + +```bash +# 方式 1: 设置环境变量后启动 +NODE_ENV=development pnpm start:dev + +# 方式 2: 在 package.json 中配置(推荐) +# 已自动配置,直接运行: +pnpm start:dev +``` + +### 生产环境 + +```bash +# 方式 1: 设置环境变量后启动 +NODE_ENV=production pnpm start:prod + +# 方式 2: 在部署脚本中设置 +export NODE_ENV=production +pnpm start:prod +``` + +### 测试环境(可选) + +```bash +# 创建 .env.test 文件 +NODE_ENV=test +DATABASE_URL="mysql://root:password@localhost:3306/competition_management_test?schema=public" +JWT_SECRET="test-secret-key" +PORT=3002 + +# 运行测试 +NODE_ENV=test pnpm test +``` + +## 数据库命名规范 + +建议使用以下命名规范来区分不同环境的数据库: + +| 环境 | 数据库名 | 说明 | +|------|---------|------| +| 开发 | `competition_management_dev` | 开发环境数据库 | +| 测试 | `competition_management_test` | 测试环境数据库 | +| 生产 | `competition_management` | 生产环境数据库 | +| 预发布 | `competition_management_staging` | 预发布环境数据库 | + +## 创建不同环境的数据库 + +### 开发环境数据库 + +```sql +CREATE DATABASE competition_management_dev +CHARACTER SET utf8mb4 +COLLATE utf8mb4_unicode_ci; +``` + +### 生产环境数据库 + +```sql +CREATE DATABASE competition_management +CHARACTER SET utf8mb4 +COLLATE utf8mb4_unicode_ci; +``` + +## 环境变量管理最佳实践 + +### 1. 使用 .gitignore + +确保 `.env*` 文件不被提交到版本控制: + +```gitignore +# .env files +.env +.env.local +.env.*.local +.env.development +.env.production +.env.test +``` + +### 2. 提供示例文件 + +创建 `.env.example` 或 `.env.*.example` 文件作为模板: + +```bash +# 开发环境示例 +cp .env.development.example .env.development + +# 生产环境示例 +cp .env.production.example .env.production +``` + +### 3. 使用环境变量管理工具(生产环境) + +- **Docker**: 使用 `docker-compose.yml` 中的 `env_file` +- **Kubernetes**: 使用 `ConfigMap` 和 `Secret` +- **云平台**: + - AWS: Secrets Manager + - Azure: Key Vault + - GCP: Secret Manager + +### 4. 验证配置 + +在应用启动时验证必要的环境变量: + +```typescript +// 可以在 main.ts 中添加验证 +if (!process.env.DATABASE_URL) { + throw new Error('DATABASE_URL is required'); +} +``` + +## 快速开始 + +### 1. 创建开发环境配置 + +```bash +cd backend + +# 创建开发环境配置文件 +cat > .env.development << EOF +NODE_ENV=development +DATABASE_URL="mysql://root:password@localhost:3306/competition_management_dev?schema=public" +JWT_SECRET="dev-secret-key" +PORT=3001 +EOF +``` + +### 2. 创建生产环境配置 + +```bash +# 创建生产环境配置文件(不要提交到 Git) +cat > .env.production << EOF +NODE_ENV=production +DATABASE_URL="mysql://prod_user:password@prod-host:3306/competition_management?schema=public&sslmode=require" +JWT_SECRET="$(openssl rand -hex 32)" +PORT=3001 +EOF +``` + +### 3. 初始化数据库 + +```bash +# 开发环境 +NODE_ENV=development pnpm prisma:migrate dev + +# 生产环境(部署时) +NODE_ENV=production pnpm prisma:migrate deploy +``` + +## 常见问题 + +### Q: 如何确保使用正确的环境配置? + +A: 在启动应用前检查 `NODE_ENV` 环境变量: +```bash +echo $NODE_ENV # 应该显示 development 或 production +``` + +### Q: 生产环境配置应该存储在哪里? + +A: +- **不要提交到 Git** +- 使用环境变量管理工具(如 Docker secrets、K8s secrets) +- 或使用云平台提供的密钥管理服务 + +### Q: 如何在不同环境间切换? + +A: 通过设置 `NODE_ENV` 环境变量: +```bash +# 开发环境 +export NODE_ENV=development +pnpm start:dev + +# 生产环境 +export NODE_ENV=production +pnpm start:prod +``` + +### Q: 数据库迁移如何区分环境? + +A: Prisma 会根据 `DATABASE_URL` 环境变量自动使用对应的数据库: +```bash +# 开发环境迁移 +NODE_ENV=development pnpm prisma:migrate dev + +# 生产环境迁移 +NODE_ENV=production pnpm prisma:migrate deploy +``` + diff --git a/backend/docs/ENV_CHANGE_GUIDE.md b/backend/docs/ENV_CHANGE_GUIDE.md new file mode 100644 index 0000000..433326b --- /dev/null +++ b/backend/docs/ENV_CHANGE_GUIDE.md @@ -0,0 +1,254 @@ +# 修改 DATABASE_URL 后的操作指南 + +## 📋 操作决策树 + +``` +修改 DATABASE_URL + │ + ├─ 只改了连接信息(地址/端口/用户名/密码/数据库名) + │ └─ schema.prisma 未修改 + │ ├─ 目标数据库已有表结构 → ✅ 只需重启应用 + │ └─ 目标数据库是空的 → ⚠️ 需要运行迁移 + │ + └─ 同时修改了 schema.prisma + └─ ✅ 必须执行:生成 Client + 运行迁移 +``` + +## 🔄 场景 1:只修改连接信息(最常见) + +### 情况 A:目标数据库已有表结构 + +**示例**:从本地数据库切换到远程数据库,但表结构已存在 + +```bash +# 1. 修改 .development.env 文件 +DATABASE_URL="mysql://user:pass@new-host:3306/db_name?schema=public" + +# 2. 重启应用即可(无需执行 Prisma 命令) +npm run start:dev +``` + +**原因**: + +- Prisma Client 在应用启动时读取 `process.env.DATABASE_URL` +- 如果目标数据库已有表结构,直接连接即可 +- 不需要重新生成 Client(类型定义没变) +- 不需要运行迁移(表结构没变) + +--- + +### 情况 B:目标数据库是空的(新数据库) + +**示例**:切换到全新的数据库,还没有表结构 + +```bash +# 1. 修改 .development.env 文件 +DATABASE_URL="mysql://user:pass@new-host:3306/new_db?schema=public" + +# 2. 运行迁移创建表结构 +npm run prisma:migrate + +# 或使用部署模式(生产环境) +npm run prisma:migrate:deploy + +# 3. 重启应用 +npm run start:dev +``` + +**原因**: + +- 新数据库没有表结构 +- 需要运行迁移来创建表 +- 迁移会读取 `process.env.DATABASE_URL` 连接到新数据库 + +--- + +## 🔄 场景 2:同时修改了 schema.prisma + +**示例**:修改了数据库模型(添加/删除字段、表等) + +```bash +# 1. 修改 schema.prisma(添加字段、表等) + +# 2. 生成 Prisma Client(必须) +npm run prisma:generate + +# 3. 创建并运行迁移(必须) +npm run prisma:migrate +# 会提示输入迁移名称,如:add_user_email_field + +# 4. 重启应用 +npm run start:dev +``` + +**原因**: + +- schema.prisma 改变 → TypeScript 类型定义改变 → 需要重新生成 Client +- 数据库结构改变 → 需要创建迁移并应用到数据库 + +--- + +## 📝 完整操作流程 + +### 开发环境(推荐流程) + +```bash +cd backend + +# 1. 修改 .development.env 中的 DATABASE_URL +vim .development.env + +# 2. 检查目标数据库是否有表结构 +# 方式 A:使用 Prisma Studio 查看 +npm run prisma:studio + +# 方式 B:直接连接数据库查看 +mysql -h host -u user -p database -e "SHOW TABLES;" + +# 3. 根据情况选择操作: + +# 情况 1:数据库已有表结构 → 只需重启 +npm run start:dev + +# 情况 2:数据库是空的 → 运行迁移 +npm run prisma:migrate +npm run start:dev + +# 情况 3:修改了 schema.prisma → 生成 + 迁移 +npm run prisma:generate +npm run prisma:migrate +npm run start:dev +``` + +### 生产环境(部署流程) + +```bash +cd backend + +# 1. 修改生产环境配置文件或环境变量 +# 注意:生产环境通常使用环境变量,而不是文件 + +# 2. 生成 Prisma Client +npm run prisma:generate + +# 3. 运行迁移(生产环境使用 deploy,不会创建新迁移) +NODE_ENV=production npm run prisma:migrate:deploy + +# 4. 重启应用 +npm run start:prod +``` + +--- + +## ✅ 快速检查清单 + +修改 `DATABASE_URL` 后,按以下顺序检查: + +- [ ] **只改了连接信息?** + - [ ] 目标数据库有表 → ✅ 重启应用 + - [ ] 目标数据库为空 → ⚠️ 运行迁移 + +- [ ] **修改了 schema.prisma?** + - [ ] 是 → ✅ 生成 Client + 运行迁移 + - [ ] 否 → 跳过 + +- [ ] **应用启动后验证** + - [ ] 检查启动日志中的 DATABASE_URL + - [ ] 访问 `/api/config-verification/env-info` 验证 + - [ ] 测试数据库操作是否正常 + +--- + +## 🔍 验证方法 + +### 1. 验证 DATABASE_URL 是否生效 + +```bash +# 启动应用后查看日志 +npm run start:dev + +# 应该看到: +# DATABASE_URL: 已设置 mysql://... +``` + +### 2. 验证数据库连接 + +```bash +# 使用 Prisma Studio 连接 +npm run prisma:studio + +# 如果能打开并看到表,说明连接成功 +``` + +### 3. 验证表结构 + +```bash +# 检查迁移状态 +npx prisma migrate status + +# 应该显示:All migrations have been successfully applied +``` + +--- + +## ⚠️ 常见错误 + +### 错误 1:连接失败 + +``` +Error: Can't reach database server +``` + +**解决**: + +- 检查 DATABASE_URL 格式是否正确 +- 检查数据库服务是否运行 +- 检查网络连接和防火墙 + +### 错误 2:表不存在 + +``` +Error: Table 'xxx' doesn't exist +``` + +**解决**: + +- 运行迁移:`npm run prisma:migrate` +- 或使用:`npx prisma db push`(仅开发环境) + +### 错误 3:迁移状态不一致 + +``` +Error: The migration failed to apply +``` + +**解决**: + +- 检查迁移历史:`npx prisma migrate status` +- 重置数据库(仅开发环境):`npx prisma migrate reset` +- 或手动修复迁移文件 + +--- + +## 📚 相关命令速查 + +| 操作 | 命令 | 说明 | +| ----------- | ------------------------------- | ------------------------- | +| 生成 Client | `npm run prisma:generate` | 根据 schema 生成类型 | +| 创建迁移 | `npm run prisma:migrate` | 开发环境,会创建新迁移 | +| 应用迁移 | `npm run prisma:migrate:deploy` | 生产环境,只应用已有迁移 | +| 查看状态 | `npx prisma migrate status` | 查看迁移状态 | +| 打开 Studio | `npm run prisma:studio` | 可视化数据库 | +| 推送结构 | `npx prisma db push` | 直接同步 schema(仅开发) | + +--- + +## 🎯 总结 + +**修改 DATABASE_URL 后的最小操作**: + +1. **只改连接信息 + 数据库有表** → ✅ **重启应用** +2. **只改连接信息 + 数据库为空** → ⚠️ **运行迁移** +3. **修改了 schema.prisma** → ✅ **生成 Client + 运行迁移** + +**记住**:Prisma Client 在应用启动时读取 `process.env.DATABASE_URL`,所以修改后必须重启应用才能生效! diff --git a/backend/docs/MENU_INIT.md b/backend/docs/MENU_INIT.md new file mode 100644 index 0000000..f889e55 --- /dev/null +++ b/backend/docs/MENU_INIT.md @@ -0,0 +1,219 @@ +# 菜单初始化指南 + +## 📋 概述 + +菜单初始化脚本会根据项目的前端路由配置,自动创建菜单数据到数据库中。脚本会创建树形结构的菜单,包括顶级菜单和子菜单。 + +## 🚀 使用方法 + +### 1. 执行初始化脚本 + +在 `backend` 目录下执行: + +```bash +pnpm init:menus +``` + +或者使用 npm: + +```bash +npm run init:menus +``` + +### 2. 脚本功能 + +脚本会根据 `frontend/src/router/index.ts` 中的路由配置,自动创建以下菜单结构: + +``` +仪表盘 (/dashboard) +系统管理 (/system) +├── 用户管理 (/system/users) +├── 角色管理 (/system/roles) +├── 菜单管理 (/system/menus) +├── 数据字典 (/system/dict) +├── 系统配置 (/system/config) +└── 日志记录 (/system/logs) +``` + +## 📝 菜单数据结构 + +### 顶级菜单 + +1. **仪表盘** + - 路径: `/dashboard` + - 图标: `DashboardOutlined` + - 组件: `dashboard/Index` + - 排序: 1 + +2. **系统管理** + - 路径: `/system` + - 图标: `SettingOutlined` + - 组件: `null` (父菜单) + - 排序: 10 + +### 系统管理子菜单 + +1. **用户管理** + - 路径: `/system/users` + - 图标: `UserOutlined` + - 组件: `system/users/Index` + - 排序: 1 + +2. **角色管理** + - 路径: `/system/roles` + - 图标: `TeamOutlined` + - 组件: `system/roles/Index` + - 排序: 2 + +3. **菜单管理** + - 路径: `/system/menus` + - 图标: `MenuOutlined` + - 组件: `system/menus/Index` + - 排序: 3 + +4. **数据字典** + - 路径: `/system/dict` + - 图标: `BookOutlined` + - 组件: `system/dict/Index` + - 排序: 4 + +5. **系统配置** + - 路径: `/system/config` + - 图标: `ToolOutlined` + - 组件: `system/config/Index` + - 排序: 5 + +6. **日志记录** + - 路径: `/system/logs` + - 图标: `FileTextOutlined` + - 组件: `system/logs/Index` + - 排序: 6 + +## 🔄 脚本特性 + +### 1. 幂等性 + +- 脚本支持重复执行 +- 如果菜单已存在(相同名称和父菜单),会更新现有菜单 +- 如果菜单不存在,会创建新菜单 + +### 2. 树形结构 + +- 自动处理父子菜单关系 +- 递归创建子菜单 +- 保持菜单层级结构 + +### 3. 数据更新 + +- 如果菜单已存在,会更新以下字段: + - 路径 (path) + - 图标 (icon) + - 组件路径 (component) + - 排序 (sort) + - 有效状态 (validState) + +## ⚙️ 自定义菜单数据 + +如果需要修改菜单数据,可以编辑 `backend/scripts/init-menus.ts` 文件中的 `menus` 数组: + +```typescript +const menus = [ + { + name: '菜单名称', + path: '/路由路径', + icon: 'IconOutlined', // Ant Design Icons 图标名称 + component: '组件路径', // 相对于 views 目录的路径 + parentId: null, // null 表示顶级菜单 + sort: 1, // 排序值,越小越靠前 + children: [ + // 子菜单数组(可选) + // ... + ], + }, +]; +``` + +## 🗑️ 清空现有菜单(可选) + +如果需要清空所有现有菜单后重新创建,可以取消注释脚本中的以下代码: + +```typescript +// 清空现有菜单 +console.log('🗑️ 清空现有菜单...'); +await prisma.menu.deleteMany({}); +console.log('✅ 已清空现有菜单\n'); +``` + +**注意**: 清空菜单会删除所有现有菜单数据,请谨慎操作! + +## 📊 执行结果示例 + +脚本执行成功后会显示: + +``` +🚀 开始初始化菜单数据... + +📝 创建菜单... + + ✓ 仪表盘 (/dashboard) + ✓ 系统管理 (/system) + ✓ 用户管理 (/system/users) + ✓ 角色管理 (/system/roles) + ✓ 菜单管理 (/system/menus) + ✓ 数据字典 (/system/dict) + ✓ 系统配置 (/system/config) + ✓ 日志记录 (/system/logs) + +🔍 验证结果... + +📊 初始化结果: + 顶级菜单数量: 2 + 总菜单数量: 8 + +📋 菜单结构: +├─ 仪表盘 (/dashboard) +├─ 系统管理 (/system) +│ ├─ 用户管理 (/system/users) +│ ├─ 角色管理 (/system/roles) +│ ├─ 菜单管理 (/system/menus) +│ ├─ 数据字典 (/system/dict) +│ ├─ 系统配置 (/system/config) +│ └─ 日志记录 (/system/logs) + +✅ 菜单初始化完成! + +🎉 菜单初始化脚本执行完成! +``` + +## 🔍 验证菜单数据 + +初始化完成后,可以通过以下方式验证: + +### 方式一:使用 Prisma Studio + +```bash +pnpm prisma:studio +``` + +在浏览器中打开 Prisma Studio,查看 `menus` 表的数据。 + +### 方式二:通过菜单管理页面 + +1. 登录系统 +2. 访问"系统管理" -> "菜单管理" +3. 查看菜单列表,确认菜单已正确创建 + +## ⚠️ 注意事项 + +1. **数据库连接**: 确保 `.env` 文件中的 `DATABASE_URL` 配置正确 +2. **Prisma Client**: 确保已运行 `pnpm prisma:generate` 生成 Prisma Client +3. **数据库迁移**: 确保已运行 `pnpm prisma:migrate` 创建数据库表结构 +4. **图标名称**: 图标名称必须是有效的 Ant Design Icons 组件名称 +5. **路径格式**: 路由路径必须以 `/` 开头 +6. **组件路径**: 组件路径是相对于 `frontend/src/views/` 目录的路径 + +## 🔗 相关文档 + +- [数据库配置指南](./DATABASE_SETUP.md) +- [管理员账户初始化](./ADMIN_ACCOUNT.md) +- [路由配置说明](../frontend/src/router/index.ts) diff --git a/backend/docs/MIGRATION_INCREMENTAL_GUIDE.md b/backend/docs/MIGRATION_INCREMENTAL_GUIDE.md new file mode 100644 index 0000000..e837370 --- /dev/null +++ b/backend/docs/MIGRATION_INCREMENTAL_GUIDE.md @@ -0,0 +1,312 @@ +# Prisma 增量迁移指南 + +## 📋 概述 + +Prisma 的迁移机制**已经内置了增量执行功能**。当你运行迁移命令时,Prisma 会自动: + +- ✅ 只执行**新增的、未应用的**迁移 +- ✅ **跳过**已经执行过的迁移 +- ✅ 通过 `_prisma_migrations` 表跟踪迁移状态 + +--- + +## 🔍 Prisma 如何跟踪迁移状态 + +Prisma 在数据库中维护一个特殊的表 `_prisma_migrations`,用于记录: + +- 迁移名称(migration_name) +- 应用时间(applied_at) +- 迁移文件内容(checksum) +- 其他元数据 + +每次迁移执行后,Prisma 会在这个表中记录一条记录,确保不会重复执行。 + +--- + +## 🚀 迁移命令对比 + +### 1. `prisma migrate deploy`(生产环境推荐) + +**特点**: + +- ✅ **只执行未应用的迁移** +- ✅ 不会创建新迁移 +- ✅ 不会重置数据库 +- ✅ 适合生产环境 + +**使用场景**: + +- 生产环境部署 +- CI/CD 流程 +- 多环境同步 + +**示例**: + +```bash +# 生产环境 +npm run prisma:migrate:deploy + +# 或直接使用 +NODE_ENV=production prisma migrate deploy +``` + +**执行逻辑**: + +1. 读取 `prisma/migrations` 目录中的所有迁移文件 +2. 查询数据库中的 `_prisma_migrations` 表 +3. 对比找出未应用的迁移 +4. **只执行未应用的迁移** +5. 在 `_prisma_migrations` 表中记录新应用的迁移 + +--- + +### 2. `prisma migrate dev`(开发环境推荐) + +**特点**: + +- ✅ 创建新迁移(如果有 schema 变更) +- ✅ **只执行未应用的迁移** +- ✅ 可能会重置开发数据库(如果使用 shadow database) +- ✅ 适合开发环境 + +**使用场景**: + +- 本地开发 +- Schema 变更后创建迁移 + +**示例**: + +```bash +# 开发环境 +npm run prisma:migrate + +# 或直接使用 +prisma migrate dev +``` + +**执行逻辑**: + +1. 检查 schema.prisma 是否有变更 +2. 如果有变更,创建新迁移文件 +3. 查询 `_prisma_migrations` 表找出未应用的迁移 +4. **只执行未应用的迁移**(包括新创建的) +5. 记录到 `_prisma_migrations` 表 + +--- + +## 📊 查看迁移状态 + +### 检查哪些迁移已应用 + +```bash +# 查看迁移状态 +npx prisma migrate status + +# 输出示例: +# ✅ Database schema is up to date! +# +# The following migrations have been applied: +# - 20251118035205_init +# - 20251118041000_add_comments +# - 20251118211424_change_log_content_to_text +``` + +### 直接查询数据库 + +```sql +-- 查看所有已应用的迁移 +SELECT * FROM _prisma_migrations ORDER BY applied_at DESC; + +-- 查看迁移名称和状态 +SELECT migration_name, applied_at, finished_at +FROM _prisma_migrations +ORDER BY applied_at DESC; +``` + +--- + +## 🎯 实际使用场景 + +### 场景 1:生产环境部署 + +**情况**:生产数据库已经有部分迁移,现在要部署新版本 + +```bash +# 1. 部署新代码(包含新的迁移文件) + +# 2. 运行迁移(只会执行新增的迁移) +npm run prisma:migrate:deploy + +# Prisma 会自动: +# - 检查 _prisma_migrations 表 +# - 找出未应用的迁移(如:20251120000000_new_feature) +# - 只执行这个新迁移 +# - 跳过已执行的迁移(如:20251118035205_init) +``` + +**结果**: + +- ✅ 已执行的迁移不会重复执行 +- ✅ 只执行新增的迁移 +- ✅ 数据库结构同步到最新状态 + +--- + +### 场景 2:多环境同步 + +**情况**:开发环境有 3 个迁移,生产环境只有 2 个 + +```bash +# 开发环境迁移: +# - 20251118035205_init ✅ +# - 20251118041000_add_comments ✅ +# - 20251118211424_change_log_content_to_text ✅ + +# 生产环境迁移: +# - 20251118035205_init ✅ +# - 20251118041000_add_comments ✅ +# - 20251118211424_change_log_content_to_text ❌(未应用) + +# 在生产环境运行: +npm run prisma:migrate:deploy + +# Prisma 会: +# - 跳过前两个已应用的迁移 +# - 只执行最后一个未应用的迁移 +``` + +--- + +### 场景 3:回滚和修复 + +**情况**:某个迁移执行失败,需要修复 + +```bash +# 1. 检查迁移状态 +npx prisma migrate status + +# 2. 如果迁移失败,_prisma_migrations 表中不会有记录 +# 3. 修复迁移文件后,重新运行 +npm run prisma:migrate:deploy + +# Prisma 会: +# - 检查失败的迁移是否已记录 +# - 如果没有记录,会重新执行 +# - 如果已记录,会跳过 +``` + +--- + +## ⚠️ 注意事项 + +### 1. 不要手动修改 `_prisma_migrations` 表 + +这个表由 Prisma 自动管理,手动修改可能导致迁移状态不一致。 + +### 2. 迁移文件不要删除 + +即使迁移已执行,也不要删除 `prisma/migrations` 目录中的迁移文件。这些文件是迁移历史的一部分。 + +### 3. 生产环境使用 `migrate deploy` + +```bash +# ✅ 正确:生产环境 +prisma migrate deploy + +# ❌ 错误:生产环境不要使用 +prisma migrate dev # 可能会重置数据库 +``` + +### 4. 迁移文件顺序很重要 + +Prisma 按照迁移文件名(时间戳)的顺序执行迁移。确保迁移文件名的时间戳顺序正确。 + +--- + +## 🔧 故障排查 + +### 问题 1:迁移状态不一致 + +**症状**:`prisma migrate status` 显示状态不一致 + +**解决**: + +```bash +# 1. 检查 _prisma_migrations 表 +SELECT * FROM _prisma_migrations; + +# 2. 检查迁移文件 +ls -la prisma/migrations/ + +# 3. 如果迁移文件存在但未记录,手动标记(谨慎操作) +# 或者重新运行迁移 +prisma migrate deploy +``` + +### 问题 2:迁移重复执行 + +**症状**:迁移被重复执行 + +**原因**:`_prisma_migrations` 表中没有记录 + +**解决**: + +```bash +# 检查迁移记录 +npx prisma migrate status + +# 如果显示迁移未应用,但数据库结构已存在 +# 可能需要手动标记迁移为已应用(谨慎操作) +``` + +### 问题 3:迁移文件丢失 + +**症状**:迁移文件被删除,但数据库中有记录 + +**解决**: + +```bash +# 1. 从版本控制恢复迁移文件 +git checkout prisma/migrations/ + +# 2. 重新运行迁移检查 +npx prisma migrate status +``` + +--- + +## 📚 相关命令速查 + +| 命令 | 说明 | 使用场景 | +| ----------------------- | ---------------------- | -------- | +| `prisma migrate deploy` | 只执行未应用的迁移 | 生产环境 | +| `prisma migrate dev` | 创建并执行迁移 | 开发环境 | +| `prisma migrate status` | 查看迁移状态 | 所有环境 | +| `prisma migrate reset` | 重置数据库(开发环境) | 开发环境 | +| `prisma db push` | 直接同步 schema | 快速原型 | + +--- + +## ✅ 总结 + +**Prisma 迁移机制的核心特点**: + +1. ✅ **自动增量执行**:只执行未应用的迁移 +2. ✅ **状态跟踪**:通过 `_prisma_migrations` 表跟踪 +3. ✅ **安全可靠**:不会重复执行已应用的迁移 +4. ✅ **环境区分**:`migrate deploy` 用于生产,`migrate dev` 用于开发 + +**最佳实践**: + +- 🎯 生产环境:使用 `prisma migrate deploy` +- 🎯 开发环境:使用 `prisma migrate dev` +- 🎯 定期检查:使用 `prisma migrate status` 查看状态 +- 🎯 版本控制:提交所有迁移文件到 Git + +--- + +## 🔗 相关文档 + +- [Prisma 官方迁移文档](https://www.prisma.io/docs/concepts/components/prisma-migrate) +- [SCHEMA_CHANGE_GUIDE.md](./SCHEMA_CHANGE_GUIDE.md) - Schema 修改指南 +- [DATABASE_SETUP.md](./DATABASE_SETUP.md) - 数据库设置指南 diff --git a/backend/docs/QUICK_START_ENV.md b/backend/docs/QUICK_START_ENV.md new file mode 100644 index 0000000..a755bcd --- /dev/null +++ b/backend/docs/QUICK_START_ENV.md @@ -0,0 +1,131 @@ +# 环境配置快速参考 + +## 🚀 快速开始 + +### 1. 创建开发环境配置 + +```bash +cd backend + +# 创建开发环境配置文件 +cat > .env.development << 'EOF' +NODE_ENV=development +DATABASE_URL="mysql://root:password@localhost:3306/competition_management_dev?schema=public" +JWT_SECRET="dev-secret-key" +PORT=3001 +EOF +``` + +### 2. 创建生产环境配置 + +```bash +# 创建生产环境配置文件(不要提交到 Git) +cat > .env.production << 'EOF' +NODE_ENV=production +DATABASE_URL="mysql://prod_user:strong_password@prod-host:3306/competition_management?schema=public&sslmode=require" +JWT_SECRET="$(openssl rand -hex 32)" +PORT=3001 +EOF +``` + +### 3. 创建数据库 + +```sql +-- 开发环境数据库 +CREATE DATABASE competition_management_dev +CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- 生产环境数据库 +CREATE DATABASE competition_management +CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +``` + +### 4. 初始化数据库 + +```bash +# 开发环境 +pnpm prisma:generate +pnpm prisma:migrate + +# 生产环境(部署时) +NODE_ENV=production pnpm prisma:migrate:deploy +``` + +## 📋 环境区分总结 + +| 项目 | 开发环境 | 生产环境 | +|------|---------|---------| +| **配置文件** | `.env.development` | `.env.production` | +| **数据库名** | `competition_management_dev` | `competition_management` | +| **启动命令** | `pnpm start:dev` | `pnpm start:prod` | +| **迁移命令** | `pnpm prisma:migrate` | `pnpm prisma:migrate:deploy` | +| **Prisma Studio** | `pnpm prisma:studio:dev` | `pnpm prisma:studio:prod` | +| **日志级别** | `debug` | `error` | +| **CORS** | `*` (所有来源) | 指定域名 | +| **SSL** | 可选 | 必须启用 | + +## 🔑 关键区别 + +### 开发环境 +- ✅ 使用本地数据库 +- ✅ 简单的 JWT 密钥(便于开发) +- ✅ 详细的日志输出 +- ✅ 允许所有 CORS 来源 +- ✅ 热重载支持 + +### 生产环境 +- ✅ 独立的数据库服务器 +- ✅ 强随机 JWT 密钥 +- ✅ 最小化日志输出 +- ✅ 限制 CORS 来源 +- ✅ 启用 SSL/TLS +- ✅ 连接池优化 + +## 📝 配置文件示例 + +### `.env.development` +```env +NODE_ENV=development +DATABASE_URL="mysql://root:password@localhost:3306/competition_management_dev?schema=public" +JWT_SECRET="dev-secret-key" +PORT=3001 +LOG_LEVEL=debug +CORS_ORIGIN=* +``` + +### `.env.production` +```env +NODE_ENV=production +DATABASE_URL="mysql://prod_user:strong_password@prod-host:3306/competition_management?schema=public&sslmode=require" +JWT_SECRET="your-production-secret-key-must-be-strong-and-random" +PORT=3001 +LOG_LEVEL=error +CORS_ORIGIN=https://yourdomain.com +SSL_ENABLED=true +DB_POOL_MIN=2 +DB_POOL_MAX=10 +``` + +## ⚠️ 注意事项 + +1. **不要提交 `.env` 文件到 Git** +2. **生产环境必须使用强密码和 JWT_SECRET** +3. **生产环境建议启用 SSL 连接** +4. **定期备份生产数据库** +5. **使用不同的数据库名称区分环境** + +## 🔍 验证配置 + +```bash +# 检查当前环境 +echo $NODE_ENV + +# 验证数据库连接(开发环境) +NODE_ENV=development pnpm prisma:studio + +# 验证数据库连接(生产环境) +NODE_ENV=production pnpm prisma:studio:prod +``` + +更多详细信息请查看 [ENVIRONMENT_CONFIG.md](./ENVIRONMENT_CONFIG.md) + diff --git a/backend/docs/RBAC_EXAMPLES.md b/backend/docs/RBAC_EXAMPLES.md new file mode 100644 index 0000000..769285c --- /dev/null +++ b/backend/docs/RBAC_EXAMPLES.md @@ -0,0 +1,444 @@ +# RBAC 权限控制使用示例 + +## 📋 目录 +1. [基础使用](#基础使用) +2. [角色控制示例](#角色控制示例) +3. [权限控制示例](#权限控制示例) +4. [完整示例](#完整示例) + +## 🔧 基础使用 + +### 1. 创建权限 + +```typescript +// 在数据库中创建权限 +const permissions = [ + { code: 'user:create', resource: 'user', action: 'create', name: '创建用户' }, + { code: 'user:read', resource: 'user', action: 'read', name: '查看用户' }, + { code: 'user:update', resource: 'user', action: 'update', name: '更新用户' }, + { code: 'user:delete', resource: 'user', action: 'delete', name: '删除用户' }, + { code: 'role:create', resource: 'role', action: 'create', name: '创建角色' }, + { code: 'role:read', resource: 'role', action: 'read', name: '查看角色' }, +]; + +for (const perm of permissions) { + await prisma.permission.create({ data: perm }); +} +``` + +### 2. 创建角色并分配权限 + +```typescript +// 创建管理员角色 +const adminRole = await prisma.role.create({ + data: { + name: '管理员', + code: 'admin', + permissions: { + create: [ + { permission: { connect: { code: 'user:create' } } }, + { permission: { connect: { code: 'user:read' } } }, + { permission: { connect: { code: 'user:update' } } }, + { permission: { connect: { code: 'user:delete' } } }, + { permission: { connect: { code: 'role:create' } } }, + { permission: { connect: { code: 'role:read' } } }, + ] + } + } +}); + +// 创建编辑角色(只有查看和更新权限) +const editorRole = await prisma.role.create({ + data: { + name: '编辑', + code: 'editor', + permissions: { + create: [ + { permission: { connect: { code: 'user:read' } } }, + { permission: { connect: { code: 'user:update' } } }, + ] + } + } +}); +``` + +### 3. 给用户分配角色 + +```typescript +// 给用户分配管理员角色 +await prisma.userRole.create({ + data: { + user: { connect: { id: 1 } }, + role: { connect: { code: 'admin' } } + } +}); + +// 用户可以有多个角色 +await prisma.userRole.create({ + data: { + user: { connect: { id: 1 } }, + role: { connect: { code: 'editor' } } + } +}); +``` + +## 🎯 角色控制示例 + +### 在控制器中使用角色装饰器 + +```typescript +import { Controller, Get, Post, Delete, UseGuards } from '@nestjs/common'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; + +@Controller('users') +@UseGuards(JwtAuthGuard, RolesGuard) // 先验证 JWT,再验证角色 +export class UsersController { + + // 所有已登录用户都可以查看 + @Get() + findAll() { + return this.usersService.findAll(); + } + + // 只有管理员和编辑可以创建用户 + @Post() + @Roles('admin', 'editor') + create(@Body() createUserDto: CreateUserDto) { + return this.usersService.create(createUserDto); + } + + // 只有管理员可以删除用户 + @Delete(':id') + @Roles('admin') + remove(@Param('id') id: string) { + return this.usersService.remove(+id); + } +} +``` + +## 🔐 权限控制示例 + +### 创建权限守卫(可选扩展) + +```typescript +// src/auth/guards/permissions.guard.ts +import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; + +@Injectable() +export class PermissionsGuard implements CanActivate { + constructor(private reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const requiredPermissions = this.reflector.getAllAndOverride( + 'permissions', + [context.getHandler(), context.getClass()], + ); + + if (!requiredPermissions) { + return true; // 没有权限要求,允许访问 + } + + const { user } = context.switchToHttp().getRequest(); + const userPermissions = user.permissions || []; + + // 检查用户是否拥有任一所需权限 + return requiredPermissions.some((permission) => + userPermissions.includes(permission), + ); + } +} +``` + +### 创建权限装饰器 + +```typescript +// src/auth/decorators/permissions.decorator.ts +import { SetMetadata } from '@nestjs/common'; + +export const PERMISSIONS_KEY = 'permissions'; +export const Permissions = (...permissions: string[]) => + SetMetadata(PERMISSIONS_KEY, permissions); +``` + +### 使用权限控制 + +```typescript +import { Controller, Get, Post, Delete, UseGuards } from '@nestjs/common'; +import { Permissions } from '../auth/decorators/permissions.decorator'; +import { PermissionsGuard } from '../auth/guards/permissions.guard'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; + +@Controller('users') +@UseGuards(JwtAuthGuard, PermissionsGuard) +export class UsersController { + + @Get() + @Permissions('user:read') // 需要 user:read 权限 + findAll() { + return this.usersService.findAll(); + } + + @Post() + @Permissions('user:create') // 需要 user:create 权限 + create(@Body() createUserDto: CreateUserDto) { + return this.usersService.create(createUserDto); + } + + @Delete(':id') + @Permissions('user:delete') // 需要 user:delete 权限 + remove(@Param('id') id: string) { + return this.usersService.remove(+id); + } +} +``` + +## 📚 完整示例 + +### 完整的用户管理控制器 + +```typescript +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + Query, + UseGuards, + Request, +} from '@nestjs/common'; +import { UsersService } from './users.service'; +import { CreateUserDto } from './dto/create-user.dto'; +import { UpdateUserDto } from './dto/update-user.dto'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { Roles } from '../auth/decorators/roles.decorator'; + +@Controller('users') +@UseGuards(JwtAuthGuard) // 所有接口都需要登录 +export class UsersController { + constructor(private readonly usersService: UsersService) {} + + // 查看用户列表 - 所有已登录用户都可以访问 + @Get() + findAll(@Query('page') page?: string, @Query('pageSize') pageSize?: string) { + return this.usersService.findAll( + page ? parseInt(page) : 1, + pageSize ? parseInt(pageSize) : 10, + ); + } + + // 查看用户详情 - 所有已登录用户都可以访问 + @Get(':id') + findOne(@Param('id') id: string) { + return this.usersService.findOne(+id); + } + + // 创建用户 - 需要 admin 或 editor 角色 + @Post() + @UseGuards(RolesGuard) + @Roles('admin', 'editor') + create(@Body() createUserDto: CreateUserDto, @Request() req) { + // req.user 包含当前用户信息(从 JWT 中提取) + return this.usersService.create(createUserDto); + } + + // 更新用户 - 需要 admin 角色,或者用户自己更新自己 + @Patch(':id') + @UseGuards(RolesGuard) + async update( + @Param('id') id: string, + @Body() updateUserDto: UpdateUserDto, + @Request() req, + ) { + const userId = parseInt(id); + const currentUserId = req.user.userId; + + // 管理员可以更新任何人,普通用户只能更新自己 + if (req.user.roles?.includes('admin') || userId === currentUserId) { + return this.usersService.update(userId, updateUserDto); + } + + throw new ForbiddenException('无权更新此用户'); + } + + // 删除用户 - 只有管理员可以删除 + @Delete(':id') + @UseGuards(RolesGuard) + @Roles('admin') + remove(@Param('id') id: string) { + return this.usersService.remove(+id); + } +} +``` + +## 🔍 权限检查流程 + +### 1. 用户登录 + +```typescript +// POST /api/auth/login +{ + "username": "admin", + "password": "password123" +} + +// 返回 +{ + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "user": { + "id": 1, + "username": "admin", + "nickname": "管理员", + "roles": ["admin"], // 用户的角色列表 + "permissions": [ // 用户的所有权限(从角色中聚合) + "user:create", + "user:read", + "user:update", + "user:delete", + "role:create", + "role:read" + ] + } +} +``` + +### 2. 访问受保护的接口 + +```typescript +// 请求头 +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... + +// 流程 +1. JwtAuthGuard 验证 Token + └─> 提取用户信息,添加到 req.user + +2. RolesGuard 检查角色 + └─> 从 req.user.roles 中检查是否包含所需角色 + └─> 如果包含,允许访问;否则返回 403 Forbidden +``` + +## 🎨 前端权限控制示例 + +### Vue 3 中使用权限 + +```typescript +// stores/auth.ts +export const useAuthStore = defineStore('auth', () => { + const user = ref(null); + + // 检查是否有指定角色 + const hasRole = (role: string) => { + return user.value?.roles?.includes(role) ?? false; + }; + + // 检查是否有指定权限 + const hasPermission = (permission: string) => { + return user.value?.permissions?.includes(permission) ?? false; + }; + + // 检查是否有任一角色 + const hasAnyRole = (roles: string[]) => { + return roles.some(role => hasRole(role)); + }; + + // 检查是否有任一权限 + const hasAnyPermission = (permissions: string[]) => { + return permissions.some(perm => hasPermission(perm)); + }; + + return { + user, + hasRole, + hasPermission, + hasAnyRole, + hasAnyPermission, + }; +}); +``` + +### 在组件中使用 + +```vue + + + +``` + +### 路由守卫 + +```typescript +// router/index.ts +router.beforeEach((to, from, next) => { + const authStore = useAuthStore(); + + // 检查是否需要认证 + if (to.meta.requiresAuth && !authStore.isAuthenticated) { + next({ name: 'Login' }); + return; + } + + // 检查角色 + if (to.meta.roles && !authStore.hasAnyRole(to.meta.roles)) { + next({ name: 'Forbidden' }); + return; + } + + // 检查权限 + if (to.meta.permissions && !authStore.hasAnyPermission(to.meta.permissions)) { + next({ name: 'Forbidden' }); + return; + } + + next(); +}); +``` + +## 📊 权限矩阵示例 + +| 角色 | user:create | user:read | user:update | user:delete | role:create | role:read | +|------|-------------|-----------|-------------|------------|-------------|-----------| +| admin | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| editor | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | +| viewer | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | + +## 🎯 总结 + +RBAC 权限控制的核心是: + +1. **用户** ←→ **角色** ←→ **权限** +2. 通过 `@Roles()` 装饰器控制接口访问 +3. 前端根据返回的 `roles` 和 `permissions` 控制 UI 显示 +4. 权限由 `resource:action` 组成,如 `user:create` + +这样的设计既保证了安全性,又提供了良好的灵活性和可维护性! + diff --git a/backend/docs/RBAC_GUIDE.md b/backend/docs/RBAC_GUIDE.md new file mode 100644 index 0000000..afca22d --- /dev/null +++ b/backend/docs/RBAC_GUIDE.md @@ -0,0 +1,397 @@ +# RBAC 权限控制详解 + +## 📚 什么是 RBAC? + +**RBAC(Role-Based Access Control)** 即**基于角色的访问控制**,是一种权限管理模型。它的核心思想是: + +> **用户 → 角色 → 权限** + +通过给用户分配角色,角色拥有权限,从而间接地给用户授予权限。 + +## 🎯 RBAC 的核心概念 + +### 1. **用户(User)** + +系统中的实际使用者,如:张三、李四 + +### 2. **角色(Role)** + +一组权限的集合,如:管理员、编辑、访客 + +### 3. **权限(Permission)** + +对资源的操作能力,如:创建用户、删除文章、查看报表 + +### 4. **资源(Resource)** + +系统中的实体对象,如:用户、文章、订单 + +### 5. **操作(Action)** + +对资源的操作类型,如:create(创建)、read(查看)、update(更新)、delete(删除) + +## 🏗️ 项目中的 RBAC 架构 + +### 数据模型关系 + +``` +User (用户) + ↓ (多对多) +UserRole (用户角色关联) + ↓ +Role (角色) + ↓ (多对多) +RolePermission (角色权限关联) + ↓ +Permission (权限) + ├─ resource: 资源名称 (如: user, role, menu) + └─ action: 操作类型 (如: create, read, update, delete) +``` + +### 数据库表结构 + +#### 1. **users** - 用户表 + +存储系统用户的基本信息 + +#### 2. **roles** - 角色表 + +存储角色信息,如: + +- `admin` - 管理员 +- `editor` - 编辑 +- `viewer` - 查看者 + +#### 3. **permissions** - 权限表 + +存储权限信息,权限由 `resource` + `action` 组成,如: + +- `user:create` - 创建用户 +- `user:read` - 查看用户 +- `user:update` - 更新用户 +- `user:delete` - 删除用户 +- `role:create` - 创建角色 +- `menu:read` - 查看菜单 + +#### 4. **user_roles** - 用户角色关联表 + +用户和角色的多对多关系 + +#### 5. **role_permissions** - 角色权限关联表 + +角色和权限的多对多关系 + +## 🔄 RBAC 工作流程 + +### 1. **权限分配流程** + +``` +1. 创建权限 + └─> 定义资源(resource)和操作(action) + └─> 例如:user:create, user:read + +2. 创建角色 + └─> 给角色分配权限 + └─> 例如:管理员角色 = [user:create, user:read, user:update, user:delete] + +3. 给用户分配角色 + └─> 用户继承角色的所有权限 + └─> 例如:张三 = 管理员角色 +``` + +### 2. **权限验证流程** + +``` +用户请求 API + ↓ +JWT 认证(验证用户身份) + ↓ +提取用户信息(包含 roles 和 permissions) + ↓ +RolesGuard 检查(检查用户是否有指定角色) + ↓ +PermissionGuard 检查(检查用户是否有指定权限) + ↓ +允许/拒绝访问 +``` + +## 💻 代码实现示例 + +### 1. **定义权限** + +权限由 `resource` + `action` 组成: + +```typescript +// 权限示例 +{ + code: 'user:create', // 权限编码 + resource: 'user', // 资源:用户 + action: 'create', // 操作:创建 + name: '创建用户', + description: '允许创建新用户' +} + +{ + code: 'user:read', + resource: 'user', + action: 'read', + name: '查看用户', + description: '允许查看用户列表和详情' +} +``` + +### 2. **创建角色并分配权限** + +```typescript +// 创建管理员角色 +const adminRole = await prisma.role.create({ + data: { + name: '管理员', + code: 'admin', + permissions: { + create: [ + { permission: { connect: { code: 'user:create' } } }, + { permission: { connect: { code: 'user:read' } } }, + { permission: { connect: { code: 'user:update' } } }, + { permission: { connect: { code: 'user:delete' } } }, + { permission: { connect: { code: 'role:create' } } }, + // ... 更多权限 + ], + }, + }, +}); +``` + +### 3. **给用户分配角色** + +```typescript +// 给用户分配管理员角色 +await prisma.userRole.create({ + data: { + user: { connect: { id: userId } }, + role: { connect: { code: 'admin' } }, + }, +}); +``` + +### 4. **在控制器中使用权限控制** + +#### 方式一:使用角色装饰器 + +```typescript +import { Controller, Get, UseGuards } from '@nestjs/common'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { RolesGuard } from '../auth/guards/roles.guard'; + +@Controller('users') +@UseGuards(RolesGuard) +export class UsersController { + @Get() + @Roles('admin', 'editor') // 需要 admin 或 editor 角色 + findAll() { + // 只有拥有 admin 或 editor 角色的用户才能访问 + } + + @Delete(':id') + @Roles('admin') // 只有 admin 角色可以删除 + remove() { + // 只有管理员可以删除用户 + } +} +``` + +#### 方式二:使用权限装饰器(可扩展) + +```typescript +// 可以创建 PermissionGuard 和 @Permissions() 装饰器 +@Get() +@Permissions('user:read') // 需要 user:read 权限 +findAll() { + // 只有拥有 user:read 权限的用户才能访问 +} +``` + +### 5. **获取用户权限** + +```typescript +// 在 AuthService 中 +private async getUserPermissions(userId: number): Promise { + const user = await this.usersService.findOne(userId); + if (!user) return []; + + const permissions = new Set(); + + // 遍历用户的所有角色 + user.roles?.forEach((ur: any) => { + // 遍历角色的所有权限 + ur.role.permissions?.forEach((rp: any) => { + permissions.add(rp.permission.code); + }); + }); + + return Array.from(permissions); + // 返回: ['user:create', 'user:read', 'user:update', 'role:create', ...] +} +``` + +## 📊 RBAC 的优势 + +### 1. **灵活性** + +- ✅ 一个用户可以有多个角色 +- ✅ 一个角色可以有多个权限 +- ✅ 权限可以动态分配和回收 + +### 2. **可维护性** + +- ✅ 权限变更只需修改角色,不需要逐个修改用户 +- ✅ 角色可以复用,减少重复配置 + +### 3. **可扩展性** + +- ✅ 新增资源只需添加新的权限 +- ✅ 新增角色只需组合现有权限 + +### 4. **安全性** + +- ✅ 最小权限原则:用户只获得必要的权限 +- ✅ 权限集中管理,便于审计 + +## 🎨 实际应用场景 + +### 场景 1:内容管理系统 + +``` +角色定义: +- 超级管理员:所有权限 +- 内容管理员:文章 CRUD、评论管理 +- 编辑:文章创建、编辑 +- 作者:文章创建 +- 访客:文章查看 + +权限示例: +- article:create +- article:read +- article:update +- article:delete +- comment:moderate +``` + +### 场景 2:电商系统 + +``` +角色定义: +- 平台管理员:所有权限 +- 店铺管理员:店铺管理、订单管理 +- 客服:订单查看、退款处理 +- 财务:订单查看、财务报表 + +权限示例: +- order:create +- order:read +- order:update +- order:refund +- report:financial +``` + +## 🔐 项目中的权限控制实现 + +### 1. **JWT 认证** + +用户登录后获得 JWT Token,Token 中包含用户 ID + +### 2. **JwtAuthGuard** + +验证 JWT Token,提取用户信息 + +### 3. **RolesGuard** + +检查用户是否拥有指定的角色 + +### 4. **权限获取** + +登录时,系统会: + +1. 查询用户的所有角色 +2. 查询角色关联的所有权限 +3. 合并所有权限并返回给前端 + +### 5. **前端权限控制** + +前端可以根据返回的 `roles` 和 `permissions` 数组: + +- 控制菜单显示 +- 控制按钮显示 +- 控制路由访问 + +## 📝 最佳实践 + +### 1. **权限命名规范** + +``` +格式:resource:action +示例: +- user:create +- user:read +- user:update +- user:delete +- role:assign +- menu:manage +``` + +### 2. **角色命名规范** + +``` +使用有意义的英文代码: +- admin: 管理员 +- editor: 编辑 +- viewer: 查看者 +- guest: 访客 +``` + +### 3. **权限粒度** + +- ✅ 不要过粗:避免一个权限包含太多操作 +- ✅ 不要过细:避免权限过多难以管理 +- ✅ 按业务模块划分:user、role、menu、dict 等 + +### 4. **默认角色** + +建议创建以下默认角色: + +- **超级管理员**:拥有所有权限 +- **普通用户**:基础查看权限 +- **访客**:只读权限 + +## 🚀 扩展功能 + +### 1. **权限继承** + +可以实现角色继承,子角色继承父角色的权限 + +### 2. **动态权限** + +可以根据数据范围动态控制权限,如: + +- 用户只能管理自己创建的订单 +- 部门管理员只能管理本部门的用户 + +### 3. **权限缓存** + +将用户权限缓存到 Redis,提高性能 + +### 4. **权限审计** + +记录权限变更日志,便于追溯 + +## 📖 总结 + +RBAC 权限控制通过 **用户 → 角色 → 权限** 的三层关系,实现了灵活、可维护的权限管理系统。在你的项目中: + +1. ✅ **用户** 通过 `user_roles` 表关联 **角色** +2. ✅ **角色** 通过 `role_permissions` 表关联 **权限** +3. ✅ **权限** 由 `resource` + `action` 组成 +4. ✅ 使用 `@Roles()` 装饰器控制接口访问 +5. ✅ 登录时返回用户的角色和权限列表 + +这样的设计既保证了安全性,又提供了良好的扩展性和可维护性! diff --git a/backend/docs/README.md b/backend/docs/README.md new file mode 100644 index 0000000..6c44953 --- /dev/null +++ b/backend/docs/README.md @@ -0,0 +1,105 @@ +# 项目文档索引 + +本目录包含项目后端的所有指南和文档。 + +## 📚 文档分类 + +### 🚀 快速开始 + +- **[QUICK_START_ENV.md](./QUICK_START_ENV.md)** - 环境配置快速参考 + - 快速创建开发和生产环境配置 + - 环境区分总结表 + - 关键区别说明 + +### 🗄️ 数据库相关 + +- **[DATABASE_SETUP.md](./DATABASE_SETUP.md)** - 数据库配置指南 + - 创建数据库 + - DATABASE_URL 格式说明 + - 初始化数据库步骤 + - 验证连接方法 + +- **[DATABASE_URL_SOURCE.md](./DATABASE_URL_SOURCE.md)** - DATABASE_URL 来源说明 + - DATABASE_URL 的定义位置 + - 加载流程详解 + - 配置文件优先级 + - 验证方法 + +- **[SCHEMA_CHANGE_GUIDE.md](./SCHEMA_CHANGE_GUIDE.md)** - Prisma Schema 修改指南 + - 修改 schema.prisma 后的操作步骤 + - 生成 Prisma Client + - 应用数据库迁移 + - 验证迁移是否成功 + +- **[ENV_CHANGE_GUIDE.md](./ENV_CHANGE_GUIDE.md)** - 修改 DATABASE_URL 后的操作指南 + - 操作决策树 + - 不同场景的处理方法 + - 完整操作流程 + - 常见错误解决 + +### ⚙️ 环境配置 + +- **[ENVIRONMENT_CONFIG.md](./ENVIRONMENT_CONFIG.md)** - 环境配置指南 + - 环境区分方案 + - 配置文件结构 + - 配置优先级 + - 开发/生产环境配置示例 + - 安全注意事项 + +### 🔐 权限管理 + +- **[RBAC_GUIDE.md](./RBAC_GUIDE.md)** - RBAC 权限系统指南 + - 权限系统架构 + - 权限模型说明 + - 使用示例 + - 最佳实践 + +- **[RBAC_EXAMPLES.md](./RBAC_EXAMPLES.md)** - RBAC 使用示例 + - 完整的权限配置示例 + - 常见场景实现 + - 代码示例 + +### 👤 账户管理 + +- **[ADMIN_ACCOUNT.md](./ADMIN_ACCOUNT.md)** - 管理员账户指南 + - 初始化管理员账户 + - 验证管理员账户 + - 账户管理说明 + +## 📖 文档使用建议 + +### 新项目设置流程 + +1. **环境配置** → [QUICK_START_ENV.md](./QUICK_START_ENV.md) +2. **数据库设置** → [DATABASE_SETUP.md](./DATABASE_SETUP.md) +3. **初始化管理员** → [ADMIN_ACCOUNT.md](./ADMIN_ACCOUNT.md) +4. **权限配置** → [RBAC_GUIDE.md](./RBAC_GUIDE.md) + +### 日常开发流程 + +- **修改数据库结构** → [SCHEMA_CHANGE_GUIDE.md](./SCHEMA_CHANGE_GUIDE.md) +- **修改环境变量** → [ENV_CHANGE_GUIDE.md](./ENV_CHANGE_GUIDE.md) +- **配置权限** → [RBAC_EXAMPLES.md](./RBAC_EXAMPLES.md) + +### 问题排查 + +- **数据库连接问题** → [DATABASE_URL_SOURCE.md](./DATABASE_URL_SOURCE.md) +- **环境配置问题** → [ENVIRONMENT_CONFIG.md](./ENVIRONMENT_CONFIG.md) +- **迁移问题** → [SCHEMA_CHANGE_GUIDE.md](./SCHEMA_CHANGE_GUIDE.md) + +## 🔍 快速查找 + +| 需求 | 文档 | +|------|------| +| 如何设置开发环境? | [QUICK_START_ENV.md](./QUICK_START_ENV.md) | +| 如何配置数据库? | [DATABASE_SETUP.md](./DATABASE_SETUP.md) | +| DATABASE_URL 从哪里来? | [DATABASE_URL_SOURCE.md](./DATABASE_URL_SOURCE.md) | +| 修改 schema 后做什么? | [SCHEMA_CHANGE_GUIDE.md](./SCHEMA_CHANGE_GUIDE.md) | +| 修改环境变量后做什么? | [ENV_CHANGE_GUIDE.md](./ENV_CHANGE_GUIDE.md) | +| 如何配置权限? | [RBAC_GUIDE.md](./RBAC_GUIDE.md) | +| 如何创建管理员? | [ADMIN_ACCOUNT.md](./ADMIN_ACCOUNT.md) | + +## 📝 文档更新记录 + +- 2024-11-19: 创建文档索引,归档所有指南文件 + diff --git a/backend/docs/SCHEMA_CHANGE_GUIDE.md b/backend/docs/SCHEMA_CHANGE_GUIDE.md new file mode 100644 index 0000000..1c8c511 --- /dev/null +++ b/backend/docs/SCHEMA_CHANGE_GUIDE.md @@ -0,0 +1,128 @@ +# Prisma Schema 修改后的操作指南 + +## 修改 schema.prisma 后需要执行的步骤 + +### 1. 生成 Prisma Client(必须) + +```bash +cd backend +npx prisma generate +# 或使用 npm script +npm run prisma:generate +``` + +**作用**:根据最新的 schema 重新生成 Prisma Client,使 TypeScript 类型和代码与数据库结构同步。 + +--- + +### 2. 应用数据库迁移(必须) + +根据环境选择不同的方式: + +#### 开发环境(推荐) + +```bash +cd backend +npx prisma migrate dev +# 或使用 npm script +npm run prisma:migrate +``` + +**作用**: + +- 应用待执行的迁移到数据库 +- 如果有新的迁移,会自动创建并应用 +- 会重置开发数据库(如果使用 shadow database) + +#### 生产环境 + +```bash +cd backend +npx prisma migrate deploy +# 或使用 npm script +npm run prisma:migrate:deploy +``` + +**作用**: + +- 仅应用待执行的迁移,不会创建新迁移 +- 不会重置数据库 +- 适合生产环境使用 + +#### 快速同步(仅开发环境,不推荐用于生产) + +```bash +cd backend +npx prisma db push +``` + +**作用**: + +- 直接将 schema 变更推送到数据库 +- 不创建迁移文件 +- 适合快速原型开发 + +--- + +### 3. 重启应用(如果正在运行) + +应用迁移后,需要重启 NestJS 应用以加载新的 Prisma Client: + +```bash +# 如果使用 npm run start:dev,会自动重启 +# 如果使用其他方式启动,需要手动重启 +``` + +--- + +## 当前状态 + +✅ **已完成**: + +- schema.prisma 已修改(content 字段改为 TEXT) +- 迁移文件已创建:`20251118211424_change_log_content_to_text` + +⏳ **待执行**: + +1. 生成 Prisma Client +2. 应用数据库迁移 +3. 重启应用(如果正在运行) + +--- + +## 执行顺序 + +```bash +# 1. 生成 Prisma Client +cd backend +npx prisma generate + +# 2. 应用迁移(开发环境) +npx prisma migrate dev +# 或生产环境 +npx prisma migrate deploy + +# 3. 重启应用(如果需要) +# 如果使用 start:dev,会自动重启 +``` + +--- + +## 验证迁移是否成功 + +```bash +# 检查迁移状态 +npx prisma migrate status + +# 查看数据库结构 +npx prisma studio +``` + +--- + +## 注意事项 + +1. **生产环境**:务必使用 `prisma migrate deploy`,不要使用 `prisma migrate dev` +2. **备份数据**:在生产环境应用迁移前,建议先备份数据库 +3. **迁移冲突**:如果迁移失败,检查错误信息并解决后再继续 +4. **类型同步**:每次修改 schema 后都要运行 `prisma generate` 更新类型 diff --git a/backend/docs/TENANT_GUIDE.md b/backend/docs/TENANT_GUIDE.md new file mode 100644 index 0000000..a923341 --- /dev/null +++ b/backend/docs/TENANT_GUIDE.md @@ -0,0 +1,270 @@ +# 多租户系统实现指南 + +## 概述 + +本系统实现了完整的多租户架构,支持: +- 每个租户独立的数据隔离(用户、角色、权限、菜单等) +- 每个租户独立的访问链接(通过租户编码或域名) +- 超级租户可以创建和管理其他租户 +- 超级租户可以为租户分配菜单 + +## 数据库设计 + +### 核心表结构 + +1. **Tenant(租户表)** + - `id`: 租户ID + - `name`: 租户名称 + - `code`: 租户编码(唯一,用于访问链接) + - `domain`: 租户域名(可选,用于子域名访问) + - `isSuper`: 是否为超级租户(0-否,1-是) + - `validState`: 有效状态(1-有效,2-失效) + +2. **TenantMenu(租户菜单关联表)** + - `tenantId`: 租户ID + - `menuId`: 菜单ID + - 用于关联租户和菜单,实现菜单分配 + +3. **其他表添加租户字段** + - `User`: 添加 `tenantId` 字段 + - `Role`: 添加 `tenantId` 字段 + - `Permission`: 添加 `tenantId` 字段 + - `Dict`: 添加 `tenantId` 字段 + - `Config`: 添加 `tenantId` 字段 + +### 唯一性约束调整 + +- `User.username`: 从全局唯一改为 `(tenantId, username)` 唯一 +- `User.email`: 从全局唯一改为 `(tenantId, email)` 唯一 +- `Role.name/code`: 从全局唯一改为 `(tenantId, name/code)` 唯一 +- `Permission.code`: 从全局唯一改为 `(tenantId, code)` 唯一 +- 其他类似字段也做了相应调整 + +## 租户识别机制 + +系统支持多种方式识别租户: + +1. **请求头方式**(推荐) + - `X-Tenant-Code`: 租户编码 + - `X-Tenant-Id`: 租户ID + +2. **子域名方式** + - 从 `Host` 请求头提取子域名 + - 匹配租户的 `code` 或 `domain` 字段 + +3. **JWT Token方式** + - Token中包含 `tenantId` 字段 + - 登录时自动关联租户 + +4. **登录参数方式** + - 登录接口支持 `tenantCode` 参数 + +## 使用流程 + +### 1. 数据库迁移 + +首先需要生成并执行数据库迁移: + +```bash +# 生成迁移文件 +npm run prisma:migrate:dev -- --name add_tenant_support + +# 执行迁移 +npm run prisma:migrate +``` + +### 2. 初始化超级租户 + +运行初始化脚本创建超级租户: + +```bash +npm run init:super-tenant +``` + +这将创建: +- 超级租户(code: `super`) +- 超级管理员用户(username: `admin`, password: `admin123`) +- 超级管理员角色 +- 基础权限 + +### 3. 创建普通租户 + +使用超级租户的管理员账号登录后,通过租户管理接口创建新租户: + +```bash +POST /api/tenants +Headers: + Authorization: Bearer + X-Tenant-Code: super +Body: +{ + "name": "租户A", + "code": "tenant-a", + "domain": "tenant-a.example.com", + "description": "租户A的描述", + "menuIds": [1, 2, 3] // 分配的菜单ID列表 +} +``` + +### 4. 为租户分配菜单 + +超级租户可以为租户分配菜单: + +```bash +PATCH /api/tenants/:id +Headers: + Authorization: Bearer + X-Tenant-Code: super +Body: +{ + "menuIds": [1, 2, 3, 4, 5] +} +``` + +### 5. 租户用户登录 + +租户用户登录时需要指定租户: + +```bash +POST /api/auth/login +Body: +{ + "username": "user1", + "password": "password123", + "tenantCode": "tenant-a" // 可选,也可以从请求头获取 +} +``` + +或者在请求头中指定: + +```bash +POST /api/auth/login +Headers: + X-Tenant-Code: tenant-a +Body: +{ + "username": "user1", + "password": "password123" +} +``` + +### 6. 访问租户数据 + +所有API请求都会自动根据租户ID过滤数据: + +```bash +GET /api/users +Headers: + Authorization: Bearer + X-Tenant-Code: tenant-a +``` + +返回的数据只会包含该租户的用户。 + +## API接口 + +### 租户管理接口 + +- `POST /api/tenants` - 创建租户(需要 `tenant:create` 权限) +- `GET /api/tenants` - 获取租户列表(需要 `tenant:read` 权限) +- `GET /api/tenants/:id` - 获取租户详情(需要 `tenant:read` 权限) +- `PATCH /api/tenants/:id` - 更新租户(需要 `tenant:update` 权限) +- `DELETE /api/tenants/:id` - 删除租户(需要 `tenant:delete` 权限) +- `GET /api/tenants/:id/menus` - 获取租户的菜单树(需要 `tenant:read` 权限) + +### 其他接口 + +所有其他接口(用户、角色、权限等)都支持租户隔离,会自动根据请求中的租户信息过滤数据。 + +## 前端集成 + +### 1. 请求拦截器 + +在前端请求拦截器中添加租户信息: + +```typescript +// utils/request.ts +service.interceptors.request.use( + (config: InternalAxiosRequestConfig) => { + const token = getToken(); + const tenantCode = getTenantCode(); // 从localStorage或store获取 + + if (token && config.headers) { + config.headers.Authorization = `Bearer ${token}`; + } + + if (tenantCode && config.headers) { + config.headers['X-Tenant-Code'] = tenantCode; + } + + return config; + }, + (error) => { + return Promise.reject(error); + } +); +``` + +### 2. 登录时保存租户信息 + +```typescript +// 登录成功后 +localStorage.setItem('tenantCode', response.data.user.tenantCode); +localStorage.setItem('tenantId', response.data.user.tenantId); +``` + +### 3. 租户切换 + +如果需要支持租户切换,可以在前端实现租户选择器,切换时更新localStorage中的租户信息并重新加载数据。 + +## 权限控制 + +### 超级租户权限 + +超级租户的用户拥有所有权限,包括: +- 创建、查看、更新、删除租户 +- 为租户分配菜单 +- 管理所有租户的数据(如果需要在超级租户中查看所有租户数据) + +### 普通租户权限 + +普通租户的用户只能: +- 管理自己租户内的数据 +- 查看分配给租户的菜单 +- 无法访问其他租户的数据 + +## 注意事项 + +1. **数据隔离**: 所有查询都会自动添加租户过滤条件,确保数据隔离 +2. **唯一性**: 用户名、邮箱等在租户内唯一,不同租户可以有相同的用户名 +3. **菜单管理**: 菜单是全局的(由超级租户管理),但通过 `TenantMenu` 表分配给各个租户 +4. **超级租户**: 超级租户不能被删除,且拥有所有权限 +5. **迁移数据**: 如果现有系统已有数据,需要编写迁移脚本将现有数据关联到超级租户 + +## 迁移现有数据 + +如果系统已有数据,需要将现有数据迁移到超级租户: + +```sql +-- 假设超级租户ID为1 +UPDATE users SET tenant_id = 1 WHERE tenant_id IS NULL; +UPDATE roles SET tenant_id = 1 WHERE tenant_id IS NULL; +UPDATE permissions SET tenant_id = 1 WHERE tenant_id IS NULL; +-- 其他表类似 +``` + +## 故障排查 + +1. **租户识别失败**: 检查请求头是否正确设置,或检查JWT token中是否包含tenantId +2. **数据查询为空**: 确认租户ID正确,且数据确实属于该租户 +3. **权限不足**: 确认用户角色有相应权限,且角色属于正确的租户 + +## 扩展功能 + +未来可以考虑的扩展: +1. 租户级别的配置(每个租户可以有自己的系统配置) +2. 租户级别的主题和品牌定制 +3. 租户级别的功能开关 +4. 租户使用统计和监控 +5. 租户数据导出和备份 + diff --git a/backend/docs/TENANT_LOGIN_GUIDE.md b/backend/docs/TENANT_LOGIN_GUIDE.md new file mode 100644 index 0000000..1b99f40 --- /dev/null +++ b/backend/docs/TENANT_LOGIN_GUIDE.md @@ -0,0 +1,226 @@ +# 租户登录使用指南 + +## 概述 + +系统已完整支持多租户登录功能,每个租户可以独立访问系统,数据完全隔离。 + +## 租户识别方式 + +系统支持以下方式识别租户: + +### 1. URL参数方式(推荐) + +在登录页面URL中添加 `tenant` 参数: + +``` +http://your-domain.com/login?tenant=tenant-a +``` + +登录页面会自动识别租户编码,并在登录时自动发送。 + +### 2. 登录表单输入 + +如果URL中没有租户参数,登录页面会显示租户编码输入框,用户可以手动输入。 + +### 3. 请求头方式 + +前端会自动将租户信息添加到所有API请求的请求头中: +- `X-Tenant-Code`: 租户编码 +- `X-Tenant-Id`: 租户ID + +## 使用流程 + +### 方式一:通过URL参数访问(推荐) + +1. **访问租户登录页面** + ``` + http://your-domain.com/login?tenant=tenant-a + ``` + +2. **输入用户名和密码** + - 用户名:租户内的用户名 + - 密码:用户密码 + - 租户编码:已自动填充(从URL参数) + +3. **登录成功** + - 系统自动保存租户信息到 localStorage + - 后续所有API请求都会自动携带租户信息 + - 用户只能看到和操作自己租户的数据 + +### 方式二:手动输入租户编码 + +1. **访问登录页面** + ``` + http://your-domain.com/login + ``` + +2. **输入租户信息** + - 租户编码:输入租户编码(如:`tenant-a`) + - 用户名:租户内的用户名 + - 密码:用户密码 + +3. **登录成功** + - 系统保存租户信息 + - 后续请求自动携带租户信息 + +## 后端API使用 + +### 登录接口 + +**请求:** +```bash +POST /api/auth/login +Content-Type: application/json + +{ + "username": "user1", + "password": "password123", + "tenantCode": "tenant-a" // 可选,也可以从请求头获取 +} +``` + +**或者通过请求头:** +```bash +POST /api/auth/login +X-Tenant-Code: tenant-a +Content-Type: application/json + +{ + "username": "user1", + "password": "password123" +} +``` + +**响应:** +```json +{ + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "user": { + "id": 1, + "username": "user1", + "nickname": "用户1", + "email": "user1@example.com", + "tenantId": 2, + "tenantCode": "tenant-a", + "roles": ["admin"], + "permissions": ["user:read", "user:create", ...] + } +} +``` + +### 其他API请求 + +登录后,所有API请求都会自动携带租户信息(通过JWT Token或请求头),后端会自动过滤数据: + +```bash +GET /api/users +Authorization: Bearer +X-Tenant-Code: tenant-a # 自动添加 +``` + +返回的数据只会包含该租户的用户。 + +## 前端实现细节 + +### 1. 登录页面自动识别租户 + +登录页面 (`Login.vue`) 会: +- 从URL参数 `?tenant=xxx` 获取租户编码 +- 如果URL中没有,从 localStorage 读取之前保存的租户编码 +- 如果都没有,显示租户输入框 + +### 2. 请求拦截器自动添加租户信息 + +所有API请求都会自动添加租户信息到请求头: + +```typescript +// utils/request.ts +service.interceptors.request.use((config) => { + const tenantCode = getTenantCode(); + const tenantId = getTenantId(); + + if (tenantCode) { + config.headers['X-Tenant-Code'] = tenantCode; + } + if (tenantId) { + config.headers['X-Tenant-Id'] = tenantId; + } + + return config; +}); +``` + +### 3. 登录后保存租户信息 + +登录成功后,系统会自动保存: +- Token +- 租户编码 (tenantCode) +- 租户ID (tenantId) + +这些信息保存在 localStorage 中,页面刷新后仍然有效。 + +## 示例场景 + +### 场景1:租户A的用户登录 + +1. 访问:`http://your-domain.com/login?tenant=tenant-a` +2. 输入用户名和密码 +3. 登录后只能看到租户A的数据 + +### 场景2:租户B的用户登录 + +1. 访问:`http://your-domain.com/login?tenant=tenant-b` +2. 输入用户名和密码 +3. 登录后只能看到租户B的数据 +4. 租户A的数据完全不可见 + +### 场景3:超级租户管理员登录 + +1. 访问:`http://your-domain.com/login?tenant=super` +2. 使用超级管理员账号登录 +3. 可以管理所有租户 + +## 注意事项 + +1. **租户编码必须唯一**:每个租户都有唯一的编码(code) +2. **用户属于特定租户**:用户只能登录到自己所属的租户 +3. **数据完全隔离**:不同租户的数据完全隔离,无法互相访问 +4. **租户信息持久化**:登录后租户信息保存在 localStorage,刷新页面不会丢失 +5. **切换租户**:如果需要切换租户,需要先登出,然后使用新的租户编码登录 + +## 故障排查 + +### 问题1:登录时提示"无法确定租户信息" + +**原因**:没有提供租户编码或租户ID + +**解决**: +- 在URL中添加 `?tenant=xxx` 参数 +- 或者在登录表单中输入租户编码 +- 或者通过请求头 `X-Tenant-Code` 提供 + +### 问题2:登录时提示"用户不属于该租户" + +**原因**:用户不属于指定的租户 + +**解决**: +- 确认租户编码是否正确 +- 确认用户是否属于该租户 +- 联系管理员检查用户和租户的关联关系 + +### 问题3:登录后看不到数据 + +**原因**:可能是租户信息没有正确传递 + +**解决**: +- 检查浏览器控制台的网络请求,确认请求头中是否包含 `X-Tenant-Code` +- 检查 localStorage 中是否保存了租户信息 +- 确认后端是否正确识别了租户 + +## 开发建议 + +1. **使用URL参数方式**:这是最用户友好的方式,用户只需要记住租户的访问链接 +2. **提供租户选择器**:如果系统需要支持租户切换,可以在前端添加租户选择器 +3. **错误提示优化**:当租户信息缺失时,提供清晰的错误提示 +4. **租户信息显示**:在用户界面显示当前租户信息,让用户知道自己在哪个租户下操作 + diff --git a/backend/nest-cli.json b/backend/nest-cli.json new file mode 100644 index 0000000..ed33892 --- /dev/null +++ b/backend/nest-cli.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} + diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..001dc3f --- /dev/null +++ b/backend/package.json @@ -0,0 +1,98 @@ +{ + "name": "competition-management-backend", + "version": "1.0.0", + "description": "比赛管理系统后端", + "author": "", + "private": true, + "license": "MIT", + "scripts": { + "build": "nest build", + "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", + "start": "nest start", + "start:dev": "NODE_ENV=development nest start --watch", + "start:debug": "NODE_ENV=development nest start --debug --watch", + "start:prod": "NODE_ENV=production node dist/main", + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "test": "NODE_ENV=test jest", + "test:watch": "NODE_ENV=test jest --watch", + "test:cov": "NODE_ENV=test jest --coverage", + "test:debug": "NODE_ENV=test node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", + "test:e2e": "NODE_ENV=test jest --config ./test/jest-e2e.json", + "prisma:generate": "prisma generate", + "prisma:generate:dev": "dotenv -e .env.development -- prisma generate", + "prisma:migrate": "prisma migrate dev", + "prisma:migrate:dev": "dotenv -e .env.development -- prisma migrate dev --create-only --name add_tenant_support", + "prisma:migrate:deploy": "NODE_ENV=production prisma migrate deploy", + "prisma:studio": "prisma studio", + "prisma:studio:dev": "NODE_ENV=development prisma studio", + "prisma:studio:prod": "NODE_ENV=production prisma studio", + "init:admin": "ts-node scripts/init-admin.ts", + "init:admin:permissions": "ts-node scripts/init-admin-permissions.ts", + "init:menus": "ts-node scripts/init-menus.ts", + "init:super-tenant": "ts-node scripts/init-super-tenant.ts", + "init:tenant-admin": "ts-node scripts/init-tenant-admin.ts", + "init:tenant-admin:permissions": "ts-node scripts/init-tenant-admin.ts --permissions-only", + "update:password": "ts-node scripts/update-password.ts" + }, + "dependencies": { + "@nestjs/common": "^10.3.3", + "@nestjs/config": "^3.1.1", + "@nestjs/core": "^10.3.3", + "@nestjs/jwt": "^10.2.0", + "@nestjs/passport": "^10.0.3", + "@nestjs/platform-express": "^10.3.3", + "@prisma/client": "^5.9.1", + "bcrypt": "^6.0.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.1", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", + "passport-local": "^1.0.0", + "reflect-metadata": "^0.2.1", + "rxjs": "^7.8.1" + }, + "devDependencies": { + "@nestjs/cli": "^10.3.2", + "@nestjs/schematics": "^10.1.0", + "@nestjs/testing": "^10.3.3", + "@types/bcrypt": "^5.0.2", + "@types/express": "^4.17.21", + "@types/jest": "^29.5.11", + "@types/node": "^20.11.5", + "@types/passport-jwt": "^4.0.1", + "@types/passport-local": "^1.0.36", + "@typescript-eslint/eslint-plugin": "^6.19.1", + "@typescript-eslint/parser": "^6.19.1", + "dotenv": "^17.2.3", + "dotenv-cli": "^11.0.0", + "eslint": "^8.56.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-prettier": "^5.1.3", + "jest": "^29.7.0", + "prettier": "^3.2.4", + "prisma": "^5.9.1", + "source-map-support": "^0.5.21", + "ts-jest": "^29.1.2", + "ts-loader": "^9.5.1", + "ts-node": "^10.9.2", + "tsconfig-paths": "^4.2.0", + "typescript": "^5.3.3" + }, + "jest": { + "moduleFileExtensions": [ + "js", + "json", + "ts" + ], + "rootDir": "src", + "testRegex": ".*\\.spec\\.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "collectCoverageFrom": [ + "**/*.(t|j)s" + ], + "coverageDirectory": "../coverage", + "testEnvironment": "node" + } +} diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma new file mode 100644 index 0000000..4bc71e8 --- /dev/null +++ b/backend/prisma/schema.prisma @@ -0,0 +1,269 @@ +// This is your Prisma schema file, +// learn more about it in the docs: https://pris.ly/d/prisma-schema + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "mysql" + url = env("DATABASE_URL") +} + +/// 租户表 +model Tenant { + id Int @id @default(autoincrement()) + name String /// 租户名称 + code String @unique /// 租户编码(唯一,用于访问链接) + domain String? @unique /// 租户域名(可选,用于子域名访问) + description String? /// 租户描述 + isSuper Int @default(0) @map("is_super") /// 是否为超级租户:0-否,1-是 + validState Int @default(1) @map("valid_state") /// 有效状态:1-有效,2-失效 + creator Int? /// 创建人ID(超级租户的用户ID) + modifier Int? /// 修改人ID + createTime DateTime @default(now()) @map("create_time") /// 创建时间 + modifyTime DateTime @updatedAt @map("modify_time") /// 修改时间 + + users User[] + roles Role[] + menus TenantMenu[] + permissions Permission[] + dicts Dict[] + configs Config[] + creatorUser User? @relation("TenantCreator", fields: [creator], references: [id], onDelete: SetNull) + modifierUser User? @relation("TenantModifier", fields: [modifier], references: [id], onDelete: SetNull) + + @@map("tenants") +} + +/// 用户表 +model User { + id Int @id @default(autoincrement()) + tenantId Int @map("tenant_id") /// 租户ID + username String /// 用户名(在租户内唯一) + password String /// 密码(加密存储) + nickname String /// 昵称 + email String? /// 邮箱(在租户内唯一,可选) + avatar String? /// 头像URL + validState Int @default(1) @map("valid_state") /// 有效状态:1-有效,2-失效 + creator Int? @map("creator") /// 创建人ID + modifier Int? @map("modifier") /// 修改人ID + createTime DateTime @default(now()) @map("create_time") /// 创建时间 + modifyTime DateTime @updatedAt @map("modify_time") /// 修改时间 + + tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) + roles UserRole[] + logs Log[] + createdBy User? @relation("UserCreator", fields: [creator], references: [id], onDelete: SetNull) + modifiedBy User? @relation("UserModifier", fields: [modifier], references: [id], onDelete: SetNull) + createdUsers User[] @relation("UserCreator") + modifiedUsers User[] @relation("UserModifier") + createdRoles Role[] @relation("RoleCreator") + modifiedRoles Role[] @relation("RoleModifier") + createdPermissions Permission[] @relation("PermissionCreator") + modifiedPermissions Permission[] @relation("PermissionModifier") + createdMenus Menu[] @relation("MenuCreator") + modifiedMenus Menu[] @relation("MenuModifier") + createdDicts Dict[] @relation("DictCreator") + modifiedDicts Dict[] @relation("DictModifier") + createdDictItems DictItem[] @relation("DictItemCreator") + modifiedDictItems DictItem[] @relation("DictItemModifier") + createdConfigs Config[] @relation("ConfigCreator") + modifiedConfigs Config[] @relation("ConfigModifier") + createdTenants Tenant[] @relation("TenantCreator") + modifiedTenants Tenant[] @relation("TenantModifier") + + @@unique([tenantId, username]) + @@unique([tenantId, email]) + @@map("users") +} + +/// 角色表 +model Role { + id Int @id @default(autoincrement()) + tenantId Int @map("tenant_id") /// 租户ID + name String /// 角色名称(在租户内唯一) + code String /// 角色编码(在租户内唯一) + description String? /// 角色描述 + validState Int @default(1) @map("valid_state") /// 有效状态:1-有效,2-失效 + creator Int? /// 创建人ID + modifier Int? /// 修改人ID + createTime DateTime @default(now()) @map("create_time") /// 创建时间 + modifyTime DateTime @updatedAt @map("modify_time") /// 修改时间 + + tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) + users UserRole[] + permissions RolePermission[] + creatorUser User? @relation("RoleCreator", fields: [creator], references: [id], onDelete: SetNull) + modifierUser User? @relation("RoleModifier", fields: [modifier], references: [id], onDelete: SetNull) + + @@unique([tenantId, name]) + @@unique([tenantId, code]) + @@map("roles") +} + +/// 用户角色关联表 +model UserRole { + id Int @id @default(autoincrement()) + userId Int @map("user_id") /// 用户ID + roleId Int @map("role_id") /// 角色ID + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + role Role @relation(fields: [roleId], references: [id], onDelete: Cascade) + + @@unique([userId, roleId]) + @@map("user_roles") +} + +/// 权限表 +model Permission { + id Int @id @default(autoincrement()) + tenantId Int @map("tenant_id") /// 租户ID + name String /// 权限名称 + code String /// 权限编码(在租户内唯一) + resource String /// 资源名称,如 user, role, menu + action String /// 操作类型,如 create, read, update, delete + description String? /// 权限描述 + validState Int @default(1) @map("valid_state") /// 有效状态:1-有效,2-失效 + creator Int? /// 创建人ID + modifier Int? /// 修改人ID + createTime DateTime @default(now()) @map("create_time") /// 创建时间 + modifyTime DateTime @updatedAt @map("modify_time") /// 修改时间 + + tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) + roles RolePermission[] + creatorUser User? @relation("PermissionCreator", fields: [creator], references: [id], onDelete: SetNull) + modifierUser User? @relation("PermissionModifier", fields: [modifier], references: [id], onDelete: SetNull) + + @@unique([tenantId, resource, action]) + @@unique([tenantId, code]) + @@map("permissions") +} + +/// 角色权限关联表 +model RolePermission { + id Int @id @default(autoincrement()) + roleId Int @map("role_id") /// 角色ID + permissionId Int @map("permission_id") /// 权限ID + + role Role @relation(fields: [roleId], references: [id], onDelete: Cascade) + permission Permission @relation(fields: [permissionId], references: [id], onDelete: Cascade) + + @@unique([roleId, permissionId]) + @@map("role_permissions") +} + +/// 菜单表(全局菜单模板,超级租户管理) +model Menu { + id Int @id @default(autoincrement()) + name String /// 菜单名称 + path String? /// 路由路径 + icon String? /// 图标 + component String? /// 组件路径 + parentId Int? @map("parent_id") /// 父菜单ID + permission String? /// 权限编码(用于控制菜单显示,如:menu:read) + sort Int @default(0) /// 排序 + validState Int @default(1) @map("valid_state") /// 有效状态:1-有效,2-失效 + creator Int? @map("creator") /// 创建人ID + modifier Int? @map("modifier") /// 修改人ID + createTime DateTime @default(now()) @map("create_time") /// 创建时间 + modifyTime DateTime @updatedAt @map("modify_time") /// 修改时间 + + parent Menu? @relation("MenuTree", fields: [parentId], references: [id]) + children Menu[] @relation("MenuTree") + tenantMenus TenantMenu[] /// 租户菜单关联 + creatorUser User? @relation("MenuCreator", fields: [creator], references: [id], onDelete: SetNull) + modifierUser User? @relation("MenuModifier", fields: [modifier], references: [id], onDelete: SetNull) + + @@map("menus") +} + +/// 租户菜单关联表(租户分配的菜单) +model TenantMenu { + id Int @id @default(autoincrement()) + tenantId Int @map("tenant_id") /// 租户ID + menuId Int @map("menu_id") /// 菜单ID + + tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) + menu Menu @relation(fields: [menuId], references: [id], onDelete: Cascade) + + @@unique([tenantId, menuId]) + @@map("tenant_menus") +} + +/// 数据字典表 +model Dict { + id Int @id @default(autoincrement()) + tenantId Int @map("tenant_id") /// 租户ID + name String /// 字典名称 + code String /// 字典编码(在租户内唯一) + description String? /// 字典描述 + validState Int @default(1) @map("valid_state") /// 有效状态:1-有效,2-失效 + creator Int? /// 创建人ID + modifier Int? /// 修改人ID + createTime DateTime @default(now()) @map("create_time") /// 创建时间 + modifyTime DateTime @updatedAt @map("modify_time") /// 修改时间 + + tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) + items DictItem[] + creatorUser User? @relation("DictCreator", fields: [creator], references: [id], onDelete: SetNull) + modifierUser User? @relation("DictModifier", fields: [modifier], references: [id], onDelete: SetNull) + + @@unique([tenantId, code]) + @@map("dicts") +} + +/// 字典项表 +model DictItem { + id Int @id @default(autoincrement()) + dictId Int @map("dict_id") /// 字典ID + label String /// 标签 + value String /// 值 + sort Int @default(0) /// 排序 + validState Int @default(1) @map("valid_state") /// 有效状态:1-有效,2-失效 + creator Int? @map("creator") /// 创建人ID + modifier Int? @map("modifier") /// 修改人ID + createTime DateTime @default(now()) @map("create_time") /// 创建时间 + modifyTime DateTime @updatedAt @map("modify_time") /// 修改时间 + + dict Dict @relation(fields: [dictId], references: [id], onDelete: Cascade) + creatorUser User? @relation("DictItemCreator", fields: [creator], references: [id], onDelete: SetNull) + modifierUser User? @relation("DictItemModifier", fields: [modifier], references: [id], onDelete: SetNull) + + @@map("dict_items") +} + +/// 系统配置表 +model Config { + id Int @id @default(autoincrement()) + tenantId Int @map("tenant_id") /// 租户ID + key String /// 配置键(在租户内唯一) + value String /// 配置值 + description String? /// 配置描述 + creator Int? /// 创建人ID + modifier Int? /// 修改人ID + createTime DateTime @default(now()) @map("create_time") /// 创建时间 + modifyTime DateTime @updatedAt @map("modify_time") /// 修改时间 + + tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) + creatorUser User? @relation("ConfigCreator", fields: [creator], references: [id], onDelete: SetNull) + modifierUser User? @relation("ConfigModifier", fields: [modifier], references: [id], onDelete: SetNull) + + @@unique([tenantId, key]) + @@map("configs") +} + +/// 日志记录表 +model Log { + id Int @id @default(autoincrement()) + userId Int? @map("user_id") /// 用户ID + action String /// 操作类型 + content String? @db.Text /// 操作内容(使用 TEXT 类型支持长文本) + ip String? /// IP地址 + userAgent String? @map("user_agent") /// 用户代理 + createTime DateTime @default(now()) @map("create_time") /// 创建时间 + + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + + @@map("logs") +} diff --git a/backend/scripts/init-admin-permissions.ts b/backend/scripts/init-admin-permissions.ts new file mode 100644 index 0000000..6817218 --- /dev/null +++ b/backend/scripts/init-admin-permissions.ts @@ -0,0 +1,421 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-nocheck + +// 加载环境变量(必须在其他导入之前) +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +// 根据 NODE_ENV 加载对应的环境配置文件 +const nodeEnv = process.env.NODE_ENV || 'development'; +const envFile = `.env.${nodeEnv}`; +// scripts 目录的父目录就是 backend 目录 +const backendDir = path.resolve(__dirname, '..'); +const envPath = path.resolve(backendDir, envFile); + +// 尝试加载环境特定的配置文件 +dotenv.config({ path: envPath }); + +// 如果环境特定文件不存在,尝试加载默认的 .env 文件 +if (!process.env.DATABASE_URL) { + dotenv.config({ path: path.resolve(backendDir, '.env') }); +} + +// 验证必要的环境变量 +if (!process.env.DATABASE_URL) { + console.error('❌ 错误: 未找到 DATABASE_URL 环境变量'); + console.error(` 请确保存在以下文件之一:`); + console.error(` - ${envPath}`); + console.error(` - ${path.resolve(backendDir, '.env')}`); + console.error(` 或者设置 NODE_ENV 环境变量(当前: ${nodeEnv})`); + process.exit(1); +} + +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +// 定义所有基础权限 +const permissions = [ + // 用户管理权限 + { + code: 'user:create', + resource: 'user', + action: 'create', + name: '创建用户', + description: '允许创建新用户', + }, + { + code: 'user:read', + resource: 'user', + action: 'read', + name: '查看用户', + description: '允许查看用户列表和详情', + }, + { + code: 'user:update', + resource: 'user', + action: 'update', + name: '更新用户', + description: '允许更新用户信息', + }, + { + code: 'user:delete', + resource: 'user', + action: 'delete', + name: '删除用户', + description: '允许删除用户', + }, + + // 角色管理权限 + { + code: 'role:create', + resource: 'role', + action: 'create', + name: '创建角色', + description: '允许创建新角色', + }, + { + code: 'role:read', + resource: 'role', + action: 'read', + name: '查看角色', + description: '允许查看角色列表和详情', + }, + { + code: 'role:update', + resource: 'role', + action: 'update', + name: '更新角色', + description: '允许更新角色信息', + }, + { + code: 'role:delete', + resource: 'role', + action: 'delete', + name: '删除角色', + description: '允许删除角色', + }, + { + code: 'role:assign', + resource: 'role', + action: 'assign', + name: '分配角色', + description: '允许给用户分配角色', + }, + + // 权限管理权限 + { + code: 'permission:create', + resource: 'permission', + action: 'create', + name: '创建权限', + description: '允许创建新权限', + }, + { + code: 'permission:read', + resource: 'permission', + action: 'read', + name: '查看权限', + description: '允许查看权限列表和详情', + }, + { + code: 'permission:update', + resource: 'permission', + action: 'update', + name: '更新权限', + description: '允许更新权限信息', + }, + { + code: 'permission:delete', + resource: 'permission', + action: 'delete', + name: '删除权限', + description: '允许删除权限', + }, + + // 菜单管理权限 + { + code: 'menu:create', + resource: 'menu', + action: 'create', + name: '创建菜单', + description: '允许创建新菜单', + }, + { + code: 'menu:read', + resource: 'menu', + action: 'read', + name: '查看菜单', + description: '允许查看菜单列表和详情', + }, + { + code: 'menu:update', + resource: 'menu', + action: 'update', + name: '更新菜单', + description: '允许更新菜单信息', + }, + { + code: 'menu:delete', + resource: 'menu', + action: 'delete', + name: '删除菜单', + description: '允许删除菜单', + }, + + // 数据字典权限 + { + code: 'dict:create', + resource: 'dict', + action: 'create', + name: '创建字典', + description: '允许创建新字典', + }, + { + code: 'dict:read', + resource: 'dict', + action: 'read', + name: '查看字典', + description: '允许查看字典列表和详情', + }, + { + code: 'dict:update', + resource: 'dict', + action: 'update', + name: '更新字典', + description: '允许更新字典信息', + }, + { + code: 'dict:delete', + resource: 'dict', + action: 'delete', + name: '删除字典', + description: '允许删除字典', + }, + + // 系统配置权限 + { + code: 'config:create', + resource: 'config', + action: 'create', + name: '创建配置', + description: '允许创建新配置', + }, + { + code: 'config:read', + resource: 'config', + action: 'read', + name: '查看配置', + description: '允许查看配置列表和详情', + }, + { + code: 'config:update', + resource: 'config', + action: 'update', + name: '更新配置', + description: '允许更新配置信息', + }, + { + code: 'config:delete', + resource: 'config', + action: 'delete', + name: '删除配置', + description: '允许删除配置', + }, + + // 日志管理权限 + { + code: 'log:read', + resource: 'log', + action: 'read', + name: '查看日志', + description: '允许查看系统日志', + }, + { + code: 'log:delete', + resource: 'log', + action: 'delete', + name: '删除日志', + description: '允许删除系统日志', + }, + + // 用户密码管理权限 + { + code: 'user:password:update', + resource: 'user', + action: 'password:update', + name: '修改用户密码', + description: '允许修改用户密码', + }, +]; + +async function initAdminPermissions() { + try { + console.log('🚀 开始为超级管理员(admin)用户初始化权限...\n'); + + // 1. 检查 admin 用户是否存在 + console.log('👤 步骤 1: 检查 admin 用户...'); + const adminUser = await prisma.user.findUnique({ + where: { username: 'admin' }, + }); + + if (!adminUser) { + console.error('❌ 错误: admin 用户不存在!'); + console.error(' 请先运行 pnpm init:admin 创建 admin 用户'); + process.exit(1); + } + console.log( + `✅ admin 用户存在: ${adminUser.username} (${adminUser.nickname})\n`, + ); + + // 2. 创建或更新所有权限 + console.log('📝 步骤 2: 确保所有权限存在...'); + const createdPermissions = []; + for (const perm of permissions) { + const permission = await prisma.permission.upsert({ + where: { code: perm.code }, + update: { + name: perm.name, + resource: perm.resource, + action: perm.action, + description: perm.description, + }, + create: perm, + }); + createdPermissions.push(permission); + } + console.log(`✅ 共确保 ${createdPermissions.length} 个权限存在\n`); + + // 3. 创建或获取超级管理员角色 + console.log('👤 步骤 3: 确保超级管理员角色存在...'); + const adminRole = await prisma.role.upsert({ + where: { code: 'super_admin' }, + update: { + name: '超级管理员', + description: '拥有系统所有权限的超级管理员角色', + validState: 1, + }, + create: { + name: '超级管理员', + code: 'super_admin', + description: '拥有系统所有权限的超级管理员角色', + validState: 1, + }, + }); + console.log( + `✅ 超级管理员角色已确保存在: ${adminRole.name} (${adminRole.code})\n`, + ); + + // 4. 确保超级管理员角色拥有所有权限 + console.log('🔗 步骤 4: 为超级管理员角色分配所有权限...'); + const existingRolePermissions = await prisma.rolePermission.findMany({ + where: { roleId: adminRole.id }, + select: { permissionId: true }, + }); + const existingPermissionIds = new Set( + existingRolePermissions.map((rp) => rp.permissionId), + ); + + let addedCount = 0; + for (const permission of createdPermissions) { + if (!existingPermissionIds.has(permission.id)) { + await prisma.rolePermission.create({ + data: { + roleId: adminRole.id, + permissionId: permission.id, + }, + }); + addedCount++; + } + } + + if (addedCount > 0) { + console.log(`✅ 为超级管理员角色添加了 ${addedCount} 个权限\n`); + } else { + console.log( + `✅ 超级管理员角色已拥有所有权限(${createdPermissions.length} 个)\n`, + ); + } + + // 5. 确保 admin 用户拥有超级管理员角色 + console.log('🔗 步骤 5: 确保 admin 用户拥有超级管理员角色...'); + const existingUserRole = await prisma.userRole.findUnique({ + where: { + userId_roleId: { + userId: adminUser.id, + roleId: adminRole.id, + }, + }, + }); + + if (!existingUserRole) { + await prisma.userRole.create({ + data: { + userId: adminUser.id, + roleId: adminRole.id, + }, + }); + console.log(`✅ 已为 admin 用户分配超级管理员角色\n`); + } else { + console.log(`✅ admin 用户已拥有超级管理员角色\n`); + } + + // 6. 验证结果 + console.log('🔍 步骤 6: 验证结果...'); + const userWithRoles = await prisma.user.findUnique({ + where: { id: adminUser.id }, + include: { + roles: { + include: { + role: { + include: { + permissions: { + include: { + permission: true, + }, + }, + }, + }, + }, + }, + }, + }); + + const roleCodes = userWithRoles?.roles.map((ur) => ur.role.code) || []; + const permissionCodes = new Set(); + userWithRoles?.roles.forEach((ur) => { + ur.role.permissions.forEach((rp) => { + permissionCodes.add(rp.permission.code); + }); + }); + + console.log(`\n📊 初始化结果:`); + console.log(` 用户名: ${adminUser.username}`); + console.log(` 昵称: ${adminUser.nickname}`); + console.log(` 角色: ${roleCodes.join(', ')}`); + console.log(` 权限数量: ${permissionCodes.size}`); + console.log(` 权限列表:`); + Array.from(permissionCodes) + .sort() + .forEach((code) => { + console.log(` - ${code}`); + }); + console.log(`\n✅ 超级管理员权限初始化完成!`); + } catch (error) { + console.error('❌ 初始化失败:', error); + throw error; + } finally { + await prisma.$disconnect(); + } +} + +// 执行初始化 +initAdminPermissions() + .then(() => { + console.log('\n🎉 权限初始化脚本执行完成!'); + process.exit(0); + }) + .catch((error) => { + console.error('\n💥 权限初始化脚本执行失败:', error); + process.exit(1); + }); diff --git a/backend/scripts/init-admin.ts b/backend/scripts/init-admin.ts new file mode 100644 index 0000000..2b9419b --- /dev/null +++ b/backend/scripts/init-admin.ts @@ -0,0 +1,526 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-nocheck +// 加载环境变量(必须在其他导入之前) +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +// 根据 NODE_ENV 加载对应的环境配置文件 +const nodeEnv = process.env.NODE_ENV || 'development'; +const envFile = `.env.${nodeEnv}`; +// scripts 目录的父目录就是 backend 目录 +const backendDir = path.resolve(__dirname, '..'); +const envPath = path.resolve(backendDir, envFile); + +// 尝试加载环境特定的配置文件 +dotenv.config({ path: envPath }); + +// 如果环境特定文件不存在,尝试加载默认的 .env 文件 +if (!process.env.DATABASE_URL) { + dotenv.config({ path: path.resolve(backendDir, '.env') }); +} + +// 验证必要的环境变量 +if (!process.env.DATABASE_URL) { + console.error('❌ 错误: 未找到 DATABASE_URL 环境变量'); + console.error(` 请确保存在以下文件之一:`); + console.error(` - ${envPath}`); + console.error(` - ${path.resolve(backendDir, '.env')}`); + console.error(` 或者设置 NODE_ENV 环境变量(当前: ${nodeEnv})`); + process.exit(1); +} + +import { PrismaClient } from '@prisma/client'; +import * as bcrypt from 'bcrypt'; + +const prisma = new PrismaClient(); + +// 定义所有基础权限 +const permissions = [ + // 用户管理权限 + { + code: 'user:create', + resource: 'user', + action: 'create', + name: '创建用户', + description: '允许创建新用户', + }, + { + code: 'user:read', + resource: 'user', + action: 'read', + name: '查看用户', + description: '允许查看用户列表和详情', + }, + { + code: 'user:update', + resource: 'user', + action: 'update', + name: '更新用户', + description: '允许更新用户信息', + }, + { + code: 'user:delete', + resource: 'user', + action: 'delete', + name: '删除用户', + description: '允许删除用户', + }, + + // 角色管理权限 + { + code: 'role:create', + resource: 'role', + action: 'create', + name: '创建角色', + description: '允许创建新角色', + }, + { + code: 'role:read', + resource: 'role', + action: 'read', + name: '查看角色', + description: '允许查看角色列表和详情', + }, + { + code: 'role:update', + resource: 'role', + action: 'update', + name: '更新角色', + description: '允许更新角色信息', + }, + { + code: 'role:delete', + resource: 'role', + action: 'delete', + name: '删除角色', + description: '允许删除角色', + }, + { + code: 'role:assign', + resource: 'role', + action: 'assign', + name: '分配角色', + description: '允许给用户分配角色', + }, + + // 权限管理权限 + { + code: 'permission:create', + resource: 'permission', + action: 'create', + name: '创建权限', + description: '允许创建新权限', + }, + { + code: 'permission:read', + resource: 'permission', + action: 'read', + name: '查看权限', + description: '允许查看权限列表和详情', + }, + { + code: 'permission:update', + resource: 'permission', + action: 'update', + name: '更新权限', + description: '允许更新权限信息', + }, + { + code: 'permission:delete', + resource: 'permission', + action: 'delete', + name: '删除权限', + description: '允许删除权限', + }, + + // 菜单管理权限 + { + code: 'menu:create', + resource: 'menu', + action: 'create', + name: '创建菜单', + description: '允许创建新菜单', + }, + { + code: 'menu:read', + resource: 'menu', + action: 'read', + name: '查看菜单', + description: '允许查看菜单列表和详情', + }, + { + code: 'menu:update', + resource: 'menu', + action: 'update', + name: '更新菜单', + description: '允许更新菜单信息', + }, + { + code: 'menu:delete', + resource: 'menu', + action: 'delete', + name: '删除菜单', + description: '允许删除菜单', + }, + + // 数据字典权限 + { + code: 'dict:create', + resource: 'dict', + action: 'create', + name: '创建字典', + description: '允许创建新字典', + }, + { + code: 'dict:read', + resource: 'dict', + action: 'read', + name: '查看字典', + description: '允许查看字典列表和详情', + }, + { + code: 'dict:update', + resource: 'dict', + action: 'update', + name: '更新字典', + description: '允许更新字典信息', + }, + { + code: 'dict:delete', + resource: 'dict', + action: 'delete', + name: '删除字典', + description: '允许删除字典', + }, + + // 系统配置权限 + { + code: 'config:create', + resource: 'config', + action: 'create', + name: '创建配置', + description: '允许创建新配置', + }, + { + code: 'config:read', + resource: 'config', + action: 'read', + name: '查看配置', + description: '允许查看配置列表和详情', + }, + { + code: 'config:update', + resource: 'config', + action: 'update', + name: '更新配置', + description: '允许更新配置信息', + }, + { + code: 'config:delete', + resource: 'config', + action: 'delete', + name: '删除配置', + description: '允许删除配置', + }, + + // 日志管理权限 + { + code: 'log:read', + resource: 'log', + action: 'read', + name: '查看日志', + description: '允许查看系统日志', + }, + { + code: 'log:delete', + resource: 'log', + action: 'delete', + name: '删除日志', + description: '允许删除系统日志', + }, + + // 用户密码管理权限 + { + code: 'user:password:update', + resource: 'user', + action: 'password:update', + name: '修改用户密码', + description: '允许修改用户密码', + }, +]; + +// 根据路由配置定义的菜单数据 +const menus = [ + // 顶级菜单:仪表盘 + { + name: '仪表盘', + path: '/dashboard', + icon: 'DashboardOutlined', + component: 'dashboard/Index', + parentId: null, + sort: 1, + }, + // 父菜单:系统管理 + { + name: '系统管理', + path: '/system', + icon: 'SettingOutlined', + component: null, // 父菜单不需要组件 + parentId: null, + sort: 10, + children: [ + { + name: '用户管理', + path: '/system/users', + icon: 'UserOutlined', + component: 'system/users/Index', + sort: 1, + }, + { + name: '角色管理', + path: '/system/roles', + icon: 'TeamOutlined', + component: 'system/roles/Index', + sort: 2, + }, + { + name: '菜单管理', + path: '/system/menus', + icon: 'MenuOutlined', + component: 'system/menus/Index', + sort: 3, + }, + { + name: '数据字典', + path: '/system/dict', + icon: 'BookOutlined', + component: 'system/dict/Index', + sort: 4, + }, + { + name: '系统配置', + path: '/system/config', + icon: 'ToolOutlined', + component: 'system/config/Index', + sort: 5, + }, + { + name: '日志记录', + path: '/system/logs', + icon: 'FileTextOutlined', + component: 'system/logs/Index', + sort: 6, + }, + ], + }, +]; + +async function initAdmin() { + try { + console.log('🚀 开始初始化超级管理员...\n'); + + // 1. 创建或获取所有权限 + console.log('📝 步骤 1: 创建基础权限...'); + const createdPermissions = []; + for (const perm of permissions) { + const permission = await prisma.permission.upsert({ + where: { code: perm.code }, + update: perm, + create: perm, + }); + createdPermissions.push(permission); + console.log(` ✓ ${perm.code} - ${perm.name}`); + } + console.log(`✅ 共创建/更新 ${createdPermissions.length} 个权限\n`); + + // 2. 创建或获取超级管理员角色 + console.log('👤 步骤 2: 创建超级管理员角色...'); + const adminRole = await prisma.role.upsert({ + where: { code: 'super_admin' }, + update: { + name: '超级管理员', + description: '拥有系统所有权限的超级管理员角色', + }, + create: { + name: '超级管理员', + code: 'super_admin', + description: '拥有系统所有权限的超级管理员角色', + permissions: { + create: createdPermissions.map((perm) => ({ + permission: { connect: { id: perm.id } }, + })), + }, + }, + }); + console.log( + `✅ 超级管理员角色已创建/更新: ${adminRole.name} (${adminRole.code})\n`, + ); + + // 3. 创建或获取 admin 用户 + console.log('👤 步骤 3: 创建 admin 用户...'); + const hashedPassword = await bcrypt.hash('cms@admin', 10); + + const adminUser = await prisma.user.upsert({ + where: { username: 'admin' }, + update: { + password: hashedPassword, + nickname: '超级管理员', + validState: 1, + }, + create: { + username: 'admin', + password: hashedPassword, + nickname: '超级管理员', + email: 'admin@example.com', + validState: 1, + }, + }); + console.log( + `✅ 用户已创建/更新: ${adminUser.username} (${adminUser.nickname})\n`, + ); + + // 4. 给 admin 用户分配超级管理员角色 + console.log('🔗 步骤 4: 分配角色...'); + await prisma.userRole.upsert({ + where: { + userId_roleId: { + userId: adminUser.id, + roleId: adminRole.id, + }, + }, + update: {}, + create: { + user: { connect: { id: adminUser.id } }, + role: { connect: { id: adminRole.id } }, + }, + }); + console.log(`✅ 角色分配成功\n`); + + // 5. 初始化菜单数据 + console.log('📋 步骤 5: 初始化菜单数据...'); + + // 递归创建菜单 + async function createMenu(menuData: any, parentId: number | null = null) { + const { children, ...menuFields } = menuData; + + // 查找是否已存在相同名称和父菜单的菜单 + const existingMenu = await prisma.menu.findFirst({ + where: { + name: menuFields.name, + parentId: parentId, + }, + }); + + let menu; + if (existingMenu) { + // 更新现有菜单 + menu = await prisma.menu.update({ + where: { id: existingMenu.id }, + data: { + name: menuFields.name, + path: menuFields.path || null, + icon: menuFields.icon || null, + component: menuFields.component || null, + parentId: parentId, + sort: menuFields.sort || 0, + validState: 1, + }, + }); + } else { + // 创建新菜单 + menu = await prisma.menu.create({ + data: { + name: menuFields.name, + path: menuFields.path || null, + icon: menuFields.icon || null, + component: menuFields.component || null, + parentId: parentId, + sort: menuFields.sort || 0, + validState: 1, + }, + }); + } + + // 如果有子菜单,递归创建 + if (children && children.length > 0) { + for (const child of children) { + await createMenu(child, menu.id); + } + } + + return menu; + } + + // 创建所有菜单 + for (const menu of menus) { + await createMenu(menu); + } + + // 统计菜单数量 + const menuCount = await prisma.menu.count(); + const topLevelMenuCount = await prisma.menu.count({ + where: { parentId: null }, + }); + + console.log( + `✅ 菜单初始化完成: 共 ${menuCount} 个菜单(${topLevelMenuCount} 个顶级菜单)\n`, + ); + + // 6. 验证结果 + console.log('🔍 步骤 6: 验证结果...'); + const userWithRoles = await prisma.user.findUnique({ + where: { id: adminUser.id }, + include: { + roles: { + include: { + role: { + include: { + permissions: { + include: { + permission: true, + }, + }, + }, + }, + }, + }, + }, + }); + + const roleCodes = userWithRoles?.roles.map((ur) => ur.role.code) || []; + const permissionCodes = new Set(); + userWithRoles?.roles.forEach((ur) => { + ur.role.permissions.forEach((rp) => { + permissionCodes.add(rp.permission.code); + }); + }); + + console.log(`\n📊 初始化结果:`); + console.log(` 用户名: ${adminUser.username}`); + console.log(` 昵称: ${adminUser.nickname}`); + console.log(` 密码: cms@admin`); + console.log(` 角色: ${roleCodes.join(', ')}`); + console.log(` 权限数量: ${permissionCodes.size}`); + console.log(` 菜单数量: ${menuCount} (${topLevelMenuCount} 个顶级菜单)`); + console.log(`\n✅ 超级管理员和菜单数据初始化完成!`); + console.log(`\n💡 现在可以使用以下凭据登录:`); + console.log(` 用户名: admin`); + console.log(` 密码: cms@admin`); + } catch (error) { + console.error('❌ 初始化失败:', error); + throw error; + } finally { + await prisma.$disconnect(); + } +} + +// 执行初始化 +initAdmin() + .then(() => { + console.log('\n🎉 初始化脚本执行完成!'); + process.exit(0); + }) + .catch((error) => { + console.error('\n💥 初始化脚本执行失败:', error); + process.exit(1); + }); diff --git a/backend/scripts/init-menus.ts b/backend/scripts/init-menus.ts new file mode 100644 index 0000000..e25b150 --- /dev/null +++ b/backend/scripts/init-menus.ts @@ -0,0 +1,237 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-nocheck +// 加载环境变量(必须在其他导入之前) +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +// 根据 NODE_ENV 加载对应的环境配置文件 +const nodeEnv = process.env.NODE_ENV || 'development'; +const envFile = `.env.${nodeEnv}`; +// scripts 目录的父目录就是 backend 目录 +const backendDir = path.resolve(__dirname, '..'); +const envPath = path.resolve(backendDir, envFile); + +// 尝试加载环境特定的配置文件 +dotenv.config({ path: envPath }); + +// 如果环境特定文件不存在,尝试加载默认的 .env 文件 +if (!process.env.DATABASE_URL) { + dotenv.config({ path: path.resolve(backendDir, '.env') }); +} + +// 验证必要的环境变量 +if (!process.env.DATABASE_URL) { + console.error('❌ 错误: 未找到 DATABASE_URL 环境变量'); + console.error(` 请确保存在以下文件之一:`); + console.error(` - ${envPath}`); + console.error(` - ${path.resolve(backendDir, '.env')}`); + console.error(` 或者设置 NODE_ENV 环境变量(当前: ${nodeEnv})`); + process.exit(1); +} + +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +// 根据路由配置定义的菜单数据 +const menus = [ + // 顶级菜单:仪表盘 + { + name: '仪表盘', + path: '/dashboard', + icon: 'DashboardOutlined', + component: 'dashboard/Index', + parentId: null, + sort: 1, + }, + // 父菜单:系统管理 + { + name: '系统管理', + path: '/system', + icon: 'SettingOutlined', + component: null, // 父菜单不需要组件 + parentId: null, + sort: 10, + children: [ + { + name: '用户管理', + path: '/system/users', + icon: 'UserOutlined', + component: 'system/users/Index', + sort: 1, + }, + { + name: '角色管理', + path: '/system/roles', + icon: 'TeamOutlined', + component: 'system/roles/Index', + sort: 2, + }, + { + name: '菜单管理', + path: '/system/menus', + icon: 'MenuOutlined', + component: 'system/menus/Index', + sort: 3, + }, + { + name: '数据字典', + path: '/system/dict', + icon: 'BookOutlined', + component: 'system/dict/Index', + sort: 4, + }, + { + name: '系统配置', + path: '/system/config', + icon: 'ToolOutlined', + component: 'system/config/Index', + sort: 5, + }, + { + name: '日志记录', + path: '/system/logs', + icon: 'FileTextOutlined', + component: 'system/logs/Index', + sort: 6, + }, + ], + }, +]; + +async function initMenus() { + try { + console.log('🚀 开始初始化菜单数据...\n'); + + // 递归创建菜单 + async function createMenu(menuData: any, parentId: number | null = null) { + const { children, ...menuFields } = menuData; + + // 查找是否已存在相同名称和父菜单的菜单 + const existingMenu = await prisma.menu.findFirst({ + where: { + name: menuFields.name, + parentId: parentId, + }, + }); + + let menu; + if (existingMenu) { + // 更新现有菜单 + menu = await prisma.menu.update({ + where: { id: existingMenu.id }, + data: { + name: menuFields.name, + path: menuFields.path || null, + icon: menuFields.icon || null, + component: menuFields.component || null, + parentId: parentId, + sort: menuFields.sort || 0, + validState: 1, + }, + }); + } else { + // 创建新菜单 + menu = await prisma.menu.create({ + data: { + name: menuFields.name, + path: menuFields.path || null, + icon: menuFields.icon || null, + component: menuFields.component || null, + parentId: parentId, + sort: menuFields.sort || 0, + validState: 1, + }, + }); + } + + console.log(` ✓ ${menu.name} (${menu.path || '无路径'})`); + + // 如果有子菜单,递归创建 + if (children && children.length > 0) { + for (const child of children) { + await createMenu(child, menu.id); + } + } + + return menu; + } + + // 清空现有菜单(重新初始化) + console.log('🗑️ 清空现有菜单...'); + // 先删除所有子菜单,再删除父菜单(避免外键约束问题) + await prisma.menu.deleteMany({ + where: { + parentId: { + not: null, + }, + }, + }); + await prisma.menu.deleteMany({ + where: { + parentId: null, + }, + }); + console.log('✅ 已清空现有菜单\n'); + + // 创建所有菜单 + console.log('📝 创建菜单...\n'); + for (const menu of menus) { + await createMenu(menu); + } + + // 验证结果 + console.log('\n🔍 验证结果...'); + const allMenus = await prisma.menu.findMany({ + orderBy: [{ sort: 'asc' }, { id: 'asc' }], + include: { + children: { + orderBy: { + sort: 'asc', + }, + }, + }, + }); + + const topLevelMenus = allMenus.filter((m) => !m.parentId); + const totalMenus = allMenus.length; + + console.log(`\n📊 初始化结果:`); + console.log(` 顶级菜单数量: ${topLevelMenus.length}`); + console.log(` 总菜单数量: ${totalMenus}`); + console.log(`\n📋 菜单结构:`); + + function printMenuTree(menu: any, indent: string = '') { + console.log(`${indent}├─ ${menu.name} (${menu.path || '无路径'})`); + if (menu.children && menu.children.length > 0) { + menu.children.forEach((child: any, index: number) => { + const isLast = index === menu.children.length - 1; + const childIndent = indent + (isLast ? ' ' : '│ '); + printMenuTree(child, childIndent); + }); + } + } + + topLevelMenus.forEach((menu) => { + printMenuTree(menu); + }); + + console.log(`\n✅ 菜单初始化完成!`); + } catch (error) { + console.error('\n💥 初始化菜单失败:', error); + throw error; + } finally { + await prisma.$disconnect(); + } +} + +// 执行初始化 +initMenus() + .then(() => { + console.log('\n🎉 菜单初始化脚本执行完成!'); + process.exit(0); + }) + .catch((error) => { + console.error('\n💥 菜单初始化脚本执行失败:', error); + process.exit(1); + }); diff --git a/backend/scripts/init-super-tenant.ts b/backend/scripts/init-super-tenant.ts new file mode 100644 index 0000000..49e8b47 --- /dev/null +++ b/backend/scripts/init-super-tenant.ts @@ -0,0 +1,360 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-nocheck +// 加载环境变量(必须在其他导入之前) +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +// 根据 NODE_ENV 加载对应的环境配置文件 +const nodeEnv = process.env.NODE_ENV || 'development'; +const envFile = `.env.${nodeEnv}`; +// scripts 目录的父目录就是 backend 目录 +const backendDir = path.resolve(__dirname, '..'); +const envPath = path.resolve(backendDir, envFile); + +// 尝试加载环境特定的配置文件 +dotenv.config({ path: envPath }); + +// 如果环境特定文件不存在,尝试加载默认的 .env 文件 +if (!process.env.DATABASE_URL) { + dotenv.config({ path: path.resolve(backendDir, '.env') }); +} + +// 验证必要的环境变量 +if (!process.env.DATABASE_URL) { + console.error('❌ 错误: 未找到 DATABASE_URL 环境变量'); + console.error(` 请确保存在以下文件之一:`); + console.error(` - ${envPath}`); + console.error(` - ${path.resolve(backendDir, '.env')}`); + console.error(` 或者设置 NODE_ENV 环境变量(当前: ${nodeEnv})`); + process.exit(1); +} + +import { PrismaClient } from '@prisma/client'; +import * as bcrypt from 'bcrypt'; + +const prisma = new PrismaClient(); + +async function main() { + console.log('开始初始化超级租户...'); + + // 检查是否已存在超级租户 + const existingSuperTenant = await prisma.tenant.findFirst({ + where: { isSuper: 1 }, + }); + + if (existingSuperTenant) { + console.log('超级租户已存在,跳过创建'); + console.log(`租户编码: ${existingSuperTenant.code}`); + return; + } + + // 创建超级租户 + const superTenant = await prisma.tenant.create({ + data: { + name: '超级租户', + code: 'super', + domain: 'super', + description: '系统超级租户,拥有所有权限', + isSuper: 1, + validState: 1, + }, + }); + + console.log('超级租户创建成功!'); + console.log(`租户ID: ${superTenant.id}`); + console.log(`租户编码: ${superTenant.code}`); + console.log(`租户名称: ${superTenant.name}`); + + // 创建超级管理员用户 + const hashedPassword = await bcrypt.hash('admin123', 10); + + const superAdmin = await prisma.user.create({ + data: { + tenantId: superTenant.id, + username: 'admin', + password: hashedPassword, + nickname: '超级管理员', + email: 'admin@super.com', + validState: 1, + }, + }); + + console.log('超级管理员用户创建成功!'); + console.log(`用户名: ${superAdmin.username}`); + console.log(`密码: admin123`); + console.log(`用户ID: ${superAdmin.id}`); + + // 创建超级管理员角色 + const superAdminRole = await prisma.role.create({ + data: { + tenantId: superTenant.id, + name: '超级管理员', + code: 'super_admin', + description: '超级管理员角色,拥有所有权限', + validState: 1, + }, + }); + + console.log('超级管理员角色创建成功!'); + console.log(`角色编码: ${superAdminRole.code}`); + + // 将超级管理员角色分配给用户 + await prisma.userRole.create({ + data: { + userId: superAdmin.id, + roleId: superAdminRole.id, + }, + }); + + console.log('超级管理员角色已分配给用户'); + + // 创建基础权限 + const permissions = [ + { + name: '租户管理-创建', + code: 'tenant:create', + resource: 'tenant', + action: 'create', + }, + { + name: '租户管理-查看', + code: 'tenant:read', + resource: 'tenant', + action: 'read', + }, + { + name: '租户管理-更新', + code: 'tenant:update', + resource: 'tenant', + action: 'update', + }, + { + name: '租户管理-删除', + code: 'tenant:delete', + resource: 'tenant', + action: 'delete', + }, + { + name: '用户管理-创建', + code: 'user:create', + resource: 'user', + action: 'create', + }, + { + name: '用户管理-查看', + code: 'user:read', + resource: 'user', + action: 'read', + }, + { + name: '用户管理-更新', + code: 'user:update', + resource: 'user', + action: 'update', + }, + { + name: '用户管理-删除', + code: 'user:delete', + resource: 'user', + action: 'delete', + }, + { + name: '角色管理-创建', + code: 'role:create', + resource: 'role', + action: 'create', + }, + { + name: '角色管理-查看', + code: 'role:read', + resource: 'role', + action: 'read', + }, + { + name: '角色管理-更新', + code: 'role:update', + resource: 'role', + action: 'update', + }, + { + name: '角色管理-删除', + code: 'role:delete', + resource: 'role', + action: 'delete', + }, + { + name: '权限管理-创建', + code: 'permission:create', + resource: 'permission', + action: 'create', + }, + { + name: '权限管理-查看', + code: 'permission:read', + resource: 'permission', + action: 'read', + }, + { + name: '权限管理-更新', + code: 'permission:update', + resource: 'permission', + action: 'update', + }, + { + name: '权限管理-删除', + code: 'permission:delete', + resource: 'permission', + action: 'delete', + }, + { + name: '菜单管理-创建', + code: 'menu:create', + resource: 'menu', + action: 'create', + }, + { + name: '菜单管理-查看', + code: 'menu:read', + resource: 'menu', + action: 'read', + }, + { + name: '菜单管理-更新', + code: 'menu:update', + resource: 'menu', + action: 'update', + }, + { + name: '菜单管理-删除', + code: 'menu:delete', + resource: 'menu', + action: 'delete', + }, + ]; + + const createdPermissions = []; + for (const perm of permissions) { + const existing = await prisma.permission.findFirst({ + where: { + tenantId: superTenant.id, + code: perm.code, + }, + }); + + if (!existing) { + const permission = await prisma.permission.create({ + data: { + tenantId: superTenant.id, + ...perm, + validState: 1, + }, + }); + createdPermissions.push(permission); + } else { + createdPermissions.push(existing); + } + } + + console.log(`创建了 ${createdPermissions.length} 个权限`); + + // 将所有权限分配给超级管理员角色 + await prisma.rolePermission.createMany({ + data: createdPermissions.map((perm) => ({ + roleId: superAdminRole.id, + permissionId: perm.id, + })), + skipDuplicates: true, + }); + + console.log('所有权限已分配给超级管理员角色'); + + // 创建租户管理菜单(如果不存在) + console.log('\n创建租户管理菜单...'); + + // 查找系统管理菜单(父菜单) + const systemMenu = await prisma.menu.findFirst({ + where: { + name: '系统管理', + parentId: null, + }, + }); + + if (systemMenu) { + // 检查租户管理菜单是否已存在 + const existingTenantMenu = await prisma.menu.findFirst({ + where: { + name: '租户管理', + path: '/system/tenants', + }, + }); + + let tenantMenu; + if (!existingTenantMenu) { + tenantMenu = await prisma.menu.create({ + data: { + name: '租户管理', + path: '/system/tenants', + icon: 'TeamOutlined', + component: 'system/tenants/Index', + parentId: systemMenu.id, + permission: 'tenant:read', + sort: 7, + validState: 1, + }, + }); + console.log('租户管理菜单创建成功'); + } else { + tenantMenu = existingTenantMenu; + console.log('租户管理菜单已存在,跳过创建'); + } + + // 为超级租户分配租户管理菜单 + if (tenantMenu) { + const existingTenantMenuRelation = await prisma.tenantMenu.findFirst({ + where: { + tenantId: superTenant.id, + menuId: tenantMenu.id, + }, + }); + + if (!existingTenantMenuRelation) { + await prisma.tenantMenu.create({ + data: { + tenantId: superTenant.id, + menuId: tenantMenu.id, + }, + }); + console.log('租户管理菜单已分配给超级租户'); + } else { + console.log('租户管理菜单已分配给超级租户,跳过'); + } + } + } else { + console.log('警告:未找到系统管理菜单,无法创建租户管理菜单'); + } + + console.log('\n初始化完成!'); + console.log('========================================'); + console.log('超级租户信息:'); + console.log(` 租户编码: ${superTenant.code}`); + console.log(` 访问链接: http://your-domain.com/?tenant=${superTenant.code}`); + console.log('========================================'); + console.log('超级管理员登录信息:'); + console.log(` 用户名: ${superAdmin.username}`); + console.log(` 密码: admin123`); + console.log(` 租户编码: ${superTenant.code}`); + console.log('========================================'); +} + +main() + .then(() => { + console.log('\n🎉 初始化脚本执行完成!'); + process.exit(0); + }) + .catch((error) => { + console.error('\n💥 初始化脚本执行失败:', error); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/backend/scripts/init-tenant-admin.ts b/backend/scripts/init-tenant-admin.ts new file mode 100644 index 0000000..08caae6 --- /dev/null +++ b/backend/scripts/init-tenant-admin.ts @@ -0,0 +1,755 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-nocheck +// 加载环境变量(必须在其他导入之前) +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +// 根据 NODE_ENV 加载对应的环境配置文件 +const nodeEnv = process.env.NODE_ENV || 'development'; +const envFile = `.env.${nodeEnv}`; +// scripts 目录的父目录就是 backend 目录 +const backendDir = path.resolve(__dirname, '..'); +const envPath = path.resolve(backendDir, envFile); + +// 尝试加载环境特定的配置文件 +dotenv.config({ path: envPath }); + +// 如果环境特定文件不存在,尝试加载默认的 .env 文件 +if (!process.env.DATABASE_URL) { + dotenv.config({ path: path.resolve(backendDir, '.env') }); +} + +// 验证必要的环境变量 +if (!process.env.DATABASE_URL) { + console.error('❌ 错误: 未找到 DATABASE_URL 环境变量'); + console.error(` 请确保存在以下文件之一:`); + console.error(` - ${envPath}`); + console.error(` - ${path.resolve(backendDir, '.env')}`); + console.error(` 或者设置 NODE_ENV 环境变量(当前: ${nodeEnv})`); + process.exit(1); +} + +import { PrismaClient } from '@prisma/client'; +import * as bcrypt from 'bcrypt'; + +const prisma = new PrismaClient(); + +// 定义所有基础权限 +const permissions = [ + { + code: 'workbench:read', + resource: 'workbench', + action: 'read', + name: '查看工作台', + description: '允许查看工作台', + }, + // 用户管理权限 + { + code: 'user:create', + resource: 'user', + action: 'create', + name: '创建用户', + description: '允许创建新用户', + }, + { + code: 'user:read', + resource: 'user', + action: 'read', + name: '查看用户', + description: '允许查看用户列表和详情', + }, + { + code: 'user:update', + resource: 'user', + action: 'update', + name: '更新用户', + description: '允许更新用户信息', + }, + { + code: 'user:delete', + resource: 'user', + action: 'delete', + name: '删除用户', + description: '允许删除用户', + }, + + // 角色管理权限 + { + code: 'role:create', + resource: 'role', + action: 'create', + name: '创建角色', + description: '允许创建新角色', + }, + { + code: 'role:read', + resource: 'role', + action: 'read', + name: '查看角色', + description: '允许查看角色列表和详情', + }, + { + code: 'role:update', + resource: 'role', + action: 'update', + name: '更新角色', + description: '允许更新角色信息', + }, + { + code: 'role:delete', + resource: 'role', + action: 'delete', + name: '删除角色', + description: '允许删除角色', + }, + { + code: 'role:assign', + resource: 'role', + action: 'assign', + name: '分配角色', + description: '允许给用户分配角色', + }, + + // 权限管理权限 + { + code: 'permission:create', + resource: 'permission', + action: 'create', + name: '创建权限', + description: '允许创建新权限', + }, + { + code: 'permission:read', + resource: 'permission', + action: 'read', + name: '查看权限', + description: '允许查看权限列表和详情', + }, + { + code: 'permission:update', + resource: 'permission', + action: 'update', + name: '更新权限', + description: '允许更新权限信息', + }, + { + code: 'permission:delete', + resource: 'permission', + action: 'delete', + name: '删除权限', + description: '允许删除权限', + }, + + // 菜单管理权限 + { + code: 'menu:create', + resource: 'menu', + action: 'create', + name: '创建菜单', + description: '允许创建新菜单', + }, + { + code: 'menu:read', + resource: 'menu', + action: 'read', + name: '查看菜单', + description: '允许查看菜单列表和详情', + }, + { + code: 'menu:update', + resource: 'menu', + action: 'update', + name: '更新菜单', + description: '允许更新菜单信息', + }, + { + code: 'menu:delete', + resource: 'menu', + action: 'delete', + name: '删除菜单', + description: '允许删除菜单', + }, + + // 数据字典权限 + { + code: 'dict:create', + resource: 'dict', + action: 'create', + name: '创建字典', + description: '允许创建新字典', + }, + { + code: 'dict:read', + resource: 'dict', + action: 'read', + name: '查看字典', + description: '允许查看字典列表和详情', + }, + { + code: 'dict:update', + resource: 'dict', + action: 'update', + name: '更新字典', + description: '允许更新字典信息', + }, + { + code: 'dict:delete', + resource: 'dict', + action: 'delete', + name: '删除字典', + description: '允许删除字典', + }, + + // 系统配置权限 + { + code: 'config:create', + resource: 'config', + action: 'create', + name: '创建配置', + description: '允许创建新配置', + }, + { + code: 'config:read', + resource: 'config', + action: 'read', + name: '查看配置', + description: '允许查看配置列表和详情', + }, + { + code: 'config:update', + resource: 'config', + action: 'update', + name: '更新配置', + description: '允许更新配置信息', + }, + { + code: 'config:delete', + resource: 'config', + action: 'delete', + name: '删除配置', + description: '允许删除配置', + }, + + // 日志管理权限 + { + code: 'log:read', + resource: 'log', + action: 'read', + name: '查看日志', + description: '允许查看系统日志', + }, + { + code: 'log:delete', + resource: 'log', + action: 'delete', + name: '删除日志', + description: '允许删除系统日志', + }, + + // 用户密码管理权限 + { + code: 'user:password:update', + resource: 'user', + action: 'password:update', + name: '修改用户密码', + description: '允许修改用户密码', + }, +]; + +/** + * 仅初始化 admin 角色的权限(不创建用户、角色和菜单) + */ +async function initTenantAdminPermissionsOnly(tenantCode: string) { + try { + console.log(`🚀 开始为租户 "${tenantCode}" 的 admin 角色初始化权限...\n`); + + // 1. 查找租户 + console.log(`📋 步骤 1: 查找租户 "${tenantCode}"...`); + const tenant = await prisma.tenant.findUnique({ + where: { code: tenantCode }, + }); + + if (!tenant) { + console.error(`❌ 错误: 租户 "${tenantCode}" 不存在!`); + console.error(' 请先创建租户后再运行此脚本'); + process.exit(1); + } + + if (tenant.validState !== 1) { + console.error(`❌ 错误: 租户 "${tenantCode}" 状态无效!`); + process.exit(1); + } + + console.log(`✅ 找到租户: ${tenant.name} (${tenant.code})\n`); + + // 2. 检查 admin 角色是否存在 + console.log(`👤 步骤 2: 检查 admin 角色是否存在...`); + const adminRole = await prisma.role.findFirst({ + where: { + tenantId: tenant.id, + code: 'admin', + }, + }); + + if (!adminRole) { + console.error(`❌ 错误: 租户 "${tenantCode}" 的 admin 角色不存在!`); + console.error(' 请先运行完整初始化脚本创建 admin 角色'); + console.error(` 使用方法: pnpm init:tenant-admin ${tenantCode}`); + process.exit(1); + } + + console.log(`✅ 找到 admin 角色: ${adminRole.name} (${adminRole.code})\n`); + + // 3. 初始化租户权限(如果不存在则创建) + console.log(`📝 步骤 3: 初始化租户权限...`); + const createdPermissions = []; + + for (const perm of permissions) { + // 检查权限是否已存在 + const existingPermission = await prisma.permission.findFirst({ + where: { + tenantId: tenant.id, + code: perm.code, + }, + }); + + if (!existingPermission) { + // 创建权限 + const permission = await prisma.permission.create({ + data: { + tenantId: tenant.id, + code: perm.code, + resource: perm.resource, + action: perm.action, + name: perm.name, + description: perm.description, + validState: 1, + }, + }); + createdPermissions.push(permission); + console.log(` ✓ 创建权限: ${perm.code} - ${perm.name}`); + } else { + // 更新现有权限(确保信息是最新的) + const permission = await prisma.permission.update({ + where: { id: existingPermission.id }, + data: { + name: perm.name, + resource: perm.resource, + action: perm.action, + description: perm.description, + validState: 1, + }, + }); + createdPermissions.push(permission); + } + } + + console.log(`✅ 共确保 ${createdPermissions.length} 个权限存在\n`); + + // 获取租户的所有有效权限 + const tenantPermissions = await prisma.permission.findMany({ + where: { + tenantId: tenant.id, + validState: 1, + }, + }); + + // 4. 为 admin 角色分配所有权限 + console.log(`🔗 步骤 4: 为 admin 角色分配所有权限...`); + const existingRolePermissions = await prisma.rolePermission.findMany({ + where: { roleId: adminRole.id }, + select: { permissionId: true }, + }); + const existingPermissionIds = new Set( + existingRolePermissions.map((rp) => rp.permissionId), + ); + + let addedCount = 0; + for (const permission of tenantPermissions) { + if (!existingPermissionIds.has(permission.id)) { + await prisma.rolePermission.create({ + data: { + roleId: adminRole.id, + permissionId: permission.id, + }, + }); + addedCount++; + } + } + + if (addedCount > 0) { + console.log(`✅ 为 admin 角色添加了 ${addedCount} 个权限`); + console.log(`✅ admin 角色现在拥有 ${tenantPermissions.length} 个权限\n`); + } else { + console.log( + `✅ admin 角色已拥有所有权限(${tenantPermissions.length} 个)\n`, + ); + } + + // 5. 验证结果 + console.log('🔍 步骤 5: 验证结果...'); + const roleWithPermissions = await prisma.role.findUnique({ + where: { id: adminRole.id }, + include: { + permissions: { + include: { + permission: true, + }, + }, + }, + }); + + const permissionCodes = new Set(); + roleWithPermissions?.permissions.forEach((rp) => { + permissionCodes.add(rp.permission.code); + }); + + console.log(`\n📊 初始化结果:`); + console.log(` 租户名称: ${tenant.name}`); + console.log(` 租户编码: ${tenant.code}`); + console.log(` 角色名称: ${adminRole.name}`); + console.log(` 角色编码: ${adminRole.code}`); + console.log(` 权限数量: ${permissionCodes.size}`); + if (permissionCodes.size > 0) { + console.log(` 权限列表:`); + Array.from(permissionCodes) + .sort() + .forEach((code) => { + console.log(` - ${code}`); + }); + } + console.log(`\n✅ admin 角色权限初始化完成!`); + } catch (error) { + console.error('❌ 初始化失败:', error); + throw error; + } finally { + await prisma.$disconnect(); + } +} + +async function initTenantAdmin(tenantCode: string) { + try { + console.log(`🚀 开始为租户 "${tenantCode}" 初始化 admin 账号...\n`); + + // 1. 查找租户 + console.log(`📋 步骤 1: 查找租户 "${tenantCode}"...`); + const tenant = await prisma.tenant.findUnique({ + where: { code: tenantCode }, + }); + + if (!tenant) { + console.error(`❌ 错误: 租户 "${tenantCode}" 不存在!`); + console.error(' 请先创建租户后再运行此脚本'); + process.exit(1); + } + + if (tenant.validState !== 1) { + console.error(`❌ 错误: 租户 "${tenantCode}" 状态无效!`); + process.exit(1); + } + + console.log(`✅ 找到租户: ${tenant.name} (${tenant.code})\n`); + + // 2. 检查是否已存在 admin 用户 + console.log(`👤 步骤 2: 检查 admin 用户是否已存在...`); + const existingAdmin = await prisma.user.findFirst({ + where: { + tenantId: tenant.id, + username: 'admin', + }, + }); + + if (existingAdmin) { + console.log(`⚠️ 警告: 租户 "${tenantCode}" 已存在 admin 用户`); + console.log(` 用户ID: ${existingAdmin.id}`); + console.log(` 用户名: ${existingAdmin.username}`); + console.log(` 昵称: ${existingAdmin.nickname}`); + console.log(` 将更新密码和权限...\n`); + } + + // 3. 初始化租户权限(如果不存在则创建) + console.log(`📝 步骤 3: 初始化租户权限...`); + const createdPermissions = []; + + for (const perm of permissions) { + // 检查权限是否已存在 + const existingPermission = await prisma.permission.findFirst({ + where: { + tenantId: tenant.id, + code: perm.code, + }, + }); + + if (!existingPermission) { + // 创建权限 + const permission = await prisma.permission.create({ + data: { + tenantId: tenant.id, + code: perm.code, + resource: perm.resource, + action: perm.action, + name: perm.name, + description: perm.description, + validState: 1, + }, + }); + createdPermissions.push(permission); + console.log(` ✓ 创建权限: ${perm.code} - ${perm.name}`); + } else { + // 更新现有权限(确保信息是最新的) + const permission = await prisma.permission.update({ + where: { id: existingPermission.id }, + data: { + name: perm.name, + resource: perm.resource, + action: perm.action, + description: perm.description, + validState: 1, + }, + }); + createdPermissions.push(permission); + } + } + + console.log(`✅ 共确保 ${createdPermissions.length} 个权限存在\n`); + + // 获取租户的所有有效权限 + const tenantPermissions = await prisma.permission.findMany({ + where: { + tenantId: tenant.id, + validState: 1, + }, + }); + + // 4. 创建或获取 admin 角色 + console.log(`👤 步骤 4: 创建或获取 admin 角色...`); + let adminRole = await prisma.role.findFirst({ + where: { + tenantId: tenant.id, + code: 'admin', + }, + }); + + if (!adminRole) { + adminRole = await prisma.role.create({ + data: { + tenantId: tenant.id, + name: '管理员', + code: 'admin', + description: '租户管理员角色,拥有租户的所有权限', + validState: 1, + }, + }); + console.log( + `✅ admin 角色已创建: ${adminRole.name} (${adminRole.code})\n`, + ); + } else { + // 更新角色信息 + adminRole = await prisma.role.update({ + where: { id: adminRole.id }, + data: { + name: '管理员', + description: '租户管理员角色,拥有租户的所有权限', + validState: 1, + }, + }); + console.log( + `✅ admin 角色已更新: ${adminRole.name} (${adminRole.code})\n`, + ); + } + + // 5. 为 admin 角色分配所有权限 + console.log(`🔗 步骤 5: 为 admin 角色分配所有权限...`); + const existingRolePermissions = await prisma.rolePermission.findMany({ + where: { roleId: adminRole.id }, + select: { permissionId: true }, + }); + const existingPermissionIds = new Set( + existingRolePermissions.map((rp) => rp.permissionId), + ); + + let addedCount = 0; + for (const permission of tenantPermissions) { + if (!existingPermissionIds.has(permission.id)) { + await prisma.rolePermission.create({ + data: { + roleId: adminRole.id, + permissionId: permission.id, + }, + }); + addedCount++; + } + } + + if (addedCount > 0) { + console.log(`✅ 为 admin 角色添加了 ${addedCount} 个权限`); + console.log(`✅ admin 角色现在拥有 ${tenantPermissions.length} 个权限\n`); + } else { + console.log( + `✅ admin 角色已拥有所有权限(${tenantPermissions.length} 个)\n`, + ); + } + + // 6. 创建或更新 admin 用户 + console.log(`👤 步骤 6: 创建或更新 admin 用户...`); + const password = `admin@${tenantCode}`; + const hashedPassword = await bcrypt.hash(password, 10); + + let adminUser; + if (existingAdmin) { + adminUser = await prisma.user.update({ + where: { id: existingAdmin.id }, + data: { + password: hashedPassword, + nickname: '管理员', + email: `admin@${tenantCode}.com`, + validState: 1, + }, + }); + console.log( + `✅ 用户已更新: ${adminUser.username} (${adminUser.nickname})\n`, + ); + } else { + adminUser = await prisma.user.create({ + data: { + tenantId: tenant.id, + username: 'admin', + password: hashedPassword, + nickname: '管理员', + email: `admin@${tenantCode}.com`, + validState: 1, + }, + }); + console.log( + `✅ 用户已创建: ${adminUser.username} (${adminUser.nickname})\n`, + ); + } + + // 7. 为 admin 用户分配 admin 角色 + console.log(`🔗 步骤 7: 为 admin 用户分配 admin 角色...`); + const existingUserRole = await prisma.userRole.findUnique({ + where: { + userId_roleId: { + userId: adminUser.id, + roleId: adminRole.id, + }, + }, + }); + + if (!existingUserRole) { + await prisma.userRole.create({ + data: { + userId: adminUser.id, + roleId: adminRole.id, + }, + }); + console.log(`✅ 角色分配成功\n`); + } else { + console.log(`✅ 用户已拥有 admin 角色\n`); + } + + // 8. 验证结果 + console.log('🔍 步骤 8: 验证结果...'); + const userWithRoles = await prisma.user.findUnique({ + where: { id: adminUser.id }, + include: { + roles: { + include: { + role: { + include: { + permissions: { + include: { + permission: true, + }, + }, + }, + }, + }, + }, + }, + }); + + const roleCodes = userWithRoles?.roles.map((ur) => ur.role.code) || []; + const permissionCodes = new Set(); + userWithRoles?.roles.forEach((ur) => { + ur.role.permissions.forEach((rp) => { + permissionCodes.add(rp.permission.code); + }); + }); + + console.log(`\n📊 初始化结果:`); + console.log(` 租户名称: ${tenant.name}`); + console.log(` 租户编码: ${tenant.code}`); + console.log(` 用户名: ${adminUser.username}`); + console.log(` 昵称: ${adminUser.nickname}`); + console.log(` 密码: ${password}`); + console.log(` 角色: ${roleCodes.join(', ')}`); + console.log(` 权限数量: ${permissionCodes.size}`); + if (permissionCodes.size > 0) { + console.log(` 权限列表:`); + Array.from(permissionCodes) + .sort() + .forEach((code) => { + console.log(` - ${code}`); + }); + } + console.log(`\n✅ 租户 admin 账号初始化完成!`); + console.log(`\n💡 现在可以使用以下凭据登录:`); + console.log(` 租户编码: ${tenant.code}`); + console.log(` 用户名: ${adminUser.username}`); + console.log(` 密码: ${password}`); + } catch (error) { + console.error('❌ 初始化失败:', error); + throw error; + } finally { + await prisma.$disconnect(); + } +} + +// 获取命令行参数 +// 支持两种调用方式: +// 1. pnpm init:tenant-admin tenant1 --permissions-only +// 2. pnpm init:tenant-admin:permissions tenant1 (--permissions-only 在 argv[2]) +let tenantCode: string | undefined; +let permissionsOnly = false; + +// 检查是否有 --permissions-only 标志 +if (process.argv[2] === '--permissions-only') { + permissionsOnly = true; + tenantCode = process.argv[3]; +} else if (process.argv[3] === '--permissions-only') { + permissionsOnly = true; + tenantCode = process.argv[2]; +} else { + tenantCode = process.argv[2]; +} + +if (!tenantCode) { + console.error('❌ 错误: 请提供租户编码作为参数'); + console.error(' 使用方法:'); + console.error(' 完整初始化: pnpm init:tenant-admin <租户编码>'); + console.error( + ' 仅初始化权限: pnpm init:tenant-admin <租户编码> --permissions-only', + ); + console.error(' 或: pnpm init:tenant-admin:permissions <租户编码>'); + console.error(' 示例:'); + console.error(' pnpm init:tenant-admin tenant1'); + console.error(' pnpm init:tenant-admin tenant1 --permissions-only'); + console.error(' pnpm init:tenant-admin:permissions tenant1'); + process.exit(1); +} + +// 执行初始化 +const initFunction = permissionsOnly + ? initTenantAdminPermissionsOnly + : initTenantAdmin; + +initFunction(tenantCode) + .then(() => { + console.log('\n🎉 初始化脚本执行完成!'); + process.exit(0); + }) + .catch((error) => { + console.error('\n💥 初始化脚本执行失败:', error); + process.exit(1); + }); diff --git a/backend/scripts/update-password.ts b/backend/scripts/update-password.ts new file mode 100644 index 0000000..407e335 --- /dev/null +++ b/backend/scripts/update-password.ts @@ -0,0 +1,120 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-nocheck +// 加载环境变量(必须在其他导入之前) +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +// 根据 NODE_ENV 加载对应的环境配置文件 +const nodeEnv = process.env.NODE_ENV || 'development'; +const envFile = `.env.${nodeEnv}`; +// scripts 目录的父目录就是 backend 目录 +const backendDir = path.resolve(__dirname, '..'); +const envPath = path.resolve(backendDir, envFile); + +// 尝试加载环境特定的配置文件 +dotenv.config({ path: envPath }); + +// 如果环境特定文件不存在,尝试加载默认的 .env 文件 +if (!process.env.DATABASE_URL) { + dotenv.config({ path: path.resolve(backendDir, '.env') }); +} + +// 验证必要的环境变量 +if (!process.env.DATABASE_URL) { + console.error('❌ 错误: 未找到 DATABASE_URL 环境变量'); + console.error(` 请确保存在以下文件之一:`); + console.error(` - ${envPath}`); + console.error(` - ${path.resolve(backendDir, '.env')}`); + console.error(` 或者设置 NODE_ENV 环境变量(当前: ${nodeEnv})`); + process.exit(1); +} + +import { PrismaClient } from '@prisma/client'; +import * as bcrypt from 'bcrypt'; + +const prisma = new PrismaClient(); + +async function updatePassword() { + try { + const tenantCode = 'super'; + const username = 'admin'; + const newPassword = process.argv[2] || 'cms@admin'; // 支持命令行参数传入新密码 + + console.log(`🔐 开始修改租户 "${tenantCode}" 的 admin 用户密码...\n`); + + // 1. 查找租户 + console.log(`📋 步骤 1: 查找租户 "${tenantCode}"...`); + const tenant = await prisma.tenant.findUnique({ + where: { code: tenantCode }, + }); + + if (!tenant) { + console.error(`❌ 错误: 租户 "${tenantCode}" 不存在!`); + process.exit(1); + } + + console.log(`✅ 找到租户: ${tenant.name} (${tenant.code})\n`); + + // 2. 查找用户 + console.log(`👤 步骤 2: 查找用户 "${username}"...`); + const existingUser = await prisma.user.findFirst({ + where: { + tenantId: tenant.id, + username: username, + }, + }); + + if (!existingUser) { + console.error( + `❌ 错误: 租户 "${tenantCode}" 下不存在用户 "${username}"!`, + ); + console.error(` 请先创建该用户`); + process.exit(1); + } + + console.log( + `✅ 找到用户: ${existingUser.username} (${existingUser.nickname})\n`, + ); + + // 3. 加密新密码 + console.log(`🔒 步骤 3: 加密新密码...`); + const hashedPassword = await bcrypt.hash(newPassword, 10); + console.log(`✅ 密码加密完成\n`); + + // 4. 更新密码 + console.log(`💾 步骤 4: 更新用户密码...`); + const updatedUser = await prisma.user.update({ + where: { id: existingUser.id }, + data: { + password: hashedPassword, + }, + }); + + console.log(`✅ 密码修改成功!\n`); + console.log(`📊 更新结果:`); + console.log(` 租户名称: ${tenant.name}`); + console.log(` 租户编码: ${tenant.code}`); + console.log(` 用户ID: ${updatedUser.id}`); + console.log(` 用户名: ${updatedUser.username}`); + console.log(` 昵称: ${updatedUser.nickname}`); + console.log(` 新密码: ${newPassword}`); + console.log(` 修改时间: ${updatedUser.modifyTime}\n`); + } catch (error) { + console.error('❌ 修改密码时发生错误:'); + console.error(error); + process.exit(1); + } finally { + await prisma.$disconnect(); + } +} + +// 执行脚本 +updatePassword() + .then(() => { + console.log('🎉 密码修改脚本执行完成!'); + process.exit(0); + }) + .catch((error) => { + console.error('\n💥 密码修改脚本执行失败:', error); + process.exit(1); + }); diff --git a/backend/scripts/verify-admin.js b/backend/scripts/verify-admin.js new file mode 100644 index 0000000..b470bf5 --- /dev/null +++ b/backend/scripts/verify-admin.js @@ -0,0 +1,64 @@ +const { PrismaClient } = require('@prisma/client'); +const prisma = new PrismaClient(); + +async function verifyAdmin() { + try { + const user = await prisma.user.findUnique({ + where: { username: 'admin' }, + include: { + roles: { + include: { + role: { + include: { + permissions: { + include: { + permission: true, + }, + }, + }, + }, + }, + }, + }, + }); + + if (user) { + console.log('\n✅ 验证结果:'); + console.log(`用户名: ${user.username}`); + console.log(`昵称: ${user.nickname}`); + console.log(`邮箱: ${user.email || '未设置'}`); + console.log(`状态: ${user.validState === 1 ? '有效' : '失效'}`); + console.log(`\n角色列表:`); + user.roles.forEach((ur) => { + console.log(` - ${ur.role.name} (${ur.role.code})`); + console.log(` 权限数量: ${ur.role.permissions.length}`); + }); + + const allPermissions = new Set(); + user.roles.forEach((ur) => { + ur.role.permissions.forEach((rp) => { + allPermissions.add(rp.permission.code); + }); + }); + + console.log(`\n总权限数: ${allPermissions.size}`); + console.log(`\n权限列表 (前10个):`); + Array.from(allPermissions).sort().slice(0, 10).forEach((perm) => { + console.log(` - ${perm}`); + }); + if (allPermissions.size > 10) { + console.log(` ... 还有 ${allPermissions.size - 10} 个权限`); + } + } else { + console.log('❌ 未找到 admin 用户'); + } + } catch (error) { + console.error('验证失败:', error.message); + } finally { + await prisma.$disconnect(); + } +} + +verifyAdmin(); + + diff --git a/backend/scripts/verify-admin.ts b/backend/scripts/verify-admin.ts new file mode 100644 index 0000000..52e71f4 --- /dev/null +++ b/backend/scripts/verify-admin.ts @@ -0,0 +1,59 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-nocheck +import { PrismaClient } from '@prisma/client'; +const prisma = new PrismaClient(); + +async function verifyAdmin() { + const user = await prisma.user.findUnique({ + where: { username: 'admin' }, + include: { + roles: { + include: { + role: { + include: { + permissions: { + include: { + permission: true, + }, + }, + }, + }, + }, + }, + }, + }); + + if (user) { + console.log('\n✅ 验证结果:'); + console.log(`用户名: ${user.username}`); + console.log(`昵称: ${user.nickname}`); + console.log(`邮箱: ${user.email || '未设置'}`); + console.log(`状态: ${user.validState === 1 ? '有效' : '失效'}`); + console.log(`\n角色列表:`); + user.roles.forEach((ur) => { + console.log(` - ${ur.role.name} (${ur.role.code})`); + console.log(` 权限数量: ${ur.role.permissions.length}`); + }); + + const allPermissions = new Set(); + user.roles.forEach((ur) => { + ur.role.permissions.forEach((rp) => { + allPermissions.add(rp.permission.code); + }); + }); + + console.log(`\n总权限数: ${allPermissions.size}`); + console.log(`\n权限列表:`); + Array.from(allPermissions) + .sort() + .forEach((perm) => { + console.log(` - ${perm}`); + }); + } else { + console.log('❌ 未找到 admin 用户'); + } + + await prisma.$disconnect(); +} + +verifyAdmin(); diff --git a/backend/sql/add_tenant_menu.sql b/backend/sql/add_tenant_menu.sql new file mode 100644 index 0000000..7fe49dd --- /dev/null +++ b/backend/sql/add_tenant_menu.sql @@ -0,0 +1,58 @@ +-- 为超级租户添加租户管理菜单 +-- 注意:需要先查询系统管理菜单的ID,然后替换下面的 parent_id + +-- 查询系统管理菜单的ID +-- SELECT id FROM menus WHERE name = '系统管理' AND parent_id IS NULL; + +-- 假设系统管理菜单的ID为某个值(需要根据实际情况调整) +-- 这里使用子查询来动态获取系统管理菜单的ID + +INSERT INTO menus ( + name, + path, + icon, + component, + parent_id, + permission, + sort, + valid_state, + create_time, + modify_time +) +SELECT + '租户管理', + '/system/tenants', + 'TeamOutlined', + 'system/tenants/Index', + id, -- 系统管理菜单的ID + 'tenant:read', + 7, -- 排序,放在其他系统管理菜单之后 + 1, + NOW(), + NOW() +FROM menus +WHERE name = '系统管理' AND parent_id IS NULL +LIMIT 1; + +-- 如果系统管理菜单不存在,可以手动指定ID: +-- INSERT INTO menus (name, path, icon, component, parent_id, permission, sort, valid_state, create_time, modify_time) +-- VALUES ('租户管理', '/system/tenants', 'TeamOutlined', 'system/tenants/Index', 2, 'tenant:read', 7, 1, NOW(), NOW()); + +-- 为超级租户分配租户管理菜单 +-- 假设超级租户的ID为1(需要根据实际情况调整) +-- 假设租户管理菜单的ID为刚插入的菜单ID + +INSERT INTO tenant_menus (tenant_id, menu_id) +SELECT + t.id AS tenant_id, + m.id AS menu_id +FROM tenants t +CROSS JOIN menus m +WHERE t.code = 'super' AND t.is_super = 1 + AND m.name = '租户管理' AND m.path = '/system/tenants' +LIMIT 1; + +-- 如果上面的查询没有结果,可以手动指定ID: +-- INSERT INTO tenant_menus (tenant_id, menu_id) +-- VALUES (1, (SELECT id FROM menus WHERE name = '租户管理' AND path = '/system/tenants' LIMIT 1)); + diff --git a/backend/sql/competition.sql b/backend/sql/competition.sql new file mode 100644 index 0000000..ca13c4b --- /dev/null +++ b/backend/sql/competition.sql @@ -0,0 +1,200 @@ +CREATE TABLE `t_contest` ( + `contest_id` varchar(63) NOT NULL COMMENT '主键id', + `contest_name` varchar(127) NOT NULL COMMENT '赛事名称', + `contest_type` varchar(31) NOT NULL COMMENT '赛事类型,字典:contest_type:individual/team', + `contest_state` varchar(31) NOT NULL COMMENT '赛事状态(未发布: unpublished 已发布: published'), + `start_time` datetime NOT NULL COMMENT '赛事开始时间', + `end_time` datetime NOT NULL COMMENT '赛事结束时间', + `address` varchar(512) DEFAULT NULL COMMENT '线下地址', + `content` text COMMENT '赛事详情', + `contest_tenant` text COMMENT '赛事参赛范围(授权租户)', + `cover_url` varchar(255) DEFAULT NULL COMMENT '封面url', + `poster_url` varchar(255) DEFAULT NULL COMMENT '海报url', + `contact_name` varchar(63) DEFAULT NULL COMMENT '联系人', + `contact_phone` varchar(63) DEFAULT NULL COMMENT '联系电话', + `contact_qrcode` varchar(255) DEFAULT NULL COMMENT '联系人二维码', + `organizers` text DEFAULT NULL COMMENT '主办单位数组', + `co_organizers` text DEFAULT NULL COMMENT '协办单位数组', + `sponsors` text DEFAULT NULL COMMENT '赞助单位数组', + `register_start_time` datetime NOT NULL COMMENT '报名开始时间', + `register_end_time` datetime NOT NULL COMMENT '报名结束时间', + `register_state` varchar(31) DEFAULT NULL COMMENT '报名任务状态,映射写死:启动(started),已关闭(closed)', + `submit_rule` varchar(31) NOT NULL DEFAULT 'once' COMMENT '提交规则:once/resubmit', + `submit_start_time` datetime NOT NULL COMMENT '作品提交开始时间', + `submit_end_time` datetime NOT NULL COMMENT '作品提交结束时间', + `review_rule_id` varchar(63) DEFAULT NULL COMMENT '评审规则id', + `review_start_time` datetime NOT NULL COMMENT '评审开始时间', + `review_end_time` datetime NOT NULL COMMENT '评审结束时间', + `result_publish_time` datetime DEFAULT NULL COMMENT '结果发布时间', + `creator` varchar(63) NOT NULL DEFAULT '' COMMENT '创建人', + `modifier` varchar(63) NOT NULL DEFAULT '' COMMENT '修改人', + `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `modify_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `valid_state` varchar(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '1' COMMENT '有效状态(1-有效,2-失效)', + PRIMARY KEY (`contest_id`) USING BTREE, + UNIQUE KEY `uk_contest_name` (`contest_name`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='赛事表'; + +CREATE TABLE `t_contest_attachment` ( + `id` varchar(63) NOT NULL, + `contest_id` varchar(63) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '赛事id', + `file_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '文件名', + `file_url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '文件路径', + `format` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '文件类型(png,mp4)', + `file_type` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '素材类型(image,video)', + `size` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '0' COMMENT '文件大小', + `creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '创建人', + `modifier` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '修改人', + `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `modify_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `valid_state` varchar(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '1' COMMENT '有效状态(1-有效,2-失效)', + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='赛事附件'; + +CREATE TABLE `t_contest_work` ( + `id` varchar(63) NOT NULL COMMENT '主键id', + `tenant_key` varchar(127) NOT NULL COMMENT '作品所属租户键', + `contest_id` varchar(63) NOT NULL COMMENT '赛事id', + `entry_id` varchar(63) NOT NULL COMMENT '参赛报名实体id', + `work_no` varchar(63) DEFAULT NULL COMMENT '作品编号(展示用唯一编号)', + `title` varchar(255) NOT NULL COMMENT '作品标题', + `description` text DEFAULT NULL COMMENT '作品说明', + `files` json DEFAULT NULL COMMENT '作品文件列表(简易场景)', + `version` int NOT NULL DEFAULT 1 COMMENT '作品版本号(递增)', + `is_latest` tinyint(1) NOT NULL DEFAULT 1 COMMENT '是否最新版本:1是/0否', + `status` varchar(31) NOT NULL DEFAULT 'submitted' COMMENT '作品状态:submitted/locked/reviewing/rejected/accepted', + `submit_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '提交时间', + `submitter_user_id` varchar(63) DEFAULT NULL COMMENT '提交人用户id', + `submitter_account_no` varchar(127) DEFAULT NULL COMMENT '提交人账号(手机号/学号)', + `submit_source` varchar(31) NOT NULL DEFAULT 'teacher' COMMENT '提交来源:teacher/student/team_leader', + `preview_url` varchar(255) DEFAULT NULL COMMENT '作品预览URL(3D/视频)', + `ai_model_meta` json DEFAULT NULL COMMENT 'AI建模元数据(模型类型、版本、参数)', + `creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '创建人', + `modifier` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '修改人', + `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `modify_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `valid_state` varchar(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '1' COMMENT '有效状态(1-有效,2-失效)', + + PRIMARY KEY (`id`), + UNIQUE KEY `uk_work_no` (`work_no`), + KEY `idx_work_contest_latest` (`tenant_key`,`contest_id`,`is_latest`), + KEY `idx_work_entry` (`entry_id`), + KEY `idx_submit_filter` (`tenant_key`,`contest_id`,`submit_time`,`review_status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='参赛作品'; + +CREATE TABLE `t_contest_work_attachment` ( + `id` varchar(63) NOT NULL COMMENT '主键id', + `tenant_key` varchar(127) NOT NULL COMMENT '所属租户键', + `contest_id` varchar(63) NOT NULL COMMENT '赛事id', + `work_id` varchar(63) NOT NULL COMMENT '作品id', + `file_name` varchar(255) NOT NULL COMMENT '文件名', + `file_url` varchar(255) NOT NULL COMMENT '文件路径', + `format` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '文件类型(png,mp4)', + `file_type` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '素材类型(image,video)', + `size` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '0' COMMENT '文件大小', + `creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '创建人', + `modifier` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '修改人', + `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `modify_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + + PRIMARY KEY (`id`), + KEY `idx_work_file` (`tenant_key`,`contest_id`,`work_id`), +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='作品附件文件表'; + +CREATE TABLE `t_contest_work_score` ( + `id` varchar(63) NOT NULL COMMENT '主键id', + `tenant_key` varchar(127) NOT NULL COMMENT '所属租户键', + `contest_id` varchar(63) NOT NULL COMMENT '赛事id', + `work_id` varchar(63) NOT NULL COMMENT '作品id', + `assignment_id` varchar(63) NOT NULL COMMENT '分配记录id(关联t_contest_work_judge_assignment)', + `judge_id` varchar(63) NOT NULL COMMENT '评委账号id', + `judge_name` varchar(127) NOT NULL COMMENT '评委姓名', + `dimension_scores` json NOT NULL COMMENT '各维度评分JSON,格式:{"dimension1": 85, "dimension2": 90, ...}', + `total_score` decimal(10,2) NOT NULL COMMENT '总分(根据评审规则计算)', + `comments` text DEFAULT NULL COMMENT '评语', + `score_time` datetime NOT NULL COMMENT '评分时间', + `creator` varchar(63) NOT NULL DEFAULT '' COMMENT '创建人', + `modifier` varchar(63) NOT NULL DEFAULT '' COMMENT '修改人', + `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `modify_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `valid_state` varchar(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '1' COMMENT '有效状态(1-有效,2-失效)', + PRIMARY KEY (`id`), + KEY `idx_contest_work_final` (`contest_id`, `work_id`, `judge_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='作品评分表'; + +CREATE TABLE `t_contest_work_score` ( + `id` varchar(63) NOT NULL COMMENT '主键id', + `tenant_key` varchar(127) NOT NULL COMMENT '所属租户键', + `contest_id` varchar(63) NOT NULL COMMENT '赛事id', + `work_id` varchar(63) NOT NULL COMMENT '作品id', + `assignment_id` varchar(63) NOT NULL COMMENT '分配记录id(关联t_contest_work_judge_assignment)', + `judge_id` varchar(63) NOT NULL COMMENT '评委账号id', + `judge_name` varchar(127) NOT NULL COMMENT '评委姓名', + `dimension_scores` json NOT NULL COMMENT '各维度评分JSON,格式:{"dimension1": 85, "dimension2": 90, ...}', + `total_score` decimal(10,2) NOT NULL COMMENT '总分(根据评审规则计算)', + `comments` text DEFAULT NULL COMMENT '评语', + `score_time` datetime NOT NULL COMMENT '评分时间', + `creator` varchar(63) NOT NULL DEFAULT '' COMMENT '创建人', + `modifier` varchar(63) NOT NULL DEFAULT '' COMMENT '修改人', + `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `modify_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `valid_state` varchar(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '1' COMMENT '有效状态(1-有效,2-失效)', + PRIMARY KEY (`id`), + KEY `idx_contest_work_final` (`contest_id`, `work_id`, `judge_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='作品评分表'; + +CREATE TABLE `t_contest_registration` ( + `id` varchar(63) NOT NULL COMMENT '主键id', + `contest_id` varchar(63) NOT NULL COMMENT '赛事id', + `tenant_key` varchar(64) NOT NULL COMMENT '所属租户键(学校/机构)', + `registration_type` varchar(20) DEFAULT NULL COMMENT '报名类型:individual(个人)/team(团队)', + `team_id` varchar(64) DEFAULT NULL COMMENT '团队id', + `team_name` varchar(255) DEFAULT NULL COMMENT '团队名称快照(团队赛)', + `account_id` varchar(64) NOT NULL COMMENT '账号id', + `account_no` varchar(64) NOT NULL COMMENT '报名账号(记录报名快照)', + `account_name` varchar(100) NOT NULL COMMENT '报名账号名称(记录报名快照)', + `role` varchar(63) DEFAULT NULL COMMENT '报名角色快照:leader(队长)/member(队员)/mentor(指导教师)', + `registration_state` varchar(31) NOT NULL COMMENT '报名状态:pending(待审核)、passed(已通过)、rejected(已拒绝)、withdrawn(已撤回)', + `registrant` varchar(63) DEFAULT NULL COMMENT '实际报名人(老师报名填老师账号)', + `registration_time` datetime NOT NULL COMMENT '报名时间', + `reason` varchar(1023) DEFAULT NULL COMMENT '审核理由', + `operator` varchar(64) DEFAULT NULL COMMENT '审核人', + `operation_date` datetime DEFAULT NULL COMMENT '审核时间', + `creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '创建人', + `modifier` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '修改人', + `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `modify_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='赛事报名人员记录表'; + +CREATE TABLE `t_contest_team` ( + `id` varchar(63) NOT NULL COMMENT '主键id', + `tenant_key` varchar(127) NOT NULL COMMENT '团队所属租户键', + `contest_id` varchar(63) NOT NULL COMMENT '赛事id', + `team_name` varchar(127) NOT NULL COMMENT '团队名称(租户内唯一)', + `leader_account_id` varchar(63) NOT NULL COMMENT '团队负责人用户id', + `max_members` int DEFAULT NULL COMMENT '团队最大成员数', + `creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '创建人', + `modifier` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '修改人', + `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `modify_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `valid_state` varchar(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '1' COMMENT '有效状态(1-有效,2-失效)', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_team_name` (`tenant_key`,`contest_id`,`name`), + KEY `idx_contest` (`contest_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='赛事团队'; + +CREATE TABLE `t_contest_team_member` ( + `id` varchar(63) NOT NULL COMMENT '主键id', + `tenant_key` varchar(127) NOT NULL COMMENT '成员所属租户键', + `team_id` varchar(63) NOT NULL COMMENT '团队id', + `account_id` varchar(63) NOT NULL COMMENT '成员用户id', + `role` varchar(31) NOT NULL DEFAULT 'member' COMMENT '成员角色:member/leader/mentor', + `creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '创建人', + `modifier` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '修改人', + `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `modify_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_member_once` (`tenant_key`,`team_id`,`account_id`), + KEY `idx_team` (`team_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='团队成员'; diff --git a/backend/sql/competition_fixes.md b/backend/sql/competition_fixes.md new file mode 100644 index 0000000..3ba843a --- /dev/null +++ b/backend/sql/competition_fixes.md @@ -0,0 +1,180 @@ +# 赛事管理 SQL 文件修复清单 + +## ⚠️ 需要修复的问题 + +### 1. 删除重复的表定义 + +**问题位置:** 第125-144行 +**问题描述:** `t_contest_work_score` 表被定义了两次(第104-123行和第125-144行) +**修复方案:** 删除第125-144行的重复定义 + +```sql +-- 删除以下重复定义(第125-144行) +CREATE TABLE `t_contest_work_score` ( + `id` varchar(63) NOT NULL COMMENT '主键id', + ... +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='作品评分表'; +``` + +### 2. 修复表定义语法错误 + +**问题位置:** 第102行 +**问题描述:** `t_contest_work_attachment` 表定义末尾有多余的逗号 +**修复方案:** 删除第101行末尾的逗号 + +```sql +-- 第101行,删除末尾逗号 +KEY `idx_work_file` (`tenant_key`,`contest_id`,`work_id`), -- ❌ 错误:末尾有逗号 +KEY `idx_work_file` (`tenant_key`,`contest_id`,`work_id`) -- ✅ 正确:删除逗号 +``` + +### 3. 修复索引字段名错误 + +**问题位置:** 第183行 +**问题描述:** `t_contest_team` 表的唯一索引引用了不存在的字段 `name`,实际字段名是 `team_name` +**修复方案:** 将索引字段名改为 `team_name` + +```sql +-- 第183行,修改前: +UNIQUE KEY `uk_team_name` (`tenant_key`,`contest_id`,`name`), -- ❌ 错误:字段名错误 + +-- 修改后: +UNIQUE KEY `uk_team_name` (`tenant_key`,`contest_id`,`team_name`), -- ✅ 正确 +``` + +### 4. 修复索引字段不存在错误 + +**问题位置:** 第82行 +**问题描述:** `t_contest_work` 表的索引 `idx_submit_filter` 引用了不存在的字段 `review_status` +**修复方案:** 删除该索引或修改为存在的字段 + +```sql +-- 第82行,修改前: +KEY `idx_submit_filter` (`tenant_key`,`contest_id`,`submit_time`,`review_status`) -- ❌ 错误:review_status 字段不存在 + +-- 修改方案1:删除该索引(如果不需要) +-- 直接删除这一行 + +-- 修改方案2:修改为存在的字段(如果需要该索引) +KEY `idx_submit_filter` (`tenant_key`,`contest_id`,`submit_time`,`status`) -- ✅ 使用 status 字段 +``` + +## 📝 修复后的完整 SQL(关键部分) + +### t_contest_work_attachment 表(修复后) + +```sql +CREATE TABLE `t_contest_work_attachment` ( + `id` varchar(63) NOT NULL COMMENT '主键id', + `tenant_key` varchar(127) NOT NULL COMMENT '所属租户键', + `contest_id` varchar(63) NOT NULL COMMENT '赛事id', + `work_id` varchar(63) NOT NULL COMMENT '作品id', + `file_name` varchar(255) NOT NULL COMMENT '文件名', + `file_url` varchar(255) NOT NULL COMMENT '文件路径', + `format` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '文件类型(png,mp4)', + `file_type` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '素材类型(image,video)', + `size` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '0' COMMENT '文件大小', + `creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '创建人', + `modifier` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '修改人', + `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `modify_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + + PRIMARY KEY (`id`), + KEY `idx_work_file` (`tenant_key`,`contest_id`,`work_id`) -- ✅ 已删除末尾逗号 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='作品附件文件表'; +``` + +### t_contest_work 表(修复后) + +```sql +CREATE TABLE `t_contest_work` ( + `id` varchar(63) NOT NULL COMMENT '主键id', + `tenant_key` varchar(127) NOT NULL COMMENT '作品所属租户键', + `contest_id` varchar(63) NOT NULL COMMENT '赛事id', + `entry_id` varchar(63) NOT NULL COMMENT '参赛报名实体id', + `work_no` varchar(63) DEFAULT NULL COMMENT '作品编号(展示用唯一编号)', + `title` varchar(255) NOT NULL COMMENT '作品标题', + `description` text DEFAULT NULL COMMENT '作品说明', + `files` json DEFAULT NULL COMMENT '作品文件列表(简易场景)', + `version` int NOT NULL DEFAULT 1 COMMENT '作品版本号(递增)', + `is_latest` tinyint(1) NOT NULL DEFAULT 1 COMMENT '是否最新版本:1是/0否', + `status` varchar(31) NOT NULL DEFAULT 'submitted' COMMENT '作品状态:submitted/locked/reviewing/rejected/accepted', + `submit_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '提交时间', + `submitter_user_id` varchar(63) DEFAULT NULL COMMENT '提交人用户id', + `submitter_account_no` varchar(127) DEFAULT NULL COMMENT '提交人账号(手机号/学号)', + `submit_source` varchar(31) NOT NULL DEFAULT 'teacher' COMMENT '提交来源:teacher/student/team_leader', + `preview_url` varchar(255) DEFAULT NULL COMMENT '作品预览URL(3D/视频)', + `ai_model_meta` json DEFAULT NULL COMMENT 'AI建模元数据(模型类型、版本、参数)', + `creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '创建人', + `modifier` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '修改人', + `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `modify_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `valid_state` varchar(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '1' COMMENT '有效状态(1-有效,2-失效)', + + PRIMARY KEY (`id`), + UNIQUE KEY `uk_work_no` (`work_no`), + KEY `idx_work_contest_latest` (`tenant_key`,`contest_id`,`is_latest`), + KEY `idx_work_entry` (`entry_id`), + KEY `idx_submit_filter` (`tenant_key`,`contest_id`,`submit_time`,`status`) -- ✅ 已修复:使用 status 字段 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='参赛作品'; +``` + +### t_contest_team 表(修复后) + +```sql +CREATE TABLE `t_contest_team` ( + `id` varchar(63) NOT NULL COMMENT '主键id', + `tenant_key` varchar(127) NOT NULL COMMENT '团队所属租户键', + `contest_id` varchar(63) NOT NULL COMMENT '赛事id', + `team_name` varchar(127) NOT NULL COMMENT '团队名称(租户内唯一)', + `leader_account_id` varchar(63) NOT NULL COMMENT '团队负责人用户id', + `max_members` int DEFAULT NULL COMMENT '团队最大成员数', + `creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '创建人', + `modifier` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '修改人', + `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `modify_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `valid_state` varchar(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '1' COMMENT '有效状态(1-有效,2-失效)', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_team_name` (`tenant_key`,`contest_id`,`team_name`), -- ✅ 已修复:使用 team_name 字段 + KEY `idx_contest` (`contest_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='赛事团队'; +``` + +## 🔍 验证步骤 + +修复完成后,请执行以下验证: + +1. **语法检查** + ```bash + # 使用 MySQL 客户端检查语法 + mysql -u root -p < competition.sql + ``` + +2. **表结构验证** + ```sql + -- 检查表是否存在 + SHOW TABLES LIKE 't_contest%'; + + -- 检查表结构 + DESCRIBE t_contest_work; + DESCRIBE t_contest_work_attachment; + DESCRIBE t_contest_team; + DESCRIBE t_contest_work_score; + ``` + +3. **索引验证** + ```sql + -- 检查索引是否正确 + SHOW INDEX FROM t_contest_work; + SHOW INDEX FROM t_contest_team; + ``` + +## 📌 建议 + +在修复 SQL 文件后,建议: + +1. **创建数据库迁移文件**:使用 Prisma Migrate 或手动创建迁移 +2. **更新 Prisma Schema**:将修复后的表结构同步到 `schema.prisma` +3. **测试数据插入**:插入测试数据验证表结构正确性 +4. **备份数据库**:在执行迁移前备份现有数据 + diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts new file mode 100644 index 0000000..c9cf93f --- /dev/null +++ b/backend/src/app.module.ts @@ -0,0 +1,65 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { APP_GUARD, APP_INTERCEPTOR, APP_FILTER } from '@nestjs/core'; +import { PrismaModule } from './prisma/prisma.module'; +import { AuthModule } from './auth/auth.module'; +import { UsersModule } from './users/users.module'; +import { RolesModule } from './roles/roles.module'; +import { PermissionsModule } from './permissions/permissions.module'; +import { MenusModule } from './menus/menus.module'; +import { DictModule } from './dict/dict.module'; +import { ConfigModule as SystemConfigModule } from './config/config.module'; +import { LogsModule } from './logs/logs.module'; +import { TenantsModule } from './tenants/tenants.module'; +import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; +import { RolesGuard } from './auth/guards/roles.guard'; +import { TransformInterceptor } from './common/interceptors/transform.interceptor'; +import { LoggingInterceptor } from './common/interceptors/logging.interceptor'; +import { HttpExceptionFilter } from './common/filters/http-exception.filter'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + // envFilePath 指定配置文件路径 + // 如果需要后备文件,可以取消下面的注释,但要注意 .env 会覆盖 .development.env 的值 + envFilePath: [ + '.env', + `.env.${process.env.NODE_ENV || 'development'}`, // 优先加载 + ], + }), + PrismaModule, + AuthModule, + UsersModule, + RolesModule, + PermissionsModule, + MenusModule, + DictModule, + SystemConfigModule, + LogsModule, + TenantsModule, + ], + providers: [ + { + provide: APP_GUARD, + useClass: JwtAuthGuard, + }, + { + provide: APP_GUARD, + useClass: RolesGuard, + }, + { + provide: APP_INTERCEPTOR, + useClass: LoggingInterceptor, // 日志拦截器,先执行 + }, + { + provide: APP_INTERCEPTOR, + useClass: TransformInterceptor, // 响应转换拦截器 + }, + { + provide: APP_FILTER, + useClass: HttpExceptionFilter, + }, + ], +}) +export class AppModule {} diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts new file mode 100644 index 0000000..384e003 --- /dev/null +++ b/backend/src/auth/auth.controller.ts @@ -0,0 +1,41 @@ +import { + Controller, + Post, + Get, + Body, + UseGuards, + Request, +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { AuthService } from './auth.service'; +import { LoginDto } from './dto/login.dto'; +import { Public } from './decorators/public.decorator'; + +@Controller('auth') +export class AuthController { + constructor(private authService: AuthService) {} + + @Public() + @UseGuards(AuthGuard('local')) + @Post('login') + async login(@Body() loginDto: LoginDto, @Request() req) { + // 从请求头或请求体获取租户ID + const tenantId = req.headers['x-tenant-id'] + ? parseInt(req.headers['x-tenant-id'], 10) + : req.user?.tenantId; + + return this.authService.login(req.user, tenantId); + } + + @UseGuards(AuthGuard('jwt')) + @Get('user-info') + async getUserInfo(@Request() req) { + return this.authService.getUserInfo(req.user.userId); + } + + @UseGuards(AuthGuard('jwt')) + @Post('logout') + async logout() { + return { message: '登出成功' }; + } +} diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts new file mode 100644 index 0000000..cb6d857 --- /dev/null +++ b/backend/src/auth/auth.module.ts @@ -0,0 +1,30 @@ +import { Module } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; +import { PassportModule } from '@nestjs/passport'; +import { ConfigService } from '@nestjs/config'; +import { AuthService } from './auth.service'; +import { AuthController } from './auth.controller'; +import { JwtStrategy } from './strategies/jwt.strategy'; +import { LocalStrategy } from './strategies/local.strategy'; +import { RolesGuard } from './guards/roles.guard'; +import { UsersModule } from '../users/users.module'; +import { PrismaModule } from '../prisma/prisma.module'; + +@Module({ + imports: [ + UsersModule, + PrismaModule, + PassportModule, + JwtModule.registerAsync({ + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + secret: config.get('JWT_SECRET') || 'your-secret-key', + signOptions: { expiresIn: '7d' }, + }), + }), + ], + controllers: [AuthController], + providers: [AuthService, JwtStrategy, LocalStrategy, RolesGuard], + exports: [AuthService, RolesGuard], +}) +export class AuthModule {} diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts new file mode 100644 index 0000000..d1a120f --- /dev/null +++ b/backend/src/auth/auth.service.ts @@ -0,0 +1,111 @@ +import { Injectable, UnauthorizedException, BadRequestException } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { UsersService } from '../users/users.service'; +import { PrismaService } from '../prisma/prisma.service'; +import * as bcrypt from 'bcrypt'; + +@Injectable() +export class AuthService { + constructor( + private usersService: UsersService, + private jwtService: JwtService, + private prisma: PrismaService, + ) {} + + async validateUser(username: string, password: string, tenantId?: number): Promise { + const user = await this.usersService.findByUsername(username, tenantId); + if (user && (await bcrypt.compare(password, user.password))) { + // 验证租户是否匹配 + if (tenantId && user.tenantId !== tenantId) { + throw new UnauthorizedException('用户不属于该租户'); + } + const { password, ...result } = user; + return result; + } + return null; + } + + async login(user: any, tenantId?: number) { + // 确保租户ID存在 + const finalTenantId = tenantId || user.tenantId; + if (!finalTenantId) { + throw new BadRequestException('无法确定租户信息'); + } + + // 验证租户是否有效 + const tenant = await this.prisma.tenant.findUnique({ + where: { id: finalTenantId }, + }); + + if (!tenant) { + throw new BadRequestException('租户不存在'); + } + + if (tenant.validState !== 1) { + throw new BadRequestException('租户已失效'); + } + + // 验证用户是否属于该租户 + if (user.tenantId !== finalTenantId) { + throw new UnauthorizedException('用户不属于该租户'); + } + + const payload = { + username: user.username, + sub: user.id, + tenantId: finalTenantId, + }; + + return { + token: this.jwtService.sign(payload), + user: { + id: user.id, + username: user.username, + nickname: user.nickname, + email: user.email, + avatar: user.avatar, + tenantId: finalTenantId, + tenantCode: tenant.code, + roles: user.roles?.map((ur: any) => ur.role.code) || [], + permissions: await this.getUserPermissions(user.id), + }, + }; + } + + async getUserInfo(userId: number) { + const user = await this.usersService.findOne(userId); + if (!user) { + throw new UnauthorizedException('用户不存在'); + } + + const tenant = await this.prisma.tenant.findUnique({ + where: { id: user.tenantId }, + }); + + return { + id: user.id, + username: user.username, + nickname: user.nickname, + email: user.email, + avatar: user.avatar, + tenantId: user.tenantId, + tenantCode: tenant?.code, + roles: user.roles?.map((ur: any) => ur.role.code) || [], + permissions: await this.getUserPermissions(userId), + }; + } + + async getUserPermissions(userId: number): Promise { + const user = await this.usersService.findOne(userId); + if (!user) return []; + + const permissions = new Set(); + user.roles?.forEach((ur: any) => { + ur.role.permissions?.forEach((rp: any) => { + permissions.add(rp.permission.code); + }); + }); + + return Array.from(permissions); + } +} diff --git a/backend/src/auth/decorators/public.decorator.ts b/backend/src/auth/decorators/public.decorator.ts new file mode 100644 index 0000000..b3845e1 --- /dev/null +++ b/backend/src/auth/decorators/public.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; + +export const IS_PUBLIC_KEY = 'isPublic'; +export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); diff --git a/backend/src/auth/decorators/require-permission.decorator.ts b/backend/src/auth/decorators/require-permission.decorator.ts new file mode 100644 index 0000000..feae0a2 --- /dev/null +++ b/backend/src/auth/decorators/require-permission.decorator.ts @@ -0,0 +1,5 @@ +import { SetMetadata } from '@nestjs/common'; + +export const PERMISSION_KEY = 'permission'; +export const RequirePermission = (permission: string) => + SetMetadata(PERMISSION_KEY, permission); diff --git a/backend/src/auth/decorators/roles.decorator.ts b/backend/src/auth/decorators/roles.decorator.ts new file mode 100644 index 0000000..e038e16 --- /dev/null +++ b/backend/src/auth/decorators/roles.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; + +export const ROLES_KEY = 'roles'; +export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles); diff --git a/backend/src/auth/dto/login.dto.ts b/backend/src/auth/dto/login.dto.ts new file mode 100644 index 0000000..1c75b9b --- /dev/null +++ b/backend/src/auth/dto/login.dto.ts @@ -0,0 +1,15 @@ +import { IsString, IsNotEmpty, IsOptional } from 'class-validator'; + +export class LoginDto { + @IsString() + @IsNotEmpty() + username: string; + + @IsString() + @IsNotEmpty() + password: string; + + @IsString() + @IsOptional() + tenantCode?: string; // 租户编码(可选,如果未提供则从请求头获取) +} diff --git a/backend/src/auth/guards/jwt-auth.guard.ts b/backend/src/auth/guards/jwt-auth.guard.ts new file mode 100644 index 0000000..f75ddaa --- /dev/null +++ b/backend/src/auth/guards/jwt-auth.guard.ts @@ -0,0 +1,24 @@ +import { Injectable, ExecutionContext } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { Reflector } from '@nestjs/core'; +import { IS_PUBLIC_KEY } from '../decorators/public.decorator'; + +@Injectable() +export class JwtAuthGuard extends AuthGuard('jwt') { + constructor(private reflector: Reflector) { + super(); + } + + canActivate(context: ExecutionContext) { + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + if (isPublic) { + return true; + } + + return super.canActivate(context); + } +} diff --git a/backend/src/auth/guards/permissions.guard.ts b/backend/src/auth/guards/permissions.guard.ts new file mode 100644 index 0000000..a7be481 --- /dev/null +++ b/backend/src/auth/guards/permissions.guard.ts @@ -0,0 +1,40 @@ +import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { AuthService } from '../auth.service'; +import { PERMISSION_KEY } from '../decorators/require-permission.decorator'; + +@Injectable() +export class PermissionsGuard implements CanActivate { + constructor( + private reflector: Reflector, + private authService: AuthService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const requiredPermission = this.reflector.getAllAndOverride(PERMISSION_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + if (!requiredPermission) { + return true; + } + + const request = context.switchToHttp().getRequest(); + const user = request.user; + + if (!user || !user.userId) { + throw new ForbiddenException('未授权访问'); + } + + // 获取用户的所有权限 + const userPermissions = await this.authService.getUserPermissions(user.userId); + + if (!userPermissions.includes(requiredPermission)) { + throw new ForbiddenException(`缺少权限: ${requiredPermission}`); + } + + return true; + } +} + diff --git a/backend/src/auth/guards/roles.guard.ts b/backend/src/auth/guards/roles.guard.ts new file mode 100644 index 0000000..0508db5 --- /dev/null +++ b/backend/src/auth/guards/roles.guard.ts @@ -0,0 +1,56 @@ +import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { PrismaService } from '../../prisma/prisma.service'; + +@Injectable() +export class RolesGuard implements CanActivate { + constructor( + private reflector: Reflector, + private prisma: PrismaService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const requiredRoles = this.reflector.getAllAndOverride('roles', [ + context.getHandler(), + context.getClass(), + ]); + + if (!requiredRoles) { + return true; + } + + const request = context.switchToHttp().getRequest(); + const user = request.user; + + if (!user || !user.userId) { + throw new ForbiddenException('未授权访问'); + } + + // 从数据库获取用户的角色 + const userWithRoles = await this.prisma.user.findUnique({ + where: { id: user.userId }, + include: { + roles: { + include: { + role: true, + }, + }, + }, + }); + + if (!userWithRoles) { + throw new ForbiddenException('用户不存在'); + } + + const userRoles = userWithRoles.roles?.map((ur: any) => ur.role.code) || []; + + // 检查用户是否有任一所需角色 + const hasRequiredRole = requiredRoles.some((role) => userRoles.includes(role)); + + if (!hasRequiredRole) { + throw new ForbiddenException(`需要以下角色之一: ${requiredRoles.join(', ')}`); + } + + return true; + } +} diff --git a/backend/src/auth/strategies/jwt.strategy.ts b/backend/src/auth/strategies/jwt.strategy.ts new file mode 100644 index 0000000..6dc8eb9 --- /dev/null +++ b/backend/src/auth/strategies/jwt.strategy.ts @@ -0,0 +1,23 @@ +import { ExtractJwt, Strategy } from 'passport-jwt'; +import { PassportStrategy } from '@nestjs/passport'; +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy) { + constructor(private configService: ConfigService) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + secretOrKey: configService.get('JWT_SECRET') || 'your-secret-key', + }); + } + + async validate(payload: any) { + return { + userId: payload.sub, + username: payload.username, + tenantId: payload.tenantId, + }; + } +} diff --git a/backend/src/auth/strategies/local.strategy.ts b/backend/src/auth/strategies/local.strategy.ts new file mode 100644 index 0000000..aa880b4 --- /dev/null +++ b/backend/src/auth/strategies/local.strategy.ts @@ -0,0 +1,45 @@ +import { Strategy } from 'passport-local'; +import { PassportStrategy } from '@nestjs/passport'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { AuthService } from '../auth.service'; +import { PrismaService } from '../../prisma/prisma.service'; + +@Injectable() +export class LocalStrategy extends PassportStrategy(Strategy) { + constructor( + private authService: AuthService, + private prisma: PrismaService, + ) { + super({ + usernameField: 'username', + passwordField: 'password', + passReqToCallback: true, // 允许访问request对象 + }); + } + + async validate(req: any, username: string, password: string): Promise { + // 从请求体或请求头获取租户信息 + const tenantCode = req.body?.tenantCode || req.headers['x-tenant-code']; + const tenantId = req.headers['x-tenant-id']; + + let finalTenantId: number | undefined; + + if (tenantId) { + finalTenantId = parseInt(tenantId, 10); + } else if (tenantCode) { + const tenant = await this.prisma.tenant.findUnique({ + where: { code: tenantCode }, + }); + if (!tenant) { + throw new UnauthorizedException('租户不存在'); + } + finalTenantId = tenant.id; + } + + const user = await this.authService.validateUser(username, password, finalTenantId); + if (!user) { + throw new UnauthorizedException('用户名或密码错误'); + } + return user; + } +} diff --git a/backend/src/common/filters/http-exception.filter.ts b/backend/src/common/filters/http-exception.filter.ts new file mode 100644 index 0000000..e554838 --- /dev/null +++ b/backend/src/common/filters/http-exception.filter.ts @@ -0,0 +1,89 @@ +import { + ExceptionFilter, + Catch, + ArgumentsHost, + HttpException, + HttpStatus, +} from '@nestjs/common'; +import { Request, Response } from 'express'; +import { LogsService } from '../../logs/logs.service'; + +@Catch() +export class HttpExceptionFilter implements ExceptionFilter { + constructor(private logsService: LogsService) {} + + catch(exception: unknown, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + + const status = + exception instanceof HttpException + ? exception.getStatus() + : HttpStatus.INTERNAL_SERVER_ERROR; + + const message = + exception instanceof HttpException + ? exception.getResponse() + : 'Internal server error'; + + const errorMessage = + typeof message === 'string' + ? message + : (message as any).message || 'Error'; + + const errorResponse = { + code: status, + message: errorMessage, + data: null, + timestamp: new Date().toISOString(), + path: request.url, + }; + + // 记录错误日志(仅记录 500 及以上错误) + // 跳过日志接口本身,避免循环记录 + if (status >= 500 && !request.url.startsWith('/logs')) { + const user = (request as any).user; + const userId = user?.userId || null; + console.error( + '[HttpExceptionFilter]', + request.method, + request.url, + userId, + exception, + ); + + // const errorContent = { + // status, + // message: errorMessage, + // method: request.method, + // url: request.url, + // error: exception instanceof Error ? exception.stack : String(exception), + // }; + // 限制内容长度,避免过长(TEXT 类型最大 65KB,这里限制为 50KB) + // const content = this.truncateContent(JSON.stringify(errorContent), 50000); + + // this.logsService + // .create({ + // userId, + // action: `ERROR ${request.method} ${request.url}`, + // content, + // ip: request.ip || '', + // userAgent: request.headers['user-agent'] || '', + // }) + // .catch((error) => { + // console.error('Failed to log error:', error); + // }); + } + + response.status(status).json(errorResponse); + } + + // 截断内容,避免超过数据库字段限制 + private truncateContent(content: string, maxLength: number): string { + if (!content || content.length <= maxLength) { + return content; + } + return content.substring(0, maxLength - 50) + '\n...(内容过长,已截断)'; + } +} diff --git a/backend/src/common/interceptors/logging.interceptor.ts b/backend/src/common/interceptors/logging.interceptor.ts new file mode 100644 index 0000000..83e7dc3 --- /dev/null +++ b/backend/src/common/interceptors/logging.interceptor.ts @@ -0,0 +1,94 @@ +import { + Injectable, + NestInterceptor, + ExecutionContext, + CallHandler, +} from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { Request } from 'express'; +import { Reflector } from '@nestjs/core'; +import { LogsService } from '../../logs/logs.service'; +import { IS_PUBLIC_KEY } from '../../auth/decorators/public.decorator'; + +@Injectable() +export class LoggingInterceptor implements NestInterceptor { + constructor( + private logsService: LogsService, + private reflector: Reflector, + ) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const request = context.switchToHttp().getRequest(); + const { method, url, ip, headers } = request; + const userAgent = headers['user-agent'] || ''; + + // 检查是否为公共接口,公共接口不记录日志 + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + // 跳过日志接口本身,避免循环记录 + if (url.startsWith('/logs') || isPublic) { + return next.handle(); + } + + // 获取用户信息(如果已认证) + const user = (request as any).user; + const userId = user?.userId || null; + + // 构建操作内容 + const action = `${method} ${url}`; + const contentData = { + method, + url, + query: request.query, + body: this.sanitizeBody(request.body), + }; + // 限制内容长度,避免过长(TEXT 类型最大 65KB,这里限制为 50KB) + console.log('[LoggingInterceptor]', contentData); + const content = this.truncateContent(JSON.stringify(contentData), 50000); + + // 异步记录日志,不阻塞请求 + this.logsService + .create({ + userId, + action, + content, + ip: ip || request.ip || '', + userAgent, + }) + .catch((error) => { + // 日志记录失败不影响主流程,只打印错误 + console.error('Failed to log request:', error); + }); + + return next.handle(); + } + + // 清理敏感信息(如密码) + private sanitizeBody(body: any): any { + if (!body || typeof body !== 'object') { + return body; + } + + const sanitized = { ...body }; + const sensitiveFields = ['password', 'oldPassword', 'newPassword', 'token']; + + sensitiveFields.forEach((field) => { + if (sanitized[field]) { + sanitized[field] = '***'; + } + }); + + return sanitized; + } + + // 截断内容,避免超过数据库字段限制 + private truncateContent(content: string, maxLength: number): string { + if (!content || content.length <= maxLength) { + return content; + } + return content.substring(0, maxLength - 50) + '\n...(内容过长,已截断)'; + } +} diff --git a/backend/src/common/interceptors/transform.interceptor.ts b/backend/src/common/interceptors/transform.interceptor.ts new file mode 100644 index 0000000..60e4efb --- /dev/null +++ b/backend/src/common/interceptors/transform.interceptor.ts @@ -0,0 +1,32 @@ +import { + Injectable, + NestInterceptor, + ExecutionContext, + CallHandler, +} from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +export interface Response { + code: number; + message: string; + data: T; +} + +@Injectable() +export class TransformInterceptor + implements NestInterceptor> +{ + intercept( + context: ExecutionContext, + next: CallHandler, + ): Observable> { + return next.handle().pipe( + map((data) => ({ + code: 200, + message: 'success', + data, + })), + ); + } +} diff --git a/backend/src/config/config-verification.controller.ts b/backend/src/config/config-verification.controller.ts new file mode 100644 index 0000000..7674feb --- /dev/null +++ b/backend/src/config/config-verification.controller.ts @@ -0,0 +1,114 @@ +import { Controller, Get, UseGuards } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { Public } from '../auth/decorators/public.decorator'; +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * 配置验证控制器 + * 用于验证环境配置文件是否正确加载 + */ +@Controller('config-verification') +export class ConfigVerificationController { + constructor(private configService: ConfigService) {} + + /** + * 公开接口,用于验证配置加载 + */ + @Public() + @Get('env-info') + getEnvInfo() { + const nodeEnv = process.env.NODE_ENV || 'development'; + const expectedEnvFile = `.env.${nodeEnv}`; // 匹配实际文件名格式:.development.env + const envFilePath = path.join(process.cwd(), expectedEnvFile); + const fallbackEnvPath = path.join(process.cwd(), '.env'); + + // 检查文件是否存在 + const envFileExists = fs.existsSync(envFilePath); + const fallbackExists = fs.existsSync(fallbackEnvPath); + + // 获取一些关键配置(不暴露敏感信息) + const config = { + nodeEnv, + expectedEnvFile, + envFileExists, + fallbackExists, + envFilePath, + fallbackEnvPath, + loadedFrom: envFileExists + ? expectedEnvFile + : fallbackExists + ? '.env' + : '环境变量', + // 显示具体配置信息(包括实际值) + configs: { + PORT: this.configService.get('PORT') || process.env.PORT || 3001, + DATABASE_URL: + this.configService.get('DATABASE_URL') || + process.env.DATABASE_URL || + '未配置', + JWT_SECRET: + this.configService.get('JWT_SECRET') || + process.env.JWT_SECRET || + '未配置', + NODE_ENV: this.configService.get('NODE_ENV') || nodeEnv, + }, + publicConfigs: { + PORT: this.configService.get('PORT') || process.env.PORT || 3001, + NODE_ENV: this.configService.get('NODE_ENV') || nodeEnv, + }, + }; + + return { + code: 200, + message: '配置信息', + data: config, + }; + } + + /** + * 需要认证的接口,显示更多配置详情(仍隐藏敏感信息) + */ + @Get('detailed') + getDetailedConfig() { + const nodeEnv = process.env.NODE_ENV || 'development'; + const expectedEnvFile = `.env.${nodeEnv}`; + const envFilePath = path.join(process.cwd(), expectedEnvFile); + + // 读取文件内容(用于验证,但不返回敏感信息) + let fileContent = ''; + try { + if (fs.existsSync(envFilePath)) { + fileContent = fs.readFileSync(envFilePath, 'utf-8'); + } + } catch (error) { + // 忽略读取错误 + } + + // 统计配置项数量 + const configKeys = fileContent + .split('\n') + .filter((line) => line.trim() && !line.trim().startsWith('#')) + .map((line) => line.split('=')[0]?.trim()) + .filter(Boolean); + + return { + code: 200, + message: '详细配置信息', + data: { + nodeEnv, + expectedEnvFile, + fileExists: fs.existsSync(envFilePath), + configKeysCount: configKeys.length, + configKeys: configKeys, // 只显示键名,不显示值 + // 验证关键配置是否加载 + verification: { + DATABASE_URL: !!this.configService.get('DATABASE_URL'), + JWT_SECRET: !!this.configService.get('JWT_SECRET'), + PORT: !!this.configService.get('PORT'), + }, + }, + }; + } +} diff --git a/backend/src/config/config.controller.ts b/backend/src/config/config.controller.ts new file mode 100644 index 0000000..87e6cd0 --- /dev/null +++ b/backend/src/config/config.controller.ts @@ -0,0 +1,73 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + Query, + UseGuards, + Request, +} from '@nestjs/common'; +import { ConfigService } from './config.service'; +import { CreateConfigDto } from './dto/create-config.dto'; +import { UpdateConfigDto } from './dto/update-config.dto'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; + +@Controller('config') +@UseGuards(JwtAuthGuard) +export class ConfigController { + constructor(private readonly configService: ConfigService) {} + + @Post() + create(@Body() createConfigDto: CreateConfigDto, @Request() req) { + const tenantId = req.tenantId || req.user?.tenantId; + if (!tenantId) { + throw new Error('无法确定租户信息'); + } + return this.configService.create(createConfigDto, tenantId); + } + + @Get() + findAll( + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + @Request() req?: any, + ) { + const tenantId = req?.tenantId || req?.user?.tenantId; + return this.configService.findAll( + page ? parseInt(page) : 1, + pageSize ? parseInt(pageSize) : 10, + tenantId, + ); + } + + @Get('key/:key') + findByKey(@Param('key') key: string, @Request() req) { + const tenantId = req.tenantId || req.user?.tenantId; + return this.configService.findByKey(key, tenantId); + } + + @Get(':id') + findOne(@Param('id') id: string, @Request() req) { + const tenantId = req.tenantId || req.user?.tenantId; + return this.configService.findOne(+id, tenantId); + } + + @Patch(':id') + update( + @Param('id') id: string, + @Body() updateConfigDto: UpdateConfigDto, + @Request() req, + ) { + const tenantId = req.tenantId || req.user?.tenantId; + return this.configService.update(+id, updateConfigDto, tenantId); + } + + @Delete(':id') + remove(@Param('id') id: string, @Request() req) { + const tenantId = req.tenantId || req.user?.tenantId; + return this.configService.remove(+id, tenantId); + } +} diff --git a/backend/src/config/config.module.ts b/backend/src/config/config.module.ts new file mode 100644 index 0000000..9fb71a7 --- /dev/null +++ b/backend/src/config/config.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { ConfigService as SystemConfigService } from './config.service'; +import { ConfigController } from './config.controller'; +import { ConfigVerificationController } from './config-verification.controller'; + +@Module({ + controllers: [ConfigController, ConfigVerificationController], + providers: [SystemConfigService], +}) +export class ConfigModule {} diff --git a/backend/src/config/config.service.ts b/backend/src/config/config.service.ts new file mode 100644 index 0000000..df54b69 --- /dev/null +++ b/backend/src/config/config.service.ts @@ -0,0 +1,88 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { CreateConfigDto } from './dto/create-config.dto'; +import { UpdateConfigDto } from './dto/update-config.dto'; + +@Injectable() +export class ConfigService { + constructor(private prisma: PrismaService) {} + + async create(createConfigDto: CreateConfigDto, tenantId: number) { + return this.prisma.config.create({ + data: { + ...createConfigDto, + tenantId, + }, + }); + } + + async findAll(page: number = 1, pageSize: number = 10, tenantId?: number) { + const skip = (page - 1) * pageSize; + const where = tenantId ? { tenantId } : {}; + + const [list, total] = await Promise.all([ + this.prisma.config.findMany({ + where, + skip, + take: pageSize, + }), + this.prisma.config.count({ where }), + ]); + + return { + list, + total, + page, + pageSize, + }; + } + + async findOne(id: number, tenantId?: number) { + const where: any = { id }; + if (tenantId) { + where.tenantId = tenantId; + } + + const config = await this.prisma.config.findFirst({ + where, + }); + + if (!config) { + throw new NotFoundException('配置不存在'); + } + + return config; + } + + async findByKey(key: string, tenantId?: number) { + if (!tenantId) { + throw new NotFoundException('无法确定租户信息'); + } + + return this.prisma.config.findFirst({ + where: { + key, + tenantId, + }, + }); + } + + async update(id: number, updateConfigDto: UpdateConfigDto, tenantId?: number) { + // 验证配置是否存在且属于该租户 + await this.findOne(id, tenantId); + + return this.prisma.config.update({ + where: { id }, + data: updateConfigDto, + }); + } + + async remove(id: number, tenantId?: number) { + // 验证配置是否存在且属于该租户 + await this.findOne(id, tenantId); + + return this.prisma.config.delete({ + where: { id }, + }); + } +} diff --git a/backend/src/config/dto/create-config.dto.ts b/backend/src/config/dto/create-config.dto.ts new file mode 100644 index 0000000..9999c84 --- /dev/null +++ b/backend/src/config/dto/create-config.dto.ts @@ -0,0 +1,13 @@ +import { IsString, IsOptional } from 'class-validator'; + +export class CreateConfigDto { + @IsString() + key: string; + + @IsString() + value: string; + + @IsString() + @IsOptional() + description?: string; +} diff --git a/backend/src/config/dto/update-config.dto.ts b/backend/src/config/dto/update-config.dto.ts new file mode 100644 index 0000000..703b334 --- /dev/null +++ b/backend/src/config/dto/update-config.dto.ts @@ -0,0 +1,15 @@ +import { IsString, IsOptional } from 'class-validator'; + +export class UpdateConfigDto { + @IsString() + @IsOptional() + key?: string; + + @IsString() + @IsOptional() + value?: string; + + @IsString() + @IsOptional() + description?: string; +} diff --git a/backend/src/dict/dict.controller.ts b/backend/src/dict/dict.controller.ts new file mode 100644 index 0000000..a1e53df --- /dev/null +++ b/backend/src/dict/dict.controller.ts @@ -0,0 +1,73 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + Query, + UseGuards, + Request, +} from '@nestjs/common'; +import { DictService } from './dict.service'; +import { CreateDictDto } from './dto/create-dict.dto'; +import { UpdateDictDto } from './dto/update-dict.dto'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; + +@Controller('dict') +@UseGuards(JwtAuthGuard) +export class DictController { + constructor(private readonly dictService: DictService) {} + + @Post() + create(@Body() createDictDto: CreateDictDto, @Request() req) { + const tenantId = req.tenantId || req.user?.tenantId; + if (!tenantId) { + throw new Error('无法确定租户信息'); + } + return this.dictService.create(createDictDto, tenantId); + } + + @Get() + findAll( + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + @Request() req?: any, + ) { + const tenantId = req?.tenantId || req?.user?.tenantId; + return this.dictService.findAll( + page ? parseInt(page) : 1, + pageSize ? parseInt(pageSize) : 10, + tenantId, + ); + } + + @Get('code/:code') + findByCode(@Param('code') code: string, @Request() req) { + const tenantId = req.tenantId || req.user?.tenantId; + return this.dictService.findByCode(code, tenantId); + } + + @Get(':id') + findOne(@Param('id') id: string, @Request() req) { + const tenantId = req.tenantId || req.user?.tenantId; + return this.dictService.findOne(+id, tenantId); + } + + @Patch(':id') + update( + @Param('id') id: string, + @Body() updateDictDto: UpdateDictDto, + @Request() req, + ) { + const tenantId = req.tenantId || req.user?.tenantId; + return this.dictService.update(+id, updateDictDto, tenantId); + } + + @Delete(':id') + remove(@Param('id') id: string, @Request() req) { + const tenantId = req.tenantId || req.user?.tenantId; + return this.dictService.remove(+id, tenantId); + } +} diff --git a/backend/src/dict/dict.module.ts b/backend/src/dict/dict.module.ts new file mode 100644 index 0000000..e985212 --- /dev/null +++ b/backend/src/dict/dict.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { DictService } from './dict.service'; +import { DictController } from './dict.controller'; + +@Module({ + controllers: [DictController], + providers: [DictService], +}) +export class DictModule {} diff --git a/backend/src/dict/dict.service.ts b/backend/src/dict/dict.service.ts new file mode 100644 index 0000000..936efbd --- /dev/null +++ b/backend/src/dict/dict.service.ts @@ -0,0 +1,112 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { CreateDictDto } from './dto/create-dict.dto'; +import { UpdateDictDto } from './dto/update-dict.dto'; + +@Injectable() +export class DictService { + constructor(private prisma: PrismaService) {} + + async create(createDictDto: CreateDictDto, tenantId: number) { + return this.prisma.dict.create({ + data: { + ...createDictDto, + tenantId, + }, + }); + } + + async findAll(page: number = 1, pageSize: number = 10, tenantId?: number) { + const skip = (page - 1) * pageSize; + const where = tenantId ? { tenantId } : {}; + + const [list, total] = await Promise.all([ + this.prisma.dict.findMany({ + where, + skip, + take: pageSize, + include: { + items: { + orderBy: { + sort: 'asc', + }, + }, + }, + }), + this.prisma.dict.count({ where }), + ]); + + return { + list, + total, + page, + pageSize, + }; + } + + async findOne(id: number, tenantId?: number) { + const where: any = { id }; + if (tenantId) { + where.tenantId = tenantId; + } + + const dict = await this.prisma.dict.findFirst({ + where, + include: { + items: { + orderBy: { + sort: 'asc', + }, + }, + }, + }); + + if (!dict) { + throw new NotFoundException('字典不存在'); + } + + return dict; + } + + async findByCode(code: string, tenantId?: number) { + if (!tenantId) { + throw new NotFoundException('无法确定租户信息'); + } + + return this.prisma.dict.findFirst({ + where: { + code, + tenantId, + }, + include: { + items: { + where: { + validState: 1, + }, + orderBy: { + sort: 'asc', + }, + }, + }, + }); + } + + async update(id: number, updateDictDto: UpdateDictDto, tenantId?: number) { + // 验证字典是否存在且属于该租户 + await this.findOne(id, tenantId); + + return this.prisma.dict.update({ + where: { id }, + data: updateDictDto, + }); + } + + async remove(id: number, tenantId?: number) { + // 验证字典是否存在且属于该租户 + await this.findOne(id, tenantId); + + return this.prisma.dict.delete({ + where: { id }, + }); + } +} diff --git a/backend/src/dict/dto/create-dict.dto.ts b/backend/src/dict/dto/create-dict.dto.ts new file mode 100644 index 0000000..5734610 --- /dev/null +++ b/backend/src/dict/dto/create-dict.dto.ts @@ -0,0 +1,13 @@ +import { IsString, IsOptional } from 'class-validator'; + +export class CreateDictDto { + @IsString() + name: string; + + @IsString() + code: string; + + @IsString() + @IsOptional() + description?: string; +} diff --git a/backend/src/dict/dto/update-dict.dto.ts b/backend/src/dict/dto/update-dict.dto.ts new file mode 100644 index 0000000..3a8454d --- /dev/null +++ b/backend/src/dict/dto/update-dict.dto.ts @@ -0,0 +1,15 @@ +import { IsString, IsOptional } from 'class-validator'; + +export class UpdateDictDto { + @IsString() + @IsOptional() + name?: string; + + @IsString() + @IsOptional() + code?: string; + + @IsString() + @IsOptional() + description?: string; +} diff --git a/backend/src/main.ts b/backend/src/main.ts new file mode 100644 index 0000000..5e5650a --- /dev/null +++ b/backend/src/main.ts @@ -0,0 +1,35 @@ +import { NestFactory } from '@nestjs/core'; +import { ValidationPipe } from '@nestjs/common'; +import { AppModule } from './app.module'; +import { ConfigService } from '@nestjs/config'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + + // Enable CORS + app.enableCors({ + origin: true, + credentials: true, + }); + + // Global validation pipe + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ); + + // Global prefix + app.setGlobalPrefix('api'); + + // 验证环境配置加载 + const configService = app.get(ConfigService); + + const port = configService.get('PORT') || process.env.PORT || 3001; + await app.listen(port); + console.log(`Application is running on: http://localhost:${port}`); +} + +bootstrap(); diff --git a/backend/src/menus/dto/create-menu.dto.ts b/backend/src/menus/dto/create-menu.dto.ts new file mode 100644 index 0000000..9d757a3 --- /dev/null +++ b/backend/src/menus/dto/create-menu.dto.ts @@ -0,0 +1,30 @@ +import { IsString, IsOptional, IsInt, IsNumber } from 'class-validator'; + +export class CreateMenuDto { + @IsString() + name: string; + + @IsString() + @IsOptional() + path?: string; + + @IsString() + @IsOptional() + icon?: string; + + @IsString() + @IsOptional() + component?: string; + + @IsNumber() + @IsOptional() + parentId?: number; + + @IsString() + @IsOptional() + permission?: string; + + @IsInt() + @IsOptional() + sort?: number; +} diff --git a/backend/src/menus/dto/update-menu.dto.ts b/backend/src/menus/dto/update-menu.dto.ts new file mode 100644 index 0000000..8583373 --- /dev/null +++ b/backend/src/menus/dto/update-menu.dto.ts @@ -0,0 +1,31 @@ +import { IsString, IsOptional, IsInt, IsNumber } from 'class-validator'; + +export class UpdateMenuDto { + @IsString() + @IsOptional() + name?: string; + + @IsString() + @IsOptional() + path?: string; + + @IsString() + @IsOptional() + icon?: string; + + @IsString() + @IsOptional() + component?: string; + + @IsNumber() + @IsOptional() + parentId?: number; + + @IsString() + @IsOptional() + permission?: string; + + @IsInt() + @IsOptional() + sort?: number; +} diff --git a/backend/src/menus/menus.controller.ts b/backend/src/menus/menus.controller.ts new file mode 100644 index 0000000..1a037fe --- /dev/null +++ b/backend/src/menus/menus.controller.ts @@ -0,0 +1,55 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + UseGuards, + Request, +} from '@nestjs/common'; +import { MenusService } from './menus.service'; +import { CreateMenuDto } from './dto/create-menu.dto'; +import { UpdateMenuDto } from './dto/update-menu.dto'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; + +@Controller('menus') +@UseGuards(JwtAuthGuard) +export class MenusController { + constructor(private readonly menusService: MenusService) {} + + @Post() + create(@Body() createMenuDto: CreateMenuDto) { + return this.menusService.create(createMenuDto); + } + + @Get() + findAll() { + return this.menusService.findAll(); + } + + @Get('user-menus') + getUserMenus(@Request() req) { + const tenantId = req.tenantId || req.user?.tenantId; + if (!tenantId) { + throw new Error('无法确定租户信息'); + } + return this.menusService.findUserMenus(req.user.userId, tenantId); + } + + @Get(':id') + findOne(@Param('id') id: string) { + return this.menusService.findOne(+id); + } + + @Patch(':id') + update(@Param('id') id: string, @Body() updateMenuDto: UpdateMenuDto) { + return this.menusService.update(+id, updateMenuDto); + } + + @Delete(':id') + remove(@Param('id') id: string) { + return this.menusService.remove(+id); + } +} diff --git a/backend/src/menus/menus.module.ts b/backend/src/menus/menus.module.ts new file mode 100644 index 0000000..7ded269 --- /dev/null +++ b/backend/src/menus/menus.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { MenusService } from './menus.service'; +import { MenusController } from './menus.controller'; +import { AuthModule } from '../auth/auth.module'; + +@Module({ + imports: [AuthModule], + controllers: [MenusController], + providers: [MenusService], +}) +export class MenusModule {} diff --git a/backend/src/menus/menus.service.ts b/backend/src/menus/menus.service.ts new file mode 100644 index 0000000..d4799a7 --- /dev/null +++ b/backend/src/menus/menus.service.ts @@ -0,0 +1,174 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { CreateMenuDto } from './dto/create-menu.dto'; +import { UpdateMenuDto } from './dto/update-menu.dto'; +import { AuthService } from '../auth/auth.service'; + +@Injectable() +export class MenusService { + constructor( + private prisma: PrismaService, + private authService: AuthService, + ) {} + + async create(createMenuDto: CreateMenuDto) { + return this.prisma.menu.create({ + data: createMenuDto, + }); + } + + async findAll() { + return this.prisma.menu.findMany({ + where: { + parentId: null, + }, + include: { + children: { + orderBy: { + sort: 'asc', + }, + }, + }, + orderBy: { + sort: 'asc', + }, + }); + } + + async findOne(id: number) { + const menu = await this.prisma.menu.findUnique({ + where: { id }, + include: { + children: true, + parent: true, + }, + }); + + if (!menu) { + throw new NotFoundException('菜单不存在'); + } + + return menu; + } + + async update(id: number, updateMenuDto: UpdateMenuDto) { + return this.prisma.menu.update({ + where: { id }, + data: updateMenuDto, + }); + } + + async remove(id: number) { + return this.prisma.menu.delete({ + where: { id }, + }); + } + + /** + * 获取当前用户的菜单(根据权限过滤) + * @param userId 用户ID + * @param tenantId 租户ID + * @returns 过滤后的菜单树 + */ + async findUserMenus(userId: number, tenantId: number) { + // 获取用户的所有权限 + const userPermissions = await this.authService.getUserPermissions(userId); + + // 获取租户分配的菜单ID + const tenantMenus = await this.prisma.tenantMenu.findMany({ + where: { tenantId }, + }); + const menuIds = tenantMenus.map((tm) => tm.menuId); + + if (menuIds.length === 0) { + return []; + } + + // 获取租户分配的所有菜单(包括父菜单) + const allMenus = await this.prisma.menu.findMany({ + where: { + OR: [ + { id: { in: menuIds } }, + { children: { some: { id: { in: menuIds } } } }, + ], + validState: 1, // 只获取有效的菜单 + }, + orderBy: { + sort: 'asc', + }, + }); + + // 构建树形结构 + const buildTree = (menus: any[], parentId: number | null = null): any[] => { + return menus + .filter((menu) => menu.parentId === parentId) + .map((menu) => ({ + ...menu, + children: buildTree(menus, menu.id), + })); + }; + + // 先构建树 + const menuTree = buildTree(allMenus); + + // 过滤菜单:如果菜单有permission字段,检查用户是否有该权限;如果没有permission字段,则显示 + const filterMenus = (menus: any[]): any[] => { + return menus + .filter((menu) => { + // 如果菜单没有设置权限要求,则显示 + if (!menu.permission) { + return true; + } + // 如果设置了权限要求,检查用户是否有该权限 + return userPermissions.includes(menu.permission); + }) + .map((menu) => { + const filtered = { ...menu }; + // 递归过滤子菜单 + if (menu.children && menu.children.length > 0) { + filtered.children = filterMenus(menu.children); + } + return filtered; + }); + }; + + // 过滤菜单树 + const filteredTree = filterMenus(menuTree); + + // 移除没有子菜单且没有path的父菜单(空菜单) + const removeEmptyParents = (menus: any[]): any[] => { + return menus + .map((menu) => { + const hasChildren = menu.children && menu.children.length > 0; + const hasPath = menu.path && menu.path.trim() !== ''; + + // 如果有子菜单,递归处理 + if (hasChildren) { + const processedChildren = removeEmptyParents(menu.children); + // 如果处理后还有子菜单,保留此菜单 + if (processedChildren.length > 0) { + return { + ...menu, + children: processedChildren, + }; + } + // 如果处理后没有子菜单,但有path,保留此菜单(作为叶子节点) + if (hasPath) { + return { + ...menu, + children: [], + }; + } + // 既没有子菜单也没有path,移除 + return null; + } + + // 叶子节点,保留 + return menu; + }) + .filter((menu) => menu !== null); + }; + + return removeEmptyParents(filteredTree); + } +} diff --git a/backend/src/permissions/dto/create-permission.dto.ts b/backend/src/permissions/dto/create-permission.dto.ts new file mode 100644 index 0000000..2af8889 --- /dev/null +++ b/backend/src/permissions/dto/create-permission.dto.ts @@ -0,0 +1,20 @@ +import { IsString, IsOptional } from 'class-validator'; + +export class CreatePermissionDto { + @IsString() + name: string; + + @IsString() + code: string; + + @IsString() + resource: string; + + @IsString() + action: string; + + @IsString() + @IsOptional() + description?: string; +} + diff --git a/backend/src/permissions/dto/update-permission.dto.ts b/backend/src/permissions/dto/update-permission.dto.ts new file mode 100644 index 0000000..93af138 --- /dev/null +++ b/backend/src/permissions/dto/update-permission.dto.ts @@ -0,0 +1,24 @@ +import { IsString, IsOptional } from 'class-validator'; + +export class UpdatePermissionDto { + @IsString() + @IsOptional() + name?: string; + + @IsString() + @IsOptional() + code?: string; + + @IsString() + @IsOptional() + resource?: string; + + @IsString() + @IsOptional() + action?: string; + + @IsString() + @IsOptional() + description?: string; +} + diff --git a/backend/src/permissions/permissions.controller.ts b/backend/src/permissions/permissions.controller.ts new file mode 100644 index 0000000..f93e081 --- /dev/null +++ b/backend/src/permissions/permissions.controller.ts @@ -0,0 +1,71 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + Query, + UseGuards, + Request, +} from '@nestjs/common'; +import { PermissionsService } from './permissions.service'; +import { CreatePermissionDto } from './dto/create-permission.dto'; +import { UpdatePermissionDto } from './dto/update-permission.dto'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { Roles } from '../auth/decorators/roles.decorator'; + +@Controller('permissions') +@UseGuards(JwtAuthGuard) +export class PermissionsController { + constructor(private readonly permissionsService: PermissionsService) {} + + @Post() + @Roles('super_admin') + create(@Body() createPermissionDto: CreatePermissionDto, @Request() req) { + const tenantId = req.tenantId || req.user?.tenantId; + if (!tenantId) { + throw new Error('无法确定租户信息'); + } + return this.permissionsService.create(createPermissionDto, tenantId); + } + + @Get() + findAll( + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + @Request() req?: any, + ) { + const tenantId = req?.tenantId || req?.user?.tenantId; + return this.permissionsService.findAll( + page ? parseInt(page) : 1, + pageSize ? parseInt(pageSize) : 10, + tenantId, + ); + } + + @Get(':id') + findOne(@Param('id') id: string, @Request() req) { + const tenantId = req.tenantId || req.user?.tenantId; + return this.permissionsService.findOne(+id, tenantId); + } + + @Patch(':id') + @Roles('super_admin') + update( + @Param('id') id: string, + @Body() updatePermissionDto: UpdatePermissionDto, + @Request() req, + ) { + const tenantId = req.tenantId || req.user?.tenantId; + return this.permissionsService.update(+id, updatePermissionDto, tenantId); + } + + @Delete(':id') + @Roles('super_admin') + remove(@Param('id') id: string, @Request() req) { + const tenantId = req.tenantId || req.user?.tenantId; + return this.permissionsService.remove(+id, tenantId); + } +} diff --git a/backend/src/permissions/permissions.module.ts b/backend/src/permissions/permissions.module.ts new file mode 100644 index 0000000..e068ce6 --- /dev/null +++ b/backend/src/permissions/permissions.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { PermissionsService } from './permissions.service'; +import { PermissionsController } from './permissions.controller'; + +@Module({ + controllers: [PermissionsController], + providers: [PermissionsService], + exports: [PermissionsService], +}) +export class PermissionsModule {} + diff --git a/backend/src/permissions/permissions.service.ts b/backend/src/permissions/permissions.service.ts new file mode 100644 index 0000000..80cb26a --- /dev/null +++ b/backend/src/permissions/permissions.service.ts @@ -0,0 +1,79 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { CreatePermissionDto } from './dto/create-permission.dto'; +import { UpdatePermissionDto } from './dto/update-permission.dto'; + +@Injectable() +export class PermissionsService { + constructor(private prisma: PrismaService) {} + + async create(createPermissionDto: CreatePermissionDto, tenantId: number) { + return this.prisma.permission.create({ + data: { + ...createPermissionDto, + tenantId, + }, + }); + } + + async findAll(page: number = 1, pageSize: number = 10, tenantId?: number) { + const skip = (page - 1) * pageSize; + const where = tenantId ? { tenantId } : {}; + + const [list, total] = await Promise.all([ + this.prisma.permission.findMany({ + where, + skip, + take: pageSize, + orderBy: { + createTime: 'desc', + }, + }), + this.prisma.permission.count({ where }), + ]); + + return { + list, + total, + page, + pageSize, + }; + } + + async findOne(id: number, tenantId?: number) { + const where: any = { id }; + if (tenantId) { + where.tenantId = tenantId; + } + + const permission = await this.prisma.permission.findFirst({ + where, + }); + + if (!permission) { + throw new NotFoundException('权限不存在'); + } + + return permission; + } + + async update(id: number, updatePermissionDto: UpdatePermissionDto, tenantId?: number) { + // 检查权限是否存在 + await this.findOne(id, tenantId); + + return this.prisma.permission.update({ + where: { id }, + data: updatePermissionDto, + }); + } + + async remove(id: number, tenantId?: number) { + // 检查权限是否存在 + await this.findOne(id, tenantId); + + return this.prisma.permission.delete({ + where: { id }, + }); + } +} + diff --git a/backend/src/prisma/prisma.module.ts b/backend/src/prisma/prisma.module.ts new file mode 100644 index 0000000..23c626e --- /dev/null +++ b/backend/src/prisma/prisma.module.ts @@ -0,0 +1,9 @@ +import { Module, Global } from '@nestjs/common'; +import { PrismaService } from './prisma.service'; + +@Global() +@Module({ + providers: [PrismaService], + exports: [PrismaService], +}) +export class PrismaModule {} diff --git a/backend/src/prisma/prisma.service.ts b/backend/src/prisma/prisma.service.ts new file mode 100644 index 0000000..7ffd32d --- /dev/null +++ b/backend/src/prisma/prisma.service.ts @@ -0,0 +1,16 @@ +import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; + +@Injectable() +export class PrismaService + extends PrismaClient + implements OnModuleInit, OnModuleDestroy +{ + async onModuleInit() { + await this.$connect(); + } + + async onModuleDestroy() { + await this.$disconnect(); + } +} diff --git a/backend/src/roles/dto/create-role.dto.ts b/backend/src/roles/dto/create-role.dto.ts new file mode 100644 index 0000000..212d569 --- /dev/null +++ b/backend/src/roles/dto/create-role.dto.ts @@ -0,0 +1,18 @@ +import { IsString, IsOptional, IsArray, IsNumber } from 'class-validator'; + +export class CreateRoleDto { + @IsString() + name: string; + + @IsString() + code: string; + + @IsString() + @IsOptional() + description?: string; + + @IsArray() + @IsNumber({}, { each: true }) + @IsOptional() + permissionIds?: number[]; +} diff --git a/backend/src/roles/dto/update-role.dto.ts b/backend/src/roles/dto/update-role.dto.ts new file mode 100644 index 0000000..7950f07 --- /dev/null +++ b/backend/src/roles/dto/update-role.dto.ts @@ -0,0 +1,20 @@ +import { IsString, IsOptional, IsArray, IsNumber } from 'class-validator'; + +export class UpdateRoleDto { + @IsString() + @IsOptional() + name?: string; + + @IsString() + @IsOptional() + code?: string; + + @IsString() + @IsOptional() + description?: string; + + @IsArray() + @IsNumber({}, { each: true }) + @IsOptional() + permissionIds?: number[]; +} diff --git a/backend/src/roles/roles.controller.ts b/backend/src/roles/roles.controller.ts new file mode 100644 index 0000000..60dddc1 --- /dev/null +++ b/backend/src/roles/roles.controller.ts @@ -0,0 +1,67 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + Query, + UseGuards, + Request, +} from '@nestjs/common'; +import { RolesService } from './roles.service'; +import { CreateRoleDto } from './dto/create-role.dto'; +import { UpdateRoleDto } from './dto/update-role.dto'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; + +@Controller('roles') +@UseGuards(JwtAuthGuard) +export class RolesController { + constructor(private readonly rolesService: RolesService) {} + + @Post() + create(@Body() createRoleDto: CreateRoleDto, @Request() req) { + const tenantId = req.tenantId || req.user?.tenantId; + if (!tenantId) { + throw new Error('无法确定租户信息'); + } + return this.rolesService.create(createRoleDto, tenantId); + } + + @Get() + findAll( + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + @Request() req?: any, + ) { + const tenantId = req?.tenantId || req?.user?.tenantId; + return this.rolesService.findAll( + page ? parseInt(page) : 1, + pageSize ? parseInt(pageSize) : 10, + tenantId, + ); + } + + @Get(':id') + findOne(@Param('id') id: string, @Request() req) { + const tenantId = req.tenantId || req.user?.tenantId; + return this.rolesService.findOne(+id, tenantId); + } + + @Patch(':id') + update( + @Param('id') id: string, + @Body() updateRoleDto: UpdateRoleDto, + @Request() req, + ) { + const tenantId = req.tenantId || req.user?.tenantId; + return this.rolesService.update(+id, updateRoleDto, tenantId); + } + + @Delete(':id') + remove(@Param('id') id: string, @Request() req) { + const tenantId = req.tenantId || req.user?.tenantId; + return this.rolesService.remove(+id, tenantId); + } +} diff --git a/backend/src/roles/roles.module.ts b/backend/src/roles/roles.module.ts new file mode 100644 index 0000000..75a60a5 --- /dev/null +++ b/backend/src/roles/roles.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { RolesService } from './roles.service'; +import { RolesController } from './roles.controller'; + +@Module({ + controllers: [RolesController], + providers: [RolesService], + exports: [RolesService], +}) +export class RolesModule {} diff --git a/backend/src/roles/roles.service.ts b/backend/src/roles/roles.service.ts new file mode 100644 index 0000000..14229c0 --- /dev/null +++ b/backend/src/roles/roles.service.ts @@ -0,0 +1,158 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { CreateRoleDto } from './dto/create-role.dto'; +import { UpdateRoleDto } from './dto/update-role.dto'; + +@Injectable() +export class RolesService { + constructor(private prisma: PrismaService) {} + + async create(createRoleDto: CreateRoleDto, tenantId: number) { + const { permissionIds, ...roleData } = createRoleDto; + + // 验证权限是否属于该租户 + if (permissionIds && permissionIds.length > 0) { + const permissions = await this.prisma.permission.findMany({ + where: { + id: { in: permissionIds }, + tenantId, + }, + }); + if (permissions.length !== permissionIds.length) { + throw new NotFoundException('部分权限不存在或不属于该租户'); + } + } + + return this.prisma.role.create({ + data: { + ...roleData, + tenantId, + permissions: + permissionIds && permissionIds.length > 0 + ? { + create: permissionIds.map((permissionId) => ({ + permissionId, + })), + } + : undefined, + }, + include: { + permissions: { + include: { + permission: true, + }, + }, + }, + }); + } + + async findAll(page: number = 1, pageSize: number = 10, tenantId?: number) { + const skip = (page - 1) * pageSize; + const where = tenantId ? { tenantId } : {}; + + const [list, total] = await Promise.all([ + this.prisma.role.findMany({ + where, + skip, + take: pageSize, + include: { + permissions: { + include: { + permission: true, + }, + }, + }, + }), + this.prisma.role.count({ where }), + ]); + + return { + list, + total, + page, + pageSize, + }; + } + + async findOne(id: number, tenantId?: number) { + const where: any = { id }; + if (tenantId) { + where.tenantId = tenantId; + } + + const role = await this.prisma.role.findFirst({ + where, + include: { + permissions: { + include: { + permission: true, + }, + }, + }, + }); + + if (!role) { + throw new NotFoundException('角色不存在'); + } + + return role; + } + + async update(id: number, updateRoleDto: UpdateRoleDto, tenantId?: number) { + const { permissionIds, ...roleData } = updateRoleDto; + + // 验证角色是否存在且属于该租户 + await this.findOne(id, tenantId); + + const data: any = { ...roleData }; + + // 如果提供了 permissionIds,更新权限关联 + if (permissionIds !== undefined && tenantId) { + // 验证权限是否属于该租户 + const permissions = await this.prisma.permission.findMany({ + where: { + id: { in: permissionIds }, + tenantId, + }, + }); + if (permissions.length !== permissionIds.length) { + throw new NotFoundException('部分权限不存在或不属于该租户'); + } + + // 先删除所有现有权限关联 + await this.prisma.rolePermission.deleteMany({ + where: { roleId: id }, + }); + + // 创建新的权限关联 + if (permissionIds.length > 0) { + data.permissions = { + create: permissionIds.map((permissionId) => ({ + permissionId, + })), + }; + } + } + + return this.prisma.role.update({ + where: { id }, + data, + include: { + permissions: { + include: { + permission: true, + }, + }, + }, + }); + } + + async remove(id: number, tenantId?: number) { + // 验证角色是否存在且属于该租户 + await this.findOne(id, tenantId); + + return this.prisma.role.delete({ + where: { id }, + }); + } +} diff --git a/backend/src/tenants/decorators/tenant.decorator.ts b/backend/src/tenants/decorators/tenant.decorator.ts new file mode 100644 index 0000000..b0bb3dc --- /dev/null +++ b/backend/src/tenants/decorators/tenant.decorator.ts @@ -0,0 +1,16 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; + +export const Tenant = createParamDecorator( + (data: unknown, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest(); + return request.tenant; + }, +); + +export const TenantId = createParamDecorator( + (data: unknown, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest(); + return request.tenantId; + }, +); + diff --git a/backend/src/tenants/dto/create-tenant.dto.ts b/backend/src/tenants/dto/create-tenant.dto.ts new file mode 100644 index 0000000..d70bcd1 --- /dev/null +++ b/backend/src/tenants/dto/create-tenant.dto.ts @@ -0,0 +1,31 @@ +import { + IsString, + IsOptional, + IsArray, + IsNumber, + IsInt, + Min, + Max, +} from 'class-validator'; + +export class CreateTenantDto { + @IsString() + name: string; + + @IsString() + code: string; // 租户编码,用于访问链接 + + @IsString() + @IsOptional() + domain?: string; // 租户域名(可选) + + @IsString() + @IsOptional() + description?: string; + + @IsArray() + @IsNumber({}, { each: true }) + @IsOptional() + menuIds?: number[]; // 分配的菜单ID列表 +} + diff --git a/backend/src/tenants/dto/update-tenant.dto.ts b/backend/src/tenants/dto/update-tenant.dto.ts new file mode 100644 index 0000000..6d76845 --- /dev/null +++ b/backend/src/tenants/dto/update-tenant.dto.ts @@ -0,0 +1,31 @@ +import { IsString, IsOptional, IsArray, IsNumber, IsInt, Min, Max } from 'class-validator'; + +export class UpdateTenantDto { + @IsString() + @IsOptional() + name?: string; + + @IsString() + @IsOptional() + code?: string; + + @IsString() + @IsOptional() + domain?: string; + + @IsString() + @IsOptional() + description?: string; + + @IsInt() + @Min(0) + @Max(2) + @IsOptional() + validState?: number; + + @IsArray() + @IsNumber({}, { each: true }) + @IsOptional() + menuIds?: number[]; // 分配的菜单ID列表 +} + diff --git a/backend/src/tenants/guards/tenant.guard.ts b/backend/src/tenants/guards/tenant.guard.ts new file mode 100644 index 0000000..25a7a09 --- /dev/null +++ b/backend/src/tenants/guards/tenant.guard.ts @@ -0,0 +1,89 @@ +import { + Injectable, + CanActivate, + ExecutionContext, + BadRequestException, + UnauthorizedException, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { PrismaService } from '../../prisma/prisma.service'; + +export const TENANT_KEY = 'tenant'; +export const SetTenant = () => Reflector.createDecorator(); + +@Injectable() +export class TenantGuard implements CanActivate { + constructor( + private prisma: PrismaService, + private reflector: Reflector, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + + // 从请求头获取租户信息 + const tenantCode = request.headers['x-tenant-code']; + const tenantId = request.headers['x-tenant-id']; + const host = request.headers['host']; + + let tenantIdValue: number | null = null; + + // 方式1: 从请求头获取租户ID + if (tenantId) { + tenantIdValue = parseInt(tenantId, 10); + } + // 方式2: 从请求头获取租户编码 + else if (tenantCode) { + const tenant = await this.prisma.tenant.findUnique({ + where: { code: tenantCode }, + }); + if (!tenant) { + throw new BadRequestException('租户不存在'); + } + tenantIdValue = tenant.id; + } + // 方式3: 从子域名获取租户(如果配置了domain) + else if (host) { + const subdomain = host.split('.')[0]; + if (subdomain && subdomain !== 'www' && subdomain !== 'localhost') { + const tenant = await this.prisma.tenant.findFirst({ + where: { + OR: [ + { code: subdomain }, + { domain: subdomain }, + ], + }, + }); + if (tenant) { + tenantIdValue = tenant.id; + } + } + } + // 方式4: 从JWT token中获取(如果用户已登录) + else if (request.user?.tenantId) { + tenantIdValue = request.user.tenantId; + } + + // 如果找到了租户,验证租户是否有效 + if (tenantIdValue) { + const tenant = await this.prisma.tenant.findUnique({ + where: { id: tenantIdValue }, + }); + + if (!tenant) { + throw new BadRequestException('租户不存在'); + } + + if (tenant.validState !== 1) { + throw new BadRequestException('租户已失效'); + } + + // 将租户信息附加到请求对象 + request.tenant = tenant; + request.tenantId = tenantIdValue; + } + + return true; + } +} + diff --git a/backend/src/tenants/tenants.controller.ts b/backend/src/tenants/tenants.controller.ts new file mode 100644 index 0000000..6141c19 --- /dev/null +++ b/backend/src/tenants/tenants.controller.ts @@ -0,0 +1,79 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + UseGuards, + Request, + Query, + ParseIntPipe, +} from '@nestjs/common'; +import { TenantsService } from './tenants.service'; +import { CreateTenantDto } from './dto/create-tenant.dto'; +import { UpdateTenantDto } from './dto/update-tenant.dto'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { PermissionsGuard } from '../auth/guards/permissions.guard'; +import { RequirePermission } from '../auth/decorators/require-permission.decorator'; + +@Controller('tenants') +@UseGuards(JwtAuthGuard, PermissionsGuard) +export class TenantsController { + constructor(private readonly tenantsService: TenantsService) {} + + @Post() + @RequirePermission('tenant:create') + create(@Body() createTenantDto: CreateTenantDto, @Request() req) { + const userId = req.user?.userId; + const currentTenantId = req.user?.tenantId; + return this.tenantsService.create(createTenantDto, userId, currentTenantId); + } + + @Get() + @RequirePermission('tenant:read') + findAll( + @Query('page', new ParseIntPipe({ optional: true })) page: number = 1, + @Query('pageSize', new ParseIntPipe({ optional: true })) + pageSize: number = 10, + ) { + return this.tenantsService.findAll(page, pageSize); + } + + @Get(':id') + @RequirePermission('tenant:read') + findOne(@Param('id', ParseIntPipe) id: number) { + return this.tenantsService.findOne(id); + } + + @Patch(':id') + @RequirePermission('tenant:update') + update( + @Param('id', ParseIntPipe) id: number, + @Body() updateTenantDto: UpdateTenantDto, + @Request() req, + ) { + const userId = req.user?.userId; + const currentTenantId = req.user?.tenantId; + return this.tenantsService.update( + id, + updateTenantDto, + userId, + currentTenantId, + ); + } + + @Delete(':id') + @RequirePermission('tenant:delete') + remove(@Param('id', ParseIntPipe) id: number, @Request() req) { + const currentTenantId = req.user?.tenantId; + return this.tenantsService.remove(id, currentTenantId); + } + + @Get(':id/menus') + @RequirePermission('tenant:read') + getTenantMenus(@Param('id', ParseIntPipe) id: number) { + return this.tenantsService.getTenantMenus(id); + } +} diff --git a/backend/src/tenants/tenants.module.ts b/backend/src/tenants/tenants.module.ts new file mode 100644 index 0000000..08a2faa --- /dev/null +++ b/backend/src/tenants/tenants.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TenantsService } from './tenants.service'; +import { TenantsController } from './tenants.controller'; +import { PrismaModule } from '../prisma/prisma.module'; +import { AuthModule } from '../auth/auth.module'; + +@Module({ + imports: [PrismaModule, AuthModule], + controllers: [TenantsController], + providers: [TenantsService], + exports: [TenantsService], +}) +export class TenantsModule {} diff --git a/backend/src/tenants/tenants.service.ts b/backend/src/tenants/tenants.service.ts new file mode 100644 index 0000000..404e91d --- /dev/null +++ b/backend/src/tenants/tenants.service.ts @@ -0,0 +1,343 @@ +import { + Injectable, + NotFoundException, + BadRequestException, + ForbiddenException, +} from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { CreateTenantDto } from './dto/create-tenant.dto'; +import { UpdateTenantDto } from './dto/update-tenant.dto'; + +@Injectable() +export class TenantsService { + constructor(private prisma: PrismaService) {} + + /** + * 检查当前用户所属租户是否为超级租户 + */ + private async checkSuperTenant(currentTenantId?: number): Promise { + if (!currentTenantId) { + throw new ForbiddenException('无法确定当前租户信息'); + } + + const currentTenant = await this.prisma.tenant.findUnique({ + where: { id: currentTenantId }, + }); + + if (!currentTenant) { + throw new ForbiddenException('当前租户不存在'); + } + + if (currentTenant.isSuper !== 1) { + throw new ForbiddenException('只有超级租户才能操作租户管理'); + } + } + + async create( + createTenantDto: CreateTenantDto, + creatorId?: number, + currentTenantId?: number, + ) { + // 检查是否为超级租户 + await this.checkSuperTenant(currentTenantId); + + const { menuIds, ...tenantData } = createTenantDto; + + // 检查租户编码是否已存在 + const existingTenant = await this.prisma.tenant.findUnique({ + where: { code: tenantData.code }, + }); + if (existingTenant) { + throw new BadRequestException('租户编码已存在'); + } + + // 如果提供了域名,检查域名是否已存在 + if (tenantData.domain) { + const existingDomain = await this.prisma.tenant.findUnique({ + where: { domain: tenantData.domain }, + }); + if (existingDomain) { + throw new BadRequestException('租户域名已存在'); + } + } + + return this.prisma.tenant.create({ + data: { + ...tenantData, + creator: creatorId, + menus: + menuIds && menuIds.length > 0 + ? { + create: menuIds.map((menuId) => ({ + menuId, + })), + } + : undefined, + }, + include: { + menus: { + include: { + menu: true, + }, + }, + }, + }); + } + + async findAll(page: number = 1, pageSize: number = 10) { + const skip = (page - 1) * pageSize; + const [list, total] = await Promise.all([ + this.prisma.tenant.findMany({ + skip, + take: pageSize, + include: { + menus: { + include: { + menu: true, + }, + }, + _count: { + select: { + users: true, + roles: true, + }, + }, + }, + orderBy: { + createTime: 'desc', + }, + }), + this.prisma.tenant.count(), + ]); + + return { + list, + total, + page, + pageSize, + }; + } + + async findOne(id: number) { + const tenant = await this.prisma.tenant.findUnique({ + where: { id }, + include: { + menus: { + include: { + menu: true, + }, + }, + _count: { + select: { + users: true, + roles: true, + }, + }, + }, + }); + + if (!tenant) { + throw new NotFoundException('租户不存在'); + } + + return tenant; + } + + async findByCode(code: string) { + return this.prisma.tenant.findUnique({ + where: { code }, + include: { + menus: { + include: { + menu: true, + }, + }, + }, + }); + } + + async findByDomain(domain: string) { + return this.prisma.tenant.findUnique({ + where: { domain }, + include: { + menus: { + include: { + menu: true, + }, + }, + }, + }); + } + + async update( + id: number, + updateTenantDto: UpdateTenantDto, + modifierId?: number, + currentTenantId?: number, + ) { + // 检查是否为超级租户 + await this.checkSuperTenant(currentTenantId); + + const { menuIds, ...tenantData } = updateTenantDto; + + // 检查租户是否存在 + await this.findOne(id); + + // 如果更新了code,检查是否冲突 + if (tenantData.code) { + const existingTenant = await this.prisma.tenant.findFirst({ + where: { + code: tenantData.code, + id: { not: id }, + }, + }); + if (existingTenant) { + throw new BadRequestException('租户编码已存在'); + } + } + + // 如果更新了domain,检查是否冲突 + if (tenantData.domain) { + const existingDomain = await this.prisma.tenant.findFirst({ + where: { + domain: tenantData.domain, + id: { not: id }, + }, + }); + if (existingDomain) { + throw new BadRequestException('租户域名已存在'); + } + } + + const data: any = { + ...tenantData, + modifier: modifierId, + }; + + // 如果提供了 menuIds,更新菜单关联 + if (menuIds !== undefined) { + // 先删除所有现有菜单关联 + await this.prisma.tenantMenu.deleteMany({ + where: { tenantId: id }, + }); + + // 创建新的菜单关联 + if (menuIds.length > 0) { + data.menus = { + create: menuIds.map((menuId) => ({ + menuId, + })), + }; + } + } + + return this.prisma.tenant.update({ + where: { id }, + data, + include: { + menus: { + include: { + menu: true, + }, + }, + }, + }); + } + + async remove(id: number, currentTenantId?: number) { + // 检查是否为超级租户 + await this.checkSuperTenant(currentTenantId); + + // 检查租户是否存在 + await this.findOne(id); + + // 检查要删除的租户是否为超级租户 + const tenant = await this.prisma.tenant.findUnique({ + where: { id }, + }); + if (tenant?.isSuper === 1) { + throw new BadRequestException('不能删除超级租户'); + } + + return this.prisma.tenant.delete({ + where: { id }, + }); + } + + /** + * 获取租户的菜单树(根据租户分配的菜单) + */ + async getTenantMenus(tenantId: number) { + const tenant = await this.findOne(tenantId); + + if (!tenant) { + throw new NotFoundException('租户不存在'); + } + + // 获取租户分配的所有菜单ID + const tenantMenus = await this.prisma.tenantMenu.findMany({ + where: { tenantId }, + include: { + menu: true, + }, + }); + + const menuIds = tenantMenus.map((tm) => tm.menuId); + + if (menuIds.length === 0) { + return []; + } + + // 获取所有菜单(包括父菜单,因为子菜单可能被分配) + const allMenus = await this.prisma.menu.findMany({ + where: { + OR: [ + { id: { in: menuIds } }, + { children: { some: { id: { in: menuIds } } } }, + ], + validState: 1, + }, + orderBy: { + sort: 'asc', + }, + }); + + // 构建树形结构 + const buildTree = (menus: any[], parentId: number | null = null): any[] => { + return menus + .filter((menu) => menu.parentId === parentId) + .map((menu) => ({ + ...menu, + children: buildTree(menus, menu.id), + })); + }; + + const menuTree = buildTree(allMenus); + + // 过滤:只保留被分配的菜单及其父菜单 + const filterMenus = (menus: any[]): any[] => { + return menus + .filter((menu) => { + // 如果菜单被分配,保留 + if (menuIds.includes(menu.id)) { + return true; + } + // 如果有子菜单被分配,保留 + if (menu.children && menu.children.length > 0) { + const filteredChildren = filterMenus(menu.children); + return filteredChildren.length > 0; + } + return false; + }) + .map((menu) => { + const filtered = { ...menu }; + if (menu.children && menu.children.length > 0) { + filtered.children = filterMenus(menu.children); + } + return filtered; + }); + }; + + return filterMenus(menuTree); + } +} diff --git a/backend/src/users/dto/create-user.dto.ts b/backend/src/users/dto/create-user.dto.ts new file mode 100644 index 0000000..0999661 --- /dev/null +++ b/backend/src/users/dto/create-user.dto.ts @@ -0,0 +1,31 @@ +import { + IsString, + IsEmail, + IsOptional, + IsArray, + IsNumber, +} from 'class-validator'; + +export class CreateUserDto { + @IsString() + username: string; + + @IsString() + password: string; + + @IsString() + nickname: string; + + @IsEmail() + @IsOptional() + email?: string; + + @IsString() + @IsOptional() + avatar?: string; + + @IsArray() + @IsNumber({}, { each: true }) + @IsOptional() + roleIds?: number[]; +} diff --git a/backend/src/users/dto/update-user.dto.ts b/backend/src/users/dto/update-user.dto.ts new file mode 100644 index 0000000..0725466 --- /dev/null +++ b/backend/src/users/dto/update-user.dto.ts @@ -0,0 +1,28 @@ +import { IsString, IsEmail, IsOptional, IsArray, IsNumber } from 'class-validator'; + +export class UpdateUserDto { + @IsString() + @IsOptional() + username?: string; + + @IsString() + @IsOptional() + password?: string; + + @IsString() + @IsOptional() + nickname?: string; + + @IsEmail() + @IsOptional() + email?: string; + + @IsString() + @IsOptional() + avatar?: string; + + @IsArray() + @IsNumber({}, { each: true }) + @IsOptional() + roleIds?: number[]; +} diff --git a/backend/src/users/users.controller.ts b/backend/src/users/users.controller.ts new file mode 100644 index 0000000..b06891d --- /dev/null +++ b/backend/src/users/users.controller.ts @@ -0,0 +1,68 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + Query, + UseGuards, + Request, +} from '@nestjs/common'; +import { UsersService } from './users.service'; +import { CreateUserDto } from './dto/create-user.dto'; +import { UpdateUserDto } from './dto/update-user.dto'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { TenantId } from '../tenants/decorators/tenant.decorator'; + +@Controller('users') +@UseGuards(JwtAuthGuard) +export class UsersController { + constructor(private readonly usersService: UsersService) {} + + @Post() + create(@Body() createUserDto: CreateUserDto, @Request() req) { + const tenantId = req.tenantId || req.user?.tenantId; + if (!tenantId) { + throw new Error('无法确定租户信息'); + } + return this.usersService.create(createUserDto, tenantId); + } + + @Get() + findAll( + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + @Request() req?: any, + ) { + const tenantId = req?.tenantId || req?.user?.tenantId; + return this.usersService.findAll( + page ? parseInt(page) : 1, + pageSize ? parseInt(pageSize) : 10, + tenantId, + ); + } + + @Get(':id') + findOne(@Param('id') id: string, @Request() req) { + const tenantId = req.tenantId || req.user?.tenantId; + return this.usersService.findOne(+id, tenantId); + } + + @Patch(':id') + update( + @Param('id') id: string, + @Body() updateUserDto: UpdateUserDto, + @Request() req, + ) { + const tenantId = req.tenantId || req.user?.tenantId; + return this.usersService.update(+id, updateUserDto, tenantId); + } + + @Delete(':id') + remove(@Param('id') id: string, @Request() req) { + const tenantId = req.tenantId || req.user?.tenantId; + return this.usersService.remove(+id, tenantId); + } +} diff --git a/backend/src/users/users.module.ts b/backend/src/users/users.module.ts new file mode 100644 index 0000000..276e9b6 --- /dev/null +++ b/backend/src/users/users.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { UsersService } from './users.service'; +import { UsersController } from './users.controller'; + +@Module({ + controllers: [UsersController], + providers: [UsersService], + exports: [UsersService], +}) +export class UsersModule {} diff --git a/backend/src/users/users.service.ts b/backend/src/users/users.service.ts new file mode 100644 index 0000000..e6fa6da --- /dev/null +++ b/backend/src/users/users.service.ts @@ -0,0 +1,198 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { CreateUserDto } from './dto/create-user.dto'; +import { UpdateUserDto } from './dto/update-user.dto'; +import * as bcrypt from 'bcrypt'; + +@Injectable() +export class UsersService { + constructor(private prisma: PrismaService) {} + + async create(createUserDto: CreateUserDto, tenantId: number) { + const hashedPassword = await bcrypt.hash(createUserDto.password, 10); + const { roleIds, ...userData } = createUserDto; + + // 验证角色是否属于该租户 + if (roleIds && roleIds.length > 0) { + const roles = await this.prisma.role.findMany({ + where: { + id: { in: roleIds }, + tenantId, + }, + }); + if (roles.length !== roleIds.length) { + throw new NotFoundException('部分角色不存在或不属于该租户'); + } + } + + return this.prisma.user.create({ + data: { + ...userData, + tenantId, + password: hashedPassword, + roles: + roleIds && roleIds.length > 0 + ? { + create: roleIds.map((roleId) => ({ + roleId, + })), + } + : undefined, + }, + include: { + roles: { + include: { + role: true, + }, + }, + }, + }); + } + + async findAll(page: number = 1, pageSize: number = 10, tenantId?: number) { + const skip = (page - 1) * pageSize; + const where = tenantId ? { tenantId } : {}; + + const [list, total] = await Promise.all([ + this.prisma.user.findMany({ + where, + skip, + take: pageSize, + include: { + roles: { + include: { + role: true, + }, + }, + }, + }), + this.prisma.user.count({ where }), + ]); + + return { + list, + total, + page, + pageSize, + }; + } + + async findOne(id: number, tenantId?: number) { + const where: any = { id }; + if (tenantId) { + where.tenantId = tenantId; + } + + const user = await this.prisma.user.findFirst({ + where, + include: { + roles: { + include: { + role: { + include: { + permissions: { + include: { + permission: true, + }, + }, + }, + }, + }, + }, + }, + }); + + if (!user) { + throw new NotFoundException('用户不存在'); + } + + return user; + } + + async findByUsername(username: string, tenantId?: number) { + const where: any = { username }; + if (tenantId) { + where.tenantId = tenantId; + } + + return this.prisma.user.findFirst({ + where, + include: { + roles: { + include: { + role: { + include: { + permissions: { + include: { + permission: true, + }, + }, + }, + }, + }, + }, + }, + }); + } + + async update(id: number, updateUserDto: UpdateUserDto, tenantId?: number) { + const { roleIds, ...userData } = updateUserDto; + const data: any = { ...userData }; + + // 验证用户是否存在且属于该租户 + const existingUser = await this.findOne(id, tenantId); + + if (updateUserDto.password) { + data.password = await bcrypt.hash(updateUserDto.password, 10); + } + + // 如果提供了 roleIds,更新角色关联 + if (roleIds !== undefined && tenantId) { + // 验证角色是否属于该租户 + const roles = await this.prisma.role.findMany({ + where: { + id: { in: roleIds }, + tenantId, + }, + }); + if (roles.length !== roleIds.length) { + throw new NotFoundException('部分角色不存在或不属于该租户'); + } + + // 先删除所有现有角色关联 + await this.prisma.userRole.deleteMany({ + where: { userId: id }, + }); + + // 创建新的角色关联 + if (roleIds.length > 0) { + data.roles = { + create: roleIds.map((roleId) => ({ + roleId, + })), + }; + } + } + + return this.prisma.user.update({ + where: { id }, + data, + include: { + roles: { + include: { + role: true, + }, + }, + }, + }); + } + + async remove(id: number, tenantId?: number) { + // 验证用户是否存在且属于该租户 + await this.findOne(id, tenantId); + + return this.prisma.user.delete({ + where: { id }, + }); + } +} diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..4485963 --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": false, + "noImplicitAny": false, + "strictBindCallApply": false, + "forceConsistentCasingInFileNames": false, + "noFallthroughCasesInSwitch": false, + "paths": { + "@/*": ["src/*"] + } + } +} + diff --git a/docs/CONTEST_MANAGEMENT_PLAN.md b/docs/CONTEST_MANAGEMENT_PLAN.md new file mode 100644 index 0000000..a5b05c2 --- /dev/null +++ b/docs/CONTEST_MANAGEMENT_PLAN.md @@ -0,0 +1,749 @@ +# 赛事管理模块产品方案与实现计划 + +## 📋 目录 +1. [产品交互方案](#产品交互方案) +2. [数据库设计分析](#数据库设计分析) +3. [功能模块划分](#功能模块划分) +4. [实现计划](#实现计划) +5. [技术实现要点](#技术实现要点) + +--- + +## 产品交互方案 + +### 1. 赛事创建 + +#### 1.1 功能概述 +管理员创建赛事,填写赛事基本信息、时间安排、参赛范围等。 + +#### 1.2 交互流程 +``` +管理员进入"赛事管理" → 点击"创建赛事" → 填写表单 → 保存草稿/提交审核 +``` + +#### 1.3 表单字段(基于 t_contest 表) +- **基本信息** + - 赛事名称(必填,唯一性校验) + - 赛事类型(字典:individual/team) + - 赛事状态(默认:unpublished) + - 封面图(上传) + - 海报图(上传) + - 赛事详情(富文本编辑器) + +- **时间安排** + - 赛事开始时间 + - 赛事结束时间 + - 报名开始时间 + - 报名结束时间 + - 作品提交开始时间 + - 作品提交结束时间 + - 评审开始时间 + - 评审结束时间 + - 结果发布时间(可选) + +- **参赛范围** + - 授权租户(多选,支持租户列表选择) + - 提交规则(once/resubmit) + +- **联系信息** + - 联系人姓名 + - 联系电话 + - 联系人二维码(上传) + +- **组织信息** + - 主办单位(数组) + - 协办单位(数组) + - 赞助单位(数组) + +- **线下信息** + - 线下地址(可选) + +- **评审规则** + - 评审规则ID(关联评审规则配置) + +#### 1.4 业务规则 +- 时间顺序校验:报名开始 < 报名结束 < 提交开始 < 提交结束 < 评审开始 < 评审结束 < 结果发布 +- 赛事名称在系统内唯一 +- 创建后状态为 `unpublished`,需要发布后才能被租户看到 +- 支持保存草稿(可多次编辑) + +--- + +### 2. 赛事发布 + +#### 2.1 功能概述 +管理员将已创建的赛事发布,使其对授权租户可见。 + +#### 2.2 交互流程 +``` +赛事列表 → 选择赛事 → 点击"发布" → 确认发布 → 更新状态为 published +``` + +#### 2.3 业务规则 +- 只有状态为 `unpublished` 的赛事可以发布 +- 发布前校验必填字段完整性 +- 发布后,授权租户可以看到该赛事 +- 已发布的赛事可以撤回(状态改回 `unpublished`),但需要检查是否有报名记录 + +--- + +### 3. 赛事公告 + +#### 3.1 功能概述 +管理员发布赛事相关公告,通知参赛者重要信息。 + +#### 3.2 交互流程 +``` +赛事详情页 → "公告管理" → 创建公告 → 编辑内容 → 发布公告 +``` + +#### 3.3 功能设计 +**注意**:当前 SQL 中没有公告表,需要新增: +- `t_contest_notice` 表 + - id, contest_id, title, content, notice_type, priority, publish_time, creator, create_time, modify_time, valid_state + +#### 3.4 公告类型 +- 系统公告(系统自动生成) +- 人工公告(管理员发布) +- 紧急通知(高优先级) + +--- + +### 4. 赛事报名 + +#### 4.1 功能概述 +授权租户的用户(学生/老师)报名参加赛事,支持个人赛和团队赛。 + +#### 4.2 交互流程 + +**个人赛报名:** +``` +租户用户登录 → 浏览已发布赛事 → 选择赛事 → 点击"立即报名" → 填写信息 → 提交报名 +``` + +**团队赛报名:** +``` +队长创建团队 → 邀请成员 → 成员确认加入 → 队长提交团队报名 → 等待审核 +``` + +#### 4.3 报名流程详细设计 + +**4.3.1 个人赛报名** +1. 用户选择赛事 +2. 检查报名时间是否在有效期内 +3. 检查用户是否已报名(防止重复报名) +4. 填写报名信息(账号信息自动填充) +5. 提交报名(状态:pending) +6. 管理员审核(可选,根据赛事配置) +7. 审核通过(状态:passed)或拒绝(状态:rejected) + +**4.3.2 团队赛报名** +1. 队长创建团队 + - 填写团队名称(租户内唯一) + - 设置最大成员数 + - 邀请成员(通过账号搜索) +2. 成员确认加入 + - 收到邀请通知 + - 确认/拒绝加入 +3. 队长提交团队报名 + - 检查团队成员数量是否符合要求 + - 提交报名(所有成员状态:pending) +4. 管理员审核团队报名 + - 审核通过:所有成员状态改为 passed + - 审核拒绝:所有成员状态改为 rejected + +#### 4.4 数据表关系 +- `t_contest_registration`:报名记录表 + - 个人赛:registration_type = 'individual',team_id = null + - 团队赛:registration_type = 'team',team_id 关联 t_contest_team +- `t_contest_team`:团队表 +- `t_contest_team_member`:团队成员表 + +#### 4.5 业务规则 +- 报名时间限制:必须在 `register_start_time` 和 `register_end_time` 之间 +- 报名状态流转:pending → passed/rejected/withdrawn +- 已通过的报名可以撤回(withdrawn),但需要检查是否已提交作品 +- 团队名称在同一赛事、同一租户内唯一 +- 团队成员角色:leader(队长)、member(队员)、mentor(指导教师) + +--- + +### 5. 赛事作品提交 + +#### 5.1 功能概述 +已报名的用户提交参赛作品,支持单次提交和多次提交(根据赛事配置)。 + +#### 5.2 交互流程 +``` +已报名用户 → 进入"我的赛事" → 选择赛事 → 点击"提交作品" → 上传作品文件 → 填写作品信息 → 提交 +``` + +#### 5.3 作品提交表单 +- 作品标题(必填) +- 作品说明(可选) +- 作品文件(支持多文件上传) + - 图片、视频、3D模型等 + - 文件类型和大小限制 +- 作品预览URL(可选,用于3D/视频预览) +- AI建模元数据(可选,JSON格式) + +#### 5.4 提交规则 + +**单次提交(submit_rule = 'once'):** +- 只能提交一次作品 +- 提交后状态为 `submitted`,不可修改 + +**多次提交(submit_rule = 'resubmit'):** +- 可以多次提交作品 +- 每次提交创建新版本(version 递增) +- 旧版本 `is_latest = 0`,新版本 `is_latest = 1` +- 只有最新版本参与评审 + +#### 5.5 数据表关系 +- `t_contest_work`:作品主表 + - entry_id 关联 t_contest_registration.id + - files 字段存储简易文件列表(JSON) +- `t_contest_work_attachment`:作品附件表(详细文件信息) + +#### 5.6 业务规则 +- 提交时间限制:必须在 `submit_start_time` 和 `submit_end_time` 之间 +- 必须已通过报名审核(registration_state = 'passed') +- 作品编号(work_no)自动生成,格式:CONTEST-{contest_id}-{序号} +- 作品状态流转: + - submitted(已提交) + - locked(已锁定,不可修改) + - reviewing(评审中) + - rejected(已拒绝) + - accepted(已接受) + +--- + +### 6. 赛事作品评审 + +#### 6.1 功能概述 +评委对提交的作品进行评分,支持多维度评分和评语。 + +#### 6.2 交互流程 +``` +评委登录 → 进入"评审管理" → 选择赛事 → 查看分配的作品列表 → 点击作品 → 查看作品详情 → 评分 → 提交评分 +``` + +#### 6.3 评审流程设计 + +**6.3.1 作品分配** +- 需要新增表:`t_contest_work_judge_assignment`(作品分配表) + - id, contest_id, work_id, judge_id, assignment_time, status +- 管理员或系统自动分配作品给评委 +- 支持手动分配和自动分配(轮询、随机等) + +**6.3.2 评分界面** +- 显示作品信息(标题、说明、文件、预览等) +- 显示评审规则(review_rule_id 关联的评审规则) +- 多维度评分表单 + - 根据评审规则动态生成评分维度 + - 每个维度设置分数范围 + - 总分自动计算(根据评审规则配置的权重) +- 评语输入框 +- 提交评分按钮 + +**6.3.3 评分数据** +- `t_contest_work_score`:评分表 + - dimension_scores:JSON格式,存储各维度分数 + - total_score:总分(根据规则计算) + - comments:评语 + +#### 6.4 评审规则设计 +需要新增表:`t_contest_review_rule`(评审规则表) +- id, contest_id, rule_name, dimensions(JSON,存储评分维度配置) +- 示例维度配置: + ```json + { + "dimension1": { + "name": "创意性", + "weight": 0.3, + "maxScore": 100 + }, + "dimension2": { + "name": "技术性", + "weight": 0.4, + "maxScore": 100 + }, + "dimension3": { + "name": "完成度", + "weight": 0.3, + "maxScore": 100 + } + } + ``` + +#### 6.5 业务规则 +- 评审时间限制:必须在 `review_start_time` 和 `review_end_time` 之间 +- 作品状态更新:评审开始时,作品状态改为 `reviewing` +- 每个作品可以被多个评委评审 +- 最终得分计算:取所有评委的平均分,或根据评审规则计算 +- 评审完成后,作品状态改为 `accepted` 或 `rejected` + +--- + +### 7. 赛事结果公布 + +#### 7.1 功能概述 +管理员公布赛事评审结果,包括获奖名单、排名等。 + +#### 7.2 交互流程 +``` +管理员 → 进入"赛事管理" → 选择赛事 → 点击"公布结果" → 确认公布 → 结果发布 +``` + +#### 7.3 结果公布内容 +- 获奖名单(按奖项分类) +- 作品排名(按总分排序) +- 各维度平均分统计 +- 评审统计信息(参与评审人数、作品数量等) + +#### 7.4 业务规则 +- 只有评审已完成的赛事可以公布结果 +- 公布后,`result_publish_time` 设置为当前时间 +- 结果公布后,所有用户可以看到排名和获奖信息 +- 支持导出结果(Excel/PDF) + +--- + +## 数据库设计分析 + +### 现有表结构 + +#### 1. t_contest(赛事表) +✅ **优点:** +- 字段设计完整,覆盖赛事全生命周期 +- 支持多租户(contest_tenant 字段) +- 时间字段齐全 + +⚠️ **注意事项:** +- `contest_tenant` 使用 text 类型存储租户列表,建议考虑 JSON 类型或关联表 +- `organizers`、`co_organizers`、`sponsors` 使用 text 存储数组,建议使用 JSON + +#### 2. t_contest_attachment(赛事附件表) +✅ 设计合理,支持多种文件类型 + +#### 3. t_contest_work(作品表) +✅ **优点:** +- 支持版本控制(version、is_latest) +- 支持多种提交来源(teacher/student/team_leader) +- 支持 AI 建模元数据 + +⚠️ **问题:** +- 索引 `idx_submit_filter` 引用了不存在的字段 `review_status`,应删除或修正 + +#### 4. t_contest_work_attachment(作品附件表) +⚠️ **问题:** +- 第102行末尾有多余的逗号,需要删除 + +#### 5. t_contest_work_score(评分表) +⚠️ **问题:** +- 表定义重复(104-123行和125-144行),需要删除重复定义 + +#### 6. t_contest_registration(报名表) +✅ 设计合理,支持个人和团队报名 + +#### 7. t_contest_team(团队表) +⚠️ **问题:** +- 唯一索引 `uk_team_name` 引用了不存在的字段 `name`,应改为 `team_name` + +#### 8. t_contest_team_member(团队成员表) +✅ 设计合理 + +### 缺失的表结构 + +#### 1. t_contest_notice(赛事公告表) +```sql +CREATE TABLE `t_contest_notice` ( + `id` varchar(63) NOT NULL COMMENT '主键id', + `contest_id` varchar(63) NOT NULL COMMENT '赛事id', + `title` varchar(255) NOT NULL COMMENT '公告标题', + `content` text NOT NULL COMMENT '公告内容', + `notice_type` varchar(31) NOT NULL DEFAULT 'manual' COMMENT '公告类型:system/manual/urgent', + `priority` int DEFAULT 0 COMMENT '优先级(数字越大优先级越高)', + `publish_time` datetime DEFAULT NULL COMMENT '发布时间', + `creator` varchar(63) NOT NULL DEFAULT '' COMMENT '创建人', + `modifier` varchar(63) NOT NULL DEFAULT '' COMMENT '修改人', + `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `modify_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `valid_state` varchar(1) NOT NULL DEFAULT '1' COMMENT '有效状态(1-有效,2-失效)', + PRIMARY KEY (`id`), + KEY `idx_contest` (`contest_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='赛事公告表'; +``` + +#### 2. t_contest_review_rule(评审规则表) +```sql +CREATE TABLE `t_contest_review_rule` ( + `id` varchar(63) NOT NULL COMMENT '主键id', + `contest_id` varchar(63) NOT NULL COMMENT '赛事id', + `rule_name` varchar(127) NOT NULL COMMENT '规则名称', + `dimensions` json NOT NULL COMMENT '评分维度配置JSON', + `calculation_rule` varchar(31) DEFAULT 'average' COMMENT '计算规则:average/max/min/weighted', + `creator` varchar(63) NOT NULL DEFAULT '' COMMENT '创建人', + `modifier` varchar(63) NOT NULL DEFAULT '' COMMENT '修改人', + `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `modify_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `valid_state` varchar(1) NOT NULL DEFAULT '1' COMMENT '有效状态(1-有效,2-失效)', + PRIMARY KEY (`id`), + KEY `idx_contest` (`contest_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='评审规则表'; +``` + +#### 3. t_contest_work_judge_assignment(作品分配表) +```sql +CREATE TABLE `t_contest_work_judge_assignment` ( + `id` varchar(63) NOT NULL COMMENT '主键id', + `contest_id` varchar(63) NOT NULL COMMENT '赛事id', + `work_id` varchar(63) NOT NULL COMMENT '作品id', + `judge_id` varchar(63) NOT NULL COMMENT '评委用户id', + `assignment_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '分配时间', + `status` varchar(31) NOT NULL DEFAULT 'assigned' COMMENT '分配状态:assigned/reviewing/completed', + `creator` varchar(63) NOT NULL DEFAULT '' COMMENT '创建人', + `modifier` varchar(63) NOT NULL DEFAULT '' COMMENT '修改人', + `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `modify_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_work_judge` (`work_id`, `judge_id`), + KEY `idx_contest_judge` (`contest_id`, `judge_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='作品分配表'; +``` + +--- + +## 功能模块划分 + +### 后端模块结构 + +``` +backend/src/contests/ +├── contests.module.ts # 赛事主模块 +├── contests.controller.ts # 赛事控制器 +├── contests.service.ts # 赛事服务 +├── dto/ +│ ├── create-contest.dto.ts +│ ├── update-contest.dto.ts +│ ├── query-contest.dto.ts +│ └── publish-contest.dto.ts +├── works/ +│ ├── works.module.ts +│ ├── works.controller.ts +│ ├── works.service.ts +│ └── dto/ +│ ├── create-work.dto.ts +│ ├── update-work.dto.ts +│ └── submit-work.dto.ts +├── registrations/ +│ ├── registrations.module.ts +│ ├── registrations.controller.ts +│ ├── registrations.service.ts +│ └── dto/ +│ ├── create-registration.dto.ts +│ ├── review-registration.dto.ts +│ └── create-team.dto.ts +├── teams/ +│ ├── teams.module.ts +│ ├── teams.controller.ts +│ ├── teams.service.ts +│ └── dto/ +│ ├── create-team.dto.ts +│ └── invite-member.dto.ts +├── reviews/ +│ ├── reviews.module.ts +│ ├── reviews.controller.ts +│ ├── reviews.service.ts +│ └── dto/ +│ ├── create-score.dto.ts +│ ├── assign-work.dto.ts +│ └── create-review-rule.dto.ts +└── notices/ + ├── notices.module.ts + ├── notices.controller.ts + ├── notices.service.ts + └── dto/ + ├── create-notice.dto.ts + └── update-notice.dto.ts +``` + +### 前端模块结构 + +``` +frontend/src/views/contests/ +├── Index.vue # 赛事列表页 +├── Create.vue # 创建赛事页 +├── Detail.vue # 赛事详情页 +├── Edit.vue # 编辑赛事页 +├── works/ +│ ├── Index.vue # 作品列表页 +│ ├── Submit.vue # 提交作品页 +│ └── Detail.vue # 作品详情页 +├── registrations/ +│ ├── Index.vue # 报名列表页 +│ ├── Register.vue # 报名页 +│ └── Review.vue # 审核报名页 +├── teams/ +│ ├── Index.vue # 团队列表页 +│ ├── Create.vue # 创建团队页 +│ └── Detail.vue # 团队详情页 +├── reviews/ +│ ├── Index.vue # 评审列表页 +│ ├── Score.vue # 评分页 +│ └── Results.vue # 结果公布页 +└── notices/ + ├── Index.vue # 公告列表页 + └── Create.vue # 创建公告页 +``` + +--- + +## 实现计划 + +### 阶段一:基础功能(2-3周) + +#### 1.1 数据库迁移 +- [ ] 修复 SQL 文件中的错误 + - 删除 `t_contest_work_score` 重复定义 + - 修复 `t_contest_work_attachment` 的逗号错误 + - 修复 `t_contest_team` 索引字段名错误 + - 修复 `t_contest_work` 索引字段错误 +- [ ] 创建缺失的表(公告表、评审规则表、作品分配表) +- [ ] 更新 Prisma schema +- [ ] 执行数据库迁移 + +#### 1.2 后端基础模块 +- [ ] 创建 contests 模块(Controller、Service、DTO) +- [ ] 实现赛事 CRUD 接口 +- [ ] 实现赛事发布/撤回接口 +- [ ] 实现租户权限控制 +- [ ] 编写单元测试 + +#### 1.3 前端基础页面 +- [ ] 创建赛事列表页 +- [ ] 创建赛事创建/编辑页 +- [ ] 创建赛事详情页 +- [ ] 集成权限控制 +- [ ] 实现路由配置 + +### 阶段二:报名功能(2周) + +#### 2.1 后端实现 +- [ ] 创建 registrations 模块 +- [ ] 实现个人报名接口 +- [ ] 实现团队报名接口 +- [ ] 实现报名审核接口 +- [ ] 实现报名状态流转逻辑 + +#### 2.2 前端实现 +- [ ] 创建报名页面 +- [ ] 创建团队管理页面 +- [ ] 创建报名审核页面 +- [ ] 实现报名状态展示 + +### 阶段三:作品提交功能(2周) + +#### 3.1 后端实现 +- [ ] 创建 works 模块 +- [ ] 实现作品提交接口 +- [ ] 实现文件上传功能 +- [ ] 实现作品版本控制逻辑 +- [ ] 实现作品状态管理 + +#### 3.2 前端实现 +- [ ] 创建作品提交页面 +- [ ] 实现文件上传组件 +- [ ] 创建作品列表页 +- [ ] 创建作品详情页 + +### 阶段四:评审功能(2-3周) + +#### 4.1 后端实现 +- [ ] 创建 reviews 模块 +- [ ] 实现评审规则管理接口 +- [ ] 实现作品分配接口 +- [ ] 实现评分接口 +- [ ] 实现评分计算逻辑 + +#### 4.2 前端实现 +- [ ] 创建评审规则配置页 +- [ ] 创建作品分配页 +- [ ] 创建评分页面 +- [ ] 实现多维度评分表单 + +### 阶段五:结果公布与公告(1周) + +#### 5.1 后端实现 +- [ ] 创建 notices 模块 +- [ ] 实现公告 CRUD 接口 +- [ ] 实现结果公布接口 +- [ ] 实现结果统计接口 + +#### 5.2 前端实现 +- [ ] 创建公告管理页面 +- [ ] 创建结果公布页面 +- [ ] 实现结果展示页面 + +### 阶段六:优化与测试(1-2周) + +#### 6.1 功能优化 +- [ ] 性能优化(数据库查询优化、缓存) +- [ ] 用户体验优化 +- [ ] 错误处理完善 + +#### 6.2 测试 +- [ ] 单元测试 +- [ ] 集成测试 +- [ ] 端到端测试 +- [ ] 压力测试 + +--- + +## 技术实现要点 + +### 1. 权限设计 + +#### 1.1 权限编码规划 +``` +contest:create # 创建赛事 +contest:read # 查看赛事 +contest:update # 更新赛事 +contest:delete # 删除赛事 +contest:publish # 发布赛事 +contest:register # 报名赛事 +work:submit # 提交作品 +work:read # 查看作品 +work:update # 更新作品 +review:assign # 分配作品 +review:score # 评分 +review:read # 查看评审 +result:publish # 公布结果 +notice:create # 创建公告 +notice:read # 查看公告 +``` + +#### 1.2 角色规划 +- **超级管理员**:所有权限 +- **赛事管理员**:contest:*、notice:*、result:publish +- **评委**:review:assign、review:score、review:read、work:read +- **参赛者**:contest:read、contest:register、work:submit、work:read + +### 2. 租户隔离 + +#### 2.1 数据隔离策略 +- 赛事创建:超级租户创建,通过 `contest_tenant` 字段控制可见范围 +- 报名数据:通过 `tenant_key` 字段隔离 +- 作品数据:通过 `tenant_key` 字段隔离 +- 评审数据:通过 `tenant_key` 字段隔离 + +#### 2.2 接口权限控制 +- 使用 `@TenantId()` 装饰器获取租户信息 +- Service 层自动过滤租户数据 +- Controller 层验证租户权限 + +### 3. 时间状态管理 + +#### 3.1 赛事状态机 +``` +unpublished → published → (可撤回) → unpublished +``` + +#### 3.2 报名状态机 +``` +pending → passed/rejected/withdrawn +``` + +#### 3.3 作品状态机 +``` +submitted → locked → reviewing → accepted/rejected +``` + +#### 3.4 定时任务 +- 自动更新报名状态(根据时间) +- 自动更新作品提交状态 +- 自动更新评审状态 +- 自动发送通知(可选) + +### 4. 文件上传 + +#### 4.1 文件存储 +- 使用对象存储(OSS/S3)或本地存储 +- 文件类型限制:图片、视频、3D模型、文档等 +- 文件大小限制:根据文件类型设置 + +#### 4.2 文件管理 +- 文件上传接口 +- 文件删除接口 +- 文件预览接口 +- 文件下载接口 + +### 5. 通知系统 + +#### 5.1 通知类型 +- 报名成功通知 +- 报名审核结果通知 +- 作品提交成功通知 +- 评审结果通知 +- 结果公布通知 + +#### 5.2 通知方式(可选) +- 站内消息 +- 邮件通知 +- 短信通知(可选) + +### 6. 数据统计 + +#### 6.1 赛事统计 +- 报名人数统计 +- 作品提交数量统计 +- 评审进度统计 +- 结果统计 + +#### 6.2 报表导出 +- 报名名单导出(Excel) +- 作品列表导出(Excel) +- 评审结果导出(Excel) +- 结果报告导出(PDF) + +--- + +## 注意事项 + +### 1. SQL 文件问题修复 +在开始开发前,必须先修复 SQL 文件中的错误: +- 删除重复的 `t_contest_work_score` 表定义 +- 修复 `t_contest_work_attachment` 表的语法错误 +- 修复 `t_contest_team` 表的索引错误 +- 修复 `t_contest_work` 表的索引错误 + +### 2. 数据一致性 +- 报名和作品的关系:一个报名可以对应多个作品版本 +- 团队和报名的关系:一个团队对应多个报名记录(每个成员一条) +- 作品和评分的关系:一个作品可以有多条评分记录(多个评委) + +### 3. 性能考虑 +- 赛事列表查询需要分页 +- 作品列表查询需要分页和筛选 +- 评分计算需要缓存 +- 文件上传需要异步处理 + +### 4. 安全性 +- 文件上传需要验证文件类型和大小 +- 接口需要权限验证 +- 敏感操作需要日志记录 +- 防止 SQL 注入和 XSS 攻击 + +--- + +## 总结 + +本方案基于现有的多租户 RBAC 系统架构,设计了完整的赛事管理功能模块。主要特点: + +1. **完整的生命周期管理**:从赛事创建到结果公布的全流程 +2. **灵活的参赛方式**:支持个人赛和团队赛 +3. **强大的评审系统**:支持多维度评分和自定义评审规则 +4. **良好的扩展性**:模块化设计,易于扩展新功能 + +建议按照阶段逐步实现,每个阶段完成后进行测试和优化,确保系统稳定可靠。 + diff --git a/docs/MENU_PERMISSION_CONTROL.md b/docs/MENU_PERMISSION_CONTROL.md new file mode 100644 index 0000000..9032676 --- /dev/null +++ b/docs/MENU_PERMISSION_CONTROL.md @@ -0,0 +1,246 @@ +# 菜单权限控制说明 + +## 📋 概述 + +系统通过 **权限编码(Permission Code)** 来控制用户对菜单的访问。菜单权限控制分为两个层面: + +1. **菜单显示控制**:根据用户权限过滤菜单,只显示用户有权限访问的菜单 +2. **路由访问控制**:通过路由守卫检查用户是否有权限访问某个页面 + +## 🔄 权限控制流程 + +``` +用户登录 + ↓ +获取用户角色和权限 + ↓ +调用 /api/menus/user-menus 获取用户菜单 + ↓ +后端根据用户权限过滤菜单 + ↓ +前端动态生成路由和菜单 + ↓ +路由守卫检查页面访问权限 +``` + +## 🎯 如何配置菜单权限 + +### 1. 创建权限 + +首先需要在权限管理中创建权限,例如: + +- `menu:read` - 查看菜单权限 +- `user:read` - 查看用户权限 +- `role:read` - 查看角色权限 + +### 2. 将权限分配给角色 + +在角色管理中,为角色分配相应的权限。例如: + +- **管理员角色**:拥有所有权限 +- **普通用户角色**:只拥有 `user:read` 权限 + +### 3. 为用户分配角色 + +在用户管理中,为用户分配角色。用户会继承角色的所有权限。 + +### 4. 为菜单设置权限编码 + +在菜单管理中,为菜单设置 `权限编码` 字段: + +#### 示例配置 + +| 菜单名称 | 路径 | 权限编码 | 说明 | +| -------- | ------------------- | ----------- | --------------------------------------------- | +| 用户管理 | /system/users | `user:read` | 只有拥有 `user:read` 权限的用户才能看到此菜单 | +| 角色管理 | /system/roles | `role:read` | 只有拥有 `role:read` 权限的用户才能看到此菜单 | +| 权限管理 | /system/permissions | - | 不设置权限编码,所有用户都可以看到 | +| 仪表盘 | /dashboard | - | 不设置权限编码,所有用户都可以看到 | + +### 5. 权限编码规则 + +权限编码格式:`资源:操作` + +常见示例: + +- `user:read` - 查看用户 +- `user:create` - 创建用户 +- `user:update` - 更新用户 +- `user:delete` - 删除用户 +- `role:read` - 查看角色 +- `menu:read` - 查看菜单 + +## 💻 技术实现 + +### 后端实现 + +#### 1. 菜单权限过滤(`MenusService.findUserMenus`) + +```typescript +// 获取用户的所有权限 +const userPermissions = await this.authService.getUserPermissions(userId); + +// 过滤菜单:如果菜单有permission字段,检查用户是否有该权限 +const filterMenus = (menus: any[]): any[] => { + return menus + .filter((menu) => { + // 如果菜单没有设置权限要求,则显示 + if (!menu.permission) { + return true; + } + // 如果设置了权限要求,检查用户是否有该权限 + return userPermissions.includes(menu.permission); + }) + .map((menu) => { + // 递归过滤子菜单 + if (menu.children && menu.children.length > 0) { + menu.children = filterMenus(menu.children); + } + return menu; + }); +}; +``` + +#### 2. 用户权限获取(`AuthService.getUserPermissions`) + +```typescript +async getUserPermissions(userId: number): Promise { + const user = await this.usersService.findOne(userId); + const permissions = new Set(); + + // 遍历用户的所有角色 + user.roles?.forEach((ur: any) => { + // 遍历角色的所有权限 + ur.role.permissions?.forEach((rp: any) => { + permissions.add(rp.permission.code); + }); + }); + + return Array.from(permissions); +} +``` + +### 前端实现 + +#### 1. 菜单转换为路由(`convertMenusToRoutes`) + +```typescript +const route: RouteRecordRaw = { + path: routePath, + name: routeName, + meta: { + title: menu.name, + requiresAuth: true, + // 如果菜单有权限要求,添加到路由meta中 + ...(menu.permission && { permissions: [menu.permission] }), + }, + component: componentLoader, +}; +``` + +#### 2. 路由守卫检查(`router.beforeEach`) + +```typescript +// 检查权限 +const requiredPermissions = to.meta.permissions; +if (requiredPermissions && requiredPermissions.length > 0) { + if (!authStore.hasAnyPermission(requiredPermissions)) { + // 没有所需权限,跳转到 403 页面 + next({ name: "Forbidden" }); + return; + } +} +``` + +#### 3. 权限检查方法(`authStore`) + +```typescript +// 检查是否有指定权限 +const hasPermission = (permission: string): boolean => { + return user.value?.permissions?.includes(permission) ?? false; +}; + +// 检查是否有任一权限 +const hasAnyPermission = (permissions: string[]): boolean => { + if (!permissions || permissions.length === 0) return true; + return permissions.some((perm) => hasPermission(perm)); +}; +``` + +## 📝 使用示例 + +### 示例 1:创建需要权限的菜单 + +1. 登录系统,进入 **菜单管理** +2. 点击 **新增菜单** +3. 填写菜单信息: + - 菜单名称:`用户管理` + - 路由路径:`/system/users` + - 组件路径:`system/users/Index` + - **权限编码**:`user:read` ⭐ + - 父菜单:选择 `系统管理` +4. 保存菜单 + +### 示例 2:创建公开菜单(所有用户可见) + +1. 在菜单管理中新增菜单 +2. **权限编码字段留空** +3. 这样所有用户都可以看到此菜单 + +### 示例 3:为用户分配权限 + +1. 进入 **权限管理**,创建权限: + - 权限名称:`查看用户` + - 权限编码:`user:read` + - 资源:`user` + - 操作:`read` + +2. 进入 **角色管理**,编辑角色: + - 为角色分配 `user:read` 权限 + +3. 进入 **用户管理**,编辑用户: + - 为用户分配该角色 + +## ⚠️ 注意事项 + +1. **权限编码必须唯一**:每个权限编码在系统中是唯一的 +2. **菜单权限为空则公开**:如果菜单的 `权限编码` 字段为空,所有用户都可以看到 +3. **子菜单继承父菜单权限**:子菜单会独立检查权限,不会自动继承父菜单权限 +4. **路由和菜单双重控制**: + - 菜单显示控制:控制菜单是否在侧边栏显示 + - 路由访问控制:控制用户是否可以直接访问页面(通过 URL) +5. **权限变更后需重新登录**:权限变更后,用户需要重新登录才能看到新的菜单 + +## 🔍 调试技巧 + +### 1. 查看用户权限 + +在浏览器控制台执行: + +```javascript +// 查看当前用户权限 +console.log(useAuthStore().user?.permissions); + +// 检查是否有特定权限 +console.log(useAuthStore().hasPermission("user:read")); +``` + +### 2. 查看用户菜单 + +```javascript +// 查看当前用户的菜单 +console.log(useAuthStore().menus); +``` + +### 3. 后端调试 + +在后端日志中查看: + +- 用户权限列表 +- 菜单过滤结果 + +## 📚 相关文档 + +- [RBAC 权限控制详解](./RBAC_GUIDE.md) +- [菜单管理使用说明](./MENU_MANAGEMENT.md) +- [权限管理使用说明](./PERMISSION_MANAGEMENT.md) diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..d600b6c --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,25 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + diff --git a/frontend/.npmrc b/frontend/.npmrc new file mode 100644 index 0000000..0caa1d3 --- /dev/null +++ b/frontend/.npmrc @@ -0,0 +1,4 @@ +# 前端 pnpm 配置 +shamefully-hoist=true +strict-peer-dependencies=false + diff --git a/frontend/PERMISSION_USAGE.md b/frontend/PERMISSION_USAGE.md new file mode 100644 index 0000000..0f88991 --- /dev/null +++ b/frontend/PERMISSION_USAGE.md @@ -0,0 +1,378 @@ +# 前端权限控制使用指南 + +## 📋 概述 + +前端权限控制系统已经完善,支持: + +- ✅ 自动获取用户信息(刷新页面时) +- ✅ 角色权限检查 +- ✅ 权限码检查 +- ✅ 路由守卫自动验证 + +## 🔧 已修复的问题 + +### 1. **认证状态判断** + +- **之前**: `isAuthenticated = !!token && !!user`(刷新页面时 user 为 null 导致判断失败) +- **现在**: `isAuthenticated = !!token`(只要有 token 就认为已认证) + +### 2. **自动获取用户信息** + +- **之前**: 刷新页面后用户信息丢失 +- **现在**: + - 应用启动时自动获取(`main.ts`) + - 路由守卫中自动获取(如果 token 存在但 user 不存在) + +### 3. **权限检查** + +- **之前**: 只检查 `requiresAuth`,没有角色和权限检查 +- **现在**: 支持角色和权限检查 + +## 🎯 使用方法 + +### 1. 在路由中配置权限 + +#### 使用角色控制 + +```typescript +{ + path: "users", + name: "SystemUsers", + component: () => import("@/views/system/users/Index.vue"), + meta: { + title: "用户管理", + requiresAuth: true, + roles: ["super_admin", "admin"], // 需要 super_admin 或 admin 角色 + }, +} +``` + +#### 使用权限控制 + +```typescript +{ + path: "users", + name: "SystemUsers", + component: () => import("@/views/system/users/Index.vue"), + meta: { + title: "用户管理", + requiresAuth: true, + permissions: ["user:read"], // 需要 user:read 权限 + }, +} +``` + +#### 同时使用角色和权限 + +```typescript +{ + path: "users", + name: "SystemUsers", + component: () => import("@/views/system/users/Index.vue"), + meta: { + title: "用户管理", + requiresAuth: true, + roles: ["admin"], // 需要 admin 角色 + permissions: ["user:read"], // 并且需要 user:read 权限 + }, +} +``` + +**注意**: 如果同时设置了 `roles` 和 `permissions`,需要**同时满足**两者。 + +### 2. 在组件中使用权限 + +```vue + + + +``` + +### 3. Store 方法说明 + +#### `hasRole(role: string): boolean` + +检查用户是否有指定角色 + +```typescript +if (authStore.hasRole("super_admin")) { + // 用户是超级管理员 +} +``` + +#### `hasPermission(permission: string): boolean` + +检查用户是否有指定权限 + +```typescript +if (authStore.hasPermission("user:create")) { + // 用户可以创建用户 +} +``` + +#### `hasAnyRole(roles: string[]): boolean` + +检查用户是否有任一指定角色 + +```typescript +if (authStore.hasAnyRole(["admin", "editor"])) { + // 用户是 admin 或 editor +} +``` + +#### `hasAnyPermission(permissions: string[]): boolean` + +检查用户是否有任一指定权限 + +```typescript +if (authStore.hasAnyPermission(["user:create", "user:update"])) { + // 用户可以创建或更新用户 +} +``` + +## 🔄 工作流程 + +### 1. 应用启动流程 + +``` +应用启动 + ↓ +检查 localStorage 中是否有 token + ↓ +如果有 token,调用 fetchUserInfo() 获取用户信息 + ↓ +用户信息包含 roles 和 permissions + ↓ +应用挂载完成 +``` + +### 2. 路由导航流程 + +``` +用户访问路由 + ↓ +路由守卫 beforeEach + ↓ +检查 token 是否存在 + ↓ +如果 token 存在但 user 不存在 → 自动获取用户信息 + ↓ +检查 requiresAuth → 是否需要登录 + ↓ +检查 roles → 是否有指定角色 + ↓ +检查 permissions → 是否有指定权限 + ↓ +允许/拒绝访问 +``` + +### 3. 登录流程 + +``` +用户登录 + ↓ +调用 authApi.login() + ↓ +返回 token 和 user 信息(包含 roles 和 permissions) + ↓ +保存 token 到 localStorage + ↓ +保存 user 到 store + ↓ +跳转到目标页面 +``` + +## 📝 路由配置示例 + +### 完整的路由配置示例 + +```typescript +const routes: RouteRecordRaw[] = [ + { + path: "/login", + name: "Login", + component: () => import("@/views/auth/Login.vue"), + meta: { requiresAuth: false }, + }, + { + path: "/", + component: () => import("@/layouts/BasicLayout.vue"), + redirect: "/workbench", + meta: { requiresAuth: true }, + children: [ + { + path: "workbench", + name: "workbench", + component: () => import("@/views/workbench/Index.vue"), + meta: { + title: "仪表盘", + requiresAuth: true, + }, + }, + { + path: "system/users", + name: "SystemUsers", + component: () => import("@/views/system/users/Index.vue"), + meta: { + title: "用户管理", + requiresAuth: true, + permissions: ["user:read"], // 需要查看用户权限 + }, + }, + { + path: "system/roles", + name: "SystemRoles", + component: () => import("@/views/system/roles/Index.vue"), + meta: { + title: "角色管理", + requiresAuth: true, + roles: ["super_admin", "admin"], // 需要管理员角色 + }, + }, + ], + }, +]; +``` + +## 🎨 实际应用场景 + +### 场景 1: 根据权限显示菜单 + +```vue + +``` + +### 场景 2: 根据权限显示按钮 + +```vue + +``` + +### 场景 3: 组合权限检查 + +```vue + +``` + +## ⚠️ 注意事项 + +### 1. 路由守卫是异步的 + +路由守卫使用了 `async/await`,确保在检查权限前用户信息已加载。 + +### 2. 权限检查顺序 + +1. 认证检查(`requiresAuth`) +2. 角色检查(`roles`) +3. 权限检查(`permissions`) + +### 3. 403 页面 + +如果没有权限,会跳转到 `/403` 页面。你可以自定义这个页面。 + +### 4. 权限更新 + +如果用户权限发生变化,需要: + +- 重新登录,或 +- 调用 `authStore.fetchUserInfo()` 刷新用户信息 + +## 🔍 调试技巧 + +### 1. 查看当前用户信息 + +```typescript +import { useAuthStore } from "@/stores/auth"; + +const authStore = useAuthStore(); +console.log("用户信息:", authStore.user); +console.log("角色:", authStore.user?.roles); +console.log("权限:", authStore.user?.permissions); +``` + +### 2. 检查权限 + +```typescript +console.log("是否有 admin 角色:", authStore.hasRole("admin")); +console.log("是否有 user:create 权限:", authStore.hasPermission("user:create")); +``` + +## 📚 总结 + +现在权限控制系统已经完善: + +1. ✅ **自动获取用户信息** - 刷新页面不会丢失 +2. ✅ **路由权限检查** - 支持角色和权限控制 +3. ✅ **组件权限检查** - 可以在组件中使用权限方法 +4. ✅ **类型安全** - TypeScript 类型定义完善 + +可以开始使用权限控制功能了! diff --git a/frontend/TENANT_MANAGEMENT_GUIDE.md b/frontend/TENANT_MANAGEMENT_GUIDE.md new file mode 100644 index 0000000..2ac1d31 --- /dev/null +++ b/frontend/TENANT_MANAGEMENT_GUIDE.md @@ -0,0 +1,219 @@ +# 租户管理页面使用指南 + +## 概述 + +租户管理页面已创建完成,只有超级租户才能访问和使用。该页面提供了完整的租户管理功能,包括租户的创建、编辑、删除和菜单分配。 + +## 功能特性 + +### 1. 租户列表 + +- 显示所有租户的基本信息 +- 显示租户类型(超级租户/普通租户) +- 显示租户状态(有效/失效) +- 显示租户统计信息(用户数、角色数) +- 显示租户已分配的菜单 + +### 2. 创建租户 + +- 租户名称 +- 租户编码(用于访问链接,必须唯一) +- 租户域名(可选,用于子域名访问) +- 租户描述 + +### 3. 编辑租户 + +- 修改租户基本信息 +- 修改租户状态(有效/失效) +- 注意:租户编码创建后不可修改 + +### 4. 分配菜单 + +- 以树形结构展示所有可用菜单 +- 支持多选 +- 显示租户当前已分配的菜单 +- 可以批量分配或取消分配菜单 + +### 5. 删除租户 + +- 删除租户及其所有关联数据 +- 超级租户不能被删除 +- 删除操作不可恢复 + +## 权限要求 + +所有操作都需要相应的权限: + +- `tenant:create` - 创建租户 +- `tenant:read` - 查看租户列表和详情 +- `tenant:update` - 编辑租户和分配菜单 +- `tenant:delete` - 删除租户 + +**注意**:只有超级租户的用户才拥有这些权限。 + +## 添加租户管理菜单 + +### 方式一:通过数据库直接添加 + +在数据库中执行以下SQL,为超级租户添加租户管理菜单: + +```sql +-- 假设系统管理菜单的ID为某个值,需要根据实际情况调整 parent_id +-- 假设系统管理菜单的ID为 2(需要根据实际情况查询) + +INSERT INTO menus (name, path, icon, component, parent_id, permission, sort, valid_state, create_time, modify_time) +VALUES ( + '租户管理', + '/system/tenants', + 'TeamOutlined', + 'system/tenants/Index', + 2, -- 系统管理菜单的ID,需要根据实际情况调整 + 'tenant:read', + 7, -- 排序,放在其他系统管理菜单之后 + 1, + NOW(), + NOW() +); +``` + +### 方式二:通过后端API添加 + +使用超级管理员账号登录后,通过菜单管理接口添加: + +```bash +POST /api/menus +Authorization: Bearer +X-Tenant-Code: super + +{ + "name": "租户管理", + "path": "/system/tenants", + "icon": "TeamOutlined", + "component": "system/tenants/Index", + "parentId": 2, // 系统管理菜单的ID + "permission": "tenant:read", + "sort": 7 +} +``` + +### 方式三:更新初始化脚本 + +修改 `backend/scripts/init-menus.ts` 或 `backend/scripts/init-super-tenant.ts`,在菜单初始化时添加租户管理菜单。 + +## 页面访问 + +添加菜单后,超级租户的用户登录后可以在"系统管理"菜单下看到"租户管理"选项。 + +访问路径:`/system/tenants` + +## 使用示例 + +### 创建新租户 + +1. 点击"新增租户"按钮 +2. 填写租户信息: + - 租户名称:例如 "租户A" + - 租户编码:例如 "tenant-a"(必须唯一,只能包含小写字母、数字、下划线和连字符) + - 租户域名:例如 "tenant-a.example.com"(可选) + - 描述:租户的描述信息 +3. 点击"确定"创建 + +### 为租户分配菜单 + +1. 在租户列表中,点击某个租户的"分配菜单"按钮 +2. 在弹出的菜单树中,勾选要分配给该租户的菜单 +3. 点击"确定"保存 + +**注意**: + +- 只有被分配的菜单才会在该租户的用户登录后显示 +- 父菜单如果被分配,其子菜单也会自动显示(但需要单独分配才能访问) + +### 编辑租户 + +1. 点击租户列表中的"编辑"按钮 +2. 修改租户信息(租户编码不可修改) +3. 可以修改租户状态(有效/失效) +4. 点击"确定"保存 + +### 删除租户 + +1. 点击租户列表中的"删除"按钮 +2. 确认删除操作 +3. **警告**:删除租户会同时删除该租户的所有数据(用户、角色、权限等),此操作不可恢复 + +## 注意事项 + +1. **权限控制**:只有超级租户的用户才能看到和使用租户管理功能 +2. **租户编码唯一性**:租户编码必须全局唯一,创建后不可修改 +3. **超级租户保护**:超级租户不能被删除 +4. **菜单分配**:菜单分配后,租户的用户登录后才能看到相应的菜单 +5. **数据隔离**:每个租户的数据完全隔离,互不影响 + +## 故障排查 + +### 问题1:看不到租户管理菜单 + +**原因**:菜单未添加到数据库,或当前用户不是超级租户 + +**解决**: + +- 确认菜单已添加到数据库 +- 确认当前用户属于超级租户 +- 确认用户有 `tenant:read` 权限 +- 刷新页面或重新登录 + +### 问题2:无法创建租户 + +**原因**:缺少 `tenant:create` 权限 + +**解决**: + +- 确认当前用户有创建租户的权限 +- 联系超级管理员分配权限 + +### 问题3:菜单分配不生效 + +**原因**:菜单分配后,用户需要重新登录才能看到新菜单 + +**解决**: + +- 让租户的用户重新登录 +- 或者清除浏览器缓存后重新登录 + +## 技术实现 + +### 文件结构 + +``` +frontend/src/ +├── api/ +│ └── tenants.ts # 租户API接口 +├── views/ +│ └── system/ +│ └── tenants/ +│ └── Index.vue # 租户管理页面 +└── utils/ + └── menu.ts # 菜单工具(已添加租户管理组件映射) +``` + +### API接口 + +所有API接口都在 `frontend/src/api/tenants.ts` 中定义: + +- `getTenantsList()` - 获取租户列表 +- `getTenantDetail()` - 获取租户详情 +- `createTenant()` - 创建租户 +- `updateTenant()` - 更新租户 +- `deleteTenant()` - 删除租户 +- `getTenantMenus()` - 获取租户菜单树 + +### 组件映射 + +在 `frontend/src/utils/menu.ts` 中添加了组件映射: + +```typescript +"system/tenants/Index": () => import("@/views/system/tenants/Index.vue") +``` + +这样当菜单的 `component` 字段为 `system/tenants/Index` 时,系统会自动加载租户管理页面。 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..91ae8b6 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,14 @@ + + + + + + + 比赛管理系统 + + +
+ + + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..3d80d35 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,36 @@ +{ + "name": "competition-management-frontend", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc && vite build", + "preview": "vite preview", + "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore" + }, + "dependencies": { + "vue": "^3.4.21", + "vue-router": "^4.3.0", + "pinia": "^2.1.7", + "axios": "^1.6.7", + "ant-design-vue": "^4.1.1", + "@ant-design/icons-vue": "^7.0.1", + "vee-validate": "^4.12.4", + "zod": "^3.22.4", + "@vee-validate/zod": "^4.12.4", + "dayjs": "^1.11.10" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.4", + "@vue/eslint-config-typescript": "^13.0.0", + "typescript": "^5.4.3", + "vue-tsc": "^1.8.27", + "vite": "^5.1.6", + "sass": "^1.71.1", + "tailwindcss": "^3.4.1", + "postcss": "^8.4.35", + "autoprefixer": "^10.4.18", + "eslint": "^8.57.0", + "eslint-plugin-vue": "^9.22.0" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..b4a6220 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,7 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} + diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..7d2f4fa --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,11 @@ + + + + + + diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts new file mode 100644 index 0000000..29376e2 --- /dev/null +++ b/frontend/src/api/auth.ts @@ -0,0 +1,23 @@ +import request from "@/utils/request"; +import type { LoginForm, LoginResponse, User } from "@/types/auth"; + +export const authApi = { + login: async (data: LoginForm): Promise => { + const response = await request.post("/auth/login", data); + return response as LoginResponse; + }, + + logout: async (): Promise => { + await request.post("/auth/logout"); + }, + + getUserInfo: async (): Promise => { + const response = await request.get("/auth/user-info"); + return response as User; + }, + + refreshToken: async (): Promise<{ token: string }> => { + const response = await request.post("/auth/refresh-token"); + return response as { token: string }; + }, +}; diff --git a/frontend/src/api/config.ts b/frontend/src/api/config.ts new file mode 100644 index 0000000..999a476 --- /dev/null +++ b/frontend/src/api/config.ts @@ -0,0 +1,81 @@ +import request from "@/utils/request"; +import type { PaginationParams, PaginationResponse } from "@/types/api"; + +export interface Config { + id: number; + key: string; + value: string; + description?: string; + creator?: number; + modifier?: number; + createTime?: string; + modifyTime?: string; +} + +export interface CreateConfigForm { + key: string; + value: string; + description?: string; +} + +export interface UpdateConfigForm { + key?: string; + value?: string; + description?: string; +} + +// 获取配置列表 +export async function getConfigsList( + params: PaginationParams +): Promise> { + const response = await request.get>( + "/config", + { + params, + } + ); + return response; +} + +// 获取单个配置详情 +export async function getConfigDetail(id: number): Promise { + const response = await request.get(`/config/${id}`); + return response; +} + +// 根据key获取配置 +export async function getConfigByKey(key: string): Promise { + const response = await request.get(`/config/key/${key}`); + return response; +} + +// 创建配置 +export async function createConfig(data: CreateConfigForm): Promise { + const response = await request.post("/config", data); + return response; +} + +// 更新配置 +export async function updateConfig( + id: number, + data: UpdateConfigForm +): Promise { + const response = await request.patch(`/config/${id}`, data); + return response; +} + +// 删除配置 +export async function deleteConfig(id: number): Promise { + return await request.delete(`/config/${id}`); +} + +// 兼容性导出:保留 configApi 对象 +export const configApi = { + getList: getConfigsList, + getDetail: getConfigDetail, + getByKey: getConfigByKey, + create: createConfig, + update: updateConfig, + delete: deleteConfig, +}; + diff --git a/frontend/src/api/dict.ts b/frontend/src/api/dict.ts new file mode 100644 index 0000000..93c42fa --- /dev/null +++ b/frontend/src/api/dict.ts @@ -0,0 +1,89 @@ +import request from "@/utils/request"; +import type { PaginationParams, PaginationResponse } from "@/types/api"; + +export interface Dict { + id: number; + name: string; + code: string; + description?: string; + validState?: number; + creator?: number; + modifier?: number; + createTime?: string; + modifyTime?: string; + items?: DictItem[]; +} + +export interface DictItem { + id: number; + dictId: number; + label: string; + value: string; + sort: number; + validState: number; +} + +export interface CreateDictForm { + name: string; + code: string; + description?: string; +} + +export interface UpdateDictForm { + name?: string; + code?: string; + description?: string; +} + +// 获取字典列表 +export async function getDictsList( + params: PaginationParams +): Promise> { + const response = await request.get>("/dict", { + params, + }); + return response; +} + +// 获取单个字典详情 +export async function getDictDetail(id: number): Promise { + const response = await request.get(`/dict/${id}`); + return response; +} + +// 根据编码获取字典 +export async function getDictByCode(code: string): Promise { + const response = await request.get(`/dict/code/${code}`); + return response; +} + +// 创建字典 +export async function createDict(data: CreateDictForm): Promise { + const response = await request.post("/dict", data); + return response; +} + +// 更新字典 +export async function updateDict( + id: number, + data: UpdateDictForm +): Promise { + const response = await request.patch(`/dict/${id}`, data); + return response; +} + +// 删除字典 +export async function deleteDict(id: number): Promise { + return await request.delete(`/dict/${id}`); +} + +// 兼容性导出:保留 dictApi 对象 +export const dictApi = { + getList: getDictsList, + getDetail: getDictDetail, + getByCode: getDictByCode, + create: createDict, + update: updateDict, + delete: deleteDict, +}; + diff --git a/frontend/src/api/logs.ts b/frontend/src/api/logs.ts new file mode 100644 index 0000000..e7df121 --- /dev/null +++ b/frontend/src/api/logs.ts @@ -0,0 +1,40 @@ +import request from "@/utils/request"; +import type { PaginationParams, PaginationResponse } from "@/types/api"; + +export interface Log { + id: number; + userId?: number; + action: string; + content?: string; + ip?: string; + userAgent?: string; + createTime?: string; + user?: { + id: number; + username: string; + nickname: string; + }; +} + +// 获取日志列表 +export async function getLogsList( + params: PaginationParams +): Promise> { + const response = await request.get>("/logs", { + params, + }); + return response; +} + +// 获取单个日志详情 +export async function getLogDetail(id: number): Promise { + const response = await request.get(`/logs/${id}`); + return response; +} + +// 兼容性导出:保留 logsApi 对象 +export const logsApi = { + getList: getLogsList, + getDetail: getLogDetail, +}; + diff --git a/frontend/src/api/menus.ts b/frontend/src/api/menus.ts new file mode 100644 index 0000000..79f39e4 --- /dev/null +++ b/frontend/src/api/menus.ts @@ -0,0 +1,87 @@ +import request from "@/utils/request"; + +export interface Menu { + id: number; + name: string; + path?: string; + icon?: string; + component?: string; + parentId?: number; + permission?: string; + sort: number; + validState?: number; + creator?: number; + modifier?: number; + createTime?: string; + modifyTime?: string; + children?: Menu[]; + parent?: Menu; +} + +export interface CreateMenuForm { + name: string; + path?: string; + icon?: string; + component?: string; + parentId?: number; + permission?: string; + sort?: number; +} + +export interface UpdateMenuForm { + name?: string; + path?: string; + icon?: string; + component?: string; + parentId?: number; + permission?: string; + sort?: number; +} + +// 获取菜单列表(树形结构) +export async function getMenusList(): Promise { + const response = await request.get("/menus"); + return response; +} + +// 获取单个菜单详情 +export async function getMenuDetail(id: number): Promise { + const response = await request.get(`/menus/${id}`); + return response; +} + +// 创建菜单 +export async function createMenu(data: CreateMenuForm): Promise { + const response = await request.post("/menus", data); + return response; +} + +// 更新菜单 +export async function updateMenu( + id: number, + data: UpdateMenuForm +): Promise { + const response = await request.patch(`/menus/${id}`, data); + return response; +} + +// 删除菜单 +export async function deleteMenu(id: number): Promise { + return await request.delete(`/menus/${id}`); +} + +// 获取当前用户的菜单(根据权限过滤) +export async function getUserMenus(): Promise { + const response = await request.get("/menus/user-menus"); + return response; +} + +// 兼容性导出:保留 menusApi 对象 +export const menusApi = { + getList: getMenusList, + getDetail: getMenuDetail, + create: createMenu, + update: updateMenu, + delete: deleteMenu, + getUserMenus: getUserMenus, +}; diff --git a/frontend/src/api/permissions.ts b/frontend/src/api/permissions.ts new file mode 100644 index 0000000..28c5c81 --- /dev/null +++ b/frontend/src/api/permissions.ts @@ -0,0 +1,78 @@ +import request from "@/utils/request"; +import type { PaginationParams, PaginationResponse } from "@/types/api"; + +export interface Permission { + id: number; + name: string; + code: string; + resource: string; + action: string; + description?: string; + validState?: number; + creator?: number; + modifier?: number; + createTime?: string; + modifyTime?: string; +} + +export interface CreatePermissionForm { + name: string; + code: string; + resource: string; + action: string; + description?: string; +} + +export interface UpdatePermissionForm { + name?: string; + code?: string; + resource?: string; + action?: string; + description?: string; +} + +// 获取权限列表 +export async function getPermissionsList( + params: PaginationParams +): Promise> { + const response = await request.get>("/permissions", { + params, + }); + return response; +} + +// 获取单个权限详情 +export async function getPermissionDetail(id: number): Promise { + const response = await request.get(`/permissions/${id}`); + return response; +} + +// 创建权限 +export async function createPermission(data: CreatePermissionForm): Promise { + const response = await request.post("/permissions", data); + return response; +} + +// 更新权限 +export async function updatePermission( + id: number, + data: UpdatePermissionForm +): Promise { + const response = await request.patch(`/permissions/${id}`, data); + return response; +} + +// 删除权限 +export async function deletePermission(id: number): Promise { + return await request.delete(`/permissions/${id}`); +} + +// 兼容性导出:保留 permissionsApi 对象 +export const permissionsApi = { + getList: getPermissionsList, + getDetail: getPermissionDetail, + create: createPermission, + update: updatePermission, + delete: deletePermission, +}; + diff --git a/frontend/src/api/roles.ts b/frontend/src/api/roles.ts new file mode 100644 index 0000000..56e8aae --- /dev/null +++ b/frontend/src/api/roles.ts @@ -0,0 +1,83 @@ +import request from "@/utils/request"; +import type { PaginationParams, PaginationResponse } from "@/types/api"; + +export interface Role { + id: number; + name: string; + code: string; + description?: string; + validState?: number; + creator?: number; + modifier?: number; + createTime?: string; + modifyTime?: string; + permissions?: Array<{ + id: number; + permission: { + id: number; + name: string; + code: string; + resource: string; + action: string; + }; + }>; +} + +export interface CreateRoleForm { + name: string; + code: string; + description?: string; + permissionIds?: number[]; +} + +export interface UpdateRoleForm { + name?: string; + code?: string; + description?: string; + permissionIds?: number[]; +} + +// 获取角色列表 +export async function getRolesList( + params: PaginationParams +): Promise> { + const response = await request.get>("/roles", { + params, + }); + return response; +} + +// 获取单个角色详情 +export async function getRoleDetail(id: number): Promise { + const response = await request.get(`/roles/${id}`); + return response; +} + +// 创建角色 +export async function createRole(data: CreateRoleForm): Promise { + const response = await request.post("/roles", data); + return response; +} + +// 更新角色 +export async function updateRole( + id: number, + data: UpdateRoleForm +): Promise { + const response = await request.patch(`/roles/${id}`, data); + return response; +} + +// 删除角色 +export async function deleteRole(id: number): Promise { + return await request.delete(`/roles/${id}`); +} + +// 兼容性导出:保留 rolesApi 对象 +export const rolesApi = { + getList: getRolesList, + getDetail: getRoleDetail, + create: createRole, + update: updateRole, + delete: deleteRole, +}; diff --git a/frontend/src/api/tenants.ts b/frontend/src/api/tenants.ts new file mode 100644 index 0000000..0b87a85 --- /dev/null +++ b/frontend/src/api/tenants.ts @@ -0,0 +1,97 @@ +import request from "@/utils/request"; +import type { PaginationParams, PaginationResponse } from "@/types/api"; +import type { Menu } from "./menus"; + +export interface Tenant { + id: number; + name: string; + code: string; + domain?: string; + description?: string; + isSuper: number; + validState: number; + creator?: number; + modifier?: number; + createTime?: string; + modifyTime?: string; + menus?: Array<{ + id: number; + menu: Menu; + }>; + _count?: { + users: number; + roles: number; + }; +} + +export interface CreateTenantForm { + name: string; + code: string; + domain?: string; + description?: string; + menuIds?: number[]; +} + +export interface UpdateTenantForm { + name?: string; + code?: string; + domain?: string; + description?: string; + validState?: number; + menuIds?: number[]; +} + +// 获取租户列表 +export async function getTenantsList( + params: PaginationParams +): Promise> { + const response = await request.get>( + "/tenants", + { + params, + } + ); + return response; +} + +// 获取单个租户详情 +export async function getTenantDetail(id: number): Promise { + const response = await request.get(`/tenants/${id}`); + return response; +} + +// 创建租户 +export async function createTenant(data: CreateTenantForm): Promise { + const response = await request.post("/tenants", data); + return response; +} + +// 更新租户 +export async function updateTenant( + id: number, + data: UpdateTenantForm +): Promise { + const response = await request.patch(`/tenants/${id}`, data); + return response; +} + +// 删除租户 +export async function deleteTenant(id: number): Promise { + return await request.delete(`/tenants/${id}`); +} + +// 获取租户的菜单树 +export async function getTenantMenus(id: number): Promise { + const response = await request.get(`/tenants/${id}/menus`); + return response; +} + +// 兼容性导出:保留 tenantsApi 对象 +export const tenantsApi = { + getList: getTenantsList, + getDetail: getTenantDetail, + create: createTenant, + update: updateTenant, + delete: deleteTenant, + getTenantMenus: getTenantMenus, +}; diff --git a/frontend/src/api/users.ts b/frontend/src/api/users.ts new file mode 100644 index 0000000..5905888 --- /dev/null +++ b/frontend/src/api/users.ts @@ -0,0 +1,86 @@ +import request from "@/utils/request"; +import type { PaginationParams, PaginationResponse } from "@/types/api"; + +export interface User { + id: number; + username: string; + nickname: string; + email?: string; + avatar?: string; + validState?: number; + creator?: number; + modifier?: number; + createTime?: string; + modifyTime?: string; + roles?: Array<{ + id: number; + role: { + id: number; + name: string; + code: string; + }; + }>; +} + +export interface CreateUserForm { + username: string; + password: string; + nickname: string; + email?: string; + avatar?: string; + roleIds?: number[]; +} + +export interface UpdateUserForm { + username?: string; + password?: string; + nickname?: string; + email?: string; + avatar?: string; + roleIds?: number[]; +} + +// 获取用户列表 +export async function getUsersList( + params: PaginationParams +): Promise> { + const response = await request.get>("/users", { + params, + }); + return response; +} + +// 获取单个用户详情 +export async function getUserDetail(id: number): Promise { + const response = await request.get(`/users/${id}`); + return response; +} + +// 创建用户 +export async function createUser(data: CreateUserForm): Promise { + const response = await request.post("/users", data); + return response; +} + +// 更新用户 +export async function updateUser( + id: number, + data: UpdateUserForm +): Promise { + const response = await request.patch(`/users/${id}`, data); + return response; +} + +// 删除用户 +export async function deleteUser(id: number): Promise { + return await request.delete(`/users/${id}`); +} + +// 兼容性导出:保留 usersApi 对象 +export const usersApi = { + getList: getUsersList, + getDetail: getUserDetail, + create: createUser, + update: updateUser, + delete: deleteUser, +}; diff --git a/frontend/src/composables/useListRequest.ts b/frontend/src/composables/useListRequest.ts new file mode 100644 index 0000000..7927be6 --- /dev/null +++ b/frontend/src/composables/useListRequest.ts @@ -0,0 +1,118 @@ +import { ref, reactive, type Ref } from "vue"; +import { message } from "ant-design-vue"; +import type { TableProps } from "ant-design-vue"; +import type { PaginationParams, PaginationResponse } from "@/types/api"; + +export interface UseListRequestOptions< + T, + P extends Record = Record, +> { + // 请求函数 + requestFn: (params: PaginationParams & P) => Promise>; + // 默认搜索参数 + defaultSearchParams?: P; + // 默认分页大小 + defaultPageSize?: number; + // 错误提示信息 + errorMessage?: string; + // 是否在挂载时自动加载 + immediate?: boolean; +} + +export function useListRequest< + T, + P extends Record = Record, +>(options: UseListRequestOptions) { + const { + requestFn, + defaultSearchParams = {} as P, + defaultPageSize = 10, + errorMessage = "获取列表失败", + immediate = true, + } = options; + + // 加载状态 + const loading = ref(false); + + // 数据源 + const dataSource = ref([]) as Ref; + + // 分页信息 + const pagination = reactive({ + current: 1, + pageSize: defaultPageSize, + total: 0, + }); + + // 搜索参数 + const searchParams = reactive

({ ...defaultSearchParams } as P); + + // 获取列表数据 + const fetchList = async () => { + loading.value = true; + try { + const params = { + page: pagination.current, + pageSize: pagination.pageSize, + ...searchParams, + } as PaginationParams & P; + + const response = await requestFn(params); + dataSource.value = response.list; + pagination.total = response.total; + } catch (error) { + message.error(errorMessage); + console.error("List request error:", error); + } finally { + loading.value = false; + } + }; + + // 重置搜索并刷新 + const resetSearch = () => { + Object.assign(searchParams, defaultSearchParams); + pagination.current = 1; + fetchList(); + }; + + // 搜索 + const search = (params?: Partial

) => { + if (params) { + Object.assign(searchParams, params); + } + pagination.current = 1; + fetchList(); + }; + + // 刷新当前页 + const refresh = () => { + fetchList(); + }; + + // 表格分页变化处理 + const handleTableChange: TableProps["onChange"] = (pag) => { + pagination.current = pag.current || 1; + pagination.pageSize = pag.pageSize || defaultPageSize; + fetchList(); + }; + + // 如果 immediate 为 true,在组合函数被调用时自动加载 + if (immediate) { + fetchList(); + } + + return { + // 状态 + loading, + dataSource, + pagination, + searchParams, + + // 方法 + fetchList, + resetSearch, + search, + refresh, + handleTableChange, + }; +} diff --git a/frontend/src/composables/useSimpleListRequest.ts b/frontend/src/composables/useSimpleListRequest.ts new file mode 100644 index 0000000..7484111 --- /dev/null +++ b/frontend/src/composables/useSimpleListRequest.ts @@ -0,0 +1,62 @@ +import { ref, type Ref } from "vue"; +import { message } from "ant-design-vue"; + +export interface UseSimpleListRequestOptions { + // 请求函数(返回数组,不是分页响应) + requestFn: () => Promise; + // 错误提示信息 + errorMessage?: string; + // 是否在挂载时自动加载 + immediate?: boolean; +} + +export function useSimpleListRequest( + options: UseSimpleListRequestOptions +) { + const { + requestFn, + errorMessage = "获取列表失败", + immediate = true, + } = options; + + // 加载状态 + const loading = ref(false); + + // 数据源 + const dataSource = ref([]) as Ref; + + // 获取列表数据 + const fetchList = async () => { + loading.value = true; + try { + const response = await requestFn(); + dataSource.value = response; + } catch (error) { + message.error(errorMessage); + console.error("List request error:", error); + } finally { + loading.value = false; + } + }; + + // 刷新列表 + const refresh = () => { + fetchList(); + }; + + // 如果 immediate 为 true,在组合函数被调用时自动加载 + if (immediate) { + fetchList(); + } + + return { + // 状态 + loading, + dataSource, + + // 方法 + fetchList, + refresh, + }; +} + diff --git a/frontend/src/directives/permission.ts b/frontend/src/directives/permission.ts new file mode 100644 index 0000000..195cc7a --- /dev/null +++ b/frontend/src/directives/permission.ts @@ -0,0 +1,146 @@ +import type { App, DirectiveBinding } from "vue"; +import { useAuthStore } from "@/stores/auth"; + +/** + * 权限指令配置 + */ +interface PermissionDirectiveValue { + // 权限码或权限码数组 + permission: string | string[]; + // 是否隐藏元素(默认 false,即禁用) + hide?: boolean; + // 是否需要所有权限(默认 false,即任一权限即可) + all?: boolean; +} + +/** + * 检查权限 + */ +function checkPermission( + value: string | string[] | PermissionDirectiveValue, + all: boolean = false +): boolean { + // 在指令中,我们需要通过 getCurrentInstance 获取 store + // 但更好的方式是直接导入 store,因为 Pinia store 是全局的 + const authStore = useAuthStore(); + + // 如果值是字符串或数组,直接检查权限 + if (typeof value === "string") { + return authStore.hasPermission(value); + } + + if (Array.isArray(value)) { + return all + ? value.every((perm) => authStore.hasPermission(perm)) + : authStore.hasAnyPermission(value); + } + + // 如果是对象配置 + if (typeof value === "object" && value !== null) { + const { permission, all: needAll = false } = value as PermissionDirectiveValue; + if (typeof permission === "string") { + return authStore.hasPermission(permission); + } + if (Array.isArray(permission)) { + return needAll + ? permission.every((perm) => authStore.hasPermission(perm)) + : authStore.hasAnyPermission(permission); + } + } + + return false; +} + +/** + * 处理元素权限 + */ +function handlePermission( + el: HTMLElement, + binding: DirectiveBinding +) { + const { value, modifiers } = binding; + + // 如果没有值,默认允许 + if (!value) { + return; + } + + // 检查是否需要所有权限 + const needAll = modifiers.all || false; + + // 解析配置 + let config: PermissionDirectiveValue; + if (typeof value === "string" || Array.isArray(value)) { + config = { + permission: value, + hide: modifiers.hide || false, + all: needAll, + }; + } else { + config = { + permission: value.permission, + hide: value.hide ?? modifiers.hide ?? false, + all: value.all ?? needAll, + }; + } + + const hasPermission = checkPermission(config.permission, config.all); + + if (config.hide) { + // 隐藏元素 + if (hasPermission) { + el.style.display = ""; + el.removeAttribute("data-permission-hidden"); + } else { + el.style.display = "none"; + el.setAttribute("data-permission-hidden", "true"); + } + } else { + // 禁用元素(默认行为) + if (hasPermission) { + // 恢复元素状态 + if (el instanceof HTMLButtonElement || el instanceof HTMLInputElement) { + el.disabled = false; + el.classList.remove("permission-disabled"); + } else { + el.style.pointerEvents = ""; + el.style.opacity = ""; + el.classList.remove("permission-disabled"); + } + el.removeAttribute("data-permission-disabled"); + } else { + // 禁用元素 + if (el instanceof HTMLButtonElement || el instanceof HTMLInputElement) { + el.disabled = true; + el.classList.add("permission-disabled"); + } else { + el.style.pointerEvents = "none"; + el.style.opacity = "0.6"; + el.classList.add("permission-disabled"); + } + el.setAttribute("data-permission-disabled", "true"); + } + } +} + +/** + * 权限指令 + */ +const permissionDirective = { + mounted(el: HTMLElement, binding: DirectiveBinding) { + handlePermission(el, binding); + }, + updated(el: HTMLElement, binding: DirectiveBinding) { + handlePermission(el, binding); + }, +}; + +/** + * 注册权限指令 + */ +export function setupPermissionDirective(app: App) { + app.directive("permission", permissionDirective); +} + +export default permissionDirective; + diff --git a/frontend/src/layouts/BasicLayout.vue b/frontend/src/layouts/BasicLayout.vue new file mode 100644 index 0000000..f53637c --- /dev/null +++ b/frontend/src/layouts/BasicLayout.vue @@ -0,0 +1,183 @@ + + + + + diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..cf0d4c8 --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,26 @@ +import { createApp } from "vue"; +import { createPinia } from "pinia"; +import Antd from "ant-design-vue"; +import "ant-design-vue/dist/reset.css"; +import "./styles/global.scss"; +import App from "./App.vue"; +import router from "./router"; +import { useAuthStore } from "./stores/auth"; +import { setupPermissionDirective } from "./directives/permission"; + +const app = createApp(App); +const pinia = createPinia(); + +app.use(pinia); +app.use(router); +app.use(Antd); + +// 注册权限指令 +setupPermissionDirective(app); + +// 应用启动时初始化认证状态 +// 如果有 token,自动获取用户信息 +const authStore = useAuthStore(); +authStore.initAuth().finally(() => { + app.mount("#app"); +}); diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts new file mode 100644 index 0000000..8d53f60 --- /dev/null +++ b/frontend/src/router/index.ts @@ -0,0 +1,358 @@ +import { createRouter, createWebHistory } from "vue-router"; +import type { RouteRecordRaw } from "vue-router"; +import { nextTick } from "vue"; +import { useAuthStore } from "@/stores/auth"; +import { convertMenusToRoutes } from "@/utils/menu"; +import "@/types/router"; + +// 基础路由(不需要动态加载的) +const baseRoutes: RouteRecordRaw[] = [ + { + path: "/:tenantCode/login", + name: "Login", + component: () => import("@/views/auth/Login.vue"), + meta: { requiresAuth: false }, + }, + { + path: "/login", + name: "LoginFallback", + component: () => import("@/views/auth/Login.vue"), + meta: { requiresAuth: false }, + }, + { + path: "/:tenantCode", + name: "Main", + component: () => import("@/layouts/BasicLayout.vue"), + redirect: (to) => { + return { path: `/${to.params.tenantCode}/workbench` }; + }, + meta: {}, + children: [ + // 动态路由将在这里添加 + ], + }, + { + path: "/403", + name: "Forbidden", + component: () => import("@/views/error/403.vue"), + meta: { requiresAuth: false }, + }, + { + path: "/:pathMatch(.*)*", + name: "NotFound", + component: () => import("@/views/error/404.vue"), + }, +]; + +const router = createRouter({ + history: createWebHistory(), + routes: baseRoutes, +}); + +// 标记是否已经添加了动态路由 +let dynamicRoutesAdded = false; + +/** + * 添加动态路由 + * @returns Promise,当路由添加完成并生效后 resolve + */ +async function addDynamicRoutes(): Promise { + if (dynamicRoutesAdded) return; + + const authStore = useAuthStore(); + if (!authStore.menus || authStore.menus.length === 0) return; + + // 将菜单转换为路由 + const dynamicRoutes = convertMenusToRoutes(authStore.menus); + + // 添加动态路由到根路由下 + dynamicRoutes.forEach((route) => { + router.addRoute("Main", route); + }); + + dynamicRoutesAdded = true; + + // 等待下一个 tick,确保路由已完全注册 + await nextTick(); + // 额外等待一个 tick,确保路由系统完全更新 + await nextTick(); +} + +/** + * 从路径中提取租户编码 + */ +function extractTenantCodeFromPath(path: string): string | null { + const match = path.match(/^\/([^/]+)/); + return match ? match[1] : null; +} + +/** + * 构建带租户编码的路径 + */ +function buildPathWithTenantCode(tenantCode: string, path: string): string { + // 如果路径已经包含租户编码,直接返回 + if (path.startsWith(`/${tenantCode}/`)) { + return path; + } + // 移除开头的斜杠(如果有) + const cleanPath = path.startsWith("/") ? path.slice(1) : path; + // 如果路径是根路径,返回租户编码路径 + if (cleanPath === "" || cleanPath === tenantCode) { + return `/${tenantCode}/workbench`; + } + return `/${tenantCode}/${cleanPath}`; +} + +router.beforeEach(async (to, _from, next) => { + console.log("to -----", to); + const authStore = useAuthStore(); + + // 从URL中提取租户编码 + const tenantCodeFromUrl = extractTenantCodeFromPath(to.path); + + // 如果 token 存在但用户信息不存在,先获取用户信息 + if (authStore.token && !authStore.user) { + try { + const userInfo = await authStore.fetchUserInfo(); + + // 如果获取用户信息失败或用户信息为空,跳转到登录页 + if (!userInfo) { + authStore.logout(); + const tenantCode = + tenantCodeFromUrl || extractTenantCodeFromPath(to.path); + if (tenantCode) { + next({ + path: `/${tenantCode}/login`, + query: { redirect: to.fullPath }, + }); + } else { + next({ name: "LoginFallback", query: { redirect: to.fullPath } }); + } + return; + } + + // 获取用户信息后,检查租户编码一致性 + const userTenantCode = userInfo?.tenantCode; + if (userTenantCode) { + // 如果URL中的租户编码与用户信息不一致,更正URL + if (tenantCodeFromUrl && tenantCodeFromUrl !== userTenantCode) { + const correctedPath = buildPathWithTenantCode( + userTenantCode, + to.path.replace(`/${tenantCodeFromUrl}`, "") + ); + next({ path: correctedPath, replace: true }); + return; + } + // 如果URL中没有租户编码,添加租户编码 + if (!tenantCodeFromUrl) { + const correctedPath = buildPathWithTenantCode( + userTenantCode, + to.path + ); + next({ path: correctedPath, replace: true }); + return; + } + } + // 获取用户信息后,添加动态路由并等待生效 + await addDynamicRoutes(); + // 保存原始目标路径 + const targetPath = to.fullPath; + // 路由已生效,重新解析目标路由 + const resolved = router.resolve(targetPath); + console.log("resolved -----", resolved); + // 如果解析后的路由不是404,说明路由存在,重新导航 + if (resolved.name !== "NotFound") { + next({ path: targetPath, replace: true }); + } else { + // 如果路由不存在,但需要认证,跳转到登录页(而不是404) + if (to.meta.requiresAuth === false) { + // 路由确实不存在,允许继续(会显示404页面) + next(); + } else { + const tenantCode = + tenantCodeFromUrl || extractTenantCodeFromPath(to.path); + if (tenantCode) { + next({ + path: `/${tenantCode}/login`, + query: { redirect: to.fullPath }, + }); + } else { + next({ name: "LoginFallback", query: { redirect: to.fullPath } }); + } + } + } + return; + } catch (error) { + // 获取失败,清除 token 并跳转到登录页 + console.error("获取用户信息失败:", error); + authStore.logout(); + const tenantCode = + tenantCodeFromUrl || extractTenantCodeFromPath(to.path); + if (tenantCode) { + next({ + path: `/${tenantCode}/login`, + query: { redirect: to.fullPath }, + }); + } else { + next({ name: "LoginFallback", query: { redirect: to.fullPath } }); + } + return; + } + } + + // 如果 token 不存在,但需要认证,跳转到登录页 + if (!authStore.token && to.meta.requiresAuth !== false) { + const tenantCode = tenantCodeFromUrl || extractTenantCodeFromPath(to.path); + if (tenantCode) { + next({ + path: `/${tenantCode}/login`, + query: { redirect: to.fullPath }, + }); + } else { + next({ name: "LoginFallback", query: { redirect: to.fullPath } }); + } + return; + } + + // 如果已登录,检查租户编码一致性 + if (authStore.isAuthenticated && authStore.user) { + const userTenantCode = authStore.user.tenantCode; + if (userTenantCode) { + // 如果URL中的租户编码与用户信息不一致,更正URL + if (tenantCodeFromUrl && tenantCodeFromUrl !== userTenantCode) { + const correctedPath = buildPathWithTenantCode( + userTenantCode, + to.path.replace(`/${tenantCodeFromUrl}`, "") + ); + next({ path: correctedPath, replace: true }); + return; + } + // 如果URL中没有租户编码,添加租户编码 + if (!tenantCodeFromUrl && to.path !== "/login") { + const correctedPath = buildPathWithTenantCode(userTenantCode, to.path); + next({ path: correctedPath, replace: true }); + return; + } + } + } + + // 如果已登录且有菜单数据,但动态路由未添加,则添加 + if ( + authStore.isAuthenticated && + authStore.menus.length > 0 && + !dynamicRoutesAdded + ) { + // 添加动态路由并等待生效 + await addDynamicRoutes(); + // 保存原始目标路径 + const targetPath = to.fullPath; + // 路由已生效,重新解析目标路由 + const resolved = router.resolve(targetPath); + // 如果解析后的路由不是404,说明路由存在,重新导航 + if (resolved.name !== "NotFound") { + next({ path: targetPath, replace: true }); + } else { + // 如果路由不存在,但需要认证,跳转到登录页(而不是404) + if (to.meta.requiresAuth !== false) { + const tenantCode = + tenantCodeFromUrl || extractTenantCodeFromPath(to.path); + if (tenantCode) { + next({ + path: `/${tenantCode}/login`, + query: { redirect: to.fullPath }, + }); + } else { + next({ name: "LoginFallback", query: { redirect: to.fullPath } }); + } + } else { + // 路由确实不存在,允许继续(会显示404页面) + next(); + } + } + return; + } + + // 检查是否需要认证 + if (to.meta.requiresAuth !== false) { + // 如果没有 token,跳转到登录页 + if (!authStore.token) { + const tenantCode = + tenantCodeFromUrl || extractTenantCodeFromPath(to.path); + if (tenantCode) { + next({ + path: `/${tenantCode}/login`, + query: { redirect: to.fullPath }, + }); + } else { + next({ name: "LoginFallback", query: { redirect: to.fullPath } }); + } + return; + } + // 如果有 token 但没有用户信息,跳转到登录页 + if (!authStore.user) { + const tenantCode = + tenantCodeFromUrl || extractTenantCodeFromPath(to.path); + if (tenantCode) { + next({ + path: `/${tenantCode}/login`, + query: { redirect: to.fullPath }, + }); + } else { + next({ name: "LoginFallback", query: { redirect: to.fullPath } }); + } + return; + } + } + + // 如果已登录,访问登录页则重定向到首页 + if ( + (to.name === "Login" || to.name === "LoginFallback") && + authStore.isAuthenticated + ) { + // 确保动态路由已添加并等待生效 + if (!dynamicRoutesAdded && authStore.menus.length > 0) { + await addDynamicRoutes(); + } + // 重定向到带租户编码的工作台 + const userTenantCode = authStore.user?.tenantCode || "default"; + next({ path: `/${userTenantCode}/workbench` }); + return; + } + + // 处理登录页面的租户编码 + if (to.name === "LoginFallback" && !tenantCodeFromUrl) { + // 如果访问的是 /login,但没有租户编码,检查是否有用户信息中的租户编码 + if (authStore.isAuthenticated && authStore.user?.tenantCode) { + const userTenantCode = authStore.user.tenantCode; + next({ path: `/${userTenantCode}/login`, replace: true }); + return; + } + // 如果没有租户编码,允许访问(会显示租户输入框) + next(); + return; + } + + // 检查角色权限 + const requiredRoles = to.meta.roles; + if (requiredRoles && requiredRoles.length > 0) { + if (!authStore.hasAnyRole(requiredRoles)) { + // 没有所需角色,跳转到 403 页面 + next({ name: "Forbidden" }); + return; + } + } + + // 检查权限 + const requiredPermissions = to.meta.permissions; + if (requiredPermissions && requiredPermissions.length > 0) { + if (!authStore.hasAnyPermission(requiredPermissions)) { + // 没有所需权限,跳转到 403 页面 + next({ name: "Forbidden" }); + 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..871e0ff --- /dev/null +++ b/frontend/src/stores/auth.ts @@ -0,0 +1,149 @@ +import { defineStore } from "pinia"; +import { ref, computed } from "vue"; +import type { User, LoginForm } from "@/types/auth"; +import { authApi } from "@/api/auth"; +import { menusApi, type Menu } from "@/api/menus"; +import { getToken, setToken, removeToken, getTenantCode } from "@/utils/auth"; + +export const useAuthStore = defineStore("auth", () => { + const user = ref(null); + const token = ref(getToken() || ""); + const loading = ref(false); + const menus = ref([]); + + const isAuthenticated = computed(() => !!token.value); + + // 获取当前用户的租户编码(优先从用户信息获取,其次从 URL 获取) + const tenantCode = computed(() => { + return user.value?.tenantCode || getTenantCode(); + }); + + // 检查是否有指定角色 + const hasRole = (role: string): boolean => { + return user.value?.roles?.includes(role) ?? false; + }; + + // 检查是否有指定权限 + const hasPermission = (permission: string): boolean => { + return user.value?.permissions?.includes(permission) ?? false; + }; + + // 检查是否有任一角色 + const hasAnyRole = (roles: string[]): boolean => { + if (!roles || roles.length === 0) return true; + return roles.some((role) => hasRole(role)); + }; + + // 检查是否有任一权限 + const hasAnyPermission = (permissions: string[]): boolean => { + if (!permissions || permissions.length === 0) return true; + return permissions.some((perm) => hasPermission(perm)); + }; + + const login = async (form: LoginForm) => { + const response = await authApi.login(form); + token.value = response.token; + user.value = response.user; + + // 使用租户编码作为 cookie path(不再存储到 localStorage) + if (response.user.tenantCode) { + setToken(response.token, response.user.tenantCode); + } else { + setToken(response.token); + } + + // 登录后获取用户菜单 + await fetchUserMenus(); + return response; + }; + + const logout = async () => { + try { + await authApi.logout(); + } finally { + token.value = ""; + // 删除 token cookie,使用当前用户的租户编码或 URL 中的租户编码 + const tenantCode = user.value?.tenantCode || getTenantCode(); + removeToken(tenantCode || undefined); + user.value = null; + menus.value = []; + } + }; + + const fetchUserInfo = async () => { + if (!token.value) { + throw new Error("未登录"); + } + + try { + loading.value = true; + const response = await authApi.getUserInfo(); + user.value = response; + // 获取用户菜单 + await fetchUserMenus(); + return response; + } catch (error) { + // 如果获取用户信息失败,清除 token + token.value = ""; + user.value = null; + menus.value = []; + removeToken(); + throw error; + } finally { + loading.value = false; + } + }; + + const fetchUserMenus = async () => { + if (!token.value) { + return; + } + + try { + const userMenus = await menusApi.getUserMenus(); + menus.value = userMenus; + return userMenus; + } catch (error) { + console.error("获取用户菜单失败:", error); + menus.value = []; + throw error; + } + }; + + const updateToken = (newToken: string) => { + token.value = newToken; + // 使用当前用户的租户编码更新 token cookie + const tenantCode = user.value?.tenantCode || getTenantCode(); + setToken(newToken, tenantCode || undefined); + }; + + // 初始化:如果有 token 但没有用户信息,自动获取 + const initAuth = async () => { + if (token.value && !user.value) { + try { + await fetchUserInfo(); + } catch (error) { + console.error("自动获取用户信息失败:", error); + } + } + }; + + return { + user, + token, + loading, + menus, + isAuthenticated, + tenantCode, + hasRole, + hasPermission, + hasAnyRole, + hasAnyPermission, + login, + logout, + fetchUserInfo, + fetchUserMenus, + updateToken, + initAuth, + }; +}); diff --git a/frontend/src/styles/global.scss b/frontend/src/styles/global.scss new file mode 100644 index 0000000..614eeb5 --- /dev/null +++ b/frontend/src/styles/global.scss @@ -0,0 +1,19 @@ +@use "./tailwind.scss"; +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", + Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", + "Segoe UI Symbol", "Noto Color Emoji"; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +#app { + min-height: 100vh; +} diff --git a/frontend/src/styles/tailwind.scss b/frontend/src/styles/tailwind.scss new file mode 100644 index 0000000..2fe505b --- /dev/null +++ b/frontend/src/styles/tailwind.scss @@ -0,0 +1,31 @@ +// 引入 Tailwind 基础样式、组件样式、工具样式 +@tailwind base; +@tailwind components; +@tailwind utilities; + +// 全局样式重置(配合 Tailwind,避免默认样式冲突) +@layer base { + body { + @apply font-sans bg-neutral-100 text-neutral-900; // 后台默认背景、字体 + } + h1 { + @apply text-2xl font-bold; + } + h2 { + @apply text-xl font-semibold; + } + // ... 其他全局标签样式 +} + +// 自定义工具类(可选,比如自定义滚动条) +@layer utilities { + .scrollbar-hide { + scrollbar-width: none; + &::-webkit-scrollbar { + display: none; + } + } + .content-auto { + content-visibility: auto; + } +} diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts new file mode 100644 index 0000000..b5c97bb --- /dev/null +++ b/frontend/src/types/api.ts @@ -0,0 +1,17 @@ +export interface ApiResponse { + code: number; + message: string; + data: T; +} + +export interface PaginationParams { + page: number; + pageSize: number; +} + +export interface PaginationResponse { + list: T[]; + total: number; + page: number; + pageSize: number; +} diff --git a/frontend/src/types/auth.ts b/frontend/src/types/auth.ts new file mode 100644 index 0000000..9197022 --- /dev/null +++ b/frontend/src/types/auth.ts @@ -0,0 +1,23 @@ +export interface User { + id: number; + username: string; + nickname: string; + email: string; + avatar?: string; + tenantId?: number; + tenantCode?: string; + roles: string[]; + permissions: string[]; +} + +export interface LoginForm { + username: string; + password: string; + tenantCode?: string; + captcha?: string; +} + +export interface LoginResponse { + token: string; + user: User; +} diff --git a/frontend/src/types/router.ts b/frontend/src/types/router.ts new file mode 100644 index 0000000..c1d9461 --- /dev/null +++ b/frontend/src/types/router.ts @@ -0,0 +1,10 @@ +import "vue-router"; + +declare module "vue-router" { + interface RouteMeta { + title?: string; + requiresAuth?: boolean; + roles?: string[]; // 需要的角色列表 + permissions?: string[]; // 需要的权限列表 + } +} diff --git a/frontend/src/utils/auth.ts b/frontend/src/utils/auth.ts new file mode 100644 index 0000000..b15f729 --- /dev/null +++ b/frontend/src/utils/auth.ts @@ -0,0 +1,151 @@ +const TOKEN_KEY = "token"; + +/** + * 从当前 URL 路径中提取租户编码 + */ +function getTenantCodeFromUrl(): string | null { + const path = window.location.pathname; + const match = path.match(/^\/([^/]+)/); + return match ? match[1] : null; +} + +/** + * 设置 Cookie + */ +function setCookie( + name: string, + value: string, + options: { path?: string; expires?: number; maxAge?: number } = {} +): void { + const { path = "/", expires, maxAge } = options; + let cookieString = `${name}=${encodeURIComponent(value)}; path=${path}`; + + if (expires) { + const date = new Date(); + date.setTime(date.getTime() + expires * 1000); + cookieString += `; expires=${date.toUTCString()}`; + } + + if (maxAge) { + cookieString += `; max-age=${maxAge}`; + } + + document.cookie = cookieString; +} + +/** + * 获取 Cookie + */ +function getCookie(name: string): string | null { + const nameEQ = name + "="; + const ca = document.cookie.split(";"); + for (let i = 0; i < ca.length; i++) { + let c = ca[i]; + while (c.charAt(0) === " ") c = c.substring(1, c.length); + if (c.indexOf(nameEQ) === 0) { + return decodeURIComponent(c.substring(nameEQ.length, c.length)); + } + } + return null; +} + +/** + * 删除 Cookie(通过设置过期时间) + */ +function removeCookie(name: string, path: string = "/"): void { + document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=${path}`; +} + +/** + * 获取所有可能的 token cookie(因为不同租户可能有不同的 path) + */ +function getAllTokenCookies(): string | null { + // 直接尝试从当前路径获取 cookie + // 注意:由于 cookie 的 path 限制,我们只能读取当前路径或其父路径下的 cookie + // 如果 cookie 的 path 是 /tenant_code,那么只有在 /tenant_code/* 路径下才能读取 + return getCookie(TOKEN_KEY); +} + +export const getToken = (): string | null => { + return getAllTokenCookies(); +}; + +export const setToken = (token: string, tenantCode?: string): void => { + // 如果提供了租户编码,使用租户编码作为 path + // 否则从 URL 获取或使用默认路径 + const urlTenantCode = tenantCode || getTenantCodeFromUrl(); + const path = urlTenantCode ? `/${urlTenantCode}` : "/"; + + // 设置 cookie,过期时间设置为 7 天 + const expires = 7 * 24 * 60 * 60; // 7 天(秒) + setCookie(TOKEN_KEY, token, { path, expires }); +}; + +export const removeToken = (tenantCode?: string): void => { + // 如果提供了租户编码,删除该路径下的 cookie + if (tenantCode) { + removeCookie(TOKEN_KEY, `/${tenantCode}`); + } else { + // 否则从 URL 获取租户编码并删除对应路径下的 cookie + const urlTenantCode = getTenantCodeFromUrl(); + if (urlTenantCode) { + removeCookie(TOKEN_KEY, `/${urlTenantCode}`); + } + // 也删除根路径下的 cookie(如果有) + removeCookie(TOKEN_KEY, "/"); + } +}; + +/** + * 获取租户编码(从 URL 路径中获取) + * 注意:这些函数不再使用 localStorage,只从 URL 或用户信息中获取 + */ +export const getTenantCode = (): string | null => { + return getTenantCodeFromUrl(); +}; + +/** + * 设置租户编码(已废弃,不再存储到 localStorage) + * 保留此函数以保持兼容性,但不执行任何操作 + */ +export const setTenantCode = (_tenantCode: string): void => { + // 不再存储到 localStorage,租户编码从 URL 或用户信息中获取 +}; + +/** + * 移除租户编码(已废弃,不再从 localStorage 删除) + * 保留此函数以保持兼容性,但不执行任何操作 + */ +export const removeTenantCode = (): void => { + // 不再从 localStorage 删除,租户编码从 URL 或用户信息中获取 +}; + +/** + * 获取租户ID(已废弃,不再从 localStorage 获取) + * 保留此函数以保持兼容性,返回 null + */ +export const getTenantId = (): string | null => { + // 不再从 localStorage 获取,租户ID 从用户信息中获取 + return null; +}; + +/** + * 设置租户ID(已废弃,不再存储到 localStorage) + * 保留此函数以保持兼容性,但不执行任何操作 + */ +export const setTenantId = (_tenantId: number | string): void => { + // 不再存储到 localStorage,租户ID 从用户信息中获取 +}; + +/** + * 移除租户ID(已废弃,不再从 localStorage 删除) + * 保留此函数以保持兼容性,但不执行任何操作 + */ +export const removeTenantId = (): void => { + // 不再从 localStorage 删除,租户ID 从用户信息中获取 +}; + +export const clearAuth = (): void => { + // 只清除 token,租户信息不再存储在 localStorage + removeToken(); +}; diff --git a/frontend/src/utils/menu.ts b/frontend/src/utils/menu.ts new file mode 100644 index 0000000..770905f --- /dev/null +++ b/frontend/src/utils/menu.ts @@ -0,0 +1,218 @@ +import { h } from "vue"; +import type { RouteRecordRaw } from "vue-router"; +import type { MenuProps } from "ant-design-vue"; +import type { Menu } from "@/api/menus"; +import * as Icons from "@ant-design/icons-vue"; + +/** + * 组件路径映射 + * 将数据库中的组件路径映射到实际的导入函数 + * 注意:Vite 的动态 import() 不支持路径别名,所以需要在这里预先定义所有组件 + */ +const componentMap: Record Promise> = { + "workbench/Index": () => import("@/views/workbench/Index.vue"), + "system/users/Index": () => import("@/views/system/users/Index.vue"), + "system/roles/Index": () => import("@/views/system/roles/Index.vue"), + "system/permissions/Index": () => + import("@/views/system/permissions/Index.vue"), + "system/menus/Index": () => import("@/views/system/menus/Index.vue"), + "system/tenants/Index": () => import("@/views/system/tenants/Index.vue"), + "system/dict/Index": () => import("@/views/system/dict/Index.vue"), + "system/config/Index": () => import("@/views/system/config/Index.vue"), + "system/logs/Index": () => import("@/views/system/logs/Index.vue"), +}; + +/** + * 获取图标组件 + */ +export function getIconComponent(iconName: string | null | undefined) { + if (!iconName) return null; + const IconComponent = (Icons as any)[iconName]; + if (IconComponent) { + return () => h(IconComponent); + } + return null; +} + +/** + * 从菜单路径生成路由名称(与 convertMenusToRoutes 中的逻辑一致) + */ +function getRouteNameFromPath( + path: string | null | undefined, + menuId: number +): string { + if (path) { + return path + .split("/") + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(""); + } + return `Menu${menuId}`; +} + +/** + * 将数据库菜单转换为 Ant Design Vue Menu 的 items 格式 + * key 使用路由名称而不是路径 + */ +export function convertMenusToMenuItems(menus: Menu[]): MenuProps["items"] { + return menus.map((menu) => { + // 使用路由名称作为 key + const routeName = getRouteNameFromPath(menu.path, menu.id); + + const item: any = { + key: routeName, + label: menu.name, + title: menu.name, + }; + + // 添加图标 + if (menu.icon) { + const IconComponent = getIconComponent(menu.icon); + if (IconComponent) { + item.icon = IconComponent; + } + } + + // 如果有子菜单,递归处理 + if (menu.children && menu.children.length > 0) { + item.children = convertMenusToMenuItems(menu.children); + } + + return item; + }); +} + +/** + * 将数据库菜单转换为 Vue Router 的路由配置 + * 注意:这些路由会被添加到 /:tenantCode 父路由下,所以不需要再添加 tenantCode 前缀 + */ +/** + * 移除路径中与父路径重合的部分 + */ +function removeParentPathFromRoutePath( + routePath: string, + parentPath: string +): string { + if (!parentPath) { + return routePath; + } + + // 标准化路径:移除开头的斜杠 + const normalizedRoutePath = routePath.startsWith("/") + ? routePath.slice(1) + : routePath; + const normalizedParentPath = parentPath.startsWith("/") + ? parentPath.slice(1) + : parentPath; + + // 如果子路径以父路径开头,移除父路径部分 + if (normalizedRoutePath.startsWith(normalizedParentPath + "/")) { + return normalizedRoutePath.slice(normalizedParentPath.length + 1); + } else if (normalizedRoutePath === normalizedParentPath) { + // 如果路径完全相同,返回空字符串(表示当前路由) + return ""; + } + + return normalizedRoutePath; +} + +export function convertMenusToRoutes( + menus: Menu[], + parentPath: string = "" +): RouteRecordRaw[] { + const routes: RouteRecordRaw[] = []; + + menus.forEach((menu) => { + // 构建路由路径 + // 注意:这些路由会被添加到 /:tenantCode 父路由下,所以路径应该是相对路径 + let routePath = menu.path + ? menu.path.startsWith("/") + ? menu.path.slice(1) // 移除开头的斜杠,因为这是相对路径 + : menu.path + : `menu-${menu.id}`; + + // 如果有父路径,移除与父路径重合的部分 + if (parentPath) { + routePath = removeParentPathFromRoutePath(routePath, parentPath); + } + + // 构建路由名称(与 convertMenusToMenuItems 中的逻辑一致) + const routeName = getRouteNameFromPath(menu.path, menu.id); + + // 确定组件加载器 + let componentLoader: (() => Promise) | undefined; + if (menu.component) { + // 从组件映射中获取导入函数 + // 如果组件路径以 @/ 开头,说明是完整路径,需要去掉 @/views/ 前缀和 .vue 后缀来匹配 + let componentKey = menu.component; + if (componentKey.startsWith("@/views/")) { + componentKey = componentKey.replace("@/views/", "").replace(".vue", ""); + } else if (componentKey.endsWith(".vue")) { + componentKey = componentKey.replace(".vue", ""); + } + + // 从映射中获取组件导入函数 + const mappedLoader = componentMap[componentKey]; + if (mappedLoader) { + componentLoader = mappedLoader; + } else { + const componentPath = menu.component; + console.warn( + `组件路径 "${componentPath}" (key: "${componentKey}") 未在 componentMap 中定义,请添加到 menu.ts 的 componentMap 中` + ); + // 如果找不到映射,尝试直接导入(可能会失败,但至少不会阻塞) + componentLoader = () => + import( + /* @vite-ignore */ componentPath.startsWith("@/") + ? componentPath + : `@/views/${componentPath}.vue` + ); + } + } + const route: RouteRecordRaw = { + path: routePath, + name: routeName, + meta: { + title: menu.name, + requiresAuth: true, + // 如果菜单有权限要求,添加到路由meta中 + ...(menu.permission && { permissions: [menu.permission] }), + }, + ...(componentLoader && { component: componentLoader }), + // 如果有子菜单,递归处理 + // 传递完整的路径(包含父路径)给子路由,以便正确移除重合部分 + ...(menu.children && + menu.children.length > 0 && { + children: convertMenusToRoutes( + menu.children, + menu.path + ? menu.path.startsWith("/") + ? menu.path.slice(1) + : menu.path + : parentPath + ), + }), + } as RouteRecordRaw; + + routes.push(route); + }); + + console.log("routes -----", routes); + return routes; +} + +/** + * 扁平化菜单树,用于查找特定路径的菜单 + */ +export function flattenMenus(menus: Menu[]): Menu[] { + const result: Menu[] = []; + + menus.forEach((menu) => { + result.push(menu); + if (menu.children && menu.children.length > 0) { + result.push(...flattenMenus(menu.children)); + } + }); + + return result; +} diff --git a/frontend/src/utils/request.ts b/frontend/src/utils/request.ts new file mode 100644 index 0000000..83e116e --- /dev/null +++ b/frontend/src/utils/request.ts @@ -0,0 +1,100 @@ +import axios, { + InternalAxiosRequestConfig, + type AxiosInstance, + type AxiosResponse, +} from "axios"; +import { message } from "ant-design-vue"; +import { getToken, removeToken, getTenantCode } from "./auth"; +import { useAuthStore } from "@/stores/auth"; +import router from "@/router"; + +const service: AxiosInstance = axios.create({ + baseURL: "/api", + timeout: 30000, +}); + +// Request interceptor +service.interceptors.request.use( + (config: InternalAxiosRequestConfig) => { + const token = getToken(); + if (token && config.headers) { + config.headers.Authorization = `Bearer ${token}`; + } + + // 添加租户信息到请求头 + // 租户编码从 URL 获取,租户ID 从用户信息获取 + const tenantCode = getTenantCode(); + const authStore = useAuthStore(); + const tenantId = authStore.user?.tenantId; + + if (config.headers) { + if (tenantCode) { + config.headers["X-Tenant-Code"] = tenantCode; + } + if (tenantId) { + config.headers["X-Tenant-Id"] = String(tenantId); + } + } + + return config; + }, + (error) => { + return Promise.reject(error); + } +); + +// Response interceptor +service.interceptors.response.use( + (response: AxiosResponse) => { + const res = response.data; + + // 如果响应已经是统一格式 { code, message, data } + if (res && typeof res === "object" && "code" in res) { + if (res.code !== 200 && res.code !== 0) { + message.error(res.message || "请求失败"); + + if (res.code === 401) { + removeToken(); + // 从 URL 获取租户编码,跳转到对应的登录页 + const path = window.location.pathname; + const match = path.match(/^\/([^/]+)/); + const tenantCode = match ? match[1] : null; + if (tenantCode && tenantCode !== "login") { + router.push(`/${tenantCode}/login`); + } else { + router.push("/login"); + } + } + + return Promise.reject(new Error(res.message || "请求失败")); + } + + return res.data !== undefined ? res.data : res; + } + + // 如果响应是直接的数据,直接返回 + return res; + }, + (error) => { + const errorMessage = + error.response?.data?.message || error.message || "网络错误"; + message.error(errorMessage); + + if (error.response?.status === 401) { + removeToken(); + // 从 URL 获取租户编码,跳转到对应的登录页 + const path = window.location.pathname; + const match = path.match(/^\/([^/]+)/); + const tenantCode = match ? match[1] : null; + if (tenantCode && tenantCode !== "login") { + router.push(`/${tenantCode}/login`); + } else { + router.push("/login"); + } + } + + return Promise.reject(error); + } +); + +export default service; diff --git a/frontend/src/views/auth/Login.vue b/frontend/src/views/auth/Login.vue new file mode 100644 index 0000000..df5192c --- /dev/null +++ b/frontend/src/views/auth/Login.vue @@ -0,0 +1,141 @@ + + + + + diff --git a/frontend/src/views/error/403.vue b/frontend/src/views/error/403.vue new file mode 100644 index 0000000..f31a656 --- /dev/null +++ b/frontend/src/views/error/403.vue @@ -0,0 +1,27 @@ + + + + + diff --git a/frontend/src/views/error/404.vue b/frontend/src/views/error/404.vue new file mode 100644 index 0000000..c121db4 --- /dev/null +++ b/frontend/src/views/error/404.vue @@ -0,0 +1,24 @@ + + + + + diff --git a/frontend/src/views/system/config/Index.vue b/frontend/src/views/system/config/Index.vue new file mode 100644 index 0000000..7a8ad8f --- /dev/null +++ b/frontend/src/views/system/config/Index.vue @@ -0,0 +1,217 @@ + + + + + diff --git a/frontend/src/views/system/dict/Index.vue b/frontend/src/views/system/dict/Index.vue new file mode 100644 index 0000000..ba00e83 --- /dev/null +++ b/frontend/src/views/system/dict/Index.vue @@ -0,0 +1,212 @@ + + + + + diff --git a/frontend/src/views/system/menus/Index.vue b/frontend/src/views/system/menus/Index.vue new file mode 100644 index 0000000..4c814e5 --- /dev/null +++ b/frontend/src/views/system/menus/Index.vue @@ -0,0 +1,298 @@ + + + diff --git a/frontend/src/views/system/permissions/Index.vue b/frontend/src/views/system/permissions/Index.vue new file mode 100644 index 0000000..78d580e --- /dev/null +++ b/frontend/src/views/system/permissions/Index.vue @@ -0,0 +1,260 @@ + + + + + diff --git a/frontend/src/views/system/roles/Index.vue b/frontend/src/views/system/roles/Index.vue new file mode 100644 index 0000000..a4363b7 --- /dev/null +++ b/frontend/src/views/system/roles/Index.vue @@ -0,0 +1,374 @@ + + + + + diff --git a/frontend/src/views/system/tenants/Index.vue b/frontend/src/views/system/tenants/Index.vue new file mode 100644 index 0000000..bd17322 --- /dev/null +++ b/frontend/src/views/system/tenants/Index.vue @@ -0,0 +1,619 @@ + + + + + diff --git a/frontend/src/views/system/users/Index.vue b/frontend/src/views/system/users/Index.vue new file mode 100644 index 0000000..cbbdd9c --- /dev/null +++ b/frontend/src/views/system/users/Index.vue @@ -0,0 +1,462 @@ + + + diff --git a/frontend/src/views/workbench/Index.vue b/frontend/src/views/workbench/Index.vue new file mode 100644 index 0000000..46b6c2e --- /dev/null +++ b/frontend/src/views/workbench/Index.vue @@ -0,0 +1,95 @@ + + + + + diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..f4d7cda --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1,8 @@ +/// + +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent<{}, {}, any> + export default component +} + diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..49be99b --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,55 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ["./index.html", "./src/**/*.{vue,js,ts,jsx,tsx}"], + theme: { + extend: { + // 自定义颜色(品牌色、功能色) + // colors: { + // primary: { + // // 主色调(比如后台常用的蓝色) + // 50: "#f0f9ff", + // 100: "#e0f2fe", + // 500: "#0ea5e9", // 常用主色 + // 600: "#0284c7", // 加深主色 + // }, + // secondary: { + // // 辅助色(比如橙色) + // 500: "#f97316", + // }, + // neutral: { + // // 中性色(背景、文本) + // 100: "#f8fafc", + // 200: "#e2e8f0", + // 700: "#334155", + // 900: "#0f172a", + // }, + // }, + // // 自定义间距(按 8px 基准,后台常用) + // spacing: { + // 1: "4px", + // 2: "8px", + // 3: "12px", + // 4: "16px", + // 5: "20px", + // 6: "24px", + // // ... 更多间距 + // }, + // // 自定义字体(后台常用无衬线字体) + // fontFamily: { + // sans: ["Inter", "system-ui", "sans-serif"], + // }, + // // 自定义边框圆角 + // borderRadius: { + // sm: "4px", + // md: "6px", + // lg: "8px", + // }, + }, + }, + plugins: [ + // require('tailwindcss-animate'), // 额外动画插件 + ], + corePlugins: { + preflight: false, + }, +}; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..0e2bfc7 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,32 @@ +{ + "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 alias */ + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src/**/*.ts", "src/**/*.d.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..1575052 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from "vite"; +import vue from "@vitejs/plugin-vue"; +import { resolve } from "path"; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + "@": resolve(__dirname, "src"), + }, + }, + server: { + port: 3000, + proxy: { + "/api": { + target: "http://localhost:3001", + changeOrigin: true, + // rewrite: (path) => path.replace(/^\/api/, ''), + }, + }, + }, +}); diff --git a/package.json b/package.json new file mode 100644 index 0000000..b13158c --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "competition-management-system", + "version": "1.0.0", + "description": "比赛管理系统", + "private": true, + "scripts": { + "dev:frontend": "pnpm --filter frontend dev", + "dev:backend": "pnpm --filter backend start:dev", + "dev": "pnpm --parallel dev:frontend dev:backend", + "build:frontend": "pnpm --filter frontend build", + "build:backend": "pnpm --filter backend build", + "build": "pnpm build:frontend && pnpm build:backend", + "install:all": "pnpm install", + "clean": "pnpm --recursive exec rm -rf node_modules dist" + }, + "engines": { + "node": ">=18.0.0", + "pnpm": ">=10.0.0" + }, + "packageManager": "pnpm@10.22.0" +} + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..c46c923 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,8180 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: {} + + backend: + dependencies: + '@nestjs/common': + specifier: ^10.3.3 + version: 10.4.20(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/config': + specifier: ^3.1.1 + version: 3.3.0(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2) + '@nestjs/core': + specifier: ^10.3.3 + version: 10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.20)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/jwt': + specifier: ^10.2.0 + version: 10.2.0(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2)) + '@nestjs/passport': + specifier: ^10.0.3 + version: 10.0.3(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0) + '@nestjs/platform-express': + specifier: ^10.3.3 + version: 10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.20) + '@prisma/client': + specifier: ^5.9.1 + version: 5.22.0(prisma@5.22.0) + bcrypt: + specifier: ^6.0.0 + version: 6.0.0 + class-transformer: + specifier: ^0.5.1 + version: 0.5.1 + class-validator: + specifier: ^0.14.1 + version: 0.14.2 + passport: + specifier: ^0.7.0 + version: 0.7.0 + passport-jwt: + specifier: ^4.0.1 + version: 4.0.1 + passport-local: + specifier: ^1.0.0 + version: 1.0.0 + reflect-metadata: + specifier: ^0.2.1 + version: 0.2.2 + rxjs: + specifier: ^7.8.1 + version: 7.8.2 + devDependencies: + '@nestjs/cli': + specifier: ^10.3.2 + version: 10.4.9 + '@nestjs/schematics': + specifier: ^10.1.0 + version: 10.2.3(chokidar@3.6.0)(typescript@5.9.3) + '@nestjs/testing': + specifier: ^10.3.3 + version: 10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.20)(@nestjs/platform-express@10.4.20) + '@types/bcrypt': + specifier: ^5.0.2 + version: 5.0.2 + '@types/express': + specifier: ^4.17.21 + version: 4.17.25 + '@types/jest': + specifier: ^29.5.11 + version: 29.5.14 + '@types/node': + specifier: ^20.11.5 + version: 20.19.25 + '@types/passport-jwt': + specifier: ^4.0.1 + version: 4.0.1 + '@types/passport-local': + specifier: ^1.0.36 + version: 1.0.38 + '@typescript-eslint/eslint-plugin': + specifier: ^6.19.1 + version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^6.19.1 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + dotenv: + specifier: ^17.2.3 + version: 17.2.3 + dotenv-cli: + specifier: ^11.0.0 + version: 11.0.0 + eslint: + specifier: ^8.56.0 + version: 8.57.1 + eslint-config-prettier: + specifier: ^9.1.0 + version: 9.1.2(eslint@8.57.1) + eslint-plugin-prettier: + specifier: ^5.1.3 + version: 5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@9.1.2(eslint@8.57.1))(eslint@8.57.1)(prettier@3.6.2) + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@20.19.25)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)) + prettier: + specifier: ^3.2.4 + version: 3.6.2 + prisma: + specifier: ^5.9.1 + version: 5.22.0 + source-map-support: + specifier: ^0.5.21 + version: 0.5.21 + ts-jest: + specifier: ^29.1.2 + version: 29.4.5(@babel/core@7.28.5)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.5))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.25)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)))(typescript@5.9.3) + ts-loader: + specifier: ^9.5.1 + version: 9.5.4(typescript@5.9.3)(webpack@5.97.1) + ts-node: + specifier: ^10.9.2 + version: 10.9.2(@types/node@20.19.25)(typescript@5.9.3) + tsconfig-paths: + specifier: ^4.2.0 + version: 4.2.0 + typescript: + specifier: ^5.3.3 + version: 5.9.3 + + frontend: + dependencies: + '@ant-design/icons-vue': + specifier: ^7.0.1 + version: 7.0.1(vue@3.5.24(typescript@5.9.3)) + '@vee-validate/zod': + specifier: ^4.12.4 + version: 4.15.1(vue@3.5.24(typescript@5.9.3))(zod@3.25.76) + ant-design-vue: + specifier: ^4.1.1 + version: 4.2.6(vue@3.5.24(typescript@5.9.3)) + axios: + specifier: ^1.6.7 + version: 1.13.2 + dayjs: + specifier: ^1.11.10 + version: 1.11.19 + pinia: + specifier: ^2.1.7 + version: 2.3.1(typescript@5.9.3)(vue@3.5.24(typescript@5.9.3)) + vee-validate: + specifier: ^4.12.4 + version: 4.15.1(vue@3.5.24(typescript@5.9.3)) + vue: + specifier: ^3.4.21 + version: 3.5.24(typescript@5.9.3) + vue-router: + specifier: ^4.3.0 + version: 4.6.3(vue@3.5.24(typescript@5.9.3)) + zod: + specifier: ^3.22.4 + version: 3.25.76 + devDependencies: + '@vitejs/plugin-vue': + specifier: ^5.0.4 + version: 5.2.4(vite@5.4.21(@types/node@20.19.25)(sass@1.94.1)(terser@5.44.1))(vue@3.5.24(typescript@5.9.3)) + '@vue/eslint-config-typescript': + specifier: ^13.0.0 + version: 13.0.0(eslint-plugin-vue@9.33.0(eslint@8.57.1))(eslint@8.57.1)(typescript@5.9.3) + autoprefixer: + specifier: ^10.4.18 + version: 10.4.22(postcss@8.5.6) + eslint: + specifier: ^8.57.0 + version: 8.57.1 + eslint-plugin-vue: + specifier: ^9.22.0 + version: 9.33.0(eslint@8.57.1) + postcss: + specifier: ^8.4.35 + version: 8.5.6 + sass: + specifier: ^1.71.1 + version: 1.94.1 + tailwindcss: + specifier: ^3.4.1 + version: 3.4.18 + typescript: + specifier: ^5.4.3 + version: 5.9.3 + vite: + specifier: ^5.1.6 + version: 5.4.21(@types/node@20.19.25)(sass@1.94.1)(terser@5.44.1) + vue-tsc: + specifier: ^1.8.27 + version: 1.8.27(typescript@5.9.3) + +packages: + + '@alloc/quick-lru@5.2.0': + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + + '@angular-devkit/core@17.3.11': + resolution: {integrity: sha512-vTNDYNsLIWpYk2I969LMQFH29GTsLzxNk/0cLw5q56ARF0v5sIWfHYwGTS88jdDqIpuuettcSczbxeA7EuAmqQ==} + engines: {node: ^18.13.0 || >=20.9.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + peerDependencies: + chokidar: ^3.5.2 + peerDependenciesMeta: + chokidar: + optional: true + + '@angular-devkit/schematics-cli@17.3.11': + resolution: {integrity: sha512-kcOMqp+PHAKkqRad7Zd7PbpqJ0LqLaNZdY1+k66lLWmkEBozgq8v4ASn/puPWf9Bo0HpCiK+EzLf0VHE8Z/y6Q==} + engines: {node: ^18.13.0 || >=20.9.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + hasBin: true + + '@angular-devkit/schematics@17.3.11': + resolution: {integrity: sha512-I5wviiIqiFwar9Pdk30Lujk8FczEEc18i22A5c6Z9lbmhPQdTroDnEQdsfXjy404wPe8H62s0I15o4pmMGfTYQ==} + engines: {node: ^18.13.0 || >=20.9.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + + '@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' + + '@babel/code-frame@7.27.1': + resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.28.5': + resolution: {integrity: sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.28.5': + resolution: {integrity: sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.28.5': + resolution: {integrity: sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.27.2': + resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.27.1': + resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.3': + resolution: {integrity: sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.27.1': + resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==} + engines: {node: '>=6.9.0'} + + '@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/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.28.4': + resolution: {integrity: sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.28.5': + resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-syntax-async-generators@7.8.4': + resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-bigint@7.8.3': + resolution: {integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-class-properties@7.12.13': + resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-class-static-block@7.14.5': + resolution: {integrity: sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-attributes@7.27.1': + resolution: {integrity: sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-meta@7.10.4': + resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-json-strings@7.8.3': + resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-jsx@7.27.1': + resolution: {integrity: sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-logical-assignment-operators@7.10.4': + resolution: {integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3': + resolution: {integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-numeric-separator@7.10.4': + resolution: {integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-object-rest-spread@7.8.3': + resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-optional-catch-binding@7.8.3': + resolution: {integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-optional-chaining@7.8.3': + resolution: {integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-private-property-in-object@7.14.5': + resolution: {integrity: sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-top-level-await@7.14.5': + resolution: {integrity: sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.27.1': + resolution: {integrity: sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.28.4': + resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.27.2': + resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.28.5': + resolution: {integrity: sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.28.5': + resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@0.2.3': + resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + + '@borewit/text-codec@0.1.1': + resolution: {integrity: sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA==} + + '@colors/colors@1.5.0': + resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} + engines: {node: '>=0.1.90'} + + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + + '@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} + + '@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 + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@istanbuljs/load-nyc-config@1.1.0': + resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} + engines: {node: '>=8'} + + '@istanbuljs/schema@0.1.3': + resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} + engines: {node: '>=8'} + + '@jest/console@29.7.0': + resolution: {integrity: sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/core@29.7.0': + resolution: {integrity: sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + '@jest/environment@29.7.0': + resolution: {integrity: sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/expect-utils@29.7.0': + resolution: {integrity: sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/expect@29.7.0': + resolution: {integrity: sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/fake-timers@29.7.0': + resolution: {integrity: sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/globals@29.7.0': + resolution: {integrity: sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/reporters@29.7.0': + resolution: {integrity: sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + '@jest/schemas@29.6.3': + resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/source-map@29.6.3': + resolution: {integrity: sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/test-result@29.7.0': + resolution: {integrity: sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/test-sequencer@29.7.0': + resolution: {integrity: sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/transform@29.7.0': + resolution: {integrity: sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/types@29.6.3': + resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/source-map@0.3.11': + resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + + '@ljharb/through@2.3.14': + resolution: {integrity: sha512-ajBvlKpWucBB17FuQYUShqpqy8GRgYEpJW0vWJbUu1CV9lWyrDCapy0lScU8T8Z6qn49sSwJB3+M+evYIdGg+A==} + engines: {node: '>= 0.4'} + + '@lukeed/csprng@1.1.0': + resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} + engines: {node: '>=8'} + + '@nestjs/cli@10.4.9': + resolution: {integrity: sha512-s8qYd97bggqeK7Op3iD49X2MpFtW4LVNLAwXFkfbRxKME6IYT7X0muNTJ2+QfI8hpbNx9isWkrLWIp+g5FOhiA==} + engines: {node: '>= 16.14'} + hasBin: true + peerDependencies: + '@swc/cli': ^0.1.62 || ^0.3.0 || ^0.4.0 || ^0.5.0 + '@swc/core': ^1.3.62 + peerDependenciesMeta: + '@swc/cli': + optional: true + '@swc/core': + optional: true + + '@nestjs/common@10.4.20': + resolution: {integrity: sha512-hxJxZF7jcKGuUzM9EYbuES80Z/36piJbiqmPy86mk8qOn5gglFebBTvcx7PWVbRNSb4gngASYnefBj/Y2HAzpQ==} + peerDependencies: + class-transformer: '*' + class-validator: '*' + reflect-metadata: ^0.1.12 || ^0.2.0 + rxjs: ^7.1.0 + peerDependenciesMeta: + class-transformer: + optional: true + class-validator: + optional: true + + '@nestjs/config@3.3.0': + resolution: {integrity: sha512-pdGTp8m9d0ZCrjTpjkUbZx6gyf2IKf+7zlkrPNMsJzYZ4bFRRTpXrnj+556/5uiI6AfL5mMrJc2u7dB6bvM+VA==} + peerDependencies: + '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 + rxjs: ^7.1.0 + + '@nestjs/core@10.4.20': + resolution: {integrity: sha512-kRdtyKA3+Tu70N3RQ4JgmO1E3LzAMs/eppj7SfjabC7TgqNWoS4RLhWl4BqmsNVmjj6D5jgfPVtHtgYkU3AfpQ==} + peerDependencies: + '@nestjs/common': ^10.0.0 + '@nestjs/microservices': ^10.0.0 + '@nestjs/platform-express': ^10.0.0 + '@nestjs/websockets': ^10.0.0 + reflect-metadata: ^0.1.12 || ^0.2.0 + rxjs: ^7.1.0 + peerDependenciesMeta: + '@nestjs/microservices': + optional: true + '@nestjs/platform-express': + optional: true + '@nestjs/websockets': + optional: true + + '@nestjs/jwt@10.2.0': + resolution: {integrity: sha512-x8cG90SURkEiLOehNaN2aRlotxT0KZESUliOPKKnjWiyJOcWurkF3w345WOX0P4MgFzUjGoZ1Sy0aZnxeihT0g==} + peerDependencies: + '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 + + '@nestjs/passport@10.0.3': + resolution: {integrity: sha512-znJ9Y4S8ZDVY+j4doWAJ8EuuVO7SkQN3yOBmzxbGaXbvcSwFDAdGJ+OMCg52NdzIO4tQoN4pYKx8W6M0ArfFRQ==} + peerDependencies: + '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 + passport: ^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0 + + '@nestjs/platform-express@10.4.20': + resolution: {integrity: sha512-rh97mX3rimyf4xLMLHuTOBKe6UD8LOJ14VlJ1F/PTd6C6ZK9Ak6EHuJvdaGcSFQhd3ZMBh3I6CuujKGW9pNdIg==} + peerDependencies: + '@nestjs/common': ^10.0.0 + '@nestjs/core': ^10.0.0 + + '@nestjs/schematics@10.2.3': + resolution: {integrity: sha512-4e8gxaCk7DhBxVUly2PjYL4xC2ifDFexCqq1/u4TtivLGXotVk0wHdYuPYe1tHTHuR1lsOkRbfOCpkdTnigLVg==} + peerDependencies: + typescript: '>=4.8.2' + + '@nestjs/testing@10.4.20': + resolution: {integrity: sha512-nMkRDukDKskdPruM6EsgMq7yJua+CPZM6I6FrLP8yXw8BiVSPv9Nm0CtcGGwt3kgZF9hfxKjGqLjsvVBsv6Vfw==} + peerDependencies: + '@nestjs/common': ^10.0.0 + '@nestjs/core': ^10.0.0 + '@nestjs/microservices': ^10.0.0 + '@nestjs/platform-express': ^10.0.0 + peerDependenciesMeta: + '@nestjs/microservices': + optional: true + '@nestjs/platform-express': + optional: true + + '@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'} + + '@nuxtjs/opencollective@0.3.2': + resolution: {integrity: sha512-um0xL3fO7Mf4fDxcqx9KryrB7zgRM5JSlvGN5AGkP6JLM5XEKyjeAiPbNxdXVXQ16isuAhYpvP88NgL2BGd6aA==} + engines: {node: '>=8.0.0', npm: '>=5.0.0'} + hasBin: true + + '@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] + libc: [glibc] + + '@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] + libc: [musl] + + '@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] + libc: [glibc] + + '@parcel/watcher-linux-arm64-musl@2.5.1': + resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@parcel/watcher-linux-x64-glibc@2.5.1': + resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-x64-musl@2.5.1': + resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@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'} + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@pkgr/core@0.2.9': + resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + + '@prisma/client@5.22.0': + resolution: {integrity: sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==} + engines: {node: '>=16.13'} + peerDependencies: + prisma: '*' + peerDependenciesMeta: + prisma: + optional: true + + '@prisma/debug@5.22.0': + resolution: {integrity: sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==} + + '@prisma/engines-version@5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2': + resolution: {integrity: sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==} + + '@prisma/engines@5.22.0': + resolution: {integrity: sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==} + + '@prisma/fetch-engine@5.22.0': + resolution: {integrity: sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==} + + '@prisma/get-platform@5.22.0': + resolution: {integrity: sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==} + + '@rollup/rollup-android-arm-eabi@4.53.3': + resolution: {integrity: sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.53.3': + resolution: {integrity: sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.53.3': + resolution: {integrity: sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.53.3': + resolution: {integrity: sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.53.3': + resolution: {integrity: sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.53.3': + resolution: {integrity: sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.53.3': + resolution: {integrity: sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.53.3': + resolution: {integrity: sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.53.3': + resolution: {integrity: sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.53.3': + resolution: {integrity: sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.53.3': + resolution: {integrity: sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-gnu@4.53.3': + resolution: {integrity: sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-gnu@4.53.3': + resolution: {integrity: sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.53.3': + resolution: {integrity: sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.53.3': + resolution: {integrity: sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.53.3': + resolution: {integrity: sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.53.3': + resolution: {integrity: sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openharmony-arm64@4.53.3': + resolution: {integrity: sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.53.3': + resolution: {integrity: sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.53.3': + resolution: {integrity: sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.53.3': + resolution: {integrity: sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.53.3': + resolution: {integrity: sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==} + cpu: [x64] + os: [win32] + + '@simonwep/pickr@1.8.2': + resolution: {integrity: sha512-/l5w8BIkrpP6n1xsetx9MWPWlU6OblN5YgZZphxan0Tq4BByTCETL6lyIeY8lagalS2Nbt4F2W034KHLIiunKA==} + + '@sinclair/typebox@0.27.8': + resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} + + '@sinonjs/commons@3.0.1': + resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} + + '@sinonjs/fake-timers@10.3.0': + resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + + '@tokenizer/inflate@0.2.7': + resolution: {integrity: sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg==} + engines: {node: '>=18'} + + '@tokenizer/token@0.3.0': + resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + + '@tsconfig/node10@1.0.12': + resolution: {integrity: sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==} + + '@tsconfig/node12@1.0.11': + resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} + + '@tsconfig/node14@1.0.3': + resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==} + + '@tsconfig/node16@1.0.4': + resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/bcrypt@5.0.2': + resolution: {integrity: sha512-6atioO8Y75fNcbmj0G7UjI9lXN2pQ/IGJ2FWT4a/btd0Lk9lQalHLKhkgKVZ3r+spnmWUKfbMi1GEe9wyHQfNQ==} + + '@types/body-parser@1.19.6': + resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + + '@types/eslint-scope@3.7.7': + resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} + + '@types/eslint@9.6.1': + resolution: {integrity: sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/express-serve-static-core@4.19.7': + resolution: {integrity: sha512-FvPtiIf1LfhzsaIXhv/PHan/2FeQBbtBDtfX2QfvPxdUelMDEckK08SM6nqo1MIZY3RUlfA+HV8+hFUSio78qg==} + + '@types/express@4.17.25': + resolution: {integrity: sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==} + + '@types/graceful-fs@4.1.9': + resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} + + '@types/http-errors@2.0.5': + resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + + '@types/istanbul-lib-coverage@2.0.6': + resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} + + '@types/istanbul-lib-report@3.0.3': + resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} + + '@types/istanbul-reports@3.0.4': + resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + + '@types/jest@29.5.14': + resolution: {integrity: sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/jsonwebtoken@9.0.10': + resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + + '@types/jsonwebtoken@9.0.5': + resolution: {integrity: sha512-VRLSGzik+Unrup6BsouBeHsf4d1hOEgYWTm/7Nmw1sXoN1+tRly/Gy/po3yeahnP4jfnQWWAhQAqcNfH7ngOkA==} + + '@types/mime@1.3.5': + resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/node@20.19.25': + resolution: {integrity: sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==} + + '@types/passport-jwt@4.0.1': + resolution: {integrity: sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==} + + '@types/passport-local@1.0.38': + resolution: {integrity: sha512-nsrW4A963lYE7lNTv9cr5WmiUD1ibYJvWrpE13oxApFsRt77b0RdtZvKbCdNIY4v/QZ6TRQWaDDEwV1kCTmcXg==} + + '@types/passport-strategy@0.2.38': + resolution: {integrity: sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==} + + '@types/passport@1.0.17': + resolution: {integrity: sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==} + + '@types/qs@6.14.0': + resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + + '@types/semver@7.7.1': + resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} + + '@types/send@0.17.6': + resolution: {integrity: sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==} + + '@types/send@1.2.1': + resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} + + '@types/serve-static@1.15.10': + resolution: {integrity: sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==} + + '@types/stack-utils@2.0.3': + resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + + '@types/validator@13.15.10': + resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==} + + '@types/yargs-parser@21.0.3': + resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} + + '@types/yargs@17.0.35': + resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} + + '@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/eslint-plugin@7.18.0': + resolution: {integrity: sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw==} + engines: {node: ^18.18.0 || >=20.0.0} + peerDependencies: + '@typescript-eslint/parser': ^7.0.0 + eslint: ^8.56.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/parser@7.18.0': + resolution: {integrity: sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==} + engines: {node: ^18.18.0 || >=20.0.0} + peerDependencies: + eslint: ^8.56.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/scope-manager@7.18.0': + resolution: {integrity: sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==} + engines: {node: ^18.18.0 || >=20.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/type-utils@7.18.0': + resolution: {integrity: sha512-XL0FJXuCLaDuX2sYqZUUSOJ2sG5/i1AAze+axqmLnSkNEVMVYLF+cbwlB2w8D1tinFuSikHmFta+P+HOofrLeA==} + engines: {node: ^18.18.0 || >=20.0.0} + peerDependencies: + eslint: ^8.56.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/types@7.18.0': + resolution: {integrity: sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==} + engines: {node: ^18.18.0 || >=20.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/typescript-estree@7.18.0': + resolution: {integrity: sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==} + engines: {node: ^18.18.0 || >=20.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/utils@7.18.0': + resolution: {integrity: sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw==} + engines: {node: ^18.18.0 || >=20.0.0} + peerDependencies: + eslint: ^8.56.0 + + '@typescript-eslint/visitor-keys@6.21.0': + resolution: {integrity: sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==} + engines: {node: ^16.0.0 || >=18.0.0} + + '@typescript-eslint/visitor-keys@7.18.0': + resolution: {integrity: sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==} + engines: {node: ^18.18.0 || >=20.0.0} + + '@ungap/structured-clone@1.3.0': + resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + + '@vee-validate/zod@4.15.1': + resolution: {integrity: sha512-329Z4TDBE5Vx0FdbA8S4eR9iGCFFUNGbxjpQ20ff5b5wGueScjocUIx9JHPa79LTG06RnlUR4XogQsjN4tecKA==} + peerDependencies: + zod: ^3.24.0 + + '@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/devtools-api@7.7.9': + resolution: {integrity: sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==} + + '@vue/devtools-kit@7.7.9': + resolution: {integrity: sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==} + + '@vue/devtools-shared@7.7.9': + resolution: {integrity: sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==} + + '@vue/eslint-config-typescript@13.0.0': + resolution: {integrity: sha512-MHh9SncG/sfqjVqjcuFLOLD6Ed4dRAis4HNt0dXASeAuLqIAx4YMB1/m2o4pUKK1vCt8fUvYG8KKX2Ot3BVZTg==} + engines: {node: ^18.18.0 || >=20.0.0} + peerDependencies: + eslint: ^8.56.0 + eslint-plugin-vue: ^9.0.0 + typescript: '>=4.7.4' + peerDependenciesMeta: + typescript: + optional: true + + '@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==} + + '@webassemblyjs/ast@1.14.1': + resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} + + '@webassemblyjs/floating-point-hex-parser@1.13.2': + resolution: {integrity: sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==} + + '@webassemblyjs/helper-api-error@1.13.2': + resolution: {integrity: sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==} + + '@webassemblyjs/helper-buffer@1.14.1': + resolution: {integrity: sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==} + + '@webassemblyjs/helper-numbers@1.13.2': + resolution: {integrity: sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==} + + '@webassemblyjs/helper-wasm-bytecode@1.13.2': + resolution: {integrity: sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==} + + '@webassemblyjs/helper-wasm-section@1.14.1': + resolution: {integrity: sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==} + + '@webassemblyjs/ieee754@1.13.2': + resolution: {integrity: sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==} + + '@webassemblyjs/leb128@1.13.2': + resolution: {integrity: sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==} + + '@webassemblyjs/utf8@1.13.2': + resolution: {integrity: sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==} + + '@webassemblyjs/wasm-edit@1.14.1': + resolution: {integrity: sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==} + + '@webassemblyjs/wasm-gen@1.14.1': + resolution: {integrity: sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==} + + '@webassemblyjs/wasm-opt@1.14.1': + resolution: {integrity: sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==} + + '@webassemblyjs/wasm-parser@1.14.1': + resolution: {integrity: sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==} + + '@webassemblyjs/wast-printer@1.14.1': + resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==} + + '@xtuc/ieee754@1.2.0': + resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} + + '@xtuc/long@4.2.2': + resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} + + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn-walk@8.3.4: + resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} + engines: {node: '>=0.4.0'} + + acorn@8.15.0: + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv-formats@2.1.1: + resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv-keywords@3.5.2: + resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==} + peerDependencies: + ajv: ^6.9.1 + + ajv-keywords@5.1.0: + resolution: {integrity: sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==} + peerDependencies: + ajv: ^8.8.2 + + ajv@6.12.6: + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + + ajv@8.12.0: + resolution: {integrity: sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==} + + ajv@8.17.1: + resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + + ansi-escapes@4.3.2: + resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} + engines: {node: '>=8'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + ant-design-vue@4.2.6: + resolution: {integrity: sha512-t7eX13Yj3i9+i5g9lqFyYneoIb3OzTvQjq9Tts1i+eiOd3Eva/6GagxBSXM1fOCjqemIu0FYVE1ByZ/38epR3Q==} + engines: {node: '>=12.22.0'} + peerDependencies: + vue: '>=3.2.0' + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + append-field@1.0.0: + resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==} + + arg@4.1.3: + resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} + + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + array-flatten@1.1.1: + resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + + array-timsort@1.0.3: + resolution: {integrity: sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==} + + 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==} + + autoprefixer@10.4.22: + resolution: {integrity: sha512-ARe0v/t9gO28Bznv6GgqARmVqcWOV3mfgUPn9becPHMiD3o9BwlRgaeccZnwTpZ7Zwqrm+c1sUSsMxIzQzc8Xg==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + + axios@1.13.2: + resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==} + + babel-jest@29.7.0: + resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@babel/core': ^7.8.0 + + babel-plugin-istanbul@6.1.1: + resolution: {integrity: sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==} + engines: {node: '>=8'} + + babel-plugin-jest-hoist@29.6.3: + resolution: {integrity: sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + babel-preset-current-node-syntax@1.2.0: + resolution: {integrity: sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==} + peerDependencies: + '@babel/core': ^7.0.0 || ^8.0.0-0 + + babel-preset-jest@29.6.3: + resolution: {integrity: sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@babel/core': ^7.0.0 + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + baseline-browser-mapping@2.8.29: + resolution: {integrity: sha512-sXdt2elaVnhpDNRDz+1BDx1JQoJRuNk7oVlAlbGiFkLikHCAQiccexF/9e91zVi6RCgqspl04aP+6Cnl9zRLrA==} + hasBin: true + + bcrypt@6.0.0: + resolution: {integrity: sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==} + engines: {node: '>= 18'} + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + birpc@2.8.0: + resolution: {integrity: sha512-Bz2a4qD/5GRhiHSwj30c/8kC8QGj12nNDwz3D4ErQ4Xhy35dsSDvF+RA/tWpjyU0pdGtSDiEk6B5fBGE1qNVhw==} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + body-parser@1.20.3: + resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + 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'} + + browserslist@4.28.0: + resolution: {integrity: sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + bs-logger@0.2.6: + resolution: {integrity: sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==} + engines: {node: '>= 6'} + + bser@2.1.1: + resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} + + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + busboy@1.6.0: + resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} + engines: {node: '>=10.16.0'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.8: + resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + camelcase-css@2.0.1: + resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} + engines: {node: '>= 6'} + + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + + camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + + caniuse-lite@1.0.30001756: + resolution: {integrity: sha512-4HnCNKbMLkLdhJz3TToeVWHSnfJvPaq6vu/eRP0Ahub/07n484XHhBF5AJoSGHdVrS8tKFauUQz8Bp9P7LVx7A==} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + char-regex@1.0.2: + resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} + engines: {node: '>=10'} + + chardet@0.7.0: + resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==} + + 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'} + + chrome-trace-event@1.0.4: + resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} + engines: {node: '>=6.0'} + + ci-info@3.9.0: + resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} + engines: {node: '>=8'} + + cjs-module-lexer@1.4.3: + resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} + + class-transformer@0.5.1: + resolution: {integrity: sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==} + + class-validator@0.14.2: + resolution: {integrity: sha512-3kMVRF2io8N8pY1IFIXlho9r8IPUUIfHe2hYVtiebvAzU2XeQFXTv+XI4WX+TnXmtwXMDcjngcpkiPM0O9PvLw==} + + cli-cursor@3.1.0: + resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} + engines: {node: '>=8'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + + cli-table3@0.6.5: + resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} + engines: {node: 10.* || >= 12.*} + + cli-width@3.0.0: + resolution: {integrity: sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==} + engines: {node: '>= 10'} + + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + clone@1.0.4: + resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} + engines: {node: '>=0.8'} + + co@4.6.0: + resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} + engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} + + collect-v8-coverage@1.0.3: + resolution: {integrity: sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==} + + 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'} + + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + comment-json@4.2.5: + resolution: {integrity: sha512-bKw/r35jR3HGt5PEPm1ljsQQGyCrR8sFGNiN5L+ykDHdpO8Smxkrkla9Yi6NkQyUrb8V54PGhfMs6NrIwtxtdw==} + engines: {node: '>= 6'} + + 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==} + + concat-stream@2.0.0: + resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} + engines: {'0': node >= 6.0} + + consola@2.15.3: + resolution: {integrity: sha512-9vAdYbHj6x2fLKC4+oPH0kFzY/orMZyG2Aj+kNylHxKGJ/Ed4dpNyAQYwJOdqO4zdM7XpVHmyejQDcQHrnuXbw==} + + content-disposition@0.5.4: + resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} + engines: {node: '>= 0.6'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-signature@1.0.6: + resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} + + cookie@0.7.1: + resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==} + engines: {node: '>= 0.6'} + + copy-anything@4.0.5: + resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} + engines: {node: '>=18'} + + core-js@3.47.0: + resolution: {integrity: sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==} + + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + cors@2.8.5: + resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==} + engines: {node: '>= 0.10'} + + cosmiconfig@8.3.6: + resolution: {integrity: sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + + create-jest@29.7.0: + resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + + create-require@1.1.1: + resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + + 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.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + dayjs@1.11.19: + resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==} + + de-indent@1.0.2: + resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} + + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + dedent@1.7.0: + resolution: {integrity: sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ==} + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + defaults@1.0.4: + resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + detect-libc@1.0.3: + resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==} + engines: {node: '>=0.10'} + hasBin: true + + detect-newline@3.1.0: + resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} + engines: {node: '>=8'} + + didyoumean@1.2.2: + resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + + diff-sequences@29.6.3: + resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + diff@4.0.2: + resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} + engines: {node: '>=0.3.1'} + + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + + dlv@1.1.3: + resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + + 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==} + + dotenv-cli@11.0.0: + resolution: {integrity: sha512-r5pA8idbk7GFWuHEU7trSTflWcdBpQEK+Aw17UrSHjS6CReuhrrPcyC3zcQBPQvhArRHnBo/h6eLH1fkCvNlww==} + hasBin: true + + dotenv-expand@10.0.0: + resolution: {integrity: sha512-GopVGCpVS1UKH75VKHGuQFqS1Gusej0z4FyQkPdwjil2gNIv+LNsqBlboOzpJFZKVT95GkCyWJbBSdFEFUWI2A==} + engines: {node: '>=12'} + + dotenv-expand@12.0.3: + resolution: {integrity: sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==} + engines: {node: '>=12'} + + dotenv@16.4.5: + resolution: {integrity: sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==} + engines: {node: '>=12'} + + dotenv@17.2.3: + resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} + engines: {node: '>=12'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + electron-to-chromium@1.5.256: + resolution: {integrity: sha512-uqYq1IQhpXXLX+HgiXdyOZml7spy4xfy42yPxcCCRjswp0fYM2X+JwCON07lqnpLEGVCj739B7Yr+FngmHBMEQ==} + + emittery@0.13.1: + resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} + engines: {node: '>=12'} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + encodeurl@1.0.2: + resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} + engines: {node: '>= 0.8'} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + enhanced-resolve@5.18.3: + resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==} + engines: {node: '>=10.13.0'} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + 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-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + 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 + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + + escape-string-regexp@2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-config-prettier@9.1.2: + resolution: {integrity: sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + + eslint-plugin-prettier@5.5.4: + resolution: {integrity: sha512-swNtI95SToIz05YINMA6Ox5R057IMAmWZ26GqPxusAp1TZzj+IdY9tXNWWD3vkF/wEqydCONcwjTFpxybBqZsg==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + '@types/eslint': '>=8.0.0' + eslint: '>=8.0.0' + eslint-config-prettier: '>= 7.0.0 <10.0.0 || >=10.1.0' + prettier: '>=3.0.0' + peerDependenciesMeta: + '@types/eslint': + optional: true + eslint-config-prettier: + optional: true + + 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@5.1.1: + resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} + engines: {node: '>=8.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} + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + 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@4.3.0: + resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} + 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==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + + execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + + exit@0.1.2: + resolution: {integrity: sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==} + engines: {node: '>= 0.8.0'} + + expect@29.7.0: + resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + express@4.21.2: + resolution: {integrity: sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==} + engines: {node: '>= 0.10.0'} + + external-editor@3.1.0: + resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==} + engines: {node: '>=4'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-diff@1.3.0: + resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} + + 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==} + + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + + fastq@1.19.1: + resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} + + fb-watchman@2.0.2: + resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} + + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + + figures@3.2.0: + resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} + engines: {node: '>=8'} + + file-entry-cache@6.0.1: + resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} + engines: {node: ^10.12.0 || >=12.0.0} + + file-type@20.4.1: + resolution: {integrity: sha512-hw9gNZXUfZ02Jo0uafWLaFVPter5/k2rfcrjFJJHX/77xtSDOfJuEFb6oKlFV86FLP1SuyHMW1PSk0U9M5tKkQ==} + engines: {node: '>=18'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + finalhandler@1.3.1: + resolution: {integrity: sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==} + engines: {node: '>= 0.8'} + + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + 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 + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + fork-ts-checker-webpack-plugin@9.0.2: + resolution: {integrity: sha512-Uochze2R8peoN1XqlSi/rGUkDQpRogtLFocP9+PGu68zk1BDAKXfdeCdyVZpgTk8V8WFVQXdEz426VKjXLO1Gg==} + engines: {node: '>=12.13.0', yarn: '>=1.0.0'} + peerDependencies: + typescript: '>3.6.0' + webpack: ^5.11.0 + + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fraction.js@5.3.4: + resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + + fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + + fs-extra@10.1.0: + resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} + engines: {node: '>=12'} + + fs-monkey@1.1.0: + resolution: {integrity: sha512-QMUezzXWII9EV5aTFXW1UBVUO77wYPpjqIF8/AviUCThNeSYZykpoTixUeaNNBwmCev0AMDWMAni+f8Hxb1IFw==} + + 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==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-package-type@0.1.0: + resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} + engines: {node: '>=8.0.0'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + + 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-to-regexp@0.4.1: + resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} + + glob@10.4.5: + resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + hasBin: true + + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + hasBin: true + + 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'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + graphemer@1.4.0: + resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + + handlebars@4.7.8: + resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==} + engines: {node: '>=0.4.7'} + hasBin: true + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-own-prop@2.0.0: + resolution: {integrity: sha512-Pq0h+hvsVm6dDEa8x82GnLSYHOzNDt7f0ddFa3FqcQlgzEiptPqL+XrOJNavjOzSYiYWIrgeVYYgGlLmnxwilQ==} + engines: {node: '>=8'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + 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 + + hookable@5.5.3: + resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + http-errors@2.0.0: + resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} + engines: {node: '>= 0.8'} + + human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + + iconv-lite@0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + engines: {node: '>=0.10.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + 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'} + + import-local@3.2.0: + resolution: {integrity: sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==} + engines: {node: '>=8'} + hasBin: true + + 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==} + + inquirer@8.2.6: + resolution: {integrity: sha512-M1WuAmb7pn9zdFRtQYk26ZBoY043Sse0wVDdk4Bppr+JOXyQYybdtvK+l9wUibhtjdjvtoiNy8tk+EgsYIUqKg==} + engines: {node: '>=12.0.0'} + + inquirer@9.2.15: + resolution: {integrity: sha512-vI2w4zl/mDluHt9YEQ/543VTCwPKWiHzKtm9dM2V0NdFcqEexDAjUHzO1oA60HRNaVifGXXM1tRRNluLVHa0Kg==} + engines: {node: '>=18'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + 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-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-generator-fn@2.1.0: + resolution: {integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==} + engines: {node: '>=6'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-interactive@1.0.0: + resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} + engines: {node: '>=8'} + + 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'} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + is-unicode-supported@0.1.0: + resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} + engines: {node: '>=10'} + + is-what@5.5.0: + resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==} + engines: {node: '>=18'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-instrument@5.2.1: + resolution: {integrity: sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==} + engines: {node: '>=8'} + + istanbul-lib-instrument@6.0.3: + resolution: {integrity: sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==} + engines: {node: '>=10'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@4.0.1: + resolution: {integrity: sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + iterare@1.2.1: + resolution: {integrity: sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==} + engines: {node: '>=6'} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + jest-changed-files@29.7.0: + resolution: {integrity: sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-circus@29.7.0: + resolution: {integrity: sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-cli@29.7.0: + resolution: {integrity: sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + jest-config@29.7.0: + resolution: {integrity: sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@types/node': '*' + ts-node: '>=9.0.0' + peerDependenciesMeta: + '@types/node': + optional: true + ts-node: + optional: true + + jest-diff@29.7.0: + resolution: {integrity: sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-docblock@29.7.0: + resolution: {integrity: sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-each@29.7.0: + resolution: {integrity: sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-environment-node@29.7.0: + resolution: {integrity: sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-get-type@29.6.3: + resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-haste-map@29.7.0: + resolution: {integrity: sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-leak-detector@29.7.0: + resolution: {integrity: sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-matcher-utils@29.7.0: + resolution: {integrity: sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-message-util@29.7.0: + resolution: {integrity: sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-mock@29.7.0: + resolution: {integrity: sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-pnp-resolver@1.2.3: + resolution: {integrity: sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==} + engines: {node: '>=6'} + peerDependencies: + jest-resolve: '*' + peerDependenciesMeta: + jest-resolve: + optional: true + + jest-regex-util@29.6.3: + resolution: {integrity: sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-resolve-dependencies@29.7.0: + resolution: {integrity: sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-resolve@29.7.0: + resolution: {integrity: sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-runner@29.7.0: + resolution: {integrity: sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-runtime@29.7.0: + resolution: {integrity: sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-snapshot@29.7.0: + resolution: {integrity: sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-util@29.7.0: + resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-validate@29.7.0: + resolution: {integrity: sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-watcher@29.7.0: + resolution: {integrity: sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-worker@27.5.1: + resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} + engines: {node: '>= 10.13.0'} + + jest-worker@29.7.0: + resolution: {integrity: sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest@29.7.0: + resolution: {integrity: sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + jiti@1.21.7: + resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@3.14.2: + resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} + hasBin: true + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonc-parser@3.2.1: + resolution: {integrity: sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA==} + + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + + jsonfile@6.2.0: + resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + + jsonwebtoken@9.0.2: + resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} + engines: {node: '>=12', npm: '>=6'} + + jwa@1.4.2: + resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==} + + jws@3.2.2: + resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + kleur@3.0.3: + resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} + engines: {node: '>=6'} + + leven@3.1.0: + resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} + engines: {node: '>=6'} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + libphonenumber-js@1.12.27: + resolution: {integrity: sha512-8gHhHzzcnY1EF4BS5L/lrjv2VAZWd6ltU7c/sqoktRZSQvZl4g8hrgXtXHXGkSFKFYArFON12zUNJrNVqJ9u4g==} + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + loader-runner@4.3.1: + resolution: {integrity: sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==} + engines: {node: '>=6.11.5'} + + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + + 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.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + + lodash.memoize@4.1.2: + resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + + lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + + log-symbols@4.1.0: + resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} + engines: {node: '>=10'} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magic-string@0.30.8: + resolution: {integrity: sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==} + engines: {node: '>=12'} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + make-error@1.3.6: + resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + + makeerror@1.0.12: + resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + + memfs@3.5.3: + resolution: {integrity: sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==} + engines: {node: '>= 4.0.0'} + + merge-descriptors@1.0.3: + resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + + 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'} + + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=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'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@7.1.2: + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} + + mitt@3.0.1: + resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + + mkdirp@0.5.6: + resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} + hasBin: true + + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + muggle-string@0.3.1: + resolution: {integrity: sha512-ckmWDJjphvd/FvZawgygcUeQCxzvohjFO5RxTjj4eq8kw359gFF3E1brjfI+viLMxss5JrHTDRHZvu2/tuy0Qg==} + + multer@2.0.2: + resolution: {integrity: sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==} + engines: {node: '>= 10.16.0'} + + mute-stream@0.0.8: + resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==} + + mute-stream@1.0.0: + resolution: {integrity: sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + 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==} + + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + + neo-async@2.6.2: + resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + + node-abort-controller@3.1.1: + resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} + + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + + node-addon-api@8.5.0: + resolution: {integrity: sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==} + engines: {node: ^18 || ^20 || >= 21} + + node-emoji@1.11.0: + resolution: {integrity: sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A==} + + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + + node-int64@0.4.0: + resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} + + node-releases@2.0.27: + resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + normalize-range@0.1.2: + resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==} + engines: {node: '>=0.10.0'} + + npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + ora@5.4.1: + resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} + engines: {node: '>=10'} + + os-tmpdir@1.0.2: + resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==} + engines: {node: '>=0.10.0'} + + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + passport-jwt@4.0.1: + resolution: {integrity: sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==} + + passport-local@1.0.0: + resolution: {integrity: sha512-9wCE6qKznvf9mQYYbgJ3sVOHmCWoUNMVFoZzNoznmISbhnNNPhN9xfY3sLmScHMetEJeoY7CXwfhCe7argfQow==} + engines: {node: '>= 0.4.0'} + + passport-strategy@1.0.0: + resolution: {integrity: sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==} + engines: {node: '>= 0.4.0'} + + passport@0.7.0: + resolution: {integrity: sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==} + engines: {node: '>= 0.4.0'} + + 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-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + path-to-regexp@0.1.12: + resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==} + + path-to-regexp@3.3.0: + resolution: {integrity: sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + pause@0.0.1: + resolution: {integrity: sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==} + + perfect-debounce@1.0.0: + resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + + 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.1: + resolution: {integrity: sha512-xUXwsxNjwTQ8K3GnT4pCJm+xq3RUPQbmkYJTP5aFIfNIvbcc/4MUxgBaaRSZJ6yGJZiGSyYlM6MzwTsRk8SYCg==} + engines: {node: '>=12'} + + pify@2.3.0: + resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} + engines: {node: '>=0.10.0'} + + 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 + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + pkg-dir@4.2.0: + resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} + engines: {node: '>=8'} + + pluralize@8.0.0: + resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} + engines: {node: '>=4'} + + postcss-import@15.1.0: + resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} + engines: {node: '>=14.0.0'} + peerDependencies: + postcss: ^8.0.0 + + postcss-js@4.1.0: + resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==} + engines: {node: ^12 || ^14 || >= 16} + peerDependencies: + postcss: ^8.4.21 + + postcss-load-config@6.0.1: + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} + peerDependencies: + jiti: '>=1.21.0' + postcss: '>=8.0.9' + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + jiti: + optional: true + postcss: + optional: true + tsx: + optional: true + yaml: + optional: true + + postcss-nested@6.2.0: + resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.2.14 + + postcss-selector-parser@6.1.2: + resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} + engines: {node: '>=4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + 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-linter-helpers@1.0.0: + resolution: {integrity: sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==} + engines: {node: '>=6.0.0'} + + prettier@3.6.2: + resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==} + engines: {node: '>=14'} + hasBin: true + + pretty-format@29.7.0: + resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + prisma@5.22.0: + resolution: {integrity: sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==} + engines: {node: '>=16.13'} + hasBin: true + + prompts@2.4.2: + resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} + engines: {node: '>= 6'} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + 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'} + + pure-rand@6.1.0: + resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + + qs@6.13.0: + resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==} + engines: {node: '>=0.6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + randombytes@2.1.0: + resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@2.5.2: + resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==} + engines: {node: '>= 0.8'} + + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + + read-cache@1.0.0: + resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + 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'} + + reflect-metadata@0.2.2: + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + + repeat-string@1.6.1: + resolution: {integrity: sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==} + engines: {node: '>=0.10'} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resize-observer-polyfill@1.5.1: + resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==} + + resolve-cwd@3.0.0: + resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} + engines: {node: '>=8'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + resolve.exports@2.0.3: + resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==} + engines: {node: '>=10'} + + resolve@1.22.11: + resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} + engines: {node: '>= 0.4'} + hasBin: true + + restore-cursor@3.1.0: + resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} + engines: {node: '>=8'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + 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.3: + resolution: {integrity: sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + run-async@2.4.1: + resolution: {integrity: sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==} + engines: {node: '>=0.12.0'} + + run-async@3.0.0: + resolution: {integrity: sha512-540WwVDOMxA6dN6We19EcT9sc3hkXPw5mzRNGM3FkdN/vtE9NFvj5lFAPNwUDmJjXidm3v7TC1cTE7t17Ulm1Q==} + engines: {node: '>=0.12.0'} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + rxjs@7.8.1: + resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==} + + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + sass@1.94.1: + resolution: {integrity: sha512-/YVm5FRQaRlr3oNh2LLFYne1PdPlRZGyKnHh1sLleOqLcohTR4eUUvBjBIqkl1fEXd1MGOHgzJGJh+LgTtV4KQ==} + engines: {node: '>=14.0.0'} + hasBin: true + + schema-utils@3.3.0: + resolution: {integrity: sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==} + engines: {node: '>= 10.13.0'} + + schema-utils@4.3.3: + resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==} + engines: {node: '>= 10.13.0'} + + scroll-into-view-if-needed@2.2.31: + resolution: {integrity: sha512-dGCXy99wZQivjmjIqihaBQNjryrz5rueJY7eHfTdyWEiR4ttYpsajb14rn9s5d4DY4EcY6+4+U/maARBXJedkA==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.7.3: + resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + engines: {node: '>=10'} + hasBin: true + + send@0.19.0: + resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==} + engines: {node: '>= 0.8.0'} + + serialize-javascript@6.0.2: + resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} + + serve-static@1.16.2: + resolution: {integrity: sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==} + engines: {node: '>= 0.8.0'} + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + 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'} + + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + 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'} + + source-map-support@0.5.13: + resolution: {integrity: sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + source-map@0.7.4: + resolution: {integrity: sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==} + engines: {node: '>= 8'} + + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + + speakingurl@14.0.1: + resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} + engines: {node: '>=0.10.0'} + + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + + stack-utils@2.0.6: + resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} + engines: {node: '>=10'} + + statuses@2.0.1: + resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} + engines: {node: '>= 0.8'} + + streamsearch@1.1.0: + resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} + engines: {node: '>=10.0.0'} + + string-length@4.0.2: + resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} + engines: {node: '>=10'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.1.2: + resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} + engines: {node: '>=12'} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + strip-bom@4.0.0: + resolution: {integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==} + engines: {node: '>=8'} + + strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + strtok3@10.3.4: + resolution: {integrity: sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==} + engines: {node: '>=18'} + + stylis@4.3.6: + resolution: {integrity: sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==} + + sucrase@3.35.0: + resolution: {integrity: sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + superjson@2.2.5: + resolution: {integrity: sha512-zWPTX96LVsA/eVYnqOM2+ofcdPqdS1dAF1LN4TS2/MWuUpfitd9ctTa87wt4xrYnZnkLtS69xpBdSxVBP5Rm6w==} + engines: {node: '>=16'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + symbol-observable@4.0.0: + resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==} + engines: {node: '>=0.10'} + + synckit@0.11.11: + resolution: {integrity: sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==} + engines: {node: ^14.18.0 || >=16.0.0} + + tailwindcss@3.4.18: + resolution: {integrity: sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==} + engines: {node: '>=14.0.0'} + hasBin: true + + tapable@2.3.0: + resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} + engines: {node: '>=6'} + + terser-webpack-plugin@5.3.14: + resolution: {integrity: sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==} + engines: {node: '>= 10.13.0'} + peerDependencies: + '@swc/core': '*' + esbuild: '*' + uglify-js: '*' + webpack: ^5.1.0 + peerDependenciesMeta: + '@swc/core': + optional: true + esbuild: + optional: true + uglify-js: + optional: true + + terser@5.44.1: + resolution: {integrity: sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==} + engines: {node: '>=10'} + hasBin: true + + test-exclude@6.0.0: + resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} + engines: {node: '>=8'} + + text-table@0.2.0: + resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + throttle-debounce@5.0.2: + resolution: {integrity: sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==} + engines: {node: '>=12.22'} + + through@2.3.8: + resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + + tmp@0.0.33: + resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} + engines: {node: '>=0.6.0'} + + tmpl@1.0.5: + resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + token-types@6.1.1: + resolution: {integrity: sha512-kh9LVIWH5CnL63Ipf0jhlBIy0UsrMj/NJDfpsy1SqOXlLKEVyXXYrnFxFT1yOOYVGBSApeVnjPw/sBz5BfEjAQ==} + engines: {node: '>=14.16'} + + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + ts-api-utils@1.4.3: + resolution: {integrity: sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==} + engines: {node: '>=16'} + peerDependencies: + typescript: '>=4.2.0' + + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + ts-jest@29.4.5: + resolution: {integrity: sha512-HO3GyiWn2qvTQA4kTgjDcXiMwYQt68a1Y8+JuLRVpdIzm+UOLSHgl/XqR4c6nzJkq5rOkjc02O2I7P7l/Yof0Q==} + engines: {node: ^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@babel/core': '>=7.0.0-beta.0 <8' + '@jest/transform': ^29.0.0 || ^30.0.0 + '@jest/types': ^29.0.0 || ^30.0.0 + babel-jest: ^29.0.0 || ^30.0.0 + esbuild: '*' + jest: ^29.0.0 || ^30.0.0 + jest-util: ^29.0.0 || ^30.0.0 + typescript: '>=4.3 <6' + peerDependenciesMeta: + '@babel/core': + optional: true + '@jest/transform': + optional: true + '@jest/types': + optional: true + babel-jest: + optional: true + esbuild: + optional: true + jest-util: + optional: true + + ts-loader@9.5.4: + resolution: {integrity: sha512-nCz0rEwunlTZiy6rXFByQU1kVVpCIgUpc/psFiKVrUwrizdnIbRFu8w7bxhUF0X613DYwT4XzrZHpVyMe758hQ==} + engines: {node: '>=12.0.0'} + peerDependencies: + typescript: '*' + webpack: ^5.0.0 + + ts-node@10.9.2: + resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} + hasBin: true + peerDependencies: + '@swc/core': '>=1.2.50' + '@swc/wasm': '>=1.2.50' + '@types/node': '*' + typescript: '>=2.7' + peerDependenciesMeta: + '@swc/core': + optional: true + '@swc/wasm': + optional: true + + tsconfig-paths-webpack-plugin@4.2.0: + resolution: {integrity: sha512-zbem3rfRS8BgeNK50Zz5SIQgXzLafiHjOwUAvk/38/o1jHn/V5QAgVUcz884or7WYcPaH3N2CIfUc2u0ul7UcA==} + engines: {node: '>=10.13.0'} + + tsconfig-paths@4.2.0: + resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} + engines: {node: '>=6'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-detect@4.0.8: + resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} + engines: {node: '>=4'} + + type-fest@0.20.2: + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} + engines: {node: '>=10'} + + type-fest@0.21.3: + resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} + engines: {node: '>=10'} + + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + + typedarray@0.0.6: + resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + + typescript@5.7.2: + resolution: {integrity: sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==} + engines: {node: '>=14.17'} + hasBin: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + uglify-js@3.19.3: + resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} + engines: {node: '>=0.8.0'} + hasBin: true + + uid@2.0.2: + resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==} + engines: {node: '>=8'} + + uint8array-extras@1.5.0: + resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} + engines: {node: '>=18'} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + update-browserslist-db@1.1.4: + resolution: {integrity: sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + + v8-compile-cache-lib@3.0.1: + resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} + + v8-to-istanbul@9.3.0: + resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} + engines: {node: '>=10.12.0'} + + validator@13.15.23: + resolution: {integrity: sha512-4yoz1kEWqUjzi5zsPbAS/903QXSYp0UOtHsPpp7p9rHAw/W+dkInskAE386Fat3oKRROwO98d9ZB0G4cObgUyw==} + engines: {node: '>= 0.10'} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vee-validate@4.15.1: + resolution: {integrity: sha512-DkFsiTwEKau8VIxyZBGdO6tOudD+QoUBPuHj3e6QFqmbfCRj1ArmYWue9lEp6jLSWBIw4XPlDLjFIZNLdRAMSg==} + peerDependencies: + vue: ^3.4.26 + + 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 + + walker@1.0.8: + resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} + + warning@4.0.3: + resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==} + + watchpack@2.4.4: + resolution: {integrity: sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==} + engines: {node: '>=10.13.0'} + + wcwidth@1.0.1: + resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + webpack-node-externals@3.0.0: + resolution: {integrity: sha512-LnL6Z3GGDPht/AigwRh2dvL9PQPFQ8skEpVrWZXLWBYmqcaojHNN0onvHzie6rq7EWKrrBfPYqNEzTJgiwEQDQ==} + engines: {node: '>=6'} + + webpack-sources@3.3.3: + resolution: {integrity: sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==} + engines: {node: '>=10.13.0'} + + webpack@5.97.1: + resolution: {integrity: sha512-EksG6gFY3L1eFMROS/7Wzgrii5mBAFe4rIr3r2BTfo7bcc+DWwFZ4OJ/miOuHJO/A85HwyI4eQ0F6IKXesO7Fg==} + engines: {node: '>=10.13.0'} + hasBin: true + peerDependencies: + webpack-cli: '*' + peerDependenciesMeta: + webpack-cli: + optional: true + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + + 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'} + + wordwrap@1.0.0: + resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + write-file-atomic@4.0.2: + resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + + xml-name-validator@4.0.0: + resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} + engines: {node: '>=12'} + + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + + yn@3.1.1: + resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} + engines: {node: '>=6'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + +snapshots: + + '@alloc/quick-lru@5.2.0': {} + + '@angular-devkit/core@17.3.11(chokidar@3.6.0)': + dependencies: + ajv: 8.12.0 + ajv-formats: 2.1.1(ajv@8.12.0) + jsonc-parser: 3.2.1 + picomatch: 4.0.1 + rxjs: 7.8.1 + source-map: 0.7.4 + optionalDependencies: + chokidar: 3.6.0 + + '@angular-devkit/schematics-cli@17.3.11(chokidar@3.6.0)': + dependencies: + '@angular-devkit/core': 17.3.11(chokidar@3.6.0) + '@angular-devkit/schematics': 17.3.11(chokidar@3.6.0) + ansi-colors: 4.1.3 + inquirer: 9.2.15 + symbol-observable: 4.0.0 + yargs-parser: 21.1.1 + transitivePeerDependencies: + - chokidar + + '@angular-devkit/schematics@17.3.11(chokidar@3.6.0)': + dependencies: + '@angular-devkit/core': 17.3.11(chokidar@3.6.0) + jsonc-parser: 3.2.1 + magic-string: 0.30.8 + ora: 5.4.1 + rxjs: 7.8.1 + transitivePeerDependencies: + - chokidar + + '@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.9.3))': + dependencies: + '@ant-design/colors': 6.0.0 + '@ant-design/icons-svg': 4.4.2 + vue: 3.5.24(typescript@5.9.3) + + '@babel/code-frame@7.27.1': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.28.5': {} + + '@babel/core@7.28.5': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.28.5 + '@babel/helper-compilation-targets': 7.27.2 + '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.5) + '@babel/helpers': 7.28.4 + '@babel/parser': 7.28.5 + '@babel/template': 7.27.2 + '@babel/traverse': 7.28.5 + '@babel/types': 7.28.5 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.28.5': + dependencies: + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.27.2': + dependencies: + '@babel/compat-data': 7.28.5 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.0 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.27.1': + dependencies: + '@babel/traverse': 7.28.5 + '@babel/types': 7.28.5 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.3(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-module-imports': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.28.5 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.27.1': {} + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.28.4': + dependencies: + '@babel/template': 7.27.2 + '@babel/types': 7.28.5 + + '@babel/parser@7.28.5': + dependencies: + '@babel/types': 7.28.5 + + '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-import-attributes@7.27.1(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-typescript@7.27.1(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/runtime@7.28.4': {} + + '@babel/template@7.27.2': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + + '@babel/traverse@7.28.5': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.28.5 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.28.5 + '@babel/template': 7.27.2 + '@babel/types': 7.28.5 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.28.5': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@bcoe/v8-coverage@0.2.3': {} + + '@borewit/text-codec@0.1.1': {} + + '@colors/colors@1.5.0': + optional: true + + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + + '@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.1 + minimatch: 3.1.2 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@8.57.1': {} + + '@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': {} + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.1.2 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@istanbuljs/load-nyc-config@1.1.0': + dependencies: + camelcase: 5.3.1 + find-up: 4.1.0 + get-package-type: 0.1.0 + js-yaml: 3.14.2 + resolve-from: 5.0.0 + + '@istanbuljs/schema@0.1.3': {} + + '@jest/console@29.7.0': + dependencies: + '@jest/types': 29.6.3 + '@types/node': 20.19.25 + chalk: 4.1.2 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + slash: 3.0.0 + + '@jest/core@29.7.0(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3))': + dependencies: + '@jest/console': 29.7.0 + '@jest/reporters': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 20.19.25 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + ci-info: 3.9.0 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-changed-files: 29.7.0 + jest-config: 29.7.0(@types/node@20.19.25)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)) + jest-haste-map: 29.7.0 + jest-message-util: 29.7.0 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-resolve-dependencies: 29.7.0 + jest-runner: 29.7.0 + jest-runtime: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + jest-watcher: 29.7.0 + micromatch: 4.0.8 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-ansi: 6.0.1 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + - ts-node + + '@jest/environment@29.7.0': + dependencies: + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 20.19.25 + jest-mock: 29.7.0 + + '@jest/expect-utils@29.7.0': + dependencies: + jest-get-type: 29.6.3 + + '@jest/expect@29.7.0': + dependencies: + expect: 29.7.0 + jest-snapshot: 29.7.0 + transitivePeerDependencies: + - supports-color + + '@jest/fake-timers@29.7.0': + dependencies: + '@jest/types': 29.6.3 + '@sinonjs/fake-timers': 10.3.0 + '@types/node': 20.19.25 + jest-message-util: 29.7.0 + jest-mock: 29.7.0 + jest-util: 29.7.0 + + '@jest/globals@29.7.0': + dependencies: + '@jest/environment': 29.7.0 + '@jest/expect': 29.7.0 + '@jest/types': 29.6.3 + jest-mock: 29.7.0 + transitivePeerDependencies: + - supports-color + + '@jest/reporters@29.7.0': + dependencies: + '@bcoe/v8-coverage': 0.2.3 + '@jest/console': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@jridgewell/trace-mapping': 0.3.31 + '@types/node': 20.19.25 + chalk: 4.1.2 + collect-v8-coverage: 1.0.3 + exit: 0.1.2 + glob: 7.2.3 + graceful-fs: 4.2.11 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-instrument: 6.0.3 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 4.0.1 + istanbul-reports: 3.2.0 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + jest-worker: 29.7.0 + slash: 3.0.0 + string-length: 4.0.2 + strip-ansi: 6.0.1 + v8-to-istanbul: 9.3.0 + transitivePeerDependencies: + - supports-color + + '@jest/schemas@29.6.3': + dependencies: + '@sinclair/typebox': 0.27.8 + + '@jest/source-map@29.6.3': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + callsites: 3.1.0 + graceful-fs: 4.2.11 + + '@jest/test-result@29.7.0': + dependencies: + '@jest/console': 29.7.0 + '@jest/types': 29.6.3 + '@types/istanbul-lib-coverage': 2.0.6 + collect-v8-coverage: 1.0.3 + + '@jest/test-sequencer@29.7.0': + dependencies: + '@jest/test-result': 29.7.0 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + slash: 3.0.0 + + '@jest/transform@29.7.0': + dependencies: + '@babel/core': 7.28.5 + '@jest/types': 29.6.3 + '@jridgewell/trace-mapping': 0.3.31 + babel-plugin-istanbul: 6.1.1 + chalk: 4.1.2 + convert-source-map: 2.0.0 + fast-json-stable-stringify: 2.1.0 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + jest-regex-util: 29.6.3 + jest-util: 29.7.0 + micromatch: 4.0.8 + pirates: 4.0.7 + slash: 3.0.0 + write-file-atomic: 4.0.2 + transitivePeerDependencies: + - supports-color + + '@jest/types@29.6.3': + dependencies: + '@jest/schemas': 29.6.3 + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 20.19.25 + '@types/yargs': 17.0.35 + chalk: 4.1.2 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/source-map@0.3.11': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@ljharb/through@2.3.14': + dependencies: + call-bind: 1.0.8 + + '@lukeed/csprng@1.1.0': {} + + '@nestjs/cli@10.4.9': + dependencies: + '@angular-devkit/core': 17.3.11(chokidar@3.6.0) + '@angular-devkit/schematics': 17.3.11(chokidar@3.6.0) + '@angular-devkit/schematics-cli': 17.3.11(chokidar@3.6.0) + '@nestjs/schematics': 10.2.3(chokidar@3.6.0)(typescript@5.7.2) + chalk: 4.1.2 + chokidar: 3.6.0 + cli-table3: 0.6.5 + commander: 4.1.1 + fork-ts-checker-webpack-plugin: 9.0.2(typescript@5.7.2)(webpack@5.97.1) + glob: 10.4.5 + inquirer: 8.2.6 + node-emoji: 1.11.0 + ora: 5.4.1 + tree-kill: 1.2.2 + tsconfig-paths: 4.2.0 + tsconfig-paths-webpack-plugin: 4.2.0 + typescript: 5.7.2 + webpack: 5.97.1 + webpack-node-externals: 3.0.0 + transitivePeerDependencies: + - esbuild + - uglify-js + - webpack-cli + + '@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + file-type: 20.4.1 + iterare: 1.2.1 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + tslib: 2.8.1 + uid: 2.0.2 + optionalDependencies: + class-transformer: 0.5.1 + class-validator: 0.14.2 + transitivePeerDependencies: + - supports-color + + '@nestjs/config@3.3.0(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 10.4.20(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + dotenv: 16.4.5 + dotenv-expand: 10.0.0 + lodash: 4.17.21 + rxjs: 7.8.2 + + '@nestjs/core@10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.20)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 10.4.20(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nuxtjs/opencollective': 0.3.2 + fast-safe-stringify: 2.1.1 + iterare: 1.2.1 + path-to-regexp: 3.3.0 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + tslib: 2.8.1 + uid: 2.0.2 + optionalDependencies: + '@nestjs/platform-express': 10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.20) + transitivePeerDependencies: + - encoding + + '@nestjs/jwt@10.2.0(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))': + dependencies: + '@nestjs/common': 10.4.20(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@types/jsonwebtoken': 9.0.5 + jsonwebtoken: 9.0.2 + + '@nestjs/passport@10.0.3(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)': + dependencies: + '@nestjs/common': 10.4.20(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + passport: 0.7.0 + + '@nestjs/platform-express@10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.20)': + dependencies: + '@nestjs/common': 10.4.20(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.20)(reflect-metadata@0.2.2)(rxjs@7.8.2) + body-parser: 1.20.3 + cors: 2.8.5 + express: 4.21.2 + multer: 2.0.2 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@nestjs/schematics@10.2.3(chokidar@3.6.0)(typescript@5.7.2)': + dependencies: + '@angular-devkit/core': 17.3.11(chokidar@3.6.0) + '@angular-devkit/schematics': 17.3.11(chokidar@3.6.0) + comment-json: 4.2.5 + jsonc-parser: 3.3.1 + pluralize: 8.0.0 + typescript: 5.7.2 + transitivePeerDependencies: + - chokidar + + '@nestjs/schematics@10.2.3(chokidar@3.6.0)(typescript@5.9.3)': + dependencies: + '@angular-devkit/core': 17.3.11(chokidar@3.6.0) + '@angular-devkit/schematics': 17.3.11(chokidar@3.6.0) + comment-json: 4.2.5 + jsonc-parser: 3.3.1 + pluralize: 8.0.0 + typescript: 5.9.3 + transitivePeerDependencies: + - chokidar + + '@nestjs/testing@10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.20)(@nestjs/platform-express@10.4.20)': + dependencies: + '@nestjs/common': 10.4.20(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.20)(reflect-metadata@0.2.2)(rxjs@7.8.2) + tslib: 2.8.1 + optionalDependencies: + '@nestjs/platform-express': 10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.20) + + '@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 + + '@nuxtjs/opencollective@0.3.2': + dependencies: + chalk: 4.1.2 + consola: 2.15.3 + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + + '@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 + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@pkgr/core@0.2.9': {} + + '@prisma/client@5.22.0(prisma@5.22.0)': + optionalDependencies: + prisma: 5.22.0 + + '@prisma/debug@5.22.0': {} + + '@prisma/engines-version@5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2': {} + + '@prisma/engines@5.22.0': + dependencies: + '@prisma/debug': 5.22.0 + '@prisma/engines-version': 5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2 + '@prisma/fetch-engine': 5.22.0 + '@prisma/get-platform': 5.22.0 + + '@prisma/fetch-engine@5.22.0': + dependencies: + '@prisma/debug': 5.22.0 + '@prisma/engines-version': 5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2 + '@prisma/get-platform': 5.22.0 + + '@prisma/get-platform@5.22.0': + dependencies: + '@prisma/debug': 5.22.0 + + '@rollup/rollup-android-arm-eabi@4.53.3': + optional: true + + '@rollup/rollup-android-arm64@4.53.3': + optional: true + + '@rollup/rollup-darwin-arm64@4.53.3': + optional: true + + '@rollup/rollup-darwin-x64@4.53.3': + optional: true + + '@rollup/rollup-freebsd-arm64@4.53.3': + optional: true + + '@rollup/rollup-freebsd-x64@4.53.3': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.53.3': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.53.3': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.53.3': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.53.3': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.53.3': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.53.3': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.53.3': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.53.3': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.53.3': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.53.3': + optional: true + + '@rollup/rollup-linux-x64-musl@4.53.3': + optional: true + + '@rollup/rollup-openharmony-arm64@4.53.3': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.53.3': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.53.3': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.53.3': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.53.3': + optional: true + + '@simonwep/pickr@1.8.2': + dependencies: + core-js: 3.47.0 + nanopop: 2.4.2 + + '@sinclair/typebox@0.27.8': {} + + '@sinonjs/commons@3.0.1': + dependencies: + type-detect: 4.0.8 + + '@sinonjs/fake-timers@10.3.0': + dependencies: + '@sinonjs/commons': 3.0.1 + + '@tokenizer/inflate@0.2.7': + dependencies: + debug: 4.4.3 + fflate: 0.8.2 + token-types: 6.1.1 + transitivePeerDependencies: + - supports-color + + '@tokenizer/token@0.3.0': {} + + '@tsconfig/node10@1.0.12': {} + + '@tsconfig/node12@1.0.11': {} + + '@tsconfig/node14@1.0.3': {} + + '@tsconfig/node16@1.0.4': {} + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.28.5 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.28.5 + + '@types/bcrypt@5.0.2': + dependencies: + '@types/node': 20.19.25 + + '@types/body-parser@1.19.6': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 20.19.25 + + '@types/connect@3.4.38': + dependencies: + '@types/node': 20.19.25 + + '@types/eslint-scope@3.7.7': + dependencies: + '@types/eslint': 9.6.1 + '@types/estree': 1.0.8 + + '@types/eslint@9.6.1': + dependencies: + '@types/estree': 1.0.8 + '@types/json-schema': 7.0.15 + + '@types/estree@1.0.8': {} + + '@types/express-serve-static-core@4.19.7': + dependencies: + '@types/node': 20.19.25 + '@types/qs': 6.14.0 + '@types/range-parser': 1.2.7 + '@types/send': 1.2.1 + + '@types/express@4.17.25': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 4.19.7 + '@types/qs': 6.14.0 + '@types/serve-static': 1.15.10 + + '@types/graceful-fs@4.1.9': + dependencies: + '@types/node': 20.19.25 + + '@types/http-errors@2.0.5': {} + + '@types/istanbul-lib-coverage@2.0.6': {} + + '@types/istanbul-lib-report@3.0.3': + dependencies: + '@types/istanbul-lib-coverage': 2.0.6 + + '@types/istanbul-reports@3.0.4': + dependencies: + '@types/istanbul-lib-report': 3.0.3 + + '@types/jest@29.5.14': + dependencies: + expect: 29.7.0 + pretty-format: 29.7.0 + + '@types/json-schema@7.0.15': {} + + '@types/jsonwebtoken@9.0.10': + dependencies: + '@types/ms': 2.1.0 + '@types/node': 20.19.25 + + '@types/jsonwebtoken@9.0.5': + dependencies: + '@types/node': 20.19.25 + + '@types/mime@1.3.5': {} + + '@types/ms@2.1.0': {} + + '@types/node@20.19.25': + dependencies: + undici-types: 6.21.0 + + '@types/passport-jwt@4.0.1': + dependencies: + '@types/jsonwebtoken': 9.0.10 + '@types/passport-strategy': 0.2.38 + + '@types/passport-local@1.0.38': + dependencies: + '@types/express': 4.17.25 + '@types/passport': 1.0.17 + '@types/passport-strategy': 0.2.38 + + '@types/passport-strategy@0.2.38': + dependencies: + '@types/express': 4.17.25 + '@types/passport': 1.0.17 + + '@types/passport@1.0.17': + dependencies: + '@types/express': 4.17.25 + + '@types/qs@6.14.0': {} + + '@types/range-parser@1.2.7': {} + + '@types/semver@7.7.1': {} + + '@types/send@0.17.6': + dependencies: + '@types/mime': 1.3.5 + '@types/node': 20.19.25 + + '@types/send@1.2.1': + dependencies: + '@types/node': 20.19.25 + + '@types/serve-static@1.15.10': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 20.19.25 + '@types/send': 0.17.6 + + '@types/stack-utils@2.0.3': {} + + '@types/validator@13.15.10': {} + + '@types/yargs-parser@21.0.3': {} + + '@types/yargs@17.0.35': + dependencies: + '@types/yargs-parser': 21.0.3 + + '@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 6.21.0(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 6.21.0 + '@typescript-eslint/type-utils': 6.21.0(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/utils': 6.21.0(eslint@8.57.1)(typescript@5.9.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.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 7.18.0(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 7.18.0 + '@typescript-eslint/type-utils': 7.18.0(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/utils': 7.18.0(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 7.18.0 + eslint: 8.57.1 + graphemer: 1.4.0 + ignore: 5.3.2 + natural-compare: 1.4.0 + ts-api-utils: 1.4.3(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 6.21.0 + '@typescript-eslint/types': 6.21.0 + '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 6.21.0 + debug: 4.4.3 + eslint: 8.57.1 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 7.18.0 + '@typescript-eslint/types': 7.18.0 + '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 7.18.0 + debug: 4.4.3 + eslint: 8.57.1 + optionalDependencies: + typescript: 5.9.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/scope-manager@7.18.0': + dependencies: + '@typescript-eslint/types': 7.18.0 + '@typescript-eslint/visitor-keys': 7.18.0 + + '@typescript-eslint/type-utils@6.21.0(eslint@8.57.1)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.9.3) + '@typescript-eslint/utils': 6.21.0(eslint@8.57.1)(typescript@5.9.3) + debug: 4.4.3 + eslint: 8.57.1 + ts-api-utils: 1.4.3(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/type-utils@7.18.0(eslint@8.57.1)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.9.3) + '@typescript-eslint/utils': 7.18.0(eslint@8.57.1)(typescript@5.9.3) + debug: 4.4.3 + eslint: 8.57.1 + ts-api-utils: 1.4.3(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@6.21.0': {} + + '@typescript-eslint/types@7.18.0': {} + + '@typescript-eslint/typescript-estree@6.21.0(typescript@5.9.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.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/typescript-estree@7.18.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 7.18.0 + '@typescript-eslint/visitor-keys': 7.18.0 + debug: 4.4.3 + globby: 11.1.0 + is-glob: 4.0.3 + minimatch: 9.0.5 + semver: 7.7.3 + ts-api-utils: 1.4.3(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@6.21.0(eslint@8.57.1)(typescript@5.9.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.9.3) + eslint: 8.57.1 + semver: 7.7.3 + transitivePeerDependencies: + - supports-color + - typescript + + '@typescript-eslint/utils@7.18.0(eslint@8.57.1)(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.0(eslint@8.57.1) + '@typescript-eslint/scope-manager': 7.18.0 + '@typescript-eslint/types': 7.18.0 + '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.9.3) + eslint: 8.57.1 + transitivePeerDependencies: + - supports-color + - typescript + + '@typescript-eslint/visitor-keys@6.21.0': + dependencies: + '@typescript-eslint/types': 6.21.0 + eslint-visitor-keys: 3.4.3 + + '@typescript-eslint/visitor-keys@7.18.0': + dependencies: + '@typescript-eslint/types': 7.18.0 + eslint-visitor-keys: 3.4.3 + + '@ungap/structured-clone@1.3.0': {} + + '@vee-validate/zod@4.15.1(vue@3.5.24(typescript@5.9.3))(zod@3.25.76)': + dependencies: + type-fest: 4.41.0 + vee-validate: 4.15.1(vue@3.5.24(typescript@5.9.3)) + zod: 3.25.76 + transitivePeerDependencies: + - vue + + '@vitejs/plugin-vue@5.2.4(vite@5.4.21(@types/node@20.19.25)(sass@1.94.1)(terser@5.44.1))(vue@3.5.24(typescript@5.9.3))': + dependencies: + vite: 5.4.21(@types/node@20.19.25)(sass@1.94.1)(terser@5.44.1) + vue: 3.5.24(typescript@5.9.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/devtools-api@7.7.9': + dependencies: + '@vue/devtools-kit': 7.7.9 + + '@vue/devtools-kit@7.7.9': + dependencies: + '@vue/devtools-shared': 7.7.9 + birpc: 2.8.0 + hookable: 5.5.3 + mitt: 3.0.1 + perfect-debounce: 1.0.0 + speakingurl: 14.0.1 + superjson: 2.2.5 + + '@vue/devtools-shared@7.7.9': + dependencies: + rfdc: 1.4.1 + + '@vue/eslint-config-typescript@13.0.0(eslint-plugin-vue@9.33.0(eslint@8.57.1))(eslint@8.57.1)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/eslint-plugin': 7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/parser': 7.18.0(eslint@8.57.1)(typescript@5.9.3) + eslint: 8.57.1 + eslint-plugin-vue: 9.33.0(eslint@8.57.1) + vue-eslint-parser: 9.4.3(eslint@8.57.1) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@vue/language-core@1.8.27(typescript@5.9.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.9.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.2.3 + + '@vue/server-renderer@3.5.24(vue@3.5.24(typescript@5.9.3))': + dependencies: + '@vue/compiler-ssr': 3.5.24 + '@vue/shared': 3.5.24 + vue: 3.5.24(typescript@5.9.3) + + '@vue/shared@3.5.24': {} + + '@webassemblyjs/ast@1.14.1': + dependencies: + '@webassemblyjs/helper-numbers': 1.13.2 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + + '@webassemblyjs/floating-point-hex-parser@1.13.2': {} + + '@webassemblyjs/helper-api-error@1.13.2': {} + + '@webassemblyjs/helper-buffer@1.14.1': {} + + '@webassemblyjs/helper-numbers@1.13.2': + dependencies: + '@webassemblyjs/floating-point-hex-parser': 1.13.2 + '@webassemblyjs/helper-api-error': 1.13.2 + '@xtuc/long': 4.2.2 + + '@webassemblyjs/helper-wasm-bytecode@1.13.2': {} + + '@webassemblyjs/helper-wasm-section@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/wasm-gen': 1.14.1 + + '@webassemblyjs/ieee754@1.13.2': + dependencies: + '@xtuc/ieee754': 1.2.0 + + '@webassemblyjs/leb128@1.13.2': + dependencies: + '@xtuc/long': 4.2.2 + + '@webassemblyjs/utf8@1.13.2': {} + + '@webassemblyjs/wasm-edit@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/helper-wasm-section': 1.14.1 + '@webassemblyjs/wasm-gen': 1.14.1 + '@webassemblyjs/wasm-opt': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + '@webassemblyjs/wast-printer': 1.14.1 + + '@webassemblyjs/wasm-gen@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/ieee754': 1.13.2 + '@webassemblyjs/leb128': 1.13.2 + '@webassemblyjs/utf8': 1.13.2 + + '@webassemblyjs/wasm-opt@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/wasm-gen': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + + '@webassemblyjs/wasm-parser@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-api-error': 1.13.2 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/ieee754': 1.13.2 + '@webassemblyjs/leb128': 1.13.2 + '@webassemblyjs/utf8': 1.13.2 + + '@webassemblyjs/wast-printer@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@xtuc/long': 4.2.2 + + '@xtuc/ieee754@1.2.0': {} + + '@xtuc/long@4.2.2': {} + + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + + acorn-jsx@5.3.2(acorn@8.15.0): + dependencies: + acorn: 8.15.0 + + acorn-walk@8.3.4: + dependencies: + acorn: 8.15.0 + + acorn@8.15.0: {} + + ajv-formats@2.1.1(ajv@8.12.0): + optionalDependencies: + ajv: 8.12.0 + + ajv-formats@2.1.1(ajv@8.17.1): + optionalDependencies: + ajv: 8.17.1 + + ajv-keywords@3.5.2(ajv@6.12.6): + dependencies: + ajv: 6.12.6 + + ajv-keywords@5.1.0(ajv@8.17.1): + dependencies: + ajv: 8.17.1 + fast-deep-equal: 3.1.3 + + 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 + + ajv@8.12.0: + dependencies: + fast-deep-equal: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + uri-js: 4.4.1 + + ajv@8.17.1: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-colors@4.1.3: {} + + ansi-escapes@4.3.2: + dependencies: + type-fest: 0.21.3 + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@5.2.0: {} + + ansi-styles@6.2.3: {} + + ant-design-vue@4.2.6(vue@3.5.24(typescript@5.9.3)): + dependencies: + '@ant-design/colors': 6.0.0 + '@ant-design/icons-vue': 7.0.1(vue@3.5.24(typescript@5.9.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.2.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.9.3) + vue-types: 3.0.2(vue@3.5.24(typescript@5.9.3)) + warning: 4.0.3 + + any-promise@1.3.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + + append-field@1.0.0: {} + + arg@4.1.3: {} + + arg@5.0.2: {} + + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + + argparse@2.0.1: {} + + array-flatten@1.1.1: {} + + array-timsort@1.0.3: {} + + array-tree-filter@2.1.0: {} + + array-union@2.1.0: {} + + async-validator@4.2.5: {} + + asynckit@0.4.0: {} + + autoprefixer@10.4.22(postcss@8.5.6): + dependencies: + browserslist: 4.28.0 + caniuse-lite: 1.0.30001756 + fraction.js: 5.3.4 + normalize-range: 0.1.2 + picocolors: 1.1.1 + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + axios@1.13.2: + dependencies: + follow-redirects: 1.15.11 + form-data: 4.0.5 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + + babel-jest@29.7.0(@babel/core@7.28.5): + dependencies: + '@babel/core': 7.28.5 + '@jest/transform': 29.7.0 + '@types/babel__core': 7.20.5 + babel-plugin-istanbul: 6.1.1 + babel-preset-jest: 29.6.3(@babel/core@7.28.5) + chalk: 4.1.2 + graceful-fs: 4.2.11 + slash: 3.0.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-istanbul@6.1.1: + dependencies: + '@babel/helper-plugin-utils': 7.27.1 + '@istanbuljs/load-nyc-config': 1.1.0 + '@istanbuljs/schema': 0.1.3 + istanbul-lib-instrument: 5.2.1 + test-exclude: 6.0.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-jest-hoist@29.6.3: + dependencies: + '@babel/template': 7.27.2 + '@babel/types': 7.28.5 + '@types/babel__core': 7.20.5 + '@types/babel__traverse': 7.28.0 + + babel-preset-current-node-syntax@1.2.0(@babel/core@7.28.5): + dependencies: + '@babel/core': 7.28.5 + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.28.5) + '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.28.5) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.28.5) + '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.28.5) + '@babel/plugin-syntax-import-attributes': 7.27.1(@babel/core@7.28.5) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.28.5) + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.28.5) + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.28.5) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.28.5) + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.28.5) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.28.5) + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.28.5) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.28.5) + '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.28.5) + '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.28.5) + + babel-preset-jest@29.6.3(@babel/core@7.28.5): + dependencies: + '@babel/core': 7.28.5 + babel-plugin-jest-hoist: 29.6.3 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.28.5) + + balanced-match@1.0.2: {} + + base64-js@1.5.1: {} + + baseline-browser-mapping@2.8.29: {} + + bcrypt@6.0.0: + dependencies: + node-addon-api: 8.5.0 + node-gyp-build: 4.8.4 + + binary-extensions@2.3.0: {} + + birpc@2.8.0: {} + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + + body-parser@1.20.3: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + http-errors: 2.0.0 + iconv-lite: 0.4.24 + on-finished: 2.4.1 + qs: 6.13.0 + raw-body: 2.5.2 + type-is: 1.6.18 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + 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 + + browserslist@4.28.0: + dependencies: + baseline-browser-mapping: 2.8.29 + caniuse-lite: 1.0.30001756 + electron-to-chromium: 1.5.256 + node-releases: 2.0.27 + update-browserslist-db: 1.1.4(browserslist@4.28.0) + + bs-logger@0.2.6: + dependencies: + fast-json-stable-stringify: 2.1.0 + + bser@2.1.1: + dependencies: + node-int64: 0.4.0 + + buffer-equal-constant-time@1.0.1: {} + + buffer-from@1.1.2: {} + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + busboy@1.6.0: + dependencies: + streamsearch: 1.1.0 + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.8: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + callsites@3.1.0: {} + + camelcase-css@2.0.1: {} + + camelcase@5.3.1: {} + + camelcase@6.3.0: {} + + caniuse-lite@1.0.30001756: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chalk@5.6.2: {} + + char-regex@1.0.2: {} + + chardet@0.7.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 + + chrome-trace-event@1.0.4: {} + + ci-info@3.9.0: {} + + cjs-module-lexer@1.4.3: {} + + class-transformer@0.5.1: {} + + class-validator@0.14.2: + dependencies: + '@types/validator': 13.15.10 + libphonenumber-js: 1.12.27 + validator: 13.15.23 + + cli-cursor@3.1.0: + dependencies: + restore-cursor: 3.1.0 + + cli-spinners@2.9.2: {} + + cli-table3@0.6.5: + dependencies: + string-width: 4.2.3 + optionalDependencies: + '@colors/colors': 1.5.0 + + cli-width@3.0.0: {} + + cli-width@4.1.0: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + clone@1.0.4: {} + + co@4.6.0: {} + + collect-v8-coverage@1.0.3: {} + + 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 + + commander@2.20.3: {} + + commander@4.1.1: {} + + comment-json@4.2.5: + dependencies: + array-timsort: 1.0.3 + core-util-is: 1.0.3 + esprima: 4.0.1 + has-own-prop: 2.0.0 + repeat-string: 1.6.1 + + compute-scroll-into-view@1.0.20: {} + + computeds@0.0.1: {} + + concat-map@0.0.1: {} + + concat-stream@2.0.0: + dependencies: + buffer-from: 1.1.2 + inherits: 2.0.4 + readable-stream: 3.6.2 + typedarray: 0.0.6 + + consola@2.15.3: {} + + content-disposition@0.5.4: + dependencies: + safe-buffer: 5.2.1 + + content-type@1.0.5: {} + + convert-source-map@2.0.0: {} + + cookie-signature@1.0.6: {} + + cookie@0.7.1: {} + + copy-anything@4.0.5: + dependencies: + is-what: 5.5.0 + + core-js@3.47.0: {} + + core-util-is@1.0.3: {} + + cors@2.8.5: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + cosmiconfig@8.3.6(typescript@5.7.2): + dependencies: + import-fresh: 3.3.1 + js-yaml: 4.1.1 + parse-json: 5.2.0 + path-type: 4.0.0 + optionalDependencies: + typescript: 5.7.2 + + create-jest@29.7.0(@types/node@20.19.25)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)): + dependencies: + '@jest/types': 29.6.3 + chalk: 4.1.2 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-config: 29.7.0(@types/node@20.19.25)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)) + jest-util: 29.7.0 + prompts: 2.4.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + + create-require@1.1.1: {} + + 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.2.3: {} + + dayjs@1.11.19: {} + + de-indent@1.0.2: {} + + debug@2.6.9: + dependencies: + ms: 2.0.0 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + dedent@1.7.0: {} + + deep-is@0.1.4: {} + + deepmerge@4.3.1: {} + + defaults@1.0.4: + dependencies: + clone: 1.0.4 + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + delayed-stream@1.0.0: {} + + depd@2.0.0: {} + + destroy@1.2.0: {} + + detect-libc@1.0.3: + optional: true + + detect-newline@3.1.0: {} + + didyoumean@1.2.2: {} + + diff-sequences@29.6.3: {} + + diff@4.0.2: {} + + dir-glob@3.0.1: + dependencies: + path-type: 4.0.0 + + dlv@1.1.3: {} + + doctrine@3.0.0: + dependencies: + esutils: 2.0.3 + + dom-align@1.12.4: {} + + dom-scroll-into-view@2.0.1: {} + + dotenv-cli@11.0.0: + dependencies: + cross-spawn: 7.0.6 + dotenv: 17.2.3 + dotenv-expand: 12.0.3 + minimist: 1.2.8 + + dotenv-expand@10.0.0: {} + + dotenv-expand@12.0.3: + dependencies: + dotenv: 16.4.5 + + dotenv@16.4.5: {} + + dotenv@17.2.3: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + eastasianwidth@0.2.0: {} + + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + + ee-first@1.1.1: {} + + electron-to-chromium@1.5.256: {} + + emittery@0.13.1: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + encodeurl@1.0.2: {} + + encodeurl@2.0.0: {} + + enhanced-resolve@5.18.3: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.0 + + entities@4.5.0: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@1.7.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 + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + escape-string-regexp@1.0.5: {} + + escape-string-regexp@2.0.0: {} + + escape-string-regexp@4.0.0: {} + + eslint-config-prettier@9.1.2(eslint@8.57.1): + dependencies: + eslint: 8.57.1 + + eslint-plugin-prettier@5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@9.1.2(eslint@8.57.1))(eslint@8.57.1)(prettier@3.6.2): + dependencies: + eslint: 8.57.1 + prettier: 3.6.2 + prettier-linter-helpers: 1.0.0 + synckit: 0.11.11 + optionalDependencies: + '@types/eslint': 9.6.1 + eslint-config-prettier: 9.1.2(eslint@8.57.1) + + 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@5.1.1: + dependencies: + esrecurse: 4.3.0 + estraverse: 4.3.0 + + 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.1 + 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 + + esprima@4.0.1: {} + + esquery@1.6.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@4.3.0: {} + + estraverse@5.3.0: {} + + estree-walker@2.0.2: {} + + esutils@2.0.3: {} + + etag@1.8.1: {} + + events@3.3.0: {} + + execa@5.1.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + + exit@0.1.2: {} + + expect@29.7.0: + dependencies: + '@jest/expect-utils': 29.7.0 + jest-get-type: 29.6.3 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + + express@4.21.2: + dependencies: + accepts: 1.3.8 + array-flatten: 1.1.1 + body-parser: 1.20.3 + content-disposition: 0.5.4 + content-type: 1.0.5 + cookie: 0.7.1 + cookie-signature: 1.0.6 + debug: 2.6.9 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 1.3.1 + fresh: 0.5.2 + http-errors: 2.0.0 + merge-descriptors: 1.0.3 + methods: 1.1.2 + on-finished: 2.4.1 + parseurl: 1.3.3 + path-to-regexp: 0.1.12 + proxy-addr: 2.0.7 + qs: 6.13.0 + range-parser: 1.2.1 + safe-buffer: 5.2.1 + send: 0.19.0 + serve-static: 1.16.2 + setprototypeof: 1.2.0 + statuses: 2.0.1 + type-is: 1.6.18 + utils-merge: 1.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + external-editor@3.1.0: + dependencies: + chardet: 0.7.0 + iconv-lite: 0.4.24 + tmp: 0.0.33 + + fast-deep-equal@3.1.3: {} + + fast-diff@1.3.0: {} + + 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: {} + + fast-safe-stringify@2.1.1: {} + + fast-uri@3.1.0: {} + + fastq@1.19.1: + dependencies: + reusify: 1.1.0 + + fb-watchman@2.0.2: + dependencies: + bser: 2.1.1 + + fflate@0.8.2: {} + + figures@3.2.0: + dependencies: + escape-string-regexp: 1.0.5 + + file-entry-cache@6.0.1: + dependencies: + flat-cache: 3.2.0 + + file-type@20.4.1: + dependencies: + '@tokenizer/inflate': 0.2.7 + strtok3: 10.3.4 + token-types: 6.1.1 + uint8array-extras: 1.5.0 + transitivePeerDependencies: + - supports-color + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + finalhandler@1.3.1: + dependencies: + debug: 2.6.9 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.1 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + + 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: {} + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + fork-ts-checker-webpack-plugin@9.0.2(typescript@5.7.2)(webpack@5.97.1): + dependencies: + '@babel/code-frame': 7.27.1 + chalk: 4.1.2 + chokidar: 3.6.0 + cosmiconfig: 8.3.6(typescript@5.7.2) + deepmerge: 4.3.1 + fs-extra: 10.1.0 + memfs: 3.5.3 + minimatch: 3.1.2 + node-abort-controller: 3.1.1 + schema-utils: 3.3.0 + semver: 7.7.3 + tapable: 2.3.0 + typescript: 5.7.2 + webpack: 5.97.1 + + form-data@4.0.5: + 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 + + forwarded@0.2.0: {} + + fraction.js@5.3.4: {} + + fresh@0.5.2: {} + + fs-extra@10.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.0 + universalify: 2.0.1 + + fs-monkey@1.1.0: {} + + fs.realpath@1.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + + 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-package-type@0.1.0: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + get-stream@6.0.1: {} + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob-to-regexp@0.4.1: {} + + glob@10.4.5: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.5 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.5 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + 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: {} + + graceful-fs@4.2.11: {} + + graphemer@1.4.0: {} + + handlebars@4.7.8: + dependencies: + minimist: 1.2.8 + neo-async: 2.6.2 + source-map: 0.6.1 + wordwrap: 1.0.0 + optionalDependencies: + uglify-js: 3.19.3 + + has-flag@4.0.0: {} + + has-own-prop@2.0.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + 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: {} + + hookable@5.5.3: {} + + html-escaper@2.0.2: {} + + http-errors@2.0.0: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.1 + toidentifier: 1.0.1 + + human-signals@2.1.0: {} + + iconv-lite@0.4.24: + dependencies: + safer-buffer: 2.1.2 + + ieee754@1.2.1: {} + + ignore@5.3.2: {} + + immutable@5.1.4: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + import-local@3.2.0: + dependencies: + pkg-dir: 4.2.0 + resolve-cwd: 3.0.0 + + imurmurhash@0.1.4: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + inquirer@8.2.6: + dependencies: + ansi-escapes: 4.3.2 + chalk: 4.1.2 + cli-cursor: 3.1.0 + cli-width: 3.0.0 + external-editor: 3.1.0 + figures: 3.2.0 + lodash: 4.17.21 + mute-stream: 0.0.8 + ora: 5.4.1 + run-async: 2.4.1 + rxjs: 7.8.2 + string-width: 4.2.3 + strip-ansi: 6.0.1 + through: 2.3.8 + wrap-ansi: 6.2.0 + + inquirer@9.2.15: + dependencies: + '@ljharb/through': 2.3.14 + ansi-escapes: 4.3.2 + chalk: 5.6.2 + cli-cursor: 3.1.0 + cli-width: 4.1.0 + external-editor: 3.1.0 + figures: 3.2.0 + lodash: 4.17.21 + mute-stream: 1.0.0 + ora: 5.4.1 + run-async: 3.0.0 + rxjs: 7.8.2 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + + ipaddr.js@1.9.1: {} + + is-arrayish@0.2.1: {} + + 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-fullwidth-code-point@3.0.0: {} + + is-generator-fn@2.1.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-interactive@1.0.0: {} + + is-number@7.0.0: {} + + is-path-inside@3.0.3: {} + + is-plain-object@3.0.1: {} + + is-stream@2.0.1: {} + + is-unicode-supported@0.1.0: {} + + is-what@5.5.0: {} + + isexe@2.0.0: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-instrument@5.2.1: + dependencies: + '@babel/core': 7.28.5 + '@babel/parser': 7.28.5 + '@istanbuljs/schema': 0.1.3 + istanbul-lib-coverage: 3.2.2 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + istanbul-lib-instrument@6.0.3: + dependencies: + '@babel/core': 7.28.5 + '@babel/parser': 7.28.5 + '@istanbuljs/schema': 0.1.3 + istanbul-lib-coverage: 3.2.2 + semver: 7.7.3 + transitivePeerDependencies: + - supports-color + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@4.0.1: + dependencies: + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + source-map: 0.6.1 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + iterare@1.2.1: {} + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + jest-changed-files@29.7.0: + dependencies: + execa: 5.1.1 + jest-util: 29.7.0 + p-limit: 3.1.0 + + jest-circus@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/expect': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 20.19.25 + chalk: 4.1.2 + co: 4.6.0 + dedent: 1.7.0 + is-generator-fn: 2.1.0 + jest-each: 29.7.0 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-runtime: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + p-limit: 3.1.0 + pretty-format: 29.7.0 + pure-rand: 6.1.0 + slash: 3.0.0 + stack-utils: 2.0.6 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-cli@29.7.0(@types/node@20.19.25)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)): + dependencies: + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)) + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + chalk: 4.1.2 + create-jest: 29.7.0(@types/node@20.19.25)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)) + exit: 0.1.2 + import-local: 3.2.0 + jest-config: 29.7.0(@types/node@20.19.25)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)) + jest-util: 29.7.0 + jest-validate: 29.7.0 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + + jest-config@29.7.0(@types/node@20.19.25)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)): + dependencies: + '@babel/core': 7.28.5 + '@jest/test-sequencer': 29.7.0 + '@jest/types': 29.6.3 + babel-jest: 29.7.0(@babel/core@7.28.5) + chalk: 4.1.2 + ci-info: 3.9.0 + deepmerge: 4.3.1 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-circus: 29.7.0 + jest-environment-node: 29.7.0 + jest-get-type: 29.6.3 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-runner: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + micromatch: 4.0.8 + parse-json: 5.2.0 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 20.19.25 + ts-node: 10.9.2(@types/node@20.19.25)(typescript@5.9.3) + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-diff@29.7.0: + dependencies: + chalk: 4.1.2 + diff-sequences: 29.6.3 + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + + jest-docblock@29.7.0: + dependencies: + detect-newline: 3.1.0 + + jest-each@29.7.0: + dependencies: + '@jest/types': 29.6.3 + chalk: 4.1.2 + jest-get-type: 29.6.3 + jest-util: 29.7.0 + pretty-format: 29.7.0 + + jest-environment-node@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 20.19.25 + jest-mock: 29.7.0 + jest-util: 29.7.0 + + jest-get-type@29.6.3: {} + + jest-haste-map@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/graceful-fs': 4.1.9 + '@types/node': 20.19.25 + anymatch: 3.1.3 + fb-watchman: 2.0.2 + graceful-fs: 4.2.11 + jest-regex-util: 29.6.3 + jest-util: 29.7.0 + jest-worker: 29.7.0 + micromatch: 4.0.8 + walker: 1.0.8 + optionalDependencies: + fsevents: 2.3.3 + + jest-leak-detector@29.7.0: + dependencies: + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + + jest-matcher-utils@29.7.0: + dependencies: + chalk: 4.1.2 + jest-diff: 29.7.0 + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + + jest-message-util@29.7.0: + dependencies: + '@babel/code-frame': 7.27.1 + '@jest/types': 29.6.3 + '@types/stack-utils': 2.0.3 + chalk: 4.1.2 + graceful-fs: 4.2.11 + micromatch: 4.0.8 + pretty-format: 29.7.0 + slash: 3.0.0 + stack-utils: 2.0.6 + + jest-mock@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/node': 20.19.25 + jest-util: 29.7.0 + + jest-pnp-resolver@1.2.3(jest-resolve@29.7.0): + optionalDependencies: + jest-resolve: 29.7.0 + + jest-regex-util@29.6.3: {} + + jest-resolve-dependencies@29.7.0: + dependencies: + jest-regex-util: 29.6.3 + jest-snapshot: 29.7.0 + transitivePeerDependencies: + - supports-color + + jest-resolve@29.7.0: + dependencies: + chalk: 4.1.2 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + jest-pnp-resolver: 1.2.3(jest-resolve@29.7.0) + jest-util: 29.7.0 + jest-validate: 29.7.0 + resolve: 1.22.11 + resolve.exports: 2.0.3 + slash: 3.0.0 + + jest-runner@29.7.0: + dependencies: + '@jest/console': 29.7.0 + '@jest/environment': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 20.19.25 + chalk: 4.1.2 + emittery: 0.13.1 + graceful-fs: 4.2.11 + jest-docblock: 29.7.0 + jest-environment-node: 29.7.0 + jest-haste-map: 29.7.0 + jest-leak-detector: 29.7.0 + jest-message-util: 29.7.0 + jest-resolve: 29.7.0 + jest-runtime: 29.7.0 + jest-util: 29.7.0 + jest-watcher: 29.7.0 + jest-worker: 29.7.0 + p-limit: 3.1.0 + source-map-support: 0.5.13 + transitivePeerDependencies: + - supports-color + + jest-runtime@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/fake-timers': 29.7.0 + '@jest/globals': 29.7.0 + '@jest/source-map': 29.6.3 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 20.19.25 + chalk: 4.1.2 + cjs-module-lexer: 1.4.3 + collect-v8-coverage: 1.0.3 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + jest-message-util: 29.7.0 + jest-mock: 29.7.0 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + slash: 3.0.0 + strip-bom: 4.0.0 + transitivePeerDependencies: + - supports-color + + jest-snapshot@29.7.0: + dependencies: + '@babel/core': 7.28.5 + '@babel/generator': 7.28.5 + '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.5) + '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.28.5) + '@babel/types': 7.28.5 + '@jest/expect-utils': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.28.5) + chalk: 4.1.2 + expect: 29.7.0 + graceful-fs: 4.2.11 + jest-diff: 29.7.0 + jest-get-type: 29.6.3 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + natural-compare: 1.4.0 + pretty-format: 29.7.0 + semver: 7.7.3 + transitivePeerDependencies: + - supports-color + + jest-util@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/node': 20.19.25 + chalk: 4.1.2 + ci-info: 3.9.0 + graceful-fs: 4.2.11 + picomatch: 2.3.1 + + jest-validate@29.7.0: + dependencies: + '@jest/types': 29.6.3 + camelcase: 6.3.0 + chalk: 4.1.2 + jest-get-type: 29.6.3 + leven: 3.1.0 + pretty-format: 29.7.0 + + jest-watcher@29.7.0: + dependencies: + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 20.19.25 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + emittery: 0.13.1 + jest-util: 29.7.0 + string-length: 4.0.2 + + jest-worker@27.5.1: + dependencies: + '@types/node': 20.19.25 + merge-stream: 2.0.0 + supports-color: 8.1.1 + + jest-worker@29.7.0: + dependencies: + '@types/node': 20.19.25 + jest-util: 29.7.0 + merge-stream: 2.0.0 + supports-color: 8.1.1 + + jest@29.7.0(@types/node@20.19.25)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)): + dependencies: + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)) + '@jest/types': 29.6.3 + import-local: 3.2.0 + jest-cli: 29.7.0(@types/node@20.19.25)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + + jiti@1.21.7: {} + + js-tokens@4.0.0: {} + + js-yaml@3.14.2: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-parse-even-better-errors@2.3.1: {} + + json-schema-traverse@0.4.1: {} + + json-schema-traverse@1.0.0: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@2.2.3: {} + + jsonc-parser@3.2.1: {} + + jsonc-parser@3.3.1: {} + + jsonfile@6.2.0: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + jsonwebtoken@9.0.2: + dependencies: + jws: 3.2.2 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.7.3 + + jwa@1.4.2: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@3.2.2: + dependencies: + jwa: 1.4.2 + safe-buffer: 5.2.1 + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + kleur@3.0.3: {} + + leven@3.1.0: {} + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + libphonenumber-js@1.12.27: {} + + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + loader-runner@4.3.1: {} + + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash-es@4.17.21: {} + + lodash.includes@4.3.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + + lodash.memoize@4.1.2: {} + + lodash.merge@4.6.2: {} + + lodash.once@4.1.1: {} + + lodash@4.17.21: {} + + log-symbols@4.1.0: + dependencies: + chalk: 4.1.2 + is-unicode-supported: 0.1.0 + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + lru-cache@10.4.3: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magic-string@0.30.8: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + make-dir@4.0.0: + dependencies: + semver: 7.7.3 + + make-error@1.3.6: {} + + makeerror@1.0.12: + dependencies: + tmpl: 1.0.5 + + math-intrinsics@1.1.0: {} + + media-typer@0.3.0: {} + + memfs@3.5.3: + dependencies: + fs-monkey: 1.1.0 + + merge-descriptors@1.0.3: {} + + merge-stream@2.0.0: {} + + merge2@1.4.1: {} + + methods@1.1.2: {} + + 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 + + mime@1.6.0: {} + + mimic-fn@2.1.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 + + minimist@1.2.8: {} + + minipass@7.1.2: {} + + mitt@3.0.1: {} + + mkdirp@0.5.6: + dependencies: + minimist: 1.2.8 + + ms@2.0.0: {} + + ms@2.1.3: {} + + muggle-string@0.3.1: {} + + multer@2.0.2: + dependencies: + append-field: 1.0.0 + busboy: 1.6.0 + concat-stream: 2.0.0 + mkdirp: 0.5.6 + object-assign: 4.1.1 + type-is: 1.6.18 + xtend: 4.0.2 + + mute-stream@0.0.8: {} + + mute-stream@1.0.0: {} + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + nanoid@3.3.11: {} + + nanopop@2.4.2: {} + + natural-compare@1.4.0: {} + + negotiator@0.6.3: {} + + neo-async@2.6.2: {} + + node-abort-controller@3.1.1: {} + + node-addon-api@7.1.1: + optional: true + + node-addon-api@8.5.0: {} + + node-emoji@1.11.0: + dependencies: + lodash: 4.17.21 + + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + + node-gyp-build@4.8.4: {} + + node-int64@0.4.0: {} + + node-releases@2.0.27: {} + + normalize-path@3.0.0: {} + + normalize-range@0.1.2: {} + + npm-run-path@4.0.1: + dependencies: + path-key: 3.1.1 + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + + object-assign@4.1.1: {} + + object-hash@3.0.0: {} + + object-inspect@1.13.4: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + 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 + + ora@5.4.1: + dependencies: + bl: 4.1.0 + chalk: 4.1.2 + cli-cursor: 3.1.0 + cli-spinners: 2.9.2 + is-interactive: 1.0.0 + is-unicode-supported: 0.1.0 + log-symbols: 4.1.0 + strip-ansi: 6.0.1 + wcwidth: 1.0.1 + + os-tmpdir@1.0.2: {} + + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + p-try@2.2.0: {} + + package-json-from-dist@1.0.1: {} + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.27.1 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + parseurl@1.3.3: {} + + passport-jwt@4.0.1: + dependencies: + jsonwebtoken: 9.0.2 + passport-strategy: 1.0.0 + + passport-local@1.0.0: + dependencies: + passport-strategy: 1.0.0 + + passport-strategy@1.0.0: {} + + passport@0.7.0: + dependencies: + passport-strategy: 1.0.0 + pause: 0.0.1 + utils-merge: 1.0.1 + + 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-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.2 + + path-to-regexp@0.1.12: {} + + path-to-regexp@3.3.0: {} + + path-type@4.0.0: {} + + pause@0.0.1: {} + + perfect-debounce@1.0.0: {} + + picocolors@1.1.1: {} + + picomatch@2.3.1: {} + + picomatch@4.0.1: {} + + pify@2.3.0: {} + + pinia@2.3.1(typescript@5.9.3)(vue@3.5.24(typescript@5.9.3)): + dependencies: + '@vue/devtools-api': 6.6.4 + vue: 3.5.24(typescript@5.9.3) + vue-demi: 0.14.10(vue@3.5.24(typescript@5.9.3)) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@vue/composition-api' + + pirates@4.0.7: {} + + pkg-dir@4.2.0: + dependencies: + find-up: 4.1.0 + + pluralize@8.0.0: {} + + postcss-import@15.1.0(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + read-cache: 1.0.0 + resolve: 1.22.11 + + postcss-js@4.1.0(postcss@8.5.6): + dependencies: + camelcase-css: 2.0.1 + postcss: 8.5.6 + + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.6): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + jiti: 1.21.7 + postcss: 8.5.6 + + postcss-nested@6.2.0(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-selector-parser: 6.1.2 + + postcss-selector-parser@6.1.2: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-value-parser@4.2.0: {} + + 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-linter-helpers@1.0.0: + dependencies: + fast-diff: 1.3.0 + + prettier@3.6.2: {} + + pretty-format@29.7.0: + dependencies: + '@jest/schemas': 29.6.3 + ansi-styles: 5.2.0 + react-is: 18.3.1 + + prisma@5.22.0: + dependencies: + '@prisma/engines': 5.22.0 + optionalDependencies: + fsevents: 2.3.3 + + prompts@2.4.2: + dependencies: + kleur: 3.0.3 + sisteransi: 1.0.5 + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + proxy-from-env@1.1.0: {} + + punycode@2.3.1: {} + + pure-rand@6.1.0: {} + + qs@6.13.0: + dependencies: + side-channel: 1.1.0 + + queue-microtask@1.2.3: {} + + randombytes@2.1.0: + dependencies: + safe-buffer: 5.2.1 + + range-parser@1.2.1: {} + + raw-body@2.5.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.0 + iconv-lite: 0.4.24 + unpipe: 1.0.0 + + react-is@18.3.1: {} + + read-cache@1.0.0: + dependencies: + pify: 2.3.0 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.1 + + readdirp@4.1.2: {} + + reflect-metadata@0.2.2: {} + + repeat-string@1.6.1: {} + + require-directory@2.1.1: {} + + require-from-string@2.0.2: {} + + resize-observer-polyfill@1.5.1: {} + + resolve-cwd@3.0.0: + dependencies: + resolve-from: 5.0.0 + + resolve-from@4.0.0: {} + + resolve-from@5.0.0: {} + + resolve.exports@2.0.3: {} + + resolve@1.22.11: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + restore-cursor@3.1.0: + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + + reusify@1.1.0: {} + + rfdc@1.4.1: {} + + rimraf@3.0.2: + dependencies: + glob: 7.2.3 + + rollup@4.53.3: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.53.3 + '@rollup/rollup-android-arm64': 4.53.3 + '@rollup/rollup-darwin-arm64': 4.53.3 + '@rollup/rollup-darwin-x64': 4.53.3 + '@rollup/rollup-freebsd-arm64': 4.53.3 + '@rollup/rollup-freebsd-x64': 4.53.3 + '@rollup/rollup-linux-arm-gnueabihf': 4.53.3 + '@rollup/rollup-linux-arm-musleabihf': 4.53.3 + '@rollup/rollup-linux-arm64-gnu': 4.53.3 + '@rollup/rollup-linux-arm64-musl': 4.53.3 + '@rollup/rollup-linux-loong64-gnu': 4.53.3 + '@rollup/rollup-linux-ppc64-gnu': 4.53.3 + '@rollup/rollup-linux-riscv64-gnu': 4.53.3 + '@rollup/rollup-linux-riscv64-musl': 4.53.3 + '@rollup/rollup-linux-s390x-gnu': 4.53.3 + '@rollup/rollup-linux-x64-gnu': 4.53.3 + '@rollup/rollup-linux-x64-musl': 4.53.3 + '@rollup/rollup-openharmony-arm64': 4.53.3 + '@rollup/rollup-win32-arm64-msvc': 4.53.3 + '@rollup/rollup-win32-ia32-msvc': 4.53.3 + '@rollup/rollup-win32-x64-gnu': 4.53.3 + '@rollup/rollup-win32-x64-msvc': 4.53.3 + fsevents: 2.3.3 + + run-async@2.4.1: {} + + run-async@3.0.0: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + rxjs@7.8.1: + dependencies: + tslib: 2.8.1 + + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + + safe-buffer@5.2.1: {} + + safer-buffer@2.1.2: {} + + sass@1.94.1: + dependencies: + chokidar: 4.0.3 + immutable: 5.1.4 + source-map-js: 1.2.1 + optionalDependencies: + '@parcel/watcher': 2.5.1 + + schema-utils@3.3.0: + dependencies: + '@types/json-schema': 7.0.15 + ajv: 6.12.6 + ajv-keywords: 3.5.2(ajv@6.12.6) + + schema-utils@4.3.3: + dependencies: + '@types/json-schema': 7.0.15 + ajv: 8.17.1 + ajv-formats: 2.1.1(ajv@8.17.1) + ajv-keywords: 5.1.0(ajv@8.17.1) + + scroll-into-view-if-needed@2.2.31: + dependencies: + compute-scroll-into-view: 1.0.20 + + semver@6.3.1: {} + + semver@7.7.3: {} + + send@0.19.0: + dependencies: + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 1.0.2 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.0 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.1 + transitivePeerDependencies: + - supports-color + + serialize-javascript@6.0.2: + dependencies: + randombytes: 2.1.0 + + serve-static@1.16.2: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 0.19.0 + transitivePeerDependencies: + - supports-color + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + setprototypeof@1.2.0: {} + + shallow-equal@1.2.1: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + + sisteransi@1.0.5: {} + + slash@3.0.0: {} + + source-map-js@1.2.1: {} + + source-map-support@0.5.13: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + source-map@0.7.4: {} + + source-map@0.7.6: {} + + speakingurl@14.0.1: {} + + sprintf-js@1.0.3: {} + + stack-utils@2.0.6: + dependencies: + escape-string-regexp: 2.0.0 + + statuses@2.0.1: {} + + streamsearch@1.1.0: {} + + string-length@4.0.2: + dependencies: + char-regex: 1.0.2 + strip-ansi: 6.0.1 + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.2 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.1.2: + dependencies: + ansi-regex: 6.2.2 + + strip-bom@3.0.0: {} + + strip-bom@4.0.0: {} + + strip-final-newline@2.0.0: {} + + strip-json-comments@3.1.1: {} + + strtok3@10.3.4: + dependencies: + '@tokenizer/token': 0.3.0 + + stylis@4.3.6: {} + + sucrase@3.35.0: + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + commander: 4.1.1 + glob: 10.5.0 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + ts-interface-checker: 0.1.13 + + superjson@2.2.5: + dependencies: + copy-anything: 4.0.5 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + symbol-observable@4.0.0: {} + + synckit@0.11.11: + dependencies: + '@pkgr/core': 0.2.9 + + tailwindcss@3.4.18: + dependencies: + '@alloc/quick-lru': 5.2.0 + arg: 5.0.2 + chokidar: 3.6.0 + didyoumean: 1.2.2 + dlv: 1.1.3 + fast-glob: 3.3.3 + glob-parent: 6.0.2 + is-glob: 4.0.3 + jiti: 1.21.7 + lilconfig: 3.1.3 + micromatch: 4.0.8 + normalize-path: 3.0.0 + object-hash: 3.0.0 + picocolors: 1.1.1 + postcss: 8.5.6 + postcss-import: 15.1.0(postcss@8.5.6) + postcss-js: 4.1.0(postcss@8.5.6) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.6) + postcss-nested: 6.2.0(postcss@8.5.6) + postcss-selector-parser: 6.1.2 + resolve: 1.22.11 + sucrase: 3.35.0 + transitivePeerDependencies: + - tsx + - yaml + + tapable@2.3.0: {} + + terser-webpack-plugin@5.3.14(webpack@5.97.1): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + jest-worker: 27.5.1 + schema-utils: 4.3.3 + serialize-javascript: 6.0.2 + terser: 5.44.1 + webpack: 5.97.1 + + terser@5.44.1: + dependencies: + '@jridgewell/source-map': 0.3.11 + acorn: 8.15.0 + commander: 2.20.3 + source-map-support: 0.5.21 + + test-exclude@6.0.0: + dependencies: + '@istanbuljs/schema': 0.1.3 + glob: 7.2.3 + minimatch: 3.1.2 + + text-table@0.2.0: {} + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + throttle-debounce@5.0.2: {} + + through@2.3.8: {} + + tmp@0.0.33: + dependencies: + os-tmpdir: 1.0.2 + + tmpl@1.0.5: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toidentifier@1.0.1: {} + + token-types@6.1.1: + dependencies: + '@borewit/text-codec': 0.1.1 + '@tokenizer/token': 0.3.0 + ieee754: 1.2.1 + + tr46@0.0.3: {} + + tree-kill@1.2.2: {} + + ts-api-utils@1.4.3(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + ts-interface-checker@0.1.13: {} + + ts-jest@29.4.5(@babel/core@7.28.5)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.5))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.25)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)))(typescript@5.9.3): + dependencies: + bs-logger: 0.2.6 + fast-json-stable-stringify: 2.1.0 + handlebars: 4.7.8 + jest: 29.7.0(@types/node@20.19.25)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)) + json5: 2.2.3 + lodash.memoize: 4.1.2 + make-error: 1.3.6 + semver: 7.7.3 + type-fest: 4.41.0 + typescript: 5.9.3 + yargs-parser: 21.1.1 + optionalDependencies: + '@babel/core': 7.28.5 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + babel-jest: 29.7.0(@babel/core@7.28.5) + jest-util: 29.7.0 + + ts-loader@9.5.4(typescript@5.9.3)(webpack@5.97.1): + dependencies: + chalk: 4.1.2 + enhanced-resolve: 5.18.3 + micromatch: 4.0.8 + semver: 7.7.3 + source-map: 0.7.6 + typescript: 5.9.3 + webpack: 5.97.1 + + ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node10': 1.0.12 + '@tsconfig/node12': 1.0.11 + '@tsconfig/node14': 1.0.3 + '@tsconfig/node16': 1.0.4 + '@types/node': 20.19.25 + acorn: 8.15.0 + acorn-walk: 8.3.4 + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.2 + make-error: 1.3.6 + typescript: 5.9.3 + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 + + tsconfig-paths-webpack-plugin@4.2.0: + dependencies: + chalk: 4.1.2 + enhanced-resolve: 5.18.3 + tapable: 2.3.0 + tsconfig-paths: 4.2.0 + + tsconfig-paths@4.2.0: + dependencies: + json5: 2.2.3 + minimist: 1.2.8 + strip-bom: 3.0.0 + + tslib@2.8.1: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-detect@4.0.8: {} + + type-fest@0.20.2: {} + + type-fest@0.21.3: {} + + type-fest@4.41.0: {} + + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + + typedarray@0.0.6: {} + + typescript@5.7.2: {} + + typescript@5.9.3: {} + + uglify-js@3.19.3: + optional: true + + uid@2.0.2: + dependencies: + '@lukeed/csprng': 1.1.0 + + uint8array-extras@1.5.0: {} + + undici-types@6.21.0: {} + + universalify@2.0.1: {} + + unpipe@1.0.0: {} + + update-browserslist-db@1.1.4(browserslist@4.28.0): + dependencies: + browserslist: 4.28.0 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + util-deprecate@1.0.2: {} + + utils-merge@1.0.1: {} + + v8-compile-cache-lib@3.0.1: {} + + v8-to-istanbul@9.3.0: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@types/istanbul-lib-coverage': 2.0.6 + convert-source-map: 2.0.0 + + validator@13.15.23: {} + + vary@1.1.2: {} + + vee-validate@4.15.1(vue@3.5.24(typescript@5.9.3)): + dependencies: + '@vue/devtools-api': 7.7.9 + type-fest: 4.41.0 + vue: 3.5.24(typescript@5.9.3) + + vite@5.4.21(@types/node@20.19.25)(sass@1.94.1)(terser@5.44.1): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.6 + rollup: 4.53.3 + optionalDependencies: + '@types/node': 20.19.25 + fsevents: 2.3.3 + sass: 1.94.1 + terser: 5.44.1 + + vue-demi@0.14.10(vue@3.5.24(typescript@5.9.3)): + dependencies: + vue: 3.5.24(typescript@5.9.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.9.3)): + dependencies: + '@vue/devtools-api': 6.6.4 + vue: 3.5.24(typescript@5.9.3) + + vue-template-compiler@2.7.16: + dependencies: + de-indent: 1.0.2 + he: 1.2.0 + + vue-tsc@1.8.27(typescript@5.9.3): + dependencies: + '@volar/typescript': 1.11.1 + '@vue/language-core': 1.8.27(typescript@5.9.3) + semver: 7.7.3 + typescript: 5.9.3 + + vue-types@3.0.2(vue@3.5.24(typescript@5.9.3)): + dependencies: + is-plain-object: 3.0.1 + vue: 3.5.24(typescript@5.9.3) + + vue@3.5.24(typescript@5.9.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.9.3)) + '@vue/shared': 3.5.24 + optionalDependencies: + typescript: 5.9.3 + + walker@1.0.8: + dependencies: + makeerror: 1.0.12 + + warning@4.0.3: + dependencies: + loose-envify: 1.4.0 + + watchpack@2.4.4: + dependencies: + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + + wcwidth@1.0.1: + dependencies: + defaults: 1.0.4 + + webidl-conversions@3.0.1: {} + + webpack-node-externals@3.0.0: {} + + webpack-sources@3.3.3: {} + + webpack@5.97.1: + dependencies: + '@types/eslint-scope': 3.7.7 + '@types/estree': 1.0.8 + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/wasm-edit': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + acorn: 8.15.0 + browserslist: 4.28.0 + chrome-trace-event: 1.0.4 + enhanced-resolve: 5.18.3 + es-module-lexer: 1.7.0 + eslint-scope: 5.1.1 + events: 3.3.0 + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + json-parse-even-better-errors: 2.3.1 + loader-runner: 4.3.1 + mime-types: 2.1.35 + neo-async: 2.6.2 + schema-utils: 3.3.0 + tapable: 2.3.0 + terser-webpack-plugin: 5.3.14(webpack@5.97.1) + watchpack: 2.4.4 + webpack-sources: 3.3.3 + transitivePeerDependencies: + - '@swc/core' + - esbuild + - uglify-js + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + word-wrap@1.2.5: {} + + wordwrap@1.0.0: {} + + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.1.2 + + wrappy@1.0.2: {} + + write-file-atomic@4.0.2: + dependencies: + imurmurhash: 0.1.4 + signal-exit: 3.0.7 + + xml-name-validator@4.0.0: {} + + xtend@4.0.2: {} + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yn@3.1.1: {} + + yocto-queue@0.1.0: {} + + zod@3.25.76: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..a5e5eab --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,4 @@ +packages: + - 'frontend' + - 'backend' +