From 95dd32c75ac47d6a3284024ff3258d824ecc964e Mon Sep 17 00:00:00 2001 From: nikelaluo <2135665716@qq.com> Date: Tue, 14 Jul 2026 01:38:41 +0000 Subject: [PATCH] feat(server): modularize backend and admin console --- .dockerignore | 9 +- .env.docker.example | 99 + .gitignore | 9 + Dockerfile | 23 +- Docs/ReadMe.txt | 38 + Docs/后端模板化改造说明.md | 124 + Docs/服务端部署说明.md | 417 + admin-ui/.browserslistrc | 4 + admin-ui/.dockerignore | 21 + admin-ui/.editorconfig | 14 + admin-ui/.env | 5 + admin-ui/.env.development | 8 + admin-ui/.env.production | 13 + admin-ui/.env.staging | 16 + admin-ui/.gitignore | 22 + admin-ui/.husky/commit-msg | 8 + admin-ui/.husky/common.sh | 9 + admin-ui/.husky/pre-commit | 10 + admin-ui/.lintstagedrc | 20 + admin-ui/.markdownlint.json | 11 + admin-ui/.npmrc | 4 + admin-ui/.nvmrc | 1 + admin-ui/.prettierrc.js | 9 + admin-ui/.stylelintignore | 4 + admin-ui/Dockerfile | 20 + admin-ui/LICENSE | 21 + admin-ui/README.en-US.md | 39 + admin-ui/README.md | 43 + admin-ui/build/cdn.ts | 55 + admin-ui/build/compress.ts | 63 + admin-ui/build/info.ts | 57 + admin-ui/build/optimize.ts | 29 + admin-ui/build/plugins.ts | 56 + admin-ui/build/utils.ts | 110 + admin-ui/commitlint.config.js | 35 + admin-ui/eslint.config.js | 173 + admin-ui/index.html | 84 + admin-ui/package-lock.json | 11532 ++++++++++++++++ admin-ui/package.json | 132 + admin-ui/pnpm-lock.yaml | 7387 ++++++++++ admin-ui/postcss.config.js | 8 + admin-ui/public/favicon.ico | Bin 0 -> 1270 bytes admin-ui/public/logo.svg | 1 + admin-ui/public/platform-config.json | 26 + admin-ui/src/App.vue | 26 + admin-ui/src/api/routes.ts | 11 + admin-ui/src/api/simcae.ts | 134 + admin-ui/src/api/user.ts | 45 + admin-ui/src/assets/iconfont/iconfont.css | 27 + admin-ui/src/assets/iconfont/iconfont.js | 68 + admin-ui/src/assets/iconfont/iconfont.json | 30 + admin-ui/src/assets/iconfont/iconfont.ttf | Bin 0 -> 3904 bytes admin-ui/src/assets/iconfont/iconfont.woff | Bin 0 -> 2484 bytes admin-ui/src/assets/iconfont/iconfont.woff2 | Bin 0 -> 2016 bytes admin-ui/src/assets/login/avatar.svg | 1 + admin-ui/src/assets/login/bg.png | Bin 0 -> 17468 bytes admin-ui/src/assets/login/illustration.svg | 1 + admin-ui/src/assets/status/403.svg | 1 + admin-ui/src/assets/status/404.svg | 1 + admin-ui/src/assets/status/500.svg | 1 + admin-ui/src/assets/svg/back_top.svg | 1 + admin-ui/src/assets/svg/dark.svg | 1 + admin-ui/src/assets/svg/day.svg | 1 + admin-ui/src/assets/svg/enter_outlined.svg | 1 + admin-ui/src/assets/svg/exit_screen.svg | 1 + admin-ui/src/assets/svg/full_screen.svg | 1 + admin-ui/src/assets/svg/keyboard_esc.svg | 1 + admin-ui/src/assets/svg/system.svg | 1 + admin-ui/src/assets/table-bar/collapse.svg | 1 + admin-ui/src/assets/table-bar/drag.svg | 1 + admin-ui/src/assets/table-bar/expand.svg | 1 + admin-ui/src/assets/table-bar/refresh.svg | 1 + admin-ui/src/assets/table-bar/settings.svg | 1 + admin-ui/src/assets/user.jpg | Bin 0 -> 3694 bytes admin-ui/src/components/ReAuth/index.ts | 5 + admin-ui/src/components/ReAuth/src/auth.tsx | 20 + admin-ui/src/components/ReCol/index.ts | 29 + admin-ui/src/components/ReDialog/index.ts | 69 + admin-ui/src/components/ReDialog/index.vue | 206 + admin-ui/src/components/ReDialog/type.ts | 275 + admin-ui/src/components/ReIcon/index.ts | 12 + admin-ui/src/components/ReIcon/src/hooks.ts | 63 + .../src/components/ReIcon/src/iconfont.ts | 47 + .../ReIcon/src/iconifyIconOffline.ts | 47 + .../ReIcon/src/iconifyIconOnline.ts | 31 + .../src/components/ReIcon/src/offlineIcon.ts | 23 + admin-ui/src/components/ReIcon/src/types.ts | 20 + admin-ui/src/components/RePerms/index.ts | 5 + admin-ui/src/components/RePerms/src/perms.tsx | 20 + .../src/components/RePureTableBar/index.ts | 5 + .../src/components/RePureTableBar/src/bar.tsx | 393 + admin-ui/src/components/ReSegmented/index.ts | 8 + .../src/components/ReSegmented/src/index.css | 156 + .../src/components/ReSegmented/src/index.tsx | 216 + .../src/components/ReSegmented/src/type.ts | 20 + admin-ui/src/components/ReText/index.ts | 7 + admin-ui/src/components/ReText/src/index.vue | 69 + admin-ui/src/config/index.ts | 55 + admin-ui/src/directives/auth/index.ts | 15 + admin-ui/src/directives/copy/index.ts | 33 + admin-ui/src/directives/index.ts | 6 + admin-ui/src/directives/longpress/index.ts | 63 + admin-ui/src/directives/optimize/index.ts | 68 + admin-ui/src/directives/perms/index.ts | 15 + admin-ui/src/directives/ripple/index.scss | 48 + admin-ui/src/directives/ripple/index.ts | 229 + .../layout/components/lay-content/index.vue | 213 + .../layout/components/lay-footer/index.vue | 24 + .../src/layout/components/lay-frame/index.vue | 79 + .../layout/components/lay-navbar/index.vue | 123 + .../lay-search/components/SearchFooter.vue | 61 + .../lay-search/components/SearchHistory.vue | 198 + .../components/SearchHistoryItem.vue | 52 + .../lay-search/components/SearchModal.vue | 334 + .../lay-search/components/SearchResult.vue | 113 + .../layout/components/lay-search/index.vue | 21 + .../src/layout/components/lay-search/types.ts | 20 + .../components/lay-sidebar/NavHorizontal.vue | 111 + .../layout/components/lay-sidebar/NavMix.vue | 131 + .../components/lay-sidebar/NavVertical.vue | 137 + .../components/SidebarBreadCrumb.vue | 120 + .../components/SidebarCenterCollapse.vue | 70 + .../components/SidebarExtraIcon.vue | 20 + .../components/SidebarFullScreen.vue | 30 + .../lay-sidebar/components/SidebarItem.vue | 228 + .../components/SidebarLeftCollapse.vue | 69 + .../components/SidebarLinkItem.vue | 32 + .../lay-sidebar/components/SidebarLogo.vue | 72 + .../components/SidebarTopCollapse.vue | 33 + .../lay-tag/components/TagChrome.vue | 33 + .../src/layout/components/lay-tag/index.scss | 371 + .../src/layout/components/lay-tag/index.vue | 690 + admin-ui/src/layout/frame.vue | 110 + admin-ui/src/layout/hooks/useBoolean.ts | 26 + .../src/layout/hooks/useDataThemeChange.ts | 138 + admin-ui/src/layout/hooks/useLayout.ts | 58 + admin-ui/src/layout/hooks/useMultiFrame.ts | 25 + admin-ui/src/layout/hooks/useNav.ts | 157 + admin-ui/src/layout/hooks/useTag.ts | 252 + admin-ui/src/layout/index.vue | 232 + admin-ui/src/layout/redirect.vue | 24 + admin-ui/src/layout/types.ts | 93 + admin-ui/src/main.ts | 64 + admin-ui/src/plugins/echarts.ts | 44 + admin-ui/src/plugins/elementPlus.ts | 248 + admin-ui/src/router/index.ts | 222 + admin-ui/src/router/modules/error.ts | 39 + admin-ui/src/router/modules/home.ts | 26 + admin-ui/src/router/modules/remaining.ts | 48 + admin-ui/src/router/utils.ts | 415 + admin-ui/src/store/index.ts | 9 + admin-ui/src/store/modules/app.ts | 85 + admin-ui/src/store/modules/epTheme.ts | 49 + admin-ui/src/store/modules/multiTags.ts | 138 + admin-ui/src/store/modules/permission.ts | 75 + admin-ui/src/store/modules/settings.ts | 35 + admin-ui/src/store/modules/user.ts | 109 + admin-ui/src/store/types.ts | 47 + admin-ui/src/store/utils.ts | 28 + admin-ui/src/style/dark.scss | 182 + admin-ui/src/style/element-plus.scss | 189 + admin-ui/src/style/index.scss | 37 + admin-ui/src/style/login.css | 96 + admin-ui/src/style/reset.scss | 250 + admin-ui/src/style/sidebar.scss | 689 + admin-ui/src/style/tailwind.css | 46 + admin-ui/src/style/theme.scss | 95 + admin-ui/src/style/transition.scss | 54 + admin-ui/src/utils/auth.ts | 141 + admin-ui/src/utils/globalPolyfills.ts | 7 + admin-ui/src/utils/http/index.ts | 187 + admin-ui/src/utils/http/types.d.ts | 47 + admin-ui/src/utils/localforage/index.ts | 109 + admin-ui/src/utils/localforage/types.d.ts | 166 + admin-ui/src/utils/message.ts | 89 + admin-ui/src/utils/mitt.ts | 14 + admin-ui/src/utils/preventDefault.ts | 28 + admin-ui/src/utils/print.ts | 223 + admin-ui/src/utils/progress/index.ts | 17 + admin-ui/src/utils/propTypes.ts | 39 + admin-ui/src/utils/responsive.ts | 42 + admin-ui/src/utils/sso.ts | 59 + admin-ui/src/utils/tree.ts | 188 + admin-ui/src/views/error/403.vue | 79 + admin-ui/src/views/error/404.vue | 79 + admin-ui/src/views/error/500.vue | 79 + admin-ui/src/views/login/index.vue | 175 + admin-ui/src/views/login/utils/motion.ts | 40 + admin-ui/src/views/login/utils/rule.ts | 28 + admin-ui/src/views/login/utils/static.ts | 5 + .../src/views/permission/button/index.vue | 99 + .../src/views/permission/button/perms.vue | 109 + admin-ui/src/views/permission/page/index.vue | 66 + admin-ui/src/views/simcae/index.vue | 1181 ++ admin-ui/stylelint.config.js | 87 + admin-ui/tsconfig.json | 55 + admin-ui/types/directives.d.ts | 28 + admin-ui/types/global-components.d.ts | 135 + admin-ui/types/global.d.ts | 193 + admin-ui/types/index.d.ts | 80 + admin-ui/types/router.d.ts | 111 + admin-ui/types/shims-tsx.d.ts | 24 + admin-ui/types/shims-vue.d.ts | 11 + admin-ui/vite.config.ts | 62 + app/__init__.py | 1 + app/api/__init__.py | 1 + app/api/router.py | 33 + app/api/routes/__init__.py | 1 + app/api/routes/admin_app_channel.py | 45 + app/api/routes/admin_config.py | 110 + app/api/routes/admin_crash_report.py | 56 + app/api/routes/admin_device.py | 24 + app/api/routes/admin_license.py | 81 + app/api/routes/admin_logs.py | 76 + app/api/routes/admin_policy.py | 52 + app/api/routes/admin_publish.py | 18 + app/api/routes/admin_version.py | 126 + app/api/routes/client_update.py | 244 + app/api/routes/crash_api.py | 35 + app/api/routes/frontend.py | 68 + app/core/__init__.py | 1 + app/core/audit.py | 43 + app/core/config.py | 34 + app/core/security.py | 61 + app/db/__init__.py | 1 + app/repositories/__init__.py | 1 + app/repositories/app_channel_repository.py | 93 + app/repositories/client_update_repository.py | 168 + app/repositories/crash_report_repository.py | 133 + app/repositories/device_repository.py | 36 + app/repositories/license_repository.py | 75 + app/repositories/log_repository.py | 99 + app/repositories/policy_repository.py | 62 + app/repositories/publish_repository.py | 24 + app/repositories/version_repository.py | 91 + app/schemas/__init__.py | 1 + app/schemas/admin_config.py | 15 + app/schemas/app_channel.py | 14 + app/schemas/client_update.py | 47 + app/schemas/device.py | 7 + app/schemas/license.py | 18 + app/schemas/log.py | 9 + app/schemas/policy.py | 13 + app/schemas/version.py | 10 + app/services/__init__.py | 1 + app/services/admin_config_service.py | 63 + app/services/common_service.py | 56 + app/services/crash_api_service.py | 598 + app/services/device_credential_service.py | 45 + app/services/license_service.py | 37 + app/services/publish_service.py | 643 + app/services/signing_service.py | 49 + docker-compose.image.yml | 2 +- docker-compose.yml | 2 +- keys/manifest_public_key.pem | 14 +- legacy/ReadMe.txt | 13 + admin.html => legacy/admin.html | 268 +- main.py | 2175 +-- scripts/ReadMe.txt | 17 + .../package-offline-server.sh | 127 +- tables.sql | 1 + 261 files changed, 39289 insertions(+), 2156 deletions(-) create mode 100644 .env.docker.example create mode 100644 Docs/ReadMe.txt create mode 100644 Docs/后端模板化改造说明.md create mode 100644 Docs/服务端部署说明.md create mode 100644 admin-ui/.browserslistrc create mode 100644 admin-ui/.dockerignore create mode 100644 admin-ui/.editorconfig create mode 100644 admin-ui/.env create mode 100644 admin-ui/.env.development create mode 100644 admin-ui/.env.production create mode 100644 admin-ui/.env.staging create mode 100644 admin-ui/.gitignore create mode 100755 admin-ui/.husky/commit-msg create mode 100644 admin-ui/.husky/common.sh create mode 100755 admin-ui/.husky/pre-commit create mode 100644 admin-ui/.lintstagedrc create mode 100644 admin-ui/.markdownlint.json create mode 100644 admin-ui/.npmrc create mode 100644 admin-ui/.nvmrc create mode 100644 admin-ui/.prettierrc.js create mode 100644 admin-ui/.stylelintignore create mode 100644 admin-ui/Dockerfile create mode 100644 admin-ui/LICENSE create mode 100644 admin-ui/README.en-US.md create mode 100644 admin-ui/README.md create mode 100644 admin-ui/build/cdn.ts create mode 100644 admin-ui/build/compress.ts create mode 100644 admin-ui/build/info.ts create mode 100644 admin-ui/build/optimize.ts create mode 100644 admin-ui/build/plugins.ts create mode 100644 admin-ui/build/utils.ts create mode 100644 admin-ui/commitlint.config.js create mode 100644 admin-ui/eslint.config.js create mode 100644 admin-ui/index.html create mode 100644 admin-ui/package-lock.json create mode 100644 admin-ui/package.json create mode 100644 admin-ui/pnpm-lock.yaml create mode 100644 admin-ui/postcss.config.js create mode 100644 admin-ui/public/favicon.ico create mode 100644 admin-ui/public/logo.svg create mode 100644 admin-ui/public/platform-config.json create mode 100644 admin-ui/src/App.vue create mode 100644 admin-ui/src/api/routes.ts create mode 100644 admin-ui/src/api/simcae.ts create mode 100644 admin-ui/src/api/user.ts create mode 100644 admin-ui/src/assets/iconfont/iconfont.css create mode 100644 admin-ui/src/assets/iconfont/iconfont.js create mode 100644 admin-ui/src/assets/iconfont/iconfont.json create mode 100644 admin-ui/src/assets/iconfont/iconfont.ttf create mode 100644 admin-ui/src/assets/iconfont/iconfont.woff create mode 100644 admin-ui/src/assets/iconfont/iconfont.woff2 create mode 100644 admin-ui/src/assets/login/avatar.svg create mode 100644 admin-ui/src/assets/login/bg.png create mode 100644 admin-ui/src/assets/login/illustration.svg create mode 100644 admin-ui/src/assets/status/403.svg create mode 100644 admin-ui/src/assets/status/404.svg create mode 100644 admin-ui/src/assets/status/500.svg create mode 100644 admin-ui/src/assets/svg/back_top.svg create mode 100644 admin-ui/src/assets/svg/dark.svg create mode 100644 admin-ui/src/assets/svg/day.svg create mode 100644 admin-ui/src/assets/svg/enter_outlined.svg create mode 100644 admin-ui/src/assets/svg/exit_screen.svg create mode 100644 admin-ui/src/assets/svg/full_screen.svg create mode 100644 admin-ui/src/assets/svg/keyboard_esc.svg create mode 100644 admin-ui/src/assets/svg/system.svg create mode 100644 admin-ui/src/assets/table-bar/collapse.svg create mode 100644 admin-ui/src/assets/table-bar/drag.svg create mode 100644 admin-ui/src/assets/table-bar/expand.svg create mode 100644 admin-ui/src/assets/table-bar/refresh.svg create mode 100644 admin-ui/src/assets/table-bar/settings.svg create mode 100644 admin-ui/src/assets/user.jpg create mode 100644 admin-ui/src/components/ReAuth/index.ts create mode 100644 admin-ui/src/components/ReAuth/src/auth.tsx create mode 100644 admin-ui/src/components/ReCol/index.ts create mode 100644 admin-ui/src/components/ReDialog/index.ts create mode 100644 admin-ui/src/components/ReDialog/index.vue create mode 100644 admin-ui/src/components/ReDialog/type.ts create mode 100644 admin-ui/src/components/ReIcon/index.ts create mode 100644 admin-ui/src/components/ReIcon/src/hooks.ts create mode 100644 admin-ui/src/components/ReIcon/src/iconfont.ts create mode 100644 admin-ui/src/components/ReIcon/src/iconifyIconOffline.ts create mode 100644 admin-ui/src/components/ReIcon/src/iconifyIconOnline.ts create mode 100644 admin-ui/src/components/ReIcon/src/offlineIcon.ts create mode 100644 admin-ui/src/components/ReIcon/src/types.ts create mode 100644 admin-ui/src/components/RePerms/index.ts create mode 100644 admin-ui/src/components/RePerms/src/perms.tsx create mode 100644 admin-ui/src/components/RePureTableBar/index.ts create mode 100644 admin-ui/src/components/RePureTableBar/src/bar.tsx create mode 100644 admin-ui/src/components/ReSegmented/index.ts create mode 100644 admin-ui/src/components/ReSegmented/src/index.css create mode 100644 admin-ui/src/components/ReSegmented/src/index.tsx create mode 100644 admin-ui/src/components/ReSegmented/src/type.ts create mode 100644 admin-ui/src/components/ReText/index.ts create mode 100644 admin-ui/src/components/ReText/src/index.vue create mode 100644 admin-ui/src/config/index.ts create mode 100644 admin-ui/src/directives/auth/index.ts create mode 100644 admin-ui/src/directives/copy/index.ts create mode 100644 admin-ui/src/directives/index.ts create mode 100644 admin-ui/src/directives/longpress/index.ts create mode 100644 admin-ui/src/directives/optimize/index.ts create mode 100644 admin-ui/src/directives/perms/index.ts create mode 100644 admin-ui/src/directives/ripple/index.scss create mode 100644 admin-ui/src/directives/ripple/index.ts create mode 100644 admin-ui/src/layout/components/lay-content/index.vue create mode 100644 admin-ui/src/layout/components/lay-footer/index.vue create mode 100644 admin-ui/src/layout/components/lay-frame/index.vue create mode 100644 admin-ui/src/layout/components/lay-navbar/index.vue create mode 100644 admin-ui/src/layout/components/lay-search/components/SearchFooter.vue create mode 100644 admin-ui/src/layout/components/lay-search/components/SearchHistory.vue create mode 100644 admin-ui/src/layout/components/lay-search/components/SearchHistoryItem.vue create mode 100644 admin-ui/src/layout/components/lay-search/components/SearchModal.vue create mode 100644 admin-ui/src/layout/components/lay-search/components/SearchResult.vue create mode 100644 admin-ui/src/layout/components/lay-search/index.vue create mode 100644 admin-ui/src/layout/components/lay-search/types.ts create mode 100644 admin-ui/src/layout/components/lay-sidebar/NavHorizontal.vue create mode 100644 admin-ui/src/layout/components/lay-sidebar/NavMix.vue create mode 100644 admin-ui/src/layout/components/lay-sidebar/NavVertical.vue create mode 100644 admin-ui/src/layout/components/lay-sidebar/components/SidebarBreadCrumb.vue create mode 100644 admin-ui/src/layout/components/lay-sidebar/components/SidebarCenterCollapse.vue create mode 100644 admin-ui/src/layout/components/lay-sidebar/components/SidebarExtraIcon.vue create mode 100644 admin-ui/src/layout/components/lay-sidebar/components/SidebarFullScreen.vue create mode 100644 admin-ui/src/layout/components/lay-sidebar/components/SidebarItem.vue create mode 100644 admin-ui/src/layout/components/lay-sidebar/components/SidebarLeftCollapse.vue create mode 100644 admin-ui/src/layout/components/lay-sidebar/components/SidebarLinkItem.vue create mode 100644 admin-ui/src/layout/components/lay-sidebar/components/SidebarLogo.vue create mode 100644 admin-ui/src/layout/components/lay-sidebar/components/SidebarTopCollapse.vue create mode 100644 admin-ui/src/layout/components/lay-tag/components/TagChrome.vue create mode 100644 admin-ui/src/layout/components/lay-tag/index.scss create mode 100644 admin-ui/src/layout/components/lay-tag/index.vue create mode 100644 admin-ui/src/layout/frame.vue create mode 100644 admin-ui/src/layout/hooks/useBoolean.ts create mode 100644 admin-ui/src/layout/hooks/useDataThemeChange.ts create mode 100644 admin-ui/src/layout/hooks/useLayout.ts create mode 100644 admin-ui/src/layout/hooks/useMultiFrame.ts create mode 100644 admin-ui/src/layout/hooks/useNav.ts create mode 100644 admin-ui/src/layout/hooks/useTag.ts create mode 100644 admin-ui/src/layout/index.vue create mode 100644 admin-ui/src/layout/redirect.vue create mode 100644 admin-ui/src/layout/types.ts create mode 100644 admin-ui/src/main.ts create mode 100644 admin-ui/src/plugins/echarts.ts create mode 100644 admin-ui/src/plugins/elementPlus.ts create mode 100644 admin-ui/src/router/index.ts create mode 100644 admin-ui/src/router/modules/error.ts create mode 100644 admin-ui/src/router/modules/home.ts create mode 100644 admin-ui/src/router/modules/remaining.ts create mode 100644 admin-ui/src/router/utils.ts create mode 100644 admin-ui/src/store/index.ts create mode 100644 admin-ui/src/store/modules/app.ts create mode 100644 admin-ui/src/store/modules/epTheme.ts create mode 100644 admin-ui/src/store/modules/multiTags.ts create mode 100644 admin-ui/src/store/modules/permission.ts create mode 100644 admin-ui/src/store/modules/settings.ts create mode 100644 admin-ui/src/store/modules/user.ts create mode 100644 admin-ui/src/store/types.ts create mode 100644 admin-ui/src/store/utils.ts create mode 100644 admin-ui/src/style/dark.scss create mode 100644 admin-ui/src/style/element-plus.scss create mode 100644 admin-ui/src/style/index.scss create mode 100644 admin-ui/src/style/login.css create mode 100644 admin-ui/src/style/reset.scss create mode 100644 admin-ui/src/style/sidebar.scss create mode 100644 admin-ui/src/style/tailwind.css create mode 100644 admin-ui/src/style/theme.scss create mode 100644 admin-ui/src/style/transition.scss create mode 100644 admin-ui/src/utils/auth.ts create mode 100644 admin-ui/src/utils/globalPolyfills.ts create mode 100644 admin-ui/src/utils/http/index.ts create mode 100644 admin-ui/src/utils/http/types.d.ts create mode 100644 admin-ui/src/utils/localforage/index.ts create mode 100644 admin-ui/src/utils/localforage/types.d.ts create mode 100644 admin-ui/src/utils/message.ts create mode 100644 admin-ui/src/utils/mitt.ts create mode 100644 admin-ui/src/utils/preventDefault.ts create mode 100644 admin-ui/src/utils/print.ts create mode 100644 admin-ui/src/utils/progress/index.ts create mode 100644 admin-ui/src/utils/propTypes.ts create mode 100644 admin-ui/src/utils/responsive.ts create mode 100644 admin-ui/src/utils/sso.ts create mode 100644 admin-ui/src/utils/tree.ts create mode 100644 admin-ui/src/views/error/403.vue create mode 100644 admin-ui/src/views/error/404.vue create mode 100644 admin-ui/src/views/error/500.vue create mode 100644 admin-ui/src/views/login/index.vue create mode 100644 admin-ui/src/views/login/utils/motion.ts create mode 100644 admin-ui/src/views/login/utils/rule.ts create mode 100644 admin-ui/src/views/login/utils/static.ts create mode 100644 admin-ui/src/views/permission/button/index.vue create mode 100644 admin-ui/src/views/permission/button/perms.vue create mode 100644 admin-ui/src/views/permission/page/index.vue create mode 100644 admin-ui/src/views/simcae/index.vue create mode 100644 admin-ui/stylelint.config.js create mode 100644 admin-ui/tsconfig.json create mode 100644 admin-ui/types/directives.d.ts create mode 100644 admin-ui/types/global-components.d.ts create mode 100644 admin-ui/types/global.d.ts create mode 100644 admin-ui/types/index.d.ts create mode 100644 admin-ui/types/router.d.ts create mode 100644 admin-ui/types/shims-tsx.d.ts create mode 100644 admin-ui/types/shims-vue.d.ts create mode 100644 admin-ui/vite.config.ts create mode 100644 app/__init__.py create mode 100644 app/api/__init__.py create mode 100644 app/api/router.py create mode 100644 app/api/routes/__init__.py create mode 100644 app/api/routes/admin_app_channel.py create mode 100644 app/api/routes/admin_config.py create mode 100644 app/api/routes/admin_crash_report.py create mode 100644 app/api/routes/admin_device.py create mode 100644 app/api/routes/admin_license.py create mode 100644 app/api/routes/admin_logs.py create mode 100644 app/api/routes/admin_policy.py create mode 100644 app/api/routes/admin_publish.py create mode 100644 app/api/routes/admin_version.py create mode 100644 app/api/routes/client_update.py create mode 100644 app/api/routes/crash_api.py create mode 100644 app/api/routes/frontend.py create mode 100644 app/core/__init__.py create mode 100644 app/core/audit.py create mode 100644 app/core/config.py create mode 100644 app/core/security.py create mode 100644 app/db/__init__.py create mode 100644 app/repositories/__init__.py create mode 100644 app/repositories/app_channel_repository.py create mode 100644 app/repositories/client_update_repository.py create mode 100644 app/repositories/crash_report_repository.py create mode 100644 app/repositories/device_repository.py create mode 100644 app/repositories/license_repository.py create mode 100644 app/repositories/log_repository.py create mode 100644 app/repositories/policy_repository.py create mode 100644 app/repositories/publish_repository.py create mode 100644 app/repositories/version_repository.py create mode 100644 app/schemas/__init__.py create mode 100644 app/schemas/admin_config.py create mode 100644 app/schemas/app_channel.py create mode 100644 app/schemas/client_update.py create mode 100644 app/schemas/device.py create mode 100644 app/schemas/license.py create mode 100644 app/schemas/log.py create mode 100644 app/schemas/policy.py create mode 100644 app/schemas/version.py create mode 100644 app/services/__init__.py create mode 100644 app/services/admin_config_service.py create mode 100644 app/services/common_service.py create mode 100644 app/services/crash_api_service.py create mode 100644 app/services/device_credential_service.py create mode 100644 app/services/license_service.py create mode 100644 app/services/publish_service.py create mode 100644 app/services/signing_service.py create mode 100644 legacy/ReadMe.txt rename admin.html => legacy/admin.html (88%) create mode 100644 scripts/ReadMe.txt rename package-offline-server.sh => scripts/package-offline-server.sh (75%) diff --git a/.dockerignore b/.dockerignore index 1c52f22..6309281 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,4 +5,11 @@ !db.py !minio_tool.py !tables.sql -!admin.html +!app/ +!app/** +!legacy/ +!legacy/** +!admin-ui/ +!admin-ui/** +admin-ui/node_modules/ +admin-ui/dist/ diff --git a/.env.docker.example b/.env.docker.example new file mode 100644 index 0000000..5accb67 --- /dev/null +++ b/.env.docker.example @@ -0,0 +1,99 @@ +# Docker 镜像名称。通常不用改;重新打镜像并改版本号时才需要同步修改。 +SIMCAE_UPDATE_SERVER_IMAGE=simcae-update-server:0.1.0 + +# 服务端端口。浏览器访问 http://服务器IP:8000/。 +SERVER_PORT=8000 + +# 如果后台经过域名、反向代理或端口映射访问,可以在这里固定客户端 API 地址。 +# 留空时,管理页会按当前访问后台的地址自动生成 api_base_url。 +PUBLIC_API_BASE_URL= + +# 容器内运行用户。通常不用改;如果服务器文件权限特殊,再改成对应用户的 uid/gid。 +APP_UID=1000 +APP_GID=1000 + +# 管理后台标题。可不填或不改。 +SERVICE_TITLE=SimCAE Update Service + +# 跨域来源。内网部署一般保持 * 即可;生产环境可改成指定域名。 +CORS_ALLOW_ORIGINS=* + +# 发布包目标平台。当前客户端是 Windows x64,通常不用改。 +TARGET_PLATFORM=windows +TARGET_ARCH=x64 + +# 业务主程序相对发布根目录的路径。SimCAE 默认在 bin/SimCAE.exe。 +RELEASE_MAIN_EXECUTABLE=bin/SimCAE.exe + +# Manifest 签名密钥编号。通常不用改,换签名密钥体系时再改。 +SIGNING_KEY_ID=manifest-key-v1 + +# 发布新版本保护。超过上限会直接拒绝,避免大目录上传把服务器磁盘或内存拖死。 +# 如果你的正式发布包超过 4GB,可以按服务器磁盘空间调大。 +PUBLISH_MAX_REQUEST_MB=4096 +PUBLISH_MAX_FILES=20000 +# 通常不用改;每个上传文件还会带一个 relative_paths 字段,所以要大于 PUBLISH_MAX_FILES。 +PUBLISH_MAX_FIELDS=20100 +# 上传时额外保留的磁盘空间,单位 MB。通常不用改。 +UPLOAD_SPACE_RESERVE_MB=256 + +# 客户端访问服务端 API 的令牌。已给默认值,可直接试跑;正式部署建议修改。 +# 管理页“客户端配置生成”会自动把这个值写入 app_config.json 的 client_token。 +CLIENT_API_TOKEN=SimCAEClientToken2026 + +# 管理后台令牌。已给默认值,可直接试跑;网页登录时在管理员令牌输入这个值,正式部署建议修改。 +ADMIN_TOKEN=SimCAEAdminToken2026 + +# License Key 加密保存密钥。后台授权列表需要用它解密显示 License Key。 +# 已给默认值,可直接试跑;正式部署建议修改,并且部署后长期保持不变。 +# 如果后续改掉它,旧 License 仍可用于客户端校验,但后台无法再显示旧 License Key 原文。 +LICENSE_KEY_ENCRYPTION_SECRET=SimCAE_License_Key_Encryption_2026_ChangeMe + +# 管理页生成 app_config.json 时使用的客户端默认值。普通 SimCAE 部署通常不用改。 +CLIENT_LAUNCH_TOKEN=SimCAE_Launch_Token_2026_ChangeMe_32Bytes +CLIENT_REQUEST_TIMEOUT_MS=5000 +CLIENT_TEMP_FOLDER=update_temp +CLIENT_INSTALL_ROOT=.. +CLIENT_MAIN_EXECUTABLE=SimCAE.exe +CLIENT_LAUNCHER_EXECUTABLE=Launcher.exe +CLIENT_UPDATER_EXECUTABLE=Updater.exe +CLIENT_BOOTSTRAP_EXECUTABLE=Bootstrap.exe +CLIENT_HEALTH_CHECK_TIMEOUT_MS=15000 + +# 崩溃报告接口。已给默认值,可直接试跑;正式部署建议修改。 +CRASH_SERVICE_VERSION=1.0.0 +CRASH_REPORT_TOKEN=SimCAECrashReportToken2026 +CRASH_SYMBOL_TOKEN=SimCAESymbolToken2026 + +# 崩溃报告管理令牌。可不填;不填时查询/下载崩溃报告使用 ADMIN_TOKEN。 +CRASH_ADMIN_TOKEN= + +# 崩溃报告大小限制。通常不用改。 +CRASH_METADATA_MAX_KB=256 +CRASH_MINIDUMP_MAX_MB=128 +CRASH_ATTACHMENTS_MAX_MB=64 +CRASH_REQUEST_MAX_MB=200 +CRASH_SYMBOLS_MAX_MB=512 + +# MinIO 端口。通常不用改;如果端口被占用再改。 +MINIO_API_PORT=9000 +MINIO_CONSOLE_PORT=9001 + +# MinIO 用户名和密码。Docker 启动 MinIO 时会用这里的值初始化账号;已给默认值,可直接试跑,正式部署建议修改。 +MINIO_ACCESS_KEY=simcae_minio_admin +MINIO_SECRET_KEY=SimCAE_MinIO_2026_ChangeMe + +# MinIO 存储桶名称。通常不用改。 +MINIO_BUCKET=updates + +# 客户端下载升级文件时访问的 MinIO 地址。必须改成 Windows 客户端能访问到的服务器地址。 +MINIO_PUBLIC_ENDPOINT=http://服务器IP:9000 + +# MinIO 预签名下载链接有效期,单位分钟。通常不用改。 +SIGN_EXPIRE_MIN=60 + +# MinIO 连接超时和重试参数。通常不用改。 +MINIO_CONNECT_TIMEOUT_SEC=2 +MINIO_READ_TIMEOUT_SEC=5 +MINIO_RETRY_TOTAL=1 +MINIO_HEALTH_TIMEOUT_SEC=2 diff --git a/.gitignore b/.gitignore index 9ef82b1..f52594f 100644 --- a/.gitignore +++ b/.gitignore @@ -30,11 +30,20 @@ __pycache__/ .coverage htmlcov/ +# Frontend dependencies and build output +node_modules/ +admin-ui/node_modules/ +admin-ui/dist/ + # Local configuration and secrets .env .env.* !.env.example !.env.docker.example +!admin-ui/.env +!admin-ui/.env.development +!admin-ui/.env.production +!admin-ui/.env.staging admin_token.sha256 keys/*private*.pem keys/*private*.key diff --git a/Dockerfile b/Dockerfile index 2bccc45..16316d5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,14 @@ +FROM node:22-alpine AS admin_ui_builder + +WORKDIR /ui + +COPY admin-ui/package*.json ./ +RUN npm ci + +COPY admin-ui ./ +RUN npm run build + + FROM python:3.12-slim ENV PYTHONDONTWRITEBYTECODE=1 \ @@ -10,15 +21,19 @@ RUN apt-get update \ && apt-get install -y --no-install-recommends libarchive-tools \ && rm -rf /var/lib/apt/lists/* -COPY server/requirements.txt ./requirements.txt +COPY requirements.txt ./requirements.txt RUN pip install --no-cache-dir -r requirements.txt -COPY server/main.py server/db.py server/minio_tool.py server/tables.sql ./ -COPY --chown=updateapp:updateapp server/admin.html ./admin.html +COPY main.py db.py minio_tool.py tables.sql ./ +COPY app ./app +COPY --chown=updateapp:updateapp legacy/admin.html ./legacy/admin.html +COPY --from=admin_ui_builder --chown=updateapp:updateapp /ui/dist ./admin-ui/dist RUN mkdir -p /data/uploads /data/upload_spool /run/secrets/update-keys \ && chown -R updateapp:updateapp /app /data \ - && chmod 644 /app/admin.html + && chmod 644 /app/legacy/admin.html \ + && find /app/admin-ui/dist -type f -exec chmod 644 {} \; \ + && find /app/admin-ui/dist -type d -exec chmod 755 {} \; USER updateapp diff --git a/Docs/ReadMe.txt b/Docs/ReadMe.txt new file mode 100644 index 0000000..949d27c --- /dev/null +++ b/Docs/ReadMe.txt @@ -0,0 +1,38 @@ +服务端文档入口 +============== + +本目录用于保存 update-server 服务端子仓库的说明文档。 + +建议阅读顺序: + +1. 服务端部署说明.md + 面向部署和运维,说明 Docker 离线包里有什么、怎么部署、.env 每个字段怎么填、怎么查看日志和备份数据。 + +2. 后端模板化改造说明.md + 面向后端维护,说明当前 FastAPI 后端如何按 routes/services/repositories/schemas 分层,以及后续工程化方向。 + +常用源码入口: + +- main.py:服务启动、数据库初始化、MinIO 初始化、全局客户端鉴权。 +- app/api/routes/:管理后台、客户端更新、崩溃报告等 HTTP 路由。 +- app/services/:业务逻辑。 +- app/repositories/:SQLite 数据库读写。 +- admin-ui/:新版管理后台前端源码。 +- legacy/:旧版单文件管理后台,仅作为兼容 fallback。 +- scripts/package-offline-server.sh:生成服务端 Docker 离线部署包。 + +本地源码启动: + +```bash +cd update-server +./venv/bin/python3 main.py +``` + +Docker 离线包打包: + +```bash +cd update-server +bash ./scripts/package-offline-server.sh \ + --version 0.1.0 \ + --output-dir ./dist/SimCAEServerDockerPackage +``` diff --git a/Docs/后端模板化改造说明.md b/Docs/后端模板化改造说明.md new file mode 100644 index 0000000..36cb265 --- /dev/null +++ b/Docs/后端模板化改造说明.md @@ -0,0 +1,124 @@ +# 后端成熟模板化改造说明 + +## 目标 + +服务端后端不再继续把所有配置、鉴权、审计、页面入口和业务接口堆在 `main.py` 里,而是逐步改成成熟 FastAPI 后台项目常见的分层结构。 + +参考方向: + +- FastAPI 官方 full-stack 模板:后端工程结构、配置、JWT、数据库模型、迁移、Docker。 +- FastAPI Users:用户、认证、JWT、密码哈希、用户管理。 +- 现有 SimCAE 业务:版本发布、Manifest 签名、License、设备、崩溃报告、日志审计。 + +## 当前已落地 + +已经新增模板化目录: + +```text +app/ + api/ + router.py + routes/ + admin_license.py + admin_policy.py + admin_version.py + admin_app_channel.py + admin_crash_report.py + admin_device.py + admin_logs.py + admin_config.py + admin_publish.py + client_update.py + crash_api.py + frontend.py + core/ + config.py + security.py + audit.py + db/ + repositories/ + app_channel_repository.py + crash_report_repository.py + device_repository.py + license_repository.py + log_repository.py + policy_repository.py + version_repository.py + client_update_repository.py + publish_repository.py + schemas/ + license.py + policy.py + version.py + app_channel.py + device.py + log.py + admin_config.py + client_update.py + services/ + common_service.py + license_service.py + signing_service.py + admin_config_service.py + device_credential_service.py + publish_service.py + crash_api_service.py +``` + +当前已经从 `main.py` 抽出的通用能力: + +- `app/core/config.py`:统一读取 `.env`、路径配置和基础 settings。 +- `app/core/security.py`:后台令牌校验、令牌摘要、`.env` 写入工具。 +- `app/core/audit.py`:管理后台写操作审计中间件。 +- `app/api/routes/frontend.py`:管理后台静态页面、favicon、platform-config、health 路由。 +- `app/api/routes/admin_license.py`:License 创建、列表、禁用/启用、软删除接口。 +- `app/api/routes/admin_policy.py`:渠道更新策略查询和保存接口。 +- `app/api/routes/admin_version.py`:版本列表、设为最新、删除、客户端协议修改、离线包下载接口。 +- `app/api/routes/admin_app_channel.py`:应用列表、应用创建、渠道列表、渠道保存接口。 +- `app/api/routes/admin_logs.py`:升级日志、下载日志、管理员审计日志的列表、删除和清空接口。 +- `app/api/routes/admin_crash_report.py`:崩溃报告管理列表和附件下载接口。 +- `app/api/routes/admin_device.py`:设备列表和设备禁用/恢复接口。 +- `app/api/routes/admin_config.py`:管理员令牌校验/变更、运行时配置、客户端配置生成接口。 +- `app/api/routes/admin_publish.py`:管理端发布新版本接口,包含发布锁和管理员鉴权。 +- `app/api/routes/client_update.py`:客户端设备登记、更新检测、下载链接、Manifest、下载日志和升级结果上报接口。 +- `app/api/routes/crash_api.py`:崩溃报告健康检查、报告上传/下载和符号包上传接口。 +- `app/schemas/`:License、策略、版本管理接口的 Pydantic 请求模型。 +- `app/services/common_service.py`:渠道校验、版本号比较、策略行转换等通用业务函数。 +- `app/services/license_service.py`:License Key 加密保存和解密展示。 +- `app/services/signing_service.py`:Manifest、策略、设备身份签名。 +- `app/services/admin_config_service.py`:管理端运行时配置和客户端配置默认值生成。 +- `app/services/device_credential_service.py`:客户端设备凭证验签、有效性校验和 last_seen 更新。 +- `app/services/publish_service.py`:发布包表单解析、Release 目录校验、压缩包解压、空间检查和 MinIO 上传。 +- `app/services/crash_api_service.py`:崩溃报告接口鉴权、multipart 校验、文件落盘、幂等校验和符号包处理。 +- `app/repositories/`:管理后台已迁移接口的数据库读写层,route 不再直接拼 SQL 或打开数据库连接。 +- `app/api/router.py`:统一注册基础路由。 +- `main.py`:已经压缩为服务入口,只保留生命周期、MinIO 初始化、静态挂载和全局客户端鉴权。 + +当前业务接口路径保持不变,避免影响 Launcher、Updater 和已部署管理页面。 + +## 后续迁移路线 + +后续不建议一次性重写全部后端,而是按下面顺序迁移: + +1. 接入成熟用户认证模块,例如 FastAPI Users。 +2. 将 `ADMIN_TOKEN` 替换成管理员用户名/密码登录、JWT、refresh token。 +3. 增加用户、角色、权限、菜单权限、API 权限表。 +4. 管理后台常用业务接口、客户端更新接口、版本发布接口、崩溃报告接口已经按领域拆到 `app/api/routes/`,并将 SQL 下沉到 `app/repositories/`。 +5. 继续将请求/响应 `dict` 改成 `app/schemas/` 下的 Pydantic 模型。 +6. 继续补齐请求/响应模型、权限模型、异常模型,让接口契约更清晰。 +7. 继续细化 repository,后续可把 SQLite SQL 逐步迁到 ORM 或统一查询层。 +8. 引入 ORM 和迁移工具,例如 SQLModel/SQLAlchemy + Alembic。 +9. 保持客户端接口 `/api/v1/...` 尽量兼容,避免客户端 SDK 重编。 + +## 为什么不是直接删除 main.py + +`main.py` 原来包含大量已经跑通的业务逻辑: + +- 版本发布和压缩包解包。 +- Manifest 哈希和签名。 +- MinIO 文件上传、下载授权。 +- 设备身份签发和校验。 +- License 管理。 +- 崩溃报告上传、查询和下载。 + +这些是 SimCAE 自有业务,GitHub 模板不会直接提供。当前已经完成第一轮“搬家式重构”:业务模块进入 `routes/services/repositories`,入口文件不再堆业务代码。后续可以继续做更深的工程化,例如 ORM、JWT 用户体系、角色权限和 Alembic 数据库迁移。 diff --git a/Docs/服务端部署说明.md b/Docs/服务端部署说明.md new file mode 100644 index 0000000..df2c26c --- /dev/null +++ b/Docs/服务端部署说明.md @@ -0,0 +1,417 @@ +# SimCAE 服务端 Docker 部署包说明 + +本文从“维护者打包”到“使用者部署”完整说明 SimCAE 服务端 Docker 包的交付流程。 + +服务端 Docker 包用于把 SimCAE 自动升级服务端部署到你的服务器。包里已经包含后端 API、pure-admin(Vue3 + Element Plus)管理后台、MinIO 对象存储、初始化工具、配置模板、签名密钥和 Docker 编排文件;目标服务器即使不能联网,也可以按本文完成部署。 + +如果你是维护者,要先按“一、维护者:生成 Docker 离线部署包”打包。如果你已经拿到 `SimCAEServerDockerPackage.tar.gz`,直接从“三、使用者:最小部署流程”开始。 + +## 一、维护者:生成 Docker 离线部署包 + +在服务端源码所在机器上执行。通常这台机器是 Ubuntu,并且已经安装 Docker。 + +进入服务端源码目录: + +```bash +cd update-server +``` + +执行打包脚本: + +```bash +bash ./scripts/package-offline-server.sh \ + --version 0.1.0 \ + --output-dir ./dist/SimCAEServerDockerPackage +``` + +脚本会做这些事情: + +```text +1. 构建 simcae-update-server:0.1.0 镜像。 +2. 拉取 MinIO 和 MinIO Client 镜像。 +3. 复制 docker-compose.yml、.env.example、签名密钥和部署说明。 +4. 把 API、MinIO、MinIO Client 三个镜像保存到 images/simcae-server-all-images_0.1.0.tar。 +5. 生成完整目录 dist/SimCAEServerDockerPackage/。 +6. 生成最终交付压缩包 dist/SimCAEServerDockerPackage.tar.gz。 +``` + +打包完成后,重点确认这两个输出: + +```text +dist/SimCAEServerDockerPackage/ +dist/SimCAEServerDockerPackage.tar.gz +``` + +通常只需要把下面这个文件发给部署人员或拷贝到目标服务器: + +```text +dist/SimCAEServerDockerPackage.tar.gz +``` + +如果只是重新打包已有镜像,可以使用: + +```bash +bash ./scripts/package-offline-server.sh \ + --version 0.1.0 \ + --output-dir ./dist/SimCAEServerDockerPackage \ + --skip-build +``` + +如果打包机器不能联网,但 MinIO 镜像已经提前存在本机,可以使用: + +```bash +bash ./scripts/package-offline-server.sh \ + --version 0.1.0 \ + --output-dir ./dist/SimCAEServerDockerPackage \ + --skip-pull +``` + +注意:`keys/manifest_private_key.pem` 会被放进部署包,用于服务端发布版本时签名 Manifest。这个私钥必须保护好,不要公开上传。 + +## 二、这个包里面有什么 + +解压后目录结构如下: + +```text +SimCAEServerDockerPackage/ + README.md 当前说明文档 + docker-compose.yml 容器编排文件 + .env.example 环境变量模板 + load-images.sh 导入 Docker 镜像并创建 .env 的脚本 + images/ + simcae-server-all-images_0.1.0.tar Docker 镜像包,包含 api、minio、minio-init + keys/ + manifest_private_key.pem 服务端 Manifest 签名私钥 + manifest_public_key.pem 与客户端配套的验签公钥 +``` + +这些文件的关系可以这样理解: + +```text +images/*.tar 程序镜像包,相当于安装材料 +docker-compose.yml 容器部署图纸,说明启动哪些服务、端口怎么映射、目录怎么挂载 +.env 你的实际配置,第一次运行 load-images.sh 时由 .env.example 生成 +keys/ 发布版本时用于签名 Manifest,客户端用对应公钥验签 +runtime/ 启动后自动生成,保存服务端数据库、上传临时文件、崩溃报告等 +minio_data/ 启动后自动生成,保存升级包文件 +``` + +## 三、使用者:最小部署流程 + +先把 `SimCAEServerDockerPackage.tar.gz` 拷贝到要部署的 Ubuntu 服务器上,然后执行: + +```bash +tar -xzf SimCAEServerDockerPackage.tar.gz +cd SimCAEServerDockerPackage +pwd +ls +``` + +你需要确认当前目录就是解压后的 `SimCAEServerDockerPackage` 目录,并且能看到: + +```text +docker-compose.yml +.env.example +load-images.sh +images/ +keys/ +``` + +后面所有 `docker compose` 命令都要在这个目录执行,因为 `docker-compose.yml` 和 `.env` 都在这里。 + +继续在 `SimCAEServerDockerPackage` 目录执行: + +```bash +bash ./load-images.sh +``` + +这个脚本会做两件事: + +```text +1. docker load 导入 images/simcae-server-all-images_0.1.0.tar 里的镜像 +2. 如果当前目录没有 .env,就从 .env.example 复制一份 .env +3. 自动创建 runtime/、minio_data/ 等运行目录,并尽量修正目录权限 +``` + +然后修改 `.env`: + +```bash +nano .env +``` + +最少只需要把 `MINIO_PUBLIC_ENDPOINT` 改成 Windows 客户端能访问到的服务器地址: + +```text +MINIO_PUBLIC_ENDPOINT=http://你的服务器IP:9000 +``` + +例如服务器 IP 是 `192.168.229.128`: + +```text +MINIO_PUBLIC_ENDPOINT=http://192.168.229.128:9000 +``` + +启动服务: + +```bash +docker compose up -d +``` + +查看状态和后端日志: + +```bash +docker compose ps +docker compose logs -f api +``` + +浏览器访问: + +```text +后台/API: http://你的服务器IP:8000/ +MinIO 控制台: http://你的服务器IP:9001/ +``` + +如果服务器开启防火墙,至少放行: + +```text +8000 后台/API +9000 客户端下载升级文件 +9001 MinIO 控制台,可选 +``` + +## 四、.env 配置怎么填 + +`.env.example` 里已经给了一组能直接试跑的默认值。下面按重要程度说明。 + +### 必须确认或修改 + +| 字段 | 是否必填 | 默认能否直接用 | 怎么填 | +| --- | --- | --- | --- | +| `MINIO_PUBLIC_ENDPOINT` | 必填 | 不能直接用于正式环境 | 改成 Windows 客户端能访问到的 MinIO 地址,格式是 `http://服务器IP:9000`。客户端会用这个地址下载升级文件。 | +| `SERVER_PORT` | 必填 | 可以 | 后台/API 端口,默认 `8000`。如果服务器 8000 被占用,可以改成其他端口。 | +| `PUBLIC_API_BASE_URL` | 可不填 | 可以 | 管理页生成客户端 `app_config.json` 时使用的后端 API 地址。不填时自动使用当前访问后台的地址;如果经过域名、反向代理或端口映射,建议填成客户端实际能访问的地址,例如 `http://服务器IP:8000`。 | +| `RELEASE_MAIN_EXECUTABLE` | 必填 | SimCAE 默认可以 | 发布包里主程序的相对路径。当前 SimCAE 发布根目录下主程序是 `bin/SimCAE.exe`,所以默认是这个。 | +| `CLIENT_API_TOKEN` | 必填 | 可以 | 客户端访问服务端 API 的令牌。必须和客户端 `config/app_config.json` 里的 `client_token` 完全一致。 | +| `ADMIN_TOKEN` | 必填 | 可以 | 管理后台登录令牌。网页登录时输入这个值。网页里“更改管理员令牌”成功后,会写回当前目录的 `.env`。 | +| `LICENSE_KEY_ENCRYPTION_SECRET` | 必填 | 可以 | 后台授权列表显示 License Key 时使用的加密密钥。正式部署建议修改,并且部署后长期保持不变;如果后续改掉它,旧 License 仍可用于客户端校验,但后台无法再显示旧 License Key 原文。 | +| `MINIO_ACCESS_KEY` | 必填 | 可以 | MinIO 用户名。默认可试跑,正式环境建议改。 | +| `MINIO_SECRET_KEY` | 必填 | 可以 | MinIO 密码。默认可试跑,正式环境建议改。 | + +### 通常不用改 + +| 字段 | 作用 | 默认值说明 | +| --- | --- | --- | +| `SIMCAE_UPDATE_SERVER_IMAGE` | API 镜像名称 | 打包脚本会自动写成当前版本,例如 `simcae-update-server:0.1.0`。 | +| `APP_UID` / `APP_GID` | API 容器写入 `runtime/` 时使用的用户 ID | `load-images.sh` 会尽量自动改成当前服务器用户。遇到权限问题时再检查。 | +| `SERVICE_TITLE` | 管理后台标题 | 默认 `SimCAE Update Service`。 | +| `CORS_ALLOW_ORIGINS` | 跨域来源 | 内网部署保持 `*` 即可。 | +| `TARGET_PLATFORM` / `TARGET_ARCH` | 发布包目标平台 | 当前客户端是 Windows x64,默认 `windows` / `x64`。 | +| `SIGNING_KEY_ID` | Manifest 签名密钥编号 | 默认 `manifest-key-v1`。只有更换签名体系时才需要改。 | +| `MINIO_API_PORT` | MinIO 文件下载端口 | 默认 `9000`。客户端下载升级文件要能访问这个端口。 | +| `MINIO_CONSOLE_PORT` | MinIO 控制台端口 | 默认 `9001`。不需要控制台时可以不开放到外部。 | +| `MINIO_BUCKET` | MinIO 存储桶名 | 默认 `updates`。 | +| `SIGN_EXPIRE_MIN` | 升级文件下载链接有效期 | 默认 `60` 分钟。 | +| `MINIO_CONNECT_TIMEOUT_SEC` / `MINIO_READ_TIMEOUT_SEC` / `MINIO_RETRY_TOTAL` / `MINIO_HEALTH_TIMEOUT_SEC` | MinIO 连接超时与重试 | 默认适合内网部署。 | + +### 发布保护参数 + +这些字段用于防止上传超大目录导致服务器磁盘、内存压力过大。默认一般不用改。 + +| 字段 | 作用 | 默认值 | +| --- | --- | --- | +| `PUBLISH_MAX_REQUEST_MB` | 单次发布请求最大大小,单位 MB | `4096` | +| `PUBLISH_MAX_FILES` | 单次发布最多文件数 | `20000` | +| `PUBLISH_MAX_FIELDS` | 表单字段数上限,通常要大于文件数 | `20100` | +| `UPLOAD_SPACE_RESERVE_MB` | 上传时额外保留的磁盘空间,单位 MB | `256` | + +如果正式发布目录超过 4GB,可以在确认服务器磁盘空间足够后调大 `PUBLISH_MAX_REQUEST_MB`。如果页面提示空间不足,优先清理旧版本、扩容磁盘,或把服务端部署到更大的数据盘。 + +### 崩溃报告接口参数 + +| 字段 | 是否必填 | 默认能否直接用 | 怎么填 | +| --- | --- | --- | --- | +| `CRASH_SERVICE_VERSION` | 必填 | 可以 | 崩溃报告接口版本,默认 `1.0.0`。 | +| `CRASH_REPORT_TOKEN` | 必填 | 可以 | 客户端上传崩溃报告时使用的令牌。正式环境建议改。 | +| `CRASH_SYMBOL_TOKEN` | 必填 | 可以 | 上传符号文件时使用的令牌。正式环境建议改。 | +| `CRASH_ADMIN_TOKEN` | 可不填 | 可以 | 崩溃报告管理令牌。不填时使用 `ADMIN_TOKEN`。 | +| `CRASH_METADATA_MAX_KB` | 必填 | 可以 | 崩溃报告 metadata 最大大小。 | +| `CRASH_MINIDUMP_MAX_MB` | 必填 | 可以 | minidump 最大大小。 | +| `CRASH_ATTACHMENTS_MAX_MB` | 必填 | 可以 | 附件最大大小。 | +| `CRASH_REQUEST_MAX_MB` | 必填 | 可以 | 崩溃报告完整请求最大大小。 | +| `CRASH_SYMBOLS_MAX_MB` | 必填 | 可以 | 符号文件最大大小。 | + +## 五、客户端配置要同步哪些值 + +推荐直接使用后台页面生成: + +```text +1. 登录后台 +2. 选择应用和渠道 +3. 如需预置授权,先创建或选择一个 License,页面会自动把可查看的 License 填入“客户端配置生成” +4. 打开“客户端配置生成” +5. 点击“生成配套配置” +6. 点击“复制配置” +7. 粘贴到客户端 bin/config/app_config.json +``` + +客户端 `config/app_config.json` 至少要和服务端保持这两个值一致: + +```json +{ + "api_base_url": "http://你的服务器IP:8000", + "client_token": "和服务端 CLIENT_API_TOKEN 一样" +} +``` + +如果服务端 `.env` 里保持默认: + +```text +CLIENT_API_TOKEN=SimCAEClientToken2026 +``` + +客户端就填: + +```json +"client_token": "SimCAEClientToken2026" +``` + +`api_base_url` 是后端 API 地址,走 `SERVER_PORT`,不是 MinIO 地址。 + +## 六、发布新版本 + +进入后台后,“发布新版本”支持两种方式: + +```text +1. 选择软件发布根目录,例如 SimCAE 目录,目录内应包含 bin/SimCAE.exe +2. 上传压缩发布包,支持 zip、tar.gz、tgz、tar.bz2、tbz2、rar +``` + +压缩发布包上传到服务器后,服务端会先解压,再校验是否包含 `.env` 中 `RELEASE_MAIN_EXECUTABLE` 指定的主程序路径。当前默认是: + +```text +RELEASE_MAIN_EXECUTABLE=bin/SimCAE.exe +``` + +`.rar` 需要服务器镜像内有 rar 解压工具。当前 Docker 镜像已内置 `bsdtar`;如果某些 rar 变体仍然解压失败,请改用 zip/tar.gz/tar.bz2,或在服务器镜像中补充 `unrar`/`7z`。 + +## 七、常用命令 + +确认你仍然在 `SimCAEServerDockerPackage` 目录: + +```bash +pwd +``` + +启动: + +```bash +docker compose up -d +``` + +查看状态: + +```bash +docker compose ps +``` + +查看后端日志: + +```bash +docker compose logs -f api +``` + +查看所有容器日志: + +```bash +docker compose logs -f +``` + +重启后端: + +```bash +docker compose restart api +``` + +停止服务: + +```bash +docker compose down +``` + +如果提示 `docker: 'compose' is not a docker command`,说明服务器没有安装 Docker Compose plugin,需要先安装它。 + +## 八、数据、备份和迁移 + +运行后会生成这些目录或文件: + +```text +.env 当前服务器实际配置 +runtime/ 服务端数据库、上传临时目录、崩溃报告等 +minio_data/ MinIO 对象数据,也就是上传后的升级文件 +keys/ Manifest 签名密钥 +``` + +备份或迁移正式环境时,重点备份: + +```text +.env +runtime/ +minio_data/ +keys/ +docker-compose.yml +``` + +不要只备份容器。容器可以由镜像重新创建,真正重要的数据在上面这些挂载目录里。 + +## 九、常见问题 + +### 启动后看不到后端日志 + +`docker compose up -d` 是后台启动,不会持续打印日志。用下面命令看后端日志: + +```bash +docker compose logs -f api +``` + +### API 容器反复重启,提示 Permission denied: '/data/uploads' + +这是 `runtime/` 目录权限不对。进入 `SimCAEServerDockerPackage` 目录后执行: + +```bash +docker compose down +mkdir -p runtime/uploads runtime/upload_spool runtime/crash_storage +sudo chown -R $(id -u):$(id -g) runtime +docker compose up -d +``` + +### 网页能打开,但客户端提示令牌校验失败 + +检查客户端 `config/app_config.json`: + +```json +"client_token": "..." +``` + +必须和服务端 `.env`: + +```text +CLIENT_API_TOKEN=... +``` + +完全一致。 + +### 客户端能连 API,但下载升级文件失败 + +检查 `.env`: + +```text +MINIO_PUBLIC_ENDPOINT=http://你的服务器IP:9000 +``` + +这个地址必须从 Windows 客户端能访问。还要确认服务器防火墙放行了 `9000` 端口。 + +## 十、安全提醒 + +`keys/manifest_private_key.pem` 是服务端签 Manifest 的私钥,必须保护好。客户端 SDK 里的 `manifest_public_key.pem` 必须和这个私钥配套,否则客户端会 Manifest 验签失败。 + +默认 token 和默认 MinIO 密码可以直接试跑。正式部署建议改掉,避免多个环境共用同一套公开示例值。 diff --git a/admin-ui/.browserslistrc b/admin-ui/.browserslistrc new file mode 100644 index 0000000..40bd99c --- /dev/null +++ b/admin-ui/.browserslistrc @@ -0,0 +1,4 @@ +> 1% +last 2 versions +not dead +not ie 11 \ No newline at end of file diff --git a/admin-ui/.dockerignore b/admin-ui/.dockerignore new file mode 100644 index 0000000..0376edd --- /dev/null +++ b/admin-ui/.dockerignore @@ -0,0 +1,21 @@ +node_modules +.DS_Store +dist +dist-ssr +*.local +.eslintcache +report.html + +yarn.lock +npm-debug.log* +.pnpm-error.log* +.pnpm-debug.log +tests/**/coverage/ + +# Editor directories and files +.idea +*.suo +*.ntvs* +*.njsproj +*.sln +tsconfig.tsbuildinfo diff --git a/admin-ui/.editorconfig b/admin-ui/.editorconfig new file mode 100644 index 0000000..ea6e20f --- /dev/null +++ b/admin-ui/.editorconfig @@ -0,0 +1,14 @@ +# http://editorconfig.org +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +insert_final_newline = false +trim_trailing_whitespace = false diff --git a/admin-ui/.env b/admin-ui/.env new file mode 100644 index 0000000..09344c1 --- /dev/null +++ b/admin-ui/.env @@ -0,0 +1,5 @@ +# 平台本地运行端口号 +VITE_PORT = 8848 + +# 是否隐藏首页 隐藏 true 不隐藏 false (勿删除,VITE_HIDE_HOME只需在.env文件配置) +VITE_HIDE_HOME = false diff --git a/admin-ui/.env.development b/admin-ui/.env.development new file mode 100644 index 0000000..90d1146 --- /dev/null +++ b/admin-ui/.env.development @@ -0,0 +1,8 @@ +# 平台本地运行端口号 +VITE_PORT = 8848 + +# 开发环境读取配置文件路径 +VITE_PUBLIC_PATH = / + +# 开发环境路由历史模式(Hash模式传"hash"、HTML5模式传"h5"、Hash模式带base参数传"hash,base参数"、HTML5模式带base参数传"h5,base参数") +VITE_ROUTER_HISTORY = "hash" diff --git a/admin-ui/.env.production b/admin-ui/.env.production new file mode 100644 index 0000000..84e6086 --- /dev/null +++ b/admin-ui/.env.production @@ -0,0 +1,13 @@ +# 线上环境平台打包路径 +VITE_PUBLIC_PATH = / + +# 线上环境路由历史模式(Hash模式传"hash"、HTML5模式传"h5"、Hash模式带base参数传"hash,base参数"、HTML5模式带base参数传"h5,base参数") +VITE_ROUTER_HISTORY = "hash" + +# 是否在打包时使用cdn替换本地库 替换 true 不替换 false +VITE_CDN = false + +# 是否启用gzip压缩或brotli压缩(分两种情况,删除原始文件和不删除原始文件) +# 压缩时不删除原始文件的配置:gzip、brotli、both(同时开启 gzip 与 brotli 压缩)、none(不开启压缩,默认) +# 压缩时删除原始文件的配置:gzip-clear、brotli-clear、both-clear(同时开启 gzip 与 brotli 压缩)、none(不开启压缩,默认) +VITE_COMPRESSION = "none" \ No newline at end of file diff --git a/admin-ui/.env.staging b/admin-ui/.env.staging new file mode 100644 index 0000000..65b57e3 --- /dev/null +++ b/admin-ui/.env.staging @@ -0,0 +1,16 @@ +# 预发布也需要生产环境的行为 +# https://cn.vitejs.dev/guide/env-and-mode.html#modes +# NODE_ENV = development + +VITE_PUBLIC_PATH = / + +# 预发布环境路由历史模式(Hash模式传"hash"、HTML5模式传"h5"、Hash模式带base参数传"hash,base参数"、HTML5模式带base参数传"h5,base参数") +VITE_ROUTER_HISTORY = "hash" + +# 是否在打包时使用cdn替换本地库 替换 true 不替换 false +VITE_CDN = true + +# 是否启用gzip压缩或brotli压缩(分两种情况,删除原始文件和不删除原始文件) +# 压缩时不删除原始文件的配置:gzip、brotli、both(同时开启 gzip 与 brotli 压缩)、none(不开启压缩,默认) +# 压缩时删除原始文件的配置:gzip-clear、brotli-clear、both-clear(同时开启 gzip 与 brotli 压缩)、none(不开启压缩,默认) +VITE_COMPRESSION = "none" diff --git a/admin-ui/.gitignore b/admin-ui/.gitignore new file mode 100644 index 0000000..423ed2b --- /dev/null +++ b/admin-ui/.gitignore @@ -0,0 +1,22 @@ +node_modules +.DS_Store +dist +dist-ssr +*.local +.eslintcache +report.html +vite.config.*.timestamp* + +yarn.lock +npm-debug.log* +.pnpm-error.log* +.pnpm-debug.log +tests/**/coverage/ + +# Editor directories and files +.idea +*.suo +*.ntvs* +*.njsproj +*.sln +tsconfig.tsbuildinfo \ No newline at end of file diff --git a/admin-ui/.husky/commit-msg b/admin-ui/.husky/commit-msg new file mode 100755 index 0000000..5ee2d16 --- /dev/null +++ b/admin-ui/.husky/commit-msg @@ -0,0 +1,8 @@ +#!/bin/sh + +# shellcheck source=./_/husky.sh +. "$(dirname "$0")/_/husky.sh" + +PATH="/usr/local/bin:$PATH" + +npx --no-install commitlint --edit "$1" \ No newline at end of file diff --git a/admin-ui/.husky/common.sh b/admin-ui/.husky/common.sh new file mode 100644 index 0000000..5f0540b --- /dev/null +++ b/admin-ui/.husky/common.sh @@ -0,0 +1,9 @@ +#!/bin/sh +command_exists () { + command -v "$1" >/dev/null 2>&1 +} + +# Workaround for Windows 10, Git Bash and Pnpm +if command_exists winpty && test -t 1; then + exec < /dev/tty +fi diff --git a/admin-ui/.husky/pre-commit b/admin-ui/.husky/pre-commit new file mode 100755 index 0000000..6e229ea --- /dev/null +++ b/admin-ui/.husky/pre-commit @@ -0,0 +1,10 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" +. "$(dirname "$0")/common.sh" + +[ -n "$CI" ] && exit 0 + +PATH="/usr/local/bin:$PATH" + +# Perform lint check on files in the staging area through .lintstagedrc configuration +pnpm exec lint-staged \ No newline at end of file diff --git a/admin-ui/.lintstagedrc b/admin-ui/.lintstagedrc new file mode 100644 index 0000000..ebf359a --- /dev/null +++ b/admin-ui/.lintstagedrc @@ -0,0 +1,20 @@ +{ + "*.{js,jsx,ts,tsx}": [ + "prettier --cache --ignore-unknown --write", + "eslint --cache --fix" + ], + "{!(package)*.json,*.code-snippets,.!({browserslist,npm,nvm})*rc}": [ + "prettier --cache --write--parser json" + ], + "package.json": ["prettier --cache --write"], + "*.vue": [ + "prettier --write", + "eslint --cache --fix", + "stylelint --fix --allow-empty-input" + ], + "*.{css,scss,html}": [ + "prettier --cache --ignore-unknown --write", + "stylelint --fix --allow-empty-input" + ], + "*.md": ["prettier --cache --ignore-unknown --write"] +} diff --git a/admin-ui/.markdownlint.json b/admin-ui/.markdownlint.json new file mode 100644 index 0000000..d628d44 --- /dev/null +++ b/admin-ui/.markdownlint.json @@ -0,0 +1,11 @@ +{ + "default": true, + "MD003": false, + "MD033": false, + "MD013": false, + "MD001": false, + "MD025": false, + "MD024": false, + "MD007": { "indent": 4 }, + "no-hard-tabs": false +} diff --git a/admin-ui/.npmrc b/admin-ui/.npmrc new file mode 100644 index 0000000..dddf8bc --- /dev/null +++ b/admin-ui/.npmrc @@ -0,0 +1,4 @@ +shell-emulator=true +shamefully-hoist=true +enable-pre-post-scripts=false +strict-peer-dependencies=false \ No newline at end of file diff --git a/admin-ui/.nvmrc b/admin-ui/.nvmrc new file mode 100644 index 0000000..c07f712 --- /dev/null +++ b/admin-ui/.nvmrc @@ -0,0 +1 @@ +v22.20.0 \ No newline at end of file diff --git a/admin-ui/.prettierrc.js b/admin-ui/.prettierrc.js new file mode 100644 index 0000000..775d970 --- /dev/null +++ b/admin-ui/.prettierrc.js @@ -0,0 +1,9 @@ +// @ts-check + +/** @type {import("prettier").Config} */ +export default { + bracketSpacing: true, + singleQuote: false, + arrowParens: "avoid", + trailingComma: "none" +}; diff --git a/admin-ui/.stylelintignore b/admin-ui/.stylelintignore new file mode 100644 index 0000000..0c34e61 --- /dev/null +++ b/admin-ui/.stylelintignore @@ -0,0 +1,4 @@ +/dist/* +/public/* +public/* +src/style/reset.scss \ No newline at end of file diff --git a/admin-ui/Dockerfile b/admin-ui/Dockerfile new file mode 100644 index 0000000..cd6d51a --- /dev/null +++ b/admin-ui/Dockerfile @@ -0,0 +1,20 @@ +FROM node:20-alpine as build-stage + +WORKDIR /app +RUN corepack enable +RUN corepack prepare pnpm@latest --activate + +RUN npm config set registry https://registry.npmmirror.com + +COPY .npmrc package.json pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile + +COPY . . +RUN pnpm build + +FROM nginx:stable-alpine as production-stage + +COPY --from=build-stage /app/dist /usr/share/nginx/html +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/admin-ui/LICENSE b/admin-ui/LICENSE new file mode 100644 index 0000000..6d4889d --- /dev/null +++ b/admin-ui/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020-present, pure-admin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/admin-ui/README.en-US.md b/admin-ui/README.en-US.md new file mode 100644 index 0000000..104b8d5 --- /dev/null +++ b/admin-ui/README.en-US.md @@ -0,0 +1,39 @@ +
E>{Tx+r&IV+)YybXt5haUXyrZML=! zh7aObrHp{F5>0CHq@BMx98P3HftXS+y^t;d_0&%)UyB&tri^wLu~EQ zUAp3!RI$- bblgkb(zQ=^4F4vIEWoxp$-+3f; zBF;AsD#fz>%yj*2lqEW?n~J*5xI;onG7{m(h$LG+%&R5oyS``Lx_ye0-Z98~*P@a( zT%S8XCnz_|MgC}cC^Nad6C2gBdu8W_okFEV$^BfLo+pV8k|J}VNa@9yy2*-q&-G;! z8d`{x=&z;{@U2}_CeF^FWvy8S4`2Dit7NmJ-B=O6qTy}GZvB})AR-czwp!n;I*}rd zPKKTBNcQ+MW#IPTAGI#b1bS`El=Xc)#lJq>xFK-riqMfeiU~qM_xAUXMBU{NW3bq3 ztbo#Zj)K8@|LztY*@CPLEu+&6nimZ<#?7RSS9kuS*6k%2;9Q`VUvvAXFqW+8ck!!j z_AfrwMtR+O-(v@FAEmghA1(>}E>+_5 wKBG=@j^ V@mfEAV zVErbYp;mKDh1!|TihGA#3x5DetM?^DO~0+eD{DLR+Zn5G=U!YH^1sYK;8+)3`BS8J zV8%=N$xx-ZS &5WPdG|(OsOTD%%u2E}KZF5acb8SR)NKw{}dgRsKh;l2j zYN_AYQ`r*7kADj5C%(#_+BhC!k&*^Ozs;C^>{+PSzZ#H#2D^>!l#2H$P-kk~RbbCZ zR=hk+7UR H zb>y{Dj+-g|%aL`5F)8B2oZYr_*7r9zLE&`)1sLNHX3`%2%Y4PJ#7q~phfb`MDvS@x zwmlGs-DB5?NT|J*vM-HE`K3+h?&p(%jIq7Mg7W9>@3mh@iuel*E4so?oy zOovul8=p#BD#N81@SCk=_XV2XV@icyEGqU#F+%uv$^2}%Wj52KTW6tIU_^XgXSH4M zaiXN3jl8n(o@>h2@6H2s9H+1(%)!ToZtJGAAC`xhd%Q3Gr^#T|ZcGMQHFwz8yYOi8 zecEY?#Ps>E)5CpLN!KzsA*xtGC~B6^?ooYFA$K;aJpyjn{g!|}Bb6{a z%?V{jn{_*0E%ZhE)h@a35H|EzP?+U01ECA~+~>qETVD1X!mW#*@)F3q+%JYhK*O 9uT-@I-xsN{ R=idgZ?k_l5P0&-`WSeYMGPMEiBi z*_#pTH66USlFC)F)r{qE#KkRj&FVVZDwOQmLpc?yQtp5ovj|cve&FOdg(_^{c`B}- z!@@|ro00cLIX=MaP&p^jO5@nkH pMkxR+;yLh1zv& z<24(*;=U*z@|X1culIny1>oi*o%*TKxla(2st6Q$zc*11z8hmK#1q29=y}QE73Tw# zY=mU~tv~P}(e^d~3O;X$1RDUX1fhYTk6|$wj9D81umLg98Nq-+A?du4z`uPMtT -MZBN;hzs- zwewc>BW@bjLuW6iG7f)$FA_tNtiwmmc`Td9$AY*m*(bVy&+LSRuT7uS$J!NY@2NcF z6p=G_)7W;=N2&@ES>y-1 S0n1zEhYL(@_y0h81bp5v`i$&u?>CLbsWGcYk&V zwg(?6-51o?{8AO4OAMdkES0_s(VZxDAr8JN{!C5X!X36-Ej1d@LuWhRxZUR r)1$lk?ccClsDD$6ydJL(rSy5oJs)ffH@KXthF*5KK~W*~ 3X9qhQpA1oy4pN5fivqhr-VkY97!yvS zIIyecWI*&8`V)Z yPj>fwXIQ2sN}~}XjY>%jkLTNaNf8YB{ZaQ1-4|I*Kyv3~b1<=gpf5dDC!l>m zF3iW@eEYfj|1b|t56Ogr$9yvTCX>U&;vS*UOyWX9R6~SNdF%8}EI^*-9zQLXMVN%= zl1X3VDh_;!K%;g0+WY6X53q47QUhDM#LU3y8@IU?Fx(nSLPDsQYta*ncvnMuNS}B! zem?%&42+~E6Sq1zKhyAa=35pjf&AsZya}{(LJcyOTjq+( )KvfE??(DNCiz9FPwJg`#HygM1P~NSgOE@(Q71{+rGmgG9U%5y zIwl^!9CtoR)MduGRXipg5su1Z_uDrM{H6 hZNfoKmsJ|*y*V+^mER;BJs&ls zM6%Nk+E=3qHwi_JAV!5To@`)
nb{ zsA+^mD=C|3T7;Z+F`1xgi6nKJPzY9HSdC$1Hk~4!SVm=8ZCwO l1OEeF{+E)-*){1hrT95R!!&qv>0wyEq9ZGIdIX#3`YoyyZM8 z)>U=RLljdj(at>2v`5vE@3E{_I1C+3Iw5>-c9xXQs+;7Oi18?4KTI#^QT4Uft{!hQ zgyLJoJCX0=w~E#owzu)U*!ha_#5Tf+yoybV77ooUoVjHd^&D>4#%D_(X9Y6@*`KE4 z+VFGd3oo48!bg022*Hg=1OGhnkazI5ZSUFa){P%$eUkn`%7Z`nXsApW#1h;`Y+&C- zM-m*gDNU?HW6-53ONe*zC3tl@4tl?1hnt(7P21Uk l_=?DG#m9b_GSNLpP~l=`)MIP6N9DS *(^#>U_Ss2F=#MHiXvnDFaw$N;8#uQx;|TKh(mm zxca!ds+8K4E>l|J3h?@tf&YC{%hwNR>NBmF`^|rH-SVkT(`gh;3G6-XNm{Fo$$tKo zjlYyWUK-^$52>D9HvjQ+fsZD(j7qHeeAJ9MGj;{ ?;I*e@#zFFYIZ6rA`+mbWXs)acuD7-~ik2LzSW7%GeEiKzd3 z#!N#1=ve&;*K!Ue`Mlfr7eQOZvz98MAkPHN2n`D$7=Ajn(rKe0{P|&W=a+Afp&$jQ zz1w!|8Tg;6FnWSxYxegI89g@f_pFs-Ze0AQNYd|h#E*+zTlrID(`sp4Fz(ZUgoUto z!Ap3^y@`){>Hl8{H*mj#j7z{P_;{D!H6HhYD~|D~Ii0)PE@a?bI)=Xkjp~k&RrUw- zDyGDHlf0l`wuW#YWfTUlcZz*TpD2a!cd09l4;$A= $@7 zpb`=30hgt|^dyWWQSjMb5;(k_J6x&i3|qIpekZSzssfTV++Gt86H`gN-A3fJG&3#D zMB2gdN)2OiS GalQW#!QGeo9UbhU=|SDqPviP!T)(9DtJq$BB%eFb^hI)^61ClyJAgedYmXJkIqsb Id!;N zUH7+LP(z`JL*k2Fr)1CsuJL?*JE|&17q}pm=2=Q^d$D#Nb;w+C4JQI3BrUUsr1z{j zg*A-t o>eE^uoNoo^vrwP|L^ yMK>0RIu*}lIU1d93(1#3g?ar_ee*7YMnW&~cDquE8s$#N*sa)dr=eE>0000mD!%Oi literal 0 HcmV?d00001 diff --git a/admin-ui/src/assets/login/avatar.svg b/admin-ui/src/assets/login/avatar.svg new file mode 100644 index 0000000..a63d2b1 --- /dev/null +++ b/admin-ui/src/assets/login/avatar.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin-ui/src/assets/login/bg.png b/admin-ui/src/assets/login/bg.png new file mode 100644 index 0000000000000000000000000000000000000000..8cdd3001fc95b63c1d334ab7d717812f4240839e GIT binary patch literal 17468 zcmd6Oc|gtC-~YLFQ3+XQTw_Z}40TgVX}M$>=B8%CEX1u8p+$>I#fKrxM16-D33bhP zgs-g`OH}G(7#V|_v4l$3ShCg~p(J|V=Y7uS-k#_8%=7oJKW6SZ=Y8JG>%E-qV}t+n zDP1f+upoqVnflShPYLNH5F#II))9IhUODXp|LYSs>GQaNsIa(%pqNlHetuMNsP)u{ zpar3yh6c?~TKZe42O*}m;WIvu``lOU9ugH{A4F;F6C c6qVXohtK!4`D~1@(l^>CbYb{M%VR @upACqMnIGo<>BPxg7_>ZX7RJRzyE`}}BqZ1; zIN3+VEO2mib8~Z0j&>M5dK6HMicN})3rZXn89P|wFflYXBqlsME<7sInsN*Zj*5@- zv;kF_4H3}>wvn+MCqT?0F(}%>(OyYyk_df$|8LTW2m^I&+^3;Neg9X+u``mQLmfU1 zjg5+r2?5WA4W^=^-F;$0gW{rMW<*6Tkzn*+7!?;4yD%!+dh}@fG1hjzK_TIhv}ZU@ z@%43|8W|fG6d4jab)u&YFtHC0pYJ|NIcEH1H&-_&SEZ|?<0PN)%85SX$4^!|I*xXp z;5f!rnmaKnBt9ZEGESO1-#B;tKhCA$5D^WMCx*s^FAJSNIVLK?8m)E@|7TrX|B)Y` zJO7_`8T*fO9Y7fe>f3g{mbxH-=uboFLc R)ZY6%4$5aldOr$ cIko!FaPBh}DZsOK3-fdUwMan0rI|uOgmV>WrSI*5cVNLB46D9kl z8d}m(_uX(#b>V^B&@b1pPGMR~7qQXm$}jym(fvgwuN$VzxSqY#gzL5UY=JB>v@4$# z<0j)O`kTuXtW(>gwxcd-eRxn0PBeOqcWcxh$pDL&Atch5tLUFM5Ud=+WrjXCTgc~3 zYgGu9w%lgBD;cuH_k&|BBnhfZgWC)m*;RyqM(^{9&pqB3W*^}Ux0DUmO1S;~Imua< zH7ifz`Elb}?|Iv}Roan$9dy^7B|VZxkmPO>MnOi5Jl+$w4$51b#OW^l;nCRU) mkf`;s)OHGt62uMJ2KNE0cJ1b3CZ-z@}stJP^I>dP$}} z=-Nd*xQ`8y=k3A-`nTqBpPjGTMZK};lEmZigZ17|94q*=KE_a8swR;}st^ S8B0l$r4$ zy|gO38z&rCC)YH{c>J7S-&17$ ~`wXIk@fwcq=Gvh4R zb|Ghbxr=BtYeU{wxqoDLXLB?ufpT4EC+>^%a&rymH7%~YP&dfdkY|&x$GwRCGC(5y z*cc5&3fK*YO!AwgC`&)^NWb9N136=-@Y^bDzO{oj$lXVy!KR+9rSS;th&J(hB96`8 zv~uoZ*qP8jVX4ukhwet3#E%NQ8f{X3o3}1EDPk6%bLVAuNLsF{<>3s(UEP_DDi7rC z$*s!f39k9vSkzi9#L7G#g^F48Fg3RIX0tS7eKWj^#yGH+7~Nti4lwP)Ia&{ss?mr) z7SOU_ujZI2)AQ Wh>vToNE>okYU(A0 U!V7L_&7#RIrwsKg&QOx~p8cF5<;@6H z3EACO76gcVTK^B6NWlY1(@n =bQ$7_9b~WK*L(Xur2QkTMfAlq+)sTQYkW4%nEKNbjlWm z(3G(%vPP=?4P(}>sNXo7gQq|BscF7J`&R8$x>jxwxzyT)7Vn-)#Yc3V)>U+cy})d~ zUhB2PTN}qodjY+-M5r1%t(*86ja5$Qzk=M3m3A_kSyKSZja))J4b(@!9ON}P@6f(` z$xEuIu~E(AMdro=s-8QFmb)IS7-YiK#UonmC7H_bF&%W lUCT^r67u9^p6 0k4}3z+|Q0GS#0O z#r>c6!z=2Fz@kf>cQ5Cj&71lb@DiJ~dbnJ-AL`OUPQR#9eJ?q(npa%yG6@*f(7YbB za9n>T5nfk6EQ+j-WNfuz0T#@uyl!6o&$b&)H7D+~Vb#IB_l2k$UO2V00%$e|JUPxw z<(Ngb%{yHqdBO1i_wbJBgM&P{7F|)lNRFyr&PBNdbP)?8Jz2|t`@W8_Mm)OpOd|eM z-CPt^y@C_l26V+3TFIdlzI5){_O5T61sf;FtPJa!<4aO(=@`+vm=9gmI psUFF7oxme&IjX|9=3=)8;6Uym)o*g$$_Od%x2Vax%F$=9 zX7il2`;N4pn$5QeWOomtG~vDAFSkjf|01nU(wZmOn2X+3YqH)C<2p_oOxl)|6dmH; zC0izw71ai{!sWWMQLri))G`zOs 6VP$w=wyzQH7gB;WxgNry6A2+`204KGS* 6Y?MI$$y}_tA%U#ePO7^YO#K6fZW@L*lj@yMpww_g#n0us`ilvb25 CYgr(x(5Gx2S-1c_n?sqSVNI&Ch#y_v;vR(wmUb76NC!V_9! zXA)g{+?J1~*^nl~s;N<$M7J4-tVvV3v`%WQO!P{_P>U_;c19Y~CYb1>jYC67w*@OL znhgOoPp%7!@KJY`5Kpxd){UtRG@yJ6+6hmjok>L}Sz==Qv9ddcAvalKKf}<@o ^SwJ%OIbU1 z9odm#2%lIp@%4!~33WQQ)i4%dE+*Z~HMuQaR*YRscH|mIVdfWUS&>o(_xqS!z5*I5 zc@Rfi%EtON*4&p&itZ&FJFlQ!y!n`muWtc+gJN6AjsjT148_O?Bos+!+h{Y{*p;-7 zOHuc+TsOLUJNJ6x1~MnDT} }4kgG;W(R zo~bGRXfDQ|v~WDejTFLr2v1_8_@bX>D?8x@Wm5uB>1h(ZGZOZZ93t^3NX{ pftg(z*mOdc3qC cuzVGp3WAO<;>ur}u6p9{n3@;-k1jXnWLWMt}PclO-;%_ms4IJcRg_$0S5Z(Fdo= zJ&z(_C&jh(?=4F#td~} jd2O_? BM&~zSj}`6elZtQe*ZV$G;1{`%G&34{P 3Vm+9OBLUk6)XAv zF79IzX8EM%zQnY`QI_=+Y# tPpb1`6A(Dp2IfxEFf*ES zh kp+aWSXa^RGcOq63 ?2GfU1gKjW5yL=*7)^;MvTlU5* zMKV86MZzo&O;9(5vAOhA6R>nJpX@(QmNoq56x}$Ubp9X8vi2qQ4Zg+KYvBWPQT@l1 z?(BvYTi=;6qg3&7O`e*YpdA5R3o=A$|F(A(u{?6~un|-^GAP21w6lqJ3|KERrJw|+ zt&~p8y?jYpgQ4*;Ny{}frjoQWLt_d_+hb@ GYG{lnX=fxkuo+J~BaO@l zId@YwiU;|H %P$NlNy~(lyM;g zS2ge;6tm=-hnF{Uq_Fk9)L1z8R8rIZfljKr6}5qt^i`*-7n5fBtGVW3+5P&KesERK zR$O71nOKqg^64Oxc~Z|H=y8x7avB|{tnBO %>$uc4
(c z&!TRpzAm%$UsIb<`EpOU!)-2H@r RH1T}UD`bib zPPs`A{9Z0Y-&JE*m0Ps`r8MQ$4wCls!Gy|ru&v
v-r~xFc7#dM78uRh|1OKPa)$RTXa?Z~dONYNWO4e$ykS`vuF3 z14rogex-bGVw#0=?Zwo47MrS91&+TQxp4Bbkzf39bWZ(6cn4Jus!c6$2>bp`KbJ^T zwvfy(>!Q&)8d|^5R$Fz)$nqRF=GSxZY?1RgA{okl08i!g&BSs^aH(g5kGi)sGd+V? zdX#N{Rh^bKUGm%juyM}jSFHnr3zw9y;{2AuquAWXGmo^m=&QNYi(XzSFKzvMYabye zWnuZvYp?(QlaHJM;3Aq2ez8{P#{pJs8m3(pkyw*cWp!m6hbQfBM`eCQ&RP}#iuHX( z@p%8^b#R>cBgZtBnXx1TdNaKW>V6nk(v4N!3U60o)ga$njmJG*2jy+!`nB8#W*=8R z+xEtb1+L0luF+WDK34biaci>_r{#6#3pJK=kJXh9%KPTk(`^h)T~amac iMi zXKdvbpU4G^=R7T-_T0?b$JHzWo|*ffguQfrA%XQF%pEs~EibyxGKJk*D`w~nfP7lx zPk`cDhL~{6OjPY#qd{o2?j}nALVLNxt=OeTrQxbcqkTWOD)x_@U%?> zV(~23)lppKEfYz{MayaqOwjM<%zK!LWPgMFaPGQSJDCXU*(OSHXZ^U-H4(a#4RQvS zTMt22Uzfx((|R0GWW<;IIAmXItSETEoOTYLGZG6EE{%{Ey~j*R|CU(hyA`a)+8}%a zEav9DnHAl8wN&et`I3$g%j#am`S~O`CWbStHNE8;;a!pqJ4)SvcXCaUm-Bu_%J7a% zmnvIgYFQU}WVvnOH>YzivdZ%USl#I2$N+t~69gv*vyi9&bTxAhZ7))!Eann=g8qrA z3(J4@{APdS-M&2PGP(=0=lY+%_*GZGO#f%VA83}C3fg!a%>8<9VSi>7TwRgZvunH7 zU*27H8fp9;WD2dvlc-K@sq!G~*}INd`g@IM+Kd=POs`I{!hjbh!0ZF1gHo;>SgqeU z6^X53=^{-hBkO zr^T8V= z^-0O_R=-}6YnXgRI 63-U%c`n> zy}PG1b&os(CzXI}RNK5 P~bN&P@uEc>d*QrIoew-aRPj zdmiW9wSgzq)aI6Rf4f`Nu5gQdA8oaSNF`}UOJ){tI@{KoOk*9s%7Q 6*Ljf^u92Ows}F=QS@F~-wkiFn z9Y#ZXHcZTS`vh@O3)y!&4N^WRHw{j4yhb3MFV<^@L&HbW)Kt-+}D%F6N0e{RMY z6+^&yAtlalvP0im7G?t|&AlguCyV;0gkaFC08YCzbq_waj7;%HZT~YAXtspEofDTZ zj_2N9SP+SS350*G>sQ-nTD^Lvp$6D(q20OA{lgcY?>%Et?{7tojU2fB+B1#cB&wN8 z26~X|*Zn3XI5z)@ YP--J5G>6^_=fJ+qRE!n{DUHKV~Yau988tc z_h6};z8>VN1{OwjJnW<2jYUo4N@cVh-*lR0F7Jc$C?QoZXy(%1*_hAbG76^UeV^p_ zFFg*+Z|Fc(6yK+^pZ-!5_TK7L2ZUky5RjPvaZS_e-j_yntj$7<=R!IV&B(%GKR}^< zv;oUr;s9#PPmdxyBtW?;SiKeNp0J0iuQ-x%>G3T6F9$(6LK1gSz3o>lE?*nP#GavA z$K0Kb^+Hc5qdD=l7IU5V&pyrTC+! -^U= z?fmyco$vkZi{|NA26oes*M@`QbMSjf>ja8y^`i;V34;@vT5uI_O2no^+plfZu{GrU z2H;M*^~5rN{%hLE;EmO^;a9m9-STk&s7sWSDa$37H-e6YOr8oj3w4ZK@gA5$=1xb^ znqJh3hZ(pkiqoktD@yI 2+*?E-q- z*!cEUaW}_NRk8EC68LM;bp$KY&qO_QYfOc4P#ZR5gecZkUV8jSj73xRb0R5L&2hDz z>LX3(Wz&tj;A r^d-QTh_VmiB$?vK>JJ;lRVgS=EIU(m7B963p=`Z3Q zt9AV_t&CVZ)Eud(X Uaw2^HbpAcCbM8Ible_ZuT?Z!^ZhOAVbI)gWWd(VZ`20E= zL1Jpx?bkL}I3b+(&QQ@h#|A{@-F%v$!cy20Latxj9~R#?aS`jHwLsW&BdQ$kZrFzT z?tSVBO+e){x0gd`Nf(<>P(Lhr{G%saYqMQ?I%4Tj_M>Mh{kDeYx^h5{&=JDwvCom^ zJzCgKwg`5+gy=tA_w1y(qc5VF51Tc}jCJ)>A2tojJBijVE9+SSUaN&1-CV&^Ksk~k zDU|GV-al+t1`{xnhS!a>x360J1ZQuf!AURnAa{IA)A(DXA}xvn=hIw~v#$Y9ic7X4 zI96S&VxJU=Fo#qjwm#dOOFC1Z< #&rQ zd%8;SCpC|FV{n~J> F)K9t~$~kj$WuP36VW@q{Fbdkqq?(-Fc=w_Aq)4CtBZyr- zrJ%hr*Cx^DZWf=jXsxqe@CH=L$0-%GN~qr1vYQmWD`jAJ+TA1<6KyZut&lB0w9|7D zdSUnLnRqe7T0`+`2Vn%Q52}3=K?I@42gs|@c#X0bYxS1tIORWdt3<|F(GMhq5h#9C zp&9$URy!YOZFY^s7#NCCek-vn-8$KQPj$Ka5i _gq+3 I@_&>?SZ*X->1LY<7?xMsG zAH*y86(fw3bOc%-7bwrcD jP~8UN^yKE%B1bd^A#3YWwVqHCeAP*Dc|21aoSGlI;;$*1oZ6GrwUL z1|fJ=jkaf et~3=G=$gbg&%VIqdbpiNN`BwN6VwaP z_!h+b>E)Ghzkv=&MdO$K-b67FZX?U0G2!J*DKFMe=n GJyJD|0(L2 z*vwistQA*lIv1rU+9UOxxofj7Uj%aE0jD~^#;eVoqjH08jEQo3=UP*+jksVEre6t4 zet_D#U{0l1LY^~M?V;xfa_&+<1yGBd5%jw*lD2y=7tS>dIU5&7*}=0ab^U+`gYrz# zSZ}&zBlk|T>Ho&aN$HoC#iR{jqxa$M1k;6{Tmh5Tk!s*Ys Kb&rQb$wBV+a4XdKb `gg(q! z?S-jy6(i2B3`zabt=fLW2IXPes7|3~KVS4wCsUvVcT^{In{y6*4HV$RE$F?U^j>f_ zzTQ5jM?N^J+(Di8!sQjzu)Mdgc3@~eM!UB>0lP&QApYH`Ioc&?j>?mX+R4LEaL16i zUt|W^2zC;9lcc;Mj!)7!)~2Ar{UK~OU4%A)+dv?Dyfla|kaif4{uz)r=umnw2uK9l zPvC}(M$O}IMwM?vZfYD)y*xqP2@QRY(qC>nx*!q^C3eh|J(WC{wIh-K)B{@>6n %wAO;>;(}b zc;BR>$4;69sdW?;N*2Rsk(g7e5(*h9VRG)_htRsk!y@T>BOiMg5lP`V4_AvIvoyXy z!1NSaQRC_bY!r jHXz3Nm9M_wvxMOR)?k9zr!Wa}dCsCuiBz z-l)vhtZl%DoI}6qhC)5>dXaYArAKurMO-LCQG7L=`!OHaMB)G?4I@eeZ*(xJ1t*DS zNc|!R>Kg|0GIW;Kf$@q&173uBpLNEk VrEhIvv^Ff|#MApstRL60S8w$TvMBqEGe`2%^zreP(_AB909im3DP`!jj? zDf=NJ5!r}H8fJICZ_X+jCNjqN$`HO1St4GVsxVB6AT)SM)l|+YKNSW0=o!)iy2Xmo zK0`+ ~18SZ^HZV~86ZgfTcccH5J zL)pY+_(THN&KLL(Ni@A#xQ#+oZ>&X5sy0Mo*&XHCI1T0Wkm+>j1b%Dk$wKqJS{X$m z2iZTp02z)_AF`mczodqV {EjfB|9X+8seUx$0Smr6R(y|KoSC&F zo2r+?Ku|NV>zFHgqOxLV#%JRKEfg{x`=0z u$-WNTjaH2jX zE=)pL06HGt`JpfU<_f-~>YbU&d!(1=`J(>9p>aHt8Au&eKv0_;dW9s596N&k2 zoJfV3G9lZT5InYIZiaBj%|SF ^tRT5%BisLV z$o)ls4JZI#572URXu2;nNG wgy^2#n0GK!h9M>pR=JFl$MWcNM;dpKW;;<6V?sN^K=4AEE%dk=p}kD z2bXaDuq+|F3rduP0lVeSS*kli<-Z;_{Pf6w72{LetAzfeD%DGzG|dqw9mBY=TT2h4 zgud#)?xNQlzL)dIa1*}8#8+Ko^G4&mF>KxjHV@Y>?T}$KinL+qnMpiyRma#gCngR7 zw%f?2v4wzz>oqoVX%`Luy_gcJrLcki5Sspj(dHgWG~d}#k!)cr!!#5&6esV6mmIKf zLs#lB)kc*2b+6#;FCl~J`xNwUxqz`f1D&Uxfs^S~JM40 rJBj?TBf0{T!h~9{~5iv7w;NXzj?@DKPu6=@IV#5WQtrKrGa_uKuf>2 z02LRpk$RDO@GAcMKy4hoog@BTkl@dqSunym^m2gmOwS{Wn6tk!rPaWnUd0pP12tmM z1$A7MzW{aQbc5T|p~xBZ{)=ckZnoNt<<5>J#VD*&TDEMmuhFFuX4>D3J&UB>7!iAa zMHubiZ3}E4^$OL%OAPX#?!oRqQ$_G-PWJwmLjlJ}4WjbJ*k)&VGG-~gw894*0OBmY ziy?OO4Fl-9qGNtzl7GfeAE#m2@c&NI9uNDfh*wCtk5RJlgD=>D75%foiUptOqYkVB zL$|*f!gp&20{to0!x_WQ9$i3zo`&Z-|9>NZZ6OVt77dXLc4juRPTzd>>*-)J^@Puj zz||JZ|1+exc6ylB@NX>EW9`m*sHC#tV TvHmI*{3r%kUYd0mf z#%R#|*jSjr5`spB^4cQwE6&Kfh2~=TiZixjEMvH4U7&Y+B!*M5+@PZApB2KOyjG4O zhrvL*yQ7jJJ1b9p0Xhpk5R~6sFmN!^DbYkES1&`Tkc9SfO@F>dIc{Mj$LS82acNI- zsU68H{Aiu+Qh 6{f&L2?Ei=Cmna=nhYF=@}Jj44MF>bmFkjX@|s4T8zot)lk z_Cd4S(^C?TK@G0$BN$9?&?pOPH~nKq(O=A$iiYC)Gqkey`X9PL7gL@5{xe;B1QTTO zeJCD>I{bIv!#Ec%3>4uv(^c1r1_<*2{NU{17t<4daKikN-sliaYhyS4^^dc~(#FDW zG=lWTe?Eh8NN4>|0kn^C@<7+~IsdHzB>$|Tk%5sc;|%qBG1dn<{?9^ \ No newline at end of file diff --git a/admin-ui/src/assets/status/403.svg b/admin-ui/src/assets/status/403.svg new file mode 100644 index 0000000..ba3ce29 --- /dev/null +++ b/admin-ui/src/assets/status/403.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin-ui/src/assets/status/404.svg b/admin-ui/src/assets/status/404.svg new file mode 100644 index 0000000..aacb740 --- /dev/null +++ b/admin-ui/src/assets/status/404.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin-ui/src/assets/status/500.svg b/admin-ui/src/assets/status/500.svg new file mode 100644 index 0000000..ea23a37 --- /dev/null +++ b/admin-ui/src/assets/status/500.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin-ui/src/assets/svg/back_top.svg b/admin-ui/src/assets/svg/back_top.svg new file mode 100644 index 0000000..f8e6aa0 --- /dev/null +++ b/admin-ui/src/assets/svg/back_top.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin-ui/src/assets/svg/dark.svg b/admin-ui/src/assets/svg/dark.svg new file mode 100644 index 0000000..b5c4d2d --- /dev/null +++ b/admin-ui/src/assets/svg/dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin-ui/src/assets/svg/day.svg b/admin-ui/src/assets/svg/day.svg new file mode 100644 index 0000000..b760034 --- /dev/null +++ b/admin-ui/src/assets/svg/day.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin-ui/src/assets/svg/enter_outlined.svg b/admin-ui/src/assets/svg/enter_outlined.svg new file mode 100644 index 0000000..ab4f9b6 --- /dev/null +++ b/admin-ui/src/assets/svg/enter_outlined.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin-ui/src/assets/svg/exit_screen.svg b/admin-ui/src/assets/svg/exit_screen.svg new file mode 100644 index 0000000..c431a05 --- /dev/null +++ b/admin-ui/src/assets/svg/exit_screen.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin-ui/src/assets/svg/full_screen.svg b/admin-ui/src/assets/svg/full_screen.svg new file mode 100644 index 0000000..b7452e4 --- /dev/null +++ b/admin-ui/src/assets/svg/full_screen.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin-ui/src/assets/svg/keyboard_esc.svg b/admin-ui/src/assets/svg/keyboard_esc.svg new file mode 100644 index 0000000..e128594 --- /dev/null +++ b/admin-ui/src/assets/svg/keyboard_esc.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin-ui/src/assets/svg/system.svg b/admin-ui/src/assets/svg/system.svg new file mode 100644 index 0000000..9ad39a5 --- /dev/null +++ b/admin-ui/src/assets/svg/system.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin-ui/src/assets/table-bar/collapse.svg b/admin-ui/src/assets/table-bar/collapse.svg new file mode 100644 index 0000000..0823ae6 --- /dev/null +++ b/admin-ui/src/assets/table-bar/collapse.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin-ui/src/assets/table-bar/drag.svg b/admin-ui/src/assets/table-bar/drag.svg new file mode 100644 index 0000000..f477f16 --- /dev/null +++ b/admin-ui/src/assets/table-bar/drag.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin-ui/src/assets/table-bar/expand.svg b/admin-ui/src/assets/table-bar/expand.svg new file mode 100644 index 0000000..bb41c35 --- /dev/null +++ b/admin-ui/src/assets/table-bar/expand.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin-ui/src/assets/table-bar/refresh.svg b/admin-ui/src/assets/table-bar/refresh.svg new file mode 100644 index 0000000..140288c --- /dev/null +++ b/admin-ui/src/assets/table-bar/refresh.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin-ui/src/assets/table-bar/settings.svg b/admin-ui/src/assets/table-bar/settings.svg new file mode 100644 index 0000000..4ecd077 --- /dev/null +++ b/admin-ui/src/assets/table-bar/settings.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin-ui/src/assets/user.jpg b/admin-ui/src/assets/user.jpg new file mode 100644 index 0000000000000000000000000000000000000000..a2973ace3367cf7181b470e2814db5a9c06a4533 GIT binary patch literal 3694 zcmV-!4w3OvNk&Fy4gdgGMM6+kP&go34gdfUJpi2nDxd(M06vjGnMtLiq9G@=4G6Fj z31 %=YJ_J#Mqzynh+5qkiBt9n)6vG`w5 z59Yt%zw-M=|Ci+3>i=N>Ew5u=yB^RVw5UFe3RiK5ppxFI5hZzrgl1&wg-n3c))Y%AQ zztZ0A)fc1!SiK%ISX~$|)-hs;X=zAAAD@Z*gWVgzm)%g1+`$}ptJ-~!RpF(M+D9~| zyzKiw5rIMAM4W$##xF)FI@aAEmGoC*qlTH*Y#7}@iW_duD(>JV*{zEr22N+LV {FP<1qq}&(tt4p3;yxmOQ!w+`1j={ETfnRl zb*POc9Om`L-8@f+Gk(-A2)n%p1tC~&oQ?yQ38vTpU!MQ)ux#BxhN6FS+Ip3+kZ zo)vP~0RH)Kb6xxU_v?P7m=(NjXJ{Qq)1;v4mM+fSM86gKRbTl S-Q*o$`VsB;freYXMC&Fq|O+`H_g4W7j#I156=zk-lyK(W;g4Pr^fJEUsrmG@~M< z8YJKtA)lr1dC%-VDU&Z@=;T7n$wm_N*ypE#R{lf>W{F^Cc+%9AvK*ifnV3I72I=~y zrX$rW(zQAbZygSnMX7Y;U5>deXIh31`1g-ozrKYzNYUp04RlM>U>Ij}<*ZZho!<05 zYmb%3O)I4=oaZb8Dw2p_yb?=pr8GA){5J)#o6zoeg0xoY9UXILJq>qq^Hr+Y5_Yag zId(Fll_vTlz>?58^}uZksfi)kv{%Pol8y;#(Z3Qjz(#GqGs97bk8XnB4BC(unp=DF zwb@#fdhkZHt ?382*X9i4H3( zP%33VLUkL7<7cB&pEAN|2jiP_KpfK{BwK(ZQ;sRM)qUC5H(1&2goq|LP tRZv0 uO}(WVRdjlr->H4e|s$?c(t= z0Y+*WC>B3@x61{)IcDz!K7qehBk6sfXnf(5`f*hK;TvIM#Isf=jLMsYL;sj$x!2Y8 z{cbjG*LdoeU*lwo%hL0ALneG>2>`vmeQ(IQ^#t3emMwCnq#_gAJ@P0XY1%L2vwB1J z^X|s=t%IgV-(Qy%kXc93u`9Dk&2Ls3i4mGtR(DUGY_(Zm{2##iZJ{CzQ@OnEfM{Qo ze#f+W8o~d;onaf71-BcWY!b=oo`-r;Nq_}|8ehGiG5* utgI7MMek4CM1hxkxXrlORW=5OfHu(6?{I)XQg87b!kL2!Z zukGavgW)KJ+F`?&ZR$T>*<|}e=^$Y)*j|G&9bU3GMX6GxjN?uRK FarnJ1Ii3(q-FM*cM~QMOv{tbAPN} BsP9Y?3ec^(|(8D>aYFf8<+-HrcvmwDl-?u-Vp zJ|3KvuNMo~Fj-xr9OYGmPg6Q I~cU=+S4PG9EoZLla zf~s<=?i7=OIk8oX2%pFnZz4v}0d_Yy_WSVi&tWnP*o6YH(vSfzlb$(ZTy2RS$T+)f z(5r!}zZSDj4mr^-J1`2Yp{${_`5%})c >iJ5iW)1!>1mFAFGx z;B;)cd(7#6vu0zB3Qiz&wE9A&QQy8+YwetHC?0#?zR%)|rIuC^P(^NcLGFLLWvrwT ziSba^I7L^ceM_vWH_7yL!}bLW3DNWJjqs&HWJa>c6e!xK_ _eysks1GnQI z=f_v}Bj_px0`6cV_hwtJ5)QfC)>1IDGb$J>Vy9^nQaj04!p nQu2D>_Ozw$GD}C-N_mEcRS!<`}D?FWA;{I4@mjmo4iX`{bVhu`N5_P7$ z$*T<)7`5sHvXa}65d`e%prY8yx~xO-i9`QIz@F?0o2dtu#R1kNto!5Rr&u4`(XmBy zEWzm3Sj90g7xrfDLt|Z F4$qYzc0Sq}*PSHT6JDm}trLU{TeU&6#Fqam6qK9*P z=N#HV3O9LNdF|4Kk}aJkxHdJT#X4I;oHW$da#uo4OO6Srw>$?F!``-RN^mw_Yxw1o zxo`|bmxWItG&jIDM3j3qsV{jks(QdvNM(AEOv@?eVx=4w8)k6rnBFTQh?@{8W=HvQ zk>-?8)CHGVg;; uXr{F^8={SY<_^v;05dcUlv@?V@0}i&WN8? z@E5~EBta9?8mDw9)qrV$3OL(`wg?`sH$8rc1(LIi5)6y=vR_*$r<4r|;t)!_qCNEp zF}2c#-d4enAZYOlHcS0MkH5V_;>cd~ETx8-fJK#EXWSaQg!I}N`C=~yR^_y3!@Duh z(oAswXyBc3&2Q>c31feA8*p;L0a89nQ9)??NeirTT=;C8ExeBxwgMzTBU5Pr#G_C} zGE9$H@qU}U)DgzCagb1$xUof9d-id5Bs6=`!!BN#>C#n?d6;ksZSVCO-%~UvJ_O z7K93jU4giQlw`I7QvtbdUa&p-I;*l@#2YW?%Ku7!?Gt*r8+9vL{`Ga=mN)Kn1Dh}1 zFFK$=E^uzUi^92o+^tUHv9BwVxin&)&?V)_I#1gC?F^CYC}ZI^4rPOaD>`V-8W`mJ zj)FqAei|k^JBZFZDI5_r$N{6To8A>=DE (nmG4eZ z0Oco+JGokWUkHtBtHHGj|L+S#dTpwVov{G}q#pQd^(dy7@qE`=a28_{C^L8vH zbFj1Kh!|RsnqvSfIg*Y=_P5~OclqFwGl`?fv>G@;%KOeL5AjlIPzeTbrk;ru(H1L0 zd;Isk3}O=|1XHS9xWfK3LT8g=)b>X@PjwP=3)n ^4FB>`%!VQ>9+IHe-L z!`KUQU$KxlEY<-h#RJ_gB9-EV)mBh34C%_DsmSnql4?BtvY8Hl^pqD(!_cnWV@K}M zYr8t?Tlh}AngVOKjWx9G3uu{pb {O&yFosG{7Fnp+`9aBx?GId7yL=665}>Y zbn=*9Gkfa|B$iYPJv{zfO!NZx9NZ9*AXlhxm=t>4pfj{%eUw ~!G)t0L4-lj)}T~z zuzBX_ { + if (!slots) return null; + return hasAuth(props.value) ? ( + {slots.default?.()} + ) : null; + }; + } +}); diff --git a/admin-ui/src/components/ReCol/index.ts b/admin-ui/src/components/ReCol/index.ts new file mode 100644 index 0000000..7a6c937 --- /dev/null +++ b/admin-ui/src/components/ReCol/index.ts @@ -0,0 +1,29 @@ +import { ElCol } from "element-plus"; +import { h, defineComponent } from "vue"; + +// 封装element-plus的el-col组件 +export default defineComponent({ + name: "ReCol", + props: { + value: { + type: Number, + default: 24 + } + }, + render() { + const attrs = this.$attrs; + const val = this.value; + return h( + ElCol, + { + xs: val, + sm: val, + md: val, + lg: val, + xl: val, + ...attrs + }, + { default: () => this.$slots.default() } + ); + } +}); diff --git a/admin-ui/src/components/ReDialog/index.ts b/admin-ui/src/components/ReDialog/index.ts new file mode 100644 index 0000000..b471764 --- /dev/null +++ b/admin-ui/src/components/ReDialog/index.ts @@ -0,0 +1,69 @@ +import { ref } from "vue"; +import reDialog from "./index.vue"; +import { useTimeoutFn } from "@vueuse/core"; +import { withInstall } from "@pureadmin/utils"; +import type { + EventType, + ArgsType, + DialogProps, + ButtonProps, + DialogOptions +} from "./type"; + +const dialogStore = ref>([]); + +/** 打开弹框 */ +const addDialog = (options: DialogOptions) => { + const open = () => + dialogStore.value.push(Object.assign(options, { visible: true })); + if (options?.openDelay) { + useTimeoutFn(() => { + open(); + }, options.openDelay); + } else { + open(); + } +}; + +/** 关闭弹框 */ +const closeDialog = (options: DialogOptions, index: number, args?: any) => { + dialogStore.value[index].visible = false; + options.closeCallBack && options.closeCallBack({ options, index, args }); + + const closeDelay = options?.closeDelay ?? 200; + useTimeoutFn(() => { + dialogStore.value.splice(index, 1); + }, closeDelay); +}; + +/** + * @description 更改弹框自身属性值 + * @param value 属性值 + * @param key 属性,默认`title` + * @param index 弹框索引(默认`0`,代表只有一个弹框,对于嵌套弹框要改哪个弹框的属性值就把该弹框索引赋给`index`) + */ +const updateDialog = (value: any, key = "title", index = 0) => { + dialogStore.value[index][key] = value; +}; + +/** 关闭所有弹框 */ +const closeAllDialog = () => { + dialogStore.value = []; +}; + +/** 千万别忘了在下面这三处引入并注册下,放心注册,不使用`addDialog`调用就不会被挂载 + * https://github.com/pure-admin/vue-pure-admin/blob/main/src/App.vue#L4 + * https://github.com/pure-admin/vue-pure-admin/blob/main/src/App.vue#L12 + * https://github.com/pure-admin/vue-pure-admin/blob/main/src/App.vue#L22 + */ +const ReDialog = withInstall(reDialog); + +export type { EventType, ArgsType, DialogProps, ButtonProps, DialogOptions }; +export { + ReDialog, + dialogStore, + addDialog, + closeDialog, + updateDialog, + closeAllDialog +}; diff --git a/admin-ui/src/components/ReDialog/index.vue b/admin-ui/src/components/ReDialog/index.vue new file mode 100644 index 0000000..fb3abaf --- /dev/null +++ b/admin-ui/src/components/ReDialog/index.vue @@ -0,0 +1,206 @@ + + + + + + + + diff --git a/admin-ui/src/components/ReDialog/type.ts b/admin-ui/src/components/ReDialog/type.ts new file mode 100644 index 0000000..7efbe20 --- /dev/null +++ b/admin-ui/src/components/ReDialog/type.ts @@ -0,0 +1,275 @@ +import type { CSSProperties, VNode, Component } from "vue"; + +type DoneFn = (cancel?: boolean) => void; +type EventType = + | "open" + | "close" + | "openAutoFocus" + | "closeAutoFocus" + | "fullscreenCallBack"; +type ArgsType = { + /** `cancel` 点击取消按钮、`sure` 点击确定按钮、`close` 点击右上角关闭按钮或空白页或按下了esc键 */ + command: "cancel" | "sure" | "close"; +}; +type ButtonType = + | "primary" + | "success" + | "warning" + | "danger" + | "info" + | "text"; + +/** https://element-plus.org/zh-CN/component/dialog.html#attributes */ +type DialogProps = { + /** `Dialog` 的显示与隐藏 */ + visible?: boolean; + /** `Dialog` 的标题 */ + title?: string; + /** `Dialog` 的宽度,默认 `50%` */ + width?: string | number; + /** 是否为全屏 `Dialog`(会一直处于全屏状态,除非弹框关闭),默认 `false`,`fullscreen` 和 `fullscreenIcon` 都传时只有 `fullscreen` 会生效 */ + fullscreen?: boolean; + /** 是否显示全屏操作图标,默认 `false`,`fullscreen` 和 `fullscreenIcon` 都传时只有 `fullscreen` 会生效 */ + fullscreenIcon?: boolean; + /** `Dialog CSS` 中的 `margin-top` 值,默认 `15vh` */ + top?: string; + /** 是否需要遮罩层,默认 `true` */ + modal?: boolean; + /** `Dialog` 自身是否插入至 `body` 元素上。嵌套的 `Dialog` 必须指定该属性并赋值为 `true`,默认 `false` */ + appendToBody?: boolean; + /** 是否在 `Dialog` 出现时将 `body` 滚动锁定,默认 `true` */ + lockScroll?: boolean; + /** `Dialog` 的自定义类名 */ + class?: string; + /** `Dialog` 的自定义样式 */ + style?: CSSProperties; + /** `Dialog` 打开的延时时间,单位毫秒,默认 `0` */ + openDelay?: number; + /** `Dialog` 关闭的延时时间,单位毫秒,默认 `0` */ + closeDelay?: number; + /** 是否可以通过点击 `modal` 关闭 `Dialog`,默认 `true` */ + closeOnClickModal?: boolean; + /** 是否可以通过按下 `ESC` 关闭 `Dialog`,默认 `true` */ + closeOnPressEscape?: boolean; + /** 是否显示关闭按钮,默认 `true` */ + showClose?: boolean; + /** 关闭前的回调,会暂停 `Dialog` 的关闭. 回调函数内执行 `done` 参数方法的时候才是真正关闭对话框的时候 */ + beforeClose?: (done: DoneFn) => void; + /** 为 `Dialog` 启用可拖拽功能,默认 `false` */ + draggable?: boolean; + /** 是否让 `Dialog` 的 `header` 和 `footer` 部分居中排列,默认 `false` */ + center?: boolean; + /** 是否水平垂直对齐对话框,默认 `false` */ + alignCenter?: boolean; + /** 当关闭 `Dialog` 时,销毁其中的元素,默认 `false` */ + destroyOnClose?: boolean; +}; + +//element-plus.org/zh-CN/component/popconfirm.html#attributes +type Popconfirm = { + /** 标题 */ + title?: string; + /** 确定按钮文字 */ + confirmButtonText?: string; + /** 取消按钮文字 */ + cancelButtonText?: string; + /** 确定按钮类型,默认 `primary` */ + confirmButtonType?: ButtonType; + /** 取消按钮类型,默认 `text` */ + cancelButtonType?: ButtonType; + /** 自定义图标,默认 `QuestionFilled` */ + icon?: string | Component; + /** `Icon` 颜色,默认 `#f90` */ + iconColor?: string; + /** 是否隐藏 `Icon`,默认 `false` */ + hideIcon?: boolean; + /** 关闭时的延迟,默认 `200` */ + hideAfter?: number; + /** 是否将 `popover` 的下拉列表插入至 `body` 元素,默认 `true` */ + teleported?: boolean; + /** 当 `popover` 组件长时间不触发且 `persistent` 属性设置为 `false` 时, `popover` 将会被删除,默认 `false` */ + persistent?: boolean; + /** 弹层宽度,最小宽度 `150px`,默认 `150` */ + width?: string | number; +}; + +type BtnClickDialog = { + options?: DialogOptions; + index?: number; +}; +type BtnClickButton = { + btn?: ButtonProps; + index?: number; +}; +/** https://element-plus.org/zh-CN/component/button.html#button-attributes */ +type ButtonProps = { + /** 按钮文字 */ + label: string; + /** 按钮尺寸 */ + size?: "large" | "default" | "small"; + /** 按钮类型 */ + type?: "primary" | "success" | "warning" | "danger" | "info"; + /** 是否为朴素按钮,默认 `false` */ + plain?: boolean; + /** 是否为文字按钮,默认 `false` */ + text?: boolean; + /** 是否显示文字按钮背景颜色,默认 `false` */ + bg?: boolean; + /** 是否为链接按钮,默认 `false` */ + link?: boolean; + /** 是否为圆角按钮,默认 `false` */ + round?: boolean; + /** 是否为圆形按钮,默认 `false` */ + circle?: boolean; + /** 确定按钮的 `Popconfirm` 气泡确认框相关配置 */ + popconfirm?: Popconfirm; + /** 是否为加载中状态,默认 `false` */ + loading?: boolean; + /** 自定义加载中状态图标组件 */ + loadingIcon?: string | Component; + /** 按钮是否为禁用状态,默认 `false` */ + disabled?: boolean; + /** 图标组件 */ + icon?: string | Component; + /** 是否开启原生 `autofocus` 属性,默认 `false` */ + autofocus?: boolean; + /** 原生 `type` 属性,默认 `button` */ + nativeType?: "button" | "submit" | "reset"; + /** 自动在两个中文字符之间插入空格 */ + autoInsertSpace?: boolean; + /** 自定义按钮颜色, 并自动计算 `hover` 和 `active` 触发后的颜色 */ + color?: string; + /** `dark` 模式, 意味着自动设置 `color` 为 `dark` 模式的颜色,默认 `false` */ + dark?: boolean; + /** 自定义元素标签 */ + tag?: string | Component; + /** 点击按钮后触发的回调 */ + btnClick?: ({ + dialog, + button + }: { + /** 当前 `Dialog` 信息 */ + dialog: BtnClickDialog; + /** 当前 `button` 信息 */ + button: BtnClickButton; + }) => void; +}; + +interface DialogOptions extends DialogProps { + /** 内容区组件的 `props`,可通过 `defineProps` 接收 */ + props?: any; + /** 是否隐藏 `Dialog` 按钮操作区的内容 */ + hideFooter?: boolean; + /** 确定按钮的 `Popconfirm` 气泡确认框相关配置 */ + popconfirm?: Popconfirm; + /** 点击确定按钮后是否开启 `loading` 加载动画 */ + sureBtnLoading?: boolean; + /** + * @description 自定义对话框标题的内容渲染器 + * @see {@link https://element-plus.org/zh-CN/component/dialog.html#%E8%87%AA%E5%AE%9A%E4%B9%89%E5%A4%B4%E9%83%A8} + */ + headerRenderer?: ({ + close, + titleId, + titleClass + }: { + close: Function; + titleId: string; + titleClass: string; + }) => VNode | Component; + /** 自定义内容渲染器 */ + contentRenderer?: ({ + options, + index + }: { + options: DialogOptions; + index: number; + }) => VNode | Component; + /** 自定义按钮操作区的内容渲染器,会覆盖`footerButtons`以及默认的 `取消` 和 `确定` 按钮 */ + footerRenderer?: ({ + options, + index + }: { + options: DialogOptions; + index: number; + }) => VNode | Component; + /** 自定义底部按钮操作 */ + footerButtons?: Array+ {{ options?.title }} + { + fullscreen = !fullscreen; + eventsCallBack( + 'fullscreenCallBack', + { ...options, fullscreen }, + index, + true + ); + } + " + > +++ + + + handleClose(options, index, args)" + /> + + + + + + + + + + +{{ btn?.label }} + ++ {{ btn?.label }} + + + + +; + /** `Dialog` 打开后的回调 */ + open?: ({ + options, + index + }: { + options: DialogOptions; + index: number; + }) => void; + /** `Dialog` 关闭后的回调(只有点击右上角关闭按钮或空白页或按下了esc键关闭页面时才会触发) */ + close?: ({ + options, + index + }: { + options: DialogOptions; + index: number; + }) => void; + /** `Dialog` 关闭后的回调。 `args` 返回的 `command` 值解析:`cancel` 点击取消按钮、`sure` 点击确定按钮、`close` 点击右上角关闭按钮或空白页或按下了esc键 */ + closeCallBack?: ({ + options, + index, + args + }: { + options: DialogOptions; + index: number; + args: any; + }) => void; + /** 点击全屏按钮时的回调 */ + fullscreenCallBack?: ({ + options, + index + }: { + options: DialogOptions; + index: number; + }) => void; + /** 输入焦点聚焦在 `Dialog` 内容时的回调 */ + openAutoFocus?: ({ + options, + index + }: { + options: DialogOptions; + index: number; + }) => void; + /** 输入焦点从 `Dialog` 内容失焦时的回调 */ + closeAutoFocus?: ({ + options, + index + }: { + options: DialogOptions; + index: number; + }) => void; + /** 点击底部取消按钮的回调,会暂停 `Dialog` 的关闭. 回调函数内执行 `done` 参数方法的时候才是真正关闭对话框的时候 */ + beforeCancel?: ( + done: Function, + { + options, + index + }: { + options: DialogOptions; + index: number; + } + ) => void; + /** 点击底部确定按钮的回调,会暂停 `Dialog` 的关闭. 回调函数内执行 `done` 参数方法的时候才是真正关闭对话框的时候 */ + beforeSure?: ( + done: Function, + { + options, + index, + closeLoading + }: { + options: DialogOptions; + index: number; + /** 关闭确定按钮的 `loading` 加载动画 */ + closeLoading: Function; + } + ) => void; +} + +export type { EventType, ArgsType, DialogProps, ButtonProps, DialogOptions }; diff --git a/admin-ui/src/components/ReIcon/index.ts b/admin-ui/src/components/ReIcon/index.ts new file mode 100644 index 0000000..86efe72 --- /dev/null +++ b/admin-ui/src/components/ReIcon/index.ts @@ -0,0 +1,12 @@ +import iconifyIconOffline from "./src/iconifyIconOffline"; +import iconifyIconOnline from "./src/iconifyIconOnline"; +import fontIcon from "./src/iconfont"; + +/** 本地图标组件 */ +const IconifyIconOffline = iconifyIconOffline; +/** 在线图标组件 */ +const IconifyIconOnline = iconifyIconOnline; +/** `iconfont`组件 */ +const FontIcon = fontIcon; + +export { IconifyIconOffline, IconifyIconOnline, FontIcon }; diff --git a/admin-ui/src/components/ReIcon/src/hooks.ts b/admin-ui/src/components/ReIcon/src/hooks.ts new file mode 100644 index 0000000..57ef18d --- /dev/null +++ b/admin-ui/src/components/ReIcon/src/hooks.ts @@ -0,0 +1,63 @@ +import type { iconType } from "./types"; +import { h, defineComponent, type Component } from "vue"; +import { FontIcon, IconifyIconOnline, IconifyIconOffline } from "../index"; + +/** + * 支持 `iconfont`、自定义 `svg` 以及 `iconify` 中所有的图标 + * @see 点击查看文档图标篇 {@link https://pure-admin.cn/pages/icon/} + * @param icon 必传 图标 + * @param attrs 可选 iconType 属性 + * @returns Component + */ +export function useRenderIcon(icon: any, attrs?: iconType): Component { + // iconfont + const ifReg = /^IF-/; + // typeof icon === "function" 属于SVG + if (ifReg.test(icon)) { + // iconfont + const name = icon.split(ifReg)[1]; + const iconName = name.slice( + 0, + name.indexOf(" ") == -1 ? name.length : name.indexOf(" ") + ); + const iconType = name.slice(name.indexOf(" ") + 1, name.length); + return defineComponent({ + name: "FontIcon", + render() { + return h(FontIcon, { + icon: iconName, + iconType, + ...attrs + }); + } + }); + } else if (typeof icon === "function" || typeof icon?.render === "function") { + // svg + return attrs ? h(icon, { ...attrs }) : icon; + } else if (typeof icon === "object") { + return defineComponent({ + name: "OfflineIcon", + render() { + return h(IconifyIconOffline, { + icon: icon, + ...attrs + }); + } + }); + } else { + // 通过是否存在 : 符号来判断是在线还是本地图标,存在即是在线图标,反之 + return defineComponent({ + name: "Icon", + render() { + if (!icon) return; + const IconifyIcon = icon.includes(":") + ? IconifyIconOnline + : IconifyIconOffline; + return h(IconifyIcon, { + icon, + ...attrs + }); + } + }); + } +} diff --git a/admin-ui/src/components/ReIcon/src/iconfont.ts b/admin-ui/src/components/ReIcon/src/iconfont.ts new file mode 100644 index 0000000..df60f25 --- /dev/null +++ b/admin-ui/src/components/ReIcon/src/iconfont.ts @@ -0,0 +1,47 @@ +import { h, defineComponent } from "vue"; + +// 封装iconfont组件,默认`font-class`引用模式,支持`unicode`引用、`font-class`引用、`symbol`引用 (https://www.iconfont.cn/help/detail?spm=a313x.7781069.1998910419.20&helptype=code) +export default defineComponent({ + name: "FontIcon", + props: { + icon: { + type: String, + default: "" + } + }, + render() { + const attrs = this.$attrs; + if (Object.keys(attrs).includes("uni") || attrs?.iconType === "uni") { + return h( + "i", + { + class: "iconfont", + ...attrs + }, + this.icon + ); + } else if ( + Object.keys(attrs).includes("svg") || + attrs?.iconType === "svg" + ) { + return h( + "svg", + { + class: "icon-svg" + }, + { + default: () => [ + h("use", { + "xlink:href": `#${this.icon}` + }) + ] + } + ); + } else { + return h("i", { + class: `iconfont ${this.icon}`, + ...attrs + }); + } + } +}); diff --git a/admin-ui/src/components/ReIcon/src/iconifyIconOffline.ts b/admin-ui/src/components/ReIcon/src/iconifyIconOffline.ts new file mode 100644 index 0000000..e5782b2 --- /dev/null +++ b/admin-ui/src/components/ReIcon/src/iconifyIconOffline.ts @@ -0,0 +1,47 @@ +import { h, defineComponent } from "vue"; +import { Icon as IconifyIcon, addIcon } from "@iconify/vue/dist/offline"; + +// Iconify Icon在Vue里本地使用(用于内网环境) +export default defineComponent({ + name: "IconifyIconOffline", + components: { IconifyIcon }, + props: { + icon: { + default: null + } + }, + render() { + if (typeof this.icon === "object") addIcon(this.icon, this.icon); + const attrs = this.$attrs; + if (typeof this.icon === "string") { + return h( + IconifyIcon, + { + icon: this.icon, + "aria-hidden": false, + style: attrs?.style + ? Object.assign(attrs.style, { outline: "none" }) + : { outline: "none" }, + ...attrs + }, + { + default: () => [] + } + ); + } else { + return h( + this.icon, + { + "aria-hidden": false, + style: attrs?.style + ? Object.assign(attrs.style, { outline: "none" }) + : { outline: "none" }, + ...attrs + }, + { + default: () => [] + } + ); + } + } +}); diff --git a/admin-ui/src/components/ReIcon/src/iconifyIconOnline.ts b/admin-ui/src/components/ReIcon/src/iconifyIconOnline.ts new file mode 100644 index 0000000..8467e07 --- /dev/null +++ b/admin-ui/src/components/ReIcon/src/iconifyIconOnline.ts @@ -0,0 +1,31 @@ +import { h, defineComponent } from "vue"; +import { Icon as IconifyIcon } from "@iconify/vue"; + +// Iconify Icon在Vue里在线使用(用于外网环境) +export default defineComponent({ + name: "IconifyIconOnline", + components: { IconifyIcon }, + props: { + icon: { + type: String, + default: "" + } + }, + render() { + const attrs = this.$attrs; + return h( + IconifyIcon, + { + icon: `${this.icon}`, + "aria-hidden": false, + style: attrs?.style + ? Object.assign(attrs.style, { outline: "none" }) + : { outline: "none" }, + ...attrs + }, + { + default: () => [] + } + ); + } +}); diff --git a/admin-ui/src/components/ReIcon/src/offlineIcon.ts b/admin-ui/src/components/ReIcon/src/offlineIcon.ts new file mode 100644 index 0000000..b820740 --- /dev/null +++ b/admin-ui/src/components/ReIcon/src/offlineIcon.ts @@ -0,0 +1,23 @@ +// 这里存放本地图标,在 src/layout/index.vue 文件中加载,避免在首启动加载 +import { getSvgInfo } from "@pureadmin/utils"; +import { addIcon } from "@iconify/vue/dist/offline"; + +// https://icon-sets.iconify.design/ep/?keyword=ep +import EpHomeFilled from "~icons/ep/home-filled?raw"; + +// https://icon-sets.iconify.design/ri/?keyword=ri +import RiSearchLine from "~icons/ri/search-line?raw"; +import RiInformationLine from "~icons/ri/information-line?raw"; + +const icons = [ + // Element Plus Icon: https://github.com/element-plus/element-plus-icons + ["ep/home-filled", EpHomeFilled], + // Remix Icon: https://github.com/Remix-Design/RemixIcon + ["ri/search-line", RiSearchLine], + ["ri/information-line", RiInformationLine] +]; + +// 本地菜单图标,后端在路由的 icon 中返回对应的图标字符串并且前端在此处使用 addIcon 添加即可渲染菜单图标 +icons.forEach(([name, icon]) => { + addIcon(name as string, getSvgInfo(icon as string)); +}); diff --git a/admin-ui/src/components/ReIcon/src/types.ts b/admin-ui/src/components/ReIcon/src/types.ts new file mode 100644 index 0000000..000bdc5 --- /dev/null +++ b/admin-ui/src/components/ReIcon/src/types.ts @@ -0,0 +1,20 @@ +export interface iconType { + // iconify (https://docs.iconify.design/icon-components/vue/#properties) + inline?: boolean; + width?: string | number; + height?: string | number; + horizontalFlip?: boolean; + verticalFlip?: boolean; + flip?: string; + rotate?: number | string; + color?: string; + horizontalAlign?: boolean; + verticalAlign?: boolean; + align?: string; + onLoad?: Function; + includes?: Function; + // svg 需要什么SVG属性自行添加 + fill?: string; + // all icon + style?: object; +} diff --git a/admin-ui/src/components/RePerms/index.ts b/admin-ui/src/components/RePerms/index.ts new file mode 100644 index 0000000..3701c3c --- /dev/null +++ b/admin-ui/src/components/RePerms/index.ts @@ -0,0 +1,5 @@ +import perms from "./src/perms"; + +const Perms = perms; + +export { Perms }; diff --git a/admin-ui/src/components/RePerms/src/perms.tsx b/admin-ui/src/components/RePerms/src/perms.tsx new file mode 100644 index 0000000..da01bc1 --- /dev/null +++ b/admin-ui/src/components/RePerms/src/perms.tsx @@ -0,0 +1,20 @@ +import { defineComponent, Fragment } from "vue"; +import { hasPerms } from "@/utils/auth"; + +export default defineComponent({ + name: "Perms", + props: { + value: { + type: undefined, + default: [] + } + }, + setup(props, { slots }) { + return () => { + if (!slots) return null; + return hasPerms(props.value) ? ( + {slots.default?.()} + ) : null; + }; + } +}); diff --git a/admin-ui/src/components/RePureTableBar/index.ts b/admin-ui/src/components/RePureTableBar/index.ts new file mode 100644 index 0000000..31b8a16 --- /dev/null +++ b/admin-ui/src/components/RePureTableBar/index.ts @@ -0,0 +1,5 @@ +import pureTableBar from "./src/bar"; +import { withInstall } from "@pureadmin/utils"; + +/** 配合 `@pureadmin/table` 实现快速便捷的表格操作 https://github.com/pure-admin/pure-admin-table */ +export const PureTableBar = withInstall(pureTableBar); diff --git a/admin-ui/src/components/RePureTableBar/src/bar.tsx b/admin-ui/src/components/RePureTableBar/src/bar.tsx new file mode 100644 index 0000000..1dfadbe --- /dev/null +++ b/admin-ui/src/components/RePureTableBar/src/bar.tsx @@ -0,0 +1,393 @@ +import Sortable from "sortablejs"; +import { useEpThemeStoreHook } from "@/store/modules/epTheme"; +import { + type PropType, + ref, + unref, + computed, + nextTick, + defineComponent, + getCurrentInstance +} from "vue"; +import { + delay, + cloneDeep, + isBoolean, + isFunction, + getKeyList +} from "@pureadmin/utils"; + +import Fullscreen from "~icons/ri/fullscreen-fill"; +import ExitFullscreen from "~icons/ri/fullscreen-exit-fill"; +import DragIcon from "@/assets/table-bar/drag.svg?component"; +import ExpandIcon from "@/assets/table-bar/expand.svg?component"; +import RefreshIcon from "@/assets/table-bar/refresh.svg?component"; +import SettingIcon from "@/assets/table-bar/settings.svg?component"; +import CollapseIcon from "@/assets/table-bar/collapse.svg?component"; + +const props = { + /** 头部最左边的标题 */ + title: { + type: String, + default: "列表" + }, + /** 对于树形表格,如果想启用展开和折叠功能,传入当前表格的ref即可 */ + tableRef: { + type: Object as PropType+ }, + /** 需要展示的列 */ + columns: { + type: Array as PropType , + default: () => [] + }, + isExpandAll: { + type: Boolean, + default: true + }, + tableKey: { + type: [String, Number] as PropType , + default: "0" + } +}; + +export default defineComponent({ + name: "PureTableBar", + props, + emits: ["refresh", "fullscreen"], + setup(props, { emit, slots, attrs }) { + const size = ref("default"); + const loading = ref(false); + const checkAll = ref(true); + const isFullscreen = ref(false); + const isIndeterminate = ref(false); + const instance = getCurrentInstance()!; + const isExpandAll = ref(props.isExpandAll); + const filterColumns = cloneDeep(props?.columns).filter(column => + isBoolean(column?.hide) + ? !column.hide + : !(isFunction(column?.hide) && column?.hide()) + ); + let checkColumnList = getKeyList(cloneDeep(props?.columns), "label"); + const checkedColumns = ref(getKeyList(cloneDeep(filterColumns), "label")); + const dynamicColumns = ref(cloneDeep(props?.columns)); + + const getDropdownItemStyle = computed(() => { + return s => { + return { + background: + s === size.value ? useEpThemeStoreHook().epThemeColor : "", + color: s === size.value ? "#fff" : "var(--el-text-color-primary)" + }; + }; + }); + + const iconClass = computed(() => { + return [ + "text-black", + "dark:text-white", + "duration-100", + "hover:text-primary!", + "cursor-pointer", + "outline-hidden" + ]; + }); + + const topClass = computed(() => { + return [ + "flex", + "justify-between", + "pt-[3px]", + "px-[11px]", + "border-b-[1px]", + "border-solid", + "border-[#dcdfe6]", + "dark:border-[#303030]" + ]; + }); + + function onReFresh() { + loading.value = true; + emit("refresh"); + delay(500).then(() => (loading.value = false)); + } + + function onExpand() { + isExpandAll.value = !isExpandAll.value; + toggleRowExpansionAll(props.tableRef.data, isExpandAll.value); + } + + function onFullscreen() { + isFullscreen.value = !isFullscreen.value; + emit("fullscreen", isFullscreen.value); + } + + function toggleRowExpansionAll(data, isExpansion) { + data.forEach(item => { + props.tableRef.toggleRowExpansion(item, isExpansion); + if (item.children !== undefined && item.children !== null) { + toggleRowExpansionAll(item.children, isExpansion); + } + }); + } + + function handleCheckAllChange(val: boolean) { + checkedColumns.value = val ? checkColumnList : []; + isIndeterminate.value = false; + dynamicColumns.value.map(column => + val ? (column.hide = false) : (column.hide = true) + ); + } + + function handleCheckedColumnsChange(value: string[]) { + checkedColumns.value = value; + const checkedCount = value.length; + checkAll.value = checkedCount === checkColumnList.length; + isIndeterminate.value = + checkedCount > 0 && checkedCount < checkColumnList.length; + } + + function handleCheckColumnListChange(val: boolean, label: string) { + dynamicColumns.value.filter(item => item.label === label)[0].hide = !val; + } + + async function onReset() { + checkAll.value = true; + isIndeterminate.value = false; + dynamicColumns.value = cloneDeep(props?.columns); + checkColumnList = []; + checkColumnList = await getKeyList(cloneDeep(props?.columns), "label"); + checkedColumns.value = getKeyList(cloneDeep(filterColumns), "label"); + } + + const dropdown = { + dropdown: () => ( + + + ) + }; + + /** 列展示拖拽排序 */ + const rowDrop = (event: { preventDefault: () => void }) => { + event.preventDefault(); + nextTick(() => { + const wrapper: HTMLElement = ( + instance?.proxy?.$refs[`GroupRef${unref(props.tableKey)}`] as any + ).$el.firstElementChild; + Sortable.create(wrapper, { + animation: 300, + handle: ".drag-btn", + onEnd: ({ newIndex, oldIndex, item }) => { + const targetThElem = item; + const wrapperElem = targetThElem.parentNode as HTMLElement; + const oldColumn = dynamicColumns.value[oldIndex]; + const newColumn = dynamicColumns.value[newIndex]; + if (oldColumn?.fixed || newColumn?.fixed) { + // 当前列存在fixed属性 则不可拖拽 + const oldThElem = wrapperElem.children[oldIndex] as HTMLElement; + if (newIndex > oldIndex) { + wrapperElem.insertBefore(targetThElem, oldThElem); + } else { + wrapperElem.insertBefore( + targetThElem, + oldThElem ? oldThElem.nextElementSibling : oldThElem + ); + } + return; + } + const currentRow = dynamicColumns.value.splice(oldIndex, 1)[0]; + dynamicColumns.value.splice(newIndex, 0, currentRow); + } + }); + }); + }; + + const isFixedColumn = (label: string) => { + return dynamicColumns.value.filter(item => item.label === label)[0].fixed + ? true + : false; + }; + + const rendTippyProps = (content: string) => { + // https://vue-tippy.netlify.app/props + return { + content, + offset: [0, 18], + duration: [300, 0], + followCursor: true, + hideOnClick: "toggle" + }; + }; + + const reference = { + reference: () => ( +(size.value = "large")} + > + 宽松 + +(size.value = "default")} + > + 默认 + +(size.value = "small")} + > + 紧凑 + ++ ) + }; + + return () => ( + <> + ++ > + ); + } +}); diff --git a/admin-ui/src/components/ReSegmented/index.ts b/admin-ui/src/components/ReSegmented/index.ts new file mode 100644 index 0000000..de4253c --- /dev/null +++ b/admin-ui/src/components/ReSegmented/index.ts @@ -0,0 +1,8 @@ +import reSegmented from "./src/index"; +import { withInstall } from "@pureadmin/utils"; + +/** 分段控制器组件 */ +export const ReSegmented = withInstall(reSegmented); + +export default ReSegmented; +export type { OptionsType } from "./src/type"; diff --git a/admin-ui/src/components/ReSegmented/src/index.css b/admin-ui/src/components/ReSegmented/src/index.css new file mode 100644 index 0000000..4fe79ef --- /dev/null +++ b/admin-ui/src/components/ReSegmented/src/index.css @@ -0,0 +1,156 @@ +.pure-segmented { + --pure-control-padding-horizontal: 12px; + --pure-control-padding-horizontal-sm: 8px; + --pure-segmented-track-padding: 2px; + --pure-segmented-line-width: 1px; + + --pure-segmented-border-radius-small: 4px; + --pure-segmented-border-radius-base: 6px; + --pure-segmented-border-radius-large: 8px; + + box-sizing: border-box; + display: inline-block; + padding: var(--pure-segmented-track-padding); + font-size: var(--el-font-size-base); + color: rgba(0, 0, 0, 0.65); + background-color: rgb(0 0 0 / 4%); + border-radius: var(--pure-segmented-border-radius-base); +} + +.pure-segmented-block { + display: flex; +} + +.pure-segmented-block .pure-segmented-item { + flex: 1; + min-width: 0; +} + +.pure-segmented-block .pure-segmented-item > .pure-segmented-item-label > span { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +/* small */ +.pure-segmented.pure-segmented--small { + border-radius: var(--pure-segmented-border-radius-small); +} +.pure-segmented.pure-segmented--small .pure-segmented-item { + border-radius: var(--el-border-radius-small); +} +.pure-segmented.pure-segmented--small .pure-segmented-item > div { + min-height: calc( + var(--el-component-size-small) - var(--pure-segmented-track-padding) * 2 + ); + line-height: calc( + var(--el-component-size-small) - var(--pure-segmented-track-padding) * 2 + ); + padding: 0 + calc( + var(--pure-control-padding-horizontal-sm) - + var(--pure-segmented-line-width) + ); +} + +/* large */ +.pure-segmented.pure-segmented--large { + border-radius: var(--pure-segmented-border-radius-large); +} +.pure-segmented.pure-segmented--large .pure-segmented-item { + border-radius: calc( + var(--el-border-radius-base) + var(--el-border-radius-small) + ); +} +.pure-segmented.pure-segmented--large .pure-segmented-item > div { + min-height: calc( + var(--el-component-size-large) - var(--pure-segmented-track-padding) * 2 + ); + line-height: calc( + var(--el-component-size-large) - var(--pure-segmented-track-padding) * 2 + ); + padding: 0 + calc( + var(--pure-control-padding-horizontal) - var(--pure-segmented-line-width) + ); + font-size: var(--el-font-size-medium); +} + +/* default */ +.pure-segmented-item { + position: relative; + text-align: center; + cursor: pointer; + border-radius: var(--el-border-radius-base); + transition: all 0.1s cubic-bezier(0.645, 0.045, 0.355, 1); +} +.pure-segmented .pure-segmented-item > div { + min-height: calc( + var(--el-component-size) - var(--pure-segmented-track-padding) * 2 + ); + line-height: calc( + var(--el-component-size) - var(--pure-segmented-track-padding) * 2 + ); + padding: 0 + calc( + var(--pure-control-padding-horizontal) - var(--pure-segmented-line-width) + ); + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.pure-segmented-group { + position: relative; + display: flex; + align-items: stretch; + justify-items: flex-start; + width: 100%; +} + +.pure-segmented-item-selected { + position: absolute; + top: 0; + left: 0; + box-sizing: border-box; + display: none; + width: 0; + height: 100%; + padding: 4px 0; + background-color: #fff; + border-radius: 4px; + box-shadow: + 0 2px 8px -2px rgb(0 0 0 / 5%), + 0 1px 4px -1px rgb(0 0 0 / 7%), + 0 0 1px rgb(0 0 0 / 7%); + transition: + transform 0.5s cubic-bezier(0.645, 0.045, 0.355, 1), + width 0.5s cubic-bezier(0.645, 0.045, 0.355, 1); + will-change: transform, width; +} + +.pure-segmented-item > input { + position: absolute; + inset-block-start: 0; + inset-inline-start: 0; + width: 0; + height: 0; + opacity: 0; + pointer-events: none; +} + +.pure-segmented-item-label { + display: flex; + align-items: center; + justify-content: center; +} + +.pure-segmented-item-icon svg { + width: 16px; + height: 16px; +} + +.pure-segmented-item-disabled { + color: rgba(0, 0, 0, 0.25); + cursor: not-allowed; +} diff --git a/admin-ui/src/components/ReSegmented/src/index.tsx b/admin-ui/src/components/ReSegmented/src/index.tsx new file mode 100644 index 0000000..39580ed --- /dev/null +++ b/admin-ui/src/components/ReSegmented/src/index.tsx @@ -0,0 +1,216 @@ +import "./index.css"; +import type { OptionsType } from "./type"; +import { useRenderIcon } from "@/components/ReIcon/src/hooks"; +import { + useDark, + isNumber, + isFunction, + useResizeObserver +} from "@pureadmin/utils"; +import { + type PropType, + h, + ref, + toRef, + watch, + nextTick, + defineComponent, + getCurrentInstance +} from "vue"; + +const props = { + options: { + type: Array+ {slots?.title ? ( + slots.title() + ) : ( ++ {slots.default({ + size: size.value, + dynamicColumns: dynamicColumns.value + })} +{props.title}
+ )} ++ {slots?.buttons ? ( ++{slots.buttons()}+ ) : null} + {props.tableRef?.size ? ( + <> +onExpand()} + /> + + > + ) : null} + onReFresh()} + /> + + + ++ + + + +++ +handleCheckAllChange(value)} + /> + onReset()}> + 重置 + ++++ +handleCheckedColumnsChange(value)} + > + ++ {checkColumnList.map((item, index) => { + return ( + +++ ); + })} +void; + }) => rowDrop(event)} + /> + + handleCheckColumnListChange(value, item) + } + > + + {item} + + ++ + onFullscreen()} + /> + , + default: () => [] + }, + /** 默认选中,按照第一个索引为 `0` 的模式,可选(`modelValue`只有传`number`类型时才为响应式) */ + modelValue: { + type: undefined, + require: false, + default: "0" + }, + /** 将宽度调整为父元素宽度 */ + block: { + type: Boolean, + default: false + }, + /** 控件尺寸 */ + size: { + type: String as PropType<"small" | "default" | "large"> + }, + /** 是否全局禁用,默认 `false` */ + disabled: { + type: Boolean, + default: false + }, + /** 当内容发生变化时,设置 `resize` 可使其自适应容器位置 */ + resize: { + type: Boolean, + default: false + } +}; + +export default defineComponent({ + name: "ReSegmented", + props, + emits: ["change", "update:modelValue"], + setup(props, { emit }) { + const width = ref(0); + const translateX = ref(0); + const { isDark } = useDark(); + const initStatus = ref(false); + const curMouseActive = ref(-1); + const segmentedItembg = ref(""); + const instance = getCurrentInstance()!; + const curIndex = isNumber(props.modelValue) + ? toRef(props, "modelValue") + : ref(0); + + function handleChange({ option, index }, event: Event) { + if (props.disabled || option.disabled) return; + event.preventDefault(); + isNumber(props.modelValue) + ? emit("update:modelValue", index) + : (curIndex.value = index); + segmentedItembg.value = ""; + emit("change", { index, option }); + } + + function handleMouseenter({ option, index }, event: Event) { + if (props.disabled) return; + event.preventDefault(); + curMouseActive.value = index; + if (option.disabled || curIndex.value === index) { + segmentedItembg.value = ""; + } else { + segmentedItembg.value = isDark.value + ? "#1f1f1f" + : "rgba(0, 0, 0, 0.06)"; + } + } + + function handleMouseleave(_, event: Event) { + if (props.disabled) return; + event.preventDefault(); + curMouseActive.value = -1; + } + + function handleInit(index = curIndex.value) { + nextTick(() => { + const curLabelRef = instance?.proxy?.$refs[`labelRef${index}`] as ElRef; + if (!curLabelRef) return; + width.value = curLabelRef.clientWidth; + translateX.value = curLabelRef.offsetLeft; + initStatus.value = true; + }); + } + + function handleResizeInit() { + useResizeObserver(".pure-segmented", () => { + nextTick(() => { + handleInit(curIndex.value); + }); + }); + } + + (props.block || props.resize) && handleResizeInit(); + + watch( + () => curIndex.value, + index => { + nextTick(() => { + handleInit(index); + }); + }, + { + immediate: true + } + ); + + watch(() => props.size, handleResizeInit, { + immediate: true + }); + + const rendLabel = () => { + return props.options.map((option, index) => { + return ( + + ); + }); + }; + + return () => ( + ++ ); + } +}); diff --git a/admin-ui/src/components/ReSegmented/src/type.ts b/admin-ui/src/components/ReSegmented/src/type.ts new file mode 100644 index 0000000..6c29889 --- /dev/null +++ b/admin-ui/src/components/ReSegmented/src/type.ts @@ -0,0 +1,20 @@ +import type { VNode, Component } from "vue"; +import type { iconType } from "@/components/ReIcon/src/types.ts"; + +export interface OptionsType { + /** 文字 */ + label?: string | (() => VNode | Component); + /** + * @description 图标,采用平台内置的 `useRenderIcon` 函数渲染 + * @see {@link 用法参考 https://pure-admin.cn/pages/icon/#%E9%80%9A%E7%94%A8%E5%9B%BE%E6%A0%87-userendericon-hooks } + */ + icon?: string | Component; + /** 图标属性、样式配置 */ + iconAttrs?: iconType; + /** 值 */ + value?: any; + /** 是否禁用 */ + disabled?: boolean; + /** `tooltip` 提示 */ + tip?: string; +} diff --git a/admin-ui/src/components/ReText/index.ts b/admin-ui/src/components/ReText/index.ts new file mode 100644 index 0000000..6213566 --- /dev/null +++ b/admin-ui/src/components/ReText/index.ts @@ -0,0 +1,7 @@ +import reText from "./src/index.vue"; +import { withInstall } from "@pureadmin/utils"; + +/** 支持`Tooltip`提示的文本省略组件 */ +export const ReText = withInstall(reText); + +export default ReText; diff --git a/admin-ui/src/components/ReText/src/index.vue b/admin-ui/src/components/ReText/src/index.vue new file mode 100644 index 0000000..4c4a232 --- /dev/null +++ b/admin-ui/src/components/ReText/src/index.vue @@ -0,0 +1,69 @@ + + + ++ + {rendLabel()} +++ + diff --git a/admin-ui/src/config/index.ts b/admin-ui/src/config/index.ts new file mode 100644 index 0000000..c81d1c4 --- /dev/null +++ b/admin-ui/src/config/index.ts @@ -0,0 +1,55 @@ +import axios from "axios"; +import type { App } from "vue"; + +let config: object = {}; +const { VITE_PUBLIC_PATH } = import.meta.env; + +const setConfig = (cfg?: unknown) => { + config = Object.assign(config, cfg); +}; + +const getConfig = (key?: string): PlatformConfigs => { + if (typeof key === "string") { + const arr = key.split("."); + if (arr && arr.length) { + let data = config; + arr.forEach(v => { + if (data && typeof data[v] !== "undefined") { + data = data[v]; + } else { + data = null; + } + }); + return data; + } + } + return config; +}; + +/** 获取项目动态全局配置 */ +export const getPlatformConfig = async (app: App): Promise+ => { + app.config.globalProperties.$config = getConfig(); + return axios({ + method: "get", + url: `${VITE_PUBLIC_PATH}platform-config.json` + }) + .then(({ data: config }) => { + let $config = app.config.globalProperties.$config; + // 自动注入系统配置 + if (app && $config && typeof config === "object") { + $config = Object.assign($config, config); + app.config.globalProperties.$config = $config; + // 设置全局配置 + setConfig($config); + } + return $config; + }) + .catch(() => { + throw "请在public文件夹下添加platform-config.json配置文件"; + }); +}; + +/** 本地响应式存储的命名空间 */ +const responsiveStorageNameSpace = () => getConfig().ResponsiveStorageNameSpace; + +export { getConfig, setConfig, responsiveStorageNameSpace }; diff --git a/admin-ui/src/directives/auth/index.ts b/admin-ui/src/directives/auth/index.ts new file mode 100644 index 0000000..2fc6490 --- /dev/null +++ b/admin-ui/src/directives/auth/index.ts @@ -0,0 +1,15 @@ +import { hasAuth } from "@/router/utils"; +import type { Directive, DirectiveBinding } from "vue"; + +export const auth: Directive = { + mounted(el: HTMLElement, binding: DirectiveBinding >) { + const { value } = binding; + if (value) { + !hasAuth(value) && el.parentNode?.removeChild(el); + } else { + throw new Error( + "[Directive: auth]: need auths! Like v-auth=\"['btn.add','btn.edit']\"" + ); + } + } +}; diff --git a/admin-ui/src/directives/copy/index.ts b/admin-ui/src/directives/copy/index.ts new file mode 100644 index 0000000..b71fa19 --- /dev/null +++ b/admin-ui/src/directives/copy/index.ts @@ -0,0 +1,33 @@ +import { message } from "@/utils/message"; +import { useEventListener } from "@vueuse/core"; +import { copyTextToClipboard } from "@pureadmin/utils"; +import type { Directive, DirectiveBinding } from "vue"; + +export interface CopyEl extends HTMLElement { + copyValue: string; +} + +/** 文本复制指令(默认双击复制) */ +export const copy: Directive = { + mounted(el: CopyEl, binding: DirectiveBinding ) { + const { value } = binding; + if (value) { + el.copyValue = value; + const arg = binding.arg ?? "dblclick"; + // Register using addEventListener on mounted, and removeEventListener automatically on unmounted + useEventListener(el, arg, () => { + const success = copyTextToClipboard(el.copyValue); + success + ? message("复制成功", { type: "success" }) + : message("复制失败", { type: "error" }); + }); + } else { + throw new Error( + '[Directive: copy]: need value! Like v-copy="modelValue"' + ); + } + }, + updated(el: CopyEl, binding: DirectiveBinding) { + el.copyValue = binding.value; + } +}; diff --git a/admin-ui/src/directives/index.ts b/admin-ui/src/directives/index.ts new file mode 100644 index 0000000..d01fe71 --- /dev/null +++ b/admin-ui/src/directives/index.ts @@ -0,0 +1,6 @@ +export * from "./auth"; +export * from "./copy"; +export * from "./longpress"; +export * from "./optimize"; +export * from "./perms"; +export * from "./ripple"; diff --git a/admin-ui/src/directives/longpress/index.ts b/admin-ui/src/directives/longpress/index.ts new file mode 100644 index 0000000..4eec6a2 --- /dev/null +++ b/admin-ui/src/directives/longpress/index.ts @@ -0,0 +1,63 @@ +import { useEventListener } from "@vueuse/core"; +import type { Directive, DirectiveBinding } from "vue"; +import { subBefore, subAfter, isFunction } from "@pureadmin/utils"; + +export const longpress: Directive = { + mounted(el: HTMLElement, binding: DirectiveBinding ) { + const cb = binding.value; + if (cb && isFunction(cb)) { + let timer = null; + let interTimer = null; + let num = 500; + let interNum = null; + const isInter = binding?.arg?.includes(":") ?? false; + + if (isInter) { + num = Number(subBefore(binding.arg, ":")); + interNum = Number(subAfter(binding.arg, ":")); + } else if (binding.arg) { + num = Number(binding.arg); + } + + const clear = () => { + if (timer) { + clearTimeout(timer); + timer = null; + } + if (interTimer) { + clearInterval(interTimer); + interTimer = null; + } + }; + + const onDownInter = (ev: PointerEvent) => { + ev.preventDefault(); + if (interTimer === null) { + interTimer = setInterval(() => cb(), interNum); + } + }; + + const onDown = (ev: PointerEvent) => { + clear(); + ev.preventDefault(); + if (timer === null) { + timer = isInter + ? setTimeout(() => { + cb(); + onDownInter(ev); + }, num) + : setTimeout(() => cb(), num); + } + }; + + // Register using addEventListener on mounted, and removeEventListener automatically on unmounted + useEventListener(el, "pointerdown", onDown); + useEventListener(el, "pointerup", clear); + useEventListener(el, "pointerleave", clear); + } else { + throw new Error( + '[Directive: longpress]: need callback and callback must be a function! Like v-longpress="callback"' + ); + } + } +}; diff --git a/admin-ui/src/directives/optimize/index.ts b/admin-ui/src/directives/optimize/index.ts new file mode 100644 index 0000000..7b92538 --- /dev/null +++ b/admin-ui/src/directives/optimize/index.ts @@ -0,0 +1,68 @@ +import { + isArray, + throttle, + debounce, + isObject, + isFunction +} from "@pureadmin/utils"; +import { useEventListener } from "@vueuse/core"; +import type { Directive, DirectiveBinding } from "vue"; + +export interface OptimizeOptions { + /** 事件名 */ + event: string; + /** 事件触发的方法 */ + fn: (...params: any) => any; + /** 是否立即执行 */ + immediate?: boolean; + /** 防抖或节流的延迟时间(防抖默认:`200`毫秒、节流默认:`1000`毫秒) */ + timeout?: number; + /** 传递的参数 */ + params?: any; +} + +/** 防抖(v-optimize或v-optimize:debounce)、节流(v-optimize:throttle)指令 */ +export const optimize: Directive = { + mounted(el: HTMLElement, binding: DirectiveBinding ) { + const { value } = binding; + const optimizeType = binding.arg ?? "debounce"; + const type = ["debounce", "throttle"].find(t => t === optimizeType); + if (type) { + if (value && value.event && isFunction(value.fn)) { + let params = value?.params; + if (params) { + if (isArray(params) || isObject(params)) { + params = isObject(params) ? Array.of(params) : params; + } else { + throw new Error( + "[Directive: optimize]: `params` must be an array or object" + ); + } + } + // Register using addEventListener on mounted, and removeEventListener automatically on unmounted + useEventListener( + el, + value.event, + type === "debounce" + ? debounce( + params ? () => value.fn(...params) : value.fn, + value?.timeout ?? 200, + value?.immediate ?? false + ) + : throttle( + params ? () => value.fn(...params) : value.fn, + value?.timeout ?? 1000 + ) + ); + } else { + throw new Error( + "[Directive: optimize]: `event` and `fn` are required, and `fn` must be a function" + ); + } + } else { + throw new Error( + "[Directive: optimize]: only `debounce` and `throttle` are supported" + ); + } + } +}; diff --git a/admin-ui/src/directives/perms/index.ts b/admin-ui/src/directives/perms/index.ts new file mode 100644 index 0000000..073c918 --- /dev/null +++ b/admin-ui/src/directives/perms/index.ts @@ -0,0 +1,15 @@ +import { hasPerms } from "@/utils/auth"; +import type { Directive, DirectiveBinding } from "vue"; + +export const perms: Directive = { + mounted(el: HTMLElement, binding: DirectiveBinding >) { + const { value } = binding; + if (value) { + !hasPerms(value) && el.parentNode?.removeChild(el); + } else { + throw new Error( + "[Directive: perms]: need perms! Like v-perms=\"['btn.add','btn.edit']\"" + ); + } + } +}; diff --git a/admin-ui/src/directives/ripple/index.scss b/admin-ui/src/directives/ripple/index.scss new file mode 100644 index 0000000..2958920 --- /dev/null +++ b/admin-ui/src/directives/ripple/index.scss @@ -0,0 +1,48 @@ +/* stylelint-disable-next-line scss/dollar-variable-colon-space-after */ +$ripple-animation-transition-in: + transform 0.4s cubic-bezier(0, 0, 0.2, 1), + opacity 0.2s cubic-bezier(0, 0, 0.2, 1) !default; +$ripple-animation-transition-out: opacity 0.5s cubic-bezier(0, 0, 0.2, 1) !default; +$ripple-animation-visible-opacity: 0.25 !default; + +.v-ripple { + &__container { + position: absolute; + top: 0; + left: 0; + z-index: 0; + width: 100%; + height: 100%; + contain: strict; + overflow: hidden; + pointer-events: none; + border-radius: inherit; + } + + &__animation { + position: absolute; + top: 0; + left: 0; + overflow: hidden; + pointer-events: none; + background: currentcolor; + border-radius: 50%; + opacity: 0; + will-change: transform, opacity; + + &--enter { + opacity: 0; + transition: none; + } + + &--in { + opacity: $ripple-animation-visible-opacity; + transition: $ripple-animation-transition-in; + } + + &--out { + opacity: 0; + transition: $ripple-animation-transition-out; + } + } +} diff --git a/admin-ui/src/directives/ripple/index.ts b/admin-ui/src/directives/ripple/index.ts new file mode 100644 index 0000000..8aef2d1 --- /dev/null +++ b/admin-ui/src/directives/ripple/index.ts @@ -0,0 +1,229 @@ +import "./index.scss"; +import { isObject } from "@pureadmin/utils"; +import type { Directive, DirectiveBinding } from "vue"; + +export interface RippleOptions { + /** 自定义`ripple`颜色,支持`tailwindcss` */ + class?: string; + /** 是否从中心扩散 */ + center?: boolean; + circle?: boolean; +} + +export interface RippleDirectiveBinding + extends Omit { + value?: boolean | { class: string }; + modifiers: { + center?: boolean; + circle?: boolean; + }; +} + +function transform(el: HTMLElement, value: string) { + el.style.transform = value; + el.style.webkitTransform = value; +} + +const calculate = ( + e: PointerEvent, + el: HTMLElement, + value: RippleOptions = {} +) => { + const offset = el.getBoundingClientRect(); + + // 获取点击位置距离 el 的垂直和水平距离 + const localX = e.clientX - offset.left; + const localY = e.clientY - offset.top; + + let radius = 0; + let scale = 0.3; + // 计算点击位置到 el 顶点最远距离,即为圆的最大半径(勾股定理) + if (el._ripple?.circle) { + scale = 0.15; + radius = el.clientWidth / 2; + radius = value.center + ? radius + : radius + Math.sqrt((localX - radius) ** 2 + (localY - radius) ** 2) / 4; + } else { + radius = Math.sqrt(el.clientWidth ** 2 + el.clientHeight ** 2) / 2; + } + + // 中心点坐标 + const centerX = `${(el.clientWidth - radius * 2) / 2}px`; + const centerY = `${(el.clientHeight - radius * 2) / 2}px`; + + // 点击位置坐标 + const x = value.center ? centerX : `${localX - radius}px`; + const y = value.center ? centerY : `${localY - radius}px`; + + return { radius, scale, x, y, centerX, centerY }; +}; + +const ripples = { + show(e: PointerEvent, el: HTMLElement, value: RippleOptions = {}) { + if (!el?._ripple?.enabled) { + return; + } + + // 创建 ripple 元素和 ripple 父元素 + const container = document.createElement("span"); + const animation = document.createElement("span"); + + container.appendChild(animation); + container.className = "v-ripple__container"; + + if (value.class) { + container.className += ` ${value.class}`; + } + + const { radius, scale, x, y, centerX, centerY } = calculate(e, el, value); + + // ripple 圆大小 + const size = `${radius * 2}px`; + + animation.className = "v-ripple__animation"; + animation.style.width = size; + animation.style.height = size; + + el.appendChild(container); + + // 获取目标元素样式表 + const computed = window.getComputedStyle(el); + // 防止 position 被覆盖导致 ripple 位置有问题 + if (computed && computed.position === "static") { + el.style.position = "relative"; + el.dataset.previousPosition = "static"; + } + + animation.classList.add("v-ripple__animation--enter"); + animation.classList.add("v-ripple__animation--visible"); + transform( + animation, + `translate(${x}, ${y}) scale3d(${scale},${scale},${scale})` + ); + animation.dataset.activated = String(performance.now()); + + setTimeout(() => { + animation.classList.remove("v-ripple__animation--enter"); + animation.classList.add("v-ripple__animation--in"); + transform(animation, `translate(${centerX}, ${centerY}) scale3d(1,1,1)`); + }, 0); + }, + + hide(el: HTMLElement | null) { + if (!el?._ripple?.enabled) return; + + const ripples = el.getElementsByClassName("v-ripple__animation"); + + if (ripples.length === 0) return; + const animation = ripples[ripples.length - 1] as HTMLElement; + + if (animation.dataset.isHiding) return; + else animation.dataset.isHiding = "true"; + + const diff = performance.now() - Number(animation.dataset.activated); + const delay = Math.max(250 - diff, 0); + + setTimeout(() => { + animation.classList.remove("v-ripple__animation--in"); + animation.classList.add("v-ripple__animation--out"); + + setTimeout(() => { + const ripples = el.getElementsByClassName("v-ripple__animation"); + if (ripples.length === 1 && el.dataset.previousPosition) { + el.style.position = el.dataset.previousPosition; + delete el.dataset.previousPosition; + } + + if (animation.parentNode?.parentNode === el) + el.removeChild(animation.parentNode); + }, 300); + }, delay); + } +}; + +function isRippleEnabled(value: any): value is true { + return typeof value === "undefined" || !!value; +} + +function rippleShow(e: PointerEvent) { + const value: RippleOptions = {}; + const element = e.currentTarget as HTMLElement | undefined; + + if (!element?._ripple || element._ripple.touched) return; + + value.center = element._ripple.centered; + if (element._ripple.class) { + value.class = element._ripple.class; + } + + ripples.show(e, element, value); +} + +function rippleHide(e: Event) { + const element = e.currentTarget as HTMLElement | null; + if (!element?._ripple) return; + + window.setTimeout(() => { + if (element._ripple) { + element._ripple.touched = false; + } + }); + ripples.hide(element); +} + +function updateRipple( + el: HTMLElement, + binding: RippleDirectiveBinding, + wasEnabled: boolean +) { + const { value, modifiers } = binding; + const enabled = isRippleEnabled(value); + if (!enabled) { + ripples.hide(el); + } + + el._ripple = el._ripple ?? {}; + el._ripple.enabled = enabled; + el._ripple.centered = modifiers.center; + el._ripple.circle = modifiers.circle; + if (isObject(value) && value.class) { + el._ripple.class = value.class; + } + + if (enabled && !wasEnabled) { + el.addEventListener("pointerdown", rippleShow); + el.addEventListener("pointerup", rippleHide); + } else if (!enabled && wasEnabled) { + removeListeners(el); + } +} + +function removeListeners(el: HTMLElement) { + el.removeEventListener("pointerdown", rippleShow); + el.removeEventListener("pointerup", rippleHide); +} + +function mounted(el: HTMLElement, binding: RippleDirectiveBinding) { + updateRipple(el, binding, false); +} + +function unmounted(el: HTMLElement) { + delete el._ripple; + removeListeners(el); +} + +function updated(el: HTMLElement, binding: RippleDirectiveBinding) { + if (binding.value === binding.oldValue) { + return; + } + + const wasEnabled = isRippleEnabled(binding.oldValue); + updateRipple(el, binding, wasEnabled); +} + +export const Ripple: Directive = { + mounted, + unmounted, + updated +}; diff --git a/admin-ui/src/layout/components/lay-content/index.vue b/admin-ui/src/layout/components/lay-content/index.vue new file mode 100644 index 0000000..5c7ceb9 --- /dev/null +++ b/admin-ui/src/layout/components/lay-content/index.vue @@ -0,0 +1,213 @@ + + + + + + + + diff --git a/admin-ui/src/layout/components/lay-footer/index.vue b/admin-ui/src/layout/components/lay-footer/index.vue new file mode 100644 index 0000000..35a1653 --- /dev/null +++ b/admin-ui/src/layout/components/lay-footer/index.vue @@ -0,0 +1,24 @@ + + + + + + + diff --git a/admin-ui/src/layout/components/lay-frame/index.vue b/admin-ui/src/layout/components/lay-frame/index.vue new file mode 100644 index 0000000..b2bb9d5 --- /dev/null +++ b/admin-ui/src/layout/components/lay-frame/index.vue @@ -0,0 +1,79 @@ + + + ++ + + + ++ + + ++ ++ ++ +++ ++ ++ + + ++ ++ ++ ++ + + ++ ++ ++ diff --git a/admin-ui/src/layout/components/lay-navbar/index.vue b/admin-ui/src/layout/components/lay-navbar/index.vue new file mode 100644 index 0000000..b686571 --- /dev/null +++ b/admin-ui/src/layout/components/lay-navbar/index.vue @@ -0,0 +1,123 @@ + + + + + + + diff --git a/admin-ui/src/layout/components/lay-search/components/SearchFooter.vue b/admin-ui/src/layout/components/lay-search/components/SearchFooter.vue new file mode 100644 index 0000000..ebac0e7 --- /dev/null +++ b/admin-ui/src/layout/components/lay-search/components/SearchFooter.vue @@ -0,0 +1,61 @@ + + + + + + + diff --git a/admin-ui/src/layout/components/lay-search/components/SearchHistory.vue b/admin-ui/src/layout/components/lay-search/components/SearchHistory.vue new file mode 100644 index 0000000..dd5875a --- /dev/null +++ b/admin-ui/src/layout/components/lay-search/components/SearchHistory.vue @@ -0,0 +1,198 @@ + + + ++ + ++ + + diff --git a/admin-ui/src/layout/components/lay-search/components/SearchHistoryItem.vue b/admin-ui/src/layout/components/lay-search/components/SearchHistoryItem.vue new file mode 100644 index 0000000..b94ccde --- /dev/null +++ b/admin-ui/src/layout/components/lay-search/components/SearchHistoryItem.vue @@ -0,0 +1,52 @@ + + + +搜索历史+++ + ++ + {{ `收藏${collectList.length > 1 ? "(可拖拽排序)" : ""}` }} ++++ ++++ + + {{ item.meta?.title }} + + + + + + diff --git a/admin-ui/src/layout/components/lay-search/components/SearchModal.vue b/admin-ui/src/layout/components/lay-search/components/SearchModal.vue new file mode 100644 index 0000000..af778c0 --- /dev/null +++ b/admin-ui/src/layout/components/lay-search/components/SearchModal.vue @@ -0,0 +1,334 @@ + + + + + + + + diff --git a/admin-ui/src/layout/components/lay-search/components/SearchResult.vue b/admin-ui/src/layout/components/lay-search/components/SearchResult.vue new file mode 100644 index 0000000..1dc7841 --- /dev/null +++ b/admin-ui/src/layout/components/lay-search/components/SearchResult.vue @@ -0,0 +1,113 @@ + + + ++ + ++ + ++ ++ ++ + + + + ++ + + diff --git a/admin-ui/src/layout/components/lay-search/index.vue b/admin-ui/src/layout/components/lay-search/index.vue new file mode 100644 index 0000000..b9bf15c --- /dev/null +++ b/admin-ui/src/layout/components/lay-search/index.vue @@ -0,0 +1,21 @@ + + + ++++ + {{ item.meta?.title }} + + + + ++ diff --git a/admin-ui/src/layout/components/lay-search/types.ts b/admin-ui/src/layout/components/lay-search/types.ts new file mode 100644 index 0000000..a39adbd --- /dev/null +++ b/admin-ui/src/layout/components/lay-search/types.ts @@ -0,0 +1,20 @@ +interface optionsItem { + path: string; + type: "history" | "collect"; + meta: { + icon?: string; + title?: string; + }; +} + +interface dragItem { + oldIndex: number; + newIndex: number; +} + +interface Props { + value: string; + options: Array+ ; +} + +export type { optionsItem, dragItem, Props }; diff --git a/admin-ui/src/layout/components/lay-sidebar/NavHorizontal.vue b/admin-ui/src/layout/components/lay-sidebar/NavHorizontal.vue new file mode 100644 index 0000000..d0b9bac --- /dev/null +++ b/admin-ui/src/layout/components/lay-sidebar/NavHorizontal.vue @@ -0,0 +1,111 @@ + + + + ++ + + diff --git a/admin-ui/src/layout/components/lay-sidebar/NavMix.vue b/admin-ui/src/layout/components/lay-sidebar/NavMix.vue new file mode 100644 index 0000000..98504d0 --- /dev/null +++ b/admin-ui/src/layout/components/lay-sidebar/NavMix.vue @@ -0,0 +1,131 @@ + + + ++++ {{ title }} +
+ ++ + +++ + + + + + + ++ + ++ ++ 退出系统 + ++ + + diff --git a/admin-ui/src/layout/components/lay-sidebar/NavVertical.vue b/admin-ui/src/layout/components/lay-sidebar/NavVertical.vue new file mode 100644 index 0000000..0e9fa12 --- /dev/null +++ b/admin-ui/src/layout/components/lay-sidebar/NavVertical.vue @@ -0,0 +1,137 @@ + + + ++ ++ + ++++ + + {{ route.meta.title }} + ++ ++ + +++ + + + + + + ++ + ++ ++ 退出系统 + ++ + + diff --git a/admin-ui/src/layout/components/lay-sidebar/components/SidebarBreadCrumb.vue b/admin-ui/src/layout/components/lay-sidebar/components/SidebarBreadCrumb.vue new file mode 100644 index 0000000..c73d5b9 --- /dev/null +++ b/admin-ui/src/layout/components/lay-sidebar/components/SidebarBreadCrumb.vue @@ -0,0 +1,120 @@ + + + ++ + ++ ++ + + + + diff --git a/admin-ui/src/layout/components/lay-sidebar/components/SidebarCenterCollapse.vue b/admin-ui/src/layout/components/lay-sidebar/components/SidebarCenterCollapse.vue new file mode 100644 index 0000000..945a37a --- /dev/null +++ b/admin-ui/src/layout/components/lay-sidebar/components/SidebarCenterCollapse.vue @@ -0,0 +1,70 @@ + + + ++ ++ + {{ item.meta.title }} + + +++ + + diff --git a/admin-ui/src/layout/components/lay-sidebar/components/SidebarExtraIcon.vue b/admin-ui/src/layout/components/lay-sidebar/components/SidebarExtraIcon.vue new file mode 100644 index 0000000..7cad16e --- /dev/null +++ b/admin-ui/src/layout/components/lay-sidebar/components/SidebarExtraIcon.vue @@ -0,0 +1,20 @@ + + + ++ ++ diff --git a/admin-ui/src/layout/components/lay-sidebar/components/SidebarFullScreen.vue b/admin-ui/src/layout/components/lay-sidebar/components/SidebarFullScreen.vue new file mode 100644 index 0000000..4d38bd0 --- /dev/null +++ b/admin-ui/src/layout/components/lay-sidebar/components/SidebarFullScreen.vue @@ -0,0 +1,30 @@ + + + + + diff --git a/admin-ui/src/layout/components/lay-sidebar/components/SidebarItem.vue b/admin-ui/src/layout/components/lay-sidebar/components/SidebarItem.vue new file mode 100644 index 0000000..fccd130 --- /dev/null +++ b/admin-ui/src/layout/components/lay-sidebar/components/SidebarItem.vue @@ -0,0 +1,228 @@ + + + ++ + ++ + ++ {{ onlyOneChild.meta.title }} + + + +++ ++ {{ onlyOneChild.meta.title }} + ++ + + + + diff --git a/admin-ui/src/layout/components/lay-sidebar/components/SidebarLeftCollapse.vue b/admin-ui/src/layout/components/lay-sidebar/components/SidebarLeftCollapse.vue new file mode 100644 index 0000000..50e5125 --- /dev/null +++ b/admin-ui/src/layout/components/lay-sidebar/components/SidebarLeftCollapse.vue @@ -0,0 +1,69 @@ + + + ++ {{ item.meta.title }} + ++ + + + ++ + + diff --git a/admin-ui/src/layout/components/lay-sidebar/components/SidebarLinkItem.vue b/admin-ui/src/layout/components/lay-sidebar/components/SidebarLinkItem.vue new file mode 100644 index 0000000..8911c12 --- /dev/null +++ b/admin-ui/src/layout/components/lay-sidebar/components/SidebarLinkItem.vue @@ -0,0 +1,32 @@ + + + ++ + + diff --git a/admin-ui/src/layout/components/lay-sidebar/components/SidebarLogo.vue b/admin-ui/src/layout/components/lay-sidebar/components/SidebarLogo.vue new file mode 100644 index 0000000..ccbc7ab --- /dev/null +++ b/admin-ui/src/layout/components/lay-sidebar/components/SidebarLogo.vue @@ -0,0 +1,72 @@ + + + + + + + diff --git a/admin-ui/src/layout/components/lay-sidebar/components/SidebarTopCollapse.vue b/admin-ui/src/layout/components/lay-sidebar/components/SidebarTopCollapse.vue new file mode 100644 index 0000000..350df33 --- /dev/null +++ b/admin-ui/src/layout/components/lay-sidebar/components/SidebarTopCollapse.vue @@ -0,0 +1,33 @@ + + + + + diff --git a/admin-ui/src/layout/components/lay-tag/components/TagChrome.vue b/admin-ui/src/layout/components/lay-tag/components/TagChrome.vue new file mode 100644 index 0000000..137365b --- /dev/null +++ b/admin-ui/src/layout/components/lay-tag/components/TagChrome.vue @@ -0,0 +1,33 @@ + + + diff --git a/admin-ui/src/layout/components/lay-tag/index.scss b/admin-ui/src/layout/components/lay-tag/index.scss new file mode 100644 index 0000000..e399680 --- /dev/null +++ b/admin-ui/src/layout/components/lay-tag/index.scss @@ -0,0 +1,371 @@ +@keyframes schedule-in-width { + from { + width: 0; + } + + to { + width: 100%; + } +} + +@keyframes schedule-out-width { + from { + width: 100%; + } + + to { + width: 0; + } +} + +.tags-view { + position: relative; + display: flex; + align-items: center; + width: 100%; + font-size: 14px; + color: var(--el-text-color-primary); + background: #fff; + box-shadow: 0 0 1px #888; + + .scroll-item { + position: relative; + display: inline-block; + height: 34px; + padding-left: 6px; + line-height: 34px; + cursor: pointer; + transition: all 0.4s; + + &:not(:first-child) { + padding-right: 24px; + } + + &.chrome-item { + padding-right: 0; + padding-left: 0; + margin-right: -18px; + box-shadow: none; + } + + .el-icon-close { + position: absolute; + top: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + color: var(--el-color-primary); + cursor: pointer; + border-radius: 4px; + transform: translate(0, -50%); + transition: + background-color 0.12s, + color 0.12s; + + &:hover { + color: rgb(0 0 0 / 88%) !important; + background-color: rgb(0 0 0 / 6%); + } + } + } + + .tag-title { + padding: 0 4px; + color: var(--el-text-color-primary); + text-decoration: none; + } + + .scroll-container { + position: relative; + flex: 1; + overflow: hidden; + white-space: nowrap; + + &.chrome-scroll-container { + padding-top: 4px; + + .fixed-tag { + padding: 0 !important; + } + } + + .tab { + position: relative; + float: left; + overflow: visible; + white-space: nowrap; + list-style: none; + + .scroll-item { + transition: all 0.2s cubic-bezier(0.645, 0.045, 0.355, 1); + + &:nth-child(1) { + padding: 0 12px; + } + + &.chrome-item { + &:nth-child(1) { + padding: 0; + } + } + } + + .fixed-tag { + padding: 0 12px; + } + } + } + + /* 右键菜单 */ + .contextmenu { + position: absolute; + padding: 5px 0; + margin: 0; + font-size: 13px; + font-weight: normal; + color: var(--el-text-color-primary); + white-space: nowrap; + outline: 0; + list-style-type: none; + background: #fff; + border-radius: 4px; + box-shadow: 0 2px 8px rgb(0 0 0 / 15%); + + li { + display: flex; + align-items: center; + width: 100%; + padding: 7px 12px; + margin: 0; + cursor: pointer; + + &:hover { + color: var(--el-color-primary); + } + + svg { + display: block; + margin-right: 0.5em; + } + } + } +} + +.el-dropdown-menu { + li { + display: flex; + align-items: center; + width: 100%; + margin: 0; + cursor: pointer; + + svg { + display: block; + margin-right: 0.5em; + } + } +} + +.el-dropdown-menu__item:not(.is-disabled):hover { + color: #606266; + background: #f0f0f0; +} + +:deep(.el-dropdown-menu__item) i { + margin-right: 10px; +} + +:deep(.el-dropdown-menu__item--divided) { + margin: 1px 0; +} + +.el-dropdown-menu__item--divided::before { + margin: 0; +} + +.el-dropdown-menu__item.is-disabled { + cursor: not-allowed; +} + +.scroll-item.is-active { + position: relative; + color: #fff; + box-shadow: 0 0 0.7px #888; + + .chrome-tab { + z-index: 10; + } + + .chrome-tab__bg { + color: var(--el-color-primary-light-9) !important; + } + + .tag-title { + color: var(--el-color-primary) !important; + } + + .chrome-close-btn { + color: var(--el-color-primary); + + &:hover { + background-color: var(--el-color-primary); + } + } + + .chrome-tab-divider { + opacity: 0; + } +} + +.arrow-left, +.arrow-right, +.arrow-down { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 34px; + color: var(--el-text-color-primary); + + svg { + width: 20px; + height: 20px; + } +} + +.arrow-left { + box-shadow: 5px 0 5px -6px #ccc; + + &:hover { + cursor: w-resize; + } +} + +.arrow-right { + border-right: 0.5px solid #ccc; + box-shadow: -5px 0 5px -6px #ccc; + + &:hover { + cursor: e-resize; + } +} + +/* 卡片模式下鼠标移入显示蓝色边框 */ +.card-in { + color: var(--el-color-primary); + + .tag-title { + color: var(--el-color-primary); + } +} + +/* 卡片模式下鼠标移出隐藏蓝色边框 */ +.card-out { + color: #666; + border: none; + + .tag-title { + color: #666; + } +} + +/* 灵动模式 */ +.schedule-active { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 2px; + background: var(--el-color-primary); +} + +/* 灵动模式下鼠标移入显示蓝色进度条 */ +.schedule-in { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 2px; + background: var(--el-color-primary); + animation: schedule-in-width 200ms ease-in; +} + +/* 灵动模式下鼠标移出隐藏蓝色进度条 */ +.schedule-out { + position: absolute; + bottom: 0; + left: 0; + width: 0; + height: 2px; + background: var(--el-color-primary); + animation: schedule-out-width 200ms ease-in; +} + +/* 谷歌风格的页签 */ +.chrome-tab { + position: relative; + display: inline-flex; + gap: 16px; + align-items: center; + justify-content: center; + padding: 0 24px; + white-space: nowrap; + cursor: pointer; + + .tag-title { + padding: 0; + } + + .chrome-tab-divider { + position: absolute; + right: 7px; + width: 1px; + height: 14px; + background-color: #2b2d2f; + } + + &:hover { + z-index: 10; + + .chrome-tab__bg { + color: #dee1e6; + } + + .tag-title { + color: #1f1f1f; + } + + .chrome-tab-divider { + opacity: 0; + } + } + + .chrome-tab__bg { + position: absolute; + top: 0; + left: 0; + z-index: -10; + width: 100%; + height: 100%; + color: transparent; + pointer-events: none; + } + + .chrome-close-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + color: #666; + border-radius: 50%; + + &:hover { + color: white; + background-color: #b1b3b8; + } + } +} diff --git a/admin-ui/src/layout/components/lay-tag/index.vue b/admin-ui/src/layout/components/lay-tag/index.vue new file mode 100644 index 0000000..7345c6a --- /dev/null +++ b/admin-ui/src/layout/components/lay-tag/index.vue @@ -0,0 +1,690 @@ + + + + + + + diff --git a/admin-ui/src/layout/frame.vue b/admin-ui/src/layout/frame.vue new file mode 100644 index 0000000..ea2b993 --- /dev/null +++ b/admin-ui/src/layout/frame.vue @@ -0,0 +1,110 @@ + + + ++ + ++ + + diff --git a/admin-ui/src/layout/hooks/useBoolean.ts b/admin-ui/src/layout/hooks/useBoolean.ts new file mode 100644 index 0000000..1d14031 --- /dev/null +++ b/admin-ui/src/layout/hooks/useBoolean.ts @@ -0,0 +1,26 @@ +import { ref } from "vue"; + +export function useBoolean(initValue = false) { + const bool = ref(initValue); + + function setBool(value: boolean) { + bool.value = value; + } + function setTrue() { + setBool(true); + } + function setFalse() { + setBool(false); + } + function toggle() { + setBool(!bool.value); + } + + return { + bool, + setBool, + setTrue, + setFalse, + toggle + }; +} diff --git a/admin-ui/src/layout/hooks/useDataThemeChange.ts b/admin-ui/src/layout/hooks/useDataThemeChange.ts new file mode 100644 index 0000000..4b08dff --- /dev/null +++ b/admin-ui/src/layout/hooks/useDataThemeChange.ts @@ -0,0 +1,138 @@ +import { ref } from "vue"; +import { getConfig } from "@/config"; +import { useLayout } from "./useLayout"; +import { removeToken } from "@/utils/auth"; +import { routerArrays } from "@/layout/types"; +import { router, resetRouter } from "@/router"; +import type { themeColorsType } from "../types"; +import { useAppStoreHook } from "@/store/modules/app"; +import { useEpThemeStoreHook } from "@/store/modules/epTheme"; +import { useMultiTagsStoreHook } from "@/store/modules/multiTags"; +import { darken, lighten, useGlobal, storageLocal } from "@pureadmin/utils"; + +export function useDataThemeChange() { + const { layoutTheme, layout } = useLayout(); + const themeColors = ref>([ + /* 亮白色 */ + { color: "#ffffff", themeColor: "light" }, + /* 道奇蓝 */ + { color: "#1b2a47", themeColor: "default" }, + /* 深紫罗兰色 */ + { color: "#722ed1", themeColor: "saucePurple" }, + /* 深粉色 */ + { color: "#eb2f96", themeColor: "pink" }, + /* 猩红色 */ + { color: "#f5222d", themeColor: "dusk" }, + /* 橙红色 */ + { color: "#fa541c", themeColor: "volcano" }, + /* 绿宝石 */ + { color: "#13c2c2", themeColor: "mingQing" }, + /* 酸橙绿 */ + { color: "#52c41a", themeColor: "auroraGreen" } + ]); + + const { $storage } = useGlobal (); + const dataTheme = ref ($storage?.layout?.darkMode); + const overallStyle = ref ($storage?.layout?.overallStyle); + const body = document.documentElement as HTMLElement; + + function toggleClass(flag: boolean, clsName: string, target?: HTMLElement) { + const targetEl = target || document.body; + let { className } = targetEl; + className = className.replace(clsName, "").trim(); + targetEl.className = flag ? `${className} ${clsName}` : className; + } + + /** 设置导航主题色 */ + function setLayoutThemeColor( + theme = getConfig().Theme ?? "light", + isClick = true + ) { + layoutTheme.value.theme = theme; + document.documentElement.setAttribute("data-theme", theme); + // 如果非isClick,保留之前的themeColor + const storageThemeColor = $storage.layout.themeColor; + $storage.layout = { + layout: layout.value, + theme, + darkMode: dataTheme.value, + sidebarStatus: $storage.layout?.sidebarStatus, + epThemeColor: $storage.layout?.epThemeColor, + themeColor: isClick ? theme : storageThemeColor, + overallStyle: overallStyle.value + }; + + if (theme === "default" || theme === "light") { + setEpThemeColor(getConfig().EpThemeColor); + } else { + const colors = themeColors.value.find(v => v.themeColor === theme); + setEpThemeColor(colors.color); + } + } + + function setPropertyPrimary(mode: string, i: number, color: string) { + document.documentElement.style.setProperty( + `--el-color-primary-${mode}-${i}`, + dataTheme.value ? darken(color, i / 10) : lighten(color, i / 10) + ); + } + + /** 设置 `element-plus` 主题色 */ + const setEpThemeColor = (color: string) => { + useEpThemeStoreHook().setEpThemeColor(color); + document.documentElement.style.setProperty("--el-color-primary", color); + for (let i = 1; i <= 2; i++) { + setPropertyPrimary("dark", i, color); + } + for (let i = 1; i <= 9; i++) { + setPropertyPrimary("light", i, color); + } + }; + + /** 浅色、深色整体风格切换 */ + function dataThemeChange(overall?: string) { + overallStyle.value = overall; + if (useEpThemeStoreHook().epTheme === "light" && dataTheme.value) { + setLayoutThemeColor("default", false); + } else { + setLayoutThemeColor(useEpThemeStoreHook().epTheme, false); + } + + if (dataTheme.value) { + document.documentElement.classList.add("dark"); + } else { + if ($storage.layout.themeColor === "light") { + setLayoutThemeColor("light", false); + } + document.documentElement.classList.remove("dark"); + } + } + + /** 清空缓存并返回登录页 */ + function onReset() { + removeToken(); + storageLocal().clear(); + const { Grey, Weak, MultiTagsCache, EpThemeColor, Layout } = getConfig(); + useAppStoreHook().setLayout(Layout); + setEpThemeColor(EpThemeColor); + useMultiTagsStoreHook().multiTagsCacheChange(MultiTagsCache); + toggleClass(Grey, "html-grey", document.querySelector("html")); + toggleClass(Weak, "html-weakness", document.querySelector("html")); + router.push("/login"); + useMultiTagsStoreHook().handleTags("equal", [...routerArrays]); + resetRouter(); + } + + return { + body, + dataTheme, + overallStyle, + layoutTheme, + themeColors, + onReset, + toggleClass, + dataThemeChange, + setEpThemeColor, + setLayoutThemeColor + }; +} diff --git a/admin-ui/src/layout/hooks/useLayout.ts b/admin-ui/src/layout/hooks/useLayout.ts new file mode 100644 index 0000000..a45ea4f --- /dev/null +++ b/admin-ui/src/layout/hooks/useLayout.ts @@ -0,0 +1,58 @@ +import { computed } from "vue"; +import { routerArrays } from "../types"; +import { useGlobal } from "@pureadmin/utils"; +import { useMultiTagsStore } from "@/store/modules/multiTags"; + +export function useLayout() { + const { $storage, $config } = useGlobal (); + + const initStorage = () => { + /** 路由 */ + if ( + useMultiTagsStore().multiTagsCache && + (!$storage.tags || $storage.tags.length === 0) + ) { + $storage.tags = routerArrays; + } + /** 导航 */ + if (!$storage.layout) { + $storage.layout = { + layout: $config?.Layout ?? "vertical", + theme: $config?.Theme ?? "light", + darkMode: $config?.DarkMode ?? false, + sidebarStatus: $config?.SidebarStatus ?? true, + epThemeColor: $config?.EpThemeColor ?? "#409EFF", + themeColor: $config?.Theme ?? "light", + overallStyle: $config?.OverallStyle ?? "light" + }; + } + /** 灰色模式、色弱模式、隐藏标签页 */ + if (!$storage.configure) { + $storage.configure = { + grey: $config?.Grey ?? false, + weak: $config?.Weak ?? false, + hideTabs: $config?.HideTabs ?? false, + hideFooter: $config.HideFooter ?? true, + showLogo: $config?.ShowLogo ?? true, + showModel: $config?.ShowModel ?? "smart", + multiTagsCache: $config?.MultiTagsCache ?? false, + stretch: $config?.Stretch ?? false + }; + } + }; + + /** 清空缓存后从platform-config.json读取默认配置并赋值到storage中 */ + const layout = computed(() => { + return $storage?.layout.layout; + }); + + const layoutTheme = computed(() => { + return $storage.layout; + }); + + return { + layout, + layoutTheme, + initStorage + }; +} diff --git a/admin-ui/src/layout/hooks/useMultiFrame.ts b/admin-ui/src/layout/hooks/useMultiFrame.ts new file mode 100644 index 0000000..73a779d --- /dev/null +++ b/admin-ui/src/layout/hooks/useMultiFrame.ts @@ -0,0 +1,25 @@ +const MAP = new Map(); + +export const useMultiFrame = () => { + function setMap(path, Comp) { + MAP.set(path, Comp); + } + + function getMap(path?) { + if (path) { + return MAP.get(path); + } + return [...MAP.entries()]; + } + + function delMap(path) { + MAP.delete(path); + } + + return { + setMap, + getMap, + delMap, + MAP + }; +}; diff --git a/admin-ui/src/layout/hooks/useNav.ts b/admin-ui/src/layout/hooks/useNav.ts new file mode 100644 index 0000000..2448efb --- /dev/null +++ b/admin-ui/src/layout/hooks/useNav.ts @@ -0,0 +1,157 @@ +import { storeToRefs } from "pinia"; +import { getConfig } from "@/config"; +import { emitter } from "@/utils/mitt"; +import Avatar from "@/assets/user.jpg"; +import { getTopMenu } from "@/router/utils"; +import { useFullscreen } from "@vueuse/core"; +import type { routeMetaType } from "../types"; +import { useRouter, useRoute } from "vue-router"; +import { router, remainingPaths } from "@/router"; +import { computed, type CSSProperties } from "vue"; +import { useAppStoreHook } from "@/store/modules/app"; +import { useUserStoreHook } from "@/store/modules/user"; +import { useGlobal, isAllEmpty } from "@pureadmin/utils"; +import { usePermissionStoreHook } from "@/store/modules/permission"; +import ExitFullscreen from "~icons/ri/fullscreen-exit-fill"; +import Fullscreen from "~icons/ri/fullscreen-fill"; + +const errorInfo = + "The current routing configuration is incorrect, please check the configuration"; + +export function useNav() { + const route = useRoute(); + const pureApp = useAppStoreHook(); + const routers = useRouter().options.routes; + const { isFullscreen, toggle } = useFullscreen(); + const { wholeMenus } = storeToRefs(usePermissionStoreHook()); + /** 平台`layout`中所有`el-tooltip`的`effect`配置,默认`light` */ + const tooltipEffect = getConfig()?.TooltipEffect ?? "light"; + + const getDivStyle = computed((): CSSProperties => { + return { + width: "100%", + display: "flex", + alignItems: "center", + justifyContent: "space-between", + overflow: "hidden" + }; + }); + + /** 头像(如果头像为空则使用 src/assets/user.jpg ) */ + const userAvatar = computed(() => { + return isAllEmpty(useUserStoreHook()?.avatar) + ? Avatar + : useUserStoreHook()?.avatar; + }); + + /** 昵称(如果昵称为空则显示用户名) */ + const username = computed(() => { + return isAllEmpty(useUserStoreHook()?.nickname) + ? useUserStoreHook()?.username + : useUserStoreHook()?.nickname; + }); + + const avatarsStyle = computed(() => { + return username.value ? { marginRight: "10px" } : ""; + }); + + const isCollapse = computed(() => { + return !pureApp.getSidebarStatus; + }); + + const device = computed(() => { + return pureApp.getDevice; + }); + + const { $storage, $config } = useGlobal (); + const layout = computed(() => { + return $storage?.layout?.layout; + }); + + const title = computed(() => { + return $config.Title; + }); + + /** 动态title */ + function changeTitle(meta: routeMetaType) { + const Title = getConfig().Title; + if (Title) document.title = `${meta.title} | ${Title}`; + else document.title = meta.title; + } + + /** 退出登录 */ + function logout() { + useUserStoreHook().logOut(); + } + + function backTopMenu() { + router.push(getTopMenu()?.path); + } + + function onPanel() { + emitter.emit("openPanel"); + } + + function toggleSideBar() { + pureApp.toggleSideBar(); + } + + function handleResize(menuRef) { + menuRef?.handleResize(); + } + + function resolvePath(route) { + if (!route.children) return console.error(errorInfo); + const httpReg = /^http(s?):\/\//; + const routeChildPath = route.children[0]?.path; + if (httpReg.test(routeChildPath)) { + return route.path + "/" + routeChildPath; + } else { + return routeChildPath; + } + } + + function menuSelect(indexPath: string) { + if (wholeMenus.value.length === 0 || isRemaining(indexPath)) return; + emitter.emit("changLayoutRoute", indexPath); + } + + /** 判断路径是否参与菜单 */ + function isRemaining(path: string) { + return remainingPaths.includes(path); + } + + /** 获取`logo` */ + function getLogo() { + return new URL("/logo.svg", import.meta.url).href; + } + + return { + route, + title, + device, + layout, + logout, + routers, + $storage, + isFullscreen, + Fullscreen, + ExitFullscreen, + toggle, + backTopMenu, + onPanel, + getDivStyle, + changeTitle, + toggleSideBar, + menuSelect, + handleResize, + resolvePath, + getLogo, + isCollapse, + pureApp, + username, + userAvatar, + avatarsStyle, + tooltipEffect + }; +} diff --git a/admin-ui/src/layout/hooks/useTag.ts b/admin-ui/src/layout/hooks/useTag.ts new file mode 100644 index 0000000..82742ee --- /dev/null +++ b/admin-ui/src/layout/hooks/useTag.ts @@ -0,0 +1,252 @@ +import { + ref, + unref, + computed, + reactive, + onMounted, + type CSSProperties, + getCurrentInstance +} from "vue"; +import type { tagsViewsType } from "../types"; +import { useRoute, useRouter } from "vue-router"; +import { responsiveStorageNameSpace } from "@/config"; +import { useSettingStoreHook } from "@/store/modules/settings"; +import { useMultiTagsStoreHook } from "@/store/modules/multiTags"; +import { + isEqual, + isBoolean, + storageLocal, + toggleClass, + hasClass +} from "@pureadmin/utils"; + +import Fullscreen from "~icons/ri/fullscreen-fill"; +import CloseAllTags from "~icons/ri/subtract-line"; +import CloseOtherTags from "~icons/ri/text-spacing"; +import CloseRightTags from "~icons/ri/text-direction-l"; +import CloseLeftTags from "~icons/ri/text-direction-r"; +import RefreshRight from "~icons/ep/refresh-right"; +import Close from "~icons/ep/close"; + +export function useTags() { + const route = useRoute(); + const router = useRouter(); + const instance = getCurrentInstance(); + const pureSetting = useSettingStoreHook(); + + const buttonTop = ref(0); + const buttonLeft = ref(0); + const translateX = ref(0); + const visible = ref(false); + const activeIndex = ref(-1); + // 当前右键选中的路由信息 + const currentSelect = ref({}); + const isScrolling = ref(false); + + /** 显示模式,默认灵动模式 */ + const showModel = ref( + storageLocal().getItem ( + `${responsiveStorageNameSpace()}configure` + )?.showModel || "smart" + ); + /** 是否隐藏标签页,默认显示 */ + const showTags = + ref( + storageLocal().getItem ( + `${responsiveStorageNameSpace()}configure` + ).hideTabs + ) ?? ref("false"); + const multiTags: any = computed(() => { + return useMultiTagsStoreHook().multiTags; + }); + + const tagsViews = reactive >([ + { + icon: RefreshRight, + text: "重新加载", + divided: false, + disabled: false, + show: true + }, + { + icon: Close, + text: "关闭当前标签页", + divided: false, + disabled: multiTags.value.length > 1 ? false : true, + show: true + }, + { + icon: CloseLeftTags, + text: "关闭左侧标签页", + divided: true, + disabled: multiTags.value.length > 1 ? false : true, + show: true + }, + { + icon: CloseRightTags, + text: "关闭右侧标签页", + divided: false, + disabled: multiTags.value.length > 1 ? false : true, + show: true + }, + { + icon: CloseOtherTags, + text: "关闭其他标签页", + divided: true, + disabled: multiTags.value.length > 2 ? false : true, + show: true + }, + { + icon: CloseAllTags, + text: "关闭全部标签页", + divided: false, + disabled: multiTags.value.length > 1 ? false : true, + show: true + }, + { + icon: Fullscreen, + text: "内容区全屏", + divided: true, + disabled: false, + show: true + } + ]); + + function conditionHandle(item, previous, next) { + const currentName = route.name || ""; + const itemName = item.name || ""; + + if (isBoolean(route?.meta?.showLink) && route?.meta?.showLink === false) { + if (Object.keys(route.query).length > 0) { + return currentName === itemName && isEqual(route.query, item.query) + ? previous + : next; + } else { + return currentName === itemName && isEqual(route.params, item.params) + ? previous + : next; + } + } else { + return currentName === itemName ? previous : next; + } + } + + const isFixedTag = computed(() => { + return item => { + return isBoolean(item?.meta?.fixedTag) && item?.meta?.fixedTag === true; + }; + }); + + const iconIsActive = computed(() => { + return (item, index) => { + if (index === 0) return; + return conditionHandle(item, true, false); + }; + }); + + const linkIsActive = computed(() => { + return item => { + return conditionHandle(item, "is-active", ""); + }; + }); + + const scheduleIsActive = computed(() => { + return item => { + return conditionHandle(item, "schedule-active", ""); + }; + }); + + const getTabStyle = computed((): CSSProperties => { + return { + transform: `translateX(${translateX.value}px)`, + transition: isScrolling.value ? "none" : "transform 0.5s ease-in-out" + }; + }); + + const getContextMenuStyle = computed((): CSSProperties => { + return { left: buttonLeft.value + "px", top: buttonTop.value + "px" }; + }); + + const closeMenu = () => { + visible.value = false; + }; + + /** 鼠标移入添加激活样式 */ + function onMouseenter(index) { + if (index) activeIndex.value = index; + if (unref(showModel) === "smart") { + if (hasClass(instance.refs["schedule" + index][0], "schedule-active")) + return; + toggleClass(true, "schedule-in", instance.refs["schedule" + index][0]); + toggleClass(false, "schedule-out", instance.refs["schedule" + index][0]); + } else { + if (hasClass(instance.refs["dynamic" + index][0], "is-active")) return; + toggleClass(true, "card-in", instance.refs["dynamic" + index][0]); + toggleClass(false, "card-out", instance.refs["dynamic" + index][0]); + } + } + + /** 鼠标移出恢复默认样式 */ + function onMouseleave(index) { + activeIndex.value = -1; + if (unref(showModel) === "smart") { + if (hasClass(instance.refs["schedule" + index][0], "schedule-active")) + return; + toggleClass(false, "schedule-in", instance.refs["schedule" + index][0]); + toggleClass(true, "schedule-out", instance.refs["schedule" + index][0]); + } else { + if (hasClass(instance.refs["dynamic" + index][0], "is-active")) return; + toggleClass(false, "card-in", instance.refs["dynamic" + index][0]); + toggleClass(true, "card-out", instance.refs["dynamic" + index][0]); + } + } + + function onContentFullScreen() { + pureSetting.hiddenSideBar + ? pureSetting.changeSetting({ key: "hiddenSideBar", value: false }) + : pureSetting.changeSetting({ key: "hiddenSideBar", value: true }); + } + + onMounted(() => { + if (!showModel.value) { + const configure = storageLocal().getItem ( + `${responsiveStorageNameSpace()}configure` + ); + configure.showModel = "card"; + storageLocal().setItem( + `${responsiveStorageNameSpace()}configure`, + configure + ); + } + }); + + return { + Close, + route, + router, + visible, + showTags, + instance, + multiTags, + showModel, + tagsViews, + buttonTop, + buttonLeft, + translateX, + isFixedTag, + pureSetting, + activeIndex, + getTabStyle, + isScrolling, + iconIsActive, + linkIsActive, + currentSelect, + scheduleIsActive, + getContextMenuStyle, + closeMenu, + onMounted, + onMouseenter, + onMouseleave, + onContentFullScreen + }; +} diff --git a/admin-ui/src/layout/index.vue b/admin-ui/src/layout/index.vue new file mode 100644 index 0000000..ddf12f2 --- /dev/null +++ b/admin-ui/src/layout/index.vue @@ -0,0 +1,232 @@ + + + + + ++ + + diff --git a/admin-ui/src/layout/redirect.vue b/admin-ui/src/layout/redirect.vue new file mode 100644 index 0000000..6e16339 --- /dev/null +++ b/admin-ui/src/layout/redirect.vue @@ -0,0 +1,24 @@ + + + + + diff --git a/admin-ui/src/layout/types.ts b/admin-ui/src/layout/types.ts new file mode 100644 index 0000000..f5e69af --- /dev/null +++ b/admin-ui/src/layout/types.ts @@ -0,0 +1,93 @@ +import type { FunctionalComponent } from "vue"; +const { VITE_HIDE_HOME } = import.meta.env; + +export const routerArrays: Array+ +++++ + + + ++ ++ + + + = + VITE_HIDE_HOME === "false" + ? [ + { + path: "/simcae/console", + name: "SimCAEConsole", + meta: { + title: "服务端控制台", + icon: "ep/platform" + } + } + ] + : []; + +export type routeMetaType = { + title?: string; + icon?: string | FunctionalComponent; + showLink?: boolean; + savedPosition?: boolean; + auths?: Array ; +}; + +export type RouteConfigs = { + path?: string; + query?: object; + params?: object; + meta?: routeMetaType; + children?: RouteConfigs[]; + name?: string; +}; + +export type multiTagsType = { + tags: Array ; +}; + +export type tagsViewsType = { + icon: string | FunctionalComponent; + text: string; + divided: boolean; + disabled: boolean; + show: boolean; +}; + +export interface setType { + sidebar: { + opened: boolean; + withoutAnimation: boolean; + isClickCollapse: boolean; + }; + device: string; + fixedHeader: boolean; + classes: { + hideSidebar: boolean; + openSidebar: boolean; + withoutAnimation: boolean; + mobile: boolean; + }; + hideTabs: boolean; +} + +export type menuType = { + id?: number; + name?: string; + path?: string; + noShowingChildren?: boolean; + children?: menuType[]; + value: unknown; + meta?: { + icon?: string; + title?: string; + rank?: number; + showParent?: boolean; + extraIcon?: string; + }; + showTooltip?: boolean; + parentId?: number; + pathList?: number[]; + redirect?: string; +}; + +export type themeColorsType = { + color: string; + themeColor: string; +}; + +export interface scrollbarDomType extends HTMLElement { + wrap?: { + offsetWidth: number; + }; +} diff --git a/admin-ui/src/main.ts b/admin-ui/src/main.ts new file mode 100644 index 0000000..d603e32 --- /dev/null +++ b/admin-ui/src/main.ts @@ -0,0 +1,64 @@ +import App from "./App.vue"; +import router from "./router"; +import { setupStore } from "@/store"; +import { getPlatformConfig } from "./config"; +import { MotionPlugin } from "@vueuse/motion"; +// import { useEcharts } from "@/plugins/echarts"; +import { createApp, type Directive } from "vue"; +import { useElementPlus } from "@/plugins/elementPlus"; +import { injectResponsiveStorage } from "@/utils/responsive"; + +import Table from "@pureadmin/table"; +// import PureDescriptions from "@pureadmin/descriptions"; + +// 引入重置样式 +import "./style/reset.scss"; +// 导入公共样式 +import "./style/index.scss"; +// 一定要在main.ts中导入tailwind.css,防止vite每次hmr都会请求src/style/index.scss整体css文件导致热更新慢的问题 +import "./style/tailwind.css"; +import "element-plus/dist/index.css"; +// 导入字体图标 +import "./assets/iconfont/iconfont.js"; +import "./assets/iconfont/iconfont.css"; + +const app = createApp(App); + +// 自定义指令 +import * as directives from "@/directives"; +Object.keys(directives).forEach(key => { + app.directive(key, (directives as { [key: string]: Directive })[key]); +}); + +// 全局注册@iconify/vue图标库 +import { + IconifyIconOffline, + IconifyIconOnline, + FontIcon +} from "./components/ReIcon"; +app.component("IconifyIconOffline", IconifyIconOffline); +app.component("IconifyIconOnline", IconifyIconOnline); +app.component("FontIcon", FontIcon); + +// 全局注册按钮级别权限组件 +import { Auth } from "@/components/ReAuth"; +import { Perms } from "@/components/RePerms"; +app.component("Auth", Auth); +app.component("Perms", Perms); + +// 全局注册vue-tippy +import "tippy.js/dist/tippy.css"; +import "tippy.js/themes/light.css"; +import VueTippy from "vue-tippy"; +app.use(VueTippy); + +getPlatformConfig(app).then(async config => { + setupStore(app); + app.use(router); + await router.isReady(); + injectResponsiveStorage(app, config); + app.use(MotionPlugin).use(useElementPlus).use(Table); + // .use(PureDescriptions) + // .use(useEcharts); + app.mount("#app"); +}); diff --git a/admin-ui/src/plugins/echarts.ts b/admin-ui/src/plugins/echarts.ts new file mode 100644 index 0000000..cb62d96 --- /dev/null +++ b/admin-ui/src/plugins/echarts.ts @@ -0,0 +1,44 @@ +import type { App } from "vue"; +import * as echarts from "echarts/core"; +import { PieChart, BarChart, LineChart } from "echarts/charts"; +import { CanvasRenderer, SVGRenderer } from "echarts/renderers"; +import { + GridComponent, + TitleComponent, + PolarComponent, + LegendComponent, + GraphicComponent, + ToolboxComponent, + TooltipComponent, + DataZoomComponent, + VisualMapComponent +} from "echarts/components"; + +const { use } = echarts; + +use([ + PieChart, + BarChart, + LineChart, + CanvasRenderer, + SVGRenderer, + GridComponent, + TitleComponent, + PolarComponent, + LegendComponent, + GraphicComponent, + ToolboxComponent, + TooltipComponent, + DataZoomComponent, + VisualMapComponent +]); + +/** + * @description 按需引入echarts,具体看 https://echarts.apache.org/handbook/zh/basics/import/#%E5%9C%A8-typescript-%E4%B8%AD%E6%8C%89%E9%9C%80%E5%BC%95%E5%85%A5 + * @see 温馨提示:必须将 `$echarts` 添加到全局 `globalProperties` ,具体看 https://pure-admin-utils.netlify.app/hooks/useECharts/useECharts#%E4%BD%BF%E7%94%A8%E5%89%8D%E6%8F%90 + */ +export function useEcharts(app: App) { + app.config.globalProperties.$echarts = echarts; +} + +export default echarts; diff --git a/admin-ui/src/plugins/elementPlus.ts b/admin-ui/src/plugins/elementPlus.ts new file mode 100644 index 0000000..8363187 --- /dev/null +++ b/admin-ui/src/plugins/elementPlus.ts @@ -0,0 +1,248 @@ +// 按需引入element-plus(该方法稳定且明确。当然也支持:https://element-plus.org/zh-CN/guide/quickstart.html#%E6%8C%89%E9%9C%80%E5%AF%BC%E5%85%A5) +import type { App, Component } from "vue"; +import { + /** + * 为了方便演示平台将 element-plus 导出的所有组件引入,实际使用中如果你没用到哪个组件,将其注释掉就行 + * 导出来源:https://github.com/element-plus/element-plus/blob/dev/packages/element-plus/component.ts#L111-L211 + * */ + ElAffix, + ElAlert, + ElAutocomplete, + ElAutoResizer, + ElAvatar, + ElAnchor, + ElAnchorLink, + ElBacktop, + ElBadge, + ElBreadcrumb, + ElBreadcrumbItem, + ElButton, + ElButtonGroup, + ElCalendar, + ElCard, + ElCarousel, + ElCarouselItem, + ElCascader, + ElCascaderPanel, + ElCheckTag, + ElCheckbox, + ElCheckboxButton, + ElCheckboxGroup, + ElCol, + ElCollapse, + ElCollapseItem, + ElCollapseTransition, + ElColorPicker, + ElConfigProvider, + ElContainer, + ElAside, + ElFooter, + ElHeader, + ElMain, + ElDatePicker, + ElDescriptions, + ElDescriptionsItem, + ElDialog, + ElDivider, + ElDrawer, + ElDropdown, + ElDropdownItem, + ElDropdownMenu, + ElEmpty, + ElForm, + ElFormItem, + ElIcon, + ElImage, + ElImageViewer, + ElInput, + ElInputNumber, + ElLink, + ElMenu, + ElMenuItem, + ElMenuItemGroup, + ElSubMenu, + ElPageHeader, + ElPagination, + ElPopconfirm, + ElPopover, + ElPopper, + ElProgress, + ElRadio, + ElRadioButton, + ElRadioGroup, + ElRate, + ElResult, + ElRow, + ElScrollbar, + ElSelect, + ElOption, + ElOptionGroup, + ElSelectV2, + ElSkeleton, + ElSkeletonItem, + ElSlider, + ElSpace, + ElStatistic, + ElCountdown, + ElSteps, + ElStep, + ElSwitch, + ElTable, + ElTableColumn, + ElTableV2, + ElTabs, + ElTabPane, + ElTag, + ElText, + ElTimePicker, + ElTimeSelect, + ElTimeline, + ElTimelineItem, + ElTooltip, + ElTransfer, + ElTree, + ElTreeSelect, + ElTreeV2, + ElUpload, + ElWatermark, + ElTour, + ElTourStep, + ElSegmented, + /** + * 为了方便演示平台将 element-plus 导出的所有插件引入,实际使用中如果你没用到哪个插件,将其注释掉就行 + * 导出来源:https://github.com/element-plus/element-plus/blob/dev/packages/element-plus/plugin.ts#L11-L16 + * */ + ElLoading, // v-loading 指令 + ElInfiniteScroll, // v-infinite-scroll 指令 + ElPopoverDirective, // v-popover 指令 + ElMessage, // $message 全局属性对象globalProperties + ElMessageBox, // $msgbox、$alert、$confirm、$prompt 全局属性对象globalProperties + ElNotification // $notify 全局属性对象globalProperties +} from "element-plus"; + +const components = [ + ElAffix, + ElAlert, + ElAutocomplete, + ElAutoResizer, + ElAvatar, + ElAnchor, + ElAnchorLink, + ElBacktop, + ElBadge, + ElBreadcrumb, + ElBreadcrumbItem, + ElButton, + ElButtonGroup, + ElCalendar, + ElCard, + ElCarousel, + ElCarouselItem, + ElCascader, + ElCascaderPanel, + ElCheckTag, + ElCheckbox, + ElCheckboxButton, + ElCheckboxGroup, + ElCol, + ElCollapse, + ElCollapseItem, + ElCollapseTransition, + ElColorPicker, + ElConfigProvider, + ElContainer, + ElAside, + ElFooter, + ElHeader, + ElMain, + ElDatePicker, + ElDescriptions, + ElDescriptionsItem, + ElDialog, + ElDivider, + ElDrawer, + ElDropdown, + ElDropdownItem, + ElDropdownMenu, + ElEmpty, + ElForm, + ElFormItem, + ElIcon, + ElImage, + ElImageViewer, + ElInput, + ElInputNumber, + ElLink, + ElMenu, + ElMenuItem, + ElMenuItemGroup, + ElSubMenu, + ElPageHeader, + ElPagination, + ElPopconfirm, + ElPopover, + ElPopper, + ElProgress, + ElRadio, + ElRadioButton, + ElRadioGroup, + ElRate, + ElResult, + ElRow, + ElScrollbar, + ElSelect, + ElOption, + ElOptionGroup, + ElSelectV2, + ElSkeleton, + ElSkeletonItem, + ElSlider, + ElSpace, + ElStatistic, + ElCountdown, + ElSteps, + ElStep, + ElSwitch, + ElTable, + ElTableColumn, + ElTableV2, + ElTabs, + ElTabPane, + ElTag, + ElText, + ElTimePicker, + ElTimeSelect, + ElTimeline, + ElTimelineItem, + ElTooltip, + ElTransfer, + ElTree, + ElTreeSelect, + ElTreeV2, + ElUpload, + ElWatermark, + ElTour, + ElTourStep, + ElSegmented +]; + +const plugins = [ + ElLoading, + ElInfiniteScroll, + ElPopoverDirective, + ElMessage, + ElMessageBox, + ElNotification +]; + +/** 按需引入`element-plus` */ +export function useElementPlus(app: App) { + // 全局注册组件 + components.forEach((component: Component) => { + app.component(component.name, component); + }); + // 全局注册插件 + plugins.forEach(plugin => { + app.use(plugin); + }); +} diff --git a/admin-ui/src/router/index.ts b/admin-ui/src/router/index.ts new file mode 100644 index 0000000..7b9ad5e --- /dev/null +++ b/admin-ui/src/router/index.ts @@ -0,0 +1,222 @@ +// import "@/utils/sso"; +import Cookies from "js-cookie"; +import { getConfig } from "@/config"; +import NProgress from "@/utils/progress"; +import { buildHierarchyTree } from "@/utils/tree"; +import remainingRouter from "./modules/remaining"; +import { useMultiTagsStoreHook } from "@/store/modules/multiTags"; +import { usePermissionStoreHook } from "@/store/modules/permission"; +import { + isUrl, + openLink, + cloneDeep, + isAllEmpty, + storageLocal +} from "@pureadmin/utils"; +import { + ascending, + getTopMenu, + initRouter, + isOneOfArray, + getHistoryMode, + findRouteByPath, + handleAliveRoute, + formatTwoStageRoutes, + formatFlatteningRoutes +} from "./utils"; +import { + type Router, + type RouteRecordRaw, + type RouteComponent, + createRouter +} from "vue-router"; +import { + type DataInfo, + userKey, + removeToken, + multipleTabsKey +} from "@/utils/auth"; + +/** 自动导入全部静态路由,无需再手动引入!匹配 src/router/modules 目录(任何嵌套级别)中具有 .ts 扩展名的所有文件,除了 remaining.ts 文件 + * 如何匹配所有文件请看:https://github.com/mrmlnc/fast-glob#basic-syntax + * 如何排除文件请看:https://cn.vitejs.dev/guide/features.html#negative-patterns + */ +const modules: Record = import.meta.glob( + ["./modules/**/*.ts", "!./modules/**/remaining.ts"], + { + eager: true + } +); + +/** 原始静态路由(未做任何处理) */ +const routes = []; + +Object.keys(modules).forEach(key => { + routes.push(modules[key].default); +}); + +/** 导出处理后的静态路由(三级及以上的路由全部拍成二级) */ +export const constantRoutes: Array = formatTwoStageRoutes( + formatFlatteningRoutes(buildHierarchyTree(ascending(routes.flat(Infinity)))) +); + +/** 初始的静态路由,用于退出登录时重置路由 */ +const initConstantRoutes: Array = cloneDeep(constantRoutes); + +/** 用于渲染菜单,保持原始层级 */ +export const constantMenus: Array = ascending( + routes.flat(Infinity) +).concat(...remainingRouter); + +/** 不参与菜单的路由 */ +export const remainingPaths = Object.keys(remainingRouter).map(v => { + return remainingRouter[v].path; +}); + +/** 创建路由实例 */ +export const router: Router = createRouter({ + history: getHistoryMode(import.meta.env.VITE_ROUTER_HISTORY), + routes: constantRoutes.concat(...(remainingRouter as any)), + strict: true, + scrollBehavior(to, from, savedPosition) { + return new Promise(resolve => { + if (savedPosition) { + return savedPosition; + } else { + if (from.meta.saveSrollTop) { + const top: number = + document.documentElement.scrollTop || document.body.scrollTop; + resolve({ left: 0, top }); + } + } + }); + } +}); + +/** 记录已经加载的页面路径 */ +const loadedPaths = new Set (); + +/** 重置已加载页面记录 */ +export function resetLoadedPaths() { + loadedPaths.clear(); +} + +/** 重置路由 */ +export function resetRouter() { + router.clearRoutes(); + for (const route of initConstantRoutes.concat(...(remainingRouter as any))) { + router.addRoute(route); + } + router.options.routes = formatTwoStageRoutes( + formatFlatteningRoutes(buildHierarchyTree(ascending(routes.flat(Infinity)))) + ); + usePermissionStoreHook().clearAllCachePage(); + resetLoadedPaths(); +} + +/** 路由白名单 */ +const whiteList = ["/login"]; + +const { VITE_HIDE_HOME } = import.meta.env; + +router.beforeEach((to: ToRouteType, _from, next) => { + to.meta.loaded = loadedPaths.has(to.path); + + if (!to.meta.loaded) { + NProgress.start(); + } + + if (to.meta?.keepAlive) { + handleAliveRoute(to, "add"); + // 页面整体刷新和点击标签页刷新 + if (_from.name === undefined || _from.name === "Redirect") { + handleAliveRoute(to); + } + } + const userInfo = storageLocal().getItem >(userKey); + const externalLink = isUrl(to?.name as string); + if (!externalLink) { + to.matched.some(item => { + if (!item.meta.title) return ""; + const Title = getConfig().Title; + if (Title) document.title = `${item.meta.title} | ${Title}`; + else document.title = item.meta.title as string; + }); + } + /** 如果已经登录并存在登录信息后不能跳转到路由白名单,而是继续保持在当前页面 */ + function toCorrectRoute() { + whiteList.includes(to.fullPath) ? next(_from.fullPath) : next(); + } + if (Cookies.get(multipleTabsKey) && userInfo) { + // 无权限跳转403页面 + if (to.meta?.roles && !isOneOfArray(to.meta?.roles, userInfo?.roles)) { + next({ path: "/error/403" }); + } + if (_from?.name) { + // name为超链接 + if (externalLink) { + openLink(to?.name as string); + NProgress.done(); + } else { + toCorrectRoute(); + } + } else { + // 刷新 + if ( + usePermissionStoreHook().wholeMenus.length === 0 && + to.path !== "/login" + ) { + initRouter().then((router: Router) => { + if (!useMultiTagsStoreHook().getMultiTagsCache) { + const { path } = to; + const route = findRouteByPath( + path, + router.options.routes[0].children + ); + getTopMenu(true); + // query、params模式路由传参数的标签页不在此处处理 + if (route && route.meta?.title) { + if (isAllEmpty(route.parentId) && route.meta?.backstage) { + // 此处为动态顶级路由(目录) + const { path, name, meta } = route.children[0]; + useMultiTagsStoreHook().handleTags("push", { + path, + name, + meta + }); + } else { + const { path, name, meta } = route; + useMultiTagsStoreHook().handleTags("push", { + path, + name, + meta + }); + } + } + } + // 确保动态路由完全加入路由列表并且不影响静态路由(注意:动态路由刷新时router.beforeEach可能会触发两次,第一次触发动态路由还未完全添加,第二次动态路由才完全添加到路由列表,如果需要在router.beforeEach做一些判断可以在to.name存在的条件下去判断,这样就只会触发一次) + if (isAllEmpty(to.name)) router.push(to.fullPath); + }); + } + toCorrectRoute(); + } + } else { + if (to.path !== "/login") { + if (whiteList.indexOf(to.path) !== -1) { + next(); + } else { + removeToken(); + next({ path: "/login" }); + } + } else { + next(); + } + } +}); + +router.afterEach(to => { + loadedPaths.add(to.path); + NProgress.done(); +}); + +export default router; diff --git a/admin-ui/src/router/modules/error.ts b/admin-ui/src/router/modules/error.ts new file mode 100644 index 0000000..3b20bc6 --- /dev/null +++ b/admin-ui/src/router/modules/error.ts @@ -0,0 +1,39 @@ +export default { + path: "/error", + redirect: "/error/403", + meta: { + icon: "ri/information-line", + showLink: false, + title: "错误页", + rank: 9 + }, + children: [ + { + path: "/error/403", + name: "403", + component: () => import("@/views/error/403.vue"), + meta: { + showLink: false, + title: "403" + } + }, + { + path: "/error/404", + name: "404", + component: () => import("@/views/error/404.vue"), + meta: { + showLink: false, + title: "404" + } + }, + { + path: "/error/500", + name: "500", + component: () => import("@/views/error/500.vue"), + meta: { + showLink: false, + title: "500" + } + } + ] +} satisfies RouteConfigsTable; diff --git a/admin-ui/src/router/modules/home.ts b/admin-ui/src/router/modules/home.ts new file mode 100644 index 0000000..191ff88 --- /dev/null +++ b/admin-ui/src/router/modules/home.ts @@ -0,0 +1,26 @@ +const Layout = () => import("@/layout/index.vue"); + +export default { + path: "/", + name: "Home", + component: Layout, + redirect: "/simcae/console", + meta: { + icon: "ep/platform", + title: "SimCAE 管理", + rank: 0 + }, + children: [ + { + path: "/simcae/console", + name: "SimCAEConsole", + component: () => import("@/views/simcae/index.vue"), + meta: { + title: "服务端控制台", + icon: "ep/monitor", + keepAlive: true, + fixedTag: true + } + } + ] +} satisfies RouteConfigsTable; diff --git a/admin-ui/src/router/modules/remaining.ts b/admin-ui/src/router/modules/remaining.ts new file mode 100644 index 0000000..e0024b4 --- /dev/null +++ b/admin-ui/src/router/modules/remaining.ts @@ -0,0 +1,48 @@ +const Layout = () => import("@/layout/index.vue"); + +export default [ + { + path: "/login", + name: "Login", + component: () => import("@/views/login/index.vue"), + meta: { + title: "登录", + showLink: false + } + }, + // 全屏403(无权访问)页面 + { + path: "/access-denied", + name: "AccessDenied", + component: () => import("@/views/error/403.vue"), + meta: { + title: "403", + showLink: false + } + }, + // 全屏500(服务器出错)页面 + { + path: "/server-error", + name: "ServerError", + component: () => import("@/views/error/500.vue"), + meta: { + title: "500", + showLink: false + } + }, + { + path: "/redirect", + component: Layout, + meta: { + title: "加载中...", + showLink: false + }, + children: [ + { + path: "/redirect/:path(.*)", + name: "Redirect", + component: () => import("@/layout/redirect.vue") + } + ] + } +] satisfies Array ; diff --git a/admin-ui/src/router/utils.ts b/admin-ui/src/router/utils.ts new file mode 100644 index 0000000..51a1bc5 --- /dev/null +++ b/admin-ui/src/router/utils.ts @@ -0,0 +1,415 @@ +import { + type RouterHistory, + type RouteRecordRaw, + type RouteComponent, + createWebHistory, + createWebHashHistory +} from "vue-router"; +import { router } from "./index"; +import { isProxy, toRaw } from "vue"; +import { useTimeoutFn } from "@vueuse/core"; +import { + isString, + cloneDeep, + isAllEmpty, + intersection, + storageLocal, + isIncludeAllChildren +} from "@pureadmin/utils"; +import { getConfig } from "@/config"; +import { buildHierarchyTree } from "@/utils/tree"; +import { userKey, type DataInfo } from "@/utils/auth"; +import { type menuType, routerArrays } from "@/layout/types"; +import { useMultiTagsStoreHook } from "@/store/modules/multiTags"; +import { usePermissionStoreHook } from "@/store/modules/permission"; +const IFrame = () => import("@/layout/frame.vue"); +// https://cn.vitejs.dev/guide/features.html#glob-import +const modulesRoutes = import.meta.glob("/src/views/**/*.{vue,tsx}"); + +// 动态路由 +import { getAsyncRoutes } from "@/api/routes"; + +function handRank(routeInfo: any) { + const { name, path, parentId, meta } = routeInfo; + return isAllEmpty(parentId) + ? isAllEmpty(meta?.rank) || + (meta?.rank === 0 && name !== "Home" && path !== "/") + ? true + : false + : false; +} + +/** 按照路由中meta下的rank等级升序来排序路由 */ +function ascending(arr: any[]) { + arr.forEach((v, index) => { + // 当rank不存在时,根据顺序自动创建,首页路由永远在第一位 + if (handRank(v)) v.meta.rank = index + 2; + }); + return arr.sort( + (a: { meta: { rank: number } }, b: { meta: { rank: number } }) => { + return a?.meta.rank - b?.meta.rank; + } + ); +} + +/** 过滤meta中showLink为false的菜单 */ +function filterTree(data: RouteComponent[]) { + const newTree = cloneDeep(data).filter( + (v: { meta: { showLink: boolean } }) => v.meta?.showLink !== false + ); + newTree.forEach( + (v: { children }) => v.children && (v.children = filterTree(v.children)) + ); + return newTree; +} + +/** 过滤children长度为0的的目录,当目录下没有菜单时,会过滤此目录,目录没有赋予roles权限,当目录下只要有一个菜单有显示权限,那么此目录就会显示 */ +function filterChildrenTree(data: RouteComponent[]) { + const newTree = cloneDeep(data).filter((v: any) => v?.children?.length !== 0); + newTree.forEach( + (v: { children }) => v.children && (v.children = filterTree(v.children)) + ); + return newTree; +} + +/** 判断两个数组彼此是否存在相同值 */ +function isOneOfArray(a: Array , b: Array ) { + return Array.isArray(a) && Array.isArray(b) + ? intersection(a, b).length > 0 + ? true + : false + : true; +} + +/** 从localStorage里取出当前登录用户的角色roles,过滤无权限的菜单 */ +function filterNoPermissionTree(data: RouteComponent[]) { + const currentRoles = + storageLocal().getItem >(userKey)?.roles ?? []; + const newTree = cloneDeep(data).filter((v: any) => + isOneOfArray(v.meta?.roles, currentRoles) + ); + newTree.forEach( + (v: any) => v.children && (v.children = filterNoPermissionTree(v.children)) + ); + return filterChildrenTree(newTree); +} + +/** 通过指定 `key` 获取父级路径集合,默认 `key` 为 `path` */ +function getParentPaths(value: string, routes: RouteRecordRaw[], key = "path") { + // 深度遍历查找 + function dfs(routes: RouteRecordRaw[], value: string, parents: string[]) { + for (let i = 0; i < routes.length; i++) { + const item = routes[i]; + // 返回父级path + if (item[key] === value) return parents; + // children不存在或为空则不递归 + if (!item.children || !item.children.length) continue; + // 往下查找时将当前path入栈 + parents.push(item.path); + + if (dfs(item.children, value, parents).length) return parents; + // 深度遍历查找未找到时当前path 出栈 + parents.pop(); + } + // 未找到时返回空数组 + return []; + } + + return dfs(routes, value, []); +} + +/** 查找对应 `path` 的路由信息 */ +function findRouteByPath(path: string, routes: RouteRecordRaw[]) { + let res = routes.find((item: { path: string }) => item.path == path); + if (res) { + return isProxy(res) ? toRaw(res) : res; + } else { + for (let i = 0; i < routes.length; i++) { + if ( + routes[i].children instanceof Array && + routes[i].children.length > 0 + ) { + res = findRouteByPath(path, routes[i].children); + if (res) { + return isProxy(res) ? toRaw(res) : res; + } + } + } + return null; + } +} + +/** 动态路由注册完成后,再添加全屏404(页面不存在)页面,避免刷新动态路由页面时误跳转到404页面 */ +function addPathMatch() { + if (!router.hasRoute("pathMatch")) { + router.addRoute({ + path: "/:pathMatch(.*)*", + name: "PageNotFound", + component: () => import("@/views/error/404.vue"), + meta: { + title: "404", + showLink: false + } + }); + } +} + +/** 处理动态路由(后端返回的路由) */ +function handleAsyncRoutes(routeList) { + if (routeList.length === 0) { + usePermissionStoreHook().handleWholeMenus(routeList); + } else { + formatFlatteningRoutes(addAsyncRoutes(routeList)).map( + (v: RouteRecordRaw) => { + // 防止重复添加路由 + if ( + router.options.routes[0].children.findIndex( + value => value.path === v.path + ) !== -1 + ) { + return; + } else { + // 切记将路由push到routes后还需要使用addRoute,这样路由才能正常跳转 + router.options.routes[0].children.push(v); + // 最终路由进行升序 + ascending(router.options.routes[0].children); + if (!router.hasRoute(v?.name)) router.addRoute(v); + const flattenRouters: any = router + .getRoutes() + .find(n => n.path === "/"); + // 保持router.options.routes[0].children与path为"/"的children一致,防止数据不一致导致异常 + flattenRouters.children = router.options.routes[0].children; + router.addRoute(flattenRouters); + } + } + ); + usePermissionStoreHook().handleWholeMenus(routeList); + } + if (!useMultiTagsStoreHook().getMultiTagsCache) { + useMultiTagsStoreHook().handleTags("equal", [ + ...routerArrays, + ...usePermissionStoreHook().flatteningRoutes.filter( + v => v?.meta?.fixedTag + ) + ]); + } + addPathMatch(); +} + +/** 初始化路由(`new Promise` 写法防止在异步请求中造成无限循环)*/ +function initRouter() { + if (getConfig()?.CachingAsyncRoutes) { + // 开启动态路由缓存本地localStorage + const key = "async-routes"; + const asyncRouteList = storageLocal().getItem(key) as any; + if (asyncRouteList && asyncRouteList?.length > 0) { + return new Promise(resolve => { + handleAsyncRoutes(asyncRouteList); + resolve(router); + }); + } else { + return new Promise(resolve => { + getAsyncRoutes().then(({ data }) => { + handleAsyncRoutes(cloneDeep(data)); + storageLocal().setItem(key, data); + resolve(router); + }); + }); + } + } else { + return new Promise(resolve => { + getAsyncRoutes().then(({ data }) => { + handleAsyncRoutes(cloneDeep(data)); + resolve(router); + }); + }); + } +} + +/** + * 将多级嵌套路由处理成一维数组 + * @param routesList 传入路由 + * @returns 返回处理后的一维路由 + */ +function formatFlatteningRoutes(routesList: RouteRecordRaw[]) { + if (routesList.length === 0) return routesList; + let hierarchyList = buildHierarchyTree(routesList); + for (let i = 0; i < hierarchyList.length; i++) { + if (hierarchyList[i].children) { + hierarchyList = hierarchyList + .slice(0, i + 1) + .concat(hierarchyList[i].children, hierarchyList.slice(i + 1)); + } + } + return hierarchyList; +} + +/** + * 一维数组处理成多级嵌套数组(三级及以上的路由全部拍成二级,keep-alive 只支持到二级缓存) + * https://github.com/pure-admin/vue-pure-admin/issues/67 + * @param routesList 处理后的一维路由菜单数组 + * @returns 返回将一维数组重新处理成规定路由的格式 + */ +function formatTwoStageRoutes(routesList: RouteRecordRaw[]) { + if (routesList.length === 0) return routesList; + const newRoutesList: RouteRecordRaw[] = []; + routesList.forEach((v: RouteRecordRaw) => { + if (v.path === "/") { + newRoutesList.push({ + component: v.component, + name: v.name, + path: v.path, + redirect: v.redirect, + meta: v.meta, + children: [] + }); + } else { + newRoutesList[0]?.children.push({ ...v }); + } + }); + return newRoutesList; +} + +/** 处理缓存路由(添加、删除、刷新) */ +function handleAliveRoute({ name }: ToRouteType, mode?: string) { + switch (mode) { + case "add": + usePermissionStoreHook().cacheOperate({ + mode: "add", + name + }); + break; + case "delete": + usePermissionStoreHook().cacheOperate({ + mode: "delete", + name + }); + break; + case "refresh": + usePermissionStoreHook().cacheOperate({ + mode: "refresh", + name + }); + break; + default: + usePermissionStoreHook().cacheOperate({ + mode: "delete", + name + }); + useTimeoutFn(() => { + usePermissionStoreHook().cacheOperate({ + mode: "add", + name + }); + }, 100); + } +} + +/** 过滤后端传来的动态路由 重新生成规范路由 */ +function addAsyncRoutes(arrRoutes: Array ) { + if (!arrRoutes || !arrRoutes.length) return; + const modulesRoutesKeys = Object.keys(modulesRoutes); + arrRoutes.forEach((v: RouteRecordRaw) => { + // 将backstage属性加入meta,标识此路由为后端返回路由 + v.meta.backstage = true; + // 父级的redirect属性取值:如果子级存在且父级的redirect属性不存在,默认取第一个子级的path;如果子级存在且父级的redirect属性存在,取存在的redirect属性,会覆盖默认值 + if (v?.children && v.children.length && !v.redirect) + v.redirect = v.children[0].path; + // 父级的name属性取值:如果子级存在且父级的name属性不存在,默认取第一个子级的name;如果子级存在且父级的name属性存在,取存在的name属性,会覆盖默认值(注意:测试中发现父级的name不能和子级name重复,如果重复会造成重定向无效(跳转404),所以这里给父级的name起名的时候后面会自动加上`Parent`,避免重复) + if (v?.children && v.children.length && !v.name) + v.name = (v.children[0].name as string) + "Parent"; + if (v.meta?.frameSrc) { + v.component = IFrame; + } else { + // 对后端传component组件路径和不传做兼容(如果后端传component组件路径,那么path可以随便写,如果不传,component组件路径会跟path保持一致) + const index = v?.component + ? modulesRoutesKeys.findIndex(ev => ev.includes(v.component as any)) + : modulesRoutesKeys.findIndex(ev => ev.includes(v.path)); + v.component = modulesRoutes[modulesRoutesKeys[index]]; + } + if (v?.children && v.children.length) { + addAsyncRoutes(v.children); + } + }); + return arrRoutes; +} + +/** 获取路由历史模式 https://next.router.vuejs.org/zh/guide/essentials/history-mode.html */ +function getHistoryMode(routerHistory): RouterHistory { + // len为1 代表只有历史模式 为2 代表历史模式中存在base参数 https://next.router.vuejs.org/zh/api/#%E5%8F%82%E6%95%B0-1 + const historyMode = routerHistory.split(","); + const leftMode = historyMode[0]; + const rightMode = historyMode[1]; + // no param + if (historyMode.length === 1) { + if (leftMode === "hash") { + return createWebHashHistory(""); + } else if (leftMode === "h5") { + return createWebHistory(""); + } + } //has param + else if (historyMode.length === 2) { + if (leftMode === "hash") { + return createWebHashHistory(rightMode); + } else if (leftMode === "h5") { + return createWebHistory(rightMode); + } + } +} + +/** 获取当前页面按钮级别的权限 */ +function getAuths(): Array { + return router.currentRoute.value.meta.auths as Array ; +} + +/** 是否有按钮级别的权限(根据路由`meta`中的`auths`字段进行判断)*/ +function hasAuth(value: string | Array ): boolean { + if (!value) return false; + /** 从当前路由的`meta`字段里获取按钮级别的所有自定义`code`值 */ + const metaAuths = getAuths(); + if (!metaAuths) return false; + const isAuths = isString(value) + ? metaAuths.includes(value) + : isIncludeAllChildren(value, metaAuths); + return isAuths ? true : false; +} + +function handleTopMenu(route) { + if (route?.children && route.children.length > 1) { + if (route.redirect) { + return route.children.filter(cur => cur.path === route.redirect)[0]; + } else { + return route.children[0]; + } + } else { + return route; + } +} + +/** 获取所有菜单中的第一个菜单(顶级菜单)*/ +function getTopMenu(tag = false): menuType { + const topMenu = handleTopMenu( + usePermissionStoreHook().wholeMenus[0]?.children[0] + ); + tag && useMultiTagsStoreHook().handleTags("push", topMenu); + return topMenu; +} + +export { + hasAuth, + getAuths, + ascending, + filterTree, + initRouter, + getTopMenu, + addPathMatch, + isOneOfArray, + getHistoryMode, + addAsyncRoutes, + getParentPaths, + findRouteByPath, + handleAliveRoute, + formatTwoStageRoutes, + formatFlatteningRoutes, + filterNoPermissionTree +}; diff --git a/admin-ui/src/store/index.ts b/admin-ui/src/store/index.ts new file mode 100644 index 0000000..a8dc752 --- /dev/null +++ b/admin-ui/src/store/index.ts @@ -0,0 +1,9 @@ +import type { App } from "vue"; +import { createPinia } from "pinia"; +const store = createPinia(); + +export function setupStore(app: App ) { + app.use(store); +} + +export { store }; diff --git a/admin-ui/src/store/modules/app.ts b/admin-ui/src/store/modules/app.ts new file mode 100644 index 0000000..1f5a9a1 --- /dev/null +++ b/admin-ui/src/store/modules/app.ts @@ -0,0 +1,85 @@ +import { defineStore } from "pinia"; +import { + type appType, + store, + getConfig, + storageLocal, + deviceDetection, + responsiveStorageNameSpace +} from "../utils"; + +export const useAppStore = defineStore("pure-app", { + state: (): appType => ({ + sidebar: { + opened: + storageLocal().getItem ( + `${responsiveStorageNameSpace()}layout` + )?.sidebarStatus ?? getConfig().SidebarStatus, + withoutAnimation: false, + isClickCollapse: false + }, + // 这里的layout用于监听容器拖拉后恢复对应的导航模式 + layout: + storageLocal().getItem ( + `${responsiveStorageNameSpace()}layout` + )?.layout ?? getConfig().Layout, + device: deviceDetection() ? "mobile" : "desktop", + // 浏览器窗口的可视区域大小 + viewportSize: { + width: document.documentElement.clientWidth, + height: document.documentElement.clientHeight + } + }), + getters: { + getSidebarStatus(state) { + return state.sidebar.opened; + }, + getDevice(state) { + return state.device; + }, + getViewportWidth(state) { + return state.viewportSize.width; + }, + getViewportHeight(state) { + return state.viewportSize.height; + } + }, + actions: { + TOGGLE_SIDEBAR(opened?: boolean, resize?: string) { + const layout = storageLocal().getItem ( + `${responsiveStorageNameSpace()}layout` + ); + if (opened && resize) { + this.sidebar.withoutAnimation = true; + this.sidebar.opened = true; + layout.sidebarStatus = true; + } else if (!opened && resize) { + this.sidebar.withoutAnimation = true; + this.sidebar.opened = false; + layout.sidebarStatus = false; + } else if (!opened && !resize) { + this.sidebar.withoutAnimation = false; + this.sidebar.opened = !this.sidebar.opened; + this.sidebar.isClickCollapse = !this.sidebar.opened; + layout.sidebarStatus = this.sidebar.opened; + } + storageLocal().setItem(`${responsiveStorageNameSpace()}layout`, layout); + }, + async toggleSideBar(opened?: boolean, resize?: string) { + await this.TOGGLE_SIDEBAR(opened, resize); + }, + toggleDevice(device: string) { + this.device = device; + }, + setLayout(layout) { + this.layout = layout; + }, + setViewportSize(size) { + this.viewportSize = size; + } + } +}); + +export function useAppStoreHook() { + return useAppStore(store); +} diff --git a/admin-ui/src/store/modules/epTheme.ts b/admin-ui/src/store/modules/epTheme.ts new file mode 100644 index 0000000..e6f62d2 --- /dev/null +++ b/admin-ui/src/store/modules/epTheme.ts @@ -0,0 +1,49 @@ +import { defineStore } from "pinia"; +import { + store, + getConfig, + storageLocal, + responsiveStorageNameSpace +} from "../utils"; + +export const useEpThemeStore = defineStore("pure-epTheme", { + state: () => ({ + epThemeColor: + storageLocal().getItem ( + `${responsiveStorageNameSpace()}layout` + )?.epThemeColor ?? getConfig().EpThemeColor, + epTheme: + storageLocal().getItem ( + `${responsiveStorageNameSpace()}layout` + )?.theme ?? getConfig().Theme + }), + getters: { + getEpThemeColor(state) { + return state.epThemeColor; + }, + /** 用于mix导航模式下hamburger-svg的fill属性 */ + fill(state) { + if (state.epTheme === "light") { + return "#409eff"; + } else { + return "#fff"; + } + } + }, + actions: { + setEpThemeColor(newColor: string): void { + const layout = storageLocal().getItem ( + `${responsiveStorageNameSpace()}layout` + ); + this.epTheme = layout?.theme; + this.epThemeColor = newColor; + if (!layout) return; + layout.epThemeColor = newColor; + storageLocal().setItem(`${responsiveStorageNameSpace()}layout`, layout); + } + } +}); + +export function useEpThemeStoreHook() { + return useEpThemeStore(store); +} diff --git a/admin-ui/src/store/modules/multiTags.ts b/admin-ui/src/store/modules/multiTags.ts new file mode 100644 index 0000000..d798578 --- /dev/null +++ b/admin-ui/src/store/modules/multiTags.ts @@ -0,0 +1,138 @@ +import { defineStore } from "pinia"; +import { + type multiType, + type positionType, + store, + isUrl, + isEqual, + isNumber, + isBoolean, + getConfig, + routerArrays, + storageLocal, + responsiveStorageNameSpace +} from "../utils"; +import { usePermissionStoreHook } from "./permission"; + +export const useMultiTagsStore = defineStore("pure-multiTags", { + state: () => ({ + // 存储标签页信息(路由信息) + multiTags: storageLocal().getItem ( + `${responsiveStorageNameSpace()}configure` + )?.multiTagsCache + ? storageLocal().getItem ( + `${responsiveStorageNameSpace()}tags` + ) + : ([ + ...routerArrays, + ...usePermissionStoreHook().flatteningRoutes.filter( + v => v?.meta?.fixedTag + ) + ] as any), + multiTagsCache: storageLocal().getItem ( + `${responsiveStorageNameSpace()}configure` + )?.multiTagsCache + }), + getters: { + getMultiTagsCache(state) { + return state.multiTagsCache; + } + }, + actions: { + multiTagsCacheChange(multiTagsCache: boolean) { + this.multiTagsCache = multiTagsCache; + if (multiTagsCache) { + storageLocal().setItem( + `${responsiveStorageNameSpace()}tags`, + this.multiTags + ); + } else { + storageLocal().removeItem(`${responsiveStorageNameSpace()}tags`); + } + }, + tagsCache(multiTags) { + this.getMultiTagsCache && + storageLocal().setItem( + `${responsiveStorageNameSpace()}tags`, + multiTags + ); + }, + handleTags ( + mode: string, + value?: T | multiType, + position?: positionType + ): T { + switch (mode) { + case "equal": + this.multiTags = value; + this.tagsCache(this.multiTags); + break; + case "push": + { + const tagVal = value as multiType; + // 不添加到标签页 + if (tagVal?.meta?.hiddenTag) return; + // 如果是外链无需添加信息到标签页 + if (isUrl(tagVal?.name)) return; + // 如果title为空拒绝添加空信息到标签页 + if (tagVal?.meta?.title.length === 0) return; + // showLink:false 不添加到标签页 + if (isBoolean(tagVal?.meta?.showLink) && !tagVal?.meta?.showLink) + return; + const tagPath = tagVal.path; + const tagHasExits = this.multiTags.some(tag => { + return ( + tag.path === tagPath && + isEqual(tag?.query, tagVal?.query) && + isEqual(tag?.params, tagVal?.params) + ); + }); + + if (tagHasExits) return; + + // 动态路由可打开的最大数量 + const dynamicLevel = tagVal?.meta?.dynamicLevel ?? -1; + if (dynamicLevel > 0) { + if ( + this.multiTags.filter(e => e?.path === tagPath).length >= + dynamicLevel + ) { + // 如果当前已打开的动态路由数大于dynamicLevel,替换第一个动态路由标签 + const index = this.multiTags.findIndex( + item => item?.path === tagPath + ); + index !== -1 && this.multiTags.splice(index, 1); + } + } + this.multiTags.push(value); + this.tagsCache(this.multiTags); + if ( + getConfig()?.MaxTagsLevel && + isNumber(getConfig().MaxTagsLevel) + ) { + if (this.multiTags.length > getConfig().MaxTagsLevel) { + this.multiTags.splice(1, 1); + } + } + } + break; + case "splice": + if (!position) { + const index = this.multiTags.findIndex(v => v.path === value); + if (index === -1) return; + this.multiTags.splice(index, 1); + } else { + this.multiTags.splice(position?.startIndex, position?.length); + } + this.tagsCache(this.multiTags); + return this.multiTags; + case "slice": + return this.multiTags.slice(-1); + } + } + } +}); + +export function useMultiTagsStoreHook() { + return useMultiTagsStore(store); +} diff --git a/admin-ui/src/store/modules/permission.ts b/admin-ui/src/store/modules/permission.ts new file mode 100644 index 0000000..b5e3b5d --- /dev/null +++ b/admin-ui/src/store/modules/permission.ts @@ -0,0 +1,75 @@ +import { defineStore } from "pinia"; +import { + type cacheType, + store, + ascending, + getKeyList, + filterTree, + constantMenus, + filterNoPermissionTree, + formatFlatteningRoutes +} from "../utils"; +import { useMultiTagsStoreHook } from "./multiTags"; + +export const usePermissionStore = defineStore("pure-permission", { + state: () => ({ + // 静态路由生成的菜单 + constantMenus, + // 整体路由生成的菜单(静态、动态) + wholeMenus: [], + // 整体路由(一维数组格式) + flatteningRoutes: [], + // 缓存页面keepAlive + cachePageList: [] + }), + actions: { + /** 组装整体路由生成的菜单 */ + handleWholeMenus(routes: any[]) { + this.wholeMenus = filterNoPermissionTree( + filterTree(ascending(this.constantMenus.concat(routes))) + ); + this.flatteningRoutes = formatFlatteningRoutes( + this.constantMenus.concat(routes) as any + ); + }, + /** 监听缓存页面是否存在于标签页,不存在则删除 */ + clearCache() { + let cacheLength = this.cachePageList.length; + const nameList = getKeyList(useMultiTagsStoreHook().multiTags, "name"); + while (cacheLength > 0) { + nameList.findIndex(v => v === this.cachePageList[cacheLength - 1]) === + -1 && + this.cachePageList.splice( + this.cachePageList.indexOf(this.cachePageList[cacheLength - 1]), + 1 + ); + cacheLength--; + } + }, + cacheOperate({ mode, name }: cacheType) { + const delIndex = this.cachePageList.findIndex(v => v === name); + switch (mode) { + case "refresh": + this.cachePageList = this.cachePageList.filter(v => v !== name); + this.clearCache(); + break; + case "add": + this.cachePageList.push(name); + break; + case "delete": + delIndex !== -1 && this.cachePageList.splice(delIndex, 1); + this.clearCache(); + break; + } + }, + /** 清空缓存页面 */ + clearAllCachePage() { + this.wholeMenus = []; + this.cachePageList = []; + } + } +}); + +export function usePermissionStoreHook() { + return usePermissionStore(store); +} diff --git a/admin-ui/src/store/modules/settings.ts b/admin-ui/src/store/modules/settings.ts new file mode 100644 index 0000000..05c9807 --- /dev/null +++ b/admin-ui/src/store/modules/settings.ts @@ -0,0 +1,35 @@ +import { defineStore } from "pinia"; +import { type setType, store, getConfig } from "../utils"; + +export const useSettingStore = defineStore("pure-setting", { + state: (): setType => ({ + title: getConfig().Title, + fixedHeader: getConfig().FixedHeader, + hiddenSideBar: getConfig().HiddenSideBar + }), + getters: { + getTitle(state) { + return state.title; + }, + getFixedHeader(state) { + return state.fixedHeader; + }, + getHiddenSideBar(state) { + return state.hiddenSideBar; + } + }, + actions: { + CHANGE_SETTING({ key, value }) { + if (Reflect.has(this, key)) { + this[key] = value; + } + }, + changeSetting(data) { + this.CHANGE_SETTING(data); + } + } +}); + +export function useSettingStoreHook() { + return useSettingStore(store); +} diff --git a/admin-ui/src/store/modules/user.ts b/admin-ui/src/store/modules/user.ts new file mode 100644 index 0000000..09b6642 --- /dev/null +++ b/admin-ui/src/store/modules/user.ts @@ -0,0 +1,109 @@ +import { defineStore } from "pinia"; +import { + type userType, + store, + router, + resetRouter, + routerArrays, + storageLocal +} from "../utils"; +import { + type UserResult, + type RefreshTokenResult, + getLogin, + refreshTokenApi +} from "@/api/user"; +import { useMultiTagsStoreHook } from "./multiTags"; +import { type DataInfo, setToken, removeToken, userKey } from "@/utils/auth"; + +export const useUserStore = defineStore("pure-user", { + state: (): userType => ({ + // 头像 + avatar: storageLocal().getItem >(userKey)?.avatar ?? "", + // 用户名 + username: storageLocal().getItem >(userKey)?.username ?? "", + // 昵称 + nickname: storageLocal().getItem >(userKey)?.nickname ?? "", + // 页面级别权限 + roles: storageLocal().getItem >(userKey)?.roles ?? [], + // 按钮级别权限 + permissions: + storageLocal().getItem >(userKey)?.permissions ?? [], + // 是否勾选了登录页的免登录 + isRemembered: false, + // 登录页的免登录存储几天,默认7天 + loginDay: 7 + }), + actions: { + /** 存储头像 */ + SET_AVATAR(avatar: string) { + this.avatar = avatar; + }, + /** 存储用户名 */ + SET_USERNAME(username: string) { + this.username = username; + }, + /** 存储昵称 */ + SET_NICKNAME(nickname: string) { + this.nickname = nickname; + }, + /** 存储角色 */ + SET_ROLES(roles: Array ) { + this.roles = roles; + }, + /** 存储按钮级别权限 */ + SET_PERMS(permissions: Array ) { + this.permissions = permissions; + }, + /** 存储是否勾选了登录页的免登录 */ + SET_ISREMEMBERED(bool: boolean) { + this.isRemembered = bool; + }, + /** 设置登录页的免登录存储几天 */ + SET_LOGINDAY(value: number) { + this.loginDay = Number(value); + }, + /** 登入 */ + async loginByUsername(data) { + return new Promise ((resolve, reject) => { + getLogin(data) + .then(data => { + if (data?.success) setToken(data.data); + resolve(data); + }) + .catch(error => { + reject(error); + }); + }); + }, + /** 前端登出(不调用接口) */ + logOut() { + this.username = ""; + this.roles = []; + this.permissions = []; + removeToken(); + useMultiTagsStoreHook().handleTags("equal", [...routerArrays]); + resetRouter(); + router.push("/login"); + }, + /** 刷新`token` */ + async handRefreshToken(data) { + return new Promise ((resolve, reject) => { + refreshTokenApi(data) + .then(data => { + if (data) { + setToken(data.data); + resolve(data); + } + }) + .catch(error => { + reject(error); + }); + }); + } + } +}); + +export function useUserStoreHook() { + return useUserStore(store); +} diff --git a/admin-ui/src/store/types.ts b/admin-ui/src/store/types.ts new file mode 100644 index 0000000..c33268a --- /dev/null +++ b/admin-ui/src/store/types.ts @@ -0,0 +1,47 @@ +import type { RouteRecordName } from "vue-router"; + +export type cacheType = { + mode: string; + name?: RouteRecordName; +}; + +export type positionType = { + startIndex?: number; + length?: number; +}; + +export type appType = { + sidebar: { + opened: boolean; + withoutAnimation: boolean; + // 判断是否手动点击Collapse + isClickCollapse: boolean; + }; + layout: string; + device: string; + viewportSize: { width: number; height: number }; +}; + +export type multiType = { + path: string; + name: string; + meta: any; + query?: object; + params?: object; +}; + +export type setType = { + title: string; + fixedHeader: boolean; + hiddenSideBar: boolean; +}; + +export type userType = { + avatar?: string; + username?: string; + nickname?: string; + roles?: Array ; + permissions?: Array ; + isRemembered?: boolean; + loginDay?: number; +}; diff --git a/admin-ui/src/store/utils.ts b/admin-ui/src/store/utils.ts new file mode 100644 index 0000000..5dd8c75 --- /dev/null +++ b/admin-ui/src/store/utils.ts @@ -0,0 +1,28 @@ +export { store } from "@/store"; +export { routerArrays } from "@/layout/types"; +export { router, resetRouter, constantMenus } from "@/router"; +export { getConfig, responsiveStorageNameSpace } from "@/config"; +export { + ascending, + filterTree, + filterNoPermissionTree, + formatFlatteningRoutes +} from "@/router/utils"; +export { + isUrl, + isEqual, + isNumber, + debounce, + isBoolean, + getKeyList, + storageLocal, + deviceDetection +} from "@pureadmin/utils"; +export type { + setType, + appType, + userType, + multiType, + cacheType, + positionType +} from "./types"; diff --git a/admin-ui/src/style/dark.scss b/admin-ui/src/style/dark.scss new file mode 100644 index 0000000..746902f --- /dev/null +++ b/admin-ui/src/style/dark.scss @@ -0,0 +1,182 @@ +@use "element-plus/theme-chalk/src/dark/css-vars.scss" as *; + +/* 整体暗色风格适配 */ +html.dark { + $border-style: #303030; + $color-white: #fff; + + /* 自定义深色背景颜色 */ + // --el-bg-color: #020409; + + /* 常用border-color 需要时可取用 */ + --pure-border-color: rgb(253 253 253 / 12%); + + /* switch关闭状态下的color 需要时可取用 */ + --pure-switch-off-color: #ffffff3f; + + .navbar, + .tags-view, + .contextmenu, + .sidebar-container, + .horizontal-header, + .sidebar-logo-container, + .horizontal-header .el-sub-menu__title, + .horizontal-header .submenu-title-noDropdown { + background: var(--el-bg-color) !important; + } + + .app-main, + .app-main-nofixed-header { + background: #020409 !important; + } + + /* 标签页 */ + .tags-view { + .arrow-left, + .arrow-right { + border-right: 1px solid $border-style; + box-shadow: none; + } + + .arrow-right { + border-left: 1px solid $border-style; + } + + .scroll-item { + .el-icon-close { + &:hover { + color: rgb(255 255 255 / 85%) !important; + background-color: rgb(255 255 255 / 12%); + } + } + + .chrome-tab { + .tag-title { + color: #666; + } + + &:hover { + .chrome-tab__bg { + color: #333; + } + + .tag-title { + color: #adadad; + } + } + } + } + } + + /* 系统配置面板 */ + .right-panel-items { + .el-divider__text { + --el-bg-color: var(--el-bg-color); + } + + .el-divider--horizontal { + border-top: none; + } + } + + .el-card { + --el-card-bg-color: var(--el-bg-color); + } + + .el-backtop { + --el-backtop-bg-color: rgb(72 72 78); + --el-backtop-hover-bg-color: var(--el-color-primary); + + transition: background-color 0.25s cubic-bezier(0.7, 0.3, 0.1, 1); + } + + .el-dropdown-menu__item:not(.is-disabled):hover { + background: transparent; + } + + /* 全局覆盖element-plus的el-dialog、el-drawer、el-message-box、el-notification组件右上角关闭图标的样式,表现更鲜明 */ + .el-icon { + &.el-dialog__close, + &.el-drawer__close, + &.el-message-box__close, + &.el-notification__closeBtn { + &:hover { + color: rgb(255 255 255 / 85%) !important; + background-color: rgb(255 255 255 / 12%); + + .pure-dialog-svg { + color: rgb(255 255 255 / 85%) !important; + } + } + } + } + + /* 克隆并自定义 ElMessage 样式,不会影响 ElMessage 原本样式,在 src/utils/message.ts 中调用自定义样式 ElMessage 方法即可,整体浅色风格在 src/style/element-plus.scss 文件进行了适配 */ + .pure-message { + background-color: rgb(36 37 37) !important; + background-image: initial !important; + box-shadow: + rgb(13 13 13 / 12%) 0 3px 6px -4px, + rgb(13 13 13 / 8%) 0 6px 16px 0, + rgb(13 13 13 / 5%) 0 9px 28px 8px !important; + + & .el-message__content { + color: $color-white !important; + pointer-events: all !important; + background-image: initial !important; + } + + & .el-message__closeBtn { + &:hover { + color: rgb(255 255 255 / 85%); + background-color: rgb(255 255 255 / 12%); + } + } + } + + /* 自定义菜单搜索样式 */ + .pure-search-dialog { + .el-dialog__footer { + box-shadow: + 0 -1px 0 0 #555a64, + 0 -3px 6px 0 rgb(69 98 155 / 12%); + } + + .search-footer { + .search-footer-item { + color: rgb(235 235 235 / 60%); + + .icon { + box-shadow: none; + } + } + } + } + + /* ReSegmented 组件 */ + .pure-segmented { + color: rgb(255 255 255 / 65%); + background-color: #000; + + .pure-segmented-item-selected { + background-color: #1f1f1f; + } + + .pure-segmented-item-disabled { + color: rgb(255 255 255 / 25%); + } + } + + /* 仿 el-scrollbar 滚动条样式 支持大多数浏览器,如Chrome、Edge、Firefox、Safari等 */ + .pure-scrollbar { + scrollbar-color: rgb(63 64 66) transparent; + + ::-webkit-scrollbar-thumb { + background-color: rgb(63 64 66); + } + + ::-webkit-scrollbar-thumb:hover { + background: rgb(92 93 96); + } + } +} diff --git a/admin-ui/src/style/element-plus.scss b/admin-ui/src/style/element-plus.scss new file mode 100644 index 0000000..f111d75 --- /dev/null +++ b/admin-ui/src/style/element-plus.scss @@ -0,0 +1,189 @@ +.el-form-item__label { + font-weight: 700; +} + +.el-breadcrumb__inner, +.el-breadcrumb__inner a { + font-weight: 400 !important; +} + +.el-dropdown-menu { + padding: 0 !important; +} + +.is-dark { + z-index: 9999 !important; +} + +/* 重置 el-button 中 icon 的 margin */ +.reset-margin [class*="el-icon"] + span { + margin-left: 2px !important; +} + +/* 自定义 popover 的类名 */ +.pure-popper { + padding: 0 !important; +} + +/* nprogress 适配 element-plus 的主题色 */ +#nprogress { + & .bar { + background-color: var(--el-color-primary) !important; + } + + & .peg { + box-shadow: + 0 0 10px var(--el-color-primary), + 0 0 5px var(--el-color-primary) !important; + } + + & .spinner-icon { + border-top-color: var(--el-color-primary); + border-left-color: var(--el-color-primary); + } +} + +.pure-dialog { + .el-dialog__header.show-close { + padding-right: 16px; + } + + .el-dialog__headerbtn { + top: 16px; + right: 12px; + width: 24px; + height: 24px; + } + + .pure-dialog-svg { + color: var(--el-color-info); + } + + .el-dialog__footer { + padding-top: 0; + } +} + +/* 全局覆盖element-plus的el-tour、el-dialog、el-drawer、el-message-box、el-notification组件右上角关闭图标和el-upload上传文件列表右侧关闭图标的样式,表现更鲜明 */ +.el-dialog__headerbtn, +.el-message-box__headerbtn { + &:hover { + .el-dialog__close { + color: var(--el-color-info) !important; + } + } +} + +.el-icon { + &.el-tour__close, + &.el-dialog__close, + &.el-drawer__close, + &.el-message-box__close, + &.el-notification__closeBtn, + .el-upload-list__item.is-ready &.el-icon--close { + width: 24px; + height: 24px; + outline: none; + border-radius: 4px; + transition: + background-color 0.2s, + color 0.2s; + + &:hover { + color: rgb(0 0 0 / 88%) !important; + text-decoration: none; + background-color: rgb(0 0 0 / 6%); + + .pure-dialog-svg { + color: rgb(0 0 0 / 88%) !important; + } + } + } +} + +/* 克隆并自定义 ElMessage 样式,不会影响 ElMessage 原本样式,在 src/utils/message.ts 中调用自定义样式 ElMessage 方法即可,整体暗色风格在 src/style/dark.scss 文件进行了适配 */ +.pure-message { + background: #fff !important; + border-width: 0 !important; + box-shadow: + 0 3px 6px -4px #0000001f, + 0 6px 16px #00000014, + 0 9px 28px 8px #0000000d !important; + + & .el-message__content { + color: #000000d9 !important; + pointer-events: all !important; + background-image: initial !important; + } + + & .el-message__closeBtn { + outline: none; + border-radius: 4px; + transition: + background-color 0.2s, + color 0.2s; + + &:hover { + background-color: rgb(0 0 0 / 6%); + } + } +} + +/* 自定义菜单搜索样式 */ +.pure-search-dialog { + @media screen and (width > 760px) and (width <= 940px) { + .el-input__inner { + font-size: 12px; + } + } + + @media screen and (width <= 470px) { + .el-input__inner { + font-size: 12px; + } + } + + .el-dialog__header { + display: none; + } + + .el-input__inner { + font-size: 1.2em; + } + + .el-dialog__footer { + width: calc(100% + 32px); + padding: 10px 20px; + margin: auto -16px -16px; + box-shadow: + 0 -1px 0 0 #e0e3e8, + 0 -3px 6px 0 rgb(69 98 155 / 12%); + } +} + +/* 仿 el-scrollbar 滚动条样式,支持大多数浏览器,如Chrome、Edge、Firefox、Safari等。整体暗色风格在 src/style/dark.scss 文件进行了适配 */ +.pure-scrollbar { + scrollbar-color: rgb(221 222 224) transparent; /* 滑块颜色、轨道颜色 */ + + /* Firefox */ + scrollbar-width: thin; /* 可选值为 'auto', 'thin', 'none' */ + ::-webkit-scrollbar { + width: 6px; /* 滚动条宽度 */ + } + + /* 滚动条轨道 */ + ::-webkit-scrollbar-track { + background: transparent; /* 轨道颜色 */ + } + + /* 滚动条滑块 */ + ::-webkit-scrollbar-thumb { + background-color: rgb(221 222 224); + border-radius: 4px; + } + + /* 滚动条滑块:hover状态 */ + ::-webkit-scrollbar-thumb:hover { + background: rgb(199 201 203); /* 滑块hover颜色 */ + } +} diff --git a/admin-ui/src/style/index.scss b/admin-ui/src/style/index.scss new file mode 100644 index 0000000..aac8c32 --- /dev/null +++ b/admin-ui/src/style/index.scss @@ -0,0 +1,37 @@ +@use "theme"; +@use "transition"; +@use "element-plus"; +@use "sidebar"; +@use "dark"; + +/* 自定义全局 CssVar */ +:root { + /* 左侧菜单展开、收起动画时长 */ + --pure-transition-duration: 0.3s; + + /* 常用border-color 需要时可取用 */ + --pure-border-color: rgb(5 5 5 / 6%); + + /* switch关闭状态下的color 需要时可取用 */ + --pure-switch-off-color: #a6a6a6; + + /** 主题色 */ + --pure-theme-sub-menu-active-text: initial; + --pure-theme-menu-bg: none; + --pure-theme-menu-hover: none; + --pure-theme-sub-menu-bg: transparent; + --pure-theme-menu-text: initial; + --pure-theme-sidebar-logo: none; + --pure-theme-menu-title-hover: initial; + --pure-theme-menu-active-before: transparent; +} + +/* 灰色模式 */ +.html-grey { + filter: grayscale(100%); +} + +/* 色弱模式 */ +.html-weakness { + filter: invert(80%); +} diff --git a/admin-ui/src/style/login.css b/admin-ui/src/style/login.css new file mode 100644 index 0000000..3e0a8ab --- /dev/null +++ b/admin-ui/src/style/login.css @@ -0,0 +1,96 @@ +.wave { + position: fixed; + height: 100%; + width: 80%; + left: 0; + bottom: 0; + z-index: -1; +} + +.login-container { + width: 100vw; + height: 100vh; + max-width: 100%; + display: grid; + grid-template-columns: repeat(2, 1fr); + grid-gap: 18rem; + padding: 0 2rem; +} + +.img { + display: flex; + justify-content: flex-end; + align-items: center; +} + +.img img { + width: 500px; +} + +.login-box { + display: flex; + align-items: center; + text-align: center; + overflow: hidden; +} + +.login-form { + width: 360px; +} + +.avatar { + width: 350px; + height: 80px; +} + +.login-form h2 { + text-transform: uppercase; + margin: 15px 0; + color: #999; + font: + bold 200% Consolas, + Monaco, + monospace; +} + +@media screen and (max-width: 1180px) { + .login-container { + grid-gap: 9rem; + } + + .login-form { + width: 290px; + } + + .login-form h2 { + font-size: 2.4rem; + margin: 8px 0; + } + + .img img { + width: 360px; + } + + .avatar { + width: 280px; + height: 80px; + } +} + +@media screen and (max-width: 968px) { + .wave { + display: none; + } + + .img { + display: none; + } + + .login-container { + grid-template-columns: 1fr; + } + + .login-box { + justify-content: center; + } +} diff --git a/admin-ui/src/style/reset.scss b/admin-ui/src/style/reset.scss new file mode 100644 index 0000000..07fdfb8 --- /dev/null +++ b/admin-ui/src/style/reset.scss @@ -0,0 +1,250 @@ +#app { + width: 100%; + height: 100%; +} + +html { + box-sizing: border-box; + width: 100%; + height: 100%; + line-height: 1.5; + tab-size: 4; + text-size-adjust: 100%; +} + +body { + width: 100%; + height: 100%; + margin: 0; + font-family: + "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", + "Microsoft YaHei", "微软雅黑", Arial, sans-serif; + line-height: inherit; + -moz-osx-font-smoothing: grayscale; + -webkit-font-smoothing: antialiased; + text-rendering: optimizelegibility; +} + +hr { + height: 0; + color: inherit; + border-top-width: 1px; +} + +abbr:where([title]) { + text-decoration: underline dotted; +} + +a { + color: inherit; + text-decoration: inherit; +} + +b, +strong { + font-weight: bolder; +} + +code, +kbd, +samp, +pre { + font-family: + ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", + "Courier New", monospace; + font-size: 1em; +} + +small { + font-size: 80%; +} + +sub, +sup { + position: relative; + font-size: 75%; + line-height: 0; + vertical-align: baseline; +} + +sub { + bottom: -0.25em; +} + +sup { + top: -0.5em; +} + +table { + text-indent: 0; + border-collapse: collapse; + border-color: inherit; +} + +button, +input, +optgroup, +select, +textarea { + padding: 0; + margin: 0; + font-family: inherit; + font-size: 100%; + line-height: inherit; + color: inherit; +} + +button, +select { + text-transform: none; +} + +button, +[type="button"], +[type="reset"], +[type="submit"] { + background-image: none; +} + +:-moz-focusring { + outline: auto; +} + +:-moz-ui-invalid { + box-shadow: none; +} + +progress { + vertical-align: baseline; +} + +::-webkit-inner-spin-button, +::-webkit-outer-spin-button { + height: auto; +} + +[type="search"] { + outline-offset: -2px; +} + +::-webkit-file-upload-button { + font: inherit; +} + +summary { + display: list-item; +} + +blockquote, +dl, +dd, +h1, +h2, +h3, +h4, +h5, +h6, +hr, +figure, +p, +pre { + margin: 0; +} + +fieldset { + padding: 0; + margin: 0; +} + +legend { + padding: 0; +} + +ol, +ul, +menu { + padding: 0; + margin: 0; + list-style: none; +} + +textarea { + resize: vertical; +} + +input::placeholder, +textarea::placeholder { + color: #9ca3af; + opacity: 1; +} + +button, +[role="button"] { + cursor: pointer; +} + +:disabled { + cursor: default; +} + +svg, +video, +canvas, +audio, +iframe, +embed, +object { + display: block; +} + +img, +video { + max-width: 100%; + height: auto; +} + +[hidden] { + display: none; +} + +.dark { + color-scheme: dark; +} + +label { + font-weight: 700; +} + +*, +*::before, +*::after { + box-sizing: inherit; +} + +a:focus, +a:active { + outline: none; +} + +a, +a:focus, +a:hover { + color: inherit; + text-decoration: none; + cursor: pointer; +} + +div:focus { + outline: none; +} + +.clearfix { + &::after { + display: block; + height: 0; + clear: both; + font-size: 0; + visibility: hidden; + content: " "; + } +} diff --git a/admin-ui/src/style/sidebar.scss b/admin-ui/src/style/sidebar.scss new file mode 100644 index 0000000..1a18edf --- /dev/null +++ b/admin-ui/src/style/sidebar.scss @@ -0,0 +1,689 @@ +/* $sideBarWidth: vertical 模式下主体内容距离网页文档左侧的距离 */ +@mixin merge-style($sideBarWidth) { + @media screen and (width >= 150px) and (width <= 420px) { + .app-main-nofixed-header { + overflow-y: hidden; + } + } + + @media screen and (width >= 420px) { + .app-main-nofixed-header { + overflow: hidden; + } + } + + /* 修复 windows 下双滚动条问题 https://github.com/pure-admin/vue-pure-admin/pull/936#issuecomment-1968125992 */ + .el-popper.pure-scrollbar { + overflow: hidden; + } + + /* popper menu 超出内容区可滚动 */ + .pure-scrollbar { + max-height: calc(100vh - calc(50px * 2.5)); + overflow: hidden auto; + } + + .sub-menu-icon { + margin-right: 5px; + font-size: 18px; + + svg { + width: 18px; + height: 18px; + } + } + + .fullscreen-icon { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 48px; + cursor: pointer; + } + + .main-container { + position: relative; + height: 100vh; + min-height: 100%; + margin-left: $sideBarWidth; + background: #f0f2f5; + + /* main-content 属性动画 */ + transition: margin-left var(--pure-transition-duration); + + .el-scrollbar__wrap { + height: 100%; + overflow: auto; + } + } + + .fixed-header { + position: fixed; + top: 0; + right: 0; + z-index: 998; + width: calc(100% - #{$sideBarWidth}); + + /* fixed-header 属性左上角动画 */ + transition: width var(--pure-transition-duration); + } + + .main-hidden { + margin-left: 0 !important; + + .fixed-header { + width: 100% !important; + + + .app-main { + padding-top: 37px !important; + } + } + } + + .sidebar-container { + position: fixed; + top: 0; + bottom: 0; + left: 0; + z-index: 1001; + width: $sideBarWidth !important; + height: 100%; + overflow: visible; + font-size: 0; + background: var(--pure-theme-menu-bg) !important; + border-right: 1px solid var(--pure-border-color); + + /* 展开动画 */ + transition: width var(--pure-transition-duration); + + .scrollbar-wrapper { + overflow-x: hidden !important; + } + + .el-scrollbar__bar.is-vertical { + right: 0; + } + + &.has-logo { + .el-scrollbar.pc { + /* logo: 48px、leftCollapse: 40px、leftCollapse-shadow: 4px */ + height: calc(100% - 92px); + } + + /* logo: 48px */ + .el-scrollbar.mobile { + height: calc(100% - 48px); + } + } + + &.no-logo { + .el-scrollbar.pc { + /* leftCollapse: 40px、leftCollapse-shadow: 4px */ + height: calc(100% - 44px); + } + + .el-scrollbar.mobile { + height: 100%; + } + } + + .is-horizontal { + display: none; + } + + a { + display: flex; + flex-wrap: wrap; + width: 100%; + } + + .el-menu { + height: 100%; + background-color: transparent !important; + border: none; + } + + .el-menu-item, + .el-sub-menu__title { + height: 50px; + color: var(--pure-theme-menu-text); + background-color: transparent !important; + + &:hover { + color: var(--pure-theme-menu-title-hover) !important; + } + + div, + span { + height: 50px; + line-height: 50px; + } + } + + .submenu-title-noDropdown, + .el-sub-menu__title { + &:hover { + background-color: transparent; + } + } + + .is-active > .el-sub-menu__title, + .is-active.submenu-title-noDropdown { + color: var(--pure-theme-sub-menu-active-text) !important; + } + + .is-active { + color: var(--pure-theme-sub-menu-active-text) !important; + transition: color 0.3s; + } + + .el-menu-item.is-active.nest-menu > * { + z-index: 1; + color: #fff; + } + + .el-menu-item.is-active.nest-menu::before { + position: absolute; + inset: 0 8px; + clear: both; + margin: 4px 0; + content: ""; + background: var(--el-color-primary) !important; + border-radius: 3px; + } + + .el-menu .el-menu--inline .el-sub-menu__title, + & .el-sub-menu .el-menu-item { + min-width: $sideBarWidth !important; + font-size: 14px; + background-color: var(--pure-theme-sub-menu-bg) !important; + } + + /* 有子集的激活菜单左侧小竖条 */ + .el-menu--collapse + .is-active.outer-most.el-sub-menu + > .el-sub-menu__title::before { + position: absolute; + top: 0; + left: 0; + clear: both; + width: 2px; + height: 100%; + content: ""; + background-color: var(--pure-theme-menu-active-before); + transform: translateY(0); + transition: all var(--pure-transition-duration) ease-in-out; + } + + .el-menu--collapse .outer-most.el-sub-menu > .el-sub-menu__title::before { + position: absolute; + top: 50%; + display: block; + width: 3px; + height: 0; + content: ""; + transform: translateY(-50%); + } + + /* 无子集的激活菜单背景 */ + .is-active.submenu-title-noDropdown.outer-most > * { + z-index: 1; + color: #fff; + } + + .is-active.submenu-title-noDropdown.outer-most::before { + position: absolute; + inset: 0 8px; + clear: both; + margin: 4px 0; + content: ""; + background: var(--el-color-primary) !important; + border-radius: 3px; + } + } + + /* vertical 菜单折叠 */ + .el-menu--vertical { + .el-menu--popup { + background-color: var(--pure-theme-sub-menu-bg) !important; + + .el-menu-item { + span { + font-size: 14px; + } + } + } + + & > .el-menu { + i, + svg { + margin-right: 5px; + } + } + + .is-active > .el-sub-menu__title, + .is-active.submenu-title-noDropdown { + color: var(--pure-theme-sub-menu-active-text) !important; + } + + /* 子菜单中还有子菜单 */ + .el-menu .el-sub-menu__title { + min-width: $sideBarWidth !important; + font-size: 14px; + background-color: var(--pure-theme-sub-menu-bg) !important; + } + + .el-menu-item, + .el-sub-menu__title { + height: 50px; + line-height: 50px; + color: var(--pure-theme-menu-text); + background-color: var(--pure-theme-sub-menu-bg); + + &:hover { + color: var(--pure-theme-menu-title-hover) !important; + } + } + + .is-active { + color: var(--pure-theme-sub-menu-active-text) !important; + transition: color 0.3s; + } + + .el-menu-item.is-active.nest-menu > * { + z-index: 1; + color: #fff; + } + + .el-menu-item.is-active.nest-menu::before { + position: absolute; + inset: 0 8px; + clear: both; + content: ""; + background: var(--el-color-primary) !important; + border-radius: 3px; + } + + .el-menu-item, + .el-sub-menu { + .iconfont { + font-size: 18px; + } + + .el-menu-tooltip__trigger { + width: 54px; + padding: 0; + } + } + } + + /* horizontal 菜单 */ + .el-menu--horizontal { + & > .el-sub-menu .el-sub-menu__icon-arrow { + position: static !important; + margin-top: 0; + } + + /* 无子菜单时激活 border-bottom */ + a > .is-active.submenu-title-noDropdown { + border-bottom: 2px solid var(--el-menu-active-color); + } + + .el-menu--popup { + background-color: var(--pure-theme-sub-menu-bg) !important; + + a > .is-active.submenu-title-noDropdown { + border-bottom: none; + } + + .el-menu-item { + color: var(--pure-theme-menu-text); + background-color: var(--pure-theme-sub-menu-bg); + + span { + font-size: 14px; + } + } + + .el-sub-menu__title { + color: var(--pure-theme-menu-text); + } + } + + /* 子菜单中还有子菜单 */ + .el-menu .el-sub-menu__title { + min-width: $sideBarWidth !important; + font-size: 14px; + background-color: var(--pure-theme-sub-menu-bg) !important; + + &:hover { + color: var(--pure-theme-menu-title-hover) !important; + } + } + + .is-active > .el-sub-menu__title, + .is-active.submenu-title-noDropdown { + color: var(--pure-theme-sub-menu-active-text) !important; + } + + .nest-menu .el-sub-menu > .el-sub-menu__title, + .el-menu-item { + &:hover { + color: var(--pure-theme-menu-title-hover) !important; + } + } + + .el-menu-item.is-active { + color: var(--pure-theme-sub-menu-active-text) !important; + transition: color 0.3s; + } + + .el-menu-item.is-active.nest-menu > * { + z-index: 1; + color: #fff; + } + + .el-menu-item.is-active.nest-menu::before { + position: absolute; + inset: 0 5px; + clear: both; + content: ""; + background: var(--el-color-primary) !important; + border-radius: 3px; + } + } + + .horizontal-header { + display: flex; + align-items: center; + justify-content: space-around; + width: 100%; + height: 48px; + background: var(--pure-theme-menu-bg) !important; + + .horizontal-header-left { + display: flex; + align-items: center; + width: auto; + min-width: 200px; + height: 100%; + padding-left: 10px; + cursor: pointer; + transition: all var(--pure-transition-duration) ease; + + img { + display: inline-block; + height: 32px; + } + + span { + display: inline-block; + height: 32px; + margin: 2px 0 0 12px; + overflow: hidden; + text-overflow: ellipsis; + font-size: 18px; + font-weight: 600; + line-height: 32px; + color: var(--pure-theme-sub-menu-active-text); + white-space: nowrap; + } + } + + .horizontal-header-menu { + flex: 1; + align-items: center; + min-width: 0; + height: 100%; + } + + .horizontal-header-right { + display: flex; + align-items: center; + justify-content: flex-end; + min-width: 240px; + color: var(--pure-theme-sub-menu-active-text); + + /* 搜索 */ + .search-container, + /* 全屏 */ + .fullscreen-icon, + /* 用户名 */ + .el-dropdown-link { + &:hover { + background: var(--pure-theme-menu-hover); + } + } + + .el-dropdown-link { + display: flex; + align-items: center; + justify-content: space-around; + height: 48px; + padding: 10px; + color: var(--pure-theme-sub-menu-active-text); + cursor: pointer; + + p { + font-size: 14px; + } + + img { + width: 22px; + height: 22px; + border-radius: 50%; + } + } + } + + .el-menu { + width: 100% !important; + height: 100%; + background-color: transparent; + border: none; + } + + .el-menu-item, + .el-sub-menu__title { + padding-right: var(--el-menu-base-level-padding); + color: var(--pure-theme-menu-text); + + &:hover { + color: var(--pure-theme-menu-title-hover) !important; + } + } + + .submenu-title-noDropdown, + .el-sub-menu__title { + height: 48px; + line-height: 48px; + background: var(--pure-theme-menu-bg) !important; + + svg { + position: static !important; + } + } + + .is-active > .el-sub-menu__title, + .is-active.submenu-title-noDropdown { + color: var(--pure-theme-sub-menu-active-text) !important; + } + + .is-active { + color: var(--pure-theme-sub-menu-active-text) !important; + transition: color 0.3s; + } + } + + .el-menu--collapse .el-menu .el-sub-menu { + min-width: $sideBarWidth !important; + } + + /* 手机端 */ + .mobile { + .fixed-header { + width: 100% !important; + transition: width var(--pure-transition-duration); + } + + .main-container { + margin-left: 0 !important; + } + + .sidebar-container { + z-index: 2001; + width: $sideBarWidth; + transition: transform var(--pure-transition-duration); + } + + &.hideSidebar { + .sidebar-container { + pointer-events: none; + transform: translate3d(-$sideBarWidth, 0, 0); + transition-duration: 0.3s; + } + } + } +} + +body[layout="vertical"] { + $sideBarWidth: 210px; + + @include merge-style($sideBarWidth); + + .el-menu--collapse { + width: 54px; + } + + .sidebar-logo-container { + background: var(--pure-theme-sidebar-logo); + } + + .hideSidebar { + .fixed-header { + width: calc(100% - 54px); + transition: width var(--pure-transition-duration); + } + + .sidebar-container { + width: 54px !important; + transition: width var(--pure-transition-duration); + + .is-active.submenu-title-noDropdown.outer-most { + background: transparent !important; + } + } + + .main-container { + margin-left: 54px; + } + + /* 菜单折叠 */ + .el-menu--collapse { + .el-sub-menu { + & > .el-sub-menu__title { + & > span { + visibility: visible; + width: 100%; + height: 100%; + text-align: center; + } + } + } + + .submenu-title-noDropdown { + background: transparent !important; + } + + .el-sub-menu__title { + padding: 0; + } + } + + .sub-menu-icon { + margin-right: 0; + } + } + + /* 搜索 */ + .search-container, + /* 全屏 */ + .fullscreen-icon, + /* 用户名 */ + .el-dropdown-link { + &:hover { + background: #f6f6f6; + } + } +} + +body[layout="horizontal"] { + $sideBarWidth: 0; + + @include merge-style($sideBarWidth); + + .el-menu { + --el-menu-hover-text-color: var(--pure-theme-menu-text) !important; + } + + .fixed-header, + .main-container { + transition: none !important; + } + + .fixed-header { + width: 100%; + } +} + +body[layout="mix"] { + $sideBarWidth: 210px; + + @include merge-style($sideBarWidth); + + .el-menu--collapse { + width: 54px; + } + + .el-menu { + --el-menu-hover-bg-color: transparent !important; + --el-menu-hover-text-color: var(--pure-theme-menu-text) !important; + } + + .hideSidebar { + .fixed-header { + width: calc(100% - 54px); + transition: width var(--pure-transition-duration); + } + + .sidebar-container { + width: 54px !important; + transition: width var(--pure-transition-duration); + + .is-active.submenu-title-noDropdown.outer-most { + background: transparent !important; + } + } + + .main-container { + margin-left: 54px; + } + + /* 菜单折叠 */ + .el-menu--collapse { + .el-sub-menu { + & > .el-sub-menu__title { + padding: 0; + + & > span { + visibility: visible; + width: 100%; + height: 100%; + text-align: center; + } + } + } + } + } +} diff --git a/admin-ui/src/style/tailwind.css b/admin-ui/src/style/tailwind.css new file mode 100644 index 0000000..e495ae2 --- /dev/null +++ b/admin-ui/src/style/tailwind.css @@ -0,0 +1,46 @@ +@layer theme, base, components, utilities; +@import "tailwindcss/theme.css" layer(theme); +@import "tailwindcss/utilities.css" layer(utilities); + +@custom-variant dark (&:is(.dark *)); + +@theme { + --color-bg_color: var(--el-bg-color); + --color-primary: var(--el-color-primary); + --color-text_color_primary: var(--el-text-color-primary); + --color-text_color_regular: var(--el-text-color-regular); +} + +/* + The default border color has changed to `currentColor` in Tailwind CSS v4, + so we've added these compatibility styles to make sure everything still + looks the same as it did with Tailwind CSS v3. + + If we ever want to remove these styles, we need to add an explicit border + color utility to any element that depends on these defaults. +*/ +@layer base { + *, + ::after, + ::before, + ::backdrop, + ::file-selector-button { + border-color: var(--color-gray-200, currentColor); + } +} + +@utility flex-c { + @apply flex justify-center items-center; +} + +@utility flex-ac { + @apply flex justify-around items-center; +} + +@utility flex-bc { + @apply flex justify-between items-center; +} + +@utility navbar-bg-hover { + @apply dark:text-white dark:hover:bg-[#242424]!; +} diff --git a/admin-ui/src/style/theme.scss b/admin-ui/src/style/theme.scss new file mode 100644 index 0000000..f2fbc1c --- /dev/null +++ b/admin-ui/src/style/theme.scss @@ -0,0 +1,95 @@ +/* 亮白色 */ +html[data-theme="light"] { + --pure-theme-sub-menu-active-text: #000000d9; + --pure-theme-menu-bg: #fff; + --pure-theme-menu-hover: #f6f6f6; + --pure-theme-sub-menu-bg: #fff; + --pure-theme-menu-text: rgb(0 0 0 / 60%); + --pure-theme-sidebar-logo: #fff; + --pure-theme-menu-title-hover: #000; + --pure-theme-menu-active-before: #4091f7; +} + +/* 道奇蓝 */ +html[data-theme="default"] { + --pure-theme-sub-menu-active-text: #fff; + --pure-theme-menu-bg: #001529; + --pure-theme-menu-hover: rgb(64 145 247 / 15%); + --pure-theme-sub-menu-bg: #0f0303; + --pure-theme-menu-text: rgb(254 254 254 / 65%); + --pure-theme-sidebar-logo: #002140; + --pure-theme-menu-title-hover: #fff; + --pure-theme-menu-active-before: #4091f7; +} + +/* 深紫罗兰色 */ +html[data-theme="saucePurple"] { + --pure-theme-sub-menu-active-text: #fff; + --pure-theme-menu-bg: #130824; + --pure-theme-menu-hover: rgb(105 58 201 / 15%); + --pure-theme-sub-menu-bg: #000; + --pure-theme-menu-text: #7a80b4; + --pure-theme-sidebar-logo: #1f0c38; + --pure-theme-menu-title-hover: #fff; + --pure-theme-menu-active-before: #693ac9; +} + +/* 深粉色 */ +html[data-theme="pink"] { + --pure-theme-sub-menu-active-text: #fff; + --pure-theme-menu-bg: #28081a; + --pure-theme-menu-hover: rgb(216 68 147 / 15%); + --pure-theme-sub-menu-bg: #000; + --pure-theme-menu-text: #7a80b4; + --pure-theme-sidebar-logo: #3f0d29; + --pure-theme-menu-title-hover: #fff; + --pure-theme-menu-active-before: #d84493; +} + +/* 猩红色 */ +html[data-theme="dusk"] { + --pure-theme-sub-menu-active-text: #fff; + --pure-theme-menu-bg: #2a0608; + --pure-theme-menu-hover: rgb(225 60 57 / 15%); + --pure-theme-sub-menu-bg: #000; + --pure-theme-menu-text: rgb(254 254 254 / 65.1%); + --pure-theme-sidebar-logo: #42090c; + --pure-theme-menu-title-hover: #fff; + --pure-theme-menu-active-before: #e13c39; +} + +/* 橙红色 */ +html[data-theme="volcano"] { + --pure-theme-sub-menu-active-text: #fff; + --pure-theme-menu-bg: #2b0e05; + --pure-theme-menu-hover: rgb(232 95 51 / 15%); + --pure-theme-sub-menu-bg: #0f0603; + --pure-theme-menu-text: rgb(254 254 254 / 65%); + --pure-theme-sidebar-logo: #441708; + --pure-theme-menu-title-hover: #fff; + --pure-theme-menu-active-before: #e85f33; +} + +/* 绿宝石 */ +html[data-theme="mingQing"] { + --pure-theme-sub-menu-active-text: #fff; + --pure-theme-menu-bg: #032121; + --pure-theme-menu-hover: rgb(89 191 193 / 15%); + --pure-theme-sub-menu-bg: #000; + --pure-theme-menu-text: #7a80b4; + --pure-theme-sidebar-logo: #053434; + --pure-theme-menu-title-hover: #fff; + --pure-theme-menu-active-before: #59bfc1; +} + +/* 酸橙绿 */ +html[data-theme="auroraGreen"] { + --pure-theme-sub-menu-active-text: #fff; + --pure-theme-menu-bg: #0b1e15; + --pure-theme-menu-hover: rgb(96 172 128 / 15%); + --pure-theme-sub-menu-bg: #000; + --pure-theme-menu-text: #7a80b4; + --pure-theme-sidebar-logo: #112f21; + --pure-theme-menu-title-hover: #fff; + --pure-theme-menu-active-before: #60ac80; +} diff --git a/admin-ui/src/style/transition.scss b/admin-ui/src/style/transition.scss new file mode 100644 index 0000000..c7274dd --- /dev/null +++ b/admin-ui/src/style/transition.scss @@ -0,0 +1,54 @@ +/* fade */ +.fade-enter-active, +.fade-leave-active { + transition: opacity 0.28s; +} + +.fade-enter, +.fade-leave-active { + opacity: 0; +} + +/* fade-transform */ +.fade-transform-leave-active, +.fade-transform-enter-active { + transition: all 0.5s; +} + +.fade-transform-enter-from { + opacity: 0; + transform: translateX(-30px); +} + +.fade-transform-leave-to { + opacity: 0; + transform: translateX(30px); +} + +/* breadcrumb transition */ +.breadcrumb-enter-active { + transition: all 0.4s; +} + +.breadcrumb-leave-active { + position: absolute; + transition: all 0.3s; +} + +.breadcrumb-enter-from, +.breadcrumb-leave-active { + opacity: 0; + transform: translateX(20px); +} + +/** + * @description 重置el-menu的展开收起动画时长 + */ +.outer-most .el-collapse-transition-leave-active, +.outer-most .el-collapse-transition-enter-active { + transition: 0.2s all ease-in-out !important; +} + +.horizontal-collapse-transition { + transition: var(--pure-transition-duration) all !important; +} diff --git a/admin-ui/src/utils/auth.ts b/admin-ui/src/utils/auth.ts new file mode 100644 index 0000000..f2b28cb --- /dev/null +++ b/admin-ui/src/utils/auth.ts @@ -0,0 +1,141 @@ +import Cookies from "js-cookie"; +import { useUserStoreHook } from "@/store/modules/user"; +import { storageLocal, isString, isIncludeAllChildren } from "@pureadmin/utils"; + +export interface DataInfo { + /** token */ + accessToken: string; + /** `accessToken`的过期时间(时间戳) */ + expires: T; + /** 用于调用刷新accessToken的接口时所需的token */ + refreshToken: string; + /** 头像 */ + avatar?: string; + /** 用户名 */ + username?: string; + /** 昵称 */ + nickname?: string; + /** 当前登录用户的角色 */ + roles?: Array ; + /** 当前登录用户的按钮级别权限 */ + permissions?: Array ; +} + +export const userKey = "user-info"; +export const TokenKey = "authorized-token"; +/** + * 通过`multiple-tabs`是否在`cookie`中,判断用户是否已经登录系统, + * 从而支持多标签页打开已经登录的系统后无需再登录。 + * 浏览器完全关闭后`multiple-tabs`将自动从`cookie`中销毁, + * 再次打开浏览器需要重新登录系统 + * */ +export const multipleTabsKey = "multiple-tabs"; + +/** 获取`token` */ +export function getToken(): DataInfo { + // 此处与`TokenKey`相同,此写法解决初始化时`Cookies`中不存在`TokenKey`报错 + return Cookies.get(TokenKey) + ? JSON.parse(Cookies.get(TokenKey)) + : storageLocal().getItem(userKey); +} + +/** + * @description 设置`token`以及一些必要信息并采用无感刷新`token`方案 + * 无感刷新:后端返回`accessToken`(访问接口使用的`token`)、`refreshToken`(用于调用刷新`accessToken`的接口时所需的`token`,`refreshToken`的过期时间(比如30天)应大于`accessToken`的过期时间(比如2小时))、`expires`(`accessToken`的过期时间) + * 将`accessToken`、`expires`、`refreshToken`这三条信息放在key值为authorized-token的cookie里(过期自动销毁) + * 将`avatar`、`username`、`nickname`、`roles`、`permissions`、`refreshToken`、`expires`这七条信息放在key值为`user-info`的localStorage里(利用`multipleTabsKey`当浏览器完全关闭后自动销毁) + */ +export function setToken(data: DataInfo ) { + let expires = 0; + const { accessToken, refreshToken } = data; + const { isRemembered, loginDay } = useUserStoreHook(); + expires = new Date(data.expires).getTime(); // 如果后端直接设置时间戳,将此处代码改为expires = data.expires,然后把上面的DataInfo 改成DataInfo 即可 + const cookieString = JSON.stringify({ accessToken, expires, refreshToken }); + + expires > 0 + ? Cookies.set(TokenKey, cookieString, { + expires: (expires - Date.now()) / 86400000 + }) + : Cookies.set(TokenKey, cookieString); + + Cookies.set( + multipleTabsKey, + "true", + isRemembered + ? { + expires: loginDay + } + : {} + ); + + function setUserKey({ avatar, username, nickname, roles, permissions }) { + useUserStoreHook().SET_AVATAR(avatar); + useUserStoreHook().SET_USERNAME(username); + useUserStoreHook().SET_NICKNAME(nickname); + useUserStoreHook().SET_ROLES(roles); + useUserStoreHook().SET_PERMS(permissions); + storageLocal().setItem(userKey, { + refreshToken, + expires, + avatar, + username, + nickname, + roles, + permissions + }); + } + + if (data.username && data.roles) { + const { username, roles } = data; + setUserKey({ + avatar: data?.avatar ?? "", + username, + nickname: data?.nickname ?? "", + roles, + permissions: data?.permissions ?? [] + }); + } else { + const avatar = + storageLocal().getItem >(userKey)?.avatar ?? ""; + const username = + storageLocal().getItem >(userKey)?.username ?? ""; + const nickname = + storageLocal().getItem >(userKey)?.nickname ?? ""; + const roles = + storageLocal().getItem >(userKey)?.roles ?? []; + const permissions = + storageLocal().getItem >(userKey)?.permissions ?? []; + setUserKey({ + avatar, + username, + nickname, + roles, + permissions + }); + } +} + +/** 删除`token`以及key值为`user-info`的localStorage信息 */ +export function removeToken() { + Cookies.remove(TokenKey); + Cookies.remove(multipleTabsKey); + storageLocal().removeItem(userKey); +} + +/** 格式化token(jwt格式) */ +export const formatToken = (token: string): string => { + return "Bearer " + token; +}; + +/** 是否有按钮级别的权限(根据登录接口返回的`permissions`字段进行判断)*/ +export const hasPerms = (value: string | Array ): boolean => { + if (!value) return false; + const allPerms = "*:*:*"; + const { permissions } = useUserStoreHook(); + if (!permissions) return false; + if (permissions.length === 1 && permissions[0] === allPerms) return true; + const isAuths = isString(value) + ? permissions.includes(value) + : isIncludeAllChildren(value, permissions); + return isAuths ? true : false; +}; diff --git a/admin-ui/src/utils/globalPolyfills.ts b/admin-ui/src/utils/globalPolyfills.ts new file mode 100644 index 0000000..e9bc9a8 --- /dev/null +++ b/admin-ui/src/utils/globalPolyfills.ts @@ -0,0 +1,7 @@ +// 如果项目出现 `global is not defined` 报错,可能是您引入某个库的问题,比如 aws-sdk-js https://github.com/aws/aws-sdk-js +// 解决办法就是将该文件引入 src/main.ts 即可 import "@/utils/globalPolyfills"; +if (typeof (window as any).global === "undefined") { + (window as any).global = window; +} + +export {}; diff --git a/admin-ui/src/utils/http/index.ts b/admin-ui/src/utils/http/index.ts new file mode 100644 index 0000000..a975cc9 --- /dev/null +++ b/admin-ui/src/utils/http/index.ts @@ -0,0 +1,187 @@ +import Axios, { + type AxiosInstance, + type AxiosRequestConfig, + type CustomParamsSerializer +} from "axios"; +import type { + PureHttpError, + RequestMethods, + PureHttpResponse, + PureHttpRequestConfig +} from "./types.d"; +import { stringify } from "qs"; +import { getToken, formatToken } from "@/utils/auth"; +import { useUserStoreHook } from "@/store/modules/user"; + +// 相关配置请参考:www.axios-js.com/zh-cn/docs/#axios-request-config-1 +const defaultConfig: AxiosRequestConfig = { + // 请求超时时间 + timeout: 10000, + headers: { + Accept: "application/json, text/plain, */*", + "Content-Type": "application/json", + "X-Requested-With": "XMLHttpRequest" + }, + // 数组格式参数序列化(https://github.com/axios/axios/issues/5142) + paramsSerializer: { + serialize: stringify as unknown as CustomParamsSerializer + } +}; + +class PureHttp { + constructor() { + this.httpInterceptorsRequest(); + this.httpInterceptorsResponse(); + } + + /** `token`过期后,暂存待执行的请求 */ + private static requests = []; + + /** 防止重复刷新`token` */ + private static isRefreshing = false; + + /** 初始化配置对象 */ + private static initConfig: PureHttpRequestConfig = {}; + + /** 保存当前`Axios`实例对象 */ + private static axiosInstance: AxiosInstance = Axios.create(defaultConfig); + + /** 重连原始请求 */ + private static retryOriginalRequest(config: PureHttpRequestConfig) { + return new Promise(resolve => { + PureHttp.requests.push((token: string) => { + config.headers["Authorization"] = formatToken(token); + resolve(config); + }); + }); + } + + /** 请求拦截 */ + private httpInterceptorsRequest(): void { + PureHttp.axiosInstance.interceptors.request.use( + async (config: PureHttpRequestConfig): Promise => { + // 优先判断post/get等方法是否传入回调,否则执行初始化设置等回调 + if (typeof config.beforeRequestCallback === "function") { + config.beforeRequestCallback(config); + return config; + } + if (PureHttp.initConfig.beforeRequestCallback) { + PureHttp.initConfig.beforeRequestCallback(config); + return config; + } + /** 请求白名单,放置一些不需要`token`的接口(通过设置请求白名单,防止`token`过期后再请求造成的死循环问题) */ + const whiteList = ["/refresh-token", "/login"]; + return whiteList.some(url => config.url.endsWith(url)) + ? config + : new Promise(resolve => { + const data = getToken(); + if (data) { + const now = new Date().getTime(); + const expired = parseInt(data.expires) - now <= 0; + if (expired) { + if (!PureHttp.isRefreshing) { + PureHttp.isRefreshing = true; + // token过期刷新 + useUserStoreHook() + .handRefreshToken({ refreshToken: data.refreshToken }) + .then(res => { + const token = res.data.accessToken; + config.headers["Authorization"] = formatToken(token); + PureHttp.requests.forEach(cb => cb(token)); + PureHttp.requests = []; + }) + .finally(() => { + PureHttp.isRefreshing = false; + }); + } + resolve(PureHttp.retryOriginalRequest(config)); + } else { + config.headers["Authorization"] = formatToken( + data.accessToken + ); + resolve(config); + } + } else { + resolve(config); + } + }); + }, + error => { + return Promise.reject(error); + } + ); + } + + /** 响应拦截 */ + private httpInterceptorsResponse(): void { + const instance = PureHttp.axiosInstance; + instance.interceptors.response.use( + (response: PureHttpResponse) => { + const $config = response.config; + // 优先判断post/get等方法是否传入回调,否则执行初始化设置等回调 + if (typeof $config.beforeResponseCallback === "function") { + $config.beforeResponseCallback(response); + return response.data; + } + if (PureHttp.initConfig.beforeResponseCallback) { + PureHttp.initConfig.beforeResponseCallback(response); + return response.data; + } + return response.data; + }, + (error: PureHttpError) => { + const $error = error; + $error.isCancelRequest = Axios.isCancel($error); + // 所有的响应异常 区分来源为取消请求/非取消请求 + return Promise.reject($error); + } + ); + } + + /** 通用请求工具函数 */ + public request ( + method: RequestMethods, + url: string, + param?: AxiosRequestConfig, + axiosConfig?: PureHttpRequestConfig + ): Promise { + const config = { + method, + url, + ...param, + ...axiosConfig + } as PureHttpRequestConfig; + + // 单独处理自定义请求/响应回调 + return new Promise((resolve, reject) => { + PureHttp.axiosInstance + .request(config) + .then((response: undefined) => { + resolve(response); + }) + .catch(error => { + reject(error); + }); + }); + } + + /** 单独抽离的`post`工具函数 */ + public post ( + url: string, + params?: AxiosRequestConfig , + config?: PureHttpRequestConfig + ): Promise
{ + return this.request ("post", url, params, config); + } + + /** 单独抽离的`get`工具函数 */ + public get ( + url: string, + params?: AxiosRequestConfig , + config?: PureHttpRequestConfig + ): Promise
{ + return this.request ("get", url, params, config); + } +} + +export const http = new PureHttp(); diff --git a/admin-ui/src/utils/http/types.d.ts b/admin-ui/src/utils/http/types.d.ts new file mode 100644 index 0000000..ef7c25f --- /dev/null +++ b/admin-ui/src/utils/http/types.d.ts @@ -0,0 +1,47 @@ +import type { + Method, + AxiosError, + AxiosResponse, + AxiosRequestConfig +} from "axios"; + +export type resultType = { + accessToken?: string; +}; + +export type RequestMethods = Extract< + Method, + "get" | "post" | "put" | "delete" | "patch" | "option" | "head" +>; + +export interface PureHttpError extends AxiosError { + isCancelRequest?: boolean; +} + +export interface PureHttpResponse extends AxiosResponse { + config: PureHttpRequestConfig; +} + +export interface PureHttpRequestConfig extends AxiosRequestConfig { + beforeRequestCallback?: (request: PureHttpRequestConfig) => void; + beforeResponseCallback?: (response: PureHttpResponse) => void; +} + +export default class PureHttp { + request ( + method: RequestMethods, + url: string, + param?: AxiosRequestConfig, + axiosConfig?: PureHttpRequestConfig + ): Promise ; + post ( + url: string, + params?: P, + config?: PureHttpRequestConfig + ): Promise ; + get ( + url: string, + params?: P, + config?: PureHttpRequestConfig + ): Promise ; +} diff --git a/admin-ui/src/utils/localforage/index.ts b/admin-ui/src/utils/localforage/index.ts new file mode 100644 index 0000000..013545f --- /dev/null +++ b/admin-ui/src/utils/localforage/index.ts @@ -0,0 +1,109 @@ +import forage from "localforage"; +import type { LocalForage, ProxyStorage, ExpiresData } from "./types.d"; + +class StorageProxy implements ProxyStorage { + protected storage: LocalForage; + constructor(storageModel) { + this.storage = storageModel; + this.storage.config({ + // 首选IndexedDB作为第一驱动,不支持IndexedDB会自动降级到localStorage(WebSQL被弃用,详情看https://developer.chrome.com/blog/deprecating-web-sql) + driver: [this.storage.INDEXEDDB, this.storage.LOCALSTORAGE], + name: "pure-admin" + }); + } + + /** + * @description 将对应键名的数据保存到离线仓库 + * @param k 键名 + * @param v 键值 + * @param m 缓存时间(单位`分`,默认`0`分钟,永久缓存) + */ + public async setItem (k: string, v: T, m = 0): Promise { + return new Promise((resolve, reject) => { + this.storage + .setItem(k, { + data: v, + expires: m ? new Date().getTime() + m * 60 * 1000 : 0 + }) + .then(value => { + resolve(value.data); + }) + .catch(err => { + reject(err); + }); + }); + } + + /** + * @description 从离线仓库中获取对应键名的值 + * @param k 键名 + */ + public async getItem (k: string): Promise { + return new Promise((resolve, reject) => { + this.storage + .getItem(k) + .then((value: ExpiresData ) => { + value && (value.expires > new Date().getTime() || value.expires === 0) + ? resolve(value.data) + : resolve(null); + }) + .catch(err => { + reject(err); + }); + }); + } + + /** + * @description 从离线仓库中删除对应键名的值 + * @param k 键名 + */ + public async removeItem(k: string) { + return new Promise ((resolve, reject) => { + this.storage + .removeItem(k) + .then(() => { + resolve(); + }) + .catch(err => { + reject(err); + }); + }); + } + + /** + * @description 从离线仓库中删除所有的键名,重置数据库 + */ + public async clear() { + return new Promise ((resolve, reject) => { + this.storage + .clear() + .then(() => { + resolve(); + }) + .catch(err => { + reject(err); + }); + }); + } + + /** + * @description 获取数据仓库中所有的key + */ + public async keys() { + return new Promise ((resolve, reject) => { + this.storage + .keys() + .then(keys => { + resolve(keys); + }) + .catch(err => { + reject(err); + }); + }); + } +} + +/** + * 二次封装 [localforage](https://localforage.docschina.org/) 支持设置过期时间,提供完整的类型提示 + */ +export const localForage = () => new StorageProxy(forage); diff --git a/admin-ui/src/utils/localforage/types.d.ts b/admin-ui/src/utils/localforage/types.d.ts new file mode 100644 index 0000000..b013c5b --- /dev/null +++ b/admin-ui/src/utils/localforage/types.d.ts @@ -0,0 +1,166 @@ +// https://github.com/localForage/localForage/blob/master/typings/localforage.d.ts + +interface LocalForageDbInstanceOptions { + name?: string; + + storeName?: string; +} + +interface LocalForageOptions extends LocalForageDbInstanceOptions { + driver?: string | string[]; + + size?: number; + + version?: number; + + description?: string; +} + +interface LocalForageDbMethodsCore { + getItem ( + key: string, + callback?: (err: any, value: T | null) => void + ): Promise ; + + setItem ( + key: string, + value: T, + callback?: (err: any, value: T) => void + ): Promise ; + + removeItem(key: string, callback?: (err: any) => void): Promise ; + + clear(callback?: (err: any) => void): Promise ; + + length(callback?: (err: any, numberOfKeys: number) => void): Promise ; + + key( + keyIndex: number, + callback?: (err: any, key: string) => void + ): Promise ; + + keys(callback?: (err: any, keys: string[]) => void): Promise ; + + iterate ( + iteratee: (value: T, key: string, iterationNumber: number) => U, + callback?: (err: any, result: U) => void + ): Promise