├── .editorconfig ├── .env ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .husky └── pre-commit ├── .npmrc ├── .prettierignore ├── .vscode ├── extensions.json ├── hook.code-snippets ├── settings.json └── vue.code-snippets ├── LICENSE ├── README.md ├── changelogithub.config.json ├── index.html ├── package.json ├── pnpm-lock.yaml ├── prettier.config.mjs ├── script ├── core │ ├── AppConfig.ts │ ├── AppLogger.ts │ ├── AppMain.ts │ ├── AppMenu.ts │ ├── AppTray.ts │ └── WinMain.ts ├── index.ts └── tool │ ├── index.ts │ └── ipc-dict.ts ├── src ├── App.vue ├── api │ ├── hook-demo │ │ ├── use-fetch-select.ts │ │ └── use-fullscreen-loading.ts │ ├── login │ │ ├── index.ts │ │ └── types │ │ │ └── login.ts │ └── table │ │ ├── index.ts │ │ └── types │ │ └── table.ts ├── assets │ ├── docs │ │ ├── preview1.png │ │ ├── preview2.png │ │ ├── preview3.png │ │ ├── qq.png │ │ └── wechat.png │ ├── error-page │ │ ├── 403.svg │ │ └── 404.svg │ ├── layouts │ │ ├── logo-text-1.png │ │ ├── logo-text-2.png │ │ └── logo.png │ └── login │ │ ├── close-eyes.png │ │ ├── face.png │ │ ├── hand-down-left.png │ │ ├── hand-down-right.png │ │ ├── hand-up-left.png │ │ └── hand-up-right.png ├── components │ ├── Notify │ │ ├── NotifyList.vue │ │ ├── data.ts │ │ └── index.vue │ ├── Screenfull │ │ └── index.vue │ ├── SearchMenu │ │ ├── SearchFooter.vue │ │ ├── SearchModal.vue │ │ ├── SearchResult.vue │ │ └── index.vue │ ├── SvgIcon │ │ └── index.vue │ └── ThemeSwitch │ │ └── index.vue ├── config │ ├── layouts.ts │ ├── route.ts │ └── white-list.ts ├── constants │ ├── app-key.ts │ ├── cache-key.ts │ └── ipc-dict.ts ├── directives │ ├── index.ts │ └── permission │ │ └── index.ts ├── hooks │ ├── useDevice.ts │ ├── useFetchSelect.ts │ ├── useFullscreenLoading.ts │ ├── useGreyAndColorWeakness.ts │ ├── useLayoutMode.ts │ ├── usePagination.ts │ ├── useRouteListener.ts │ ├── useTheme.ts │ ├── useTitle.ts │ └── useWatermark.ts ├── icons │ ├── index.ts │ └── svg │ │ ├── 404.svg │ │ ├── bug.svg │ │ ├── component.svg │ │ ├── dashboard.svg │ │ ├── fullscreen-exit.svg │ │ ├── fullscreen.svg │ │ ├── keyboard-down.svg │ │ ├── keyboard-enter.svg │ │ ├── keyboard-esc.svg │ │ ├── keyboard-up.svg │ │ ├── link.svg │ │ ├── lock.svg │ │ ├── menu.svg │ │ ├── search.svg │ │ └── unocss.svg ├── layouts │ ├── LeftMode.vue │ ├── LeftTopMode.vue │ ├── TopMode.vue │ ├── components │ │ ├── AppMain.vue │ │ ├── Breadcrumb │ │ │ └── index.vue │ │ ├── Footer │ │ │ └── index.vue │ │ ├── Hamburger │ │ │ └── index.vue │ │ ├── Logo │ │ │ └── index.vue │ │ ├── NavigationBar │ │ │ └── index.vue │ │ ├── RightPanel │ │ │ └── index.vue │ │ ├── Settings │ │ │ ├── SelectLayoutMode.vue │ │ │ └── index.vue │ │ ├── Sidebar │ │ │ ├── SidebarItem.vue │ │ │ ├── SidebarItemLink.vue │ │ │ └── index.vue │ │ ├── TagsView │ │ │ ├── ScrollPane.vue │ │ │ └── index.vue │ │ └── index.ts │ ├── hooks │ │ └── useResize.ts │ └── index.vue ├── main.ts ├── plugins │ ├── element-plus-icon │ │ └── index.ts │ ├── element-plus │ │ └── index.ts │ ├── index.ts │ └── vxe-table │ │ └── index.ts ├── router │ ├── helper.ts │ ├── index.ts │ └── permission.ts ├── store │ ├── index.ts │ └── modules │ │ ├── app.ts │ │ ├── permission.ts │ │ ├── settings.ts │ │ ├── tags-view.ts │ │ └── user.ts ├── styles │ ├── element-plus.css │ ├── element-plus.scss │ ├── index.scss │ ├── mixins.scss │ ├── theme │ │ ├── core │ │ │ ├── element-plus.scss │ │ │ ├── index.scss │ │ │ └── layouts.scss │ │ ├── dark-blue │ │ │ ├── index.scss │ │ │ └── variables.scss │ │ ├── dark │ │ │ ├── index.scss │ │ │ └── variables.scss │ │ └── register.scss │ ├── transition.scss │ ├── variables.css │ ├── view-transition.scss │ ├── vxe-table.css │ └── vxe-table.scss ├── utils │ ├── cache │ │ ├── local-storage.ts │ │ └── session-storage.ts │ ├── css.ts │ ├── datetime.ts │ ├── permission.ts │ ├── service.ts │ ├── validate.ts │ └── with-prototype.ts └── views │ ├── dashboard │ ├── components │ │ ├── Admin.vue │ │ └── Editor.vue │ └── index.vue │ ├── error-page │ ├── 403.vue │ ├── 404.vue │ └── components │ │ └── ErrorPageLayout.vue │ ├── hook-demo │ ├── use-fetch-select.vue │ ├── use-fullscreen-loading.vue │ └── use-watermark.vue │ ├── login │ ├── components │ │ └── Owl.vue │ ├── hooks │ │ └── useFocus.ts │ └── index.vue │ ├── menu │ ├── menu1 │ │ ├── index.vue │ │ ├── menu1-1 │ │ │ └── index.vue │ │ ├── menu1-2 │ │ │ ├── index.vue │ │ │ ├── menu1-2-1 │ │ │ │ └── index.vue │ │ │ └── menu1-2-2 │ │ │ │ └── index.vue │ │ └── menu1-3 │ │ │ └── index.vue │ └── menu2 │ │ └── index.vue │ ├── permission │ ├── components │ │ └── SwitchRoles.vue │ ├── directive.vue │ └── page.vue │ ├── redirect │ └── index.vue │ ├── table │ ├── element-plus │ │ └── index.vue │ └── vxe-table │ │ └── index.vue │ └── unocss │ └── index.vue ├── static └── icons │ ├── logo.png │ ├── logo_256x256.icns │ ├── logo_256x256.ico │ └── logo_256x256.png ├── tsconfig.json ├── types ├── api.d.ts ├── electron-vue.d.ts ├── env.d.ts ├── global-components.d.ts ├── shims-vue.d.ts └── vue-router.d.ts ├── unocss.config.ts └── vite.config.mts /.editorconfig: -------------------------------------------------------------------------------- 1 | # 修改配置后重启编辑器 2 | # 配置项文档:https://editorconfig.org/ 3 | 4 | # 告知 EditorConfig 插件,当前即是根文件 5 | root = true 6 | 7 | # 适用全部文件 8 | [*] 9 | ## 设置字符集 10 | charset = utf-8 11 | ## 缩进风格 space | tab,建议 space(会自动继承给 Prettier) 12 | indent_style = space 13 | ## 缩进的空格数(会自动继承给 Prettier) 14 | indent_size = 2 15 | ## 换行符类型 lf | cr | crlf,一般都是设置为 lf 16 | end_of_line = lf 17 | ## 是否在文件末尾插入空白行 18 | insert_final_newline = true 19 | ## 是否删除一行中的前后空格 20 | trim_trailing_whitespace = true 21 | 22 | # 适用 .md 文件 23 | [*.md] 24 | insert_final_newline = false 25 | trim_trailing_whitespace = false 26 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | # 所有环境自定义的环境变量(命名必须以 VITE_ 开头) 2 | 3 | ## 项目标题 4 | VITE_APP_TITLE = 'V3 Electron Vite' 5 | 6 | ## 后端接口公共路径 7 | VITE_BASE_API = 'https://mock.mengxuegu.com/mock/63218b5fb4c53348ed2bc212/api/v1' 8 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # Eslint 会忽略的文件 2 | 3 | .DS_Store 4 | node_modules 5 | dist 6 | dist-ssr 7 | *.local 8 | .npmrc 9 | build 10 | release 11 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | browser: true, 5 | node: true, 6 | es6: true 7 | }, 8 | extends: [ 9 | "plugin:vue/vue3-essential", 10 | "eslint:recommended", 11 | "@vue/typescript/recommended", 12 | "@vue/prettier", 13 | "@vue/eslint-config-typescript" 14 | ], 15 | parser: "vue-eslint-parser", 16 | parserOptions: { 17 | parser: "@typescript-eslint/parser", 18 | ecmaVersion: 2020, 19 | sourceType: "module", 20 | jsxPragma: "React", 21 | ecmaFeatures: { 22 | jsx: true, 23 | tsx: true 24 | } 25 | }, 26 | rules: { 27 | // TS 28 | "@typescript-eslint/no-require-imports": "off", 29 | "@typescript-eslint/no-unused-expressions": "off", 30 | "no-debugger": "off", 31 | "@typescript-eslint/no-var-requires": "off", 32 | "@typescript-eslint/no-explicit-any": "off", 33 | "@typescript-eslint/explicit-module-boundary-types": "off", 34 | "@typescript-eslint/ban-types": "off", 35 | "@typescript-eslint/ban-ts-comment": "off", 36 | "@typescript-eslint/no-empty-function": "off", 37 | "@typescript-eslint/no-non-null-assertion": "off", 38 | "@typescript-eslint/no-unused-vars": [ 39 | "error", 40 | { 41 | argsIgnorePattern: "^_", 42 | varsIgnorePattern: "^_" 43 | } 44 | ], 45 | "no-unused-vars": [ 46 | "error", 47 | { 48 | argsIgnorePattern: "^_", 49 | varsIgnorePattern: "^_" 50 | } 51 | ], 52 | // Vue 53 | "vue/no-v-html": "off", 54 | "vue/require-default-prop": "off", 55 | "vue/require-explicit-emits": "off", 56 | "vue/multi-word-component-names": "off", 57 | "vue/html-self-closing": [ 58 | "error", 59 | { 60 | html: { 61 | void: "always", 62 | normal: "always", 63 | component: "always" 64 | }, 65 | svg: "always", 66 | math: "always" 67 | } 68 | ], 69 | // Prettier 70 | "prettier/prettier": [ 71 | "error", 72 | { 73 | endOfLine: "auto" 74 | } 75 | ] 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Git 会忽略的文件 2 | 3 | .DS_Store 4 | node_modules 5 | dist* 6 | release 7 | .eslintcache 8 | src-* 9 | vite.config.mts.timestamp-*.mjs 10 | 11 | # Local env files 12 | *.local 13 | 14 | # Logs 15 | logs 16 | *.log 17 | npm-debug.log* 18 | yarn-debug.log* 19 | yarn-error.log* 20 | pnpm-debug.log* 21 | lerna-debug.log* 22 | 23 | # Editor directories and files 24 | .vscode/* 25 | !.vscode/extensions.json 26 | !.vscode/settings.json 27 | !.vscode/*.code-snippets 28 | .idea 29 | *.suo 30 | *.ntvs* 31 | *.njsproj 32 | *.sln 33 | *.sw? 34 | 35 | # Use the PNPM 36 | package-lock.json 37 | yarn.lock 38 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | npx vue-tsc --noEmit 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | # 解决国内用户下载 Electron 相关依赖时失败的问题 2 | registry = https://registry.npmmirror.com/ 3 | electron_mirror = https://npmmirror.com/mirrors/electron/ 4 | electron_builder_binaries_mirror = https://npmmirror.com/mirrors/electron-builder-binaries/ 5 | # 通过该配置兜底解决组件没有类型提示的问题 6 | shamefully-hoist = true 7 | # 安装依赖时锁定版本号 8 | save-exact = true 9 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Prettier 会忽略的文件 2 | 3 | .DS_Store 4 | node_modules 5 | dist 6 | dist-ssr 7 | *.local 8 | .npmrc 9 | build 10 | release 11 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "editorconfig.editorconfig", 4 | "dbaeumer.vscode-eslint", 5 | "esbenp.prettier-vscode", 6 | "vue.volar", 7 | "antfu.unocss", 8 | "wiensss.region-highlighter" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /.vscode/hook.code-snippets: -------------------------------------------------------------------------------- 1 | { 2 | "Vue3 Hook 代码结构一键生成": { 3 | "prefix": "Vue3 Hook", 4 | "body": [ 5 | "import { ref } from \"vue\"\n", 6 | "const refName1 = ref(\"这是一个响应式变量\")\n", 7 | "export function useHookName() {", 8 | "\tconst refName2 = ref(\"这是一个响应式变量\")\n", 9 | "\tconst fnName = () => {}\n", 10 | "\treturn { refName1, refName2, fnName }", 11 | "}", 12 | "$1" 13 | ], 14 | "description": "Vue3 Hook" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | "prettier.enable": true, 4 | "editor.codeActionsOnSave": { 5 | "source.fixAll.eslint": "explicit" 6 | }, 7 | "[vue]": { 8 | "editor.defaultFormatter": "esbenp.prettier-vscode" 9 | }, 10 | "[javascript]": { 11 | "editor.defaultFormatter": "esbenp.prettier-vscode" 12 | }, 13 | "[typescript]": { 14 | "editor.defaultFormatter": "esbenp.prettier-vscode" 15 | }, 16 | "[json]": { 17 | "editor.defaultFormatter": "esbenp.prettier-vscode" 18 | }, 19 | "[jsonc]": { 20 | "editor.defaultFormatter": "esbenp.prettier-vscode" 21 | }, 22 | "[html]": { 23 | "editor.defaultFormatter": "esbenp.prettier-vscode" 24 | }, 25 | "[css]": { 26 | "editor.defaultFormatter": "esbenp.prettier-vscode" 27 | }, 28 | "[scss]": { 29 | "editor.defaultFormatter": "esbenp.prettier-vscode" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /.vscode/vue.code-snippets: -------------------------------------------------------------------------------- 1 | { 2 | "Vue3 SFC 代码结构一键生成": { 3 | "prefix": "Vue3 SFC", 4 | "body": [ 5 | "\n", 6 | "\n", 9 | "", 10 | "$1" 11 | ], 12 | "description": "Vue3 SFC" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 nevlf 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 简介 2 | 3 | 🥳 `Electron` + `Vue3` + `Vite` + `Pinia` + `Element Plus` + `TypeScript` 4 | 5 | - src 渲染进程的源码主要来自 [v3-admin-vite](https://github.com/un-pany/v3-admin-vite) 6 | - 注意: **Electron23 开始不再支持 win7/8/8.1** 7 | 8 | ## 运行项目 9 | 10 | ```bash 11 | # 配置 12 | 1. 一键安装 .vscode 目录中推荐的插件 13 | 2. node 版本 18.x 或 20+ 14 | 3. pnpm 版本 8.x 或最新版 15 | 16 | # 克隆项目 17 | git clone https://github.com/un-pany/v3-electron-vite.git 18 | 19 | # 进入项目目录 20 | cd v3-electron-vite 21 | 22 | # 安装依赖 23 | pnpm i 24 | 25 | # 启动服务 26 | pnpm dev 27 | 28 | # 升级所有依赖 29 | pnpm up --latest 30 | ``` 31 | 32 | ## 打包 33 | 34 | 打包配置,请参考文档 [electron-builder](https://www.electron.build/) 35 | 36 | ```bash 37 | # 根据当前系统环境构建 38 | pnpm build 39 | 40 | # 打包成解压后的目录 41 | pnpm build:dir 42 | 43 | # 构建 linux 安装包, 已设置构建 AppImage 与 deb 文件 44 | pnpm build:linux 45 | 46 | # 构建 MacOS 安装包 (只有在 MacOS 系统上打包), 已设置构建 dmg 文件 47 | pnpm build:macos 48 | 49 | # 构建 x64 位 exe 50 | pnpm build:win-x64 51 | 52 | # 构建 x32 位 exe 53 | pnpm build:win-x32 54 | ``` 55 | 56 | ## 代码格式检查 57 | 58 | ```bash 59 | pnpm lint 60 | ``` 61 | 62 | ## 目录结构 63 | 64 | ```tree 65 | ├── script 主进程源码 66 | ├ ├── core 主窗口、系统菜单与托盘、本地日志等模块 67 | ├ ├── tool 一些工具类方法 68 | ├ ├── index.ts 69 | ├ 70 | ├── src 渲染进程源码 71 | ├ ├── api 72 | ├ ├── assets 73 | ├ ├── ...... 74 | ├ 75 | ├── static 静态资源 76 | ├ ├── icons 系统图标 77 | ``` 78 | 79 | ## Git 提交规范 80 | 81 | - `feat` 增加新的业务功能 82 | - `fix` 修复业务问题/BUG 83 | - `perf` 优化性能 84 | - `style` 更改代码风格, 不影响运行结果 85 | - `refactor` 重构代码 86 | - `revert` 撤销更改 87 | - `test` 测试相关, 不涉及业务代码的更改 88 | - `docs` 文档和注释相关 89 | - `chore` 更新依赖/修改脚手架配置等琐事 90 | - `workflow` 工作流改进 91 | - `ci` 持续集成相关 92 | - `types` 类型定义文件更改 93 | - `wip` 开发中 94 | 95 | ## 站在巨人的肩膀上 96 | 97 | - [electron-vite-vue](https://github.com/electron-vite/electron-vite-vue) 98 | - [electron-vue-admin](https://github.com/PanJiaChen/electron-vue-admin) 99 | - [fast-vue3](https://github.com/study-vue3/fast-vue3) 100 | -------------------------------------------------------------------------------- /changelogithub.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "types": { 3 | "feat": { "title": "Feat" }, 4 | "fix": { "title": "Fix" }, 5 | "perf": { "title": "Perf" }, 6 | "style": { "title": "Style" }, 7 | "refactor": { "title": "Refactor" }, 8 | "revert": { "title": "Revert" }, 9 | "test": { "title": "Test" }, 10 | "docs": { "title": "Docs" }, 11 | "chore": { "title": "Chore" }, 12 | "workflow": { "title": "Workflow" }, 13 | "ci": { "title": "CI" }, 14 | "types": { "title": "Types" } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /prettier.config.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * 修改配置后重启编辑器 3 | * 配置项文档:https://prettier.io/docs/en/configuration.html 4 | * @type {import("prettier").Config} 5 | */ 6 | 7 | export default { 8 | /** 每一行的宽度 */ 9 | printWidth: 120, 10 | /** 在对象中的括号之间是否用空格来间隔 */ 11 | bracketSpacing: true, 12 | /** 箭头函数的参数无论有几个,都要括号包裹 */ 13 | arrowParens: "always", 14 | /** 换行符的使用 */ 15 | endOfLine: "auto", 16 | /** 是否采用单引号 */ 17 | singleQuote: false, 18 | /** 对象或者数组的最后一个元素后面不要加逗号 */ 19 | trailingComma: "none", 20 | /** 是否加分号 */ 21 | semi: false 22 | } 23 | -------------------------------------------------------------------------------- /script/core/AppConfig.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 全局配置 3 | */ 4 | 5 | import NodePath from "path" 6 | import { app, screen, BrowserWindow } from "electron" 7 | import PKG from "../../package.json" 8 | 9 | export class AppConfig { 10 | //#region 只读属性-应用信息 11 | 12 | /** 是否为 Windows 平台 */ 13 | static readonly IS_WIN32 = process.platform === "win32" 14 | /** 是否为 MacOS 平台 */ 15 | static readonly IS_MACOS = process.platform === "darwin" 16 | /** 是否为 Linux 平台 */ 17 | static readonly IS_LINUX = process.platform === "linux" 18 | /** 是否为 x64 客户端 */ 19 | static readonly IS_X64_ARCH = process.arch === "x64" 20 | /** 是否为开发模式 */ 21 | static readonly IS_DEV_MODE = !app.isPackaged 22 | /** 项目名称 */ 23 | static readonly PROJECT_NAME = PKG.name // same as build.productName 24 | /** 版本号 */ 25 | static readonly APP_VERSION = PKG.version 26 | 27 | //#endregion 28 | 29 | //#region 只读属性-路径 30 | 31 | /** 32 | * 应用程序目录 33 | * - dev : {project directory}/ 34 | * - prod : 35 | * 1. on macOS : {?}/{app name}.app/Contents/Resources/app?.asar 36 | * 2. on Linux : {?}/{app name}/resources/app?.asar 37 | * 3. on Windows : {?}/{app name}/resources/app?.asar 38 | */ 39 | static readonly DIR_APP = app.getAppPath() 40 | 41 | /** 42 | * 静态资源目录 43 | * - dev : {project directory}/static 44 | * - prod : 45 | * 1. on macOS : {?}/{app name}.app/Contents/Resources/app?.asar/static 46 | * 2. on Linux : {?}/{app name}/resources/app?.asar/static 47 | * 3. on Windows : {?}/{app name}/resources/app?.asar/static 48 | */ 49 | static readonly DIR_STATIC = NodePath.resolve(this.DIR_APP, "static") 50 | 51 | /** 52 | * Resources 目录 53 | * - dev : {project directory}/ 54 | * - prod : 55 | * 1. on macOS : {?}/{app name}.app/Contents/Resources 56 | * 2. on Linux : {?}/{app name}/resources 57 | * 3. on Windows : {?}/{app name}/resources 58 | */ 59 | static readonly DIR_RESOURCES = NodePath.resolve(this.DIR_APP, this.IS_DEV_MODE ? "" : "..") 60 | 61 | /** 62 | * 根目录/安装目录 63 | * - dev : {project directory}/ 64 | * - prod : 65 | * 1. on macOS : {?}/{app name}.app/Contents 66 | * 2. on Linux : {?}/{app name}/ 67 | * 3. on Windows : {?}/{app name}/ 68 | */ 69 | static readonly DIR_ROOT = this.IS_DEV_MODE ? this.DIR_APP : NodePath.resolve(this.DIR_RESOURCES, "..") 70 | 71 | /** 可执行文件路径 */ 72 | static readonly PATH_EXEC = NodePath.resolve(this.DIR_ROOT, this.IS_MACOS ? "MacOS" : "", this.PROJECT_NAME) 73 | 74 | //#endregion 75 | 76 | /** 程序名称 */ 77 | static getAppTitle(withVersion?: boolean) { 78 | const suffix = this.IS_DEV_MODE || withVersion ? ` | v${this.APP_VERSION}` : "" 79 | return `${import.meta.env.VITE_APP_TITLE || this.PROJECT_NAME}${suffix}` 80 | } 81 | 82 | /** 程序图标 */ 83 | static getAppLogo(usePng?: boolean) { 84 | const size = 256 85 | const logoList = { 86 | win32: `icons/logo_${size}x${size}.ico`, 87 | darwin: `icons/logo_${size}x${size}.icns`, 88 | linux: `icons/logo_${size}x${size}.png` 89 | } 90 | const type = usePng ? "linux" : process.platform 91 | return NodePath.join(this.DIR_STATIC, logoList[type]) 92 | } 93 | 94 | /** 运行地址/路径 */ 95 | static getWinUrl() { 96 | if (this.IS_DEV_MODE) return `http://${PKG.env.host}:${PKG.env.port}` 97 | return NodePath.join(this.DIR_APP, "dist/index.html") 98 | } 99 | 100 | /** 挂载全局变量 */ 101 | static mountGlobalVariables() { 102 | // 路径-静态资源 103 | global.StaticPath = this.DIR_STATIC 104 | // 图标 105 | global.ClientLogo = this.getAppLogo() 106 | // 版本号 107 | global.ClientVersion = this.APP_VERSION 108 | } 109 | 110 | /** 根据分辨率适配窗口大小 */ 111 | static adaptByScreen(dto: WinStateDTO, win: BrowserWindow | null) { 112 | const devSize = { width: 1920, height: 1080 } 113 | // const devSize = { width: 3440, height: 1440 } 114 | const areaSize = screen.getPrimaryDisplay().workAreaSize 115 | // const areaSize = { width: 3440, height: 1440 } 116 | // const areaSize = { width: 2160, height: 1440 } 117 | // const areaSize = { width: 1920, height: 1440 } 118 | // const areaSize = { width: 1600, height: 1200 } 119 | // const areaSize = { width: 1440, height: 900 } 120 | // const areaSize = { width: 1280, height: 768 } 121 | // const areaSize = { width: 1024, height: 768 } 122 | // const areaSize = { width: 800, height: 600 } 123 | const zoomFactor = Math.min(1, areaSize.width / devSize.width, areaSize.height / devSize.height) 124 | const realSize = { width: 0, height: 0 } 125 | realSize.width = Math.floor(dto.width * zoomFactor) 126 | realSize.height = Math.floor(dto.height * zoomFactor) 127 | win?.webContents.setZoomFactor(zoomFactor) 128 | // console.log(zoomFactor, realSize) 129 | return realSize 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /script/core/AppLogger.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 本地日志 3 | * https://github.com/megahertz/electron-log 4 | * - 默认路径 5 | * - [Linux] ~/.config/{app name}/logs/main.log 6 | * - [macOS] ~/Library/Logs/{app name}/main.log 7 | * - [Windows] %USERPROFILE%\AppData\Roaming\{app name}\logs\main.log 8 | */ 9 | 10 | import NodePath from "path" 11 | import DayJS from "dayjs" 12 | import EleLog from "electron-log" 13 | import { AppConfig } from "./AppConfig" 14 | 15 | function getLogId(name: string) { 16 | const tid = DayJS(new Date()).format("YYYYMMDD") 17 | return `[${AppConfig.PROJECT_NAME}]-${AppConfig.IS_DEV_MODE ? "dev" : tid}-${name}` 18 | } 19 | 20 | function setTagToLog(logger: EleLog.MainLogger) { 21 | if (AppConfig.IS_DEV_MODE) { 22 | logger.transports.file.getFile().clear() 23 | } else { 24 | logger.log("\n".repeat(4), `⚡`.repeat(99), "\n".repeat(3)) 25 | } 26 | } 27 | 28 | EleLog.initialize() 29 | EleLog.transports.file.fileName = `${getLogId("console")}.log` 30 | setTagToLog(EleLog) 31 | 32 | export function getLocalLogsPath() { 33 | return NodePath.dirname(EleLog.transports.file.getFile().path) 34 | } 35 | 36 | /** 日志工厂 */ 37 | export class LogFactory { 38 | // 39 | static readonly nameSet = new Set() 40 | 41 | /** 文件名称 */ 42 | private fileName: string 43 | /** 日志器实例 */ 44 | private logInst: EleLog.MainLogger 45 | 46 | /** 构造函数 */ 47 | constructor(name: string) { 48 | const logId = getLogId(name) 49 | this.fileName = `${logId}.log` 50 | this.logInst = EleLog.create({ logId }) 51 | this.logInst.transports.file.fileName = this.fileName 52 | this.logInst.transports.file.maxSize = 1048576 53 | this.logInst.transports.file.format = `[{y}-{m}-{d} {h}:{i}:{s}.{ms}] >> {text}` 54 | this.logInst.transports.ipc.level = ["index", "cmd"].includes(name) ? false : "info" 55 | this.logInst.transports.console.level = false 56 | } 57 | 58 | /** 统一处理, 可在这里对日志进行加密 */ 59 | private handle(type: string, ...params: any[]) { 60 | try { 61 | if (!LogFactory.nameSet.has(this.fileName)) { 62 | LogFactory.nameSet.add(this.fileName) 63 | setTagToLog(this.logInst) 64 | } 65 | this.logInst[type](...params) 66 | } catch (reason: any) { 67 | console.log("[LogFactory.handle] ", reason) 68 | } 69 | } 70 | 71 | //#region 日志方法 72 | log(...params: any[]) { 73 | this.handle("log", ...params) 74 | } 75 | info(...params: any[]) { 76 | this.handle("info", ...params) 77 | } 78 | error(...params: any[]) { 79 | this.handle("error", ...params) 80 | } 81 | warn(...params: any[]) { 82 | this.handle("warn", ...params) 83 | } 84 | verbose(...params: any[]) { 85 | this.handle("verbose", ...params) 86 | } 87 | debug(...params: any[]) { 88 | this.handle("debug", ...params) 89 | } 90 | silly(...params: any[]) { 91 | this.handle("silly", ...params) 92 | } 93 | //#endregion 94 | 95 | getFilePath() { 96 | return this.logInst.transports.file.getFile().path 97 | } 98 | } 99 | 100 | export class LocalLogger { 101 | static readonly Exception = new LogFactory("exception") 102 | static readonly Index = new LogFactory("index") 103 | static readonly Cmd = new LogFactory("cmd") 104 | static readonly Net = new LogFactory("net") 105 | } 106 | -------------------------------------------------------------------------------- /script/core/AppMain.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * app 3 | */ 4 | 5 | import * as remote from "@electron/remote/main" 6 | import { app, dialog, ipcMain, clipboard, type MessageBoxOptions } from "electron" 7 | import { LogFactory, LocalLogger } from "./AppLogger" 8 | import { AppConfig } from "./AppConfig" 9 | import IpcDict from "../tool/ipc-dict" 10 | import AppMenu from "./AppMenu" 11 | import AppTray from "./AppTray" 12 | import WinMain from "./WinMain" 13 | 14 | export default class AppMain { 15 | // 打印日志 16 | private static printf(...params: any[]) { 17 | LocalLogger.Index.log(...params) 18 | } 19 | 20 | /** 是否获取应用单例锁 */ 21 | static hasSingleLock() { 22 | return AppConfig.IS_DEV_MODE || app.requestSingleInstanceLock() 23 | } 24 | 25 | /** 启动应用 */ 26 | static startApp() { 27 | // 初始化 remote 28 | remote.initialize() 29 | // 挂载全局变量 30 | AppConfig.mountGlobalVariables() 31 | 32 | // 初始化完成 33 | app.whenReady().then(() => { 34 | this.printf("[main.app.初始化完成]", "") 35 | this.ipcListening() 36 | if (AppConfig.IS_MACOS) { 37 | AppMenu.update() 38 | AppMenu.ipcListening() 39 | } 40 | // 托盘/程序坞 41 | AppTray.create() 42 | AppTray.ipcListening() 43 | // 主窗口 44 | WinMain.create() 45 | WinMain.ipcListening() 46 | }) 47 | 48 | /** 应用被激活 */ 49 | app.on("activate", () => { 50 | this.printf("[main.app.应用被激活]", "") 51 | AppConfig.IS_MACOS && WinMain.show() 52 | }) 53 | 54 | /** 聚焦窗口 */ 55 | app.on("browser-window-focus", () => { 56 | // 57 | }) 58 | 59 | // 运行第二个实例时 60 | app.on("second-instance", (_, argv: string[]) => { 61 | this.printf("[main.app.运行第二个实例]", "", { argv }) 62 | WinMain.show() 63 | }) 64 | 65 | // 所有的窗口都被关闭 66 | app.on("window-all-closed", () => { 67 | this.printf("[main.app.所有的窗口都被关闭]", "") 68 | this.exitApp() 69 | }) 70 | 71 | // 子进程意外消失 72 | app.on("child-process-gone", (event, detail) => { 73 | this.printf("[main.app.子进程意外消失]", "", event, detail) 74 | }) 75 | 76 | // 渲染进程意外消失 77 | app.on("render-process-gone", (event, web, detail) => { 78 | this.printf("[main.app.渲染进程意外消失]", "", event, web.getURL(), detail) 79 | web.reload() 80 | }) 81 | 82 | // 程序退出之前 83 | app.on("before-quit", () => { 84 | this.printf("[main.app.程序退出之前]", "") 85 | }) 86 | 87 | // 程序退出 88 | app.on("quit", () => { 89 | this.printf("[main.app.程序已退出]", "") 90 | app.releaseSingleInstanceLock() 91 | process.exit(0) 92 | }) 93 | } 94 | 95 | /** 退出应用 */ 96 | static async exitApp(title?: string, content?: string, command?: string) { 97 | this.printf("[main.app.退出提示]", { title, content, command }) 98 | if (title && content) { 99 | if (!app.isReady()) await app.whenReady() 100 | const opt: MessageBoxOptions = { 101 | type: "warning", 102 | icon: AppConfig.getAppLogo(), 103 | noLink: true, 104 | title: title, 105 | message: `${content}`, 106 | buttons: [command ? "确定并复制命令" : "确定"], 107 | cancelId: -1, 108 | defaultId: 0 109 | } 110 | dialog.showMessageBoxSync(opt) 111 | if (command) { 112 | clipboard.writeText(command, "selection") 113 | clipboard.readText("selection") 114 | } 115 | } 116 | app.quit() 117 | } 118 | 119 | /** 重启应用 */ 120 | static restartApp() { 121 | this.printf("[main.app.重启应用]") 122 | !AppConfig.IS_DEV_MODE && app.relaunch() 123 | app.exit(0) 124 | } 125 | 126 | /** 监听相关事件 */ 127 | static ipcListening() { 128 | ipcMain.on(IpcDict.CODE_01002, () => this.restartApp()) 129 | ipcMain.on(IpcDict.CODE_02001, (_, logName: string, ...params: any[]) => { 130 | new LogFactory(logName).log(...params) 131 | }) 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /script/core/AppMenu.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * MacOS-顶部菜单 3 | */ 4 | 5 | import { Menu, MenuItem, type MenuItemConstructorOptions } from "electron" 6 | import { LocalLogger, getLocalLogsPath } from "./AppLogger" 7 | import { AppConfig } from "./AppConfig" 8 | import { openFolder } from "../tool" 9 | import WinMain from "./WinMain" 10 | 11 | export default class AppMenu { 12 | // 打印日志 13 | private static printf(...params: any[]) { 14 | LocalLogger.Index.log(...params) 15 | } 16 | 17 | private static readonly MID_WINDOW = "menu.window" 18 | private static readonly MID_HELP = "menu.help" 19 | private static readonly MID_HELP_OPEN_DEVTOOLS = "menu.open.devtools" 20 | 21 | private static menuList: (MenuItemConstructorOptions | MenuItem)[] = [ 22 | { 23 | label: AppConfig.PROJECT_NAME, 24 | submenu: [ 25 | { label: `关于 ${AppConfig.getAppTitle()}` }, 26 | { label: `版本号 v${AppConfig.APP_VERSION}` }, 27 | { type: "separator" }, 28 | { label: "退出", role: "quit" } 29 | ] 30 | }, 31 | { 32 | id: this.MID_WINDOW, 33 | label: "窗口", 34 | submenu: [ 35 | { 36 | label: "最小化", 37 | role: "minimize", 38 | click: (item) => { 39 | this.printf("[main.top-menu]", `<${item.label}>`, "点击") 40 | WinMain.instance()?.minimize() 41 | } 42 | }, 43 | { 44 | label: "显示", 45 | click: (item) => { 46 | this.printf("[main.top-menu]", `<${item.label}>`, "点击") 47 | WinMain.show(true) 48 | } 49 | }, 50 | { 51 | label: "隐藏", 52 | role: "hide", 53 | click: (item) => { 54 | this.printf("[main.top-menu]", `<${item.label}>`, "点击") 55 | WinMain.instance()?.hide() 56 | } 57 | }, 58 | { label: "关闭", role: "close" } 59 | ] 60 | }, 61 | { 62 | id: this.MID_HELP, 63 | label: "帮助", 64 | submenu: [ 65 | { 66 | id: this.MID_HELP_OPEN_DEVTOOLS, 67 | label: "控制台", 68 | enabled: true, 69 | click: (item) => { 70 | this.printf("[main.top-menu]", `<帮助.${item.label}>`, "点击:打开") 71 | WinMain.show() 72 | WinMain.openDevtool("undocked") 73 | }, 74 | submenu: [ 75 | { lable: "右侧", value: "right" }, 76 | { lable: "底部", value: "bottom" }, 77 | { lable: "分离", value: "undocked" } 78 | ].map((k) => ({ 79 | label: k.lable, 80 | click: (item) => { 81 | this.printf("[main.top-menu]", `<帮助.打开控制台>`, `位置:${item.label}`) 82 | WinMain.show() 83 | WinMain.openDevtool(k.value as any) 84 | } 85 | })) 86 | }, 87 | { 88 | label: "本地日志", 89 | click: (item) => { 90 | const logsPath = getLocalLogsPath() 91 | this.printf("[main.top-menu]", `<帮助.${item.label}>`, `${logsPath}`) 92 | openFolder(logsPath).catch((e) => this.printf("[本地日志]", e)) 93 | } 94 | } 95 | ] 96 | } 97 | ] 98 | 99 | static update() { 100 | const list = Menu.buildFromTemplate(this.menuList) 101 | Menu.setApplicationMenu(list) 102 | } 103 | 104 | static destroy() { 105 | this.printf("[main.top-menu]", `关闭`) 106 | Menu.setApplicationMenu(Menu.buildFromTemplate([])) 107 | } 108 | 109 | /** 监听相关事件 */ 110 | static ipcListening() { 111 | // 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /script/core/AppTray.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Windows-任务栏托盘 3 | * MacOS-程序坞 4 | */ 5 | 6 | import { app, Tray, Menu, MenuItem, type MenuItemConstructorOptions } from "electron" 7 | import { LocalLogger, getLocalLogsPath } from "./AppLogger" 8 | import { AppConfig } from "./AppConfig" 9 | import { openFolder } from "../tool" 10 | import WinMain from "./WinMain" 11 | 12 | export default class AppTray { 13 | // 打印日志 14 | private static printf(...params: any[]) { 15 | LocalLogger.Index.log(...params) 16 | } 17 | 18 | private static readonly MID_OPEN_DEVTOOLS = "menu.open.devtools" 19 | 20 | /** 托盘实例 */ 21 | private static trayInst: Tray | null = null 22 | 23 | /** 托盘菜单 */ 24 | private static menuList: (MenuItemConstructorOptions | MenuItem)[] = [ 25 | { 26 | id: this.MID_OPEN_DEVTOOLS, 27 | label: "控制台", 28 | visible: true, 29 | click: (item) => { 30 | this.printf("[main.tray]", `<${item.label}>`, "点击:打开") 31 | WinMain.show() 32 | WinMain.openDevtool("undocked") 33 | }, 34 | submenu: [ 35 | { lable: "右侧", value: "right" }, 36 | { lable: "底部", value: "bottom" }, 37 | { lable: "分离", value: "undocked" } 38 | ].map((k) => ({ 39 | label: k.lable, 40 | click: (item) => { 41 | this.printf("[main.tray]", `<打开控制台>`, `位置:${item.label}`) 42 | WinMain.show() 43 | WinMain.openDevtool(k.value as any) 44 | } 45 | })) 46 | }, 47 | { 48 | label: "本地日志", 49 | click: (item) => { 50 | const logsPath = getLocalLogsPath() 51 | this.printf("[main.tray]", `<${item.label}>`, `${logsPath}`) 52 | openFolder(logsPath).catch((e) => this.printf("[本地日志]", e)) 53 | } 54 | }, 55 | { type: "separator" }, 56 | { 57 | label: "最小化", 58 | click: (item) => { 59 | this.printf("[main.tray]", `<${item.label}>`, "点击") 60 | WinMain.instance()?.minimize() 61 | } 62 | }, 63 | { 64 | label: "隐藏", 65 | click: (item) => { 66 | this.printf("[main.tray]", `<${item.label}>`, "点击") 67 | WinMain.instance()?.hide() 68 | } 69 | }, 70 | { 71 | label: "显示", 72 | click: (item) => { 73 | this.printf("[main.tray]", `<${item.label}>`, "点击") 74 | WinMain.show(true) 75 | } 76 | }, 77 | { 78 | label: `退出${AppConfig.IS_DEV_MODE ? "--dev" : ""}`, 79 | role: "quit" 80 | } 81 | ] 82 | 83 | //#region 操作菜单 84 | 85 | static create() { 86 | const contextMenu = Menu.buildFromTemplate(this.menuList) 87 | if (AppConfig.IS_MACOS) return app.dock?.setMenu(contextMenu) 88 | const icon = AppConfig.getAppLogo() 89 | const title = AppConfig.getAppTitle(true) 90 | // 声明托盘对象 91 | this.trayInst = new Tray(icon) 92 | this.trayInst.setTitle(title) 93 | this.trayInst.setToolTip(title) 94 | this.trayInst.setContextMenu(contextMenu) 95 | // 双击图标打开窗口 96 | this.trayInst.on("double-click", () => WinMain.show()) 97 | } 98 | 99 | static update() { 100 | const contextMenu = Menu.buildFromTemplate(this.menuList) 101 | this.trayInst?.setContextMenu(contextMenu) 102 | app.dock?.setMenu(contextMenu) 103 | } 104 | 105 | /** 销毁托盘 */ 106 | static destroy() { 107 | this.trayInst?.removeAllListeners() 108 | this.trayInst?.destroy() 109 | this.trayInst = null 110 | app.dock?.hide() 111 | } 112 | 113 | //#endregion 114 | 115 | /** 监听相关事件 */ 116 | static ipcListening() { 117 | // 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /script/core/WinMain.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 主窗口 3 | */ 4 | 5 | import * as remote from "@electron/remote/main" 6 | import { ipcMain, BrowserWindow, type BrowserWindowConstructorOptions } from "electron" 7 | import { LocalLogger } from "./AppLogger" 8 | import { AppConfig } from "./AppConfig" 9 | import IpcDict from "../tool/ipc-dict" 10 | 11 | export default class WinMain { 12 | // 打印日志 13 | private static printf(...params: any[]) { 14 | LocalLogger.Index.log(...params) 15 | } 16 | 17 | /** 窗口实例 */ 18 | private static winInst: BrowserWindow | null = null 19 | 20 | /** 窗口配置 */ 21 | private static winOption: BrowserWindowConstructorOptions = { 22 | icon: AppConfig.getAppLogo(), // 图标 23 | title: AppConfig.getAppTitle(), // 如果由 loadURL() 加载的 HTML 文件中含有标签 ,此属性将被忽略 24 | minWidth: 1024, 25 | minHeight: 768, 26 | show: false, // 是否在创建时显示, 默认值为 true 27 | frame: true, // 是否有边框 28 | center: true, // 是否在屏幕居中 29 | hasShadow: false, // 窗口是否有阴影. 默认值为 true 30 | resizable: true, // 是否允许拉伸大小 31 | fullscreenable: true, // 是否允许全屏 32 | autoHideMenuBar: true, // 自动隐藏菜单栏, 除非按了 Alt 键, 默认值为 false 33 | backgroundColor: "transparent", // 背景颜色 34 | webPreferences: { 35 | devTools: true, // 是否开启 DevTools, 如果设置为 false(默认值为 true), 则无法使用 BrowserWindow.webContents.openDevTools() 36 | webSecurity: false, // 当设置为 false, 将禁用同源策略 37 | nodeIntegration: true, // 是否启用 Node 集成 38 | contextIsolation: false, // 是否在独立 JavaScript 环境中运行 Electron API 和指定的 preload 脚本,默认为 true 39 | nodeIntegrationInWorker: true, // 是否在 Web 工作器中启用了 Node 集成 40 | backgroundThrottling: false, // 是否在页面成为背景时限制动画和计时器,默认值为 true 41 | spellcheck: false // 是否启用内置拼写检查器 42 | } 43 | } 44 | 45 | /** 获取窗口实例 */ 46 | static instance() { 47 | return this.winInst 48 | } 49 | 50 | static sendToRenderer(channel: string, ...params: any[]) { 51 | this.printf("[main.win.主进程>>>渲染进程]", `<频道>`, channel) 52 | this.printf("[main.win.主进程>>>渲染进程]", `<参数>`, ...params) 53 | this.winInst?.webContents.send(channel, ...params) 54 | } 55 | 56 | /** 显示窗口 */ 57 | static show(center?: boolean) { 58 | this.printf("[main.win.显示主窗口]", { center }) 59 | this.winInst?.show() 60 | this.winInst?.focus() 61 | center && this.winInst?.center() 62 | } 63 | 64 | /** 创建窗口 */ 65 | static create() { 66 | if (this.winInst) return 67 | 68 | this.winInst = new BrowserWindow(this.winOption) 69 | this.winInst.removeMenu() 70 | if (AppConfig.IS_DEV_MODE) { 71 | this.winInst.loadURL(AppConfig.getWinUrl()) 72 | } else { 73 | this.winInst.loadFile(AppConfig.getWinUrl()) 74 | } 75 | 76 | // 启用 remote 77 | remote.enable(this.winInst.webContents) 78 | // AppConfig.IS_DEV_MODE && this.openDevtool() 79 | 80 | // 窗口-准备好显示 81 | // 在窗口的控制台中使用 F5 刷新时,也会触发该事件 82 | this.winInst.on("ready-to-show", () => { 83 | this.printf("[main.win.即将显示]", "<ready-to-show>") 84 | this.show(true) 85 | this.winInst?.center() 86 | }) 87 | 88 | // 窗口-即将关闭 89 | this.winInst.on("close", () => { 90 | this.printf("[main.win.即将关闭]", "<close>") 91 | }) 92 | 93 | // 窗口-已关闭 94 | this.winInst.on("closed", () => { 95 | this.printf("[main.win.已关闭]", "<closed>") 96 | this.winInst?.removeAllListeners() 97 | this.winInst = null 98 | }) 99 | } 100 | 101 | /** 打开控制台 */ 102 | static openDevtool(type?: "right" | "bottom" | "undocked") { 103 | if (!this.winInst) return 104 | const winCtns = this.winInst.webContents 105 | winCtns.closeDevTools() 106 | winCtns.openDevTools({ mode: type || "undocked", title: " " }) 107 | } 108 | 109 | /** 监听通信事件 */ 110 | static ipcListening() { 111 | // 设置窗口默认尺寸 112 | ipcMain.on(IpcDict.CODE_01001, (_, dto: WinStateDTO) => { 113 | if (!this.winInst) return 114 | const size = AppConfig.adaptByScreen(dto, this.winInst) 115 | this.winInst.setResizable(true) 116 | this.winInst.setMinimumSize(size.width, size.height) 117 | this.winInst.setSize(size.width, size.height) 118 | dto.center && this.winInst.center() 119 | typeof dto.maxable === "boolean" && this.winInst.setMaximizable(dto.maxable) 120 | typeof dto.resizable === "boolean" && this.winInst.setResizable(dto.resizable) 121 | }) 122 | // 中转消息-代替中央事件总线 123 | ipcMain.on(IpcDict.CODE_02002, (_, args: any) => { 124 | if (!this.winInst || !args || !args.channel) return 125 | this.printf("[main.win.事件总线]", args) 126 | this.sendToRenderer(args.channel, args.data) 127 | }) 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /script/index.ts: -------------------------------------------------------------------------------- 1 | import NodeOS from "os" 2 | import { app, Menu } from "electron" 3 | import { LocalLogger } from "./core/AppLogger" 4 | import { AppConfig } from "./core/AppConfig" 5 | import AppMain from "./core/AppMain" 6 | 7 | function printf(...params: any[]) { 8 | LocalLogger.Index.log(...params) 9 | } 10 | 11 | async function main() { 12 | if (!AppMain.hasSingleLock()) return AppMain.exitApp("Instance is running.") 13 | // 本地开发 14 | if (AppConfig.IS_DEV_MODE) { 15 | // 16 | } else { 17 | // 打包后 18 | } 19 | // 打印一些信息 20 | { 21 | printf("[main.index.os.arch()]", NodeOS.arch()) 22 | printf("[main.index.os.machine()]", NodeOS.machine()) 23 | printf("[main.index.os.homedir()]", NodeOS.homedir()) 24 | printf("[main.index.os.userInfo()]", NodeOS.userInfo()) 25 | } 26 | // 启动 27 | AppMain.startApp() 28 | } 29 | 30 | //#region 其他 31 | 32 | function handleProcessError(error: Error) { 33 | LocalLogger.Exception.log("[main.index.进程异常]", error) 34 | } 35 | 36 | /** 关闭安全警告 */ 37 | process.env["ELECTRON_DISABLE_SECURITY_WARNINGS"] = "false" 38 | /** 全局错误捕获 */ 39 | process.on("uncaughtException", handleProcessError) 40 | process.on("unhandledRejection", handleProcessError) 41 | 42 | // 禁用 硬件加速 43 | app.disableHardwareAcceleration() 44 | // // 禁用 Chromium 沙盒 45 | // app.commandLine.appendSwitch("no-sandbox") 46 | // 忽略证书相关错误 47 | app.commandLine.appendSwitch("ignore-certificate-errors") 48 | // // 禁用 GPU 49 | // app.commandLine.appendSwitch("disable-gpu") 50 | // // 禁用 GPU 沙盒 51 | // app.commandLine.appendSwitch("disable-gpu-sandbox") 52 | // // 禁用 GPU 合成 53 | // app.commandLine.appendSwitch("disable-gpu-compositing") 54 | // // 禁用 GPU 光栅化 55 | // app.commandLine.appendSwitch("disable-gpu-rasterization") 56 | // // 禁用软件光栅化器 57 | // app.commandLine.appendSwitch("disable-software-rasterizer") 58 | // 禁用 HTTP 缓存 59 | app.commandLine.appendSwitch("disable-http-cache") 60 | // 禁用动画, 解决透明窗口打开闪烁问题 61 | app.commandLine.appendSwitch("wm-window-animations-disabled") 62 | 63 | // 禁用默认系统菜单 64 | Menu.setApplicationMenu(Menu.buildFromTemplate([])) 65 | 66 | // 启动 67 | main().catch(handleProcessError) 68 | 69 | //#endregion 70 | -------------------------------------------------------------------------------- /script/tool/index.ts: -------------------------------------------------------------------------------- 1 | import NodeOS from "os" 2 | import IconvLite from "iconv-lite" 3 | import { exec, type ExecOptions } from "child_process" 4 | import { shell } from "electron" 5 | import { AppConfig } from "../core/AppConfig" 6 | import { LocalLogger } from "../core/AppLogger" 7 | 8 | /** 延时器 */ 9 | export const delayer = (cd: number) => new Promise<void>((resolve) => setTimeout(resolve, cd)) 10 | 11 | /** 格式化数字 */ 12 | export const formatNumber = (num: number | string) => { 13 | const base = 1024 14 | const unitList = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB", "BB", "NB", "DB", "CB"] 15 | const value = Number(num) 16 | if (!Number.isInteger(value) || value === 0) return `0 ${unitList[0]}` 17 | const pow = Math.floor(Math.log(value) / Math.log(base)) 18 | if (pow < 0) return `0 ${unitList[0]}` 19 | let data = (value / Math.pow(base, pow)).toFixed(2) 20 | if (data.endsWith(".00")) { 21 | data = data.substring(0, data.length - 3) 22 | } 23 | return `${data} ${unitList[pow]}` 24 | } 25 | 26 | /** 转义乱码 */ 27 | export const iconvDecode = (text: string | Buffer, dataDecode?: string) => { 28 | const value = AppConfig.IS_WIN32 ? text.toString() : IconvLite.decode(Buffer.from(text), dataDecode || "cp936") 29 | return value.replace(/\n$/, "").trim() 30 | } 31 | 32 | /** 运行 CMD 命令 */ 33 | export const runCmdOrder = (command: string, options?: ExecOptions, dataEncode?: string, dataDecode?: string) => { 34 | return new Promise<CmdResult>((resolve) => { 35 | const opt: { 36 | encoding: string 37 | } & ExecOptions = { 38 | encoding: dataEncode || "buffer", 39 | windowsHide: true, 40 | ...options 41 | } 42 | const startTime = Date.now() 43 | LocalLogger.Cmd.log("[命令]", command) 44 | LocalLogger.Cmd.log("[配置]", opt) 45 | exec(command, opt, (error, stdout, stderr) => { 46 | const result: CmdResult = { 47 | tid: Date.now().toString(), 48 | success: false, 49 | command, 50 | options: opt, 51 | spent: Date.now() - startTime, 52 | error: error || {}, 53 | stderr: iconvDecode(stderr || "", dataDecode), 54 | stdout: iconvDecode(stdout || "", dataDecode), 55 | message: "" 56 | } 57 | result.message = result.stderr || error?.message || "" 58 | result.success = !result.message 59 | LocalLogger.Cmd.log(result, "\n") 60 | return resolve(result) 61 | }) 62 | }) 63 | } 64 | 65 | export const openFolder = (path: string) => { 66 | const isLinuxRoot = AppConfig.IS_LINUX && NodeOS.userInfo().uid === 0 67 | return isLinuxRoot ? runCmdOrder(`xdg-open "${path}"`) : shell.openPath(path) 68 | } 69 | -------------------------------------------------------------------------------- /script/tool/ipc-dict.ts: -------------------------------------------------------------------------------- 1 | export default class IpcDict { 2 | static readonly CODE_01001 = `设置窗口默认尺寸` 3 | static readonly CODE_01002 = `重启应用程序` 4 | 5 | static readonly CODE_02001 = `记录本地日志` 6 | static readonly CODE_02002 = `vue中央事件总线` 7 | } 8 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | <script lang="ts" setup> 2 | import { onMounted } from "vue" 3 | import { ElNotification } from "element-plus" 4 | import { useGreyAndColorWeakness } from "@/hooks/useGreyAndColorWeakness" 5 | import zhCn from "element-plus/es/locale/lang/zh-cn" // Element Plus 中文包 6 | import IpcDict from "@/constants/ipc-dict" 7 | import { useTheme } from "@/hooks/useTheme" 8 | import { APP_TITLE } from "@/hooks/useTitle" 9 | 10 | const { initTheme } = useTheme() 11 | const { initGreyAndColorWeakness } = useGreyAndColorWeakness() 12 | 13 | /** 初始化主题 */ 14 | initTheme() 15 | /** 初始化灰色模式和色弱模式 */ 16 | initGreyAndColorWeakness() 17 | 18 | /** 作者小心思 */ 19 | ElNotification({ 20 | title: "Hello", 21 | type: "success", 22 | dangerouslyUseHTMLString: true, 23 | message: `<a style='color: teal' target='_blank' href='https://github.com/un-pany/v3-admin-vite'>小项目获取 star 不易,如果你喜欢这个项目的话,欢迎点击这里支持一个 star !这是作者持续维护的唯一动力(小声:毕竟是免费的)</a>`, 24 | // duration: 0, 25 | position: "bottom-right" 26 | }) 27 | 28 | onMounted(() => { 29 | console.log(`Hello, ${APP_TITLE}! \n`) 30 | const winState: WinStateDTO = { 31 | width: 1024, 32 | height: 768, 33 | center: true, 34 | maxable: true, 35 | resizable: true 36 | } 37 | window.vIpcRenderer.send(IpcDict.CODE_01001, winState) 38 | }) 39 | </script> 40 | 41 | <template> 42 | <el-config-provider :locale="zhCn"> 43 | <router-view /> 44 | </el-config-provider> 45 | </template> 46 | -------------------------------------------------------------------------------- /src/api/hook-demo/use-fetch-select.ts: -------------------------------------------------------------------------------- 1 | /** 模拟接口响应数据 */ 2 | const SELECT_RESPONSE_DATA = { 3 | code: 0, 4 | data: [ 5 | { 6 | label: "苹果", 7 | value: 1 8 | }, 9 | { 10 | label: "香蕉", 11 | value: 2 12 | }, 13 | { 14 | label: "橘子", 15 | value: 3, 16 | disabled: true 17 | } 18 | ], 19 | message: "获取 Select 数据成功" 20 | } 21 | 22 | /** 模拟接口 */ 23 | export function getSelectDataApi() { 24 | return new Promise<typeof SELECT_RESPONSE_DATA>((resolve, reject) => { 25 | // 模拟接口响应时间 2s 26 | setTimeout(() => { 27 | // 模拟接口调用成功 28 | if (Math.random() < 0.8) { 29 | resolve(SELECT_RESPONSE_DATA) 30 | } else { 31 | // 模拟接口调用出错 32 | reject(new Error("接口发生错误")) 33 | } 34 | }, 2000) 35 | }) 36 | } 37 | -------------------------------------------------------------------------------- /src/api/hook-demo/use-fullscreen-loading.ts: -------------------------------------------------------------------------------- 1 | /** 模拟接口响应数据 */ 2 | const SUCCESS_RESPONSE_DATA = { 3 | code: 0, 4 | data: { 5 | list: [] as number[] 6 | }, 7 | message: "获取成功" 8 | } 9 | 10 | /** 模拟请求接口成功 */ 11 | export function getSuccessApi(list: number[]) { 12 | return new Promise<typeof SUCCESS_RESPONSE_DATA>((resolve) => { 13 | setTimeout(() => { 14 | resolve({ ...SUCCESS_RESPONSE_DATA, data: { list } }) 15 | }, 1000) 16 | }) 17 | } 18 | 19 | /** 模拟请求接口失败 */ 20 | export function getErrorApi() { 21 | return new Promise((_resolve, reject) => { 22 | setTimeout(() => { 23 | reject(new Error("发生错误")) 24 | }, 1000) 25 | }) 26 | } 27 | -------------------------------------------------------------------------------- /src/api/login/index.ts: -------------------------------------------------------------------------------- 1 | import { request } from "@/utils/service" 2 | import type * as Login from "./types/login" 3 | 4 | /** 获取登录验证码 */ 5 | export function getLoginCodeApi() { 6 | return request<Login.LoginCodeResponseData>({ 7 | url: "login/code", 8 | method: "get" 9 | }) 10 | } 11 | 12 | /** 登录并返回 Token */ 13 | export function loginApi(data: Login.LoginRequestData) { 14 | return request<Login.LoginResponseData>({ 15 | url: "users/login", 16 | method: "post", 17 | data 18 | }) 19 | } 20 | 21 | /** 获取用户详情 */ 22 | export function getUserInfoApi() { 23 | return request<Login.UserInfoResponseData>({ 24 | url: "users/info", 25 | method: "get" 26 | }) 27 | } 28 | -------------------------------------------------------------------------------- /src/api/login/types/login.ts: -------------------------------------------------------------------------------- 1 | export interface LoginRequestData { 2 | /** admin 或 editor */ 3 | username: "admin" | "editor" 4 | /** 密码 */ 5 | password: string 6 | /** 验证码 */ 7 | code: string 8 | } 9 | 10 | export type LoginCodeResponseData = ApiResponseData<string> 11 | 12 | export type LoginResponseData = ApiResponseData<{ token: string }> 13 | 14 | export type UserInfoResponseData = ApiResponseData<{ username: string; roles: string[] }> 15 | -------------------------------------------------------------------------------- /src/api/table/index.ts: -------------------------------------------------------------------------------- 1 | import { request } from "@/utils/service" 2 | import type * as Table from "./types/table" 3 | 4 | /** 增 */ 5 | export function createTableDataApi(data: Table.CreateOrUpdateTableRequestData) { 6 | return request({ 7 | url: "table", 8 | method: "post", 9 | data 10 | }) 11 | } 12 | 13 | /** 删 */ 14 | export function deleteTableDataApi(id: string) { 15 | return request({ 16 | url: `table/${id}`, 17 | method: "delete" 18 | }) 19 | } 20 | 21 | /** 改 */ 22 | export function updateTableDataApi(data: Table.CreateOrUpdateTableRequestData) { 23 | return request({ 24 | url: "table", 25 | method: "put", 26 | data 27 | }) 28 | } 29 | 30 | /** 查 */ 31 | export function getTableDataApi(params: Table.TableRequestData) { 32 | return request<Table.TableResponseData>({ 33 | url: "table", 34 | method: "get", 35 | params 36 | }) 37 | } 38 | -------------------------------------------------------------------------------- /src/api/table/types/table.ts: -------------------------------------------------------------------------------- 1 | export interface CreateOrUpdateTableRequestData { 2 | id?: string 3 | username: string 4 | password?: string 5 | } 6 | 7 | export interface TableRequestData { 8 | /** 当前页码 */ 9 | currentPage: number 10 | /** 查询条数 */ 11 | size: number 12 | /** 查询参数:用户名 */ 13 | username?: string 14 | /** 查询参数:手机号 */ 15 | phone?: string 16 | } 17 | 18 | export interface TableData { 19 | createTime: string 20 | email: string 21 | id: string 22 | phone: string 23 | roles: string 24 | status: boolean 25 | username: string 26 | } 27 | 28 | export type TableResponseData = ApiResponseData<{ 29 | list: TableData[] 30 | total: number 31 | }> 32 | -------------------------------------------------------------------------------- /src/assets/docs/preview1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/un-pany/v3-electron-vite/8cf3b2d9f2351a2e9c455b8bc285633a4ee125a9/src/assets/docs/preview1.png -------------------------------------------------------------------------------- /src/assets/docs/preview2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/un-pany/v3-electron-vite/8cf3b2d9f2351a2e9c455b8bc285633a4ee125a9/src/assets/docs/preview2.png -------------------------------------------------------------------------------- /src/assets/docs/preview3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/un-pany/v3-electron-vite/8cf3b2d9f2351a2e9c455b8bc285633a4ee125a9/src/assets/docs/preview3.png -------------------------------------------------------------------------------- /src/assets/docs/qq.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/un-pany/v3-electron-vite/8cf3b2d9f2351a2e9c455b8bc285633a4ee125a9/src/assets/docs/qq.png -------------------------------------------------------------------------------- /src/assets/docs/wechat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/un-pany/v3-electron-vite/8cf3b2d9f2351a2e9c455b8bc285633a4ee125a9/src/assets/docs/wechat.png -------------------------------------------------------------------------------- /src/assets/layouts/logo-text-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/un-pany/v3-electron-vite/8cf3b2d9f2351a2e9c455b8bc285633a4ee125a9/src/assets/layouts/logo-text-1.png -------------------------------------------------------------------------------- /src/assets/layouts/logo-text-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/un-pany/v3-electron-vite/8cf3b2d9f2351a2e9c455b8bc285633a4ee125a9/src/assets/layouts/logo-text-2.png -------------------------------------------------------------------------------- /src/assets/layouts/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/un-pany/v3-electron-vite/8cf3b2d9f2351a2e9c455b8bc285633a4ee125a9/src/assets/layouts/logo.png -------------------------------------------------------------------------------- /src/assets/login/close-eyes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/un-pany/v3-electron-vite/8cf3b2d9f2351a2e9c455b8bc285633a4ee125a9/src/assets/login/close-eyes.png -------------------------------------------------------------------------------- /src/assets/login/face.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/un-pany/v3-electron-vite/8cf3b2d9f2351a2e9c455b8bc285633a4ee125a9/src/assets/login/face.png -------------------------------------------------------------------------------- /src/assets/login/hand-down-left.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/un-pany/v3-electron-vite/8cf3b2d9f2351a2e9c455b8bc285633a4ee125a9/src/assets/login/hand-down-left.png -------------------------------------------------------------------------------- /src/assets/login/hand-down-right.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/un-pany/v3-electron-vite/8cf3b2d9f2351a2e9c455b8bc285633a4ee125a9/src/assets/login/hand-down-right.png -------------------------------------------------------------------------------- /src/assets/login/hand-up-left.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/un-pany/v3-electron-vite/8cf3b2d9f2351a2e9c455b8bc285633a4ee125a9/src/assets/login/hand-up-left.png -------------------------------------------------------------------------------- /src/assets/login/hand-up-right.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/un-pany/v3-electron-vite/8cf3b2d9f2351a2e9c455b8bc285633a4ee125a9/src/assets/login/hand-up-right.png -------------------------------------------------------------------------------- /src/components/Notify/NotifyList.vue: -------------------------------------------------------------------------------- 1 | <script lang="ts" setup> 2 | import { type ListItem } from "./data" 3 | 4 | interface Props { 5 | list: ListItem[] 6 | } 7 | 8 | const props = defineProps<Props>() 9 | </script> 10 | 11 | <template> 12 | <el-empty v-if="props.list.length === 0" /> 13 | <el-card v-else v-for="(item, index) in props.list" :key="index" shadow="never" class="card-container"> 14 | <template #header> 15 | <div class="card-header"> 16 | <div> 17 | <span> 18 | <span class="card-title">{{ item.title }}</span> 19 | <el-tag v-if="item.extra" :type="item.status" effect="plain" size="small">{{ item.extra }}</el-tag> 20 | </span> 21 | <div class="card-time">{{ item.datetime }}</div> 22 | </div> 23 | <div v-if="item.avatar" class="card-avatar"> 24 | <img :src="item.avatar" width="34" /> 25 | </div> 26 | </div> 27 | </template> 28 | <div class="card-body"> 29 | {{ item.description ?? "No Data" }} 30 | </div> 31 | </el-card> 32 | </template> 33 | 34 | <style lang="scss" scoped> 35 | .card-container { 36 | margin-bottom: 10px; 37 | .card-header { 38 | display: flex; 39 | justify-content: space-between; 40 | align-items: center; 41 | .card-title { 42 | font-weight: bold; 43 | margin-right: 10px; 44 | } 45 | .card-time { 46 | font-size: 12px; 47 | color: var(--el-text-color-secondary); 48 | } 49 | .card-avatar { 50 | display: flex; 51 | align-items: center; 52 | } 53 | } 54 | .card-body { 55 | font-size: 12px; 56 | } 57 | } 58 | </style> 59 | -------------------------------------------------------------------------------- /src/components/Notify/data.ts: -------------------------------------------------------------------------------- 1 | export interface ListItem { 2 | avatar?: string 3 | title: string 4 | datetime?: string 5 | description?: string 6 | status?: "primary" | "success" | "info" | "warning" | "danger" 7 | extra?: string 8 | } 9 | 10 | export const notifyData: ListItem[] = [ 11 | { 12 | avatar: "https://gw.alipayobjects.com/zos/rmsportal/OKJXDXrmkNshAMvwtvhu.png", 13 | title: "V3 Admin Vite 上线啦", 14 | datetime: "一年前", 15 | description: 16 | "一个免费开源的中后台管理系统基础解决方案,基于 Vue3、TypeScript、Element Plus、Pinia 和 Vite 等主流技术" 17 | }, 18 | { 19 | avatar: "https://gw.alipayobjects.com/zos/rmsportal/OKJXDXrmkNshAMvwtvhu.png", 20 | title: "V3 Admin 上线啦", 21 | datetime: "两年前", 22 | description: "一个中后台管理系统基础解决方案,基于 Vue3、TypeScript、Element Plus 和 Pinia" 23 | } 24 | ] 25 | 26 | export const messageData: ListItem[] = [ 27 | { 28 | avatar: "https://gw.alipayobjects.com/zos/rmsportal/ThXAXghbEsBCCSDihZxY.png", 29 | title: "来自楚门的世界", 30 | description: "如果再也不能见到你,祝你早安、午安和晚安", 31 | datetime: "1998-06-05" 32 | }, 33 | { 34 | avatar: "https://gw.alipayobjects.com/zos/rmsportal/ThXAXghbEsBCCSDihZxY.png", 35 | title: "来自大话西游", 36 | description: "如果非要在这份爱上加上一个期限,我希望是一万年", 37 | datetime: "1995-02-04" 38 | }, 39 | { 40 | avatar: "https://gw.alipayobjects.com/zos/rmsportal/ThXAXghbEsBCCSDihZxY.png", 41 | title: "来自龙猫", 42 | description: "心存善意,定能途遇天使", 43 | datetime: "1988-04-16" 44 | } 45 | ] 46 | 47 | export const todoData: ListItem[] = [ 48 | { 49 | title: "任务名称", 50 | description: "这家伙很懒,什么都没留下", 51 | extra: "未开始", 52 | status: "info" 53 | }, 54 | { 55 | title: "任务名称", 56 | description: "这家伙很懒,什么都没留下", 57 | extra: "进行中", 58 | status: "primary" 59 | }, 60 | { 61 | title: "任务名称", 62 | description: "这家伙很懒,什么都没留下", 63 | extra: "已超时", 64 | status: "danger" 65 | } 66 | ] 67 | -------------------------------------------------------------------------------- /src/components/Notify/index.vue: -------------------------------------------------------------------------------- 1 | <script lang="ts" setup> 2 | import { ref, computed } from "vue" 3 | import { ElMessage } from "element-plus" 4 | import { Bell } from "@element-plus/icons-vue" 5 | import NotifyList from "./NotifyList.vue" 6 | import { type ListItem, notifyData, messageData, todoData } from "./data" 7 | 8 | type TabName = "通知" | "消息" | "待办" 9 | 10 | interface DataItem { 11 | name: TabName 12 | type: "primary" | "success" | "warning" | "danger" | "info" 13 | list: ListItem[] 14 | } 15 | 16 | /** 角标当前值 */ 17 | const badgeValue = computed(() => { 18 | return data.value.reduce((sum, item) => sum + item.list.length, 0) 19 | }) 20 | /** 角标最大值 */ 21 | const badgeMax = 99 22 | /** 面板宽度 */ 23 | const popoverWidth = 350 24 | /** 当前 Tab */ 25 | const activeName = ref<TabName>("通知") 26 | /** 所有数据 */ 27 | const data = ref<DataItem[]>([ 28 | // 通知数据 29 | { 30 | name: "通知", 31 | type: "primary", 32 | list: notifyData 33 | }, 34 | // 消息数据 35 | { 36 | name: "消息", 37 | type: "danger", 38 | list: messageData 39 | }, 40 | // 待办数据 41 | { 42 | name: "待办", 43 | type: "warning", 44 | list: todoData 45 | } 46 | ]) 47 | 48 | const handleHistory = () => { 49 | ElMessage.success(`跳转到${activeName.value}历史页面`) 50 | } 51 | </script> 52 | 53 | <template> 54 | <div class="notify"> 55 | <el-popover placement="bottom" :width="popoverWidth" trigger="click"> 56 | <template #reference> 57 | <el-badge :value="badgeValue" :max="badgeMax" :hidden="badgeValue === 0"> 58 | <el-tooltip effect="dark" content="消息通知" placement="bottom"> 59 | <el-icon :size="20"> 60 | <Bell /> 61 | </el-icon> 62 | </el-tooltip> 63 | </el-badge> 64 | </template> 65 | <template #default> 66 | <el-tabs v-model="activeName" class="demo-tabs" stretch> 67 | <el-tab-pane v-for="(item, index) in data" :name="item.name" :key="index"> 68 | <template #label> 69 | {{ item.name }} 70 | <el-badge :value="item.list.length" :max="badgeMax" :type="item.type" /> 71 | </template> 72 | <el-scrollbar height="400px"> 73 | <NotifyList :list="item.list" /> 74 | </el-scrollbar> 75 | </el-tab-pane> 76 | </el-tabs> 77 | <div class="notify-history"> 78 | <el-button link @click="handleHistory">查看{{ activeName }}历史</el-button> 79 | </div> 80 | </template> 81 | </el-popover> 82 | </div> 83 | </template> 84 | 85 | <style lang="scss" scoped> 86 | .notify { 87 | margin-right: 10px; 88 | } 89 | .notify-history { 90 | text-align: center; 91 | padding-top: 12px; 92 | border-top: 1px solid var(--el-border-color); 93 | } 94 | </style> 95 | -------------------------------------------------------------------------------- /src/components/Screenfull/index.vue: -------------------------------------------------------------------------------- 1 | <script lang="ts" setup> 2 | import { computed, ref, watchEffect } from "vue" 3 | import { ElMessage } from "element-plus" 4 | import screenfull from "screenfull" 5 | 6 | interface Props { 7 | /** 全屏的元素,默认是 html */ 8 | element?: string 9 | /** 打开全屏提示语 */ 10 | openTips?: string 11 | /** 关闭全屏提示语 */ 12 | exitTips?: string 13 | /** 是否只针对内容区 */ 14 | content?: boolean 15 | } 16 | 17 | const props = withDefaults(defineProps<Props>(), { 18 | element: "html", 19 | openTips: "全屏", 20 | exitTips: "退出全屏", 21 | content: false 22 | }) 23 | 24 | const CONTENT_LARGE = "content-large" 25 | const CONTENT_FULL = "content-full" 26 | const classList = document.body.classList 27 | 28 | //#region 全屏 29 | const isEnabled = screenfull.isEnabled 30 | const isFullscreen = ref<boolean>(false) 31 | const fullscreenTips = computed(() => (isFullscreen.value ? props.exitTips : props.openTips)) 32 | const fullscreenSvgName = computed(() => (isFullscreen.value ? "fullscreen-exit" : "fullscreen")) 33 | 34 | const handleFullscreenClick = () => { 35 | const dom = document.querySelector(props.element) || undefined 36 | isEnabled ? screenfull.toggle(dom) : ElMessage.warning("您的浏览器无法工作") 37 | } 38 | const handleFullscreenChange = () => { 39 | isFullscreen.value = screenfull.isFullscreen 40 | // 退出全屏时清除相关的 class 41 | isFullscreen.value || classList.remove(CONTENT_LARGE, CONTENT_FULL) 42 | } 43 | watchEffect((onCleanup) => { 44 | if (isEnabled) { 45 | // 挂载组件时自动执行 46 | screenfull.on("change", handleFullscreenChange) 47 | // 卸载组件时自动执行 48 | onCleanup(() => screenfull.off("change", handleFullscreenChange)) 49 | } 50 | }) 51 | //#endregion 52 | 53 | //#region 内容区 54 | const isContentLarge = ref<boolean>(false) 55 | const contentLargeTips = computed(() => (isContentLarge.value ? "内容区复原" : "内容区放大")) 56 | const contentLargeSvgName = computed(() => (isContentLarge.value ? "fullscreen-exit" : "fullscreen")) 57 | const handleContentLargeClick = () => { 58 | isContentLarge.value = !isContentLarge.value 59 | // 内容区放大时,将不需要的组件隐藏 60 | classList.toggle(CONTENT_LARGE, isContentLarge.value) 61 | } 62 | const handleContentFullClick = () => { 63 | // 取消内容区放大 64 | isContentLarge.value && handleContentLargeClick() 65 | // 内容区全屏时,将不需要的组件隐藏 66 | classList.add(CONTENT_FULL) 67 | // 开启全屏 68 | handleFullscreenClick() 69 | } 70 | //#endregion 71 | </script> 72 | 73 | <template> 74 | <div> 75 | <!-- 全屏 --> 76 | <el-tooltip v-if="!content" effect="dark" :content="fullscreenTips" placement="bottom"> 77 | <SvgIcon :name="fullscreenSvgName" @click="handleFullscreenClick" /> 78 | </el-tooltip> 79 | <!-- 内容区 --> 80 | <el-dropdown v-else :disabled="isFullscreen"> 81 | <SvgIcon :name="contentLargeSvgName" /> 82 | <template #dropdown> 83 | <el-dropdown-menu> 84 | <!-- 内容区放大 --> 85 | <el-dropdown-item @click="handleContentLargeClick">{{ contentLargeTips }}</el-dropdown-item> 86 | <!-- 内容区全屏 --> 87 | <el-dropdown-item @click="handleContentFullClick">内容区全屏</el-dropdown-item> 88 | </el-dropdown-menu> 89 | </template> 90 | </el-dropdown> 91 | </div> 92 | </template> 93 | 94 | <style lang="scss" scoped> 95 | .svg-icon { 96 | font-size: 20px; 97 | &:focus { 98 | outline: none; 99 | } 100 | } 101 | </style> 102 | -------------------------------------------------------------------------------- /src/components/SearchMenu/SearchFooter.vue: -------------------------------------------------------------------------------- 1 | <script lang="ts" setup> 2 | import { useDevice } from "@/hooks/useDevice" 3 | 4 | interface Props { 5 | total: number 6 | } 7 | 8 | const props = defineProps<Props>() 9 | 10 | const { isMobile } = useDevice() 11 | </script> 12 | 13 | <template> 14 | <div class="search-footer"> 15 | <template v-if="!isMobile"> 16 | <span class="search-footer-item"> 17 | <SvgIcon name="keyboard-enter" /> 18 | <span>确认</span> 19 | </span> 20 | <span class="search-footer-item"> 21 | <SvgIcon name="keyboard-up" /> 22 | <SvgIcon name="keyboard-down" /> 23 | <span>切换</span> 24 | </span> 25 | <span class="search-footer-item"> 26 | <SvgIcon name="keyboard-esc" /> 27 | <span>关闭</span> 28 | </span> 29 | </template> 30 | <span class="search-footer-total">共 {{ props.total }} 项</span> 31 | </div> 32 | </template> 33 | 34 | <style lang="scss" scoped> 35 | .search-footer { 36 | display: flex; 37 | color: var(--el-text-color-secondary); 38 | font-size: 14px; 39 | &-item { 40 | display: flex; 41 | align-items: center; 42 | margin-right: 12px; 43 | .svg-icon { 44 | margin-right: 5px; 45 | padding: 2px; 46 | font-size: 20px; 47 | background-color: var(--el-fill-color); 48 | } 49 | } 50 | &-total { 51 | margin: 0 0 0 auto; 52 | } 53 | } 54 | </style> 55 | -------------------------------------------------------------------------------- /src/components/SearchMenu/SearchResult.vue: -------------------------------------------------------------------------------- 1 | <script lang="ts" setup> 2 | import { getCurrentInstance, onBeforeMount, onBeforeUnmount, onMounted, ref } from "vue" 3 | import { type RouteRecordName, type RouteRecordRaw } from "vue-router" 4 | 5 | interface Props { 6 | list: RouteRecordRaw[] 7 | isPressUpOrDown: boolean 8 | } 9 | 10 | /** 选中的菜单 */ 11 | const modelValue = defineModel<RouteRecordName | undefined>({ required: true }) 12 | const props = defineProps<Props>() 13 | 14 | const instance = getCurrentInstance() 15 | const scrollbarHeight = ref<number>(0) 16 | 17 | /** 菜单的样式 */ 18 | const itemStyle = (item: RouteRecordRaw) => { 19 | const flag = item.name === modelValue.value 20 | return { 21 | background: flag ? "var(--el-color-primary)" : "", 22 | color: flag ? "#ffffff" : "" 23 | } 24 | } 25 | 26 | /** 鼠标移入 */ 27 | const handleMouseenter = (item: RouteRecordRaw) => { 28 | // 如果上键或下键与 mouseenter 事件同时生效,则以上下键为准,不执行该函数的赋值逻辑 29 | if (props.isPressUpOrDown) return 30 | modelValue.value = item.name 31 | } 32 | 33 | /** 计算滚动可视区高度 */ 34 | const getScrollbarHeight = () => { 35 | // el-scrollbar max-height="40vh" 36 | scrollbarHeight.value = Number((window.innerHeight * 0.4).toFixed(1)) 37 | } 38 | 39 | /** 根据下标计算到顶部的距离 */ 40 | const getScrollTop = (index: number) => { 41 | const currentInstance = instance?.proxy?.$refs[`resultItemRef${index}`] as HTMLDivElement[] 42 | if (!currentInstance) return 0 43 | const currentRef = currentInstance[0] 44 | const scrollTop = currentRef.offsetTop + 128 // 128 = 两个 result-item (56 + 56 = 112)高度与上下 margin(8 + 8 = 16)大小之和 45 | return scrollTop > scrollbarHeight.value ? scrollTop - scrollbarHeight.value : 0 46 | } 47 | 48 | /** 在组件挂载前添加窗口大小变化事件监听器 */ 49 | onBeforeMount(() => { 50 | window.addEventListener("resize", getScrollbarHeight) 51 | }) 52 | 53 | /** 在组件挂载时立即计算滚动可视区高度 */ 54 | onMounted(() => { 55 | getScrollbarHeight() 56 | }) 57 | 58 | /** 在组件卸载前移除窗口大小变化事件监听器 */ 59 | onBeforeUnmount(() => { 60 | window.removeEventListener("resize", getScrollbarHeight) 61 | }) 62 | 63 | defineExpose({ getScrollTop }) 64 | </script> 65 | 66 | <template> 67 | <!-- 外层 div 不能删除,是用来接收父组件 click 事件的 --> 68 | <div> 69 | <div 70 | v-for="(item, index) in list" 71 | :key="index" 72 | :ref="`resultItemRef${index}`" 73 | class="result-item" 74 | :style="itemStyle(item)" 75 | @mouseenter="handleMouseenter(item)" 76 | > 77 | <SvgIcon v-if="item.meta?.svgIcon" :name="item.meta.svgIcon" /> 78 | <component v-else-if="item.meta?.elIcon" :is="item.meta.elIcon" class="el-icon" /> 79 | <span class="result-item-title"> 80 | {{ item.meta?.title }} 81 | </span> 82 | <SvgIcon v-if="modelValue && modelValue === item.name" name="keyboard-enter" /> 83 | </div> 84 | </div> 85 | </template> 86 | 87 | <style lang="scss" scoped> 88 | @import "@/styles/mixins.scss"; 89 | 90 | .result-item { 91 | display: flex; 92 | align-items: center; 93 | height: 56px; 94 | padding: 0 15px; 95 | margin-bottom: 8px; 96 | border: 1px solid var(--el-border-color); 97 | border-radius: 4px; 98 | cursor: pointer; 99 | .svg-icon { 100 | min-width: 1em; 101 | font-size: 18px; 102 | } 103 | .el-icon { 104 | width: 1em; 105 | font-size: 18px; 106 | } 107 | &-title { 108 | flex: 1; 109 | margin-left: 12px; 110 | @extend %ellipsis; 111 | } 112 | } 113 | </style> 114 | -------------------------------------------------------------------------------- /src/components/SearchMenu/index.vue: -------------------------------------------------------------------------------- 1 | <script lang="ts" setup> 2 | import { ref } from "vue" 3 | import SearchModal from "./SearchModal.vue" 4 | 5 | /** 控制 modal 显隐 */ 6 | const modalVisible = ref<boolean>(false) 7 | /** 打开 modal */ 8 | const handleOpen = () => { 9 | modalVisible.value = true 10 | } 11 | </script> 12 | 13 | <template> 14 | <div> 15 | <el-tooltip effect="dark" content="搜索菜单" placement="bottom"> 16 | <SvgIcon name="search" @click="handleOpen" /> 17 | </el-tooltip> 18 | <SearchModal v-model="modalVisible" /> 19 | </div> 20 | </template> 21 | 22 | <style lang="scss" scoped> 23 | .svg-icon { 24 | font-size: 20px; 25 | &:focus { 26 | outline: none; 27 | } 28 | } 29 | </style> 30 | -------------------------------------------------------------------------------- /src/components/SvgIcon/index.vue: -------------------------------------------------------------------------------- 1 | <script lang="ts" setup> 2 | import { computed } from "vue" 3 | 4 | interface Props { 5 | prefix?: string 6 | name: string 7 | } 8 | 9 | const props = withDefaults(defineProps<Props>(), { 10 | prefix: "icon" 11 | }) 12 | 13 | const symbolId = computed(() => `#${props.prefix}-${props.name}`) 14 | </script> 15 | 16 | <template> 17 | <svg class="svg-icon"> 18 | <use :href="symbolId" /> 19 | </svg> 20 | </template> 21 | 22 | <style lang="scss" scoped> 23 | .svg-icon { 24 | width: 1em; 25 | height: 1em; 26 | fill: currentColor; 27 | overflow: hidden; 28 | } 29 | </style> 30 | -------------------------------------------------------------------------------- /src/components/ThemeSwitch/index.vue: -------------------------------------------------------------------------------- 1 | <script lang="ts" setup> 2 | import { type ThemeName, useTheme } from "@/hooks/useTheme" 3 | import { MagicStick } from "@element-plus/icons-vue" 4 | 5 | const { themeList, activeThemeName, setTheme } = useTheme() 6 | 7 | const handleChangeTheme = ({ clientX, clientY }: MouseEvent, themeName: ThemeName) => { 8 | const maxRadius = Math.hypot( 9 | Math.max(clientX, window.innerWidth - clientX), 10 | Math.max(clientY, window.innerHeight - clientY) 11 | ) 12 | const style = document.documentElement.style 13 | style.setProperty("--v3-theme-x", clientX + "px") 14 | style.setProperty("--v3-theme-y", clientY + "px") 15 | style.setProperty("--v3-theme-r", maxRadius + "px") 16 | const handler = () => { 17 | setTheme(themeName) 18 | } 19 | document.startViewTransition ? document.startViewTransition(handler) : handler() 20 | } 21 | </script> 22 | 23 | <template> 24 | <el-dropdown trigger="click"> 25 | <div> 26 | <el-tooltip effect="dark" content="主题模式" placement="bottom"> 27 | <el-icon :size="20"> 28 | <MagicStick /> 29 | </el-icon> 30 | </el-tooltip> 31 | </div> 32 | <template #dropdown> 33 | <el-dropdown-menu> 34 | <el-dropdown-item 35 | v-for="(theme, index) in themeList" 36 | :key="index" 37 | :disabled="activeThemeName === theme.name" 38 | @click="(e: MouseEvent) => handleChangeTheme(e, theme.name)" 39 | > 40 | <span>{{ theme.title }}</span> 41 | </el-dropdown-item> 42 | </el-dropdown-menu> 43 | </template> 44 | </el-dropdown> 45 | </template> 46 | -------------------------------------------------------------------------------- /src/config/layouts.ts: -------------------------------------------------------------------------------- 1 | import { getConfigLayout } from "@/utils/cache/local-storage" 2 | import { LayoutModeEnum } from "@/constants/app-key" 3 | 4 | /** 项目配置类型 */ 5 | export interface LayoutSettings { 6 | /** 是否显示 Settings Panel */ 7 | showSettings: boolean 8 | /** 布局模式 */ 9 | layoutMode: LayoutModeEnum 10 | /** 是否显示标签栏 */ 11 | showTagsView: boolean 12 | /** 是否显示 Logo */ 13 | showLogo: boolean 14 | /** 是否固定 Header */ 15 | fixedHeader: boolean 16 | /** 是否显示页脚 Footer */ 17 | showFooter: boolean 18 | /** 是否显示消息通知 */ 19 | showNotify: boolean 20 | /** 是否显示切换主题按钮 */ 21 | showThemeSwitch: boolean 22 | /** 是否显示全屏按钮 */ 23 | showScreenfull: boolean 24 | /** 是否显示搜索按钮 */ 25 | showSearchMenu: boolean 26 | /** 是否缓存标签栏 */ 27 | cacheTagsView: boolean 28 | /** 开启系统水印 */ 29 | showWatermark: boolean 30 | /** 是否显示灰色模式 */ 31 | showGreyMode: boolean 32 | /** 是否显示色弱模式 */ 33 | showColorWeakness: boolean 34 | } 35 | 36 | /** 默认配置 */ 37 | const defaultSettings: LayoutSettings = { 38 | layoutMode: LayoutModeEnum.Left, 39 | showSettings: true, 40 | showTagsView: true, 41 | fixedHeader: true, 42 | showFooter: true, 43 | showLogo: true, 44 | showNotify: true, 45 | showThemeSwitch: true, 46 | showScreenfull: true, 47 | showSearchMenu: true, 48 | cacheTagsView: false, 49 | showWatermark: true, 50 | showGreyMode: false, 51 | showColorWeakness: false 52 | } 53 | 54 | /** 项目配置 */ 55 | export const layoutSettings: LayoutSettings = { ...defaultSettings, ...getConfigLayout() } 56 | -------------------------------------------------------------------------------- /src/config/route.ts: -------------------------------------------------------------------------------- 1 | /** 路由配置 */ 2 | interface RouteSettings { 3 | /** 4 | * 是否开启动态路由功能? 5 | * 1. 开启后需要后端配合,在查询用户详情接口返回当前用户可以用来判断并加载动态路由的字段(该项目用的是角色 roles 字段) 6 | * 2. 假如项目不需要根据不同的用户来显示不同的页面,则应该将 dynamic: false 7 | */ 8 | dynamic: boolean 9 | /** 当动态路由功能关闭时: 10 | * 1. 应该将所有路由都写到常驻路由里面(表明所有登录的用户能访问的页面都是一样的) 11 | * 2. 系统自动给当前登录用户赋值一个没有任何作用的默认角色 12 | */ 13 | defaultRoles: Array<string> 14 | /** 15 | * 是否开启三级及其以上路由缓存功能? 16 | * 1. 开启后会进行路由降级(把三级及其以上的路由转化为二级路由) 17 | * 2. 由于都会转成二级路由,所以二级及其以上路由有内嵌子路由将会失效 18 | */ 19 | thirdLevelRouteCache: boolean 20 | } 21 | 22 | const routeSettings: RouteSettings = { 23 | dynamic: true, 24 | defaultRoles: ["DEFAULT_ROLE"], 25 | thirdLevelRouteCache: false 26 | } 27 | 28 | export default routeSettings 29 | -------------------------------------------------------------------------------- /src/config/white-list.ts: -------------------------------------------------------------------------------- 1 | import { type RouteLocationNormalized, type RouteRecordNameGeneric } from "vue-router" 2 | 3 | /** 免登录白名单(匹配路由 path) */ 4 | const whiteListByPath: string[] = ["/login"] 5 | 6 | /** 免登录白名单(匹配路由 name) */ 7 | const whiteListByName: RouteRecordNameGeneric[] = [] 8 | 9 | /** 判断是否在白名单 */ 10 | const isWhiteList = (to: RouteLocationNormalized) => { 11 | // path 和 name 任意一个匹配上即可 12 | return whiteListByPath.indexOf(to.path) !== -1 || whiteListByName.indexOf(to.name) !== -1 13 | } 14 | 15 | export default isWhiteList 16 | -------------------------------------------------------------------------------- /src/constants/app-key.ts: -------------------------------------------------------------------------------- 1 | /** 设备类型 */ 2 | export enum DeviceEnum { 3 | Mobile, 4 | Desktop 5 | } 6 | 7 | /** 布局模式 */ 8 | export enum LayoutModeEnum { 9 | Left = "left", 10 | Top = "top", 11 | LeftTop = "left-top" 12 | } 13 | 14 | /** 侧边栏打开状态常量 */ 15 | export const SIDEBAR_OPENED = "opened" 16 | /** 侧边栏关闭状态常量 */ 17 | export const SIDEBAR_CLOSED = "closed" 18 | 19 | export type SidebarOpened = typeof SIDEBAR_OPENED 20 | export type SidebarClosed = typeof SIDEBAR_CLOSED 21 | -------------------------------------------------------------------------------- /src/constants/cache-key.ts: -------------------------------------------------------------------------------- 1 | const SYSTEM_NAME = "v3-admin-vite" 2 | 3 | /** 缓存数据时用到的 Key */ 4 | class CacheKey { 5 | static readonly TOKEN = `${SYSTEM_NAME}-token-key` 6 | static readonly CONFIG_LAYOUT = `${SYSTEM_NAME}-config-layout-key` 7 | static readonly SIDEBAR_STATUS = `${SYSTEM_NAME}-sidebar-status-key` 8 | static readonly ACTIVE_THEME_NAME = `${SYSTEM_NAME}-active-theme-name-key` 9 | static readonly VISITED_VIEWS = `${SYSTEM_NAME}-visited-views-key` 10 | static readonly CACHED_VIEWS = `${SYSTEM_NAME}-cached-views-key` 11 | } 12 | 13 | export default CacheKey 14 | -------------------------------------------------------------------------------- /src/constants/ipc-dict.ts: -------------------------------------------------------------------------------- 1 | export default class IpcDict { 2 | static readonly CODE_01001 = `设置窗口默认尺寸` 3 | static readonly CODE_01002 = `重启应用程序` 4 | 5 | static readonly CODE_02001 = `记录本地日志` 6 | static readonly CODE_02002 = `vue中央事件总线` 7 | } 8 | -------------------------------------------------------------------------------- /src/directives/index.ts: -------------------------------------------------------------------------------- 1 | import { type App } from "vue" 2 | import { permission } from "./permission" 3 | 4 | /** 挂载自定义指令 */ 5 | export function loadDirectives(app: App) { 6 | app.directive("permission", permission) 7 | } 8 | -------------------------------------------------------------------------------- /src/directives/permission/index.ts: -------------------------------------------------------------------------------- 1 | import { type Directive } from "vue" 2 | import { useUserStore } from "@/store/modules/user" 3 | 4 | /** 权限指令,和权限判断函数 checkPermission 功能类似 */ 5 | export const permission: Directive = { 6 | mounted(el, binding) { 7 | const { value: permissionRoles } = binding 8 | const { roles } = useUserStore() 9 | if (Array.isArray(permissionRoles) && permissionRoles.length > 0) { 10 | const hasPermission = roles.some((role) => permissionRoles.includes(role)) 11 | // hasPermission || (el.style.display = "none") // 隐藏 12 | hasPermission || el.parentNode?.removeChild(el) // 销毁 13 | } else { 14 | throw new Error(`need roles! Like v-permission="['admin','editor']"`) 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/hooks/useDevice.ts: -------------------------------------------------------------------------------- 1 | import { computed } from "vue" 2 | import { useAppStore } from "@/store/modules/app" 3 | import { DeviceEnum } from "@/constants/app-key" 4 | 5 | const appStore = useAppStore() 6 | const isMobile = computed(() => appStore.device === DeviceEnum.Mobile) 7 | const isDesktop = computed(() => appStore.device === DeviceEnum.Desktop) 8 | 9 | export function useDevice() { 10 | return { isMobile, isDesktop } 11 | } 12 | -------------------------------------------------------------------------------- /src/hooks/useFetchSelect.ts: -------------------------------------------------------------------------------- 1 | import { ref, onMounted } from "vue" 2 | 3 | type OptionValue = string | number 4 | 5 | /** Select 需要的数据格式 */ 6 | interface SelectOption { 7 | value: OptionValue 8 | label: string 9 | disabled?: boolean 10 | } 11 | 12 | /** 接口响应格式 */ 13 | type ApiData = ApiResponseData<SelectOption[]> 14 | 15 | /** 入参格式,暂时只需要传递 api 函数即可 */ 16 | interface FetchSelectProps { 17 | api: () => Promise<ApiData> 18 | } 19 | 20 | export function useFetchSelect(props: FetchSelectProps) { 21 | const { api } = props 22 | 23 | const loading = ref<boolean>(false) 24 | const options = ref<SelectOption[]>([]) 25 | const value = ref<OptionValue>("") 26 | 27 | /** 调用接口获取数据 */ 28 | const loadData = () => { 29 | loading.value = true 30 | options.value = [] 31 | api() 32 | .then((res) => { 33 | options.value = res.data 34 | }) 35 | .finally(() => { 36 | loading.value = false 37 | }) 38 | } 39 | 40 | onMounted(() => { 41 | loadData() 42 | }) 43 | 44 | return { 45 | loading, 46 | options, 47 | value 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/hooks/useFullscreenLoading.ts: -------------------------------------------------------------------------------- 1 | import { type LoadingOptions, ElLoading } from "element-plus" 2 | 3 | const defaultOptions = { 4 | lock: true, 5 | text: "加载中..." 6 | } 7 | 8 | interface LoadingInstance { 9 | close: () => void 10 | } 11 | 12 | interface UseFullscreenLoading { 13 | <T extends (...args: Parameters<T>) => ReturnType<T>>( 14 | fn: T, 15 | options?: LoadingOptions 16 | ): (...args: Parameters<T>) => Promise<ReturnType<T>> 17 | } 18 | 19 | /** 20 | * 传入一个函数 fn,在它执行周期内,加上「全屏」loading 21 | * @param fn 要执行的函数 22 | * @param options LoadingOptions 23 | * @returns 返回一个新的函数,该函数返回一个 Promise 24 | */ 25 | export const useFullscreenLoading: UseFullscreenLoading = (fn, options = {}) => { 26 | let loadingInstance: LoadingInstance 27 | return async (...args) => { 28 | try { 29 | loadingInstance = ElLoading.service({ ...defaultOptions, ...options }) 30 | return await fn(...args) 31 | } finally { 32 | loadingInstance?.close() 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/hooks/useGreyAndColorWeakness.ts: -------------------------------------------------------------------------------- 1 | import { watchEffect } from "vue" 2 | import { useSettingsStore } from "@/store/modules/settings" 3 | 4 | const GREY_MODE = "grey-mode" 5 | const COLOR_WEAKNESS = "color-weakness" 6 | const classList = document.documentElement.classList 7 | 8 | /** 初始化 */ 9 | const initGreyAndColorWeakness = () => { 10 | const settingsStore = useSettingsStore() 11 | watchEffect(() => { 12 | classList.toggle(GREY_MODE, settingsStore.showGreyMode) 13 | classList.toggle(COLOR_WEAKNESS, settingsStore.showColorWeakness) 14 | }) 15 | } 16 | 17 | /** 灰色模式和色弱模式 hook */ 18 | export function useGreyAndColorWeakness() { 19 | return { initGreyAndColorWeakness } 20 | } 21 | -------------------------------------------------------------------------------- /src/hooks/useLayoutMode.ts: -------------------------------------------------------------------------------- 1 | import { computed } from "vue" 2 | import { useSettingsStore } from "@/store/modules/settings" 3 | import { LayoutModeEnum } from "@/constants/app-key" 4 | 5 | const settingsStore = useSettingsStore() 6 | const isLeft = computed(() => settingsStore.layoutMode === LayoutModeEnum.Left) 7 | const isTop = computed(() => settingsStore.layoutMode === LayoutModeEnum.Top) 8 | const isLeftTop = computed(() => settingsStore.layoutMode === LayoutModeEnum.LeftTop) 9 | 10 | const setLayoutMode = (mode: LayoutModeEnum) => { 11 | settingsStore.layoutMode = mode 12 | } 13 | 14 | export function useLayoutMode() { 15 | return { isLeft, isTop, isLeftTop, setLayoutMode } 16 | } 17 | -------------------------------------------------------------------------------- /src/hooks/usePagination.ts: -------------------------------------------------------------------------------- 1 | import { reactive } from "vue" 2 | 3 | interface DefaultPaginationData { 4 | total: number 5 | currentPage: number 6 | pageSizes: number[] 7 | pageSize: number 8 | layout: string 9 | } 10 | 11 | interface PaginationData { 12 | total?: number 13 | currentPage?: number 14 | pageSizes?: number[] 15 | pageSize?: number 16 | layout?: string 17 | } 18 | 19 | /** 默认的分页参数 */ 20 | const defaultPaginationData: DefaultPaginationData = { 21 | total: 0, 22 | currentPage: 1, 23 | pageSizes: [10, 20, 50], 24 | pageSize: 10, 25 | layout: "total, sizes, prev, pager, next, jumper" 26 | } 27 | 28 | export function usePagination(initialPaginationData: PaginationData = {}) { 29 | /** 合并分页参数 */ 30 | const paginationData = reactive({ ...defaultPaginationData, ...initialPaginationData }) 31 | /** 改变当前页码 */ 32 | const handleCurrentChange = (value: number) => { 33 | paginationData.currentPage = value 34 | } 35 | /** 改变页面大小 */ 36 | const handleSizeChange = (value: number) => { 37 | paginationData.pageSize = value 38 | } 39 | 40 | return { paginationData, handleCurrentChange, handleSizeChange } 41 | } 42 | -------------------------------------------------------------------------------- /src/hooks/useRouteListener.ts: -------------------------------------------------------------------------------- 1 | import { onBeforeUnmount } from "vue" 2 | import mitt, { type Handler } from "mitt" 3 | import { type RouteLocationNormalized } from "vue-router" 4 | 5 | /** 回调函数的类型 */ 6 | type Callback = (route: RouteLocationNormalized) => void 7 | 8 | const emitter = mitt() 9 | const key = Symbol("ROUTE_CHANGE") 10 | let latestRoute: RouteLocationNormalized 11 | 12 | /** 设置最新的路由信息,触发路由变化事件 */ 13 | export const setRouteChange = (to: RouteLocationNormalized) => { 14 | // 触发事件 15 | emitter.emit(key, to) 16 | // 缓存最新的路由信息 17 | latestRoute = to 18 | } 19 | 20 | /** 单独监听路由会浪费渲染性能,使用发布订阅模式去进行分发管理 */ 21 | export function useRouteListener() { 22 | /** 回调函数集合 */ 23 | const callbackList: Callback[] = [] 24 | 25 | /** 监听路由变化(可以选择立即执行) */ 26 | const listenerRouteChange = (callback: Callback, immediate = false) => { 27 | // 缓存回调函数 28 | callbackList.push(callback) 29 | // 监听事件 30 | emitter.on(key, callback as Handler) 31 | // 可以选择立即执行一次回调函数 32 | immediate && latestRoute && callback(latestRoute) 33 | } 34 | 35 | /** 移除路由变化事件监听器 */ 36 | const removeRouteListener = (callback: Callback) => { 37 | emitter.off(key, callback as Handler) 38 | } 39 | 40 | /** 组件销毁前移除监听器 */ 41 | onBeforeUnmount(() => { 42 | for (let i = 0; i < callbackList.length; i++) { 43 | removeRouteListener(callbackList[i]) 44 | } 45 | }) 46 | 47 | return { listenerRouteChange, removeRouteListener } 48 | } 49 | -------------------------------------------------------------------------------- /src/hooks/useTheme.ts: -------------------------------------------------------------------------------- 1 | import { ref, watchEffect } from "vue" 2 | import { getActiveThemeName, setActiveThemeName } from "@/utils/cache/local-storage" 3 | 4 | const DEFAULT_THEME_NAME = "normal" 5 | type DefaultThemeName = typeof DEFAULT_THEME_NAME 6 | 7 | /** 注册的主题名称, 其中 DefaultThemeName 是必填的 */ 8 | export type ThemeName = DefaultThemeName | "dark" | "dark-blue" 9 | 10 | interface ThemeList { 11 | title: string 12 | name: ThemeName 13 | } 14 | 15 | /** 主题列表 */ 16 | const themeList: ThemeList[] = [ 17 | { 18 | title: "默认", 19 | name: DEFAULT_THEME_NAME 20 | }, 21 | { 22 | title: "黑暗", 23 | name: "dark" 24 | }, 25 | { 26 | title: "深蓝", 27 | name: "dark-blue" 28 | } 29 | ] 30 | 31 | /** 正在应用的主题名称 */ 32 | const activeThemeName = ref<ThemeName>(getActiveThemeName() || DEFAULT_THEME_NAME) 33 | 34 | /** 设置主题 */ 35 | const setTheme = (value: ThemeName) => { 36 | activeThemeName.value = value 37 | } 38 | 39 | /** 在 html 根元素上挂载 class */ 40 | const addHtmlClass = (value: ThemeName) => { 41 | document.documentElement.classList.add(value) 42 | } 43 | 44 | /** 在 html 根元素上移除其他主题 class */ 45 | const removeHtmlClass = (value: ThemeName) => { 46 | const otherThemeNameList = themeList.map((item) => item.name).filter((name) => name !== value) 47 | document.documentElement.classList.remove(...otherThemeNameList) 48 | } 49 | 50 | /** 初始化 */ 51 | const initTheme = () => { 52 | // watchEffect 来收集副作用 53 | watchEffect(() => { 54 | const value = activeThemeName.value 55 | removeHtmlClass(value) 56 | addHtmlClass(value) 57 | setActiveThemeName(value) 58 | }) 59 | } 60 | 61 | /** 主题 hook */ 62 | export function useTheme() { 63 | return { themeList, activeThemeName, initTheme, setTheme } 64 | } 65 | -------------------------------------------------------------------------------- /src/hooks/useTitle.ts: -------------------------------------------------------------------------------- 1 | import { ref, watch } from "vue" 2 | 3 | /** 项目标题 */ 4 | export const APP_TITLE = import.meta.env.VITE_APP_TITLE ?? "V3 Electron Vite" 5 | 6 | /** 动态标题 */ 7 | const dynamicTitle = ref<string>("") 8 | 9 | /** 设置标题 */ 10 | const setTitle = (title?: string) => { 11 | dynamicTitle.value = title ? `${APP_TITLE} | ${title}` : APP_TITLE 12 | } 13 | 14 | /** 监听标题变化 */ 15 | watch(dynamicTitle, (value, oldValue) => { 16 | if (document && value !== oldValue) { 17 | document.title = value 18 | } 19 | }) 20 | 21 | export function useTitle() { 22 | return { setTitle } 23 | } 24 | -------------------------------------------------------------------------------- /src/icons/index.ts: -------------------------------------------------------------------------------- 1 | import { type App } from "vue" 2 | import SvgIcon from "@/components/SvgIcon/index.vue" // Svg Component 3 | import "virtual:svg-icons-register" 4 | 5 | export function loadSvg(app: App) { 6 | app.component("SvgIcon", SvgIcon) 7 | } 8 | -------------------------------------------------------------------------------- /src/icons/svg/404.svg: -------------------------------------------------------------------------------- 1 | <svg t="1651119499039" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9021" width="200" height="200"><path d="M512 720m-48 0a48 48 0 1 0 96 0 48 48 0 1 0-96 0Z" p-id="9022"></path><path d="M480 416v184c0 4.4 3.6 8 8 8h48c4.4 0 8-3.6 8-8V416c0-4.4-3.6-8-8-8h-48c-4.4 0-8 3.6-8 8z" p-id="9023"></path><path d="M955.7 856l-416-720c-6.2-10.7-16.9-16-27.7-16s-21.6 5.3-27.7 16l-416 720C56 877.4 71.4 904 96 904h832c24.6 0 40-26.6 27.7-48z m-783.5-27.9L512 239.9l339.8 588.2H172.2z" p-id="9024"></path></svg> 2 | -------------------------------------------------------------------------------- /src/icons/svg/bug.svg: -------------------------------------------------------------------------------- 1 | <svg t="1651119031318" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8881" width="200" height="200"><path d="M940 512H792V412c76.8 0 139-62.2 139-139 0-4.4-3.6-8-8-8h-60c-4.4 0-8 3.6-8 8 0 34.8-28.2 63-63 63H232c-34.8 0-63-28.2-63-63 0-4.4-3.6-8-8-8h-60c-4.4 0-8 3.6-8 8 0 76.8 62.2 139 139 139v100H84c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h148v96c0 6.5 0.2 13 0.7 19.3C164.1 728.6 116 796.7 116 876c0 4.4 3.6 8 8 8h56c4.4 0 8-3.6 8-8 0-44.2 23.9-82.9 59.6-103.7 6 17.2 13.6 33.6 22.7 49 24.3 41.5 59 76.2 100.5 100.5S460.5 960 512 960s99.8-13.9 141.3-38.2c41.5-24.3 76.2-59 100.5-100.5 9.1-15.5 16.7-31.9 22.7-49C812.1 793.1 836 831.8 836 876c0 4.4 3.6 8 8 8h56c4.4 0 8-3.6 8-8 0-79.3-48.1-147.4-116.7-176.7 0.4-6.4 0.7-12.8 0.7-19.3v-96h148c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8zM716 680c0 36.8-9.7 72-27.8 102.9-17.7 30.3-43 55.6-73.3 73.3-20.1 11.8-42 20-64.9 24.3V484c0-4.4-3.6-8-8-8h-60c-4.4 0-8 3.6-8 8v396.5c-22.9-4.3-44.8-12.5-64.9-24.3-30.3-17.7-55.6-43-73.3-73.3C317.7 752 308 716.8 308 680V412h408v268z" p-id="8882"></path><path d="M304 280h56c4.4 0 8-3.6 8-8 0-28.3 5.9-53.2 17.1-73.5 10.6-19.4 26-34.8 45.4-45.4C450.9 142 475.7 136 504 136h16c28.3 0 53.2 5.9 73.5 17.1 19.4 10.6 34.8 26 45.4 45.4C650 218.9 656 243.7 656 272c0 4.4 3.6 8 8 8h56c4.4 0 8-3.6 8-8 0-40-8.8-76.7-25.9-108.1-17.2-31.5-42.5-56.8-74-74C596.7 72.8 560 64 520 64h-16c-40 0-76.7 8.8-108.1 25.9-31.5 17.2-56.8 42.5-74 74C304.8 195.3 296 232 296 272c0 4.4 3.6 8 8 8z" p-id="8883"></path></svg> 2 | -------------------------------------------------------------------------------- /src/icons/svg/component.svg: -------------------------------------------------------------------------------- 1 | <?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1672728665955" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3482" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16"><path d="M64 64h384v384H64V64z m0 512h384v384H64V576z m512 0h384v384H576V576z m192-128c106.039 0 192-85.961 192-192S874.039 64 768 64s-192 85.961-192 192 85.961 192 192 192z" p-id="3483"></path></svg> -------------------------------------------------------------------------------- /src/icons/svg/dashboard.svg: -------------------------------------------------------------------------------- 1 | <svg t="1651118937898" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8601" width="200" height="200"><path d="M924.8 385.6c-22.6-53.4-54.9-101.3-96-142.4-41.1-41.1-89-73.4-142.4-96C631.1 123.8 572.5 112 512 112s-119.1 11.8-174.4 35.2c-53.4 22.6-101.3 54.9-142.4 96-41.1 41.1-73.4 89-96 142.4C75.8 440.9 64 499.5 64 560c0 132.7 58.3 257.7 159.9 343.1l1.7 1.4c5.8 4.8 13.1 7.5 20.6 7.5h531.7c7.5 0 14.8-2.7 20.6-7.5l1.7-1.4C901.7 817.7 960 692.7 960 560c0-60.5-11.9-119.1-35.2-174.4zM761.4 836H262.6C184.5 765.5 140 665.6 140 560c0-99.4 38.7-192.8 109-263 70.3-70.3 163.7-109 263-109 99.4 0 192.8 38.7 263 109 70.3 70.3 109 163.7 109 263 0 105.6-44.5 205.5-122.6 276z" p-id="8602"></path><path d="M623.5 421.5c-3.1-3.1-8.2-3.1-11.3 0L527.7 506c-18.7-5-39.4-0.2-54.1 14.5-21.9 21.9-21.9 57.3 0 79.2 21.9 21.9 57.3 21.9 79.2 0 14.7-14.7 19.5-35.4 14.5-54.1l84.5-84.5c3.1-3.1 3.1-8.2 0-11.3l-28.3-28.3zM490 320h44c4.4 0 8-3.6 8-8v-80c0-4.4-3.6-8-8-8h-44c-4.4 0-8 3.6-8 8v80c0 4.4 3.6 8 8 8zM750 538v44c0 4.4 3.6 8 8 8h80c4.4 0 8-3.6 8-8v-44c0-4.4-3.6-8-8-8h-80c-4.4 0-8 3.6-8 8zM762.7 340.8l-31.1-31.1c-3.1-3.1-8.2-3.1-11.3 0l-56.6 56.6c-3.1 3.1-3.1 8.2 0 11.3l31.1 31.1c3.1 3.1 8.2 3.1 11.3 0l56.6-56.6c3.1-3.1 3.1-8.2 0-11.3zM304.1 309.7c-3.1-3.1-8.2-3.1-11.3 0l-31.1 31.1c-3.1 3.1-3.1 8.2 0 11.3l56.6 56.6c3.1 3.1 8.2 3.1 11.3 0l31.1-31.1c3.1-3.1 3.1-8.2 0-11.3l-56.6-56.6zM262 530h-80c-4.4 0-8 3.6-8 8v44c0 4.4 3.6 8 8 8h80c4.4 0 8-3.6 8-8v-44c0-4.4-3.6-8-8-8z" p-id="8603"></path></svg> 2 | -------------------------------------------------------------------------------- /src/icons/svg/fullscreen-exit.svg: -------------------------------------------------------------------------------- 1 | <svg t="1661153147729" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3352" width="200" height="200"><path d="M704 864v-96c0-54.4 41.6-96 96-96h96c19.2 0 32-12.8 32-32s-12.8-32-32-32h-96c-89.6 0-160 70.4-160 160v96c0 19.2 12.8 32 32 32s32-12.8 32-32z m-64-704v96c0 89.6 70.4 160 160 160h96c19.2 0 32-12.8 32-32s-12.8-32-32-32h-96c-54.4 0-96-41.6-96-96v-96c0-19.2-12.8-32-32-32s-32 12.8-32 32z m-256 704v-96c0-89.6-70.4-160-160-160h-96c-19.2 0-32 12.8-32 32s12.8 32 32 32h96c54.4 0 96 41.6 96 96v96c0 19.2 12.8 32 32 32s32-12.8 32-32z m-64-704v96c0 54.4-41.6 96-96 96h-96c-19.2 0-32 12.8-32 32s12.8 32 32 32h96c89.6 0 160-70.4 160-160v-96c0-19.2-12.8-32-32-32s-32 12.8-32 32z" p-id="3353"></path></svg> 2 | -------------------------------------------------------------------------------- /src/icons/svg/fullscreen.svg: -------------------------------------------------------------------------------- 1 | <svg t="1661151768669" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3212" width="200" height="200"><path d="M192 384v-96c0-54.4 41.6-96 96-96h96c19.2 0 32-12.8 32-32s-12.8-32-32-32h-96c-89.6 0-160 70.4-160 160v96c0 19.2 12.8 32 32 32s32-12.8 32-32z m-64 256v96c0 89.6 70.4 160 160 160h96c19.2 0 32-12.8 32-32s-12.8-32-32-32h-96c-54.4 0-96-41.6-96-96v-96c0-19.2-12.8-32-32-32s-32 12.8-32 32z m768-256v-96c0-89.6-70.4-160-160-160h-96c-19.2 0-32 12.8-32 32s12.8 32 32 32h96c54.4 0 96 41.6 96 96v96c0 19.2 12.8 32 32 32s32-12.8 32-32z m-64 256v96c0 54.4-41.6 96-96 96h-96c-19.2 0-32 12.8-32 32s12.8 32 32 32h96c89.6 0 160-70.4 160-160v-96c0-19.2-12.8-32-32-32s-32 12.8-32 32z" p-id="3213"></path></svg> 2 | -------------------------------------------------------------------------------- /src/icons/svg/keyboard-down.svg: -------------------------------------------------------------------------------- 1 | <svg width="15" height="15" aria-label="Arrow down" role="img"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2"><path d="M7.5 3.5v8M10.5 8.5l-3 3-3-3"></path></g></svg> 2 | -------------------------------------------------------------------------------- /src/icons/svg/keyboard-enter.svg: -------------------------------------------------------------------------------- 1 | <svg width="15" height="15" aria-label="Enter key" role="img"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2"><path d="M12 3.53088v3c0 1-1 2-2 2H4M7 11.53088l-3-3 3-3"></path></g></svg> 2 | -------------------------------------------------------------------------------- /src/icons/svg/keyboard-esc.svg: -------------------------------------------------------------------------------- 1 | <svg width="15" height="15" aria-label="Escape key" role="img"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2"><path d="M13.6167 8.936c-.1065.3583-.6883.962-1.4875.962-.7993 0-1.653-.9165-1.653-2.1258v-.5678c0-1.2548.7896-2.1016 1.653-2.1016.8634 0 1.3601.4778 1.4875 1.0724M9 6c-.1352-.4735-.7506-.9219-1.46-.8972-.7092.0246-1.344.57-1.344 1.2166s.4198.8812 1.3445.9805C8.465 7.3992 8.968 7.9337 9 8.5c.032.5663-.454 1.398-1.4595 1.398C6.6593 9.898 6 9 5.963 8.4851m-1.4748.5368c-.2635.5941-.8099.876-1.5443.876s-1.7073-.6248-1.7073-2.204v-.4603c0-1.0416.721-2.131 1.7073-2.131.9864 0 1.6425 1.031 1.5443 2.2492h-2.956"></path></g></svg> 2 | -------------------------------------------------------------------------------- /src/icons/svg/keyboard-up.svg: -------------------------------------------------------------------------------- 1 | <svg width="15" height="15" aria-label="Arrow up" role="img"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2"><path d="M7.5 11.5v-8M10.5 6.5l-3-3-3 3"></path></g></svg> 2 | -------------------------------------------------------------------------------- /src/icons/svg/link.svg: -------------------------------------------------------------------------------- 1 | <svg t="1651118878747" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8461" width="200" height="200"><path d="M574 665.4c-3.1-3.1-8.2-3.1-11.3 0L446.5 781.6c-53.8 53.8-144.6 59.5-204 0-59.5-59.5-53.8-150.2 0-204l116.2-116.2c3.1-3.1 3.1-8.2 0-11.3l-39.8-39.8c-3.1-3.1-8.2-3.1-11.3 0L191.4 526.5c-84.6 84.6-84.6 221.5 0 306s221.5 84.6 306 0l116.2-116.2c3.1-3.1 3.1-8.2 0-11.3L574 665.4zM832.6 191.4c-84.6-84.6-221.5-84.6-306 0L410.3 307.6c-3.1 3.1-3.1 8.2 0 11.3l39.7 39.7c3.1 3.1 8.2 3.1 11.3 0l116.2-116.2c53.8-53.8 144.6-59.5 204 0 59.5 59.5 53.8 150.2 0 204L665.3 562.6c-3.1 3.1-3.1 8.2 0 11.3l39.8 39.8c3.1 3.1 8.2 3.1 11.3 0l116.2-116.2c84.5-84.6 84.5-221.5 0-306.1z" p-id="8462"></path><path d="M610.1 372.3c-3.1-3.1-8.2-3.1-11.3 0L372.3 598.7c-3.1 3.1-3.1 8.2 0 11.3l39.6 39.6c3.1 3.1 8.2 3.1 11.3 0l226.4-226.4c3.1-3.1 3.1-8.2 0-11.3l-39.5-39.6z" p-id="8463"></path></svg> 2 | -------------------------------------------------------------------------------- /src/icons/svg/lock.svg: -------------------------------------------------------------------------------- 1 | <svg t="1651119007904" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8741" width="200" height="200"><path d="M832 464h-68V240c0-70.7-57.3-128-128-128H388c-70.7 0-128 57.3-128 128v224h-68c-17.7 0-32 14.3-32 32v384c0 17.7 14.3 32 32 32h640c17.7 0 32-14.3 32-32V496c0-17.7-14.3-32-32-32zM332 240c0-30.9 25.1-56 56-56h248c30.9 0 56 25.1 56 56v224H332V240z m460 600H232V536h560v304z" p-id="8742"></path><path d="M484 701v53c0 4.4 3.6 8 8 8h40c4.4 0 8-3.6 8-8v-53c12.1-8.7 20-22.9 20-39 0-26.5-21.5-48-48-48s-48 21.5-48 48c0 16.1 7.9 30.3 20 39z" p-id="8743"></path></svg> 2 | -------------------------------------------------------------------------------- /src/icons/svg/menu.svg: -------------------------------------------------------------------------------- 1 | <svg t="1651750906395" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9162" width="200" height="200"><path d="M904 158H408c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h496c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8zM904 582H408c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h496c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8zM904 794H120c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8zM904 370H120c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8z" p-id="9163"></path></svg> 2 | -------------------------------------------------------------------------------- /src/icons/svg/search.svg: -------------------------------------------------------------------------------- 1 | <svg t="1691398959507" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2431" width="200" height="200"><path d="M862.609 816.955L726.44 680.785l-0.059-0.056a358.907 358.907 0 0 0 56.43-91.927c18.824-44.507 28.369-91.767 28.369-140.467 0-48.701-9.545-95.96-28.369-140.467-18.176-42.973-44.19-81.56-77.319-114.689-33.13-33.129-71.717-59.144-114.69-77.32-44.507-18.825-91.767-28.37-140.467-28.37-48.701 0-95.96 9.545-140.467 28.37-42.973 18.176-81.56 44.19-114.689 77.32-33.13 33.129-59.144 71.717-77.32 114.689-18.825 44.507-28.37 91.767-28.37 140.467 0 48.7 9.545 95.96 28.37 140.467 18.176 42.974 44.19 81.561 77.32 114.69 33.129 33.129 71.717 59.144 114.689 77.319 44.507 18.824 91.767 28.369 140.467 28.369 48.7 0 95.96-9.545 140.467-28.369 32.78-13.864 62.997-32.303 90.197-54.968 0.063 0.064 0.122 0.132 0.186 0.195l136.169 136.17c6.25 6.25 14.438 9.373 22.628 9.373 8.188 0 16.38-3.125 22.627-9.372 12.496-12.496 12.496-32.758 0-45.254z m-412.274-69.466c-79.907 0-155.031-31.118-211.534-87.62-56.503-56.503-87.62-131.627-87.62-211.534s31.117-155.031 87.62-211.534c56.502-56.503 131.626-87.62 211.534-87.62s155.031 31.117 211.534 87.62c56.502 56.502 87.62 131.626 87.62 211.534s-31.118 155.031-87.62 211.534c-56.503 56.502-131.627 87.62-211.534 87.62z" p-id="2432"></path></svg> 2 | -------------------------------------------------------------------------------- /src/icons/svg/unocss.svg: -------------------------------------------------------------------------------- 1 | <svg width="220" height="220" viewBox="0 0 220 220" xmlns="http://www.w3.org/2000/svg"> 2 | <path d="M117.444 167.888C117.444 140.273 139.83 117.888 167.444 117.888V117.888C195.058 117.888 217.444 140.273 217.444 167.888V167.888C217.444 195.502 195.058 217.888 167.444 217.888V217.888C139.83 217.888 117.444 195.502 117.444 167.888V167.888Z"/> 3 | <path d="M117.444 53C117.444 25.3858 139.83 3 167.444 3V3C195.058 3 217.444 25.3858 217.444 53V98C217.444 100.761 215.205 103 212.444 103H122.444C119.683 103 117.444 100.761 117.444 98V53Z"/> 4 | <path d="M102 167.888C102 195.502 79.6142 217.888 52 217.888V217.888C24.3858 217.888 2 195.502 2 167.888L2.00001 122.888C2.00001 120.126 4.23859 117.888 7.00001 117.888L97 117.888C99.7614 117.888 102 120.126 102 122.888L102 167.888Z"/> 5 | </svg> 6 | -------------------------------------------------------------------------------- /src/layouts/LeftMode.vue: -------------------------------------------------------------------------------- 1 | <script lang="ts" setup> 2 | import { computed } from "vue" 3 | import { storeToRefs } from "pinia" 4 | import { useAppStore } from "@/store/modules/app" 5 | import { useSettingsStore } from "@/store/modules/settings" 6 | import { AppMain, NavigationBar, Sidebar, TagsView } from "./components" 7 | import { useDevice } from "@/hooks/useDevice" 8 | import { useLayoutMode } from "@/hooks/useLayoutMode" 9 | 10 | const { isMobile } = useDevice() 11 | const { isLeft } = useLayoutMode() 12 | const appStore = useAppStore() 13 | const settingsStore = useSettingsStore() 14 | const { showTagsView, fixedHeader } = storeToRefs(settingsStore) 15 | 16 | /** 定义计算属性 layoutClasses,用于控制布局的类名 */ 17 | const layoutClasses = computed(() => { 18 | return { 19 | hideSidebar: !appStore.sidebar.opened, 20 | openSidebar: appStore.sidebar.opened, 21 | withoutAnimation: appStore.sidebar.withoutAnimation, 22 | mobile: isMobile.value, 23 | noLeft: !isLeft.value 24 | } 25 | }) 26 | 27 | /** 用于处理点击 mobile 端侧边栏遮罩层的事件 */ 28 | const handleClickOutside = () => { 29 | appStore.closeSidebar(false) 30 | } 31 | </script> 32 | 33 | <template> 34 | <div :class="layoutClasses" class="app-wrapper"> 35 | <!-- mobile 端侧边栏遮罩层 --> 36 | <div v-if="layoutClasses.mobile && layoutClasses.openSidebar" class="drawer-bg" @click="handleClickOutside" /> 37 | <!-- 左侧边栏 --> 38 | <Sidebar class="sidebar-container" /> 39 | <!-- 主容器 --> 40 | <div :class="{ hasTagsView: showTagsView }" class="main-container"> 41 | <!-- 头部导航栏和标签栏 --> 42 | <div :class="{ 'fixed-header': fixedHeader }" class="layout-header"> 43 | <NavigationBar /> 44 | <TagsView v-show="showTagsView" /> 45 | </div> 46 | <!-- 页面主体内容 --> 47 | <AppMain class="app-main" /> 48 | </div> 49 | </div> 50 | </template> 51 | 52 | <style lang="scss" scoped> 53 | @import "@/styles/mixins.scss"; 54 | $transition-time: 0.35s; 55 | 56 | .app-wrapper { 57 | @extend %clearfix; 58 | position: relative; 59 | width: 100%; 60 | } 61 | 62 | .drawer-bg { 63 | background-color: rgba(0, 0, 0, 0.3); 64 | width: 100%; 65 | top: 0; 66 | height: 100%; 67 | position: absolute; 68 | z-index: 999; 69 | } 70 | 71 | .sidebar-container { 72 | background-color: var(--v3-sidebar-menu-bg-color); 73 | transition: width $transition-time; 74 | width: var(--v3-sidebar-width) !important; 75 | height: 100%; 76 | position: fixed; 77 | top: 0; 78 | bottom: 0; 79 | left: 0; 80 | z-index: 1001; 81 | overflow: hidden; 82 | border-right: var(--v3-sidebar-border-right); 83 | } 84 | 85 | .main-container { 86 | min-height: 100%; 87 | transition: margin-left $transition-time; 88 | margin-left: var(--v3-sidebar-width); 89 | position: relative; 90 | } 91 | 92 | .fixed-header { 93 | position: fixed !important; 94 | top: 0; 95 | right: 0; 96 | z-index: 9; 97 | width: calc(100% - var(--v3-sidebar-width)); 98 | transition: width $transition-time; 99 | } 100 | 101 | .layout-header { 102 | position: relative; 103 | z-index: 9; 104 | background-color: var(--v3-header-bg-color); 105 | box-shadow: var(--v3-header-box-shadow); 106 | border-bottom: var(--v3-header-border-bottom); 107 | } 108 | 109 | .app-main { 110 | min-height: calc(100vh - var(--v3-navigationbar-height)); 111 | position: relative; 112 | overflow: hidden; 113 | } 114 | 115 | .fixed-header + .app-main { 116 | padding-top: var(--v3-navigationbar-height); 117 | height: 100vh; 118 | overflow: auto; 119 | } 120 | 121 | .hasTagsView { 122 | .app-main { 123 | min-height: calc(100vh - var(--v3-header-height)); 124 | } 125 | .fixed-header + .app-main { 126 | padding-top: var(--v3-header-height); 127 | } 128 | } 129 | 130 | .hideSidebar { 131 | .sidebar-container { 132 | width: var(--v3-sidebar-hide-width) !important; 133 | } 134 | .main-container { 135 | margin-left: var(--v3-sidebar-hide-width); 136 | } 137 | .fixed-header { 138 | width: calc(100% - var(--v3-sidebar-hide-width)); 139 | } 140 | } 141 | 142 | // 适配 mobile 端 143 | .mobile { 144 | .sidebar-container { 145 | transition: transform $transition-time; 146 | width: var(--v3-sidebar-width) !important; 147 | } 148 | .main-container { 149 | margin-left: 0px; 150 | } 151 | .fixed-header { 152 | width: 100%; 153 | } 154 | &.openSidebar { 155 | position: fixed; 156 | top: 0; 157 | } 158 | &.hideSidebar { 159 | .sidebar-container { 160 | pointer-events: none; 161 | transition-duration: 0.3s; 162 | transform: translate3d(calc(0px - var(--v3-sidebar-width)), 0, 0); 163 | } 164 | } 165 | // 既是 mobile 又是顶部或混合布局模式 166 | &.noLeft { 167 | .sidebar-container { 168 | background-color: var(--el-bg-color); 169 | } 170 | } 171 | } 172 | 173 | .withoutAnimation { 174 | .sidebar-container, 175 | .main-container { 176 | transition: none; 177 | } 178 | } 179 | </style> 180 | -------------------------------------------------------------------------------- /src/layouts/LeftTopMode.vue: -------------------------------------------------------------------------------- 1 | <script lang="ts" setup> 2 | import { computed } from "vue" 3 | import { storeToRefs } from "pinia" 4 | import { useAppStore } from "@/store/modules/app" 5 | import { useSettingsStore } from "@/store/modules/settings" 6 | import { AppMain, NavigationBar, Sidebar, TagsView, Logo } from "./components" 7 | 8 | const appStore = useAppStore() 9 | const settingsStore = useSettingsStore() 10 | const { showTagsView, showLogo } = storeToRefs(settingsStore) 11 | 12 | /** 定义计算属性 layoutClasses,用于控制布局的类名 */ 13 | const layoutClasses = computed(() => { 14 | return { 15 | hideSidebar: !appStore.sidebar.opened 16 | } 17 | }) 18 | </script> 19 | 20 | <template> 21 | <div :class="layoutClasses" class="app-wrapper"> 22 | <!-- 头部导航栏和标签栏 --> 23 | <div class="fixed-header layout-header"> 24 | <Logo v-if="showLogo" :collapse="false" class="logo" /> 25 | <div class="content"> 26 | <NavigationBar /> 27 | <TagsView v-show="showTagsView" /> 28 | </div> 29 | </div> 30 | <!-- 主容器 --> 31 | <div :class="{ hasTagsView: showTagsView }" class="main-container"> 32 | <!-- 左侧边栏 --> 33 | <Sidebar class="sidebar-container" /> 34 | <!-- 页面主体内容 --> 35 | <AppMain class="app-main" /> 36 | </div> 37 | </div> 38 | </template> 39 | 40 | <style lang="scss" scoped> 41 | @import "@/styles/mixins.scss"; 42 | $transition-time: 0.35s; 43 | 44 | .app-wrapper { 45 | @extend %clearfix; 46 | width: 100%; 47 | } 48 | 49 | .fixed-header { 50 | position: fixed; 51 | top: 0; 52 | z-index: 1002; 53 | width: 100%; 54 | display: flex; 55 | .logo { 56 | flex: none; 57 | width: var(--v3-sidebar-width); 58 | } 59 | .content { 60 | flex: 1; 61 | overflow: hidden; 62 | } 63 | } 64 | 65 | .layout-header { 66 | background-color: var(--v3-header-bg-color); 67 | box-shadow: var(--v3-header-box-shadow); 68 | border-bottom: var(--v3-header-border-bottom); 69 | } 70 | 71 | .main-container { 72 | min-height: 100%; 73 | } 74 | 75 | .sidebar-container { 76 | background-color: var(--el-menu-bg-color); 77 | transition: width $transition-time; 78 | width: var(--v3-sidebar-width) !important; 79 | height: 100%; 80 | position: fixed; 81 | left: 0; 82 | z-index: 1001; 83 | overflow: hidden; 84 | border-right: var(--v3-sidebar-border-right); 85 | padding-top: var(--v3-navigationbar-height); 86 | } 87 | 88 | .app-main { 89 | transition: padding-left $transition-time; 90 | padding-top: var(--v3-navigationbar-height); 91 | padding-left: var(--v3-sidebar-width); 92 | height: 100vh; 93 | overflow: auto; 94 | } 95 | 96 | .hideSidebar { 97 | .sidebar-container { 98 | width: var(--v3-sidebar-hide-width) !important; 99 | } 100 | .app-main { 101 | padding-left: var(--v3-sidebar-hide-width); 102 | } 103 | } 104 | 105 | .hasTagsView { 106 | .sidebar-container { 107 | padding-top: var(--v3-header-height); 108 | } 109 | .app-main { 110 | padding-top: var(--v3-header-height); 111 | } 112 | } 113 | </style> 114 | -------------------------------------------------------------------------------- /src/layouts/TopMode.vue: -------------------------------------------------------------------------------- 1 | <script lang="ts" setup> 2 | import { storeToRefs } from "pinia" 3 | import { useSettingsStore } from "@/store/modules/settings" 4 | import { AppMain, NavigationBar, TagsView, Logo } from "./components" 5 | 6 | const settingsStore = useSettingsStore() 7 | const { showTagsView, showLogo } = storeToRefs(settingsStore) 8 | </script> 9 | 10 | <template> 11 | <div class="app-wrapper"> 12 | <!-- 头部导航栏和标签栏 --> 13 | <div class="fixed-header layout-header"> 14 | <div class="content"> 15 | <Logo v-if="showLogo" :collapse="false" class="logo" /> 16 | <NavigationBar class="navigation-bar" /> 17 | </div> 18 | <TagsView v-show="showTagsView" /> 19 | </div> 20 | <!-- 主容器 --> 21 | <div :class="{ hasTagsView: showTagsView }" class="main-container"> 22 | <!-- 页面主体内容 --> 23 | <AppMain class="app-main" /> 24 | </div> 25 | </div> 26 | </template> 27 | 28 | <style lang="scss" scoped> 29 | @import "@/styles/mixins.scss"; 30 | $transition-time: 0.35s; 31 | 32 | .app-wrapper { 33 | @extend %clearfix; 34 | width: 100%; 35 | } 36 | 37 | .fixed-header { 38 | position: fixed; 39 | top: 0; 40 | z-index: 1002; 41 | width: 100%; 42 | .logo { 43 | width: var(--v3-sidebar-width); 44 | } 45 | .content { 46 | display: flex; 47 | .navigation-bar { 48 | flex: 1; 49 | } 50 | } 51 | } 52 | 53 | .layout-header { 54 | background-color: var(--v3-header-bg-color); 55 | box-shadow: var(--v3-header-box-shadow); 56 | border-bottom: var(--v3-header-border-bottom); 57 | } 58 | 59 | .main-container { 60 | min-height: 100%; 61 | } 62 | 63 | .app-main { 64 | transition: padding-left $transition-time; 65 | padding-top: var(--v3-navigationbar-height); 66 | height: 100vh; 67 | overflow: auto; 68 | } 69 | 70 | .hasTagsView { 71 | .app-main { 72 | padding-top: var(--v3-header-height); 73 | } 74 | } 75 | </style> 76 | -------------------------------------------------------------------------------- /src/layouts/components/AppMain.vue: -------------------------------------------------------------------------------- 1 | <script lang="ts" setup> 2 | import { useTagsViewStore } from "@/store/modules/tags-view" 3 | import { useSettingsStore } from "@/store/modules/settings" 4 | import Footer from "./Footer/index.vue" 5 | 6 | const tagsViewStore = useTagsViewStore() 7 | const settingsStore = useSettingsStore() 8 | </script> 9 | 10 | <template> 11 | <section class="app-main"> 12 | <div class="app-scrollbar"> 13 | <!-- key 采用 route.path 和 route.fullPath 有着不同的效果,大多数时候 path 更通用 --> 14 | <router-view v-slot="{ Component, route }"> 15 | <transition name="el-fade-in" mode="out-in"> 16 | <keep-alive :include="tagsViewStore.cachedViews"> 17 | <component :is="Component" :key="route.path" class="app-container-grow" /> 18 | </keep-alive> 19 | </transition> 20 | </router-view> 21 | <!-- 页脚 --> 22 | <Footer v-if="settingsStore.showFooter" /> 23 | </div> 24 | <!-- 返回顶部 --> 25 | <el-backtop /> 26 | <!-- 返回顶部(固定 Header 情况下) --> 27 | <el-backtop target=".app-scrollbar" /> 28 | </section> 29 | </template> 30 | 31 | <style lang="scss" scoped> 32 | @import "@/styles/mixins.scss"; 33 | 34 | .app-main { 35 | width: 100%; 36 | display: flex; 37 | } 38 | 39 | .app-scrollbar { 40 | flex-grow: 1; 41 | overflow: auto; 42 | @extend %scrollbar; 43 | display: flex; 44 | flex-direction: column; 45 | .app-container-grow { 46 | flex-grow: 1; 47 | } 48 | } 49 | </style> 50 | -------------------------------------------------------------------------------- /src/layouts/components/Breadcrumb/index.vue: -------------------------------------------------------------------------------- 1 | <script lang="ts" setup> 2 | import { ref } from "vue" 3 | import { type RouteLocationMatched, useRoute, useRouter } from "vue-router" 4 | import { useRouteListener } from "@/hooks/useRouteListener" 5 | import { compile } from "path-to-regexp" 6 | 7 | const route = useRoute() 8 | const router = useRouter() 9 | const { listenerRouteChange } = useRouteListener() 10 | 11 | /** 定义响应式数据 breadcrumbs,用于存储面包屑导航信息 */ 12 | const breadcrumbs = ref<RouteLocationMatched[]>([]) 13 | 14 | /** 获取面包屑导航信息 */ 15 | const getBreadcrumb = () => { 16 | breadcrumbs.value = route.matched.filter((item) => item.meta?.title && item.meta?.breadcrumb !== false) 17 | } 18 | 19 | /** 编译路由路径 */ 20 | const pathCompile = (path: string) => { 21 | const toPath = compile(path) 22 | return toPath(route.params) 23 | } 24 | 25 | /** 处理面包屑导航点击事件 */ 26 | const handleLink = (item: RouteLocationMatched) => { 27 | const { redirect, path } = item 28 | if (redirect) { 29 | router.push(redirect as string) 30 | return 31 | } 32 | router.push(pathCompile(path)) 33 | } 34 | 35 | /** 监听路由变化,更新面包屑导航信息 */ 36 | listenerRouteChange((route) => { 37 | if (route.path.startsWith("/redirect/")) return 38 | getBreadcrumb() 39 | }, true) 40 | </script> 41 | 42 | <template> 43 | <el-breadcrumb> 44 | <el-breadcrumb-item v-for="(item, index) in breadcrumbs" :key="item.path"> 45 | <span v-if="item.redirect === 'noRedirect' || index === breadcrumbs.length - 1" class="no-redirect"> 46 | {{ item.meta.title }} 47 | </span> 48 | <a v-else @click.prevent="handleLink(item)"> 49 | {{ item.meta.title }} 50 | </a> 51 | </el-breadcrumb-item> 52 | </el-breadcrumb> 53 | </template> 54 | 55 | <style lang="scss" scoped> 56 | .el-breadcrumb { 57 | line-height: var(--v3-navigationbar-height); 58 | .no-redirect { 59 | color: var(--el-text-color-placeholder); 60 | } 61 | a { 62 | font-weight: normal; 63 | } 64 | } 65 | </style> 66 | -------------------------------------------------------------------------------- /src/layouts/components/Footer/index.vue: -------------------------------------------------------------------------------- 1 | <script lang="ts" setup> 2 | import { APP_TITLE } from "@/hooks/useTitle" 3 | </script> 4 | 5 | <template> 6 | <footer class="layout-footer">MIT © 2021-PRESENT {{ APP_TITLE }}</footer> 7 | </template> 8 | 9 | <style lang="scss" scoped> 10 | .layout-footer { 11 | width: 100%; 12 | min-height: 50px; 13 | display: flex; 14 | align-items: center; 15 | justify-content: center; 16 | color: var(--el-text-color-placeholder); 17 | } 18 | </style> 19 | -------------------------------------------------------------------------------- /src/layouts/components/Hamburger/index.vue: -------------------------------------------------------------------------------- 1 | <script lang="ts" setup> 2 | import { Expand, Fold } from "@element-plus/icons-vue" 3 | 4 | interface Props { 5 | isActive?: boolean 6 | } 7 | 8 | const props = withDefaults(defineProps<Props>(), { 9 | isActive: false 10 | }) 11 | 12 | /** Vue 3.3+ defineEmits 语法 */ 13 | const emit = defineEmits<{ 14 | toggleClick: [] 15 | }>() 16 | 17 | const toggleClick = () => { 18 | emit("toggleClick") 19 | } 20 | </script> 21 | 22 | <template> 23 | <div @click="toggleClick"> 24 | <el-icon :size="20" class="icon"> 25 | <Fold v-if="props.isActive" /> 26 | <Expand v-else /> 27 | </el-icon> 28 | </div> 29 | </template> 30 | 31 | <style lang="scss" scoped> 32 | .icon { 33 | vertical-align: middle; 34 | color: var(--v3-hamburger-text-color); 35 | } 36 | </style> 37 | -------------------------------------------------------------------------------- /src/layouts/components/Logo/index.vue: -------------------------------------------------------------------------------- 1 | <script lang="ts" setup> 2 | import { useLayoutMode } from "@/hooks/useLayoutMode" 3 | import logo from "@/assets/layouts/logo.png?url" 4 | import logoText1 from "@/assets/layouts/logo-text-1.png?url" 5 | import logoText2 from "@/assets/layouts/logo-text-2.png?url" 6 | 7 | interface Props { 8 | collapse?: boolean 9 | } 10 | 11 | const props = withDefaults(defineProps<Props>(), { 12 | collapse: true 13 | }) 14 | 15 | const { isLeft, isTop } = useLayoutMode() 16 | </script> 17 | 18 | <template> 19 | <div class="layout-logo-container" :class="{ collapse: props.collapse, 'layout-mode-top': isTop }"> 20 | <transition name="layout-logo-fade"> 21 | <router-link v-if="props.collapse" key="collapse" to="/"> 22 | <img :src="logo" class="layout-logo" /> 23 | </router-link> 24 | <router-link v-else key="expand" to="/"> 25 | <img :src="!isLeft ? logoText2 : logoText1" class="layout-logo-text" /> 26 | </router-link> 27 | </transition> 28 | </div> 29 | </template> 30 | 31 | <style lang="scss" scoped> 32 | .layout-logo-container { 33 | position: relative; 34 | width: 100%; 35 | height: var(--v3-header-height); 36 | line-height: var(--v3-header-height); 37 | text-align: center; 38 | overflow: hidden; 39 | .layout-logo { 40 | display: none; 41 | } 42 | .layout-logo-text { 43 | height: 100%; 44 | vertical-align: middle; 45 | } 46 | } 47 | 48 | .layout-mode-top { 49 | height: var(--v3-navigationbar-height); 50 | line-height: var(--v3-navigationbar-height); 51 | } 52 | 53 | .collapse { 54 | .layout-logo { 55 | width: 32px; 56 | height: 32px; 57 | vertical-align: middle; 58 | display: inline-block; 59 | } 60 | .layout-logo-text { 61 | display: none; 62 | } 63 | } 64 | </style> 65 | -------------------------------------------------------------------------------- /src/layouts/components/NavigationBar/index.vue: -------------------------------------------------------------------------------- 1 | <script lang="ts" setup> 2 | import { useRouter } from "vue-router" 3 | import { storeToRefs } from "pinia" 4 | import { useAppStore } from "@/store/modules/app" 5 | import { useSettingsStore } from "@/store/modules/settings" 6 | import { useUserStore } from "@/store/modules/user" 7 | import { UserFilled } from "@element-plus/icons-vue" 8 | import Hamburger from "../Hamburger/index.vue" 9 | import Breadcrumb from "../Breadcrumb/index.vue" 10 | import Sidebar from "../Sidebar/index.vue" 11 | import Notify from "@/components/Notify/index.vue" 12 | import ThemeSwitch from "@/components/ThemeSwitch/index.vue" 13 | import Screenfull from "@/components/Screenfull/index.vue" 14 | import SearchMenu from "@/components/SearchMenu/index.vue" 15 | import { useDevice } from "@/hooks/useDevice" 16 | import { useLayoutMode } from "@/hooks/useLayoutMode" 17 | 18 | const { isMobile } = useDevice() 19 | const { isTop } = useLayoutMode() 20 | const router = useRouter() 21 | const appStore = useAppStore() 22 | const userStore = useUserStore() 23 | const settingsStore = useSettingsStore() 24 | const { showNotify, showThemeSwitch, showScreenfull, showSearchMenu } = storeToRefs(settingsStore) 25 | 26 | /** 切换侧边栏 */ 27 | const toggleSidebar = () => { 28 | appStore.toggleSidebar(false) 29 | } 30 | 31 | /** 登出 */ 32 | const logout = () => { 33 | userStore.logout() 34 | router.push("/login") 35 | } 36 | </script> 37 | 38 | <template> 39 | <div class="navigation-bar"> 40 | <Hamburger 41 | v-if="!isTop || isMobile" 42 | :is-active="appStore.sidebar.opened" 43 | class="hamburger" 44 | @toggle-click="toggleSidebar" 45 | /> 46 | <Breadcrumb v-if="!isTop || isMobile" class="breadcrumb" /> 47 | <Sidebar v-if="isTop && !isMobile" class="sidebar" /> 48 | <div class="right-menu"> 49 | <SearchMenu v-if="showSearchMenu" class="right-menu-item" /> 50 | <Screenfull v-if="showScreenfull" class="right-menu-item" /> 51 | <ThemeSwitch v-if="showThemeSwitch" class="right-menu-item" /> 52 | <Notify v-if="showNotify" class="right-menu-item" /> 53 | <el-dropdown class="right-menu-item"> 54 | <div class="right-menu-avatar"> 55 | <el-avatar :icon="UserFilled" :size="30" /> 56 | <span>{{ userStore.username }}</span> 57 | </div> 58 | <template #dropdown> 59 | <el-dropdown-menu> 60 | <a target="_blank" href="https://github.com/un-pany/v3-admin-vite"> 61 | <el-dropdown-item>GitHub</el-dropdown-item> 62 | </a> 63 | <a target="_blank" href="https://gitee.com/un-pany/v3-admin-vite"> 64 | <el-dropdown-item>Gitee</el-dropdown-item> 65 | </a> 66 | <el-dropdown-item divided @click="logout"> 67 | <span style="display: block">退出登录</span> 68 | </el-dropdown-item> 69 | </el-dropdown-menu> 70 | </template> 71 | </el-dropdown> 72 | </div> 73 | </div> 74 | </template> 75 | 76 | <style lang="scss" scoped> 77 | .navigation-bar { 78 | height: var(--v3-navigationbar-height); 79 | overflow: hidden; 80 | color: var(--v3-navigationbar-text-color); 81 | display: flex; 82 | justify-content: space-between; 83 | .hamburger { 84 | display: flex; 85 | align-items: center; 86 | height: 100%; 87 | padding: 0 15px; 88 | cursor: pointer; 89 | } 90 | .breadcrumb { 91 | flex: 1; 92 | // 参考 Bootstrap 的响应式设计将宽度设置为 576 93 | @media screen and (max-width: 576px) { 94 | display: none; 95 | } 96 | } 97 | .sidebar { 98 | flex: 1; 99 | // 设置 min-width 是为了让 Sidebar 里的 el-menu 宽度自适应 100 | min-width: 0px; 101 | :deep(.el-menu) { 102 | background-color: transparent; 103 | } 104 | :deep(.el-sub-menu) { 105 | &.is-active { 106 | .el-sub-menu__title { 107 | color: var(--el-color-primary) !important; 108 | } 109 | } 110 | } 111 | } 112 | .right-menu { 113 | margin-right: 10px; 114 | height: 100%; 115 | display: flex; 116 | align-items: center; 117 | .right-menu-item { 118 | padding: 0 10px; 119 | cursor: pointer; 120 | .right-menu-avatar { 121 | display: flex; 122 | align-items: center; 123 | .el-avatar { 124 | margin-right: 10px; 125 | } 126 | span { 127 | font-size: 16px; 128 | } 129 | } 130 | } 131 | } 132 | } 133 | </style> 134 | -------------------------------------------------------------------------------- /src/layouts/components/RightPanel/index.vue: -------------------------------------------------------------------------------- 1 | <script lang="ts" setup> 2 | import { ref } from "vue" 3 | import { Setting } from "@element-plus/icons-vue" 4 | 5 | interface Props { 6 | buttonTop?: number 7 | } 8 | 9 | const props = withDefaults(defineProps<Props>(), { 10 | buttonTop: 350 11 | }) 12 | 13 | const buttonTopCss = props.buttonTop + "px" 14 | const show = ref(false) 15 | </script> 16 | 17 | <template> 18 | <div class="handle-button" @click="show = true"> 19 | <el-icon :size="24"> 20 | <Setting /> 21 | </el-icon> 22 | </div> 23 | <el-drawer v-model="show" size="300px" :with-header="false"> 24 | <slot /> 25 | </el-drawer> 26 | </template> 27 | 28 | <style lang="scss" scoped> 29 | .handle-button { 30 | width: 48px; 31 | height: 48px; 32 | background-color: var(--v3-rightpanel-button-bg-color); 33 | position: fixed; 34 | top: v-bind(buttonTopCss); 35 | right: 0; 36 | border-radius: 6px 0 0 6px; 37 | z-index: 2000; 38 | cursor: pointer; 39 | pointer-events: auto; 40 | color: #ffffff; 41 | display: flex; 42 | align-items: center; 43 | justify-content: center; 44 | } 45 | </style> 46 | -------------------------------------------------------------------------------- /src/layouts/components/Settings/SelectLayoutMode.vue: -------------------------------------------------------------------------------- 1 | <script lang="ts" setup> 2 | import { useLayoutMode } from "@/hooks/useLayoutMode" 3 | import { LayoutModeEnum } from "@/constants/app-key" 4 | 5 | const { isLeft, isTop, isLeftTop, setLayoutMode } = useLayoutMode() 6 | </script> 7 | 8 | <template> 9 | <div class="select-layout-mode"> 10 | <el-tooltip content="左侧模式"> 11 | <el-container class="layout-mode left" :class="{ active: isLeft }" @click="setLayoutMode(LayoutModeEnum.Left)"> 12 | <el-aside /> 13 | <el-container> 14 | <el-header /> 15 | <el-main /> 16 | </el-container> 17 | </el-container> 18 | </el-tooltip> 19 | <el-tooltip content="顶部模式"> 20 | <el-container class="layout-mode top" :class="{ active: isTop }" @click="setLayoutMode(LayoutModeEnum.Top)"> 21 | <el-header /> 22 | <el-main /> 23 | </el-container> 24 | </el-tooltip> 25 | <el-tooltip content="混合模式"> 26 | <el-container 27 | class="layout-mode left-top" 28 | :class="{ active: isLeftTop }" 29 | @click="setLayoutMode(LayoutModeEnum.LeftTop)" 30 | > 31 | <el-header /> 32 | <el-container> 33 | <el-aside /> 34 | <el-main /> 35 | </el-container> 36 | </el-container> 37 | </el-tooltip> 38 | </div> 39 | </template> 40 | 41 | <style lang="scss" scoped> 42 | .select-layout-mode { 43 | display: flex; 44 | justify-content: space-between; 45 | } 46 | 47 | .layout-mode { 48 | width: 60px; 49 | flex-grow: 0; 50 | overflow: hidden; 51 | cursor: pointer; 52 | border-radius: 6px; 53 | border: 2px solid transparent; 54 | &:hover { 55 | border: 2px solid var(--el-color-primary); 56 | } 57 | } 58 | 59 | .active { 60 | border: 2px solid var(--el-color-primary); 61 | } 62 | 63 | .el-header { 64 | height: 12px; 65 | } 66 | 67 | .el-aside { 68 | width: 16px; 69 | } 70 | 71 | .left { 72 | .el-header { 73 | background-color: var(--el-fill-color-darker); 74 | } 75 | .el-aside { 76 | background-color: var(--el-color-primary); 77 | } 78 | .el-main { 79 | background-color: var(--el-fill-color-lighter); 80 | } 81 | } 82 | 83 | .top { 84 | .el-header { 85 | background-color: var(--el-color-primary); 86 | } 87 | .el-main { 88 | background-color: var(--el-fill-color-lighter); 89 | } 90 | } 91 | 92 | .left-top { 93 | .el-header { 94 | background-color: var(--el-fill-color-darker); 95 | } 96 | .el-aside { 97 | background-color: var(--el-color-primary); 98 | } 99 | .el-main { 100 | background-color: var(--el-fill-color-lighter); 101 | } 102 | } 103 | </style> 104 | -------------------------------------------------------------------------------- /src/layouts/components/Settings/index.vue: -------------------------------------------------------------------------------- 1 | <script lang="ts" setup> 2 | import { watchEffect } from "vue" 3 | import { storeToRefs } from "pinia" 4 | import { useSettingsStore } from "@/store/modules/settings" 5 | import { useLayoutMode } from "@/hooks/useLayoutMode" 6 | import { removeConfigLayout } from "@/utils/cache/local-storage" 7 | import SelectLayoutMode from "./SelectLayoutMode.vue" 8 | import { Refresh } from "@element-plus/icons-vue" 9 | 10 | const { isLeft } = useLayoutMode() 11 | const settingsStore = useSettingsStore() 12 | 13 | /** 使用 storeToRefs 将提取的属性保持其响应性 */ 14 | const { 15 | showTagsView, 16 | showLogo, 17 | fixedHeader, 18 | showFooter, 19 | showNotify, 20 | showThemeSwitch, 21 | showScreenfull, 22 | showSearchMenu, 23 | cacheTagsView, 24 | showWatermark, 25 | showGreyMode, 26 | showColorWeakness 27 | } = storeToRefs(settingsStore) 28 | 29 | /** 定义 switch 设置项 */ 30 | const switchSettings = { 31 | 显示标签栏: showTagsView, 32 | "显示 Logo": showLogo, 33 | "固定 Header": fixedHeader, 34 | "显示页脚 Footer": showFooter, 35 | 显示消息通知: showNotify, 36 | 显示切换主题按钮: showThemeSwitch, 37 | 显示全屏按钮: showScreenfull, 38 | 显示搜索按钮: showSearchMenu, 39 | 是否缓存标签栏: cacheTagsView, 40 | 开启系统水印: showWatermark, 41 | 显示灰色模式: showGreyMode, 42 | 显示色弱模式: showColorWeakness 43 | } 44 | 45 | /** 非左侧模式时,Header 都是 fixed 布局 */ 46 | watchEffect(() => { 47 | !isLeft.value && (fixedHeader.value = true) 48 | }) 49 | 50 | /** 重置项目配置 */ 51 | const resetConfigLayout = () => { 52 | removeConfigLayout() 53 | location.reload() 54 | } 55 | </script> 56 | 57 | <template> 58 | <div class="setting-container"> 59 | <h4>布局配置</h4> 60 | <SelectLayoutMode /> 61 | <el-divider /> 62 | <h4>功能配置</h4> 63 | <div class="setting-item" v-for="(settingValue, settingName, index) in switchSettings" :key="index"> 64 | <span class="setting-name">{{ settingName }}</span> 65 | <el-switch v-model="settingValue.value" :disabled="!isLeft && settingName === '固定 Header'" /> 66 | </div> 67 | <el-button type="danger" :icon="Refresh" @click="resetConfigLayout">重 置</el-button> 68 | </div> 69 | </template> 70 | 71 | <style lang="scss" scoped> 72 | @import "@/styles/mixins.scss"; 73 | 74 | .setting-container { 75 | padding: 20px; 76 | .setting-item { 77 | font-size: 14px; 78 | color: var(--el-text-color-regular); 79 | padding: 5px 0; 80 | display: flex; 81 | justify-content: space-between; 82 | align-items: center; 83 | .setting-name { 84 | @extend %ellipsis; 85 | } 86 | } 87 | .el-button { 88 | margin-top: 40px; 89 | width: 100%; 90 | } 91 | } 92 | </style> 93 | -------------------------------------------------------------------------------- /src/layouts/components/Sidebar/SidebarItem.vue: -------------------------------------------------------------------------------- 1 | <script lang="ts" setup> 2 | import { computed } from "vue" 3 | import { type RouteRecordRaw } from "vue-router" 4 | import SidebarItemLink from "./SidebarItemLink.vue" 5 | import { isExternal } from "@/utils/validate" 6 | import path from "path-browserify" 7 | 8 | interface Props { 9 | item: RouteRecordRaw 10 | basePath?: string 11 | } 12 | 13 | const props = withDefaults(defineProps<Props>(), { 14 | basePath: "" 15 | }) 16 | 17 | /** 是否始终显示根菜单 */ 18 | const alwaysShowRootMenu = computed(() => props.item.meta?.alwaysShow) 19 | 20 | /** 显示的子菜单 */ 21 | const showingChildren = computed(() => { 22 | return props.item.children?.filter((child) => !child.meta?.hidden) ?? [] 23 | }) 24 | 25 | /** 显示的子菜单数量 */ 26 | const showingChildNumber = computed(() => { 27 | return showingChildren.value.length 28 | }) 29 | 30 | /** 唯一的子菜单项 */ 31 | const theOnlyOneChild = computed(() => { 32 | const number = showingChildNumber.value 33 | switch (true) { 34 | case number > 1: 35 | return null 36 | case number === 1: 37 | return showingChildren.value[0] 38 | default: 39 | return { ...props.item, path: "" } 40 | } 41 | }) 42 | 43 | /** 解析路径 */ 44 | const resolvePath = (routePath: string) => { 45 | switch (true) { 46 | case isExternal(routePath): 47 | return routePath 48 | case isExternal(props.basePath): 49 | return props.basePath 50 | default: 51 | return path.resolve(props.basePath, routePath) 52 | } 53 | } 54 | </script> 55 | 56 | <template> 57 | <template v-if="!alwaysShowRootMenu && theOnlyOneChild && !theOnlyOneChild.children"> 58 | <SidebarItemLink v-if="theOnlyOneChild.meta" :to="resolvePath(theOnlyOneChild.path)"> 59 | <el-menu-item :index="resolvePath(theOnlyOneChild.path)"> 60 | <SvgIcon v-if="theOnlyOneChild.meta.svgIcon" :name="theOnlyOneChild.meta.svgIcon" /> 61 | <component v-else-if="theOnlyOneChild.meta.elIcon" :is="theOnlyOneChild.meta.elIcon" class="el-icon" /> 62 | <template v-if="theOnlyOneChild.meta.title" #title> 63 | {{ theOnlyOneChild.meta.title }} 64 | </template> 65 | </el-menu-item> 66 | </SidebarItemLink> 67 | </template> 68 | <el-sub-menu v-else :index="resolvePath(props.item.path)" teleported> 69 | <template #title> 70 | <SvgIcon v-if="props.item.meta?.svgIcon" :name="props.item.meta.svgIcon" /> 71 | <component v-else-if="props.item.meta?.elIcon" :is="props.item.meta.elIcon" class="el-icon" /> 72 | <span v-if="props.item.meta?.title">{{ props.item.meta.title }}</span> 73 | </template> 74 | <template v-if="props.item.children"> 75 | <SidebarItem 76 | v-for="child in showingChildren" 77 | :key="child.path" 78 | :item="child" 79 | :base-path="resolvePath(child.path)" 80 | /> 81 | </template> 82 | </el-sub-menu> 83 | </template> 84 | 85 | <style lang="scss" scoped> 86 | .svg-icon { 87 | min-width: 1em; 88 | margin-right: 12px; 89 | font-size: 18px; 90 | } 91 | 92 | .el-icon { 93 | width: 1em !important; 94 | margin-right: 12px !important; 95 | font-size: 18px; 96 | } 97 | </style> 98 | -------------------------------------------------------------------------------- /src/layouts/components/Sidebar/SidebarItemLink.vue: -------------------------------------------------------------------------------- 1 | <script lang="ts" setup> 2 | import { isExternal } from "@/utils/validate" 3 | 4 | interface Props { 5 | to: string 6 | } 7 | 8 | const props = defineProps<Props>() 9 | </script> 10 | 11 | <template> 12 | <a v-if="isExternal(props.to)" :href="props.to" target="_blank" rel="noopener"> 13 | <slot /> 14 | </a> 15 | <router-link v-else :to="props.to"> 16 | <slot /> 17 | </router-link> 18 | </template> 19 | -------------------------------------------------------------------------------- /src/layouts/components/Sidebar/index.vue: -------------------------------------------------------------------------------- 1 | <script lang="ts" setup> 2 | import { computed } from "vue" 3 | import { useRoute } from "vue-router" 4 | import { useAppStore } from "@/store/modules/app" 5 | import { usePermissionStore } from "@/store/modules/permission" 6 | import { useSettingsStore } from "@/store/modules/settings" 7 | import SidebarItem from "./SidebarItem.vue" 8 | import Logo from "../Logo/index.vue" 9 | import { useDevice } from "@/hooks/useDevice" 10 | import { useLayoutMode } from "@/hooks/useLayoutMode" 11 | import { getCssVar } from "@/utils/css" 12 | 13 | const v3SidebarMenuBgColor = getCssVar("--v3-sidebar-menu-bg-color") 14 | const v3SidebarMenuTextColor = getCssVar("--v3-sidebar-menu-text-color") 15 | const v3SidebarMenuActiveTextColor = getCssVar("--v3-sidebar-menu-active-text-color") 16 | 17 | const { isMobile } = useDevice() 18 | const { isLeft, isTop } = useLayoutMode() 19 | const route = useRoute() 20 | const appStore = useAppStore() 21 | const permissionStore = usePermissionStore() 22 | const settingsStore = useSettingsStore() 23 | 24 | const activeMenu = computed(() => { 25 | const { 26 | meta: { activeMenu }, 27 | path 28 | } = route 29 | return activeMenu ? activeMenu : path 30 | }) 31 | const noHiddenRoutes = computed(() => permissionStore.routes.filter((item) => !item.meta?.hidden)) 32 | const isCollapse = computed(() => !appStore.sidebar.opened) 33 | const isLogo = computed(() => isLeft.value && settingsStore.showLogo) 34 | const backgroundColor = computed(() => (isLeft.value ? v3SidebarMenuBgColor : undefined)) 35 | const textColor = computed(() => (isLeft.value ? v3SidebarMenuTextColor : undefined)) 36 | const activeTextColor = computed(() => (isLeft.value ? v3SidebarMenuActiveTextColor : undefined)) 37 | const sidebarMenuItemHeight = computed(() => { 38 | return !isTop.value ? "var(--v3-sidebar-menu-item-height)" : "var(--v3-navigationbar-height)" 39 | }) 40 | const sidebarMenuHoverBgColor = computed(() => { 41 | return !isTop.value ? "var(--v3-sidebar-menu-hover-bg-color)" : "transparent" 42 | }) 43 | const tipLineWidth = computed(() => { 44 | return !isTop.value ? "2px" : "0px" 45 | }) 46 | </script> 47 | 48 | <template> 49 | <div :class="{ 'has-logo': isLogo }"> 50 | <Logo v-if="isLogo" :collapse="isCollapse" /> 51 | <el-scrollbar wrap-class="scrollbar-wrapper"> 52 | <el-menu 53 | :default-active="activeMenu" 54 | :collapse="isCollapse && !isTop" 55 | :background-color="backgroundColor" 56 | :text-color="textColor" 57 | :active-text-color="activeTextColor" 58 | :unique-opened="true" 59 | :collapse-transition="false" 60 | :mode="isTop && !isMobile ? 'horizontal' : 'vertical'" 61 | > 62 | <SidebarItem v-for="route in noHiddenRoutes" :key="route.path" :item="route" :base-path="route.path" /> 63 | </el-menu> 64 | </el-scrollbar> 65 | </div> 66 | </template> 67 | 68 | <style lang="scss" scoped> 69 | %tip-line { 70 | &::before { 71 | content: ""; 72 | position: absolute; 73 | top: 0; 74 | left: 0; 75 | width: v-bind(tipLineWidth); 76 | height: 100%; 77 | background-color: var(--v3-sidebar-menu-tip-line-bg-color); 78 | } 79 | } 80 | 81 | .has-logo { 82 | .el-scrollbar { 83 | height: calc(100% - var(--v3-header-height)); 84 | } 85 | } 86 | 87 | .el-scrollbar { 88 | height: 100%; 89 | :deep(.scrollbar-wrapper) { 90 | // 限制水平宽度 91 | overflow-x: hidden !important; 92 | } 93 | // 滚动条 94 | :deep(.el-scrollbar__bar) { 95 | &.is-horizontal { 96 | // 隐藏水平滚动条 97 | display: none; 98 | } 99 | } 100 | } 101 | 102 | .el-menu { 103 | border: none; 104 | width: 100% !important; 105 | } 106 | 107 | .el-menu--horizontal { 108 | height: v-bind(sidebarMenuItemHeight); 109 | } 110 | 111 | :deep(.el-menu-item), 112 | :deep(.el-sub-menu__title), 113 | :deep(.el-sub-menu .el-menu-item), 114 | :deep(.el-menu--horizontal .el-menu-item) { 115 | height: v-bind(sidebarMenuItemHeight); 116 | line-height: v-bind(sidebarMenuItemHeight); 117 | &.is-active, 118 | &:hover { 119 | background-color: v-bind(sidebarMenuHoverBgColor); 120 | } 121 | } 122 | 123 | :deep(.el-sub-menu) { 124 | &.is-active { 125 | > .el-sub-menu__title { 126 | color: v-bind(activeTextColor) !important; 127 | } 128 | } 129 | } 130 | 131 | :deep(.el-menu-item.is-active) { 132 | @extend %tip-line; 133 | } 134 | 135 | .el-menu--collapse { 136 | :deep(.el-sub-menu.is-active) { 137 | .el-sub-menu__title { 138 | @extend %tip-line; 139 | } 140 | } 141 | } 142 | </style> 143 | -------------------------------------------------------------------------------- /src/layouts/components/TagsView/ScrollPane.vue: -------------------------------------------------------------------------------- 1 | <script lang="ts" setup> 2 | import { ref, nextTick } from "vue" 3 | import { RouterLink, useRoute } from "vue-router" 4 | import { useSettingsStore } from "@/store/modules/settings" 5 | import { useRouteListener } from "@/hooks/useRouteListener" 6 | import Screenfull from "@/components/Screenfull/index.vue" 7 | import { ElScrollbar } from "element-plus" 8 | import { ArrowLeft, ArrowRight } from "@element-plus/icons-vue" 9 | 10 | interface Props { 11 | tagRefs: InstanceType<typeof RouterLink>[] 12 | } 13 | 14 | const props = defineProps<Props>() 15 | 16 | const route = useRoute() 17 | const settingsStore = useSettingsStore() 18 | const { listenerRouteChange } = useRouteListener() 19 | 20 | /** 滚动条组件元素的引用 */ 21 | const scrollbarRef = ref<InstanceType<typeof ElScrollbar>>() 22 | /** 滚动条内容元素的引用 */ 23 | const scrollbarContentRef = ref<HTMLDivElement>() 24 | 25 | /** 当前滚动条距离左边的距离 */ 26 | let currentScrollLeft = 0 27 | /** 每次滚动距离 */ 28 | const translateDistance = 200 29 | 30 | /** 滚动时触发 */ 31 | const scroll = ({ scrollLeft }: { scrollLeft: number }) => { 32 | currentScrollLeft = scrollLeft 33 | } 34 | 35 | /** 鼠标滚轮滚动时触发 */ 36 | const wheelScroll = ({ deltaY }: WheelEvent) => { 37 | if (/^-/.test(deltaY.toString())) { 38 | scrollTo("left") 39 | } else { 40 | scrollTo("right") 41 | } 42 | } 43 | 44 | /** 获取可能需要的宽度 */ 45 | const getWidth = () => { 46 | /** 可滚动内容的长度 */ 47 | const scrollbarContentRefWidth = scrollbarContentRef.value!.clientWidth 48 | /** 滚动可视区宽度 */ 49 | const scrollbarRefWidth = scrollbarRef.value!.wrapRef!.clientWidth 50 | /** 最后剩余可滚动的宽度 */ 51 | const lastDistance = scrollbarContentRefWidth - scrollbarRefWidth - currentScrollLeft 52 | 53 | return { scrollbarContentRefWidth, scrollbarRefWidth, lastDistance } 54 | } 55 | 56 | /** 左右滚动 */ 57 | const scrollTo = (direction: "left" | "right", distance: number = translateDistance) => { 58 | let scrollLeft = 0 59 | const { scrollbarContentRefWidth, scrollbarRefWidth, lastDistance } = getWidth() 60 | // 没有横向滚动条,直接结束 61 | if (scrollbarRefWidth > scrollbarContentRefWidth) return 62 | if (direction === "left") { 63 | scrollLeft = Math.max(0, currentScrollLeft - distance) 64 | } else { 65 | scrollLeft = Math.min(currentScrollLeft + distance, currentScrollLeft + lastDistance) 66 | } 67 | scrollbarRef.value!.setScrollLeft(scrollLeft) 68 | } 69 | 70 | /** 移动到目标位置 */ 71 | const moveTo = () => { 72 | const tagRefs = props.tagRefs 73 | for (let i = 0; i < tagRefs.length; i++) { 74 | // @ts-ignore 75 | if (route.path === tagRefs[i].$props.to.path) { 76 | // @ts-ignore 77 | const el: HTMLElement = tagRefs[i].$el 78 | const offsetWidth = el.offsetWidth 79 | const offsetLeft = el.offsetLeft 80 | const { scrollbarRefWidth } = getWidth() 81 | // 当前 tag 在可视区域左边时 82 | if (offsetLeft < currentScrollLeft) { 83 | const distance = currentScrollLeft - offsetLeft 84 | scrollTo("left", distance) 85 | return 86 | } 87 | // 当前 tag 在可视区域右边时 88 | const width = scrollbarRefWidth + currentScrollLeft - offsetWidth 89 | if (offsetLeft > width) { 90 | const distance = offsetLeft - width 91 | scrollTo("right", distance) 92 | return 93 | } 94 | } 95 | } 96 | } 97 | 98 | /** 监听路由变化,移动到目标位置 */ 99 | listenerRouteChange(() => { 100 | nextTick(moveTo) 101 | }) 102 | </script> 103 | 104 | <template> 105 | <div class="scroll-container"> 106 | <el-icon class="arrow left" @click="scrollTo('left')"> 107 | <ArrowLeft /> 108 | </el-icon> 109 | <el-scrollbar ref="scrollbarRef" @wheel.passive="wheelScroll" @scroll="scroll"> 110 | <div ref="scrollbarContentRef" class="scrollbar-content"> 111 | <slot /> 112 | </div> 113 | </el-scrollbar> 114 | <el-icon class="arrow right" @click="scrollTo('right')"> 115 | <ArrowRight /> 116 | </el-icon> 117 | <Screenfull v-if="settingsStore.showScreenfull" :content="true" class="screenfull" /> 118 | </div> 119 | </template> 120 | 121 | <style lang="scss" scoped> 122 | .scroll-container { 123 | height: 100%; 124 | user-select: none; 125 | display: flex; 126 | justify-content: space-between; 127 | .arrow { 128 | width: 40px; 129 | height: 100%; 130 | font-size: 18px; 131 | cursor: pointer; 132 | &.left { 133 | box-shadow: 5px 0 5px -6px var(--el-border-color-darker); 134 | } 135 | &.right { 136 | box-shadow: -5px 0 5px -6px var(--el-border-color-darker); 137 | } 138 | } 139 | .el-scrollbar { 140 | flex: 1; 141 | // 防止换行(超出宽度时,显示滚动条) 142 | white-space: nowrap; 143 | .scrollbar-content { 144 | display: inline-block; 145 | } 146 | } 147 | .screenfull { 148 | width: 40px; 149 | display: flex; 150 | justify-content: center; 151 | align-items: center; 152 | cursor: pointer; 153 | } 154 | } 155 | </style> 156 | -------------------------------------------------------------------------------- /src/layouts/components/index.ts: -------------------------------------------------------------------------------- 1 | export { default as AppMain } from "./AppMain.vue" 2 | export { default as NavigationBar } from "./NavigationBar/index.vue" 3 | export { default as Settings } from "./Settings/index.vue" 4 | export { default as Sidebar } from "./Sidebar/index.vue" 5 | export { default as TagsView } from "./TagsView/index.vue" 6 | export { default as RightPanel } from "./RightPanel/index.vue" 7 | export { default as Logo } from "./Logo/index.vue" 8 | -------------------------------------------------------------------------------- /src/layouts/hooks/useResize.ts: -------------------------------------------------------------------------------- 1 | import { onBeforeMount, onMounted, onBeforeUnmount } from "vue" 2 | import { useAppStore } from "@/store/modules/app" 3 | import { useRouteListener } from "@/hooks/useRouteListener" 4 | import { DeviceEnum } from "@/constants/app-key" 5 | 6 | /** 参考 Bootstrap 的响应式设计将最大移动端宽度设置为 992 */ 7 | const MAX_MOBILE_WIDTH = 992 8 | 9 | /** 根据浏览器宽度变化,变换 Layout 布局 */ 10 | export default () => { 11 | const appStore = useAppStore() 12 | const { listenerRouteChange } = useRouteListener() 13 | 14 | /** 用于判断当前设备是否为移动端 */ 15 | const _isMobile = () => { 16 | const rect = document.body.getBoundingClientRect() 17 | return rect.width - 1 < MAX_MOBILE_WIDTH 18 | } 19 | 20 | /** 用于处理窗口大小变化事件 */ 21 | const _resizeHandler = () => { 22 | if (!document.hidden) { 23 | const isMobile = _isMobile() 24 | appStore.toggleDevice(isMobile ? DeviceEnum.Mobile : DeviceEnum.Desktop) 25 | isMobile && appStore.closeSidebar(true) 26 | } 27 | } 28 | /** 监听路由变化,根据设备类型调整布局 */ 29 | listenerRouteChange(() => { 30 | if (appStore.device === DeviceEnum.Mobile && appStore.sidebar.opened) { 31 | appStore.closeSidebar(false) 32 | } 33 | }) 34 | 35 | /** 在组件挂载前添加窗口大小变化事件监听器 */ 36 | onBeforeMount(() => { 37 | window.addEventListener("resize", _resizeHandler) 38 | }) 39 | 40 | /** 在组件挂载后根据窗口大小判断设备类型并调整布局 */ 41 | onMounted(() => { 42 | if (_isMobile()) { 43 | appStore.toggleDevice(DeviceEnum.Mobile) 44 | appStore.closeSidebar(true) 45 | } 46 | }) 47 | 48 | /** 在组件卸载前移除窗口大小变化事件监听器 */ 49 | onBeforeUnmount(() => { 50 | window.removeEventListener("resize", _resizeHandler) 51 | }) 52 | } 53 | -------------------------------------------------------------------------------- /src/layouts/index.vue: -------------------------------------------------------------------------------- 1 | <script lang="ts" setup> 2 | import { watchEffect } from "vue" 3 | import { storeToRefs } from "pinia" 4 | import { useSettingsStore } from "@/store/modules/settings" 5 | import useResize from "./hooks/useResize" 6 | import { APP_TITLE } from "@/hooks/useTitle" 7 | import { useWatermark } from "@/hooks/useWatermark" 8 | import { useDevice } from "@/hooks/useDevice" 9 | import { useLayoutMode } from "@/hooks/useLayoutMode" 10 | import LeftMode from "./LeftMode.vue" 11 | import TopMode from "./TopMode.vue" 12 | import LeftTopMode from "./LeftTopMode.vue" 13 | import { Settings, RightPanel } from "./components" 14 | import { getCssVar, setCssVar } from "@/utils/css" 15 | 16 | /** Layout 布局响应式 */ 17 | useResize() 18 | 19 | const { setWatermark, clearWatermark } = useWatermark() 20 | const { isMobile } = useDevice() 21 | const { isLeft, isTop, isLeftTop } = useLayoutMode() 22 | const settingsStore = useSettingsStore() 23 | const { showSettings, showTagsView, showWatermark } = storeToRefs(settingsStore) 24 | 25 | //#region 隐藏标签栏时删除其高度,是为了让 Logo 组件高度和 Header 区域高度始终一致 26 | const cssVarName = "--v3-tagsview-height" 27 | const v3TagsviewHeight = getCssVar(cssVarName) 28 | watchEffect(() => { 29 | showTagsView.value ? setCssVar(cssVarName, v3TagsviewHeight) : setCssVar(cssVarName, "0px") 30 | }) 31 | //#endregion 32 | 33 | /** 开启或关闭系统水印 */ 34 | watchEffect(() => { 35 | showWatermark.value ? setWatermark(APP_TITLE) : clearWatermark() 36 | }) 37 | </script> 38 | 39 | <template> 40 | <div> 41 | <!-- 左侧模式 --> 42 | <LeftMode v-if="isLeft || isMobile" /> 43 | <!-- 顶部模式 --> 44 | <TopMode v-else-if="isTop" /> 45 | <!-- 混合模式 --> 46 | <LeftTopMode v-else-if="isLeftTop" /> 47 | <!-- 右侧设置面板 --> 48 | <RightPanel v-if="showSettings"> 49 | <Settings /> 50 | </RightPanel> 51 | </div> 52 | </template> 53 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import "./utils/with-prototype" 2 | // core 3 | import App from "@/App.vue" 4 | import { createApp } from "vue" 5 | import { pinia } from "@/store" 6 | import { router } from "@/router" 7 | import "@/router/permission" 8 | // load 9 | import { loadSvg } from "@/icons" 10 | import { loadPlugins } from "@/plugins" 11 | import { loadDirectives } from "@/directives" 12 | // css 13 | import "uno.css" 14 | import "normalize.css" 15 | import "element-plus/dist/index.css" 16 | import "element-plus/theme-chalk/dark/css-vars.css" 17 | import "vxe-table/lib/style.css" 18 | import "vxe-table-plugin-element/dist/style.css" 19 | import "@/styles/index.scss" 20 | 21 | const app = createApp(App) 22 | 23 | /** 加载插件 */ 24 | loadPlugins(app) 25 | /** 加载全局 SVG */ 26 | loadSvg(app) 27 | /** 加载自定义指令 */ 28 | loadDirectives(app) 29 | 30 | app.use(pinia).use(router) 31 | router.isReady().then(() => app.mount("#app")) 32 | 33 | //#region 冻结自定义属性 34 | try { 35 | const customProps = ["vRemote", "vIpcRenderer", "vLog"] 36 | customProps.forEach((prop) => { 37 | Object.defineProperty(window, prop, { 38 | writable: false, 39 | configurable: false 40 | }) 41 | }) 42 | } catch (reason: any) { 43 | console.error("[禁止修改自定义属性]", reason) 44 | } 45 | //#endregion 46 | -------------------------------------------------------------------------------- /src/plugins/element-plus-icon/index.ts: -------------------------------------------------------------------------------- 1 | import { type App } from "vue" 2 | import * as ElementPlusIconsVue from "@element-plus/icons-vue" 3 | 4 | export function loadElementPlusIcon(app: App) { 5 | /** 注册所有 Element Plus Icon */ 6 | for (const [key, component] of Object.entries(ElementPlusIconsVue)) { 7 | app.component(key, component) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/plugins/element-plus/index.ts: -------------------------------------------------------------------------------- 1 | import { type App } from "vue" 2 | import ElementPlus from "element-plus" 3 | 4 | export function loadElementPlus(app: App) { 5 | /** Element Plus 组件完整引入 */ 6 | app.use(ElementPlus) 7 | } 8 | -------------------------------------------------------------------------------- /src/plugins/index.ts: -------------------------------------------------------------------------------- 1 | import { type App } from "vue" 2 | import { loadElementPlus } from "./element-plus" 3 | import { loadElementPlusIcon } from "./element-plus-icon" 4 | import { loadVxeTable } from "./vxe-table" 5 | 6 | export function loadPlugins(app: App) { 7 | loadElementPlus(app) 8 | loadElementPlusIcon(app) 9 | loadVxeTable(app) 10 | } 11 | -------------------------------------------------------------------------------- /src/plugins/vxe-table/index.ts: -------------------------------------------------------------------------------- 1 | import { type App } from "vue" 2 | // https://VxeTable.cn 3 | import VxeTable from "vxe-table" 4 | // https://github.com/x-extends/vxe-table-plugin-element 5 | import VxeTablePluginElement from "vxe-table-plugin-element" 6 | 7 | VxeTable.use(VxeTablePluginElement) 8 | 9 | /** 全局默认参数 */ 10 | VxeTable.setConfig({ 11 | /** 全局尺寸 */ 12 | size: "medium", 13 | /** 全局 zIndex 起始值,如果项目的的 z-index 样式值过大时就需要跟随设置更大,避免被遮挡 */ 14 | zIndex: 9999, 15 | /** 版本号,对于某些带数据缓存的功能有用到,上升版本号可以用于重置数据 */ 16 | version: 0, 17 | loading: { 18 | text: "" 19 | }, 20 | table: { 21 | showHeader: true, 22 | showOverflow: "tooltip", 23 | showHeaderOverflow: "tooltip", 24 | autoResize: true, 25 | // stripe: false, 26 | border: "inner", 27 | // round: false, 28 | emptyText: "暂无数据", 29 | rowConfig: { 30 | useKey: true, 31 | isHover: true, 32 | isCurrent: true 33 | }, 34 | columnConfig: { 35 | useKey: true, 36 | resizable: false 37 | }, 38 | align: "center", 39 | headerAlign: "center" 40 | }, 41 | pager: { 42 | // size: "medium", 43 | /** 配套的样式 */ 44 | perfect: false, 45 | pageSize: 10, 46 | pagerCount: 5, 47 | pageSizes: [10, 20, 50], 48 | layouts: ["Total", "Home", "PrevJump", "PrevPage", "JumpNumber", "NextPage", "NextJump", "End", "Sizes", "FullJump"] 49 | }, 50 | modal: { 51 | minWidth: 500, 52 | minHeight: 400, 53 | dblclickZoom: false, 54 | transfer: true, 55 | draggable: false 56 | } 57 | }) 58 | 59 | export function loadVxeTable(app: App) { 60 | /** Vxe Table 组件完整引入 */ 61 | app.use(VxeTable) 62 | } 63 | -------------------------------------------------------------------------------- /src/router/helper.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type Router, 3 | type RouteRecordNormalized, 4 | type RouteRecordRaw, 5 | createRouter, 6 | createWebHashHistory 7 | } from "vue-router" 8 | import { cloneDeep, omit } from "lodash-es" 9 | 10 | /** 路由模式 */ 11 | export const history = createWebHashHistory() 12 | 13 | /** 路由降级(把三级及其以上的路由转化为二级路由) */ 14 | export const flatMultiLevelRoutes = (routes: RouteRecordRaw[]) => { 15 | const routesMirror = cloneDeep(routes) 16 | routesMirror.forEach((route) => { 17 | // 如果路由是三级及其以上路由,对其进行降级处理 18 | isMultipleRoute(route) && promoteRouteLevel(route) 19 | }) 20 | return routesMirror 21 | } 22 | 23 | /** 判断路由层级是否大于 2 */ 24 | const isMultipleRoute = (route: RouteRecordRaw) => { 25 | const children = route.children 26 | if (children?.length) { 27 | // 只要有一个子路由的 children 长度大于 0,就说明是三级及其以上路由 28 | return children.some((child) => child.children?.length) 29 | } 30 | return false 31 | } 32 | 33 | /** 生成二级路由 */ 34 | const promoteRouteLevel = (route: RouteRecordRaw) => { 35 | // 创建 router 实例是为了获取到当前传入的 route 的所有路由信息 36 | let router: Router | null = createRouter({ 37 | history, 38 | routes: [route] 39 | }) 40 | const routes = router.getRoutes() 41 | // 在 addToChildren 函数中使用上面获取到的路由信息来更新 route 的 children 42 | addToChildren(routes, route.children || [], route) 43 | router = null 44 | // 转为二级路由后,去除所有子路由中的 children 45 | route.children = route.children?.map((item) => omit(item, "children") as RouteRecordRaw) 46 | } 47 | 48 | /** 将给定的子路由添加到指定的路由模块中 */ 49 | const addToChildren = (routes: RouteRecordNormalized[], children: RouteRecordRaw[], routeModule: RouteRecordRaw) => { 50 | children.forEach((child) => { 51 | const route = routes.find((item) => item.name === child.name) 52 | if (route) { 53 | // 初始化 routeModule 的 children 54 | routeModule.children = routeModule.children || [] 55 | // 如果 routeModule 的 children 属性中不包含该路由,则将其添加进去 56 | if (!routeModule.children.includes(route)) { 57 | routeModule.children.push(route) 58 | } 59 | // 如果该子路由还有自己的子路由,则递归调用此函数将它们也添加进去 60 | if (child.children?.length) { 61 | addToChildren(routes, child.children, routeModule) 62 | } 63 | } 64 | }) 65 | } 66 | -------------------------------------------------------------------------------- /src/router/permission.ts: -------------------------------------------------------------------------------- 1 | import { router } from "@/router" 2 | import { useUserStoreHook } from "@/store/modules/user" 3 | import { usePermissionStoreHook } from "@/store/modules/permission" 4 | import { ElMessage } from "element-plus" 5 | import { setRouteChange } from "@/hooks/useRouteListener" 6 | import { useTitle } from "@/hooks/useTitle" 7 | import { getToken } from "@/utils/cache/session-storage" 8 | import routeSettings from "@/config/route" 9 | import isWhiteList from "@/config/white-list" 10 | import NProgress from "nprogress" 11 | import "nprogress/nprogress.css" 12 | 13 | NProgress.configure({ showSpinner: false }) 14 | const { setTitle } = useTitle() 15 | const userStore = useUserStoreHook() 16 | const permissionStore = usePermissionStoreHook() 17 | 18 | router.beforeEach(async (to, _from, next) => { 19 | NProgress.start() 20 | // 如果没有登陆 21 | if (!getToken()) { 22 | // 如果在免登录的白名单中,则直接进入 23 | if (isWhiteList(to)) return next() 24 | // 其他没有访问权限的页面将被重定向到登录页面 25 | return next("/login") 26 | } 27 | 28 | // 如果已经登录,并准备进入 Login 页面,则重定向到主页 29 | if (to.path === "/login") { 30 | return next({ path: "/" }) 31 | } 32 | 33 | // 如果用户已经获得其权限角色 34 | if (userStore.roles.length !== 0) return next() 35 | 36 | // 否则要重新获取权限角色 37 | try { 38 | await userStore.getInfo() 39 | // 注意:角色必须是一个数组! 例如: ["admin"] 或 ["developer", "editor"] 40 | const roles = userStore.roles 41 | // 生成可访问的 Routes 42 | routeSettings.dynamic ? permissionStore.setRoutes(roles) : permissionStore.setAllRoutes() 43 | // 将 "有访问权限的动态路由" 添加到 Router 中 44 | permissionStore.addRoutes.forEach((route) => router.addRoute(route)) 45 | // 设置 replace: true, 因此导航将不会留下历史记录 46 | next({ ...to, replace: true }) 47 | } catch (error) { 48 | // 过程中发生任何错误,都直接重置 Token,并重定向到登录页面 49 | userStore.resetToken() 50 | ElMessage.error((error as Error).message || "路由守卫过程发生错误") 51 | next("/login") 52 | } 53 | }) 54 | 55 | router.afterEach((to) => { 56 | setRouteChange(to) 57 | setTitle(to.meta.title) 58 | NProgress.done() 59 | }) 60 | -------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | import { createPinia } from "pinia" 2 | 3 | export const pinia = createPinia() 4 | -------------------------------------------------------------------------------- /src/store/modules/app.ts: -------------------------------------------------------------------------------- 1 | import { reactive, ref, watch } from "vue" 2 | import { pinia } from "@/store" 3 | import { defineStore } from "pinia" 4 | import { getSidebarStatus, setSidebarStatus } from "@/utils/cache/local-storage" 5 | import { DeviceEnum, SIDEBAR_OPENED, SIDEBAR_CLOSED } from "@/constants/app-key" 6 | 7 | interface Sidebar { 8 | opened: boolean 9 | withoutAnimation: boolean 10 | } 11 | 12 | /** 设置侧边栏状态本地缓存 */ 13 | function handleSidebarStatus(opened: boolean) { 14 | opened ? setSidebarStatus(SIDEBAR_OPENED) : setSidebarStatus(SIDEBAR_CLOSED) 15 | } 16 | 17 | export const useAppStore = defineStore("app", () => { 18 | /** 侧边栏状态 */ 19 | const sidebar: Sidebar = reactive({ 20 | opened: getSidebarStatus() !== SIDEBAR_CLOSED, 21 | withoutAnimation: false 22 | }) 23 | /** 设备类型 */ 24 | const device = ref<DeviceEnum>(DeviceEnum.Desktop) 25 | 26 | /** 监听侧边栏 opened 状态 */ 27 | watch( 28 | () => sidebar.opened, 29 | (opened) => handleSidebarStatus(opened) 30 | ) 31 | 32 | /** 切换侧边栏 */ 33 | const toggleSidebar = (withoutAnimation: boolean) => { 34 | sidebar.opened = !sidebar.opened 35 | sidebar.withoutAnimation = withoutAnimation 36 | } 37 | /** 关闭侧边栏 */ 38 | const closeSidebar = (withoutAnimation: boolean) => { 39 | sidebar.opened = false 40 | sidebar.withoutAnimation = withoutAnimation 41 | } 42 | /** 切换设备类型 */ 43 | const toggleDevice = (value: DeviceEnum) => { 44 | device.value = value 45 | } 46 | 47 | return { device, sidebar, toggleSidebar, closeSidebar, toggleDevice } 48 | }) 49 | 50 | /** 51 | * 在 SPA 应用中可用于在 pinia 实例被激活前使用 store 52 | * 在 SSR 应用中可用于在 setup 外使用 store 53 | */ 54 | export function useAppStoreHook() { 55 | return useAppStore(pinia) 56 | } 57 | -------------------------------------------------------------------------------- /src/store/modules/permission.ts: -------------------------------------------------------------------------------- 1 | import { ref } from "vue" 2 | import { pinia } from "@/store" 3 | import { defineStore } from "pinia" 4 | import { type RouteRecordRaw } from "vue-router" 5 | import { constantRoutes, dynamicRoutes } from "@/router" 6 | import { flatMultiLevelRoutes } from "@/router/helper" 7 | import routeSettings from "@/config/route" 8 | 9 | const hasPermission = (roles: string[], route: RouteRecordRaw) => { 10 | const routeRoles = route.meta?.roles 11 | return routeRoles ? roles.some((role) => routeRoles.includes(role)) : true 12 | } 13 | 14 | const filterDynamicRoutes = (routes: RouteRecordRaw[], roles: string[]) => { 15 | const res: RouteRecordRaw[] = [] 16 | routes.forEach((route) => { 17 | const tempRoute = { ...route } 18 | if (hasPermission(roles, tempRoute)) { 19 | if (tempRoute.children) { 20 | tempRoute.children = filterDynamicRoutes(tempRoute.children, roles) 21 | } 22 | res.push(tempRoute) 23 | } 24 | }) 25 | return res 26 | } 27 | 28 | export const usePermissionStore = defineStore("permission", () => { 29 | /** 可访问的路由 */ 30 | const routes = ref<RouteRecordRaw[]>([]) 31 | /** 有访问权限的动态路由 */ 32 | const addRoutes = ref<RouteRecordRaw[]>([]) 33 | 34 | /** 根据角色生成可访问的 Routes(可访问的路由 = 常驻路由 + 有访问权限的动态路由) */ 35 | const setRoutes = (roles: string[]) => { 36 | const accessedRoutes = filterDynamicRoutes(dynamicRoutes, roles) 37 | _set(accessedRoutes) 38 | } 39 | 40 | /** 所有路由 = 所有常驻路由 + 所有动态路由 */ 41 | const setAllRoutes = () => { 42 | _set(dynamicRoutes) 43 | } 44 | 45 | const _set = (accessedRoutes: RouteRecordRaw[]) => { 46 | routes.value = constantRoutes.concat(accessedRoutes) 47 | addRoutes.value = routeSettings.thirdLevelRouteCache ? flatMultiLevelRoutes(accessedRoutes) : accessedRoutes 48 | } 49 | 50 | return { routes, addRoutes, setRoutes, setAllRoutes } 51 | }) 52 | 53 | /** 54 | * 在 SPA 应用中可用于在 pinia 实例被激活前使用 store 55 | * 在 SSR 应用中可用于在 setup 外使用 store 56 | */ 57 | export function usePermissionStoreHook() { 58 | return usePermissionStore(pinia) 59 | } 60 | -------------------------------------------------------------------------------- /src/store/modules/settings.ts: -------------------------------------------------------------------------------- 1 | import { type Ref, ref, watch } from "vue" 2 | import { pinia } from "@/store" 3 | import { defineStore } from "pinia" 4 | import { type LayoutSettings, layoutSettings } from "@/config/layouts" 5 | import { setConfigLayout } from "@/utils/cache/local-storage" 6 | 7 | type SettingsStore = { 8 | // 使用映射类型来遍历 layoutSettings 对象的键 9 | [Key in keyof LayoutSettings]: Ref<LayoutSettings[Key]> 10 | } 11 | 12 | type SettingsStoreKey = keyof SettingsStore 13 | 14 | export const useSettingsStore = defineStore("settings", () => { 15 | /** 状态对象 */ 16 | const state = {} as SettingsStore 17 | // 遍历 layoutSettings 对象的键值对 18 | for (const [key, value] of Object.entries(layoutSettings)) { 19 | // 使用类型断言来指定 key 的类型,将 value 包装在 ref 函数中,创建一个响应式变量 20 | const refValue = ref(value) 21 | // @ts-ignore 22 | state[key as SettingsStoreKey] = refValue 23 | // 监听每个响应式变量 24 | watch(refValue, () => { 25 | // 缓存 26 | const settings = _getCacheData() 27 | setConfigLayout(settings) 28 | }) 29 | } 30 | /** 获取要缓存的数据:将 state 对象转化为 settings 对象 */ 31 | const _getCacheData = () => { 32 | const settings = {} as LayoutSettings 33 | for (const [key, value] of Object.entries(state)) { 34 | // @ts-ignore 35 | settings[key as SettingsStoreKey] = value.value 36 | } 37 | return settings 38 | } 39 | 40 | return state 41 | }) 42 | 43 | /** 44 | * 在 SPA 应用中可用于在 pinia 实例被激活前使用 store 45 | * 在 SSR 应用中可用于在 setup 外使用 store 46 | */ 47 | export function useSettingsStoreHook() { 48 | return useSettingsStore(pinia) 49 | } 50 | -------------------------------------------------------------------------------- /src/store/modules/tags-view.ts: -------------------------------------------------------------------------------- 1 | import { ref, watchEffect } from "vue" 2 | import { pinia } from "@/store" 3 | import { defineStore } from "pinia" 4 | import { useSettingsStore } from "./settings" 5 | import { type RouteLocationNormalized } from "vue-router" 6 | import { getVisitedViews, setVisitedViews, getCachedViews, setCachedViews } from "@/utils/cache/local-storage" 7 | 8 | export type TagView = Partial<RouteLocationNormalized> 9 | 10 | export const useTagsViewStore = defineStore("tags-view", () => { 11 | const { cacheTagsView } = useSettingsStore() 12 | const visitedViews = ref<TagView[]>(cacheTagsView ? getVisitedViews() : []) 13 | const cachedViews = ref<string[]>(cacheTagsView ? getCachedViews() : []) 14 | 15 | /** 缓存标签栏数据 */ 16 | watchEffect(() => { 17 | setVisitedViews(visitedViews.value) 18 | setCachedViews(cachedViews.value) 19 | }) 20 | 21 | //#region add 22 | const addVisitedView = (view: TagView) => { 23 | // 检查是否已经存在相同的 visitedView 24 | const index = visitedViews.value.findIndex((v) => v.path === view.path) 25 | if (index !== -1) { 26 | // 防止 query 参数丢失 27 | visitedViews.value[index].fullPath !== view.fullPath && (visitedViews.value[index] = { ...view }) 28 | } else { 29 | // 添加新的 visitedView 30 | visitedViews.value.push({ ...view }) 31 | } 32 | } 33 | 34 | const addCachedView = (view: TagView) => { 35 | if (typeof view.name !== "string") return 36 | if (cachedViews.value.includes(view.name)) return 37 | if (view.meta?.keepAlive) cachedViews.value.push(view.name) 38 | } 39 | //#endregion 40 | 41 | //#region del 42 | const delVisitedView = (view: TagView) => { 43 | const index = visitedViews.value.findIndex((v) => v.path === view.path) 44 | if (index !== -1) visitedViews.value.splice(index, 1) 45 | } 46 | 47 | const delCachedView = (view: TagView) => { 48 | if (typeof view.name !== "string") return 49 | const index = cachedViews.value.indexOf(view.name) 50 | if (index !== -1) cachedViews.value.splice(index, 1) 51 | } 52 | //#endregion 53 | 54 | //#region delOthers 55 | const delOthersVisitedViews = (view: TagView) => { 56 | visitedViews.value = visitedViews.value.filter((v) => { 57 | return v.meta?.affix || v.path === view.path 58 | }) 59 | } 60 | 61 | const delOthersCachedViews = (view: TagView) => { 62 | if (typeof view.name !== "string") return 63 | const index = cachedViews.value.indexOf(view.name) 64 | if (index !== -1) { 65 | cachedViews.value = cachedViews.value.slice(index, index + 1) 66 | } else { 67 | // 如果 index = -1, 没有缓存的 tags 68 | cachedViews.value = [] 69 | } 70 | } 71 | //#endregion 72 | 73 | //#region delAll 74 | const delAllVisitedViews = () => { 75 | // 保留固定的 tags 76 | visitedViews.value = visitedViews.value.filter((tag) => tag.meta?.affix) 77 | } 78 | 79 | const delAllCachedViews = () => { 80 | cachedViews.value = [] 81 | } 82 | //#endregion 83 | 84 | return { 85 | visitedViews, 86 | cachedViews, 87 | addVisitedView, 88 | addCachedView, 89 | delVisitedView, 90 | delCachedView, 91 | delOthersVisitedViews, 92 | delOthersCachedViews, 93 | delAllVisitedViews, 94 | delAllCachedViews 95 | } 96 | }) 97 | 98 | /** 99 | * 在 SPA 应用中可用于在 pinia 实例被激活前使用 store 100 | * 在 SSR 应用中可用于在 setup 外使用 store 101 | */ 102 | export function useTagsViewStoreHook() { 103 | return useTagsViewStore(pinia) 104 | } 105 | -------------------------------------------------------------------------------- /src/store/modules/user.ts: -------------------------------------------------------------------------------- 1 | import { ref } from "vue" 2 | import { pinia } from "@/store" 3 | import { defineStore } from "pinia" 4 | import { useTagsViewStore } from "./tags-view" 5 | import { useSettingsStore } from "./settings" 6 | import { getToken, removeToken, setToken } from "@/utils/cache/session-storage" 7 | import { resetRouter } from "@/router" 8 | import { loginApi, getUserInfoApi } from "@/api/login" 9 | import { type LoginRequestData } from "@/api/login/types/login" 10 | import routeSettings from "@/config/route" 11 | 12 | export const useUserStore = defineStore("user", () => { 13 | const token = ref<string>(getToken() || "") 14 | const roles = ref<string[]>([]) 15 | const username = ref<string>("") 16 | 17 | const tagsViewStore = useTagsViewStore() 18 | const settingsStore = useSettingsStore() 19 | 20 | /** 登录 */ 21 | const login = async ({ username, password, code }: LoginRequestData) => { 22 | const { data } = await loginApi({ username, password, code }) 23 | setToken(data.token) 24 | token.value = data.token 25 | } 26 | /** 获取用户详情 */ 27 | const getInfo = async () => { 28 | const { data } = await getUserInfoApi() 29 | username.value = data.username 30 | // 验证返回的 roles 是否为一个非空数组,否则塞入一个没有任何作用的默认角色,防止路由守卫逻辑进入无限循环 31 | roles.value = data.roles?.length > 0 ? data.roles : routeSettings.defaultRoles 32 | } 33 | /** 模拟角色变化 */ 34 | const changeRoles = async (role: string) => { 35 | const newToken = "token-" + role 36 | token.value = newToken 37 | setToken(newToken) 38 | // 用刷新页面代替重新登录 39 | window.location.reload() 40 | } 41 | /** 登出 */ 42 | const logout = () => { 43 | removeToken() 44 | token.value = "" 45 | roles.value = [] 46 | resetRouter() 47 | _resetTagsView() 48 | } 49 | /** 重置 Token */ 50 | const resetToken = () => { 51 | removeToken() 52 | token.value = "" 53 | roles.value = [] 54 | } 55 | /** 重置 Visited Views 和 Cached Views */ 56 | const _resetTagsView = () => { 57 | if (!settingsStore.cacheTagsView) { 58 | tagsViewStore.delAllVisitedViews() 59 | tagsViewStore.delAllCachedViews() 60 | } 61 | } 62 | 63 | return { token, roles, username, login, getInfo, changeRoles, logout, resetToken } 64 | }) 65 | 66 | /** 67 | * 在 SPA 应用中可用于在 pinia 实例被激活前使用 store 68 | * 在 SSR 应用中可用于在 setup 外使用 store 69 | */ 70 | export function useUserStoreHook() { 71 | return useUserStore(pinia) 72 | } 73 | -------------------------------------------------------------------------------- /src/styles/element-plus.css: -------------------------------------------------------------------------------- 1 | /** 2 | * dark-blue 主题模式下的 Element Plus CSS 变量 3 | * 在此查阅所有可自定义的变量:https://github.com/element-plus/element-plus/blob/dev/packages/theme-chalk/src/common/var.scss 4 | * 也可以打开浏览器控制台选择元素,查看要覆盖的变量名 5 | */ 6 | 7 | /** 基础颜色 */ 8 | html.dark-blue { 9 | /** color-primary */ 10 | --el-color-primary: #00bb99; 11 | --el-color-primary-light-3: #00bb99b3; 12 | --el-color-primary-light-5: #00bb9980; 13 | --el-color-primary-light-7: #00bb994d; 14 | --el-color-primary-light-8: #00bb9933; 15 | --el-color-primary-light-9: #00bb991a; 16 | --el-color-primary-dark-2: #00bb99; 17 | /** color-success */ 18 | --el-color-success: #67c23a; 19 | --el-color-success-light-3: #67c23ab3; 20 | --el-color-success-light-5: #67c23a80; 21 | --el-color-success-light-7: #67c23a4d; 22 | --el-color-success-light-8: #67c23a33; 23 | --el-color-success-light-9: #67c23a1a; 24 | --el-color-success-dark-2: #67c23a; 25 | /** color-warning */ 26 | --el-color-warning: #e6a23c; 27 | --el-color-warning-light-3: #e6a23cb3; 28 | --el-color-warning-light-5: #e6a23c80; 29 | --el-color-warning-light-7: #e6a23c4d; 30 | --el-color-warning-light-8: #e6a23c33; 31 | --el-color-warning-light-9: #e6a23c1a; 32 | --el-color-warning-dark-2: #e6a23c; 33 | /** color-danger */ 34 | --el-color-danger: #f56c6c; 35 | --el-color-danger-light-3: #f56c6cb3; 36 | --el-color-danger-light-5: #f56c6c80; 37 | --el-color-danger-light-7: #f56c6c4d; 38 | --el-color-danger-light-8: #f56c6c33; 39 | --el-color-danger-light-9: #f56c6c1a; 40 | --el-color-danger-dark-2: #f56c6c; 41 | /** color-error */ 42 | --el-color-error: #f56c6c; 43 | --el-color-error-light-3: #f56c6cb3; 44 | --el-color-error-light-5: #f56c6c80; 45 | --el-color-error-light-7: #f56c6c4d; 46 | --el-color-error-light-8: #f56c6c33; 47 | --el-color-error-light-9: #f56c6c1a; 48 | --el-color-error-dark-2: #f56c6c; 49 | /** color-info */ 50 | --el-color-info: #909399; 51 | --el-color-info-light-3: #909399b3; 52 | --el-color-info-light-5: #90939980; 53 | --el-color-info-light-7: #9093994d; 54 | --el-color-info-light-8: #90939933; 55 | --el-color-info-light-9: #9093991a; 56 | --el-color-info-dark-2: #909399; 57 | /** text-color */ 58 | --el-text-color-primary: #e5eaf3; 59 | --el-text-color-regular: #cfd3dc; 60 | --el-text-color-secondary: #a3a6ad; 61 | --el-text-color-placeholder: #8d9095; 62 | --el-text-color-disabled: #6c6e72; 63 | /** border-color */ 64 | --el-border-color-darker: #003380; 65 | --el-border-color-dark: #003380; 66 | --el-border-color: #003380; 67 | --el-border-color-light: #003380; 68 | --el-border-color-lighter: #003380; 69 | --el-border-color-extra-light: #003380; 70 | /** fill-color */ 71 | --el-fill-color-darker: #002b6b; 72 | --el-fill-color-dark: #002b6b; 73 | --el-fill-color: #002b6b; 74 | --el-fill-color-light: #002359; 75 | --el-fill-color-lighter: #002359; 76 | --el-fill-color-blank: #001b44; 77 | --el-fill-color-extra-light: #001b44; 78 | /** bg-color */ 79 | --el-bg-color-page: #001535; 80 | --el-bg-color: #001b44; 81 | --el-bg-color-overlay: #002359; 82 | /** mask-color */ 83 | --el-mask-color: rgba(0, 0, 0, 0.5); 84 | --el-mask-color-extra-light: rgba(0, 0, 0, 0.3); 85 | } 86 | 87 | /** button */ 88 | html.dark-blue .el-button { 89 | --el-button-disabled-text-color: rgba(255, 255, 255, 0.5); 90 | } 91 | -------------------------------------------------------------------------------- /src/styles/element-plus.scss: -------------------------------------------------------------------------------- 1 | /** 自定义 Element Plus 样式 */ 2 | 3 | // 卡片 4 | .el-card { 5 | background-color: var(--el-bg-color); 6 | } 7 | 8 | // 分页 9 | .el-pagination { 10 | // 参考 Bootstrap 的响应式设计 WIDTH = 768 11 | @media screen and (max-width: 768px) { 12 | .el-pagination__total, 13 | .el-pagination__sizes, 14 | .el-pagination__jump, 15 | .btn-prev, 16 | .btn-next { 17 | display: none !important; 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/styles/index.scss: -------------------------------------------------------------------------------- 1 | // 全局 CSS 变量 2 | @import "./variables.css"; 3 | // Transition 4 | @import "./transition.scss"; 5 | // Element Plus 6 | @import "./element-plus.css"; 7 | @import "./element-plus.scss"; 8 | // Vxe Table 9 | @import "./vxe-table.css"; 10 | @import "./vxe-table.scss"; 11 | // 注册多主题 12 | @import "./theme/register.scss"; 13 | // Mixins 14 | @import "./mixins.scss"; 15 | // View Transition 16 | @import "./view-transition.scss"; 17 | 18 | // 业务页面几乎都应该在根元素上挂载 class="app-container",以保持页面美观 19 | .app-container { 20 | padding: 20px; 21 | } 22 | 23 | html { 24 | height: 100%; 25 | // 灰色模式 26 | &.grey-mode { 27 | filter: grayscale(1); 28 | } 29 | // 色弱模式 30 | &.color-weakness { 31 | filter: invert(0.8); 32 | } 33 | } 34 | 35 | body { 36 | width: 100% !important; 37 | height: 100%; 38 | color: var(--v3-body-text-color); 39 | background-color: var(--v3-body-bg-color); 40 | -moz-osx-font-smoothing: grayscale; 41 | -webkit-font-smoothing: antialiased; 42 | font-family: "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "微软雅黑", Arial, 43 | sans-serif; 44 | @extend %scrollbar; 45 | } 46 | 47 | #app { 48 | height: 100%; 49 | } 50 | 51 | *, 52 | *::before, 53 | *::after { 54 | box-sizing: border-box; 55 | } 56 | 57 | a, 58 | a:focus, 59 | a:hover { 60 | color: inherit; 61 | outline: none; 62 | text-decoration: none; 63 | } 64 | 65 | div:focus { 66 | outline: none; 67 | } 68 | -------------------------------------------------------------------------------- /src/styles/mixins.scss: -------------------------------------------------------------------------------- 1 | /** 清除浮动 */ 2 | %clearfix { 3 | &::after { 4 | content: ""; 5 | display: table; 6 | clear: both; 7 | } 8 | } 9 | 10 | /** 美化原生滚动条 */ 11 | %scrollbar { 12 | // 整个滚动条 13 | &::-webkit-scrollbar { 14 | width: 8px; 15 | height: 8px; 16 | } 17 | // 滚动条上的滚动滑块 18 | &::-webkit-scrollbar-thumb { 19 | border-radius: 4px; 20 | background-color: #90939955; 21 | } 22 | &::-webkit-scrollbar-thumb:hover { 23 | background-color: #90939977; 24 | } 25 | &::-webkit-scrollbar-thumb:active { 26 | background-color: #90939999; 27 | } 28 | // 当同时有垂直滚动条和水平滚动条时交汇的部分 29 | &::-webkit-scrollbar-corner { 30 | background-color: transparent; 31 | } 32 | } 33 | 34 | /** 文本溢出时显示省略号 */ 35 | %ellipsis { 36 | // 隐藏溢出的文本 37 | overflow: hidden; 38 | // 防止文本换行 39 | white-space: nowrap; 40 | // 文本内容溢出容器时,文本末尾显示省略号 41 | text-overflow: ellipsis; 42 | } 43 | -------------------------------------------------------------------------------- /src/styles/theme/core/element-plus.scss: -------------------------------------------------------------------------------- 1 | /** Element Plus 相关 */ 2 | 3 | // 侧边栏的 item 的 popper 4 | .el-popper { 5 | .el-menu { 6 | background-color: var(--el-bg-color); 7 | .el-menu-item { 8 | background-color: var(--el-bg-color); 9 | &.is-active, 10 | &:hover { 11 | background-color: var(--el-bg-color-overlay); 12 | color: #ffffff; 13 | } 14 | } 15 | .el-sub-menu__title { 16 | background-color: var(--el-bg-color); 17 | } 18 | .el-sub-menu { 19 | &.is-active { 20 | > .el-sub-menu__title { 21 | color: #ffffff; 22 | } 23 | } 24 | } 25 | } 26 | .el-menu--horizontal { 27 | border: none; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/styles/theme/core/index.scss: -------------------------------------------------------------------------------- 1 | .#{$theme-name} { 2 | @import "./layouts.scss"; 3 | @import "./element-plus.scss"; 4 | } 5 | -------------------------------------------------------------------------------- /src/styles/theme/core/layouts.scss: -------------------------------------------------------------------------------- 1 | /** Layout 相关 */ 2 | 3 | .app-wrapper { 4 | // 侧边栏 5 | .sidebar-container { 6 | background-color: var(--el-bg-color); 7 | .el-menu { 8 | background-color: var(--el-bg-color); 9 | .el-menu-item { 10 | background-color: var(--el-bg-color); 11 | &.is-active, 12 | &:hover { 13 | background-color: var(--el-bg-color-overlay); 14 | color: #ffffff; 15 | } 16 | } 17 | } 18 | .el-sub-menu__title { 19 | background-color: var(--el-bg-color); 20 | } 21 | .el-sub-menu { 22 | &.is-active { 23 | > .el-sub-menu__title { 24 | color: #ffffff !important; 25 | } 26 | } 27 | } 28 | } 29 | } 30 | 31 | // 右侧设置面板 32 | .handle-button { 33 | background-color: lighten($theme-bg-color, 20%) !important; 34 | } 35 | -------------------------------------------------------------------------------- /src/styles/theme/dark-blue/index.scss: -------------------------------------------------------------------------------- 1 | @import "./variables.scss"; 2 | @import "../core/index.scss"; 3 | -------------------------------------------------------------------------------- /src/styles/theme/dark-blue/variables.scss: -------------------------------------------------------------------------------- 1 | /** dark-blue 主题下的变量 */ 2 | 3 | // 主题名称 4 | $theme-name: "dark-blue"; 5 | // 主题背景颜色 6 | $theme-bg-color: #001b44; 7 | -------------------------------------------------------------------------------- /src/styles/theme/dark/index.scss: -------------------------------------------------------------------------------- 1 | @import "./variables.scss"; 2 | @import "../core/index.scss"; 3 | -------------------------------------------------------------------------------- /src/styles/theme/dark/variables.scss: -------------------------------------------------------------------------------- 1 | /** dark 主题下的变量 */ 2 | 3 | // 主题名称 4 | $theme-name: "dark"; 5 | // 主题背景颜色 6 | $theme-bg-color: #141414; 7 | -------------------------------------------------------------------------------- /src/styles/theme/register.scss: -------------------------------------------------------------------------------- 1 | // 注册多主题 2 | @import "./dark/index.scss"; 3 | @import "./dark-blue/index.scss"; 4 | -------------------------------------------------------------------------------- /src/styles/transition.scss: -------------------------------------------------------------------------------- 1 | // See https://cn.vuejs.org/guide/built-ins/transition.html for detail 2 | 3 | // fade-transform 4 | .fade-transform-leave-active, 5 | .fade-transform-enter-active { 6 | transition: all 0.5s; 7 | } 8 | .fade-transform-enter { 9 | opacity: 0; 10 | transform: translateX(-30px); 11 | } 12 | .fade-transform-leave-to { 13 | opacity: 0; 14 | transform: translateX(30px); 15 | } 16 | 17 | // layout-logo-fade 18 | .layout-logo-fade-enter-active, 19 | .layout-logo-fade-leave-active { 20 | transition: opacity 1.5s; 21 | } 22 | .layout-logo-fade-enter-from, 23 | .layout-logo-fade-leave-to { 24 | opacity: 0; 25 | } 26 | -------------------------------------------------------------------------------- /src/styles/variables.css: -------------------------------------------------------------------------------- 1 | /** 全局 CSS 变量,这种变量不仅可以在 CSS 和 SCSS 中使用,还可以导入到 JS 中使用 */ 2 | 3 | :root { 4 | /** Body */ 5 | --v3-body-text-color: var(--el-text-color-primary); 6 | --v3-body-bg-color: var(--el-bg-color-page); 7 | /** Header 区域 = NavigationBar 组件 + TagsView 组件 */ 8 | --v3-header-height: calc( 9 | var(--v3-navigationbar-height) + var(--v3-tagsview-height) + var(--v3-header-border-bottom-width) 10 | ); 11 | --v3-header-bg-color: var(--el-bg-color); 12 | --v3-header-box-shadow: var(--el-box-shadow-lighter); 13 | --v3-header-border-bottom-width: 1px; 14 | --v3-header-border-bottom: var(--v3-header-border-bottom-width) solid var(--el-fill-color); 15 | /** NavigationBar 组件 */ 16 | --v3-navigationbar-height: 50px; 17 | --v3-navigationbar-text-color: var(--el-text-color-regular); 18 | /** Sidebar 组件(左侧模式全部生效、顶部模式全部不生效、混合模式非颜色部分生效) */ 19 | --v3-sidebar-width: 220px; 20 | --v3-sidebar-hide-width: 58px; 21 | --v3-sidebar-border-right: 1px solid var(--el-fill-color); 22 | --v3-sidebar-menu-item-height: 60px; 23 | --v3-sidebar-menu-tip-line-bg-color: var(--el-color-primary); 24 | --v3-sidebar-menu-bg-color: #001428; 25 | --v3-sidebar-menu-hover-bg-color: #409eff10; 26 | --v3-sidebar-menu-text-color: #cfd3dc; 27 | --v3-sidebar-menu-active-text-color: #ffffff; 28 | /** TagsView 组件 */ 29 | --v3-tagsview-height: 34px; 30 | --v3-tagsview-text-color: var(--el-text-color-regular); 31 | --v3-tagsview-tag-active-text-color: #ffffff; 32 | --v3-tagsview-tag-bg-color: var(--el-bg-color); 33 | --v3-tagsview-tag-active-bg-color: var(--el-color-primary); 34 | --v3-tagsview-tag-border-radius: 2px; 35 | --v3-tagsview-tag-border-color: var(--el-border-color-lighter); 36 | --v3-tagsview-tag-active-border-color: var(--el-color-primary); 37 | --v3-tagsview-tag-icon-hover-bg-color: #00000030; 38 | --v3-tagsview-tag-icon-hover-color: #ffffff; 39 | --v3-tagsview-contextmenu-text-color: var(--el-text-color-regular); 40 | --v3-tagsview-contextmenu-hover-text-color: var(--el-text-color-primary); 41 | --v3-tagsview-contextmenu-bg-color: var(--el-bg-color-overlay); 42 | --v3-tagsview-contextmenu-hover-bg-color: var(--el-fill-color); 43 | --v3-tagsview-contextmenu-box-shadow: var(--el-box-shadow); 44 | /** Hamburger 组件 */ 45 | --v3-hamburger-text-color: var(--el-text-color-primary); 46 | /** RightPanel 组件 */ 47 | --v3-rightpanel-button-bg-color: #001428; 48 | } 49 | 50 | /** 内容区放大时,将不需要的组件隐藏 */ 51 | body.content-large { 52 | /** Header 区域 = TagsView 组件 */ 53 | --v3-header-height: var(--v3-tagsview-height); 54 | /** NavigationBar 组件 */ 55 | --v3-navigationbar-height: 0px; 56 | /** Sidebar 组件 */ 57 | --v3-sidebar-width: 0px; 58 | --v3-sidebar-hide-width: 0px; 59 | } 60 | 61 | /** 内容区全屏时,将不需要的组件隐藏 */ 62 | body.content-full { 63 | /** Header 区域 */ 64 | --v3-header-height: 0px; 65 | /** NavigationBar 组件 */ 66 | --v3-navigationbar-height: 0px; 67 | /** Sidebar 组件 */ 68 | --v3-sidebar-width: 0px; 69 | --v3-sidebar-hide-width: 0px; 70 | /** TagsView 组件 */ 71 | --v3-tagsview-height: 0px; 72 | } 73 | -------------------------------------------------------------------------------- /src/styles/view-transition.scss: -------------------------------------------------------------------------------- 1 | /** 控制切换主题时的动画效果(只在较新的浏览器上生效,例如 Chrome 111+) */ 2 | 3 | ::view-transition-old(root) { 4 | animation: none; 5 | mix-blend-mode: normal; 6 | } 7 | 8 | ::view-transition-new(root) { 9 | animation: 0.5s ease-in clip-animation; 10 | mix-blend-mode: normal; 11 | } 12 | 13 | @keyframes clip-animation { 14 | from { 15 | clip-path: circle(0px at var(--v3-theme-x) var(--v3-theme-y)); 16 | } 17 | to { 18 | clip-path: circle(var(--v3-theme-r) at var(--v3-theme-x) var(--v3-theme-y)); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/styles/vxe-table.css: -------------------------------------------------------------------------------- 1 | /** 2 | * 所有主题模式下的 Vxe Table CSS 变量 3 | * 用 Element Plus 的 CSS 变量来覆写 Vxe Table 的 CSS 变量,目的是使 Vxe Table 支持多主题模式且样式统一 4 | * 在此查阅所有可自定义的变量:https://github.com/x-extends/vxe-table/blob/master/styles/css-variable.scss 5 | */ 6 | 7 | :root { 8 | /*color*/ 9 | --vxe-font-color: var(--el-text-color-regular); 10 | --vxe-primary-color: var(--el-color-primary); 11 | --vxe-success-color: var(--el-color-success); 12 | --vxe-info-color: var(--el-color-info); 13 | --vxe-warning-color: var(--el-color-warning); 14 | --vxe-danger-color: var(--el-color-danger); 15 | 16 | --vxe-font-lighten-color: var(--el-text-color-primary); 17 | --vxe-primary-lighten-color: var(--el-color-primary-light-3); 18 | --vxe-success-lighten-color: var(--el-color-success-light-3); 19 | --vxe-info-lighten-color: var(--el-color-info-light-3); 20 | --vxe-warning-lighten-color: var(--el-color-warning-light-3); 21 | --vxe-danger-lighten-color: var(--el-color-danger-light-3); 22 | 23 | --vxe-font-darken-color: var(--el-text-color-secondary); 24 | --vxe-primary-darken-color: var(--el-color-primary-dark-2); 25 | --vxe-success-darken-color: var(--el-color-success-dark-2); 26 | --vxe-info-darken-color: var(--el-color-info-dark-2); 27 | --vxe-warning-darken-color: var(--el-color-warning-dark-2); 28 | --vxe-danger-darken-color: var(--el-color-danger-dark-2); 29 | 30 | --vxe-font-disabled-color: var(--el-text-color-disabled); 31 | --vxe-primary-disabled-color: var(--el-color-primary-light-5); 32 | --vxe-success-disabled-color: var(--el-color-success-light-5); 33 | --vxe-info-disabled-color: var(--el-color-info-light-5); 34 | --vxe-warning-disabled-color: var(--el-color-warning-light-5); 35 | --vxe-danger-disabled-color: var(--el-color-danger-light-5); 36 | 37 | /*input/radio/checkbox*/ 38 | --vxe-input-border-color: var(--el-border-color); 39 | --vxe-input-disabled-color: var(--el-text-color-disabled); 40 | --vxe-input-disabled-background-color: var(--el-fill-color-light); 41 | --vxe-input-placeholder-color: var(--el-text-color-placeholder); 42 | 43 | /*popup*/ 44 | --vxe-table-popup-border-color: var(--el-border-color); 45 | 46 | /*table*/ 47 | --vxe-table-header-font-color: var(--el-text-color-regular); 48 | --vxe-table-footer-font-color: var(--el-text-color-regular); 49 | --vxe-table-border-color: var(--el-border-color-lighter); 50 | --vxe-table-header-background-color: var(--el-bg-color); 51 | --vxe-table-body-background-color: var(--el-bg-color); 52 | --vxe-table-footer-background-color: var(--el-bg-color); 53 | 54 | --vxe-table-row-hover-background-color: var(--el-fill-color-light); 55 | --vxe-table-row-current-background-color: var(--el-fill-color-light); 56 | --vxe-table-row-hover-current-background-color: var(--el-fill-color-light); 57 | 58 | --vxe-table-checkbox-range-background-color: var(--el-fill-color-light); 59 | 60 | /*menu*/ 61 | --vxe-table-menu-background-color: var(--el-bg-color-overlay); 62 | 63 | /*loading*/ 64 | --vxe-loading-color: var(--el-color-primary); 65 | --vxe-loading-background-color: var(--el-mask-color); 66 | 67 | /*validate*/ 68 | --vxe-table-validate-error-color: var(--el-color-danger); 69 | 70 | /*toolbar*/ 71 | --vxe-toolbar-background-color: var(--el-bg-color); 72 | --vxe-toolbar-custom-active-background-color: var(--el-bg-color-overlay); 73 | --vxe-toolbar-panel-background-color: var(--el-bg-color-overlay); 74 | 75 | /*pager*/ 76 | --vxe-pager-background-color: var(--el-bg-color); 77 | 78 | /*modal*/ 79 | --vxe-modal-header-background-color: var(--el-bg-color); 80 | --vxe-modal-body-background-color: var(--el-bg-color); 81 | --vxe-modal-border-color: var(--el-border-color); 82 | 83 | /*button*/ 84 | --vxe-button-default-background-color: var(--el-bg-color-overlay); 85 | 86 | /*input*/ 87 | --vxe-input-background-color: var(--el-fill-color-blank); 88 | --vxe-input-panel-background-color: var(--el-fill-color-blank); 89 | 90 | /*form*/ 91 | --vxe-form-background-color: var(--el-bg-color); 92 | --vxe-form-validate-error-color: var(--el-color-danger); 93 | 94 | /*select*/ 95 | --vxe-select-option-hover-background-color: var(--el-bg-color-overlay); 96 | --vxe-select-panel-background-color: var(--el-bg-color); 97 | } 98 | -------------------------------------------------------------------------------- /src/styles/vxe-table.scss: -------------------------------------------------------------------------------- 1 | /** 自定义 Vxe Table 样式 */ 2 | 3 | .vxe-grid { 4 | // 表单 5 | &--form-wrapper { 6 | .vxe-form { 7 | padding: 10px 20px !important; 8 | margin-bottom: 20px !important; 9 | } 10 | } 11 | 12 | // 工具栏 13 | &--toolbar-wrapper { 14 | .vxe-toolbar { 15 | padding: 20px !important; 16 | } 17 | } 18 | 19 | // 分页 20 | &--pager-wrapper { 21 | .vxe-pager { 22 | height: 70px !important; 23 | padding: 0 20px !important; 24 | &--wrapper { 25 | // 参考 Bootstrap 的响应式设计 WIDTH = 768 26 | @media screen and (max-width: 768px) { 27 | .vxe-pager--total, 28 | .vxe-pager--sizes, 29 | .vxe-pager--jump, 30 | .vxe-pager--jump-prev, 31 | .vxe-pager--jump-next { 32 | display: none !important; 33 | } 34 | } 35 | } 36 | } 37 | } 38 | } 39 | 40 | .vxe-modal--box { 41 | top: 50% !important; 42 | left: 50% !important; 43 | transform: translate(-50%, -50%); 44 | } 45 | -------------------------------------------------------------------------------- /src/utils/cache/local-storage.ts: -------------------------------------------------------------------------------- 1 | /** 统一处理 localStorage */ 2 | 3 | import CacheKey from "@/constants/cache-key" 4 | import { type SidebarOpened, type SidebarClosed } from "@/constants/app-key" 5 | import { type ThemeName } from "@/hooks/useTheme" 6 | import { type TagView } from "@/store/modules/tags-view" 7 | import { type LayoutSettings } from "@/config/layouts" 8 | 9 | //#region 系统布局配置 10 | export const getConfigLayout = () => { 11 | const json = localStorage.getItem(CacheKey.CONFIG_LAYOUT) 12 | return json ? (JSON.parse(json) as LayoutSettings) : null 13 | } 14 | export const setConfigLayout = (settings: LayoutSettings) => { 15 | localStorage.setItem(CacheKey.CONFIG_LAYOUT, JSON.stringify(settings)) 16 | } 17 | export const removeConfigLayout = () => { 18 | localStorage.removeItem(CacheKey.CONFIG_LAYOUT) 19 | } 20 | //#endregion 21 | 22 | //#region 侧边栏状态 23 | export const getSidebarStatus = () => { 24 | return localStorage.getItem(CacheKey.SIDEBAR_STATUS) 25 | } 26 | export const setSidebarStatus = (sidebarStatus: SidebarOpened | SidebarClosed) => { 27 | localStorage.setItem(CacheKey.SIDEBAR_STATUS, sidebarStatus) 28 | } 29 | //#endregion 30 | 31 | //#region 正在应用的主题名称 32 | export const getActiveThemeName = () => { 33 | return localStorage.getItem(CacheKey.ACTIVE_THEME_NAME) as ThemeName | null 34 | } 35 | export const setActiveThemeName = (themeName: ThemeName) => { 36 | localStorage.setItem(CacheKey.ACTIVE_THEME_NAME, themeName) 37 | } 38 | //#endregion 39 | 40 | //#region 标签栏 41 | export const getVisitedViews = () => { 42 | const json = localStorage.getItem(CacheKey.VISITED_VIEWS) 43 | return JSON.parse(json ?? "[]") as TagView[] 44 | } 45 | export const setVisitedViews = (views: TagView[]) => { 46 | views.forEach((view) => { 47 | // 删除不必要的属性,防止 JSON.stringify 处理到循环引用 48 | delete view.matched 49 | delete view.redirectedFrom 50 | }) 51 | localStorage.setItem(CacheKey.VISITED_VIEWS, JSON.stringify(views)) 52 | } 53 | export const getCachedViews = () => { 54 | const json = localStorage.getItem(CacheKey.CACHED_VIEWS) 55 | return JSON.parse(json ?? "[]") as string[] 56 | } 57 | export const setCachedViews = (views: string[]) => { 58 | localStorage.setItem(CacheKey.CACHED_VIEWS, JSON.stringify(views)) 59 | } 60 | //#endregion 61 | -------------------------------------------------------------------------------- /src/utils/cache/session-storage.ts: -------------------------------------------------------------------------------- 1 | /** 统一处理 sessionStorage */ 2 | 3 | import CacheKey from "@/constants/cache-key" 4 | 5 | export const getToken = () => { 6 | return sessionStorage.getItem(CacheKey.TOKEN) 7 | } 8 | export const setToken = (token: string) => { 9 | sessionStorage.setItem(CacheKey.TOKEN, token) 10 | } 11 | export const removeToken = () => { 12 | sessionStorage.removeItem(CacheKey.TOKEN) 13 | } 14 | -------------------------------------------------------------------------------- /src/utils/css.ts: -------------------------------------------------------------------------------- 1 | /** 获取指定元素(默认全局)上的 CSS 变量的值 */ 2 | export const getCssVar = (varName: string, element: HTMLElement = document.documentElement) => { 3 | if (!varName?.startsWith("--")) { 4 | console.warn("CSS 变量名应以 '--' 开头") 5 | return "" 6 | } 7 | // 没有拿到值时,会返回空串 8 | return getComputedStyle(element).getPropertyValue(varName) 9 | } 10 | 11 | /** 设置指定元素(默认全局)上的 CSS 变量的值 */ 12 | export const setCssVar = (varName: string, value: string, element: HTMLElement = document.documentElement) => { 13 | if (!varName?.startsWith("--")) { 14 | console.warn("CSS 变量名应以 '--' 开头") 15 | return 16 | } 17 | element.style.setProperty(varName, value) 18 | } 19 | -------------------------------------------------------------------------------- /src/utils/datetime.ts: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs" 2 | 3 | const INVALID_DATE = "N/A" 4 | 5 | /** 格式化日期时间 */ 6 | export const formatDateTime = (datetime: string | number | Date = "", template: string = "YYYY-MM-DD HH:mm:ss") => { 7 | const day = dayjs(datetime) 8 | return day.isValid() ? day.format(template) : INVALID_DATE 9 | } 10 | -------------------------------------------------------------------------------- /src/utils/permission.ts: -------------------------------------------------------------------------------- 1 | import { useUserStore } from "@/store/modules/user" 2 | 3 | /** 全局权限判断函数,和权限指令 v-permission 功能类似 */ 4 | export const checkPermission = (permissionRoles: string[]): boolean => { 5 | if (Array.isArray(permissionRoles) && permissionRoles.length > 0) { 6 | const { roles } = useUserStore() 7 | return roles.some((role) => permissionRoles.includes(role)) 8 | } else { 9 | console.error("need roles! Like checkPermission(['admin','editor'])") 10 | return false 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/utils/service.ts: -------------------------------------------------------------------------------- 1 | import axios, { type AxiosInstance, type AxiosRequestConfig } from "axios" 2 | import { useUserStore } from "@/store/modules/user" 3 | import { ElMessage } from "element-plus" 4 | import { get, merge } from "lodash-es" 5 | import { getToken } from "./cache/session-storage" 6 | 7 | /** 退出登录并强制刷新页面(会重定向到登录页) */ 8 | function logout() { 9 | useUserStore().logout() 10 | location.reload() 11 | } 12 | 13 | /** 创建请求实例 */ 14 | function createService() { 15 | // 创建一个 axios 实例命名为 service 16 | const service = axios.create() 17 | // 请求拦截 18 | service.interceptors.request.use( 19 | (config) => config, 20 | // 发送失败 21 | (error) => Promise.reject(error) 22 | ) 23 | // 响应拦截(可根据具体业务作出相应的调整) 24 | service.interceptors.response.use( 25 | (response) => { 26 | // apiData 是 api 返回的数据 27 | const apiData = response.data 28 | // 二进制数据则直接返回 29 | const responseType = response.request?.responseType 30 | if (responseType === "blob" || responseType === "arraybuffer") return apiData 31 | // 这个 code 是和后端约定的业务 code 32 | const code = apiData.code 33 | // 如果没有 code, 代表这不是项目后端开发的 api 34 | if (code === undefined) { 35 | ElMessage.error("非本系统的接口") 36 | return Promise.reject(new Error("非本系统的接口")) 37 | } 38 | switch (code) { 39 | case 0: 40 | // 本系统采用 code === 0 来表示没有业务错误 41 | return apiData 42 | case 401: 43 | // Token 过期时 44 | return logout() 45 | default: 46 | // 不是正确的 code 47 | ElMessage.error(apiData.message || "Error") 48 | return Promise.reject(new Error("Error")) 49 | } 50 | }, 51 | (error) => { 52 | // status 是 HTTP 状态码 53 | const status = get(error, "response.status") 54 | switch (status) { 55 | case 400: 56 | error.message = "请求错误" 57 | break 58 | case 401: 59 | // Token 过期时 60 | logout() 61 | break 62 | case 403: 63 | error.message = "拒绝访问" 64 | break 65 | case 404: 66 | error.message = "请求地址出错" 67 | break 68 | case 408: 69 | error.message = "请求超时" 70 | break 71 | case 500: 72 | error.message = "服务器内部错误" 73 | break 74 | case 501: 75 | error.message = "服务未实现" 76 | break 77 | case 502: 78 | error.message = "网关错误" 79 | break 80 | case 503: 81 | error.message = "服务不可用" 82 | break 83 | case 504: 84 | error.message = "网关超时" 85 | break 86 | case 505: 87 | error.message = "HTTP 版本不受支持" 88 | break 89 | default: 90 | break 91 | } 92 | ElMessage.error(error.message) 93 | return Promise.reject(error) 94 | } 95 | ) 96 | return service 97 | } 98 | 99 | /** 创建请求方法 */ 100 | function createRequest(service: AxiosInstance) { 101 | return function <T>(config: AxiosRequestConfig): Promise<T> { 102 | const token = getToken() 103 | const defaultConfig = { 104 | headers: { 105 | // 携带 Token 106 | Authorization: token ? `Bearer ${token}` : undefined, 107 | "Content-Type": "application/json" 108 | }, 109 | timeout: 5000, 110 | baseURL: import.meta.env.VITE_BASE_API, 111 | data: {} 112 | } 113 | // 将默认配置 defaultConfig 和传入的自定义配置 config 进行合并成为 mergeConfig 114 | const mergeConfig = merge(defaultConfig, config) 115 | return service(mergeConfig) 116 | } 117 | } 118 | 119 | /** 用于网络请求的实例 */ 120 | const service = createService() 121 | /** 用于网络请求的方法 */ 122 | export const request = createRequest(service) 123 | -------------------------------------------------------------------------------- /src/utils/validate.ts: -------------------------------------------------------------------------------- 1 | /** 判断是否为数组 */ 2 | export const isArray = <T>(arg: T) => { 3 | return Array.isArray ? Array.isArray(arg) : Object.prototype.toString.call(arg) === "[object Array]" 4 | } 5 | 6 | /** 判断是否为字符串 */ 7 | export const isString = <T>(str: T) => { 8 | return typeof str === "string" || str instanceof String 9 | } 10 | 11 | /** 判断是否为外链 */ 12 | export const isExternal = (path: string) => { 13 | const reg = /^(https?:|mailto:|tel:)/ 14 | return reg.test(path) 15 | } 16 | 17 | /** 判断是否为网址(带协议) */ 18 | export const isUrl = (url: string) => { 19 | const reg = /^(((ht|f)tps?):\/\/)?([^!@#$%^&*?.\s-]([^!@#$%^&*?.\s]{0,63}[^!@#$%^&*?.\s])?\.)+[a-z]{2,6}\/?/ 20 | return reg.test(url) 21 | } 22 | 23 | /** 判断是否为网址或 IP(带端口) */ 24 | export const isUrlPort = (url: string) => { 25 | const reg = /^((ht|f)tps?:\/\/)?[\w-]+(\.[\w-]+)+:\d{1,5}\/?$/ 26 | return reg.test(url) 27 | } 28 | 29 | /** 判断是否为域名(不带协议) */ 30 | export const isDomain = (domain: string) => { 31 | const reg = /^([0-9a-zA-Z-]{1,}\.)+([a-zA-Z]{2,})$/ 32 | return reg.test(domain) 33 | } 34 | 35 | /** 判断版本号格式是否为 X.Y.Z */ 36 | export const isVersion = (version: string) => { 37 | const reg = /^\d+(?:\.\d+){2}$/ 38 | return reg.test(version) 39 | } 40 | 41 | /** 判断时间格式是否为 24 小时制(HH:mm:ss) */ 42 | export const is24H = (time: string) => { 43 | const reg = /^(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d$/ 44 | return reg.test(time) 45 | } 46 | 47 | /** 判断是否为手机号(1 开头) */ 48 | export const isPhoneNumber = (str: string) => { 49 | const reg = /^(?:(?:\+|00)86)?1\d{10}$/ 50 | return reg.test(str) 51 | } 52 | 53 | /** 判断是否为第二代身份证(18 位) */ 54 | export const isChineseIdCard = (str: string) => { 55 | const reg = /^[1-9]\d{5}(?:18|19|20)\d{2}(?:0[1-9]|10|11|12)(?:0[1-9]|[1-2]\d|30|31)\d{3}[\dXx]$/ 56 | return reg.test(str) 57 | } 58 | 59 | /** 判断是否为 Email(支持中文邮箱) */ 60 | export const isEmail = (email: string) => { 61 | const reg = /^[A-Za-z0-9\u4e00-\u9fa5]+@[a-zA-Z0-9_-]+(\.[a-zA-Z0-9_-]+)+$/ 62 | return reg.test(email) 63 | } 64 | 65 | /** 判断是否为 MAC 地址 */ 66 | export const isMAC = (mac: string) => { 67 | const reg = 68 | /^(([a-f0-9][0,2,4,6,8,a,c,e]:([a-f0-9]{2}:){4})|([a-f0-9][0,2,4,6,8,a,c,e]-([a-f0-9]{2}-){4}))[a-f0-9]{2}$/i 69 | return reg.test(mac) 70 | } 71 | 72 | /** 判断是否为 IPv4 地址 */ 73 | export const isIPv4 = (ip: string) => { 74 | const reg = 75 | /^((\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.){3}(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])(?::(?:[0-9]|[1-9][0-9]{1,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?$/ 76 | return reg.test(ip) 77 | } 78 | 79 | /** 判断是否为车牌(兼容新能源车牌) */ 80 | export const isLicensePlate = (str: string) => { 81 | const reg = 82 | /^[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领][A-HJ-NP-Z][A-HJ-NP-Z0-9]{4,5}[A-HJ-NP-Z0-9挂学警港澳]$/ 83 | return reg.test(str) 84 | } 85 | -------------------------------------------------------------------------------- /src/utils/with-prototype.ts: -------------------------------------------------------------------------------- 1 | import EleLog from "electron-log/renderer" 2 | import IpcDict from "@/constants/ipc-dict" 3 | 4 | EleLog.transports.console.format = `[{y}-{m}-{d} {h}:{i}:{s}.{ms}] => {text}` 5 | 6 | if (process.platform === "win32") { 7 | Object.assign(console, EleLog.functions) 8 | } else { 9 | Object.keys(EleLog.functions).forEach((fn) => { 10 | if (fn in console) { 11 | console[fn] = function (...params: any[]) { 12 | window.vIpcRenderer.send(IpcDict.CODE_02001, "console", ...params) 13 | } 14 | } 15 | }) 16 | } 17 | 18 | window.vRemote = require("@electron/remote") 19 | window.vIpcRenderer = require("electron")["ipcRenderer"] 20 | window.vLog = (logName: string, ...params: any[]) => { 21 | window.vIpcRenderer.send(IpcDict.CODE_02001, logName, ...params) 22 | } 23 | -------------------------------------------------------------------------------- /src/views/dashboard/components/Admin.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <div class="app-container center"> 3 | <el-empty description="欢迎来到 admin 角色专属首页" /> 4 | </div> 5 | </template> 6 | 7 | <style lang="scss" scoped> 8 | .center { 9 | height: 100%; 10 | display: flex; 11 | justify-content: center; 12 | align-items: center; 13 | } 14 | </style> 15 | -------------------------------------------------------------------------------- /src/views/dashboard/components/Editor.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <div class="app-container center"> 3 | <el-empty description="欢迎来到 editor 角色专属首页" /> 4 | </div> 5 | </template> 6 | 7 | <style lang="scss" scoped> 8 | .center { 9 | height: 100%; 10 | display: flex; 11 | justify-content: center; 12 | align-items: center; 13 | } 14 | </style> 15 | -------------------------------------------------------------------------------- /src/views/dashboard/index.vue: -------------------------------------------------------------------------------- 1 | <script lang="ts" setup> 2 | import { useUserStore } from "@/store/modules/user" 3 | import Admin from "./components/Admin.vue" 4 | import Editor from "./components/Editor.vue" 5 | 6 | const userStore = useUserStore() 7 | const isAdmin = userStore.roles.includes("admin") 8 | </script> 9 | 10 | <template> 11 | <component :is="isAdmin ? Admin : Editor" /> 12 | </template> 13 | -------------------------------------------------------------------------------- /src/views/error-page/403.vue: -------------------------------------------------------------------------------- 1 | <script lang="ts" setup> 2 | import ErrorPageLayout from "./components/ErrorPageLayout.vue" 3 | import Svg403 from "@/assets/error-page/403.svg?component" // vite-svg-loader 插件的功能 4 | </script> 5 | 6 | <template> 7 | <ErrorPageLayout> 8 | <Svg403 /> 9 | </ErrorPageLayout> 10 | </template> 11 | -------------------------------------------------------------------------------- /src/views/error-page/404.vue: -------------------------------------------------------------------------------- 1 | <script lang="ts" setup> 2 | import ErrorPageLayout from "./components/ErrorPageLayout.vue" 3 | import Svg404 from "@/assets/error-page/404.svg?component" // vite-svg-loader 插件的功能 4 | </script> 5 | 6 | <template> 7 | <ErrorPageLayout> 8 | <Svg404 /> 9 | </ErrorPageLayout> 10 | </template> 11 | -------------------------------------------------------------------------------- /src/views/error-page/components/ErrorPageLayout.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <div class="error-page"> 3 | <div class="error-page-svg"> 4 | <slot /> 5 | </div> 6 | <router-link to="/"> 7 | <el-button type="primary">回到首页</el-button> 8 | </router-link> 9 | </div> 10 | </template> 11 | 12 | <style lang="scss" scoped> 13 | .error-page { 14 | height: 100%; 15 | display: flex; 16 | flex-direction: column; 17 | justify-content: center; 18 | align-items: center; 19 | &-svg { 20 | width: 400px; 21 | margin-bottom: 50px; 22 | } 23 | } 24 | </style> 25 | -------------------------------------------------------------------------------- /src/views/hook-demo/use-fetch-select.vue: -------------------------------------------------------------------------------- 1 | <script lang="ts" setup> 2 | import { useFetchSelect } from "@/hooks/useFetchSelect" 3 | import { getSelectDataApi } from "@/api/hook-demo/use-fetch-select" 4 | 5 | const { loading, options, value } = useFetchSelect({ 6 | api: getSelectDataApi 7 | }) 8 | </script> 9 | 10 | <template> 11 | <div class="app-container"> 12 | <h4>该示例是演示:通过 hook 自动调用 api 后拿到 Select 组件需要的数据并传递给 Select 组件</h4> 13 | <h5>Select 示例</h5> 14 | <el-select :loading="loading" v-model="value" filterable> 15 | <el-option v-for="(item, index) in options" v-bind="item" :key="index" placeholder="请选择" /> 16 | </el-select> 17 | <h5>Select V2 示例(如果数据量过多,可以选择该组件)</h5> 18 | <el-select-v2 :loading="loading" v-model="value" :options="options" filterable placeholder="请选择" /> 19 | </div> 20 | </template> 21 | -------------------------------------------------------------------------------- /src/views/hook-demo/use-fullscreen-loading.vue: -------------------------------------------------------------------------------- 1 | <script lang="ts" setup> 2 | import { useFullscreenLoading } from "@/hooks/useFullscreenLoading" 3 | import { getSuccessApi, getErrorApi } from "@/api/hook-demo/use-fullscreen-loading" 4 | import { ElMessage } from "element-plus" 5 | 6 | const svg = ` 7 | <path class="path" d=" 8 | M 30 15 9 | L 28 17 10 | M 25.61 25.61 11 | A 15 15, 0, 0, 1, 15 30 12 | A 15 15, 0, 1, 1, 27.99 7.5 13 | L 15 15 14 | " style="stroke-width: 4px; fill: rgba(0, 0, 0, 0)"/> 15 | ` 16 | 17 | const options = { 18 | text: "即将发生错误...", 19 | background: "#F56C6C20", 20 | svg, 21 | svgViewBox: "-10, -10, 50, 50" 22 | } 23 | 24 | const querySuccess = async () => { 25 | // 注意: 26 | // 1. getSuccessApi 是一个函数而非函数调用 27 | // 2. 如需给 getSuccessApi 函数传递参数,请在后面的括号中进行(真正的 getSuccessApi 调用) 28 | const res = await useFullscreenLoading(getSuccessApi)([2, 3, 3]) 29 | ElMessage.success(`${res.message},传参为 ${res.data.list.toString()}`) 30 | } 31 | 32 | const queryError = async () => { 33 | try { 34 | await useFullscreenLoading(getErrorApi, options)() 35 | } catch (error) { 36 | ElMessage.error((error as Error).message) 37 | } 38 | } 39 | </script> 40 | 41 | <template> 42 | <div class="app-container"> 43 | <h4>该示例是演示:通过将要执行的函数传递给 hook,让 hook 自动开启全屏 loading,函数执行结束后自动关闭 loading</h4> 44 | <el-button type="primary" @click="querySuccess">查询成功</el-button> 45 | <el-button type="danger" @click="queryError">查询失败</el-button> 46 | </div> 47 | </template> 48 | -------------------------------------------------------------------------------- /src/views/hook-demo/use-watermark.vue: -------------------------------------------------------------------------------- 1 | <script lang="ts" setup> 2 | import { ref } from "vue" 3 | import { useWatermark } from "@/hooks/useWatermark" 4 | 5 | const localRef = ref<HTMLElement | null>(null) 6 | const { setWatermark, clearWatermark } = useWatermark(localRef) 7 | const { setWatermark: setGlobalWatermark, clearWatermark: clearGlobalWatermark } = useWatermark() 8 | </script> 9 | 10 | <template> 11 | <div class="app-container"> 12 | <h4> 13 | 该示例是演示:通过调用 hook,开启或关闭水印, 14 | 支持局部、全局、自定义样式(颜色、透明度、字体大小、字体、倾斜角度等),并自带防御(防删、防隐藏)和自适应功能 15 | </h4> 16 | <div ref="localRef" class="local" /> 17 | <el-button-group> 18 | <el-button type="primary" @click="setWatermark('局部水印', { color: '#409eff' })">创建局部水印</el-button> 19 | <el-button type="warning" @click="setWatermark('没有防御功能的局部水印', { color: '#e6a23c', defense: false })"> 20 | 关闭防御功能 21 | </el-button> 22 | <el-button type="danger" @click="clearWatermark">清除局部水印</el-button> 23 | </el-button-group> 24 | <el-button-group> 25 | <el-button type="primary" @click="setGlobalWatermark('全局水印', { color: '#409eff' })">创建全局水印</el-button> 26 | <el-button 27 | type="warning" 28 | @click="setGlobalWatermark('没有防御功能的全局水印', { color: '#e6a23c', defense: false })" 29 | > 30 | 关闭防御功能 31 | </el-button> 32 | <el-button type="danger" @click="clearGlobalWatermark">清除全局水印</el-button> 33 | </el-button-group> 34 | </div> 35 | </template> 36 | 37 | <style lang="scss" scoped> 38 | .local { 39 | height: 30vh; 40 | border: 2px dashed var(--el-color-primary); 41 | margin-bottom: 20px; 42 | } 43 | 44 | .el-button-group { 45 | margin-right: 12px; 46 | } 47 | </style> 48 | -------------------------------------------------------------------------------- /src/views/login/components/Owl.vue: -------------------------------------------------------------------------------- 1 | <script lang="ts" setup> 2 | interface Props { 3 | closeEyes: boolean 4 | } 5 | 6 | const props = defineProps<Props>() 7 | </script> 8 | 9 | <template> 10 | <div class="owl" :class="{ 'owl-password': props.closeEyes }"> 11 | <div class="hand-down-left" /> 12 | <div class="hand-down-right" /> 13 | <div class="hand-up-left" /> 14 | <div class="hand-up-right" /> 15 | <div class="close-eyes" /> 16 | </div> 17 | </template> 18 | 19 | <style lang="scss" scoped> 20 | @mixin backgroundImage($url) { 21 | background-image: url($url); 22 | background-repeat: no-repeat; 23 | background-size: 100%; 24 | } 25 | 26 | .owl { 27 | position: relative; 28 | width: 120px; 29 | height: 95px; 30 | transform: translateY(12%); 31 | @include backgroundImage("@/assets/login/face.png"); 32 | .hand-down-left, 33 | .hand-down-right { 34 | z-index: 2; 35 | position: absolute; 36 | width: 45px; 37 | height: 25px; 38 | transition: transform 0.2s linear; 39 | } 40 | .hand-down-left { 41 | bottom: 3px; 42 | left: -35px; 43 | @include backgroundImage("@/assets/login/hand-down-left.png"); 44 | } 45 | .hand-down-right { 46 | bottom: 3px; 47 | right: -40px; 48 | @include backgroundImage("@/assets/login/hand-down-right.png"); 49 | } 50 | .hand-up-left, 51 | .hand-up-right { 52 | z-index: 3; 53 | position: absolute; 54 | width: 50px; 55 | height: 40px; 56 | opacity: 0; 57 | transition: opacity 0.1s linear 0.1s; 58 | } 59 | .hand-up-left { 60 | bottom: 11px; 61 | left: -5px; 62 | @include backgroundImage("@/assets/login/hand-up-left.png"); 63 | } 64 | .hand-up-right { 65 | bottom: 11px; 66 | right: 5px; 67 | @include backgroundImage("@/assets/login/hand-up-right.png"); 68 | } 69 | .close-eyes { 70 | z-index: 1; 71 | width: 100%; 72 | height: 100%; 73 | opacity: 0; 74 | transition: opacity 0.1s linear 0.1s; 75 | @include backgroundImage("@/assets/login/close-eyes.png"); 76 | } 77 | } 78 | 79 | .owl-password { 80 | .hand-down-left { 81 | transform: translateX(30px) scale(0) translateY(-10px); 82 | } 83 | .hand-down-right { 84 | transform: translateX(-40px) scale(0) translateY(-10px); 85 | } 86 | .hand-up-left, 87 | .hand-up-right, 88 | .close-eyes { 89 | opacity: 1; 90 | } 91 | } 92 | </style> 93 | -------------------------------------------------------------------------------- /src/views/login/hooks/useFocus.ts: -------------------------------------------------------------------------------- 1 | import { ref } from "vue" 2 | 3 | export function useFocus() { 4 | /** 是否有焦点 */ 5 | const isFocus = ref<boolean>(false) 6 | 7 | /** 失去焦点 */ 8 | const handleBlur = () => { 9 | isFocus.value = false 10 | } 11 | /** 获取焦点 */ 12 | const handleFocus = () => { 13 | isFocus.value = true 14 | } 15 | 16 | return { isFocus, handleBlur, handleFocus } 17 | } 18 | -------------------------------------------------------------------------------- /src/views/menu/menu1/index.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <div class="app-container"> 3 | <h4> 4 | 三级及其以上路由缓存功能默认关闭,需要请前往此配置文件中打开: 5 | <el-link 6 | type="primary" 7 | href="https://github.com/un-pany/v3-admin-vite/blob/main/src/config/route.ts" 8 | target="_blank" 9 | > 10 | src/config/route.ts 11 | </el-link> 12 | </h4> 13 | <el-card header="二级路由 - menu1"> 14 | <router-view /> 15 | </el-card> 16 | </div> 17 | </template> 18 | 19 | <style lang="scss" scoped> 20 | .el-link { 21 | font-size: 18px; 22 | } 23 | </style> 24 | -------------------------------------------------------------------------------- /src/views/menu/menu1/menu1-1/index.vue: -------------------------------------------------------------------------------- 1 | <script lang="ts" setup> 2 | import { ref } from "vue" 3 | 4 | defineOptions({ 5 | name: "Menu1-1" 6 | }) 7 | 8 | const text = ref("") 9 | </script> 10 | 11 | <template> 12 | <div class="app-container"> 13 | <el-card header="三级路由缓存 - menu1-1"> 14 | <el-input v-model="text" /> 15 | </el-card> 16 | </div> 17 | </template> 18 | -------------------------------------------------------------------------------- /src/views/menu/menu1/menu1-2/index.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <div class="app-container"> 3 | <el-card header="三级路由 - menu1-2"> 4 | <router-view /> 5 | </el-card> 6 | </div> 7 | </template> 8 | -------------------------------------------------------------------------------- /src/views/menu/menu1/menu1-2/menu1-2-1/index.vue: -------------------------------------------------------------------------------- 1 | <script lang="ts" setup> 2 | import { ref } from "vue" 3 | 4 | defineOptions({ 5 | name: "Menu1-2-1" 6 | }) 7 | 8 | const text = ref("") 9 | </script> 10 | 11 | <template> 12 | <div class="app-container"> 13 | <el-card header="四级路由缓存 - menu1-2-1"> 14 | <el-input v-model="text" /> 15 | </el-card> 16 | </div> 17 | </template> 18 | -------------------------------------------------------------------------------- /src/views/menu/menu1/menu1-2/menu1-2-2/index.vue: -------------------------------------------------------------------------------- 1 | <script lang="ts" setup> 2 | import { ref } from "vue" 3 | 4 | defineOptions({ 5 | name: "Menu1-2-2" 6 | }) 7 | 8 | const text = ref("") 9 | </script> 10 | 11 | <template> 12 | <div class="app-container"> 13 | <el-card header="四级路由缓存 - menu1-2-2"> 14 | <el-input v-model="text" /> 15 | </el-card> 16 | </div> 17 | </template> 18 | -------------------------------------------------------------------------------- /src/views/menu/menu1/menu1-3/index.vue: -------------------------------------------------------------------------------- 1 | <script lang="ts" setup> 2 | import { ref } from "vue" 3 | 4 | defineOptions({ 5 | name: "Menu1-3" 6 | }) 7 | 8 | const text = ref("") 9 | </script> 10 | 11 | <template> 12 | <div class="app-container"> 13 | <el-card header="三级路由缓存 - menu1-3"> 14 | <el-input v-model="text" /> 15 | </el-card> 16 | </div> 17 | </template> 18 | -------------------------------------------------------------------------------- /src/views/menu/menu2/index.vue: -------------------------------------------------------------------------------- 1 | <script lang="ts" setup> 2 | import { ref } from "vue" 3 | 4 | defineOptions({ 5 | name: "Menu2" 6 | }) 7 | 8 | const text = ref("") 9 | </script> 10 | 11 | <template> 12 | <div class="app-container"> 13 | <el-card header="二级路由缓存 - menu2"> 14 | <el-input v-model="text" /> 15 | </el-card> 16 | </div> 17 | </template> 18 | -------------------------------------------------------------------------------- /src/views/permission/components/SwitchRoles.vue: -------------------------------------------------------------------------------- 1 | <script lang="ts" setup> 2 | import { ref, watch } from "vue" 3 | import { useUserStore } from "@/store/modules/user" 4 | 5 | const userStore = useUserStore() 6 | const switchRoles = ref(userStore.roles[0]) 7 | watch(switchRoles, (value) => { 8 | userStore.changeRoles(value) 9 | }) 10 | </script> 11 | 12 | <template> 13 | <div> 14 | <div>你的角色:{{ userStore.roles }}</div> 15 | <div class="switch-roles"> 16 | <span>切换用户(模拟重新登录):</span> 17 | <el-radio-group v-model="switchRoles"> 18 | <el-radio-button label="editor" value="editor" /> 19 | <el-radio-button label="admin" value="admin" /> 20 | </el-radio-group> 21 | </div> 22 | </div> 23 | </template> 24 | 25 | <style lang="scss" scoped> 26 | .switch-roles { 27 | margin-top: 15px; 28 | display: flex; 29 | align-items: center; 30 | } 31 | </style> 32 | -------------------------------------------------------------------------------- /src/views/permission/directive.vue: -------------------------------------------------------------------------------- 1 | <script lang="ts" setup> 2 | import { checkPermission } from "@/utils/permission" 3 | import SwitchRoles from "./components/SwitchRoles.vue" 4 | </script> 5 | 6 | <template> 7 | <div class="app-container"> 8 | <SwitchRoles /> 9 | <!-- v-permission 示例 --> 10 | <div class="margin-top-30"> 11 | <div> 12 | <el-tag v-permission="['admin']" type="success" size="large" effect="plain"> 13 | 这里采用了 v-permission="['admin']" 所以只有 admin 可以看见这句话 14 | </el-tag> 15 | </div> 16 | <div> 17 | <el-tag v-permission="['editor']" type="success" size="large" effect="plain"> 18 | 这里采用了 v-permission="['editor']" 所以只有 editor 可以看见这句话 19 | </el-tag> 20 | </div> 21 | <div class="margin-top-15"> 22 | <el-tag v-permission="['admin', 'editor']" type="success" size="large" effect="plain"> 23 | 这里采用了 v-permission="['admin', 'editor']" 所以 admin 和 editor 都可以看见这句话 24 | </el-tag> 25 | </div> 26 | </div> 27 | <!-- checkPermission 示例 --> 28 | <div class="margin-top-30"> 29 | <el-tag type="warning" size="large"> 30 | 例如 Element Plus 的 el-tab-pane 或 el-table-column 以及其它动态渲染 Dom 的场景不适合使用 31 | v-permission,这种情况下你可以通过 v-if 和 checkPermission 来实现: 32 | </el-tag> 33 | <el-tabs type="border-card" class="margin-top-15"> 34 | <el-tab-pane v-if="checkPermission(['admin'])" label="admin"> 35 | 这里采用了 <el-tag>v-if="checkPermission(['admin'])"</el-tag> 所以只有 admin 可以看见这句话 36 | </el-tab-pane> 37 | <el-tab-pane v-if="checkPermission(['editor'])" label="editor"> 38 | 这里采用了 <el-tag>v-if="checkPermission(['editor'])"</el-tag> 所以只有 editor 可以看见这句话 39 | </el-tab-pane> 40 | <el-tab-pane v-if="checkPermission(['admin', 'editor'])" label="admin 和 editor"> 41 | 这里采用了 <el-tag>v-if="checkPermission(['admin', 'editor'])"</el-tag> 所以 admin 和 editor 都可以看见这句话 42 | </el-tab-pane> 43 | </el-tabs> 44 | </div> 45 | </div> 46 | </template> 47 | 48 | <style lang="scss" scoped> 49 | .margin-top-15 { 50 | margin-top: 15px; 51 | } 52 | 53 | .margin-top-30 { 54 | margin-top: 30px; 55 | } 56 | </style> 57 | -------------------------------------------------------------------------------- /src/views/permission/page.vue: -------------------------------------------------------------------------------- 1 | <script lang="ts" setup> 2 | import SwitchRoles from "./components/SwitchRoles.vue" 3 | </script> 4 | 5 | <template> 6 | <div class="app-container"> 7 | <SwitchRoles /> 8 | <el-tag type="warning" size="large">当前页面只有 admin 角色可见,切换角色后将不能进入该页面</el-tag> 9 | </div> 10 | </template> 11 | 12 | <style lang="scss" scoped> 13 | .el-tag { 14 | margin-top: 15px; 15 | } 16 | </style> 17 | -------------------------------------------------------------------------------- /src/views/redirect/index.vue: -------------------------------------------------------------------------------- 1 | <script lang="ts" setup> 2 | import { useRoute, useRouter } from "vue-router" 3 | 4 | const route = useRoute() 5 | const router = useRouter() 6 | 7 | router.replace({ path: "/" + route.params.path, query: route.query }) 8 | </script> 9 | 10 | <template> 11 | <div /> 12 | </template> 13 | -------------------------------------------------------------------------------- /src/views/unocss/index.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <div h-full uno-padding-20> 3 | <div h-full text-center flex select-none all:transition-400> 4 | <div ma> 5 | <div text-5xl fw100 animate-bounce-alt animate-count-infinite animate-1s>UnoCSS</div> 6 | <div op30 dark:op60 text-lg fw300 m1>该页面是一个 UnoCSS 的使用案例,其他页面依旧采用 Scss</div> 7 | <div m2 flex justify-center text-lg op30 dark:op60 hover="op80" dark:hover="op80"> 8 | <a href="https://antfu.me/posts/reimagine-atomic-css-zh" target="_blank">推荐阅读:重新构想原子化 CSS</a> 9 | </div> 10 | </div> 11 | </div> 12 | </div> 13 | </template> 14 | -------------------------------------------------------------------------------- /static/icons/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/un-pany/v3-electron-vite/8cf3b2d9f2351a2e9c455b8bc285633a4ee125a9/static/icons/logo.png -------------------------------------------------------------------------------- /static/icons/logo_256x256.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/un-pany/v3-electron-vite/8cf3b2d9f2351a2e9c455b8bc285633a4ee125a9/static/icons/logo_256x256.icns -------------------------------------------------------------------------------- /static/icons/logo_256x256.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/un-pany/v3-electron-vite/8cf3b2d9f2351a2e9c455b8bc285633a4ee125a9/static/icons/logo_256x256.ico -------------------------------------------------------------------------------- /static/icons/logo_256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/un-pany/v3-electron-vite/8cf3b2d9f2351a2e9c455b8bc285633a4ee125a9/static/icons/logo_256x256.png -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | /** https://cn.vitejs.dev/guide/features.html#typescript-compiler-options */ 5 | "useDefineForClassFields": true, 6 | "module": "esnext", 7 | "moduleResolution": "bundler", 8 | /** TS 严格模式 */ 9 | "strict": true, 10 | "jsx": "preserve", 11 | "jsxImportSource": "vue", 12 | "importHelpers": true, 13 | "noImplicitAny": false, 14 | "experimentalDecorators": true, 15 | "allowSyntheticDefaultImports": true, 16 | "sourceMap": true, 17 | "resolveJsonModule": true, 18 | /** https://cn.vitejs.dev/guide/features.html#typescript-compiler-options */ 19 | "isolatedModules": true, 20 | "esModuleInterop": true, 21 | "lib": ["esnext", "dom"], 22 | "skipLibCheck": true, 23 | "types": [ 24 | "node", 25 | "vite/client", 26 | /** Element Plus 的 Volar 插件支持 */ 27 | "element-plus/global" 28 | ], 29 | /** baseUrl 用来告诉编译器到哪里去查找模块,使用非相对模块时必须配置此项 */ 30 | "baseUrl": ".", 31 | /** 非相对模块导入的路径映射配置,根据 baseUrl 配置进行路径计算 */ 32 | "paths": { 33 | "@/*": ["src/*"] 34 | } 35 | }, 36 | "include": [ 37 | "script/**/*.ts", 38 | "src/**/*.ts", 39 | "src/**/*.d.ts", 40 | "src/**/*.tsx", 41 | "src/**/*.vue", 42 | "types/**/*.d.ts", 43 | "vite.config.mts" 44 | ], 45 | /** 编译器默认排除的编译文件 */ 46 | "exclude": ["node_modules", "dist", "release"] 47 | } 48 | -------------------------------------------------------------------------------- /types/api.d.ts: -------------------------------------------------------------------------------- 1 | /** 所有 api 接口的响应数据都应该准守该格式 */ 2 | interface ApiResponseData<T> { 3 | code: number 4 | data: T 5 | message: string 6 | } 7 | -------------------------------------------------------------------------------- /types/electron-vue.d.ts: -------------------------------------------------------------------------------- 1 | /** 设置窗口状态 */ 2 | interface WinStateDTO { 3 | width: number 4 | height: number 5 | center: boolean 6 | maxable: boolean 7 | resizable: boolean 8 | } 9 | 10 | /** 执行 cmd 命令的结果 */ 11 | interface CmdResult { 12 | tid: string 13 | success: boolean 14 | command: string 15 | options?: any 16 | spent: number 17 | error: any 18 | stderr: string 19 | stdout: string 20 | message: string 21 | } 22 | 23 | interface Window { 24 | /** electron.remote */ 25 | vRemote: typeof import("@electron/remote") 26 | /** electron.ipcRenderer */ 27 | vIpcRenderer: (typeof import("electron"))["ipcRenderer"] 28 | /** 本地日志 */ 29 | vLog: (logName: LogType, ...params: any[]) => void 30 | } 31 | -------------------------------------------------------------------------------- /types/env.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace NodeJS { 2 | interface ProcessEnv { 3 | readonly VITE_APP_TITLE: string 4 | readonly VITE_BASE_API: string 5 | } 6 | } 7 | 8 | /** 声明 vite 环境变量的类型(如果未声明则默认是 any) */ 9 | interface ImportMetaEnv { 10 | readonly VITE_APP_TITLE: string 11 | readonly VITE_BASE_API: string 12 | } 13 | 14 | interface ImportMeta { 15 | readonly env: ImportMetaEnv 16 | } 17 | -------------------------------------------------------------------------------- /types/global-components.d.ts: -------------------------------------------------------------------------------- 1 | import SvgIcon from "@/components/SvgIcon/index.vue" 2 | 3 | /** 由 app.component 全局注册的组件需要在这里声明 TS 类型才能获得 Volar 插件提供的类型提示) */ 4 | declare module "vue" { 5 | export interface GlobalComponents { 6 | SvgIcon: typeof SvgIcon 7 | } 8 | } 9 | 10 | export {} 11 | -------------------------------------------------------------------------------- /types/shims-vue.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.scss" { 2 | const scss: Record<string, string> 3 | export default scss 4 | } 5 | -------------------------------------------------------------------------------- /types/vue-router.d.ts: -------------------------------------------------------------------------------- 1 | import "vue-router" 2 | 3 | declare module "vue-router" { 4 | interface RouteMeta { 5 | /** 6 | * 设置该路由在侧边栏和面包屑中展示的名字 7 | */ 8 | title?: string 9 | /** 10 | * 设置该路由的图标,记得将 svg 导入 @/icons/svg 11 | */ 12 | svgIcon?: string 13 | /** 14 | * 设置该路由的图标,直接使用 Element Plus 的 Icon(与 svgIcon 同时设置时,svgIcon 将优先生效) 15 | */ 16 | elIcon?: string 17 | /** 18 | * 默认 false,设置 true 的时候该路由不会在侧边栏出现 19 | */ 20 | hidden?: boolean 21 | /** 22 | * 设置能进入该路由的角色,支持多个角色叠加 23 | */ 24 | roles?: string[] 25 | /** 26 | * 默认 true,如果设置为 false,则不会在面包屑中显示 27 | */ 28 | breadcrumb?: boolean 29 | /** 30 | * 默认 false,如果设置为 true,它则会固定在 tags-view 中 31 | */ 32 | affix?: boolean 33 | /** 34 | * 当一个路由下面的 children 声明的路由大于 1 个时,自动会变成嵌套的模式, 35 | * 只有一个时,会将那个子路由当做根路由显示在侧边栏, 36 | * 若想不管路由下面的 children 声明的个数都显示你的根路由, 37 | * 可以设置 alwaysShow: true,这样就会忽略之前定义的规则,一直显示根路由 38 | */ 39 | alwaysShow?: boolean 40 | /** 41 | * 示例: activeMenu: "/xxx/xxx", 42 | * 当设置了该属性进入路由时,则会高亮 activeMenu 属性对应的侧边栏。 43 | * 该属性适合使用在有 hidden: true 属性的路由上 44 | */ 45 | activeMenu?: string 46 | /** 47 | * 是否缓存该路由页面 48 | * 默认为 false,为 true 时代表需要缓存,此时该路由和该页面都需要设置一致的 Name 49 | */ 50 | keepAlive?: boolean 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /unocss.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, presetAttributify, presetUno } from "unocss" 2 | 3 | export default defineConfig({ 4 | /** 预设 */ 5 | presets: [ 6 | /** 属性化模式 & 无值的属性模式 */ 7 | presetAttributify(), 8 | /** 默认预设 */ 9 | presetUno() 10 | ], 11 | /** 自定义规则 */ 12 | rules: [["uno-padding-20", { padding: "20px" }]], 13 | /** 自定义快捷方式 */ 14 | shortcuts: { 15 | "uno-wh-full": "w-full h-full", 16 | "uno-flex-center": "flex justify-center items-center", 17 | "uno-flex-x-center": "flex justify-center", 18 | "uno-flex-y-center": "flex items-center" 19 | } 20 | }) 21 | -------------------------------------------------------------------------------- /vite.config.mts: -------------------------------------------------------------------------------- 1 | import { ConfigEnv, UserConfigExport } from "vite" 2 | import { resolve } from "path" 3 | import vue from "@vitejs/plugin-vue" 4 | import vueJsx from "@vitejs/plugin-vue-jsx" 5 | import { createSvgIconsPlugin } from "vite-plugin-svg-icons" 6 | import svgLoader from "vite-svg-loader" 7 | import UnoCSS from "unocss/vite" 8 | import electron from "vite-electron-plugin" 9 | import { loadViteEnv } from "vite-electron-plugin/plugin" 10 | import { rmSync } from "fs" 11 | import pkg from "./package.json" 12 | 13 | /** 清空 dist */ 14 | rmSync("dist", { recursive: true, force: true }) 15 | 16 | /** 配置项文档:https://cn.vitejs.dev/config */ 17 | export default ({ mode }: ConfigEnv): UserConfigExport => { 18 | // const viteEnv = loadEnv(mode, process.cwd()) as ImportMetaEnv 19 | return { 20 | resolve: { 21 | alias: { 22 | /** @ 符号指向 src 目录 */ 23 | "@": resolve(__dirname, "./src") 24 | } 25 | }, 26 | server: { 27 | /** 是否自动打开浏览器 */ 28 | open: false, 29 | /** 设置 host: true 才可以使用 Network 的形式,以 IP 访问项目 */ 30 | host: pkg.env.host, 31 | /** 端口号 */ 32 | port: pkg.env.port, 33 | /** 预热常用文件,提高初始页面加载速度 */ 34 | warmup: { 35 | clientFiles: ["./src/layouts/**/*.vue"] 36 | } 37 | }, 38 | build: { 39 | /** 单个 chunk 文件的大小超过 2048KB 时发出警告 */ 40 | chunkSizeWarningLimit: 2048, 41 | /** 禁用 gzip 压缩大小报告 */ 42 | reportCompressedSize: false, 43 | rollupOptions: { 44 | output: { 45 | /** 46 | * 分块策略 47 | * 1. 注意这些包名必须存在,否则打包会报错 48 | * 2. 如果你不想自定义 chunk 分割策略,可以直接移除这段配置 49 | */ 50 | manualChunks: { 51 | vue: ["vue", "vue-router", "pinia"], 52 | element: ["element-plus", "@element-plus/icons-vue"], 53 | vxe: ["vxe-table", "vxe-table-plugin-element", "xe-utils"] 54 | } 55 | } 56 | } 57 | }, 58 | /** 混淆器 */ 59 | esbuild: 60 | mode === "development" 61 | ? undefined 62 | : { 63 | /** 打包时移除 console.log */ 64 | // pure: ["console.log"], 65 | /** 打包时移除 debugger */ 66 | drop: ["debugger"], 67 | /** 打包时移除所有注释 */ 68 | legalComments: "none" 69 | }, 70 | /** Vite 插件 */ 71 | plugins: [ 72 | vue(), 73 | vueJsx(), 74 | /** 将 SVG 静态图转化为 Vue 组件 */ 75 | svgLoader({ defaultImport: "url" }), 76 | /** SVG 插件 */ 77 | createSvgIconsPlugin({ 78 | // Specify the icon folder to be cached 79 | iconDirs: [resolve(process.cwd(), "./src/icons/svg")], 80 | // Specify symbolId format 81 | symbolId: "icon-[dir]-[name]", 82 | inject: "body-first" 83 | }), 84 | /** UnoCSS */ 85 | UnoCSS(), 86 | electron({ 87 | outDir: "dist", 88 | include: ["script"], 89 | transformOptions: { sourcemap: false }, 90 | plugins: [ 91 | { 92 | name: "remove-comments", 93 | transform: ({ code }) => { 94 | let content = code 95 | // 匹配 块级注释、行级注释、Region注释 96 | // \s 是匹配所有空白符, 包括换行; \S 非空白符, 不包括换行 97 | const pattern1 = /\/\*[\s\S]*?\*\/|(\s)+\/\/[\s\S]*?[\n]+/g 98 | content = content.replaceAll(pattern1, "\n") 99 | // 匹配 所有空行 100 | const pattern2 = /^\s*[\r\n]/gm 101 | content = content.replaceAll(pattern2, "") 102 | return content 103 | } 104 | }, 105 | loadViteEnv() 106 | ] 107 | }) 108 | ], 109 | css: { 110 | postcss: { 111 | plugins: [ 112 | { 113 | postcssPlugin: "internal:charset-removal", 114 | AtRule: { 115 | charset: (atRule) => { 116 | if (atRule.name === "charset") { 117 | atRule.remove() 118 | } 119 | } 120 | } 121 | } 122 | ] 123 | } 124 | }, 125 | clearScreen: false 126 | } 127 | } 128 | --------------------------------------------------------------------------------