From 334d2c6312f9a129dd1f806f17fc5c81982c9398 Mon Sep 17 00:00:00 2001 From: molong Date: Sun, 8 Feb 2026 22:38:13 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E5=A7=8B=E5=8C=96=E9=A1=B9=E7=9B=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .clinerules/admin-rule.md | 869 +++++++++++++++ .clinerules/rule.md | 996 ++++++++++++++++++ .editorconfig | 18 + .env.example | 65 ++ .gitattributes | 11 + .gitignore | 25 + README.md | 59 ++ app/Exports/DepartmentExport.php | 67 ++ app/Exports/GenericExport.php | 34 + app/Exports/UserExport.php | 71 ++ app/Http/Controllers/Auth/Admin/Auth.php | 184 ++++ .../Controllers/Auth/Admin/Department.php | 228 ++++ .../Controllers/Auth/Admin/Permission.php | 182 ++++ app/Http/Controllers/Auth/Admin/Role.php | 240 +++++ app/Http/Controllers/Auth/Admin/User.php | 329 ++++++ app/Http/Controllers/Controller.php | 8 + app/Http/Controllers/System/Admin/City.php | 172 +++ app/Http/Controllers/System/Admin/Config.php | 142 +++ .../Controllers/System/Admin/Dictionary.php | 211 ++++ app/Http/Controllers/System/Admin/Log.php | 126 +++ app/Http/Controllers/System/Admin/Task.php | 152 +++ app/Http/Controllers/System/Admin/Upload.php | 152 +++ app/Http/Controllers/System/Api/City.php | 75 ++ app/Http/Controllers/System/Api/Config.php | 51 + .../Controllers/System/Api/Dictionary.php | 59 ++ app/Http/Controllers/System/Api/Upload.php | 106 ++ app/Http/Controllers/System/WebSocket.php | 395 +++++++ app/Http/Middleware/AuthCheckMiddleware.php | 120 +++ app/Http/Middleware/LogRequestMiddleware.php | 242 +++++ app/Http/Requests/LogRequest.php | 139 +++ app/Imports/DepartmentImport.php | 120 +++ app/Imports/UserImport.php | 147 +++ app/Models/Auth/Department.php | 53 + app/Models/Auth/Permission.php | 58 + app/Models/Auth/Role.php | 45 + app/Models/Auth/User.php | 109 ++ app/Models/System/City.php | 46 + app/Models/System/Config.php | 47 + app/Models/System/Dictionary.php | 38 + app/Models/System/DictionaryItem.php | 32 + app/Models/System/Log.php | 39 + app/Models/System/Task.php | 42 + app/Providers/AppServiceProvider.php | 24 + app/Services/Auth/AuthService.php | 248 +++++ app/Services/Auth/DepartmentService.php | 319 ++++++ app/Services/Auth/ImportExportService.php | 201 ++++ app/Services/Auth/PermissionCacheService.php | 256 +++++ app/Services/Auth/PermissionService.php | 323 ++++++ app/Services/Auth/RoleService.php | 430 ++++++++ app/Services/Auth/UserOnlineService.php | 208 ++++ app/Services/Auth/UserService.php | 320 ++++++ app/Services/System/CityService.php | 198 ++++ app/Services/System/ConfigService.php | 158 +++ app/Services/System/DictionaryService.php | 210 ++++ app/Services/System/LogService.php | 125 +++ app/Services/System/TaskService.php | 167 +++ app/Services/System/UploadService.php | 160 +++ app/Services/WebSocket/WebSocketHandler.php | 451 ++++++++ app/Services/WebSocket/WebSocketService.php | 402 +++++++ artisan | 18 + bin/fswatch | 26 + bin/inotify | 28 + bin/laravels | 169 +++ bootstrap/app.php | 35 + bootstrap/cache/.gitignore | 2 + bootstrap/providers.php | 5 + composer.json | 90 ++ config/app.php | 126 +++ config/auth.php | 124 +++ config/cache.php | 117 ++ config/cors.php | 34 + config/database.php | 183 ++++ config/filesystems.php | 80 ++ config/jwt.php | 301 ++++++ config/laravels.php | 317 ++++++ config/logging.php | 132 +++ config/mail.php | 118 +++ config/queue.php | 129 +++ config/services.php | 38 + config/session.php | 217 ++++ database/.gitignore | 1 + .../2024_01_01_000000_create_auth_tables.php | 121 +++ ...2024_01_02_000001_create_system_tables.php | 145 +++ database/seeders/AuthSeeder.php | 570 ++++++++++ database/seeders/DatabaseSeeder.php | 18 + database/seeders/SystemSeeder.php | 636 +++++++++++ docs/LOG_IMPLEMENTATION_SUMMARY.md | 337 ++++++ docs/README_AUTH.md | 638 +++++++++++ docs/README_LOG.md | 608 +++++++++++ docs/README_SYSTEM.md | 792 ++++++++++++++ docs/README_WEBSOCKET.md | 684 ++++++++++++ package.json | 18 + phpunit.xml | 35 + public/.htaccess | 25 + public/favicon.ico | 0 public/index.php | 20 + public/robots.txt | 2 + resources/admin/.gitignore | 24 + resources/admin/.vscode/extensions.json | 3 + resources/admin/README.md | 5 + resources/admin/index.html | 247 +++++ resources/admin/package.json | 43 + resources/admin/public/vite.svg | 1 + resources/admin/src/App.vue | 92 ++ resources/admin/src/api/auth.js | 302 ++++++ resources/admin/src/api/system.js | 392 +++++++ .../src/assets/images/default_avatar.jpg | Bin 0 -> 44913 bytes resources/admin/src/assets/images/logo.png | Bin 0 -> 17682 bytes resources/admin/src/assets/style/app.scss | 164 +++ .../admin/src/assets/style/auth-pages.scss | 570 ++++++++++ resources/admin/src/assets/style/auth.scss | 356 +++++++ resources/admin/src/assets/vue.svg | 1 + resources/admin/src/boot.js | 15 + resources/admin/src/components/HelloWorld.vue | 43 + .../admin/src/components/scCron/index.vue | 84 ++ .../src/components/scEditor/UploadAdapter.js | 144 +++ .../admin/src/components/scEditor/index.vue | 389 +++++++ .../admin/src/components/scExport/README.md | 394 +++++++ .../admin/src/components/scExport/index.vue | 278 +++++ .../admin/src/components/scForm/index.vue | 320 ++++++ .../src/components/scIconPicker/index.vue | 508 +++++++++ .../admin/src/components/scImport/README.md | 192 ++++ .../admin/src/components/scImport/index.vue | 323 ++++++ .../admin/src/components/scTable/index.vue | 546 ++++++++++ .../admin/src/components/scUpload/file.vue | 186 ++++ .../admin/src/components/scUpload/index.vue | 383 +++++++ resources/admin/src/config/index.js | 59 ++ resources/admin/src/config/routes.js | 7 + resources/admin/src/config/upload.js | 20 + resources/admin/src/hooks/useI18n.js | 16 + resources/admin/src/hooks/useTable.js | 248 +++++ resources/admin/src/i18n/index.js | 15 + resources/admin/src/i18n/locales/en-US.js | 249 +++++ resources/admin/src/i18n/locales/zh-CN.js | 248 +++++ .../src/layouts/components/breadcrumb.vue | 55 + .../admin/src/layouts/components/navMenu.vue | 54 + .../admin/src/layouts/components/search.vue | 302 ++++++ .../admin/src/layouts/components/setting.vue | 350 ++++++ .../admin/src/layouts/components/sideMenu.vue | 172 +++ .../admin/src/layouts/components/tags.vue | 487 +++++++++ .../admin/src/layouts/components/task.vue | 448 ++++++++ .../admin/src/layouts/components/userbar.vue | 380 +++++++ resources/admin/src/layouts/index.vue | 665 ++++++++++++ resources/admin/src/layouts/other/404.vue | 228 ++++ resources/admin/src/layouts/other/empty.vue | 211 ++++ resources/admin/src/main.js | 20 + .../admin/src/pages/home/iconPickerDemo.vue | 50 + resources/admin/src/pages/home/index.vue | 47 + .../pages/home/widgets/components/about.vue | 27 + .../pages/home/widgets/components/echarts.vue | 132 +++ .../pages/home/widgets/components/index.js | 8 + .../pages/home/widgets/components/info.vue | 63 ++ .../home/widgets/components/progress.vue | 36 + .../src/pages/home/widgets/components/sms.vue | 93 ++ .../pages/home/widgets/components/time.vue | 72 ++ .../src/pages/home/widgets/components/ver.vue | 69 ++ .../pages/home/widgets/components/welcome.vue | 97 ++ .../admin/src/pages/home/widgets/index.vue | 505 +++++++++ .../src/pages/home/work/components/myapp.vue | 469 +++++++++ resources/admin/src/pages/home/work/index.vue | 21 + resources/admin/src/pages/login/index.vue | 163 +++ .../admin/src/pages/login/resetPassword.vue | 164 +++ .../admin/src/pages/login/userRegister.vue | 161 +++ .../pages/ucenter/components/BasicInfo.vue | 105 ++ .../src/pages/ucenter/components/Password.vue | 81 ++ .../pages/ucenter/components/ProfileInfo.vue | 73 ++ .../src/pages/ucenter/components/Security.vue | 85 ++ resources/admin/src/pages/ucenter/index.vue | 210 ++++ resources/admin/src/router/index.js | 205 ++++ resources/admin/src/router/systemRoutes.js | 43 + resources/admin/src/stores/index.js | 9 + resources/admin/src/stores/modules/i18n.js | 36 + resources/admin/src/stores/modules/layout.js | 130 +++ resources/admin/src/stores/modules/user.js | 118 +++ resources/admin/src/stores/persist.js | 50 + resources/admin/src/style.css | 79 ++ resources/admin/src/utils/request.js | 140 +++ resources/admin/src/utils/tool.js | 499 +++++++++ resources/admin/src/utils/websocket.js | 257 +++++ resources/admin/vite.config.js | 18 + resources/views/welcome.blade.php | 426 ++++++++ routes/admin.php | 188 ++++ routes/api.php | 3 + routes/console.php | 8 + routes/web.php | 7 + storage/app/.gitignore | 4 + storage/app/private/.gitignore | 2 + storage/app/public/.gitignore | 2 + storage/framework/.gitignore | 9 + storage/framework/cache/.gitignore | 3 + storage/framework/cache/data/.gitignore | 2 + storage/framework/sessions/.gitignore | 2 + storage/framework/testing/.gitignore | 2 + storage/framework/views/.gitignore | 2 + storage/laravels.conf | 1 + storage/laravels.pid | 1 + storage/logs/.gitignore | 2 + tests/Feature/ExampleTest.php | 19 + tests/TestCase.php | 10 + tests/Unit/ExampleTest.php | 16 + vite.config.js | 18 + 201 files changed, 32724 insertions(+) create mode 100644 .clinerules/admin-rule.md create mode 100644 .clinerules/rule.md create mode 100644 .editorconfig create mode 100644 .env.example create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app/Exports/DepartmentExport.php create mode 100644 app/Exports/GenericExport.php create mode 100644 app/Exports/UserExport.php create mode 100644 app/Http/Controllers/Auth/Admin/Auth.php create mode 100644 app/Http/Controllers/Auth/Admin/Department.php create mode 100644 app/Http/Controllers/Auth/Admin/Permission.php create mode 100644 app/Http/Controllers/Auth/Admin/Role.php create mode 100644 app/Http/Controllers/Auth/Admin/User.php create mode 100644 app/Http/Controllers/Controller.php create mode 100644 app/Http/Controllers/System/Admin/City.php create mode 100644 app/Http/Controllers/System/Admin/Config.php create mode 100644 app/Http/Controllers/System/Admin/Dictionary.php create mode 100644 app/Http/Controllers/System/Admin/Log.php create mode 100644 app/Http/Controllers/System/Admin/Task.php create mode 100644 app/Http/Controllers/System/Admin/Upload.php create mode 100644 app/Http/Controllers/System/Api/City.php create mode 100644 app/Http/Controllers/System/Api/Config.php create mode 100644 app/Http/Controllers/System/Api/Dictionary.php create mode 100644 app/Http/Controllers/System/Api/Upload.php create mode 100644 app/Http/Controllers/System/WebSocket.php create mode 100644 app/Http/Middleware/AuthCheckMiddleware.php create mode 100644 app/Http/Middleware/LogRequestMiddleware.php create mode 100644 app/Http/Requests/LogRequest.php create mode 100644 app/Imports/DepartmentImport.php create mode 100644 app/Imports/UserImport.php create mode 100644 app/Models/Auth/Department.php create mode 100644 app/Models/Auth/Permission.php create mode 100644 app/Models/Auth/Role.php create mode 100644 app/Models/Auth/User.php create mode 100644 app/Models/System/City.php create mode 100644 app/Models/System/Config.php create mode 100644 app/Models/System/Dictionary.php create mode 100644 app/Models/System/DictionaryItem.php create mode 100644 app/Models/System/Log.php create mode 100644 app/Models/System/Task.php create mode 100644 app/Providers/AppServiceProvider.php create mode 100644 app/Services/Auth/AuthService.php create mode 100644 app/Services/Auth/DepartmentService.php create mode 100644 app/Services/Auth/ImportExportService.php create mode 100644 app/Services/Auth/PermissionCacheService.php create mode 100644 app/Services/Auth/PermissionService.php create mode 100644 app/Services/Auth/RoleService.php create mode 100644 app/Services/Auth/UserOnlineService.php create mode 100644 app/Services/Auth/UserService.php create mode 100644 app/Services/System/CityService.php create mode 100644 app/Services/System/ConfigService.php create mode 100644 app/Services/System/DictionaryService.php create mode 100644 app/Services/System/LogService.php create mode 100644 app/Services/System/TaskService.php create mode 100644 app/Services/System/UploadService.php create mode 100644 app/Services/WebSocket/WebSocketHandler.php create mode 100644 app/Services/WebSocket/WebSocketService.php create mode 100644 artisan create mode 100644 bin/fswatch create mode 100644 bin/inotify create mode 100644 bin/laravels create mode 100644 bootstrap/app.php create mode 100644 bootstrap/cache/.gitignore create mode 100644 bootstrap/providers.php create mode 100644 composer.json create mode 100644 config/app.php create mode 100644 config/auth.php create mode 100644 config/cache.php create mode 100644 config/cors.php create mode 100644 config/database.php create mode 100644 config/filesystems.php create mode 100644 config/jwt.php create mode 100644 config/laravels.php create mode 100644 config/logging.php create mode 100644 config/mail.php create mode 100644 config/queue.php create mode 100644 config/services.php create mode 100644 config/session.php create mode 100644 database/.gitignore create mode 100644 database/migrations/2024_01_01_000000_create_auth_tables.php create mode 100644 database/migrations/2024_01_02_000001_create_system_tables.php create mode 100644 database/seeders/AuthSeeder.php create mode 100644 database/seeders/DatabaseSeeder.php create mode 100644 database/seeders/SystemSeeder.php create mode 100644 docs/LOG_IMPLEMENTATION_SUMMARY.md create mode 100644 docs/README_AUTH.md create mode 100644 docs/README_LOG.md create mode 100644 docs/README_SYSTEM.md create mode 100644 docs/README_WEBSOCKET.md create mode 100644 package.json create mode 100644 phpunit.xml create mode 100644 public/.htaccess create mode 100644 public/favicon.ico create mode 100644 public/index.php create mode 100644 public/robots.txt create mode 100644 resources/admin/.gitignore create mode 100644 resources/admin/.vscode/extensions.json create mode 100644 resources/admin/README.md create mode 100644 resources/admin/index.html create mode 100644 resources/admin/package.json create mode 100644 resources/admin/public/vite.svg create mode 100644 resources/admin/src/App.vue create mode 100644 resources/admin/src/api/auth.js create mode 100644 resources/admin/src/api/system.js create mode 100644 resources/admin/src/assets/images/default_avatar.jpg create mode 100644 resources/admin/src/assets/images/logo.png create mode 100644 resources/admin/src/assets/style/app.scss create mode 100644 resources/admin/src/assets/style/auth-pages.scss create mode 100644 resources/admin/src/assets/style/auth.scss create mode 100644 resources/admin/src/assets/vue.svg create mode 100644 resources/admin/src/boot.js create mode 100644 resources/admin/src/components/HelloWorld.vue create mode 100644 resources/admin/src/components/scCron/index.vue create mode 100644 resources/admin/src/components/scEditor/UploadAdapter.js create mode 100644 resources/admin/src/components/scEditor/index.vue create mode 100644 resources/admin/src/components/scExport/README.md create mode 100644 resources/admin/src/components/scExport/index.vue create mode 100644 resources/admin/src/components/scForm/index.vue create mode 100644 resources/admin/src/components/scIconPicker/index.vue create mode 100644 resources/admin/src/components/scImport/README.md create mode 100644 resources/admin/src/components/scImport/index.vue create mode 100644 resources/admin/src/components/scTable/index.vue create mode 100644 resources/admin/src/components/scUpload/file.vue create mode 100644 resources/admin/src/components/scUpload/index.vue create mode 100644 resources/admin/src/config/index.js create mode 100644 resources/admin/src/config/routes.js create mode 100644 resources/admin/src/config/upload.js create mode 100644 resources/admin/src/hooks/useI18n.js create mode 100644 resources/admin/src/hooks/useTable.js create mode 100644 resources/admin/src/i18n/index.js create mode 100644 resources/admin/src/i18n/locales/en-US.js create mode 100644 resources/admin/src/i18n/locales/zh-CN.js create mode 100644 resources/admin/src/layouts/components/breadcrumb.vue create mode 100644 resources/admin/src/layouts/components/navMenu.vue create mode 100644 resources/admin/src/layouts/components/search.vue create mode 100644 resources/admin/src/layouts/components/setting.vue create mode 100644 resources/admin/src/layouts/components/sideMenu.vue create mode 100644 resources/admin/src/layouts/components/tags.vue create mode 100644 resources/admin/src/layouts/components/task.vue create mode 100644 resources/admin/src/layouts/components/userbar.vue create mode 100644 resources/admin/src/layouts/index.vue create mode 100644 resources/admin/src/layouts/other/404.vue create mode 100644 resources/admin/src/layouts/other/empty.vue create mode 100644 resources/admin/src/main.js create mode 100644 resources/admin/src/pages/home/iconPickerDemo.vue create mode 100644 resources/admin/src/pages/home/index.vue create mode 100644 resources/admin/src/pages/home/widgets/components/about.vue create mode 100644 resources/admin/src/pages/home/widgets/components/echarts.vue create mode 100644 resources/admin/src/pages/home/widgets/components/index.js create mode 100644 resources/admin/src/pages/home/widgets/components/info.vue create mode 100644 resources/admin/src/pages/home/widgets/components/progress.vue create mode 100644 resources/admin/src/pages/home/widgets/components/sms.vue create mode 100644 resources/admin/src/pages/home/widgets/components/time.vue create mode 100644 resources/admin/src/pages/home/widgets/components/ver.vue create mode 100644 resources/admin/src/pages/home/widgets/components/welcome.vue create mode 100644 resources/admin/src/pages/home/widgets/index.vue create mode 100644 resources/admin/src/pages/home/work/components/myapp.vue create mode 100644 resources/admin/src/pages/home/work/index.vue create mode 100644 resources/admin/src/pages/login/index.vue create mode 100644 resources/admin/src/pages/login/resetPassword.vue create mode 100644 resources/admin/src/pages/login/userRegister.vue create mode 100644 resources/admin/src/pages/ucenter/components/BasicInfo.vue create mode 100644 resources/admin/src/pages/ucenter/components/Password.vue create mode 100644 resources/admin/src/pages/ucenter/components/ProfileInfo.vue create mode 100644 resources/admin/src/pages/ucenter/components/Security.vue create mode 100644 resources/admin/src/pages/ucenter/index.vue create mode 100644 resources/admin/src/router/index.js create mode 100644 resources/admin/src/router/systemRoutes.js create mode 100644 resources/admin/src/stores/index.js create mode 100644 resources/admin/src/stores/modules/i18n.js create mode 100644 resources/admin/src/stores/modules/layout.js create mode 100644 resources/admin/src/stores/modules/user.js create mode 100644 resources/admin/src/stores/persist.js create mode 100644 resources/admin/src/style.css create mode 100644 resources/admin/src/utils/request.js create mode 100644 resources/admin/src/utils/tool.js create mode 100644 resources/admin/src/utils/websocket.js create mode 100644 resources/admin/vite.config.js create mode 100644 resources/views/welcome.blade.php create mode 100644 routes/admin.php create mode 100644 routes/api.php create mode 100644 routes/console.php create mode 100644 routes/web.php create mode 100644 storage/app/.gitignore create mode 100644 storage/app/private/.gitignore create mode 100644 storage/app/public/.gitignore create mode 100644 storage/framework/.gitignore create mode 100644 storage/framework/cache/.gitignore create mode 100644 storage/framework/cache/data/.gitignore create mode 100644 storage/framework/sessions/.gitignore create mode 100644 storage/framework/testing/.gitignore create mode 100644 storage/framework/views/.gitignore create mode 100644 storage/laravels.conf create mode 100644 storage/laravels.pid create mode 100644 storage/logs/.gitignore create mode 100644 tests/Feature/ExampleTest.php create mode 100644 tests/TestCase.php create mode 100644 tests/Unit/ExampleTest.php create mode 100644 vite.config.js diff --git a/.clinerules/admin-rule.md b/.clinerules/admin-rule.md new file mode 100644 index 0000000..fdc5969 --- /dev/null +++ b/.clinerules/admin-rule.md @@ -0,0 +1,869 @@ +# Vue3 后台管理项目开发规范 + +## 项目概述 + +本项目是一个基于 Vue3 的后台管理系统,采用现代化技术栈构建,提供高效、美观的管理界面。 + +## 技术栈 + +- **Vue 3**: 渐进式 JavaScript 框架 +- **Vite**: 下一代前端构建工具 +- **Ant Design Vue**: 基于 Vue 3 的 UI 组件库 +- **Vue Router**: Vue.js 官方路由管理器 +- **Pinia**: Vue 3 官方状态管理库 +- **Axios**: HTTP 客户端 +- **JavaScript**: 主要开发语言(非 TypeScript) +- **Composition API**: 组合式 API 开发模式 + +## 图标系统 + +项目采用 Ant Design Vue 图标库,已全局引入: + +- **Ant Design Vue Icons**: Ant Design Vue 官方图标库 + +**重要提示**: 图标已全局默认引入,开发时请勿重复引入或按需引入,直接使用即可。 + +## 开发规范 + +### 1. 项目结构 + +``` +resources/admin/ +├── src/ +│ ├── api/ # API 接口层 +│ │ ├── auth.js # 认证相关接口 +│ │ ├── menu.js # 菜单接口 +│ │ └── system.js # 系统相关接口 +│ ├── assets/ # 静态资源 +│ │ ├── images/ # 图片资源 +│ │ └── style/ # 全局样式 +│ ├── components/ # 公共组件 +│ │ ├── scEditor/ # 富文本编辑器 +│ │ ├── scForm/ # 表单组件 +│ │ ├── scIconPicker/ # 图标选择器 +│ │ ├── scTable/ # 表格组件 +│ │ └── scUpload/ # 上传组件 +│ ├── config/ # 配置文件 +│ │ ├── index.js # 主配置 +│ │ ├── routes.js # 路由配置 +│ │ └── upload.js # 上传配置 +│ ├── hooks/ # 组合式 API Hooks +│ │ ├── useI18n.js # 国际化 Hook +│ │ └── useTable.js # 表格 Hook +│ ├── i18n/ # 国际化配置 +│ │ ├── index.js # i18n 配置 +│ │ └── locales/ # 语言包 +│ ├── layouts/ # 布局组件 +│ │ ├── components/ # 布局子组件 +│ │ ├── other/ # 其他布局 +│ │ └── index.vue # 主布局 +│ ├── pages/ # 页面组件 +│ │ ├── auth/ # 认证页面 +│ │ ├── home/ # 首页 +│ │ ├── login/ # 登录页 +│ │ ├── system/ # 系统管理 +│ │ └── ucenter/ # 个人中心 +│ ├── router/ # 路由配置 +│ │ ├── index.js # 主路由 +│ │ └── systemRoutes.js # 系统路由 +│ ├── stores/ # 状态管理 +│ │ ├── index.js # Store 入口 +│ │ ├── persist.js # 持久化配置 +│ │ └── modules/ # Store 模块 +│ ├── utils/ # 工具函数 +│ │ ├── request.js # Axios 封装 +│ │ └── tool.js # 工具函数 +│ ├── App.vue # 根组件 +│ ├── boot.js # 引导文件 +│ ├── main.js # 入口文件 +│ └── style.css # 全局样式 +├── public/ # 公共资源 +├── index.html # HTML 模板 +├── package.json # 依赖配置 +├── vite.config.js # Vite 配置 +└── README.md # 项目说明 +``` + +### 2. 组件开发规范 + +#### 组件命名 + +- **单文件组件**: 使用 PascalCase 命名,如 `UserList.vue` +- **公共组件**: 以 `sc` 开头,如 `scTable.vue` +- **页面组件**: 使用语义化命名,如 `UserManagement.vue` + +#### 组件结构 + +```vue + + + + + +``` + +### 3. API 接口开发规范 + +#### API 文件组织 + +每个业务模块对应一个 API 文件,统一放在 `src/api/` 目录下。 + +```javascript +// src/api/auth.js +import request from '@/utils/request' + +export default { + // 认证相关 + login: { + post: async function (params) { + return await request.post('auth/login', params) + }, + }, + logout: { + post: async function () { + return await request.post('auth/logout') + }, + }, + me: { + get: async function () { + return await request.get('auth/me') + }, + }, + + // 权限和菜单 + permissions: { + menu: { + get: async function () { + return await request.get('permissions/menu') + }, + }, + tree: { + get: async function () { + return await request.get('permissions/tree') + }, + }, + }, +} +``` + +#### 使用示例 + +```javascript +import authApi from '@/api/auth' + +const login = async () => { + try { + const res = await authApi.login.post({ + username: 'admin', + password: '123456' + }) + // 处理响应 + } catch (error) { + // 处理错误 + } +} + +// 获取用户菜单 +const getMenu = async () => { + const res = await authApi.permissions.menu.get() + return res.data +} +``` + +### 4. 路由开发规范 + +#### 路由类型 + +项目包含两类路由: + +1. **静态路由**: 定义在 `src/router/systemRoutes.js` 中的基础路由,如登录页、404 页面等 +2. **动态路由**: 用户登录后,通过 API 获取菜单数据,动态添加到路由中 + +#### 静态路由定义 + +```javascript +// src/router/systemRoutes.js +export default [ + { + path: '/login', + name: 'Login', + component: () => import('@/pages/login/index.vue'), + meta: { + title: '登录', + hidden: true + } + }, + { + path: '/', + name: 'Layout', + component: () => import('@/layouts/index.vue'), + redirect: '/dashboard', + children: [ + // 动态路由将被添加到这里 + ] + } +] +``` + +#### 动态路由加载 + +用户登录后,系统会自动执行以下流程: + +1. 调用后端 API 获取用户菜单和权限信息 +2. 将菜单数据转换为路由格式 +3. 将动态路由添加到路由器中 +4. 生成菜单树用于侧边栏展示 + +**路由元信息 (meta)**: +- `title`: 页面标题 +- `icon`: 菜单图标(使用 Ant Design Vue 图标名称) +- `hidden`: 是否隐藏菜单 +- `noAuth`: 是否不需要认证 +- `keepAlive`: 是否缓存页面 +- `affix`: 是否固定标签页 + +**后端菜单数据格式**: + +```javascript +{ + path: '/system', + name: 'System', + title: '系统管理', + icon: 'Setting', + component: 'views/system', // 组件路径,相对于 pages 目录 + redirect: '/system/user', + children: [ + { + path: 'user', + name: 'SystemUser', + title: '用户管理', + icon: 'User', + component: 'views/system/user' + } + ] +} +``` + +**路由转换逻辑**: + +```javascript +// 将后端菜单转换为路由格式 +function transformMenusToRoutes(menus) { + return menus.map(menu => { + const route = { + path: menu.path, + name: menu.name, + meta: { + title: menu.title, + icon: menu.icon, + hidden: menu.hidden, + keepAlive: menu.keepAlive || false + } + } + + if (menu.component) { + route.component = loadComponent(menu.component) + } + + if (menu.children) { + route.children = transformMenusToRoutes(menu.children) + } + + return route + }) +} +``` + +#### 路由守卫 + +系统通过路由守卫实现权限控制和动态路由加载: + +```javascript +router.beforeEach(async (to, from, next) => { + const userStore = useUserStore() + const isLoggedIn = userStore.isLoggedIn() + + // 白名单直接放行 + if (whiteList.includes(to.path)) { + return next() + } + + // 未登录跳转登录页 + if (!isLoggedIn) { + return next({ path: '/login', query: { redirect: to.fullPath } }) + } + + // 动态路由加载 + if (!isDynamicRouteLoaded) { + const menus = userStore.getMenu() + const dynamicRoutes = transformMenusToRoutes(menus) + + // 添加动态路由 + dynamicRoutes.forEach(route => { + router.addRoute('Layout', route) + }) + + isDynamicRouteLoaded = true + next({ ...to, replace: true }) + } else { + next() + } +}) +``` + +### 5. 状态管理规范 + +#### Pinia Store 定义 + +使用组合式 API 定义 Store。 + +#### User Store(用户认证与权限) + +`src/stores/modules/user.js` 负责管理用户认证信息和权限数据: + +```javascript +// src/stores/modules/user.js +import { ref } from 'vue' +import { defineStore } from 'pinia' +import { resetRouter } from '../../router' + +export const useUserStore = defineStore('user', () => { + // State + const token = ref('') // 访问令牌 + const refreshToken = ref('') // 刷新令牌 + const userInfo = ref(null) // 用户信息 + const menu = ref([]) // 用户菜单 + const permissions = ref([]) // 用户权限节点 + + // Getters + const isLoggedIn = () => !!token.value + + // Actions + function setToken(newToken) { + token.value = newToken + } + + function setUserInfo(info) { + userInfo.value = info + } + + // 设置菜单(合并静态菜单和后端菜单) + function setMenu(newMenu) { + const staticMenus = userRoutes || [] + let mergedMenus = [...staticMenus] + + if (newMenu && newMenu.length > 0) { + const menuMap = new Map() + + // 添加静态菜单 + staticMenus.forEach(m => { + if (m.path) menuMap.set(m.path, m) + }) + + // 添加后端菜单(覆盖重复路径) + newMenu.forEach(m => { + if (m.path) menuMap.set(m.path, m) + }) + + mergedMenus = Array.from(menuMap.values()) + } + menu.value = mergedMenus + } + + function getMenu() { + return menu.value + } + + function setPermissions(data) { + permissions.value = data + } + + function hasPermission(permission) { + if (!permissions.value || permissions.value.length === 0) { + return false + } + return permissions.value.includes(permission) + } + + function logout() { + token.value = '' + refreshToken.value = '' + userInfo.value = null + menu.value = [] + resetRouter() + } + + return { + token, + refreshToken, + userInfo, + menu, + permissions, + setToken, + setUserInfo, + setMenu, + getMenu, + setPermissions, + hasPermission, + logout, + isLoggedIn + } +}) +``` + +#### Store 使用 + +```javascript +import { useUserStore } from '@/stores/modules/user' + +const userStore = useUserStore() + +// 使用 state +console.log(userStore.token) +console.log(userStore.menu) +console.log(userStore.permissions) + +// 调用 action +userStore.setToken('xxx') +userStore.setMenu(menus) +userStore.setPermissions(permissions) + +// 检查权限 +if (userStore.hasPermission('user.create')) { + // 有权限,执行操作 +} + +// 登出 +userStore.logout() +``` + +#### Store 持久化 + +使用 `pinia-plugin-persistedstate` 实现数据持久化: + +```javascript +{ + persist: { + key: 'user-store', + storage: customStorage, + pick: ['token', 'refreshToken', 'userInfo', 'menu'] + } +} +``` + +#### 权限指令 + +项目提供权限指令,用于在模板中控制元素显示: + +```vue + + + +``` + +### 6. 表格开发规范 + +#### 使用 useTable Hook + +项目提供了 `useTable` Hook 简化表格开发: + +```javascript +import { useTable } from '@/hooks/useTable' +import { ref } from 'vue' + +const { + loading, + dataSource, + pagination, + handleSearch, + handleReset, + handlePageChange +} = useTable({ + api: userApi.getList, // API 方法 + immediate: true // 是否立即加载 +}) + +// 搜索参数 +const searchParams = ref({ + keyword: '', + status: '' +}) +``` + +#### scTable 组件使用 + +```vue + +``` + +### 7. 表单开发规范 + +#### scForm 组件使用 + +```vue + + + +``` + +### 8. 图标使用规范 + +#### Ant Design Vue Icons + +```vue + + +``` + +#### 常用图标 + +- `user`: 用户 +- `setting`: 设置 +- `delete`: 删除 +- `edit`: 编辑 +- `plus`: 添加 +- `search`: 搜索 +- `reload`: 刷新 +- `download`: 下载 +- `upload`: 上传 +- `eye`: 查看 +- `eye-invisible`: 隐藏 +- `check-circle`: 成功 +- `close-circle`: 失败 +- `info-circle`: 信息 +- `warning`: 警告 + +### 9. 国际化 (i18n) 规范 + +#### 使用 i18n + +```javascript +import { useI18n } from '@/hooks/useI18n' + +const { t } = useI18n() + +// 使用 +console.log(t('common.save')) +console.log(t('user.deleteConfirm')) +``` + +#### 语言文件组织 + +```javascript +// src/i18n/locales/zh.js +export default { + common: { + save: '保存', + cancel: '取消', + confirm: '确认', + delete: '删除' + }, + user: { + deleteConfirm: '确定要删除该用户吗?' + } +} +``` + +### 10. 文件上传规范 + +#### scUpload 组件使用 + +```vue + + + +``` + +### 11. 富文本编辑器规范 + +#### scEditor 组件使用 + +```vue + + + +``` + +### 12. 样式规范 + +#### 全局样式 + +在 `src/style.css` 中定义全局样式。 + +#### 组件样式 + +使用 `scoped` 避免样式污染: + +```vue + +``` + +#### 命名规范 + +- 使用 BEM 命名法 +- 类名使用 kebab-case + +### 13. 工具函数使用 + +#### request.js (HTTP 请求) + +```javascript +import request from '@/utils/request' + +// GET 请求 +request.get('/api/users', { params: { page: 1 } }) + +// POST 请求 +request.post('/api/users', { name: 'test' }) + +// PUT 请求 +request.put('/api/users/1', { name: 'updated' }) + +// DELETE 请求 +request.delete('/api/users/1') +``` + +#### tool.js (工具函数) + +```javascript +import { formatDate, deepClone } from '@/utils/tool' + +// 格式化日期 +formatDate(new Date(), 'YYYY-MM-DD HH:mm:ss') + +// 深拷贝 +deepClone(originalObject) +``` + +### 14. 开发流程 + +#### 登录流程 + +1. 用户输入用户名和密码 +2. 调用 `authApi.login.post()` 发送登录请求 +3. 后端返回 `token`、`refreshToken`、`userInfo`、`menu`、`permissions` +4. 前端保存数据到 Store(持久化) +5. 路由守卫检测到登录状态,加载动态路由 +6. 跳转到首页或重定向页 + +```javascript +import { useUserStore } from '@/stores/modules/user' +import authApi from '@/api/auth' + +const userStore = useUserStore() + +const handleLogin = async () => { + try { + const res = await authApi.login.post({ + username: 'admin', + password: '123456' + }) + + // 保存 token + userStore.setToken(res.data.token) + userStore.setRefreshToken(res.data.refreshToken) + + // 保存用户信息 + userStore.setUserInfo(res.data.user) + + // 保存菜单(合并静态菜单) + userStore.setMenu(res.data.menu) + + // 保存权限节点 + userStore.setPermissions(res.data.permissions) + + // 跳转首页 + router.push('/dashboard') + } catch (error) { + message.error('登录失败') + } +} +``` + +#### 添加新页面 + +1. 在 `src/pages/` 对应模块下创建页面组件 +2. 在 `src/api/` 中创建对应的 API 文件 +3. 在后端添加对应的菜单和权限配置 +4. 前端会自动加载动态路由(无需手动配置路由) + +#### 添加新组件 + +1. 在 `src/components/` 或对应子目录下创建组件 +2. 遵循组件结构规范 +3. 添加必要的 Props 和 Emits +4. 编写组件文档 + +### 15. 常用命令 + +```bash +# 安装依赖 +npm install + +# 开发环境启动 +npm run dev + +# 生产环境构建 +npm run build + +# 预览生产构建 +npm run preview + +# 代码格式化 +npm run format + +# 代码检查 +npm run lint +``` + +### 16. 注意事项 + +1. **禁止重复引入图标**: Ant Design Vue 图标已全局引入,直接使用即可 +2. **使用组合式 API**: 新代码统一使用 ` +``` + +## 注意事项 + +1. **权限控制**: 日志管理接口需要相应的权限才能访问 +2. **数据安全**: 敏感信息已自动过滤,但仍需注意日志数据的安全存储 +3. **性能影响**: 虽然日志记录不影响响应速度,但大量日志会增加数据库负载 +4. **定期备份**: 重要日志数据建议定期备份 +5. **日志分析**: 可结合 BI 工具对日志数据进行深度分析 + +## 常见问题 + +### Q1: 为什么某些请求没有被记录? + +A: 检查路由是否应用了 `log.request` 中间件,或者在中间件中是否被排除了。 + +### Q2: 日志数据过多怎么办? + +A: 使用 `clearLogs` 方法定期清理历史日志,或设置任务调度器自动清理。 + +### Q3: 如何自定义日志记录规则? + +A: 修改 `LogRequestMiddleware` 中的 `parseModule` 和 `parseAction` 方法。 + +### Q4: 日志记录会影响性能吗? + +A: 日志记录在请求处理后执行,不影响响应速度。但大量日志会增加数据库写入压力。 + +### Q5: 如何查看完整的请求参数? + +A: 在日志详情接口中,`params` 字段包含了完整的请求参数(敏感信息已过滤)。 + +## 更新日志 + +### v1.0.0 (2024-01-01) +- 初始版本 +- 实现基础日志记录功能 +- 支持多维度查询和筛选 +- 支持数据导出 +- 支持批量删除和清理 diff --git a/docs/README_SYSTEM.md b/docs/README_SYSTEM.md new file mode 100644 index 0000000..39344a7 --- /dev/null +++ b/docs/README_SYSTEM.md @@ -0,0 +1,792 @@ +# System 基础模块文档 + +## 概述 + +System 基础模块提供了完整的系统管理功能,包括系统配置、数据字典、操作日志、任务管理、城市数据和文件上传等功能。 + +## 控制器结构 + +``` +app/Http/Controllers/System/ +├── Admin/ # 后台管理控制器 +│ ├── Config.php # 系统配置控制器 +│ ├── Log.php # 操作日志控制器 +│ ├── Dictionary.php # 数据字典控制器 +│ ├── Task.php # 任务管理控制器 +│ ├── City.php # 城市数据控制器 +│ └── Upload.php # 文件上传控制器 +└── Api/ # 公共API控制器 + ├── Config.php # 系统配置API + ├── Dictionary.php # 数据字典API + ├── City.php # 城市数据API + └── Upload.php # 文件上传API +``` + +### Admin 控制器 + +**命名空间**: `App\Http\Controllers\System\Admin` + +**路由前缀**: `/admin` + +**中间件**: `auth.check:admin` (后台管理认证) + +### Api 控制器 + +**命名空间**: `App\Http\Controllers\System\Api` + +**路由前缀**: `/api` + +**中间件**: `auth:api` (API认证,部分接口为公开接口) + +## 技术栈 + +- PHP 8.1+ +- Laravel 11 +- Redis 缓存 +- Intervention Image (图像处理) + +## 数据库表结构 + +### system_configs (系统配置表) +- `id`: 主键 +- `group`: 配置分组(如:system, site, upload) +- `key`: 配置键(唯一) +- `value`: 配置值(JSON格式) +- `type`: 数据类型(string, number, boolean, array, image, file) +- `name`: 配置名称 +- `description`: 配置描述 +- `options`: 可选值(JSON数组) +- `sort`: 排序 +- `status`: 状态(0:禁用, 1:启用) +- `created_at`, `updated_at`: 时间戳 + +### system_dictionaries (数据字典分类表) +- `id`: 主键 +- `name`: 字典名称 +- `code`: 字典编码(唯一) +- `description`: 描述 +- `sort`: 排序 +- `status`: 状态 +- `created_at`, `updated_at`: 时间戳 + +### system_dictionary_items (数据字典项表) +- `id`: 主键 +- `dictionary_id`: 字典ID +- `label`: 显示标签 +- `value`: 实际值 +- `sort`: 排序 +- `status`: 状态 +- `created_at`, `updated_at`: 时间戳 + +### system_logs (操作日志表) +- `id`: 主键 +- `user_id`: 用户ID +- `username`: 用户名 +- `module`: 模块 +- `action`: 操作 +- `method`: 请求方法 +- `url`: 请求URL +- `ip`: IP地址 +- `user_agent`: 用户代理 +- `request_data`: 请求数据(JSON) +- `response_data`: 响应数据(JSON) +- `duration`: 执行时间(毫秒) +- `status_code`: 状态码 +- `created_at`: 创建时间 + +### system_tasks (任务表) +- `id`: 主键 +- `name`: 任务名称 +- `code`: 任务编码(唯一) +- `command`: 命令 +- `description`: 描述 +- `cron_expression`: Cron表达式 +- `status`: 状态(0:禁用, 1:启用, 2:运行中, 3:失败) +- `last_run_at`: 最后运行时间 +- `next_run_at`: 下次运行时间 +- `run_count`: 运行次数 +- `fail_count`: 失败次数 +- `last_message`: 最后运行消息 +- `sort`: 排序 +- `created_at`, `updated_at`: 时间戳 + +### system_cities (城市表) +- `id`: 主键 +- `name`: 城市名称 +- `code`: 城市编码 +- `level`: 级别(1:省, 2:市, 3:区县) +- `parent_id`: 父级ID +- `adcode`: 行政区划编码 +- `sort`: 排序 +- `status`: 状态 +- `created_at`, `updated_at`: 时间戳 + +## Admin API 接口文档 + +### 系统配置管理 + +#### 获取配置列表 +- **接口**: `GET /admin/configs` +- **参数**: + - `page`: 页码(默认1) + - `page_size`: 每页数量(默认20) + - `keyword`: 搜索关键词 + - `group`: 配置分组 + - `status`: 状态 + - `order_by`, `order_direction`: 排序 + +#### 获取所有配置分组 +- **接口**: `GET /admin/configs/groups` + +#### 按分组获取配置 +- **接口**: `GET /admin/configs/all` +- **参数**: + - `group`: 配置分组(可选) + +#### 获取配置详情 +- **接口**: `GET /admin/configs/{id}` + +#### 创建配置 +- **接口**: `POST /admin/configs` +- **参数**: + ```json + { + "group": "site", + "key": "site_name", + "name": "网站名称", + "description": "网站显示的名称", + "type": "string", + "value": "\"我的网站\"", + "options": null, + "sort": 1, + "status": 1 + } + ``` +- **数据类型说明**: + - `string`: 字符串 + - `number`: 数字 + - `boolean`: 布尔值 + - `array`: 数组 + - `image`: 图片 + - `file`: 文件 + +#### 更新配置 +- **接口**: `PUT /admin/configs/{id}` + +#### 删除配置 +- **接口**: `DELETE /admin/configs/{id}` + +#### 批量删除配置 +- **接口**: `POST /admin/configs/batch-delete` +- **参数**: + ```json + { + "ids": [1, 2, 3] + } + ``` + +#### 批量更新配置状态 +- **接口**: `POST /admin/configs/batch-status` +- **参数**: + ```json + { + "ids": [1, 2, 3], + "status": 1 + } + ``` + +### 操作日志管理 + +#### 获取日志列表 +- **接口**: `GET /admin/logs` +- **参数**: + - `page`, `page_size` + - `keyword`: 搜索关键词(用户名/模块/操作) + - `module`: 模块 + - `action`: 操作 + - `user_id`: 用户ID + - `start_date`: 开始日期 + - `end_date`: 结束日期 + - `order_by`, `order_direction` + +#### 获取日志详情 +- **接口**: `GET /admin/logs/{id}` + +#### 删除日志 +- **接口**: `DELETE /admin/logs/{id}` + +#### 批量删除日志 +- **接口**: `POST /admin/logs/batch-delete` +- **参数**: + ```json + { + "ids": [1, 2, 3] + } + ``` + +#### 清理日志 +- **接口**: `POST /admin/logs/clear` +- **参数**: + ```json + { + "days": 30 + } + ``` +- **说明**: 删除指定天数之前的日志记录 + +#### 获取日志统计 +- **接口**: `GET /admin/logs/statistics` +- **参数**: + - `start_date`: 开始日期 + - `end_date`: 结束日期 +- **返回**: + ```json + { + "code": 200, + "message": "success", + "data": { + "total_count": 1000, + "module_stats": [ + { + "module": "user", + "count": 500 + } + ], + "user_stats": [ + { + "user_id": 1, + "username": "admin", + "count": 800 + } + ] + } + } + ``` + +### 数据字典管理 + +#### 获取字典列表 +- **接口**: `GET /admin/dictionaries` +- **参数**: + - `page`, `page_size`, `keyword`, `status`, `order_by`, `order_direction` + +#### 获取所有字典 +- **接口**: `GET /admin/dictionaries/all` + +#### 获取字典详情 +- **接口**: `GET /admin/dictionaries/{id}` + +#### 创建字典 +- **接口**: `POST /admin/dictionaries` +- **参数**: + ```json + { + "name": "用户状态", + "code": "user_status", + "description": "用户状态字典", + "sort": 1, + "status": 1 + } + ``` + +#### 更新字典 +- **接口**: `PUT /admin/dictionaries/{id}` + +#### 删除字典 +- **接口**: `DELETE /admin/dictionaries/{id}` + +#### 批量删除字典 +- **接口**: `POST /admin/dictionaries/batch-delete` + +#### 批量更新字典状态 +- **接口**: `POST /admin/dictionaries/batch-status` + +### 数据字典项管理 + +#### 获取字典项列表 +- **接口**: `GET /admin/dictionary-items` +- **参数**: + - `page`, `page_size` + - `dictionary_id`: 字典ID(必需) + - `keyword`: 搜索关键词 + - `status`, `order_by`, `order_direction` + +#### 创建字典项 +- **接口**: `POST /admin/dictionary-items` +- **参数**: + ```json + { + "dictionary_id": 1, + "label": "正常", + "value": "1", + "sort": 1, + "status": 1 + } + ``` + +#### 更新字典项 +- **接口**: `PUT /admin/dictionary-items/{id}` + +#### 删除字典项 +- **接口**: `DELETE /admin/dictionary-items/{id}` + +#### 批量删除字典项 +- **接口**: `POST /admin/dictionary-items/batch-delete` + +#### 批量更新字典项状态 +- **接口**: `POST /admin/dictionary-items/batch-status` + +### 任务管理 + +#### 获取任务列表 +- **接口**: `GET /admin/tasks` +- **参数**: + - `page`, `page_size`, `keyword`, `status`, `order_by`, `order_direction` + +#### 获取所有任务 +- **接口**: `GET /admin/tasks/all` + +#### 获取任务详情 +- **接口**: `GET /admin/tasks/{id}` + +#### 创建任务 +- **接口**: `POST /admin/tasks` +- **参数**: + ```json + { + "name": "清理临时文件", + "code": "cleanup_temp_files", + "command": "cleanup:temp", + "description": "每天清理过期临时文件", + "cron_expression": "0 2 * * *", + "sort": 1, + "status": 1 + } + ``` +- **Cron表达式说明**: + - 格式:`分 时 日 月 周` + - 示例:`0 2 * * *` (每天凌晨2点执行) + - 示例:`*/5 * * * *` (每5分钟执行一次) + +#### 更新任务 +- **接口**: `PUT /admin/tasks/{id}` + +#### 删除任务 +- **接口**: `DELETE /admin/tasks/{id}` + +#### 批量删除任务 +- **接口**: `POST /admin/tasks/batch-delete` + +#### 批量更新任务状态 +- **接口**: `POST /admin/tasks/batch-status` + +#### 手动执行任务 +- **接口**: `POST /admin/tasks/{id}/run` +- **说明**: 立即执行指定任务 + +#### 获取任务统计 +- **接口**: `GET /admin/tasks/statistics` +- **返回**: + ```json + { + "code": 200, + "message": "success", + "data": { + "total": 10, + "enabled": 8, + "disabled": 2, + "running": 0, + "failed": 0 + } + } + ``` + +### 城市数据管理 + +#### 获取城市列表 +- **接口**: `GET /admin/cities` +- **参数**: + - `page`, `page_size`, `keyword`, `level`, `parent_id`, `status` + - `order_by`, `order_direction` + +#### 获取城市树 +- **接口**: `GET /admin/cities/tree` +- **说明**: 从缓存中获取完整的三级城市树 + +#### 获取城市详情 +- **接口**: `GET /admin/cities/{id}` + +#### 获取子级城市 +- **接口**: `GET /admin/cities/{id}/children` +- **说明**: 获取指定城市的下级城市 + +#### 获取所有省份 +- **接口**: `GET /admin/cities/provinces` + +#### 获取指定省份的城市 +- **接口**: `GET /admin/cities/{provinceId}/cities` + +#### 获取指定城市的区县 +- **接口**: `GET /admin/cities/{cityId}/districts` + +#### 创建城市 +- **接口**: `POST /admin/cities` +- **参数**: + ```json + { + "name": "北京市", + "code": "110000", + "level": 1, + "parent_id": 0, + "adcode": "110000", + "sort": 1, + "status": 1 + } + ``` +- **级别说明**: + - `1`: 省级 + - `2`: 市级 + - `3`: 区县级 + +#### 更新城市 +- **接口**: `PUT /admin/cities/{id}` + +#### 删除城市 +- **接口**: `DELETE /admin/cities/{id}` + +#### 批量删除城市 +- **接口**: `POST /admin/cities/batch-delete` + +#### 批量更新城市状态 +- **接口**: `POST /admin/cities/batch-status` + +### 文件上传管理 + +#### 单文件上传 +- **接口**: `POST /admin/upload` +- **参数**: + - `file`: 文件(multipart/form-data,最大10MB) + - `directory`: 存储目录(默认:uploads) + - `compress`: 是否压缩图片(默认:false) + - `quality`: 图片质量(1-100,默认:80) + - `width`: 压缩宽度(可选) + - `height`: 压缩高度(可选) +- **返回**: + ```json + { + "code": 200, + "message": "上传成功", + "data": { + "url": "http://example.com/uploads/2024/01/xxx.jpg", + "path": "uploads/2024/01/xxx.jpg", + "name": "xxx.jpg", + "size": 102400, + "mime_type": "image/jpeg" + } + } + ``` + +#### 多文件上传 +- **接口**: `POST /admin/upload/multiple` +- **参数**: + - `files`: 文件数组(multipart/form-data) + - 其他参数同单文件上传 +- **返回**: 文件数组 + +#### Base64上传 +- **接口**: `POST /admin/upload/base64` +- **参数**: + ```json + { + "base64": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQ...", + "directory": "uploads", + "file_name": "image.jpg" + } + ``` + +#### 删除文件 +- **接口**: `POST /admin/upload/delete` +- **参数**: + ```json + { + "path": "uploads/2024/01/xxx.jpg" + } + ``` + +#### 批量删除文件 +- **接口**: `POST /admin/upload/batch-delete` +- **参数**: + ```json + { + "paths": [ + "uploads/2024/01/xxx.jpg", + "uploads/2024/01/yyy.jpg" + ] + } + ``` + +## Public API 接口文档 + +### 系统配置 API + +#### 获取所有配置 +- **接口**: `GET /api/system/configs` +- **认证**: 公开接口(无需认证) +- **返回**: + ```json + { + "code": 200, + "message": "success", + "data": { + "site_name": "我的网站", + "site_logo": "/uploads/logo.png" + } + } + ``` + +#### 按分组获取配置 +- **接口**: `GET /api/system/configs/group` +- **参数**: + - `group`: 配置分组 +- **认证**: 公开接口 + +#### 根据键获取配置 +- **接口**: `GET /api/system/configs/key` +- **参数**: + - `key`: 配置键 +- **认证**: 公开接口 + +### 数据字典 API + +#### 获取所有字典 +- **接口**: `GET /api/system/dictionaries` +- **认证**: 公开接口 +- **返回**: + ```json + { + "code": 200, + "message": "success", + "data": [ + { + "id": 1, + "code": "user_status", + "name": "用户状态", + "items": [...] + } + ] + } + ``` + +#### 根据编码获取字典项 +- **接口**: `GET /api/system/dictionaries/code` +- **参数**: + - `code`: 字典编码 +- **认证**: 公开接口 +- **返回**: + ```json + { + "code": 200, + "message": "success", + "data": { + "code": "user_status", + "items": [ + {"label": "正常", "value": "1"}, + {"label": "禁用", "value": "0"} + ] + } + } + ``` + +#### 获取字典详情 +- **接口**: `GET /api/system/dictionaries/{id}` +- **认证**: 公开接口 + +### 城市数据 API + +#### 获取城市树 +- **接口**: `GET /api/system/cities/tree` +- **认证**: 公开接口 +- **说明**: 从缓存中获取完整的三级城市树 + +#### 获取所有省份 +- **接口**: `GET /api/system/cities/provinces` +- **认证**: 公开接口 + +#### 获取指定省份的城市 +- **接口**: `GET /api/system/cities/{provinceId}/cities` +- **认证**: 公开接口 + +#### 获取指定城市的区县 +- **接口**: `GET /api/system/cities/{cityId}/districts` +- **认证**: 公开接口 + +#### 获取城市详情 +- **接口**: `GET /api/system/cities/{id}` +- **认证**: 公开接口 + +### 文件上传 API + +#### 单文件上传 +- **接口**: `POST /api/system/upload` +- **认证**: 需要认证(`auth:api`) +- **参数**: 同Admin上传接口 + +#### 多文件上传 +- **接口**: `POST /api/system/upload/multiple` +- **认证**: 需要认证(`auth:api`) +- **参数**: 同Admin上传接口 + +#### Base64上传 +- **接口**: `POST /api/system/upload/base64` +- **认证**: 需要认证(`auth:api`) +- **参数**: 同Admin上传接口 + +## 缓存机制 + +### 城市数据缓存 + +- **缓存键**: `city:tree` +- **过期时间**: 永久(手动清除) +- **更新时机**: 城市数据增删改时自动清除 + +### 系统配置缓存 + +- **缓存键**: `config:all` 或 `config:group:{group}` +- **过期时间**: 60分钟 +- **更新时机**: 配置数据增删改时自动清除 + +### 数据字典缓存 + +- **缓存键**: `dictionary:all` 或 `dictionary:code:{code}` +- **过期时间**: 60分钟 +- **更新时机**: 字典数据增删改时自动清除 + +## 服务层说明 + +### ConfigService + +提供系统配置的CRUD操作和缓存管理。 + +**主要方法**: +- `getList()`: 获取配置列表 +- `getByGroup()`: 按分组获取配置 +- `getConfigValue()`: 获取配置值 +- `create()`: 创建配置 +- `update()`: 更新配置 +- `delete()`: 删除配置 + +### LogService + +提供操作日志的记录、查询和清理功能。 + +**主要方法**: +- `getList()`: 获取日志列表 +- `getStatistics()`: 获取统计数据 +- `clearLogs()`: 清理过期日志 +- `record()`: 记录日志(由中间件自动调用) + +### DictionaryService + +提供数据字典和字典项的管理功能。 + +**主要方法**: +- `getList()`: 获取字典列表 +- `getItemsByCode()`: 按编码获取字典项 +- `create()`: 创建字典 +- `createItem()`: 创建字典项 +- `update()`: 更新字典 +- `updateItem()`: 更新字典项 + +### TaskService + +提供任务的管理和执行功能。 + +**主要方法**: +- `getList()`: 获取任务列表 +- `run()`: 手动执行任务 +- `getStatistics()`: 获取任务统计 +- `updateStatus()`: 更新任务状态 + +### CityService + +提供城市数据的管理和缓存功能。 + +**主要方法**: +- `getList()`: 获取城市列表 +- `getCachedTree()`: 从缓存获取城市树 +- `getProvinces()`: 获取省份列表 +- `getCities()`: 获取城市列表 +- `getDistricts()`: 获取区县列表 + +### UploadService + +提供文件上传和删除功能。 + +**主要方法**: +- `upload()`: 单文件上传 +- `uploadMultiple()`: 多文件上传 +- `uploadBase64()`: Base64上传 +- `delete()`: 删除文件 +- `compressImage()`: 图片压缩 + +## 初始化数据 + +```bash +# 执行迁移 +php artisan migrate + +# 填充初始数据 +php artisan db:seed --class=SystemSeeder +``` + +初始数据包括: +- 基础系统配置 +- 常用数据字典 +- 全国省市区数据 + +## 注意事项 + +1. **Swoole环境注意事项**: + - 文件上传时注意临时文件清理 + - 使用Redis缓存避免内存泄漏 + - 图片压缩使用协程安全的方式 + +2. **安全注意事项**: + - 文件上传必须验证文件类型和大小 + - 敏感操作必须记录日志 + - 配置数据不要存储密码等敏感信息 + +3. **性能优化**: + - 城市数据使用Redis缓存 + - 大量日志数据定期清理 + - 图片上传时进行压缩处理 + +4. **文件上传**: + - 限制文件上传大小 + - 验证文件MIME类型 + - 定期清理临时文件 + +## 扩展建议 + +1. **日志告警**: 添加日志异常告警功能 +2. **配置加密**: 敏感配置数据加密存储 +3. **多语言**: 支持配置数据的多语言 +4. **任务监控**: 添加任务执行监控和通知 +5. **CDN集成**: 文件上传支持CDN分发 + +## 常见问题 + +### Q: 如何清除城市数据缓存? +A: 调用 `CityService::clearCache()` 方法或运行 `php artisan cache:forget city:tree`。 + +### Q: 图片上传后如何压缩? +A: 上传时设置 `compress=true` 和 `quality` 参数,系统会自动压缩。 + +### Q: 如何配置定时任务? +A: 在Admin后台创建任务,设置Cron表达式,系统会自动调度执行。 + +### Q: 数据字典如何使用? +A: 通过Public API获取字典数据,前端根据数据渲染下拉框等组件。 + +### Q: 日志数据过多如何处理? +A: 定期使用 `/admin/logs/clear` 接口清理过期日志,或在后台设置自动清理任务。 diff --git a/docs/README_WEBSOCKET.md b/docs/README_WEBSOCKET.md new file mode 100644 index 0000000..57f00aa --- /dev/null +++ b/docs/README_WEBSOCKET.md @@ -0,0 +1,684 @@ +# WebSocket 功能文档 + +## 概述 + +本项目基于 Laravel-S 和 Swoole 实现了完整的 WebSocket 功能,支持实时通信、消息推送、广播等功能。 + +## 功能特性 + +- ✅ 实时双向通信 +- ✅ 用户连接管理 +- ✅ 点对点消息发送 +- ✅ 群发消息/广播 +- ✅ 频道订阅/取消订阅 +- ✅ 心跳机制 +- ✅ 自动重连 +- ✅ 在线状态管理 +- ✅ 系统通知推送 +- ✅ 数据更新推送 + +## 架构设计 + +### 后端组件 + +#### 1. WebSocketHandler (`app/Services/WebSocket/WebSocketHandler.php`) + +WebSocket 处理器,实现了 Swoole 的 `WebSocketHandlerInterface` 接口。 + +**主要方法:** +- `onOpen()`: 处理连接建立事件 +- `onMessage()`: 处理消息接收事件 +- `onClose()`: 处理连接关闭事件 + +**支持的消息类型:** +- `ping/pong`: 心跳检测 +- `heartbeat`: 心跳确认 +- `chat`: 私聊消息 +- `broadcast`: 广播消息 +- `subscribe/unsubscribe`: 频道订阅/取消订阅 + +#### 2. WebSocketService (`app/Services/WebSocket/WebSocketService.php`) + +WebSocket 服务类,提供便捷的 WebSocket 操作方法。 + +**主要方法:** +- `sendToUser($userId, $data)`: 发送消息给指定用户 +- `sendToUsers($userIds, $data)`: 发送消息给多个用户 +- `broadcast($data, $excludeUserId)`: 广播消息给所有用户 +- `sendToChannel($channel, $data)`: 发送消息给指定频道 +- `getOnlineUserCount()`: 获取在线用户数 +- `isUserOnline($userId)`: 检查用户是否在线 +- `sendSystemNotification()`: 发送系统通知 +- `pushDataUpdate()`: 推送数据更新 + +#### 3. WebSocketController (`app/Http/Controllers/System/WebSocket.php`) + +WebSocket API 控制器,提供 HTTP 接口用于管理 WebSocket 连接。 + +### 前端组件 + +#### WebSocketClient (`resources/admin/src/utils/websocket.js`) + +WebSocket 客户端封装类。 + +**功能:** +- 自动连接和重连 +- 心跳机制 +- 消息类型路由 +- 事件监听 +- 连接状态管理 + +## 配置说明 + +### Laravel-S 配置 (`config/laravels.php`) + +```php +'websocket' => [ + 'enable' => env('LARAVELS_WEBSOCKET', true), + 'handler' => \App\Services\WebSocket\WebSocketHandler::class, +], + +'swoole_tables' => [ + 'wsTable' => [ + 'size' => 102400, + 'column' => [ + ['name' => 'value', 'type' => \Swoole\Table::TYPE_STRING, 'size' => 1024], + ['name' => 'expiry', 'type' => \Swoole\Table::TYPE_INT, 'size' => 4], + ], + ], +], +``` + +### 环境变量 + +在 `.env` 文件中添加: + +```env +LARAVELS_WEBSOCKET=true +``` + +## API 接口 + +### 1. 获取在线用户数 + +``` +GET /admin/websocket/online-count +``` + +**响应:** +```json +{ + "code": 200, + "message": "success", + "data": { + "online_count": 10 + } +} +``` + +### 2. 获取在线用户列表 + +``` +GET /admin/websocket/online-users +``` + +**响应:** +```json +{ + "code": 200, + "message": "success", + "data": { + "user_ids": [1, 2, 3, 4, 5], + "count": 5 + } +} +``` + +### 3. 检查用户在线状态 + +``` +POST /admin/websocket/check-online +``` + +**请求参数:** +```json +{ + "user_id": 1 +} +``` + +**响应:** +```json +{ + "code": 200, + "message": "success", + "data": { + "user_id": 1, + "is_online": true + } +} +``` + +### 4. 发送消息给指定用户 + +``` +POST /admin/websocket/send-to-user +``` + +**请求参数:** +```json +{ + "user_id": 1, + "type": "notification", + "data": { + "title": "新消息", + "message": "您有一条新消息" + } +} +``` + +### 5. 发送消息给多个用户 + +``` +POST /admin/websocket/send-to-users +``` + +**请求参数:** +```json +{ + "user_ids": [1, 2, 3], + "type": "notification", + "data": { + "title": "系统通知", + "message": "系统将在今晚进行维护" + } +} +``` + +### 6. 广播消息 + +``` +POST /admin/websocket/broadcast +``` + +**请求参数:** +```json +{ + "type": "notification", + "data": { + "title": "公告", + "message": "欢迎使用新版本" + }, + "exclude_user_id": 1 // 可选:排除某个用户 +} +``` + +### 7. 发送消息到频道 + +``` +POST /admin/websocket/send-to-channel +``` + +**请求参数:** +```json +{ + "channel": "orders", + "type": "data_update", + "data": { + "order_id": 123, + "status": "paid" + } +} +``` + +### 8. 发送系统通知 + +``` +POST /admin/websocket/send-notification +``` + +**请求参数:** +```json +{ + "title": "系统维护", + "message": "系统将于今晚 23:00-24:00 进行维护", + "type": "warning", + "extra_data": { + "start_time": "23:00", + "end_time": "24:00" + } +} +``` + +### 9. 发送通知给指定用户 + +``` +POST /admin/websocket/send-notification-to-users +``` + +**请求参数:** +```json +{ + "user_ids": [1, 2, 3], + "title": "订单更新", + "message": "您的订单已发货", + "type": "success" +} +``` + +### 10. 推送数据更新 + +``` +POST /admin/websocket/push-data-update +``` + +**请求参数:** +```json +{ + "user_ids": [1, 2, 3], + "resource_type": "order", + "action": "update", + "data": { + "id": 123, + "status": "shipped" + } +} +``` + +### 11. 推送数据更新到频道 + +``` +POST /admin/websocket/push-data-update-channel +``` + +**请求参数:** +```json +{ + "channel": "orders", + "resource_type": "order", + "action": "create", + "data": { + "id": 124, + "customer": "张三", + "amount": 100.00 + } +} +``` + +### 12. 断开用户连接 + +``` +POST /admin/websocket/disconnect-user +``` + +**请求参数:** +```json +{ + "user_id": 1 +} +``` + +## 前端使用示例 + +### 1. 基本连接 + +```javascript +import { getWebSocket, closeWebSocket } from '@/utils/websocket' +import { useUserStore } from '@/stores/modules/user' + +const userStore = useUserStore() + +// 连接 WebSocket +const ws = getWebSocket(userStore.userInfo.id, userStore.token, { + onOpen: (event) => { + console.log('WebSocket 已连接') + }, + onMessage: (message) => { + console.log('收到消息:', message) + }, + onError: (error) => { + console.error('WebSocket 错误:', error) + }, + onClose: (event) => { + console.log('WebSocket 已关闭') + } +}) + +// 连接 +ws.connect() +``` + +### 2. 监听特定消息类型 + +```javascript +// 监听通知消息 +ws.on('notification', (data) => { + message.success(data.title, data.message) +}) + +// 监听数据更新 +ws.on('data_update', (data) => { + console.log('数据更新:', data.resource_type, data.action) + // 刷新数据 + loadData() +}) +``` + +### 3. 发送消息 + +```javascript +// 发送心跳 +ws.send('heartbeat', { timestamp: Date.now() }) + +// 发送私聊消息 +ws.send('chat', { + to_user_id: 2, + content: '你好,这是一条私聊消息' +}) + +// 订阅频道 +ws.send('subscribe', { channel: 'orders' }) + +// 取消订阅 +ws.send('unsubscribe', { channel: 'orders' }) +``` + +### 4. 发送广播消息 + +```javascript +ws.send('broadcast', { + message: '这是一条广播消息' +}) +``` + +### 5. 断开连接 + +```javascript +// 断开连接 +ws.disconnect() + +// 或使用全局方法 +closeWebSocket() +``` + +### 6. 在 Vue 组件中使用 + +```vue + + + +``` + +## 消息格式 + +### 服务端发送的消息格式 + +```json +{ + "type": "notification", + "data": { + "title": "标题", + "message": "内容", + "type": "info", + "timestamp": 1641234567 + } +} +``` + +### 客户端发送的消息格式 + +```json +{ + "type": "chat", + "data": { + "to_user_id": 2, + "content": "消息内容" + } +} +``` + +## 启动和停止 + +### 启动 Laravel-S 服务 + +```bash +php bin/laravels start +``` + +### 停止 Laravel-S 服务 + +```bash +php bin/laravels stop +``` + +### 重启 Laravel-S 服务 + +```bash +php bin/laravels restart +``` + +### 重载 Laravel-S 服务(平滑重启) + +```bash +php bin/laravels reload +``` + +### 查看服务状态 + +```bash +php bin/laravels status +``` + +## WebSocket 连接地址 + +### 开发环境 + +``` +ws://localhost:5200/ws?user_id={user_id}&token={token} +``` + +### 生产环境 + +``` +wss://yourdomain.com/ws?user_id={user_id}&token={token} +``` + +## Nginx 配置示例 + +```nginx +server { + listen 80; + server_name yourdomain.com; + root /path/to/your/project/public; + + location / { + try_files $uri $uri/ /index.php?$query_string; + } + + # WebSocket 代理配置 + location /ws { + proxy_pass http://127.0.0.1:5200; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $host; + proxy_read_timeout 86400; + } + + location ~ \.php$ { + fastcgi_pass 127.0.0.1:9000; + fastcgi_index index.php; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + include fastcgi_params; + } +} +``` + +## 使用场景 + +### 1. 实时通知 + +```php +// 发送系统通知 +$webSocketService->sendSystemNotification( + '系统维护', + '系统将于今晚进行维护', + 'warning' +); +``` + +### 2. 订单状态更新 + +```php +// 推送订单状态更新给相关人员 +$webSocketService->pushDataUpdate( + [$order->user_id], + 'order', + 'update', + [ + 'id' => $order->id, + 'status' => $order->status, + 'updated_at' => $order->updated_at + ] +); +``` + +### 3. 实时聊天 + +```javascript +// 发送私聊消息 +ws.send('chat', { + to_user_id: 2, + content: '你好' +}) +``` + +### 4. 数据监控 + +```php +// 推送系统监控数据到特定频道 +$webSocketService->sendToChannel('system_monitor', 'monitor', [ + 'cpu_usage' => 75, + 'memory_usage' => 80, + 'disk_usage' => 60 +]); +``` + +## 注意事项 + +1. **连接认证**: WebSocket 连接时需要提供 `user_id` 和 `token` 参数 +2. **心跳机制**: 客户端默认每 30 秒发送一次心跳 +3. **自动重连**: 连接断开后会自动尝试重连,最多重试 5 次 +4. **并发限制**: Swoole Table 最多支持 102,400 个连接 +5. **内存管理**: 注意内存泄漏问题,定期重启服务 +6. **安全性**: 生产环境建议使用 WSS (WebSocket Secure) +7. **日志监控**: 查看日志文件 `storage/logs/swoole-YYYY-MM.log` + +## 故障排查 + +### 1. 无法连接 WebSocket + +- 检查 Laravel-S 服务是否启动 +- 检查端口 5200 是否被占用 +- 检查防火墙设置 +- 查看日志文件 + +### 2. 连接频繁断开 + +- 检查网络稳定性 +- 调整心跳间隔 +- 检查服务器资源使用情况 + +### 3. 消息发送失败 + +- 检查用户是否在线 +- 检查消息格式是否正确 +- 查看错误日志 + +## 参考资料 + +- [Laravel-S 文档](https://github.com/hhxsv5/laravel-s) +- [Swoole 文档](https://www.swoole.com/) +- [WebSocket API](https://developer.mozilla.org/zh-CN/docs/Web/API/WebSocket) + +## 更新日志 + +### 2024-02-08 + +- ✅ 初始版本发布 +- ✅ 实现基础 WebSocket 功能 +- ✅ 实现消息推送功能 +- ✅ 实现频道订阅功能 +- ✅ 实现前端客户端封装 +- ✅ 实现管理 API 接口 diff --git a/package.json b/package.json new file mode 100644 index 0000000..c96c632 --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.1", + "laravel-vite-plugin": "^1.0", + "vite": "^5.0" + }, + "dependencies": { + "axios": "^1.7.9", + "vue": "^3.5.13", + "vuex": "^4.1.0" + } +} diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..d703241 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,35 @@ + + + + + tests/Unit + + + tests/Feature + + + + + app + + + + + + + + + + + + + + + + + + diff --git a/public/.htaccess b/public/.htaccess new file mode 100644 index 0000000..b574a59 --- /dev/null +++ b/public/.htaccess @@ -0,0 +1,25 @@ + + + Options -MultiViews -Indexes + + + RewriteEngine On + + # Handle Authorization Header + RewriteCond %{HTTP:Authorization} . + RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] + + # Handle X-XSRF-Token Header + RewriteCond %{HTTP:x-xsrf-token} . + RewriteRule .* - [E=HTTP_X_XSRF_TOKEN:%{HTTP:X-XSRF-Token}] + + # Redirect Trailing Slashes If Not A Folder... + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_URI} (.+)/$ + RewriteRule ^ %1 [L,R=301] + + # Send Requests To Front Controller... + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_FILENAME} !-f + RewriteRule ^ index.php [L] + diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..e69de29 diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..ee8f07e --- /dev/null +++ b/public/index.php @@ -0,0 +1,20 @@ +handleRequest(Request::capture()); diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..eb05362 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: diff --git a/resources/admin/.gitignore b/resources/admin/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/resources/admin/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/resources/admin/.vscode/extensions.json b/resources/admin/.vscode/extensions.json new file mode 100644 index 0000000..a7cea0b --- /dev/null +++ b/resources/admin/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["Vue.volar"] +} diff --git a/resources/admin/README.md b/resources/admin/README.md new file mode 100644 index 0000000..1511959 --- /dev/null +++ b/resources/admin/README.md @@ -0,0 +1,5 @@ +# Vue 3 + Vite + +This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 ` + + + + diff --git a/resources/admin/package.json b/resources/admin/package.json new file mode 100644 index 0000000..3a86e1a --- /dev/null +++ b/resources/admin/package.json @@ -0,0 +1,43 @@ +{ + "name": "admin", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "lint": "eslint . --fix --cache", + "format": "prettier --write --experimental-cli src/" + }, + "dependencies": { + "@ant-design/icons-vue": "^7.0.1", + "@ckeditor/ckeditor5-vue": "^7.3.0", + "@element-plus/icons-vue": "^2.3.2", + "ant-design-vue": "^4.2.6", + "axios": "^1.13.4", + "ckeditor5": "^47.4.0", + "crypto-js": "^4.2.0", + "echarts": "^6.0.0", + "nprogress": "^0.2.0", + "pinia": "^3.0.4", + "pinia-plugin-persistedstate": "^4.7.1", + "vue": "^3.5.24", + "vue-i18n": "^11.2.8", + "vue-router": "^5.0.2", + "vuedraggable": "^4.0.3" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@vitejs/plugin-vue": "^6.0.1", + "@vue/eslint-config-prettier": "^10.2.0", + "eslint": "^10.0.0", + "eslint-plugin-vue": "^10.7.0", + "globals": "^17.3.0", + "prettier": "^3.8.1", + "sass-embedded": "^1.97.3", + "vite": "^7.2.4", + "vite-plugin-vue-devtools": "^8.0.6", + "vue-eslint-parser": "^10.2.0" + } +} diff --git a/resources/admin/public/vite.svg b/resources/admin/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/resources/admin/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/admin/src/App.vue b/resources/admin/src/App.vue new file mode 100644 index 0000000..71be399 --- /dev/null +++ b/resources/admin/src/App.vue @@ -0,0 +1,92 @@ + + + diff --git a/resources/admin/src/api/auth.js b/resources/admin/src/api/auth.js new file mode 100644 index 0000000..ad2c613 --- /dev/null +++ b/resources/admin/src/api/auth.js @@ -0,0 +1,302 @@ +import request from '@/utils/request' + +export default { + // 认证相关 + login: { + post: async function (params) { + return await request.post('auth/login', params) + }, + }, + logout: { + post: async function () { + return await request.post('auth/logout') + }, + }, + refresh: { + post: async function () { + return await request.post('auth/refresh') + }, + }, + me: { + get: async function () { + return await request.get('auth/me') + }, + }, + changePassword: { + post: async function (params) { + return await request.post('auth/change-password', params) + }, + }, + + // 用户管理 + users: { + list: { + get: async function (params) { + return await request.get('users', { params }) + }, + }, + detail: { + get: async function (id) { + return await request.get(`users/${id}`) + }, + }, + add: { + post: async function (params) { + return await request.post('users', params) + }, + }, + edit: { + put: async function (id, params) { + return await request.put(`users/${id}`, params) + }, + }, + delete: { + delete: async function (id) { + return await request.delete(`users/${id}`) + }, + }, + batchDelete: { + post: async function (params) { + return await request.post('users/batch-delete', params) + }, + }, + batchStatus: { + post: async function (params) { + return await request.post('users/batch-status', params) + }, + }, + batchDepartment: { + post: async function (params) { + return await request.post('users/batch-department', params) + }, + }, + batchRoles: { + post: async function (params) { + return await request.post('users/batch-roles', params) + }, + }, + export: { + post: async function (params) { + return await request.post('users/export', params, { responseType: 'blob' }) + }, + }, + import: { + post: async function (formData) { + return await request.post('users/import', formData, { + headers: { 'Content-Type': 'multipart/form-data' } + }) + }, + }, + downloadTemplate: { + get: async function () { + return await request.get('users/download-template', { responseType: 'blob' }) + }, + }, + }, + + // 在线用户管理 + onlineUsers: { + count: { + get: async function () { + return await request.get('online-users/count') + }, + }, + list: { + get: async function (params) { + return await request.get('online-users', { params }) + }, + }, + sessions: { + get: async function (userId) { + return await request.get(`online-users/${userId}/sessions`) + }, + }, + offline: { + post: async function (userId, params) { + return await request.post(`online-users/${userId}/offline`, params) + }, + }, + offlineAll: { + post: async function (userId) { + return await request.post(`online-users/${userId}/offline-all`) + }, + }, + }, + + // 角色管理 + roles: { + list: { + get: async function (params) { + return await request.get('roles', { params }) + }, + }, + all: { + get: async function () { + return await request.get('roles/all') + }, + }, + detail: { + get: async function (id) { + return await request.get(`roles/${id}`) + }, + }, + add: { + post: async function (params) { + return await request.post('roles', params) + }, + }, + edit: { + put: async function (id, params) { + return await request.put(`roles/${id}`, params) + }, + }, + delete: { + delete: async function (id) { + return await request.delete(`roles/${id}`) + }, + }, + batchDelete: { + post: async function (params) { + return await request.post('roles/batch-delete', params) + }, + }, + batchStatus: { + post: async function (params) { + return await request.post('roles/batch-status', params) + }, + }, + permissions: { + get: async function (id) { + return await request.get(`roles/${id}/permissions`) + }, + post: async function (id, params) { + return await request.post(`roles/${id}/permissions`, params) + }, + }, + copy: { + post: async function (id, params) { + return await request.post(`roles/${id}/copy`, params) + }, + }, + batchCopy: { + post: async function (params) { + return await request.post('roles/batch-copy', params) + }, + }, + }, + + // 权限管理 + permissions: { + list: { + get: async function (params) { + return await request.get('permissions', { params }) + }, + }, + tree: { + get: async function () { + return await request.get('permissions/tree') + }, + }, + menu: { + get: async function () { + return await request.get('permissions/menu') + }, + }, + detail: { + get: async function (id) { + return await request.get(`permissions/${id}`) + }, + }, + add: { + post: async function (params) { + return await request.post('permissions', params) + }, + }, + edit: { + put: async function (id, params) { + return await request.put(`permissions/${id}`, params) + }, + }, + delete: { + delete: async function (id) { + return await request.delete(`permissions/${id}`) + }, + }, + batchDelete: { + post: async function (params) { + return await request.post('permissions/batch-delete', params) + }, + }, + batchStatus: { + post: async function (params) { + return await request.post('permissions/batch-status', params) + }, + }, + }, + + // 部门管理 + departments: { + list: { + get: async function (params) { + return await request.get('departments', { params }) + }, + }, + tree: { + get: async function () { + return await request.get('departments/tree') + }, + }, + all: { + get: async function () { + return await request.get('departments/all') + }, + }, + detail: { + get: async function (id) { + return await request.get(`departments/${id}`) + }, + }, + add: { + post: async function (params) { + return await request.post('departments', params) + }, + }, + edit: { + put: async function (id, params) { + return await request.put(`departments/${id}`, params) + }, + }, + delete: { + delete: async function (id) { + return await request.delete(`departments/${id}`) + }, + }, + batchDelete: { + post: async function (params) { + return await request.post('departments/batch-delete', params) + }, + }, + batchStatus: { + post: async function (params) { + return await request.post('departments/batch-status', params) + }, + }, + export: { + post: async function (params) { + return await request.post('departments/export', params, { responseType: 'blob' }) + }, + }, + import: { + post: async function (formData) { + return await request.post('departments/import', formData, { + headers: { 'Content-Type': 'multipart/form-data' } + }) + }, + }, + downloadTemplate: { + get: async function () { + return await request.get('departments/download-template', { responseType: 'blob' }) + }, + }, + }, +} diff --git a/resources/admin/src/api/system.js b/resources/admin/src/api/system.js new file mode 100644 index 0000000..c7c6beb --- /dev/null +++ b/resources/admin/src/api/system.js @@ -0,0 +1,392 @@ +import request from '@/utils/request' + +export default { + // 系统配置管理 + configs: { + list: { + get: async function (params) { + return await request.get('configs', { params }) + }, + }, + groups: { + get: async function () { + return await request.get('configs/groups') + }, + }, + all: { + get: async function (params) { + return await request.get('configs/all', { params }) + }, + }, + detail: { + get: async function (id) { + return await request.get(`configs/${id}`) + }, + }, + add: { + post: async function (params) { + return await request.post('configs', params) + }, + }, + edit: { + put: async function (id, params) { + return await request.put(`configs/${id}`, params) + }, + }, + delete: { + delete: async function (id) { + return await request.delete(`configs/${id}`) + }, + }, + batchDelete: { + post: async function (params) { + return await request.post('configs/batch-delete', params) + }, + }, + batchStatus: { + post: async function (params) { + return await request.post('configs/batch-status', params) + }, + }, + }, + + // 操作日志管理 + logs: { + list: { + get: async function (params) { + return await request.get('logs', { params }) + }, + }, + detail: { + get: async function (id) { + return await request.get(`logs/${id}`) + }, + }, + delete: { + delete: async function (id) { + return await request.delete(`logs/${id}`) + }, + }, + batchDelete: { + post: async function (params) { + return await request.post('logs/batch-delete', params) + }, + }, + clear: { + post: async function (params) { + return await request.post('logs/clear', params) + }, + }, + statistics: { + get: async function (params) { + return await request.get('logs/statistics', { params }) + }, + }, + }, + + // 数据字典管理 + dictionaries: { + list: { + get: async function (params) { + return await request.get('dictionaries', { params }) + }, + }, + all: { + get: async function () { + return await request.get('dictionaries/all') + }, + }, + detail: { + get: async function (id) { + return await request.get(`dictionaries/${id}`) + }, + }, + add: { + post: async function (params) { + return await request.post('dictionaries', params) + }, + }, + edit: { + put: async function (id, params) { + return await request.put(`dictionaries/${id}`, params) + }, + }, + delete: { + delete: async function (id) { + return await request.delete(`dictionaries/${id}`) + }, + }, + batchDelete: { + post: async function (params) { + return await request.post('dictionaries/batch-delete', params) + }, + }, + batchStatus: { + post: async function (params) { + return await request.post('dictionaries/batch-status', params) + }, + }, + }, + + // 数据字典项管理 + dictionaryItems: { + list: { + get: async function (params) { + return await request.get('dictionary-items', { params }) + }, + }, + detail: { + get: async function (id) { + return await request.get(`dictionary-items/${id}`) + }, + }, + add: { + post: async function (params) { + return await request.post('dictionary-items', params) + }, + }, + edit: { + put: async function (id, params) { + return await request.put(`dictionary-items/${id}`, params) + }, + }, + delete: { + delete: async function (id) { + return await request.delete(`dictionary-items/${id}`) + }, + }, + batchDelete: { + post: async function (params) { + return await request.post('dictionary-items/batch-delete', params) + }, + }, + batchStatus: { + post: async function (params) { + return await request.post('dictionary-items/batch-status', params) + }, + }, + }, + + // 任务管理 + tasks: { + list: { + get: async function (params) { + return await request.get('tasks', { params }) + }, + }, + all: { + get: async function () { + return await request.get('tasks/all') + }, + }, + detail: { + get: async function (id) { + return await request.get(`tasks/${id}`) + }, + }, + add: { + post: async function (params) { + return await request.post('tasks', params) + }, + }, + edit: { + put: async function (id, params) { + return await request.put(`tasks/${id}`, params) + }, + }, + delete: { + delete: async function (id) { + return await request.delete(`tasks/${id}`) + }, + }, + batchDelete: { + post: async function (params) { + return await request.post('tasks/batch-delete', params) + }, + }, + batchStatus: { + post: async function (params) { + return await request.post('tasks/batch-status', params) + }, + }, + run: { + post: async function (id) { + return await request.post(`tasks/${id}/run`) + }, + }, + statistics: { + get: async function () { + return await request.get('tasks/statistics') + }, + }, + }, + + // 城市数据管理 + cities: { + list: { + get: async function (params) { + return await request.get('cities', { params }) + }, + }, + tree: { + get: async function () { + return await request.get('cities/tree') + }, + }, + detail: { + get: async function (id) { + return await request.get(`cities/${id}`) + }, + }, + children: { + get: async function (id) { + return await request.get(`cities/${id}/children`) + }, + }, + provinces: { + get: async function () { + return await request.get('cities/provinces') + }, + }, + cities: { + get: async function (provinceId) { + return await request.get(`cities/${provinceId}/cities`) + }, + }, + districts: { + get: async function (cityId) { + return await request.get(`cities/${cityId}/districts`) + }, + }, + add: { + post: async function (params) { + return await request.post('cities', params) + }, + }, + edit: { + put: async function (id, params) { + return await request.put(`cities/${id}`, params) + }, + }, + delete: { + delete: async function (id) { + return await request.delete(`cities/${id}`) + }, + }, + batchDelete: { + post: async function (params) { + return await request.post('cities/batch-delete', params) + }, + }, + batchStatus: { + post: async function (params) { + return await request.post('cities/batch-status', params) + }, + }, + }, + + // 文件上传管理 + upload: { + single: { + post: async function (formData) { + return await request.post('upload', formData, { + headers: { 'Content-Type': 'multipart/form-data' } + }) + }, + }, + multiple: { + post: async function (formData) { + return await request.post('upload/multiple', formData, { + headers: { 'Content-Type': 'multipart/form-data' } + }) + }, + }, + base64: { + post: async function (params) { + return await request.post('upload/base64', params) + }, + }, + delete: { + post: async function (params) { + return await request.post('upload/delete', params) + }, + }, + batchDelete: { + post: async function (params) { + return await request.post('upload/batch-delete', params) + }, + }, + }, + + // 公共接口 (无需认证) + public: { + configs: { + all: { + get: async function () { + return await request.get('system/configs') + }, + }, + group: { + get: async function (params) { + return await request.get('system/configs/group', { params }) + }, + }, + key: { + get: async function (params) { + return await request.get('system/configs/key', { params }) + }, + }, + }, + dictionaries: { + all: { + get: async function () { + return await request.get('system/dictionaries') + }, + }, + code: { + get: async function (params) { + return await request.get('system/dictionaries/code', { params }) + }, + }, + detail: { + get: async function (id) { + return await request.get(`system/dictionaries/${id}`) + }, + }, + }, + cities: { + tree: { + get: async function () { + return await request.get('system/cities/tree') + }, + }, + provinces: { + get: async function () { + return await request.get('system/cities/provinces') + }, + }, + cities: { + get: async function (provinceId) { + return await request.get(`system/cities/${provinceId}/cities`) + }, + }, + districts: { + get: async function (cityId) { + return await request.get(`system/cities/${cityId}/districts`) + }, + }, + detail: { + get: async function (id) { + return await request.get(`system/cities/${id}`) + }, + }, + }, + upload: { + post: async function (formData) { + return await request.post('system/upload', formData, { + headers: { 'Content-Type': 'multipart/form-data' } + }) + }, + }, + }, +} diff --git a/resources/admin/src/assets/images/default_avatar.jpg b/resources/admin/src/assets/images/default_avatar.jpg new file mode 100644 index 0000000000000000000000000000000000000000..eae583b3420728fe11f8c2089d8b92659b6db4e4 GIT binary patch literal 44913 zcmeFacR*83^C)~m?})xEcIFJY9^44qUCjW5GXUu81A+hm zPyjR#03Ze-2+Y;t>QGpOyw8+rtAyAfDj|3}%Ed|A0X%2pd3(F9-Z(L)iD> zM-YME@=D-K0FHvcGKeGrW&+{12n0X^-tl?Afj2IYFZ{owSHb{*|G>|?suR)?&Sm6^ zL?cnINN+B6QyDG+ON5sb(jRpKkQA4IDu_!dh|6+GNGnK7DM-iwU?8~z0Oa_)xTK^+ z#*g;lYsw({StkU@Al`@ZeBc8ag!l*FWWTo!;`OU-5WnBsMg-X}Ba~>rY*xm-;s71+ zj&C2~j_bjFp|R3cSGPAZG1SsMqluS1K%;X8;e{kR005p|XkQa;H7+Y_8!qyXpqwZH zGC%?V9AGGKeN!zne3`nM>Rc$02oL`Xt6hXzfOZibBQCCA_`d}mgn9d-K{ky*YAGi; z3I@VgK-j|{?TyFJgD{h$J02#&%ZbSsWDtZ8<6)OQSeQVw2dm&=Cr>XYkOt4Qx09z6 z9)1PF*ZeSW5GFnY!r^`hxIYMYfH1EI#uEX;>mbbJ33ot&T}Ok*qv0@Dd^dtHjjx%B zItZTx2QZDxZ?NNUFd7~J@(BRyNbf*jgo`VhO8|C)OF~{=j!Osb?*T`nMT{I^?hd|A zTo-1xP2kROU$_@ol?6D;5MC}|cY8S@&o+*iUOlVD1-|a z9<6R-#s$Op`r%=4PT()302M$FFafOKO3MQX03v`SAPXo0Du5<%8ZZRR0BhhJ00UeB zPrw)O2QB~?fpFjka0iG7l7Vy}8^{BSfKs3mcnUlR-T+O&C!iDP2Zn($U>cYQR)H-D z1VRp>g)l-`A)F9Ch!8{)A_qAI(S)3Vm_V!{=OHfON{NA7fLw;$fW$zOA@?D9kP=8W zOWq!rQ&8G%ef79pEN#6&biOhg<+$BD#<^oY!e?1)^5e29XGt`OZON+QZ6 zdPGz~^qi=PsFP@zXqspRT$rheS%{AjixA5Zs}ma%+Ymbw`x1u|M-nFxXA&0?*AO=n zw-XN&e<$7`AtPZV;UWhE>+$2dR$t9^Ec}3DrGD0#>vP()!dW2Mn zRDo2N)QZ%F)Son*G=cOXX*ua@(oWJb(p54tG8Qs^GFdVmGHWt7vS6~CWT|AuWY5T2 z$wtXm$jQlB$%V+3$PLLI$kF8Cj`Kxs(nL>WjKMVU=mL)k((M!7|Gkcyv5iOP)1jp`y*5>*k^YpMaN zC2DGFZfbdIV`>C-7NIC*Ff_Mma%i5>^wKQR($XHMRiU+^ zMbqA<&7*xuJ4Cxq$4Dnir%UHVhowuQtDtM6o293u=c8ApKSv)#pFsbFzJ>n#0m=i% z52zn-I1qXuMj*g-jor=9w9orI{_51DI2o>zIdGh*^%a=(2dQ zM6r~zbg^t6;y9#w2!81Lp`t@=ht>|WA67XGKOA|u_;BapO;#>eO;&f-Xx2*BK{gUL zK{jJHf3`HX*KFU}nb;NCVeFCYrR;r2h>i#zF*y=^Bgb+fhLaoB2 z!t%mi!kNO|A~YhZB7PzTA|s+KqGv=eiB^ivit&osh((JvitUO+#XZEc#rq`~Bu-0Q zlBkhbloXVNNv244NYP4ZNnxd`q!y%wq~X$O(mhZ{s6I3T`U1KmBP-)8QzSDf%PV_c z_MU9F9Fv@p+)cT+@}%-=@>uz&@*4^=3TTB=g*inLMR&z~#cwD1Pr^@TpBz=(u#E8K;Jok10DSXDWYD;Zbo?d8jg>dP3Dr^^xkVnz)*;TDjVqx`KL$`ZEn; z4Q-7`jgOiPn&z6xnnPOLS}s}zTJzda?O^R^I;1*!I?+1ax*WPN-8|j-)3T>SPQTKl z(KFRc*86fs@Qm-78hs-D)B3Uc0|tBsUIvwhfT6Botl^*$zmbnotudLgk#UOgxQT?x z1(QZoMpJv!eA87kHM1zQesckHjQI--dW*9bc@}Gy8kRAZ!&YKeAy!S+Y}N?tDjRYe z3!8^FE4G@p@wQ`Up=ZO-cG>aU1=zhi$9B%+T%A3=y@UOe^Q7l3&*z`tb})3vbXalJ zb-d>|4^xLF!ls>6oZ_4&;Y#or_=K~PbByyh7bTZimq}M;*96z^2z5jlN}SG631?E#w{UJ?x|46Yn$ctLOU=1wq-Ns?bc} zW~vz@fVqkp@jK<0;`&$yXl5XK{JKz2oB@>l)hwP5movvu* z=-iltF+nk-vHGzUaa?hcam(@c@$V9(6Vel@5;2LxNoSI(?(*Kfb9X!0HMuiIEv5M0 zk$czgt))7pwxy}06{T~e-$>ugKxFjX*S=qoc|0>Ai!948YdqT`yYYeCgS>}q4{to& z$??h=&Naz>l_#5*mwzNbs(`2fQ!x4H?4wVG8iiFwB1QL$S&DBw1|DM`PnDc6=_=JL zeep!@Nl_VJ*}Zbc^2iEEMPS8zrEBGIm338X_37#tHHtOmwW75-PmeuKsbj8-s;8>I zQjcp0YFK&Z{p|a5m*=A|>|gZ1w0ha`%J|jC*LtttzR`N~vQf3M;jPl!r|%TrRlk>e zU)dznRM8A=F8=`iQ2tToV?~Q>OVua&Pc^M4TkG4D+n%>;w7==l?P%&W=xpsW@9OD3 z+dbUl)bp*^qj#atuWzeAY=Cqia`3=l!qAbS%+Du27Z1w}*Nte5G=DMu(mx6to&JjY zx;=JfoOV26f^#D8o8-5rlUkFXrfjFiroE@PzK72ooVhnEFk3dKGS@tBH9xlCyMSA~ zxx~7Zvn;**V#R1>Xw`FdYb|n}bv<`OZliJ2a&uzKf17+eY3IaF&92_=0L~MK!_5FR zgf}I7b$tMWn+50yF~BJd+!;V{{lFD~hJu2El7fbklI9>a74<=8dKwyf=EIDP%#4hO z57H3AkK114AFmKvYHC_K+5>cS2bk#S=$P;qI;OoW2mek1xM#pY3LpbWBZhDTL5di4cH@_-En)073#GCL$ps zB`2dGC8m)A6Au!T9Al6mRW){CiD|+p!{{`*ouX-UwFd9 ziZ@;3tFqdbF8&dTIhBpw6Ke+nA|jAm5&}=;WTa363&$iFNI(vJxEV<$FGe%rIe2c; zIxofRIO2OLM%|PdhLT?3Bf~R5ejNIu4P+wgkdvml3>yArpM`(V0sk)+aD4y`F`>?b zfHKgp%Nuu#nKwrFgf4H)X%k%r2ylmTm;thFan9dLI5_XsrST+8qp@*4FEZB9pctmr9$a|Keof`>`a9d0MGFb$Sm1!zqTQAq zQ&ae2I(Gx-(w&B(sPUz*x_m=l0{KnXz*5XHOfkC2@Fxr8UrkIV@lxVD)F0(q6I(uN z$gNm{1Kz3Y@*SYa$`w<;(NW^dMj4Rb^0DwM{nC-p6tlUO7a4jp?orO-b3Ql#it<*> za`>VsxS*$0INnJA0g2i{4w&35RWRZ!5Vbs9&3f=)A=bSiB6v~YP+cwQmE_)cG)KD< z71vUiqhlb2kV1BwPsG|`Y(PwsM|(6$3@P13u&DxAT@s_c~*y9HSkL`@C%Uu`27Raq{$XB+;+KLu-FqVA5&=(JU9`vuBgd&`F zOkM=?vrP*q*L*8!Sgz>B0s3Z@CTZ3x>H4o5zbTqv(Kz6%#Y(!_lm6|&POFnIld*i) z_@`Pd$RitCa6q&1<9t!04xNsplOp#F3#^xiIK7k(nW0N_tW>zxURXI$<)zK8?#d|8+cdE**|id;Iq;%KTX6yNq>=W308gADavO>Q7_m~REURtWQ; zp7+e5?45V=Z1P4IrVoAXzS$jK-_C%!#$FzE>Dz)2;&nk=E~4pPXl&G+JgQC^&T2L5 z8Yb+K9`(e2Dbe`()l&xq(h5a=O{(7P@Y-Rmonmy)bl?X}I6v9&8opn@v?v&5MvcFBNiK;GM<+s(DX9`cAP8i+VOD=b&tt;YAoP;q(uFouZk8gNhAlbh?Jr zw{CgqrP?WfgkGH?*X^^-#sLU)XHBC-QF*Ng*P#k$j*Y$|Y<6GYQogA8QEJ}xs$lG- z=vC_%q>Rh1s4EK;UGe8PX0u~-Pj~J~t^JqOP9Ius?+{#GtznyV?`KosS*kxZCiSAb z@IjnK-!t*ncU*4#kA0(r*R{Gmwkt&C8DGI(7Ty{Pp1LLGY=F&e>lbtv4A!~CQ9Y}G z?dm?A*_iloqq&QPse`>J1YxN9mNCxe!d+3cLI3RnxT%R|_ z}S(lI|5Q^GYRr*I|i^-Z{k zC#FoY0_c*+WCKPDT@6-);_IJmd`DqFBHYk1rmvTeCOX4H+pN|k6JJ!`C zl^Rq%SxGM6KXUm?`C8qpaA}W6Gco6Nw!5H(%9_SgB1Ee-TyF}Kw$oT#$1l0*88UpfWOo%`rMaK76M#b5-XG$_hRXWnAPI?5LEqVO4GM^_1XnBGntPkKDI6POa zo=nn8G_BVEb&+C0OM|+@-mmR)A?>Dq!0tPqIp;BP^($o&^at}#*9I0=)t*S`v>m=x z5i^((HMABwRT^VtWD;+Xx&EMhEx3GL76(XL2-hd?ru5NYc?Yh|1+sz*A+;&TZXM{I zXwLIdxl3$AYqR~)>ZUW-?Gp(5Wbi3qg94uiybth?OI+7q88^|X>ggV{5xifU`i@_v zb*sgHyfElgu?fl7vlHoq#eJWbQmel)5Pf@L(j!;a39qQgx3P0?mZnwa9JEEg36*j$ zoc%m=6bCSzimAPI`FglsZZRUDZzAR_Z1dx;$+OLsuF@K>Qyho=s7l33T2S|kxNF{Y z(^f!nz(;F$GYtS*^JtAW*bui{6_sd2V zO40C-m)i%FwWrL2J^e;@JyQ}wT{PY`I+Rw@MD`bCs6SiiEy4lcPdt)-5D9y*A^(o= z<9%L>6QG<;6qvjKq!a_)yuc(<@ZT8l~0cIDjmKbDPmom*_3W;81G#z)6d4 z&IkTA69HALy9K8%mByTw83w30n1O2wm*0QcZ9t#Bad@b#-{11)n^ufy8=F0Izu}xx z%ai5KhArwcE6#J@`&V3!HB5@!Z&(-N!~qv{#nP73QjnHYP;}otK}`RRM*+OhTkM5? zpXa0cmxZ=hrUR02fOBL1t=Y%Mwr;S`q^}=AYSYW0^;x4sPrIjV!!}zlpH%Ebem1|+ z!C(T8fVibpciO66+v$Vm!&Q)H-n%{xYb&bgRJSPGoVu5jCYpsC2$x;5u@5J07&pJ|Nc3E$#)7`uan@QX2M>abS~X>C*7rQ~+1WZ9I^31v=k!7e_PkBh zT@`f`nvuwPEYDoQ!nAb$b<3t!6aQN;Q5d$g(NnLWcXGM^?WdZFQ zGgymSydj%(juJjp37YZsAE5RHGfaIO`9FSAf)h8jGX99I}vyS3jQO|@Dbh;4%9v}WO5QL zT~s8#&5ZCAvLK`>;L{+q;N<`wz{db;Z(oEL+7N^G#^5ysc&!y+ghHD-B9R^h3SBQW z9PWkj#3$fk!rhZ_+)7W#Yl;ZKQ>Y=(_&oUJy)xmx1`eKZGfgYJw&MtJ7W`)hp1v9Z z>L8>ASOHvs27m-lUNIof7eD}B02;*UgSs6kAO}JYfFr&%V6Pe@UnJ7m6pqGt8#=my z${2vu^_Z*dje4vXfr!d`KOKw{I9KyPd;Q>?#u0F-C>eZACwMZD0zB4->u zQ2QwcE@;2~h^D89#(o5p_phSWVD2tInoejQ0D$@fQ1e63%mF4&TwG2_jH3#27|$Ug zX#0Bo6Hd+JpXt9x9-I>PMjRgTv5B zUkwMe1HJ_WCXHN-P(Mcx9>$Y`cRbs`8MyOaG5=7l8K}AWZ+P0DcoV#i?6(B! zp9vsu`)K@}BA(gf0eB1E!B@?ncfx!-PDrEsQ7L#+4g-}|_*{zw92Ee7M-*V#!;;`< z=APCffRLYcKYo;uYl#r2BwWD@WN++(r4S;77^on|2dW>l8ywVxoS>o%3_5_Avoo0D z0bW5xE*Ky`F!%2!?_(qhcu2n7HD9pD7O0cUW*!T=uNB4!Ule&7!c{(SL-1oFRL ze#7BGd|7`DzY**w{92pOSuEuY5k-PXJG2VYcks(11 zG-7{c)-=VJZb-m^Wzafc&`52#7u?qYjn|0-fLWmT&j%3I9x)z+Pto0b4E_JzeqxNT z$B)O@k50i0|F0N*l*`XY-5w2@g9qBo!R0ShbTBxW4^+kLqIAsk&-`2@DSo2=LQLt3 z^bJz=K)C#r6#ZV~b$+5j0gyZGX_7&Z(hu&7{uksHKhb|Br*?Ev2Q|;WzX|u?UioU; z`w3tczyRrmmo+6C=?!iYQ1IVZMJf+)`TQp=jU!>X{U?y~rP95`sO;A7hd~#w5BYPX93` z`D0A-$C%`gG07iel0U{Ie~d}~7?b>8Vob89OF0H=O8~$e{1Hsa=>b(xX^8|aEZ_ha zXn)}f!f5b^B0!*07hwD^6kMPkhbe&ntoVl>dQa~)^}B`?O#D6U>0N)v{DSd%)x8pa z;qVXy*h!QIXpUk2=}rW^P+I{Gvm2-n;==-$N`2QAC`Kd zL>=%SX^X;;o?-zG-eMA>;$px_Fo(eJ8%VfdU&A}0+hH3g)4wC9xcYrMIb@D zD{&J(ap1DjHv&KI^o4UlMWsYw;t~>EGBTnP(oh*031Keq`*2Ayaqz#42>5+CR7OEk zmg`6025a+ma#k?W(E3pq7*pc@?Q2Tc>nx%eZTO(NNhiuAh(~HKq>rd@}C0!jZk0z zKa+ZT;-yC51%(E`jQ!8lD7>mv%mj`CKi7r9L2{m7nKk{ucKxSr|7&JVKe!iqKf4ob zKd(2aX(q_l2_^>jfO~>uD6shw`+S1UCzz(7_Otv&WC;)I0JWafCcIr znW<|aT;M2>UoCY_2?;GZd38B8S#^0?IY})kX&Ff=X>nPooUE*-+V8muDq(%36I_W~ z>UTW&)jnua<&5wE6aMKNalCxI71VvYbGJCAP$Z^X(4er z1#$5`Qm_JqbKpKHR76}#L>wgj;lbc9q~Mr$0u7D+7BPVZP}dC_CHwBOJ7&x=R9*l!VpdhzvKy(mzI~7bcBh(#hoNXBqZSCB63hEn24Obv#c~s z8s_9IE&H=HT`v^c0sQD1Um7S9Q3O~LR9#J7TtiAu9;z;_DJ>zPrYbL|Dy=T9rl~0| zr=bb`nRm}H!olPJ6h}@l1!ttMrvo?u5S|V$a4|P;xC^1Oy@92m@8IhW2bUu)2M-h+ zKb3#A`6mymU|Ue2r5EbQQlaMH@&ocfoZ4HK{wLx(DTZGqz#_QzpuJ+n{&W4HJn{Vn z_eJ=@owR(Bo?L{vtbq7U<`@hpaB*>Y2RTO>D89qQ|80luWd^zbFWvv6H0l3Xni$xU zJ^pkMczFf>kdi6fXJ1|*yWr}f-~huL7=n|?0c-~M59IG{_&IJQ<>b_~B%$)EYN{H9 zaRb$q)`F@_O3Oo`Qqrp6?A$BRA2f&~OgL~BNs5Tem`O+|NXscmN&K8edt@l2Guq$5 z7q038j`sgLIrj3vTphez@Iyq58{fjcPWxYN$A2IH|J;s$xf=W*YX@QU?RAaVKlU0S z?YHL{*lYp{d`RNK{ipM(pHI<0_dgQ&BY{5>_#=To68Iy5|A!>->-`??1#Xf2!MFE6 z?#=$VH~Zt>4ED#p*&mD4Kkm)`xHtRb-t3Qivp??5{i z$Vf>jiK#%B(V#;!fQ=-#CH$Wl5q$J-vNj$HphVO-?PZtgfwZ zY=X_lyI>gYn@X`skxa=~#FShtS zXKVu6mdP}DLGI2r&t+2AbZ-wa;s?~br@lWAaSSU!mZ3U^QKbm0Ke>f74? zYxVwxRotilQ6s7nDvv*-(^YPJHG~6h*bZIKvH9RicA@^WVeco_x>MRKqHhBc1^&W; z_iox;hRHOmLyb6;&zhL43kT!}UX(c@a>j##XR}8h`yp6#$eOp7;j}VM$asA`N5=LI z;*P72x9mPXeO4e+-*&!C`CDu{rD5}l9ko+#H|dKtpZHw(;%U-YF2gp-IbI-3XEo*U z`e{-#`WS2+2b`{`tGhB=_ZsQ|uYkR+wcM2~%h_t3GVeb%m!%UjkyQJ%>+t-kb3Ex2 zBfEAa8)8WzFV9)$!TaSfFmH(-324}t?xxN$N)o!jRYO^&xl+cAmzUxGS zdA0$1R<8-9D#~44*|bM;KG@0Htw!6KaldY#y&IFD5YkP*rng*yXL->eR*bm zV>F5TeqdJYj-&t%xIOu;Vy^e7+C39$#T(d-94XG;FY9$QipU8LwK5;Cog0kzd7A7h z77f{Rce|sQV|6vM)3C#i{o7b_ztAs_6_9?zmR&OUaX_Hk#7@<>gEb+a8Ff7qlRh@^ zrAer-zD-7KXoP+?RV>}rz{sqNKfgW5)YBZ-W&UcGOT7G{7Ti4igy7lH0)F2Y?+lHl zuupqnl}iRqRq;A=L}vnoRv8ybR!cTc@aM*ct~RC02b`?5Rn&8>UO2X6mhr_PZH2hA zRa@OTYooGzXMTHWcWL+NE9S+eZ;*mjY{8EGscQP1x|NdX?z*kQmATOA!C-JMoLif% z0};iTe~AEH-2L4lTg1Dl**Lm`U5g>#YN@k`2v;aXsqPl9gxUogbG9#`b}N^+d$1Ka zAYiSbsn{ZdrzKm~VO6wmTBO>zS{ACOX>jwzw{OGLe4!>an$F?jm&lwJn@1>l<26*Q zlOW%ppa%9;1gug_6YtJ6R7p5rAf+GcCty&4@@`d(zqf8dO*x}sn< zdg1K-Hn(P~%nmjqDb8;E6VEh=1pfycB6PRXh(5o zP~hl}@WWS<;*Ldy7!05F>5`)Z;enpckzXjSk89;Ryu#iKijru|wp^7-xt03i)=o{6 ziy$+`!N_cL*D>SfWi?5m&hllkP)9`%=L$eGQ;y}jJ~3FmFGfV+i4Mp729pC*-mE~DyLv8!P%F$R(!5Zm#IHEBWKKYp&8LdUTN*^9D$=}ksS)kF zCuSCwCT0I=*|o>D|J~7^AHNYuwyllRzUhqf7N-V7n%9C|()Ve)Mxt!{P@ zxxbbAmJ$-02JW%u{_XR@;%iD8wsBizcTuTZr!c4!(vof}QLV5osum>_`6#dP>%z)G zvsTNtxAPrs9U2%-VUzn77N$G2g^lg~kKc(ry$*ePGxkL8c~{j9bi!;~*J@v|P!)r% zHB-0+P(b|ZfVV0Rut0TQUa4%})_v0s3zlWKCo>_nO7R{!;h2|Mq`|_>+%uNU`fbW) zsDY@7U7||oc^mIcLHfvim&gzcBV-|igfm6NEvH+Drx_5J$V#reR(D8p(Dzn{r!#8! z{VN5A0G?0b_WD6OOr%MX(c#5oIXdm8lp_Q2olDFeu6Z)E7(vzLi%0V}+1Mo4l#M0M zoodiaQ&wD@;(|YQIdw_4c;@_}jb*Zk-Y9Q)+?y>kQnqC;Q9FjO4p-9UfNpUiLsuZk{CYhBW9|kF8!oj1>x4&n^f9K{N3k^@|EYu zrzUSCIO_6TVm!w?mVY57w{b{+YG^H)yH{pRqhIFj1rl@Mc)cX4v5Ej%TZNPc{jH_= z6ZSQ70|(G4-K@DDL6}2--P?Bj?ctlefyDt*X|*_jcAI{8v}u-cqG3Y}bOtG%g#*N_ zQ@=-T=lbD*rB2y(ZOz~wmB5{xZ6&>?m&L)KZTI*7DT~nK35T;uZegm{#TqBmN-5I5 z_D8XE27L&%b4^B%%)JEnIKlsBk7H-MgAJWF;|K`De#RE=t{^eo^&vs2!Jxln&b80o zn_DYnp&>z+TO!mN^;Y7Mh`C2z5T1DNj+Ud^K3y4)vtOFN4bOz$VQ#g&BXfx}kN$Wm zRhHGn`94nnf&1m=+ylnh@-Hs$vaD z2c+E9x(f~f4zXqBJF44J7_WBquFi5uPR_Bn6WiFKovi|&>4tx|Yqeu^#{m(WpcBNM zwUsp!!#g;@vZ;{4{WLnGuhv$n4pp^NS@DhU?O3VFwc$(R<_F31ZSwEhw6>e5q~-DY zGBce`Z!EX)OH{o2%C91tCG|Er)rEJ9#^XUkL;i)e=lhHN?Yf3F;RgyNPKGdRvpCC9 zN$LU^9}cOGq}$nj(Ib<|3eBB?w~KwonWf7^0@MnaNIP_op1LBTbD*c^$>WZhS-&1X zIX^`SBDR4FyS!JM^h!d{LS33~KSrf(w5_GCB{U?L^t_>Z&f$Ii9RJ%|AK#PT$lY#V zofj7QzLa4}_pe`dab16>XKQ$eUUVz#;ed*5)XLgy`8c^b{o?zAS;d#ASG;~(3LLfi z*+Z@F{p*{pY$2rA@;f{?V6$`+mYvO(VLlX>cg1tI>zo^&NVu+Ux!s{+<-NC4cDJix zA(=i=^Qpupwn!9PtQU3+&$5im%Pl`!DrA~ zoKTd^pANBlo^w%N+9ZYOt*CSuR`*nb2~*Eiq?f~My2IF0DZ4Gk#YjCH!6}<{hRoYe z$t;_aX=Xd86;dSSwl6`B_CrZJFc_|jIeF&??043-M&|}?i*|n7*PKOyiwb0!)4p-r z@^ek6Z_vty@-+6fenUU~N?lj=14Q@5;zT1;zMCFz#OOub_$&7n!*%;)@h?L;RO568%G2VN7TGv?$vsGe+Os zb4A?41aMfkZ>i^%h-g+$-qJc<1U*|3T4G!pD61Yq(=_=YOM8s#_S;j1d9!aW^scgV zBrtN6c3G_7Yd6Iv(I4ALzPEDm>F1CA-3oSvZ^mR&G?a6wVn=dXlo``sP4I`cWe70W zrJ@h)uY>%4>6mFdZFC8doJ(tR}t)j!ME>% zsm_Kh-lIj$A8-Imvfx=`qjNLxEyWN|henn)2uks~|IUYL6TY0Sx751^Pg?qg470zA zT7}0I);Dat{lLG}Y2R|6XC%Vh!|U4|8(r0RD!0vhqF4{FEQ6l+y}3f)7OTG7I=N0y z<~tmRCd4B+!jk3BW7`%U%dE0i``w}z7*@D*Ol8saq=HOp`F8%=F4^h)-OQoGgJY2< zi|SVga=lY79WL5ca4OC^eNW54`yxhXQk9dp$2P%aCqO4+hIOY-z+Ebx_VuZN-3!Xk zTPcRHM{4_vzsCC;S|F+9&O2NCGRfV2l61vsGrW1&z>q2NNSHH8n1`HLd z!*>vetzEzJ+$laY{)|EHkf1X4?DJ`(mpk@Ivv&2!_NczwQLVcVqbb~hvR_w+3HOn&v)_4bIOCQKC_>n zaxEi!lv}zQ{977LaDeg>I_m`v5I#5mR)OaB_vvI3Prj_IM*|yfZ{L*Wmu>b3dm(iA!b~4-zS_bS**UU-IB8 ze)m->kDo;$EkRPulmC*dKe?Qh%9cL%&NNmdGB}%e`y%Jaw?{ZYq9Jl8WMPZ8?Qm$p z*}^0J`R887`ppW@y4CU}z1OZB?U}rmW8>zd9q}++=A*gPwG+dlrz3=!fSv4_6Y<#( zNKZ^o?0UrvzQ`N7HYH@Uxq-0_5UGoLu21Vky~g}W??`KB$CdaPt0Z04+cIR*z>T5T z4SB1TuR_k}Wr>`Ks@#cMuaB0d)9PcAkO-a)%!iGU;J}BKv0FNWaXa}s%FP&|!Am zL;t-K#>Zy*w9=*>9XchdhBUGIJe+5Iv>q+$tbP>~9KZo#YZii$AKyeuhCY+pl3OX$ zO3;jicJds^2#nDILY-FBZDU2ZB79ty7Rjuo0v{FGb$-mDk10wkxh%VyBt4Mc^3<#5 z-r(7Rfiy{-54+2{;GWLzNT0B!dtb$ea(#=TA-RYx4RFP{&AB-FXbYJbf`CL>y=|SV z(YIMndKwZ^UCQ?2isRZA2TN5&ouy2P=u^%wi=5yConEVAVx@cMoOWTdCwTgU1FqwM z;8i1li$OtZX^O+(ROBOUT>I-2o9;pn4ObbC-vw}h)z*Saz{>gWPw7=xzfRFL4BihP z>D$7}aZpLRJhr;|6@GfM(>*JLH$}{2C5#Whf%8lZ+k?C`^cSq?r8<5}ur4#k&_05%Y{rEj6v=;mW4t(0n zYYn+II3V|@>wO{D{eJI_qH;yhmYW00Dna|rSYBR%pd^#8;FTqH zQ%iH+Ouw7V2(iM}yHDvHNTg!uEj24|hU?^NtADpONX538f8gJKq7ktZk^7;s z;+5mw(e^_5Z64)QsmGolx_+k{z{pRww z-26zfmC{_merP$WDWAbWx8-b$a{4NGC>|NSdPfdZ+`OV=|DiW&mhP5Xv$1#oa{iQp zd0M4g#QDm{&J>l(rVcwwN!D=qmiA^tx=yiNBls>6n~y3vvV{Cb *T>_)Kd?%CZC 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 U6jp*|I!U!|lz}__8$X@=U-Ja2)c^nh literal 0 HcmV?d00001 diff --git a/resources/admin/src/assets/images/logo.png b/resources/admin/src/assets/images/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..bfcd8f79cf56a2e3c632bbfdc03934bf6138414c GIT binary patch literal 17682 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 L9Yo0$i@W~~yVF7z literal 0 HcmV?d00001 diff --git a/resources/admin/src/assets/style/app.scss b/resources/admin/src/assets/style/app.scss new file mode 100644 index 0000000..3b453ad --- /dev/null +++ b/resources/admin/src/assets/style/app.scss @@ -0,0 +1,164 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} +// ==================== 全局滚动条样式优化 ==================== +// Webkit 滚动条基础样式 +::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +// 滚动条轨道 +::-webkit-scrollbar-track { + background: rgba(0, 0, 0, 0.03); + border-radius: 8px; + margin: 4px; +} + +// 滚动条滑块 - 渐变色设计 +::-webkit-scrollbar-thumb { + background: linear-gradient(180deg, #d9d9d9 0%, #bfbfbf 100%); + border-radius: 8px; + border: 2px solid transparent; + background-clip: content-box; + transition: all 0.3s ease; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1); + + &:hover { + background: linear-gradient(180deg, #c0c0c0 0%, #a6a6a6 100%); + border-radius: 8px; + border: 2px solid transparent; + background-clip: content-box; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + } + + &:active { + background: linear-gradient(180deg, #a6a6a6 0%, #8c8c8c 100%); + border-radius: 8px; + border: 2px solid transparent; + background-clip: content-box; + } +} + +// 滚动条两端按钮 +::-webkit-scrollbar-button { + display: none; +} + +// 滚动条角落 +::-webkit-scrollbar-corner { + background: rgba(0, 0, 0, 0.03); + border-radius: 8px; +} + +// Firefox 滚动条样式 +* { + scrollbar-width: thin; + scrollbar-color: #d4d4d4 rgba(0, 0, 0, 0.03); +} + +#app { + min-height: 100vh; +} + +.pages { + flex: 1; + display: flex; + flex-direction: column; + background-color: #ffffff; + + .tool-bar { + padding: 12px 16px; + background-color: #fff; + border-bottom: 1px solid #f0f0f0; + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + + .left-panel { + flex: 1; + display: flex; + align-items: center; + gap: 12px; + overflow-x: auto; + + :deep(.ant-form) { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + flex: 1; + } + + :deep(.ant-form-item) { + margin-bottom: 0; + } + + :deep(.ant-form-item-label) { + min-width: 70px; + } + } + + .right-panel { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; + } + + // 按钮组样式 + .button-group { + display: flex; + gap: 8px; + } + + // 搜索输入框样式 + :deep(.ant-input), + :deep(.ant-select-selector) { + border-radius: 4px; + } + + // 按钮样式优化 + :deep(.ant-btn) { + border-radius: 4px; + transition: all 0.3s ease; + + &:hover { + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + } + + &:active { + transform: translateY(0); + } + } + + // 主按钮特殊样式 + :deep(.ant-btn-primary) { + background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%); + border: none; + + &:hover { + background: linear-gradient(135deg, #40a9ff 0%, #1890ff 100%); + } + } + + // 危险按钮样式 + :deep(.ant-btn-dangerous) { + &:hover { + background: #ff4d4f; + border-color: #ff4d4f; + color: #fff; + } + } + } +} diff --git a/resources/admin/src/assets/style/auth-pages.scss b/resources/admin/src/assets/style/auth-pages.scss new file mode 100644 index 0000000..b07d2ff --- /dev/null +++ b/resources/admin/src/assets/style/auth-pages.scss @@ -0,0 +1,570 @@ +// 认证页面统一样式文件 +// 使用明亮暖色调配色方案 + +// ===== 颜色变量 ===== +$primary-color: #ff6b35; // 橙红色 +$primary-light: #ff8a5b; // 浅橙红色 +$primary-dark: #e55a2b; // 深橙红色 +$secondary-color: #ffd93d; // 金黄色 +$accent-color: #ffb84d; // 橙黄色 + +$bg-dark: #1a1a2e; // 深色背景 +$bg-light: #16213e; // 浅色背景 +$bg-gradient-start: #0f0f23; // 渐变开始 +$bg-gradient-end: #1a1a2e; // 渐变结束 + +$text-primary: #ffffff; +$text-secondary: rgba(255, 255, 255, 0.7); +$text-muted: rgba(255, 255, 255, 0.5); + +$border-color: rgba(255, 255, 255, 0.08); +$border-hover: rgba(255, 107, 53, 0.3); +$border-focus: rgba(255, 107, 53, 0.6); + +// ===== 基础容器 ===== +.auth-container { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + position: relative; + background: linear-gradient(135deg, $bg-gradient-start 0%, $bg-gradient-end 100%); + overflow: hidden; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', + 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif; +} + +// ===== 科技感背景 ===== +.tech-bg { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 0; + pointer-events: none; + + // 网格线 + .grid-line { + position: absolute; + width: 100%; + height: 1px; + background: linear-gradient(90deg, transparent, rgba(255, 107, 53, 0.08), transparent); + animation: gridMove 8s linear infinite; + + &:nth-child(1) { top: 20%; animation-delay: 0s; } + &:nth-child(2) { top: 40%; animation-delay: 2s; } + &:nth-child(3) { top: 60%; animation-delay: 4s; } + &:nth-child(4) { top: 80%; animation-delay: 6s; } + } + + // 光点效果 + .light-spot { + position: absolute; + width: 4px; + height: 4px; + background: $primary-color; + border-radius: 50%; + box-shadow: 0 0 10px $primary-color, 0 0 20px $primary-color; + animation: float 6s ease-in-out infinite; + + &:nth-child(5) { top: 15%; left: 20%; animation-delay: 0s; } + &:nth-child(6) { top: 25%; left: 70%; animation-delay: 2s; } + &:nth-child(7) { top: 55%; left: 15%; animation-delay: 4s; } + &:nth-child(8) { top: 75%; left: 80%; animation-delay: 1s; } + } +} + +@keyframes gridMove { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(100%); } +} + +@keyframes float { + 0%, 100% { transform: translateY(0) scale(1); opacity: 0.6; } + 50% { transform: translateY(-20px) scale(1.2); opacity: 1; } +} + +// ===== 主卡片 ===== +.auth-wrapper { + width: 100%; + max-width: 960px; + padding: 20px; + position: relative; + z-index: 1; +} + +.auth-card { + background: rgba(255, 255, 255, 0.02); + backdrop-filter: blur(24px); + -webkit-backdrop-filter: blur(24px); + border-radius: 28px; + padding: 0; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); + border: 1px solid $border-color; + overflow: hidden; + display: flex; + min-height: 580px; + animation: cardFadeIn 0.6s ease-out; +} + +@keyframes cardFadeIn { + 0% { opacity: 0; transform: translateY(20px); } + 100% { opacity: 1; transform: translateY(0); } +} + +// ===== 左侧装饰区 ===== +.decoration-area { + flex: 1; + background: linear-gradient(135deg, rgba(255, 107, 53, 0.08) 0%, rgba(255, 217, 61, 0.03) 100%); + padding: 60px 40px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + position: relative; + border-right: 1px solid $border-color; +} + +.tech-circle { + position: relative; + width: 220px; + height: 220px; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 48px; + + .circle-inner { + width: 110px; + height: 110px; + background: linear-gradient(135deg, $primary-color 0%, $primary-light 100%); + border-radius: 50%; + box-shadow: 0 0 50px rgba(255, 107, 53, 0.4); + animation: pulse 3s ease-in-out infinite; + display: flex; + align-items: center; + justify-content: center; + + &::after { + content: ''; + width: 60px; + height: 60px; + background: linear-gradient(135deg, $secondary-color 0%, $accent-color 100%); + border-radius: 50%; + box-shadow: 0 0 30px rgba(255, 217, 61, 0.5); + } + } + + .circle-ring { + position: absolute; + width: 160px; + height: 160px; + border: 2px solid rgba(255, 107, 53, 0.2); + border-radius: 50%; + animation: rotate 12s linear infinite; + + &::before { + content: ''; + position: absolute; + top: -2px; + left: 50%; + transform: translateX(-50%); + width: 8px; + height: 8px; + background: $primary-color; + border-radius: 50%; + box-shadow: 0 0 15px $primary-color; + } + } + + .circle-ring-2 { + width: 200px; + height: 200px; + border: 1px solid rgba(255, 217, 61, 0.15); + animation: rotate 18s linear infinite reverse; + } +} + +@keyframes pulse { + 0%, 100% { transform: scale(1); opacity: 1; } + 50% { transform: scale(1.08); opacity: 0.85; } +} + +@keyframes rotate { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +.decoration-text { + text-align: center; + + h2 { + margin: 0 0 16px; + font-size: 32px; + font-weight: 700; + background: linear-gradient(135deg, $primary-color 0%, $secondary-color 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + letter-spacing: 1px; + } + + p { + margin: 0; + color: $text-secondary; + font-size: 16px; + font-weight: 400; + letter-spacing: 0.5px; + } +} + +// ===== 右侧表单区 ===== +.form-area { + flex: 1.3; + padding: 60px 56px; +} + +.auth-header { + margin-bottom: 40px; + + h1 { + margin: 0 0 12px; + font-size: 36px; + font-weight: 700; + background: linear-gradient(135deg, $primary-color 0%, $accent-color 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + letter-spacing: 1px; + } + + .subtitle { + margin: 0; + color: $text-secondary; + font-size: 15px; + font-weight: 400; + } +} + +// ===== 表单样式 ===== +.auth-form { + margin-top: 0; + + :deep(.ant-form-item) { + margin-bottom: 26px; + } + + :deep(.ant-form-item-label > label) { + color: $text-secondary; + font-size: 14px; + font-weight: 500; + } + + // 输入框样式 + :deep(.ant-input-affix-wrapper), + :deep(.ant-input) { + background: rgba(255, 255, 255, 0.03); + border: 1px solid $border-color; + border-radius: 12px; + color: $text-primary; + padding: 12px 16px; + font-size: 15px; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + + &:hover { + background: rgba(255, 255, 255, 0.06); + border-color: $border-hover; + } + + &:focus, + &.ant-input-affix-wrapper-focused { + background: rgba(255, 255, 255, 0.06); + border-color: $primary-color; + box-shadow: 0 0 0 3px rgba(255, 107, 53, 0.1); + } + } + + :deep(.ant-input::placeholder) { + color: $text-muted; + } + + :deep(.ant-input-affix-wrapper > input.ant-input) { + background: transparent; + } + + // 图标样式 + :deep(.anticon) { + color: $text-secondary; + font-size: 16px; + transition: color 0.3s; + } + + :deep(.ant-input-affix-wrapper-focused .anticon) { + color: $primary-color; + } +} + +// ===== 按钮样式 ===== +.auth-form :deep(.ant-btn-primary) { + background: linear-gradient(135deg, $primary-color 0%, $primary-light 100%); + border: none; + border-radius: 12px; + height: 48px; + font-weight: 600; + font-size: 16px; + letter-spacing: 0.5px; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: 0 4px 15px rgba(255, 107, 53, 0.3); + + &:hover:not(:disabled) { + background: linear-gradient(135deg, $primary-light 0%, $accent-color 100%); + box-shadow: 0 6px 25px rgba(255, 107, 53, 0.4); + transform: translateY(-2px); + } + + &:active:not(:disabled) { + transform: translateY(0); + box-shadow: 0 2px 10px rgba(255, 107, 53, 0.3); + } + + &:disabled { + background: rgba(255, 255, 255, 0.08); + color: $text-muted; + box-shadow: none; + transform: none; + cursor: not-allowed; + } +} + +// ===== 表单选项 ===== +.form-options { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 26px; + + :deep(.ant-checkbox-wrapper) { + color: $text-primary; + font-size: 14px; + + .ant-checkbox { + .ant-checkbox-inner { + border-color: $border-color; + background: rgba(255, 255, 255, 0.03); + } + + &.ant-checkbox-checked .ant-checkbox-inner { + background: $primary-color; + border-color: $primary-color; + } + } + } +} + +.forgot-password { + color: $primary-color; + text-decoration: none; + font-size: 14px; + font-weight: 500; + transition: all 0.3s; + + &:hover { + color: $primary-light; + text-decoration: underline; + } +} + +// ===== 验证码输入框 ===== +.code-input-wrapper { + display: flex; + gap: 14px; + + .code-input { + flex: 1; + } + + .code-btn { + width: 150px; + white-space: nowrap; + background: linear-gradient(135deg, $primary-color 0%, $primary-light 100%); + border: none; + border-radius: 12px; + font-weight: 600; + font-size: 14px; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: 0 4px 15px rgba(255, 107, 53, 0.3); + + &:hover:not(:disabled) { + background: linear-gradient(135deg, $primary-light 0%, $accent-color 100%); + box-shadow: 0 6px 25px rgba(255, 107, 53, 0.4); + transform: translateY(-2px); + } + + &:active:not(:disabled) { + transform: translateY(0); + } + + &:disabled { + background: rgba(255, 255, 255, 0.08); + color: $text-muted; + box-shadow: none; + transform: none; + cursor: not-allowed; + } + } +} + +// ===== 协议复选框 ===== +.agreement-checkbox { + :deep(.ant-checkbox-wrapper) { + color: $text-secondary; + font-size: 13px; + align-items: flex-start; + line-height: 1.6; + + .ant-checkbox { + margin-top: 2px; + + .ant-checkbox-inner { + border-color: $border-color; + background: rgba(255, 255, 255, 0.03); + } + + &.ant-checkbox-checked .ant-checkbox-inner { + background: $primary-color; + border-color: $primary-color; + } + } + } +} + +.agreement-text { + font-size: 13px; + line-height: 1.6; + color: $text-secondary; +} + +.link { + color: $primary-color; + text-decoration: none; + font-weight: 500; + transition: all 0.3s; + + &:hover { + color: $primary-light; + text-decoration: underline; + } +} + +// ===== 表单底部 ===== +.form-footer { + text-align: center; + margin-top: 28px; + color: $text-secondary; + font-size: 14px; + + .auth-link { + color: $primary-color; + text-decoration: none; + font-weight: 600; + margin-left: 6px; + transition: all 0.3s; + + &:hover { + color: $primary-light; + text-decoration: underline; + } + } +} + +// ===== 响应式设计 ===== +@media (max-width: 768px) { + .auth-card { + flex-direction: column; + min-height: auto; + margin: 20px 0; + } + + .decoration-area { + padding: 48px 24px; + border-right: none; + border-bottom: 1px solid $border-color; + } + + .tech-circle { + width: 160px; + height: 160px; + + .circle-inner { + width: 80px; + height: 80px; + + &::after { + width: 45px; + height: 45px; + } + } + + .circle-ring { + width: 120px; + height: 120px; + } + + .circle-ring-2 { + width: 150px; + height: 150px; + } + } + + .decoration-text { + h2 { + font-size: 26px; + } + + p { + font-size: 14px; + } + } + + .form-area { + padding: 48px 32px; + } + + .auth-header { + h1 { + font-size: 28px; + } + + .subtitle { + font-size: 14px; + } + } + + .code-input-wrapper { + flex-direction: column; + gap: 12px; + + .code-btn { + width: 100%; + } + } +} + +@media (max-width: 480px) { + .auth-wrapper { + padding: 16px; + } + + .auth-card { + border-radius: 20px; + } + + .form-area { + padding: 36px 24px; + } + + .auth-header { + margin-bottom: 32px; + } +} diff --git a/resources/admin/src/assets/style/auth.scss b/resources/admin/src/assets/style/auth.scss new file mode 100644 index 0000000..f594d23 --- /dev/null +++ b/resources/admin/src/assets/style/auth.scss @@ -0,0 +1,356 @@ +// Auth Pages - Warm Tech Theme +// Warm color palette with tech-inspired design + +:root { + --auth-primary: #ff6b35; + --auth-primary-light: #ff8c5a; + --auth-primary-dark: #e55a2b; + --auth-secondary: #ffb347; + --accent-orange: #ffa500; + --accent-coral: #ff7f50; + --accent-amber: #ffc107; + + --bg-gradient-start: #fff5f0; + --bg-gradient-end: #ffe8dc; + --card-bg: rgba(255, 255, 255, 0.95); + + --text-primary: #2d1810; + --text-secondary: #6b4423; + --text-muted: #a67c52; + + --border-color: #ffd4b8; + --shadow-color: rgba(255, 107, 53, 0.15); + + --success: #28a745; + --warning: #ffc107; + --error: #dc3545; + + --tech-blue: #007bff; + --tech-purple: #6f42c1; +} + +.auth-container { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, var(--bg-gradient-start) 0%, var(--bg-gradient-end) 100%); + position: relative; + overflow: hidden; + + // Tech pattern background + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-image: + radial-gradient(circle at 20% 50%, rgba(255, 107, 53, 0.03) 0%, transparent 50%), + radial-gradient(circle at 80% 20%, rgba(255, 179, 71, 0.05) 0%, transparent 40%), + radial-gradient(circle at 40% 80%, rgba(255, 127, 80, 0.04) 0%, transparent 40%); + pointer-events: none; + } + + // Animated tech elements + &::after { + content: ''; + position: absolute; + width: 600px; + height: 600px; + background: radial-gradient(circle, rgba(255, 107, 53, 0.08) 0%, transparent 70%); + border-radius: 50%; + top: -200px; + right: -200px; + animation: float 20s ease-in-out infinite; + pointer-events: none; + } +} + +@keyframes float { + 0%, + 100% { + transform: translate(0, 0); + } + 50% { + transform: translate(-50px, 50px); + } +} + +.auth-card { + width: 100%; + max-width: 440px; + background: var(--card-bg); + backdrop-filter: blur(20px); + border-radius: 24px; + padding: 48px 40px; + box-shadow: + 0 20px 60px var(--shadow-color), + 0 8px 24px rgba(0, 0, 0, 0.08); + position: relative; + z-index: 1; + margin: 20px; + + // Tech accent line + &::before { + content: ''; + position: absolute; + top: 0; + left: 50%; + transform: translateX(-50%); + width: 80px; + height: 4px; + background: linear-gradient(90deg, var(--auth-primary), var(--auth-secondary)); + border-radius: 0 0 4px 4px; + } +} + +.auth-header { + text-align: center; + margin-bottom: 40px; + + .auth-title { + font-size: 28px; + font-weight: 700; + color: var(--text-primary); + margin-bottom: 8px; + background: linear-gradient(135deg, var(--auth-primary-dark), var(--auth-primary)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + } + + .auth-subtitle { + font-size: 14px; + color: var(--text-secondary); + line-height: 1.6; + } +} + +.auth-form { + .ant-form-item { + margin-bottom: 24px; + } + + .ant-input { + border-radius: 12px; + border: 1px solid var(--border-color); + transition: all 0.3s ease; + box-shadow: 0 2px 8px rgba(255, 107, 53, 0.08); + + &:hover { + border-color: var(--auth-primary-light); + box-shadow: 0 4px 16px rgba(255, 107, 53, 0.15); + } + + &:focus, + &.ant-input-focused { + border-color: var(--auth-primary); + box-shadow: 0 4px 16px rgba(255, 107, 53, 0.15); + } + } + + .ant-input-affix-wrapper { + border-radius: 12px; + border: 1px solid var(--border-color); + transition: all 0.3s ease; + box-shadow: 0 2px 8px rgba(255, 107, 53, 0.08); + + &:hover { + border-color: var(--auth-primary-light); + box-shadow: 0 4px 16px rgba(255, 107, 53, 0.15); + } + + &:focus, + &.ant-input-affix-wrapper-focused { + border-color: var(--auth-primary); + box-shadow: 0 4px 16px rgba(255, 107, 53, 0.15); + } + + .ant-input { + border: none; + box-shadow: none; + } + } + + .ant-input-prefix { + color: var(--auth-primary); + font-size: 18px; + margin-right: 8px; + } + + .ant-input-suffix { + color: var(--text-muted); + } + + .ant-btn { + height: 48px; + font-size: 16px; + font-weight: 600; + border-radius: 12px; + transition: all 0.3s ease; + + &.ant-btn-primary { + background: linear-gradient(135deg, var(--auth-primary), var(--auth-primary-dark)); + border: none; + box-shadow: 0 8px 24px rgba(255, 107, 53, 0.35); + + &:hover { + background: linear-gradient(135deg, var(--auth-primary-light), var(--auth-primary)); + transform: translateY(-2px); + box-shadow: 0 12px 32px rgba(255, 107, 53, 0.45); + } + + &:active { + transform: translateY(0); + } + } + } +} + +.auth-links { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; + + .remember-me { + .ant-checkbox-inner { + border-radius: 4px; + border-color: var(--border-color); + } + + .ant-checkbox-wrapper { + color: var(--text-secondary); + font-size: 14px; + } + + &.ant-checkbox-wrapper-checked { + .ant-checkbox-inner { + background-color: var(--auth-primary); + border-color: var(--auth-primary); + } + } + } + + .forgot-password { + color: var(--auth-primary); + font-size: 14px; + text-decoration: none; + transition: color 0.3s ease; + + &:hover { + color: var(--auth-primary-dark); + } + } +} + +.auth-divider { + display: flex; + align-items: center; + margin: 32px 0; + color: var(--text-muted); + font-size: 13px; + + &::before, + &::after { + content: ''; + flex: 1; + height: 1px; + background: var(--border-color); + } + + span { + padding: 0 16px; + } +} + +.auth-footer { + text-align: center; + margin-top: 24px; + + .auth-footer-text { + color: var(--text-secondary); + font-size: 14px; + + .auth-link { + color: var(--auth-primary); + text-decoration: none; + font-weight: 600; + margin-left: 4px; + transition: color 0.3s ease; + + &:hover { + color: var(--auth-primary-dark); + } + } + } +} + +.tech-decoration { + position: absolute; + width: 100%; + height: 100%; + top: 0; + left: 0; + pointer-events: none; + overflow: hidden; + + .tech-circle { + position: absolute; + border: 2px solid rgba(255, 107, 53, 0.1); + border-radius: 50%; + animation: pulse 4s ease-in-out infinite; + } + + .tech-circle:nth-child(1) { + width: 300px; + height: 300px; + top: -150px; + left: -150px; + animation-delay: 0s; + } + + .tech-circle:nth-child(2) { + width: 200px; + height: 200px; + bottom: -100px; + right: -100px; + animation-delay: 1s; + } + + .tech-circle:nth-child(3) { + width: 150px; + height: 150px; + bottom: 20%; + left: -75px; + animation-delay: 2s; + } +} + +@keyframes pulse { + 0%, + 100% { + opacity: 0.3; + transform: scale(1); + } + 50% { + opacity: 0.6; + transform: scale(1.05); + } +} + +// Responsive design +@media (max-width: 768px) { + .auth-card { + padding: 40px 24px; + margin: 16px; + } + + .auth-header { + .auth-title { + font-size: 24px; + } + } +} diff --git a/resources/admin/src/assets/vue.svg b/resources/admin/src/assets/vue.svg new file mode 100644 index 0000000..770e9d3 --- /dev/null +++ b/resources/admin/src/assets/vue.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/admin/src/boot.js b/resources/admin/src/boot.js new file mode 100644 index 0000000..ee7ffbf --- /dev/null +++ b/resources/admin/src/boot.js @@ -0,0 +1,15 @@ +import * as AIcons from '@ant-design/icons-vue' +import * as ElementPlusIconsVue from '@element-plus/icons-vue' + +export default { + install(app) { + + for (let icon in AIcons) { + app.component(`${icon}`, AIcons[icon]) + } + + for (const [key, component] of Object.entries(ElementPlusIconsVue)) { + app.component(`El${key}`, component) + } + } +} diff --git a/resources/admin/src/components/HelloWorld.vue b/resources/admin/src/components/HelloWorld.vue new file mode 100644 index 0000000..546ebbc --- /dev/null +++ b/resources/admin/src/components/HelloWorld.vue @@ -0,0 +1,43 @@ + + + + + diff --git a/resources/admin/src/components/scCron/index.vue b/resources/admin/src/components/scCron/index.vue new file mode 100644 index 0000000..b57a761 --- /dev/null +++ b/resources/admin/src/components/scCron/index.vue @@ -0,0 +1,84 @@ + + + + + diff --git a/resources/admin/src/components/scEditor/UploadAdapter.js b/resources/admin/src/components/scEditor/UploadAdapter.js new file mode 100644 index 0000000..f689603 --- /dev/null +++ b/resources/admin/src/components/scEditor/UploadAdapter.js @@ -0,0 +1,144 @@ +export default class UploadAdapter { + constructor(loader, options) { + this.loader = loader; + this.options = options; + this.timeout = 60000; // 60秒超时 + } + + upload() { + return this.loader.file.then( + (file) => + new Promise((resolve, reject) => { + this._initRequest(); + this._initListeners(resolve, reject, file); + this._sendRequest(file); + this._initTimeout(reject); + }), + ); + } + + abort() { + if (this.xhr) { + this.xhr.abort(); + } + if (this.timeoutId) { + clearTimeout(this.timeoutId); + } + } + + _initRequest() { + const xhr = (this.xhr = new XMLHttpRequest()); + + xhr.open("POST", this.options.upload.uploadUrl, true); + xhr.responseType = "json"; + } + + _initListeners(resolve, reject, file) { + const xhr = this.xhr; + const loader = this.loader; + const genericErrorText = `Couldn't upload file: ${file.name}.`; + + xhr.addEventListener("error", () => { + console.error("[UploadAdapter] Upload error for file:", file.name); + reject(genericErrorText); + }); + + xhr.addEventListener("abort", () => { + console.warn("[UploadAdapter] Upload aborted for file:", file.name); + reject(); + }); + + xhr.addEventListener("timeout", () => { + console.error("[UploadAdapter] Upload timeout for file:", file.name); + reject(`Upload timeout: ${file.name}. Please try again.`); + }); + + xhr.addEventListener("load", () => { + const response = xhr.response; + + // 检查响应状态码 + if (xhr.status >= 200 && xhr.status < 300) { + if (!response) { + console.error("[UploadAdapter] Empty response for file:", file.name); + reject(genericErrorText); + return; + } + + // 检查业务状态码(假设 code=1 表示成功) + if (response.code == 1 || response.code == undefined) { + const url = response.data?.url || response.data?.src; + if (!url) { + console.error("[UploadAdapter] No URL in response for file:", file.name, response); + reject("Upload succeeded but no URL returned"); + return; + } + resolve({ default: url }); + } else { + const errorMessage = response.message || genericErrorText; + console.error("[UploadAdapter] Upload failed for file:", file.name, "Error:", errorMessage); + reject(errorMessage); + } + } else { + console.error("[UploadAdapter] HTTP error for file:", file.name, "Status:", xhr.status); + reject(`Server error (${xhr.status}): ${file.name}`); + } + }); + + // 上传进度监听 + if (xhr.upload) { + xhr.upload.addEventListener("progress", (evt) => { + if (evt.lengthComputable) { + loader.uploadTotal = evt.total; + loader.uploaded = evt.loaded; + } + }); + } + } + + _initTimeout(reject) { + // 清除之前的超时定时器(如果有) + if (this.timeoutId) { + clearTimeout(this.timeoutId); + } + + // 设置新的超时定时器 + this.timeoutId = setTimeout(() => { + if (this.xhr) { + this.xhr.abort(); + reject(new Error("Upload timeout")); + } + }, this.timeout); + } + + _sendRequest(file) { + // 设置请求超时 + this.xhr.timeout = this.timeout; + + // Set headers if specified. + const headers = this.options.upload.headers || {}; + const extendData = this.options.upload.extendData || {}; + // Use the withCredentials flag if specified. + const withCredentials = this.options.upload.withCredentials || false; + const uploadName = this.options.upload.uploadName || "file"; + + for (const headerName of Object.keys(headers)) { + this.xhr.setRequestHeader(headerName, headers[headerName]); + } + + this.xhr.withCredentials = withCredentials; + + const data = new FormData(); + for (const key of Object.keys(extendData)) { + data.append(key, extendData[key]); + } + data.append(uploadName, file); + + this.xhr.send(data); + } +} + +export function UploadAdapterPlugin(editor) { + editor.plugins.get("FileRepository").createUploadAdapter = (loader) => { + return new UploadAdapter(loader, editor.config._config); + }; +} diff --git a/resources/admin/src/components/scEditor/index.vue b/resources/admin/src/components/scEditor/index.vue new file mode 100644 index 0000000..8be1b79 --- /dev/null +++ b/resources/admin/src/components/scEditor/index.vue @@ -0,0 +1,389 @@ + + + + + diff --git a/resources/admin/src/components/scExport/README.md b/resources/admin/src/components/scExport/README.md new file mode 100644 index 0000000..8ea7951 --- /dev/null +++ b/resources/admin/src/components/scExport/README.md @@ -0,0 +1,394 @@ +# scExport 异步导出组件 + +异步导出组件,支持表单参数配置、字段选择、格式选择、自定义文件名等功能。 + +## 基本使用 + +```vue + + + +``` + +## Props + +| 参数 | 说明 | 类型 | 默认值 | +|------|------|------|--------| +| open | 是否显示弹窗 | Boolean | false | +| title | 弹窗标题 | String | '导出数据' | +| api | 导出API接口 | Function | 必填 | +| showOptions | 是否显示导出选项 | Boolean | true | +| showFieldSelect | 是否显示字段选择 | Boolean | false | +| fieldOptions | 字段选项 | Array | [] | +| showFormatSelect | 是否显示格式选择 | Boolean | false | +| defaultFormat | 默认导出格式 | String | 'xlsx' | +| defaultFilename | 默认文件名 | String | '' | +| tip | 提示信息 | String | '' | + +## Events + +| 事件名 | 说明 | 回调参数 | +|--------|------|----------| +| update:open | 弹窗显示状态变化 | (visible: Boolean) | +| success | 导出成功 | (exportParams) | +| error | 导出失败 | (message, error) | +| change | 导出参数变化 | (params) | + +## Slots + +### formParams + +自定义表单参数插槽,可用于添加额外的表单字段。 + +```vue + +``` + +## 完整示例 + +### 示例1:简单导出 + +```vue + + + +``` + +### 示例2:带表单参数的导出 + +```vue + + + +``` + +### 示例3:带字段和格式选择的导出 + +```vue + + + +``` + +## 注意事项 + +1. 组件会自动处理文件下载、表单参数合并等逻辑 +2. 表单参数会作为普通对象发送到后端 +3. 后端接口应返回 Blob 类型的响应 +4. 文件名会自动添加对应的扩展名(.xlsx、.xls、.csv) +5. 字段选择功能需要在后端支持按字段过滤数据 +6. 格式选择功能需要在后端支持不同格式的导出 + +## 与表格组件结合使用 + +```vue + + + diff --git a/resources/admin/src/components/scExport/index.vue b/resources/admin/src/components/scExport/index.vue new file mode 100644 index 0000000..c3573e9 --- /dev/null +++ b/resources/admin/src/components/scExport/index.vue @@ -0,0 +1,278 @@ + + + + + + + diff --git a/resources/admin/src/components/scForm/index.vue b/resources/admin/src/components/scForm/index.vue new file mode 100644 index 0000000..2ea8863 --- /dev/null +++ b/resources/admin/src/components/scForm/index.vue @@ -0,0 +1,320 @@ + + + + + diff --git a/resources/admin/src/components/scIconPicker/index.vue b/resources/admin/src/components/scIconPicker/index.vue new file mode 100644 index 0000000..adbbb57 --- /dev/null +++ b/resources/admin/src/components/scIconPicker/index.vue @@ -0,0 +1,508 @@ + + + + + diff --git a/resources/admin/src/components/scImport/README.md b/resources/admin/src/components/scImport/README.md new file mode 100644 index 0000000..0cb7bac --- /dev/null +++ b/resources/admin/src/components/scImport/README.md @@ -0,0 +1,192 @@ +# scImport 异步导入组件 + +异步导入组件,支持文件上传、表单参数配置、模板下载等功能。 + +## 基本使用 + +```vue + + + +``` + +## Props + +| 参数 | 说明 | 类型 | 默认值 | +|------|------|------|--------| +| open | 是否显示弹窗 | Boolean | false | +| title | 弹窗标题 | String | '导入数据' | +| api | 导入API接口 | Function | 必填 | +| templateApi | 下载模板API接口 | Function | null | +| accept | 接受的文件类型 | String | '.xlsx,.xls,.csv' | +| maxSize | 文件大小限制(MB) | Number | 10 | +| showTemplate | 是否显示下载模板 | Boolean | true | +| tip | 提示信息 | String | '' | +| filename | 文件名(用于下载) | String | '导入数据' | + +## Events + +| 事件名 | 说明 | 回调参数 | +|--------|------|----------| +| update:open | 弹窗显示状态变化 | (visible: Boolean) | +| success | 导入成功 | (data, response) | +| error | 导出失败 | (message, error) | +| change | 文件列表变化 | (fileList) | + +## Slots + +### formParams + +自定义表单参数插槽,可用于添加额外的表单字段。 + +```vue + +``` + +## 完整示例 + +```vue + + + +``` + +## 注意事项 + +1. 组件会自动处理文件上传、表单参数合并等逻辑 +2. 表单参数会通过 FormData 发送到后端 +3. 数组和对象类型的参数会被转换为 JSON 字符串 +4. 下载模板功能需要后端提供对应的 API 接口 diff --git a/resources/admin/src/components/scImport/index.vue b/resources/admin/src/components/scImport/index.vue new file mode 100644 index 0000000..0eddb07 --- /dev/null +++ b/resources/admin/src/components/scImport/index.vue @@ -0,0 +1,323 @@ + + + + + diff --git a/resources/admin/src/components/scTable/index.vue b/resources/admin/src/components/scTable/index.vue new file mode 100644 index 0000000..ef8681a --- /dev/null +++ b/resources/admin/src/components/scTable/index.vue @@ -0,0 +1,546 @@ + + + + + diff --git a/resources/admin/src/components/scUpload/file.vue b/resources/admin/src/components/scUpload/file.vue new file mode 100644 index 0000000..185face --- /dev/null +++ b/resources/admin/src/components/scUpload/file.vue @@ -0,0 +1,186 @@ + + + + + diff --git a/resources/admin/src/components/scUpload/index.vue b/resources/admin/src/components/scUpload/index.vue new file mode 100644 index 0000000..b2afa4d --- /dev/null +++ b/resources/admin/src/components/scUpload/index.vue @@ -0,0 +1,383 @@ + + + + + diff --git a/resources/admin/src/config/index.js b/resources/admin/src/config/index.js new file mode 100644 index 0000000..b15316c --- /dev/null +++ b/resources/admin/src/config/index.js @@ -0,0 +1,59 @@ +export default { + APP_NAME: 'vueadmin', + DASHBOARD_URL: '/dashboard', + + // 白名单路由(不需要登录即可访问) + whiteList: ['/login', '/register', '/reset-password'], + //版本号 + APP_VER: '1.6.6', + + //内核版本号 + CORE_VER: '1.6.6', + + //接口地址 + API_URL: 'http://127.0.0.1:8000/admin/', + + //请求超时 + TIMEOUT: 50000, + + //TokenName + TOKEN_NAME: 'authorization', + + //Token前缀,注意最后有个空格,如不需要需设置空字符串 + TOKEN_PREFIX: 'Bearer ', + + //追加其他头 + HEADERS: {}, + + //请求是否开启缓存 + REQUEST_CACHE: false, + //语言 + LANG: 'zh-cn', + + DASHBOARD_LAYOUT: 'widgets', //控制台首页默认布局 + DEFAULT_GRID: { + //默认分栏数量和宽度 例如 [24] [18,6] [8,8,8] [6,12,6] + layout: [24, 12, 12], + //小组件分布,com取值:pages/home/components 文件名 + compsList: [["welcome"], ["info"], ["ver"]], + }, + + //是否加密localStorage, 为空不加密 + //支持多种加密方式: 'AES', 'BASE64', 'DES' + LS_ENCRYPTION: '', + + //localStorage加密秘钥,位数建议填写8的倍数 + LS_ENCRYPTION_key: '2XNN4K8LC0ELVWN4', + + //localStorage加密模式,AES支持: 'ECB', 'CBC', 'CTR', 'OFB', 'CFB' + LS_ENCRYPTION_mode: 'ECB', + + //localStorage加密填充方式,AES支持: 'Pkcs7', 'ZeroPadding', 'Iso10126', 'Iso97971' + LS_ENCRYPTION_padding: 'Pkcs7', + + //localStorage默认过期时间(单位:小时),0表示永不过期 + LS_DEFAULT_EXPIRE: 720, // 30天 + + //DES加密秘钥,必须是8字节 + LS_DES_key: '12345678', +} diff --git a/resources/admin/src/config/routes.js b/resources/admin/src/config/routes.js new file mode 100644 index 0000000..2139573 --- /dev/null +++ b/resources/admin/src/config/routes.js @@ -0,0 +1,7 @@ +/** + * 静态路由配置 + * 这些路由会根据用户角色进行过滤后添加到路由中 + */ +const userRoutes = [] + +export default userRoutes diff --git a/resources/admin/src/config/upload.js b/resources/admin/src/config/upload.js new file mode 100644 index 0000000..5429f34 --- /dev/null +++ b/resources/admin/src/config/upload.js @@ -0,0 +1,20 @@ +import systemApi from "@/api/system"; + +//上传配置 + +export default { + apiObj: systemApi.upload.post, //上传请求API对象 + filename: "file", //form请求时文件的key + successCode: 1, //请求完成代码 + maxSize: 10, //最大文件大小 默认10MB + parseData: function (res) { + return { + code: res.code, //分析状态字段结构 + fileName: res.data.name,//分析文件名称 + src: res.data.url, //分析图片远程地址结构 + msg: res.message //分析描述字段结构 + } + }, + apiObjFile: systemApi.upload.post, //附件上传请求API对象 + maxSizeFile: 10 //最大文件大小 默认10MB +} diff --git a/resources/admin/src/hooks/useI18n.js b/resources/admin/src/hooks/useI18n.js new file mode 100644 index 0000000..ef0f6fa --- /dev/null +++ b/resources/admin/src/hooks/useI18n.js @@ -0,0 +1,16 @@ +import { useI18n as useVueI18n } from 'vue-i18n' +import { useI18nStore } from '@/stores/modules/i18n' + +export function useI18n() { + const { t, locale, availableLocales } = useVueI18n() + const i18nStore = useI18nStore() + + return { + t, + locale, + availableLocales, + setLocale: i18nStore.setLocale, + currentLocale: i18nStore.currentLocale, + localeLabel: i18nStore.localeLabel + } +} diff --git a/resources/admin/src/hooks/useTable.js b/resources/admin/src/hooks/useTable.js new file mode 100644 index 0000000..b9850a4 --- /dev/null +++ b/resources/admin/src/hooks/useTable.js @@ -0,0 +1,248 @@ +import { ref, reactive, computed, onMounted } from 'vue' +import { message } from 'ant-design-vue' + +/** + * 表格通用hooks + * @param {Object} options 配置选项 + * @param {Function} options.api 获取列表数据的API函数,必须返回包含data和total的响应 + * @param {Object} options.searchForm 搜索表单的初始值 + * @param {Array} options.columns 表格列配置 + * @param {String} options.rowKey 行的唯一标识,默认为'id' + * @param {Boolean} options.needPagination 是否需要分页,默认为true + * @param {Object} options.paginationConfig 分页配置,可选 + * @param {Boolean} options.needSelection 是否需要行选择,默认为false + * @param {Boolean} options.immediateLoad 是否在组件挂载时自动加载数据,默认为true + * @returns {Object} 返回表格相关的状态和方法 + */ +export function useTable(options = {}) { + const { + api, + searchForm: initialSearchForm = {}, + columns = [], + rowKey = 'id', + needPagination = true, + paginationConfig = {}, + needSelection = false, + immediateLoad = true + } = options + + // 表格引用 + const tableRef = ref(null) + + // 搜索表单 + const searchForm = reactive({ ...initialSearchForm }) + + // 表格数据 + const tableData = ref([]) + + // 加载状态 + const loading = ref(false) + + // 选中的行数据 + const selectedRows = ref([]) + + // 选中的行keys + const selectedRowKeys = computed(() => selectedRows.value.map(item => item[rowKey])) + + // 分页配置 + const defaultPaginationConfig = { + current: 1, + pageSize: 20, + total: 0, + showSizeChanger: true, + showTotal: (total) => `共 ${total} 条`, + pageSizeOptions: ['20', '50', '100', '200'] + } + + const pagination = reactive({ + ...defaultPaginationConfig, + ...paginationConfig + }) + + // 行选择配置 + const rowSelection = computed(() => { + if (!needSelection) return null + return { + selectedRowKeys: selectedRowKeys.value, + onChange: (keys, rows) => { + selectedRows.value = rows + } + } + }) + + // 行选择事件处理(用于scTable的@select事件) + const handleSelectChange = (record, selected, selectedRows) => { + if (!needSelection) return + if (selected) { + selectedRows.value.push(record) + } else { + const index = selectedRows.value.findIndex(item => item[rowKey] === record[rowKey]) + if (index > -1) { + selectedRows.value.splice(index, 1) + } + } + } + + // 全选/取消全选处理(用于scTable的@selectAll事件) + const handleSelectAll = (selected, selectedRows, changeRows) => { + if (!needSelection) return + if (selected) { + changeRows.forEach(record => { + if (!selectedRows.value.find(item => item[rowKey] === record[rowKey])) { + selectedRows.value.push(record) + } + }) + } else { + changeRows.forEach(record => { + const index = selectedRows.value.findIndex(item => item[rowKey] === record[rowKey]) + if (index > -1) { + selectedRows.value.splice(index, 1) + } + }) + } + } + + // 加载数据 + const loadData = async (params = {}) => { + if (!api) { + console.warn('useTable: 未提供api函数,无法加载数据') + return + } + + loading.value = true + try { + const requestParams = { + ...searchForm, + ...params + } + + // 如果需要分页,添加分页参数 + if (needPagination) { + requestParams.page = pagination.current + requestParams.limit = pagination.pageSize + } + + // 调用API函数,确保this上下文正确 + const res = await api(requestParams) + + if (res.code === 1) { + // 如果是分页数据 + if (needPagination) { + tableData.value = res.data?.data || [] + pagination.total = res.data?.total || 0 + } else { + // 非分页数据(如树形数据) + // 确保数据是数组,如果不是数组则包装成数组 + const data = res.data + if (Array.isArray(data)) { + tableData.value = data + } else if (data && typeof data === 'object') { + // 如果返回的是对象,可能包含 list 或 items 等字段 + tableData.value = data.list || data.items || data.data || [] + } else { + tableData.value = [] + } + } + } else { + message.error(res.message || '加载数据失败') + } + } catch (error) { + console.error('加载数据失败:', error) + message.error('加载数据失败') + } finally { + loading.value = false + } + } + + // 分页变化处理 + const handlePaginationChange = ({ page, pageSize }) => { + if (!needPagination) return + pagination.current = page + pagination.pageSize = pageSize + loadData() + } + + // 搜索 + const handleSearch = () => { + if (needPagination) { + pagination.current = 1 + } + loadData() + } + + // 重置 + const handleReset = () => { + // 重置搜索表单为初始值 + Object.keys(searchForm).forEach(key => { + searchForm[key] = initialSearchForm[key] + }) + // 清空选择 + selectedRows.value = [] + // 重置分页 + if (needPagination) { + pagination.current = 1 + } + // 重新加载数据 + loadData() + } + + // 刷新表格 + const refreshTable = () => { + loadData() + } + + // 清空选择 + const clearSelection = () => { + selectedRows.value = [] + } + + // 设置选中行 + const setSelectedRows = (rows) => { + selectedRows.value = rows + } + + // 更新搜索表单 + const setSearchForm = (data) => { + Object.assign(searchForm, data) + } + + // 直接设置表格数据(用于特殊场景) + const setTableData = (data) => { + tableData.value = data + } + + // 组件挂载时自动加载数据 + if (immediateLoad) { + onMounted(() => { + loadData() + }) + } + + return { + // ref + tableRef, + // 响应式数据 + searchForm, + tableData, + loading, + pagination, + selectedRows, + selectedRowKeys, + // 配置 + columns, + rowKey, + rowSelection, + // 方法 + loadData, + handleSearch, + handleReset, + handlePaginationChange, + handleSelectChange, + handleSelectAll, + refreshTable, + clearSelection, + setSelectedRows, + setSearchForm, + setTableData + } +} diff --git a/resources/admin/src/i18n/index.js b/resources/admin/src/i18n/index.js new file mode 100644 index 0000000..686b68a --- /dev/null +++ b/resources/admin/src/i18n/index.js @@ -0,0 +1,15 @@ +import { createI18n } from 'vue-i18n' +import zh from './locales/zh-CN' +import en from './locales/en-US' + +const i18n = createI18n({ + legacy: false, + locale: 'zh-CN', + fallbackLocale: 'en-US', + messages: { + 'zh-CN': zh, + 'en-US': en + } +}) + +export default i18n diff --git a/resources/admin/src/i18n/locales/en-US.js b/resources/admin/src/i18n/locales/en-US.js new file mode 100644 index 0000000..8955705 --- /dev/null +++ b/resources/admin/src/i18n/locales/en-US.js @@ -0,0 +1,249 @@ +export default { + common: { + welcome: 'Welcome', + login: 'Login', + logout: 'Logout', + register: 'Register', + searchMenu: 'Search Menu', + searchPlaceholder: 'Please enter menu name to search', + noResults: 'No matching menus found', + searchTips: 'Keyboard Shortcuts Tips', + navigateResults: 'Use up/down arrows to navigate', + selectResult: 'Press Enter to select', + closeSearch: 'Press ESC to close', + taskCenter: 'Task Center', + totalTasks: 'Total Tasks', + pendingTasks: 'Pending', + completedTasks: 'Completed', + searchTasks: 'Search tasks...', + all: 'All', + pending: 'Pending', + completed: 'Completed', + taskTitle: 'Task Title', + enterTaskTitle: 'Please enter task title', + taskPriority: 'Task Priority', + priorityHigh: 'High', + priorityMedium: 'Medium', + priorityLow: 'Low', + confirmDelete: 'Confirm Delete', + addTask: 'Add Task', + pleaseEnterTaskTitle: 'Please enter task title', + added: 'Added', + deleted: 'Deleted', + justNow: 'Just now', + clearCache: 'Clear Cache', + confirmClearCache: 'Confirm Clear Cache', + clearCacheConfirm: 'Are you sure you want to clear all cache? This will clear local storage, session storage and cached data.', + cacheCleared: 'Cache cleared', + clearCacheFailed: 'Failed to clear cache', + messages: 'Messages', + tasks: 'Tasks', + clearAll: 'Clear All', + noMessages: 'No Messages', + noTasks: 'No Tasks', + fullscreen: 'Fullscreen', + personalCenter: 'Personal Center', + systemSettings: 'System Settings', + searchEmpty: 'Please enter search content', + searching: 'Searching: ', + cleared: 'Cleared', + languageChanged: 'Language Changed', + settingsDeveloping: 'System settings feature is under development', + logoutSuccess: 'Logout Successful', + logoutFailed: 'Logout Failed', + confirmLogout: 'Confirm Logout', + logoutConfirm: 'Are you sure you want to logout?', + username: 'Username', + password: 'Password', + confirmPassword: 'Confirm Password', + email: 'Email', + phone: 'Phone', + rememberMe: 'Remember Me', + forgotPassword: 'Forgot Password?', + submit: 'Submit', + cancel: 'Cancel', + save: 'Save', + edit: 'Edit', + delete: 'Delete', + add: 'Add', + search: 'Search', + reset: 'Reset', + confirm: 'Confirm', + back: 'Back', + next: 'Next', + previous: 'Previous', + refresh: 'Refresh', + export: 'Export', + import: 'Import', + download: 'Download', + upload: 'Upload', + view: 'View', + detail: 'Detail', + settings: 'Settings', + profile: 'Profile', + language: 'Language', + theme: 'Theme', + dark: 'Dark', + light: 'Light', + loading: 'Loading...', + noData: 'No Data', + success: 'Operation Successful', + error: 'Operation Failed', + warning: 'Warning', + info: 'Info', + confirmDelete: 'Are you sure you want to delete?', + confirmLogout: 'Are you sure you want to logout?', + addConfig: 'Add Config', + editConfig: 'Edit Config', + configCategory: 'Config Category', + configName: 'Config Name', + configTitle: 'Config Title', + configType: 'Config Type', + configValue: 'Config Value', + configTip: 'Config Tip', + typeText: 'Text', + typeTextarea: 'Textarea', + typeNumber: 'Number', + typeSwitch: 'Switch', + typeSelect: 'Select', + typeMultiselect: 'Multiselect', + typeDatetime: 'Datetime', + typeColor: 'Color', + pleaseSelect: 'Please Select', + pleaseEnter: 'Please Enter', + noConfig: 'No Config', + fetchConfigFailed: 'Failed to fetch config', + addSuccess: 'Added Successfully', + addFailed: 'Failed to Add', + editSuccess: 'Edited Successfully', + editFailed: 'Failed to Edit', + saveSuccess: 'Saved Successfully', + saveFailed: 'Failed to Save', + resetSuccess: 'Reset Successfully', + required: 'This field is required', + operation: 'Operation', + time: 'Time', + status: 'Status', + enabled: 'Enabled', + disabled: 'Disabled', + yes: 'Yes', + no: 'No', + areaManage: 'Area Management', + areaName: 'Area Name', + areaCode: 'Area Code', + areaLevel: 'Area Level', + parentArea: 'Parent Area', + province: 'Province', + city: 'City', + district: 'District', + street: 'Street', + unknown: 'Unknown', + addArea: 'Add Area', + editArea: 'Edit Area', + remark: 'Remark', + sort: 'Sort', + createTime: 'Create Time', + action: 'Action', + batchDelete: 'Batch Delete', + confirmBatchDelete: 'Confirm Batch Delete', + batchDeleteConfirm: 'Are you sure you want to delete the selected', + items: 'items?', + deleteConfirm: 'Are you sure you want to delete', + selectDataFirst: 'Please select data to operate first', + pleaseEnterNumber: 'Please enter a valid number', + exitFullScreen: 'Exit Fullscreen', + columns: 'Columns', + columnSettings: 'Column Settings', + selectAll: 'Select All', + unselectAll: 'Unselect All', + retry: 'Retry', + fetchDataFailed: 'Failed to fetch data' + }, + menu: { + dashboard: 'Dashboard', + userManagement: 'User Management', + roleManagement: 'Role Management', + permissionManagement: 'Permission Management', + systemSettings: 'System Settings', + logManagement: 'Log Management' + }, + login: { + title: 'User Login', + subtitle: 'Welcome back, please login to your account', + loginButton: 'Login', + loginSuccess: 'Login Successful', + loginFailed: 'Login Failed', + usernamePlaceholder: 'Please enter username', + passwordPlaceholder: 'Please enter password', + noAccount: "Don't have an account?", + registerNow: 'Register Now', + forgotPassword: 'Forgot Password?', + rememberMe: 'Remember Me' + }, + register: { + title: 'User Registration', + subtitle: 'Create your account and get started', + registerButton: 'Register', + registerSuccess: 'Registration Successful', + registerFailed: 'Registration Failed', + usernamePlaceholder: 'Please enter username', + emailPlaceholder: 'Please enter email address', + passwordPlaceholder: 'Please enter password', + confirmPasswordPlaceholder: 'Please enter password again', + usernameRule: 'Username length between 3 to 20 characters', + emailRule: 'Please enter a valid email address', + passwordRule: 'Password length between 6 to 20 characters', + agreeRule: 'Please agree to the user agreement', + agreeTerms: 'I have read and agree to the', + terms: 'User Agreement', + hasAccount: 'Already have an account?', + loginNow: 'Login Now' + }, + resetPassword: { + title: 'Reset Password', + subtitle: 'Reset your password via email verification code', + resetButton: 'Reset Password', + resetSuccess: 'Password reset successful', + resetFailed: 'Reset failed', + emailPlaceholder: 'Please enter email address', + codePlaceholder: 'Please enter verification code', + newPasswordPlaceholder: 'Please enter new password', + confirmPasswordPlaceholder: 'Please enter new password again', + emailRule: 'Please enter a valid email address', + codeRule: 'Verification code must be 6 characters', + passwordRule: 'Password length between 6 to 20 characters', + sendCode: 'Send Code', + codeSent: 'Verification code has been sent to your email', + resendCode: 'Resend in {seconds} seconds', + sendCodeFirst: 'Please enter email address first', + backToLogin: 'Back to Login' + }, + layout: { + toggleSidebar: 'Toggle Sidebar', + collapse: 'Collapse', + expand: 'Expand', + logout: 'Logout' + }, + table: { + total: 'Total {total} items', + selected: '{selected} items selected', + actions: 'Actions', + noData: 'No Data', + sort: 'Sort', + filter: 'Filter' + }, + pagination: { + goTo: 'Go to', + page: 'Page', + total: 'Total {total} items', + itemsPerPage: '{size} items per page' + }, + form: { + required: 'This field is required', + invalidEmail: 'Please enter a valid email address', + invalidPhone: 'Please enter a valid phone number', + passwordMismatch: 'Passwords do not match', + minLength: 'Minimum {min} characters required', + maxLength: 'Maximum {max} characters allowed' + } +} diff --git a/resources/admin/src/i18n/locales/zh-CN.js b/resources/admin/src/i18n/locales/zh-CN.js new file mode 100644 index 0000000..7f6e6ff --- /dev/null +++ b/resources/admin/src/i18n/locales/zh-CN.js @@ -0,0 +1,248 @@ +export default { + common: { + welcome: '欢迎使用', + login: '登录', + logout: '退出登录', + register: '注册', + searchMenu: '搜索菜单', + searchPlaceholder: '请输入菜单名称进行搜索', + noResults: '未找到匹配的菜单', + searchTips: '快捷键操作提示', + navigateResults: '使用上下键导航', + selectResult: '按回车键选择', + closeSearch: '按 ESC 关闭', + taskCenter: '任务中心', + totalTasks: '总任务', + pendingTasks: '待完成', + completedTasks: '已完成', + searchTasks: '搜索任务...', + all: '全部', + pending: '待完成', + completed: '已完成', + taskTitle: '任务标题', + enterTaskTitle: '请输入任务标题', + taskPriority: '任务优先级', + priorityHigh: '高', + priorityMedium: '中', + priorityLow: '低', + confirmDelete: '确认删除', + addTask: '添加任务', + pleaseEnterTaskTitle: '请输入任务标题', + added: '已添加', + deleted: '已删除', + justNow: '刚刚', + clearCache: '清除缓存', + confirmClearCache: '确认清除缓存', + clearCacheConfirm: '确定要清除所有缓存吗?这将清除本地存储、会话存储和缓存数据。', + cacheCleared: '缓存已清除', + clearCacheFailed: '清除缓存失败', + messages: '消息', + tasks: '任务', + clearAll: '清空全部', + noMessages: '暂无消息', + noTasks: '暂无任务', + fullscreen: '全屏', + personalCenter: '个人中心', + systemSettings: '系统设置', + searchEmpty: '请输入搜索内容', + searching: '正在搜索:', + cleared: '已清空', + languageChanged: '语言已切换', + settingsDeveloping: '系统设置功能开发中', + logoutSuccess: '退出成功', + logoutFailed: '退出失败', + confirmLogout: '确认退出', + logoutConfirm: '确定要退出登录吗?', + username: '用户名', + password: '密码', + confirmPassword: '确认密码', + email: '邮箱', + phone: '手机号', + rememberMe: '记住我', + forgotPassword: '忘记密码?', + submit: '提交', + cancel: '取消', + save: '保存', + edit: '编辑', + delete: '删除', + add: '添加', + search: '搜索', + reset: '重置', + confirm: '确认', + back: '返回', + next: '下一步', + previous: '上一步', + refresh: '刷新', + export: '导出', + import: '导入', + download: '下载', + upload: '上传', + view: '查看', + detail: '详情', + settings: '设置', + profile: '个人资料', + language: '语言', + theme: '主题', + dark: '暗色', + light: '亮色', + loading: '加载中...', + noData: '暂无数据', + success: '操作成功', + error: '操作失败', + warning: '警告', + info: '提示', + confirmDelete: '确定要删除吗?', + confirmLogout: '确定要退出登录吗?', + addConfig: '添加配置', + editConfig: '编辑配置', + configCategory: '配置分类', + configName: '配置名称', + configTitle: '配置标题', + configType: '配置类型', + configValue: '配置值', + configTip: '配置提示', + typeText: '文本', + typeTextarea: '文本域', + typeNumber: '数字', + typeSwitch: '开关', + typeSelect: '下拉选择', + typeMultiselect: '多选', + typeDatetime: '日期时间', + typeColor: '颜色', + pleaseSelect: '请选择', + pleaseEnter: '请输入', + noConfig: '暂无配置', + fetchConfigFailed: '获取配置失败', + addSuccess: '添加成功', + addFailed: '添加失败', + editSuccess: '编辑成功', + editFailed: '编辑失败', + saveSuccess: '保存成功', + saveFailed: '保存失败', + resetSuccess: '重置成功', + required: '此项为必填项', + operation: '操作', + time: '时间', + status: '状态', + enabled: '启用', + disabled: '禁用', + yes: '是', + no: '否', + areaManage: '地区管理', + areaName: '地区名称', + areaCode: '地区编码', + areaLevel: '地区级别', + parentArea: '上级地区', + province: '省份', + city: '城市', + district: '区县', + street: '街道', + unknown: '未知', + addArea: '添加地区', + editArea: '编辑地区', + remark: '备注', + sort: '排序', + createTime: '创建时间', + action: '操作', + batchDelete: '批量删除', + confirmBatchDelete: '确认批量删除', + batchDeleteConfirm: '确定要删除选中的', + items: '条数据吗?', + deleteConfirm: '确定要删除', + selectDataFirst: '请先选择要操作的数据', + pleaseEnterNumber: '请输入有效的数字', + exitFullScreen: '退出全屏', + columns: '列设置', + columnSettings: '列显示设置', + selectAll: '全选', + unselectAll: '取消全选', + retry: '重试' + }, + menu: { + dashboard: '仪表板', + userManagement: '用户管理', + roleManagement: '角色管理', + permissionManagement: '权限管理', + systemSettings: '系统设置', + logManagement: '日志管理' + }, + login: { + title: '用户登录', + subtitle: '欢迎回来,请登录您的账户', + loginButton: '登录', + loginSuccess: '登录成功', + loginFailed: '登录失败', + usernamePlaceholder: '请输入用户名', + passwordPlaceholder: '请输入密码', + noAccount: '还没有账户?', + registerNow: '立即注册', + forgotPassword: '忘记密码?', + rememberMe: '记住我' + }, + register: { + title: '用户注册', + subtitle: '创建您的账户,开始使用', + registerButton: '注册', + registerSuccess: '注册成功', + registerFailed: '注册失败', + usernamePlaceholder: '请输入用户名', + emailPlaceholder: '请输入邮箱地址', + passwordPlaceholder: '请输入密码', + confirmPasswordPlaceholder: '请再次输入密码', + usernameRule: '用户名长度在 3 到 20 个字符', + emailRule: '请输入正确的邮箱地址', + passwordRule: '密码长度在 6 到 20 个字符', + agreeRule: '请同意用户协议', + agreeTerms: '我已阅读并同意', + terms: '用户协议', + hasAccount: '已有账户?', + loginNow: '立即登录' + }, + resetPassword: { + title: '重置密码', + subtitle: '通过邮箱验证码重置您的密码', + resetButton: '重置密码', + resetSuccess: '密码重置成功', + resetFailed: '重置失败', + emailPlaceholder: '请输入邮箱地址', + codePlaceholder: '请输入验证码', + newPasswordPlaceholder: '请输入新密码', + confirmPasswordPlaceholder: '请再次输入新密码', + emailRule: '请输入正确的邮箱地址', + codeRule: '验证码长度为6位', + passwordRule: '密码长度在 6 到 20 个字符', + sendCode: '发送验证码', + codeSent: '验证码已发送到您的邮箱', + resendCode: '{seconds}秒后重新发送', + sendCodeFirst: '请先输入邮箱地址', + backToLogin: '返回登录' + }, + layout: { + toggleSidebar: '切换侧边栏', + collapse: '折叠', + expand: '展开', + logout: '退出登录' + }, + table: { + total: '共 {total} 条', + selected: '已选择 {selected} 项', + actions: '操作', + noData: '暂无数据', + sort: '排序', + filter: '筛选' + }, + pagination: { + goTo: '前往', + page: '页', + total: '共 {total} 条', + itemsPerPage: '每页 {size} 条' + }, + form: { + required: '此项为必填项', + invalidEmail: '请输入有效的邮箱地址', + invalidPhone: '请输入有效的手机号', + passwordMismatch: '两次输入的密码不一致', + minLength: '最少需要 {min} 个字符', + maxLength: '最多允许 {max} 个字符' + } +} diff --git a/resources/admin/src/layouts/components/breadcrumb.vue b/resources/admin/src/layouts/components/breadcrumb.vue new file mode 100644 index 0000000..f1a46ff --- /dev/null +++ b/resources/admin/src/layouts/components/breadcrumb.vue @@ -0,0 +1,55 @@ + + + diff --git a/resources/admin/src/layouts/components/navMenu.vue b/resources/admin/src/layouts/components/navMenu.vue new file mode 100644 index 0000000..798fb87 --- /dev/null +++ b/resources/admin/src/layouts/components/navMenu.vue @@ -0,0 +1,54 @@ + + + diff --git a/resources/admin/src/layouts/components/search.vue b/resources/admin/src/layouts/components/search.vue new file mode 100644 index 0000000..a64472f --- /dev/null +++ b/resources/admin/src/layouts/components/search.vue @@ -0,0 +1,302 @@ + + + + + diff --git a/resources/admin/src/layouts/components/setting.vue b/resources/admin/src/layouts/components/setting.vue new file mode 100644 index 0000000..708f01e --- /dev/null +++ b/resources/admin/src/layouts/components/setting.vue @@ -0,0 +1,350 @@ + + + + + diff --git a/resources/admin/src/layouts/components/sideMenu.vue b/resources/admin/src/layouts/components/sideMenu.vue new file mode 100644 index 0000000..bf5674e --- /dev/null +++ b/resources/admin/src/layouts/components/sideMenu.vue @@ -0,0 +1,172 @@ + + + + + diff --git a/resources/admin/src/layouts/components/tags.vue b/resources/admin/src/layouts/components/tags.vue new file mode 100644 index 0000000..86f39f1 --- /dev/null +++ b/resources/admin/src/layouts/components/tags.vue @@ -0,0 +1,487 @@ + + + + + diff --git a/resources/admin/src/layouts/components/task.vue b/resources/admin/src/layouts/components/task.vue new file mode 100644 index 0000000..c0314bc --- /dev/null +++ b/resources/admin/src/layouts/components/task.vue @@ -0,0 +1,448 @@ + + + + + diff --git a/resources/admin/src/layouts/components/userbar.vue b/resources/admin/src/layouts/components/userbar.vue new file mode 100644 index 0000000..cb2f9f2 --- /dev/null +++ b/resources/admin/src/layouts/components/userbar.vue @@ -0,0 +1,380 @@ + + + + + diff --git a/resources/admin/src/layouts/index.vue b/resources/admin/src/layouts/index.vue new file mode 100644 index 0000000..b3ec299 --- /dev/null +++ b/resources/admin/src/layouts/index.vue @@ -0,0 +1,665 @@ + + + + + diff --git a/resources/admin/src/layouts/other/404.vue b/resources/admin/src/layouts/other/404.vue new file mode 100644 index 0000000..8ea30db --- /dev/null +++ b/resources/admin/src/layouts/other/404.vue @@ -0,0 +1,228 @@ + + + + + diff --git a/resources/admin/src/layouts/other/empty.vue b/resources/admin/src/layouts/other/empty.vue new file mode 100644 index 0000000..74fe604 --- /dev/null +++ b/resources/admin/src/layouts/other/empty.vue @@ -0,0 +1,211 @@ + + + + + diff --git a/resources/admin/src/main.js b/resources/admin/src/main.js new file mode 100644 index 0000000..e70ebe6 --- /dev/null +++ b/resources/admin/src/main.js @@ -0,0 +1,20 @@ +import { createApp } from 'vue' + +import Antd from 'ant-design-vue' +import 'ant-design-vue/dist/reset.css' +import '@/assets/style/app.scss' +import App from './App.vue' +import router from './router' +import pinia from './stores' +import i18n from './i18n' +import boot from './boot' + +const app = createApp(App) + +app.use(Antd) +app.use(router) +app.use(pinia) +app.use(i18n) +app.use(boot) + +app.mount('#app') diff --git a/resources/admin/src/pages/home/iconPickerDemo.vue b/resources/admin/src/pages/home/iconPickerDemo.vue new file mode 100644 index 0000000..cf653ac --- /dev/null +++ b/resources/admin/src/pages/home/iconPickerDemo.vue @@ -0,0 +1,50 @@ + + + + + diff --git a/resources/admin/src/pages/home/index.vue b/resources/admin/src/pages/home/index.vue new file mode 100644 index 0000000..e3dd744 --- /dev/null +++ b/resources/admin/src/pages/home/index.vue @@ -0,0 +1,47 @@ + + + + + diff --git a/resources/admin/src/pages/home/widgets/components/about.vue b/resources/admin/src/pages/home/widgets/components/about.vue new file mode 100644 index 0000000..b904c7f --- /dev/null +++ b/resources/admin/src/pages/home/widgets/components/about.vue @@ -0,0 +1,27 @@ + + + + + diff --git a/resources/admin/src/pages/home/widgets/components/echarts.vue b/resources/admin/src/pages/home/widgets/components/echarts.vue new file mode 100644 index 0000000..fe47293 --- /dev/null +++ b/resources/admin/src/pages/home/widgets/components/echarts.vue @@ -0,0 +1,132 @@ + + + + + diff --git a/resources/admin/src/pages/home/widgets/components/index.js b/resources/admin/src/pages/home/widgets/components/index.js new file mode 100644 index 0000000..44be942 --- /dev/null +++ b/resources/admin/src/pages/home/widgets/components/index.js @@ -0,0 +1,8 @@ +import { markRaw } from 'vue' +const resultComps = {} +const files = import.meta.glob('./*.vue', { eager: true }) +Object.keys(files).forEach((fileName) => { + let comp = files[fileName] + resultComps[fileName.replace(/^\.\/(.*)\.\w+$/, '$1')] = comp.default +}) +export default markRaw(resultComps) diff --git a/resources/admin/src/pages/home/widgets/components/info.vue b/resources/admin/src/pages/home/widgets/components/info.vue new file mode 100644 index 0000000..f3e2602 --- /dev/null +++ b/resources/admin/src/pages/home/widgets/components/info.vue @@ -0,0 +1,63 @@ + + + + + diff --git a/resources/admin/src/pages/home/widgets/components/progress.vue b/resources/admin/src/pages/home/widgets/components/progress.vue new file mode 100644 index 0000000..90d5c88 --- /dev/null +++ b/resources/admin/src/pages/home/widgets/components/progress.vue @@ -0,0 +1,36 @@ + + + + + diff --git a/resources/admin/src/pages/home/widgets/components/sms.vue b/resources/admin/src/pages/home/widgets/components/sms.vue new file mode 100644 index 0000000..272a421 --- /dev/null +++ b/resources/admin/src/pages/home/widgets/components/sms.vue @@ -0,0 +1,93 @@ + + + + + diff --git a/resources/admin/src/pages/home/widgets/components/time.vue b/resources/admin/src/pages/home/widgets/components/time.vue new file mode 100644 index 0000000..0c0e4c3 --- /dev/null +++ b/resources/admin/src/pages/home/widgets/components/time.vue @@ -0,0 +1,72 @@ + + + + + diff --git a/resources/admin/src/pages/home/widgets/components/ver.vue b/resources/admin/src/pages/home/widgets/components/ver.vue new file mode 100644 index 0000000..34484f4 --- /dev/null +++ b/resources/admin/src/pages/home/widgets/components/ver.vue @@ -0,0 +1,69 @@ + + + + + diff --git a/resources/admin/src/pages/home/widgets/components/welcome.vue b/resources/admin/src/pages/home/widgets/components/welcome.vue new file mode 100644 index 0000000..1bf13ed --- /dev/null +++ b/resources/admin/src/pages/home/widgets/components/welcome.vue @@ -0,0 +1,97 @@ + + + + + diff --git a/resources/admin/src/pages/home/widgets/index.vue b/resources/admin/src/pages/home/widgets/index.vue new file mode 100644 index 0000000..4a84f5c --- /dev/null +++ b/resources/admin/src/pages/home/widgets/index.vue @@ -0,0 +1,505 @@ + + + + + diff --git a/resources/admin/src/pages/home/work/components/myapp.vue b/resources/admin/src/pages/home/work/components/myapp.vue new file mode 100644 index 0000000..1a941cd --- /dev/null +++ b/resources/admin/src/pages/home/work/components/myapp.vue @@ -0,0 +1,469 @@ + + + + + diff --git a/resources/admin/src/pages/home/work/index.vue b/resources/admin/src/pages/home/work/index.vue new file mode 100644 index 0000000..c1cf14a --- /dev/null +++ b/resources/admin/src/pages/home/work/index.vue @@ -0,0 +1,21 @@ + + + + + diff --git a/resources/admin/src/pages/login/index.vue b/resources/admin/src/pages/login/index.vue new file mode 100644 index 0000000..30de3cc --- /dev/null +++ b/resources/admin/src/pages/login/index.vue @@ -0,0 +1,163 @@ + + + diff --git a/resources/admin/src/pages/login/resetPassword.vue b/resources/admin/src/pages/login/resetPassword.vue new file mode 100644 index 0000000..ad33bf1 --- /dev/null +++ b/resources/admin/src/pages/login/resetPassword.vue @@ -0,0 +1,164 @@ + + + diff --git a/resources/admin/src/pages/login/userRegister.vue b/resources/admin/src/pages/login/userRegister.vue new file mode 100644 index 0000000..1c4b77d --- /dev/null +++ b/resources/admin/src/pages/login/userRegister.vue @@ -0,0 +1,161 @@ + + + diff --git a/resources/admin/src/pages/ucenter/components/BasicInfo.vue b/resources/admin/src/pages/ucenter/components/BasicInfo.vue new file mode 100644 index 0000000..157dbb4 --- /dev/null +++ b/resources/admin/src/pages/ucenter/components/BasicInfo.vue @@ -0,0 +1,105 @@ + + + + + diff --git a/resources/admin/src/pages/ucenter/components/Password.vue b/resources/admin/src/pages/ucenter/components/Password.vue new file mode 100644 index 0000000..427132c --- /dev/null +++ b/resources/admin/src/pages/ucenter/components/Password.vue @@ -0,0 +1,81 @@ + + + + + diff --git a/resources/admin/src/pages/ucenter/components/ProfileInfo.vue b/resources/admin/src/pages/ucenter/components/ProfileInfo.vue new file mode 100644 index 0000000..bd1b3dc --- /dev/null +++ b/resources/admin/src/pages/ucenter/components/ProfileInfo.vue @@ -0,0 +1,73 @@ + + + + + diff --git a/resources/admin/src/pages/ucenter/components/Security.vue b/resources/admin/src/pages/ucenter/components/Security.vue new file mode 100644 index 0000000..c953293 --- /dev/null +++ b/resources/admin/src/pages/ucenter/components/Security.vue @@ -0,0 +1,85 @@ + + + + + diff --git a/resources/admin/src/pages/ucenter/index.vue b/resources/admin/src/pages/ucenter/index.vue new file mode 100644 index 0000000..ae0dbc3 --- /dev/null +++ b/resources/admin/src/pages/ucenter/index.vue @@ -0,0 +1,210 @@ + + + + + diff --git a/resources/admin/src/router/index.js b/resources/admin/src/router/index.js new file mode 100644 index 0000000..336f34a --- /dev/null +++ b/resources/admin/src/router/index.js @@ -0,0 +1,205 @@ +import { createRouter, createWebHashHistory } from 'vue-router' +import NProgress from 'nprogress' +import 'nprogress/nprogress.css' +import config from '../config' +import { useUserStore } from '../stores/modules/user' +import systemRoutes from './systemRoutes' + +// 配置 NProgress +NProgress.configure({ + showSpinner: false, + trickleSpeed: 200, + minimum: 0.3 +}) + +/** + * 404 路由 + */ +const notFoundRoute = { + path: '/:pathMatch(.*)*', + name: 'NotFound', + component: () => import('../layouts/other/404.vue'), + meta: { + title: '404', + hidden: true + } +} + +// 创建路由实例 +const router = createRouter({ + history: createWebHashHistory(), + routes: systemRoutes +}) + +/** + * 组件导入映射 + */ +const modules = import.meta.glob('../pages/**/*.vue') + +/** + * 动态加载组件 + * @param {string} componentPath - 组件路径 + * @returns {Promise} 组件 + */ +function loadComponent(componentPath) { + // 如果组件路径以 'views/' 或 'pages/' 开头,则从相应目录加载 + if (componentPath.startsWith('views/')) { + const path = componentPath.replace('views/', '../pages/') + return modules[`${path}.vue`] + } + + // 如果是简单的组件名称,从 pages 目录加载 + return modules[`../pages/${componentPath}/index.vue`] +} + +/** + * 将后端菜单转换为路由格式 + * @param {Array} menus - 后端返回的菜单数据 + * @returns {Array} 路由数组 + */ +function transformMenusToRoutes(menus) { + if (!menus || !Array.isArray(menus)) { + return [] + } + + return menus + .filter(menu => menu && menu.path) + .map(menu => { + const route = { + path: menu.path, + name: menu.name || menu.path.replace(/\//g, '-'), + meta: { + title: menu.meta?.title || menu.title, + icon: menu.meta?.icon || menu.icon, + hidden: menu.hidden || menu.meta?.hidden, + keepAlive: menu.meta?.keepAlive || false, + affix: menu.meta?.affix || 0, + role: menu.meta?.role || [] + } + } + + // 处理组件 + if (menu.component) { + route.component = loadComponent(menu.component) + } + + // 处理子路由 + if (menu.children && menu.children.length > 0) { + route.children = transformMenusToRoutes(menu.children) + } + + // 处理重定向 + if (menu.redirect) { + route.redirect = menu.redirect + } + + return route + }) +} + +/** + * 路由守卫 + */ +let isDynamicRouteLoaded = false + +router.beforeEach(async (to, from, next) => { + // 开始进度条 + NProgress.start() + + // 设置页面标题 + document.title = to.meta.title + ? `${to.meta.title} - ${config.APP_NAME}` + : config.APP_NAME + + const userStore = useUserStore() + const isLoggedIn = userStore.isLoggedIn() + const whiteList = config.whiteList || [] + + // 1. 如果在白名单中,直接放行 + if (whiteList.includes(to.path)) { + next() + return + } + + // 2. 如果未登录,跳转到登录页 + if (!isLoggedIn) { + // 保存目标路由,登录后跳转 + next({ + path: '/login', + query: { redirect: to.fullPath } + }) + return + } + + // 3. 已登录情况 + // 如果访问登录页,重定向到首页 + if (to.path === '/login') { + next({ path: config.DASHBOARD_URL }) + return + } + + // 4. 动态路由加载 + if (!isDynamicRouteLoaded) { + try { + // 获取后端返回的用户菜单 + const mergedMenus = userStore.getMenu() + + if (mergedMenus && mergedMenus.length > 0) { + // 将合并后的菜单转换为路由 + const dynamicRoutes = transformMenusToRoutes(mergedMenus) + + // 添加动态路由到 Layout 的子路由 + dynamicRoutes.forEach(route => { + router.addRoute('Layout', route) + }) + + // 添加 404 路由(必须在最后添加) + router.addRoute(notFoundRoute) + + isDynamicRouteLoaded = true + + // 重新导航,确保新添加的路由被正确匹配 + next({ ...to, replace: true }) + } else { + // 如果没有菜单数据,重置并跳转到登录页 + userStore.logout() + next({ path: '/login', query: { redirect: to.fullPath } }) + } + } catch (error) { + console.error('动态路由加载失败:', error) + + // 加载失败,清除用户信息并跳转到登录页 + userStore.logout() + next({ + path: '/login', + query: { redirect: to.fullPath } + }) + } + } else { + // 动态路由已加载,直接放行 + next() + } +}) + +router.afterEach(() => { + // 结束进度条 + NProgress.done() +}) + +/** + * 重置路由(用于登出时) + */ +export function resetRouter() { + // 移除所有动态添加的路由 + isDynamicRouteLoaded = false + + // 重置为初始路由 + const newRouter = createRouter({ + history: createWebHashHistory(), + routes: systemRoutes + }) + + router.matcher = newRouter.matcher +} + +export default router diff --git a/resources/admin/src/router/systemRoutes.js b/resources/admin/src/router/systemRoutes.js new file mode 100644 index 0000000..8153830 --- /dev/null +++ b/resources/admin/src/router/systemRoutes.js @@ -0,0 +1,43 @@ +import config from '@/config' + +/** + * 基础路由(不需要登录) + */ +const systemRoutes = [ + { + path: '/login', + name: 'Login', + component: () => import('../pages/login/index.vue'), + meta: { + title: 'login', + hidden: true, + }, + }, + { + path: '/register', + name: 'Register', + component: () => import('../pages/login/userRegister.vue'), + meta: { + title: 'register', + hidden: true, + }, + }, + { + path: '/reset-password', + name: 'ResetPassword', + component: () => import('../pages/login/resetPassword.vue'), + meta: { + title: 'resetPassword', + hidden: true, + }, + }, + { + path: '/', + name: 'Layout', + component: () => import('@/layouts/index.vue'), + redirect: config.DASHBOARD_URL, + children: [], + }, +] + +export default systemRoutes diff --git a/resources/admin/src/stores/index.js b/resources/admin/src/stores/index.js new file mode 100644 index 0000000..bb44b70 --- /dev/null +++ b/resources/admin/src/stores/index.js @@ -0,0 +1,9 @@ +import { createPinia } from 'pinia' +import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' + +const pinia = createPinia() + +// 注册持久化插件 +pinia.use(piniaPluginPersistedstate) + +export default pinia diff --git a/resources/admin/src/stores/modules/i18n.js b/resources/admin/src/stores/modules/i18n.js new file mode 100644 index 0000000..9373b1c --- /dev/null +++ b/resources/admin/src/stores/modules/i18n.js @@ -0,0 +1,36 @@ +import { defineStore } from 'pinia' +import i18n from '@/i18n' +import { customStorage } from '../persist' + +export const useI18nStore = defineStore( + 'i18n', + { + state: () => ({ + currentLocale: 'zh-CN', + availableLocales: [ + { label: '简体中文', value: 'zh-CN' }, + { label: 'English', value: 'en-US' } + ] + }), + + getters: { + localeLabel: (state) => { + const locale = state.availableLocales.find((item) => item.value === state.currentLocale) + return locale ? locale.label : '' + } + }, + + actions: { + setLocale(locale) { + this.currentLocale = locale + i18n.global.locale.value = locale + } + }, + + persist: { + key: 'i18n-store', + storage: customStorage, + pick: ['currentLocale'] + } + } +) diff --git a/resources/admin/src/stores/modules/layout.js b/resources/admin/src/stores/modules/layout.js new file mode 100644 index 0000000..be91c8e --- /dev/null +++ b/resources/admin/src/stores/modules/layout.js @@ -0,0 +1,130 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { customStorage } from '../persist' + +export const useLayoutStore = defineStore( + 'layout', + () => { + // 布局模式:'default', 'menu', 'top' + const layoutMode = ref('default') + + // 侧边栏折叠状态 + const sidebarCollapsed = ref(false) + + // 主题颜色 + const themeColor = ref('#1890ff') + + // 显示标签栏 + const showTags = ref(true) + + // 显示面包屑 + const showBreadcrumb = ref(true) + + // 当前选中的父菜单(用于双栏布局) + const selectedParentMenu = ref(null) + + // 视图标签页(用于记录页面滚动位置) + const viewTags = ref([]) + + // 刷新标签的 key,用于触发组件刷新 + const refreshKey = ref(0) + + // 切换侧边栏折叠 + const toggleSidebar = () => { + sidebarCollapsed.value = !sidebarCollapsed.value + } + + // 设置选中的父菜单 + const setSelectedParentMenu = (menu) => { + selectedParentMenu.value = menu + } + + // 设置布局模式 + const setLayoutMode = (mode) => { + layoutMode.value = mode + } + + // 更新视图标签 + const updateViewTags = (tag) => { + const index = viewTags.value.findIndex((item) => item.fullPath === tag.fullPath) + if (index !== -1) { + viewTags.value[index] = tag + } else { + viewTags.value.push(tag) + } + } + + // 移除视图标签 + const removeViewTags = (fullPath) => { + const index = viewTags.value.findIndex((item) => item.fullPath === fullPath) + if (index !== -1) { + viewTags.value.splice(index, 1) + } + } + + // 清空视图标签 + const clearViewTags = () => { + viewTags.value = [] + } + + // 设置主题颜色 + const setThemeColor = (color) => { + themeColor.value = color + document.documentElement.style.setProperty('--primary-color', color) + } + + // 设置标签栏显示 + const setShowTags = (show) => { + showTags.value = show + document.documentElement.style.setProperty('--show-tags', show ? 'block' : 'none') + } + + // 设置面包屑显示 + const setShowBreadcrumb = (show) => { + showBreadcrumb.value = show + } + + // 刷新标签 + const refreshTag = () => { + refreshKey.value++ + } + + // 重置主题设置 + const resetTheme = () => { + themeColor.value = '#1890ff' + showTags.value = true + showBreadcrumb.value = true + document.documentElement.style.setProperty('--primary-color', '#1890ff') + document.documentElement.style.setProperty('--show-tags', 'block') + } + + return { + layoutMode, + sidebarCollapsed, + selectedParentMenu, + viewTags, + themeColor, + showTags, + showBreadcrumb, + refreshKey, + toggleSidebar, + setLayoutMode, + setSelectedParentMenu, + updateViewTags, + removeViewTags, + clearViewTags, + setThemeColor, + setShowTags, + setShowBreadcrumb, + resetTheme, + refreshTag, + } + }, + { + persist: { + key: 'layout-store', + storage: customStorage, + pick: ['layoutMode', 'sidebarCollapsed', 'themeColor', 'showTags', 'showBreadcrumb', 'viewTags'], + }, + }, +) diff --git a/resources/admin/src/stores/modules/user.js b/resources/admin/src/stores/modules/user.js new file mode 100644 index 0000000..14e5eb9 --- /dev/null +++ b/resources/admin/src/stores/modules/user.js @@ -0,0 +1,118 @@ +import { ref } from 'vue' +import { defineStore } from 'pinia' +import { resetRouter } from '../../router' +import { customStorage } from '../persist' +import userRoutes from '@/config/routes' + +export const useUserStore = defineStore( + 'user', + () => { + const token = ref('') + const refreshToken = ref('') + const userInfo = ref(null) + const menu = ref([]) + const permissions = ref([]) + + // 设置 token + function setToken(newToken) { + token.value = newToken + } + + // 设置 refresh token + function setRefreshToken(newRefreshToken) { + refreshToken.value = newRefreshToken + } + + // 设置用户信息 + function setUserInfo(info) { + userInfo.value = info + } + + // 设置菜单 + function setMenu(newMenu) { + const staticMenus = userRoutes || [] + + // 合并静态菜单和后端菜单 + // 如果后端菜单为空,只使用静态菜单 + // 如果后端菜单不为空,合并两个菜单,后端菜单优先 + let mergedMenus = [...staticMenus] + + if (newMenu && newMenu.length > 0) { + // 创建菜单映射,用于去重(以路径为唯一标识) + const menuMap = new Map() + + // 先添加静态菜单 + staticMenus.forEach(menu => { + if (menu.path) { + menuMap.set(menu.path, menu) + } + }) + + // 添加后端菜单,如果路径重复则覆盖 + newMenu.forEach(menu => { + if (menu.path) { + menuMap.set(menu.path, menu) + } + }) + + // 转换为数组 + mergedMenus = Array.from(menuMap.values()) + } + menu.value = mergedMenus + } + + // 获取菜单 + function getMenu() { + return menu.value + } + + // 清除菜单 + function clearMenu() { + menu.value = [] + } + + // 设置权限 + function setPermissions(data){ + permissions.value = data + } + + // 登出 + function logout() { + token.value = '' + refreshToken.value = '' + userInfo.value = null + menu.value = [] + + // 重置路由 + resetRouter() + } + + // 检查是否已登录 + function isLoggedIn() { + return !!token.value + } + + return { + token, + refreshToken, + userInfo, + menu, + setToken, + setRefreshToken, + setUserInfo, + setMenu, + getMenu, + clearMenu, + setPermissions, + logout, + isLoggedIn, + } + }, + { + persist: { + key: 'user-store', + storage: customStorage, + pick: ['token', 'refreshToken', 'userInfo', 'menu'] + } + } +) diff --git a/resources/admin/src/stores/persist.js b/resources/admin/src/stores/persist.js new file mode 100644 index 0000000..429b287 --- /dev/null +++ b/resources/admin/src/stores/persist.js @@ -0,0 +1,50 @@ +/* + * @Descripttion: Pinia 持久化存储适配器 - 使用 tool.data 封装的 localStorage + * @version: 1.0 + */ + +import tool from '@/utils/tool' + +/** + * 自定义存储适配器 + * 使用 tool.data 的 set/get/remove 方法,支持加密和过期时间 + */ +export const customStorage = { + /** + * 获取数据 + * @param {string} key - 存储键 + * @returns {any} - 存储的数据 + */ + getItem: (key) => { + return tool.data.get(key) + }, + + /** + * 设置数据 + * @param {string} key - 存储键 + * @param {any} value - 要存储的值 + */ + setItem: (key, value) => { + tool.data.set(key, value) + }, + + /** + * 删除数据 + * @param {string} key - 存储键 + */ + removeItem: (key) => { + tool.data.remove(key) + } +} + +/** + * 默认持久化配置 + */ +export const defaultPersistConfig = { + storage: customStorage, + // 可以在这里添加其他全局配置,如过期时间等 + // serializer: { + // serialize: (state) => JSON.stringify(state), + // deserialize: (value) => JSON.parse(value) + // } +} diff --git a/resources/admin/src/style.css b/resources/admin/src/style.css new file mode 100644 index 0000000..f691315 --- /dev/null +++ b/resources/admin/src/style.css @@ -0,0 +1,79 @@ +:root { + font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} +a:hover { + color: #535bf2; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} +button:hover { + border-color: #646cff; +} +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +.card { + padding: 2em; +} + +#app { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + a:hover { + color: #747bff; + } + button { + background-color: #f9f9f9; + } +} diff --git a/resources/admin/src/utils/request.js b/resources/admin/src/utils/request.js new file mode 100644 index 0000000..3e3f9b9 --- /dev/null +++ b/resources/admin/src/utils/request.js @@ -0,0 +1,140 @@ +import axios from "axios"; +import config from "@/config"; +import { useUserStore } from "@/stores/modules/user"; +import { message } from "ant-design-vue"; +import router from "@/router"; + +const request = axios.create({ + timeout: 30000, + baseURL: config.API_URL, +}); + +// 是否正在刷新 token +let isRefreshing = false; +// 存储待重试的请求 +let requests = []; + +// 请求拦截器 +request.interceptors.request.use( + (config) => { + const userStore = useUserStore(); + const token = userStore.token; + + // 如果有 token,添加到请求头 + if (token) { + config.headers["Authorization"] = `Bearer ${token}`; + } + + return config; + }, + (error) => { + return Promise.reject(error); + }, +); + +// 响应拦截器 +request.interceptors.response.use( + (response) => { + // 根据后端返回的数据结构进行处理 + // 后端返回格式为 { code, message, data } + const { code, data, message: msg } = response.data; + + // 请求成功 + if (code === 200 || code === 1) { + return { code, data, message: msg }; + } + + // 其他错误码处理 + message.error(msg || "请求失败"); + return Promise.reject(new Error(msg || "请求失败")); + }, + async (error) => { + const userStore = useUserStore(); + const { response } = error; + + // 无响应(网络错误、超时等) + if (!response) { + message.error("网络错误,请检查网络连接"); + return Promise.reject(error); + } + + const { status, data } = response; + + // 401 未授权 - token 过期或无效 + if (status === 401) { + // 如果正在刷新 token,将请求加入队列 + if (isRefreshing) { + return new Promise((resolve) => { + requests.push((token) => { + // 重新设置请求头 + error.config.headers["Authorization"] = + `Bearer ${token}`; + resolve(http(error.config)); + }); + }); + } + + // 标记正在刷新 + isRefreshing = true; + + try { + // 尝试刷新 token + const newToken = await refreshToken(); + + // 刷新成功,更新 token + userStore.setToken(newToken); + + // 执行队列中的所有请求 + requests.forEach((callback) => callback(newToken)); + requests = []; + + // 重新执行当前请求 + error.config.headers["Authorization"] = `Bearer ${newToken}`; + return request(error.config); + } catch (refreshError) { + // 刷新失败,清空队列并跳转登录页 + requests = []; + userStore.logout(); + router.push("/login"); + message.error("登录已过期,请重新登录"); + return Promise.reject(refreshError); + } finally { + isRefreshing = false; + } + } + + // 403 禁止访问 + if (status === 403) { + message.error("没有权限访问该资源"); + return Promise.reject(error); + } + + // 404 资源不存在 + if (status === 404) { + message.error("请求的资源不存在"); + return Promise.reject(error); + } + + // 500 服务器错误 + if (status >= 500) { + message.error("服务器错误,请稍后重试"); + return Promise.reject(error); + } + + // 其他错误 + const errorMessage = data?.message || error.message || "请求失败"; + message.error(errorMessage); + return Promise.reject(error); + }, +); + +// 刷新 token 的方法 +async function refreshToken() { + // 刷新接口需要携带当前token在请求头中 + const response = await request.post('auth/refresh'); + + // 返回格式为 { code, data: { token } } + return response.data.token; +} + +export default request; diff --git a/resources/admin/src/utils/tool.js b/resources/admin/src/utils/tool.js new file mode 100644 index 0000000..1d6bf32 --- /dev/null +++ b/resources/admin/src/utils/tool.js @@ -0,0 +1,499 @@ +/* + * @Descripttion: 工具集 + * @version: 2.0 + * @LastEditors: sakuya + * @LastEditTime: 2026年1月15日 + */ + +import CryptoJS from "crypto-js"; +import sysConfig from "@/config"; + +const tool = {}; + +/** + * 检查是否为有效的值(非null、非undefined、非空字符串、非空数组、非空对象) + * @param {*} value - 要检查的值 + * @returns {boolean} + */ +tool.isValid = function (value) { + if (value === null || value === undefined) { + return false; + } + if (typeof value === "string" && value.trim() === "") { + return false; + } + if (Array.isArray(value) && value.length === 0) { + return false; + } + if (typeof value === "object" && Object.keys(value).length === 0) { + return false; + } + return true; +}; + +/** + * 防抖函数 + * @param {Function} func - 要执行的函数 + * @param {number} wait - 等待时间(毫秒) + * @param {boolean} immediate - 是否立即执行 + * @returns {Function} + */ +tool.debounce = function (func, wait = 300, immediate = false) { + let timeout; + return function (...args) { + const context = this; + clearTimeout(timeout); + if (immediate && !timeout) { + func.apply(context, args); + } + timeout = setTimeout(() => { + timeout = null; + if (!immediate) { + func.apply(context, args); + } + }, wait); + }; +}; + +/** + * 节流函数 + * @param {Function} func - 要执行的函数 + * @param {number} wait - 等待时间(毫秒) + * @param {Object} options - 配置选项 { leading: boolean, trailing: boolean } + * @returns {Function} + */ +tool.throttle = function (func, wait = 300, options = {}) { + let timeout; + let previous = 0; + const { leading = true, trailing = true } = options; + + return function (...args) { + const context = this; + const now = Date.now(); + + if (!previous && !leading) { + previous = now; + } + + const remaining = wait - (now - previous); + + if (remaining <= 0 || remaining > wait) { + if (timeout) { + clearTimeout(timeout); + timeout = null; + } + previous = now; + func.apply(context, args); + } else if (!timeout && trailing) { + timeout = setTimeout(() => { + previous = leading ? Date.now() : 0; + timeout = null; + func.apply(context, args); + }, remaining); + } + }; +}; + +/** + * 深拷贝对象(支持循环引用) + * @param {*} obj - 要拷贝的对象 + * @param {WeakMap} hash - 用于检测循环引用 + * @returns {*} + */ +tool.deepClone = function (obj, hash = new WeakMap()) { + if (obj === null || typeof obj !== "object") { + return obj; + } + + if (hash.has(obj)) { + return hash.get(obj); + } + + const clone = Array.isArray(obj) ? [] : {}; + hash.set(obj, clone); + + for (const key in obj) { + if (Object.prototype.hasOwnProperty.call(obj, key)) { + clone[key] = tool.deepClone(obj[key], hash); + } + } + + return clone; +}; + +/* localStorage */ +tool.data = { + set(key, data, datetime = 0) { + //加密 + if (sysConfig.LS_ENCRYPTION == "AES") { + data = tool.crypto.AES.encrypt( + JSON.stringify(data), + sysConfig.LS_ENCRYPTION_key, + ); + } + let cacheValue = { + content: data, + datetime: + parseInt(datetime) === 0 + ? 0 + : new Date().getTime() + parseInt(datetime) * 1000, + }; + return localStorage.setItem(key, JSON.stringify(cacheValue)); + }, + get(key) { + try { + const value = JSON.parse(localStorage.getItem(key)); + if (value) { + let nowTime = new Date().getTime(); + if (nowTime > value.datetime && value.datetime != 0) { + localStorage.removeItem(key); + return null; + } + //解密 + if (sysConfig.LS_ENCRYPTION == "AES") { + value.content = JSON.parse( + tool.crypto.AES.decrypt( + value.content, + sysConfig.LS_ENCRYPTION_key, + ), + ); + } + return value.content; + } + return null; + } catch { + return null; + } + }, + remove(key) { + return localStorage.removeItem(key); + }, + clear() { + return localStorage.clear(); + }, +}; + +/*sessionStorage*/ +tool.session = { + set(table, settings) { + const _set = JSON.stringify(settings); + return sessionStorage.setItem(table, _set); + }, + get(table) { + const data = sessionStorage.getItem(table); + try { + return JSON.parse(data); + } catch { + return null; + } + }, + remove(table) { + return sessionStorage.removeItem(table); + }, + clear() { + return sessionStorage.clear(); + }, +}; + +/*cookie*/ +tool.cookie = { + /** + * 设置cookie + * @param {string} name - cookie名称 + * @param {string} value - cookie值 + * @param {Object} config - 配置选项 + */ + set(name, value, config = {}) { + const cfg = { + expires: null, + path: null, + domain: null, + secure: false, + httpOnly: false, + sameSite: "Lax", + ...config, + }; + let cookieStr = `${name}=${encodeURIComponent(value)}`; + if (cfg.expires) { + const exp = new Date(); + exp.setTime(exp.getTime() + parseInt(cfg.expires) * 1000); + cookieStr += `;expires=${exp.toUTCString()}`; + } + if (cfg.path) { + cookieStr += `;path=${cfg.path}`; + } + if (cfg.domain) { + cookieStr += `;domain=${cfg.domain}`; + } + if (cfg.secure) { + cookieStr += `;secure`; + } + if (cfg.sameSite) { + cookieStr += `;SameSite=${cfg.sameSite}`; + } + document.cookie = cookieStr; + }, + /** + * 获取cookie + * @param {string} name - cookie名称 + * @returns {string|null} + */ + get(name) { + const arr = document.cookie.match( + new RegExp("(^| )" + name + "=([^;]*)(;|$)"), + ); + if (arr != null) { + return decodeURIComponent(arr[2]); + } + return null; + }, + /** + * 删除cookie + * @param {string} name - cookie名称 + */ + remove(name) { + const exp = new Date(); + exp.setTime(exp.getTime() - 1); + document.cookie = `${name}=;expires=${exp.toUTCString()}`; + }, +}; + +/* Fullscreen */ +/** + * 切换全屏状态 + * @param {HTMLElement} element - 要全屏的元素 + */ +tool.screen = function (element) { + const isFull = !!( + document.webkitIsFullScreen || + document.mozFullScreen || + document.msFullscreenElement || + document.fullscreenElement + ); + if (isFull) { + if (document.exitFullscreen) { + document.exitFullscreen(); + } else if (document.msExitFullscreen) { + document.msExitFullscreen(); + } else if (document.mozCancelFullScreen) { + document.mozCancelFullScreen(); + } else if (document.webkitExitFullscreen) { + document.webkitExitFullscreen(); + } + } else { + if (element.requestFullscreen) { + element.requestFullscreen(); + } else if (element.msRequestFullscreen) { + element.msRequestFullscreen(); + } else if (element.mozRequestFullScreen) { + element.mozRequestFullScreen(); + } else if (element.webkitRequestFullscreen) { + element.webkitRequestFullscreen(); + } + } +}; + +/* 复制对象(浅拷贝) */ +/** + * 浅拷贝对象 + * @param {*} obj - 要拷贝的对象 + * @returns {*} - 拷贝后的对象 + */ +tool.objCopy = function (obj) { + if (obj === null || typeof obj !== "object") { + return obj; + } + return JSON.parse(JSON.stringify(obj)); +}; + +/* 日期格式化 */ +/** + * 格式化日期 + * @param {Date|string|number} date - 日期对象、时间戳或日期字符串 + * @param {string} fmt - 格式化字符串,默认 "yyyy-MM-dd hh:mm:ss" + * @returns {string} - 格式化后的日期字符串 + */ +tool.dateFormat = function (date, fmt = "yyyy-MM-dd hh:mm:ss") { + if (!date) return ""; + const dateObj = new Date(date); + if (isNaN(dateObj.getTime())) return ""; + + const o = { + "M+": dateObj.getMonth() + 1, // 月份 + "d+": dateObj.getDate(), // 日 + "h+": dateObj.getHours(), // 小时 + "m+": dateObj.getMinutes(), // 分 + "s+": dateObj.getSeconds(), // 秒 + "q+": Math.floor((dateObj.getMonth() + 3) / 3), // 季度 + S: dateObj.getMilliseconds(), // 毫秒 + }; + if (/(y+)/.test(fmt)) { + fmt = fmt.replace( + RegExp.$1, + (dateObj.getFullYear() + "").substr(4 - RegExp.$1.length), + ); + } + for (const k in o) { + if (new RegExp("(" + k + ")").test(fmt)) { + fmt = fmt.replace( + RegExp.$1, + RegExp.$1.length == 1 + ? o[k] + : ("00" + o[k]).substr(("" + o[k]).length), + ); + } + } + return fmt; +}; + +/* 千分符 */ +/** + * 格式化数字,添加千分位分隔符 + * @param {number|string} num - 要格式化的数字 + * @param {number} decimals - 保留小数位数,默认为0 + * @returns {string} - 格式化后的字符串 + */ +tool.groupSeparator = function (num, decimals = 0) { + if (num === null || num === undefined || num === "") return ""; + const numStr = Number(num).toFixed(decimals); + const parts = numStr.split("."); + parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ","); + return parts.join("."); +}; + +/* 常用加解密 */ +tool.crypto = { + //MD5加密 + MD5(data) { + return CryptoJS.MD5(data).toString(); + }, + //BASE64加解密 + BASE64: { + encrypt(data) { + return CryptoJS.enc.Base64.stringify(CryptoJS.enc.Utf8.parse(data)); + }, + decrypt(cipher) { + return CryptoJS.enc.Base64.parse(cipher).toString( + CryptoJS.enc.Utf8, + ); + }, + }, + //AES加解密 + AES: { + encrypt(data, secretKey, config = {}) { + if (secretKey.length % 8 != 0) { + console.warn( + "[SCUI error]: 秘钥长度需为8的倍数,否则解密将会失败。", + ); + } + const result = CryptoJS.AES.encrypt( + data, + CryptoJS.enc.Utf8.parse(secretKey), + { + iv: CryptoJS.enc.Utf8.parse(config.iv || ""), + mode: CryptoJS.mode[config.mode || "ECB"], + padding: CryptoJS.pad[config.padding || "Pkcs7"], + }, + ); + return result.toString(); + }, + decrypt(cipher, secretKey, config = {}) { + const result = CryptoJS.AES.decrypt( + cipher, + CryptoJS.enc.Utf8.parse(secretKey), + { + iv: CryptoJS.enc.Utf8.parse(config.iv || ""), + mode: CryptoJS.mode[config.mode || "ECB"], + padding: CryptoJS.pad[config.padding || "Pkcs7"], + }, + ); + return CryptoJS.enc.Utf8.stringify(result); + }, + }, +}; + +/* 树形数据转扁平数组 */ +/** + * 将树形结构转换为扁平数组 + * @param {Array} tree - 树形数组 + * @param {Object} config - 配置项 { children: "children" } + * @returns {Array} - 扁平化后的数组 + */ +tool.treeToList = function (tree, config = { children: "children" }) { + const result = []; + tree.forEach((item) => { + const tmp = { ...item }; + const childrenKey = config.children || "children"; + + if (tmp[childrenKey] && tmp[childrenKey].length > 0) { + result.push({ ...item }); + const childrenRoutes = tool.treeToList(tmp[childrenKey], config); + result.push(...childrenRoutes); + } else { + result.push(tmp); + } + }); + return result; +}; + +/* 获取父节点数据(保留原有函数名) */ +/** + * 根据ID获取父节点数据 + * @param {Array} list - 数据列表 + * @param {number|string} targetId - 目标ID + * @param {Object} config - 配置项 { pid: "parent_id", idField: "id", field: [] } + * @returns {*} - 父节点数据或指定字段 + */ +tool.get_parents = function ( + list, + targetId = 0, + config = { pid: "parent_id", idField: "id", field: [] }, +) { + let res = null; + list.forEach((item) => { + if (item[config.idField || "id"] === targetId) { + if (config.field && config.field.length > 1) { + res = {}; + config.field.forEach((field) => { + res[field] = item[field]; + }); + } else if (config.field && config.field.length === 1) { + res = item[config.field[0]]; + } else { + res = item; + } + } + }); + return res; +}; + +/* 获取数据字段 */ +/** + * 从数据对象中提取指定字段 + * @param {Object} data - 数据对象 + * @param {Array} fields - 字段名数组 + * @returns {*} - 提取的字段数据 + */ +tool.getDataField = function (data, fields = []) { + if (!data || typeof data !== "object") { + return data; + } + if (fields.length === 0) { + return data; + } + if (fields.length === 1) { + return data[fields[0]]; + } else { + const result = {}; + fields.forEach((field) => { + result[field] = data[field]; + }); + return result; + } +}; + +// 兼容旧函数名 +tool.tree_to_list = tool.treeToList; +tool.get_data_field = tool.getDataField; + +export default tool; diff --git a/resources/admin/src/utils/websocket.js b/resources/admin/src/utils/websocket.js new file mode 100644 index 0000000..f0cbc7b --- /dev/null +++ b/resources/admin/src/utils/websocket.js @@ -0,0 +1,257 @@ +/** + * WebSocket Client Helper + * + * Provides a simple interface for WebSocket connections + */ + +class WebSocketClient { + constructor(url, options = {}) { + this.url = url + this.ws = null + this.reconnectAttempts = 0 + this.maxReconnectAttempts = options.maxReconnectAttempts || 5 + this.reconnectInterval = options.reconnectInterval || 3000 + this.reconnectDelay = options.reconnectDelay || 1000 + this.heartbeatInterval = options.heartbeatInterval || 30000 + this.heartbeatTimer = null + this.isManualClose = false + this.isConnecting = false + + // Event handlers + this.onOpen = options.onOpen || null + this.onMessage = options.onMessage || null + this.onError = options.onError || null + this.onClose = options.onClose || null + + // Message handlers + this.messageHandlers = new Map() + } + + /** + * Connect to WebSocket server + */ + connect() { + if (this.isConnecting || (this.ws && this.ws.readyState === WebSocket.OPEN)) { + return + } + + this.isConnecting = true + this.isManualClose = false + + try { + this.ws = new WebSocket(this.url) + + this.ws.onopen = (event) => { + console.log('WebSocket connected', event) + this.isConnecting = false + this.reconnectAttempts = 0 + + // Start heartbeat + this.startHeartbeat() + + // Call onOpen handler + if (this.onOpen) { + this.onOpen(event) + } + } + + this.ws.onmessage = (event) => { + try { + const message = JSON.parse(event.data) + console.log('WebSocket message received', message) + + // Handle different message types + this.handleMessage(message) + + // Call onMessage handler + if (this.onMessage) { + this.onMessage(message, event) + } + } catch (error) { + console.error('Failed to parse WebSocket message', error) + } + } + + this.ws.onerror = (error) => { + console.error('WebSocket error', error) + this.isConnecting = false + + // Stop heartbeat + this.stopHeartbeat() + + // Call onError handler + if (this.onError) { + this.onError(error) + } + } + + this.ws.onclose = (event) => { + console.log('WebSocket closed', event) + this.isConnecting = false + + // Stop heartbeat + this.stopHeartbeat() + + // Call onClose handler + if (this.onClose) { + this.onClose(event) + } + + // Attempt to reconnect if not manually closed + if (!this.isManualClose && this.reconnectAttempts < this.maxReconnectAttempts) { + this.reconnect() + } + } + } catch (error) { + console.error('Failed to create WebSocket connection', error) + this.isConnecting = false + + // Call onError handler + if (this.onError) { + this.onError(error) + } + } + } + + /** + * Reconnect to WebSocket server + */ + reconnect() { + if (this.isConnecting || this.reconnectAttempts >= this.maxReconnectAttempts) { + console.log('Max reconnection attempts reached') + return + } + + this.reconnectAttempts++ + const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1) + + console.log(`Reconnecting attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts} in ${delay}ms`) + + setTimeout(() => { + this.connect() + }, delay) + } + + /** + * Disconnect from WebSocket server + */ + disconnect() { + this.isManualClose = true + this.stopHeartbeat() + + if (this.ws) { + this.ws.close() + this.ws = null + } + } + + /** + * Send message to server + */ + send(type, data = {}) { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + const message = JSON.stringify({ + type, + data + }) + this.ws.send(message) + console.log('WebSocket message sent', { type, data }) + } else { + console.warn('WebSocket is not connected') + } + } + + /** + * Handle incoming messages + */ + handleMessage(message) { + const { type, data } = message + + // Get handler for this message type + const handler = this.messageHandlers.get(type) + if (handler) { + handler(data) + } + } + + /** + * Register message handler + */ + on(messageType, handler) { + this.messageHandlers.set(messageType, handler) + } + + /** + * Unregister message handler + */ + off(messageType) { + this.messageHandlers.delete(messageType) + } + + /** + * Start heartbeat + */ + startHeartbeat() { + this.stopHeartbeat() + this.heartbeatTimer = setInterval(() => { + this.send('heartbeat', { timestamp: Date.now() }) + }, this.heartbeatInterval) + } + + /** + * Stop heartbeat + */ + stopHeartbeat() { + if (this.heartbeatTimer) { + clearInterval(this.heartbeatTimer) + this.heartbeatTimer = null + } + } + + /** + * Get connection state + */ + get readyState() { + if (!this.ws) return WebSocket.CLOSED + return this.ws.readyState + } + + /** + * Check if connected + */ + get isConnected() { + return this.ws && this.ws.readyState === WebSocket.OPEN + } +} + +/** + * Create WebSocket connection + */ +export function createWebSocket(userId, token, options = {}) { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + const host = window.location.host + const url = `${protocol}//${host}/ws?user_id=${userId}&token=${token}` + + return new WebSocketClient(url, options) +} + +/** + * WebSocket singleton instance + */ +let wsClient = null + +export function getWebSocket(userId, token, options = {}) { + if (!wsClient || !wsClient.isConnected) { + wsClient = createWebSocket(userId, token, options) + } + return wsClient +} + +export function closeWebSocket() { + if (wsClient) { + wsClient.disconnect() + wsClient = null + } +} + +export default WebSocketClient diff --git a/resources/admin/vite.config.js b/resources/admin/vite.config.js new file mode 100644 index 0000000..f7317b4 --- /dev/null +++ b/resources/admin/vite.config.js @@ -0,0 +1,18 @@ +import { fileURLToPath, URL } from 'node:url' + +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import vueDevTools from 'vite-plugin-vue-devtools' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [ + vue(), + vueDevTools(), + ], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)) + }, + }, +}) diff --git a/resources/views/welcome.blade.php b/resources/views/welcome.blade.php new file mode 100644 index 0000000..23a6be2 --- /dev/null +++ b/resources/views/welcome.blade.php @@ -0,0 +1,426 @@ + + + + + + + Laravel-S - 高性能后端 API 系统 + + + +
+ +
+ +
+
+

