From c4023c162369c7d6fd3d20d57106a68471ffa4f9 Mon Sep 17 00:00:00 2001 From: zg Date: Wed, 15 Jul 2026 16:13:27 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E5=A7=8B=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- _backup_20260613/FileController.java | 176 ++ _backup_20260613/FileList.vue | 738 +++++ _backup_20260613/FileService.java | 49 + _backup_20260613/FileServiceImpl.java | 178 ++ _backup_20260613/IntegrationDoc.vue | 936 ++++++ _backup_20260613/OpenApiController.java | 507 +++ _backup_20260613/SystemConfig.vue | 85 + _backup_20260613/open-api-doc.md | 449 +++ add_doc_token.sql | 5 + check_docker.py | 29 + docs/integration-guide.md | 1014 ++++++ docs/open-api-doc.md | 268 ++ .../plans/2026-06-13-user-chip-widget.md | 1293 ++++++++ init_data.sql | 78 + init_database.py | 266 ++ init_gateway_system.py | 252 ++ init_menu.py | 48 + init_qa_database.py | 364 +++ init_test_data.py | 68 + memory/.session-last | 1 + memory/MEMORY.md | 24 + memory/session-20260611-1.md | 102 + memory/session-20260612-1.md | 133 + memory/session-20260612-2.md | 57 + memory/session-20260613-1.md | 73 + memory/session-20260613-2.md | 138 + memory/session-20260613-3.md | 142 + memory/session-20260613-4.md | 137 + memory/session-20260613-5.md | 90 + memory/session-20260615-1.md | 89 + memory/session-20260615-2.md | 56 + memory/session-20260617-1.md | 79 + memory/session-20260618-1.md | 55 + memory/session-20260618-2.md | 72 + memory/session-20260619-1.md | 59 + memory/session-20260620-1.md | 71 + memory/session-20260620-2.md | 51 + memory/session-20260621-1.md | 50 + memory/session-20260623-1.md | 38 + memory/session-20260628-1.md | 82 + migrate_to_uuid.sql | 84 + mokee-gateway-admin/pom.xml | 36 + .../com/mokee/admin/AdminApplication.java | 25 + .../admin/aspect/OperationLogAspect.java | 174 ++ .../mokee/admin/config/UserContextHolder.java | 36 + .../admin/config/UserContextInterceptor.java | 100 + .../com/mokee/admin/config/WebMvcConfig.java | 23 + .../admin/controller/ConfigController.java | 69 + .../admin/controller/DashboardController.java | 193 ++ .../admin/controller/DictController.java | 123 + .../admin/controller/EmailController.java | 184 ++ .../admin/controller/FileController.java | 56 + .../mokee/admin/controller/LogController.java | 64 + .../admin/controller/MenuController.java | 115 + .../admin/controller/OpenApiController.java | 452 +++ .../admin/controller/RoleController.java | 147 + .../admin/controller/SystemApiController.java | 117 + .../admin/controller/SystemController.java | 179 ++ .../admin/controller/UserController.java | 222 ++ .../com/mokee/admin/mapper/SysApiMapper.java | 12 + .../mokee/admin/mapper/SysConfigMapper.java | 14 + .../com/mokee/admin/mapper/SysDictMapper.java | 12 + .../mokee/admin/mapper/SysEmailLogMapper.java | 9 + .../com/mokee/admin/mapper/SysFileMapper.java | 10 + .../mokee/admin/mapper/SysLoginLogMapper.java | 12 + .../com/mokee/admin/mapper/SysMenuMapper.java | 12 + .../admin/mapper/SysOpenTokenLogMapper.java | 9 + .../admin/mapper/SysOperationLogMapper.java | 12 + .../com/mokee/admin/mapper/SysRoleMapper.java | 12 + .../mokee/admin/mapper/SysRoleMenuMapper.java | 12 + .../mokee/admin/mapper/SysSystemMapper.java | 12 + .../com/mokee/admin/mapper/SysUserMapper.java | 12 + .../mokee/admin/mapper/SysUserRoleMapper.java | 12 + .../admin/mapper/SysUserSystemMapper.java | 12 + .../mokee/admin/service/ConfigService.java | 49 + .../com/mokee/admin/service/DictService.java | 83 + .../com/mokee/admin/service/FileService.java | 28 + .../mokee/admin/service/LogAsyncService.java | 77 + .../com/mokee/admin/service/LogService.java | 67 + .../com/mokee/admin/service/MenuService.java | 51 + .../com/mokee/admin/service/RoleService.java | 67 + .../mokee/admin/service/SystemApiService.java | 48 + .../admin/service/SystemCacheService.java | 71 + .../mokee/admin/service/SystemService.java | 103 + .../com/mokee/admin/service/UserService.java | 142 + .../admin/service/impl/ConfigServiceImpl.java | 91 + .../admin/service/impl/DictServiceImpl.java | 130 + .../admin/service/impl/FileServiceImpl.java | 75 + .../admin/service/impl/LogServiceImpl.java | 190 ++ .../admin/service/impl/MenuServiceImpl.java | 190 ++ .../admin/service/impl/RoleServiceImpl.java | 279 ++ .../service/impl/SystemApiServiceImpl.java | 92 + .../admin/service/impl/SystemServiceImpl.java | 234 ++ .../admin/service/impl/UserServiceImpl.java | 478 +++ .../com/mokee/admin/task/ScheduledTask.java | 81 + .../src/main/resources/application-prod.yml | 40 + .../src/main/resources/application-qa.yml | 39 + .../src/main/resources/application.yml | 34 + mokee-gateway-auth/pom.xml | 32 + .../java/com/mokee/auth/AuthApplication.java | 23 + .../mokee/auth/controller/AuthController.java | 129 + .../mokee/auth/mapper/SysLoginLogMapper.java | 12 + .../com/mokee/auth/mapper/SysMenuMapper.java | 50 + .../com/mokee/auth/mapper/SysRoleMapper.java | 29 + .../mokee/auth/mapper/SysSystemMapper.java | 29 + .../com/mokee/auth/mapper/SysUserMapper.java | 12 + .../mokee/auth/mapper/SysUserRoleMapper.java | 12 + .../auth/mapper/SysUserSystemMapper.java | 12 + .../com/mokee/auth/service/AuthService.java | 83 + .../mokee/auth/service/LogAsyncService.java | 33 + .../auth/service/impl/AuthServiceImpl.java | 575 ++++ .../src/main/resources/application-prod.yml | 40 + .../src/main/resources/application-qa.yml | 39 + .../src/main/resources/application.yml | 30 + mokee-gateway-common/pom.xml | 77 + .../main/java/com/mokee/common/Result.java | 59 + .../mokee/common/annotation/OperationLog.java | 20 + .../com/mokee/common/config/AsyncConfig.java | 40 + .../mokee/common/config/JacksonConfig.java | 44 + .../common/config/MybatisPlusConfig.java | 65 + .../common/dto/request/LoginRequest.java | 27 + .../common/dto/response/LoginResponse.java | 39 + .../mokee/common/dto/response/MenuMetaVO.java | 22 + .../dto/response/MenuRouterResponse.java | 44 + .../common/dto/response/MenuRouterVO.java | 120 + .../mokee/common/dto/response/RoleInfo.java | 29 + .../mokee/common/dto/response/SystemVO.java | 34 + .../mokee/common/dto/response/TokenInfo.java | 57 + .../mokee/common/dto/response/UserInfoVO.java | 48 + .../java/com/mokee/common/entity/SysApi.java | 63 + .../com/mokee/common/entity/SysConfig.java | 50 + .../java/com/mokee/common/entity/SysDict.java | 59 + .../com/mokee/common/entity/SysEmailLog.java | 37 + .../java/com/mokee/common/entity/SysFile.java | 67 + .../com/mokee/common/entity/SysLoginLog.java | 120 + .../java/com/mokee/common/entity/SysMenu.java | 79 + .../mokee/common/entity/SysOpenTokenLog.java | 43 + .../mokee/common/entity/SysOperationLog.java | 83 + .../java/com/mokee/common/entity/SysRole.java | 124 + .../com/mokee/common/entity/SysRoleMenu.java | 43 + .../com/mokee/common/entity/SysSystem.java | 93 + .../java/com/mokee/common/entity/SysUser.java | 93 + .../com/mokee/common/entity/SysUserRole.java | 43 + .../mokee/common/entity/SysUserSystem.java | 43 + .../com/mokee/common/enums/ActionEnum.java | 42 + .../com/mokee/common/enums/MenuTypeEnum.java | 27 + .../com/mokee/common/enums/StatusEnum.java | 42 + .../common/exception/BusinessException.java | 37 + .../com/mokee/common/exception/ErrorCode.java | 56 + .../exception/GlobalExceptionHandler.java | 49 + .../mokee/common/service/EmailService.java | 82 + .../java/com/mokee/common/utils/IpUtil.java | 54 + .../java/com/mokee/common/utils/JwtUtil.java | 186 ++ .../com/mokee/common/utils/RedisUtil.java | 199 ++ .../com/mokee/common/utils/UserAgentUtil.java | 74 + mokee-gateway-gateway/pom.xml | 32 + .../com/mokee/gateway/GatewayApplication.java | 22 + .../com/mokee/gateway/config/CorsConfig.java | 24 + .../gateway/config/RestTemplateConfig.java | 41 + .../gateway/filter/ApiPermissionFilter.java | 131 + .../gateway/filter/HeaderInjectFilter.java | 134 + .../mokee/gateway/filter/RateLimitFilter.java | 88 + .../mokee/gateway/filter/TokenAuthFilter.java | 236 ++ .../gateway/handler/GatewayController.java | 194 ++ .../mokee/gateway/mapper/SysApiMapper.java | 14 + .../mokee/gateway/mapper/SysSystemMapper.java | 14 + .../gateway/mapper/SysUserSystemMapper.java | 12 + .../src/main/resources/application-prod.yml | 35 + .../src/main/resources/application-qa.yml | 34 + .../src/main/resources/application.yml | 17 + mokee-gateway-web/.env.development | 5 + mokee-gateway-web/.env.production | 5 + mokee-gateway-web/.nvmrc | 1 + .../IntegrationDoc.vue | 945 ++++++ .../_backup_20260613_widget/package.json | 29 + mokee-gateway-web/auto-imports.d.ts | 10 + .../backup-on-demand/App.vue.bak | 6 + .../backup-on-demand/main.ts.bak | 22 + .../views-20260612-2236/admin/AdminLayout.vue | 557 ++++ .../views-20260612-2236/admin/Dashboard.vue | 353 +++ .../admin/PersonalView.vue | 302 ++ .../admin/dict/DictForm.vue | 216 ++ .../admin/dict/DictList.vue | 284 ++ .../admin/email/EmailList.vue | 357 +++ .../admin/file/FileList.vue | 361 +++ .../admin/log/LoginLogList.vue | 185 ++ .../admin/log/OpenTokenLogList.vue | 82 + .../admin/log/OperationLogList.vue | 314 ++ .../admin/menu/MenuForm.vue | 325 ++ .../admin/menu/MenuList.vue | 246 ++ .../admin/role/RoleForm.vue | 205 ++ .../admin/role/RoleList.vue | 315 ++ .../admin/system/ApiForm.vue | 206 ++ .../admin/system/ApiList.vue | 280 ++ .../admin/system/SystemForm.vue | 201 ++ .../admin/system/SystemList.vue | 238 ++ .../admin/user/UserForm.vue | 309 ++ .../admin/user/UserList.vue | 250 ++ .../docs/IntegrationDoc.vue | 627 ++++ .../views-20260612-2236/login/LoginView.vue | 281 ++ .../select/SystemSelectView.vue | 287 ++ .../backup-on-demand/vite.config.ts.bak | 28 + mokee-gateway-web/components.d.ts | 61 + .../dist-widget/demo-widget.html | 106 + mokee-gateway-web/dist-widget/user-chip.css | 1 + mokee-gateway-web/dist-widget/user-chip.js | 91 + mokee-gateway-web/index.html | 13 + .../memory/session-20260612-1.md | 50 + mokee-gateway-web/package-lock.json | 2754 +++++++++++++++++ mokee-gateway-web/package.json | 30 + mokee-gateway-web/public/demo-widget.html | 106 + mokee-gateway-web/src/App.vue | 19 + .../src/components/IconPicker.vue | 171 + .../src/components/UploadPanel.vue | 140 + .../src/composables/useIsMobile.ts | 18 + .../src/directives/permission.ts | 20 + mokee-gateway-web/src/env.d.ts | 7 + mokee-gateway-web/src/main.ts | 43 + mokee-gateway-web/src/router/index.ts | 208 ++ mokee-gateway-web/src/stores/auth.ts | 150 + mokee-gateway-web/src/stores/tabs.ts | 66 + mokee-gateway-web/src/stores/upload.ts | 79 + mokee-gateway-web/src/styles/global.css | 450 +++ mokee-gateway-web/src/types/components.d.ts | 61 + mokee-gateway-web/src/utils/date.ts | 127 + mokee-gateway-web/src/utils/icons.ts | 22 + mokee-gateway-web/src/utils/request.ts | 69 + .../src/views/admin/AdminLayout.vue | 564 ++++ .../src/views/admin/Dashboard.vue | 324 ++ .../src/views/admin/PersonalView.vue | 341 ++ .../src/views/admin/config/SystemConfig.vue | 61 + .../src/views/admin/dict/DictForm.vue | 216 ++ .../src/views/admin/dict/DictList.vue | 331 ++ .../src/views/admin/email/EmailList.vue | 286 ++ .../src/views/admin/file/FileList.vue | 468 +++ .../src/views/admin/log/LoginLogList.vue | 190 ++ .../src/views/admin/log/OpenTokenLogList.vue | 280 ++ .../src/views/admin/log/OperationLogList.vue | 445 +++ .../src/views/admin/menu/MenuForm.vue | 366 +++ .../src/views/admin/menu/MenuList.vue | 309 ++ .../src/views/admin/role/RoleForm.vue | 226 ++ .../src/views/admin/role/RoleList.vue | 380 +++ .../src/views/admin/system/ApiForm.vue | 236 ++ .../src/views/admin/system/ApiList.vue | 314 ++ .../src/views/admin/system/SystemForm.vue | 201 ++ .../src/views/admin/system/SystemList.vue | 272 ++ .../src/views/admin/user/UserForm.vue | 537 ++++ .../src/views/admin/user/UserList.vue | 279 ++ .../src/views/docs/IntegrationDoc.vue | 1399 +++++++++ .../src/views/login/LoginView.vue | 322 ++ .../src/views/select/SystemSelectView.vue | 356 +++ .../src/widgets/user-chip/UserChip.vue | 248 ++ .../src/widgets/user-chip/api.ts | 139 + .../src/widgets/user-chip/cookie.ts | 42 + .../src/widgets/user-chip/main.ts | 92 + mokee-gateway-web/tsconfig.json | 21 + mokee-gateway-web/vite.config.ts | 81 + mokee-gateway-web/vite.widget.config.ts | 43 + pom.xml | 156 + sql/add_file_menu.sql | 41 + sql/add_file_storage.sql | 36 + sql/fix_all_permissions.sql | 157 + sql/init_prod_migrate.py | 103 + sql/init_qa.py | 113 + sql/prod_add_email_tables.sql | 34 + sql/prod_add_file_url_compat.sql | 4 + sql/prod_add_permissions.sql | 65 + sql/prod_align_qa.sql | 33 + sql/prod_alter_20260613.sql | 3 + sql/prod_fix_data.sql | 88 + sql/prod_migrate_sys_file.sql | 34 + sql/prod_migrate_uuid.sql | 83 + sql/qa_data.sql | 128 + sql/qa_ddl.sql | 226 ++ sql/qa_recreate.sql | 6 + 统一登录网关平台-需求文档.md | 754 +++++ 276 files changed, 42622 insertions(+) create mode 100644 _backup_20260613/FileController.java create mode 100644 _backup_20260613/FileList.vue create mode 100644 _backup_20260613/FileService.java create mode 100644 _backup_20260613/FileServiceImpl.java create mode 100644 _backup_20260613/IntegrationDoc.vue create mode 100644 _backup_20260613/OpenApiController.java create mode 100644 _backup_20260613/SystemConfig.vue create mode 100644 _backup_20260613/open-api-doc.md create mode 100644 add_doc_token.sql create mode 100644 check_docker.py create mode 100644 docs/integration-guide.md create mode 100644 docs/open-api-doc.md create mode 100644 docs/superpowers/plans/2026-06-13-user-chip-widget.md create mode 100644 init_data.sql create mode 100644 init_database.py create mode 100644 init_gateway_system.py create mode 100644 init_menu.py create mode 100644 init_qa_database.py create mode 100644 init_test_data.py create mode 100644 memory/.session-last create mode 100644 memory/MEMORY.md create mode 100644 memory/session-20260611-1.md create mode 100644 memory/session-20260612-1.md create mode 100644 memory/session-20260612-2.md create mode 100644 memory/session-20260613-1.md create mode 100644 memory/session-20260613-2.md create mode 100644 memory/session-20260613-3.md create mode 100644 memory/session-20260613-4.md create mode 100644 memory/session-20260613-5.md create mode 100644 memory/session-20260615-1.md create mode 100644 memory/session-20260615-2.md create mode 100644 memory/session-20260617-1.md create mode 100644 memory/session-20260618-1.md create mode 100644 memory/session-20260618-2.md create mode 100644 memory/session-20260619-1.md create mode 100644 memory/session-20260620-1.md create mode 100644 memory/session-20260620-2.md create mode 100644 memory/session-20260621-1.md create mode 100644 memory/session-20260623-1.md create mode 100644 memory/session-20260628-1.md create mode 100644 migrate_to_uuid.sql create mode 100644 mokee-gateway-admin/pom.xml create mode 100644 mokee-gateway-admin/src/main/java/com/mokee/admin/AdminApplication.java create mode 100644 mokee-gateway-admin/src/main/java/com/mokee/admin/aspect/OperationLogAspect.java create mode 100644 mokee-gateway-admin/src/main/java/com/mokee/admin/config/UserContextHolder.java create mode 100644 mokee-gateway-admin/src/main/java/com/mokee/admin/config/UserContextInterceptor.java create mode 100644 mokee-gateway-admin/src/main/java/com/mokee/admin/config/WebMvcConfig.java create mode 100644 mokee-gateway-admin/src/main/java/com/mokee/admin/controller/ConfigController.java create mode 100644 mokee-gateway-admin/src/main/java/com/mokee/admin/controller/DashboardController.java create mode 100644 mokee-gateway-admin/src/main/java/com/mokee/admin/controller/DictController.java create mode 100644 mokee-gateway-admin/src/main/java/com/mokee/admin/controller/EmailController.java create mode 100644 mokee-gateway-admin/src/main/java/com/mokee/admin/controller/FileController.java create mode 100644 mokee-gateway-admin/src/main/java/com/mokee/admin/controller/LogController.java create mode 100644 mokee-gateway-admin/src/main/java/com/mokee/admin/controller/MenuController.java create mode 100644 mokee-gateway-admin/src/main/java/com/mokee/admin/controller/OpenApiController.java create mode 100644 mokee-gateway-admin/src/main/java/com/mokee/admin/controller/RoleController.java create mode 100644 mokee-gateway-admin/src/main/java/com/mokee/admin/controller/SystemApiController.java create mode 100644 mokee-gateway-admin/src/main/java/com/mokee/admin/controller/SystemController.java create mode 100644 mokee-gateway-admin/src/main/java/com/mokee/admin/controller/UserController.java create mode 100644 mokee-gateway-admin/src/main/java/com/mokee/admin/mapper/SysApiMapper.java create mode 100644 mokee-gateway-admin/src/main/java/com/mokee/admin/mapper/SysConfigMapper.java create mode 100644 mokee-gateway-admin/src/main/java/com/mokee/admin/mapper/SysDictMapper.java create mode 100644 mokee-gateway-admin/src/main/java/com/mokee/admin/mapper/SysEmailLogMapper.java create mode 100644 mokee-gateway-admin/src/main/java/com/mokee/admin/mapper/SysFileMapper.java create mode 100644 mokee-gateway-admin/src/main/java/com/mokee/admin/mapper/SysLoginLogMapper.java create mode 100644 mokee-gateway-admin/src/main/java/com/mokee/admin/mapper/SysMenuMapper.java create mode 100644 mokee-gateway-admin/src/main/java/com/mokee/admin/mapper/SysOpenTokenLogMapper.java create mode 100644 mokee-gateway-admin/src/main/java/com/mokee/admin/mapper/SysOperationLogMapper.java create mode 100644 mokee-gateway-admin/src/main/java/com/mokee/admin/mapper/SysRoleMapper.java create mode 100644 mokee-gateway-admin/src/main/java/com/mokee/admin/mapper/SysRoleMenuMapper.java create mode 100644 mokee-gateway-admin/src/main/java/com/mokee/admin/mapper/SysSystemMapper.java create mode 100644 mokee-gateway-admin/src/main/java/com/mokee/admin/mapper/SysUserMapper.java create mode 100644 mokee-gateway-admin/src/main/java/com/mokee/admin/mapper/SysUserRoleMapper.java create mode 100644 mokee-gateway-admin/src/main/java/com/mokee/admin/mapper/SysUserSystemMapper.java create mode 100644 mokee-gateway-admin/src/main/java/com/mokee/admin/service/ConfigService.java create mode 100644 mokee-gateway-admin/src/main/java/com/mokee/admin/service/DictService.java create mode 100644 mokee-gateway-admin/src/main/java/com/mokee/admin/service/FileService.java create mode 100644 mokee-gateway-admin/src/main/java/com/mokee/admin/service/LogAsyncService.java create mode 100644 mokee-gateway-admin/src/main/java/com/mokee/admin/service/LogService.java create mode 100644 mokee-gateway-admin/src/main/java/com/mokee/admin/service/MenuService.java create mode 100644 mokee-gateway-admin/src/main/java/com/mokee/admin/service/RoleService.java create mode 100644 mokee-gateway-admin/src/main/java/com/mokee/admin/service/SystemApiService.java create mode 100644 mokee-gateway-admin/src/main/java/com/mokee/admin/service/SystemCacheService.java create mode 100644 mokee-gateway-admin/src/main/java/com/mokee/admin/service/SystemService.java create mode 100644 mokee-gateway-admin/src/main/java/com/mokee/admin/service/UserService.java create mode 100644 mokee-gateway-admin/src/main/java/com/mokee/admin/service/impl/ConfigServiceImpl.java create mode 100644 mokee-gateway-admin/src/main/java/com/mokee/admin/service/impl/DictServiceImpl.java create mode 100644 mokee-gateway-admin/src/main/java/com/mokee/admin/service/impl/FileServiceImpl.java create mode 100644 mokee-gateway-admin/src/main/java/com/mokee/admin/service/impl/LogServiceImpl.java create mode 100644 mokee-gateway-admin/src/main/java/com/mokee/admin/service/impl/MenuServiceImpl.java create mode 100644 mokee-gateway-admin/src/main/java/com/mokee/admin/service/impl/RoleServiceImpl.java create mode 100644 mokee-gateway-admin/src/main/java/com/mokee/admin/service/impl/SystemApiServiceImpl.java create mode 100644 mokee-gateway-admin/src/main/java/com/mokee/admin/service/impl/SystemServiceImpl.java create mode 100644 mokee-gateway-admin/src/main/java/com/mokee/admin/service/impl/UserServiceImpl.java create mode 100644 mokee-gateway-admin/src/main/java/com/mokee/admin/task/ScheduledTask.java create mode 100644 mokee-gateway-admin/src/main/resources/application-prod.yml create mode 100644 mokee-gateway-admin/src/main/resources/application-qa.yml create mode 100644 mokee-gateway-admin/src/main/resources/application.yml create mode 100644 mokee-gateway-auth/pom.xml create mode 100644 mokee-gateway-auth/src/main/java/com/mokee/auth/AuthApplication.java create mode 100644 mokee-gateway-auth/src/main/java/com/mokee/auth/controller/AuthController.java create mode 100644 mokee-gateway-auth/src/main/java/com/mokee/auth/mapper/SysLoginLogMapper.java create mode 100644 mokee-gateway-auth/src/main/java/com/mokee/auth/mapper/SysMenuMapper.java create mode 100644 mokee-gateway-auth/src/main/java/com/mokee/auth/mapper/SysRoleMapper.java create mode 100644 mokee-gateway-auth/src/main/java/com/mokee/auth/mapper/SysSystemMapper.java create mode 100644 mokee-gateway-auth/src/main/java/com/mokee/auth/mapper/SysUserMapper.java create mode 100644 mokee-gateway-auth/src/main/java/com/mokee/auth/mapper/SysUserRoleMapper.java create mode 100644 mokee-gateway-auth/src/main/java/com/mokee/auth/mapper/SysUserSystemMapper.java create mode 100644 mokee-gateway-auth/src/main/java/com/mokee/auth/service/AuthService.java create mode 100644 mokee-gateway-auth/src/main/java/com/mokee/auth/service/LogAsyncService.java create mode 100644 mokee-gateway-auth/src/main/java/com/mokee/auth/service/impl/AuthServiceImpl.java create mode 100644 mokee-gateway-auth/src/main/resources/application-prod.yml create mode 100644 mokee-gateway-auth/src/main/resources/application-qa.yml create mode 100644 mokee-gateway-auth/src/main/resources/application.yml create mode 100644 mokee-gateway-common/pom.xml create mode 100644 mokee-gateway-common/src/main/java/com/mokee/common/Result.java create mode 100644 mokee-gateway-common/src/main/java/com/mokee/common/annotation/OperationLog.java create mode 100644 mokee-gateway-common/src/main/java/com/mokee/common/config/AsyncConfig.java create mode 100644 mokee-gateway-common/src/main/java/com/mokee/common/config/JacksonConfig.java create mode 100644 mokee-gateway-common/src/main/java/com/mokee/common/config/MybatisPlusConfig.java create mode 100644 mokee-gateway-common/src/main/java/com/mokee/common/dto/request/LoginRequest.java create mode 100644 mokee-gateway-common/src/main/java/com/mokee/common/dto/response/LoginResponse.java create mode 100644 mokee-gateway-common/src/main/java/com/mokee/common/dto/response/MenuMetaVO.java create mode 100644 mokee-gateway-common/src/main/java/com/mokee/common/dto/response/MenuRouterResponse.java create mode 100644 mokee-gateway-common/src/main/java/com/mokee/common/dto/response/MenuRouterVO.java create mode 100644 mokee-gateway-common/src/main/java/com/mokee/common/dto/response/RoleInfo.java create mode 100644 mokee-gateway-common/src/main/java/com/mokee/common/dto/response/SystemVO.java create mode 100644 mokee-gateway-common/src/main/java/com/mokee/common/dto/response/TokenInfo.java create mode 100644 mokee-gateway-common/src/main/java/com/mokee/common/dto/response/UserInfoVO.java create mode 100644 mokee-gateway-common/src/main/java/com/mokee/common/entity/SysApi.java create mode 100644 mokee-gateway-common/src/main/java/com/mokee/common/entity/SysConfig.java create mode 100644 mokee-gateway-common/src/main/java/com/mokee/common/entity/SysDict.java create mode 100644 mokee-gateway-common/src/main/java/com/mokee/common/entity/SysEmailLog.java create mode 100644 mokee-gateway-common/src/main/java/com/mokee/common/entity/SysFile.java create mode 100644 mokee-gateway-common/src/main/java/com/mokee/common/entity/SysLoginLog.java create mode 100644 mokee-gateway-common/src/main/java/com/mokee/common/entity/SysMenu.java create mode 100644 mokee-gateway-common/src/main/java/com/mokee/common/entity/SysOpenTokenLog.java create mode 100644 mokee-gateway-common/src/main/java/com/mokee/common/entity/SysOperationLog.java create mode 100644 mokee-gateway-common/src/main/java/com/mokee/common/entity/SysRole.java create mode 100644 mokee-gateway-common/src/main/java/com/mokee/common/entity/SysRoleMenu.java create mode 100644 mokee-gateway-common/src/main/java/com/mokee/common/entity/SysSystem.java create mode 100644 mokee-gateway-common/src/main/java/com/mokee/common/entity/SysUser.java create mode 100644 mokee-gateway-common/src/main/java/com/mokee/common/entity/SysUserRole.java create mode 100644 mokee-gateway-common/src/main/java/com/mokee/common/entity/SysUserSystem.java create mode 100644 mokee-gateway-common/src/main/java/com/mokee/common/enums/ActionEnum.java create mode 100644 mokee-gateway-common/src/main/java/com/mokee/common/enums/MenuTypeEnum.java create mode 100644 mokee-gateway-common/src/main/java/com/mokee/common/enums/StatusEnum.java create mode 100644 mokee-gateway-common/src/main/java/com/mokee/common/exception/BusinessException.java create mode 100644 mokee-gateway-common/src/main/java/com/mokee/common/exception/ErrorCode.java create mode 100644 mokee-gateway-common/src/main/java/com/mokee/common/exception/GlobalExceptionHandler.java create mode 100644 mokee-gateway-common/src/main/java/com/mokee/common/service/EmailService.java create mode 100644 mokee-gateway-common/src/main/java/com/mokee/common/utils/IpUtil.java create mode 100644 mokee-gateway-common/src/main/java/com/mokee/common/utils/JwtUtil.java create mode 100644 mokee-gateway-common/src/main/java/com/mokee/common/utils/RedisUtil.java create mode 100644 mokee-gateway-common/src/main/java/com/mokee/common/utils/UserAgentUtil.java create mode 100644 mokee-gateway-gateway/pom.xml create mode 100644 mokee-gateway-gateway/src/main/java/com/mokee/gateway/GatewayApplication.java create mode 100644 mokee-gateway-gateway/src/main/java/com/mokee/gateway/config/CorsConfig.java create mode 100644 mokee-gateway-gateway/src/main/java/com/mokee/gateway/config/RestTemplateConfig.java create mode 100644 mokee-gateway-gateway/src/main/java/com/mokee/gateway/filter/ApiPermissionFilter.java create mode 100644 mokee-gateway-gateway/src/main/java/com/mokee/gateway/filter/HeaderInjectFilter.java create mode 100644 mokee-gateway-gateway/src/main/java/com/mokee/gateway/filter/RateLimitFilter.java create mode 100644 mokee-gateway-gateway/src/main/java/com/mokee/gateway/filter/TokenAuthFilter.java create mode 100644 mokee-gateway-gateway/src/main/java/com/mokee/gateway/handler/GatewayController.java create mode 100644 mokee-gateway-gateway/src/main/java/com/mokee/gateway/mapper/SysApiMapper.java create mode 100644 mokee-gateway-gateway/src/main/java/com/mokee/gateway/mapper/SysSystemMapper.java create mode 100644 mokee-gateway-gateway/src/main/java/com/mokee/gateway/mapper/SysUserSystemMapper.java create mode 100644 mokee-gateway-gateway/src/main/resources/application-prod.yml create mode 100644 mokee-gateway-gateway/src/main/resources/application-qa.yml create mode 100644 mokee-gateway-gateway/src/main/resources/application.yml create mode 100644 mokee-gateway-web/.env.development create mode 100644 mokee-gateway-web/.env.production create mode 100644 mokee-gateway-web/.nvmrc create mode 100644 mokee-gateway-web/_backup_20260613_widget/IntegrationDoc.vue create mode 100644 mokee-gateway-web/_backup_20260613_widget/package.json create mode 100644 mokee-gateway-web/auto-imports.d.ts create mode 100644 mokee-gateway-web/backup-on-demand/App.vue.bak create mode 100644 mokee-gateway-web/backup-on-demand/main.ts.bak create mode 100644 mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/AdminLayout.vue create mode 100644 mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/Dashboard.vue create mode 100644 mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/PersonalView.vue create mode 100644 mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/dict/DictForm.vue create mode 100644 mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/dict/DictList.vue create mode 100644 mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/email/EmailList.vue create mode 100644 mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/file/FileList.vue create mode 100644 mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/log/LoginLogList.vue create mode 100644 mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/log/OpenTokenLogList.vue create mode 100644 mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/log/OperationLogList.vue create mode 100644 mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/menu/MenuForm.vue create mode 100644 mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/menu/MenuList.vue create mode 100644 mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/role/RoleForm.vue create mode 100644 mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/role/RoleList.vue create mode 100644 mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/system/ApiForm.vue create mode 100644 mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/system/ApiList.vue create mode 100644 mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/system/SystemForm.vue create mode 100644 mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/system/SystemList.vue create mode 100644 mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/user/UserForm.vue create mode 100644 mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/user/UserList.vue create mode 100644 mokee-gateway-web/backup-on-demand/views-20260612-2236/docs/IntegrationDoc.vue create mode 100644 mokee-gateway-web/backup-on-demand/views-20260612-2236/login/LoginView.vue create mode 100644 mokee-gateway-web/backup-on-demand/views-20260612-2236/select/SystemSelectView.vue create mode 100644 mokee-gateway-web/backup-on-demand/vite.config.ts.bak create mode 100644 mokee-gateway-web/components.d.ts create mode 100644 mokee-gateway-web/dist-widget/demo-widget.html create mode 100644 mokee-gateway-web/dist-widget/user-chip.css create mode 100644 mokee-gateway-web/dist-widget/user-chip.js create mode 100644 mokee-gateway-web/index.html create mode 100644 mokee-gateway-web/memory/session-20260612-1.md create mode 100644 mokee-gateway-web/package-lock.json create mode 100644 mokee-gateway-web/package.json create mode 100644 mokee-gateway-web/public/demo-widget.html create mode 100644 mokee-gateway-web/src/App.vue create mode 100644 mokee-gateway-web/src/components/IconPicker.vue create mode 100644 mokee-gateway-web/src/components/UploadPanel.vue create mode 100644 mokee-gateway-web/src/composables/useIsMobile.ts create mode 100644 mokee-gateway-web/src/directives/permission.ts create mode 100644 mokee-gateway-web/src/env.d.ts create mode 100644 mokee-gateway-web/src/main.ts create mode 100644 mokee-gateway-web/src/router/index.ts create mode 100644 mokee-gateway-web/src/stores/auth.ts create mode 100644 mokee-gateway-web/src/stores/tabs.ts create mode 100644 mokee-gateway-web/src/stores/upload.ts create mode 100644 mokee-gateway-web/src/styles/global.css create mode 100644 mokee-gateway-web/src/types/components.d.ts create mode 100644 mokee-gateway-web/src/utils/date.ts create mode 100644 mokee-gateway-web/src/utils/icons.ts create mode 100644 mokee-gateway-web/src/utils/request.ts create mode 100644 mokee-gateway-web/src/views/admin/AdminLayout.vue create mode 100644 mokee-gateway-web/src/views/admin/Dashboard.vue create mode 100644 mokee-gateway-web/src/views/admin/PersonalView.vue create mode 100644 mokee-gateway-web/src/views/admin/config/SystemConfig.vue create mode 100644 mokee-gateway-web/src/views/admin/dict/DictForm.vue create mode 100644 mokee-gateway-web/src/views/admin/dict/DictList.vue create mode 100644 mokee-gateway-web/src/views/admin/email/EmailList.vue create mode 100644 mokee-gateway-web/src/views/admin/file/FileList.vue create mode 100644 mokee-gateway-web/src/views/admin/log/LoginLogList.vue create mode 100644 mokee-gateway-web/src/views/admin/log/OpenTokenLogList.vue create mode 100644 mokee-gateway-web/src/views/admin/log/OperationLogList.vue create mode 100644 mokee-gateway-web/src/views/admin/menu/MenuForm.vue create mode 100644 mokee-gateway-web/src/views/admin/menu/MenuList.vue create mode 100644 mokee-gateway-web/src/views/admin/role/RoleForm.vue create mode 100644 mokee-gateway-web/src/views/admin/role/RoleList.vue create mode 100644 mokee-gateway-web/src/views/admin/system/ApiForm.vue create mode 100644 mokee-gateway-web/src/views/admin/system/ApiList.vue create mode 100644 mokee-gateway-web/src/views/admin/system/SystemForm.vue create mode 100644 mokee-gateway-web/src/views/admin/system/SystemList.vue create mode 100644 mokee-gateway-web/src/views/admin/user/UserForm.vue create mode 100644 mokee-gateway-web/src/views/admin/user/UserList.vue create mode 100644 mokee-gateway-web/src/views/docs/IntegrationDoc.vue create mode 100644 mokee-gateway-web/src/views/login/LoginView.vue create mode 100644 mokee-gateway-web/src/views/select/SystemSelectView.vue create mode 100644 mokee-gateway-web/src/widgets/user-chip/UserChip.vue create mode 100644 mokee-gateway-web/src/widgets/user-chip/api.ts create mode 100644 mokee-gateway-web/src/widgets/user-chip/cookie.ts create mode 100644 mokee-gateway-web/src/widgets/user-chip/main.ts create mode 100644 mokee-gateway-web/tsconfig.json create mode 100644 mokee-gateway-web/vite.config.ts create mode 100644 mokee-gateway-web/vite.widget.config.ts create mode 100644 pom.xml create mode 100644 sql/add_file_menu.sql create mode 100644 sql/add_file_storage.sql create mode 100644 sql/fix_all_permissions.sql create mode 100644 sql/init_prod_migrate.py create mode 100644 sql/init_qa.py create mode 100644 sql/prod_add_email_tables.sql create mode 100644 sql/prod_add_file_url_compat.sql create mode 100644 sql/prod_add_permissions.sql create mode 100644 sql/prod_align_qa.sql create mode 100644 sql/prod_alter_20260613.sql create mode 100644 sql/prod_fix_data.sql create mode 100644 sql/prod_migrate_sys_file.sql create mode 100644 sql/prod_migrate_uuid.sql create mode 100644 sql/qa_data.sql create mode 100644 sql/qa_ddl.sql create mode 100644 sql/qa_recreate.sql create mode 100644 统一登录网关平台-需求文档.md diff --git a/_backup_20260613/FileController.java b/_backup_20260613/FileController.java new file mode 100644 index 0000000..6994f85 --- /dev/null +++ b/_backup_20260613/FileController.java @@ -0,0 +1,176 @@ +package com.mokee.admin.controller; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.mokee.admin.service.FileService; +import com.mokee.common.Result; +import com.mokee.common.annotation.OperationLog; +import com.mokee.common.entity.SysFile; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.net.URLDecoder; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +/** + * 文件管理控制器 + *

+ * 文件二进制数据存储于本地磁盘,数据库仅保存元信息。 + * 向下兼容存量 BLOB 数据(file_data 非空时优先读磁盘,磁盘无文件则回退 DB BLOB)。 + *

+ * 请求路径前缀:{@code /zgapi/v1/admin/file} + * + * @author mokee + */ +@RestController +@RequestMapping("/zgapi/v1/admin/file") +@RequiredArgsConstructor +public class FileController { + private final FileService fileService; + + /** + * 上传文件(管理后台) + *

+ * 上传者显示名从请求头 X-User-Name 获取(由网关 HeaderInjectFilter 注入,已 URL 解码)。 + */ + @OperationLog(module = "文件管理", action = "上传") + @PostMapping("/upload") + public Result upload(@RequestParam("file") MultipartFile file, + @RequestParam(defaultValue = "other") String fileType, + @RequestParam(required = false) String systemId, + HttpServletRequest request) throws IOException { + String userId = request.getHeader("X-User-Id"); + // X-User-Name 经网关 URL 编码(防 HTTP 转发中文损坏),需解码 + String userName = decodeHeader(request.getHeader("X-User-Name")); + if (userName == null || userName.isEmpty()) { + userName = request.getHeader("X-User-Account"); + } + SysFile sf = fileService.upload(file, fileType, systemId, userId, userName); + sf.setFileData(null); // 不返回二进制数据 + return Result.ok(sf); + } + + /** + * 分页查询文件列表(不含 file_data) + */ + @PostMapping("/page") + public Result> page(@RequestBody java.util.Map params) { + Integer page = params.get("page") != null ? ((Number) params.get("page")).intValue() : 1; + Integer size = params.get("pageSize") != null ? ((Number) params.get("pageSize")).intValue() : 10; + String fileType = (String) params.get("fileType"); + String fileName = (String) params.get("fileName"); + String uploadUserName = (String) params.get("uploadUserName"); + String systemId = params.get("systemId") != null ? params.get("systemId").toString() : null; + return Result.ok(fileService.page(page, size, fileType, fileName, uploadUserName, systemId)); + } + + /** + * 文件列表查询(不分页,不含 file_data) + */ + @GetMapping("/list") + public Result> list(@RequestParam(required = false) String fileType, + @RequestParam(required = false) String systemId) { + return Result.ok(fileService.list(fileType, systemId)); + } + + /** + * 获取文件元信息(不含二进制数据) + */ + @GetMapping("/{id}") + public Result getById(@PathVariable String id) { + return Result.ok(fileService.getMetaById(id)); + } + + /** + * 预览文件 — 直接输出文件流 + *

+ * 优先读磁盘文件;磁盘文件不存在时回退到 DB BLOB(存量兼容)。 + * 设置 CORS 和缓存头,支持被 img 标签跨域引用。 + */ + @GetMapping("/{id}/view") + public void view(@PathVariable String id, HttpServletResponse response) throws IOException { + SysFile sf = fileService.getById(id); + if (sf == null) { response.setStatus(404); return; } + + Path diskPath = fileService.getDiskPath(sf); + if (!Files.exists(diskPath)) { + response.setStatus(404); + response.getWriter().write("File not found"); + return; + } + + byte[] data = Files.readAllBytes(diskPath); + response.setHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, "*"); + String mimeType = sf.getMimeType() != null ? sf.getMimeType() : "application/octet-stream"; + response.setContentType(mimeType); + response.setHeader(HttpHeaders.CACHE_CONTROL, "public, max-age=86400"); + response.setContentLengthLong(data.length); + response.getOutputStream().write(data); + response.getOutputStream().flush(); + } + + /** + * 下载文件 — 触发浏览器下载 + *

+ * 与 view 逻辑相同,额外设置 Content-Disposition: attachment 头。 + */ + @GetMapping("/{id}/download") + public void download(@PathVariable String id, HttpServletResponse response) throws IOException { + SysFile sf = fileService.getById(id); + if (sf == null) { response.setStatus(404); return; } + + Path diskPath = fileService.getDiskPath(sf); + if (!Files.exists(diskPath)) { + response.setStatus(404); + response.getWriter().write("File not found"); + return; + } + + byte[] data = Files.readAllBytes(diskPath); + // Content-Disposition: filename=ASCII兜底, filename*=UTF-8编码的原始文件名 + String safeName = "download" + ext(sf.getFileName()); + String encodedName = URLEncoder.encode(sf.getFileName(), StandardCharsets.UTF_8) + .replace("+", "%20"); + response.setContentType("application/octet-stream"); + response.setHeader(HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename=\"" + safeName + "\"; filename*=UTF-8''" + encodedName); + response.setContentLengthLong(data.length); + response.getOutputStream().write(data); + response.getOutputStream().flush(); + } + + /** 从文件名提取扩展名(含点) */ + private static String ext(String name) { + if (name == null) return ""; + int i = name.lastIndexOf('.'); + return i > 0 ? name.substring(i) : ""; + } + + /** + * 删除文件 — 删磁盘文件 + DB 记录 + */ + @OperationLog(module = "文件管理", action = "删除") + @DeleteMapping("/{id}") + public Result delete(@PathVariable String id) throws IOException { + fileService.delete(id); + return Result.ok(); + } + + /** URL 解码 HTTP 头值(网关对中文值做了 URL 编码) */ + private String decodeHeader(String value) { + if (value == null) return null; + try { + return URLDecoder.decode(value, StandardCharsets.UTF_8); + } catch (Exception e) { + return value; + } + } +} diff --git a/_backup_20260613/FileList.vue b/_backup_20260613/FileList.vue new file mode 100644 index 0000000..40c137d --- /dev/null +++ b/_backup_20260613/FileList.vue @@ -0,0 +1,738 @@ + + + + + + diff --git a/_backup_20260613/FileService.java b/_backup_20260613/FileService.java new file mode 100644 index 0000000..835145a --- /dev/null +++ b/_backup_20260613/FileService.java @@ -0,0 +1,49 @@ +package com.mokee.admin.service; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.mokee.common.entity.SysFile; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.List; + +/** + * 文件服务接口 + *

+ * 文件二进制数据存储于本地磁盘,数据库仅保存元信息。 + * + * @author mokee + */ +public interface FileService { + + /** + * 上传文件 — 写入磁盘 + 存元信息到 DB + * + * @param file 上传的文件 + * @param fileType 文件类型 + * @param systemId 所属系统ID(可选) + * @param userId 上传用户ID(可选,管理后台上传时传入) + * @param uploadUserName 上传者显示名(用户姓名或系统名称) + */ + SysFile upload(MultipartFile file, String fileType, String systemId, + String userId, String uploadUserName) throws IOException; + + /** 分页查询(不含 file_data),支持 fileName/uploadUserName 模糊搜索 */ + IPage page(Integer page, Integer size, String fileType, String fileName, String uploadUserName, String systemId); + + /** 列表查询(不含 file_data) */ + List list(String fileType, String systemId); + + /** 查询完整记录(含 file_data 兜底,用于下载/预览) */ + SysFile getById(String id); + + /** 查询元信息(不含 file_data) */ + SysFile getMetaById(String id); + + /** 获取文件在磁盘上的存储路径 */ + Path getDiskPath(SysFile sf); + + /** 删除文件 — 删磁盘文件 + DB 记录 */ + void delete(String id) throws IOException; +} diff --git a/_backup_20260613/FileServiceImpl.java b/_backup_20260613/FileServiceImpl.java new file mode 100644 index 0000000..3ab3f83 --- /dev/null +++ b/_backup_20260613/FileServiceImpl.java @@ -0,0 +1,178 @@ +package com.mokee.admin.service.impl; + +import cn.hutool.crypto.digest.MD5; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.mokee.admin.mapper.SysFileMapper; +import com.mokee.admin.service.ConfigService; +import com.mokee.admin.service.FileService; +import com.mokee.common.entity.SysFile; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; + +/** + * 文件服务实现 — 文件二进制数据存储于本地磁盘 + *

+ * 磁盘路径结构:{basePath}/{yyyy-MM}/{storageFileName} + * 例:/data/mokee/gw/file/2026-06/a1b2c3d4e5f6.png + *

+ * 向下兼容:存量 BLOB 数据(file_data 不为空)仍可正常读取。 + * + * @author mokee + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class FileServiceImpl implements FileService { + + private final SysFileMapper sysFileMapper; + private final ConfigService configService; + + private static final DateTimeFormatter YYYY_MM = DateTimeFormatter.ofPattern("yyyy-MM"); + private static final String DEFAULT_BASE_PATH = "/data/mokee/gw/file"; + + /** 获取配置的存储根目录 */ + private String getBasePath() { + return configService.getValue("file.storage.disk.path", DEFAULT_BASE_PATH); + } + + /** + * 从原始文件名提取扩展名 + */ + private String ext(String fileName) { + if (fileName == null) return ""; + int i = fileName.lastIndexOf('.'); + return i > 0 ? fileName.substring(i) : ""; + } + + @Override + public SysFile upload(MultipartFile file, String fileType, String systemId, + String userId, String uploadUserName) throws IOException { + byte[] data = file.getBytes(); + + // 计算 MD5 + String md5 = MD5.create().digestHex(data); + + // 构建实体(先生成 ID 用于存储文件名) + String fileId = cn.hutool.core.lang.UUID.randomUUID().toString().replace("-", ""); + String storageFileName = fileId + ext(file.getOriginalFilename()); + LocalDateTime now = LocalDateTime.now(); + + SysFile sf = SysFile.builder() + .id(fileId) + .systemId(systemId) + .fileName(file.getOriginalFilename()) + .storageFileName(storageFileName) + .fileType(fileType != null ? fileType : "other") + .fileSize(file.getSize()) + .mimeType(file.getContentType()) + .md5(md5) + .uploadUserId(userId) + .uploadUserName(uploadUserName) + .fileData(null) // 新文件不写入 BLOB + .createdAt(now) + .build(); + + // 写入磁盘 + String basePath = getBasePath(); + Path dir = Paths.get(basePath, now.format(YYYY_MM)); + try { + Files.createDirectories(dir); + } catch (IOException e) { + log.error("创建存储目录失败: {} — 请检查磁盘权限和路径配置", dir); + throw new IOException("存储目录创建失败: " + dir + " (请检查磁盘权限和路径配置)", e); + } + Path filePath = dir.resolve(storageFileName); + try { + Files.write(filePath, data); + } catch (IOException e) { + log.error("写入文件失败: {} — 可能磁盘空间不足或权限不足", filePath); + throw new IOException("文件写入失败: " + filePath + " (可能磁盘空间不足或权限不足)", e); + } + + log.info("文件已写入磁盘: {} ({} bytes, md5={})", filePath, file.getSize(), md5); + + // 存元信息到 DB + sysFileMapper.insert(sf); + + return sf; + } + + @Override + public IPage page(Integer page, Integer size, String fileType, + String fileName, String uploadUserName, String systemId) { + LambdaQueryWrapper w = new LambdaQueryWrapper<>(); + if (fileType != null && !fileType.isEmpty()) w.eq(SysFile::getFileType, fileType); + if (fileName != null && !fileName.isEmpty()) w.like(SysFile::getFileName, fileName); + if (uploadUserName != null && !uploadUserName.isEmpty()) w.like(SysFile::getUploadUserName, uploadUserName); + if (systemId != null) w.eq(SysFile::getSystemId, systemId); + w.orderByDesc(SysFile::getCreatedAt); + w.select(SysFile.class, f -> !"file_data".equals(f.getColumn())); + return sysFileMapper.selectPage(new Page<>(page, size), w); + } + + @Override + public List list(String fileType, String systemId) { + LambdaQueryWrapper w = new LambdaQueryWrapper<>(); + if (fileType != null && !fileType.isEmpty()) w.eq(SysFile::getFileType, fileType); + if (systemId != null) w.eq(SysFile::getSystemId, systemId); + w.orderByDesc(SysFile::getCreatedAt); + w.select(SysFile.class, f -> !"file_data".equals(f.getColumn())); + return sysFileMapper.selectList(w); + } + + @Override + public SysFile getById(String id) { + return sysFileMapper.selectById(id); + } + + @Override + public SysFile getMetaById(String id) { + LambdaQueryWrapper w = new LambdaQueryWrapper<>(); + w.eq(SysFile::getId, id); + w.select(SysFile.class, f -> !"file_data".equals(f.getColumn())); + return sysFileMapper.selectOne(w); + } + + @Override + public Path getDiskPath(SysFile sf) { + String basePath = getBasePath(); + // 存量兼容:旧文件 storageFileName 为空时,用 id + 扩展名兜底 + String storageName = sf.getStorageFileName(); + if (storageName == null || storageName.isEmpty()) { + storageName = sf.getId() + ext(sf.getFileName()); + } + return Paths.get(basePath, sf.getCreatedAt().format(YYYY_MM), storageName); + } + + @Override + public void delete(String id) throws IOException { + SysFile sf = sysFileMapper.selectById(id); + if (sf == null) return; + + // 删除磁盘文件(失败不阻断 DB 删除) + try { + Path path = getDiskPath(sf); + if (Files.exists(path)) { + Files.delete(path); + log.info("磁盘文件已删除: {}", path); + } + } catch (Exception e) { + log.warn("删除磁盘文件失败(继续删DB记录) - id: {}, error: {}", id, e.getMessage()); + } + + // 删除 DB 记录 + sysFileMapper.deleteById(id); + } +} diff --git a/_backup_20260613/IntegrationDoc.vue b/_backup_20260613/IntegrationDoc.vue new file mode 100644 index 0000000..748930d --- /dev/null +++ b/_backup_20260613/IntegrationDoc.vue @@ -0,0 +1,936 @@ + + + + + + diff --git a/_backup_20260613/OpenApiController.java b/_backup_20260613/OpenApiController.java new file mode 100644 index 0000000..36cbba8 --- /dev/null +++ b/_backup_20260613/OpenApiController.java @@ -0,0 +1,507 @@ +package com.mokee.admin.controller; + +import cn.hutool.core.io.IoUtil; +import cn.hutool.json.JSONObject; +import cn.hutool.json.JSONUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.mokee.admin.mapper.SysEmailLogMapper; +import com.mokee.admin.mapper.SysOpenTokenLogMapper; +import com.mokee.admin.mapper.SysSystemMapper; +import com.mokee.admin.service.FileService; +import com.mokee.common.Result; +import com.mokee.common.entity.SysEmailLog; +import com.mokee.common.entity.SysFile; +import com.mokee.common.entity.SysOpenTokenLog; +import com.mokee.common.entity.SysSystem; +import com.mokee.common.service.EmailService; +import com.mokee.common.utils.IpUtil; +import com.mokee.common.utils.JwtUtil; +import com.mokee.common.utils.RedisUtil; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +/** + * 对外开放 API + *

+ * Token 一次消费:每个 token 只能用一次,消费后立即失效 + * API 文档地址:http://服务地址:9002/doc.html → "对外开放API" 分组 + */ +@Slf4j +@RestController +@RequestMapping("/zgapi/v1/open") +@RequiredArgsConstructor +@Tag(name = "对外开放API", description = "使用 systemCode 获取一次性 Token,Token 消费后立即失效。文档: /doc.html") +public class OpenApiController { + + private final SysSystemMapper sysSystemMapper; + private final EmailService emailService; + private final FileService fileService; + private final SysEmailLogMapper emailLogMapper; + private final SysOpenTokenLogMapper openTokenLogMapper; + private final JwtUtil jwtUtil; + private final RedisUtil redisUtil; + + private static final int OPEN_TOKEN_TTL_MINUTES = 720; // 12小时 + + // ==================== Token 获取 ==================== + + /** + * 获取一次性 API Token + *

+ * 每个 Token 只能消费一次(调用任意 /zgapi/v1/open/* 接口即为消费),消费后立即失效。 + * Token 有效期 2 小时,过期未消费也会失效。 + */ + @PostMapping("/token") + @Operation(summary = "获取一次性API Token", + description = "传入 systemCode 换取 JWT Token。Token 有效期2小时,只能消费一次,消费后立即失效。") + public Result> getToken(HttpServletRequest request) { + Map body; + try { body = readJsonBody(request); } catch (Exception e) { return Result.fail(400, "JSON解析失败"); } + String systemCode = body.get("systemCode"); + if (systemCode == null || systemCode.isEmpty()) { + return Result.fail(400, "缺少 systemCode"); + } + + SysSystem system = sysSystemMapper.selectOne( + new LambdaQueryWrapper() + .eq(SysSystem::getSystemCode, systemCode) + .eq(SysSystem::getStatus, 1) + ); + if (system == null) { + return Result.fail(401, "systemCode 无效或系统已停用"); + } + + String tokenId = UUID.randomUUID().toString(); + String token = jwtUtil.generateOpenToken(system.getId(), system.getSystemCode(), tokenId, OPEN_TOKEN_TTL_MINUTES * 60L); + + // 缓存到 Redis + Map tokenInfo = Map.of( + "systemId", system.getId(), + "systemCode", system.getSystemCode(), + "systemName", system.getSystemName(), + "tokenId", tokenId, + "type", "open_api" + ); + redisUtil.set("open:token:" + tokenId, JSONUtil.toJsonStr(tokenInfo), OPEN_TOKEN_TTL_MINUTES, TimeUnit.MINUTES); + + // 记录获取日志 + SysOpenTokenLog log = SysOpenTokenLog.builder() + .systemId(system.getId()) + .systemCode(system.getSystemCode()) + .tokenId(tokenId) + .tokenPreview(token.substring(0, 20) + "..." + token.substring(token.length() - 10)) + .acquiredAt(LocalDateTime.now()) + .consumed(0) + .build(); + openTokenLogMapper.insert(log); + + return Result.ok(Map.of( + "token", token, + "expiresIn", OPEN_TOKEN_TTL_MINUTES * 60, + "systemName", system.getSystemName(), + "usageNote", "此 Token 只能消费一次,消费后立即失效" + )); + } + + // ==================== Token 校验(不消费) ==================== + + /** + * 校验 Token 是否有效(不消费) + *

+ * 仅检查 Token 是否存在且未被消费,不做消费标记。 + * 用于调用方在实际调用前预先确认 Token 可用。 + */ + @PostMapping("/token/validate") + @Operation(summary = "校验Token是否有效(不消费)", + description = "传入 token 检查是否存在且未被消费。注意:此接口不会消费 Token,仅做状态查询。") + public Result> validateToken(HttpServletRequest request) { + Map body; + try { body = readJsonBody(request); } catch (Exception e) { return Result.fail(400, "JSON解析失败"); } + String token = body.get("token"); + if (token == null || token.isEmpty()) { + return Result.fail(400, "缺少 token 参数"); + } + + String tokenId; + try { + io.jsonwebtoken.Claims claims = jwtUtil.validateToken(token); + String type = claims.get("type", String.class); + if (!"open_api".equals(type)) { + return Result.fail(401, "Token 类型错误,非开放 API Token"); + } + tokenId = jwtUtil.getTokenId(claims); + if (tokenId == null) { + return Result.fail(401, "Token 无效"); + } + } catch (Exception e) { + return Result.fail(401, "Token 无效或已过期"); + } + + // 检查 Redis 中是否存在 + if (!redisUtil.exists("open:token:" + tokenId)) { + return Result.fail(401, "Token 不存在或已过期"); + } + + // 检查是否已消费 + if (redisUtil.exists("open:token:" + tokenId + ":consumed")) { + return Result.fail(401, "Token 已被消费"); + } + + // 从 Redis 获取 Token 信息 + String redisJson = redisUtil.get("open:token:" + tokenId); + String systemCode = ""; + if (redisJson != null) { + cn.hutool.json.JSONObject info = cn.hutool.json.JSONUtil.parseObj(redisJson); + systemCode = info.getStr("systemCode", ""); + } + + return Result.ok(Map.of( + "valid", true, + "systemCode", systemCode, + "msg", "Token 有效,未被消费" + )); + } + + // ==================== 标记 Token 已消费 ==================== + + /** + * 标记 Token 为已消费(供外部系统回调) + *

+ * 外部系统在自己的业务中消费了 Token 后,调用此接口告知网关标记已消费, + * 防止该 Token 被重复使用。 + */ + @PostMapping("/token/consume") + @Operation(summary = "标记Token已消费", + description = "外部系统调用此接口主动标记 Token 已消费。Token 被标记后不可再使用。") + public Result> markConsumed(HttpServletRequest request) { + Map body; + try { body = readJsonBody(request); } catch (Exception e) { return Result.fail(400, "JSON解析失败"); } + String token = body.get("token"); + String action = body.get("action"); + if (token == null || token.isEmpty()) return Result.fail(400, "缺少 token 参数"); + if (action == null || action.isEmpty()) return Result.fail(400, "缺少 action 参数(如:发送邮件/上传文件/下载文件)"); + + // 验证 Token + String tokenId; + try { + io.jsonwebtoken.Claims claims = jwtUtil.validateToken(token); + String type = claims.get("type", String.class); + if (!"open_api".equals(type)) return Result.fail(401, "Token 类型错误"); + tokenId = jwtUtil.getTokenId(claims); + if (tokenId == null) return Result.fail(401, "Token 无效"); + } catch (Exception e) { + return Result.fail(401, "Token 无效或已过期"); + } + + if (!redisUtil.exists("open:token:" + tokenId)) { + return Result.fail(401, "Token 不存在或已过期"); + } + if (redisUtil.exists("open:token:" + tokenId + ":consumed")) { + return Result.fail(401, "Token 已被消费"); + } + + // 标记已消费 + consumeToken(tokenId); + recordConsumption(tokenId, request, action); + + return Result.ok(Map.of( + "tokenConsumed", true, + "action", action, + "msg", "Token 已标记为已消费" + )); + } + + // ==================== 邮件发送(消费 Token) ==================== + + /** + * 发送邮件 — 消费 Token + *

+ * 只有邮件发送成功后才消费 Token,发送失败不消费允许重试。 + */ + @PostMapping("/email/send") + @Operation(summary = "发送邮件(消费Token)", + description = "调用此接口将消费 Token,Token 只能用一次。邮件发送成功后 Token 立即失效,发送失败不消费 Token。") + public Result sendEmail(HttpServletRequest request) { + // 第1步:验证 Token(不消费,只校验有效性) + String tokenId = validateOpenToken(request); + if (tokenId == null) { + return Result.fail(401, "Token 无效或已消费"); + } + + // 第2步:读取请求体,兼容 UTF-8/GBK 编码 + Map body; + try { + body = readJsonBody(request); + } catch (Exception e) { + return Result.fail(400, "请求体 JSON 解析失败: " + e.getMessage()); + } + + String to = body.get("to"); + String subject = body.get("subject"); + String content = body.get("content"); + String contentType = body.getOrDefault("contentType", "html"); + + if (to == null || subject == null || content == null) { + return Result.fail(400, "缺少必填参数: to, subject, content"); + } + + // 第3步:先发邮件,成功后再消费 Token + SysEmailLog log = SysEmailLog.builder() + .toAddress(to) + .subject(subject) + .content(content) + .contentType(contentType) + .createdAt(LocalDateTime.now()) + .build(); + + try { + if ("html".equals(contentType)) { + emailService.sendHtml(to, subject, content); + } else { + emailService.sendText(to, subject, content); + } + log.setStatus(1); + emailLogMapper.insert(log); + + // 发邮件成功 → 消费 Token(标记已用,不可重试) + consumeToken(tokenId); + recordConsumption(tokenId, request, "/zgapi/v1/open/email/send"); + + return Result.ok(Map.of("msg", "发送成功", "tokenConsumed", true)); + } catch (Exception e) { + log.setStatus(0); + log.setErrorMsg(e.getMessage()); + emailLogMapper.insert(log); + // 发送失败不消费 token,允许重试 + return Result.fail(500, "发送失败: " + e.getMessage()); + } + } + + + // ==================== 文件上传/下载(消费 Token) ==================== + + /** + * 开放 API 文件上传 — 消费 Token + *

+ * Token 通过 multipart form 字段传递。上传成功后消费 Token。 + */ + @PostMapping("/file/upload") + @Operation(summary = "上传文件(消费Token)", + description = "上传文件到文件对象存储。Token 作为 form 字段传递,上传成功后立即消费 Token。") + public Result> uploadFile( + @RequestParam("file") MultipartFile file, + @RequestParam("token") String token, + @RequestParam(defaultValue = "other") String fileType, + HttpServletRequest request) { + + // 1. 验证 Token + String tokenId = validateOpenTokenFromValue(token); + if (tokenId == null) { + return Result.fail(401, "Token 无效或已消费"); + } + + // 2. 从 Redis 获取系统信息 + String systemId = null; + String systemName = null; + String redisJson = redisUtil.get("open:token:" + tokenId); + if (redisJson != null) { + cn.hutool.json.JSONObject info = JSONUtil.parseObj(redisJson); + systemId = info.getStr("systemId"); + systemName = info.getStr("systemName"); + } + + // 3. 执行上传(上传者显示系统名称) + try { + SysFile sf = fileService.upload(file, fileType, systemId, null, systemName); + + // 4. 上传成功 → 消费 Token + consumeToken(tokenId); + recordConsumption(tokenId, request, "/zgapi/v1/open/file/upload"); + + return Result.ok(Map.of( + "fileId", sf.getId(), + "fileName", sf.getFileName(), + "storageFileName", sf.getStorageFileName(), + "fileSize", sf.getFileSize(), + "tokenConsumed", true + )); + } catch (IOException e) { + log.error("开放 API 文件上传失败: {}", e.getMessage()); + // 上传失败不消费 token + return Result.fail(500, "文件上传失败: " + e.getMessage()); + } + } + + /** + * 开放 API 文件下载 — 消费 Token + *

+ * Token 通过 query 参数传递。下载成功后消费 Token。 + * 直接输出文件二进制流,设置 Content-Disposition 触发浏览器下载。 + */ + @GetMapping("/file/{id}/download") + @Operation(summary = "下载文件(消费Token)", + description = "下载文件对象存储中的文件。Token 通过 ?token=xxx 参数传递,下载成功后立即消费 Token。") + public void downloadFile(@PathVariable String id, + @RequestParam("token") String token, + HttpServletRequest request, + HttpServletResponse response) throws IOException { + + // 1. 验证 Token + String tokenId = validateOpenTokenFromValue(token); + if (tokenId == null) { + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(401); + response.getWriter().write(JSONUtil.toJsonStr(Result.fail(401, "Token 无效或已消费"))); + return; + } + + // 2. 查询文件 + SysFile sf = fileService.getById(id); + if (sf == null) { + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(404); + response.getWriter().write(JSONUtil.toJsonStr(Result.fail(404, "文件不存在"))); + return; + } + + // 3. 读取磁盘文件 + java.nio.file.Path diskPath = fileService.getDiskPath(sf); + if (!java.nio.file.Files.exists(diskPath)) { + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(404); + response.getWriter().write(JSONUtil.toJsonStr(Result.fail(404, "文件不存在"))); + return; + } + byte[] data = java.nio.file.Files.readAllBytes(diskPath); + + // 4. 下载成功 → 消费 Token + consumeToken(tokenId); + recordConsumption(tokenId, request, "/zgapi/v1/open/file/" + id + "/download"); + + // 5. 输出文件流 + String safeName = "download" + ext(sf.getFileName()); + String encodedName = URLEncoder.encode(sf.getFileName(), StandardCharsets.UTF_8) + .replace("+", "%20"); + response.setContentType("application/octet-stream"); + response.setHeader(HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename=\"" + safeName + "\"; filename*=UTF-8''" + encodedName); + response.setContentLengthLong(data.length); + response.getOutputStream().write(data); + response.getOutputStream().flush(); + } + + private static String ext(String name) { + if (name == null) return ""; + int i = name.lastIndexOf('.'); + return i > 0 ? name.substring(i) : ""; + } + + // ==================== 私有方法 ==================== + + /** + * 验证 Open API Token 有效性(不消费) + *

仅校验 JWT 合法性 + Redis 中存在且未被消费,不做消费标记 + */ + private String validateOpenToken(HttpServletRequest request) { + String authHeader = request.getHeader("Authorization"); + if (authHeader == null || !authHeader.startsWith("Bearer ")) return null; + String token = authHeader.substring(7); + try { + io.jsonwebtoken.Claims claims = jwtUtil.validateToken(token); + String tokenId = jwtUtil.getTokenId(claims); + if (tokenId == null) return null; + + // 检查 Redis 中 token 是否存在 + if (!redisUtil.exists("open:token:" + tokenId)) return null; + + // 检查是否已消费 + String consumedKey = "open:token:" + tokenId + ":consumed"; + if (redisUtil.exists(consumedKey)) return null; + + return tokenId; + } catch (Exception e) { + return null; + } + } + + /** + * 消费 Token(标记为已消费,业务操作成功后调用) + */ + private void consumeToken(String tokenId) { + redisUtil.set("open:token:" + tokenId + ":consumed", "1", OPEN_TOKEN_TTL_MINUTES, TimeUnit.MINUTES); + } + + /** + * 记录 Token 消费日志 + */ + private void recordConsumption(String tokenId, HttpServletRequest request, String api) { + try { + SysOpenTokenLog log = new SysOpenTokenLog(); + log.setTokenId(tokenId); + log.setConsumed(1); + log.setConsumedAt(LocalDateTime.now()); + log.setConsumeApi(api); + log.setConsumeIp(IpUtil.getClientIp(request)); + + // 根据 tokenId 更新记录 + LambdaQueryWrapper w = new LambdaQueryWrapper<>(); + w.eq(SysOpenTokenLog::getTokenId, tokenId); + openTokenLogMapper.update(log, w); + } catch (Exception ignored) {} + } + + /** + * 验证 Open API Token 有效性(不消费),从原始 token 字符串直接验证 + *

用于 token 不在 Authorization 头的场景(如 form 字段、query 参数) + */ + private String validateOpenTokenFromValue(String token) { + if (token == null || token.isEmpty()) return null; + try { + io.jsonwebtoken.Claims claims = jwtUtil.validateToken(token); + String type = claims.get("type", String.class); + if (!"open_api".equals(type)) return null; + String tokenId = jwtUtil.getTokenId(claims); + if (tokenId == null) return null; + + // 检查 Redis 中 token 是否存在且未被消费 + if (!redisUtil.exists("open:token:" + tokenId)) return null; + if (redisUtil.exists("open:token:" + tokenId + ":consumed")) return null; + + return tokenId; + } catch (Exception e) { + return null; + } + } + + /** + * 从 HttpServletRequest 读取 JSON 请求体,自动检测 UTF-8/GBK 编码 + */ + @SuppressWarnings("unchecked") + private Map readJsonBody(HttpServletRequest request) throws java.io.IOException { + byte[] bytes = IoUtil.readBytes(request.getInputStream()); + if (bytes == null || bytes.length == 0) return java.util.Collections.emptyMap(); + + String json = new String(bytes, java.nio.charset.StandardCharsets.UTF_8); + if (json.indexOf('�') >= 0) { + json = new String(bytes, java.nio.charset.Charset.forName("GBK")); + } + return JSONUtil.toBean(json, Map.class); + } +} diff --git a/_backup_20260613/SystemConfig.vue b/_backup_20260613/SystemConfig.vue new file mode 100644 index 0000000..a411c98 --- /dev/null +++ b/_backup_20260613/SystemConfig.vue @@ -0,0 +1,85 @@ + + + + + + diff --git a/_backup_20260613/open-api-doc.md b/_backup_20260613/open-api-doc.md new file mode 100644 index 0000000..72ceb8a --- /dev/null +++ b/_backup_20260613/open-api-doc.md @@ -0,0 +1,449 @@ +# Mokee Gateway 对外开放 API 文档 + +**网关地址**: `https://gw.server.zgitm.com` +**版本**: v1 +**更新时间**: 2026-06-13 + +--- + +## 1. 概述 + +对外开放 API 用于外部业务系统接入 Mokee 网关平台,支持获取一次性 Token 并调用邮件发送等功能。 + +> ⚠️ **编码要求**:所有请求和响应均使用 **UTF-8** 编码。`Content-Type` 必须指定 `charset=UTF-8`,否则中文会出现乱码或解析错误。 + +### 认证流程 + +``` +外部系统 ──POST /zgapi/v1/open/token──▶ 获取一次性 Token +外部系统 ──POST /zgapi/v1/open/token/validate──▶ 校验 Token(不消费) +外部系统 ──POST /zgapi/v1/open/token/consume──▶ 标记 Token 已消费 +外部系统 ──POST /zgapi/v1/open/email/send──▶ 发送邮件(消费 Token) +外部系统 ──POST /zgapi/v1/open/file/upload──▶ 上传文件(消费 Token) +外部系统 ──GET /zgapi/v1/open/file/{id}/download──▶ 下载文件(消费 Token) +``` + +### Token 规则 + +| 规则 | 说明 | +|------|------| +| 获取方式 | 传入 `systemCode` 即可获取,无需密钥 | +| 有效期 | 获取后 **12 小时**内有效 | +| 消费限制 | 每个 Token **只能使用一次**,调用任意接口即消费 | +| 消费失败 | 接口调用失败(如邮件发送失败)**不消费** Token,可重试 | + +--- + +## 2. 获取 Token + +传入系统编码,获取一次性 JWT Token。 + +**请求** + +``` +POST /zgapi/v1/open/token +Content-Type: application/json; charset=UTF-8 +``` + +> **重要**:所有请求 Body 必须使用 **UTF-8** 编码。 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|:--:|------| +| systemCode | string | 是 | 系统编码,需在网关平台已注册且启用 | + +**请求示例** + +```bash +curl -X POST "https://gw.server.zgitm.com/zgapi/v1/open/token" \ + -H "Content-Type: application/json" \ + -d '{"systemCode": "gateway"}' +``` + +**响应** + +```json +{ + "code": 200, + "data": { + "token": "eyJhbGciOiJIUzM4NCJ9.eyJzdWIiOi...", + "expiresIn": 43200, + "systemName": "网关管理平台", + "usageNote": "此 Token 只能消费一次,消费后立即失效" + } +} +``` + +| 字段 | 类型 | 说明 | +|------|------|------| +| code | int | 200=成功 | +| data.token | string | JWT Token,后续请求放入 Authorization 头 | +| data.expiresIn | int | 有效时间(秒),43200 = 12小时 | +| data.systemName | string | 系统名称 | +| data.usageNote | string | 使用提示 | + +**错误响应** + +| code | 说明 | +|:----:|------| +| 400 | 缺少 `systemCode` 参数 | +| 401 | `systemCode` 无效或系统已停用 | + +--- + +## 3. 校验 Token(不消费) + +检查 Token 是否有效(存在且未被消费)。**此接口不会消费 Token**,仅做状态查询。调用方可在实际操作前预先确认 Token 可用。 + +**请求** + +``` +POST /zgapi/v1/open/token/validate +Content-Type: application/json; charset=UTF-8 +``` + +| 参数 | 类型 | 必填 | 说明 | +|------|------|:--:|------| +| token | string | 是 | 需要校验的 JWT Token | + +**请求示例** + +```bash +curl -X POST "https://gw.server.zgitm.com/zgapi/v1/open/token/validate" \ + -H "Content-Type: application/json" \ + -d '{"token": "eyJhbGciOiJIUzM4NCJ9..."}' +``` + +**成功响应(Token 有效,未被消费)** + +```json +{ + "code": 200, + "data": { + "valid": true, + "systemCode": "gateway", + "msg": "Token 有效,未被消费" + } +} +``` + +| 字段 | 类型 | 说明 | +|------|------|------| +| data.valid | bool | true = Token 有效可用 | +| data.systemCode | string | Token 所属的系统编码 | +| data.msg | string | 状态描述 | + +**错误响应** + +| code | 说明 | +|:----:|------| +| 400 | 缺少 `token` 参数 | +| 401 | Token 无效、已过期、已被消费、或类型错误 | + +> **关键:此接口不会消费 Token。** 校验通过后 Token 仍可用于后续操作。 + +--- + +## 4. 标记 Token 已消费 + +外部系统在自己的业务逻辑中消费了 Token 后(如上传文件到自己的存储),调用此接口告知网关标记 Token 已消费,防止被重复使用。 + +**请求** + +``` +POST /zgapi/v1/open/token/consume +Content-Type: application/json; charset=UTF-8 +``` + +| 参数 | 类型 | 必填 | 说明 | +|------|------|:--:|------| +| token | string | 是 | 需要标记已消费的 JWT Token | +| action | string | 是 | 消费动作描述,如 `发送邮件` / `上传文件` / `下载文件` | + +**请求示例** + +```bash +curl -X POST "https://gw.server.zgitm.com/zgapi/v1/open/token/consume" \ + -H "Content-Type: application/json" \ + -d '{"token": "eyJhbGciOiJIUzM4NCJ9...", "action": "上传文件"}' +``` + +**成功响应** + +```json +{ + "code": 200, + "data": { + "tokenConsumed": true, + "action": "上传文件", + "msg": "Token 已标记为已消费" + } +} +``` + +**错误响应** + +| code | 说明 | +|:----:|------| +| 400 | 缺少 `token` 或 `action` 参数 | +| 401 | Token 无效、已过期、或已被消费 | + +--- + +## 5. 发送邮件 + +使用 Token 认证后发送邮件。**调用此接口将消费当前 Token。** + +**请求** + +``` +POST /zgapi/v1/open/email/send +Content-Type: application/json; charset=UTF-8 +Authorization: Bearer +``` + +> **重要**:请求 Body 必须使用 **UTF-8** 编码。 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|:--:|------| +| to | string | 是 | 收件人邮箱地址 | +| subject | string | 是 | 邮件主题 | +| content | string | 是 | 邮件内容 | +| contentType | string | 否 | `html`(默认)或 `text` | + +**请求示例** + +```bash +# 先获取 Token +TOKEN=$(curl -s -X POST "https://gw.server.zgitm.com/zgapi/v1/open/token" \ + -H "Content-Type: application/json" \ + -d '{"systemCode":"gateway"}' | sed 's/.*"token":"\([^"]*\)".*/\1/') + +# 发送 HTML 邮件 +curl -X POST "https://gw.server.zgitm.com/zgapi/v1/open/email/send" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{ + "to": "user@example.com", + "subject": "通知标题", + "content": "

您好

这是邮件内容

", + "contentType": "html" + }' +``` + +**成功响应** + +```json +{ + "code": 200, + "data": { + "msg": "发送成功", + "tokenConsumed": true + } +} +``` + +**错误响应** + +| code | 说明 | +|:----:|------| +| 400 | 缺少必填参数 `to` / `subject` / `content` | +| 401 | Token 无效、已过期或已消费 | +| 500 | 邮件发送失败(Token 未消费,可重试) | + +--- + +## 6. 上传文件 + +使用 Token 认证后上传文件到文件对象存储。**调用此接口将消费当前 Token。** + +> 上传成功后返回文件 ID,可用于后续下载。 + +**请求** + +``` +POST /zgapi/v1/open/file/upload +Content-Type: multipart/form-data +``` + +| 参数 | 类型 | 必填 | 说明 | +|------|------|:--:|------| +| file | file | 是 | 上传的文件 | +| token | string | 是 | 开放 API Token(作为 form 字段传递) | +| fileType | string | 否 | 文件类型: `avatar`/`icon`/`doc`/`other`(默认 `other`) | + +**请求示例** + +```bash +# 先获取 Token +TOKEN=$(curl -s -X POST "https://gw.server.zgitm.com/zgapi/v1/open/token" \ + -H "Content-Type: application/json" \ + -d '{"systemCode":"gateway"}' | sed 's/.*"token":"\([^"]*\)".*/\1/') + +# 上传文件 +curl -X POST "https://gw.server.zgitm.com/zgapi/v1/open/file/upload" \ + -F "file=@/path/to/avatar.png" \ + -F "token=$TOKEN" \ + -F "fileType=avatar" +``` + +**成功响应** + +```json +{ + "code": 200, + "data": { + "fileId": "a1b2c3d4e5f6", + "fileName": "avatar.png", + "storageFileName": "a1b2c3d4e5f6.png", + "fileSize": 51200, + "tokenConsumed": true + } +} +``` + +| 字段 | 类型 | 说明 | +|------|------|------| +| data.fileId | string | 文件唯一 ID,用于下载 | +| data.fileName | string | 原始文件名 | +| data.storageFileName | string | 磁盘存储的实际文件名 | +| data.fileSize | long | 文件大小(字节) | +| data.tokenConsumed | bool | Token 已消费 | + +**错误响应** + +| code | 说明 | +|:----:|------| +| 400 | 缺少 `file` 或 `token` 参数 | +| 401 | Token 无效、已过期或已消费 | +| 500 | 文件上传失败(Token 未消费,可重试) | + +--- + +## 7. 下载文件 + +使用 Token 认证后下载文件。**调用此接口将消费当前 Token。** +Token 通过 URL query 参数传递。 + +> **注意**: 每个下载链接的 Token 只能使用一次。如需多次下载,需每次获取新 Token。 + +**请求** + +``` +GET /zgapi/v1/open/file/{id}/download?token= +``` + +| 参数 | 类型 | 必填 | 说明 | +|------|------|:--:|------| +| id | string | 是 | 文件 ID(路径参数) | +| token | string | 是 | 开放 API Token(query 参数) | + +**请求示例** + +```bash +# 先获取 Token +TOKEN=$(curl -s -X POST "https://gw.server.zgitm.com/zgapi/v1/open/token" \ + -H "Content-Type: application/json" \ + -d '{"systemCode":"gateway"}' | sed 's/.*"token":"\([^"]*\)".*/\1/') + +# 下载文件(保存到本地) +curl -o downloaded.png \ + "https://gw.server.zgitm.com/zgapi/v1/open/file/a1b2c3d4e5f6/download?token=$TOKEN" +``` + +**成功响应**: 直接返回文件二进制流,`Content-Type: application/octet-stream`,`Content-Disposition: attachment`。 + +**错误响应** + +| HTTP 状态码 | code | 说明 | +|:----------:|:----:|------| +| 401 | 401 | Token 无效、已过期或已消费 | +| 404 | 404 | 文件不存在或文件数据丢失 | + +--- + +## 8. Java 调用示例 + +使用 Hutool 或 OkHttp 均可,以下为 Hutool 示例: + +```java +import cn.hutool.http.HttpRequest; +import cn.hutool.http.HttpResponse; +import cn.hutool.json.JSONObject; +import cn.hutool.json.JSONUtil; + +public class OpenApiDemo { + + private static final String BASE = "https://gw.server.zgitm.com"; + + public static void main(String[] args) { + // 1. 获取 Token + JSONObject tokenReq = JSONUtil.createObj().set("systemCode", "gateway"); + String tokenResp = HttpRequest.post(BASE + "/zgapi/v1/open/token") + .header("Content-Type", "application/json; charset=UTF-8") + .body(tokenReq.toString()) + .execute() + .body(); + + JSONObject tokenJson = JSONUtil.parseObj(tokenResp); + String token = tokenJson.getJSONObject("data").getStr("token"); + System.out.println("Token: " + token.substring(0, 30) + "..."); + + // 2. 上传文件 + JSONObject uploadResp = HttpRequest.post(BASE + "/zgapi/v1/open/file/upload") + .form("file", new java.io.File("./avatar.png")) + .form("token", token) + .form("fileType", "avatar") + .execute() + .body(); + System.out.println("上传结果: " + uploadResp); + + // 3. 发送邮件 + JSONObject emailReq = JSONUtil.createObj() + .set("to", "user@example.com") + .set("subject", "测试邮件") + .set("content", "

Hello

这是一封测试邮件

") + .set("contentType", "html"); + + String emailResp = HttpRequest.post(BASE + "/zgapi/v1/open/email/send") + .header("Authorization", "Bearer " + token) + .body(emailReq.toString()) + .execute() + .body(); + + System.out.println(emailResp); + } +} +``` + +### Maven 依赖 + +```xml + + cn.hutool + hutool-all + 5.8.28 + +``` + +--- + +## 9. 附录 + +### 可用系统编码 + +请联系管理员获取已注册的系统编码,或登录网关管理平台查看。 + +### 错误码汇总 + +| HTTP 状态码 | code | 说明 | +|:----------:|:----:|------| +| 200 | 200 | 成功 | +| 200 | 400 | 参数错误 | +| 200 | 401 | 认证失败 | +| 200 | 500 | 服务内部错误 | + +> **注意**:所有响应 HTTP 状态码均为 200,实际结果通过响应体中的 `code` 字段判断。 + +### 联系支持 + +如有问题请联系 Mokee Gateway 管理员。 diff --git a/add_doc_token.sql b/add_doc_token.sql new file mode 100644 index 0000000..120879c --- /dev/null +++ b/add_doc_token.sql @@ -0,0 +1,5 @@ +-- 系统表新增文档令牌字段 +ALTER TABLE sys_system ADD COLUMN IF NOT EXISTS doc_token VARCHAR(64) DEFAULT NULL COMMENT '文档访问令牌(UUID)'; + +-- 为已有系统生成随机令牌(16位 hex) +UPDATE sys_system SET doc_token = SUBSTRING(MD5(UUID()), 1, 16) WHERE doc_token IS NULL; diff --git a/check_docker.py b/check_docker.py new file mode 100644 index 0000000..5d3ddb7 --- /dev/null +++ b/check_docker.py @@ -0,0 +1,29 @@ +import paramiko + +HOST = '10.1.1.170' +USER = 'root' +PASS = 'mokee.' + +c = paramiko.SSHClient() +c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +c.connect(HOST, username=USER, password=PASS, timeout=15) + +# 列出所有运行中的 Docker 容器 +_, stdout, _ = c.exec_command('docker ps --format "table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}" 2>&1') +out = stdout.read().decode() +print('=== Docker 容器列表 ===') +print(out) + +# 也检查一下 /opt/mokee 目录结构 +_, stdout2, _ = c.exec_command('ls -la /opt/mokee/ 2>&1') +out2 = stdout2.read().decode() +print('=== /opt/mokee 目录 ===') +print(out2) + +# 查找与 mokee 相关的所有容器 +_, stdout3, _ = c.exec_command('docker ps -a --format "table {{.Names}}\t{{.Image}}\t{{.Status}}" --filter name=mokee 2>&1') +out3 = stdout3.read().decode() +print('=== mokee 相关容器 ===') +print(out3) + +c.close() diff --git a/docs/integration-guide.md b/docs/integration-guide.md new file mode 100644 index 0000000..935eff3 --- /dev/null +++ b/docs/integration-guide.md @@ -0,0 +1,1014 @@ +# Mokee Gateway 系统接入完整指南 + +> 版本:2026-06-13 | 适用于所有需要通过 Mokee Gateway 统一登录的业务系统 + +--- + +## 目录 + +1. [架构概览](#一架构概览) +2. [接入流程](#二接入流程) +3. [前端接入(完整代码)](#三前端接入完整代码) +4. [后端接入(完整代码)](#四后端接入完整代码) +5. [API 接口参考(完整请求/响应结构)](#五api-接口参考) +6. [数据库初始化](#六数据库初始化) +7. [创建管理员账号](#七创建网关管理员账号) +8. [创建管理员账号](#七创建网关管理员账号) +9. [UserChip Widget 一键接入](#八usership-widget-一键接入) +10. [对接测试清单](#九对接测试清单) + +--- + +## 一、架构概览 + +``` +┌──────────────┐ ┌──────────────────┐ ┌──────────────────┐ +│ 业务前端 │────▶│ Mokee Gateway │────▶│ 业务后端 │ +│ (Vue/React) │ │ gw.server.zgitm.com │ │ (任意语言) │ +│ │ │ │ │ │ +│ 携带 Token │ │ 验证 Token │ │ 从请求头读取: │ +│ + X-System-Code │ 识别系统编码 │ │ X-User-Id │ +│ │ │ 转发请求 │ │ X-User-Name │ +│ │ │ 注入用户信息头 │ │ X-System-Code │ +└──────────────┘ └──────────────────┘ └──────────────────┘ +``` + +### 流程 + +1. 用户访问业务前端 → 无 Token → 跳转统一登录页 +2. 登录成功 → 获得 JWT Token → 回跳到业务前端 +3. 业务前端所有 API 请求 → 网关 (携带 Authorization + X-System-Code) +4. 网关验证 Token → 注入用户信息请求头 → 转发到业务后端 +5. 业务后端从请求头读取用户信息 → 执行业务逻辑 + +### 三条关键规则 + +**规则一:前端只调网关** — 业务前端 baseURL 配置为网关地址,不能直接调业务后端。 + +**规则二:后端必须开跨域 (CORS)** — 业务后端需要允许来自网关域名的跨域请求。 + +**规则三:业务后端不再做登录/鉴权** — Token 验证由网关完成,业务后端只需从请求头读取用户信息。 + +--- + +## 二、接入流程 + +1. 在网关管理后台注册业务系统 → 获得 `systemCode` 和 `docToken` +2. 在网关数据库执行初始化 SQL → 创建菜单和角色 +3. 前端接入:配置 Axios 实例 + 路由守卫 +4. 后端接入:配置 CORS + UserContext 中间件 +5. 创建管理员账号 → 登录管理后台维护本系统数据 +6. 对接测试 → 完成验收 + +--- + +## 三、前端接入(完整代码) + +### 3.1 安装依赖 + +```bash +npm install axios +``` + +### 3.2 环境变量 + +```env +VITE_GATEWAY_URL = https://gw.server.zgitm.com +VITE_LOGIN_URL = https://web.gw.zgitm.com/login +VITE_SYSTEM_CODE = YOUR_SYSTEM_CODE +``` + +| 变量 | 说明 | 从哪里获取 | +|------|------|-----------| +| `VITE_GATEWAY_URL` | 网关 API 地址 | 固定值,见附录地址表 | +| `VITE_LOGIN_URL` | 统一登录页地址 | 固定值,见附录地址表 | +| `VITE_SYSTEM_CODE` | 你的业务系统编码 | 在网关管理后台注册系统后获得(联系平台管理员) | + +### 3.3 Axios 实例 (src/utils/request.ts) + +```typescript +import axios from 'axios' +import { ElMessage } from 'element-plus' + +const SYSTEM_CODE = import.meta.env.VITE_SYSTEM_CODE + +const request = axios.create({ + baseURL: import.meta.env.VITE_GATEWAY_URL, + timeout: 30000, +}) + +// 从 Cookie 读取 Token(登录后网关写入的跨域 Cookie) +function getToken(): string | null { + const match = document.cookie.match(/(?:^|;\s*)token=([^;]*)/) + return match ? decodeURIComponent(match[1]) : null +} + +// 请求拦截器:携带 Token 和系统编码 +request.interceptors.request.use(config => { + const token = getToken() + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + // 关键:网关根据此头识别业务系统 + config.headers['X-System-Code'] = SYSTEM_CODE + return config +}) + +// 响应拦截器:401 自动跳转登录 +request.interceptors.response.use( + res => res.data.code === 200 ? res.data : Promise.reject(res.data), + err => { + if (err.response?.status === 401) { + window.location.href = `${import.meta.env.VITE_LOGIN_URL}?systemCode=${SYSTEM_CODE}` + } + return Promise.reject(err) + } +) + +export default request +``` + +### 3.4 路由守卫 (src/router/index.ts) + +```typescript +import { createRouter, createWebHistory } from 'vue-router' + +const SYSTEM_CODE = import.meta.env.VITE_SYSTEM_CODE +const LOGIN_URL = import.meta.env.VITE_LOGIN_URL + +const router = createRouter({ + history: createWebHistory(), + routes: [ /* 你的路由 */ ], +}) + +router.beforeEach((to, _from, next) => { + const token = document.cookie.match(/(?:^|;\s*)token=([^;]*)/)?.[1] + if (!token && to.path !== '/login') { + // 无 Token → 跳统一登录页 + window.location.href = `${LOGIN_URL}?systemCode=${SYSTEM_CODE}` + return + } + next() +}) + +export default router +``` + +### 3.5 登录成功回调处理 + +用户在统一登录页登录成功后,网关将 Token 写入 **Cookie**(domain: `.zgitm.com`,跨子域共享),然后跳回系统注册时填写的 `frontUrl`(即你的业务前端首页地址)。业务前端无需自行实现登录接口,只需从 Cookie 中读取 Token 即可。 + +```typescript +// 从 Cookie 读取 Token +function getToken(): string | null { + const match = document.cookie.match(/(?:^|;\s*)token=([^;]*)/) + return match ? decodeURIComponent(match[1]) : null +} + +// App.vue onMounted 或 main.ts +const token = getToken() +if (token) { + // 用户已登录,正常渲染应用 + // 如需获取用户信息,调用网关 API: + const res = await request.get('/zgapi/v1/auth/user/info') + console.log('当前用户:', res.data) +} else { + // 无 Token → 跳统一登录页 + window.location.href = `${import.meta.env.VITE_LOGIN_URL}?systemCode=${SYSTEM_CODE}` +} +``` + +#### 多系统场景说明 + +一个用户可能绑定了多个业务系统。跳转登录页时在 URL 中带上 `systemCode` 参数,登录页会自动选择对应系统: + +``` +https://web.gw.zgitm.com/login?systemCode=YOUR_SYSTEM_CODE +``` + +这就是为什么路由守卫和 Axios 拦截器中跳转登录时都要拼 `?systemCode=${SYSTEM_CODE}`——网关登录页根据这个参数知道用户要从哪个系统登录,登录成功后直接回跳到该系统的 `frontUrl`。 + +> **💡 Token 存储说明**:Token 存在 Cookie 中(而非 localStorage),因为 Cookie 支持跨子域共享(`.zgitm.com`),统一登录、管理后台、文件服务等不同子站都能读取到同一个 Token。Cookie 有效期 2 小时,与 Token 过期时间一致。 + +### 3.6 按钮权限控制(v-permission 指令) + +用户信息接口(`/zgapi/v1/auth/user/info`)会返回 `permissions` 字段(按钮权限标识列表),前端据此控制按钮的显示/隐藏。你需要注册一个 `v-permission` 自定义指令: + +**src/directives/permission.ts:** + +```typescript +import type { Directive } from 'vue' + +// 从 Cookie 读取 Token,解析出 payload 中的 permissions +// 或从 Vuex/Pinia store 中读取(取决于你的状态管理方案) +function getPermissions(): string[] { + const token = document.cookie.match(/(?:^|;\s*)token=([^;]*)/)?.[1] + if (!token) return [] + try { + // JWT payload 在中间那段(Base64) + const payload = JSON.parse(atob(token.split('.')[1])) + return payload.permissions ?? [] + } catch { + return [] + } +} + +export const permission: Directive = { + mounted(el, binding) { + const permissions = getPermissions() + const required = binding.value as string + // 如果用户没有该权限,隐藏按钮 + if (!permissions.includes(required)) { + el.parentNode?.removeChild(el) + } + }, +} +``` + +**src/main.ts 中全局注册:** + +```typescript +import { createApp } from 'vue' +import App from './App.vue' +import { permission } from './directives/permission' + +const app = createApp(App) +app.directive('permission', permission) +app.mount('#app') +``` + +**业务代码中使用:** + +```html + +``` + +> **原理**:后端返回的 `permissions` 就是你在 `sys_menu` 表里配置的 `permission` 字段值(menu_type=3 的按钮记录)。它们通过角色绑定到用户,登录时返回。前端 `v-permission` 指令检查当前用户是否有该权限,没有就从 DOM 中移除元素。 + +--- + +## 四、后端接入(完整代码) + +### 4.1 网关转发时注入的请求头 + +| 请求头名称 | 数据类型 | 说明 | +|-----------|---------|------| +| `X-User-Id` | String | 当前登录用户的唯一ID | +| `X-User-Name` | String | 用户真实姓名 | +| `X-User-Account` | String | 用户登录账号 | +| `X-User-Email` | String | 用户邮箱 | +| `X-User-Post` | String | 用户岗位 | +| `X-User-Roles` | JSON | 用户角色列表 `[{roleId, roleName, roleCode}]` | +| `X-System-Id` | String | 当前业务系统的数据库ID | +| `X-System-Code` | String | 当前业务系统的唯一编码 | +| `X-System-Name` | String | 当前业务系统的显示名称 | + +> **重要**:这些请求头由网关自动注入,业务后端可以信任这些值。不需要再验证 Token 或查询用户信息。 + +### 4.2 Java — UserContext 工具类 + +```java +// ====== UserContext.java ====== +import java.util.*; + +public class UserContext { + private static final ThreadLocal> CTX = new ThreadLocal<>(); + + public static void set(Map user) { CTX.set(user); } + public static Map get() { return CTX.get(); } + public static void clear() { CTX.remove(); } + + // 快捷方法 + public static String getUserId() { return get().get("X-User-Id"); } + public static String getUserName() { return get().get("X-User-Name"); } + public static String getUserAccount(){ return get().get("X-User-Account"); } + public static String getSystemCode() { return get().get("X-System-Code"); } + public static String getSystemId() { return get().get("X-System-Id"); } +} + +// ====== UserContextFilter.java ====== +import jakarta.servlet.*; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.stereotype.Component; +import java.io.IOException; +import java.util.*; + +@Component +public class UserContextFilter implements Filter { + private static final String[] HEADERS = { + "X-User-Id", "X-User-Name", "X-User-Account", + "X-User-Email", "X-User-Post", "X-User-Roles", + "X-System-Id", "X-System-Code", "X-System-Name" + }; + + @Override + public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) + throws IOException, ServletException { + HttpServletRequest request = (HttpServletRequest) req; + Map user = new HashMap<>(); + for (String h : HEADERS) { + String v = request.getHeader(h); + if (v != null) user.put(h, v); + } + UserContext.set(user); + try { + chain.doFilter(req, res); + } finally { + UserContext.clear(); + } + } +} + +// ====== 业务代码中使用 ====== +@RestController +public class OrderController { + + @GetMapping("/api/orders") + public List list() { + String userId = UserContext.getUserId(); // "123" + String userName = UserContext.getUserName(); // "张三" + String systemCode = UserContext.getSystemCode(); // "msg" + return orderService.findByUser(userId, systemCode); + } +} +``` + +### 4.3 Python — Flask 中间件 + +```python +# ====== middleware.py ====== +from flask import request, g + +USER_HEADERS = [ + 'X-User-Id', 'X-User-Name', 'X-User-Account', + 'X-User-Email', 'X-User-Post', 'X-User-Roles', + 'X-System-Id', 'X-System-Code', 'X-System-Name', +] + +def user_context_middleware(): + """将网关注入的用户信息存到 Flask g 对象""" + for h in USER_HEADERS: + val = request.headers.get(h) + if val: + setattr(g, h.replace('-', '_').lower(), val) + +# 注册到 app +# app.before_request(user_context_middleware) + +# ====== 业务代码中使用 ====== +from flask import g + +@app.route('/api/orders') +def list_orders(): + user_id = g.x_user_id # "123" + user_name = g.x_user_name # "张三" + system_code = g.x_system_code # "msg" + return order_service.query(user_id, system_code) +``` + +### 4.4 Go — 中间件 + +```go +// ====== middleware.go ====== +package middleware + +import ( + "context" + "net/http" +) + +type contextKey string + +func UserContext(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + for _, h := range []string{ + "X-User-Id", "X-User-Name", "X-User-Account", + "X-User-Email", "X-User-Roles", + "X-System-Id", "X-System-Code", + } { + if v := r.Header.Get(h); v != "" { + ctx = context.WithValue(ctx, contextKey(h), v) + } + } + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// 快捷函数 +func GetUserID(ctx context.Context) string { + return ctx.Value(contextKey("X-User-Id")).(string) +} +func GetSystemCode(ctx context.Context) string { + return ctx.Value(contextKey("X-System-Code")).(string) +} + +// ====== main.go: 注册中间件 ====== +// http.Handle("/api/", middleware.UserContext(yourHandler)) + +// ====== 业务代码 ====== +func listOrders(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) // "123" + systemCode := middleware.GetSystemCode(r.Context()) // "msg" + // ... +} +``` + +### 4.5 Java CORS 配置 + +```java +@Configuration +public class CorsConfig implements WebMvcConfigurer { + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") + .allowedOriginPatterns("*") + .allowedMethods("*") + .allowedHeaders("*") + .allowCredentials(true); + } +} +``` + +> **注意**:`permissions` 字段**仅在当前系统为网关(gateway)时返回**。业务系统调用此接口时 `permissions` 为 `null`,业务系统应通过 §5.2 菜单路由接口获取自身系统的权限列表。 + +--- + +## 五、API 接口参考 + +> 所有接口统一通过网关地址访问:`https://gw.server.zgitm.com` + +### 统一响应格式 `Result` + +```json +{ + "code": 200, + "msg": "操作成功", + "data": {}, + "timestamp": 1718294400000 +} +``` + +| code 值 | 含义 | HTTP 状态码 | 说明 | +|---------|------|------------|------| +| `200` | 成功 | 200 | 业务正常返回 | +| `401` | 未登录/Token 过期 | 401 | 前端自动跳转登录页 | +| `403` | 无权限 | 403 | Token 有效但用户无此接口权限 | +| `500` | 服务器错误 | 500 | 后端异常,查看网关日志 | + +> **💡 注意**:`code` 和 HTTP 状态码是**两套独立的值**。通常它们保持一致,但判断业务结果应看响应体的 `code` 字段而不是 HTTP status code。响应拦截器里 `res.data.code === 200` 判断成功就是这个道理。 + +### 接口速查表 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/zgapi/v1/auth/user/info` | 获取当前登录用户信息 | +| GET | `/zgapi/v1/auth/menu/router?systemCode=xxx` | 获取树形菜单路由 + 系统所有权限标识 | +| GET | `/zgapi/v1/auth/system/list` | 获取可切换的业务系统列表 | +| PUT | `/zgapi/v1/auth/password/update?oldPwd=xxx&newPwd=xxx` | 修改当前用户密码 | + +--- + +### 5.1 获取当前用户信息 + +``` +GET /zgapi/v1/auth/user/info +Authorization: Bearer {token} +``` + +**响应 data (UserInfoVO):** + +```json +{ + "userId": "a1b2c3d4e5f6789012345678", + "username": "zhangsan", + "realName": "张三", + "email": "zhangsan@example.com", + "phone": "13800138000", + "avatar": "https://file.wg.zgitm.com/api/download/xxx?token=...", + "post": "高级工程师", + "createdAt": "2025-01-15T10:30:00", + "lastLoginTime": "2026-06-13T09:15:30" +} +``` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `userId` | String | 用户UUID | +| `username` | String | 用户名 | +| `realName` | String | 真实姓名 | +| `email` | String | 邮箱 | +| `phone` | String | 手机号 | +| `avatar` | String | 头像URL(可能来自文件服务,每次都需刷新 Token) | +| `post` | String | 岗位 | +| `createdAt` | String | 注册时间 (ISO 8601) | +| `lastLoginTime` | String | 最后登录时间 | + +--- + +### 5.2 获取系统菜单路由 + +``` +GET /zgapi/v1/auth/menu/router?systemCode=YOUR_CODE +Authorization: Bearer {token} +``` + +**响应 data (MenuRouterResponse):** + +```json +{ + "menu": [ + { + "id": "menu-001", + "menuName": "系统管理", + "menuType": 1, + "icon": "Setting", + "permission": null, + "path": null, + "name": "系统管理", + "component": "Layout", + "sortOrder": 1, + "status": 1, + "meta": { "title": "系统管理", "icon": "Setting" }, + "children": [ + { + "id": "menu-002", + "menuName": "用户管理", + "menuType": 2, + "icon": "User", + "permission": "system:user:list", + "path": "/system/user", + "name": "用户管理", + "component": "system/user/index", + "sortOrder": 1, + "status": 1, + "meta": { "title": "用户管理", "icon": "User" }, + "children": null + }, + { + "id": "menu-003", + "menuName": "角色管理", + "menuType": 2, + "icon": "Avatar", + "permission": "system:role:list", + "path": "/system/role", + "name": "角色管理", + "component": "system/role/index", + "sortOrder": 2, + "status": 1, + "meta": { "title": "角色管理", "icon": "Avatar" }, + "children": null + } + ] + } + ], + "permission": [ + "system:user:list", + "system:user:add", + "system:user:edit", + "system:user:delete", + "system:role:list", + "system:role:add", + "system:role:edit", + "system:role:delete" + ] +} +``` + +| 顶层字段 | 类型 | 说明 | +|------|------|------| +| `menu` | List\ | 树形菜单路由列表(结构见下表) | +| `permission` | List\ | 该系统所有权限标识(用于前端 v-permission 指令控制按钮显隐) | + +**menu[] 中每个菜单节点的字段说明:** +|------|------|------| +| `id` | String | 菜单ID | +| `menuName` | String | 菜单显示名称 | +| `menuType` | Integer | **1=目录**(折叠分组,path=null,component="Layout")
**2=菜单**(页面,有path和component)
**3=按钮**(权限点,不在菜单显示) | +| `icon` | String | 图标名称 | +| `permission` | String | 权限标识(如 `system:user:list`) | +| `path` | String | 前端路由路径(目录为 null) | +| `name` | String | 路由名称 | +| `component` | String | Vue 组件路径 | +| `sortOrder` | Integer | 排序号(越小越靠前) | +| `status` | Integer | 0=隐藏, 1=显示 | +| `meta` | Object | `{title, icon}` 路由元数据 | +| `children` | List | 子菜单(叶子节点为 **null**) | + +> **注意**:菜单按用户角色过滤,不同角色看到的菜单不同。叶子节点 `children` 为 `null`(非空数组),前端据此判断是否显示展开箭头。 + +--- + +### 5.3 获取可切换系统列表 + +``` +GET /zgapi/v1/auth/system/list +Authorization: Bearer {token} +``` + +**响应 data (List\):** + +```json +[ + { + "systemId": "sys-001", + "systemCode": "erp", + "systemName": "ERP系统", + "frontUrl": "https://erp.example.com", + "icon": "el-icon-s-shop", + "description": "企业资源计划管理系统" + } +] +``` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `systemId` | String | 系统UUID | +| `systemCode` | String | 系统编码 | +| `systemName` | String | 系统名称 | +| `frontUrl` | String | 前端入口地址 | +| `icon` | String | 系统图标 | +| `description` | String | 系统描述 | + +> 列表中不包含网关系统本身(systemCode="gateway"),用户无绑定系统时返回空数组 `[]`。 + +--- + +### 5.4 修改当前用户密码 + +``` +PUT /zgapi/v1/auth/password/update?oldPwd={旧密码}&newPwd={新密码} +Authorization: Bearer {token} +``` + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `oldPwd` | String | 是 | 旧密码(Query参数) | +| `newPwd` | String | 是 | 新密码(Query参数,至少6位) | + +响应:`{"code":200,"msg":"密码修改成功","data":null}`。 + +需验证旧密码正确后才能修改,修改后需重新登录。 + +--- + +## 六、数据库初始化 + +在网关数据库执行以下 SQL,为你的业务系统创建默认菜单和角色。 + +### 6.1 涉及的表 + +#### sys_menu(菜单/权限表) + +菜单采用**三级结构**:目录 → 页面菜单 → 按钮权限。后端返回菜单树时按角色过滤,用户只能看到自己角色绑定的菜单。 + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `id` | VARCHAR(64) | 是 | UUID 主键,用 `REPLACE(UUID(),'-','')` 生成 | +| `system_id` | VARCHAR(64) | 是 | 所属业务系统的 ID,从 `sys_system` 表查出 | +| `parent_id` | VARCHAR(64) | 是 | 父菜单 ID,顶级菜单填 `'0'`,子菜单填父菜单的 ID | +| `menu_name` | VARCHAR(64) | 是 | 菜单显示名称(如"用户管理"、"新增用户") | +| `menu_path` | VARCHAR(256) | 否 | 前端路由路径。**目录**填 `/xxx`,**页面菜单**填 `/xxx/yyy`,**按钮**填 `NULL` | +| `component` | VARCHAR(256) | 否 | Vue 组件路径。**目录**固定填 `'Layout'`,**页面菜单**填 `'xxx/yyy/index'`,**按钮**填 `NULL` | +| `icon` | VARCHAR(128) | 否 | 图标名称(Element Plus 图标)。**目录/菜单**填写,**按钮**填空串 `''` | +| `menu_type` | TINYINT | 是 | **1=目录**(侧边栏折叠分组,不对应具体页面)
**2=页面菜单**(具体页面,可点击跳转)
**3=按钮**(页面内的操作按钮,不显示在菜单树上,仅用于后端权限校验) | +| `permission` | VARCHAR(128) | 否 | 权限标识符,命名规范:`{模块}:{资源}:{操作}`。**目录**填 `NULL`,**页面菜单**填 `xxx:yyy:list`,**按钮**填 `xxx:yyy:add/edit/delete` 等 | +| `sort_order` | INT | 是 | 排序号,数字越小越靠前。同级菜单按此字段升序排列 | +| `status` | TINYINT | 是 | 0=隐藏(不在菜单树中返回),1=显示 | + +**permission 命名规范示例**: + +| 资源 | 页面菜单 (list) | 新增按钮 (add) | 编辑按钮 (edit) | 删除按钮 (delete) | +|------|----------------|---------------|----------------|----------------| +| 用户 | `system:user:list` | `system:user:add` | `system:user:edit` | `system:user:delete` | +| 角色 | `system:role:list` | `system:role:add` | `system:role:edit` | `system:role:delete` | +| 订单 | `biz:order:list` | `biz:order:add` | `biz:order:edit` | `biz:order:delete` | + +> 接入方可以自定义 permission 标识,只要和前端 `v-permission` 指令、后端 `@RequirePermission` 注解保持一致即可。 + +#### sys_role(角色表) + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `id` | VARCHAR(64) | 是 | UUID 主键 | +| `system_id` | VARCHAR(64) | 是 | 所属业务系统 ID | +| `role_name` | VARCHAR(64) | 是 | 角色显示名称(如"管理员"、"普通用户") | +| `role_code` | VARCHAR(64) | 是 | 角色编码(唯一标识,如 `admin`、`user`),同一系统内不可重复 | +| `description` | VARCHAR(256) | 否 | 角色描述 | +| `status` | TINYINT | 是 | 0=禁用,1=启用 | + +#### sys_role_menu(角色菜单关联表) + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `id` | VARCHAR(64) | 是 | UUID 主键 | +| `role_id` | VARCHAR(64) | 是 | 角色 ID | +| `menu_id` | VARCHAR(64) | 是 | 菜单 ID(含按钮权限) | + +> 一条 `sys_role_menu` 记录表示:该角色拥有该菜单/按钮的权限。菜单接口返回时会根据当前用户的角色自动过滤。 + +### 6.2 初始化 SQL + +```sql +-- ============================================ +-- {系统名称} 菜单 & 角色初始化 SQL +-- 系统编码: {systemCode} +-- ============================================ + +-- 0. 获取系统 ID +SET @system_id = (SELECT id FROM sys_system WHERE system_code = '{systemCode}' LIMIT 1); + +-- 1. 创建顶级菜单目录(menu_type=1,permission 为 NULL) +-- 用变量持有父菜单 UUID,后续子菜单直接引用变量名,无需手动查 ID +-- 目录仅作为侧边栏折叠分组,不绑定具体页面,不需要权限标识 +SET @menu_sys = REPLACE(UUID(),'-',''); +SET @menu_biz = REPLACE(UUID(),'-',''); + +INSERT INTO sys_menu (id, system_id, parent_id, menu_name, menu_path, component, icon, menu_type, sort_order, status, permission) VALUES +(@menu_sys, @system_id, '0', '系统管理', '/system', 'Layout', 'Setting', 1, 1, 1, NULL), +(@menu_biz, @system_id, '0', '业务管理', '/biz', 'Layout', 'Document', 1, 2, 1, NULL); + +-- 2. 创建页面菜单(menu_type=2,permission 为页面访问权限标识) +-- parent_id 直接用上面定义的变量,MySQL 变量在同一个会话内全局可见 +-- 命名规范:{模块}:{资源}:list +INSERT INTO sys_menu (id, system_id, parent_id, menu_name, menu_path, component, icon, menu_type, sort_order, status, permission) VALUES +(REPLACE(UUID(),'-',''), @system_id, @menu_sys, '用户管理', '/system/user', 'system/user/index', 'User', 2, 1, 1, 'system:user:list'), +(REPLACE(UUID(),'-',''), @system_id, @menu_sys, '角色管理', '/system/role', 'system/role/index', 'Avatar', 2, 2, 1, 'system:role:list'), +(REPLACE(UUID(),'-',''), @system_id, @menu_biz, '订单管理', '/biz/order', 'biz/order/index', 'Tickets', 2, 1, 1, 'biz:order:list'); + +-- 3. 创建按钮权限(menu_type=3,permission 为按钮操作权限标识) +-- 按钮的 parent_id 统一挂在对应页面的父菜单目录下(@menu_sys 或 @menu_biz) +-- 按钮不显示在菜单树中,仅用于后端权限校验,path/component/icon 均为 NULL/空串 +-- 命名规范:{模块}:{资源}:{操作}(add/edit/delete) +INSERT INTO sys_menu (id, system_id, parent_id, menu_name, menu_path, component, icon, menu_type, sort_order, status, permission) VALUES +-- 用户管理按钮 +(REPLACE(UUID(),'-',''), @system_id, @menu_sys, '新增用户', NULL, NULL, '', 3, 1, 1, 'system:user:add'), +(REPLACE(UUID(),'-',''), @system_id, @menu_sys, '编辑用户', NULL, NULL, '', 3, 2, 1, 'system:user:edit'), +(REPLACE(UUID(),'-',''), @system_id, @menu_sys, '删除用户', NULL, NULL, '', 3, 3, 1, 'system:user:delete'), +-- 角色管理按钮 +(REPLACE(UUID(),'-',''), @system_id, @menu_sys, '新增角色', NULL, NULL, '', 3, 1, 1, 'system:role:add'), +(REPLACE(UUID(),'-',''), @system_id, @menu_sys, '编辑角色', NULL, NULL, '', 3, 2, 1, 'system:role:edit'), +(REPLACE(UUID(),'-',''), @system_id, @menu_sys, '删除角色', NULL, NULL, '', 3, 3, 1, 'system:role:delete'), +-- 订单管理按钮 +(REPLACE(UUID(),'-',''), @system_id, @menu_biz, '新增订单', NULL, NULL, '', 3, 1, 1, 'biz:order:add'), +(REPLACE(UUID(),'-',''), @system_id, @menu_biz, '编辑订单', NULL, NULL, '', 3, 2, 1, 'biz:order:edit'), +(REPLACE(UUID(),'-',''), @system_id, @menu_biz, '删除订单', NULL, NULL, '', 3, 3, 1, 'biz:order:delete'); + +-- 4. 创建角色(用变量持有角色 UUID,供后续步骤 5 分配用户时引用) +SET @role_admin = REPLACE(UUID(),'-',''); +SET @role_user = REPLACE(UUID(),'-',''); + +INSERT INTO sys_role (id, system_id, role_name, role_code, description, status) VALUES +(@role_admin, @system_id, '管理员', 'admin', '拥有全部权限', 1), +(@role_user, @system_id, '普通用户', 'user', '拥有基本权限', 1); + +-- 5. 将本系统所有菜单(含按钮权限)分配给管理员角色 +INSERT INTO sys_role_menu (id, role_id, menu_id) +SELECT REPLACE(UUID(),'-',''), @role_admin, m.id +FROM sys_menu m WHERE m.system_id = @system_id; + +-- 6. (可选)为普通用户分配菜单——根据实际需求手动给 user 角色分配菜单 +-- INSERT INTO sys_role_menu (id, role_id, menu_id) +-- SELECT REPLACE(UUID(),'-',''), @role_user, m.id +-- FROM sys_menu m WHERE m.system_id = @system_id +-- AND m.menu_type != 3; -- 普通用户只分配页面菜单,不给按钮权限 +``` + +> **💡 普通用户 (user) 角色**:初始化 SQL 只给管理员分配了菜单,普通用户角色默认没有任何菜单权限。这是因为不同系统对普通用户的权限要求不同,无法预置。接入后请登录管理后台,在「角色管理」中手动为 `user` 角色分配菜单。或者取消上面第 6 步的注释,给普通用户分配所有页面菜单(不含按钮)。 + +--- + +## 七、创建网关管理员账号 + +执行以下 SQL 创建一个应用系统管理员账号,该管理员只能看到自己绑定系统的数据: + +```sql +-- ============================================ +-- 创建应用系统管理员账号 +-- 角色编码: gateway-app-admin (只能看到自己绑定系统的数据) +-- ============================================ + +-- 1. 创建管理员用户(密码 'admin123' 的 BCrypt 密文,建议自行生成替换) +INSERT INTO sys_user (id, username, password, real_name, email, phone, status, is_deleted) +SELECT REPLACE(UUID(),'-',''), 'admin_{systemCode}', + '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iKTVKIUi', + '{系统名称}管理员', 'admin@{systemCode}.com', NULL, 1, 0 +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM sys_user WHERE username = 'admin_{systemCode}'); + +-- 2. 绑定管理员到网关系统 (gateway,用于登录管理后台) +INSERT INTO sys_user_system (id, user_id, system_id) +SELECT REPLACE(UUID(),'-',''), u.id, s.id +FROM sys_user u, sys_system s +WHERE u.username = 'admin_{systemCode}' + AND s.system_code = 'gateway' + AND NOT EXISTS ( + SELECT 1 FROM sys_user_system us + WHERE us.user_id = u.id AND us.system_id = s.id + ); + +-- 3. 分配 gateway-app-admin 角色(管理后台权限,只能看到自己绑定系统的数据) +INSERT INTO sys_user_role (id, user_id, role_id) +SELECT REPLACE(UUID(),'-',''), u.id, r.id +FROM sys_user u, sys_role r, sys_system s +WHERE u.username = 'admin_{systemCode}' + AND r.role_code = 'gateway-app-admin' + AND r.system_id = s.id + AND s.system_code = 'gateway' + AND NOT EXISTS ( + SELECT 1 FROM sys_user_role ur + WHERE ur.user_id = u.id AND ur.role_id = r.id + ); + +-- 4. 绑定管理员到自己的业务系统(用于操作本系统的用户/角色/菜单) +INSERT INTO sys_user_system (id, user_id, system_id) +SELECT REPLACE(UUID(),'-',''), u.id, s.id +FROM sys_user u, sys_system s +WHERE u.username = 'admin_{systemCode}' + AND s.system_code = '{systemCode}' + AND NOT EXISTS ( + SELECT 1 FROM sys_user_system us + WHERE us.user_id = u.id AND us.system_id = s.id + ); + +-- 5. 分配本系统的管理员角色(admin,拥有本系统全部菜单权限) +INSERT INTO sys_user_role (id, user_id, role_id) +SELECT REPLACE(UUID(),'-',''), u.id, r.id +FROM sys_user u, sys_role r, sys_system s +WHERE u.username = 'admin_{systemCode}' + AND r.role_code = 'admin' + AND r.system_id = s.id + AND s.system_code = '{systemCode}' + AND NOT EXISTS ( + SELECT 1 FROM sys_user_role ur + WHERE ur.user_id = u.id AND ur.role_id = r.id + ); +``` + +> ⚠️ **如何生成 BCrypt 密文**: +> +> **Python(最方便)**: +> ```bash +> python -c "import bcrypt; print(bcrypt.hashpw(b'your_password', bcrypt.gensalt()).decode())" +> ``` +> 如果没装 bcrypt 库:`pip install bcrypt`(Windows 用 `pip install bcrypt`,Linux 用 `pip3 install bcrypt`)。 +> +> **Java**: +> ```java +> String hash = BCrypt.hashpw("your_password", BCrypt.gensalt()); +> ``` +> +> **在线工具**(仅测试环境):https://www.bcryptcalculator.com +> +> 如果已创建过用户,跳过步骤 1,只执行步骤 2-5。 + +--- + +## 八、UserChip Widget 一键接入 + +在页面右上角添加用户头像和下拉菜单,引入网关提供的 UserChip Widget,按以下三步操作。 + +> 🚫 **常见错误:`process is not defined`** +> 直接在 `index.html` 中写 ` +``` + +--- + +## 二、文件结构 + +### 新建文件 + +``` +mokee-gateway-web/ +├── src/widgets/user-chip/ ← 新建目录 +│ ├── main.ts ← 入口:自动初始化,扫描 #mokee-user-chip 并挂载 Vue +│ ├── UserChip.vue ← 核心组件(~120行,模板 + 逻辑 + 样式) +│ ├── api.ts ← API 调用层(3个函数) +│ └── cookie.ts ← Cookie 工具(从 request.ts/auth.ts 提取复用) +├── vite.widget.config.ts ← 新建:Widget 专用 Vite 构建配置 +└── public/demo-widget.html ← 新建:接入演示页面 +``` + +### 修改文件 + +| 文件 | 改动 | +|------|------| +| `package.json` | 新增 `build:widget` 脚本 | + +### 文件职责划分 + +``` +┌─ main.ts ──────────────────────────────────────┐ +│ 职责:DOM 就绪后扫描 #mokee-user-chip, │ +│ 读取 data-* 配置,创建并挂载 Vue 应用 │ +│ 依赖:UserChip.vue │ +│ 输出:立即执行,无 export │ +└─────────────────────────────────────────────────┘ + │ 创建 Vue app,传入 props + ▼ +┌─ UserChip.vue ──────────────────────────────────┐ +│ 职责:渲染头像 + 用户名 + 下拉菜单 │ +│ 依赖:api.ts, cookie.ts, element-plus 组件 │ +│ 状态:loading / userInfo / 错误静默降级 │ +│ Props:apiBase, loginUrl, selectUrl │ +│ Emits:无(通过 window.location.href 跳转) │ +└─────────────────────────────────────────────────┘ + │ 调用 + ▼ +┌─ api.ts ────────────────────────────────────────┐ +│ 职责:封装 3 个 API 调用 │ +│ - fetchUserInfo(apiBase): UserInfo | null │ +│ - logout(apiBase): void │ +│ - refreshFileServiceUrl(apiBase, url): string │ +│ 依赖:cookie.ts │ +└─────────────────────────────────────────────────┘ + │ 调用 + ▼ +┌─ cookie.ts ─────────────────────────────────────┐ +│ 职责:Token 的 Cookie 读写 │ +│ - getToken(): string | null │ +│ - removeToken(): void │ +└─────────────────────────────────────────────────┘ +``` + +--- + +## 三、API 依赖分析 + +### 全部复用现有接口,后端零改动 + +| API | 方法 | 用途 | 调用时机 | +|-----|------|------|----------| +| `GET /zgapi/v1/auth/user/info` | GET | 获取当前用户信息 | 组件挂载时 | +| `POST /zgapi/v1/auth/logout` | POST | 通知服务端销毁 session | 点"退出登录"时 | +| `POST /zgapi/v1/open/token` | POST | 获取文件服务下载 Token | 用户头像 URL 指向文件服务时 | + +### API 调用细节 + +#### 1. 获取用户信息 + +``` +请求:GET {apiBase}/zgapi/v1/auth/user/info +请求头:Authorization: Bearer {token} (token 从 Cookie 读取) + Content-Type: application/json +响应:{ + "code": 200, + "data": { + "userId": "a1b2c3d4...", + "username": "zhangsan", + "realName": "张三", + "email": "zhangsan@zgitm.com", + "phone": "13800000000", + "avatar": null | "https://file.wg.zgitm.com/api/download/xxx", + "post": "工程师" + } +} +``` + +#### 2. 退出登录 + +``` +请求:POST {apiBase}/zgapi/v1/auth/logout +请求头:Authorization: Bearer {token} +``` + +#### 3. 获取文件服务下载 Token(仅在 avatar URL 指向文件服务时调用) + +``` +请求:POST {apiBase}/zgapi/v1/open/token +请求体:{ "systemCode": "gateway" } +响应:{ "code": 200, "data": { "token": "eyJ..." } } +``` + +拼接:`{avatarBaseUrl}?token={新Token}` + +--- + +## 四、配置与数据流 + +### 4.1 外部系统可配置项 + +通过占位元素的 `data-*` 属性配置: + +```html +
+ data-login-url="https://web.gw.zgitm.com" + data-select-url="https://web.gw.zgitm.com/select" +>
+``` + +| 属性 | 默认值 | 说明 | +|------|--------|------| +| `data-api-base` | `window.location.origin` | 网关 API 地址 | +| `data-login-url` | `window.location.origin + '/login'` | 登录页完整 URL | +| `data-select-url` | `window.location.origin + '/select'` | 切换系统页完整 URL | + +### 4.2 运行时数据流 + +``` +页面加载 + │ + ▼ +widgets/user-chip/main.ts 立即执行(IIFE) + │ + ├─ 1. document.querySelectorAll('#mokee-user-chip') + │ └─ 找到所有占位元素(支持同一页面多个实例) + │ + ├─ 2. 对每个占位元素: + │ ├─ 读取 data-api-base / data-login-url / data-select-url + │ ├─ 创建 Vue 3 app,以 UserChip 为根组件 + │ ├─ 将配置作为 props 传入 + │ └─ app.mount(container) ← 挂载到占位元素内部 + │ + ▼ +UserChip.vue mounted() + │ + ├─ 3. 从 Cookie 读取 token(cookie.getToken()) + │ + ├─ 4. 调用 fetchUserInfo(apiBase) + │ ├─ 成功 → userInfo.value = 响应数据 + │ │ └─ 若 avatar URL 指向文件服务 → refreshFileServiceUrl() + │ │ 获取新 Token 拼接完整下载 URL + │ └─ 失败(401 / 网络异常)→ userInfo.value = null(显示"登录"按钮) + │ + └─ 5. 渲染: + ├─ loading = true → 骨架屏 + ├─ userInfo = null → "登录" 按钮 + └─ userInfo 有值 → 头像 + 用户名 + ▼(可点开下拉) +``` + +### 4.3 退出登录流程 + +``` +用户点击"退出登录" + │ + ├─ 1. POST {apiBase}/zgapi/v1/auth/logout (fire-and-forget,失败不阻塞) + │ + ├─ 2. cookie.removeToken() + │ └─ document.cookie = "token=;path=/;max-age=0;domain=.zgitm.com" + │ + └─ 3. window.location.href = loginUrl + └─ 完整页面跳转,清空所有内存状态 +``` + +--- + +## 五、样式隔离方案 + +### 策略:BEM 命名空间 + 独立 CSS 变量 + +不使用 Shadow DOM(因为 Element Plus 的 `el-dropdown` 弹出层通过 Teleport 渲染到 `document.body`,Shadow DOM 会阻止弹出层正常工作)。 + +``` +所有类名: .mokee-uc-{Block}__{Element}--{Modifier} +所有 CSS 变量: --mokee-uc-{name} +``` + +### 根容器结构 + +```html +
← Vue 挂载点(替换 #mokee-user-chip 的内部) +
← 用户条(头像 + 名字 + 箭头) + ... +
+
+``` + +### CSS 变量隔离 + +```css +.mokee-uc-root { + /* 所有颜色变量自给自足,不依赖宿主的 CSS 变量 */ + --mokee-uc-text-primary: #1f2937; + --mokee-uc-text-secondary: #6b7280; + --mokee-uc-text-muted: #9ca3af; + --mokee-uc-bg-hover: #f3f4f6; + --mokee-uc-border-color: #e5e7eb; + --mokee-uc-color-primary: #6366f1; + --mokee-uc-radius: 8px; + --mokee-uc-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + font-family: var(--mokee-uc-font-family); + font-size: 14px; + line-height: 1.5; + color: var(--mokee-uc-text-primary); +} + +/* 所有内部元素使用 --mokee-uc-* 变量 */ +.mokee-uc-chip { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 5px 12px 5px 5px; + border-radius: 24px; + cursor: pointer; + border: 1px solid transparent; + transition: all 0.15s ease; + user-select: none; +} +.mokee-uc-chip:hover { + background: var(--mokee-uc-bg-hover); + border-color: var(--mokee-uc-border-color); +} +``` + +--- + +## 六、构建配置 + +### 6.1 vite.widget.config.ts + +```typescript +/** + * Widget 构建配置 — 将 UserChip 组件打包为独立的 IIFE 文件 + * + * 产物: + * - dist/widgets/user-chip.js (~200KB raw, ~65KB gzip) + * - dist/widgets/user-chip.css (~4KB raw, ~1KB gzip) + * + * 策略: + * - 全部依赖打入包内(vue, element-plus, axios),外部系统无需引入任何前置依赖 + * - IIFE 格式,浏览器直接可用 + * - Element Plus 按需导入 — 只打 el-avatar, el-dropdown, el-dropdown-menu, + * el-dropdown-item, el-icon 这 5 个组件 + * - CSS 单独提取,方便外部系统 引入 + */ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import path from 'path' + +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': path.resolve(__dirname, 'src'), + }, + }, + build: { + // 输出到 dist/widgets/ + outDir: 'dist/widgets', + // Library Mode + lib: { + entry: path.resolve(__dirname, 'src/widgets/user-chip/main.ts'), + name: 'MokeeUserChip', + formats: ['iife'], + fileName: () => 'user-chip.js', + }, + rollupOptions: { + // 全部依赖打入包内,不做 external + external: [], + output: { + // IIFE 模式下不需要 globals + // CSS 文件命名 + assetFileNames: (assetInfo) => { + if (assetInfo.name?.endsWith('.css')) return 'user-chip.css' + return assetInfo.name || '[name]-[hash][extname]' + }, + }, + }, + // 目标兼容大部分浏览器 + target: 'es2015', + cssTarget: 'chrome61', + // 不生成 sourcemap(减少体积) + sourcemap: false, + }, +}) +``` + +### 6.2 package.json 新增脚本 + +```json +{ + "scripts": { + "dev": "vite --port 5001", + "build": "vue-tsc && vite build", + "build:prod": "vite build", + "build:widget": "vite build --config vite.widget.config.ts", + "preview": "vite preview" + } +} +``` + +### 6.3 构建命令 + +```bash +cd mokee-gateway-web +npm run build:widget +``` + +### 6.4 产物体积预估 + +| 产物 | 未压缩 | gzip (~level 6) | brotli | +|------|--------|-----------------|--------| +| user-chip.js | ~200 KB | ~65 KB | ~55 KB | +| user-chip.css | ~4 KB | ~1 KB | ~0.8 KB | +| **总计** | **~204 KB** | **~66 KB** | **~56 KB** | + +--- + +## 七、完整代码 + +### Task 1: cookie.ts — Cookie 工具 + +**文件:** `src/widgets/user-chip/cookie.ts` (新建) + +```typescript +/** + * Cookie 工具 — Token 的 Cookie 读写 + * + * 与 mokee-gateway-web/src/utils/request.ts 和 src/stores/auth.ts 的 + * Cookie 读写逻辑完全一致,保持 Token 机制统一。 + * + * Cookie 属性: + * - path=/ : 全站可读 + * - max-age=7200 : 2 小时,与 JWT Token 过期时间一致 + * - SameSite=Lax : 允许同站点内跨子域携带(.zgitm.com) + * - domain : 由外部配置传入或允许跨子域共享 + */ + +const TOKEN_KEY = 'token' + +/** 从外部 data-* 属性或默认值获取 Cookie domain */ +function readCookieDomain(): string { + // 尝试从占位元素读取 + const el = document.getElementById('mokee-user-chip') + if (el) { + const d = el.getAttribute('data-cookie-domain') + if (d !== null) return d // 即使是空字符串也接受 + } + // 默认:不设置 domain(仅当前域名) + return '' +} + +function getDomainPart(): string { + const domain = readCookieDomain() + return domain ? `;domain=${domain}` : '' +} + +/** 从 Cookie 读取 Token */ +export function getToken(): string | null { + const match = document.cookie.match(new RegExp(`(?:^|;\\s*)${TOKEN_KEY}=([^;]*)`)) + return match ? decodeURIComponent(match[1]) : null +} + +/** 清除 Cookie 中的 Token */ +export function removeToken(): void { + const domainPart = getDomainPart() + document.cookie = `${TOKEN_KEY}=;path=/;max-age=0${domainPart}` +} +``` + +--- + +### Task 2: api.ts — API 调用层 + +**文件:** `src/widgets/user-chip/api.ts` (新建) + +```typescript +/** + * API 调用层 — 封装 Widget 需要的 3 个网关 API + * + * 设计原则: + * - 直接用 fetch API,不依赖 axios(减少包体积 ~15KB) + * - 不依赖 Pinia、request.ts 等网关后台专用的状态管理 + * - 所有函数都是纯函数(apiBase 参数化),方便测试 + * - 错误静默处理,返回 null 让调用方降级 + */ +import { getToken } from './cookie' + +/** 用户信息(与网关 /zgapi/v1/auth/user/info 响应一致) */ +export interface UserInfo { + userId: string + username: string + realName: string + email: string + phone: string + avatar: string | null + post: string +} + +/** 文件服务地址(用于判断头像 URL 是否需要刷新 Token) */ +const FILE_SERVICE_HOST = 'file.wg.zgitm.com' + +/** 判断 URL 是否为文件服务地址(需要动态 Token) */ +function isFileServiceUrl(url: string): boolean { + if (!url) return false + return url.includes(FILE_SERVICE_HOST) +} + +/** 从 URL 中去掉 ?token=xxx 参数,保留基础路径 */ +function stripToken(url: string): string { + return url.split('?')[0] +} + +/** + * 获取当前用户信息 + * + * 调用 GET {apiBase}/zgapi/v1/auth/user/info + * 通过 Cookie 自动携带 Authorization Token + * + * @param apiBase - 网关 API 地址(如 https://gw.server.zgitm.com) + * @returns 用户信息对象,失败返回 null + */ +export async function fetchUserInfo(apiBase: string): Promise { + const token = getToken() + if (!token) return null + + try { + const res = await fetch(`${apiBase}/zgapi/v1/auth/user/info`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json;charset=UTF-8', + }, + credentials: 'include', // 跨域时携带 Cookie + }) + + if (res.status === 401) { + // Token 过期或无效,静默降级为未登录状态 + return null + } + + if (!res.ok) return null + + const body = await res.json() + // 网关的响应结构:{ code: 200, data: {...} } + if (body.code !== 200) return null + + return body.data as UserInfo + } catch { + // 网络异常,静默降级 + return null + } +} + +/** + * 退出登录 + * + * 调用 POST {apiBase}/zgapi/v1/auth/logout + * fire-and-forget:失败不阻塞后续的 Cookie 清除和页面跳转 + * + * @param apiBase - 网关 API 地址 + */ +export async function requestLogout(apiBase: string): Promise { + const token = getToken() + if (!token) return + + try { + await fetch(`${apiBase}/zgapi/v1/auth/logout`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json;charset=UTF-8', + }, + credentials: 'include', + }) + } catch { + // fire-and-forget:即使失败也继续后续流程 + } +} + +/** + * 为文件服务 URL 获取新的下载 Token + * + * 文件服务 Token 是一次一密的:数据库中只存基础 URL(不带 ?token=xxx), + * 每次加载头像时需要重新获取下载 Token 并拼接完整 URL。 + * + * 调用 POST {apiBase}/zgapi/v1/open/token + * + * @param apiBase - 网关 API 地址 + * @param url - 原始头像 URL(可能是带过期 Token 的 URL) + * @returns 拼接了新 Token 的完整 URL,获取失败则返回原 URL + */ +export async function refreshFileServiceUrl(apiBase: string, url: string): Promise { + if (!url || !isFileServiceUrl(url)) return url + + const token = getToken() + if (!token) return url + + try { + const baseUrl = stripToken(url) + const res = await fetch(`${apiBase}/zgapi/v1/open/token`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json;charset=UTF-8', + }, + credentials: 'include', + body: JSON.stringify({ systemCode: 'gateway' }), + }) + + if (!res.ok) return url + + const body = await res.json() + if (body.code !== 200 || !body.data?.token) return url + + return `${baseUrl}?token=${body.data.token}` + } catch { + return url + } +} +``` + +--- + +### Task 3: UserChip.vue — 核心组件 + +**文件:** `src/widgets/user-chip/UserChip.vue` (新建) + +```vue + + + + + + +``` + +--- + +### Task 4: main.ts — 初始化入口 + +**文件:** `src/widgets/user-chip/main.ts` (新建) + +```typescript +/** + * UserChip Widget — 自动初始化入口 + * + * 功能: + * - DOM 就绪后自动扫描所有 #mokee-user-chip 占位元素 + * - 读取 data-* 配置属性 + * - 为每个占位元素创建独立的 Vue 3 应用实例并挂载 + * + * 使用方式(外部系统): + * + *
+ * + * + * selectUrl 自动从脚本 URL 推断,无需手动配置: + * 脚本 src = https://web.gw.zgitm.com/widgets/user-chip.js → selectUrl = https://web.gw.zgitm.com/select + * + * 为什么不能用 window.location.origin? + * - 外部系统的域名 ≠ 网关域名(如外部系统在 app1.zgitm.com,/select 在 web.gw.zgitm.com) + * - window.location.origin 拿到的是外部系统域名,拼接 /select 是错的 + * - 正确做法:从 Widget 脚本自身的 + + +``` + +--- + +## 八、测试要点 + +### 8.1 本地开发调试 + +```bash +# 1. 构建 Widget +cd mokee-gateway-web +npm run build:widget + +# 2. 启动 Vite 开发服务器(Widget 产物在 dist/widgets/,dev server 默认不提供) +# 方案:直接用 nginx 或 vite preview 提供 dist/widgets/ 静态文件 + +# 3. 或者用 http-server 快速验证 +npx http-server dist/widgets -p 8080 --cors +# 然后访问 http://127.0.0.1:8080/demo-widget.html +``` + +### 8.2 功能测试 + +| 测试场景 | 前置条件 | 预期结果 | +|----------|----------|----------| +| 首次加载(无 Token) | Cookie 中无 token | 显示"🔑 登录"按钮 | +| 点击"登录"按钮 | 无 Token | 跳转到 `data-login-url` 指定的地址 | +| 有 Token,API 正常 | Cookie 中有有效 token | 显示头像 + 用户名 + ▼ | +| 点击用户名区域 | 已登录 | 弹出下拉菜单(切换系统、退出登录) | +| 点击"切换系统" | 已登录 | `window.location.href = data-select-url` | +| 点击"退出登录" | 已登录 | POST logout → 清除 Cookie → 跳转登录页 | +| Token 过期(401) | Cookie 中有过期 token | 静默降级,显示"登录"按钮 | +| 网络异常 | 断开网络 | 静默降级,显示"登录"按钮(不报错) | +| 用户无头像 | 已登录,avatar = null | 显示用户名首字符文字头像 | +| 用户头像来自文件服务 | 已登录,avatar 指向 file.wg.zgitm.com | 自动刷新 Token 后正确显示 | +| 页面无 #mokee-user-chip | — | 不做任何事(静默退出) | +| 同时存在多个占位元素 | 多个 `#mokee-user-chip` | 每个独立渲染,互不影响 | + +### 8.3 兼容性测试 + +| 测试项 | 验证方式 | +|--------|----------| +| Chrome/Edge 最新版 | 手动打开 demo 页验证 | +| Firefox 最新版 | 手动验证 | +| Safari 最新版 | 手动验证 | +| 移动端 Chrome/Safari | 开发者工具模拟 + 真机 | +| 宿主页已有 Element Plus | 检查样式是否污染(`.mokee-uc-*` 命名空间隔离) | +| 宿主页无 Element Plus | Widget 自带全部依赖,正常渲染 | + +--- + +## 九、部署方案 + +### 9.1 Nginx 静态文件服务 + +```nginx +# 网关 Nginx 配置新增 +location /widgets/ { + alias /opt/mokee-gateway-web/dist/widgets/; + expires 30d; + add_header Cache-Control "public, immutable"; + + # 允许跨域引用(外部系统从其他域名引入时) + add_header Access-Control-Allow-Origin *; + + # 正确的 MIME 类型 + types { + application/javascript js; + text/css css; + } +} +``` + +### 9.2 部署后 URL + +``` +https://web.gw.zgitm.com/widgets/user-chip.js +https://web.gw.zgitm.com/widgets/user-chip.css +``` + +### 9.3 CI/CD 集成(可选,后续优化) + +```bash +# 在现有构建流程中增加 +npm run build:widget +# 产物 dist/widgets/ 随网关前端一起部署 +``` + +--- + +## 十、潜在风险与缓解 + +| 风险 | 影响 | 缓解措施 | +|------|------|----------| +| **Element Plus 弹出层 Teleport** | `el-dropdown` 弹出内容 Teleport 到 `document.body`,若宿主编译了 Element Plus,样式可能冲突 | `.mokee-uc-root` 内不使用 Element Plus 全局 CSS 变量;弹出菜单本身的样式由 Element Plus 自带,与宿主 Element Plus 共存时版本差异可能导致细微差异 — 这是可接受的风险(Vue 3 支持多实例) | +| **Vue 多实例** | 同一页面可能同时存在宿主 Vue 应用和 Widget 的 Vue 实例 | Vue 3 支持多个独立应用实例,无冲突 | +| **包体积 66KB gzip** | 首次加载较慢 | 1. 后续可改为 CDN 外部依赖;2. 接入方可用 `` 提前加载 | +| **Cookie domain 不一致** | 网关写 Cookie 时指定了 `.zgitm.com`,外部系统如果在完全不同域名下,读不到 Cookie | 同域名/子域名下完美工作;跨域场景需额外配置(增加 `data-cookie-domain` 支持 + Cookie `SameSite=None; Secure`) | +| **文件服务 Token 刷新** | 每次页面加载都要额外调用 `/zgapi/v1/open/token`,增加延迟 | 只在 avatar URL 指向文件服务时才调用(绝大多数用户不上传头像,不会触发) | + +--- + +## 十一、与接入文档的关系 + +接入文档(`IntegrationDoc.vue`)的 §3 "前端接入" 章节已经教外部系统如何: +- 从 Cookie 读取 Token +- 调用 `/zgapi/v1/auth/user/info` 获取用户信息 +- 实现路由守卫和 401 跳转登录 + +**UserChip Widget 是对 §3 的组件化封装**,把以上步骤自动化成一个 `") + List selectDistinctByRoleIds(@Param("roleIds") List roleIds, @Param("systemId") String systemId); + + /** + * 查询多个角色的权限标识列表 + * + * @param roleIds 角色ID列表 + * @return 权限标识列表(已去重,过滤空值) + */ + @Select("") + List selectPermissionsByRoleIds(@Param("roleIds") List roleIds); +} diff --git a/mokee-gateway-auth/src/main/java/com/mokee/auth/mapper/SysRoleMapper.java b/mokee-gateway-auth/src/main/java/com/mokee/auth/mapper/SysRoleMapper.java new file mode 100644 index 0000000..59dd1f1 --- /dev/null +++ b/mokee-gateway-auth/src/main/java/com/mokee/auth/mapper/SysRoleMapper.java @@ -0,0 +1,29 @@ +package com.mokee.auth.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.mokee.common.entity.SysRole; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +/** + * 角色 Mapper + */ +@Mapper +public interface SysRoleMapper extends BaseMapper { + + /** + * 查询用户在指定系统中的角色列表 + * + * @param userId 用户ID + * @param systemId 系统ID + * @return 角色列表 + */ + @Select("SELECT r.* FROM sys_role r " + + "INNER JOIN sys_user_role ur ON r.id = ur.role_id " + + "WHERE ur.user_id = #{userId} AND r.system_id = #{systemId} " + + "AND r.status = 1 AND r.is_deleted = 0") + List selectListByUserIdAndSystemId(@Param("userId") String userId, @Param("systemId") String systemId); +} diff --git a/mokee-gateway-auth/src/main/java/com/mokee/auth/mapper/SysSystemMapper.java b/mokee-gateway-auth/src/main/java/com/mokee/auth/mapper/SysSystemMapper.java new file mode 100644 index 0000000..7bc93b8 --- /dev/null +++ b/mokee-gateway-auth/src/main/java/com/mokee/auth/mapper/SysSystemMapper.java @@ -0,0 +1,29 @@ +package com.mokee.auth.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.mokee.common.entity.SysSystem; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +/** + * 系统信息 Mapper + */ +@Mapper +public interface SysSystemMapper extends BaseMapper { + + /** + * 查询用户绑定的启用系统列表 + * + * @param userId 用户ID + * @return 系统列表 + */ + @Select("SELECT s.* FROM sys_system s " + + "INNER JOIN sys_user_system us ON s.id = us.system_id " + + "WHERE us.user_id = #{userId} " + + "AND s.status = 1 AND s.is_deleted = 0 " + + "ORDER BY s.sort_order ASC") + List selectListByUserId(@Param("userId") String userId); +} diff --git a/mokee-gateway-auth/src/main/java/com/mokee/auth/mapper/SysUserMapper.java b/mokee-gateway-auth/src/main/java/com/mokee/auth/mapper/SysUserMapper.java new file mode 100644 index 0000000..bd331e6 --- /dev/null +++ b/mokee-gateway-auth/src/main/java/com/mokee/auth/mapper/SysUserMapper.java @@ -0,0 +1,12 @@ +package com.mokee.auth.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.mokee.common.entity.SysUser; +import org.apache.ibatis.annotations.Mapper; + +/** + * 用户 Mapper + */ +@Mapper +public interface SysUserMapper extends BaseMapper { +} diff --git a/mokee-gateway-auth/src/main/java/com/mokee/auth/mapper/SysUserRoleMapper.java b/mokee-gateway-auth/src/main/java/com/mokee/auth/mapper/SysUserRoleMapper.java new file mode 100644 index 0000000..5178bdf --- /dev/null +++ b/mokee-gateway-auth/src/main/java/com/mokee/auth/mapper/SysUserRoleMapper.java @@ -0,0 +1,12 @@ +package com.mokee.auth.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.mokee.common.entity.SysUserRole; +import org.apache.ibatis.annotations.Mapper; + +/** + * 用户角色关联 Mapper + */ +@Mapper +public interface SysUserRoleMapper extends BaseMapper { +} diff --git a/mokee-gateway-auth/src/main/java/com/mokee/auth/mapper/SysUserSystemMapper.java b/mokee-gateway-auth/src/main/java/com/mokee/auth/mapper/SysUserSystemMapper.java new file mode 100644 index 0000000..24f411f --- /dev/null +++ b/mokee-gateway-auth/src/main/java/com/mokee/auth/mapper/SysUserSystemMapper.java @@ -0,0 +1,12 @@ +package com.mokee.auth.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.mokee.common.entity.SysUserSystem; +import org.apache.ibatis.annotations.Mapper; + +/** + * 用户系统关联 Mapper + */ +@Mapper +public interface SysUserSystemMapper extends BaseMapper { +} diff --git a/mokee-gateway-auth/src/main/java/com/mokee/auth/service/AuthService.java b/mokee-gateway-auth/src/main/java/com/mokee/auth/service/AuthService.java new file mode 100644 index 0000000..ab06b85 --- /dev/null +++ b/mokee-gateway-auth/src/main/java/com/mokee/auth/service/AuthService.java @@ -0,0 +1,83 @@ +package com.mokee.auth.service; + +import com.mokee.common.dto.request.LoginRequest; +import com.mokee.common.dto.response.LoginResponse; +import com.mokee.common.dto.response.MenuRouterResponse; +import com.mokee.common.dto.response.MenuRouterVO; +import com.mokee.common.dto.response.SystemVO; +import com.mokee.common.dto.response.UserInfoVO; + +import java.util.List; + +/** + * 认证服务接口 + *

+ * 定义登录、登出、用户信息、菜单路由、系统切换等核心认证方法 + */ +public interface AuthService { + + /** + * 用户登录 + * + * @param req 登录请求(用户名、密码、可选系统编码) + * @param ip 客户端 IP + * @param userAgent 浏览器 User-Agent + * @return 登录响应(Token + 用户信息 + 系统列表) + */ + LoginResponse login(LoginRequest req, String ip, String userAgent); + + /** + * 用户登出 + *

+ * 清除 Redis 中的登录会话 + * + * @param token JWT Token + */ + void logout(String token); + + /** + * 获取当前登录用户信息 + * + * @param token JWT Token + * @return 用户信息 VO + */ + UserInfoVO getUserInfo(String token); + + /** + * 获取指定系统的菜单路由树和权限列表 + * + * @param token JWT Token + * @param systemCode 系统编码 + * @return 菜单路由响应(含菜单树 + 权限列表) + */ + MenuRouterResponse getMenuRouter(String token, String systemCode); + + /** + * 获取当前用户可切换的业务系统列表 + * + * @param token JWT Token + * @return 系统列表 + */ + List getSystemList(String token); + + /** + * 切换当前业务系统 + *

+ * 切换后更新 Redis 会话中的 currentSystem 信息 + * + * @param token JWT Token + * @param systemId 目标系统 ID + */ + void selectSystem(String token, String systemId); + + /** + * 修改密码 + *

+ * 验证旧密码后使用 BCrypt 加密新密码并更新 + * + * @param token JWT Token + * @param oldPwd 旧密码 + * @param newPwd 新密码 + */ + void updatePassword(String token, String oldPwd, String newPwd); +} diff --git a/mokee-gateway-auth/src/main/java/com/mokee/auth/service/LogAsyncService.java b/mokee-gateway-auth/src/main/java/com/mokee/auth/service/LogAsyncService.java new file mode 100644 index 0000000..6d57684 --- /dev/null +++ b/mokee-gateway-auth/src/main/java/com/mokee/auth/service/LogAsyncService.java @@ -0,0 +1,33 @@ +package com.mokee.auth.service; + +import com.mokee.auth.mapper.SysLoginLogMapper; +import com.mokee.common.entity.SysLoginLog; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +/** + * 异步日志写入服务 + *

+ * 将登录日志的写入从主业务线程剥离,避免同步数据库写入阻塞登录接口响应。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class LogAsyncService { + + private final SysLoginLogMapper sysLoginLogMapper; + + /** + * 异步写入登录日志 + */ + @Async("logTaskExecutor") + public void saveLoginLog(SysLoginLog logEntity) { + try { + sysLoginLogMapper.insert(logEntity); + } catch (Exception e) { + log.error("异步写入登录日志失败 - username: {}", logEntity.getUsername(), e); + } + } +} diff --git a/mokee-gateway-auth/src/main/java/com/mokee/auth/service/impl/AuthServiceImpl.java b/mokee-gateway-auth/src/main/java/com/mokee/auth/service/impl/AuthServiceImpl.java new file mode 100644 index 0000000..87d64e5 --- /dev/null +++ b/mokee-gateway-auth/src/main/java/com/mokee/auth/service/impl/AuthServiceImpl.java @@ -0,0 +1,575 @@ +package com.mokee.auth.service.impl; + +import cn.hutool.core.util.StrUtil; +import cn.hutool.crypto.digest.BCrypt; +import cn.hutool.json.JSONUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.mokee.auth.mapper.*; +import com.mokee.auth.service.AuthService; +import com.mokee.auth.service.LogAsyncService; +import com.mokee.common.dto.request.LoginRequest; +import com.mokee.common.dto.response.*; +import com.mokee.common.entity.*; +import com.mokee.common.enums.StatusEnum; +import com.mokee.common.exception.BusinessException; +import com.mokee.common.exception.ErrorCode; +import com.mokee.common.utils.JwtUtil; +import com.mokee.common.utils.RedisUtil; +import io.jsonwebtoken.Claims; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.*; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +/** + * 认证服务实现类 + *

+ * 完整登录流程:限流 → 锁定检查 → 用户验证 → 密码校验 → 锁定/成功处理 → 系统查询 → 角色查询 → Token生成 → Redis缓存 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AuthServiceImpl implements AuthService { + + private final SysUserMapper sysUserMapper; + private final SysRoleMapper sysRoleMapper; + private final SysMenuMapper sysMenuMapper; + private final SysUserRoleMapper sysUserRoleMapper; + private final SysUserSystemMapper sysUserSystemMapper; + private final SysSystemMapper sysSystemMapper; + private final SysLoginLogMapper sysLoginLogMapper; + private final LogAsyncService logAsyncService; + private final JwtUtil jwtUtil; + private final RedisUtil redisUtil; + + /** 登录失败最大次数 */ + private static final int MAX_FAIL_COUNT = 5; + /** 锁定时间(分钟) */ + private static final int LOCK_MINUTES = 5; + /** 限流每分钟最大请求数 */ + private static final int RATE_LIMIT_MAX = 10; + /** Token 缓存时间(小时) */ + private static final int TOKEN_TTL_HOURS = 2; + /** Token 续期阈值(秒),小于此值自动续期 */ + private static final long RENEW_THRESHOLD_SECONDS = 1800; + /** 本地缓存 TTL(分钟),用 Caffeine 避免跨子网 DB/Redis 查询 */ + private static final int CACHE_TTL_MINUTES = 3; + + // Caffeine 本地缓存(微秒级命中,不走网络) + private Cache userInfoCache; + private Cache> systemListCache; + private Cache menuRouterCache; + + @PostConstruct + public void initCaches() { + userInfoCache = Caffeine.newBuilder() + .maximumSize(500).expireAfterWrite(Duration.ofMinutes(CACHE_TTL_MINUTES)).build(); + systemListCache = Caffeine.newBuilder() + .maximumSize(500).expireAfterWrite(Duration.ofMinutes(CACHE_TTL_MINUTES)).build(); + menuRouterCache = Caffeine.newBuilder() + .maximumSize(500).expireAfterWrite(Duration.ofMinutes(CACHE_TTL_MINUTES)).build(); + } + + // ==================== 登录 ==================== + + @Override + public LoginResponse login(LoginRequest req, String ip, String userAgent) { + // 1. 限流检查:按 IP 限制登录频率,每分钟最多 10 次 + String rateKey = RedisUtil.key("rate:login:" + ip); + Long rateCount = redisUtil.incr(rateKey); + if (rateCount == 1) { + redisUtil.expire(rateKey, 1, TimeUnit.MINUTES); + } + if (rateCount > RATE_LIMIT_MAX) { + log.warn("登录限流触发 - ip: {}, count: {}", ip, rateCount); + throw new BusinessException(ErrorCode.RATE_LIMITED); + } + + // 2. 锁定检查:检查 Redis 中是否存在账号锁定标记 + String lockKey = RedisUtil.key("lock:login:" + req.getUsername()); + if (redisUtil.exists(lockKey)) { + log.warn("账号锁定中(Redis标记) - username: {}", req.getUsername()); + throw new BusinessException(ErrorCode.ACCOUNT_LOCKED); + } + + // 3. 查询用户 + SysUser user = sysUserMapper.selectOne( + new LambdaQueryWrapper().eq(SysUser::getUsername, req.getUsername()) + ); + if (user == null) { + log.warn("用户不存在 - username: {}", req.getUsername()); + throw new BusinessException(ErrorCode.USERNAME_PASSWORD_ERROR); + } + + // 4. 状态校验:禁用/锁定的用户拒绝登录 + if (user.getStatus() == null || user.getStatus().equals(StatusEnum.DISABLED.getCode())) { + log.warn("用户已禁用 - username: {}", req.getUsername()); + throw new BusinessException(ErrorCode.USER_DISABLED); + } + if (user.getStatus().equals(StatusEnum.LOCKED.getCode()) + && user.getLockUntil() != null + && user.getLockUntil().isAfter(LocalDateTime.now())) { + log.warn("账号已被锁定 - username: {}, lockUntil: {}", req.getUsername(), user.getLockUntil()); + throw new BusinessException(ErrorCode.ACCOUNT_LOCKED); + } + + // 5. 密码校验:使用 BCrypt 比对 + boolean passwordMatch = BCrypt.checkpw(req.getPassword(), user.getPassword()); + if (!passwordMatch) { + return handleLoginFailure(user, ip, userAgent, "密码错误"); + } + + // 6. 登录成功处理 + return handleLoginSuccess(user, req.getSystemCode(), ip, userAgent); + } + + /** + * 处理登录失败:累计失败次数、超限锁定账号、记录失败日志 + */ + private LoginResponse handleLoginFailure(SysUser user, String ip, String userAgent, String failReason) { + int failCount = (user.getLoginFailCount() == null ? 0 : user.getLoginFailCount()) + 1; + user.setLoginFailCount(failCount); + + // 连续失败 >= 5 次,锁定账号 30 分钟 + if (failCount >= MAX_FAIL_COUNT) { + user.setStatus(StatusEnum.LOCKED.getCode()); + LocalDateTime lockUntil = LocalDateTime.now().plusMinutes(LOCK_MINUTES); + user.setLockUntil(lockUntil); + + // 在 Redis 中设置锁定标记,30 分钟后自动过期 + redisUtil.set(RedisUtil.key("lock:login:" + user.getUsername()), "1", LOCK_MINUTES, TimeUnit.MINUTES); + log.warn("账号已被锁定 - username: {}, lockUntil: {}", user.getUsername(), lockUntil); + } + sysUserMapper.updateById(user); + + // 记录登录失败日志 + saveLoginLog(user.getId(), user.getUsername(), user.getRealName(), null, ip, userAgent, 0, failReason); + + throw new BusinessException(ErrorCode.USERNAME_PASSWORD_ERROR); + } + + /** + * 处理登录成功:重置失败计数、更新登录信息、查询系统/角色、生成Token并缓存Redis + */ + private LoginResponse handleLoginSuccess(SysUser user, String systemCode, String ip, String userAgent) { + // 重置失败次数并更新登录信息 + user.setLoginFailCount(0); + user.setLastLoginIp(ip); + user.setLastLoginTime(LocalDateTime.now()); + sysUserMapper.updateById(user); + + // 查询用户绑定的业务系统列表 + List boundSystems = sysSystemMapper.selectListByUserId(user.getId()); + if (boundSystems.isEmpty()) { + log.warn("用户未绑定任何业务系统 - userId: {}", user.getId()); + throw new BusinessException(ErrorCode.SYSTEM_DISABLED); + } + + // 确定当前系统:优先使用请求指定的 systemCode,其次取唯一边界系统 + SysSystem currentSystem = null; + if (StrUtil.isNotBlank(systemCode)) { + currentSystem = boundSystems.stream() + .filter(s -> systemCode.equals(s.getSystemCode())) + .findFirst().orElse(null); + } + if (currentSystem == null && boundSystems.size() == 1) { + currentSystem = boundSystems.get(0); + } + + // 查询用户在当前系统中的角色及权限 + List roleInfoList = new ArrayList<>(); + List permissionList = new ArrayList<>(); + if (currentSystem != null) { + List roles = sysRoleMapper.selectListByUserIdAndSystemId(user.getId(), currentSystem.getId()); + roleInfoList = roles.stream() + .map(r -> new RoleInfo(r.getId(), r.getRoleName(), r.getRoleCode())) + .collect(Collectors.toList()); + if (!roles.isEmpty()) { + List roleIds = roles.stream().map(SysRole::getId).collect(Collectors.toList()); + permissionList = sysMenuMapper.selectPermissionsByRoleIds(roleIds); + } + } + + // 生成 JWT Token(JwtUtil 内部自动生成 UUID 作为 tokenId) + String token = jwtUtil.generateToken(user.getId(), user.getUsername()); + Claims tokenClaims = jwtUtil.validateToken(token); + String actualTokenId = jwtUtil.getTokenId(tokenClaims); + + // 构建 TokenInfo 并缓存到 Redis(TTL 2小时) + TokenInfo tokenInfo = new TokenInfo(); + tokenInfo.setUserId(user.getId()); + tokenInfo.setUsername(user.getUsername()); + tokenInfo.setRealName(user.getRealName()); + tokenInfo.setEmail(user.getEmail()); + tokenInfo.setPhone(user.getPhone()); + tokenInfo.setAvatar(user.getAvatar()); + tokenInfo.setPost(user.getPost()); + tokenInfo.setRoles(roleInfoList); + tokenInfo.setPermissions(permissionList); + if (currentSystem != null) { + tokenInfo.setCurrentSystemId(currentSystem.getId()); + tokenInfo.setCurrentSystemCode(currentSystem.getSystemCode()); + tokenInfo.setCurrentSystemName(currentSystem.getSystemName()); + } + + redisUtil.set(RedisUtil.key("auth:token:" + actualTokenId), JSONUtil.toJsonStr(tokenInfo), TOKEN_TTL_HOURS, TimeUnit.HOURS); + // 反向索引:userId → tokenId,用于用户信息更新时同步刷新 Redis + redisUtil.set(RedisUtil.key("auth:user_session:" + user.getId()), actualTokenId, TOKEN_TTL_HOURS, TimeUnit.HOURS); + + // 记录登录成功日志 + saveLoginLog(user.getId(), user.getUsername(), user.getRealName(), + currentSystem != null ? currentSystem.getId() : null, + ip, userAgent, 1, null); + + // 登录成功后刷新本地缓存(下次查询自动重建) + userInfoCache.invalidate(user.getId()); + systemListCache.invalidate(user.getId()); + if (currentSystem != null) { + menuRouterCache.invalidate(currentSystem.getId() + ":" + user.getId()); + } + + // 构建响应对象 + UserInfoVO userInfoVO = new UserInfoVO(); + userInfoVO.setUserId(user.getId()); + userInfoVO.setUsername(user.getUsername()); + userInfoVO.setRealName(user.getRealName()); + userInfoVO.setEmail(user.getEmail()); + userInfoVO.setPhone(user.getPhone()); + userInfoVO.setAvatar(user.getAvatar()); + userInfoVO.setPost(user.getPost()); + + List systemVOList = boundSystems.stream() + .map(s -> new SystemVO(s.getId(), s.getSystemCode(), s.getSystemName(), + s.getFrontUrl(), s.getIcon(), s.getDescription())) + .collect(Collectors.toList()); + + SystemVO currentSystemVO = null; + if (currentSystem != null) { + currentSystemVO = new SystemVO(currentSystem.getId(), currentSystem.getSystemCode(), + currentSystem.getSystemName(), currentSystem.getFrontUrl(), + currentSystem.getIcon(), currentSystem.getDescription()); + } + + LoginResponse response = new LoginResponse(); + response.setToken(token); + response.setUserInfo(userInfoVO); + response.setSystemList(systemVOList); + response.setNeedSelectSystem(currentSystem == null && boundSystems.size() > 1); + response.setCurrentSystem(currentSystemVO); + // 判断是否为平台管理员(拥有 super_admin 角色的用户可以进入后台管理) + boolean isAdmin = roleInfoList.stream().anyMatch(r -> "super_admin".equals(r.getRoleCode())); + response.setIsAdmin(isAdmin); + response.setPermissions(permissionList); + + log.info("用户登录成功 - username: {}, ip: {}, tokenId: {}", user.getUsername(), ip, actualTokenId); + return response; + } + + // ==================== 登出 ==================== + + @Override + public void logout(String token) { + try { + Claims claims = jwtUtil.validateToken(token); + String tokenId = jwtUtil.getTokenId(claims); + String userId = jwtUtil.getUserId(claims); + redisUtil.delete(RedisUtil.key(RedisUtil.key("auth:token:" + tokenId))); + redisUtil.delete(RedisUtil.key("auth:user_session:" + userId)); + log.info("用户登出成功 - tokenId: {}, userId: {}", tokenId, userId); + } catch (Exception e) { + // Token 已过期或无效,忽略即可,登出本身就是为了清除会话 + log.warn("登出时 Token 无效,忽略 - {}", e.getMessage()); + } + } + + // ==================== 获取用户信息 ==================== + + @Override + public UserInfoVO getUserInfo(String token) { + TokenInfo tokenInfo = getTokenInfoFromRedis(token); + String userId = tokenInfo.getUserId(); + + // 优先读本地缓存(微秒级) + UserInfoVO cached = userInfoCache.getIfPresent(userId); + if (cached != null) { + if ("gateway".equals(tokenInfo.getCurrentSystemCode())) { + cached.setPermissions(tokenInfo.getPermissions()); + } + return cached; + } + + // 缓存未命中,查 DB 构建 + UserInfoVO vo = new UserInfoVO(); + vo.setUserId(tokenInfo.getUserId()); + vo.setUsername(tokenInfo.getUsername()); + vo.setRealName(tokenInfo.getRealName()); + vo.setEmail(tokenInfo.getEmail()); + vo.setPhone(tokenInfo.getPhone()); + vo.setAvatar(tokenInfo.getAvatar()); + vo.setPost(tokenInfo.getPost()); + if ("gateway".equals(tokenInfo.getCurrentSystemCode())) { + vo.setPermissions(tokenInfo.getPermissions()); + } + SysUser user = sysUserMapper.selectById(userId); + if (user != null) { + vo.setCreatedAt(user.getCreatedAt() != null ? user.getCreatedAt().toString() : null); + vo.setLastLoginTime(user.getLastLoginTime() != null ? user.getLastLoginTime().toString() : null); + } + + userInfoCache.put(userId, vo); + return vo; + } + + // ==================== 获取菜单路由 ==================== + + @Override + public MenuRouterResponse getMenuRouter(String token, String systemCode) { + TokenInfo tokenInfo = getTokenInfoFromRedis(token); + String userId = tokenInfo.getUserId(); + + // 查询指定系统 + SysSystem system = sysSystemMapper.selectOne( + new LambdaQueryWrapper().eq(SysSystem::getSystemCode, systemCode) + ); + if (system == null) { + throw new BusinessException(ErrorCode.NOT_FOUND); + } + String systemId = system.getId(); + String cacheKey = systemId + ":" + userId; + + // 优先读本地缓存(微秒级) + MenuRouterResponse cached = menuRouterCache.getIfPresent(cacheKey); + if (cached != null) { + return cached; + } + + // 缓存未命中,查 DB 构建 + List userRoles = sysRoleMapper.selectListByUserIdAndSystemId(userId, systemId); + if (userRoles.isEmpty()) { + return new MenuRouterResponse(Collections.emptyList(), Collections.emptyList()); + } + + List roleIds = userRoles.stream().map(SysRole::getId).collect(Collectors.toList()); + List permissions = sysMenuMapper.selectPermissionsByRoleIds(roleIds); + List menus = sysMenuMapper.selectDistinctByRoleIds(roleIds, systemId); + List routerMenus = menus.stream() + .filter(m -> m.getStatus() == 1) + .filter(m -> m.getMenuType() == 1 || m.getMenuType() == 2) + .sorted(Comparator.comparingInt(m -> m.getSortOrder() != null ? m.getSortOrder() : 0)) + .collect(Collectors.toList()); + + List menuTree = buildMenuTree(routerMenus, "0"); + MenuRouterResponse response = new MenuRouterResponse(menuTree, permissions); + menuRouterCache.put(cacheKey, response); + return response; + } + + /** + * 递归构建菜单路由树 + * + * @param allMenus 所有菜单列表 + * @param parentId 当前父节点 ID + * @return 树形路由节点列表 + */ + private List buildMenuTree(List allMenus, String parentId) { + List list = new ArrayList<>(); + for (SysMenu menu : allMenus) { + if (menu.getParentId().equals(parentId)) { + MenuRouterVO vo = new MenuRouterVO(); + vo.setId(menu.getId()); + vo.setMenuName(menu.getMenuName()); + vo.setMenuType(menu.getMenuType()); + vo.setIcon(menu.getIcon()); + vo.setPermission(menu.getPermission()); + vo.setPath(menu.getMenuPath()); + vo.setName(menu.getMenuName()); + vo.setComponent(menu.getComponent()); + vo.setSortOrder(menu.getSortOrder()); + vo.setStatus(menu.getStatus()); + vo.setMeta(new MenuMetaVO(menu.getMenuName(), menu.getIcon())); + // 递归构建子菜单 + List children = buildMenuTree(allMenus, menu.getId()); + vo.setChildren(children.isEmpty() ? null : children); + list.add(vo); + } + } + return list; + } + + // ==================== 获取系统列表 ==================== + + @Override + public List getSystemList(String token) { + TokenInfo tokenInfo = getTokenInfoFromRedis(token); + String userId = tokenInfo.getUserId(); + + // 优先读本地缓存(微秒级) + List cached = systemListCache.getIfPresent(userId); + if (cached != null) { + return cached; + } + + // 缓存未命中,查 DB + List systems = sysSystemMapper.selectListByUserId(userId); + List result = systems.stream() + .map(s -> new SystemVO(s.getId(), s.getSystemCode(), s.getSystemName(), + s.getFrontUrl(), s.getIcon(), s.getDescription())) + .collect(Collectors.toList()); + + systemListCache.put(userId, result); + return result; + } + + // ==================== 切换系统 ==================== + + @Override + public void selectSystem(String token, String systemId) { + Claims claims = jwtUtil.validateToken(token); + String tokenId = jwtUtil.getTokenId(claims); + String userId = jwtUtil.getUserId(claims); + + // 验证该系统是否属于用户的绑定系统 + List userSystems = sysSystemMapper.selectListByUserId(userId); + boolean hasSystem = userSystems.stream().anyMatch(s -> s.getId().equals(systemId)); + if (!hasSystem) { + throw new BusinessException(ErrorCode.FORBIDDEN); + } + + // 获取当前 Redis 中的 TokenInfo + String redisKey = RedisUtil.key("auth:token:" + tokenId); + String jsonStr = redisUtil.get(redisKey); + if (jsonStr == null) { + throw new BusinessException(ErrorCode.TOKEN_EXPIRED); + } + TokenInfo tokenInfo = JSONUtil.toBean(jsonStr, TokenInfo.class); + + // 查询目标系统信息 + SysSystem targetSystem = sysSystemMapper.selectById(systemId); + if (targetSystem == null || targetSystem.getStatus() == 0) { + throw new BusinessException(ErrorCode.SYSTEM_DISABLED); + } + + // 更新 TokenInfo 中的当前系统信息 + tokenInfo.setCurrentSystemId(targetSystem.getId()); + tokenInfo.setCurrentSystemCode(targetSystem.getSystemCode()); + tokenInfo.setCurrentSystemName(targetSystem.getSystemName()); + + // 重新查询该系统中的角色和权限 + List roles = sysRoleMapper.selectListByUserIdAndSystemId(userId, systemId); + tokenInfo.setRoles(roles.stream() + .map(r -> new RoleInfo(r.getId(), r.getRoleName(), r.getRoleCode())) + .collect(Collectors.toList())); + if (!roles.isEmpty()) { + List roleIds = roles.stream().map(SysRole::getId).collect(Collectors.toList()); + tokenInfo.setPermissions(sysMenuMapper.selectPermissionsByRoleIds(roleIds)); + } else { + tokenInfo.setPermissions(Collections.emptyList()); + } + + // 更新 Redis 缓存,保持原有 TTL + Long ttl = redisUtil.getExpire(redisKey); + if (ttl != null && ttl > 0) { + redisUtil.set(redisKey, JSONUtil.toJsonStr(tokenInfo), ttl, TimeUnit.SECONDS); + } else { + redisUtil.set(redisKey, JSONUtil.toJsonStr(tokenInfo), TOKEN_TTL_HOURS, TimeUnit.HOURS); + } + + // 刷新缓存:切换系统后下次查询自动重建 + menuRouterCache.invalidate(systemId + ":" + userId); + + // 更新最近一条登录日志的选择系统字段 + try { + SysLoginLog latestLog = sysLoginLogMapper.selectOne( + new LambdaQueryWrapper() + .eq(SysLoginLog::getUserId, userId) + .eq(SysLoginLog::getStatus, 1) + .orderByDesc(SysLoginLog::getCreatedAt) + .last("LIMIT 1") + ); + if (latestLog != null) { + latestLog.setSystemId(systemId); + sysLoginLogMapper.updateById(latestLog); + } + } catch (Exception e) { + log.warn("更新登录日志系统ID失败 - userId: {}, systemId: {}", userId, systemId, e); + } + + log.info("用户切换系统 - userId: {}, systemId: {}, systemName: {}", userId, systemId, targetSystem.getSystemName()); + } + + // ==================== 修改密码 ==================== + + @Override + public void updatePassword(String token, String oldPwd, String newPwd) { + Claims claims = jwtUtil.validateToken(token); + String userId = jwtUtil.getUserId(claims); + + SysUser user = sysUserMapper.selectById(userId); + if (user == null) { + throw new BusinessException(ErrorCode.NOT_FOUND); + } + + // 验证旧密码 + if (!BCrypt.checkpw(oldPwd, user.getPassword())) { + throw new BusinessException(400, "旧密码错误"); + } + + // BCrypt 加密新密码并更新 + user.setPassword(BCrypt.hashpw(newPwd)); + sysUserMapper.updateById(user); + + log.info("用户密码修改成功 - userId: {}", userId); + } + + // ==================== 私有辅助方法 ==================== + + /** + * 从 Redis 中获取 TokenInfo 并进行续期检查 + *

+ * 如果 Token 剩余有效时间不足 30 分钟,自动续期为 2 小时 + */ + private TokenInfo getTokenInfoFromRedis(String token) { + Claims claims = jwtUtil.validateToken(token); + String tokenId = jwtUtil.getTokenId(claims); + String redisKey = RedisUtil.key("auth:token:" + tokenId); + + String jsonStr = redisUtil.get(redisKey); + if (jsonStr == null) { + throw new BusinessException(ErrorCode.TOKEN_EXPIRED); + } + + // 每次请求都刷新 TTL(GET + EXPIRE 共 2 次 Redis 往返,省掉 TTL 检查) + redisUtil.expire(redisKey, TOKEN_TTL_HOURS, TimeUnit.HOURS); + + return JSONUtil.toBean(jsonStr, TokenInfo.class); + } + + /** + * 保存登录日志到数据库 + */ + private void saveLoginLog(String userId, String username, String realName, String systemId, + String ip, String userAgent, int status, String failReason) { + SysLoginLog loginLog = SysLoginLog.builder() + .userId(userId) + .username(username) + .realName(realName) + .systemId(systemId) + .loginIp(ip) + .userAgent(userAgent) + .browser(com.mokee.common.utils.UserAgentUtil.parseBrowser(userAgent)) + .os(com.mokee.common.utils.UserAgentUtil.parseOs(userAgent)) + .status(status) + .failReason(failReason) + .createdAt(LocalDateTime.now()) + .build(); + logAsyncService.saveLoginLog(loginLog); + } +} diff --git a/mokee-gateway-auth/src/main/resources/application-prod.yml b/mokee-gateway-auth/src/main/resources/application-prod.yml new file mode 100644 index 0000000..b633eab --- /dev/null +++ b/mokee-gateway-auth/src/main/resources/application-prod.yml @@ -0,0 +1,40 @@ +app: + env-prefix: "[PROD]" + +spring: + mail: + host: smtp.qiye.aliyun.com + port: 465 + username: admin@zgitm.com + password: mokee2016. + properties: + mail.smtp.auth: true + mail.smtp.ssl.enable: true + mail.smtp.socketFactory.port: 465 + mail.smtp.socketFactory.class: javax.net.ssl.SSLSocketFactory + datasource: + url: jdbc:mysql://db1.prod.zgitm.com:3306/mokeegateway?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai + username: mokeegateway + password: mokee. + driver-class-name: com.mysql.cj.jdbc.Driver + data: + redis: + host: redis.db1.zgitm.com + port: 6379 + password: mokee2016. + database: 1 + timeout: 3000ms + lettuce: + pool: + max-active: 8 + max-idle: 8 + min-idle: 0 + +mybatis-plus: + configuration: + log-impl: org.apache.ibatis.logging.nologging.NoLoggingImpl + +logging: + level: + com.mokee: INFO + org.springframework.web.servlet.DispatcherServlet: DEBUG diff --git a/mokee-gateway-auth/src/main/resources/application-qa.yml b/mokee-gateway-auth/src/main/resources/application-qa.yml new file mode 100644 index 0000000..d9e91b2 --- /dev/null +++ b/mokee-gateway-auth/src/main/resources/application-qa.yml @@ -0,0 +1,39 @@ +app: + env-prefix: "[QA]" + +spring: + mail: + host: smtp.qiye.aliyun.com + port: 465 + username: admin@zgitm.com + password: mokee2016. + properties: + mail.smtp.auth: true + mail.smtp.ssl.enable: true + mail.smtp.socketFactory.port: 465 + mail.smtp.socketFactory.class: javax.net.ssl.SSLSocketFactory + datasource: + url: jdbc:mysql://db1.qa.zgitm.com:3306/mokeegatewayqa?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai + username: mokeegatewayqa + password: mokee. + driver-class-name: com.mysql.cj.jdbc.Driver + data: + redis: + host: redis.db2.zgitm.com + port: 6379 + password: mokee2016. + database: 2 + timeout: 3000ms + lettuce: + pool: + max-active: 8 + max-idle: 8 + min-idle: 0 + +mybatis-plus: + configuration: + log-impl: org.apache.ibatis.logging.stdout.StdOutImpl + +logging: + level: + com.mokee: INFO diff --git a/mokee-gateway-auth/src/main/resources/application.yml b/mokee-gateway-auth/src/main/resources/application.yml new file mode 100644 index 0000000..afcdded --- /dev/null +++ b/mokee-gateway-auth/src/main/resources/application.yml @@ -0,0 +1,30 @@ +server: + port: 9001 + +spring: + application: + name: mokee-gateway-auth + profiles: + active: @spring.profiles.active@ + +mybatis-plus: + mapper-locations: classpath*:/mapper/**/*.xml + type-aliases-package: com.mokee.common.entity + configuration: + map-underscore-to-camel-case: true + global-config: + db-config: + logic-delete-field: isDeleted + logic-delete-value: 1 + logic-not-delete-value: 0 + +jwt: + secret: mokee-gateway-jwt-secret-key-2024-must-be-256-bits!! + expiration: 7200 + +knife4j: + enable: true + openapi: + title: 认证用户服务 + description: 登录/登出/Token/用户/角色/菜单 + version: 1.0.0 diff --git a/mokee-gateway-common/pom.xml b/mokee-gateway-common/pom.xml new file mode 100644 index 0000000..dd491c3 --- /dev/null +++ b/mokee-gateway-common/pom.xml @@ -0,0 +1,77 @@ + + + 4.0.0 + + + com.mokee + mokee-gateway + 1.0.0 + + + mokee-gateway-common + mokee-gateway-common + 公共模块:Entity、DTO、工具类、异常 + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-data-redis + + + com.baomidou + mybatis-plus-spring-boot3-starter + + + com.mysql + mysql-connector-j + + + io.jsonwebtoken + jjwt-api + + + io.jsonwebtoken + jjwt-impl + runtime + + + io.jsonwebtoken + jjwt-jackson + runtime + + + cn.hutool + hutool-all + + + org.springframework.boot + spring-boot-starter-mail + + + com.github.ben-manes.caffeine + caffeine + + + com.github.xiaoymin + knife4j-openapi3-jakarta-spring-boot-starter + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + true + + + + + diff --git a/mokee-gateway-common/src/main/java/com/mokee/common/Result.java b/mokee-gateway-common/src/main/java/com/mokee/common/Result.java new file mode 100644 index 0000000..459c382 --- /dev/null +++ b/mokee-gateway-common/src/main/java/com/mokee/common/Result.java @@ -0,0 +1,59 @@ +package com.mokee.common; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 统一返回类 + *

+ * 封装所有 Controller 返回结果,提供链式静态工厂方法 + * + * @param 数据泛型 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class Result { + + /** 状态码 */ + private int code; + + /** 返回消息 */ + private String msg; + + /** 返回数据 */ + private T data; + + /** 时间戳 */ + private long timestamp; + + // ==================== 成功响应 ==================== + + /** 操作成功(无数据) */ + public static Result ok() { + return new Result<>(200, "操作成功", null, System.currentTimeMillis()); + } + + /** 操作成功(带数据) */ + public static Result ok(T data) { + return new Result<>(200, "操作成功", data, System.currentTimeMillis()); + } + + /** 操作成功(自定义消息 + 数据) */ + public static Result ok(String msg, T data) { + return new Result<>(200, msg, data, System.currentTimeMillis()); + } + + // ==================== 失败响应 ==================== + + /** 操作失败(指定错误码和消息) */ + public static Result fail(int code, String msg) { + return new Result<>(code, msg, null, System.currentTimeMillis()); + } + + /** 操作失败(仅消息,默认500) */ + public static Result fail(String msg) { + return new Result<>(500, msg, null, System.currentTimeMillis()); + } +} diff --git a/mokee-gateway-common/src/main/java/com/mokee/common/annotation/OperationLog.java b/mokee-gateway-common/src/main/java/com/mokee/common/annotation/OperationLog.java new file mode 100644 index 0000000..40c2c36 --- /dev/null +++ b/mokee-gateway-common/src/main/java/com/mokee/common/annotation/OperationLog.java @@ -0,0 +1,20 @@ +package com.mokee.common.annotation; + +import java.lang.annotation.*; + +/** + * 操作日志注解 + *

+ * 标记在 Controller 方法上,由 OperationLogAspect 拦截并自动记录操作日志 + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface OperationLog { + + /** 操作模块名称,如"用户管理"、"角色管理" */ + String module() default ""; + + /** 操作类型,如 CREATE / UPDATE / DELETE / QUERY */ + String action() default ""; +} diff --git a/mokee-gateway-common/src/main/java/com/mokee/common/config/AsyncConfig.java b/mokee-gateway-common/src/main/java/com/mokee/common/config/AsyncConfig.java new file mode 100644 index 0000000..cf8bc41 --- /dev/null +++ b/mokee-gateway-common/src/main/java/com/mokee/common/config/AsyncConfig.java @@ -0,0 +1,40 @@ +package com.mokee.common.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.Executor; +import java.util.concurrent.ThreadPoolExecutor; + +/** + * 异步任务配置 + *

+ * 提供专用线程池用于日志异步写入,避免数据库日志写入阻塞主业务线程 + */ +@Configuration +@EnableAsync +public class AsyncConfig { + + /** + * 异步日志写入专用线程池 + *

+ * 核心线程 4,最大 16,队列容量 200。队列满时回调主线程执行(CallerRunsPolicy), + * 确保日志不丢失(牺牲一点性能换取数据完整性) + */ + @Bean("logTaskExecutor") + public Executor logTaskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(4); + executor.setMaxPoolSize(16); + executor.setQueueCapacity(200); + executor.setKeepAliveSeconds(60); + executor.setThreadNamePrefix("async-log-"); + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(30); + executor.initialize(); + return executor; + } +} diff --git a/mokee-gateway-common/src/main/java/com/mokee/common/config/JacksonConfig.java b/mokee-gateway-common/src/main/java/com/mokee/common/config/JacksonConfig.java new file mode 100644 index 0000000..a1d569d --- /dev/null +++ b/mokee-gateway-common/src/main/java/com/mokee/common/config/JacksonConfig.java @@ -0,0 +1,44 @@ +package com.mokee.common.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.text.SimpleDateFormat; +import java.util.TimeZone; + +/** + * Jackson 序列化配置 + *

+ * 统一日期格式和时区,避免前后端时区不一致导致的时间偏差 + */ +@Configuration +public class JacksonConfig { + + /** + * 配置 ObjectMapper + *

    + *
  • 时区设为 Asia/Shanghai(UTC+8)
  • + *
  • 日期格式化 yyyy-MM-dd HH:mm:ss
  • + *
  • 注册 JavaTimeModule 并禁用时间戳写入
  • + *
+ */ + @Bean + public ObjectMapper objectMapper() { + ObjectMapper objectMapper = new ObjectMapper(); + + // 设置时区 + objectMapper.setTimeZone(TimeZone.getTimeZone("Asia/Shanghai")); + + // 设置日期格式 + objectMapper.setDateFormat(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss")); + + // 注册 JavaTimeModule,禁用 WRITE_DATES_AS_TIMESTAMPS + objectMapper.registerModule(new JavaTimeModule()); + objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + + return objectMapper; + } +} diff --git a/mokee-gateway-common/src/main/java/com/mokee/common/config/MybatisPlusConfig.java b/mokee-gateway-common/src/main/java/com/mokee/common/config/MybatisPlusConfig.java new file mode 100644 index 0000000..4a7ef85 --- /dev/null +++ b/mokee-gateway-common/src/main/java/com/mokee/common/config/MybatisPlusConfig.java @@ -0,0 +1,65 @@ +package com.mokee.common.config; + +import com.baomidou.mybatisplus.annotation.DbType; +import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler; +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; +import org.apache.ibatis.reflection.MetaObject; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.LocalDateTime; + +/** + * MyBatis-Plus 配置 + *

+ * 注册分页插件和自动填充处理器 + */ +@Configuration +public class MybatisPlusConfig { + + /** + * MyBatis-Plus 拦截器,注册 MySQL 分页插件 + */ + @Bean + public MybatisPlusInterceptor mybatisPlusInterceptor() { + MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); + PaginationInnerInterceptor paginationInterceptor = new PaginationInnerInterceptor(DbType.MYSQL); + interceptor.addInnerInterceptor(paginationInterceptor); + return interceptor; + } + + /** + * 自动填充处理器 + *

+ * 插入时自动填充 createTime / updateTime,更新时自动填充 updateTime + */ + @Bean + public MetaObjectHandler metaObjectHandler() { + return new MyMetaObjectHandler(); + } + + /** + * 自动填充处理器内部类 + */ + public static class MyMetaObjectHandler implements MetaObjectHandler { + + /** 创建时间字段名 */ + private static final String CREATE_TIME = "createTime"; + + /** 更新时间字段名 */ + private static final String UPDATE_TIME = "updateTime"; + + @Override + public void insertFill(MetaObject metaObject) { + LocalDateTime now = LocalDateTime.now(); + this.strictInsertFill(metaObject, CREATE_TIME, LocalDateTime.class, now); + this.strictInsertFill(metaObject, UPDATE_TIME, LocalDateTime.class, now); + } + + @Override + public void updateFill(MetaObject metaObject) { + this.strictUpdateFill(metaObject, UPDATE_TIME, LocalDateTime.class, LocalDateTime.now()); + } + } +} diff --git a/mokee-gateway-common/src/main/java/com/mokee/common/dto/request/LoginRequest.java b/mokee-gateway-common/src/main/java/com/mokee/common/dto/request/LoginRequest.java new file mode 100644 index 0000000..f606784 --- /dev/null +++ b/mokee-gateway-common/src/main/java/com/mokee/common/dto/request/LoginRequest.java @@ -0,0 +1,27 @@ +package com.mokee.common.dto.request; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import jakarta.validation.constraints.NotBlank; + +/** + * 登录请求 DTO + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class LoginRequest { + + /** 用户名 */ + @NotBlank(message = "用户名不能为空") + private String username; + + /** 密码 */ + @NotBlank(message = "密码不能为空") + private String password; + + /** 业务系统编码(可选,用于指定登录哪个系统) */ + private String systemCode; +} diff --git a/mokee-gateway-common/src/main/java/com/mokee/common/dto/response/LoginResponse.java b/mokee-gateway-common/src/main/java/com/mokee/common/dto/response/LoginResponse.java new file mode 100644 index 0000000..70b063b --- /dev/null +++ b/mokee-gateway-common/src/main/java/com/mokee/common/dto/response/LoginResponse.java @@ -0,0 +1,39 @@ +package com.mokee.common.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 登录响应 DTO + *

+ * 登录成功后返回 Token、用户信息、可切换系统列表等 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class LoginResponse { + + /** JWT Token */ + private String token; + + /** 用户信息 */ + private UserInfoVO userInfo; + + /** 可登录的业务系统列表 */ + private List systemList; + + /** 是否需要选择系统(多系统时需前端展示系统选择页) */ + private Boolean needSelectSystem; + + /** 当前登录的系统 */ + private SystemVO currentSystem; + + /** 是否为平台管理员(拥有 super_admin 角色),可进入后台管理 */ + private Boolean isAdmin; + + /** 按钮权限标识列表(如 system:user:add),前端 v-permission 指令使用 */ + private List permissions; +} diff --git a/mokee-gateway-common/src/main/java/com/mokee/common/dto/response/MenuMetaVO.java b/mokee-gateway-common/src/main/java/com/mokee/common/dto/response/MenuMetaVO.java new file mode 100644 index 0000000..59ef7c0 --- /dev/null +++ b/mokee-gateway-common/src/main/java/com/mokee/common/dto/response/MenuMetaVO.java @@ -0,0 +1,22 @@ +package com.mokee.common.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 菜单元数据 VO + *

+ * 路由的 meta 信息,用于前端渲染面包屑、图标等 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class MenuMetaVO { + + /** 菜单标题 */ + private String title; + + /** 菜单图标 */ + private String icon; +} diff --git a/mokee-gateway-common/src/main/java/com/mokee/common/dto/response/MenuRouterResponse.java b/mokee-gateway-common/src/main/java/com/mokee/common/dto/response/MenuRouterResponse.java new file mode 100644 index 0000000..54add54 --- /dev/null +++ b/mokee-gateway-common/src/main/java/com/mokee/common/dto/response/MenuRouterResponse.java @@ -0,0 +1,44 @@ +package com.mokee.common.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 菜单路由响应对象 + *

+ * 包含系统的菜单路由树和所有权限标识列表,供业务系统前端一次性获取 + * 完整的前端路由和权限控制所需数据。 + *

+ * 使用场景: + *

    + *
  • 业务系统前端初始化时调用,获取菜单结构用于渲染侧边栏
  • + *
  • 同时获取该系统所有权限标识,用于前端按钮级权限控制(v-permission)
  • + *
+ *

+ * 返回示例: + *

+ * {
+ *   "code": 200,
+ *   "data": {
+ *     "menu": [{ "name": "用户管理", "path": "/user", ... }],
+ *     "permission": ["system:user:list", "system:user:add", "system:user:edit", "system:user:delete"]
+ *   }
+ * }
+ * 
+ * + * @author mokee + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class MenuRouterResponse { + + /** 菜单路由树(前端侧边栏渲染所需的结构) */ + private List menu; + + /** 该系统的所有权限标识列表(用于前端 v-permission 指令控制按钮显隐) */ + private List permission; +} diff --git a/mokee-gateway-common/src/main/java/com/mokee/common/dto/response/MenuRouterVO.java b/mokee-gateway-common/src/main/java/com/mokee/common/dto/response/MenuRouterVO.java new file mode 100644 index 0000000..77783f9 --- /dev/null +++ b/mokee-gateway-common/src/main/java/com/mokee/common/dto/response/MenuRouterVO.java @@ -0,0 +1,120 @@ +package com.mokee.common.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 菜单路由 VO(视图对象) + *

+ * 前端动态路由的树形结构节点,用于构建侧边栏菜单和路由表。 + *

+ * 设计说明: + *

    + *
  • 这是 SysMenu 实体到前端路由的映射对象,去掉了数据库相关的字段(systemId、createdAt等), + * 只保留前端路由渲染所需的字段
  • + *
  • 通过 parentId 的递归关系构建树形结构:每个节点的 children 是其子路由列表
  • + *
  • 与 SysMenu 的关键区别:增加了 meta(MenuMetaVO)字段用于 Vue Router 的 meta 配置, + * 同时将 menuPath 映射为 path、menuName 映射为 name
  • + *
+ *

+ * 字段详细说明: + *

    + *
  • menuType:控制菜单渲染方式 + *
      + *
    • 1 = 目录(无路由路径,只作为分组折叠项)
    • + *
    • 2 = 菜单(有路由路径,可点击跳转)
    • + *
    • 3 = 按钮(权限控制点,通常不在菜单中显示)
    • + *
    + *
  • + *
  • sortOrder:控制同级菜单的显示顺序(数字越小越靠前)。 + * 在 MenuServiceImpl.buildMenuTree() 构建树时保持数据库的排序, + * 前端按此顺序渲染侧边栏菜单项
  • + *
  • status:控制菜单是否在前端显示 + *
      + *
    • 0 = 禁用/隐藏:该菜单不在侧边栏显示,路由也不注册
    • + *
    • 1 = 启用/显示:正常显示和路由注册
    • + *
    + * 当前 Service 层查询时不按 status 过滤,由前端根据此字段决定是否渲染
  • + *
  • children:子路由列表。叶子节点(无子菜单)时此字段为 null(非空列表), + * 前端据此判断是否显示展开箭头和子菜单区域
  • + *
  • permission:权限标识字符串(如 "system:user:list"),用于前端按钮级权限控制
  • + *
  • component:Vue 组件路径(如 "system/user/index"),前端动态 import 此路径的组件
  • + *
+ * + * @author mokee + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class MenuRouterVO { + + /** 菜单ID(对应 SysMenu.id) */ + private String id; + + /** 菜单显示名称 */ + private String menuName; + + /** + * 菜单类型:1=目录(无路由路径), 2=菜单(有路由路径), 3=按钮(权限控制点) + */ + private Integer menuType; + + /** 菜单图标(如 "el-icon-user") */ + private String icon; + + /** 权限标识字符串(如 "system:user:list"),用于前端按钮级权限控制 */ + private String permission; + + /** 前端路由路径(如 "/system/user"),对应 SysMenu.menuPath */ + private String path; + + /** 路由名称(兼容旧字段,与 menuName 相同) */ + private String name; + + /** Vue 组件路径(如 "system/user/index"),前端动态 import */ + private String component; + + /** + * 排序号(数字越小越靠前) + *

+ * 控制同级菜单在侧边栏中的显示顺序。 + * 数据库查询时已按 sortOrder 升序排列,构建树时保持该顺序。 + */ + private Integer sortOrder; + + /** + * 菜单状态:0=禁用/隐藏, 1=启用/显示 + *

+ * 前端根据此字段决定是否渲染该菜单路由: + *

    + *
  • 0 = 不注册路由,不在侧边栏显示
  • + *
  • 1 = 正常注册路由并显示
  • + *
+ * 当前 Service 层不按 status 过滤,所有菜单都返回,由前端自行过滤。 + */ + private Integer status; + + /** + * 菜单元数据(Vue Router meta 配置) + *

+ * 包含菜单名称(title)和图标(icon),用于前端路由的 meta 属性: + *

+     * {
+     *   path: '/system/user',
+     *   meta: { title: '用户管理', icon: 'el-icon-user' }
+     * }
+     * 
+ */ + private MenuMetaVO meta; + + /** + * 子路由列表 + *

+ * 叶子节点(无子菜单)时此字段为 null(非空列表), + * 前端据此判断是否显示展开箭头和渲染子菜单区域。 + */ + private List children; +} diff --git a/mokee-gateway-common/src/main/java/com/mokee/common/dto/response/RoleInfo.java b/mokee-gateway-common/src/main/java/com/mokee/common/dto/response/RoleInfo.java new file mode 100644 index 0000000..630879a --- /dev/null +++ b/mokee-gateway-common/src/main/java/com/mokee/common/dto/response/RoleInfo.java @@ -0,0 +1,29 @@ +package com.mokee.common.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +/** + * 角色信息 + *

+ * TokenInfo 内部使用的角色摘要,存储于 Redis 会话中 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class RoleInfo implements Serializable { + + private static final long serialVersionUID = 1L; + + /** 角色ID */ + private String roleId; + + /** 角色名称 */ + private String roleName; + + /** 角色编码 */ + private String roleCode; +} diff --git a/mokee-gateway-common/src/main/java/com/mokee/common/dto/response/SystemVO.java b/mokee-gateway-common/src/main/java/com/mokee/common/dto/response/SystemVO.java new file mode 100644 index 0000000..11ddb93 --- /dev/null +++ b/mokee-gateway-common/src/main/java/com/mokee/common/dto/response/SystemVO.java @@ -0,0 +1,34 @@ +package com.mokee.common.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 业务系统 VO + *

+ * 用户可登录的业务系统信息 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class SystemVO { + + /** 系统ID */ + private String systemId; + + /** 系统编码 */ + private String systemCode; + + /** 系统名称 */ + private String systemName; + + /** 前端入口地址 */ + private String frontUrl; + + /** 系统图标 */ + private String icon; + + /** 系统描述 */ + private String description; +} diff --git a/mokee-gateway-common/src/main/java/com/mokee/common/dto/response/TokenInfo.java b/mokee-gateway-common/src/main/java/com/mokee/common/dto/response/TokenInfo.java new file mode 100644 index 0000000..35a6c5b --- /dev/null +++ b/mokee-gateway-common/src/main/java/com/mokee/common/dto/response/TokenInfo.java @@ -0,0 +1,57 @@ +package com.mokee.common.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; +import java.util.List; + +/** + * Redis 会话对象 + *

+ * 存储在 Redis 中的用户登录会话信息,包含用户基础信息、当前系统及角色权限 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class TokenInfo implements Serializable { + + private static final long serialVersionUID = 1L; + + /** 用户ID */ + private String userId; + + /** 用户名 */ + private String username; + + /** 真实姓名 */ + private String realName; + + /** 邮箱 */ + private String email; + + /** 手机号 */ + private String phone; + + /** 头像地址 */ + private String avatar; + + /** 岗位 */ + private String post; + + /** 当前系统ID */ + private String currentSystemId; + + /** 当前系统编码 */ + private String currentSystemCode; + + /** 当前系统名称 */ + private String currentSystemName; + + /** 角色列表 */ + private List roles; + + /** 权限标识列表 */ + private List permissions; +} diff --git a/mokee-gateway-common/src/main/java/com/mokee/common/dto/response/UserInfoVO.java b/mokee-gateway-common/src/main/java/com/mokee/common/dto/response/UserInfoVO.java new file mode 100644 index 0000000..ddbcb4c --- /dev/null +++ b/mokee-gateway-common/src/main/java/com/mokee/common/dto/response/UserInfoVO.java @@ -0,0 +1,48 @@ +package com.mokee.common.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 用户信息 VO + *

+ * 用户基础信息,嵌入 LoginResponse 使用 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class UserInfoVO { + + /** 用户ID */ + private String userId; + + /** 用户名 */ + private String username; + + /** 真实姓名 */ + private String realName; + + /** 邮箱 */ + private String email; + + /** 手机号 */ + private String phone; + + /** 头像地址 */ + private String avatar; + + /** 岗位 */ + private String post; + + /** 创建时间 */ + private String createdAt; + + /** 最后登录时间 */ + private String lastLoginTime; + + /** 按钮权限列表 */ + private List permissions; +} diff --git a/mokee-gateway-common/src/main/java/com/mokee/common/entity/SysApi.java b/mokee-gateway-common/src/main/java/com/mokee/common/entity/SysApi.java new file mode 100644 index 0000000..0797628 --- /dev/null +++ b/mokee-gateway-common/src/main/java/com/mokee/common/entity/SysApi.java @@ -0,0 +1,63 @@ +package com.mokee.common.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 系统API实体 + * + * @author mokee + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@TableName("sys_api") +@Schema(description = "系统API") +public class SysApi { + + @TableId(value = "id", type = IdType.ASSIGN_UUID) + @Schema(description = "主键ID") + private String id; + + @TableField(value = "system_id") + @Schema(description = "系统ID") + private String systemId; + + @TableField(value = "api_path") + @Schema(description = "API路径") + private String apiPath; + + @TableField(value = "api_method") + @Schema(description = "请求方法(GET/POST/PUT/DELETE)") + private String apiMethod; + + @TableField(value = "api_name") + @Schema(description = "API名称") + private String apiName; + + @TableField(value = "api_desc") + @Schema(description = "API描述") + private String apiDesc; + + @TableField(value = "status") + @Schema(description = "状态(0停用 1启用)") + private Integer status; + + @TableField(value = "created_at") + @Schema(description = "创建时间") + private LocalDateTime createdAt; + + @TableField(value = "updated_at") + @Schema(description = "更新时间") + private LocalDateTime updatedAt; +} diff --git a/mokee-gateway-common/src/main/java/com/mokee/common/entity/SysConfig.java b/mokee-gateway-common/src/main/java/com/mokee/common/entity/SysConfig.java new file mode 100644 index 0000000..91bd0f5 --- /dev/null +++ b/mokee-gateway-common/src/main/java/com/mokee/common/entity/SysConfig.java @@ -0,0 +1,50 @@ +package com.mokee.common.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 系统配置实体 + *

+ * 键值对形式的系统级配置,如文件存储路径等。 + * 通过 ConfigService 内存缓存读取,避免每次请求查库。 + * + * @author mokee + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@TableName("sys_config") +@Schema(description = "系统配置") +public class SysConfig { + + @TableId(value = "id", type = IdType.ASSIGN_UUID) + @Schema(description = "主键ID") + private String id; + + @TableField("config_key") + @Schema(description = "配置键") + private String configKey; + + @TableField("config_value") + @Schema(description = "配置值") + private String configValue; + + @TableField("description") + @Schema(description = "配置说明") + private String description; + + @TableField("updated_at") + @Schema(description = "更新时间") + private LocalDateTime updatedAt; +} diff --git a/mokee-gateway-common/src/main/java/com/mokee/common/entity/SysDict.java b/mokee-gateway-common/src/main/java/com/mokee/common/entity/SysDict.java new file mode 100644 index 0000000..f6f52c0 --- /dev/null +++ b/mokee-gateway-common/src/main/java/com/mokee/common/entity/SysDict.java @@ -0,0 +1,59 @@ +package com.mokee.common.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 系统字典实体 + * + * @author mokee + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@TableName("sys_dict") +@Schema(description = "系统字典") +public class SysDict { + + @TableId(value = "id", type = IdType.ASSIGN_UUID) + @Schema(description = "主键ID") + private String id; + + @TableField(value = "dict_type") + @Schema(description = "字典类型") + private String dictType; + + @TableField(value = "dict_label") + @Schema(description = "字典标签") + private String dictLabel; + + @TableField(value = "dict_value") + @Schema(description = "字典值") + private String dictValue; + + @TableField(value = "sort_order") + @Schema(description = "排序号") + private Integer sortOrder; + + @TableField(value = "status") + @Schema(description = "状态(0停用 1启用)") + private Integer status; + + @TableField(value = "created_at") + @Schema(description = "创建时间") + private LocalDateTime createdAt; + + @TableField(value = "updated_at") + @Schema(description = "更新时间") + private LocalDateTime updatedAt; +} diff --git a/mokee-gateway-common/src/main/java/com/mokee/common/entity/SysEmailLog.java b/mokee-gateway-common/src/main/java/com/mokee/common/entity/SysEmailLog.java new file mode 100644 index 0000000..2a68320 --- /dev/null +++ b/mokee-gateway-common/src/main/java/com/mokee/common/entity/SysEmailLog.java @@ -0,0 +1,37 @@ +package com.mokee.common.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import java.time.LocalDateTime; + +/** 邮件发送记录 */ +@Data @NoArgsConstructor @AllArgsConstructor @Builder +@TableName("sys_email_log") +public class SysEmailLog { + @TableId(value = "id", type = IdType.ASSIGN_UUID) + private String id; + + @TableField("to_address") + private String toAddress; + + @TableField("subject") + private String subject; + + @TableField("content") + private String content; + + @TableField("content_type") + private String contentType; + + @TableField("status") + private Integer status; + + @TableField("error_msg") + private String errorMsg; + + @TableField("created_at") + private LocalDateTime createdAt; +} diff --git a/mokee-gateway-common/src/main/java/com/mokee/common/entity/SysFile.java b/mokee-gateway-common/src/main/java/com/mokee/common/entity/SysFile.java new file mode 100644 index 0000000..76c8a65 --- /dev/null +++ b/mokee-gateway-common/src/main/java/com/mokee/common/entity/SysFile.java @@ -0,0 +1,67 @@ +package com.mokee.common.entity; + +import com.baomidou.mybatisplus.annotation.*; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import java.time.LocalDateTime; + +/** + * 系统文件实体 + *

+ * 文件二进制数据存储于本地磁盘,数据库仅保存元信息。 + */ +@Data @NoArgsConstructor @AllArgsConstructor @Builder +@TableName("sys_file") +@Schema(description = "系统文件") +public class SysFile { + @TableId(value = "id", type = IdType.ASSIGN_UUID) + @Schema(description = "文件ID") + private String id; + + @TableField("system_id") + @Schema(description = "所属系统ID") + private String systemId; + + @TableField("file_name") + @Schema(description = "原始文件名") + private String fileName; + + @TableField("storage_file_name") + @Schema(description = "磁盘存储的实际文件名(如 uuid.png)") + private String storageFileName; + + @TableField("file_type") + @Schema(description = "文件类型(icon/avatar/doc/other)") + private String fileType; + + @TableField("file_size") + @Schema(description = "文件大小(字节)") + private Long fileSize; + + @TableField("mime_type") + @Schema(description = "MIME类型") + private String mimeType; + + @TableField("md5") + @Schema(description = "文件MD5摘要,用于去重校验") + private String md5; + + @TableField("upload_user_id") + @Schema(description = "上传用户ID") + private String uploadUserId; + + @TableField("upload_user_name") + @Schema(description = "上传者显示名(用户姓名或系统名称)") + private String uploadUserName; + + @TableField("file_data") + @Schema(description = "文件二进制数据(LONGBLOB) — 存量兼容,新文件为null") + private byte[] fileData; + + @TableField("created_at") + @Schema(description = "上传时间") + private LocalDateTime createdAt; +} diff --git a/mokee-gateway-common/src/main/java/com/mokee/common/entity/SysLoginLog.java b/mokee-gateway-common/src/main/java/com/mokee/common/entity/SysLoginLog.java new file mode 100644 index 0000000..2617ad5 --- /dev/null +++ b/mokee-gateway-common/src/main/java/com/mokee/common/entity/SysLoginLog.java @@ -0,0 +1,120 @@ +package com.mokee.common.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 登录日志实体 + *

+ * 对应数据库表 {@code sys_login_log},记录每次用户登录的详细信息。 + *

+ * 数据写入方:网关的登录过滤器在用户登录成功/失败时插入记录。 + * 数据读取方:LogController → LogServiceImpl 分页查询,用于后台审计和统计分析。 + *

+ * 字段说明: + *

    + *
  • status:1=登录成功, 0=登录失败。DashboardController 据此统计今日登录人数和成功率
  • + *
  • failReason:登录失败时记录具体原因(如 "密码错误"、"账号被锁定" 等)
  • + *
  • systemId:记录用户在哪个业务系统登录的
  • + *
  • systemName非数据库字段(@TableField(exist=false)),查询时由 Service 层 + * 通过 systemId 联表查询 sys_system 表后填充,用于前端直接展示系统名称
  • + *
  • browser / os:通过解析 User-Agent 请求头获得,用于安全审计(识别异常设备登录)
  • + *
+ *

+ * 统计分析用途: + *

    + *
  • DashboardController 统计今日登录人数(去重 user_id)和登录总次数
  • + *
  • DashboardController 统计最近 30 分钟在线用户数
  • + *
  • DashboardController 统计过去 30 天每日登录趋势(折线图数据)
  • + *
+ * + * @author mokee + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@TableName("sys_login_log") +@Schema(description = "登录日志") +public class SysLoginLog { + + /** 主键ID,使用 UUID 自动生成 */ + @TableId(value = "id", type = IdType.ASSIGN_UUID) + @Schema(description = "主键ID") + private String id; + + /** 用户ID,关联 sys_user 表 */ + @TableField(value = "user_id") + @Schema(description = "用户ID") + private String userId; + + /** 登录用户名 */ + @TableField(value = "username") + @Schema(description = "用户名") + private String username; + + /** 用户真实姓名 */ + @TableField(value = "real_name") + @Schema(description = "用户姓名") + private String realName; + + /** 登录所在系统ID,关联 sys_system 表 */ + @TableField(value = "system_id") + @Schema(description = "系统ID") + private String systemId; + + /** 客户端IP地址 */ + @TableField(value = "login_ip") + @Schema(description = "登录IP") + private String loginIp; + + /** 原始 User-Agent 请求头 */ + @TableField(value = "user_agent") + @Schema(description = "用户代理") + private String userAgent; + + /** 浏览器类型(解析 User-Agent 获得,如 Chrome、Firefox) */ + @TableField(value = "browser") + @Schema(description = "浏览器") + private String browser; + + /** 操作系统类型(解析 User-Agent 获得,如 Windows 10、macOS) */ + @TableField(value = "os") + @Schema(description = "操作系统") + private String os; + + /** 登录状态:0=失败, 1=成功 */ + @TableField(value = "status") + @Schema(description = "登录状态(0失败 1成功)") + private Integer status; + + /** 登录失败时的具体原因(成功时为 null) */ + @TableField(value = "fail_reason") + @Schema(description = "失败原因") + private String failReason; + + /** 记录创建时间(即登录时间) */ + @TableField(value = "created_at") + @Schema(description = "创建时间") + private LocalDateTime createdAt; + + /** + * 系统名称(查询时联表填充,非数据库字段) + *

+ * {@code @TableField(exist = false)} 表示此字段不对应数据库列。 + * 在 LogServiceImpl.loginLogPage() 中,通过 systemId 查询 sys_system 表 + * 获取 system_name 后填充此字段,供前端直接展示系统名称而非系统ID。 + */ + @TableField(exist = false) + @Schema(description = "系统名称") + private String systemName; +} diff --git a/mokee-gateway-common/src/main/java/com/mokee/common/entity/SysMenu.java b/mokee-gateway-common/src/main/java/com/mokee/common/entity/SysMenu.java new file mode 100644 index 0000000..701d15a --- /dev/null +++ b/mokee-gateway-common/src/main/java/com/mokee/common/entity/SysMenu.java @@ -0,0 +1,79 @@ +package com.mokee.common.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 系统菜单实体 + * + * @author mokee + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@TableName("sys_menu") +@Schema(description = "系统菜单") +public class SysMenu { + + @TableId(value = "id", type = IdType.ASSIGN_UUID) + @Schema(description = "主键ID") + private String id; + + @TableField(value = "system_id") + @Schema(description = "系统ID") + private String systemId; + + @TableField(value = "parent_id") + @Schema(description = "父菜单ID") + private String parentId; + + @TableField(value = "menu_name") + @Schema(description = "菜单名称") + private String menuName; + + @TableField(value = "menu_path") + @Schema(description = "菜单路径") + private String menuPath; + + @TableField(value = "component") + @Schema(description = "前端组件路径") + private String component; + + @TableField(value = "icon") + @Schema(description = "图标") + private String icon; + + @TableField(value = "menu_type") + @Schema(description = "菜单类型(0目录 1菜单 2按钮)") + private Integer menuType; + + @TableField(value = "permission") + @Schema(description = "权限标识") + private String permission; + + @TableField(value = "sort_order") + @Schema(description = "排序号") + private Integer sortOrder; + + @TableField(value = "status") + @Schema(description = "状态(0停用 1启用)") + private Integer status; + + @TableField(value = "created_at") + @Schema(description = "创建时间") + private LocalDateTime createdAt; + + @TableField(value = "updated_at") + @Schema(description = "更新时间") + private LocalDateTime updatedAt; +} diff --git a/mokee-gateway-common/src/main/java/com/mokee/common/entity/SysOpenTokenLog.java b/mokee-gateway-common/src/main/java/com/mokee/common/entity/SysOpenTokenLog.java new file mode 100644 index 0000000..8a9b14c --- /dev/null +++ b/mokee-gateway-common/src/main/java/com/mokee/common/entity/SysOpenTokenLog.java @@ -0,0 +1,43 @@ +package com.mokee.common.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import java.time.LocalDateTime; + +/** 开放API Token获取/消费日志 */ +@Data @NoArgsConstructor @AllArgsConstructor @Builder +@TableName("sys_open_token_log") +public class SysOpenTokenLog { + @TableId(value = "id", type = IdType.ASSIGN_UUID) + private String id; + + @TableField("system_id") + private String systemId; + + @TableField("system_code") + private String systemCode; + + @TableField("token_id") + private String tokenId; + + @TableField("token_preview") + private String tokenPreview; + + @TableField("acquired_at") + private LocalDateTime acquiredAt; + + @TableField("consumed") + private Integer consumed; + + @TableField("consumed_at") + private LocalDateTime consumedAt; + + @TableField("consume_api") + private String consumeApi; + + @TableField("consume_ip") + private String consumeIp; +} diff --git a/mokee-gateway-common/src/main/java/com/mokee/common/entity/SysOperationLog.java b/mokee-gateway-common/src/main/java/com/mokee/common/entity/SysOperationLog.java new file mode 100644 index 0000000..a2deae2 --- /dev/null +++ b/mokee-gateway-common/src/main/java/com/mokee/common/entity/SysOperationLog.java @@ -0,0 +1,83 @@ +package com.mokee.common.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 操作日志实体 + * + * @author mokee + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@TableName("sys_operation_log") +@Schema(description = "操作日志") +public class SysOperationLog { + + @TableId(value = "id", type = IdType.ASSIGN_UUID) + @Schema(description = "主键ID") + private String id; + + @TableField(value = "user_id") + @Schema(description = "用户ID") + private String userId; + + @TableField(value = "username") + @Schema(description = "用户名") + private String username; + + @TableField(value = "system_id") + @Schema(description = "系统ID") + private String systemId; + + @TableField(value = "module") + @Schema(description = "操作模块") + private String module; + + @TableField(value = "action") + @Schema(description = "操作动作") + private String action; + + @TableField(value = "request_url") + @Schema(description = "请求地址") + private String requestUrl; + + @TableField(value = "request_method") + @Schema(description = "请求方法") + private String requestMethod; + + @TableField(value = "request_body") + @Schema(description = "请求体") + private String requestBody; + + @TableField(value = "response_status") + @Schema(description = "响应状态码") + private Integer responseStatus; + + @TableField(value = "error_msg") + @Schema(description = "错误信息") + private String errorMsg; + + @TableField(value = "response_body") + @Schema(description = "响应体(截断至2000字符)") + private String responseBody; + + @TableField(value = "cost_time") + @Schema(description = "耗时(毫秒)") + private Integer costTime; + + @TableField(value = "created_at") + @Schema(description = "创建时间") + private LocalDateTime createdAt; +} diff --git a/mokee-gateway-common/src/main/java/com/mokee/common/entity/SysRole.java b/mokee-gateway-common/src/main/java/com/mokee/common/entity/SysRole.java new file mode 100644 index 0000000..e30842c --- /dev/null +++ b/mokee-gateway-common/src/main/java/com/mokee/common/entity/SysRole.java @@ -0,0 +1,124 @@ +package com.mokee.common.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 系统角色实体 + *

+ * 对应数据库表 {@code sys_role},定义每个业务系统中的角色。 + *

+ * 设计说明: + *

    + *
  • 角色隶属于具体的业务系统(通过 systemId 关联),不同系统的角色相互隔离
  • + *
  • role_code 是角色的唯一编码标识,用于权限判断。如 "super_admin"、"admin"、"user"
  • + *
  • 权限判断逻辑(SystemController.listAll())通过检查 role_code == "super_admin" 来决定是否返回全部系统
  • + *
  • 角色通过 sys_role_menu 中间表关联菜单,实现 RBAC 权限模型
  • + *
  • 角色通过 sys_user_role 中间表关联用户
  • + *
  • 使用 MyBatis-Plus 逻辑删除(@TableLogic),删除时 is_deleted 标记为 1
  • + *
+ *

+ * 关键字段: + *

    + *
  • systemName非数据库字段(@TableField(exist=false)),查询时由 RoleServiceImpl + * 通过 systemId 联表查询 sys_system 表后填充,用于前端直接展示所属系统名称
  • + *
  • is_deleted:逻辑删除标记,使用 @TableLogic 注解实现自动过滤
  • + *
+ * + * @author mokee + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@TableName("sys_role") +@Schema(description = "系统角色") +public class SysRole { + + /** 主键ID,使用 UUID 自动生成 */ + @TableId(value = "id", type = IdType.ASSIGN_UUID) + @Schema(description = "主键ID") + private String id; + + /** 所属系统ID,关联 sys_system 表 */ + @TableField(value = "system_id") + @Schema(description = "系统ID") + private String systemId; + + /** 角色名称(显示用,如 "超级管理员") */ + @TableField(value = "role_name") + @Schema(description = "角色名称") + private String roleName; + + /** 角色编码(权限判断用,如 "super_admin"、"admin"、"user") */ + @TableField(value = "role_code") + @Schema(description = "角色编码") + private String roleCode; + + /** 角色描述 */ + @TableField(value = "description") + @Schema(description = "描述") + private String description; + + /** 状态:0=停用, 1=启用 */ + @TableField(value = "status") + @Schema(description = "状态(0停用 1启用)") + private Integer status; + + /** 创建人ID */ + @TableField(value = "created_by") + @Schema(description = "创建人ID") + private String createdBy; + + /** 更新人ID */ + @TableField(value = "updated_by") + @Schema(description = "更新人ID") + private String updatedBy; + + /** 创建时间 */ + @TableField(value = "created_at") + @Schema(description = "创建时间") + private LocalDateTime createdAt; + + /** 更新时间 */ + @TableField(value = "updated_at") + @Schema(description = "更新时间") + private LocalDateTime updatedAt; + + /** + * 逻辑删除标记 + *

+ * 使用 MyBatis-Plus @TableLogic 注解: + *

    + *
  • value="0":正常记录
  • + *
  • delval="1":已删除
  • + *
  • 自动过滤:select 时自动添加 WHERE is_deleted=0
  • + *
  • 逻辑删除:deleteById 时执行 UPDATE is_deleted=1 而非物理 DELETE
  • + *
+ */ + @TableField(value = "is_deleted") + @TableLogic(value = "0", delval = "1") + @Schema(description = "逻辑删除(0未删除 1已删除)") + private Integer isDeleted; + + /** + * 所属系统名称(查询时联表填充,非数据库字段) + *

+ * {@code @TableField(exist = false)} 表示此字段不对应数据库列。 + * 在 RoleServiceImpl.page() 中,通过 systemId 查询 sys_system 表 + * 获取 system_name 后填充此字段,供前端直接展示系统名称而非系统ID。 + */ + @TableField(exist = false) + @Schema(description = "所属系统名称") + private String systemName; +} diff --git a/mokee-gateway-common/src/main/java/com/mokee/common/entity/SysRoleMenu.java b/mokee-gateway-common/src/main/java/com/mokee/common/entity/SysRoleMenu.java new file mode 100644 index 0000000..b0e8889 --- /dev/null +++ b/mokee-gateway-common/src/main/java/com/mokee/common/entity/SysRoleMenu.java @@ -0,0 +1,43 @@ +package com.mokee.common.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 角色菜单关联实体 + * + * @author mokee + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@TableName("sys_role_menu") +@Schema(description = "角色菜单关联") +public class SysRoleMenu { + + @TableId(value = "id", type = IdType.ASSIGN_UUID) + @Schema(description = "主键ID") + private String id; + + @TableField(value = "role_id") + @Schema(description = "角色ID") + private String roleId; + + @TableField(value = "menu_id") + @Schema(description = "菜单ID") + private String menuId; + + @TableField(value = "created_at") + @Schema(description = "创建时间") + private LocalDateTime createdAt; +} diff --git a/mokee-gateway-common/src/main/java/com/mokee/common/entity/SysSystem.java b/mokee-gateway-common/src/main/java/com/mokee/common/entity/SysSystem.java new file mode 100644 index 0000000..7b1189a --- /dev/null +++ b/mokee-gateway-common/src/main/java/com/mokee/common/entity/SysSystem.java @@ -0,0 +1,93 @@ +package com.mokee.common.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 系统信息实体 + * + * @author mokee + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@TableName("sys_system") +@Schema(description = "系统信息") +public class SysSystem { + + @TableId(value = "id", type = IdType.ASSIGN_UUID) + @Schema(description = "主键ID") + private String id; + + @TableField(value = "system_code") + @Schema(description = "系统编码") + private String systemCode; + + @TableField(value = "system_name") + @Schema(description = "系统名称") + private String systemName; + + @TableField(value = "front_url") + @Schema(description = "前端地址") + private String frontUrl; + + @TableField(value = "backend_url") + @Schema(description = "后端地址") + private String backendUrl; + + @TableField(value = "gateway_url") + @Schema(description = "网关地址(已废弃,前端使用全局配置)") + private String gatewayUrl; + + @TableField(value = "icon") + @Schema(description = "图标") + private String icon; + + @TableField(value = "sort_order") + @Schema(description = "排序号") + private Integer sortOrder; + + @TableField(value = "status") + @Schema(description = "状态(0停用 1启用)") + private Integer status; + + @TableField(value = "description") + @Schema(description = "描述") + private String description; + + @TableField(value = "doc_token") + @Schema(description = "文档访问令牌(UUID)") + private String docToken; + + @TableField(value = "created_by") + @Schema(description = "创建人ID") + private String createdBy; + + @TableField(value = "updated_by") + @Schema(description = "更新人ID") + private String updatedBy; + + @TableField(value = "created_at") + @Schema(description = "创建时间") + private LocalDateTime createdAt; + + @TableField(value = "updated_at") + @Schema(description = "更新时间") + private LocalDateTime updatedAt; + + @TableField(value = "is_deleted") + @TableLogic(value = "0", delval = "1") + @Schema(description = "逻辑删除(0未删除 1已删除)") + private Integer isDeleted; +} diff --git a/mokee-gateway-common/src/main/java/com/mokee/common/entity/SysUser.java b/mokee-gateway-common/src/main/java/com/mokee/common/entity/SysUser.java new file mode 100644 index 0000000..7170081 --- /dev/null +++ b/mokee-gateway-common/src/main/java/com/mokee/common/entity/SysUser.java @@ -0,0 +1,93 @@ +package com.mokee.common.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 系统用户实体 + * + * @author mokee + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@TableName("sys_user") +@Schema(description = "系统用户") +public class SysUser { + + @TableId(value = "id", type = IdType.ASSIGN_UUID) + @Schema(description = "主键ID") + private String id; + + @TableField(value = "username") + @Schema(description = "用户名") + private String username; + + @TableField(value = "password") + @Schema(description = "密码") + private String password; + + @TableField(value = "real_name") + @Schema(description = "真实姓名") + private String realName; + + @TableField(value = "email") + @Schema(description = "邮箱") + private String email; + + @TableField(value = "phone") + @Schema(description = "手机号") + private String phone; + + @TableField(value = "avatar") + @Schema(description = "头像") + private String avatar; + + @TableField(value = "post") + @Schema(description = "岗位") + private String post; + + @TableField(value = "status") + @Schema(description = "状态(0停用 1启用)") + private Integer status; + + @TableField(value = "lock_until") + @Schema(description = "锁定截止时间") + private LocalDateTime lockUntil; + + @TableField(value = "login_fail_count") + @Schema(description = "登录失败次数") + private Integer loginFailCount; + + @TableField(value = "last_login_ip") + @Schema(description = "最后登录IP") + private String lastLoginIp; + + @TableField(value = "last_login_time") + @Schema(description = "最后登录时间") + private LocalDateTime lastLoginTime; + + @TableField(value = "created_at") + @Schema(description = "创建时间") + private LocalDateTime createdAt; + + @TableField(value = "updated_at") + @Schema(description = "更新时间") + private LocalDateTime updatedAt; + + @TableField(value = "is_deleted") + @TableLogic(value = "0", delval = "1") + @Schema(description = "逻辑删除(0未删除 1已删除)") + private Integer isDeleted; +} diff --git a/mokee-gateway-common/src/main/java/com/mokee/common/entity/SysUserRole.java b/mokee-gateway-common/src/main/java/com/mokee/common/entity/SysUserRole.java new file mode 100644 index 0000000..3da39d7 --- /dev/null +++ b/mokee-gateway-common/src/main/java/com/mokee/common/entity/SysUserRole.java @@ -0,0 +1,43 @@ +package com.mokee.common.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 用户角色关联实体 + * + * @author mokee + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@TableName("sys_user_role") +@Schema(description = "用户角色关联") +public class SysUserRole { + + @TableId(value = "id", type = IdType.ASSIGN_UUID) + @Schema(description = "主键ID") + private String id; + + @TableField(value = "user_id") + @Schema(description = "用户ID") + private String userId; + + @TableField(value = "role_id") + @Schema(description = "角色ID") + private String roleId; + + @TableField(value = "created_at") + @Schema(description = "创建时间") + private LocalDateTime createdAt; +} diff --git a/mokee-gateway-common/src/main/java/com/mokee/common/entity/SysUserSystem.java b/mokee-gateway-common/src/main/java/com/mokee/common/entity/SysUserSystem.java new file mode 100644 index 0000000..b2c0140 --- /dev/null +++ b/mokee-gateway-common/src/main/java/com/mokee/common/entity/SysUserSystem.java @@ -0,0 +1,43 @@ +package com.mokee.common.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 用户系统关联实体 + * + * @author mokee + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@TableName("sys_user_system") +@Schema(description = "用户系统关联") +public class SysUserSystem { + + @TableId(value = "id", type = IdType.ASSIGN_UUID) + @Schema(description = "主键ID") + private String id; + + @TableField(value = "user_id") + @Schema(description = "用户ID") + private String userId; + + @TableField(value = "system_id") + @Schema(description = "系统ID") + private String systemId; + + @TableField(value = "created_at") + @Schema(description = "创建时间") + private LocalDateTime createdAt; +} diff --git a/mokee-gateway-common/src/main/java/com/mokee/common/enums/ActionEnum.java b/mokee-gateway-common/src/main/java/com/mokee/common/enums/ActionEnum.java new file mode 100644 index 0000000..a0a7afa --- /dev/null +++ b/mokee-gateway-common/src/main/java/com/mokee/common/enums/ActionEnum.java @@ -0,0 +1,42 @@ +package com.mokee.common.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 操作类型枚举 + *

+ * 用于操作日志记录,标识用户执行的业务动作 + */ +@Getter +@AllArgsConstructor +public enum ActionEnum { + + /** 新增 */ + CREATE("CREATE", "新增"), + + /** 编辑 */ + UPDATE("UPDATE", "编辑"), + + /** 删除 */ + DELETE("DELETE", "删除"), + + /** 查询 */ + QUERY("QUERY", "查询"), + + /** 导入 */ + IMPORT("IMPORT", "导入"), + + /** 导出 */ + EXPORT("EXPORT", "导出"), + + /** 登录 */ + LOGIN("LOGIN", "登录"), + + /** 登出 */ + LOGOUT("LOGOUT", "登出"); + + private final String code; + + private final String desc; +} diff --git a/mokee-gateway-common/src/main/java/com/mokee/common/enums/MenuTypeEnum.java b/mokee-gateway-common/src/main/java/com/mokee/common/enums/MenuTypeEnum.java new file mode 100644 index 0000000..b5f056a --- /dev/null +++ b/mokee-gateway-common/src/main/java/com/mokee/common/enums/MenuTypeEnum.java @@ -0,0 +1,27 @@ +package com.mokee.common.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 菜单类型枚举 + *

+ * 区分前端路由树的节点类型 + */ +@Getter +@AllArgsConstructor +public enum MenuTypeEnum { + + /** 目录 */ + CATALOG(1, "目录"), + + /** 菜单 */ + MENU(2, "菜单"), + + /** 按钮 */ + BUTTON(3, "按钮"); + + private final int code; + + private final String desc; +} diff --git a/mokee-gateway-common/src/main/java/com/mokee/common/enums/StatusEnum.java b/mokee-gateway-common/src/main/java/com/mokee/common/enums/StatusEnum.java new file mode 100644 index 0000000..8adb60a --- /dev/null +++ b/mokee-gateway-common/src/main/java/com/mokee/common/enums/StatusEnum.java @@ -0,0 +1,42 @@ +package com.mokee.common.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 通用状态枚举 + *

+ * 用于用户、系统、角色等实体的状态标记 + */ +@Getter +@AllArgsConstructor +public enum StatusEnum { + + /** 禁用 */ + DISABLED(0, "禁用"), + + /** 启用 */ + ENABLED(1, "启用"), + + /** 锁定(仅用户) */ + LOCKED(2, "锁定"); + + private final int code; + + private final String desc; + + /** + * 根据状态码获取对应枚举值 + * + * @param code 状态码 + * @return 对应的枚举值,未匹配时返回 null + */ + public static StatusEnum getByCode(int code) { + for (StatusEnum status : values()) { + if (status.getCode() == code) { + return status; + } + } + return null; + } +} diff --git a/mokee-gateway-common/src/main/java/com/mokee/common/exception/BusinessException.java b/mokee-gateway-common/src/main/java/com/mokee/common/exception/BusinessException.java new file mode 100644 index 0000000..e59975d --- /dev/null +++ b/mokee-gateway-common/src/main/java/com/mokee/common/exception/BusinessException.java @@ -0,0 +1,37 @@ +package com.mokee.common.exception; + +/** + * 业务异常 + *

+ * 统一业务异常类,携带错误码。由 GlobalExceptionHandler 统一拦截处理 + */ +public class BusinessException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + /** 错误码 */ + private int code; + + public BusinessException(int code, String message) { + super(message); + this.code = code; + } + + public BusinessException(String message) { + super(message); + this.code = 500; + } + + public BusinessException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.code = errorCode.getCode(); + } + + public int getCode() { + return code; + } + + public void setCode(int code) { + this.code = code; + } +} diff --git a/mokee-gateway-common/src/main/java/com/mokee/common/exception/ErrorCode.java b/mokee-gateway-common/src/main/java/com/mokee/common/exception/ErrorCode.java new file mode 100644 index 0000000..de4bbaa --- /dev/null +++ b/mokee-gateway-common/src/main/java/com/mokee/common/exception/ErrorCode.java @@ -0,0 +1,56 @@ +package com.mokee.common.exception; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 错误码枚举 + *

+ * 定义业务异常的统一错误码及对应描述,覆盖认证、授权、参数校验、服务端异常等场景 + */ +@Getter +@AllArgsConstructor +public enum ErrorCode { + + /** Token 已过期,请重新登录 */ + TOKEN_EXPIRED(401, "Token已过期,请重新登录"), + + /** Token 无效 */ + TOKEN_INVALID(401, "Token无效"), + + /** 未授权访问 */ + UNAUTHORIZED(401, "未授权访问"), + + /** 接口无访问权限 */ + FORBIDDEN(403, "接口无访问权限"), + + /** 资源不存在 */ + NOT_FOUND(404, "资源不存在"), + + /** 操作太频繁,请稍后再试 */ + RATE_LIMITED(429, "操作太频繁,请稍后再试"), + + /** 账号已被锁定,请稍后重试 */ + ACCOUNT_LOCKED(423, "账号已被锁定,请稍后重试"), + + /** 账号或密码错误 */ + USERNAME_PASSWORD_ERROR(400, "账号或密码错误"), + + /** 账号已被禁用 */ + USER_DISABLED(400, "账号已被禁用"), + + /** 业务系统已被禁用 */ + SYSTEM_DISABLED(400, "业务系统已被禁用"), + + /** 参数错误 */ + PARAM_ERROR(400, "参数错误"), + + /** 服务器内部错误 */ + INTERNAL_ERROR(500, "服务器内部错误"); + + /** 错误码 */ + private final int code; + + /** 错误描述 */ + private final String message; +} diff --git a/mokee-gateway-common/src/main/java/com/mokee/common/exception/GlobalExceptionHandler.java b/mokee-gateway-common/src/main/java/com/mokee/common/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..cad47f5 --- /dev/null +++ b/mokee-gateway-common/src/main/java/com/mokee/common/exception/GlobalExceptionHandler.java @@ -0,0 +1,49 @@ +package com.mokee.common.exception; + +import com.mokee.common.Result; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.stream.Collectors; + +/** + * 全局异常处理器 + *

+ * 统一拦截 Controller 层抛出的异常,封装为 Result 返回 + */ +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + /** + * 处理业务异常 + */ + @ExceptionHandler(BusinessException.class) + public Result handleBusinessException(BusinessException e) { + log.warn("业务异常 - code: {}, message: {}", e.getCode(), e.getMessage()); + return Result.fail(e.getCode(), e.getMessage()); + } + + /** + * 处理参数校验异常(@Valid 校验失败) + */ + @ExceptionHandler(MethodArgumentNotValidException.class) + public Result handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { + String errorMsg = e.getBindingResult().getFieldErrors().stream() + .map(fieldError -> fieldError.getField() + ": " + fieldError.getDefaultMessage()) + .collect(Collectors.joining("; ")); + log.warn("参数校验失败 - {}", errorMsg); + return Result.fail(400, errorMsg); + } + + /** + * 处理未预期的异常(兜底) + */ + @ExceptionHandler(Exception.class) + public Result handleException(Exception e) { + log.error("服务器内部错误", e); + return Result.fail("服务器内部错误"); + } +} diff --git a/mokee-gateway-common/src/main/java/com/mokee/common/service/EmailService.java b/mokee-gateway-common/src/main/java/com/mokee/common/service/EmailService.java new file mode 100644 index 0000000..d16dde4 --- /dev/null +++ b/mokee-gateway-common/src/main/java/com/mokee/common/service/EmailService.java @@ -0,0 +1,82 @@ +package com.mokee.common.service; + +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Service; + +/** + * 邮件服务 — 自动添加环境前缀 [QA]/[PROD] + */ +@Slf4j +@Service +public class EmailService { + + @Autowired(required = false) + private JavaMailSender mailSender; + + @Value("${spring.mail.username:}") + private String from; + + @Value("${app.env-prefix:[QA]}") + private String envPrefix; + + private JavaMailSender requireMailSender() { + if (mailSender == null) { + throw new IllegalStateException("邮件服务未配置(缺少 spring.mail 配置)"); + } + return mailSender; + } + + /** 发送纯文本邮件 */ + public void sendText(String to, String subject, String text) { + SimpleMailMessage msg = new SimpleMailMessage(); + msg.setFrom(from); + msg.setTo(to); + msg.setSubject(envPrefix + " " + subject); + msg.setText(text); + requireMailSender().send(msg); + log.info("邮件已发送(纯文本) -> to: {}, subject: {}", to, subject); + } + + /** 发送 HTML 邮件 */ + public void sendHtml(String to, String subject, String html) { + try { + MimeMessage mimeMsg = requireMailSender().createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(mimeMsg, true, "UTF-8"); + helper.setFrom(from); + helper.setTo(to); + helper.setSubject(envPrefix + " " + subject); + helper.setText(html, true); + requireMailSender().send(mimeMsg); + log.info("邮件已发送(HTML) -> from: {}, to: {}, subject: {}", from, to, subject); + } catch (MessagingException e) { + log.error("邮件发送失败 -> to: {}, subject: {}, error: {}", to, subject, e.getMessage()); + throw new RuntimeException("邮件发送失败: " + e.getMessage()); + } + } + + /** 发送带附件的 HTML 邮件 */ + public void sendHtmlWithAttachment(String to, String subject, String html, + String attachmentName, byte[] attachmentData, String mimeType) { + try { + MimeMessage mimeMsg = requireMailSender().createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(mimeMsg, true, "UTF-8"); + helper.setFrom(from); + helper.setTo(to); + helper.setSubject(envPrefix + " " + subject); + helper.setText(html, true); + helper.addAttachment(attachmentName, () -> new java.io.ByteArrayInputStream(attachmentData), mimeType); + requireMailSender().send(mimeMsg); + log.info("邮件已发送(带附件) -> to: {}, subject: {}, attachment: {}", to, subject, attachmentName); + } catch (MessagingException e) { + log.error("邮件发送失败 -> to: {}, subject: {}, error: {}", to, subject, e.getMessage()); + throw new RuntimeException("邮件发送失败: " + e.getMessage()); + } + } +} diff --git a/mokee-gateway-common/src/main/java/com/mokee/common/utils/IpUtil.java b/mokee-gateway-common/src/main/java/com/mokee/common/utils/IpUtil.java new file mode 100644 index 0000000..7f9f3fd --- /dev/null +++ b/mokee-gateway-common/src/main/java/com/mokee/common/utils/IpUtil.java @@ -0,0 +1,54 @@ +package com.mokee.common.utils; + +import lombok.extern.slf4j.Slf4j; + +import jakarta.servlet.http.HttpServletRequest; + +/** + * IP 工具类 + *

+ * 从 HttpServletRequest 中提取客户端真实 IP,依次尝试常用代理头 + */ +@Slf4j +public class IpUtil { + + private static final String UNKNOWN = "unknown"; + + /** + * 获取客户端真实 IP + *

+ * 依次从 X-Forwarded-For, X-Real-IP, Proxy-Client-IP, WL-Proxy-Client-IP 头中获取, + * 多层代理时 X-Forwarded-For 取第一个 IP。均获取不到时回退到 getRemoteAddr() + * + * @param request HTTP 请求 + * @return 客户端 IP 地址 + */ + public static String getClientIp(HttpServletRequest request) { + String ip = request.getHeader("X-Forwarded-For"); + if (ip != null && !ip.isEmpty() && !UNKNOWN.equalsIgnoreCase(ip)) { + // 多次反向代理后会有多个 IP,取第一个 + int index = ip.indexOf(','); + if (index != -1) { + ip = ip.substring(0, index).trim(); + } + return ip; + } + + ip = request.getHeader("X-Real-IP"); + if (ip != null && !ip.isEmpty() && !UNKNOWN.equalsIgnoreCase(ip)) { + return ip; + } + + ip = request.getHeader("Proxy-Client-IP"); + if (ip != null && !ip.isEmpty() && !UNKNOWN.equalsIgnoreCase(ip)) { + return ip; + } + + ip = request.getHeader("WL-Proxy-Client-IP"); + if (ip != null && !ip.isEmpty() && !UNKNOWN.equalsIgnoreCase(ip)) { + return ip; + } + + return request.getRemoteAddr(); + } +} diff --git a/mokee-gateway-common/src/main/java/com/mokee/common/utils/JwtUtil.java b/mokee-gateway-common/src/main/java/com/mokee/common/utils/JwtUtil.java new file mode 100644 index 0000000..14c79dd --- /dev/null +++ b/mokee-gateway-common/src/main/java/com/mokee/common/utils/JwtUtil.java @@ -0,0 +1,186 @@ +package com.mokee.common.utils; + +import com.mokee.common.exception.BusinessException; +import com.mokee.common.exception.ErrorCode; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.Date; +import java.util.UUID; + +/** + * JWT 工具类 + *

+ * 负责 JWT Token 的生成、解析、校验,配置可从 application.yml 读取也可使用默认值 + */ +@Slf4j +@Component +public class JwtUtil { + + /** JWT 签名密钥(默认值) */ + @Value("${jwt.secret:mokee-gateway-secret-key-2024}") + private String secret; + + /** JWT 过期时间,单位秒(默认2小时) */ + @Value("${jwt.expiration:7200}") + private long expiration; + + /** 缓存的签名密钥,避免每次请求都重新生成 */ + private volatile SecretKey cachedSigningKey; + private volatile String cachedSecret; + + /** + * 生成签名密钥(带缓存,避免重复计算 HMAC-SHA 密钥派生) + *

+ * Keys.hmacShaKeyFor() 涉及哈希计算,每次请求都调用会浪费 CPU。 + * 只要 secret 配置不变,缓存结果即可。 + */ + private SecretKey getSigningKey() { + if (cachedSigningKey != null && secret.equals(cachedSecret)) { + return cachedSigningKey; + } + synchronized (this) { + if (cachedSigningKey != null && secret.equals(cachedSecret)) { + return cachedSigningKey; + } + byte[] keyBytes = secret.getBytes(StandardCharsets.UTF_8); + cachedSigningKey = Keys.hmacShaKeyFor(keyBytes); + cachedSecret = secret; + return cachedSigningKey; + } + } + + /** + * 生成 JWT Token + * + * @param userId 用户ID + * @param username 用户名 + * @return JWT Token 字符串 + */ + public String generateToken(String userId, String username) { + String tokenId = UUID.randomUUID().toString(); + Date now = new Date(); + Date expirationDate = new Date(now.getTime() + expiration * 1000); + + return Jwts.builder() + .subject(userId) + .claim("userId", userId) + .claim("username", username) + .claim("tokenId", tokenId) + .issuedAt(now) + .expiration(expirationDate) + .signWith(getSigningKey()) + .compact(); + } + + /** + * 解析 JWT Token,返回 Claims + * + * @param token JWT Token + * @return 解析后的 Claims + */ + public Claims parseToken(String token) { + return Jwts.parser() + .verifyWith(getSigningKey()) + .build() + .parseSignedClaims(token) + .getPayload(); + } + + /** + * 从 Claims 中获取 tokenId + */ + public String getTokenId(Claims claims) { + return claims.get("tokenId", String.class); + } + + /** + * 从 Claims 中获取用户ID + */ + public String getUserId(Claims claims) { + return claims.get("userId", String.class); + } + + /** + * 从 Claims 中获取用户名 + */ + public String getUsername(Claims claims) { + return claims.get("username", String.class); + } + + /** + * 判断 Token 是否已过期 + */ + public boolean isTokenExpired(Claims claims) { + return claims.getExpiration().before(new Date()); + } + + /** + * 生成对外开放 API Token(用于外部系统调用,type=open_api 区分用户 token) + * + * @param systemId 系统ID + * @param systemCode 系统编码 + * @param tokenId 预生成的 token 唯一ID + * @return JWT Token 字符串 + */ + public String generateOpenToken(String systemId, String systemCode, String tokenId) { + return generateOpenToken(systemId, systemCode, tokenId, expiration); + } + + /** + * 生成开放 API Token(自定义过期时间) + * + * @param systemId 系统ID + * @param systemCode 系统编码 + * @param tokenId Token唯一ID + * @param expireSeconds 过期时间(秒),如 43200 = 12小时 + */ + public String generateOpenToken(String systemId, String systemCode, String tokenId, long expireSeconds) { + Date now = new Date(); + Date expirationDate = new Date(now.getTime() + expireSeconds * 1000); + + return Jwts.builder() + .subject(systemId) + .claim("systemId", systemId) + .claim("systemCode", systemCode) + .claim("tokenId", tokenId) + .claim("type", "open_api") + .issuedAt(now) + .expiration(expirationDate) + .signWith(getSigningKey()) + .compact(); + } + + /** + * 校验 JWT Token + *

+ * 解析 Token 并校验是否过期,过期或格式错误均抛出对应异常 + * + * @param token JWT Token + * @return 解析后的 Claims + * @throws BusinessException Token 过期或无效时抛出 + */ + public Claims validateToken(String token) { + try { + Claims claims = parseToken(token); + if (isTokenExpired(claims)) { + throw new BusinessException(ErrorCode.TOKEN_EXPIRED); + } + return claims; + } catch (ExpiredJwtException e) { + log.warn("Token 已过期: {}", e.getMessage()); + throw new BusinessException(ErrorCode.TOKEN_EXPIRED); + } catch (JwtException | IllegalArgumentException e) { + log.warn("Token 无效: {}", e.getMessage()); + throw new BusinessException(ErrorCode.TOKEN_INVALID); + } + } +} diff --git a/mokee-gateway-common/src/main/java/com/mokee/common/utils/RedisUtil.java b/mokee-gateway-common/src/main/java/com/mokee/common/utils/RedisUtil.java new file mode 100644 index 0000000..aacfb58 --- /dev/null +++ b/mokee-gateway-common/src/main/java/com/mokee/common/utils/RedisUtil.java @@ -0,0 +1,199 @@ +package com.mokee.common.utils; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.stereotype.Component; + +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * Redis 工具类 + *

+ * 封装常用的 StringRedisTemplate 操作,统一异常处理 + */ +@Slf4j +@Component +public class RedisUtil { + + /** Redis Key 统一前缀(所有 key 共用,改一处全改) */ + public static final String KEY_PREFIX = "GATEWAY:"; + + /** + * 生成带统一前缀的 Redis Key + *

+ * 所有 Redis 操作都应通过此方法生成 key,确保前缀统一。 + *

+     *   RedisUtil.key("auth:token:" + tokenId)  // → "GATEWAY:auth:token:xxx"
+     * 
+ */ + public static String key(String suffix) { + return KEY_PREFIX + suffix; + } + + @Autowired + private StringRedisTemplate stringRedisTemplate; + + /** + * 设置缓存(带过期时间) + * + * @param key 键 + * @param value 值 + * @param timeout 过期时间 + * @param unit 时间单位 + */ + public void set(String key, String value, long timeout, TimeUnit unit) { + try { + stringRedisTemplate.opsForValue().set(key, value, timeout, unit); + } catch (Exception e) { + log.error("Redis set 异常 - key: {}, value: {}", key, value, e); + } + } + + /** + * 获取缓存 + * + * @param key 键 + * @return 值,不存在返回 null + */ + public String get(String key) { + try { + return stringRedisTemplate.opsForValue().get(key); + } catch (Exception e) { + log.error("Redis get 异常 - key: {}", key, e); + return null; + } + } + + /** + * 删除缓存 + * + * @param key 键 + */ + public void delete(String key) { + try { + stringRedisTemplate.delete(key); + } catch (Exception e) { + log.error("Redis delete 异常 - key: {}", key, e); + } + } + + /** + * 设置过期时间 + * + * @param key 键 + * @param timeout 过期时间 + * @param unit 时间单位 + */ + public void expire(String key, long timeout, TimeUnit unit) { + try { + stringRedisTemplate.expire(key, timeout, unit); + } catch (Exception e) { + log.error("Redis expire 异常 - key: {}", key, e); + } + } + + /** + * 获取过期时间 + * + * @param key 键 + * @return 剩余秒数,-1 永不过期,-2 不存在 + */ + public Long getExpire(String key) { + try { + return stringRedisTemplate.getExpire(key, TimeUnit.SECONDS); + } catch (Exception e) { + log.error("Redis getExpire 异常 - key: {}", key, e); + return -2L; + } + } + + /** + * 自增并返回新值 + * + * @param key 键 + * @return 自增后的值 + */ + public Long incr(String key) { + try { + return stringRedisTemplate.opsForValue().increment(key); + } catch (Exception e) { + log.error("Redis incr 异常 - key: {}", key, e); + return 0L; + } + } + + /** + * 判断 key 是否存在 + * + * @param key 键 + * @return true 存在,false 不存在 + */ + public boolean exists(String key) { + try { + Boolean result = stringRedisTemplate.hasKey(key); + return result != null && result; + } catch (Exception e) { + log.error("Redis exists 异常 - key: {}", key, e); + return false; + } + } + + /** + * Lua 脚本:一次往返完成 GET + TTL + 条件续期 + *

+ * 返回 List: [value, ttl_seconds, renewed_flag] + * renewed_flag: 0=未续期, 1=已续期 + *

+ * 用于 Token 验证场景,将原本 3 次 Redis 往返(GET + TTL + EXPIRE)合并为 1 次。 + */ + private static final String GET_WITH_RENEW_SCRIPT = + "local val = redis.call('GET', KEYS[1])\n" + + "if val == false then return {val, -2, 0} end\n" + + "local ttl = redis.call('TTL', KEYS[1])\n" + + "local renewed = 0\n" + + "if ttl > 0 and ttl < tonumber(ARGV[1]) then\n" + + " redis.call('EXPIRE', KEYS[1], tonumber(ARGV[2]))\n" + + " renewed = 1\n" + + "end\n" + + "return {val, ttl, renewed}"; + + /** Lua 脚本缓存,避免每次请求都重新创建 DefaultRedisScript */ + private volatile DefaultRedisScript getWithRenewScript; + + /** + * GET + TTL + 条件续期(一次 Redis 往返) + * + * @param key Redis key + * @param renewThreshold 续期阈值(秒),TTL 小于此值时续期 + * @param newTtl 续期时长(秒) + * @return [value, ttl, renewed] — value 可能为 null(key 不存在), + * ttl 为 -2 表示不存在,renewed 1=已续期 0=未续期 + */ + public List getWithRenew(String key, long renewThreshold, long newTtl) { + try { + if (getWithRenewScript == null) { + synchronized (this) { + if (getWithRenewScript == null) { + DefaultRedisScript script = new DefaultRedisScript<>(); + script.setScriptText(GET_WITH_RENEW_SCRIPT); + script.setResultType(List.class); + getWithRenewScript = script; + } + } + } + return stringRedisTemplate.execute( + getWithRenewScript, + Arrays.asList(key), + String.valueOf(renewThreshold), + String.valueOf(newTtl) + ); + } catch (Exception e) { + log.error("Redis getWithRenew 异常 - key: {}", key, e); + return Arrays.asList(null, -2L, 0); + } + } +} diff --git a/mokee-gateway-common/src/main/java/com/mokee/common/utils/UserAgentUtil.java b/mokee-gateway-common/src/main/java/com/mokee/common/utils/UserAgentUtil.java new file mode 100644 index 0000000..f95f8bf --- /dev/null +++ b/mokee-gateway-common/src/main/java/com/mokee/common/utils/UserAgentUtil.java @@ -0,0 +1,74 @@ +package com.mokee.common.utils; + +import lombok.extern.slf4j.Slf4j; + +/** + * User-Agent 工具类 + *

+ * 从 User-Agent 字符串中解析浏览器和操作系统信息 + */ +@Slf4j +public class UserAgentUtil { + + /** + * 解析浏览器名称 + * + * @param userAgent User-Agent 字符串(不区分大小写匹配) + * @return 浏览器名称:Chrome / Firefox / Safari / Edge / IE / Unknown + */ + public static String parseBrowser(String userAgent) { + if (userAgent == null || userAgent.isEmpty()) { + return "Unknown"; + } + String ua = userAgent.toLowerCase(); + + if (ua.contains("edg") || ua.contains("edge")) { + return "Edge"; + } + if (ua.contains("chrome") && !ua.contains("edg")) { + return "Chrome"; + } + if (ua.contains("firefox") || ua.contains("fxios")) { + return "Firefox"; + } + if (ua.contains("safari") && !ua.contains("chrome") && !ua.contains("edg")) { + return "Safari"; + } + if (ua.contains("msie") || ua.contains("trident")) { + return "IE"; + } + + return "Unknown"; + } + + /** + * 解析操作系统名称 + * + * @param userAgent User-Agent 字符串(不区分大小写匹配) + * @return 操作系统名称:Windows / Mac / Linux / Android / iOS / Unknown + */ + public static String parseOs(String userAgent) { + if (userAgent == null || userAgent.isEmpty()) { + return "Unknown"; + } + String ua = userAgent.toLowerCase(); + + if (ua.contains("windows")) { + return "Windows"; + } + if (ua.contains("mac") || ua.contains("macintosh")) { + return "Mac"; + } + if (ua.contains("linux") && !ua.contains("android")) { + return "Linux"; + } + if (ua.contains("android")) { + return "Android"; + } + if (ua.contains("iphone") || ua.contains("ipad") || ua.contains("ipod")) { + return "iOS"; + } + + return "Unknown"; + } +} diff --git a/mokee-gateway-gateway/pom.xml b/mokee-gateway-gateway/pom.xml new file mode 100644 index 0000000..6a8854d --- /dev/null +++ b/mokee-gateway-gateway/pom.xml @@ -0,0 +1,32 @@ + + + 4.0.0 + + + com.mokee + mokee-gateway + 1.0.0 + + + mokee-gateway-gateway + mokee-gateway-gateway + 网关服务 — 路由转发/Token校验/请求头注入/限流/CORS + + + + com.mokee + mokee-gateway-common + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/mokee-gateway-gateway/src/main/java/com/mokee/gateway/GatewayApplication.java b/mokee-gateway-gateway/src/main/java/com/mokee/gateway/GatewayApplication.java new file mode 100644 index 0000000..f93a034 --- /dev/null +++ b/mokee-gateway-gateway/src/main/java/com/mokee/gateway/GatewayApplication.java @@ -0,0 +1,22 @@ +package com.mokee.gateway; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * 网关服务 — 启动类 + *

+ * 负责路由转发、Token 校验、API 权限控制、请求头注入、限流、CORS + */ +@SpringBootApplication(scanBasePackages = {"com.mokee.gateway", "com.mokee.common"}) +@MapperScan("com.mokee.gateway.mapper") +public class GatewayApplication { + + public static void main(String[] args) { + SpringApplication.run(GatewayApplication.class, args); + System.out.println("========================================"); + System.out.println(" 网关服务启动成功! 端口: 9000"); + System.out.println("========================================"); + } +} diff --git a/mokee-gateway-gateway/src/main/java/com/mokee/gateway/config/CorsConfig.java b/mokee-gateway-gateway/src/main/java/com/mokee/gateway/config/CorsConfig.java new file mode 100644 index 0000000..ae57a53 --- /dev/null +++ b/mokee-gateway-gateway/src/main/java/com/mokee/gateway/config/CorsConfig.java @@ -0,0 +1,24 @@ +package com.mokee.gateway.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * CORS 跨域配置 + *

+ * 允许所有来源、所有方法和所有请求头,支持携带凭证 + */ +@Configuration +public class CorsConfig implements WebMvcConfigurer { + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") + .allowedOriginPatterns("*") + .allowedMethods("*") + .allowedHeaders("*") + .allowCredentials(true) + .maxAge(3600); + } +} diff --git a/mokee-gateway-gateway/src/main/java/com/mokee/gateway/config/RestTemplateConfig.java b/mokee-gateway-gateway/src/main/java/com/mokee/gateway/config/RestTemplateConfig.java new file mode 100644 index 0000000..b45bc8d --- /dev/null +++ b/mokee-gateway-gateway/src/main/java/com/mokee/gateway/config/RestTemplateConfig.java @@ -0,0 +1,41 @@ +package com.mokee.gateway.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.web.client.ResponseErrorHandler; +import org.springframework.web.client.RestTemplate; + +/** + * RestTemplate 配置 + *

+ * 关键设计:不使用默认的错误处理器(默认会对 4xx/5xx 抛异常), + * 而是对所有响应(包括错误响应)直接返回原始 ResponseEntity, + * 让网关能原样转发业务后端的错误信息给前端。 + */ +@Configuration +public class RestTemplateConfig { + + @Bean + public RestTemplate restTemplate() { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(5000); + factory.setReadTimeout(60000); + + RestTemplate restTemplate = new RestTemplate(factory); + // 禁用默认错误处理:遇到 4xx/5xx 不抛异常,原样返回响应体 + // 这样业务后端的错误信息(code/msg)能完整到达前端 + restTemplate.setErrorHandler(new ResponseErrorHandler() { + @Override + public boolean hasError(org.springframework.http.client.ClientHttpResponse response) { + return false; // 永远不认为有错误 → 不抛异常 + } + + @Override + public void handleError(org.springframework.http.client.ClientHttpResponse response) { + // 不处理,让所有响应原样返回 + } + }); + return restTemplate; + } +} diff --git a/mokee-gateway-gateway/src/main/java/com/mokee/gateway/filter/ApiPermissionFilter.java b/mokee-gateway-gateway/src/main/java/com/mokee/gateway/filter/ApiPermissionFilter.java new file mode 100644 index 0000000..77bc9ff --- /dev/null +++ b/mokee-gateway-gateway/src/main/java/com/mokee/gateway/filter/ApiPermissionFilter.java @@ -0,0 +1,131 @@ +package com.mokee.gateway.filter; + +import cn.hutool.json.JSONUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.mokee.common.Result; +import com.mokee.common.dto.response.TokenInfo; +import com.mokee.common.entity.SysApi; +import com.mokee.common.entity.SysSystem; +import com.mokee.gateway.mapper.SysApiMapper; +import com.mokee.gateway.mapper.SysSystemMapper; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.List; + +/** + * API 权限校验过滤器 + *

+ * 通过 X-System-Code 头匹配业务系统的 system_code,查询该系统中是否配置了当前请求的 API + * 支持路径通配(如 /api/v1/user/** 匹配 /api/v1/user/list) + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ApiPermissionFilter extends OncePerRequestFilter { + + private final SysSystemMapper sysSystemMapper; + private final SysApiMapper sysApiMapper; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + String uri = request.getRequestURI(); + + // 非 /zgapi/ 路径放行(如静态资源、网关自身接口) + if (!uri.startsWith("/zgapi/")) { + filterChain.doFilter(request, response); + return; + } + + // 网关自身的认证接口放行(这些接口属于网关基础设施,由 TokenAuthFilter 校验身份即可,不需 API 白名单) + if (uri.startsWith("/zgapi/v1/auth/")) { + filterChain.doFilter(request, response); + return; + } + + // 网关管理后台接口也放行(管理后台不通过业务系统 API 白名单控制) + if (uri.startsWith("/zgapi/v1/admin/")) { + filterChain.doFilter(request, response); + return; + } + + // 通过 X-System-Code 头匹配:有该头的为业务系统请求,需要 API 权限校验 + String systemCode = request.getHeader("X-System-Code"); + if (systemCode == null || systemCode.isEmpty()) { + filterChain.doFilter(request, response); + return; + } + SysSystem system = sysSystemMapper.selectOne( + new LambdaQueryWrapper() + .eq(SysSystem::getSystemCode, systemCode) + .eq(SysSystem::getStatus, 1) + ); + if (system == null) { + log.warn("业务系统不存在或已禁用 - systemCode: {}", systemCode); + sendForbidden(response, "业务系统不存在或已禁用"); + return; + } + + // 校验接口是否在系统的放行 API 列表中 + String method = request.getMethod(); + List apiList = sysApiMapper.selectList( + new LambdaQueryWrapper() + .eq(SysApi::getSystemId, system.getId()) + .eq(SysApi::getStatus, 1) + ); + + boolean matched = apiList.stream().anyMatch(api -> { + // 请求方法必须一致(忽略大小写) + if (!api.getApiMethod().equalsIgnoreCase(method)) { + return false; + } + // 路径匹配:精确匹配 或 通配符匹配 + return matchPath(api.getApiPath(), uri); + }); + + if (!matched) { + log.warn("接口无访问权限 - systemCode: {}, path: {}, method: {}", systemCode, uri, method); + sendForbidden(response, "接口无访问权限"); + return; + } + + filterChain.doFilter(request, response); + } + + /** + * 路径匹配,支持 /** 通配符 + *

+ * 例如:/api/v1/user/** 可匹配 /api/v1/user/list、/api/v1/user/1/detail 等 + */ + private boolean matchPath(String apiPattern, String requestPath) { + // 精确匹配 + if (apiPattern.equals(requestPath)) { + return true; + } + + // 通配符 /** 匹配 + if (apiPattern.endsWith("/**")) { + String prefix = apiPattern.substring(0, apiPattern.length() - 3); + return requestPath.startsWith(prefix); + } + + return false; + } + + /** + * 返回 403 禁止访问响应 + */ + private void sendForbidden(HttpServletResponse response, String message) throws IOException { + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(403); + response.getWriter().write(JSONUtil.toJsonStr(Result.fail(403, message))); + } +} diff --git a/mokee-gateway-gateway/src/main/java/com/mokee/gateway/filter/HeaderInjectFilter.java b/mokee-gateway-gateway/src/main/java/com/mokee/gateway/filter/HeaderInjectFilter.java new file mode 100644 index 0000000..fcd5890 --- /dev/null +++ b/mokee-gateway-gateway/src/main/java/com/mokee/gateway/filter/HeaderInjectFilter.java @@ -0,0 +1,134 @@ +package com.mokee.gateway.filter; + +import cn.hutool.json.JSONUtil; +import com.mokee.common.dto.response.RoleInfo; +import com.mokee.common.dto.response.TokenInfo; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequestWrapper; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.*; + +/** + * 请求头注入过滤器 + *

+ * 将 TokenInfo 中的用户信息、角色、当前系统等注入到请求头中, + * 供下游服务通过 X-User-Id / X-User-Name 等头获取当前用户信息 + */ +@Slf4j +@Component +public class HeaderInjectFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + TokenInfo tokenInfo = (TokenInfo) request.getAttribute("tokenInfo"); + + if (tokenInfo == null) { + // 没有 TokenInfo(匿名请求),直接放行 + filterChain.doFilter(request, response); + return; + } + + // 创建包装后的请求,注入自定义请求头 + HeaderInjectRequestWrapper wrappedRequest = new HeaderInjectRequestWrapper(request, tokenInfo); + filterChain.doFilter(wrappedRequest, response); + } + + /** + * 自定义 HttpServletRequestWrapper,注入用户信息请求头 + */ + private static class HeaderInjectRequestWrapper extends HttpServletRequestWrapper { + + private final Map customHeaders = new HashMap<>(); + + public HeaderInjectRequestWrapper(HttpServletRequest request, TokenInfo tokenInfo) { + super(request); + // 注入用户基础信息 + putIfNotNull("X-User-Id", tokenInfo.getUserId() != null ? String.valueOf(tokenInfo.getUserId()) : null); + // X-User-Name: 真实姓名(URL 编码,防止中文在 HTTP 头中损坏) + putIfNotNull("X-User-Name", tokenInfo.getRealName() != null ? URLEncoder.encode(tokenInfo.getRealName(), StandardCharsets.UTF_8) : null); + putIfNotNull("X-User-Account", tokenInfo.getUsername()); + putIfNotNull("X-User-Email", tokenInfo.getEmail()); + putIfNotNull("X-User-Post", tokenInfo.getPost()); + + // 注入角色列表(JSON 格式) + List roles = tokenInfo.getRoles(); + if (roles != null && !roles.isEmpty()) { + customHeaders.put("X-User-Roles", JSONUtil.toJsonStr(roles)); + } + + // 注入当前系统信息 + putIfNotNull("X-System-Id", tokenInfo.getCurrentSystemId() != null ? String.valueOf(tokenInfo.getCurrentSystemId()) : null); + putIfNotNull("X-System-Code", tokenInfo.getCurrentSystemCode()); + putIfNotNull("X-System-Name", tokenInfo.getCurrentSystemName()); + } + + private void putIfNotNull(String key, String value) { + if (value != null) { + customHeaders.put(key, value); + } + } + + /** + * 系统信息头(X-System-*):原始请求已有则保留原始值(业务系统前端主动设置的), + * 仅当原始请求没有时才用 tokenInfo 注入的值。 + * 用户信息头(X-User-*):始终用 tokenInfo 注入的值。 + */ + private static final Set SYSTEM_HEADERS = Set.of( + "X-System-Id", "X-System-Code", "X-System-Name" + ); + + @Override + public String getHeader(String name) { + // 系统信息头:原始请求已有则保留原始值 + if (SYSTEM_HEADERS.contains(name)) { + String originalValue = super.getHeader(name); + if (originalValue != null) { + return originalValue; + } + } + // 用户信息头 / 原始请求没有的系统头:返回注入的值 + String value = customHeaders.get(name); + if (value != null) { + return value; + } + return super.getHeader(name); + } + + @Override + public Enumeration getHeaders(String name) { + // 系统信息头:原始请求已有则保留原始值 + if (SYSTEM_HEADERS.contains(name)) { + String originalValue = super.getHeader(name); + if (originalValue != null) { + return Collections.enumeration(Collections.singletonList(originalValue)); + } + } + String value = customHeaders.get(name); + if (value != null) { + return Collections.enumeration(Collections.singletonList(value)); + } + return super.getHeaders(name); + } + + @Override + public Enumeration getHeaderNames() { + // 合并原始请求头和自定义头 + Set names = new HashSet<>(customHeaders.keySet()); + Enumeration originalNames = super.getHeaderNames(); + while (originalNames.hasMoreElements()) { + names.add(originalNames.nextElement()); + } + return Collections.enumeration(names); + } + } +} diff --git a/mokee-gateway-gateway/src/main/java/com/mokee/gateway/filter/RateLimitFilter.java b/mokee-gateway-gateway/src/main/java/com/mokee/gateway/filter/RateLimitFilter.java new file mode 100644 index 0000000..25058ba --- /dev/null +++ b/mokee-gateway-gateway/src/main/java/com/mokee/gateway/filter/RateLimitFilter.java @@ -0,0 +1,88 @@ +package com.mokee.gateway.filter; + +import cn.hutool.json.JSONUtil; +import com.mokee.common.Result; +import com.mokee.common.exception.ErrorCode; +import com.mokee.common.utils.JwtUtil; +import com.mokee.common.utils.RedisUtil; +import io.jsonwebtoken.Claims; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.concurrent.TimeUnit; + +/** + * 限流过滤器 + *

+ * 基于 Redis 计数器实现接口级别的限流,按 Token 标识用户 + * 只拦截 /zgapi/ 开头的业务请求,登录接口跳过限流 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class RateLimitFilter extends OncePerRequestFilter { + + private final RedisUtil redisUtil; + private final JwtUtil jwtUtil; + + /** 每秒钟最大请求数 */ + private static final int MAX_REQUESTS_PER_SECOND = 100; + /** 限流 Redis Key 前缀 */ + private static final String RATE_KEY_PREFIX = "rate:api:"; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + String uri = request.getRequestURI(); + + // 只过滤 /zgapi/ 开头的请求 + if (!uri.startsWith("/zgapi/")) { + filterChain.doFilter(request, response); + return; + } + + // 登录接口跳过限流 + if (uri.equals("/zgapi/v1/auth/login")) { + filterChain.doFilter(request, response); + return; + } + + // 从 Authorization 头提取 Token,解析 tokenId 作为限流 Key + String rateKey = RATE_KEY_PREFIX + "anonymous"; + String authHeader = request.getHeader("Authorization"); + if (authHeader != null && authHeader.startsWith("Bearer ")) { + try { + String token = authHeader.substring(7); + Claims claims = jwtUtil.validateToken(token); + rateKey = RATE_KEY_PREFIX + jwtUtil.getTokenId(claims); + } catch (Exception e) { + // Token 无效时使用 IP 作为限流 Key + rateKey = RATE_KEY_PREFIX + "ip:" + request.getRemoteAddr(); + } + } + + // Redis 计数器自增,首次设置 1 秒过期 + Long count = redisUtil.incr(rateKey); + if (count == 1) { + redisUtil.expire(rateKey, 1, TimeUnit.SECONDS); + } + + // 超过阈值返回 429 + if (count > MAX_REQUESTS_PER_SECOND) { + log.warn("接口限流触发 - key: {}, count: {}, uri: {}", rateKey, count, uri); + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(429); + response.getWriter().write(JSONUtil.toJsonStr(Result.fail(ErrorCode.RATE_LIMITED.getCode(), ErrorCode.RATE_LIMITED.getMessage()))); + return; + } + + filterChain.doFilter(request, response); + } +} diff --git a/mokee-gateway-gateway/src/main/java/com/mokee/gateway/filter/TokenAuthFilter.java b/mokee-gateway-gateway/src/main/java/com/mokee/gateway/filter/TokenAuthFilter.java new file mode 100644 index 0000000..255b317 --- /dev/null +++ b/mokee-gateway-gateway/src/main/java/com/mokee/gateway/filter/TokenAuthFilter.java @@ -0,0 +1,236 @@ +package com.mokee.gateway.filter; + +import cn.hutool.json.JSONUtil; +import com.mokee.common.Result; +import com.mokee.common.dto.response.TokenInfo; +import com.mokee.common.exception.BusinessException; +import com.mokee.common.exception.ErrorCode; +import com.mokee.common.utils.JwtUtil; +import com.mokee.common.utils.RedisUtil; +import io.jsonwebtoken.Claims; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.concurrent.TimeUnit; + +/** + * Token 认证过滤器(优先级最高) + *

+ * 校验请求头中的 JWT Token 有效性,查询 Redis 会话并续期 + * 登录接口和静态资源直接放行 + */ +@Slf4j +@Component +@Order(1) +@RequiredArgsConstructor +public class TokenAuthFilter extends OncePerRequestFilter { + + private final JwtUtil jwtUtil; + private final RedisUtil redisUtil; + + /** Token 续期时间(小时) */ + private static final int TOKEN_TTL_HOURS = 2; + /** 续期阈值(秒),剩余 TTL 小于此值时续期 */ + private static final long RENEW_THRESHOLD_SECONDS = 1800; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + // OPTIONS 预检请求直接放行(CORS 跨域预检不带 Authorization 头) + if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { + filterChain.doFilter(request, response); + return; + } + + String uri = request.getRequestURI(); + + // 登录接口放行 + if ("/zgapi/v1/auth/login".equals(uri)) { + filterChain.doFilter(request, response); + return; + } + + // 开放 API — 获取/校验/查询/标记消费 Token 接口放行(无需认证,token 在请求参数中) + if ("/zgapi/v1/open/token".equals(uri) || "/zgapi/v1/open/token/validate".equals(uri) + || "/zgapi/v1/open/token/info".equals(uri) + || "/zgapi/v1/open/token/consume".equals(uri)) { + filterChain.doFilter(request, response); + return; + } + + // 系统接入文档接口放行(通过 UUID 令牌访问,无需登录) + if (uri.startsWith("/zgapi/v1/admin/system/doc/")) { + filterChain.doFilter(request, response); + return; + } + + // 静态资源放行(Knife4j / Swagger 文档相关) + if (isStaticResource(uri)) { + filterChain.doFilter(request, response); + return; + } + + // 开放 API 请求(/zgapi/v1/open/*):使用 open token 验证 + if (uri.startsWith("/zgapi/v1/open/")) { + authenticateOpenApi(request, response, filterChain); + return; + } + + boolean isZgapiPath = uri.startsWith("/zgapi/"); + + // 提取 Token(优先 Authorization 头,其次 Cookie) + String token = null; + String authHeader = request.getHeader("Authorization"); + if (authHeader != null && authHeader.startsWith("Bearer ")) { + token = authHeader.substring(7); + } else { + token = getTokenFromCookie(request); + } + + // /zgapi/ 路径必须有 Token,否则返回 401 + if (isZgapiPath && (token == null || token.isEmpty())) { + sendUnauthorized(response, "未提供有效的认证Token"); + return; + } + + // 非 /zgapi/ 路径:无 Token 直接放行(业务系统匿名访问) + if (!isZgapiPath && (token == null || token.isEmpty())) { + filterChain.doFilter(request, response); + return; + } + + // 以下为有 Token 时的统一认证逻辑(/zgapi/ 必须通过,非 /zgapi/ 非阻塞) + try { + Claims claims = jwtUtil.validateToken(token); + String tokenId = jwtUtil.getTokenId(claims); + + String redisKey = RedisUtil.key("auth:token:" + tokenId); + String jsonStr = redisUtil.get(redisKey); + if (jsonStr == null || jsonStr.isEmpty()) { + if (isZgapiPath) { + log.warn("Token 对应 Redis 会话不存在 - tokenId: {}", tokenId); + sendUnauthorized(response, "Token已过期,请重新登录"); + return; + } + log.debug("Token 会话已过期(非阻塞放行) - tokenId: {}", tokenId); + filterChain.doFilter(request, response); + return; + } + + TokenInfo tokenInfo = JSONUtil.toBean(jsonStr, TokenInfo.class); + + // 每次请求都刷新 Token TTL(去掉 TTL 检查,GET + EXPIRE 共 2 次 Redis 往返) + redisUtil.expire(redisKey, TOKEN_TTL_HOURS, TimeUnit.HOURS); + + request.setAttribute("tokenInfo", tokenInfo); + request.setAttribute("tokenId", tokenId); + + filterChain.doFilter(request, response); + } catch (BusinessException e) { + if (isZgapiPath) { + log.warn("Token 校验失败 - {}", e.getMessage()); + sendUnauthorized(response, e.getMessage()); + return; + } + // 非 /zgapi/:Token 校验失败不阻塞 + log.debug("Token 校验失败(非阻塞放行) - {}", e.getMessage()); + filterChain.doFilter(request, response); + } + } + + /** + * 开放 API Token 认证 + *

+ * Token 优先从 Authorization: Bearer 头提取;若无则从 query 参数 ?token= 提取 + * (文件下载等场景通过浏览器标签直接访问,无法自定义 Header)。 + */ + private void authenticateOpenApi(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws IOException, ServletException { + String token = null; + String authHeader = request.getHeader("Authorization"); + if (authHeader != null && authHeader.startsWith("Bearer ")) { + token = authHeader.substring(7); + } else { + // 兜底:从 query 参数读取(用于 GET 下载等浏览器直连场景) + token = request.getParameter("token"); + } + if (token == null || token.isEmpty()) { + sendUnauthorized(response, "未提供 Open API Token"); + return; + } + try { + Claims claims = jwtUtil.validateToken(token); + String tokenId = jwtUtil.getTokenId(claims); + String type = claims.get("type", String.class); + if (!"open_api".equals(type)) { + sendUnauthorized(response, "Token 类型错误,请使用 Open API Token"); + return; + } + + String redisKey = RedisUtil.key("open:token:" + tokenId); + String jsonStr = redisUtil.get(redisKey); + if (jsonStr == null) { + sendUnauthorized(response, "Open API Token 已过期"); + return; + } + + // 将系统信息存入 request 属性(跨进程转发不会丢失) + cn.hutool.json.JSONObject info = cn.hutool.json.JSONUtil.parseObj(jsonStr); + request.setAttribute("openSystemId", info.getStr("systemId")); + request.setAttribute("openSystemCode", info.getStr("systemCode")); + request.setAttribute("openTokenId", tokenId); + + // 放行请求,继续过滤链 → GatewayController → admin 服务 + filterChain.doFilter(request, response); + } catch (com.mokee.common.exception.BusinessException e) { + sendUnauthorized(response, e.getMessage()); + } catch (Exception e) { + log.error("Open API Token 校验异常 - uri: {}, error: {}", request.getRequestURI(), e.getMessage()); + sendUnauthorized(response, "Token 校验失败: " + e.getMessage()); + } + } + + /** + * 从 Cookie 中提取 token 值(用于图片等资源请求,浏览器不带 Authorization 头但会发 Cookie) + */ + private String getTokenFromCookie(HttpServletRequest request) { + jakarta.servlet.http.Cookie[] cookies = request.getCookies(); + if (cookies == null) return null; + for (jakarta.servlet.http.Cookie cookie : cookies) { + if ("token".equals(cookie.getName())) { + return cookie.getValue(); + } + } + return null; + } + + /** + * 判断是否为静态资源路径 + */ + private boolean isStaticResource(String uri) { + return uri.startsWith("/doc.html") + || uri.startsWith("/webjars/") + || uri.startsWith("/v3/api-docs") + || uri.startsWith("/swagger-resources") + || uri.startsWith("/favicon.ico"); + } + + /** + * 返回 401 未授权响应(带 CORS 头,避免浏览器 ORB 拦截) + */ + private void sendUnauthorized(HttpServletResponse response, String message) throws IOException { + response.setHeader("Access-Control-Allow-Origin", "*"); + response.setHeader("Access-Control-Allow-Credentials", "true"); + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(401); + response.getWriter().write(JSONUtil.toJsonStr(Result.fail(401, message))); + } +} diff --git a/mokee-gateway-gateway/src/main/java/com/mokee/gateway/handler/GatewayController.java b/mokee-gateway-gateway/src/main/java/com/mokee/gateway/handler/GatewayController.java new file mode 100644 index 0000000..596c22a --- /dev/null +++ b/mokee-gateway-gateway/src/main/java/com/mokee/gateway/handler/GatewayController.java @@ -0,0 +1,194 @@ +package com.mokee.gateway.handler; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.mokee.common.entity.SysSystem; +import com.mokee.gateway.mapper.SysSystemMapper; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.client.RestTemplate; + +import java.net.URI; +import java.util.*; + +@Slf4j +@RestController +@RequiredArgsConstructor +public class GatewayController { + + private final RestTemplate restTemplate; + private final SysSystemMapper sysSystemMapper; + + @Value("${gateway.route.auth-url:http://127.0.0.1:9001}") + private String authServiceUrl; + + @Value("${gateway.route.admin-url:http://127.0.0.1:9002}") + private String adminServiceUrl; + + /** + * 统一路由入口,匹配所有请求路径 + *

+ * 在方法内部根据 URI 前缀判断转发目标 + */ + @RequestMapping("/**") + public ResponseEntity route(HttpServletRequest request) { + String uri = request.getRequestURI(); + String method = request.getMethod(); + String queryString = request.getQueryString(); + + // 静态资源直接返回 200(由前端或 Nginx 处理) + if (isStaticResource(uri)) { + return ResponseEntity.ok().build(); + } + + // 根据 URI 前缀确定目标服务地址 + String targetBaseUrl; + if (uri.startsWith("/zgapi/v1/auth/")) { + targetBaseUrl = authServiceUrl; + } else if (uri.startsWith("/zgapi/v1/admin/")) { + targetBaseUrl = adminServiceUrl; + } else if (uri.startsWith("/zgapi/v1/open/")) { + targetBaseUrl = adminServiceUrl; + } else if (uri.startsWith("/zgapi/")) { + // 业务系统请求:通过 X-System-Code 头匹配 system_code,查询后端真实地址 + String systemCode = request.getHeader("X-System-Code"); + if (systemCode == null || systemCode.isEmpty()) { + log.warn("业务系统请求缺少 X-System-Code 头 - uri: {}", uri); + return ResponseEntity.status(404) + .body("X-System-Code 头缺失,无法识别业务系统"); + } + SysSystem system = sysSystemMapper.selectOne( + new LambdaQueryWrapper() + .eq(SysSystem::getSystemCode, systemCode) + .eq(SysSystem::getStatus, 1) + ); + if (system == null || system.getBackendUrl() == null) { + log.warn("业务系统未找到或后端地址为空 - systemCode: {}", systemCode); + return ResponseEntity.status(404) + .body("业务系统不存在或后端地址未配置"); + } + // 去掉后端地址末尾的斜杠 + targetBaseUrl = system.getBackendUrl().replaceAll("/+$", ""); + } else { + // 非 /zgapi/ 路径:业务系统请求,通过 X-System-Code 头转发到业务后端 + String systemCode = request.getHeader("X-System-Code"); + if (systemCode != null && !systemCode.isEmpty()) { + SysSystem system = sysSystemMapper.selectOne( + new LambdaQueryWrapper() + .eq(SysSystem::getSystemCode, systemCode) + .eq(SysSystem::getStatus, 1) + ); + if (system != null && system.getBackendUrl() != null) { + targetBaseUrl = system.getBackendUrl().replaceAll("/+$", ""); + } else { + log.warn("业务系统不存在或未配置后端地址 - systemCode: {}", systemCode); + return ResponseEntity.status(502) + .body("{\"code\":502,\"msg\":\"业务系统未配置后端地址\"}"); + } + } else { + log.debug("非网关路由请求(无 X-System-Code) - uri: {}", uri); + return ResponseEntity.ok().build(); + } + } + + // 拼接完整的目标 URL + String targetUrl = targetBaseUrl + uri; + if (queryString != null && !queryString.isEmpty()) { + targetUrl += "?" + queryString; + } + + log.info("网关转发 - method: {}, uri: {} → {}", method, uri, targetUrl); + + // 复制请求头(跳过不需要转发的头) + HttpHeaders headers = new HttpHeaders(); + Enumeration headerNames = request.getHeaderNames(); + while (headerNames.hasMoreElements()) { + String headerName = headerNames.nextElement(); + if ("host".equalsIgnoreCase(headerName) + || "content-length".equalsIgnoreCase(headerName) + || "transfer-encoding".equalsIgnoreCase(headerName)) { + continue; + } + Enumeration headerValues = request.getHeaders(headerName); + List values = new ArrayList<>(); + while (headerValues.hasMoreElements()) { + values.add(headerValues.nextElement()); + } + headers.put(headerName, values); + } + + // 读取请求体 + final byte[] body; + try { + body = request.getInputStream().readAllBytes(); + } catch (Exception e) { + log.warn("读取请求体失败 - {}", e.getMessage()); + return ResponseEntity.status(500).body("{\"code\":500,\"msg\":\"读取请求体失败\"}"); + } + + // 转发请求:multipart 请求使用流式转发避免 Content-Type 被 RestTemplate 修改 + try { + ResponseEntity response; + if (headers.getContentType() != null + && headers.getContentType().toString().startsWith("multipart/")) { + // multipart 请求直接用 execute() 写入原始 body,保留 boundary + final HttpHeaders fwdHeaders = headers; + response = restTemplate.execute( + URI.create(targetUrl), + HttpMethod.valueOf(method.toUpperCase()), + reqCallback -> { + reqCallback.getHeaders().putAll(fwdHeaders); + reqCallback.getBody().write(body); + }, + clientHttpResponse -> { + byte[] respBody = clientHttpResponse.getBody().readAllBytes(); + HttpHeaders respHeaders = new HttpHeaders(); + clientHttpResponse.getHeaders().forEach( + (k, v) -> respHeaders.put(k, v)); + return new ResponseEntity<>(respBody, respHeaders, + clientHttpResponse.getStatusCode()); + } + ); + } else { + HttpEntity httpEntity = new HttpEntity<>(body, headers); + response = restTemplate.exchange( + URI.create(targetUrl), + HttpMethod.valueOf(method.toUpperCase()), + httpEntity, + byte[].class + ); + } + + // 返回响应,透传关键响应头 + HttpHeaders responseHeaders = new HttpHeaders(); + response.getHeaders().forEach((key, values) -> { + // 跳过 HTTP/2 连接管理和不需要转发的头 + if ("transfer-encoding".equalsIgnoreCase(key) + || "connection".equalsIgnoreCase(key)) { + return; + } + responseHeaders.put(key, values); + }); + return new ResponseEntity<>(response.getBody(), responseHeaders, response.getStatusCode()); + } catch (Exception e) { + log.error("网关转发失败 - uri: {}, targetUrl: {}, error: {}", uri, targetUrl, e.getMessage()); + return ResponseEntity.status(502) + .body("{\"code\":502,\"msg\":\"网关转发失败: " + e.getMessage() + "\"}"); + } + } + + /** + * 判断是否为静态资源路径 + */ + private boolean isStaticResource(String uri) { + return uri.startsWith("/doc.html") + || uri.startsWith("/webjars/") + || uri.startsWith("/v3/api-docs") + || uri.startsWith("/swagger-resources") + || uri.startsWith("/favicon.ico"); + } +} diff --git a/mokee-gateway-gateway/src/main/java/com/mokee/gateway/mapper/SysApiMapper.java b/mokee-gateway-gateway/src/main/java/com/mokee/gateway/mapper/SysApiMapper.java new file mode 100644 index 0000000..6470426 --- /dev/null +++ b/mokee-gateway-gateway/src/main/java/com/mokee/gateway/mapper/SysApiMapper.java @@ -0,0 +1,14 @@ +package com.mokee.gateway.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.mokee.common.entity.SysApi; +import org.apache.ibatis.annotations.Mapper; + +/** + * 系统 API Mapper + *

+ * 网关需要查询已配置的放行 API 列表用于权限校验 + */ +@Mapper +public interface SysApiMapper extends BaseMapper { +} diff --git a/mokee-gateway-gateway/src/main/java/com/mokee/gateway/mapper/SysSystemMapper.java b/mokee-gateway-gateway/src/main/java/com/mokee/gateway/mapper/SysSystemMapper.java new file mode 100644 index 0000000..f6deccd --- /dev/null +++ b/mokee-gateway-gateway/src/main/java/com/mokee/gateway/mapper/SysSystemMapper.java @@ -0,0 +1,14 @@ +package com.mokee.gateway.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.mokee.common.entity.SysSystem; +import org.apache.ibatis.annotations.Mapper; + +/** + * 系统信息 Mapper + *

+ * 网关需要查询业务系统的后端地址用于路由转发 + */ +@Mapper +public interface SysSystemMapper extends BaseMapper { +} diff --git a/mokee-gateway-gateway/src/main/java/com/mokee/gateway/mapper/SysUserSystemMapper.java b/mokee-gateway-gateway/src/main/java/com/mokee/gateway/mapper/SysUserSystemMapper.java new file mode 100644 index 0000000..a37ab34 --- /dev/null +++ b/mokee-gateway-gateway/src/main/java/com/mokee/gateway/mapper/SysUserSystemMapper.java @@ -0,0 +1,12 @@ +package com.mokee.gateway.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.mokee.common.entity.SysUserSystem; +import org.apache.ibatis.annotations.Mapper; + +/** + * 用户系统关联 Mapper + */ +@Mapper +public interface SysUserSystemMapper extends BaseMapper { +} diff --git a/mokee-gateway-gateway/src/main/resources/application-prod.yml b/mokee-gateway-gateway/src/main/resources/application-prod.yml new file mode 100644 index 0000000..509ec2f --- /dev/null +++ b/mokee-gateway-gateway/src/main/resources/application-prod.yml @@ -0,0 +1,35 @@ +spring: + servlet: + multipart: + enabled: false + datasource: + url: jdbc:mysql://db1.prod.zgitm.com:3306/mokeegateway?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai + username: mokeegateway + password: mokee. + driver-class-name: com.mysql.cj.jdbc.Driver + data: + redis: + host: redis.db1.zgitm.com + port: 6379 + password: mokee2016. + database: 1 + timeout: 3000ms + lettuce: + pool: + max-active: 8 + max-idle: 8 + min-idle: 0 + +mybatis-plus: + configuration: + log-impl: org.apache.ibatis.logging.nologging.NoLoggingImpl + +gateway: + route: + auth-url: http://10.10.1.170:9001 + admin-url: http://10.10.1.170:9002 + +logging: + level: + com.mokee: INFO + org.springframework.web.servlet.DispatcherServlet: DEBUG diff --git a/mokee-gateway-gateway/src/main/resources/application-qa.yml b/mokee-gateway-gateway/src/main/resources/application-qa.yml new file mode 100644 index 0000000..9fd5256 --- /dev/null +++ b/mokee-gateway-gateway/src/main/resources/application-qa.yml @@ -0,0 +1,34 @@ +spring: + servlet: + multipart: + enabled: false + datasource: + url: jdbc:mysql://db1.qa.zgitm.com:3306/mokeegatewayqa?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai + username: mokeegatewayqa + password: mokee. + driver-class-name: com.mysql.cj.jdbc.Driver + data: + redis: + host: redis.db2.zgitm.com + port: 6379 + password: mokee2016. + database: 2 + timeout: 3000ms + lettuce: + pool: + max-active: 8 + max-idle: 8 + min-idle: 0 + +mybatis-plus: + configuration: + log-impl: org.apache.ibatis.logging.stdout.StdOutImpl + +gateway: + route: + auth-url: http://127.0.0.1:9001 + admin-url: http://127.0.0.1:9002 + +logging: + level: + com.mokee: INFO diff --git a/mokee-gateway-gateway/src/main/resources/application.yml b/mokee-gateway-gateway/src/main/resources/application.yml new file mode 100644 index 0000000..47235dc --- /dev/null +++ b/mokee-gateway-gateway/src/main/resources/application.yml @@ -0,0 +1,17 @@ +server: + port: 9000 + +spring: + application: + name: mokee-gateway-gateway + profiles: + active: @spring.profiles.active@ + +mybatis-plus: + type-aliases-package: com.mokee.common.entity + configuration: + map-underscore-to-camel-case: true + +jwt: + secret: mokee-gateway-jwt-secret-key-2024-must-be-256-bits!! + expiration: 7200 diff --git a/mokee-gateway-web/.env.development b/mokee-gateway-web/.env.development new file mode 100644 index 0000000..b228ab7 --- /dev/null +++ b/mokee-gateway-web/.env.development @@ -0,0 +1,5 @@ +# 开发环境配置 +VITE_LOGIN_URL=http://127.0.0.1:5001 +VITE_WEB_URL=http://127.0.0.1:5001 +VITE_GATEWAY_URL=http://127.0.0.1:9000 +VITE_COOKIE_DOMAIN= diff --git a/mokee-gateway-web/.env.production b/mokee-gateway-web/.env.production new file mode 100644 index 0000000..ab80a79 --- /dev/null +++ b/mokee-gateway-web/.env.production @@ -0,0 +1,5 @@ +# 生产环境配置 +VITE_LOGIN_URL=https://login.user.zgitm.com +VITE_WEB_URL=https://web.gw.zgitm.com +VITE_GATEWAY_URL=https://gw.server.zgitm.com +VITE_COOKIE_DOMAIN=.zgitm.com diff --git a/mokee-gateway-web/.nvmrc b/mokee-gateway-web/.nvmrc new file mode 100644 index 0000000..25bf17f --- /dev/null +++ b/mokee-gateway-web/.nvmrc @@ -0,0 +1 @@ +18 \ No newline at end of file diff --git a/mokee-gateway-web/_backup_20260613_widget/IntegrationDoc.vue b/mokee-gateway-web/_backup_20260613_widget/IntegrationDoc.vue new file mode 100644 index 0000000..e810e87 --- /dev/null +++ b/mokee-gateway-web/_backup_20260613_widget/IntegrationDoc.vue @@ -0,0 +1,945 @@ + + + + + + diff --git a/mokee-gateway-web/_backup_20260613_widget/package.json b/mokee-gateway-web/_backup_20260613_widget/package.json new file mode 100644 index 0000000..8a18bd5 --- /dev/null +++ b/mokee-gateway-web/_backup_20260613_widget/package.json @@ -0,0 +1,29 @@ +{ + "name": "mokee-gateway-web", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --port 5001", + "build": "vue-tsc && vite build", + "build:prod": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@element-plus/icons-vue": "^2.3.0", + "axios": "^1.7.0", + "echarts": "^6.1.0", + "element-plus": "^2.8.0", + "pinia": "^2.1.0", + "vue": "^3.4.0", + "vue-router": "^4.3.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "typescript": "^5.4.0", + "unplugin-auto-import": "^0.18.0", + "unplugin-vue-components": "^0.27.0", + "vite": "^5.4.0", + "vue-tsc": "^2.0.0" + } +} diff --git a/mokee-gateway-web/auto-imports.d.ts b/mokee-gateway-web/auto-imports.d.ts new file mode 100644 index 0000000..9d24007 --- /dev/null +++ b/mokee-gateway-web/auto-imports.d.ts @@ -0,0 +1,10 @@ +/* eslint-disable */ +/* prettier-ignore */ +// @ts-nocheck +// noinspection JSUnusedGlobalSymbols +// Generated by unplugin-auto-import +// biome-ignore lint: disable +export {} +declare global { + +} diff --git a/mokee-gateway-web/backup-on-demand/App.vue.bak b/mokee-gateway-web/backup-on-demand/App.vue.bak new file mode 100644 index 0000000..cd9578a --- /dev/null +++ b/mokee-gateway-web/backup-on-demand/App.vue.bak @@ -0,0 +1,6 @@ + + + diff --git a/mokee-gateway-web/backup-on-demand/main.ts.bak b/mokee-gateway-web/backup-on-demand/main.ts.bak new file mode 100644 index 0000000..28f4ab7 --- /dev/null +++ b/mokee-gateway-web/backup-on-demand/main.ts.bak @@ -0,0 +1,22 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import ElementPlus from 'element-plus' +import 'element-plus/dist/index.css' +import zhCn from 'element-plus/es/locale/lang/zh-cn' +import * as Icons from '@element-plus/icons-vue' +import App from './App.vue' +import router from './router' +import './styles/global.css' +import { permission } from './directives/permission' + +const app = createApp(App) + +for (const [key, comp] of Object.entries(Icons)) { + app.component(key, comp as any) +} + +app.use(createPinia()) +app.use(router) +app.use(ElementPlus, { locale: zhCn }) +app.directive('permission', permission) +app.mount('#app') diff --git a/mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/AdminLayout.vue b/mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/AdminLayout.vue new file mode 100644 index 0000000..b535e39 --- /dev/null +++ b/mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/AdminLayout.vue @@ -0,0 +1,557 @@ + + + + + diff --git a/mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/Dashboard.vue b/mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/Dashboard.vue new file mode 100644 index 0000000..3ac17c5 --- /dev/null +++ b/mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/Dashboard.vue @@ -0,0 +1,353 @@ + + + + + diff --git a/mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/PersonalView.vue b/mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/PersonalView.vue new file mode 100644 index 0000000..51e0720 --- /dev/null +++ b/mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/PersonalView.vue @@ -0,0 +1,302 @@ + + + + + diff --git a/mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/dict/DictForm.vue b/mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/dict/DictForm.vue new file mode 100644 index 0000000..721ebca --- /dev/null +++ b/mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/dict/DictForm.vue @@ -0,0 +1,216 @@ + + + + + diff --git a/mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/dict/DictList.vue b/mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/dict/DictList.vue new file mode 100644 index 0000000..e9f3356 --- /dev/null +++ b/mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/dict/DictList.vue @@ -0,0 +1,284 @@ + + + + + diff --git a/mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/email/EmailList.vue b/mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/email/EmailList.vue new file mode 100644 index 0000000..fa32ee4 --- /dev/null +++ b/mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/email/EmailList.vue @@ -0,0 +1,357 @@ + + + + + diff --git a/mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/file/FileList.vue b/mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/file/FileList.vue new file mode 100644 index 0000000..0884e17 --- /dev/null +++ b/mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/file/FileList.vue @@ -0,0 +1,361 @@ + + + + + diff --git a/mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/log/LoginLogList.vue b/mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/log/LoginLogList.vue new file mode 100644 index 0000000..90bc41c --- /dev/null +++ b/mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/log/LoginLogList.vue @@ -0,0 +1,185 @@ + + + + + diff --git a/mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/log/OpenTokenLogList.vue b/mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/log/OpenTokenLogList.vue new file mode 100644 index 0000000..ea5f604 --- /dev/null +++ b/mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/log/OpenTokenLogList.vue @@ -0,0 +1,82 @@ + + + + + diff --git a/mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/log/OperationLogList.vue b/mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/log/OperationLogList.vue new file mode 100644 index 0000000..fd96709 --- /dev/null +++ b/mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/log/OperationLogList.vue @@ -0,0 +1,314 @@ + + + + + diff --git a/mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/menu/MenuForm.vue b/mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/menu/MenuForm.vue new file mode 100644 index 0000000..67253f4 --- /dev/null +++ b/mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/menu/MenuForm.vue @@ -0,0 +1,325 @@ + + + + + diff --git a/mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/menu/MenuList.vue b/mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/menu/MenuList.vue new file mode 100644 index 0000000..1bee240 --- /dev/null +++ b/mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/menu/MenuList.vue @@ -0,0 +1,246 @@ + + + + + diff --git a/mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/role/RoleForm.vue b/mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/role/RoleForm.vue new file mode 100644 index 0000000..092c032 --- /dev/null +++ b/mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/role/RoleForm.vue @@ -0,0 +1,205 @@ + + + + + diff --git a/mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/role/RoleList.vue b/mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/role/RoleList.vue new file mode 100644 index 0000000..b3eeb41 --- /dev/null +++ b/mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/role/RoleList.vue @@ -0,0 +1,315 @@ + + + + + diff --git a/mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/system/ApiForm.vue b/mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/system/ApiForm.vue new file mode 100644 index 0000000..e7145a7 --- /dev/null +++ b/mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/system/ApiForm.vue @@ -0,0 +1,206 @@ + + + + + diff --git a/mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/system/ApiList.vue b/mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/system/ApiList.vue new file mode 100644 index 0000000..e06574b --- /dev/null +++ b/mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/system/ApiList.vue @@ -0,0 +1,280 @@ + + + + + diff --git a/mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/system/SystemForm.vue b/mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/system/SystemForm.vue new file mode 100644 index 0000000..a3ecb1e --- /dev/null +++ b/mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/system/SystemForm.vue @@ -0,0 +1,201 @@ + + + + + diff --git a/mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/system/SystemList.vue b/mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/system/SystemList.vue new file mode 100644 index 0000000..e95c0cf --- /dev/null +++ b/mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/system/SystemList.vue @@ -0,0 +1,238 @@ + + + + + diff --git a/mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/user/UserForm.vue b/mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/user/UserForm.vue new file mode 100644 index 0000000..4322ecf --- /dev/null +++ b/mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/user/UserForm.vue @@ -0,0 +1,309 @@ + + + + + diff --git a/mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/user/UserList.vue b/mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/user/UserList.vue new file mode 100644 index 0000000..adad61d --- /dev/null +++ b/mokee-gateway-web/backup-on-demand/views-20260612-2236/admin/user/UserList.vue @@ -0,0 +1,250 @@ + + + + + diff --git a/mokee-gateway-web/backup-on-demand/views-20260612-2236/docs/IntegrationDoc.vue b/mokee-gateway-web/backup-on-demand/views-20260612-2236/docs/IntegrationDoc.vue new file mode 100644 index 0000000..61b609a --- /dev/null +++ b/mokee-gateway-web/backup-on-demand/views-20260612-2236/docs/IntegrationDoc.vue @@ -0,0 +1,627 @@ + + + + + diff --git a/mokee-gateway-web/backup-on-demand/views-20260612-2236/login/LoginView.vue b/mokee-gateway-web/backup-on-demand/views-20260612-2236/login/LoginView.vue new file mode 100644 index 0000000..3ffb8ba --- /dev/null +++ b/mokee-gateway-web/backup-on-demand/views-20260612-2236/login/LoginView.vue @@ -0,0 +1,281 @@ + + + + + diff --git a/mokee-gateway-web/backup-on-demand/views-20260612-2236/select/SystemSelectView.vue b/mokee-gateway-web/backup-on-demand/views-20260612-2236/select/SystemSelectView.vue new file mode 100644 index 0000000..0a6d7a7 --- /dev/null +++ b/mokee-gateway-web/backup-on-demand/views-20260612-2236/select/SystemSelectView.vue @@ -0,0 +1,287 @@ + + + + + diff --git a/mokee-gateway-web/backup-on-demand/vite.config.ts.bak b/mokee-gateway-web/backup-on-demand/vite.config.ts.bak new file mode 100644 index 0000000..a634280 --- /dev/null +++ b/mokee-gateway-web/backup-on-demand/vite.config.ts.bak @@ -0,0 +1,28 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import path from 'path' + +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': path.resolve(__dirname, 'src'), + }, + }, + server: { + port: 5001, + }, + build: { + target: 'es2015', + cssTarget: 'chrome61', + rollupOptions: { + output: { + manualChunks(id) { + if (id.includes('node_modules/element-plus')) return 'el' + if (id.includes('node_modules/echarts')) return 'echarts' + if (id.includes('node_modules/vue') || id.includes('node_modules/vue-router') || id.includes('node_modules/pinia')) return 'vue-vendor' + }, + }, + }, + }, +}) diff --git a/mokee-gateway-web/components.d.ts b/mokee-gateway-web/components.d.ts new file mode 100644 index 0000000..bfc9abf --- /dev/null +++ b/mokee-gateway-web/components.d.ts @@ -0,0 +1,61 @@ +/* eslint-disable */ +// @ts-nocheck +// biome-ignore lint: disable +// oxlint-disable +// ------ +// Generated by unplugin-vue-components +// Read more: https://github.com/vuejs/core/pull/3399 + +export {} + +/* prettier-ignore */ +declare module 'vue' { + export interface GlobalComponents { + ElAvatar: typeof import('element-plus/es')['ElAvatar'] + ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb'] + ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem'] + ElButton: typeof import('element-plus/es')['ElButton'] + ElButtonGroup: typeof import('element-plus/es')['ElButtonGroup'] + ElCard: typeof import('element-plus/es')['ElCard'] + ElCheckbox: typeof import('element-plus/es')['ElCheckbox'] + ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup'] + ElCol: typeof import('element-plus/es')['ElCol'] + ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider'] + ElDatePicker: typeof import('element-plus/es')['ElDatePicker'] + ElDescriptions: typeof import('element-plus/es')['ElDescriptions'] + ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem'] + ElDialog: typeof import('element-plus/es')['ElDialog'] + ElDivider: typeof import('element-plus/es')['ElDivider'] + ElDropdown: typeof import('element-plus/es')['ElDropdown'] + ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem'] + ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu'] + ElEmpty: typeof import('element-plus/es')['ElEmpty'] + ElForm: typeof import('element-plus/es')['ElForm'] + ElFormItem: typeof import('element-plus/es')['ElFormItem'] + ElIcon: typeof import('element-plus/es')['ElIcon'] + ElInput: typeof import('element-plus/es')['ElInput'] + ElInputNumber: typeof import('element-plus/es')['ElInputNumber'] + ElOption: typeof import('element-plus/es')['ElOption'] + ElPageHeader: typeof import('element-plus/es')['ElPageHeader'] + ElPagination: typeof import('element-plus/es')['ElPagination'] + ElPopconfirm: typeof import('element-plus/es')['ElPopconfirm'] + ElRadio: typeof import('element-plus/es')['ElRadio'] + ElRadioButton: typeof import('element-plus/es')['ElRadioButton'] + ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup'] + ElRow: typeof import('element-plus/es')['ElRow'] + ElSelect: typeof import('element-plus/es')['ElSelect'] + ElSkeleton: typeof import('element-plus/es')['ElSkeleton'] + ElSwitch: typeof import('element-plus/es')['ElSwitch'] + ElTable: typeof import('element-plus/es')['ElTable'] + ElTableColumn: typeof import('element-plus/es')['ElTableColumn'] + ElTag: typeof import('element-plus/es')['ElTag'] + ElTree: typeof import('element-plus/es')['ElTree'] + ElTreeSelect: typeof import('element-plus/es')['ElTreeSelect'] + IconPicker: typeof import('./src/components/IconPicker.vue')['default'] + RouterLink: typeof import('vue-router')['RouterLink'] + RouterView: typeof import('vue-router')['RouterView'] + } + export interface GlobalDirectives { + vLoading: typeof import('element-plus/es')['ElLoadingDirective'] + } +} diff --git a/mokee-gateway-web/dist-widget/demo-widget.html b/mokee-gateway-web/dist-widget/demo-widget.html new file mode 100644 index 0000000..00f98d6 --- /dev/null +++ b/mokee-gateway-web/dist-widget/demo-widget.html @@ -0,0 +1,106 @@ + + + + + + Mokee UserChip Widget — 接入演示 + + + + + + + +

+
某业务系统
+
+ +
+
+
+ +
+

欢迎使用某业务系统

+

+ 右上角的用户头像下拉组件由 Mokee Gateway 提供。 + 外部系统只需三行代码即可接入,无需手写登录/鉴权/用户信息获取逻辑。 +

+

+ 组件功能:未登录显示"登录"按钮;已登录显示用户头像 + 用户名, + 下拉菜单包含"切换系统"和"退出登录"。 +

+
+ <!-- 1. 引入样式 -->
+ <link rel="stylesheet" href="https://web.gw.zgitm.com/widgets/user-chip.css" />

+ <!-- 2. 占位元素(只需配 API 网关地址) -->
+ <div id="mokee-user-chip"
+      data-api-base="https://gw.server.zgitm.com"></div>

+ <!-- 3. 引入脚本(自动渲染,login/select 地址自动推断) -->
+ <script src="https://web.gw.zgitm.com/widgets/user-chip.js"></script> +
+
+ + + + + diff --git a/mokee-gateway-web/dist-widget/user-chip.css b/mokee-gateway-web/dist-widget/user-chip.css new file mode 100644 index 0000000..ec0e0a3 --- /dev/null +++ b/mokee-gateway-web/dist-widget/user-chip.css @@ -0,0 +1 @@ +.mokee-uc-root[data-v-26ebf7bf]{--mokee-uc-text-primary: #1f2937;--mokee-uc-text-secondary: #6b7280;--mokee-uc-text-muted: #9ca3af;--mokee-uc-bg-hover: #f3f4f6;--mokee-uc-border-color: #e5e7eb;--mokee-uc-color-primary: #6366f1;--mokee-uc-radius-sm: 4px;--mokee-uc-radius-full: 24px;--mokee-uc-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans SC", sans-serif;--mokee-uc-transition: .15s ease;display:inline-flex;align-items:center;font-family:var(--mokee-uc-font-family);font-size:14px;line-height:1.5;color:var(--mokee-uc-text-primary);-webkit-font-smoothing:antialiased}.mokee-uc-chip[data-v-26ebf7bf]{display:inline-flex;align-items:center;gap:8px;padding:5px 12px 5px 5px;border-radius:var(--mokee-uc-radius-full);cursor:pointer;border:1px solid transparent;transition:all var(--mokee-uc-transition);user-select:none;white-space:nowrap}.mokee-uc-chip[data-v-26ebf7bf]:hover{background:var(--mokee-uc-bg-hover);border-color:var(--mokee-uc-border-color)}.mokee-uc-avatar[data-v-26ebf7bf]{flex-shrink:0}.mokee-uc-name[data-v-26ebf7bf]{font-size:14px;font-weight:500;color:var(--mokee-uc-text-secondary);max-width:100px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.mokee-uc-chevron[data-v-26ebf7bf]{color:var(--mokee-uc-text-muted);flex-shrink:0}.mokee-uc-chip--loading[data-v-26ebf7bf]{cursor:default;gap:10px;padding:5px 12px}.mokee-uc-chip--loading[data-v-26ebf7bf]:hover{background:transparent;border-color:transparent}.mokee-uc-skeleton-avatar[data-v-26ebf7bf]{width:28px;height:28px;border-radius:50%;background:var(--mokee-uc-border-color);animation:mokee-uc-pulse-26ebf7bf 1.5s ease-in-out infinite}.mokee-uc-skeleton-name[data-v-26ebf7bf]{width:50px;height:14px;border-radius:var(--mokee-uc-radius-sm);background:var(--mokee-uc-border-color);animation:mokee-uc-pulse-26ebf7bf 1.5s ease-in-out infinite}@keyframes mokee-uc-pulse-26ebf7bf{0%,to{opacity:1}50%{opacity:.4}}.mokee-uc-chip--guest[data-v-26ebf7bf]{padding:5px 14px;color:var(--mokee-uc-color-primary);font-weight:500} diff --git a/mokee-gateway-web/dist-widget/user-chip.js b/mokee-gateway-web/dist-widget/user-chip.js new file mode 100644 index 0000000..be98e22 --- /dev/null +++ b/mokee-gateway-web/dist-widget/user-chip.js @@ -0,0 +1,91 @@ +var IX=Object.defineProperty,PX=Object.defineProperties;var RX=Object.getOwnPropertyDescriptors;var Jd=Object.getOwnPropertySymbols;var N2=Object.prototype.hasOwnProperty,T2=Object.prototype.propertyIsEnumerable;var Zd=Math.pow,x2=(vn,Ft,kn)=>Ft in vn?IX(vn,Ft,{enumerable:!0,configurable:!0,writable:!0,value:kn}):vn[Ft]=kn,pe=(vn,Ft)=>{for(var kn in Ft||(Ft={}))N2.call(Ft,kn)&&x2(vn,kn,Ft[kn]);if(Jd)for(var kn of Jd(Ft))T2.call(Ft,kn)&&x2(vn,kn,Ft[kn]);return vn},Ge=(vn,Ft)=>PX(vn,RX(Ft));var ta=(vn,Ft)=>{var kn={};for(var tt in vn)N2.call(vn,tt)&&Ft.indexOf(tt)<0&&(kn[tt]=vn[tt]);if(vn!=null&&Jd)for(var tt of Jd(vn))Ft.indexOf(tt)<0&&T2.call(vn,tt)&&(kn[tt]=vn[tt]);return kn};var yt=(vn,Ft,kn)=>new Promise((tt,qr)=>{var ts=na=>{try{bn(kn.next(na))}catch(Gr){qr(Gr)}},ns=na=>{try{bn(kn.throw(na))}catch(Gr){qr(Gr)}},bn=na=>na.done?tt(na.value):Promise.resolve(na.value).then(ts,ns);bn((kn=kn.apply(vn,Ft)).next())});(function(){"use strict";/** +* @vue/shared v3.5.35 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/function vn(e){const t=Object.create(null);for(const n of e.split(","))t[n]=1;return n=>n in t}const Ft=process.env.NODE_ENV!=="production"?Object.freeze({}):{},kn=process.env.NODE_ENV!=="production"?Object.freeze([]):[],tt=()=>{},qr=()=>!1,ts=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&(e.charCodeAt(2)>122||e.charCodeAt(2)<97),ns=e=>e.startsWith("onUpdate:"),bn=Object.assign,na=(e,t)=>{const n=e.indexOf(t);n>-1&&e.splice(n,1)},Gr=Object.prototype.hasOwnProperty,Dt=(e,t)=>Gr.call(e,t),Se=Array.isArray,os=e=>Xr(e)==="[object Map]",Pu=e=>Xr(e)==="[object Set]",Ol=e=>Xr(e)==="[object Date]",He=e=>typeof e=="function",Le=e=>typeof e=="string",Co=e=>typeof e=="symbol",it=e=>e!==null&&typeof e=="object",Qa=e=>(it(e)||He(e))&&He(e.then)&&He(e.catch),$m=Object.prototype.toString,Xr=e=>$m.call(e),ef=e=>Xr(e).slice(8,-1),Jr=e=>Xr(e)==="[object Object]",Ru=e=>Le(e)&&e!=="NaN"&&e[0]!=="-"&&""+parseInt(e,10)===e,Zr=vn(",key,ref,ref_for,ref_key,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted"),O2=vn("bind,cloak,else-if,else,for,html,if,model,on,once,pre,show,slot,text,memo"),Au=e=>{const t=Object.create(null);return n=>t[n]||(t[n]=e(n))},$2=/-\w/g,Ln=Au(e=>e.replace($2,t=>t.slice(1).toUpperCase())),M2=/\B([A-Z])/g,el=Au(e=>e.replace(M2,"-$1").toLowerCase()),$l=Au(e=>e.charAt(0).toUpperCase()+e.slice(1)),oa=Au(e=>e?`on${$l(e)}`:""),Ta=(e,t)=>!Object.is(e,t),js=(e,...t)=>{for(let n=0;n{Object.defineProperty(e,t,{configurable:!0,enumerable:!1,writable:o,value:n})},tf=e=>{const t=parseFloat(e);return isNaN(t)?e:t},_2=e=>{const t=Le(e)?Number(e):NaN;return isNaN(t)?e:t};let Mm;const Qr=()=>Mm||(Mm=typeof globalThis!="undefined"?globalThis:typeof self!="undefined"?self:typeof window!="undefined"?window:typeof global!="undefined"?global:{});function Ye(e){if(Se(e)){const t={};for(let n=0;n{if(n){const o=n.split(P2);o.length>1&&(t[o[0].trim()]=o[1].trim())}}),t}function _(e){let t="";if(Le(e))t=e;else if(Se(e))for(let n=0;nas(n,t))}const Pm=e=>!!(e&&e.__v_isRef===!0),xe=e=>Le(e)?e:e==null?"":Se(e)||it(e)&&(e.toString===$m||!He(e.toString))?Pm(e)?xe(e.value):JSON.stringify(e,Rm,2):String(e),Rm=(e,t)=>Pm(t)?Rm(e,t.value):os(t)?{[`Map(${t.size})`]:[...t.entries()].reduce((n,[o,a],l)=>(n[nf(o,l)+" =>"]=a,n),{})}:Pu(t)?{[`Set(${t.size})`]:[...t.values()].map(n=>nf(n))}:Co(t)?nf(t):it(t)&&!Se(t)&&!Jr(t)?String(t):t,nf=(e,t="")=>{var n;return Co(e)?`Symbol(${(n=e.description)!=null?n:t})`:e};/** +* @vue/reactivity v3.5.35 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/function _o(e,...t){console.warn(`[Vue warn] ${e}`,...t)}let Un;class Am{constructor(t=!1){this.detached=t,this._active=!0,this._on=0,this.effects=[],this.cleanups=[],this._isPaused=!1,this._warnOnRun=!0,this.__v_skip=!0,!t&&Un&&(Un.active?(this.parent=Un,this.index=(Un.scopes||(Un.scopes=[])).push(this)-1):(this._active=!1,this._warnOnRun=!1))}get active(){return this._active}pause(){if(this._active){this._isPaused=!0;let t,n;if(this.scopes)for(t=0,n=this.scopes.length;t0&&--this._on===0){if(Un===this)Un=this.prevScope;else{let t=Un;for(;t;){if(t.prevScope===this){t.prevScope=this.prevScope;break}t=t.prevScope}}this.prevScope=void 0}}stop(t){if(this._active){this._active=!1;let n,o;for(n=0,o=this.effects.length;n0)return;if(ti){let t=ti;for(ti=void 0;t;){const n=t.next;t.next=void 0,t.flags&=-9,t=n}}let e;for(;ei;){let t=ei;for(ei=void 0;t;){const n=t.next;if(t.next=void 0,t.flags&=-9,t.flags&1)try{t.trigger()}catch(o){e||(e=o)}t=n}}if(e)throw e}function Fm(e){for(let t=e.deps;t;t=t.nextDep)t.version=-1,t.prevActiveLink=t.dep.activeLink,t.dep.activeLink=t}function zm(e){let t,n=e.depsTail,o=n;for(;o;){const a=o.prevDep;o.version===-1?(o===n&&(n=a),uf(o),j2(o)):t=o,o.dep.activeLink=o.prevActiveLink,o.prevActiveLink=void 0,o=a}e.deps=t,e.depsTail=n}function rf(e){for(let t=e.deps;t;t=t.nextDep)if(t.dep.version!==t.version||t.dep.computed&&(Hm(t.dep.computed)||t.dep.version!==t.version))return!0;return!!e._dirty}function Hm(e){if(e.flags&4&&!(e.flags&16)||(e.flags&=-17,e.globalVersion===ni)||(e.globalVersion=ni,!e.isSSR&&e.flags&128&&(!e.deps&&!e._dirty||!rf(e))))return;e.flags|=2;const t=e.dep,n=un,o=aa;un=e,aa=!0;try{Fm(e);const a=e.fn(e._value);(t.version===0||Ta(a,e._value))&&(e.flags|=128,e._value=a,t.version++)}catch(a){throw t.version++,a}finally{un=n,aa=o,zm(e),e.flags&=-3}}function uf(e,t=!1){const{dep:n,prevSub:o,nextSub:a}=e;if(o&&(o.nextSub=a,e.prevSub=void 0),a&&(a.prevSub=o,e.nextSub=void 0),process.env.NODE_ENV!=="production"&&n.subsHead===e&&(n.subsHead=a),n.subs===e&&(n.subs=o,!o&&n.computed)){n.computed.flags&=-5;for(let l=n.computed.deps;l;l=l.nextDep)uf(l,!0)}!t&&!--n.sc&&n.map&&n.map.delete(n.key)}function j2(e){const{prevDep:t,nextDep:n}=e;t&&(t.nextDep=n,e.prevDep=void 0),n&&(n.prevDep=t,e.nextDep=void 0)}let aa=!0;const Km=[];function la(){Km.push(aa),aa=!1}function sa(){const e=Km.pop();aa=e===void 0?!0:e}function Wm(e){const{cleanup:t}=e;if(e.cleanup=void 0,t){const n=un;un=void 0;try{t()}finally{un=n}}}let ni=0;class U2{constructor(t,n){this.sub=t,this.dep=n,this.version=n.version,this.nextDep=this.prevDep=this.nextSub=this.prevSub=this.prevActiveLink=void 0}}class cf{constructor(t){this.computed=t,this.version=0,this.activeLink=void 0,this.subs=void 0,this.map=void 0,this.key=void 0,this.sc=0,this.__v_skip=!0,process.env.NODE_ENV!=="production"&&(this.subsHead=void 0)}track(t){if(!un||!aa||un===this.computed)return;let n=this.activeLink;if(n===void 0||n.sub!==un)n=this.activeLink=new U2(un,this),un.deps?(n.prevDep=un.depsTail,un.depsTail.nextDep=n,un.depsTail=n):un.deps=un.depsTail=n,jm(n);else if(n.version===-1&&(n.version=this.version,n.nextDep)){const o=n.nextDep;o.prevDep=n.prevDep,n.prevDep&&(n.prevDep.nextDep=o),n.prevDep=un.depsTail,n.nextDep=void 0,un.depsTail.nextDep=n,un.depsTail=n,un.deps===n&&(un.deps=o)}return process.env.NODE_ENV!=="production"&&un.onTrack&&un.onTrack(bn({effect:un},t)),n}trigger(t){this.version++,ni++,this.notify(t)}notify(t){lf();try{if(process.env.NODE_ENV!=="production")for(let n=this.subsHead;n;n=n.nextSub)n.sub.onTrigger&&!(n.sub.flags&8)&&n.sub.onTrigger(bn({effect:n.sub},t));for(let n=this.subs;n;n=n.prevSub)n.sub.notify()&&n.sub.dep.notify()}finally{sf()}}}function jm(e){if(e.dep.sc++,e.sub.flags&4){const t=e.dep.computed;if(t&&!e.dep.subs){t.flags|=20;for(let o=t.deps;o;o=o.nextDep)jm(o)}const n=e.dep.subs;n!==e&&(e.prevSub=n,n&&(n.nextSub=e)),process.env.NODE_ENV!=="production"&&e.dep.subsHead===void 0&&(e.dep.subsHead=e),e.dep.subs=e}}const Vu=new WeakMap,ls=Symbol(process.env.NODE_ENV!=="production"?"Object iterate":""),df=Symbol(process.env.NODE_ENV!=="production"?"Map keys iterate":""),oi=Symbol(process.env.NODE_ENV!=="production"?"Array iterate":"");function Yn(e,t,n){if(aa&&un){let o=Vu.get(e);o||Vu.set(e,o=new Map);let a=o.get(n);a||(o.set(n,a=new cf),a.map=o,a.key=n),process.env.NODE_ENV!=="production"?a.track({target:e,type:t,key:n}):a.track()}}function Oa(e,t,n,o,a,l){const s=Vu.get(e);if(!s){ni++;return}const r=u=>{u&&(process.env.NODE_ENV!=="production"?u.trigger({target:e,type:t,key:n,newValue:o,oldValue:a,oldTarget:l}):u.trigger())};if(lf(),t==="clear")s.forEach(r);else{const u=Se(e),c=u&&Ru(n);if(u&&n==="length"){const d=Number(o);s.forEach((f,v)=>{(v==="length"||v===oi||!Co(v)&&v>=d)&&r(f)})}else switch((n!==void 0||s.has(void 0))&&r(s.get(n)),c&&r(s.get(oi)),t){case"add":u?c&&r(s.get("length")):(r(s.get(ls)),os(e)&&r(s.get(df)));break;case"delete":u||(r(s.get(ls)),os(e)&&r(s.get(df)));break;case"set":os(e)&&r(s.get(ls));break}}sf()}function Y2(e,t){const n=Vu.get(e);return n&&n.get(t)}function Us(e){const t=Ht(e);return t===e?t:(Yn(t,"iterate",oi),no(e)?t:t.map(ia))}function Lu(e){return Yn(e=Ht(e),"iterate",oi),e}function $a(e,t){return ra(e)?Gs(Ml(e)?ia(t):t):ia(t)}const q2={__proto__:null,[Symbol.iterator](){return ff(this,Symbol.iterator,e=>$a(this,e))},concat(...e){return Us(this).concat(...e.map(t=>Se(t)?Us(t):t))},entries(){return ff(this,"entries",e=>(e[1]=$a(this,e[1]),e))},every(e,t){return nl(this,"every",e,t,void 0,arguments)},filter(e,t){return nl(this,"filter",e,t,n=>n.map(o=>$a(this,o)),arguments)},find(e,t){return nl(this,"find",e,t,n=>$a(this,n),arguments)},findIndex(e,t){return nl(this,"findIndex",e,t,void 0,arguments)},findLast(e,t){return nl(this,"findLast",e,t,n=>$a(this,n),arguments)},findLastIndex(e,t){return nl(this,"findLastIndex",e,t,void 0,arguments)},forEach(e,t){return nl(this,"forEach",e,t,void 0,arguments)},includes(...e){return pf(this,"includes",e)},indexOf(...e){return pf(this,"indexOf",e)},join(e){return Us(this).join(e)},lastIndexOf(...e){return pf(this,"lastIndexOf",e)},map(e,t){return nl(this,"map",e,t,void 0,arguments)},pop(){return ai(this,"pop")},push(...e){return ai(this,"push",e)},reduce(e,...t){return Um(this,"reduce",e,t)},reduceRight(e,...t){return Um(this,"reduceRight",e,t)},shift(){return ai(this,"shift")},some(e,t){return nl(this,"some",e,t,void 0,arguments)},splice(...e){return ai(this,"splice",e)},toReversed(){return Us(this).toReversed()},toSorted(e){return Us(this).toSorted(e)},toSpliced(...e){return Us(this).toSpliced(...e)},unshift(...e){return ai(this,"unshift",e)},values(){return ff(this,"values",e=>$a(this,e))}};function ff(e,t,n){const o=Lu(e),a=o[t]();return o!==e&&!no(e)&&(a._next=a.next,a.next=()=>{const l=a._next();return l.done||(l.value=n(l.value)),l}),a}const G2=Array.prototype;function nl(e,t,n,o,a,l){const s=Lu(e),r=s!==e&&!no(e),u=s[t];if(u!==G2[t]){const f=u.apply(e,l);return r?ia(f):f}let c=n;s!==e&&(r?c=function(f,v){return n.call(this,$a(e,f),v,e)}:n.length>2&&(c=function(f,v){return n.call(this,f,v,e)}));const d=u.call(s,c,o);return r&&a?a(d):d}function Um(e,t,n,o){const a=Lu(e),l=a!==e&&!no(e);let s=n,r=!1;a!==e&&(l?(r=o.length===0,s=function(c,d,f){return r&&(r=!1,c=$a(e,c)),n.call(this,c,$a(e,d),f,e)}):n.length>3&&(s=function(c,d,f){return n.call(this,c,d,f,e)}));const u=a[t](s,...o);return r?$a(e,u):u}function pf(e,t,n){const o=Ht(e);Yn(o,"iterate",oi);const a=o[t](...n);return(a===-1||a===!1)&&qs(n[0])?(n[0]=Ht(n[0]),o[t](...n)):a}function ai(e,t,n=[]){la(),lf();const o=Ht(e)[t].apply(e,n);return sf(),sa(),o}const X2=vn("__proto__,__v_isRef,__isVue"),Ym=new Set(Object.getOwnPropertyNames(Symbol).filter(e=>e!=="arguments"&&e!=="caller").map(e=>Symbol[e]).filter(Co));function J2(e){Co(e)||(e=String(e));const t=Ht(this);return Yn(t,"has",e),t.hasOwnProperty(e)}class qm{constructor(t=!1,n=!1){this._isReadonly=t,this._isShallow=n}get(t,n,o){if(n==="__v_skip")return t.__v_skip;const a=this._isReadonly,l=this._isShallow;if(n==="__v_isReactive")return!a;if(n==="__v_isReadonly")return a;if(n==="__v_isShallow")return l;if(n==="__v_raw")return o===(a?l?tg:eg:l?Qm:Zm).get(t)||Object.getPrototypeOf(t)===Object.getPrototypeOf(o)?t:void 0;const s=Se(t);if(!a){let u;if(s&&(u=q2[n]))return u;if(n==="hasOwnProperty")return J2}const r=Reflect.get(t,n,tn(t)?t:o);if((Co(n)?Ym.has(n):X2(n))||(a||Yn(t,"get",n),l))return r;if(tn(r)){const u=s&&Ru(n)?r:r.value;return a&&it(u)?Ys(u):u}return it(r)?a?Ys(r):zt(r):r}}class Gm extends qm{constructor(t=!1){super(!1,t)}set(t,n,o,a){let l=t[n];const s=Se(t)&&Ru(n);if(!this._isShallow){const c=ra(l);if(!no(o)&&!ra(o)&&(l=Ht(l),o=Ht(o)),!s&&tn(l)&&!tn(o))return c?(process.env.NODE_ENV!=="production"&&_o(`Set operation on key "${String(n)}" failed: target is readonly.`,t[n]),!0):(l.value=o,!0)}const r=s?Number(n)e,Bu=e=>Reflect.getPrototypeOf(e);function nx(e,t,n){return function(...o){const a=this.__v_raw,l=Ht(a),s=os(l),r=e==="entries"||e===Symbol.iterator&&s,u=e==="keys"&&s,c=a[e](...o),d=n?vf:t?Gs:ia;return!t&&Yn(l,"iterate",u?df:ls),bn(Object.create(c),{next(){const{value:f,done:v}=c.next();return v?{value:f,done:v}:{value:r?[d(f[0]),d(f[1])]:d(f),done:v}}})}}function Fu(e){return function(...t){if(process.env.NODE_ENV!=="production"){const n=t[0]?`on key "${t[0]}" `:"";_o(`${$l(e)} operation ${n}failed: target is readonly.`,Ht(this))}return e==="delete"?!1:e==="clear"?void 0:this}}function ox(e,t){const n={get(a){const l=this.__v_raw,s=Ht(l),r=Ht(a);e||(Ta(a,r)&&Yn(s,"get",a),Yn(s,"get",r));const{has:u}=Bu(s),c=t?vf:e?Gs:ia;if(u.call(s,a))return c(l.get(a));if(u.call(s,r))return c(l.get(r));l!==s&&l.get(a)},get size(){const a=this.__v_raw;return!e&&Yn(Ht(a),"iterate",ls),a.size},has(a){const l=this.__v_raw,s=Ht(l),r=Ht(a);return e||(Ta(a,r)&&Yn(s,"has",a),Yn(s,"has",r)),a===r?l.has(a):l.has(a)||l.has(r)},forEach(a,l){const s=this,r=s.__v_raw,u=Ht(r),c=t?vf:e?Gs:ia;return!e&&Yn(u,"iterate",ls),r.forEach((d,f)=>a.call(l,c(d),c(f),s))}};return bn(n,e?{add:Fu("add"),set:Fu("set"),delete:Fu("delete"),clear:Fu("clear")}:{add(a){const l=Ht(this),s=Bu(l),r=Ht(a),u=!t&&!no(a)&&!ra(a)?r:a;return s.has.call(l,u)||Ta(a,u)&&s.has.call(l,a)||Ta(r,u)&&s.has.call(l,r)||(l.add(u),Oa(l,"add",u,u)),this},set(a,l){!t&&!no(l)&&!ra(l)&&(l=Ht(l));const s=Ht(this),{has:r,get:u}=Bu(s);let c=r.call(s,a);c?process.env.NODE_ENV!=="production"&&Jm(s,r,a):(a=Ht(a),c=r.call(s,a));const d=u.call(s,a);return s.set(a,l),c?Ta(l,d)&&Oa(s,"set",a,l,d):Oa(s,"add",a,l),this},delete(a){const l=Ht(this),{has:s,get:r}=Bu(l);let u=s.call(l,a);u?process.env.NODE_ENV!=="production"&&Jm(l,s,a):(a=Ht(a),u=s.call(l,a));const c=r?r.call(l,a):void 0,d=l.delete(a);return u&&Oa(l,"delete",a,void 0,c),d},clear(){const a=Ht(this),l=a.size!==0,s=process.env.NODE_ENV!=="production"?os(a)?new Map(a):new Set(a):void 0,r=a.clear();return l&&Oa(a,"clear",void 0,void 0,s),r}}),["keys","values","entries",Symbol.iterator].forEach(a=>{n[a]=nx(a,e,t)}),n}function zu(e,t){const n=ox(e,t);return(o,a,l)=>a==="__v_isReactive"?!e:a==="__v_isReadonly"?e:a==="__v_raw"?o:Reflect.get(Dt(n,a)&&a in o?n:o,a,l)}const ax={get:zu(!1,!1)},lx={get:zu(!1,!0)},sx={get:zu(!0,!1)},rx={get:zu(!0,!0)};function Jm(e,t,n){const o=Ht(n);if(o!==n&&t.call(e,o)){const a=ef(e);_o(`Reactive ${a} contains both the raw and reactive versions of the same object${a==="Map"?" as keys":""}, which can lead to inconsistencies. Avoid differentiating between the raw and reactive versions of an object and only use the reactive version if possible.`)}}const Zm=new WeakMap,Qm=new WeakMap,eg=new WeakMap,tg=new WeakMap;function ix(e){switch(e){case"Object":case"Array":return 1;case"Map":case"Set":case"WeakMap":case"WeakSet":return 2;default:return 0}}function zt(e){return ra(e)?e:Hu(e,!1,Z2,ax,Zm)}function hf(e){return Hu(e,!1,ex,lx,Qm)}function Ys(e){return Hu(e,!0,Q2,sx,eg)}function Fo(e){return Hu(e,!0,tx,rx,tg)}function Hu(e,t,n,o,a){if(!it(e))return process.env.NODE_ENV!=="production"&&_o(`value cannot be made ${t?"readonly":"reactive"}: ${String(e)}`),e;if(e.__v_raw&&!(t&&e.__v_isReactive)||e.__v_skip||!Object.isExtensible(e))return e;const l=a.get(e);if(l)return l;const s=ix(ef(e));if(s===0)return e;const r=new Proxy(e,s===2?o:n);return a.set(e,r),r}function Ml(e){return ra(e)?Ml(e.__v_raw):!!(e&&e.__v_isReactive)}function ra(e){return!!(e&&e.__v_isReadonly)}function no(e){return!!(e&&e.__v_isShallow)}function qs(e){return e?!!e.__v_raw:!1}function Ht(e){const t=e&&e.__v_raw;return t?Ht(t):e}function Ma(e){return!Dt(e,"__v_skip")&&Object.isExtensible(e)&&Du(e,"__v_skip",!0),e}const ia=e=>it(e)?zt(e):e,Gs=e=>it(e)?Ys(e):e;function tn(e){return e?e.__v_isRef===!0:!1}function D(e){return ng(e,!1)}function Mt(e){return ng(e,!0)}function ng(e,t){return tn(e)?e:new ux(e,t)}class ux{constructor(t,n){this.dep=new cf,this.__v_isRef=!0,this.__v_isShallow=!1,this._rawValue=n?t:Ht(t),this._value=n?t:ia(t),this.__v_isShallow=n}get value(){return process.env.NODE_ENV!=="production"?this.dep.track({target:this,type:"get",key:"value"}):this.dep.track(),this._value}set value(t){const n=this._rawValue,o=this.__v_isShallow||no(t)||ra(t);t=o?t:Ht(t),Ta(t,n)&&(this._rawValue=t,this._value=o?t:ia(t),process.env.NODE_ENV!=="production"?this.dep.trigger({target:this,type:"set",key:"value",newValue:t,oldValue:n}):this.dep.trigger())}}function Ku(e){e.dep&&(process.env.NODE_ENV!=="production"?e.dep.trigger({target:e,type:"set",key:"value",newValue:e._value}):e.dep.trigger())}function i(e){return tn(e)?e.value:e}function Mn(e){return He(e)?e():i(e)}const cx={get:(e,t,n)=>t==="__v_raw"?e:i(Reflect.get(e,t,n)),set:(e,t,n,o)=>{const a=e[t];return tn(a)&&!tn(n)?(a.value=n,!0):Reflect.set(e,t,n,o)}};function og(e){return Ml(e)?e:new Proxy(e,cx)}function Fn(e){process.env.NODE_ENV!=="production"&&!qs(e)&&_o("toRefs() expects a reactive object but received a plain one.");const t=Se(e)?new Array(e.length):{};for(const n in e)t[n]=ag(e,n);return t}class dx{constructor(t,n,o){this._object=t,this._defaultValue=o,this.__v_isRef=!0,this._value=void 0,this._key=Co(n)?n:String(n),this._raw=Ht(t);let a=!0,l=t;if(!Se(t)||Co(this._key)||!Ru(this._key))do a=!qs(l)||no(l);while(a&&(l=l.__v_raw));this._shallow=a}get value(){let t=this._object[this._key];return this._shallow&&(t=i(t)),this._value=t===void 0?this._defaultValue:t}set value(t){if(this._shallow&&tn(this._raw[this._key])){const n=this._object[this._key];if(tn(n)){n.value=t;return}}this._object[this._key]=t}get dep(){return Y2(this._raw,this._key)}}class fx{constructor(t){this._getter=t,this.__v_isRef=!0,this.__v_isReadonly=!0,this._value=void 0}get value(){return this._value=this._getter()}}function jt(e,t,n){return tn(e)?e:He(e)?new fx(e):it(e)&&arguments.length>1?ag(e,t,n):D(e)}function ag(e,t,n){return new dx(e,t,n)}class px{constructor(t,n,o){this.fn=t,this.setter=n,this._value=void 0,this.dep=new cf(this),this.__v_isRef=!0,this.deps=void 0,this.depsTail=void 0,this.flags=16,this.globalVersion=ni-1,this.next=void 0,this.effect=this,this.__v_isReadonly=!n,this.isSSR=o}notify(){if(this.flags|=16,!(this.flags&8)&&un!==this)return Bm(this,!0),!0;process.env.NODE_ENV}get value(){const t=process.env.NODE_ENV!=="production"?this.dep.track({target:this,type:"get",key:"value"}):this.dep.track();return Hm(this),t&&(t.version=this.dep.version),this._value}set value(t){this.setter?this.setter(t):process.env.NODE_ENV!=="production"&&_o("Write operation failed: computed value is readonly")}}function vx(e,t,n=!1){let o,a;He(e)?o=e:(o=e.get,a=e.set);const l=new px(o,a,n);return process.env.NODE_ENV,l}const Wu={},ju=new WeakMap;let ss;function hx(e,t=!1,n=ss){if(n){let o=ju.get(n);o||ju.set(n,o=[]),o.push(e)}else process.env.NODE_ENV!=="production"&&!t&&_o("onWatcherCleanup() was called when there was no active watcher to associate with.")}function mx(e,t,n=Ft){const{immediate:o,deep:a,once:l,scheduler:s,augmentJob:r,call:u}=n,c=S=>{(n.onWarn||_o)("Invalid watch source: ",S,"A watch source can only be a getter/effect function, a ref, a reactive object, or an array of these types.")},d=S=>a?S:no(S)||a===!1||a===0?ol(S,1):ol(S);let f,v,g,p,m=!1,h=!1;if(tn(e)?(v=()=>e.value,m=no(e)):Ml(e)?(v=()=>d(e),m=!0):Se(e)?(h=!0,m=e.some(S=>Ml(S)||no(S)),v=()=>e.map(S=>{if(tn(S))return S.value;if(Ml(S))return d(S);if(He(S))return u?u(S,2):S();process.env.NODE_ENV!=="production"&&c(S)})):He(e)?t?v=u?()=>u(e,2):e:v=()=>{if(g){la();try{g()}finally{sa()}}const S=ss;ss=f;try{return u?u(e,3,[p]):e(p)}finally{ss=S}}:(v=tt,process.env.NODE_ENV!=="production"&&c(e)),t&&a){const S=v,E=a===!0?1/0:a;v=()=>ol(S(),E)}const y=of(),w=()=>{f.stop(),y&&y.active&&na(y.effects,f)};if(l&&t){const S=t;t=(...E)=>{S(...E),w()}}let b=h?new Array(e.length).fill(Wu):Wu;const C=S=>{if(!(!(f.flags&1)||!f.dirty&&!S))if(t){const E=f.run();if(a||m||(h?E.some((x,O)=>Ta(x,b[O])):Ta(E,b))){g&&g();const x=ss;ss=f;try{const O=[E,b===Wu?void 0:h&&b[0]===Wu?[]:b,p];b=E,u?u(t,3,O):t(...O)}finally{ss=x}}}else f.run()};return r&&r(C),f=new Vm(v),f.scheduler=s?()=>s(C,!1):C,p=S=>hx(S,!1,f),g=f.onStop=()=>{const S=ju.get(f);if(S){if(u)u(S,4);else for(const E of S)E();ju.delete(f)}},process.env.NODE_ENV!=="production"&&(f.onTrack=n.onTrack,f.onTrigger=n.onTrigger),t?o?C(!0):b=f.run():s?s(C.bind(null,!0),!0):f.run(),w.pause=f.pause.bind(f),w.resume=f.resume.bind(f),w.stop=w,w}function ol(e,t=1/0,n){if(t<=0||!it(e)||e.__v_skip||(n=n||new Map,(n.get(e)||0)>=t))return e;if(n.set(e,t),t--,tn(e))ol(e.value,t,n);else if(Se(e))for(let o=0;o{ol(o,t,n)});else if(Jr(e)){for(const o in e)ol(e[o],t,n);for(const o of Object.getOwnPropertySymbols(e))Object.prototype.propertyIsEnumerable.call(e,o)&&ol(e[o],t,n)}return e}/** +* @vue/runtime-core v3.5.35 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/const rs=[];function Uu(e){rs.push(e)}function Yu(){rs.pop()}let mf=!1;function Ze(e,...t){if(mf)return;mf=!0,la();const n=rs.length?rs[rs.length-1].component:null,o=n&&n.appContext.config.warnHandler,a=gx();if(o)Xs(o,n,11,[e+t.map(l=>{var s,r;return(r=(s=l.toString)==null?void 0:s.call(l))!=null?r:JSON.stringify(l)}).join(""),n&&n.proxy,a.map(({vnode:l})=>`at <${Ci(n,l.type)}>`).join(` +`),a]);else{const l=[`[Vue warn]: ${e}`,...t];a.length&&l.push(` +`,...yx(a)),console.warn(...l)}sa(),mf=!1}function gx(){let e=rs[rs.length-1];if(!e)return[];const t=[];for(;e;){const n=t[0];n&&n.vnode===e?n.recurseCount++:t.push({vnode:e,recurseCount:0});const o=e.component&&e.component.parent;e=o&&o.vnode}return t}function yx(e){const t=[];return e.forEach((n,o)=>{t.push(...o===0?[]:[` +`],...bx(n))}),t}function bx({vnode:e,recurseCount:t}){const n=t>0?`... (${t} recursive calls)`:"",o=e.component?e.component.parent==null:!1,a=` at <${Ci(e.component,e.type,o)}`,l=">"+n;return e.props?[a,...wx(e.props),l]:[a+l]}function wx(e){const t=[],n=Object.keys(e);return n.slice(0,3).forEach(o=>{t.push(...lg(o,e[o]))}),n.length>3&&t.push(" ..."),t}function lg(e,t,n){return Le(t)?(t=JSON.stringify(t),n?t:[`${e}=${t}`]):typeof t=="number"||typeof t=="boolean"||t==null?n?t:[`${e}=${t}`]:tn(t)?(t=lg(e,Ht(t.value),!0),n?t:[`${e}=Ref<`,t,">"]):He(t)?[`${e}=fn${t.name?`<${t.name}>`:""}`]:(t=Ht(t),n?t:[`${e}=`,t])}function Sx(e,t){process.env.NODE_ENV!=="production"&&e!==void 0&&(typeof e!="number"?Ze(`${t} is not a valid number - got ${JSON.stringify(e)}.`):isNaN(e)&&Ze(`${t} is NaN - the duration expression might be incorrect.`))}const gf={sp:"serverPrefetch hook",bc:"beforeCreate hook",c:"created hook",bm:"beforeMount hook",m:"mounted hook",bu:"beforeUpdate hook",u:"updated",bum:"beforeUnmount hook",um:"unmounted hook",a:"activated hook",da:"deactivated hook",ec:"errorCaptured hook",rtc:"renderTracked hook",rtg:"renderTriggered hook",0:"setup function",1:"render function",2:"watcher getter",3:"watcher callback",4:"watcher cleanup function",5:"native event handler",6:"component event handler",7:"vnode hook",8:"directive hook",9:"transition hook",10:"app errorHandler",11:"app warnHandler",12:"ref function",13:"async component loader",14:"scheduler flush",15:"component update",16:"app unmount cleanup function"};function Xs(e,t,n,o){try{return o?e(...o):e()}catch(a){li(a,t,n)}}function zo(e,t,n,o){if(He(e)){const a=Xs(e,t,n,o);return a&&Qa(a)&&a.catch(l=>{li(l,t,n)}),a}if(Se(e)){const a=[];for(let l=0;l>>1,a=po[o],l=si(a);l=si(n)?po.push(e):po.splice(Ex(t),0,e),e.flags|=1,rg()}}function rg(){qu||(qu=sg.then(dg))}function ig(e){Se(e)?Js.push(...e):_l&&e.id===-1?_l.splice(Zs+1,0,e):e.flags&1||(Js.push(e),e.flags|=1),rg()}function ug(e,t,n=_a+1){for(process.env.NODE_ENV!=="production"&&(t=t||new Map);nsi(n)-si(o));if(Js.length=0,_l){_l.push(...t);return}for(_l=t,process.env.NODE_ENV!=="production"&&(e=e||new Map),Zs=0;Zs<_l.length;Zs++){const n=_l[Zs];process.env.NODE_ENV!=="production"&&yf(e,n)||(n.flags&4&&(n.flags&=-2),n.flags&8||n(),n.flags&=-2)}_l=null,Zs=0}}const si=e=>e.id==null?e.flags&2?-1:1/0:e.id;function dg(e){process.env.NODE_ENV!=="production"&&(e=e||new Map);const t=process.env.NODE_ENV!=="production"?n=>yf(e,n):tt;try{for(_a=0;_akx){const o=t.i,a=o&&Uf(o.type);return li(`Maximum recursive updates exceeded${a?` in component <${a}>`:""}. This means you have a reactive effect that is mutating its own dependencies and thus recursively triggering itself. Possible sources include component template, render function, updated hook or watcher source function.`,null,10),!0}return e.set(t,n+1),!1}let vo=!1;const fg=e=>{try{return vo}finally{vo=e}},Xu=new Map;process.env.NODE_ENV!=="production"&&(Qr().__VUE_HMR_RUNTIME__={createRecord:bf(pg),rerender:bf(Tx),reload:bf(Ox)});const is=new Map;function xx(e){const t=e.type.__hmrId;let n=is.get(t);n||(pg(t,e.type),n=is.get(t)),n.instances.add(e)}function Nx(e){is.get(e.type.__hmrId).instances.delete(e)}function pg(e,t){return is.has(e)?!1:(is.set(e,{initialDef:Ju(t),instances:new Set}),!0)}function Ju(e){return Ny(e)?e.__vccOpts:e}function Tx(e,t){const n=is.get(e);n&&(n.initialDef.render=t,[...n.instances].forEach(o=>{t&&(o.render=t,Ju(o.type).render=t),o.renderCache=[],vo=!0,o.job.flags&8||o.update(),vo=!1}))}function Ox(e,t){const n=is.get(e);if(!n)return;t=Ju(t),vg(n.initialDef,t);const o=[...n.instances];for(let a=0;a{l.job.flags&8||(vo=!0,l.parent.update(),vo=!1,r.delete(l))}):l.appContext.reload?l.appContext.reload():typeof window!="undefined"?window.location.reload():console.warn("[HMR] Root or manually mounted instance modified. Full reload required."),l.root.ce&&l!==l.root&&l.root.ce._removeChildStyle(s)}ig(()=>{Xu.clear()})}function vg(e,t){bn(e,t);for(const n in e)n!=="__file"&&!(n in t)&&delete e[n]}function bf(e){return(t,n)=>{try{return e(t,n)}catch(o){console.error(o),console.warn("[HMR] Something went wrong during Vue component hot-reload. Full reload required.")}}}let ua,ri=[],wf=!1;function ii(e,...t){ua?ua.emit(e,...t):wf||ri.push({event:e,args:t})}function Sf(e,t){var n,o;ua=e,ua?(ua.enabled=!0,ri.forEach(({event:a,args:l})=>ua.emit(a,...l)),ri=[]):typeof window!="undefined"&&window.HTMLElement&&!((o=(n=window.navigator)==null?void 0:n.userAgent)!=null&&o.includes("jsdom"))?((t.__VUE_DEVTOOLS_HOOK_REPLAY__=t.__VUE_DEVTOOLS_HOOK_REPLAY__||[]).push(l=>{Sf(l,t)}),setTimeout(()=>{ua||(t.__VUE_DEVTOOLS_HOOK_REPLAY__=null,wf=!0,ri=[])},3e3)):(wf=!0,ri=[])}function $x(e,t){ii("app:init",e,t,{Fragment:je,Text:Pl,Comment:cn,Static:ic})}function Mx(e){ii("app:unmount",e)}const _x=Cf("component:added"),hg=Cf("component:updated"),Ix=Cf("component:removed"),Px=e=>{ua&&typeof ua.cleanupBuffer=="function"&&!ua.cleanupBuffer(e)&&Ix(e)};function Cf(e){return t=>{ii(e,t.appContext.app,t.uid,t.parent?t.parent.uid:void 0,t)}}const Rx=mg("perf:start"),Ax=mg("perf:end");function mg(e){return(t,n,o)=>{ii(e,t.appContext.app,t.uid,t,n,o)}}function Dx(e,t,n){ii("component:emit",e.appContext.app,e,t,n)}let zn=null,gg=null;function Zu(e){const t=zn;return zn=e,gg=e&&e.type.__scopeId||null,t}function Q(e,t=zn,n){if(!t||e._n)return e;const o=(...a)=>{o._d&&uc(-1);const l=Zu(t);let s;try{s=e(...a)}finally{Zu(l),o._d&&uc(1)}return process.env.NODE_ENV!=="production"&&hg(t),s};return o._n=!0,o._c=!0,o._d=!0,o}function yg(e){O2(e)&&Ze("Do not use built-in directive ids as custom directive id: "+e)}function mt(e,t){if(zn===null)return process.env.NODE_ENV!=="production"&&Ze("withDirectives can only be used inside render functions."),e;const n=fc(zn),o=e.dirs||(e.dirs=[]);for(let a=0;a1)return n&&He(t)?t.call(o&&o.proxy):t;process.env.NODE_ENV!=="production"&&Ze(`injection "${String(e)}" not found.`)}else process.env.NODE_ENV!=="production"&&Ze("inject() can only be used inside setup() or functional components.")}function bg(){return!!(gt()||fs)}const Vx=Symbol.for("v-scx"),Lx=()=>{{const e=Fe(Vx);return e||process.env.NODE_ENV!=="production"&&Ze("Server rendering context not provided. Make sure to only call useSSRContext() conditionally in the server build."),e}};function oo(e,t){return kf(e,null,t)}function de(e,t,n){return process.env.NODE_ENV!=="production"&&!He(t)&&Ze("`watch(fn, options?)` signature has been moved to a separate API. Use `watchEffect(fn, options?)` instead. `watch` now only supports `watch(source, cb, options?) signature."),kf(e,t,n)}function kf(e,t,n=Ft){const{immediate:o,deep:a,flush:l,once:s}=n;process.env.NODE_ENV!=="production"&&!t&&(o!==void 0&&Ze('watch() "immediate" option is only respected when using the watch(source, callback, options?) signature.'),a!==void 0&&Ze('watch() "deep" option is only respected when using the watch(source, callback, options?) signature.'),s!==void 0&&Ze('watch() "once" option is only respected when using the watch(source, callback, options?) signature.'));const r=bn({},n);process.env.NODE_ENV!=="production"&&(r.onWarn=Ze);const u=t&&o||!t&&l!=="post";let c;if(Si){if(l==="sync"){const g=Lx();c=g.__watcherHandles||(g.__watcherHandles=[])}else if(!u){const g=()=>{};return g.stop=tt,g.resume=tt,g.pause=tt,g}}const d=Hn;r.call=(g,p,m)=>zo(g,d,p,m);let f=!1;l==="post"?r.scheduler=g=>{go(g,d&&d.suspense)}:l!=="sync"&&(f=!0,r.scheduler=(g,p)=>{p?g():Gu(g)}),r.augmentJob=g=>{t&&(g.flags|=4),f&&(g.flags|=2,d&&(g.id=d.uid,g.i=d))};const v=mx(e,t,r);return Si&&(c?c.push(v):u&&v()),v}function Bx(e,t,n){const o=this.proxy,a=Le(e)?e.includes(".")?wg(o,e):()=>o[e]:e.bind(o,o);let l;He(t)?l=t:(l=t.handler,n=t);const s=wi(this),r=kf(a,l.bind(o),n);return s(),r}function wg(e,t){const n=t.split(".");return()=>{let o=e;for(let a=0;ae.__isTeleport,al=e=>e&&(e.disabled||e.disabled===""),Fx=e=>e&&(e.defer||e.defer===""),kg=e=>typeof SVGElement!="undefined"&&e instanceof SVGElement,Eg=e=>typeof MathMLElement=="function"&&e instanceof MathMLElement,Ef=(e,t)=>{const n=e&&e.to;if(Le(n))if(t){const o=t(n);return process.env.NODE_ENV!=="production"&&!o&&!al(e)&&Ze(`Failed to locate Teleport target with selector "${n}". Note the target element must exist before the component is mounted - i.e. the target cannot be rendered by the component itself, and ideally should be outside of the entire Vue component tree.`),o}else return process.env.NODE_ENV!=="production"&&Ze("Current renderer does not support string target for Teleports. (missing querySelector renderer option)"),null;else return process.env.NODE_ENV!=="production"&&!n&&!al(e)&&Ze(`Invalid Teleport target: ${n}`),n},zx={name:"Teleport",__isTeleport:!0,process(e,t,n,o,a,l,s,r,u,c){const{mc:d,pc:f,pbc:v,o:{insert:g,querySelector:p,createText:m,createComment:h,parentNode:y}}=c,w=al(t.props);let{dynamicChildren:b}=t;process.env.NODE_ENV!=="production"&&vo&&(u=!1,b=null);const C=(x,O,I)=>{x.shapeFlag&16&&d(x.children,O,I,a,l,s,r,u)},S=(x=t)=>{const O=al(x.props),I=x.target=Ef(x.props,p),N=xf(I,x,m,g);I?(s!=="svg"&&kg(I)?s="svg":s!=="mathml"&&Eg(I)&&(s="mathml"),a&&a.isCE&&(a.ce._teleportTargets||(a.ce._teleportTargets=new Set)).add(I),O||(C(x,I,N),ui(x,!1))):process.env.NODE_ENV!=="production"&&!O&&Ze("Invalid Teleport target on mount:",I,`(${typeof I})`)},E=x=>{const O=()=>{if(Il.get(x)===O){if(Il.delete(x),al(x.props)){const I=y(x.el)||n;C(x,I,x.anchor),ui(x,!0)}S(x)}};Il.set(x,O),go(O,l)};if(e==null){const x=t.el=process.env.NODE_ENV!=="production"?h("teleport start"):m(""),O=t.anchor=process.env.NODE_ENV!=="production"?h("teleport end"):m("");if(g(x,n,o),g(O,n,o),Fx(t.props)||l&&l.pendingBranch){E(t);return}w&&(C(t,n,O),ui(t,!0)),S()}else{t.el=e.el;const x=t.anchor=e.anchor,O=Il.get(e);if(O){O.flags|=8,Il.delete(e),E(t);return}t.targetStart=e.targetStart;const I=t.target=e.target,N=t.targetAnchor=e.targetAnchor,$=al(e.props),M=$?n:I,A=$?x:N;if(s==="svg"||kg(I)?s="svg":(s==="mathml"||Eg(I))&&(s="mathml"),b?(v(e.dynamicChildren,b,M,a,l,s,r),gi(e,t,process.env.NODE_ENV==="production")):u||f(e,t,M,A,a,l,s,r,!1),w)$?t.props&&e.props&&t.props.to!==e.props.to&&(t.props.to=e.props.to):Qu(t,n,x,c,1);else if((t.props&&t.props.to)!==(e.props&&e.props.to)){const W=t.target=Ef(t.props,p);W?Qu(t,W,null,c,0):process.env.NODE_ENV!=="production"&&Ze("Invalid Teleport target on update:",I,`(${typeof I})`)}else $&&Qu(t,I,N,c,1);ui(t,w)}},remove(e,t,n,{um:o,o:{remove:a}},l){const{shapeFlag:s,children:r,anchor:u,targetStart:c,targetAnchor:d,target:f,props:v}=e,g=l||!al(v),p=Il.get(e);if(p&&(p.flags|=8,Il.delete(e)),f&&(a(c),a(d)),l&&a(u),!p&&s&16)for(let m=0;m{e.isMounted=!0}),Kt(()=>{e.isUnmounting=!0}),e}const Ko=[Function,Array],Ng={mode:String,appear:Boolean,persisted:Boolean,onBeforeEnter:Ko,onEnter:Ko,onAfterEnter:Ko,onEnterCancelled:Ko,onBeforeLeave:Ko,onLeave:Ko,onAfterLeave:Ko,onLeaveCancelled:Ko,onBeforeAppear:Ko,onAppear:Ko,onAfterAppear:Ko,onAppearCancelled:Ko},Tg=e=>{const t=e.subTree;return t.component?Tg(t.component):t},Kx={name:"BaseTransition",props:Ng,setup(e,{slots:t}){const n=gt(),o=xg();return()=>{const a=t.default&&Tf(t.default(),!0),l=a&&a.length?Og(a):n.subTree?ae():void 0;if(!l)return;const s=Ht(e),{mode:r}=s;if(process.env.NODE_ENV!=="production"&&r&&r!=="in-out"&&r!=="out-in"&&r!=="default"&&Ze(`invalid mode: ${r}`),o.isLeaving)return Nf(l);const u=Mg(l);if(!u)return Nf(l);let c=di(u,s,o,n,f=>c=f);u.type!==cn&&cs(u,c);let d=n.subTree&&Mg(n.subTree);if(d&&d.type!==cn&&!vs(d,u)&&Tg(n).type!==cn){let f=di(d,s,o,n);if(cs(d,f),r==="out-in"&&u.type!==cn)return o.isLeaving=!0,f.afterLeave=()=>{o.isLeaving=!1,n.job.flags&8||n.update(),delete f.afterLeave,d=void 0},Nf(l);r==="in-out"&&u.type!==cn?f.delayLeave=(v,g,p)=>{const m=$g(o,d);m[String(d.key)]=d,v[Ho]=()=>{g(),v[Ho]=void 0,delete c.delayedLeave,d=void 0},c.delayedLeave=()=>{p(),delete c.delayedLeave,d=void 0}}:d=void 0}else d&&(d=void 0);return l}}};function Og(e){let t=e[0];if(e.length>1){let n=!1;for(const o of e)if(o.type!==cn){if(process.env.NODE_ENV!=="production"&&n){Ze(" can only be used on a single element or component. Use for lists.");break}if(t=o,n=!0,process.env.NODE_ENV==="production")break}}return t}const Wx=Kx;function $g(e,t){const{leavingVNodes:n}=e;let o=n.get(t.type);return o||(o=Object.create(null),n.set(t.type,o)),o}function di(e,t,n,o,a){const{appear:l,mode:s,persisted:r=!1,onBeforeEnter:u,onEnter:c,onAfterEnter:d,onEnterCancelled:f,onBeforeLeave:v,onLeave:g,onAfterLeave:p,onLeaveCancelled:m,onBeforeAppear:h,onAppear:y,onAfterAppear:w,onAppearCancelled:b}=t,C=String(e.key),S=$g(n,e),E=(I,N)=>{I&&zo(I,o,9,N)},x=(I,N)=>{const $=N[1];E(I,N),Se(I)?I.every(M=>M.length<=1)&&$():I.length<=1&&$()},O={mode:s,persisted:r,beforeEnter(I){let N=u;if(!n.isMounted)if(l)N=h||u;else return;I[Ho]&&I[Ho](!0);const $=S[C];$&&vs(e,$)&&$.el[Ho]&&$.el[Ho](),E(N,[I])},enter(I){if(!vo&&S[C]===e)return;let N=c,$=d,M=f;if(!n.isMounted)if(l)N=y||c,$=w||d,M=b||f;else return;let A=!1;I[ci]=H=>{A||(A=!0,H?E(M,[I]):E($,[I]),O.delayedLeave&&O.delayedLeave(),I[ci]=void 0)};const W=I[ci].bind(null,!1);N?x(N,[I,W]):W()},leave(I,N){const $=String(e.key);if(I[ci]&&I[ci](!0),n.isUnmounting)return N();E(v,[I]);let M=!1;I[Ho]=W=>{M||(M=!0,N(),W?E(m,[I]):E(p,[I]),I[Ho]=void 0,S[$]===e&&delete S[$])};const A=I[Ho].bind(null,!1);S[$]=e,g?x(g,[I,A]):A()},clone(I){const N=di(I,t,n,o,a);return a&&a(N),N}};return O}function Nf(e){if(pi(e))return e=Wo(e),e.children=null,e}function Mg(e){if(!pi(e))return Cg(e.type)&&e.children?Og(e.children):e;if(e.component)return e.component.subTree;const{shapeFlag:t,children:n}=e;if(n){if(t&16)return n[0];if(t&32&&He(n.default))return n.default()}}function cs(e,t){e.shapeFlag&6&&e.component?(e.transition=t,cs(e.component.subTree,t)):e.shapeFlag&128?(e.ssContent.transition=t.clone(e.ssContent),e.ssFallback.transition=t.clone(e.ssFallback)):e.transition=t}function Tf(e,t=!1,n){let o=[],a=0;for(let l=0;l1)for(let l=0;lfi(m,t&&(Se(t)?t[h]:t),n,o,a));return}if(er(o)&&!a){o.shapeFlag&512&&o.type.__asyncResolved&&o.component.subTree.component&&fi(e,t,n,o.component.subTree);return}const l=o.shapeFlag&4?fc(o.component):o.el,s=a?null:l,{i:r,r:u}=e;if(process.env.NODE_ENV!=="production"&&!r){Ze("Missing ref owner context. ref cannot be used on hoisted vnodes. A vnode with ref must be created inside the render function.");return}const c=t&&t.r,d=r.refs===Ft?r.refs={}:r.refs,f=r.setupState,v=Ht(f),g=f===Ft?qr:m=>process.env.NODE_ENV!=="production"&&(Dt(v,m)&&!tn(v[m])&&Ze(`Template ref "${m}" used on a non-ref value. It will not work in the production build.`),Ig.has(v[m]))||Pg(d,m)?!1:Dt(v,m),p=(m,h)=>!(process.env.NODE_ENV!=="production"&&Ig.has(m)||h&&Pg(d,h));if(c!=null&&c!==u){if(Rg(t),Le(c))d[c]=null,g(c)&&(f[c]=null);else if(tn(c)){const m=t;p(c,m.k)&&(c.value=null),m.k&&(d[m.k]=null)}}if(He(u))Xs(u,r,12,[s,d]);else{const m=Le(u),h=tn(u);if(m||h){const y=()=>{if(e.f){const w=m?g(u)?f[u]:d[u]:p(u)||!e.k?u.value:d[e.k];if(a)Se(w)&&na(w,l);else if(Se(w))w.includes(l)||w.push(l);else if(m)d[u]=[l],g(u)&&(f[u]=d[u]);else{const b=[l];p(u,e.k)&&(u.value=b),e.k&&(d[e.k]=b)}}else m?(d[u]=s,g(u)&&(f[u]=s)):h?(p(u,e.k)&&(u.value=s),e.k&&(d[e.k]=s)):process.env.NODE_ENV!=="production"&&Ze("Invalid template ref type:",u,`(${typeof u})`)};if(s){const w=()=>{y(),ec.delete(e)};w.id=-1,ec.set(e,w),go(w,n)}else Rg(e),y()}else process.env.NODE_ENV!=="production"&&Ze("Invalid template ref type:",u,`(${typeof u})`)}}function Rg(e){const t=ec.get(e);t&&(t.flags|=8,ec.delete(e))}Qr().requestIdleCallback,Qr().cancelIdleCallback;const er=e=>!!e.type.__asyncLoader,pi=e=>e.type.__isKeepAlive;function vi(e,t){Ag(e,"a",t)}function Of(e,t){Ag(e,"da",t)}function Ag(e,t,n=Hn){const o=e.__wdc||(e.__wdc=()=>{let a=n;for(;a;){if(a.isDeactivated)return;a=a.parent}return e()});if(tc(t,o,n),n){let a=n.parent;for(;a&&a.parent;)pi(a.parent.vnode)&&jx(o,t,n,a),a=a.parent}}function jx(e,t,n,o){const a=tc(t,e,o,!0);tr(()=>{na(o[t],a)},n)}function tc(e,t,n=Hn,o=!1){if(n){const a=n[e]||(n[e]=[]),l=t.__weh||(t.__weh=(...s)=>{la();const r=wi(n),u=zo(t,n,e,s);return r(),sa(),u});return o?a.unshift(l):a.push(l),l}else if(process.env.NODE_ENV!=="production"){const a=oa(gf[e].replace(/ hook$/,""));Ze(`${a} is called when there is no active component instance to be associated with. Lifecycle injection APIs can only be used during execution of setup(). If you are using async setup(), make sure to register lifecycle hooks before the first await statement.`)}}const ll=e=>(t,n=Hn)=>{(!Si||e==="sp")&&tc(e,(...o)=>t(...o),n)},nc=ll("bm"),wt=ll("m"),$f=ll("bu"),ca=ll("u"),Kt=ll("bum"),tr=ll("um"),Ux=ll("sp"),Yx=ll("rtg"),qx=ll("rtc");function Gx(e,t=Hn){tc("ec",e,t)}const oc="components",Xx="directives";function Ut(e,t){return Mf(oc,e,!0,t)||e}const Dg=Symbol.for("v-ndc");function ht(e){return Le(e)?Mf(oc,e,!1)||e:e||Dg}function Vg(e){return Mf(Xx,e)}function Mf(e,t,n=!0,o=!1){const a=zn||Hn;if(a){const l=a.type;if(e===oc){const r=Uf(l,!1);if(r&&(r===t||r===Ln(t)||r===$l(Ln(t))))return l}const s=Lg(a[e]||l[e],t)||Lg(a.appContext[e],t);if(!s&&o)return l;if(process.env.NODE_ENV!=="production"&&n&&!s){const r=e===oc?` +If this is a native custom element, make sure to exclude it from component resolution via compilerOptions.isCustomElement.`:"";Ze(`Failed to resolve ${e.slice(0,-1)}: ${t}${r}`)}return s}else process.env.NODE_ENV!=="production"&&Ze(`resolve${$l(e.slice(0,-1))} can only be used in render() or setup().`)}function Lg(e,t){return e&&(e[t]||e[Ln(t)]||e[$l(Ln(t))])}function Nt(e,t,n,o){let a;const l=n,s=Se(e);if(s||Le(e)){const r=s&&Ml(e);let u=!1,c=!1;r&&(u=!no(e),c=ra(e),e=Lu(e)),a=new Array(e.length);for(let d=0,f=e.length;dt(r,u,void 0,l));else{const r=Object.keys(e);a=new Array(r.length);for(let u=0,c=r.length;u{const l=o.fn(...a);return l&&(l.key=o.key),l}:o.fn)}return e}function oe(e,t,n={},o,a){if(zn.ce||zn.parent&&er(zn.parent)&&zn.parent.ce){const c=Object.keys(n).length>0;return t!=="default"&&(n.name=t),T(),re(je,null,[q("slot",n,o&&o())],c?-2:64)}let l=e[t];process.env.NODE_ENV!=="production"&&l&&l.length>1&&(Ze("SSR-optimized slot function detected in a non-SSR-optimized render function. You need to mark this component with $dynamic-slots in the parent template."),l=()=>[]),l&&l._c&&(l._d=!1),T();const s=l&&Bg(l(n)),r=n.key||s&&s.key,u=re(je,{key:(r&&!Co(r)?r:`_${t}`)+(!s&&o?"_fb":"")},s||(o?o():[]),s&&e._===1?64:-2);return u.scopeId&&(u.slotScopeIds=[u.scopeId+"-s"]),l&&l._c&&(l._d=!0),u}function Bg(e){return e.some(t=>Qt(t)?!(t.type===cn||t.type===je&&!Bg(t.children)):!0)?e:null}function Jx(e,t){const n={};if(process.env.NODE_ENV!=="production"&&!it(e))return Ze("v-on with no argument expects an object value."),n;for(const o in e)n[oa(o)]=e[o];return n}const _f=e=>e?Sy(e)?fc(e):_f(e.parent):null,ds=bn(Object.create(null),{$:e=>e,$el:e=>e.vnode.el,$data:e=>e.data,$props:e=>process.env.NODE_ENV!=="production"?Fo(e.props):e.props,$attrs:e=>process.env.NODE_ENV!=="production"?Fo(e.attrs):e.attrs,$slots:e=>process.env.NODE_ENV!=="production"?Fo(e.slots):e.slots,$refs:e=>process.env.NODE_ENV!=="production"?Fo(e.refs):e.refs,$parent:e=>_f(e.parent),$root:e=>_f(e.root),$host:e=>e.ce,$emit:e=>e.emit,$options:e=>jg(e),$forceUpdate:e=>e.f||(e.f=()=>{Gu(e.update)}),$nextTick:e=>e.n||(e.n=De.bind(e.proxy)),$watch:e=>Bx.bind(e)}),If=e=>e==="_"||e==="$",Pf=(e,t)=>e!==Ft&&!e.__isScriptSetup&&Dt(e,t),Fg={get({_:e},t){if(t==="__v_skip")return!0;const{ctx:n,setupState:o,data:a,props:l,accessCache:s,type:r,appContext:u}=e;if(process.env.NODE_ENV!=="production"&&t==="__isVue")return!0;if(t[0]!=="$"){const v=s[t];if(v!==void 0)switch(v){case 1:return o[t];case 2:return a[t];case 4:return n[t];case 3:return l[t]}else{if(Pf(o,t))return s[t]=1,o[t];if(a!==Ft&&Dt(a,t))return s[t]=2,a[t];if(Dt(l,t))return s[t]=3,l[t];if(n!==Ft&&Dt(n,t))return s[t]=4,n[t];Rf&&(s[t]=0)}}const c=ds[t];let d,f;if(c)return t==="$attrs"?(Yn(e.attrs,"get",""),process.env.NODE_ENV!=="production"&&sc()):process.env.NODE_ENV!=="production"&&t==="$slots"&&Yn(e,"get",t),c(e);if((d=r.__cssModules)&&(d=d[t]))return d;if(n!==Ft&&Dt(n,t))return s[t]=4,n[t];if(f=u.config.globalProperties,Dt(f,t))return f[t];process.env.NODE_ENV!=="production"&&zn&&(!Le(t)||t.indexOf("__v")!==0)&&(a!==Ft&&If(t[0])&&Dt(a,t)?Ze(`Property ${JSON.stringify(t)} must be accessed via $data because it starts with a reserved character ("$" or "_") and is not proxied on the render context.`):e===zn&&Ze(`Property ${JSON.stringify(t)} was accessed during render but is not defined on instance.`))},set({_:e},t,n){const{data:o,setupState:a,ctx:l}=e;return Pf(a,t)?(a[t]=n,!0):process.env.NODE_ENV!=="production"&&a.__isScriptSetup&&Dt(a,t)?(Ze(`Cannot mutate + + diff --git a/mokee-gateway-web/memory/session-20260612-1.md b/mokee-gateway-web/memory/session-20260612-1.md new file mode 100644 index 0000000..bcc78cb --- /dev/null +++ b/mokee-gateway-web/memory/session-20260612-1.md @@ -0,0 +1,50 @@ +--- +sessionId: 20260612-1 +startTime: 2026-06-12T10:00:00+08:00 +status: completed +--- + +# 2026-06-12 会话:Vue/TypeScript 文件中文注释补全 + +## 概要 +为 mokee-gateway-web 项目的 19 个核心文件添加了详细的中文注释,包括文件头说明、逻辑块分隔、函数 JSDoc、API 调用说明、数据流说明。 + +## 已完成文件 + +### 核心配置(4 个) +1. **vite.config.ts** — unplugin-vue-components 按需加载原理、manualChunks 分包策略、modulepreload 剥离插件机制 +2. **src/main.ts** — 最小化启动引导流程、Pinia→Router 注册顺序、v-permission 指令说明 +3. **src/App.vue** — ElConfigProvider 中文语言包全局配置 +4. **src/utils/icons.ts** — 动态图标注册表原理(解决按需导入后字符串名找不到组件)、getIconComponent 使用场景 + +### 仪表盘(1 个) +5. **src/views/admin/Dashboard.vue** — 4 张统计卡片、ECharts 动态加载、JVM 信息面板、图表 renderChart 函数详解 + +### 日志页(2 个) +6. **src/views/admin/log/LoginLogList.vue** — systemName 列说明、登录状态 1/0 含义、日期参数拆分逻辑 +7. **src/views/admin/log/OperationLogList.vue** — action 枚举值映射、HTTP method 颜色映射、JSON 格式化安全处理 + +### 列表视图(6 个) +8. **src/views/admin/system/SystemList.vue** — 图标列动态渲染、v-permission 权限控制、docToken 文档跳转 +9. **src/views/admin/user/UserList.vue** — 用户分页、编辑模式路由跳转 +10. **src/views/admin/role/RoleList.vue** — 分配菜单完整流程(并行加载、半选节点处理)、systemName 连表查询 +11. **src/views/admin/menu/MenuList.vue** — 树形表格、展开/折叠递归算法、菜单表单弹窗逻辑、PUT URL 修正说明 +12. **src/views/admin/dict/DictList.vue** — 前端 dictLabel 过滤、不分页设计、新增字典类型弹窗 +13. **src/views/admin/system/ApiList.vue** — localStorage.currentSystemId 默认系统选中、method 颜色映射 + +### 表单页(4 个) +14. **src/views/admin/system/ApiForm.vue** — query.systemId 预设、apiDesc 填充修复说明 +15. **src/views/admin/user/UserForm.vue** — 系统绑定/角色分配完整流程、系统列表端点修正说明 +16. **src/views/admin/role/RoleForm.vue** — 系统列表端点统一说明 +17. **src/views/admin/menu/MenuForm.vue** — 动态校验规则(按菜单类型)、菜单树加载顺序 + +### 选择/文档页(2 个) +18. **src/views/select/SystemSelectView.vue** — getIconComponent 降级逻辑、gateway 跨域名跳转原理、渐变配色方案 +19. **src/views/docs/IntegrationDoc.vue** — 8 章节内容概述、menuSql/adminSql 生成逻辑、BCrypt 密码说明 + +## 注释规范 +- 文件头:`` 或 `/** */` 格式,说明页面/组件用途 +- 逻辑块:`// ===== 章节名 =====` 格式分隔 +- 函数:`/** */` JSDoc 格式,含 @param/@returns +- 内联:`//` 用于 API 调用、数据转换、非显而易见逻辑 +- 所有注释使用简体中文,代码逻辑未改动 diff --git a/mokee-gateway-web/package-lock.json b/mokee-gateway-web/package-lock.json new file mode 100644 index 0000000..e66331e --- /dev/null +++ b/mokee-gateway-web/package-lock.json @@ -0,0 +1,2754 @@ +{ + "name": "mokee-gateway-web", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mokee-gateway-web", + "version": "1.0.0", + "dependencies": { + "@element-plus/icons-vue": "^2.3.0", + "axios": "^1.7.0", + "echarts": "^6.1.0", + "element-plus": "^2.8.0", + "pinia": "^2.1.0", + "vue": "^3.4.0", + "vue-router": "^4.3.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "typescript": "^5.4.0", + "unplugin-auto-import": "^0.18.0", + "unplugin-vue-components": "^0.27.0", + "vite": "^5.4.0", + "vue-tsc": "^2.0.0" + } + }, + "node_modules/@antfu/utils": { + "version": "0.7.10", + "resolved": "https://registry.npmmirror.com/@antfu/utils/-/utils-0.7.10.tgz", + "integrity": "sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", + "integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@element-plus/icons-vue": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz", + "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmmirror.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmmirror.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmmirror.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@popperjs/core": { + "name": "@sxzz/popperjs-es", + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz", + "integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.4.0", + "resolved": "https://registry.npmmirror.com/@rollup/pluginutils/-/pluginutils-5.4.0.tgz", + "integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz", + "integrity": "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.1.tgz", + "integrity": "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.1.tgz", + "integrity": "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.1.tgz", + "integrity": "sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.1.tgz", + "integrity": "sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.1.tgz", + "integrity": "sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.1.tgz", + "integrity": "sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.1.tgz", + "integrity": "sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.1.tgz", + "integrity": "sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.1.tgz", + "integrity": "sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.1.tgz", + "integrity": "sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.1.tgz", + "integrity": "sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.1.tgz", + "integrity": "sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.1.tgz", + "integrity": "sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.1.tgz", + "integrity": "sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.1.tgz", + "integrity": "sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.1.tgz", + "integrity": "sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.1.tgz", + "integrity": "sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.1.tgz", + "integrity": "sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.1.tgz", + "integrity": "sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.1.tgz", + "integrity": "sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.1.tgz", + "integrity": "sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.1.tgz", + "integrity": "sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.1.tgz", + "integrity": "sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.1.tgz", + "integrity": "sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz", + "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.15" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.15.tgz", + "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.15.tgz", + "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.35", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.35.tgz", + "integrity": "sha512-BUmHaR1J+O+CKZ9uJucdVTEr1LHsdyvv7vG3eNRhK3CczEHeMd/LtsHAuD7PbrxvI2envCY2v7HI1vC1aBRzKw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@vue/shared": "3.5.35", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.35", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.35.tgz", + "integrity": "sha512-k+bprkXxuqhVajgTx5mUHuir7TwQzUKOWR40ng1ncAqQRPnrLngGGgqVEEhOnTMlc8btHYVKmrP8s5Qyg0hvYA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.35", + "@vue/shared": "3.5.35" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.35", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.35.tgz", + "integrity": "sha512-G5VPMcXTSywXBgtFOZOnHKBxKSrwXUcvY1iaF5/hRcy7t0J6CH/d8ha9F4nzi00Fax1eLV0QHM7v4mQu68jydw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@vue/compiler-core": "3.5.35", + "@vue/compiler-dom": "3.5.35", + "@vue/compiler-ssr": "3.5.35", + "@vue/shared": "3.5.35", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.15", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.35", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.35.tgz", + "integrity": "sha512-rGhAeXgdM7/ffTJGXT69rCCdTmjDewnFuUZfBQQHTdcEBeWdT5HCGY60y2ytLJr9/Dsu7IntUi5z/w0h6Rjnzw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.35", + "@vue/shared": "3.5.35" + } + }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/language-core": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.2.12.tgz", + "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^1.0.3", + "minimatch": "^9.0.3", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.35", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.35.tgz", + "integrity": "sha512-tVc+SsHConvh/Lz64qq1pP3rYArBmK42xonovEcxY74SQtvctZodG/zhq54P5dr38cVuw25d27cPNRdlMidpGQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.35" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.35", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.35.tgz", + "integrity": "sha512-A/xFNX9loIcWDygeQuNCfKuh0CoYBzxhqEMNah5TSFg9Z53DrFYEN2qi5CU9necjM1OWYegYREUTHmXTmhfXtg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.35", + "@vue/shared": "3.5.35" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.35", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.35.tgz", + "integrity": "sha512-odrJ1C391dbGnyDRh8U+rnP7J2amIEzfmRk5vXy7xi3aZhEXofTvpi0T4HJb6jlNqQZTNPR5MPHSB3RHNkIORA==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.35", + "@vue/runtime-core": "3.5.35", + "@vue/shared": "3.5.35", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.35", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.35.tgz", + "integrity": "sha512-NkebSOYdB97wi8OQcO3HqzZSlymJi/aWsN/7h74OSVhRTm6qGs3Jp3e0rCXynmWwSlKeRrnlIug+ilYoHBmQDA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.35", + "@vue/shared": "3.5.35" + }, + "peerDependencies": { + "vue": "3.5.35" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.35", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.35.tgz", + "integrity": "sha512-zSbjL7gRXwks2ZQLRGCajBtBXEOXW9Ddhn/HvSdrGkE2dqGnumzW8XtusRrxrE9LvqtiqDXQ+A60Hp6mvdYxfA==", + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.3.0.tgz", + "integrity": "sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "14.3.0", + "@vueuse/shared": "14.3.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/@vueuse/metadata": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.3.0.tgz", + "integrity": "sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.3.0.tgz", + "integrity": "sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/alien-signals": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz", + "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.17.0.tgz", + "integrity": "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmmirror.com/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.21", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", + "license": "MIT" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/echarts": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz", + "integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "6.1.0" + } + }, + "node_modules/element-plus": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.14.1.tgz", + "integrity": "sha512-UFnm1+BckNi+azkKJ7L32q1uXs9ekr99Z9pWTQPeDR05jqEWUwQq51ro4kZMVrANbjknX3Z7ukCZwTi2T6Tr9A==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^4.2.0", + "@element-plus/icons-vue": "^2.3.2", + "@floating-ui/dom": "^1.7.6", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.8", + "@types/lodash": "^4.17.24", + "@types/lodash-es": "^4.17.12", + "@vueuse/core": "14.3.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.20", + "lodash": "^4.18.1", + "lodash-es": "^4.18.1", + "lodash-unified": "^1.0.3", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0", + "vue-component-type-helpers": "^3.3.1" + }, + "peerDependencies": { + "vue": "^3.3.7" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmmirror.com/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/local-pkg": { + "version": "0.5.1", + "resolved": "https://registry.npmmirror.com/local-pkg/-/local-pkg-0.5.1.tgz", + "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mlly": "^1.7.3", + "pkg-types": "^1.2.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, + "node_modules/lodash-unified": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/lodash-unified/-/lodash-unified-1.0.3.tgz", + "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==", + "license": "MIT", + "peerDependencies": { + "@types/lodash-es": "*", + "lodash": "*", + "lodash-es": "*" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmmirror.com/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmmirror.com/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmmirror.com/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-wheel-es": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz", + "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==", + "license": "BSD-3-Clause" + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmmirror.com/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/quansync": { + "version": "0.2.11", + "resolved": "https://registry.npmmirror.com/quansync/-/quansync-0.2.11.tgz", + "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/antfu" + }, + { + "type": "individual", + "url": "https://github.com/sponsors/sxzz" + } + ], + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/readdirp/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz", + "integrity": "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.61.1", + "@rollup/rollup-android-arm64": "4.61.1", + "@rollup/rollup-darwin-arm64": "4.61.1", + "@rollup/rollup-darwin-x64": "4.61.1", + "@rollup/rollup-freebsd-arm64": "4.61.1", + "@rollup/rollup-freebsd-x64": "4.61.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.61.1", + "@rollup/rollup-linux-arm-musleabihf": "4.61.1", + "@rollup/rollup-linux-arm64-gnu": "4.61.1", + "@rollup/rollup-linux-arm64-musl": "4.61.1", + "@rollup/rollup-linux-loong64-gnu": "4.61.1", + "@rollup/rollup-linux-loong64-musl": "4.61.1", + "@rollup/rollup-linux-ppc64-gnu": "4.61.1", + "@rollup/rollup-linux-ppc64-musl": "4.61.1", + "@rollup/rollup-linux-riscv64-gnu": "4.61.1", + "@rollup/rollup-linux-riscv64-musl": "4.61.1", + "@rollup/rollup-linux-s390x-gnu": "4.61.1", + "@rollup/rollup-linux-x64-gnu": "4.61.1", + "@rollup/rollup-linux-x64-musl": "4.61.1", + "@rollup/rollup-openbsd-x64": "4.61.1", + "@rollup/rollup-openharmony-arm64": "4.61.1", + "@rollup/rollup-win32-arm64-msvc": "4.61.1", + "@rollup/rollup-win32-ia32-msvc": "4.61.1", + "@rollup/rollup-win32-x64-gnu": "4.61.1", + "@rollup/rollup-win32-x64-msvc": "4.61.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scule": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/scule/-/scule-1.3.0.tgz", + "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==", + "dev": true, + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-literal": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/strip-literal/-/strip-literal-2.1.1.tgz", + "integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmmirror.com/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/unimport": { + "version": "3.14.6", + "resolved": "https://registry.npmmirror.com/unimport/-/unimport-3.14.6.tgz", + "integrity": "sha512-CYvbDaTT04Rh8bmD8jz3WPmHYZRG/NnvYVzwD6V1YAlvvKROlAeNDUBhkBGzNav2RKaeuXvlWYaa1V4Lfi/O0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.1.4", + "acorn": "^8.14.0", + "escape-string-regexp": "^5.0.0", + "estree-walker": "^3.0.3", + "fast-glob": "^3.3.3", + "local-pkg": "^1.0.0", + "magic-string": "^0.30.17", + "mlly": "^1.7.4", + "pathe": "^2.0.1", + "picomatch": "^4.0.2", + "pkg-types": "^1.3.0", + "scule": "^1.3.0", + "strip-literal": "^2.1.1", + "unplugin": "^1.16.1" + } + }, + "node_modules/unimport/node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmmirror.com/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unimport/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/unimport/node_modules/local-pkg": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/local-pkg/-/local-pkg-1.2.1.tgz", + "integrity": "sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "mlly": "^1.7.4", + "pkg-types": "^2.3.0", + "quansync": "^0.2.11" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/unimport/node_modules/local-pkg/node_modules/pkg-types": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/pkg-types/-/pkg-types-2.3.1.tgz", + "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.2.4", + "exsolve": "^1.0.8", + "pathe": "^2.0.3" + } + }, + "node_modules/unplugin": { + "version": "1.16.1", + "resolved": "https://registry.npmmirror.com/unplugin/-/unplugin-1.16.1.tgz", + "integrity": "sha512-4/u/j4FrCKdi17jaxuJA0jClGxB1AvU2hw/IuayPc4ay1XGaJs/rbb4v5WKwAjNifjmXK9PIFyuPiaK8azyR9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.14.0", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/unplugin-auto-import": { + "version": "0.18.6", + "resolved": "https://registry.npmmirror.com/unplugin-auto-import/-/unplugin-auto-import-0.18.6.tgz", + "integrity": "sha512-LMFzX5DtkTj/3wZuyG5bgKBoJ7WSgzqSGJ8ppDRdlvPh45mx6t6w3OcbExQi53n3xF5MYkNGPNR/HYOL95KL2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@antfu/utils": "^0.7.10", + "@rollup/pluginutils": "^5.1.3", + "fast-glob": "^3.3.2", + "local-pkg": "^0.5.1", + "magic-string": "^0.30.14", + "minimatch": "^9.0.5", + "unimport": "^3.13.4", + "unplugin": "^1.16.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@nuxt/kit": "^3.2.2", + "@vueuse/core": "*" + }, + "peerDependenciesMeta": { + "@nuxt/kit": { + "optional": true + }, + "@vueuse/core": { + "optional": true + } + } + }, + "node_modules/unplugin-vue-components": { + "version": "0.27.5", + "resolved": "https://registry.npmmirror.com/unplugin-vue-components/-/unplugin-vue-components-0.27.5.tgz", + "integrity": "sha512-m9j4goBeNwXyNN8oZHHxvIIYiG8FQ9UfmKWeNllpDvhU7btKNNELGPt+o3mckQKuPwrE7e0PvCsx+IWuDSD9Vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@antfu/utils": "^0.7.10", + "@rollup/pluginutils": "^5.1.3", + "chokidar": "^3.6.0", + "debug": "^4.3.7", + "fast-glob": "^3.3.2", + "local-pkg": "^0.5.1", + "magic-string": "^0.30.14", + "minimatch": "^9.0.5", + "mlly": "^1.7.3", + "unplugin": "^1.16.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@babel/parser": "^7.15.8", + "@nuxt/kit": "^3.2.2", + "vue": "2 || 3" + }, + "peerDependenciesMeta": { + "@babel/parser": { + "optional": true + }, + "@nuxt/kit": { + "optional": true + } + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.35", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.35.tgz", + "integrity": "sha512-cx89fnr+0kVGHiNFG6y6s0bdjypJRFNZn6x3WPstNdQR1bi1mbB7h4v5IBGTsPJU3nK1+0Iqj3Zf+hZWMieR4Q==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.35", + "@vue/compiler-sfc": "3.5.35", + "@vue/runtime-dom": "3.5.35", + "@vue/server-renderer": "3.5.35", + "@vue/shared": "3.5.35" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-component-type-helpers": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.3.4.tgz", + "integrity": "sha512-joip1uZTaQR0nD23N400gIdJ7xY+WiiiMA/BCKz842gvGBknqDQAzklUvDEhqFvvrhQY8S2ZANBMu4X70VMFGw==", + "license": "MIT" + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-tsc": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.2.12.tgz", + "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.15", + "@vue/language-core": "2.2.12" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmmirror.com/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/zrender": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz", + "integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + } + } +} diff --git a/mokee-gateway-web/package.json b/mokee-gateway-web/package.json new file mode 100644 index 0000000..7fa2cbb --- /dev/null +++ b/mokee-gateway-web/package.json @@ -0,0 +1,30 @@ +{ + "name": "mokee-gateway-web", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --port 5001", + "build": "vue-tsc && vite build", + "build:widget": "vite build --config vite.widget.config.ts", + "build:prod": "vite build && vite build --config vite.widget.config.ts", + "preview": "vite preview" + }, + "dependencies": { + "@element-plus/icons-vue": "^2.3.0", + "axios": "^1.7.0", + "echarts": "^6.1.0", + "element-plus": "^2.8.0", + "pinia": "^2.1.0", + "vue": "^3.4.0", + "vue-router": "^4.3.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "typescript": "^5.4.0", + "unplugin-auto-import": "^0.18.0", + "unplugin-vue-components": "^0.27.0", + "vite": "^5.4.0", + "vue-tsc": "^2.0.0" + } +} diff --git a/mokee-gateway-web/public/demo-widget.html b/mokee-gateway-web/public/demo-widget.html new file mode 100644 index 0000000..00f98d6 --- /dev/null +++ b/mokee-gateway-web/public/demo-widget.html @@ -0,0 +1,106 @@ + + + + + + Mokee UserChip Widget — 接入演示 + + + + + + + +
+
某业务系统
+
+ +
+
+
+ +
+

欢迎使用某业务系统

+

+ 右上角的用户头像下拉组件由 Mokee Gateway 提供。 + 外部系统只需三行代码即可接入,无需手写登录/鉴权/用户信息获取逻辑。 +

+

+ 组件功能:未登录显示"登录"按钮;已登录显示用户头像 + 用户名, + 下拉菜单包含"切换系统"和"退出登录"。 +

+
+ <!-- 1. 引入样式 -->
+ <link rel="stylesheet" href="https://web.gw.zgitm.com/widgets/user-chip.css" />

+ <!-- 2. 占位元素(只需配 API 网关地址) -->
+ <div id="mokee-user-chip"
+      data-api-base="https://gw.server.zgitm.com"></div>

+ <!-- 3. 引入脚本(自动渲染,login/select 地址自动推断) -->
+ <script src="https://web.gw.zgitm.com/widgets/user-chip.js"></script> +
+
+ + + + + diff --git a/mokee-gateway-web/src/App.vue b/mokee-gateway-web/src/App.vue new file mode 100644 index 0000000..144df4e --- /dev/null +++ b/mokee-gateway-web/src/App.vue @@ -0,0 +1,19 @@ + + + + diff --git a/mokee-gateway-web/src/components/IconPicker.vue b/mokee-gateway-web/src/components/IconPicker.vue new file mode 100644 index 0000000..5d8b880 --- /dev/null +++ b/mokee-gateway-web/src/components/IconPicker.vue @@ -0,0 +1,171 @@ + + + + + diff --git a/mokee-gateway-web/src/components/UploadPanel.vue b/mokee-gateway-web/src/components/UploadPanel.vue new file mode 100644 index 0000000..d7a1f86 --- /dev/null +++ b/mokee-gateway-web/src/components/UploadPanel.vue @@ -0,0 +1,140 @@ + + + + + + diff --git a/mokee-gateway-web/src/composables/useIsMobile.ts b/mokee-gateway-web/src/composables/useIsMobile.ts new file mode 100644 index 0000000..465704c --- /dev/null +++ b/mokee-gateway-web/src/composables/useIsMobile.ts @@ -0,0 +1,18 @@ +/** + * 响应式检测是否为手机屏幕(宽度 <= 768px) + * 用于桌面表格 / 手机卡片的条件渲染 + */ +import { ref, onMounted, onUnmounted } from 'vue' + +export function useIsMobile() { + const isMobile = ref(window.innerWidth <= 768) + + function check() { + isMobile.value = window.innerWidth <= 768 + } + + onMounted(() => window.addEventListener('resize', check)) + onUnmounted(() => window.removeEventListener('resize', check)) + + return isMobile +} diff --git a/mokee-gateway-web/src/directives/permission.ts b/mokee-gateway-web/src/directives/permission.ts new file mode 100644 index 0000000..fae62f7 --- /dev/null +++ b/mokee-gateway-web/src/directives/permission.ts @@ -0,0 +1,20 @@ +import type { Directive } from 'vue' +import { useAuthStore } from '@/stores/auth' + +/** + * v-permission 自定义指令 + * 用法: 新增用户 + * 如果用户没有该权限,元素会被隐藏 + */ +export const permission: Directive = { + mounted(el, binding) { + const auth = useAuthStore() + const perm = binding.value as string + if (!perm) return + if (!auth.hasPermission(perm)) { + el.parentNode?.removeChild(el) + } + }, +} + +export default permission diff --git a/mokee-gateway-web/src/env.d.ts b/mokee-gateway-web/src/env.d.ts new file mode 100644 index 0000000..323c78a --- /dev/null +++ b/mokee-gateway-web/src/env.d.ts @@ -0,0 +1,7 @@ +/// + +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent<{}, {}, any> + export default component +} diff --git a/mokee-gateway-web/src/main.ts b/mokee-gateway-web/src/main.ts new file mode 100644 index 0000000..62e7e8d --- /dev/null +++ b/mokee-gateway-web/src/main.ts @@ -0,0 +1,43 @@ +/** + * 应用入口 — 最小化启动引导 + * + * 启动流程: + * 1. 创建 Vue 应用实例 + * 2. 注册 Pinia 状态管理(在 Router 之前,因为路由守卫可能用到 store) + * 3. 注册 Vue Router(路由守卫在此处理登录态和权限) + * 4. 注册 v-permission 自定义指令(用于按钮级权限控制) + * 5. 挂载到 #app + * + * 注意:不在此处加载 Element Plus 全局样式 — 由 vite.config.ts 中的 unplugin-vue-components 按需导入 + */ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import App from './App.vue' +import router from './router' +// 全局 CSS 变量和基础样式 +import './styles/global.css' +// 显式引入命令式组件 CSS(ElMessage/ElMessageBox/ElNotification 通过 JS 调用时 +// unplugin-vue-components 无法检测,需手动导入,否则弹窗无样式显示在页面底部) +import 'element-plus/theme-chalk/el-message.css' +import 'element-plus/theme-chalk/el-message-box.css' +import 'element-plus/theme-chalk/el-notification.css' +// v-permission 自定义指令 — 根据用户权限控制元素显隐 +import { permission } from './directives/permission' + +const app = createApp(App) + +// Pinia 必须先于 Router 注册,因为路由守卫可能访问 user/permission store +app.use(createPinia()) +app.use(router) +// 注册 v-permission 指令:新增 +app.directive('permission', permission) +app.mount('#app') + +// 手机端分页切换后自动滚回顶部(避免看到上一页的底部位置) +document.addEventListener('click', (e) => { + const el = e.target as HTMLElement + if (el.closest('.el-pager') || el.closest('.el-pagination__next') || el.closest('.el-pagination__prev')) { + setTimeout(() => window.scrollTo({ top: 0, behavior: 'smooth' }), 250) + } +}) + diff --git a/mokee-gateway-web/src/router/index.ts b/mokee-gateway-web/src/router/index.ts new file mode 100644 index 0000000..b192bb7 --- /dev/null +++ b/mokee-gateway-web/src/router/index.ts @@ -0,0 +1,208 @@ +import { createRouter, createWebHistory } from 'vue-router' + +const router = createRouter({ + history: createWebHistory(), + routes: [ + { + path: '/login', + name: 'Login', + component: () => import('@/views/login/LoginView.vue'), + meta: { title: '统一登录' }, + }, + { + path: '/select', + name: 'SystemSelect', + component: () => import('@/views/select/SystemSelectView.vue'), + meta: { title: '选择系统', requiresAuth: true }, + }, + { + path: '/admin', + name: 'AdminLayout', + component: () => import('@/views/admin/AdminLayout.vue'), + meta: { title: '后台管理', requiresAuth: true }, + redirect: '/admin/dashboard', + children: [ + // 首页仪表盘 + { + path: 'dashboard', + name: 'Dashboard', + component: () => import('@/views/admin/Dashboard.vue'), + meta: { title: '首页' }, + }, + // 个人中心 + { + path: 'personal', + name: 'PersonalView', + component: () => import('@/views/admin/PersonalView.vue'), + meta: { title: '个人中心' }, + }, + // --- 系统配置 --- + { + path: 'config', + name: 'SystemConfig', + component: () => import('@/views/admin/config/SystemConfig.vue'), + meta: { title: '系统配置' }, + }, + // --- 表单路由(独立页面,放在列表路由前面避免被匹配) --- + { + path: 'system/form/:id?', + name: 'SystemForm', + component: () => import('@/views/admin/system/SystemForm.vue'), + meta: { title: '系统表单' }, + }, + { + path: 'user/form/:id?', + name: 'UserForm', + component: () => import('@/views/admin/user/UserForm.vue'), + meta: { title: '用户表单' }, + }, + { + path: 'role/form/:id?', + name: 'RoleForm', + component: () => import('@/views/admin/role/RoleForm.vue'), + meta: { title: '角色表单' }, + }, + { + path: 'menu/form/:id?', + name: 'MenuForm', + component: () => import('@/views/admin/menu/MenuForm.vue'), + meta: { title: '菜单表单' }, + }, + { + path: 'dict/form/:id?', + name: 'DictForm', + component: () => import('@/views/admin/dict/DictForm.vue'), + meta: { title: '字典表单' }, + }, + { + path: 'api/form/:id?', + name: 'ApiForm', + component: () => import('@/views/admin/system/ApiForm.vue'), + meta: { title: '接口表单' }, + }, + // --- 列表路由 --- + { + path: 'system', + name: 'SystemList', + component: () => import('@/views/admin/system/SystemList.vue'), + meta: { title: '系统管理' }, + }, + { + path: 'user', + name: 'UserList', + component: () => import('@/views/admin/user/UserList.vue'), + meta: { title: '用户管理' }, + }, + { + path: 'role', + name: 'RoleList', + component: () => import('@/views/admin/role/RoleList.vue'), + meta: { title: '角色管理' }, + }, + { + path: 'menu', + name: 'MenuList', + component: () => import('@/views/admin/menu/MenuList.vue'), + meta: { title: '菜单管理' }, + }, + { + path: 'log/login', + name: 'LoginLogList', + component: () => import('@/views/admin/log/LoginLogList.vue'), + meta: { title: '登录日志' }, + }, + { + path: 'log/operation', + name: 'OperationLogList', + component: () => import('@/views/admin/log/OperationLogList.vue'), + meta: { title: '操作日志' }, + }, + { + path: 'log/token', + name: 'OpenTokenLogList', + component: () => import('@/views/admin/log/OpenTokenLogList.vue'), + meta: { title: 'Token日志' }, + }, + { + path: 'api', + name: 'ApiList', + component: () => import('@/views/admin/system/ApiList.vue'), + meta: { title: '接口管理' }, + }, + { + path: 'dict', + name: 'DictList', + component: () => import('@/views/admin/dict/DictList.vue'), + meta: { title: '数据字典' }, + }, + { + path: 'file', + name: 'FileList', + component: () => import('@/views/admin/file/FileList.vue'), + meta: { title: '文件管理' }, + }, + { + path: 'email', + name: 'EmailList', + component: () => import('@/views/admin/email/EmailList.vue'), + meta: { title: '邮件管理' }, + }, + ], + }, + // 系统接入文档(公开,无需登录) + { + path: '/docs/:token', + name: 'IntegrationDoc', + component: () => import('@/views/docs/IntegrationDoc.vue'), + meta: { title: '系统接入文档' }, + }, + { + path: '/', + redirect: '/login', + }, + ], +}) + +// Cookie token 读取(与 request.ts / auth.ts 一致) +function getCookieToken(): string | null { + const match = document.cookie.match(/(?:^|;\s*)token=([^;]*)/) + return match ? decodeURIComponent(match[1]) : null +} + +// 设置默认 favicon(Mokee 三角 SVG) +function setDefaultFavicon() { + const link = document.querySelector("link[rel='icon']") as HTMLLinkElement + if (link) { + link.href = 'data:image/svg+xml,' + encodeURIComponent( + '' + ) + } +} + +// 更新浏览器标题和 favicon +function updateHead(title: string, iconUrl?: string) { + document.title = title ? title + ' | Mokee Gateway' : 'Mokee Gateway' + const link = document.querySelector("link[rel='icon']") as HTMLLinkElement + if (!link) return + if (iconUrl && (iconUrl.startsWith('http') || iconUrl.startsWith('/zgapi'))) { + link.href = iconUrl + } else { + setDefaultFavicon() + } +} + +// 全局路由守卫 +router.beforeEach((to, _from, next) => { + updateHead(to.meta.title as string) + + const token = getCookieToken() + if (to.meta.requiresAuth && !token) { + next('/login') + } else if (to.path === '/login' && token) { + next('/select') + } else { + next() + } +}) + +export default router diff --git a/mokee-gateway-web/src/stores/auth.ts b/mokee-gateway-web/src/stores/auth.ts new file mode 100644 index 0000000..c294399 --- /dev/null +++ b/mokee-gateway-web/src/stores/auth.ts @@ -0,0 +1,150 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import request from '@/utils/request' + +/** 文件对象存储服务地址 */ +const FILE_SERVICE_HOST = import.meta.env.VITE_FILE_SERVICE_URL || 'https://file.wg.zgitm.com' + +/** 判断 URL 是否为文件服务地址(需要动态 Token) */ +function isFileServiceUrl(url: string): boolean { + if (!url) return false + return url.includes('file.wg.zgitm.com') || url.includes(FILE_SERVICE_HOST.replace('https://', '').replace('http://', '')) +} + +/** 从文件服务 URL 中提取 baseUrl(去掉 ?token=xxx 参数) */ +function stripToken(url: string): string { + if (!url) return url + return url.split('?')[0] +} + +/** 为文件服务 URL 获取新 Token 并拼接完整地址 */ +async function refreshFileServiceUrl(url: string): Promise { + if (!url || !isFileServiceUrl(url)) return url + try { + const baseUrl = stripToken(url) + const tokenRes = await request.post('/zgapi/v1/open/token', { systemCode: 'gateway' }) + const token = tokenRes.data?.token + if (!token) return url + return `${baseUrl}?token=${token}` + } catch { + return url + } +} + +/** Cookie token 读写 (与 request.ts 保持一致) */ +const TOKEN_KEY = 'token' +const COOKIE_DOMAIN = import.meta.env.VITE_COOKIE_DOMAIN || '' + +function getToken(): string | null { + const match = document.cookie.match(new RegExp(`(?:^|;\\s*)${TOKEN_KEY}=([^;]*)`)) + return match ? decodeURIComponent(match[1]) : null +} +function setToken(val: string) { + const domainPart = COOKIE_DOMAIN ? `;domain=${COOKIE_DOMAIN}` : '' + document.cookie = `${TOKEN_KEY}=${encodeURIComponent(val)};path=/;max-age=7200;SameSite=Lax${domainPart}` +} +function removeToken() { + const domainPart = COOKIE_DOMAIN ? `;domain=${COOKIE_DOMAIN}` : '' + document.cookie = `${TOKEN_KEY}=;path=/;max-age=0${domainPart}` +} + +/** 用户信息 */ +export interface UserInfo { + userId: string; username: string; realName: string; email: string + phone: string; avatar: string; post: string + permissions?: string[] +} +/** 业务系统 */ +export interface SystemInfo { + systemId: string; systemCode: string; systemName: string + frontUrl: string; icon: string; description: string +} + +export const useAuthStore = defineStore('auth', () => { + const token = ref(getToken() || '') + const userInfo = ref(null) + const systemList = ref([]) + const currentSystem = ref(null) + const isAdmin = ref(false) + const permissions = ref([]) + + async function login(username: string, password: string, systemCode?: string) { + const res = await request.post('/zgapi/v1/auth/login', { username, password, systemCode }) + const data = res.data + token.value = data.token + setToken(data.token) + // 立即刷新头像 Token — 否则跳转后 先用无 Token 的 URL + // 发起一次失败请求,等 fetchUserInfo() 拿到 Token 后又发起第二次 + // fetchUserInfo() 中有去重逻辑,不会重复刷新同一文件 + let userInfoData = data.userInfo + if (userInfoData?.avatar && isFileServiceUrl(userInfoData.avatar)) { + userInfoData = { ...userInfoData, avatar: await refreshFileServiceUrl(userInfoData.avatar) } + } + userInfo.value = userInfoData + systemList.value = data.systemList || [] + permissions.value = data.permissions || [] + currentSystem.value = data.currentSystem || null + isAdmin.value = data.isAdmin || false + return data + } + + async function logout() { + try { await request.post('/zgapi/v1/auth/logout') } catch {} + removeToken() + token.value = '' + userInfo.value = null + systemList.value = [] + currentSystem.value = null + permissions.value = [] + } + + async function selectSystem(systemId: string) { + await request.post('/zgapi/v1/auth/system/select', null, { params: { systemId } }) + const sys = systemList.value.find(s => s.systemId === systemId) + if (sys) currentSystem.value = sys + } + + async function fetchUserInfo() { + const res = await request.get('/zgapi/v1/auth/user/info') + let data = res.data + // 若头像地址来自文件服务,先刷新 Token 再设置到响应式变量 + // (避免 userInfo 先用旧 URL 触发一次失败的 加载,等新 Token 到了再触发第二次) + // 但如果当前 store 中已有同一文件的头像(带 Token),跳过刷新,避免页面切换时重复调用 /open/token + const prevBase = stripToken(userInfo.value?.avatar || '') + const newBase = stripToken(data?.avatar || '') + if (data?.avatar && isFileServiceUrl(data.avatar) && prevBase !== newBase) { + data = { ...data, avatar: await refreshFileServiceUrl(data.avatar) } + } else if (data?.avatar && isFileServiceUrl(data.avatar) && prevBase === newBase) { + // 同一文件,复用已有的带 Token 的 URL,避免重复调用 /open/token 和下载接口 + data = { ...data, avatar: userInfo.value?.avatar || data.avatar } + } + userInfo.value = data + if (data?.permissions) { + permissions.value = data.permissions + } + } + + async function fetchMenuRouter(systemCode: string) { + const res = await request.get('/zgapi/v1/auth/menu/router', { params: { systemCode } }) + // 返回结构: { menu: MenuRouterVO[], permission: string[] } + const data = res.data + if (data?.permission) { + permissions.value = data.permission + } + return data + } + + async function fetchSystemList() { + const res = await request.get('/zgapi/v1/auth/system/list') + systemList.value = res.data || [] + } + + function hasPermission(perm: string): boolean { + return permissions.value.includes(perm) + } + + return { + token, userInfo, systemList, currentSystem, isAdmin, permissions, + login, logout, selectSystem, fetchUserInfo, fetchMenuRouter, fetchSystemList, hasPermission, + } +}) diff --git a/mokee-gateway-web/src/stores/tabs.ts b/mokee-gateway-web/src/stores/tabs.ts new file mode 100644 index 0000000..2a8e9e2 --- /dev/null +++ b/mokee-gateway-web/src/stores/tabs.ts @@ -0,0 +1,66 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import type { RouteLocationNormalized } from 'vue-router' + +/** Tab 标签项 */ +export interface TabItem { + path: string // 路由路径,唯一标识 + title: string // 标签显示名称 + query?: any // 路由参数 +} + +export const useTabsStore = defineStore('tabs', () => { + const tabs = ref([]) + const activeTab = ref('') + + /** 添加或激活一个 Tab */ + function addTab(route: RouteLocationNormalized) { + const title = (route.meta.title as string) || route.name as string || route.path + const key = route.path + + // 已存在则激活 + const exist = tabs.value.find(t => t.path === key) + if (exist) { + activeTab.value = key + return + } + + tabs.value.push({ path: key, title, query: route.query }) + activeTab.value = key + } + + /** 关闭一个 Tab */ + function removeTab(path: string) { + const idx = tabs.value.findIndex(t => t.path === path) + if (idx === -1) return + + tabs.value.splice(idx, 1) + + // 如果关闭的是当前激活的 Tab,激活相邻的 + if (activeTab.value === path) { + if (tabs.value.length === 0) { + activeTab.value = '' + return + } + const next = tabs.value[Math.min(idx, tabs.value.length - 1)] + activeTab.value = next.path + } + } + + /** 关闭其他 Tab */ + function closeOthers(path: string) { + tabs.value = tabs.value.filter(t => t.path === path) + activeTab.value = path + } + + /** 关闭所有 Tab */ + function closeAll() { + tabs.value = [] + activeTab.value = '' + } + + /** 当前激活的 Tab 路径 */ + const currentPath = computed(() => activeTab.value) + + return { tabs, activeTab, currentPath, addTab, removeTab, closeOthers, closeAll } +}) diff --git a/mokee-gateway-web/src/stores/upload.ts b/mokee-gateway-web/src/stores/upload.ts new file mode 100644 index 0000000..85f5181 --- /dev/null +++ b/mokee-gateway-web/src/stores/upload.ts @@ -0,0 +1,79 @@ +/** + * 上传队列 Store — 支持 localStorage 持久化 + * + * 刷新页面后上传连接会中断(HTTP 无法跨页面生命周期), + * 但队列状态保留在 localStorage 中,重新加载后可查看和重试。 + */ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' + +export interface UploadTask { + id: string + fileName: string + fileSize: number + fileType: string + file: File | null // 内存中的 File 对象(刷新后丢失) + status: 'queued' | 'uploading' | 'completed' | 'failed' | 'interrupted' + progress: number + speed: string + fileId?: string // 上传成功后返回的 ID + error?: string + startedAt: number // 开始时间戳 +} + +const STORAGE_KEY = 'mokee_upload_queue' + +function loadQueue(): UploadTask[] { + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (!raw) return [] + const arr = JSON.parse(raw) as UploadTask[] + // 刷新后仍在"上传中"的标记为中断 + return arr.map((t: UploadTask) => t.status === 'uploading' ? { ...t, status: 'interrupted' as any, file: null, speed: '' } as UploadTask : { ...t, file: null } as UploadTask) + } catch { return [] } +} + +function saveQueue(tasks: UploadTask[]) { + // 只持久化可序列化的字段(file 对象不持久化) + const serializable = tasks.map(({ file, ...rest }) => rest) + localStorage.setItem(STORAGE_KEY, JSON.stringify(serializable)) +} + +export const useUploadStore = defineStore('upload', () => { + const tasks = ref(loadQueue()) + + const activeCount = computed(() => tasks.value.filter(t => t.status === 'uploading' || t.status === 'queued').length) + const failedCount = computed(() => tasks.value.filter(t => t.status === 'failed' || t.status === 'interrupted').length) + const panelVisible = ref(false) + + function addTask(task: UploadTask) { + tasks.value.unshift(task) + saveQueue(tasks.value) + } + + function updateTask(id: string, patch: Partial) { + const t = tasks.value.find(t => t.id === id) + if (t) { + Object.assign(t, patch) + saveQueue(tasks.value) + } + } + + function removeTask(id: string) { + tasks.value = tasks.value.filter(t => t.id !== id) + saveQueue(tasks.value) + } + + function clearCompleted() { + tasks.value = tasks.value.filter(t => t.status === 'uploading' || t.status === 'queued') + saveQueue(tasks.value) + } + + /** 清掉已完成/失败/中断的任务 */ + function clearAll() { + tasks.value = [] + saveQueue(tasks.value) + } + + return { tasks, activeCount, failedCount, panelVisible, addTask, updateTask, removeTask, clearCompleted, clearAll } +}) diff --git a/mokee-gateway-web/src/styles/global.css b/mokee-gateway-web/src/styles/global.css new file mode 100644 index 0000000..4892c46 --- /dev/null +++ b/mokee-gateway-web/src/styles/global.css @@ -0,0 +1,450 @@ +/* ============================================ + Mokee Gateway — 现代简约设计系统 v2 + ============================================ */ + +:root { + --color-primary: #6366f1; + --color-primary-hover: #4f46e5; + --color-primary-light: #eef2ff; + --color-primary-bg: rgba(99, 102, 241, 0.08); + --color-success: #10b981; + --color-success-light: #ecfdf5; + --color-warning: #f59e0b; + --color-warning-light: #fffbeb; + --color-danger: #ef4444; + --color-danger-light: #fef2f2; + + --text-primary: #1e293b; + --text-secondary: #475569; + --text-muted: #94a3b8; + + --bg-page: #f8fafc; + --bg-surface: #ffffff; + --bg-hover: #f1f5f9; + + --border-color: #e2e8f0; + --border-light: #f1f5f9; + + --radius-sm: 6px; + --radius-md: 8px; + --radius-lg: 12px; + --radius-xl: 16px; + + --shadow-xs: 0 1px 2px rgba(0,0,0,0.03); + --shadow-sm: 0 1px 3px rgba(0,0,0,0.05), 0 1px 2px rgba(0,0,0,0.04); + --shadow-md: 0 4px 6px -1px rgba(0,0,0,0.06), 0 2px 4px -2px rgba(0,0,0,0.03); + --shadow-lg: 0 10px 15px -3px rgba(0,0,0,0.08), 0 4px 6px -4px rgba(0,0,0,0.03); + + --transition-fast: 0.15s ease; + --transition-base: 0.2s ease; +} + +/* ===== 重置 ===== */ +* { margin:0; padding:0; box-sizing:border-box; } + +html, body, #app { + height: 100%; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif; + font-size: 15px; + color: var(--text-primary); + background: var(--bg-page); + -webkit-font-smoothing: antialiased; +} + +::-webkit-scrollbar { width:5px; height:5px; } +::-webkit-scrollbar-thumb { background:#cbd5e1; border-radius:10px; } +::-webkit-scrollbar-thumb:hover { background:#94a3b8; } +::-webkit-scrollbar-track { background:transparent; } + +/* ===== 按钮:彻底重置 ===== */ +.el-button { + font-size: 14px; + font-weight: 500; + height: 36px; + padding: 0 16px; + border-radius: var(--radius-md); + transition: all var(--transition-fast); + border: 1px solid var(--border-color); + background: #fff; + color: var(--text-secondary); + box-shadow: none; +} +.el-button:hover { + background: var(--bg-hover); + color: var(--text-primary); + border-color: #cbd5e1; +} +.el-button:active { + background: var(--bg-hover); +} + +/* primary */ +.el-button--primary { + background: var(--color-primary); + border-color: var(--color-primary); + color: #fff; +} +.el-button--primary:hover { + background: var(--color-primary-hover); + border-color: var(--color-primary-hover); + color: #fff; +} + +/* success / warning / danger */ +.el-button--success { background: var(--color-success); border-color: var(--color-success); color: #fff; } +.el-button--warning { background: var(--color-warning); border-color: var(--color-warning); color: #fff; } +.el-button--danger { background: var(--color-danger); border-color: var(--color-danger); color: #fff; } + +/* small */ +.el-button--small { + font-size: 13px; + height: 32px; + padding: 0 12px; + border-radius: var(--radius-sm); +} + +/* --- 按钮组:彻底分离 --- */ +.el-button-group { + display: inline-flex; + gap: 8px; +} +.el-button-group .el-button { + border-radius: var(--radius-sm) !important; + margin: 0 !important; +} +.el-button-group .el-button + .el-button { + margin-left: 0 !important; +} + +/* ===== 输入框 ===== */ +.el-input__wrapper { + border-radius: var(--radius-md) !important; + box-shadow: 0 0 0 1px var(--border-color) inset !important; + transition: all var(--transition-fast); + font-size: 14px; +} +.el-input.is-focus .el-input__wrapper { + box-shadow: 0 0 0 2px rgba(99,102,241,0.2) inset !important; +} +.el-input--large .el-input__wrapper { font-size: 15px; } + +/* ===== 表格 ===== */ +.el-table { + font-size: 14px; + --el-table-border-color: var(--border-color); +} +.el-table th.el-table__cell { + background: #f8fafc !important; + color: var(--text-secondary); + font-weight: 600; + font-size: 13px; + height: 46px; + border-bottom: 2px solid var(--border-color); +} +.el-table td.el-table__cell { + padding: 12px 0; + font-size: 14px; +} +.el-table--striped .el-table__body tr.el-table__row--striped td { + background: #fafbfc; +} +.el-table__body tr:hover > td { + background: #f8fafc !important; +} + +/* ===== 卡片 ===== */ +.el-card { + border-radius: var(--radius-lg) !important; + border: 1px solid var(--border-color) !important; + box-shadow: var(--shadow-sm) !important; +} +.el-card__header { + padding: 16px 20px; + border-bottom: 1px solid var(--border-light); + font-weight: 600; + font-size: 15px; +} +.el-card__body { padding: 20px; } + +/* ===== 标签 ===== */ +.el-tag { + border-radius: 20px; + font-weight: 500; + font-size: 13px; + padding: 0 10px; + height: 26px; + line-height: 26px; + border: none; +} +.el-tag--success { background: var(--color-success-light); color: #059669; } +.el-tag--danger { background: var(--color-danger-light); color: #dc2626; } +.el-tag--warning { background: var(--color-warning-light); color: #d97706; } +.el-tag--primary { background: var(--color-primary-light); color: var(--color-primary); } + +/* ===== 对话框 ===== */ +.el-dialog { + border-radius: var(--radius-xl); + box-shadow: var(--shadow-lg); +} +.el-dialog__header { padding: 20px 24px 16px; border-bottom: 1px solid var(--border-light); } +.el-dialog__title { font-size: 17px; font-weight: 600; } +.el-dialog__body { padding: 24px; } +.el-dialog__footer { padding: 12px 24px 20px; } + +/* ===== 分页 ===== */ +.el-pagination { font-size: 14px; } +.el-pagination .el-pager li { + font-size: 14px; + min-width: 32px; + height: 32px; + border-radius: var(--radius-sm); +} +.el-pagination .el-pager li.is-active { + background: var(--color-primary) !important; + color: #fff; +} + +/* ===== 菜单 ===== */ +.el-menu { border-right: none !important; } + +/* ===== 下拉菜单 ===== */ +.el-dropdown-menu { + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + border: 1px solid var(--border-color); + padding: 4px; +} +.el-dropdown-menu__item { + border-radius: var(--radius-sm); + padding: 9px 14px; + font-size: 14px; +} + +/* ===== 面包屑 ===== */ +.el-breadcrumb { font-size: 14px; } +.el-breadcrumb__inner { color: var(--text-muted); font-weight: 500; } +.el-breadcrumb__inner.is-link:hover { color: var(--color-primary); } + +/* ===== 表单 ===== */ +.el-form-item__label { + color: var(--text-secondary); + font-weight: 500; + font-size: 14px; +} +.el-form-item { margin-bottom: 20px; } + +/* ===== 空状态 ===== */ +.el-empty__description { color: var(--text-muted); font-size: 14px; } + +/* ===== 消息 ===== */ +.el-message { + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + border: none; + font-size: 14px; + z-index: 9999; +} + +/* 消息容器(Element Plus 动态创建的包装层) */ +.el-message-fade-enter-from, +.el-message-fade-leave-to { + opacity: 0; + transform: translateX(-50%) translateY(-16px); +} + +/* ===== 描述列表 ===== */ +.el-descriptions__label.el-descriptions__cell.is-bordered-label { + font-weight: 500; + color: var(--text-secondary); + background: #f8fafc; +} + +/* ===== 列表页通用布局 ===== */ +.page-wrapper { + display: flex; + flex-direction: column; + gap: 16px; +} + +.card-header-title { + display: flex; + align-items: center; + gap: 8px; + font-weight: 600; + font-size: 14px; + color: var(--text-primary); +} + +.toolbar { + display: flex; + justify-content: flex-start; +} + +.pagination-wrapper { + display: flex; + justify-content: flex-end; + margin-top: 20px; +} + +/* ===== 移动端适配 (iPhone 15: 393px, 通用断点: 768px) ===== */ +@media (max-width: 768px) { + /* ===== 手机端卡片列表 ===== */ + .mobile-cards { + display: flex; + flex-direction: column; + gap: 12px; + } + .mobile-card { + background: var(--bg-surface); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + padding: 14px; + box-shadow: var(--shadow-xs); + } + .card-field { + display: flex; + justify-content: space-between; + align-items: flex-start; + padding: 6px 0; + border-bottom: 1px solid var(--border-light); + font-size: 13px; + line-height: 1.6; + gap: 12px; + } + .card-field:last-of-type { border-bottom: none; } + .fld-label { + font-weight: 600; + color: var(--text-muted); + font-size: 12px; + flex-shrink: 0; + min-width: 56px; + } + .fld-val { + color: var(--text-primary); + text-align: right; + word-break: break-word; + } + /* 卡片中的标签 */ + .card-tags { + display: flex; + flex-wrap: wrap; + gap: 4px; + justify-content: flex-end; + } + /* 卡片底部的操作按钮 */ + .card-actions { + display: flex; + justify-content: flex-end; + gap: 6px; + margin-top: 10px; + padding-top: 8px; + border-top: 1px solid var(--border-light); + } + .card-actions .el-button { + font-size: 12px; + height: 32px; + padding: 0 12px; + } + + /* ===== 表单底部按钮:手机端上下排列,等宽 ===== */ + .form-footer { + flex-direction: column !important; + gap: 8px !important; + } + .form-footer .el-button { + width: 100% !important; + margin-left: 0 !important; + } + + /* 桌面端表格正常滚动 */ + .el-card > .el-table { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + + /* ===== 操作列:桌面按钮行隐藏,移动端下拉显示 ===== */ + .actions-desk { display: none !important; } + .actions-mob { display: inline-flex !important; } + .ops-col { + width: 60px !important; + min-width: 60px !important; + } + + /* 弹窗接近全屏 */ + .el-dialog { + width: 95vw !important; + max-width: 95vw !important; + margin-top: 5vh !important; + border-radius: var(--radius-lg) !important; + } + .el-dialog__header { padding: 14px 16px 10px; } + .el-dialog__title { font-size: 16px; } + .el-dialog__body { padding: 14px; max-height: 65vh; overflow-y: auto; } + .el-dialog__footer { padding: 10px 16px 14px; } + + /* 分页 — 居中 + 隐藏 jumper/sizes */ + .el-pagination { + justify-content: center; + } + .el-pagination .el-pagination__jump { + display: none; + } + .el-pagination__sizes { + display: none; + } + + /* 表单 — label 在上方 */ + .el-form-item { + display: block; + margin-bottom: 14px; + } + .el-form-item__label { + width: auto !important; + text-align: left; + padding-bottom: 2px; + } + .el-form-item__content { + margin-left: 0 !important; + flex: 1; + } + + /* 卡片间距缩小 */ + .page-wrapper { gap: 10px; } + .el-card__body { padding: 14px; } + .el-card__header { padding: 12px 14px; font-size: 14px; } + + /* 按钮间距 */ + .el-button + .el-button { margin-left: 6px; } + + /* 搜索卡片 — inline 表单 → 垂直排列 */ + .search-card .el-form--inline { + display: flex; + flex-wrap: wrap; + gap: 0; + } + .search-card .el-form--inline .el-form-item { + margin-right: 0; + width: 100%; + } + .search-card .el-form--inline .el-input, + .search-card .el-form--inline .el-select, + .search-card .el-form--inline .el-date-picker { + width: 100% !important; + } + .search-card .el-form--inline .el-form-item:last-child .el-button { + width: 100%; + margin-left: 0; + } + .search-card .el-form--inline .el-form-item:last-child .el-button + .el-button { + margin-top: 8px; + } +} + +/* 小屏手机 (iPhone SE, etc) */ +@media (max-width: 480px) { + .el-message { + min-width: auto; + max-width: 90vw; + padding: 10px 16px; + } +} diff --git a/mokee-gateway-web/src/types/components.d.ts b/mokee-gateway-web/src/types/components.d.ts new file mode 100644 index 0000000..db842d5 --- /dev/null +++ b/mokee-gateway-web/src/types/components.d.ts @@ -0,0 +1,61 @@ +/* eslint-disable */ +// @ts-nocheck +// Generated by unplugin-vue-components +// Read more: https://github.com/vuejs/core/pull/3399 +export {} + +/* prettier-ignore */ +declare module 'vue' { + export interface GlobalComponents { + ElAvatar: typeof import('element-plus/es')['ElAvatar'] + ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb'] + ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem'] + ElButton: typeof import('element-plus/es')['ElButton'] + ElButtonGroup: typeof import('element-plus/es')['ElButtonGroup'] + ElCard: typeof import('element-plus/es')['ElCard'] + ElCheckbox: typeof import('element-plus/es')['ElCheckbox'] + ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup'] + ElCol: typeof import('element-plus/es')['ElCol'] + ElCollapse: typeof import('element-plus/es')['ElCollapse'] + ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem'] + ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider'] + ElDatePicker: typeof import('element-plus/es')['ElDatePicker'] + ElDescriptions: typeof import('element-plus/es')['ElDescriptions'] + ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem'] + ElDialog: typeof import('element-plus/es')['ElDialog'] + ElDivider: typeof import('element-plus/es')['ElDivider'] + ElDropdown: typeof import('element-plus/es')['ElDropdown'] + ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem'] + ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu'] + ElEmpty: typeof import('element-plus/es')['ElEmpty'] + ElForm: typeof import('element-plus/es')['ElForm'] + ElFormItem: typeof import('element-plus/es')['ElFormItem'] + ElIcon: typeof import('element-plus/es')['ElIcon'] + ElInput: typeof import('element-plus/es')['ElInput'] + ElInputNumber: typeof import('element-plus/es')['ElInputNumber'] + ElOption: typeof import('element-plus/es')['ElOption'] + ElPageHeader: typeof import('element-plus/es')['ElPageHeader'] + ElPagination: typeof import('element-plus/es')['ElPagination'] + ElPopconfirm: typeof import('element-plus/es')['ElPopconfirm'] + ElProgress: typeof import('element-plus/es')['ElProgress'] + ElRadio: typeof import('element-plus/es')['ElRadio'] + ElRadioButton: typeof import('element-plus/es')['ElRadioButton'] + ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup'] + ElRow: typeof import('element-plus/es')['ElRow'] + ElSelect: typeof import('element-plus/es')['ElSelect'] + ElSkeleton: typeof import('element-plus/es')['ElSkeleton'] + ElSwitch: typeof import('element-plus/es')['ElSwitch'] + ElTable: typeof import('element-plus/es')['ElTable'] + ElTableColumn: typeof import('element-plus/es')['ElTableColumn'] + ElTag: typeof import('element-plus/es')['ElTag'] + ElTree: typeof import('element-plus/es')['ElTree'] + ElTreeSelect: typeof import('element-plus/es')['ElTreeSelect'] + IconPicker: typeof import('./../components/IconPicker.vue')['default'] + RouterLink: typeof import('vue-router')['RouterLink'] + RouterView: typeof import('vue-router')['RouterView'] + UploadPanel: typeof import('./../components/UploadPanel.vue')['default'] + } + export interface ComponentCustomProperties { + vLoading: typeof import('element-plus/es')['ElLoadingDirective'] + } +} diff --git a/mokee-gateway-web/src/utils/date.ts b/mokee-gateway-web/src/utils/date.ts new file mode 100644 index 0000000..b6addeb --- /dev/null +++ b/mokee-gateway-web/src/utils/date.ts @@ -0,0 +1,127 @@ +/** + * 日期时间格式化工具 + * + * 统一前端所有日期时间显示格式: + * - 日期+时间:yyyy-MM-dd HH:mm:ss + * - 仅日期: yyyy-MM-dd + * + * 使用方式: + * import { formatDateTime, formatDate } from '@/utils/date' + * formatDateTime(row.createdAt) // "2026-06-13 09:15:30" + * formatDate(row.createdAt) // "2026-06-13" + * + * 设计要点: + * 后端 JacksonConfig 已将 Date 统一序列化为 yyyy-MM-dd HH:mm:ss 格式, + * 前端不做 new Date() 解析(避免浏览器差异),直接操作字符串。 + */ + +/** 补零到指定位数 */ +function pad(n: number, len = 2): string { + return String(n).padStart(len, '0') +} + +/** + * 从日期字符串中提取 [年, 月, 日, 时, 分, 秒] 数组 + * 兼容格式: + * - yyyy-MM-dd HH:mm:ss (后端标准格式) + * - yyyy-MM-ddTHH:mm:ss (ISO 8601 不带时区) + * - yyyy-MM-ddTHH:mm:ss.SSS (带毫秒) + * - yyyy-MM-ddTHH:mm:ss+XX:XX (带时区偏移) + * - yyyy-MM-dd (仅日期,时分秒补0) + * - 时间戳数字字符串 + * @returns 提取成功返回 [y, m, d, h, min, s],失败返回 null + */ +function extractDateParts(value: string): [number, number, number, number, number, number] | null { + const s = value.trim() + if (!s) return null + + // 1. 先匹配完整的 yyyy-MM-dd HH:mm:ss(后端标准输出) + let m = s.match(/^(\d{4})-(\d{1,2})-(\d{1,2})[ T](\d{1,2}):(\d{1,2}):(\d{1,2})(?:\.\d+)?(?:[+-]\d{2}:\d{2}|Z)?$/) + if (m) { + return [+m[1], +m[2], +m[3], +m[4], +m[5], +m[6]] + } + + // 2. 仅日期 yyyy-MM-dd + m = s.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/) + if (m) { + return [+m[1], +m[2], +m[3], 0, 0, 0] + } + + // 3. 纯数字(时间戳毫秒) + if (/^\d{9,13}$/.test(s)) { + const ts = +s + const d = new Date(ts) + if (!isNaN(d.getTime())) { + return [d.getFullYear(), d.getMonth() + 1, d.getDate(), d.getHours(), d.getMinutes(), d.getSeconds()] + } + } + + // 4. 降级:用 new Date() 尝试解析(处理其他未知格式) + const d = new Date(s) + if (!isNaN(d.getTime())) { + return [d.getFullYear(), d.getMonth() + 1, d.getDate(), d.getHours(), d.getMinutes(), d.getSeconds()] + } + + return null +} + +/** + * 格式化日期时间为 yyyy-MM-dd HH:mm:ss + * @param value 日期字符串 / 时间戳毫秒 / Date 对象 + * @returns 格式化后的字符串,解析失败返回 "-" + */ +export function formatDateTime(value: string | number | Date | null | undefined): string { + if (value === null || value === undefined || value === '') return '-' + + // Date 对象 + if (value instanceof Date) { + if (isNaN(value.getTime())) return '-' + return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())} ${pad(value.getHours())}:${pad(value.getMinutes())}:${pad(value.getSeconds())}` + } + + // 数字 — 时间戳 + if (typeof value === 'number') { + const d = new Date(value) + if (isNaN(d.getTime())) return '-' + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` + } + + // 字符串 — 手动解析 + const parts = extractDateParts(value) + if (parts) { + return `${parts[0]}-${pad(parts[1])}-${pad(parts[2])} ${pad(parts[3])}:${pad(parts[4])}:${pad(parts[5])}` + } + + // 彻底解析失败,返回原值(比返回 "-" 更有信息量) + return value +} + +/** + * 格式化日期为 yyyy-MM-dd(只取日期部分,忽略时间) + * @param value 日期字符串 / 时间戳毫秒 / Date 对象 + * @returns 格式化后的日期字符串,解析失败返回 "-" + */ +export function formatDate(value: string | number | Date | null | undefined): string { + if (value === null || value === undefined || value === '') return '-' + + // Date 对象 + if (value instanceof Date) { + if (isNaN(value.getTime())) return '-' + return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}` + } + + // 数字 — 时间戳 + if (typeof value === 'number') { + const d = new Date(value) + if (isNaN(d.getTime())) return '-' + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` + } + + // 字符串 — 手动解析 + const parts = extractDateParts(value) + if (parts) { + return `${parts[0]}-${pad(parts[1])}-${pad(parts[2])}` + } + + return value +} diff --git a/mokee-gateway-web/src/utils/icons.ts b/mokee-gateway-web/src/utils/icons.ts new file mode 100644 index 0000000..adee421 --- /dev/null +++ b/mokee-gateway-web/src/utils/icons.ts @@ -0,0 +1,22 @@ +/** + * 图标注册表 — 动态图标解析 + * + * 因为 unplugin-vue-components 按需导入,模板中 `` 无法通过字符串找到图标。 + * 这里导入全部 Element Plus 图标建立字符串→组件映射,供系统配置表、菜单图标等动态渲染场景使用。 + * + * 使用场景:SystemList、SystemSelectView、MenuList、IntegrationDoc 等动态图标渲染 + */ +import * as Icons from '@element-plus/icons-vue' + +/** + * 根据图标名称获取图标组件 + * @param name - 图标名称(如 "Setting"、"UserFilled") + * @returns 图标组件对象,找不到返回 null + */ +export function getIconComponent(name: string | undefined | null) { + if (!name) return null + return (Icons as Record)[name] || null +} + +export { Icons } +export default Icons diff --git a/mokee-gateway-web/src/utils/request.ts b/mokee-gateway-web/src/utils/request.ts new file mode 100644 index 0000000..b4e5775 --- /dev/null +++ b/mokee-gateway-web/src/utils/request.ts @@ -0,0 +1,69 @@ +import axios from 'axios' +import { ElMessage } from 'element-plus' + +const service = axios.create({ + baseURL: import.meta.env.VITE_GATEWAY_URL || '', + timeout: 60000, +}) + +// Token 读写(Cookie 跨子域共享,域名通过 VITE_COOKIE_DOMAIN 环境变量配置) +const COOKIE_DOMAIN = import.meta.env.VITE_COOKIE_DOMAIN || '' +const DOMAIN_PART = COOKIE_DOMAIN ? `;domain=${COOKIE_DOMAIN}` : '' + +function getToken(): string | null { + const match = document.cookie.match(/(?:^|;\s*)token=([^;]*)/) + return match ? decodeURIComponent(match[1]) : null +} +function setToken(val: string) { + document.cookie = `token=${encodeURIComponent(val)};path=/;max-age=7200;SameSite=Lax${DOMAIN_PART}` +} +function removeToken() { + document.cookie = `token=;path=/;max-age=0${DOMAIN_PART}` +} + +// 请求拦截器 +service.interceptors.request.use( + (config) => { + const token = getToken() + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + return config + }, + (error) => Promise.reject(error) +) + +// 响应拦截器 +service.interceptors.response.use( + (response) => { + const res = response.data + if (res.code && res.code !== 200) { + ElMessage.error(res.msg || '请求失败') + return Promise.reject(new Error(res.msg)) + } + return res + }, + (error) => { + if (error.response) { + const status = error.response.status + if (status === 401) { + const hadToken = !!getToken() + removeToken() + if (hadToken) { + window.location.href = (import.meta.env.VITE_LOGIN_URL as string) || '/login' + } + } else if (status === 403) { + ElMessage.error('无访问权限') + } else if (status === 429) { + ElMessage.error('操作太频繁,请稍后再试') + } else { + ElMessage.error(error.response.data?.msg || '服务器错误') + } + } else { + ElMessage.error('网络连接异常') + } + return Promise.reject(error) + } +) + +export default service diff --git a/mokee-gateway-web/src/views/admin/AdminLayout.vue b/mokee-gateway-web/src/views/admin/AdminLayout.vue new file mode 100644 index 0000000..5e5e3da --- /dev/null +++ b/mokee-gateway-web/src/views/admin/AdminLayout.vue @@ -0,0 +1,564 @@ + + + + + diff --git a/mokee-gateway-web/src/views/admin/Dashboard.vue b/mokee-gateway-web/src/views/admin/Dashboard.vue new file mode 100644 index 0000000..3311e23 --- /dev/null +++ b/mokee-gateway-web/src/views/admin/Dashboard.vue @@ -0,0 +1,324 @@ + + + + + + diff --git a/mokee-gateway-web/src/views/admin/PersonalView.vue b/mokee-gateway-web/src/views/admin/PersonalView.vue new file mode 100644 index 0000000..f6d7687 --- /dev/null +++ b/mokee-gateway-web/src/views/admin/PersonalView.vue @@ -0,0 +1,341 @@ + + + + + diff --git a/mokee-gateway-web/src/views/admin/config/SystemConfig.vue b/mokee-gateway-web/src/views/admin/config/SystemConfig.vue new file mode 100644 index 0000000..f245612 --- /dev/null +++ b/mokee-gateway-web/src/views/admin/config/SystemConfig.vue @@ -0,0 +1,61 @@ + + + + + + diff --git a/mokee-gateway-web/src/views/admin/dict/DictForm.vue b/mokee-gateway-web/src/views/admin/dict/DictForm.vue new file mode 100644 index 0000000..721ebca --- /dev/null +++ b/mokee-gateway-web/src/views/admin/dict/DictForm.vue @@ -0,0 +1,216 @@ + + + + + diff --git a/mokee-gateway-web/src/views/admin/dict/DictList.vue b/mokee-gateway-web/src/views/admin/dict/DictList.vue new file mode 100644 index 0000000..c60f1cf --- /dev/null +++ b/mokee-gateway-web/src/views/admin/dict/DictList.vue @@ -0,0 +1,331 @@ + + + + + + diff --git a/mokee-gateway-web/src/views/admin/email/EmailList.vue b/mokee-gateway-web/src/views/admin/email/EmailList.vue new file mode 100644 index 0000000..a73b0dc --- /dev/null +++ b/mokee-gateway-web/src/views/admin/email/EmailList.vue @@ -0,0 +1,286 @@ + + + + + diff --git a/mokee-gateway-web/src/views/admin/file/FileList.vue b/mokee-gateway-web/src/views/admin/file/FileList.vue new file mode 100644 index 0000000..64cb584 --- /dev/null +++ b/mokee-gateway-web/src/views/admin/file/FileList.vue @@ -0,0 +1,468 @@ + + + + + + diff --git a/mokee-gateway-web/src/views/admin/log/LoginLogList.vue b/mokee-gateway-web/src/views/admin/log/LoginLogList.vue new file mode 100644 index 0000000..8db0877 --- /dev/null +++ b/mokee-gateway-web/src/views/admin/log/LoginLogList.vue @@ -0,0 +1,190 @@ + + + + + + diff --git a/mokee-gateway-web/src/views/admin/log/OpenTokenLogList.vue b/mokee-gateway-web/src/views/admin/log/OpenTokenLogList.vue new file mode 100644 index 0000000..3f43b96 --- /dev/null +++ b/mokee-gateway-web/src/views/admin/log/OpenTokenLogList.vue @@ -0,0 +1,280 @@ + + + + + diff --git a/mokee-gateway-web/src/views/admin/log/OperationLogList.vue b/mokee-gateway-web/src/views/admin/log/OperationLogList.vue new file mode 100644 index 0000000..2bffbeb --- /dev/null +++ b/mokee-gateway-web/src/views/admin/log/OperationLogList.vue @@ -0,0 +1,445 @@ + + + + + + diff --git a/mokee-gateway-web/src/views/admin/menu/MenuForm.vue b/mokee-gateway-web/src/views/admin/menu/MenuForm.vue new file mode 100644 index 0000000..bdc31c4 --- /dev/null +++ b/mokee-gateway-web/src/views/admin/menu/MenuForm.vue @@ -0,0 +1,366 @@ + + + + + + diff --git a/mokee-gateway-web/src/views/admin/menu/MenuList.vue b/mokee-gateway-web/src/views/admin/menu/MenuList.vue new file mode 100644 index 0000000..a8c23f9 --- /dev/null +++ b/mokee-gateway-web/src/views/admin/menu/MenuList.vue @@ -0,0 +1,309 @@ + + + + + + diff --git a/mokee-gateway-web/src/views/admin/role/RoleForm.vue b/mokee-gateway-web/src/views/admin/role/RoleForm.vue new file mode 100644 index 0000000..53b599d --- /dev/null +++ b/mokee-gateway-web/src/views/admin/role/RoleForm.vue @@ -0,0 +1,226 @@ + + + + + + diff --git a/mokee-gateway-web/src/views/admin/role/RoleList.vue b/mokee-gateway-web/src/views/admin/role/RoleList.vue new file mode 100644 index 0000000..c1d3ede --- /dev/null +++ b/mokee-gateway-web/src/views/admin/role/RoleList.vue @@ -0,0 +1,380 @@ + + + + + + diff --git a/mokee-gateway-web/src/views/admin/system/ApiForm.vue b/mokee-gateway-web/src/views/admin/system/ApiForm.vue new file mode 100644 index 0000000..3272708 --- /dev/null +++ b/mokee-gateway-web/src/views/admin/system/ApiForm.vue @@ -0,0 +1,236 @@ + + + + + + diff --git a/mokee-gateway-web/src/views/admin/system/ApiList.vue b/mokee-gateway-web/src/views/admin/system/ApiList.vue new file mode 100644 index 0000000..e68bca9 --- /dev/null +++ b/mokee-gateway-web/src/views/admin/system/ApiList.vue @@ -0,0 +1,314 @@ + + + + + + diff --git a/mokee-gateway-web/src/views/admin/system/SystemForm.vue b/mokee-gateway-web/src/views/admin/system/SystemForm.vue new file mode 100644 index 0000000..a3ecb1e --- /dev/null +++ b/mokee-gateway-web/src/views/admin/system/SystemForm.vue @@ -0,0 +1,201 @@ + + + + + diff --git a/mokee-gateway-web/src/views/admin/system/SystemList.vue b/mokee-gateway-web/src/views/admin/system/SystemList.vue new file mode 100644 index 0000000..18180c9 --- /dev/null +++ b/mokee-gateway-web/src/views/admin/system/SystemList.vue @@ -0,0 +1,272 @@ + + + + + + diff --git a/mokee-gateway-web/src/views/admin/user/UserForm.vue b/mokee-gateway-web/src/views/admin/user/UserForm.vue new file mode 100644 index 0000000..3b59d6b --- /dev/null +++ b/mokee-gateway-web/src/views/admin/user/UserForm.vue @@ -0,0 +1,537 @@ + + + + + + diff --git a/mokee-gateway-web/src/views/admin/user/UserList.vue b/mokee-gateway-web/src/views/admin/user/UserList.vue new file mode 100644 index 0000000..772f379 --- /dev/null +++ b/mokee-gateway-web/src/views/admin/user/UserList.vue @@ -0,0 +1,279 @@ + + + + + + diff --git a/mokee-gateway-web/src/views/docs/IntegrationDoc.vue b/mokee-gateway-web/src/views/docs/IntegrationDoc.vue new file mode 100644 index 0000000..74814e8 --- /dev/null +++ b/mokee-gateway-web/src/views/docs/IntegrationDoc.vue @@ -0,0 +1,1399 @@ + + + + + + diff --git a/mokee-gateway-web/src/views/login/LoginView.vue b/mokee-gateway-web/src/views/login/LoginView.vue new file mode 100644 index 0000000..b064660 --- /dev/null +++ b/mokee-gateway-web/src/views/login/LoginView.vue @@ -0,0 +1,322 @@ + + + + + diff --git a/mokee-gateway-web/src/views/select/SystemSelectView.vue b/mokee-gateway-web/src/views/select/SystemSelectView.vue new file mode 100644 index 0000000..d44ed3e --- /dev/null +++ b/mokee-gateway-web/src/views/select/SystemSelectView.vue @@ -0,0 +1,356 @@ + + + + + + diff --git a/mokee-gateway-web/src/widgets/user-chip/UserChip.vue b/mokee-gateway-web/src/widgets/user-chip/UserChip.vue new file mode 100644 index 0000000..99d673e --- /dev/null +++ b/mokee-gateway-web/src/widgets/user-chip/UserChip.vue @@ -0,0 +1,248 @@ + + + + + + diff --git a/mokee-gateway-web/src/widgets/user-chip/api.ts b/mokee-gateway-web/src/widgets/user-chip/api.ts new file mode 100644 index 0000000..5637669 --- /dev/null +++ b/mokee-gateway-web/src/widgets/user-chip/api.ts @@ -0,0 +1,139 @@ +/** + * API 调用层 — 封装 Widget 需要的 3 个网关 API + * + * 设计原则: + * - 直接用 fetch,不依赖 axios(省 ~15KB 包体积) + * - 不依赖 Pinia、request.ts 等网关后台专用模块 + * - 所有函数以 apiBase 为参数,方便外部配置 + * - 错误静默处理,失败返回 null,让组件安全降级 + */ +import { getToken } from './cookie' + +/** 用户信息(与网关 GET /zgapi/v1/auth/user/info 响应一致) */ +export interface UserInfo { + userId: string + username: string + realName: string + email: string + phone: string + avatar: string | null + post: string +} + +/** 文件服务域名(用于判断头像 URL 是否需要刷新 Token) */ +const FILE_SERVICE_HOST = 'file.wg.zgitm.com' + +/** 判断 URL 是否为文件服务地址(需要动态下载 Token) */ +function isFileServiceUrl(url: string): boolean { + if (!url) return false + return url.includes(FILE_SERVICE_HOST) +} + +/** 从 URL 中去掉 ?token=xxx 参数,保留基础路径 */ +function stripToken(url: string): string { + return url.split('?')[0] +} + +/** + * 获取当前用户信息 + * + * GET {apiBase}/zgapi/v1/auth/user/info + * 请求头携带 Authorization: Bearer {token}(token 从 Cookie 读取) + * + * @param apiBase - 网关 API 地址(如 https://gw.server.zgitm.com) + * @returns 用户信息对象,未登录/Token过期/网络异常返回 null + */ +export async function fetchUserInfo(apiBase: string): Promise { + const token = getToken() + if (!token) return null + + try { + const res = await fetch(`${apiBase}/zgapi/v1/auth/user/info`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json;charset=UTF-8', + }, + credentials: 'include', + }) + + if (res.status === 401) return null // Token 过期,静默降级 + if (!res.ok) return null + + const body = await res.json() + // 网关统一响应格式: { code: 200, data: {...} } + if (body.code !== 200) return null + + return body.data as UserInfo + } catch { + // 网络异常,静默降级为未登录 + return null + } +} + +/** + * 退出登录 + * + * POST {apiBase}/zgapi/v1/auth/logout + * fire-and-forget: 失败不阻塞后续 Cookie 清除和页面跳转 + * + * @param apiBase - 网关 API 地址 + */ +export async function requestLogout(apiBase: string): Promise { + const token = getToken() + if (!token) return + + try { + await fetch(`${apiBase}/zgapi/v1/auth/logout`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json;charset=UTF-8', + }, + credentials: 'include', + }) + } catch { + // fire-and-forget: 即使失败也继续后续流程 + } +} + +/** + * 为文件服务 URL 获取新的下载 Token + * + * 头像存储在文件对象存储服务中,数据库只存基础 URL(如 /api/download/{fileId}), + * Token 一次一密,每次加载页面都需要重新获取并拼接完整的下载地址。 + * + * POST {apiBase}/zgapi/v1/open/token → 获取新 Token → 拼接 baseUrl?token=xxx + * + * @param apiBase - 网关 API 地址 + * @param url - 原始头像 URL(可能是带过期 Token 的 URL 或纯 baseUrl) + * @returns 拼接了新 Token 的完整 URL;获取失败返回原 URL 降级 + */ +export async function refreshFileServiceUrl(apiBase: string, url: string): Promise { + if (!url || !isFileServiceUrl(url)) return url + + const token = getToken() + if (!token) return url + + try { + const baseUrl = stripToken(url) + const res = await fetch(`${apiBase}/zgapi/v1/open/token`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json;charset=UTF-8', + }, + credentials: 'include', + body: JSON.stringify({ systemCode: 'gateway' }), + }) + + if (!res.ok) return url + + const body = await res.json() + if (body.code !== 200 || !body.data?.token) return url + + return `${baseUrl}?token=${body.data.token}` + } catch { + return url + } +} diff --git a/mokee-gateway-web/src/widgets/user-chip/cookie.ts b/mokee-gateway-web/src/widgets/user-chip/cookie.ts new file mode 100644 index 0000000..43b6cb4 --- /dev/null +++ b/mokee-gateway-web/src/widgets/user-chip/cookie.ts @@ -0,0 +1,42 @@ +/** + * Cookie 工具 — Token 的 Cookie 读写 + * + * 与 src/utils/request.ts 和 src/stores/auth.ts 的 Cookie 逻辑完全一致, + * 保持网关 Token 机制统一。 + * + * Cookie 属性: + * - path=/ : 全站可读 + * - max-age=7200 : 2小时,与 JWT Token 过期时间一致 + * - SameSite=Lax : 允许同站内跨子域携带 + * - domain : 由 data-cookie-domain 配置(如 .zgitm.com),用于跨子域共享 Token + */ + +const TOKEN_KEY = 'token' + +/** 从占位元素读取 Cookie domain 配置 */ +function readCookieDomain(): string { + const el = document.getElementById('mokee-user-chip') + if (el) { + const d = el.getAttribute('data-cookie-domain') + if (d !== null) return d + } + return '' +} + +/** 构建 Cookie 的 domain 部分 */ +function getDomainPart(): string { + const domain = readCookieDomain() + return domain ? `;domain=${domain}` : '' +} + +/** 从 Cookie 读取 Token */ +export function getToken(): string | null { + const match = document.cookie.match(new RegExp(`(?:^|;\\s*)${TOKEN_KEY}=([^;]*)`)) + return match ? decodeURIComponent(match[1]) : null +} + +/** 清除 Cookie 中的 Token */ +export function removeToken(): void { + const domainPart = getDomainPart() + document.cookie = `${TOKEN_KEY}=;path=/;max-age=0${domainPart}` +} diff --git a/mokee-gateway-web/src/widgets/user-chip/main.ts b/mokee-gateway-web/src/widgets/user-chip/main.ts new file mode 100644 index 0000000..53ca6ce --- /dev/null +++ b/mokee-gateway-web/src/widgets/user-chip/main.ts @@ -0,0 +1,92 @@ +/** + * UserChip Widget — 自动初始化入口 + * + * 外部系统只需引入这一个 JS 文件,页面上放置
, + * 脚本自动扫描并渲染用户头像下拉组件。 + * + * 使用方式(生产环境): + * + *
+ * + * + * loginUrl 和 selectUrl 自动从脚本 URL 推断,无需手动配置: + * 脚本 src = https://web.gw.zgitm.com/widgets/user-chip.js + * → loginUrl = https://web.gw.zgitm.com/login + * → selectUrl = https://web.gw.zgitm.com/select + * + * 为什么不用 window.location.origin? + * 外部系统运行在自己域名下(如 app1.zgitm.com), + * window.location.origin 是外部系统域名,不是网关域名。 + * 正确做法是从 Widget 脚本自身的