From 6eced90ce7052c344ebaec7ee59dc7f9c5f3a30b Mon Sep 17 00:00:00 2001
From: JINGYJ <1458671527@qq.com>
Date: Mon, 23 Sep 2024 13:46:11 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20Dome=20=E6=8F=90=E4=BA=A4?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.browserslistrc | 4 +
.dockerignore | 21 +
.editorconfig | 14 +
.env | 5 +
.env.development | 8 +
.env.production | 13 +
.env.staging | 16 +
.gitignore | 22 +
.husky/commit-msg | 8 +
.husky/common.sh | 9 +
.husky/pre-commit | 10 +
.lintstagedrc | 20 +
.markdownlint.json | 11 +
.npmrc | 4 +
.nvmrc | 1 +
.prettierrc.js | 9 +
.stylelintignore | 4 +
.vscode/extensions.json | 19 +
.vscode/settings.json | 43 +
.vscode/vue3.0.code-snippets | 22 +
.vscode/vue3.2.code-snippets | 17 +
.vscode/vue3.3.code-snippets | 20 +
Dockerfile | 20 +
LICENSE | 21 +
README.en-US.md | 47 +
README.md | 52 +-
build/cdn.ts | 55 +
build/compress.ts | 63 +
build/info.ts | 57 +
build/optimize.ts | 33 +
build/plugins.ts | 71 +
build/utils.ts | 110 +
commitlint.config.js | 35 +
eslint.config.js | 181 +
index.html | 87 +
mock/asyncRoutes.ts | 69 +
mock/login.ts | 42 +
mock/refreshToken.ts | 27 +
package.json | 161 +
pnpm-lock.yaml | 7716 +++++++++++++++++
postcss.config.js | 12 +
public/favicon.ico | Bin 0 -> 1270 bytes
public/logo.svg | 1 +
public/platform-config.json | 26 +
src/App.vue | 26 +
src/api/routes.ts | 10 +
src/api/user.ts | 45 +
src/assets/iconfont/iconfont.css | 27 +
src/assets/iconfont/iconfont.js | 69 +
src/assets/iconfont/iconfont.json | 30 +
src/assets/iconfont/iconfont.ttf | Bin 0 -> 3904 bytes
src/assets/iconfont/iconfont.woff | Bin 0 -> 2484 bytes
src/assets/iconfont/iconfont.woff2 | Bin 0 -> 2016 bytes
src/assets/login/avatar.svg | 1 +
src/assets/login/bg.png | Bin 0 -> 17468 bytes
src/assets/login/illustration.svg | 1 +
src/assets/status/403.svg | 1 +
src/assets/status/404.svg | 1 +
src/assets/status/500.svg | 1 +
src/assets/svg/back_top.svg | 1 +
src/assets/svg/dark.svg | 1 +
src/assets/svg/day.svg | 1 +
src/assets/svg/enter_outlined.svg | 1 +
src/assets/svg/exit_screen.svg | 1 +
src/assets/svg/full_screen.svg | 1 +
src/assets/svg/keyboard_esc.svg | 1 +
src/assets/svg/system.svg | 1 +
src/assets/table-bar/collapse.svg | 1 +
src/assets/table-bar/drag.svg | 1 +
src/assets/table-bar/expand.svg | 1 +
src/assets/table-bar/refresh.svg | 1 +
src/assets/table-bar/settings.svg | 1 +
src/assets/user.jpg | Bin 0 -> 3694 bytes
src/components/ReAuth/index.ts | 5 +
src/components/ReAuth/src/auth.tsx | 20 +
src/components/ReCol/index.ts | 29 +
src/components/ReDialog/index.ts | 69 +
src/components/ReDialog/index.vue | 206 +
src/components/ReDialog/type.ts | 275 +
src/components/ReIcon/index.ts | 12 +
src/components/ReIcon/src/hooks.ts | 61 +
src/components/ReIcon/src/iconfont.ts | 48 +
.../ReIcon/src/iconifyIconOffline.ts | 30 +
.../ReIcon/src/iconifyIconOnline.ts | 30 +
src/components/ReIcon/src/offlineIcon.ts | 14 +
src/components/ReIcon/src/types.ts | 20 +
src/components/RePerms/index.ts | 5 +
src/components/RePerms/src/perms.tsx | 20 +
src/components/RePureTableBar/index.ts | 5 +
src/components/RePureTableBar/src/bar.tsx | 388 +
src/components/ReSegmented/index.ts | 8 +
src/components/ReSegmented/src/index.css | 157 +
src/components/ReSegmented/src/index.tsx | 216 +
src/components/ReSegmented/src/type.ts | 20 +
src/components/ReText/index.ts | 7 +
src/components/ReText/src/index.vue | 66 +
src/config/index.ts | 55 +
src/directives/auth/index.ts | 15 +
src/directives/copy/index.ts | 33 +
src/directives/index.ts | 6 +
src/directives/longpress/index.ts | 63 +
src/directives/optimize/index.ts | 68 +
src/directives/perms/index.ts | 15 +
src/directives/ripple/index.scss | 48 +
src/directives/ripple/index.ts | 229 +
src/layout/components/lay-content/index.vue | 213 +
src/layout/components/lay-footer/index.vue | 31 +
src/layout/components/lay-frame/index.vue | 79 +
src/layout/components/lay-navbar/index.vue | 135 +
.../lay-notice/components/NoticeItem.vue | 177 +
.../lay-notice/components/NoticeList.vue | 23 +
src/layout/components/lay-notice/data.ts | 97 +
src/layout/components/lay-notice/index.vue | 96 +
src/layout/components/lay-panel/index.vue | 145 +
.../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 +
src/layout/components/lay-search/index.vue | 21 +
src/layout/components/lay-search/types.ts | 20 +
src/layout/components/lay-setting/index.vue | 636 ++
.../components/lay-sidebar/NavHorizontal.vue | 123 +
src/layout/components/lay-sidebar/NavMix.vue | 143 +
.../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 | 222 +
.../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 | 683 ++
src/layout/frame.vue | 91 +
src/layout/hooks/useBoolean.ts | 26 +
src/layout/hooks/useDataThemeChange.ts | 145 +
src/layout/hooks/useLayout.ts | 58 +
src/layout/hooks/useMultiFrame.ts | 25 +
src/layout/hooks/useNav.ts | 157 +
src/layout/hooks/useTag.ts | 245 +
src/layout/index.vue | 235 +
src/layout/redirect.vue | 24 +
src/layout/theme/index.ts | 129 +
src/layout/types.ts | 92 +
src/main.ts | 64 +
src/plugins/echarts.ts | 44 +
src/plugins/elementPlus.ts | 248 +
src/router/index.ts | 206 +
src/router/modules/error.ts | 36 +
src/router/modules/home.ts | 25 +
src/router/modules/remaining.ts | 30 +
src/router/utils.ts | 408 +
src/store/index.ts | 9 +
src/store/modules/app.ts | 89 +
src/store/modules/epTheme.ts | 50 +
src/store/modules/multiTags.ts | 146 +
src/store/modules/permission.ts | 75 +
src/store/modules/settings.ts | 36 +
src/store/modules/user.ts | 110 +
src/store/types.ts | 47 +
src/store/utils.ts | 28 +
src/style/dark.scss | 182 +
src/style/element-plus.scss | 188 +
src/style/index.scss | 26 +
src/style/login.css | 96 +
src/style/reset.scss | 257 +
src/style/sidebar.scss | 716 ++
src/style/tailwind.css | 21 +
src/style/transition.scss | 54 +
src/utils/auth.ts | 141 +
src/utils/globalPolyfills.ts | 7 +
src/utils/http/index.ts | 194 +
src/utils/http/types.d.ts | 47 +
src/utils/localforage/index.ts | 109 +
src/utils/localforage/types.d.ts | 166 +
src/utils/message.ts | 85 +
src/utils/mitt.ts | 13 +
src/utils/preventDefault.ts | 28 +
src/utils/print.ts | 213 +
src/utils/progress/index.ts | 17 +
src/utils/propTypes.ts | 39 +
src/utils/responsive.ts | 42 +
src/utils/sso.ts | 59 +
src/utils/tree.ts | 188 +
src/views/error/403.vue | 70 +
src/views/error/404.vue | 70 +
src/views/error/500.vue | 70 +
src/views/login/index.vue | 168 +
src/views/login/utils/motion.ts | 40 +
src/views/login/utils/rule.ts | 28 +
src/views/login/utils/static.ts | 5 +
src/views/permission/button/index.vue | 99 +
src/views/permission/button/perms.vue | 109 +
src/views/permission/page/index.vue | 66 +
src/views/welcome/LMap/index.vue | 94 +
src/views/welcome/index.vue | 11 +
stylelint.config.js | 87 +
tailwind.config.ts | 19 +
tsconfig.json | 53 +
types/directives.d.ts | 28 +
types/global-components.d.ts | 134 +
types/global.d.ts | 193 +
types/index.d.ts | 82 +
types/router.d.ts | 108 +
types/shims-tsx.d.ts | 22 +
types/shims-vue.d.ts | 10 +
vite.config.ts | 62 +
211 files changed, 23636 insertions(+), 1 deletion(-)
create mode 100644 .browserslistrc
create mode 100644 .dockerignore
create mode 100644 .editorconfig
create mode 100644 .env
create mode 100644 .env.development
create mode 100644 .env.production
create mode 100644 .env.staging
create mode 100644 .gitignore
create mode 100644 .husky/commit-msg
create mode 100644 .husky/common.sh
create mode 100644 .husky/pre-commit
create mode 100644 .lintstagedrc
create mode 100644 .markdownlint.json
create mode 100644 .npmrc
create mode 100644 .nvmrc
create mode 100644 .prettierrc.js
create mode 100644 .stylelintignore
create mode 100644 .vscode/extensions.json
create mode 100644 .vscode/settings.json
create mode 100644 .vscode/vue3.0.code-snippets
create mode 100644 .vscode/vue3.2.code-snippets
create mode 100644 .vscode/vue3.3.code-snippets
create mode 100644 Dockerfile
create mode 100644 LICENSE
create mode 100644 README.en-US.md
create mode 100644 build/cdn.ts
create mode 100644 build/compress.ts
create mode 100644 build/info.ts
create mode 100644 build/optimize.ts
create mode 100644 build/plugins.ts
create mode 100644 build/utils.ts
create mode 100644 commitlint.config.js
create mode 100644 eslint.config.js
create mode 100644 index.html
create mode 100644 mock/asyncRoutes.ts
create mode 100644 mock/login.ts
create mode 100644 mock/refreshToken.ts
create mode 100644 package.json
create mode 100644 pnpm-lock.yaml
create mode 100644 postcss.config.js
create mode 100644 public/favicon.ico
create mode 100644 public/logo.svg
create mode 100644 public/platform-config.json
create mode 100644 src/App.vue
create mode 100644 src/api/routes.ts
create mode 100644 src/api/user.ts
create mode 100644 src/assets/iconfont/iconfont.css
create mode 100644 src/assets/iconfont/iconfont.js
create mode 100644 src/assets/iconfont/iconfont.json
create mode 100644 src/assets/iconfont/iconfont.ttf
create mode 100644 src/assets/iconfont/iconfont.woff
create mode 100644 src/assets/iconfont/iconfont.woff2
create mode 100644 src/assets/login/avatar.svg
create mode 100644 src/assets/login/bg.png
create mode 100644 src/assets/login/illustration.svg
create mode 100644 src/assets/status/403.svg
create mode 100644 src/assets/status/404.svg
create mode 100644 src/assets/status/500.svg
create mode 100644 src/assets/svg/back_top.svg
create mode 100644 src/assets/svg/dark.svg
create mode 100644 src/assets/svg/day.svg
create mode 100644 src/assets/svg/enter_outlined.svg
create mode 100644 src/assets/svg/exit_screen.svg
create mode 100644 src/assets/svg/full_screen.svg
create mode 100644 src/assets/svg/keyboard_esc.svg
create mode 100644 src/assets/svg/system.svg
create mode 100644 src/assets/table-bar/collapse.svg
create mode 100644 src/assets/table-bar/drag.svg
create mode 100644 src/assets/table-bar/expand.svg
create mode 100644 src/assets/table-bar/refresh.svg
create mode 100644 src/assets/table-bar/settings.svg
create mode 100644 src/assets/user.jpg
create mode 100644 src/components/ReAuth/index.ts
create mode 100644 src/components/ReAuth/src/auth.tsx
create mode 100644 src/components/ReCol/index.ts
create mode 100644 src/components/ReDialog/index.ts
create mode 100644 src/components/ReDialog/index.vue
create mode 100644 src/components/ReDialog/type.ts
create mode 100644 src/components/ReIcon/index.ts
create mode 100644 src/components/ReIcon/src/hooks.ts
create mode 100644 src/components/ReIcon/src/iconfont.ts
create mode 100644 src/components/ReIcon/src/iconifyIconOffline.ts
create mode 100644 src/components/ReIcon/src/iconifyIconOnline.ts
create mode 100644 src/components/ReIcon/src/offlineIcon.ts
create mode 100644 src/components/ReIcon/src/types.ts
create mode 100644 src/components/RePerms/index.ts
create mode 100644 src/components/RePerms/src/perms.tsx
create mode 100644 src/components/RePureTableBar/index.ts
create mode 100644 src/components/RePureTableBar/src/bar.tsx
create mode 100644 src/components/ReSegmented/index.ts
create mode 100644 src/components/ReSegmented/src/index.css
create mode 100644 src/components/ReSegmented/src/index.tsx
create mode 100644 src/components/ReSegmented/src/type.ts
create mode 100644 src/components/ReText/index.ts
create mode 100644 src/components/ReText/src/index.vue
create mode 100644 src/config/index.ts
create mode 100644 src/directives/auth/index.ts
create mode 100644 src/directives/copy/index.ts
create mode 100644 src/directives/index.ts
create mode 100644 src/directives/longpress/index.ts
create mode 100644 src/directives/optimize/index.ts
create mode 100644 src/directives/perms/index.ts
create mode 100644 src/directives/ripple/index.scss
create mode 100644 src/directives/ripple/index.ts
create mode 100644 src/layout/components/lay-content/index.vue
create mode 100644 src/layout/components/lay-footer/index.vue
create mode 100644 src/layout/components/lay-frame/index.vue
create mode 100644 src/layout/components/lay-navbar/index.vue
create mode 100644 src/layout/components/lay-notice/components/NoticeItem.vue
create mode 100644 src/layout/components/lay-notice/components/NoticeList.vue
create mode 100644 src/layout/components/lay-notice/data.ts
create mode 100644 src/layout/components/lay-notice/index.vue
create mode 100644 src/layout/components/lay-panel/index.vue
create mode 100644 src/layout/components/lay-search/components/SearchFooter.vue
create mode 100644 src/layout/components/lay-search/components/SearchHistory.vue
create mode 100644 src/layout/components/lay-search/components/SearchHistoryItem.vue
create mode 100644 src/layout/components/lay-search/components/SearchModal.vue
create mode 100644 src/layout/components/lay-search/components/SearchResult.vue
create mode 100644 src/layout/components/lay-search/index.vue
create mode 100644 src/layout/components/lay-search/types.ts
create mode 100644 src/layout/components/lay-setting/index.vue
create mode 100644 src/layout/components/lay-sidebar/NavHorizontal.vue
create mode 100644 src/layout/components/lay-sidebar/NavMix.vue
create mode 100644 src/layout/components/lay-sidebar/NavVertical.vue
create mode 100644 src/layout/components/lay-sidebar/components/SidebarBreadCrumb.vue
create mode 100644 src/layout/components/lay-sidebar/components/SidebarCenterCollapse.vue
create mode 100644 src/layout/components/lay-sidebar/components/SidebarExtraIcon.vue
create mode 100644 src/layout/components/lay-sidebar/components/SidebarFullScreen.vue
create mode 100644 src/layout/components/lay-sidebar/components/SidebarItem.vue
create mode 100644 src/layout/components/lay-sidebar/components/SidebarLeftCollapse.vue
create mode 100644 src/layout/components/lay-sidebar/components/SidebarLinkItem.vue
create mode 100644 src/layout/components/lay-sidebar/components/SidebarLogo.vue
create mode 100644 src/layout/components/lay-sidebar/components/SidebarTopCollapse.vue
create mode 100644 src/layout/components/lay-tag/components/TagChrome.vue
create mode 100644 src/layout/components/lay-tag/index.scss
create mode 100644 src/layout/components/lay-tag/index.vue
create mode 100644 src/layout/frame.vue
create mode 100644 src/layout/hooks/useBoolean.ts
create mode 100644 src/layout/hooks/useDataThemeChange.ts
create mode 100644 src/layout/hooks/useLayout.ts
create mode 100644 src/layout/hooks/useMultiFrame.ts
create mode 100644 src/layout/hooks/useNav.ts
create mode 100644 src/layout/hooks/useTag.ts
create mode 100644 src/layout/index.vue
create mode 100644 src/layout/redirect.vue
create mode 100644 src/layout/theme/index.ts
create mode 100644 src/layout/types.ts
create mode 100644 src/main.ts
create mode 100644 src/plugins/echarts.ts
create mode 100644 src/plugins/elementPlus.ts
create mode 100644 src/router/index.ts
create mode 100644 src/router/modules/error.ts
create mode 100644 src/router/modules/home.ts
create mode 100644 src/router/modules/remaining.ts
create mode 100644 src/router/utils.ts
create mode 100644 src/store/index.ts
create mode 100644 src/store/modules/app.ts
create mode 100644 src/store/modules/epTheme.ts
create mode 100644 src/store/modules/multiTags.ts
create mode 100644 src/store/modules/permission.ts
create mode 100644 src/store/modules/settings.ts
create mode 100644 src/store/modules/user.ts
create mode 100644 src/store/types.ts
create mode 100644 src/store/utils.ts
create mode 100644 src/style/dark.scss
create mode 100644 src/style/element-plus.scss
create mode 100644 src/style/index.scss
create mode 100644 src/style/login.css
create mode 100644 src/style/reset.scss
create mode 100644 src/style/sidebar.scss
create mode 100644 src/style/tailwind.css
create mode 100644 src/style/transition.scss
create mode 100644 src/utils/auth.ts
create mode 100644 src/utils/globalPolyfills.ts
create mode 100644 src/utils/http/index.ts
create mode 100644 src/utils/http/types.d.ts
create mode 100644 src/utils/localforage/index.ts
create mode 100644 src/utils/localforage/types.d.ts
create mode 100644 src/utils/message.ts
create mode 100644 src/utils/mitt.ts
create mode 100644 src/utils/preventDefault.ts
create mode 100644 src/utils/print.ts
create mode 100644 src/utils/progress/index.ts
create mode 100644 src/utils/propTypes.ts
create mode 100644 src/utils/responsive.ts
create mode 100644 src/utils/sso.ts
create mode 100644 src/utils/tree.ts
create mode 100644 src/views/error/403.vue
create mode 100644 src/views/error/404.vue
create mode 100644 src/views/error/500.vue
create mode 100644 src/views/login/index.vue
create mode 100644 src/views/login/utils/motion.ts
create mode 100644 src/views/login/utils/rule.ts
create mode 100644 src/views/login/utils/static.ts
create mode 100644 src/views/permission/button/index.vue
create mode 100644 src/views/permission/button/perms.vue
create mode 100644 src/views/permission/page/index.vue
create mode 100644 src/views/welcome/LMap/index.vue
create mode 100644 src/views/welcome/index.vue
create mode 100644 stylelint.config.js
create mode 100644 tailwind.config.ts
create mode 100644 tsconfig.json
create mode 100644 types/directives.d.ts
create mode 100644 types/global-components.d.ts
create mode 100644 types/global.d.ts
create mode 100644 types/index.d.ts
create mode 100644 types/router.d.ts
create mode 100644 types/shims-tsx.d.ts
create mode 100644 types/shims-vue.d.ts
create mode 100644 vite.config.ts
diff --git a/.browserslistrc b/.browserslistrc
new file mode 100644
index 0000000..40bd99c
--- /dev/null
+++ b/.browserslistrc
@@ -0,0 +1,4 @@
+> 1%
+last 2 versions
+not dead
+not ie 11
\ No newline at end of file
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..0376edd
--- /dev/null
+++ b/.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/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..ea6e20f
--- /dev/null
+++ b/.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/.env b/.env
new file mode 100644
index 0000000..09344c1
--- /dev/null
+++ b/.env
@@ -0,0 +1,5 @@
+# 平台本地运行端口号
+VITE_PORT = 8848
+
+# 是否隐藏首页 隐藏 true 不隐藏 false (勿删除,VITE_HIDE_HOME只需在.env文件配置)
+VITE_HIDE_HOME = false
diff --git a/.env.development b/.env.development
new file mode 100644
index 0000000..90d1146
--- /dev/null
+++ b/.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/.env.production b/.env.production
new file mode 100644
index 0000000..84e6086
--- /dev/null
+++ b/.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/.env.staging b/.env.staging
new file mode 100644
index 0000000..65b57e3
--- /dev/null
+++ b/.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/.gitignore b/.gitignore
new file mode 100644
index 0000000..423ed2b
--- /dev/null
+++ b/.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/.husky/commit-msg b/.husky/commit-msg
new file mode 100644
index 0000000..5ee2d16
--- /dev/null
+++ b/.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/.husky/common.sh b/.husky/common.sh
new file mode 100644
index 0000000..5f0540b
--- /dev/null
+++ b/.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/.husky/pre-commit b/.husky/pre-commit
new file mode 100644
index 0000000..6e229ea
--- /dev/null
+++ b/.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/.lintstagedrc b/.lintstagedrc
new file mode 100644
index 0000000..ebf359a
--- /dev/null
+++ b/.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/.markdownlint.json b/.markdownlint.json
new file mode 100644
index 0000000..d628d44
--- /dev/null
+++ b/.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/.npmrc b/.npmrc
new file mode 100644
index 0000000..dddf8bc
--- /dev/null
+++ b/.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/.nvmrc b/.nvmrc
new file mode 100644
index 0000000..4746584
--- /dev/null
+++ b/.nvmrc
@@ -0,0 +1 @@
+v20.15.0
\ No newline at end of file
diff --git a/.prettierrc.js b/.prettierrc.js
new file mode 100644
index 0000000..775d970
--- /dev/null
+++ b/.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/.stylelintignore b/.stylelintignore
new file mode 100644
index 0000000..0c34e61
--- /dev/null
+++ b/.stylelintignore
@@ -0,0 +1,4 @@
+/dist/*
+/public/*
+public/*
+src/style/reset.scss
\ No newline at end of file
diff --git a/.vscode/extensions.json b/.vscode/extensions.json
new file mode 100644
index 0000000..b36b3f8
--- /dev/null
+++ b/.vscode/extensions.json
@@ -0,0 +1,19 @@
+{
+ "recommendations": [
+ "christian-kohler.path-intellisense",
+ "warmthsea.vscode-custom-code-color",
+ "vscode-icons-team.vscode-icons",
+ "davidanson.vscode-markdownlint",
+ "ms-azuretools.vscode-docker",
+ "stylelint.vscode-stylelint",
+ "bradlc.vscode-tailwindcss",
+ "dbaeumer.vscode-eslint",
+ "esbenp.prettier-vscode",
+ "redhat.vscode-yaml",
+ "csstools.postcss",
+ "mikestead.dotenv",
+ "eamodio.gitlens",
+ "antfu.iconify",
+ "Vue.volar"
+ ]
+}
\ No newline at end of file
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..09f260f
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,43 @@
+{
+ "editor.formatOnType": true,
+ "editor.formatOnSave": true,
+ "[vue]": {
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
+ },
+ "editor.tabSize": 2,
+ "editor.formatOnPaste": true,
+ "editor.guides.bracketPairs": "active",
+ "files.autoSave": "off",
+ "git.confirmSync": false,
+ "workbench.startupEditor": "newUntitledFile",
+ "editor.suggestSelection": "first",
+ "editor.acceptSuggestionOnCommitCharacter": false,
+ "css.lint.propertyIgnoredDueToDisplay": "ignore",
+ "editor.quickSuggestions": {
+ "other": true,
+ "comments": true,
+ "strings": true
+ },
+ "files.associations": {
+ "editor.snippetSuggestions": "top"
+ },
+ "[css]": {
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
+ },
+ "editor.codeActionsOnSave": {
+ "source.fixAll.eslint": "explicit"
+ },
+ "iconify.excludes": [
+ "el"
+ ],
+ "vscodeCustomCodeColor.highlightValue": [
+ "v-loading",
+ "v-auth",
+ "v-copy",
+ "v-longpress",
+ "v-optimize",
+ "v-perms",
+ "v-ripple"
+ ],
+ "vscodeCustomCodeColor.highlightValueColor": "#b392f0",
+}
\ No newline at end of file
diff --git a/.vscode/vue3.0.code-snippets b/.vscode/vue3.0.code-snippets
new file mode 100644
index 0000000..bb43589
--- /dev/null
+++ b/.vscode/vue3.0.code-snippets
@@ -0,0 +1,22 @@
+{
+ "Vue3.0快速生成模板": {
+ "scope": "vue",
+ "prefix": "Vue3.0",
+ "body": [
+ "",
+ "\t
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/src/assets/login/avatar.svg b/src/assets/login/avatar.svg new file mode 100644 index 0000000..a63d2b1 --- /dev/null +++ b/src/assets/login/avatar.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/login/bg.png b/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/src/assets/status/403.svg b/src/assets/status/403.svg new file mode 100644 index 0000000..ba3ce29 --- /dev/null +++ b/src/assets/status/403.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/status/404.svg b/src/assets/status/404.svg new file mode 100644 index 0000000..aacb740 --- /dev/null +++ b/src/assets/status/404.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/status/500.svg b/src/assets/status/500.svg new file mode 100644 index 0000000..ea23a37 --- /dev/null +++ b/src/assets/status/500.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/svg/back_top.svg b/src/assets/svg/back_top.svg new file mode 100644 index 0000000..f8e6aa0 --- /dev/null +++ b/src/assets/svg/back_top.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/svg/dark.svg b/src/assets/svg/dark.svg new file mode 100644 index 0000000..b5c4d2d --- /dev/null +++ b/src/assets/svg/dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/svg/day.svg b/src/assets/svg/day.svg new file mode 100644 index 0000000..b760034 --- /dev/null +++ b/src/assets/svg/day.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/svg/enter_outlined.svg b/src/assets/svg/enter_outlined.svg new file mode 100644 index 0000000..45e0baf --- /dev/null +++ b/src/assets/svg/enter_outlined.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/svg/exit_screen.svg b/src/assets/svg/exit_screen.svg new file mode 100644 index 0000000..007c0b6 --- /dev/null +++ b/src/assets/svg/exit_screen.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/svg/full_screen.svg b/src/assets/svg/full_screen.svg new file mode 100644 index 0000000..fff93a5 --- /dev/null +++ b/src/assets/svg/full_screen.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/svg/keyboard_esc.svg b/src/assets/svg/keyboard_esc.svg new file mode 100644 index 0000000..bd67165 --- /dev/null +++ b/src/assets/svg/keyboard_esc.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/svg/system.svg b/src/assets/svg/system.svg new file mode 100644 index 0000000..9ad39a5 --- /dev/null +++ b/src/assets/svg/system.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/table-bar/collapse.svg b/src/assets/table-bar/collapse.svg new file mode 100644 index 0000000..0823ae6 --- /dev/null +++ b/src/assets/table-bar/collapse.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/table-bar/drag.svg b/src/assets/table-bar/drag.svg new file mode 100644 index 0000000..8ac32a7 --- /dev/null +++ b/src/assets/table-bar/drag.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/table-bar/expand.svg b/src/assets/table-bar/expand.svg new file mode 100644 index 0000000..bb41c35 --- /dev/null +++ b/src/assets/table-bar/expand.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/table-bar/refresh.svg b/src/assets/table-bar/refresh.svg new file mode 100644 index 0000000..140288c --- /dev/null +++ b/src/assets/table-bar/refresh.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/table-bar/settings.svg b/src/assets/table-bar/settings.svg new file mode 100644 index 0000000..4ecd077 --- /dev/null +++ b/src/assets/table-bar/settings.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/user.jpg b/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/src/components/ReCol/index.ts b/src/components/ReCol/index.ts new file mode 100644 index 0000000..7a6c937 --- /dev/null +++ b/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/src/components/ReDialog/index.ts b/src/components/ReDialog/index.ts new file mode 100644 index 0000000..b471764 --- /dev/null +++ b/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/src/components/ReDialog/index.vue b/src/components/ReDialog/index.vue new file mode 100644 index 0000000..23a0106 --- /dev/null +++ b/src/components/ReDialog/index.vue @@ -0,0 +1,206 @@ + + + + + + + + diff --git a/src/components/ReDialog/type.ts b/src/components/ReDialog/type.ts new file mode 100644 index 0000000..7efbe20 --- /dev/null +++ b/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/src/components/ReIcon/index.ts b/src/components/ReIcon/index.ts new file mode 100644 index 0000000..86efe72 --- /dev/null +++ b/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/src/components/ReIcon/src/hooks.ts b/src/components/ReIcon/src/hooks.ts new file mode 100644 index 0000000..5a377da --- /dev/null +++ b/src/components/ReIcon/src/hooks.ts @@ -0,0 +1,61 @@ +import type { iconType } from "./types"; +import { h, defineComponent, type Component } from "vue"; +import { IconifyIconOnline, IconifyIconOffline, FontIcon } from "../index"; + +/** + * 支持 `iconfont`、自定义 `svg` 以及 `iconify` 中所有的图标 + * @see 点击查看文档图标篇 {@link https://pure-admin.github.io/pure-admin-doc/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() { + const IconifyIcon = + icon && icon.includes(":") ? IconifyIconOnline : IconifyIconOffline; + return h(IconifyIcon, { + icon: icon, + ...attrs + }); + } + }); + } +} diff --git a/src/components/ReIcon/src/iconfont.ts b/src/components/ReIcon/src/iconfont.ts new file mode 100644 index 0000000..c110451 --- /dev/null +++ b/src/components/ReIcon/src/iconfont.ts @@ -0,0 +1,48 @@ +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", + "aria-hidden": true + }, + { + default: () => [ + h("use", { + "xlink:href": `#${this.icon}` + }) + ] + } + ); + } else { + return h("i", { + class: `iconfont ${this.icon}`, + ...attrs + }); + } + } +}); diff --git a/src/components/ReIcon/src/iconifyIconOffline.ts b/src/components/ReIcon/src/iconifyIconOffline.ts new file mode 100644 index 0000000..b47aa99 --- /dev/null +++ b/src/components/ReIcon/src/iconifyIconOffline.ts @@ -0,0 +1,30 @@ +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; + return h( + IconifyIcon, + { + icon: this.icon, + style: attrs?.style + ? Object.assign(attrs.style, { outline: "none" }) + : { outline: "none" }, + ...attrs + }, + { + default: () => [] + } + ); + } +}); diff --git a/src/components/ReIcon/src/iconifyIconOnline.ts b/src/components/ReIcon/src/iconifyIconOnline.ts new file mode 100644 index 0000000..a5f5822 --- /dev/null +++ b/src/components/ReIcon/src/iconifyIconOnline.ts @@ -0,0 +1,30 @@ +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}`, + style: attrs?.style + ? Object.assign(attrs.style, { outline: "none" }) + : { outline: "none" }, + ...attrs + }, + { + default: () => [] + } + ); + } +}); diff --git a/src/components/ReIcon/src/offlineIcon.ts b/src/components/ReIcon/src/offlineIcon.ts new file mode 100644 index 0000000..fc5f912 --- /dev/null +++ b/src/components/ReIcon/src/offlineIcon.ts @@ -0,0 +1,14 @@ +// 这里存放本地图标,在 src/layout/index.vue 文件中加载,避免在首启动加载 +import { addIcon } from "@iconify/vue/dist/offline"; + +// 本地菜单图标,后端在路由的 icon 中返回对应的图标字符串并且前端在此处使用 addIcon 添加即可渲染菜单图标 +// @iconify-icons/ep +import Lollipop from "@iconify-icons/ep/lollipop"; +import HomeFilled from "@iconify-icons/ep/home-filled"; +addIcon("ep:lollipop", Lollipop); +addIcon("ep:home-filled", HomeFilled); +// @iconify-icons/ri +import Search from "@iconify-icons/ri/search-line"; +import InformationLine from "@iconify-icons/ri/information-line"; +addIcon("ri:search-line", Search); +addIcon("ri:information-line", InformationLine); diff --git a/src/components/ReIcon/src/types.ts b/src/components/ReIcon/src/types.ts new file mode 100644 index 0000000..000bdc5 --- /dev/null +++ b/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/src/components/RePerms/index.ts b/src/components/RePerms/index.ts new file mode 100644 index 0000000..3701c3c --- /dev/null +++ b/src/components/RePerms/index.ts @@ -0,0 +1,5 @@ +import perms from "./src/perms"; + +const Perms = perms; + +export { Perms }; diff --git a/src/components/RePerms/src/perms.tsx b/src/components/RePerms/src/perms.tsx new file mode 100644 index 0000000..da01bc1 --- /dev/null +++ b/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/src/components/RePureTableBar/index.ts b/src/components/RePureTableBar/index.ts new file mode 100644 index 0000000..31b8a16 --- /dev/null +++ b/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/src/components/RePureTableBar/src/bar.tsx b/src/components/RePureTableBar/src/bar.tsx new file mode 100644 index 0000000..8a1b745 --- /dev/null +++ b/src/components/RePureTableBar/src/bar.tsx @@ -0,0 +1,388 @@ +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 "@iconify-icons/ri/fullscreen-fill"; +import ExitFullscreen from "@iconify-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"], + 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-none" + ]; + }); + + 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 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/src/components/ReSegmented/index.ts b/src/components/ReSegmented/index.ts new file mode 100644 index 0000000..de4253c --- /dev/null +++ b/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/src/components/ReSegmented/src/index.css b/src/components/ReSegmented/src/index.css new file mode 100644 index 0000000..503bbe4 --- /dev/null +++ b/src/components/ReSegmented/src/index.css @@ -0,0 +1,157 @@ +.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; + transition: 0.1s; +} + +.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/src/components/ReSegmented/src/index.tsx b/src/components/ReSegmented/src/index.tsx new file mode 100644 index 0000000..39580ed --- /dev/null +++ b/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} + + ++ + (isFullscreen.value = !isFullscreen.value)} + /> + , + 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/src/components/ReSegmented/src/type.ts b/src/components/ReSegmented/src/type.ts new file mode 100644 index 0000000..205e34d --- /dev/null +++ b/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.github.io/pure-admin-doc/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/src/components/ReText/index.ts b/src/components/ReText/index.ts new file mode 100644 index 0000000..6213566 --- /dev/null +++ b/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/src/components/ReText/src/index.vue b/src/components/ReText/src/index.vue new file mode 100644 index 0000000..ecaebdb --- /dev/null +++ b/src/components/ReText/src/index.vue @@ -0,0 +1,66 @@ + + + ++ + {rendLabel()} +++ + diff --git a/src/config/index.ts b/src/config/index.ts new file mode 100644 index 0000000..c81d1c4 --- /dev/null +++ b/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/src/directives/auth/index.ts b/src/directives/auth/index.ts new file mode 100644 index 0000000..2fc6490 --- /dev/null +++ b/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/src/directives/copy/index.ts b/src/directives/copy/index.ts new file mode 100644 index 0000000..b71fa19 --- /dev/null +++ b/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/src/directives/index.ts b/src/directives/index.ts new file mode 100644 index 0000000..d01fe71 --- /dev/null +++ b/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/src/directives/longpress/index.ts b/src/directives/longpress/index.ts new file mode 100644 index 0000000..4eec6a2 --- /dev/null +++ b/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/src/directives/optimize/index.ts b/src/directives/optimize/index.ts new file mode 100644 index 0000000..7b92538 --- /dev/null +++ b/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/src/directives/perms/index.ts b/src/directives/perms/index.ts new file mode 100644 index 0000000..073c918 --- /dev/null +++ b/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/src/directives/ripple/index.scss b/src/directives/ripple/index.scss new file mode 100644 index 0000000..061c82c --- /dev/null +++ b/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%; + overflow: hidden; + pointer-events: none; + border-radius: inherit; + contain: strict; + } + + &__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/src/directives/ripple/index.ts b/src/directives/ripple/index.ts new file mode 100644 index 0000000..3fd94d9 --- /dev/null +++ b/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 的垂直和水平距离 + let localX = e.clientX - offset.left; + let 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/src/layout/components/lay-content/index.vue b/src/layout/components/lay-content/index.vue new file mode 100644 index 0000000..5c7ceb9 --- /dev/null +++ b/src/layout/components/lay-content/index.vue @@ -0,0 +1,213 @@ + + + + + + + + diff --git a/src/layout/components/lay-footer/index.vue b/src/layout/components/lay-footer/index.vue new file mode 100644 index 0000000..7763134 --- /dev/null +++ b/src/layout/components/lay-footer/index.vue @@ -0,0 +1,31 @@ + + + + + + + diff --git a/src/layout/components/lay-frame/index.vue b/src/layout/components/lay-frame/index.vue new file mode 100644 index 0000000..b2bb9d5 --- /dev/null +++ b/src/layout/components/lay-frame/index.vue @@ -0,0 +1,79 @@ + + + ++ + + + ++ + + ++ ++ ++ +++ ++ ++ + + ++ ++ ++ ++ + + ++ ++ ++ diff --git a/src/layout/components/lay-navbar/index.vue b/src/layout/components/lay-navbar/index.vue new file mode 100644 index 0000000..4e0cfcd --- /dev/null +++ b/src/layout/components/lay-navbar/index.vue @@ -0,0 +1,135 @@ + + + + + + + diff --git a/src/layout/components/lay-notice/components/NoticeItem.vue b/src/layout/components/lay-notice/components/NoticeItem.vue new file mode 100644 index 0000000..823d9cd --- /dev/null +++ b/src/layout/components/lay-notice/components/NoticeItem.vue @@ -0,0 +1,177 @@ + + + ++ ++ + + + diff --git a/src/layout/components/lay-notice/components/NoticeList.vue b/src/layout/components/lay-notice/components/NoticeList.vue new file mode 100644 index 0000000..8617345 --- /dev/null +++ b/src/layout/components/lay-notice/components/NoticeList.vue @@ -0,0 +1,23 @@ + + + ++ ++++ ++ ++ {{ noticeItem.title }} +++ {{ noticeItem?.extra }} + ++ ++ {{ noticeItem.description }} +++ {{ noticeItem.datetime }} +++++ + diff --git a/src/layout/components/lay-notice/data.ts b/src/layout/components/lay-notice/data.ts new file mode 100644 index 0000000..5a07f4d --- /dev/null +++ b/src/layout/components/lay-notice/data.ts @@ -0,0 +1,97 @@ +export interface ListItem { + avatar: string; + title: string; + datetime: string; + type: string; + description: string; + status?: "primary" | "success" | "warning" | "info" | "danger"; + extra?: string; +} + +export interface TabItem { + key: string; + name: string; + list: ListItem[]; + emptyText: string; +} + +export const noticesData: TabItem[] = [ + { + key: "1", + name: "通知", + list: [], + emptyText: "暂无通知" + }, + { + key: "2", + name: "消息", + list: [ + { + avatar: "https://xiaoxian521.github.io/hyperlink/svg/smile1.svg", + title: "小铭 评论了你", + description: "诚在于心,信在于行,诚信在于心行合一。", + datetime: "今天", + type: "2" + }, + { + avatar: "https://xiaoxian521.github.io/hyperlink/svg/smile2.svg", + title: "李白 回复了你", + description: "长风破浪会有时,直挂云帆济沧海。", + datetime: "昨天", + type: "2" + }, + { + avatar: "https://xiaoxian521.github.io/hyperlink/svg/smile5.svg", + title: "标题", + description: + "请将鼠标移动到此处,以便测试超长的消息在此处将如何处理。本例中设置的描述最大行数为2,超过2行的描述内容将被省略并且可以通过tooltip查看完整内容", + datetime: "时间", + type: "2" + } + ], + emptyText: "暂无消息" + }, + { + key: "3", + name: "待办", + list: [ + { + avatar: "", + title: "第三方紧急代码变更", + description: + "小林提交于 2024-05-10,需在 2024-05-11 前完成代码变更任务", + datetime: "", + extra: "马上到期", + status: "danger", + type: "3" + }, + { + avatar: "", + title: "版本发布", + description: "指派小铭于 2024-06-18 前完成更新并发布", + datetime: "", + extra: "已耗时 8 天", + status: "warning", + type: "3" + }, + { + avatar: "", + title: "新功能开发", + description: "开发多租户管理", + datetime: "", + extra: "进行中", + type: "3" + }, + { + avatar: "", + title: "任务名称", + description: "任务需要在 2030-10-30 10:00 前启动", + datetime: "", + extra: "未开始", + status: "info", + type: "3" + } + ], + emptyText: "暂无待办" + } +]; diff --git a/src/layout/components/lay-notice/index.vue b/src/layout/components/lay-notice/index.vue new file mode 100644 index 0000000..844652a --- /dev/null +++ b/src/layout/components/lay-notice/index.vue @@ -0,0 +1,96 @@ + + + + + + + + + diff --git a/src/layout/components/lay-panel/index.vue b/src/layout/components/lay-panel/index.vue new file mode 100644 index 0000000..f12c621 --- /dev/null +++ b/src/layout/components/lay-panel/index.vue @@ -0,0 +1,145 @@ + + + ++ + + + ++ + ++ ++ + + + + + ++ ++++ + ++ + + diff --git a/src/layout/components/lay-search/components/SearchFooter.vue b/src/layout/components/lay-search/components/SearchFooter.vue new file mode 100644 index 0000000..bbc97a6 --- /dev/null +++ b/src/layout/components/lay-search/components/SearchFooter.vue @@ -0,0 +1,61 @@ + + + + + + + diff --git a/src/layout/components/lay-search/components/SearchHistory.vue b/src/layout/components/lay-search/components/SearchHistory.vue new file mode 100644 index 0000000..dd5875a --- /dev/null +++ b/src/layout/components/lay-search/components/SearchHistory.vue @@ -0,0 +1,198 @@ + + + +++++系统配置
+ ++ + + + ++ +++ 清空缓存 + ++ ++ + + diff --git a/src/layout/components/lay-search/components/SearchHistoryItem.vue b/src/layout/components/lay-search/components/SearchHistoryItem.vue new file mode 100644 index 0000000..19203b2 --- /dev/null +++ b/src/layout/components/lay-search/components/SearchHistoryItem.vue @@ -0,0 +1,52 @@ + + + +搜索历史+++ + ++ + {{ `收藏${collectList.length > 1 ? "(可拖拽排序)" : ""}` }} ++++ ++++ + + {{ item.meta?.title }} + + + + + + diff --git a/src/layout/components/lay-search/components/SearchModal.vue b/src/layout/components/lay-search/components/SearchModal.vue new file mode 100644 index 0000000..6822dff --- /dev/null +++ b/src/layout/components/lay-search/components/SearchModal.vue @@ -0,0 +1,334 @@ + + + + + + + + diff --git a/src/layout/components/lay-search/components/SearchResult.vue b/src/layout/components/lay-search/components/SearchResult.vue new file mode 100644 index 0000000..1dc7841 --- /dev/null +++ b/src/layout/components/lay-search/components/SearchResult.vue @@ -0,0 +1,113 @@ + + + ++ + ++ + ++ ++ ++ + + + + ++ + + diff --git a/src/layout/components/lay-search/index.vue b/src/layout/components/lay-search/index.vue new file mode 100644 index 0000000..123d6a6 --- /dev/null +++ b/src/layout/components/lay-search/index.vue @@ -0,0 +1,21 @@ + + + ++++ + {{ item.meta?.title }} + + + ++ diff --git a/src/layout/components/lay-search/types.ts b/src/layout/components/lay-search/types.ts new file mode 100644 index 0000000..a39adbd --- /dev/null +++ b/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/src/layout/components/lay-setting/index.vue b/src/layout/components/lay-setting/index.vue new file mode 100644 index 0000000..2891792 --- /dev/null +++ b/src/layout/components/lay-setting/index.vue @@ -0,0 +1,636 @@ + + + + + + + + diff --git a/src/layout/components/lay-sidebar/NavHorizontal.vue b/src/layout/components/lay-sidebar/NavHorizontal.vue new file mode 100644 index 0000000..f5aa1b6 --- /dev/null +++ b/src/layout/components/lay-sidebar/NavHorizontal.vue @@ -0,0 +1,123 @@ + + + +++整体风格
+{ + theme.index === 1 && theme.index !== 2 + ? (dataTheme = true) + : (dataTheme = false); + overallStyle = theme.option.theme; + dataThemeChange(theme.option.theme); + theme.index === 2 && watchSystemThemeChange(); + } + " + /> + + 主题色
++
+ +- +
++ ++ 导航模式
++
+ + +- + + +
+- + + +
+- + + +
+页宽
++ setStretch(value)" + /> + + + +页签风格
++ + 界面显示
++
+- + 灰色模式 +
++ - + 色弱模式 +
++ - + 隐藏标签页 +
++ - + 隐藏页脚 +
++ - + Logo +
++ - + 页签持久化 +
++ ++ + + diff --git a/src/layout/components/lay-sidebar/NavMix.vue b/src/layout/components/lay-sidebar/NavMix.vue new file mode 100644 index 0000000..35cb077 --- /dev/null +++ b/src/layout/components/lay-sidebar/NavMix.vue @@ -0,0 +1,143 @@ + + + ++++ {{ title }} +
+ ++ + +++ + + + + + + + + + ++ + ++ ++ 退出系统 + ++ + + diff --git a/src/layout/components/lay-sidebar/NavVertical.vue b/src/layout/components/lay-sidebar/NavVertical.vue new file mode 100644 index 0000000..0e9fa12 --- /dev/null +++ b/src/layout/components/lay-sidebar/NavVertical.vue @@ -0,0 +1,137 @@ + + + ++ ++ + ++++ + + {{ route.meta.title }} + ++ ++ + +++ + + + + + + + + + ++ + ++ ++ 退出系统 + ++ + + diff --git a/src/layout/components/lay-sidebar/components/SidebarBreadCrumb.vue b/src/layout/components/lay-sidebar/components/SidebarBreadCrumb.vue new file mode 100644 index 0000000..fbf2492 --- /dev/null +++ b/src/layout/components/lay-sidebar/components/SidebarBreadCrumb.vue @@ -0,0 +1,120 @@ + + + ++ + ++ ++ + + + + diff --git a/src/layout/components/lay-sidebar/components/SidebarCenterCollapse.vue b/src/layout/components/lay-sidebar/components/SidebarCenterCollapse.vue new file mode 100644 index 0000000..4739fc6 --- /dev/null +++ b/src/layout/components/lay-sidebar/components/SidebarCenterCollapse.vue @@ -0,0 +1,70 @@ + + + ++ ++ + {{ item.meta.title }} + + +++ + + diff --git a/src/layout/components/lay-sidebar/components/SidebarExtraIcon.vue b/src/layout/components/lay-sidebar/components/SidebarExtraIcon.vue new file mode 100644 index 0000000..7cad16e --- /dev/null +++ b/src/layout/components/lay-sidebar/components/SidebarExtraIcon.vue @@ -0,0 +1,20 @@ + + + ++ ++ diff --git a/src/layout/components/lay-sidebar/components/SidebarFullScreen.vue b/src/layout/components/lay-sidebar/components/SidebarFullScreen.vue new file mode 100644 index 0000000..4d38bd0 --- /dev/null +++ b/src/layout/components/lay-sidebar/components/SidebarFullScreen.vue @@ -0,0 +1,30 @@ + + + + + diff --git a/src/layout/components/lay-sidebar/components/SidebarItem.vue b/src/layout/components/lay-sidebar/components/SidebarItem.vue new file mode 100644 index 0000000..5bba813 --- /dev/null +++ b/src/layout/components/lay-sidebar/components/SidebarItem.vue @@ -0,0 +1,222 @@ + + + ++ + ++ + ++ {{ onlyOneChild.meta.title }} + + + +++ ++ {{ onlyOneChild.meta.title }} + ++ + + + diff --git a/src/layout/components/lay-sidebar/components/SidebarLeftCollapse.vue b/src/layout/components/lay-sidebar/components/SidebarLeftCollapse.vue new file mode 100644 index 0000000..785931d --- /dev/null +++ b/src/layout/components/lay-sidebar/components/SidebarLeftCollapse.vue @@ -0,0 +1,69 @@ + + + + ++ {{ item.meta.title }} + ++ + + + ++ + + diff --git a/src/layout/components/lay-sidebar/components/SidebarLinkItem.vue b/src/layout/components/lay-sidebar/components/SidebarLinkItem.vue new file mode 100644 index 0000000..8911c12 --- /dev/null +++ b/src/layout/components/lay-sidebar/components/SidebarLinkItem.vue @@ -0,0 +1,32 @@ + + + ++ + + diff --git a/src/layout/components/lay-sidebar/components/SidebarLogo.vue b/src/layout/components/lay-sidebar/components/SidebarLogo.vue new file mode 100644 index 0000000..0441f52 --- /dev/null +++ b/src/layout/components/lay-sidebar/components/SidebarLogo.vue @@ -0,0 +1,72 @@ + + + + + + + diff --git a/src/layout/components/lay-sidebar/components/SidebarTopCollapse.vue b/src/layout/components/lay-sidebar/components/SidebarTopCollapse.vue new file mode 100644 index 0000000..edd9519 --- /dev/null +++ b/src/layout/components/lay-sidebar/components/SidebarTopCollapse.vue @@ -0,0 +1,33 @@ + + + + + diff --git a/src/layout/components/lay-tag/components/TagChrome.vue b/src/layout/components/lay-tag/components/TagChrome.vue new file mode 100644 index 0000000..137365b --- /dev/null +++ b/src/layout/components/lay-tag/components/TagChrome.vue @@ -0,0 +1,33 @@ + + + diff --git a/src/layout/components/lay-tag/index.scss b/src/layout/components/lay-tag/index.scss new file mode 100644 index 0000000..b881216 --- /dev/null +++ b/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; + transition: + background-color 0.12s, + color 0.12s; + transform: translate(0, -50%); + + &: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; + list-style-type: none; + background: #fff; + border-radius: 4px; + outline: 0; + 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/src/layout/components/lay-tag/index.vue b/src/layout/components/lay-tag/index.vue new file mode 100644 index 0000000..29d0c36 --- /dev/null +++ b/src/layout/components/lay-tag/index.vue @@ -0,0 +1,683 @@ + + + + + + + diff --git a/src/layout/frame.vue b/src/layout/frame.vue new file mode 100644 index 0000000..a6549f7 --- /dev/null +++ b/src/layout/frame.vue @@ -0,0 +1,91 @@ + + + ++ + ++ + + diff --git a/src/layout/hooks/useBoolean.ts b/src/layout/hooks/useBoolean.ts new file mode 100644 index 0000000..1d14031 --- /dev/null +++ b/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/src/layout/hooks/useDataThemeChange.ts b/src/layout/hooks/useDataThemeChange.ts new file mode 100644 index 0000000..80db6dd --- /dev/null +++ b/src/layout/hooks/useDataThemeChange.ts @@ -0,0 +1,145 @@ +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 { useGlobal, storageLocal } from "@pureadmin/utils"; +import { useEpThemeStoreHook } from "@/store/modules/epTheme"; +import { useMultiTagsStoreHook } from "@/store/modules/multiTags"; +import { + darken, + lighten, + toggleTheme +} from "@pureadmin/theme/dist/browser-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; + toggleTheme({ + scopeName: `layout-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/src/layout/hooks/useLayout.ts b/src/layout/hooks/useLayout.ts new file mode 100644 index 0000000..a45ea4f --- /dev/null +++ b/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/src/layout/hooks/useMultiFrame.ts b/src/layout/hooks/useMultiFrame.ts new file mode 100644 index 0000000..73a779d --- /dev/null +++ b/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/src/layout/hooks/useNav.ts b/src/layout/hooks/useNav.ts new file mode 100644 index 0000000..d8a5918 --- /dev/null +++ b/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 "@iconify-icons/ri/fullscreen-exit-fill"; +import Fullscreen from "@iconify-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/src/layout/hooks/useTag.ts b/src/layout/hooks/useTag.ts new file mode 100644 index 0000000..9a9562a --- /dev/null +++ b/src/layout/hooks/useTag.ts @@ -0,0 +1,245 @@ +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 "@iconify-icons/ri/fullscreen-fill"; +import CloseAllTags from "@iconify-icons/ri/subtract-line"; +import CloseOtherTags from "@iconify-icons/ri/text-spacing"; +import CloseRightTags from "@iconify-icons/ri/text-direction-l"; +import CloseLeftTags from "@iconify-icons/ri/text-direction-r"; +import RefreshRight from "@iconify-icons/ep/refresh-right"; +import Close from "@iconify-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) { + if (isBoolean(route?.meta?.showLink) && route?.meta?.showLink === false) { + if (Object.keys(route.query).length > 0) { + return isEqual(route.query, item.query) ? previous : next; + } else { + return isEqual(route.params, item.params) ? previous : next; + } + } else { + return route.path === item.path ? 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/src/layout/index.vue b/src/layout/index.vue new file mode 100644 index 0000000..937d3e2 --- /dev/null +++ b/src/layout/index.vue @@ -0,0 +1,235 @@ + + + + + ++ + + diff --git a/src/layout/redirect.vue b/src/layout/redirect.vue new file mode 100644 index 0000000..6e16339 --- /dev/null +++ b/src/layout/redirect.vue @@ -0,0 +1,24 @@ + + + + + diff --git a/src/layout/theme/index.ts b/src/layout/theme/index.ts new file mode 100644 index 0000000..f7b4d47 --- /dev/null +++ b/src/layout/theme/index.ts @@ -0,0 +1,129 @@ +/** + * @description ⚠️:此文件仅供主题插件使用,请不要在此文件中导出别的工具函数(仅在页面加载前运行) + */ + +import type { multipleScopeVarsOptions } from "@pureadmin/theme"; + +/** 预设主题色 */ +const themeColors = { + /* 亮白色 */ + light: { + subMenuActiveText: "#000000d9", + menuBg: "#fff", + menuHover: "#f6f6f6", + subMenuBg: "#fff", + subMenuActiveBg: "#e0ebf6", + menuText: "rgb(0 0 0 / 60%)", + sidebarLogo: "#fff", + menuTitleHover: "#000", + menuActiveBefore: "#4091f7" + }, + /* 道奇蓝 */ + default: { + subMenuActiveText: "#fff", + menuBg: "#001529", + menuHover: "rgb(64 145 247 / 15%)", + subMenuBg: "#0f0303", + subMenuActiveBg: "#4091f7", + menuText: "rgb(254 254 254 / 65%)", + sidebarLogo: "#002140", + menuTitleHover: "#fff", + menuActiveBefore: "#4091f7" + }, + /* 深紫罗兰色 */ + saucePurple: { + subMenuActiveText: "#fff", + menuBg: "#130824", + menuHover: "rgb(105 58 201 / 15%)", + subMenuBg: "#000", + subMenuActiveBg: "#693ac9", + menuText: "#7a80b4", + sidebarLogo: "#1f0c38", + menuTitleHover: "#fff", + menuActiveBefore: "#693ac9" + }, + /* 深粉色 */ + pink: { + subMenuActiveText: "#fff", + menuBg: "#28081a", + menuHover: "rgb(216 68 147 / 15%)", + subMenuBg: "#000", + subMenuActiveBg: "#d84493", + menuText: "#7a80b4", + sidebarLogo: "#3f0d29", + menuTitleHover: "#fff", + menuActiveBefore: "#d84493" + }, + /* 猩红色 */ + dusk: { + subMenuActiveText: "#fff", + menuBg: "#2a0608", + menuHover: "rgb(225 60 57 / 15%)", + subMenuBg: "#000", + subMenuActiveBg: "#e13c39", + menuText: "rgb(254 254 254 / 65.1%)", + sidebarLogo: "#42090c", + menuTitleHover: "#fff", + menuActiveBefore: "#e13c39" + }, + /* 橙红色 */ + volcano: { + subMenuActiveText: "#fff", + menuBg: "#2b0e05", + menuHover: "rgb(232 95 51 / 15%)", + subMenuBg: "#0f0603", + subMenuActiveBg: "#e85f33", + menuText: "rgb(254 254 254 / 65%)", + sidebarLogo: "#441708", + menuTitleHover: "#fff", + menuActiveBefore: "#e85f33" + }, + /* 绿宝石 */ + mingQing: { + subMenuActiveText: "#fff", + menuBg: "#032121", + menuHover: "rgb(89 191 193 / 15%)", + subMenuBg: "#000", + subMenuActiveBg: "#59bfc1", + menuText: "#7a80b4", + sidebarLogo: "#053434", + menuTitleHover: "#fff", + menuActiveBefore: "#59bfc1" + }, + /* 酸橙绿 */ + auroraGreen: { + subMenuActiveText: "#fff", + menuBg: "#0b1e15", + menuHover: "rgb(96 172 128 / 15%)", + subMenuBg: "#000", + subMenuActiveBg: "#60ac80", + menuText: "#7a80b4", + sidebarLogo: "#112f21", + menuTitleHover: "#fff", + menuActiveBefore: "#60ac80" + } +}; + +/** + * @description 将预设主题色处理成主题插件所需格式 + */ +export const genScssMultipleScopeVars = (): multipleScopeVarsOptions[] => { + const result = [] as multipleScopeVarsOptions[]; + Object.keys(themeColors).forEach(key => { + result.push({ + scopeName: `layout-theme-${key}`, + varsContent: ` + $subMenuActiveText: ${themeColors[key].subMenuActiveText} !default; + $menuBg: ${themeColors[key].menuBg} !default; + $menuHover: ${themeColors[key].menuHover} !default; + $subMenuBg: ${themeColors[key].subMenuBg} !default; + $subMenuActiveBg: ${themeColors[key].subMenuActiveBg} !default; + $menuText: ${themeColors[key].menuText} !default; + $sidebarLogo: ${themeColors[key].sidebarLogo} !default; + $menuTitleHover: ${themeColors[key].menuTitleHover} !default; + $menuActiveBefore: ${themeColors[key].menuActiveBefore} !default; + ` + } as multipleScopeVarsOptions); + }); + return result; +}; diff --git a/src/layout/types.ts b/src/layout/types.ts new file mode 100644 index 0000000..44c309c --- /dev/null +++ b/src/layout/types.ts @@ -0,0 +1,92 @@ +import type { IconifyIcon } from "@iconify/vue"; +const { VITE_HIDE_HOME } = import.meta.env; + +export const routerArrays: Array+ ++ ++++ + + + ++ ++ + + + + = + VITE_HIDE_HOME === "false" + ? [ + { + path: "/welcome", + meta: { + title: "首页", + icon: "ep:home-filled" + } + } + ] + : []; + +export type routeMetaType = { + title?: string; + icon?: string | IconifyIcon; + 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 | IconifyIcon; + 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/src/main.ts b/src/main.ts new file mode 100644 index 0000000..d603e32 --- /dev/null +++ b/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/src/plugins/echarts.ts b/src/plugins/echarts.ts new file mode 100644 index 0000000..cb62d96 --- /dev/null +++ b/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/src/plugins/elementPlus.ts b/src/plugins/elementPlus.ts new file mode 100644 index 0000000..8363187 --- /dev/null +++ b/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/src/router/index.ts b/src/router/index.ts new file mode 100644 index 0000000..d3fb5bf --- /dev/null +++ b/src/router/index.ts @@ -0,0 +1,206 @@ +// 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, storageLocal, isAllEmpty } from "@pureadmin/utils"; +import { + ascending, + getTopMenu, + initRouter, + isOneOfArray, + getHistoryMode, + findRouteByPath, + handleAliveRoute, + formatTwoStageRoutes, + formatFlatteningRoutes +} from "./utils"; +import { + type Router, + createRouter, + type RouteRecordRaw, + type RouteComponent +} 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)))) +); + +/** 用于渲染菜单,保持原始层级 */ +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 }); + } + } + }); + } +}); + +/** 重置路由 */ +export function resetRouter() { + router.getRoutes().forEach(route => { + const { name, meta } = route; + if (name && router.hasRoute(name) && meta?.backstage) { + router.removeRoute(name); + router.options.routes = formatTwoStageRoutes( + formatFlatteningRoutes( + buildHierarchyTree(ascending(routes.flat(Infinity))) + ) + ); + } + }); + usePermissionStoreHook().clearAllCachePage(); +} + +/** 路由白名单 */ +const whiteList = ["/login"]; + +const { VITE_HIDE_HOME } = import.meta.env; + +router.beforeEach((to: ToRouteType, _from, next) => { + if (to.meta?.keepAlive) { + handleAliveRoute(to, "add"); + // 页面整体刷新和点击标签页刷新 + if (_from.name === undefined || _from.name === "Redirect") { + handleAliveRoute(to); + } + } + const userInfo = storageLocal().getItem >(userKey); + NProgress.start(); + 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" }); + } + // 开启隐藏首页后在浏览器地址栏手动输入首页welcome路由则跳转到404页面 + if (VITE_HIDE_HOME === "true" && to.fullPath === "/welcome") { + next({ path: "/error/404" }); + } + 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(() => { + NProgress.done(); +}); + +export default router; diff --git a/src/router/modules/error.ts b/src/router/modules/error.ts new file mode 100644 index 0000000..e2a1b1f --- /dev/null +++ b/src/router/modules/error.ts @@ -0,0 +1,36 @@ +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: { + title: "403" + } + }, + { + path: "/error/404", + name: "404", + component: () => import("@/views/error/404.vue"), + meta: { + title: "404" + } + }, + { + path: "/error/500", + name: "500", + component: () => import("@/views/error/500.vue"), + meta: { + title: "500" + } + } + ] +} satisfies RouteConfigsTable; diff --git a/src/router/modules/home.ts b/src/router/modules/home.ts new file mode 100644 index 0000000..842dbfc --- /dev/null +++ b/src/router/modules/home.ts @@ -0,0 +1,25 @@ +const { VITE_HIDE_HOME } = import.meta.env; +const Layout = () => import("@/layout/index.vue"); + +export default { + path: "/", + name: "Home", + component: Layout, + redirect: "/welcome", + meta: { + icon: "ep:home-filled", + title: "首页", + rank: 0 + }, + children: [ + { + path: "/welcome", + name: "Welcome", + component: () => import("@/views/welcome/index.vue"), + meta: { + title: "首页", + showLink: VITE_HIDE_HOME === "true" ? false : true + } + } + ] +} satisfies RouteConfigsTable; diff --git a/src/router/modules/remaining.ts b/src/router/modules/remaining.ts new file mode 100644 index 0000000..a1390dc --- /dev/null +++ b/src/router/modules/remaining.ts @@ -0,0 +1,30 @@ +const Layout = () => import("@/layout/index.vue"); + +export default [ + { + path: "/login", + name: "Login", + component: () => import("@/views/login/index.vue"), + meta: { + title: "登录", + showLink: false, + rank: 101 + } + }, + { + path: "/redirect", + component: Layout, + meta: { + title: "加载中...", + showLink: false, + rank: 102 + }, + children: [ + { + path: "/redirect/:path(.*)", + name: "Redirect", + component: () => import("@/layout/redirect.vue") + } + ] + } +] satisfies Array ; diff --git a/src/router/utils.ts b/src/router/utils.ts new file mode 100644 index 0000000..dd6df9a --- /dev/null +++ b/src/router/utils.ts @@ -0,0 +1,408 @@ +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; + } +} + +function addPathMatch() { + if (!router.hasRoute("pathMatch")) { + router.addRoute({ + path: "/:pathMatch(.*)", + name: "pathMatch", + redirect: "/error/404" + }); + } +} + +/** 处理动态路由(后端返回的路由) */ +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.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/src/store/index.ts b/src/store/index.ts new file mode 100644 index 0000000..a8dc752 --- /dev/null +++ b/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/src/store/modules/app.ts b/src/store/modules/app.ts new file mode 100644 index 0000000..2644aef --- /dev/null +++ b/src/store/modules/app.ts @@ -0,0 +1,89 @@ +import { defineStore } from "pinia"; +import { + type appType, + store, + getConfig, + storageLocal, + deviceDetection, + responsiveStorageNameSpace +} from "../utils"; + +export const useAppStore = defineStore({ + id: "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; + }, + setSortSwap(val) { + this.sortSwap = val; + } + } +}); + +export function useAppStoreHook() { + return useAppStore(store); +} diff --git a/src/store/modules/epTheme.ts b/src/store/modules/epTheme.ts new file mode 100644 index 0000000..fa73eff --- /dev/null +++ b/src/store/modules/epTheme.ts @@ -0,0 +1,50 @@ +import { defineStore } from "pinia"; +import { + store, + getConfig, + storageLocal, + responsiveStorageNameSpace +} from "../utils"; + +export const useEpThemeStore = defineStore({ + id: "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/src/store/modules/multiTags.ts b/src/store/modules/multiTags.ts new file mode 100644 index 0000000..fee2234 --- /dev/null +++ b/src/store/modules/multiTags.ts @@ -0,0 +1,146 @@ +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({ + id: "pure-multiTags", + state: () => ({ + // 存储标签页信息(路由信息) + multiTags: storageLocal().getItem ( + `${responsiveStorageNameSpace()}configure` + )?.multiTagsCache + ? storageLocal().getItem ( + `${responsiveStorageNameSpace()}tags` + ) + : [ + ...routerArrays, + ...usePermissionStoreHook().flatteningRoutes.filter( + v => v?.meta?.fixedTag + ) + ], + 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; + // 判断tag是否已存在 + const tagHasExits = this.multiTags.some(tag => { + return tag.path === tagPath; + }); + + // 判断tag中的query键值是否相等 + const tagQueryHasExits = this.multiTags.some(tag => { + return isEqual(tag?.query, tagVal?.query); + }); + + // 判断tag中的params键值是否相等 + const tagParamsHasExits = this.multiTags.some(tag => { + return isEqual(tag?.params, tagVal?.params); + }); + + if (tagHasExits && tagQueryHasExits && tagParamsHasExits) 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/src/store/modules/permission.ts b/src/store/modules/permission.ts new file mode 100644 index 0000000..2ddca78 --- /dev/null +++ b/src/store/modules/permission.ts @@ -0,0 +1,75 @@ +import { defineStore } from "pinia"; +import { + type cacheType, + store, + debounce, + ascending, + getKeyList, + filterTree, + constantMenus, + filterNoPermissionTree, + formatFlatteningRoutes +} from "../utils"; +import { useMultiTagsStoreHook } from "./multiTags"; + +export const usePermissionStore = defineStore({ + id: "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) + ); + }, + cacheOperate({ mode, name }: cacheType) { + const delIndex = this.cachePageList.findIndex(v => v === name); + switch (mode) { + case "refresh": + this.cachePageList = this.cachePageList.filter(v => v !== name); + break; + case "add": + this.cachePageList.push(name); + break; + case "delete": + delIndex !== -1 && this.cachePageList.splice(delIndex, 1); + break; + } + /** 监听缓存页面是否存在于标签页,不存在则删除 */ + debounce(() => { + 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--; + } + })(); + }, + /** 清空缓存页面 */ + clearAllCachePage() { + this.wholeMenus = []; + this.cachePageList = []; + } + } +}); + +export function usePermissionStoreHook() { + return usePermissionStore(store); +} diff --git a/src/store/modules/settings.ts b/src/store/modules/settings.ts new file mode 100644 index 0000000..7f810f7 --- /dev/null +++ b/src/store/modules/settings.ts @@ -0,0 +1,36 @@ +import { defineStore } from "pinia"; +import { type setType, store, getConfig } from "../utils"; + +export const useSettingStore = defineStore({ + id: "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/src/store/modules/user.ts b/src/store/modules/user.ts new file mode 100644 index 0000000..9d0430e --- /dev/null +++ b/src/store/modules/user.ts @@ -0,0 +1,110 @@ +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({ + id: "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/src/store/types.ts b/src/store/types.ts new file mode 100644 index 0000000..c33268a --- /dev/null +++ b/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/src/store/utils.ts b/src/store/utils.ts new file mode 100644 index 0000000..5dd8c75 --- /dev/null +++ b/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/src/style/dark.scss b/src/style/dark.scss new file mode 100644 index 0000000..746902f --- /dev/null +++ b/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/src/style/element-plus.scss b/src/style/element-plus.scss new file mode 100644 index 0000000..165008f --- /dev/null +++ b/src/style/element-plus.scss @@ -0,0 +1,188 @@ +.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; + border-radius: 4px; + outline: none; + 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 { + border-radius: 4px; + outline: none; + 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 { + /* Firefox */ + scrollbar-width: thin; /* 可选值为 'auto', 'thin', 'none' */ + scrollbar-color: rgb(221 222 224) transparent; /* 滑块颜色、轨道颜色 */ + ::-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/src/style/index.scss b/src/style/index.scss new file mode 100644 index 0000000..d267d38 --- /dev/null +++ b/src/style/index.scss @@ -0,0 +1,26 @@ +@import "./transition"; +@import "./element-plus"; +@import "./sidebar"; +@import "./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; +} + +/* 灰色模式 */ +.html-grey { + filter: grayscale(100%); +} + +/* 色弱模式 */ +.html-weakness { + filter: invert(80%); +} diff --git a/src/style/login.css b/src/style/login.css new file mode 100644 index 0000000..3e0a8ab --- /dev/null +++ b/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/src/style/reset.scss b/src/style/reset.scss new file mode 100644 index 0000000..d79cdd9 --- /dev/null +++ b/src/style/reset.scss @@ -0,0 +1,257 @@ +*, +::before, +::after { + box-sizing: border-box; + border-color: currentColor; + border-style: solid; + border-width: 0; +} + +#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/src/style/sidebar.scss b/src/style/sidebar.scss new file mode 100644 index 0000000..7208f6c --- /dev/null +++ b/src/style/sidebar.scss @@ -0,0 +1,716 @@ +/* $sideBarWidth: vertical 模式下主体内容距离网页文档左侧的距离 */ +@mixin merge-style($sideBarWidth) { + $menuActiveText: #7a80b4; + + @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; + } + } + + .set-icon, + .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: $menuBg; + 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: $menuText; + background-color: transparent !important; + + &:hover { + color: $menuTitleHover !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: $subMenuActiveText !important; + + i { + color: $subMenuActiveText !important; + } + } + + .is-active { + color: $subMenuActiveText !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; + margin: 4px 0; + clear: both; + 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: $subMenuBg !important; + } + + /* 有子集的激活菜单左侧小竖条 */ + .el-menu--collapse + .is-active.outer-most.el-sub-menu + > .el-sub-menu__title::before { + position: absolute; + top: 0; + left: 0; + width: 2px; + height: 100%; + clear: both; + content: ""; + background-color: $menuActiveBefore; + transition: all var(--pure-transition-duration) ease-in-out; + transform: translateY(0); + } + + .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; + margin: 4px 0; + clear: both; + content: ""; + background: var(--el-color-primary) !important; + border-radius: 3px; + } + } + + /* vertical 菜单折叠 */ + .el-menu--vertical { + .el-menu--popup { + background-color: $subMenuBg !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: $subMenuActiveText !important; + + i { + color: $subMenuActiveText !important; + } + } + + /* 子菜单中还有子菜单 */ + .el-menu .el-sub-menu__title { + min-width: $sideBarWidth !important; + font-size: 14px; + background-color: $subMenuBg !important; + } + + .el-menu-item, + .el-sub-menu__title { + height: 50px; + line-height: 50px; + color: $menuText; + background-color: $subMenuBg; + + &:hover { + color: $menuTitleHover !important; + } + } + + .is-active { + color: $subMenuActiveText !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: $subMenuBg !important; + + a > .is-active.submenu-title-noDropdown { + border-bottom: none; + } + + .el-menu-item { + color: $menuText; + background-color: $subMenuBg; + + span { + font-size: 14px; + } + } + + .el-sub-menu__title { + color: $menuText; + } + } + + /* 子菜单中还有子菜单 */ + .el-menu .el-sub-menu__title { + min-width: $sideBarWidth !important; + font-size: 14px; + background-color: $subMenuBg !important; + + &:hover { + color: $menuTitleHover !important; + } + } + + .is-active > .el-sub-menu__title, + .is-active.submenu-title-noDropdown { + color: $subMenuActiveText !important; + + i { + color: $subMenuActiveText !important; + } + } + + .nest-menu .el-sub-menu > .el-sub-menu__title, + .el-menu-item { + &:hover { + color: $menuTitleHover !important; + } + } + + .el-menu-item.is-active { + color: $subMenuActiveText !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: $menuBg; + + .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; + font-size: 18px; + font-weight: 600; + line-height: 32px; + color: $subMenuActiveText; + text-overflow: ellipsis; + 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: 340px; + color: $subMenuActiveText; + + /* 搜索 */ + .search-container, + /* 全屏 */ + .fullscreen-icon, + /* 消息通知 */ + .dropdown-badge, + /* 用户名 */ + .el-dropdown-link, + /* 设置 */ + .set-icon { + &:hover { + background: $menuHover; + } + } + + .dropdown-badge { + height: 48px; + color: $subMenuActiveText; + } + + .el-dropdown-link { + display: flex; + align-items: center; + justify-content: space-around; + height: 48px; + padding: 10px; + color: $subMenuActiveText; + 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: $menuText; + + &:hover { + color: $menuTitleHover !important; + } + } + + .submenu-title-noDropdown, + .el-sub-menu__title { + height: 48px; + line-height: 48px; + background: $menuBg; + + svg { + position: static !important; + } + } + + .is-active > .el-sub-menu__title, + .is-active.submenu-title-noDropdown { + color: $subMenuActiveText !important; + + i { + color: $subMenuActiveText !important; + } + } + + .is-active { + color: $subMenuActiveText !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; + transition-duration: 0.3s; + transform: translate3d(-$sideBarWidth, 0, 0); + } + } + } +} + +body[layout="vertical"] { + $sideBarWidth: 210px; + + @include merge-style($sideBarWidth); + + .el-menu--collapse { + width: 54px; + } + + .sidebar-logo-container { + background: $sidebarLogo; + } + + .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 { + width: 100%; + height: 100%; + text-align: center; + visibility: visible; + } + } + } + + .submenu-title-noDropdown { + background: transparent !important; + } + + .el-sub-menu__title { + padding: 0; + } + } + + .sub-menu-icon { + margin-right: 0; + } + } + + /* 搜索 */ + .search-container, + /* 全屏 */ + .fullscreen-icon, + /* 消息通知 */ + .dropdown-badge, + /* 用户名 */ + .el-dropdown-link, + /* 设置 */ + .set-icon { + &:hover { + background: #f6f6f6; + } + } +} + +body[layout="horizontal"] { + $sideBarWidth: 0; + + @include merge-style($sideBarWidth); + + .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; + } + + .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 { + width: 100%; + height: 100%; + text-align: center; + visibility: visible; + } + } + } + } + } +} diff --git a/src/style/tailwind.css b/src/style/tailwind.css new file mode 100644 index 0000000..3e48b68 --- /dev/null +++ b/src/style/tailwind.css @@ -0,0 +1,21 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer components { + .flex-c { + @apply flex justify-center items-center; + } + + .flex-ac { + @apply flex justify-around items-center; + } + + .flex-bc { + @apply flex justify-between items-center; + } + + .navbar-bg-hover { + @apply dark:text-white dark:hover:!bg-[#242424]; + } +} diff --git a/src/style/transition.scss b/src/style/transition.scss new file mode 100644 index 0000000..c7274dd --- /dev/null +++ b/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/src/utils/auth.ts b/src/utils/auth.ts new file mode 100644 index 0000000..f2b28cb --- /dev/null +++ b/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/src/utils/globalPolyfills.ts b/src/utils/globalPolyfills.ts new file mode 100644 index 0000000..e9bc9a8 --- /dev/null +++ b/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/src/utils/http/index.ts b/src/utils/http/index.ts new file mode 100644 index 0000000..19b5be2 --- /dev/null +++ b/src/utils/http/index.ts @@ -0,0 +1,194 @@ +import Axios, { + type AxiosInstance, + type AxiosRequestConfig, + type CustomParamsSerializer +} from "axios"; +import type { + PureHttpError, + RequestMethods, + PureHttpResponse, + PureHttpRequestConfig +} from "./types.d"; +import { stringify } from "qs"; +import NProgress from "../progress"; +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 => { + // 开启进度条动画 + NProgress.start(); + // 优先判断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; + // 关闭进度条动画 + NProgress.done(); + // 优先判断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); + // 关闭进度条动画 + NProgress.done(); + // 所有的响应异常 区分来源为取消请求/非取消请求 + 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/src/utils/http/types.d.ts b/src/utils/http/types.d.ts new file mode 100644 index 0000000..ef7c25f --- /dev/null +++ b/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/src/utils/localforage/index.ts b/src/utils/localforage/index.ts new file mode 100644 index 0000000..013545f --- /dev/null +++ b/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/src/utils/localforage/types.d.ts b/src/utils/localforage/types.d.ts new file mode 100644 index 0000000..b013c5b --- /dev/null +++ b/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; +} + +interface LocalForageDropInstanceFn { + ( + dbInstanceOptions?: LocalForageDbInstanceOptions, + callback?: (err: any) => void + ): Promise ; +} + +interface LocalForageDriverMethodsOptional { + dropInstance?: LocalForageDropInstanceFn; +} + +// duplicating LocalForageDriverMethodsOptional to preserve TS v2.0 support, +// since Partial<> isn't supported there +interface LocalForageDbMethodsOptional { + dropInstance: LocalForageDropInstanceFn; +} + +interface LocalForageDriverDbMethods + extends LocalForageDbMethodsCore, + LocalForageDriverMethodsOptional {} + +interface LocalForageDriverSupportFunc { + (): Promise ; +} + +interface LocalForageDriver extends LocalForageDriverDbMethods { + _driver: string; + + _initStorage(options: LocalForageOptions): void; + + _support?: boolean | LocalForageDriverSupportFunc; +} + +interface LocalForageSerializer { + serialize ( + value: T | ArrayBuffer | Blob, + callback: (value: string, error: any) => void + ): void; + + deserialize (value: string): T | ArrayBuffer | Blob; + + stringToBuffer(serializedString: string): ArrayBuffer; + + bufferToString(buffer: ArrayBuffer): string; +} + +interface LocalForageDbMethods + extends LocalForageDbMethodsCore, + LocalForageDbMethodsOptional {} + +export interface LocalForage extends LocalForageDbMethods { + LOCALSTORAGE: string; + WEBSQL: string; + INDEXEDDB: string; + + /** + * Set and persist localForage options. This must be called before any other calls to localForage are made, but can be called after localForage is loaded. + * If you set any config values with this method they will persist after driver changes, so you can call config() then setDriver() + * @param {LocalForageOptions} options? + */ + config(options: LocalForageOptions): boolean; + config(options: string): any; + config(): LocalForageOptions; + + /** + * Create a new instance of localForage to point to a different store. + * All the configuration options used by config are supported. + * @param {LocalForageOptions} options + */ + createInstance(options: LocalForageOptions): LocalForage; + + driver(): string; + + /** + * Force usage of a particular driver or drivers, if available. + * @param {string} driver + */ + setDriver( + driver: string | string[], + callback?: () => void, + errorCallback?: (error: any) => void + ): Promise ; + + defineDriver( + driver: LocalForageDriver, + callback?: () => void, + errorCallback?: (error: any) => void + ): Promise