高性能后端 API 系统

+

基于 Laravel + Laravel-S + Swoole 构建的现代化后端 API 系统,提供卓越的性能和开发效率

+ +
+
+ +
+
+

核心特性

+
+
+
+

高性能

+

基于 Swoole 协程框架,提供卓越的并发处理能力,轻松应对高并发场景

+
+
+
🧩
+

模块化

+

采用 Laravel Modules 实现模块化架构,业务模块独立管理,易于扩展和维护

+
+
+
🔒
+

安全可靠

+

JWT 认证、RBAC 权限控制、数据验证等多重安全防护机制

+
+
+
📊
+

完整后台

+

基于 Vue3 + Ant Design Vue 构建的现代化后台管理系统

+
+
+
🔄
+

热重载

+

开发环境支持文件监控热重载,提升开发体验

+
+
+
📝
+

RESTful API

+

遵循 RESTful 规范的 API 设计,统一的响应格式

+
+
+
+
+ +
+
+

技术栈

+
+
+
🐘
+

PHP

+
+
+
🔷
+

Laravel

+
+
+
🚀
+

Swoole

+
+
+
+

Laravel-S

+
+
+
🔑
+

JWT-Auth

+
+
+
📦
+

Laravel Modules

+
+
+
💚
+

MySQL

+
+
+
🔴
+

Redis

+
+
+
+
+ +
+
+

系统架构

+
+
+

模块化设计

+

项目采用清晰的分层架构,将业务逻辑合理划分:

+
    +
  • 基础模块(Auth、System):不使用 Laravel Modules 扩展
  • +
  • 业务模块:使用 Laravel Modules 独立管理
  • +
  • Controller 层:处理 HTTP 请求
  • +
  • Service 层:业务逻辑处理
  • +
  • Model 层:数据模型定义
  • +
  • 统一的 API 响应格式
  • +
+
+
+
+

快速开始

+
# 安装依赖
+composer install
+
+# 配置环境
+cp .env.example .env
+
+# 执行迁移
+php artisan migrate
+
+# 启动 Laravel-S
+php bin/laravels start
+
+# 访问后台
+# http://localhost:8000/admin
+
+
+
+
+
+ +
+
+

© 2024 Laravel-S. Built with ❤️ using Laravel & Swoole

+
+
+ + diff --git a/routes/admin.php b/routes/admin.php new file mode 100644 index 0000000..86f52f0 --- /dev/null +++ b/routes/admin.php @@ -0,0 +1,188 @@ +group(function () { + // 认证相关 + Route::prefix('auth')->group(function () { + Route::post('/logout', [\App\Http\Controllers\Auth\Admin\Auth::class, 'logout']); + Route::post('/refresh', [\App\Http\Controllers\Auth\Admin\Auth::class, 'refresh']); + Route::get('/me', [\App\Http\Controllers\Auth\Admin\Auth::class, 'me']); + Route::post('/change-password', [\App\Http\Controllers\Auth\Admin\Auth::class, 'changePassword']); + }); + + // 用户管理 + Route::prefix('users')->group(function () { + Route::get('/', [\App\Http\Controllers\Auth\Admin\User::class, 'index']); + Route::get('/{id}', [\App\Http\Controllers\Auth\Admin\User::class, 'show']); + Route::post('/', [\App\Http\Controllers\Auth\Admin\User::class, 'store']); + Route::put('/{id}', [\App\Http\Controllers\Auth\Admin\User::class, 'update']); + Route::delete('/{id}', [\App\Http\Controllers\Auth\Admin\User::class, 'destroy']); + Route::post('/batch-delete', [\App\Http\Controllers\Auth\Admin\User::class, 'batchDelete']); + Route::post('/batch-status', [\App\Http\Controllers\Auth\Admin\User::class, 'batchUpdateStatus']); + Route::post('/batch-department', [\App\Http\Controllers\Auth\Admin\User::class, 'batchAssignDepartment']); + Route::post('/batch-roles', [\App\Http\Controllers\Auth\Admin\User::class, 'batchAssignRoles']); + Route::post('/export', [\App\Http\Controllers\Auth\Admin\User::class, 'export']); + Route::post('/import', [\App\Http\Controllers\Auth\Admin\User::class, 'import']); + Route::get('/download-template', [\App\Http\Controllers\Auth\Admin\User::class, 'downloadTemplate']); + }); + + // 角色管理 + Route::prefix('roles')->group(function () { + Route::get('/', [\App\Http\Controllers\Auth\Admin\Role::class, 'index']); + Route::get('/all', [\App\Http\Controllers\Auth\Admin\Role::class, 'getAll']); + Route::get('/{id}', [\App\Http\Controllers\Auth\Admin\Role::class, 'show']); + Route::post('/', [\App\Http\Controllers\Auth\Admin\Role::class, 'store']); + Route::put('/{id}', [\App\Http\Controllers\Auth\Admin\Role::class, 'update']); + Route::delete('/{id}', [\App\Http\Controllers\Auth\Admin\Role::class, 'destroy']); + Route::post('/batch-delete', [\App\Http\Controllers\Auth\Admin\Role::class, 'batchDelete']); + Route::post('/batch-status', [\App\Http\Controllers\Auth\Admin\Role::class, 'batchUpdateStatus']); + Route::post('/{id}/permissions', [\App\Http\Controllers\Auth\Admin\Role::class, 'assignPermissions']); + Route::get('/{id}/permissions', [\App\Http\Controllers\Auth\Admin\Role::class, 'getPermissions']); + Route::post('/{id}/copy', [\App\Http\Controllers\Auth\Admin\Role::class, 'copy']); + Route::post('/batch-copy', [\App\Http\Controllers\Auth\Admin\Role::class, 'batchCopy']); + }); + + // 权限管理 + Route::prefix('permissions')->group(function () { + Route::get('/', [\App\Http\Controllers\Auth\Admin\Permission::class, 'index']); + Route::get('/tree', [\App\Http\Controllers\Auth\Admin\Permission::class, 'tree']); + Route::get('/menu', [\App\Http\Controllers\Auth\Admin\Permission::class, 'menu']); + Route::get('/{id}', [\App\Http\Controllers\Auth\Admin\Permission::class, 'show']); + Route::post('/', [\App\Http\Controllers\Auth\Admin\Permission::class, 'store']); + Route::put('/{id}', [\App\Http\Controllers\Auth\Admin\Permission::class, 'update']); + Route::delete('/{id}', [\App\Http\Controllers\Auth\Admin\Permission::class, 'destroy']); + Route::post('/batch-delete', [\App\Http\Controllers\Auth\Admin\Permission::class, 'batchDelete']); + Route::post('/batch-status', [\App\Http\Controllers\Auth\Admin\Permission::class, 'batchUpdateStatus']); + }); + + // 部门管理 + Route::prefix('departments')->group(function () { + Route::get('/', [\App\Http\Controllers\Auth\Admin\Department::class, 'index']); + Route::get('/tree', [\App\Http\Controllers\Auth\Admin\Department::class, 'tree']); + Route::get('/all', [\App\Http\Controllers\Auth\Admin\Department::class, 'getAll']); + Route::get('/{id}', [\App\Http\Controllers\Auth\Admin\Department::class, 'show']); + Route::post('/', [\App\Http\Controllers\Auth\Admin\Department::class, 'store']); + Route::put('/{id}', [\App\Http\Controllers\Auth\Admin\Department::class, 'update']); + Route::delete('/{id}', [\App\Http\Controllers\Auth\Admin\Department::class, 'destroy']); + Route::post('/batch-delete', [\App\Http\Controllers\Auth\Admin\Department::class, 'batchDelete']); + Route::post('/batch-status', [\App\Http\Controllers\Auth\Admin\Department::class, 'batchUpdateStatus']); + Route::post('/export', [\App\Http\Controllers\Auth\Admin\Department::class, 'export']); + Route::post('/import', [\App\Http\Controllers\Auth\Admin\Department::class, 'import']); + Route::get('/download-template', [\App\Http\Controllers\Auth\Admin\Department::class, 'downloadTemplate']); + }); + + // 在线用户管理 + Route::prefix('online-users')->group(function () { + Route::get('/count', [\App\Http\Controllers\Auth\Admin\User::class, 'getOnlineCount']); + Route::get('/', [\App\Http\Controllers\Auth\Admin\User::class, 'getOnlineUsers']); + Route::get('/{userId}/sessions', [\App\Http\Controllers\Auth\Admin\User::class, 'getUserSessions']); + Route::post('/{userId}/offline', [\App\Http\Controllers\Auth\Admin\User::class, 'setUserOffline']); + Route::post('/{userId}/offline-all', [\App\Http\Controllers\Auth\Admin\User::class, 'setUserAllOffline']); + }); + + // 系统配置管理 + Route::prefix('configs')->group(function () { + Route::get('/', [\App\Http\Controllers\System\Admin\Config::class, 'index']); + Route::get('/all', [\App\Http\Controllers\System\Admin\Config::class, 'getByGroup']); + Route::get('/groups', [\App\Http\Controllers\System\Admin\Config::class, 'getGroups']); + Route::get('/{id}', [\App\Http\Controllers\System\Admin\Config::class, 'show']); + Route::post('/', [\App\Http\Controllers\System\Admin\Config::class, 'store']); + Route::put('/{id}', [\App\Http\Controllers\System\Admin\Config::class, 'update']); + Route::delete('/{id}', [\App\Http\Controllers\System\Admin\Config::class, 'destroy']); + Route::post('/batch-delete', [\App\Http\Controllers\System\Admin\Config::class, 'batchDelete']); + Route::post('/batch-status', [\App\Http\Controllers\System\Admin\Config::class, 'batchUpdateStatus']); + }); + + // 系统操作日志 + Route::prefix('logs')->group(function () { + Route::get('/', [\App\Http\Controllers\System\Admin\Log::class, 'index']); + Route::get('/statistics', [\App\Http\Controllers\System\Admin\Log::class, 'getStatistics']); + Route::get('/{id}', [\App\Http\Controllers\System\Admin\Log::class, 'show']); + Route::delete('/{id}', [\App\Http\Controllers\System\Admin\Log::class, 'destroy']); + Route::post('/batch-delete', [\App\Http\Controllers\System\Admin\Log::class, 'batchDelete']); + Route::post('/clear', [\App\Http\Controllers\System\Admin\Log::class, 'clearLogs']); + Route::post('/export', [\App\Http\Controllers\System\Admin\Log::class, 'export']); + }); + + // 数据字典管理 + Route::prefix('dictionaries')->group(function () { + Route::get('/', [\App\Http\Controllers\System\Admin\Dictionary::class, 'index']); + Route::get('/all', [\App\Http\Controllers\System\Admin\Dictionary::class, 'all']); + Route::get('/{id}', [\App\Http\Controllers\System\Admin\Dictionary::class, 'show']); + Route::post('/', [\App\Http\Controllers\System\Admin\Dictionary::class, 'store']); + Route::put('/{id}', [\App\Http\Controllers\System\Admin\Dictionary::class, 'update']); + Route::delete('/{id}', [\App\Http\Controllers\System\Admin\Dictionary::class, 'destroy']); + Route::post('/batch-delete', [\App\Http\Controllers\System\Admin\Dictionary::class, 'batchDelete']); + Route::post('/batch-status', [\App\Http\Controllers\System\Admin\Dictionary::class, 'batchUpdateStatus']); + }); + + // 数据字典项管理 + Route::prefix('dictionary-items')->group(function () { + Route::get('/', [\App\Http\Controllers\System\Admin\Dictionary::class, 'getItemsList']); + Route::post('/', [\App\Http\Controllers\System\Admin\Dictionary::class, 'storeItem']); + Route::put('/{id}', [\App\Http\Controllers\System\Admin\Dictionary::class, 'updateItem']); + Route::delete('/{id}', [\App\Http\Controllers\System\Admin\Dictionary::class, 'destroyItem']); + Route::post('/batch-delete', [\App\Http\Controllers\System\Admin\Dictionary::class, 'batchDeleteItems']); + Route::post('/batch-status', [\App\Http\Controllers\System\Admin\Dictionary::class, 'batchUpdateItemsStatus']); + }); + + // 任务管理 + Route::prefix('tasks')->group(function () { + Route::get('/', [\App\Http\Controllers\System\Admin\Task::class, 'index']); + Route::get('/all', [\App\Http\Controllers\System\Admin\Task::class, 'all']); + Route::get('/statistics', [\App\Http\Controllers\System\Admin\Task::class, 'getStatistics']); + Route::get('/{id}', [\App\Http\Controllers\System\Admin\Task::class, 'show']); + Route::post('/', [\App\Http\Controllers\System\Admin\Task::class, 'store']); + Route::put('/{id}', [\App\Http\Controllers\System\Admin\Task::class, 'update']); + Route::delete('/{id}', [\App\Http\Controllers\System\Admin\Task::class, 'destroy']); + Route::post('/batch-delete', [\App\Http\Controllers\System\Admin\Task::class, 'batchDelete']); + Route::post('/batch-status', [\App\Http\Controllers\System\Admin\Task::class, 'batchUpdateStatus']); + Route::post('/{id}/run', [\App\Http\Controllers\System\Admin\Task::class, 'run']); + }); + + // 城市数据管理 + Route::prefix('cities')->group(function () { + Route::get('/', [\App\Http\Controllers\System\Admin\City::class, 'index']); + Route::get('/tree', [\App\Http\Controllers\System\Admin\City::class, 'tree']); + Route::get('/{id}', [\App\Http\Controllers\System\Admin\City::class, 'show']); + Route::get('/{id}/children', [\App\Http\Controllers\System\Admin\City::class, 'children']); + Route::get('/provinces', [\App\Http\Controllers\System\Admin\City::class, 'provinces']); + Route::get('/{provinceId}/cities', [\App\Http\Controllers\System\Admin\City::class, 'cities']); + Route::get('/{cityId}/districts', [\App\Http\Controllers\System\Admin\City::class, 'districts']); + Route::post('/', [\App\Http\Controllers\System\Admin\City::class, 'store']); + Route::put('/{id}', [\App\Http\Controllers\System\Admin\City::class, 'update']); + Route::delete('/{id}', [\App\Http\Controllers\System\Admin\City::class, 'destroy']); + Route::post('/batch-delete', [\App\Http\Controllers\System\Admin\City::class, 'batchDelete']); + Route::post('/batch-status', [\App\Http\Controllers\System\Admin\City::class, 'batchUpdateStatus']); + }); + + // 文件上传管理 + Route::prefix('upload')->group(function () { + Route::post('/', [\App\Http\Controllers\System\Admin\Upload::class, 'upload']); + Route::post('/multiple', [\App\Http\Controllers\System\Admin\Upload::class, 'uploadMultiple']); + Route::post('/base64', [\App\Http\Controllers\System\Admin\Upload::class, 'uploadBase64']); + Route::post('/delete', [\App\Http\Controllers\System\Admin\Upload::class, 'delete']); + Route::post('/batch-delete', [\App\Http\Controllers\System\Admin\Upload::class, 'batchDelete']); + }); + + // WebSocket 管理 + Route::prefix('websocket')->group(function () { + Route::get('/online-count', [\App\Http\Controllers\System\WebSocket::class, 'getOnlineCount']); + Route::get('/online-users', [\App\Http\Controllers\System\WebSocket::class, 'getOnlineUsers']); + Route::post('/check-online', [\App\Http\Controllers\System\WebSocket::class, 'checkOnline']); + Route::post('/send-to-user', [\App\Http\Controllers\System\WebSocket::class, 'sendToUser']); + Route::post('/send-to-users', [\App\Http\Controllers\System\WebSocket::class, 'sendToUsers']); + Route::post('/broadcast', [\App\Http\Controllers\System\WebSocket::class, 'broadcast']); + Route::post('/send-to-channel', [\App\Http\Controllers\System\WebSocket::class, 'sendToChannel']); + Route::post('/send-notification', [\App\Http\Controllers\System\WebSocket::class, 'sendNotification']); + Route::post('/send-notification-to-users', [\App\Http\Controllers\System\WebSocket::class, 'sendNotificationToUsers']); + Route::post('/push-data-update', [\App\Http\Controllers\System\WebSocket::class, 'pushDataUpdate']); + Route::post('/push-data-update-channel', [\App\Http\Controllers\System\WebSocket::class, 'pushDataUpdateToChannel']); + Route::post('/disconnect-user', [\App\Http\Controllers\System\WebSocket::class, 'disconnectUser']); + }); +}); diff --git a/routes/api.php b/routes/api.php new file mode 100644 index 0000000..7c4d255 --- /dev/null +++ b/routes/api.php @@ -0,0 +1,3 @@ +comment(Inspiring::quote()); +})->purpose('Display an inspiring quote'); diff --git a/routes/web.php b/routes/web.php new file mode 100644 index 0000000..86a06c5 --- /dev/null +++ b/routes/web.php @@ -0,0 +1,7 @@ +get('/'); + + $response->assertStatus(200); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..fe1ffc2 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,10 @@ +assertTrue(true); + } +} diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..f35b4e7 --- /dev/null +++ b/vite.config.js @@ -0,0 +1,18 @@ +import { defineConfig } from 'vite'; +import laravel from 'laravel-vite-plugin'; +import tailwindcss from '@tailwindcss/vite'; + +export default defineConfig({ + plugins: [ + laravel({ + input: ['resources/css/app.css', 'resources/js/app.js'], + refresh: true, + }), + tailwindcss(), + ], + server: { + watch: { + ignored: ['**/storage/framework/views/**'], + }, + }, +});