├── .editorconfig ├── .env ├── .env.development ├── .env.production ├── .env.staging ├── .eslintignore ├── .eslintrc.cjs ├── .github ├── FUNDING.yml └── workflows │ └── deploy.yml ├── .gitignore ├── .husky └── pre-commit ├── .npmrc ├── .prettierignore ├── .vite └── deps_temp_fe7bd124 │ └── package.json ├── .vscode ├── extensions.json ├── hook.code-snippets ├── settings.json └── vue.code-snippets ├── README.md ├── README.zh-CN.md ├── index.html ├── package-lock.json ├── package.json ├── pnpm-lock.yaml ├── prettier.config.js ├── public ├── app-loading.css └── favicon.ico ├── 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 │ ├── error-page │ │ ├── 403.svg │ │ └── 404.svg │ ├── layouts │ │ └── 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 ├── directives │ ├── index.ts │ └── permission │ │ └── index.ts ├── hooks │ ├── useDevice.ts │ ├── useFetchSelect.ts │ ├── useFullscreenLoading.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 │ │ ├── CompConsumer │ │ │ └── index.ts │ │ ├── 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 │ │ ├── cookies.ts │ │ └── local-storage.ts │ ├── index.ts │ ├── permission.ts │ ├── service.ts │ └── validate.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 │ │ └── tsx │ │ ├── RoleColumnSolts.tsx │ │ └── StatusColumnSolts.tsx │ └── unocss │ └── index.vue ├── tests ├── components │ └── Notify.test.ts ├── demo.test.ts └── utils │ └── validate.test.ts ├── tsconfig.json ├── types ├── api.d.ts ├── env.d.ts ├── global-components.d.ts ├── shims-vue.d.ts └── vue-router.d.ts ├── unocss.config.ts └── vite.config.ts /.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 = My Vue3 Template 5 | -------------------------------------------------------------------------------- /.env.development: -------------------------------------------------------------------------------- 1 | # 开发环境自定义的环境变量(命名必须以 VITE_ 开头) 2 | 3 | ## 后端接口公共路径(如果解决跨域问题采用反向代理就只需写公共路径) 4 | VITE_BASE_API = '/api/v1' 5 | 6 | ## 路由模式 hash 或 html5 7 | VITE_ROUTER_HISTORY = 'hash' 8 | 9 | ## 开发环境地址前缀(一般 '/','./' 都可以) 10 | VITE_PUBLIC_PATH = '/' 11 | -------------------------------------------------------------------------------- /.env.production: -------------------------------------------------------------------------------- 1 | # 生产环境自定义的环境变量(命名必须以 VITE_ 开头) 2 | 3 | ## 后端接口公共路径(如果解决跨域问题采用 CORS 就需要写全路径) 4 | VITE_BASE_API = 'https://mock.mengxuegu.com/mock/63218b5fb4c53348ed2bc212/api/v1' 5 | 6 | ## 路由模式 hash 或 html5 7 | VITE_ROUTER_HISTORY = 'hash' 8 | 9 | ## 打包路径(就是网站前缀,例如部署到 https://xx.github.io/vue3-admin-vite/ 域名下,就需要填写 /vue3-admin-vite/) 10 | VITE_PUBLIC_PATH = '/vue3-admin-vite/' 11 | -------------------------------------------------------------------------------- /.env.staging: -------------------------------------------------------------------------------- 1 | # 预发布环境自定义的环境变量(命名必须以 VITE_ 开头) 2 | 3 | ## 后端接口公共路径(如果解决跨域问题采用 CORS 就需要写全路径) 4 | VITE_BASE_API = 'https://mock.mengxuegu.com/mock/63218b5fb4c53348ed2bc212/api/v1' 5 | 6 | ## 路由模式 hash 或 html5 7 | VITE_ROUTER_HISTORY = 'hash' 8 | 9 | ## 打包路径(就是网站前缀,例如部署到 https://xxx.github.io/vue3-admin-vite/ 域名下,就需要填写 /vue3-admin-vite/) 10 | VITE_PUBLIC_PATH = '/vue3-admin-vite/' 11 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # Eslint 会忽略的文件 2 | 3 | .DS_Store 4 | node_modules 5 | dist 6 | dist-ssr 7 | *.local 8 | .npmrc 9 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 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-unused-expressions": "off", 29 | "@typescript-eslint/no-explicit-any": "off", 30 | "no-debugger": "off", 31 | "@typescript-eslint/explicit-module-boundary-types": "off", 32 | "@typescript-eslint/ban-types": "off", 33 | "@typescript-eslint/ban-ts-comment": "off", 34 | "@typescript-eslint/no-empty-function": "off", 35 | "@typescript-eslint/no-non-null-assertion": "off", 36 | "@typescript-eslint/no-unused-vars": [ 37 | "error", 38 | { 39 | argsIgnorePattern: "^_", 40 | varsIgnorePattern: "^_" 41 | } 42 | ], 43 | "no-unused-vars": [ 44 | "error", 45 | { 46 | argsIgnorePattern: "^_", 47 | varsIgnorePattern: "^_" 48 | } 49 | ], 50 | // Vue 51 | "vue/no-v-html": "off", 52 | "vue/require-default-prop": "off", 53 | "vue/require-explicit-emits": "off", 54 | "vue/multi-word-component-names": "off", 55 | "vue/html-self-closing": [ 56 | "error", 57 | { 58 | html: { 59 | void: "always", 60 | normal: "always", 61 | component: "always" 62 | }, 63 | svg: "always", 64 | math: "always" 65 | } 66 | ], 67 | // Prettier 68 | "prettier/prettier": [ 69 | "error", 70 | { 71 | endOfLine: "auto" 72 | } 73 | ] 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | custom: https://github.com/xsf0105/my-vue3-template/issues/69 2 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Build And Deploy my-vue3-template 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | build-and-deploy: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v2 14 | with: 15 | persist-credentials: false 16 | 17 | - name: Setup Node.js 20.15.1 18 | uses: actions/setup-node@master 19 | with: 20 | node-version: 20.15.1 21 | 22 | - name: Setup pnpm 23 | uses: pnpm/action-setup@v2 24 | with: 25 | version: 9.5.0 26 | 27 | - name: Build 28 | run: pnpm install && pnpm build:prod 29 | 30 | - name: Deploy 31 | uses: JamesIves/github-pages-deploy-action@releases/v3 32 | with: 33 | ACCESS_TOKEN: ${{ secrets.GITHUB_TOKEN }} 34 | BRANCH: gh-pages 35 | FOLDER: dist 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Git 会忽略的文件 2 | 3 | .DS_Store 4 | node_modules 5 | dist 6 | dist-ssr 7 | .eslintcache 8 | 9 | # Local env files 10 | *.local 11 | 12 | # Logs 13 | logs 14 | *.log 15 | npm-debug.log* 16 | yarn-debug.log* 17 | yarn-error.log* 18 | pnpm-debug.log* 19 | lerna-debug.log* 20 | 21 | # Editor directories and files 22 | .vscode/* 23 | !.vscode/extensions.json 24 | !.vscode/settings.json 25 | !.vscode/*.code-snippets 26 | .idea 27 | *.suo 28 | *.ntvs* 29 | *.njsproj 30 | *.sln 31 | *.sw? 32 | 33 | # Use the PNPM 34 | package-lock.json 35 | yarn.lock 36 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx lint-staged 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | # China mirror of npm 2 | # registry = https://registry.npmmirror.com 3 | 4 | # 通过该配置兜底解决组件没有类型提示的问题 5 | shamefully-hoist = true 6 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Prettier 会忽略的文件 2 | 3 | .DS_Store 4 | node_modules 5 | dist 6 | dist-ssr 7 | *.local 8 | .npmrc 9 | -------------------------------------------------------------------------------- /.vite/deps_temp_fe7bd124/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module" 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "editorconfig.editorconfig", 4 | "dbaeumer.vscode-eslint", 5 | "esbenp.prettier-vscode", 6 | "vue.volar", 7 | "antfu.unocss", 8 | "vitest.explorer", 9 | "wiensss.region-highlighter" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.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 | "prettier.enable": true, 3 | "editor.codeActionsOnSave": { 4 | "source.fixAll.eslint": "explicit" 5 | }, 6 | "[vue]": { 7 | "editor.defaultFormatter": "esbenp.prettier-vscode" 8 | }, 9 | "[javascript]": { 10 | "editor.defaultFormatter": "esbenp.prettier-vscode" 11 | }, 12 | "[typescript]": { 13 | "editor.defaultFormatter": "esbenp.prettier-vscode" 14 | }, 15 | "[json]": { 16 | "editor.defaultFormatter": "esbenp.prettier-vscode" 17 | }, 18 | "[jsonc]": { 19 | "editor.defaultFormatter": "esbenp.prettier-vscode" 20 | }, 21 | "[html]": { 22 | "editor.defaultFormatter": "esbenp.prettier-vscode" 23 | }, 24 | "[css]": { 25 | "editor.defaultFormatter": "esbenp.prettier-vscode" 26 | }, 27 | "[scss]": { 28 | "editor.defaultFormatter": "esbenp.prettier-vscode" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Introduction 2 | 3 | Vue3 Admin Vite is a free and open source middle and background management system basic solution, based on mainstream framework such as Vue3, TypeScript, Element Plus, Pinia and Vite 4 | 5 | ## Feature 6 | 7 | - **Vue3**:The latest Vue3 composition API using Vue3 + script setup 8 | - **Element Plus**:Vue3 version of Element UI 9 | - **Pinia**: An alternative to Vuex in Vue3 10 | - **Vite**:Really fast 11 | - **Vue Router**:router 12 | - **TypeScript**:JavaScript With Syntax For Types 13 | - **PNPM**:Faster, disk space saving package management tool 14 | - **Scss**:Consistent with Element Plus 15 | - **CSS variable**:Mainly controls the layout and color of the item 16 | - **ESlint**:Code verification 17 | - **Prettier**: Code formatting 18 | - **Axios**: Promise based HTTP client (encapsulated) 19 | - **UnoCSS**: Real-time atomized CSS engine with high performance and flexibility 20 | - **Mobile Compatible**: The layout is compatible with mobile page resolution 21 | 22 | ## Functions 23 | 24 | - **User management**: Log in and out of the demo 25 | - **Authority management**: Page-level permissions (dynamic routing), button-level permissions (directive permissions, permission functions), and route navigation guards 26 | - **Multiple Environments**: Development, Staging, Production 27 | - **Multiple themes**: Normal, Dark, Dark Blue, three theme modes 28 | - **Multiple layouts**:Left, Top, Left Top, three layout modes 29 | - **Error page**: 403, 404 30 | - **Dashboard**: Display different Dashboard pages according to different users 31 | - **Other functions**:SVG, Dynamic Sidebar, Dynamic Breadcrumb Navigation, Tabbed Navigation, Screenfull, Adaptive Shrink Sidebar, Hook (Composables) 32 | 33 | ## 🚀 Development 34 | 35 | ```bash 36 | # configure 37 | 1. installation of the recommended plugins in the .vscode directory 38 | 2. node version 18.x or 20+ 39 | 3. pnpm version 8.x or latest 40 | 41 | # clone 42 | git clone https://github.com/xsf0105/my-vue3-template.git 43 | 44 | # enter the project directory 45 | cd my-vue3-template 46 | 47 | # install dependencies 48 | pnpm i 49 | 50 | # start the service 51 | pnpm dev 52 | ``` 53 | 54 | ## ✔️ Preview 55 | 56 | ```bash 57 | # stage environment 58 | pnpm preview:stage 59 | 60 | # prod environment 61 | pnpm preview:prod 62 | ``` 63 | 64 | ## 📦️ Multi-environment packaging 65 | 66 | ```bash 67 | # build the stage environment 68 | pnpm build:stage 69 | 70 | # build the prod environment 71 | pnpm build:prod 72 | ``` 73 | 74 | ## 🔧 Code inspection 75 | 76 | ```bash 77 | # code formatting 78 | pnpm lint 79 | 80 | # unit test 81 | pnpm test 82 | ``` 83 | 84 | ## Git commit specification reference 85 | 86 | - `feat` add new functions 87 | - `fix` Fix issues/bugs 88 | - `perf` Optimize performance 89 | - `style` Change the code style without affecting the running result 90 | - `refactor` Re-factor code 91 | - `revert` Undo changes 92 | - `test` Test related, does not involve changes to business code 93 | - `docs` Documentation and Annotation 94 | - `chore` Updating dependencies/modifying scaffolding configuration, etc. 95 | - `workflow` Work flow Improvements 96 | - `ci` CICD 97 | - `types` Type definition 98 | - `wip` In development 99 | -------------------------------------------------------------------------------- /README.zh-CN.md: -------------------------------------------------------------------------------- 1 |
2 |

VUE3 Admin Vite

3 | English | 中文 4 |
5 | 6 | ## ⚡ 简介 7 | 8 | VUE3 Admin Vite 是一个免费开源的中后台管理系统基础解决方案,基于 Vue3、TypeScript、Element Plus、Pinia 和 Vite 等主流技术 9 | 10 | ## 特性 11 | 12 | - **Vue3**:采用 Vue3 + script setup 最新的 Vue3 组合式 API 13 | - **Element Plus**:Element UI 的 Vue3 版本 14 | - **Pinia**: 传说中的 Vuex5 15 | - **Vite**:真的很快 16 | - **Vue Router**:路由路由 17 | - **TypeScript**:JavaScript 语言的超集 18 | - **PNPM**:更快速的,节省磁盘空间的包管理工具 19 | - **Scss**:和 Element Plus 保持一致 20 | - **CSS 变量**:主要控制项目的布局和颜色 21 | - **ESlint**:代码校验 22 | - **Prettier**:代码格式化 23 | - **Axios**:发送网络请求(已封装好) 24 | - **UnoCSS**:具有高性能且极具灵活性的即时原子化 CSS 引擎 25 | - **兼容移动端**: 布局兼容移动端页面分辨率 26 | 27 | ## 功能 28 | 29 | - **用户管理**:登录、登出演示 30 | - **权限管理**:页面级权限(动态路由)、按钮级权限(指令权限、权限函数)、路由守卫 31 | - **多环境**:开发环境(development)、预发布环境(staging)、正式环境(production) 32 | - **多主题**:普通、黑暗、深蓝, 三种主题模式 33 | - **多布局**:左侧、顶部、混合, 三种布局模式 34 | - **错误页面**: 403、404 35 | - **Dashboard**:根据不同用户显示不同的 Dashboard 页面 36 | - **其他内置功能**:SVG、动态侧边栏、动态面包屑、标签页快捷导航、Screenfull 全屏、自适应收缩侧边栏、Hook(Composables) 37 | 38 | ## 🚀 开发 39 | 40 | ```bash 41 | # 配置 42 | 1. 一键安装 .vscode 目录中推荐的插件 43 | 2. node 版本 18.x 或 20+ 44 | 3. pnpm 版本 8.x 或最新版 45 | 46 | # 克隆项目 47 | git clone https://github.com/xsf0105/my-vue3-template.git 48 | 49 | # 进入项目目录 50 | cd my-vue3-template 51 | 52 | # 安装依赖 53 | pnpm i 54 | 55 | # 启动服务 56 | pnpm dev 57 | ``` 58 | 59 | ## ✔️ 预览 60 | 61 | ```bash 62 | # 预览预发布环境 63 | pnpm preview:stage 64 | 65 | # 预览正式环境 66 | pnpm preview:prod 67 | ``` 68 | 69 | ## 📦️ 多环境打包 70 | 71 | ```bash 72 | # 构建预发布环境 73 | pnpm build:stage 74 | 75 | # 构建正式环境 76 | pnpm build:prod 77 | ``` 78 | 79 | ## 🔧 代码检查 80 | 81 | ```bash 82 | # 代码格式化 83 | pnpm lint 84 | 85 | # 单元测试 86 | pnpm test 87 | ``` 88 | 89 | ## Git 提交规范参考 90 | 91 | - `feat` 增加新的业务功能 92 | - `fix` 修复业务问题/BUG 93 | - `perf` 优化性能 94 | - `style` 更改代码风格, 不影响运行结果 95 | - `refactor` 重构代码 96 | - `revert` 撤销更改 97 | - `test` 测试相关, 不涉及业务代码的更改 98 | - `docs` 文档和注释相关 99 | - `chore` 更新依赖/修改脚手架配置等琐事 100 | - `workflow` 工作流改进 101 | - `ci` 持续集成相关 102 | - `types` 类型定义文件更改 103 | - `wip` 开发中 104 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | %VITE_APP_TITLE% 9 | 10 | 11 |
12 |
13 |
14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "my-vue3-vite-template", 3 | "description": "一个免费开源的中后台管理系统基础解决方案,基于 Vue3、TypeScript、Element Plus、Pinia 和 Vite 等主流技术", 4 | "type": "module", 5 | "scripts": { 6 | "dev": "vite", 7 | "build:stage": "vue-tsc --noEmit && vite build --mode staging", 8 | "build:prod": "vue-tsc --noEmit && vite build", 9 | "preview:stage": "pnpm build:stage && vite preview", 10 | "preview:prod": "pnpm build:prod && vite preview", 11 | "lint:eslint": "eslint --cache --max-warnings 0 \"{src,tests,types}/**/*.{vue,js,jsx,ts,tsx}\" --fix", 12 | "lint:prettier": "prettier --write \"{src,tests,types}/**/*.{vue,js,jsx,ts,tsx,json,css,less,scss,html,md}\"", 13 | "lint": "pnpm lint:eslint && pnpm lint:prettier", 14 | "prepare": "husky", 15 | "test": "vitest" 16 | }, 17 | "dependencies": { 18 | "@element-plus/icons-vue": "2.3.1", 19 | "axios": "1.7.5", 20 | "dayjs": "1.11.13", 21 | "element-plus": "2.8.1", 22 | "js-cookie": "3.0.5", 23 | "lodash-es": "4.17.21", 24 | "mitt": "3.0.1", 25 | "normalize.css": "8.0.1", 26 | "nprogress": "0.2.0", 27 | "path-browserify": "1.0.1", 28 | "path-to-regexp": "8.0.0", 29 | "pinia": "2.2.2", 30 | "screenfull": "6.0.2", 31 | "vue": "3.4.38", 32 | "vue-router": "4.4.3", 33 | "vxe-table": "4.6.18", 34 | "vxe-table-plugin-element": "4.0.4", 35 | "xe-utils": "3.5.30" 36 | }, 37 | "devDependencies": { 38 | "@types/js-cookie": "3.0.6", 39 | "@types/lodash-es": "4.17.12", 40 | "@types/node": "22.5.0", 41 | "@types/nprogress": "0.2.3", 42 | "@types/path-browserify": "1.0.3", 43 | "@typescript-eslint/eslint-plugin": "8.2.0", 44 | "@typescript-eslint/parser": "8.2.0", 45 | "@vitejs/plugin-vue": "5.1.2", 46 | "@vitejs/plugin-vue-jsx": "4.0.1", 47 | "@vue/eslint-config-prettier": "9.0.0", 48 | "@vue/eslint-config-typescript": "13.0.0", 49 | "@vue/test-utils": "2.4.6", 50 | "eslint": "8.57.0", 51 | "eslint-plugin-prettier": "5.2.1", 52 | "eslint-plugin-vue": "9.27.0", 53 | "husky": "9.1.5", 54 | "jsdom": "24.1.1", 55 | "lint-staged": "15.2.9", 56 | "prettier": "3.3.3", 57 | "sass": "1.77.8", 58 | "typescript": "5.5.4", 59 | "unocss": "0.62.2", 60 | "vite": "5.4.6", 61 | "vite-plugin-dev-inspector": "^2.2.5", 62 | "vite-plugin-svg-icons": "2.0.1", 63 | "vite-svg-loader": "5.1.0", 64 | "vitest": "2.0.5", 65 | "vue-eslint-parser": "9.4.3", 66 | "vue-tsc": "2.0.29" 67 | }, 68 | "lint-staged": { 69 | "*.{vue,js,jsx,ts,tsx}": [ 70 | "eslint --fix", 71 | "prettier --write" 72 | ], 73 | "*.{css,less,scss,html,md}": [ 74 | "prettier --write" 75 | ], 76 | "package.json": [ 77 | "prettier --write" 78 | ] 79 | }, 80 | "keywords": [ 81 | "vue", 82 | "vue3", 83 | "admin", 84 | "vue-admin", 85 | "vue3-admin", 86 | "vite", 87 | "vite-admin", 88 | "element-plus", 89 | "element-plus-admin", 90 | "ts", 91 | "typescript" 92 | ], 93 | "license": "MIT" 94 | } 95 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 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 | -------------------------------------------------------------------------------- /public/app-loading.css: -------------------------------------------------------------------------------- 1 | /** 白屏阶段会执行的 CSS 加载动画 */ 2 | 3 | #app-loading { 4 | position: relative; 5 | top: 45vh; 6 | margin: 0 auto; 7 | color: #409eff; 8 | font-size: 12px; 9 | } 10 | 11 | #app-loading, 12 | #app-loading::before, 13 | #app-loading::after { 14 | width: 2em; 15 | height: 2em; 16 | border-radius: 50%; 17 | animation: 2s ease-in-out infinite app-loading-animation; 18 | } 19 | 20 | #app-loading::before, 21 | #app-loading::after { 22 | content: ""; 23 | position: absolute; 24 | } 25 | 26 | #app-loading::before { 27 | left: -4em; 28 | animation-delay: -0.2s; 29 | } 30 | 31 | #app-loading::after { 32 | left: 4em; 33 | animation-delay: 0.2s; 34 | } 35 | 36 | @keyframes app-loading-animation { 37 | 0%, 38 | 80%, 39 | 100% { 40 | box-shadow: 0 2em 0 -2em; 41 | } 42 | 40% { 43 | box-shadow: 0 2em 0 0; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xxxsf/my-vue3-template/0fd9256a32be81820731aff037c28da66dca1bdd/public/favicon.ico -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 27 | -------------------------------------------------------------------------------- /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((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((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({ 7 | url: "login/code", 8 | method: "get" 9 | }) 10 | } 11 | 12 | /** 登录并返回 Token */ 13 | export function loginApi(data: Login.LoginRequestData) { 14 | return request({ 15 | url: "users/login", 16 | method: "post", 17 | data 18 | }) 19 | } 20 | 21 | /** 获取用户详情 */ 22 | export function getUserInfoApi() { 23 | return request({ 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 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({ 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/layouts/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xxxsf/my-vue3-template/0fd9256a32be81820731aff037c28da66dca1bdd/src/assets/layouts/logo.png -------------------------------------------------------------------------------- /src/assets/login/close-eyes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xxxsf/my-vue3-template/0fd9256a32be81820731aff037c28da66dca1bdd/src/assets/login/close-eyes.png -------------------------------------------------------------------------------- /src/assets/login/face.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xxxsf/my-vue3-template/0fd9256a32be81820731aff037c28da66dca1bdd/src/assets/login/face.png -------------------------------------------------------------------------------- /src/assets/login/hand-down-left.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xxxsf/my-vue3-template/0fd9256a32be81820731aff037c28da66dca1bdd/src/assets/login/hand-down-left.png -------------------------------------------------------------------------------- /src/assets/login/hand-down-right.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xxxsf/my-vue3-template/0fd9256a32be81820731aff037c28da66dca1bdd/src/assets/login/hand-down-right.png -------------------------------------------------------------------------------- /src/assets/login/hand-up-left.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xxxsf/my-vue3-template/0fd9256a32be81820731aff037c28da66dca1bdd/src/assets/login/hand-up-left.png -------------------------------------------------------------------------------- /src/assets/login/hand-up-right.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xxxsf/my-vue3-template/0fd9256a32be81820731aff037c28da66dca1bdd/src/assets/login/hand-up-right.png -------------------------------------------------------------------------------- /src/components/Notify/NotifyList.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 33 | 34 | 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: "xx.png", 13 | title: "Vue3 Admin Vite 上线啦", 14 | datetime: "一年前", 15 | description: 16 | "一个免费开源的中后台管理系统基础解决方案,基于 Vue3、TypeScript、Element Plus、Pinia 和 Vite 等主流技术" 17 | }, 18 | { 19 | avatar: "xx.png", 20 | title: "Vue3 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 | 52 | 53 | 84 | 85 | 96 | -------------------------------------------------------------------------------- /src/components/Screenfull/index.vue: -------------------------------------------------------------------------------- 1 | 74 | 75 | 95 | 96 | 104 | -------------------------------------------------------------------------------- /src/components/SearchMenu/SearchFooter.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 33 | 34 | 55 | -------------------------------------------------------------------------------- /src/components/SearchMenu/SearchModal.vue: -------------------------------------------------------------------------------- 1 | 149 | 150 | 188 | 189 | 203 | -------------------------------------------------------------------------------- /src/components/SearchMenu/SearchResult.vue: -------------------------------------------------------------------------------- 1 | 65 | 66 | 86 | 87 | 111 | -------------------------------------------------------------------------------- /src/components/SearchMenu/index.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 21 | 22 | 30 | -------------------------------------------------------------------------------- /src/components/SvgIcon/index.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 21 | 22 | 30 | -------------------------------------------------------------------------------- /src/components/ThemeSwitch/index.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 51 | -------------------------------------------------------------------------------- /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 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 } from "vue-router" 2 | 3 | /** 免登录白名单(匹配路由 path) */ 4 | const whiteListByPath: string[] = ["/login"] 5 | 6 | /** 免登录白名单(匹配路由 name) */ 7 | const whiteListByName: string[] = [] 8 | 9 | /** 判断是否在白名单 */ 10 | const isWhiteList = (to: RouteLocationNormalized) => { 11 | // path 和 name 任意一个匹配上即可 12 | return whiteListByPath.indexOf(to.path) !== -1 || whiteListByName.indexOf(to.name as any) !== -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 = "my-vue3-template" 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/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 { useUserStoreHook } from "@/store/modules/user" 3 | 4 | /** 权限指令,和权限判断函数 checkPermission 功能类似 */ 5 | export const permission: Directive = { 6 | mounted(el, binding) { 7 | const { value: permissionRoles } = binding 8 | const { roles } = useUserStoreHook() 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 14 | 15 | /** 入参格式,暂时只需要传递 api 函数即可 */ 16 | interface FetchSelectProps { 17 | api: () => Promise 18 | } 19 | 20 | export function useFetchSelect(props: FetchSelectProps) { 21 | const { api } = props 22 | 23 | const loading = ref(false) 24 | const options = ref([]) 25 | const value = ref("") 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 | ReturnType>( 14 | fn: T, 15 | options?: LoadingOptions 16 | ): (...args: Parameters) => Promise> 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/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(getActiveThemeName() || DEFAULT_THEME_NAME) 33 | 34 | /** 设置主题 */ 35 | const setTheme = (value: ThemeName) => { 36 | activeThemeName.value = value 37 | } 38 | 39 | /** 在 html 根元素上挂载 class */ 40 | const setHtmlRootClassName = (value: ThemeName) => { 41 | document.documentElement.className = value 42 | } 43 | 44 | /** 初始化 */ 45 | const initTheme = () => { 46 | // watchEffect 来收集副作用 47 | watchEffect(() => { 48 | const value = activeThemeName.value 49 | setHtmlRootClassName(value) 50 | setActiveThemeName(value) 51 | }) 52 | } 53 | 54 | /** 主题 hook */ 55 | export function useTheme() { 56 | return { themeList, activeThemeName, initTheme, setTheme } 57 | } 58 | -------------------------------------------------------------------------------- /src/hooks/useTitle.ts: -------------------------------------------------------------------------------- 1 | import { ref, watch } from "vue" 2 | 3 | /** 项目标题 */ 4 | const VITE_APP_TITLE = import.meta.env.VITE_APP_TITLE ?? "V3 Admin Vite" 5 | 6 | /** 动态标题 */ 7 | const dynamicTitle = ref("") 8 | 9 | /** 设置标题 */ 10 | const setTitle = (title?: string) => { 11 | dynamicTitle.value = title ? `${VITE_APP_TITLE} | ${title}` : VITE_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/hooks/useWatermark.ts: -------------------------------------------------------------------------------- 1 | import { type Ref, onBeforeUnmount, ref } from "vue" 2 | import { debounce } from "lodash-es" 3 | 4 | type Observer = { 5 | watermarkElMutationObserver?: MutationObserver 6 | parentElMutationObserver?: MutationObserver 7 | parentElResizeObserver?: ResizeObserver 8 | } 9 | 10 | type DefaultConfig = typeof defaultConfig 11 | 12 | /** 默认配置 */ 13 | const defaultConfig = { 14 | /** 防御(默认开启,能防御水印被删除或隐藏,但可能会有性能损耗) */ 15 | defense: true, 16 | /** 文本颜色 */ 17 | color: "#c0c4cc", 18 | /** 文本透明度 */ 19 | opacity: 0.5, 20 | /** 文本字体大小 */ 21 | size: 16, 22 | /** 文本字体 */ 23 | family: "serif", 24 | /** 文本倾斜角度 */ 25 | angle: -20, 26 | /** 一处水印所占宽度(数值越大水印密度越低) */ 27 | width: 300, 28 | /** 一处水印所占高度(数值越大水印密度越低) */ 29 | height: 200 30 | } 31 | 32 | /** body 元素 */ 33 | const bodyEl = ref(document.body) 34 | 35 | /** 36 | * 创建水印 37 | * 1. 可以选择传入挂载水印的容器元素,默认是 body 38 | * 2. 做了水印防御,能有效防御别人打开控制台删除或隐藏水印 39 | */ 40 | export function useWatermark(parentEl: Ref = bodyEl) { 41 | /** 备份文本 */ 42 | let backupText: string 43 | /** 最终配置 */ 44 | let mergeConfig: DefaultConfig 45 | /** 水印元素 */ 46 | let watermarkEl: HTMLElement | null = null 47 | /** 观察器 */ 48 | const observer: Observer = { 49 | watermarkElMutationObserver: undefined, 50 | parentElMutationObserver: undefined, 51 | parentElResizeObserver: undefined 52 | } 53 | 54 | /** 设置水印 */ 55 | const setWatermark = (text: string, config: Partial = {}) => { 56 | if (!parentEl.value) { 57 | console.warn("请在 DOM 挂载完成后再调用 setWatermark 方法设置水印") 58 | return 59 | } 60 | // 备份文本 61 | backupText = text 62 | // 合并配置 63 | mergeConfig = { ...defaultConfig, ...config } 64 | // 创建或更新水印元素 65 | watermarkEl ? updateWatermarkEl() : createWatermarkEl() 66 | // 监听水印元素和容器元素的变化 67 | addElListener(parentEl.value) 68 | } 69 | 70 | /** 创建水印元素 */ 71 | const createWatermarkEl = () => { 72 | const isBody = parentEl.value!.tagName.toLowerCase() === bodyEl.value.tagName.toLowerCase() 73 | const watermarkElPosition = isBody ? "fixed" : "absolute" 74 | const parentElPosition = isBody ? "" : "relative" 75 | watermarkEl = document.createElement("div") 76 | watermarkEl.style.pointerEvents = "none" 77 | watermarkEl.style.top = "0" 78 | watermarkEl.style.left = "0" 79 | watermarkEl.style.position = watermarkElPosition 80 | watermarkEl.style.zIndex = "99999" 81 | const { clientWidth, clientHeight } = parentEl.value! 82 | updateWatermarkEl({ width: clientWidth, height: clientHeight }) 83 | // 设置水印容器为相对定位 84 | parentEl.value!.style.position = parentElPosition 85 | // 将水印元素添加到水印容器中 86 | parentEl.value!.appendChild(watermarkEl) 87 | } 88 | 89 | /** 更新水印元素 */ 90 | const updateWatermarkEl = ( 91 | options: Partial<{ 92 | width: number 93 | height: number 94 | }> = {} 95 | ) => { 96 | if (!watermarkEl) return 97 | backupText && (watermarkEl.style.background = `url(${createBase64()}) left top repeat`) 98 | options.width && (watermarkEl.style.width = `${options.width}px`) 99 | options.height && (watermarkEl.style.height = `${options.height}px`) 100 | } 101 | 102 | /** 创建 base64 图片 */ 103 | const createBase64 = () => { 104 | const { color, opacity, size, family, angle, width, height } = mergeConfig 105 | const canvasEl = document.createElement("canvas") 106 | canvasEl.width = width 107 | canvasEl.height = height 108 | const ctx = canvasEl.getContext("2d") 109 | if (ctx) { 110 | ctx.fillStyle = color 111 | ctx.globalAlpha = opacity 112 | ctx.font = `${size}px ${family}` 113 | ctx.rotate((Math.PI / 180) * angle) 114 | ctx.fillText(backupText, 0, height / 2) 115 | } 116 | return canvasEl.toDataURL() 117 | } 118 | 119 | /** 清除水印 */ 120 | const clearWatermark = () => { 121 | if (!parentEl.value || !watermarkEl) return 122 | // 移除对水印元素和容器元素的监听 123 | removeListener() 124 | // 移除水印元素 125 | try { 126 | parentEl.value.removeChild(watermarkEl) 127 | } catch { 128 | // 比如在无防御情况下,用户打开控制台删除了这个元素 129 | console.warn("水印元素已不存在,请重新创建") 130 | } finally { 131 | watermarkEl = null 132 | } 133 | } 134 | 135 | /** 刷新水印(防御时调用) */ 136 | const updateWatermark = debounce(() => { 137 | clearWatermark() 138 | createWatermarkEl() 139 | addElListener(parentEl.value!) 140 | }, 100) 141 | 142 | /** 监听水印元素和容器元素的变化(DOM 变化 & DOM 大小变化) */ 143 | const addElListener = (targetNode: HTMLElement) => { 144 | // 判断是否开启防御 145 | if (mergeConfig.defense) { 146 | // 防止重复添加监听 147 | if (!observer.watermarkElMutationObserver && !observer.parentElMutationObserver) { 148 | // 监听 DOM 变化 149 | addMutationListener(targetNode) 150 | } 151 | } else { 152 | // 无防御时不需要 mutation 监听 153 | removeListener("mutation") 154 | } 155 | // 防止重复添加监听 156 | if (!observer.parentElResizeObserver) { 157 | // 监听 DOM 大小变化 158 | addResizeListener(targetNode) 159 | } 160 | } 161 | 162 | /** 移除对水印元素和容器元素的监听,传参可指定要移除哪个监听,不传默认移除全部监听 */ 163 | const removeListener = (kind: "mutation" | "resize" | "all" = "all") => { 164 | // 移除 mutation 监听 165 | if (kind === "mutation" || kind === "all") { 166 | observer.watermarkElMutationObserver?.disconnect() 167 | observer.watermarkElMutationObserver = undefined 168 | observer.parentElMutationObserver?.disconnect() 169 | observer.parentElMutationObserver = undefined 170 | } 171 | // 移除 resize 监听 172 | if (kind === "resize" || kind === "all") { 173 | observer.parentElResizeObserver?.disconnect() 174 | observer.parentElResizeObserver = undefined 175 | } 176 | } 177 | 178 | /** 监听 DOM 变化 */ 179 | const addMutationListener = (targetNode: HTMLElement) => { 180 | // 当观察到变动时执行的回调 181 | const mutationCallback = debounce((mutationList: MutationRecord[]) => { 182 | // 水印的防御(防止用户手动删除水印元素或通过 CSS 隐藏水印) 183 | mutationList.forEach( 184 | debounce((mutation: MutationRecord) => { 185 | switch (mutation.type) { 186 | case "attributes": 187 | mutation.target === watermarkEl && updateWatermark() 188 | break 189 | case "childList": 190 | mutation.removedNodes.forEach((item) => { 191 | item === watermarkEl && targetNode.appendChild(watermarkEl) 192 | }) 193 | break 194 | } 195 | }, 100) 196 | ) 197 | }, 100) 198 | // 创建观察器实例并传入回调 199 | observer.watermarkElMutationObserver = new MutationObserver(mutationCallback) 200 | observer.parentElMutationObserver = new MutationObserver(mutationCallback) 201 | // 以上述配置开始观察目标节点 202 | observer.watermarkElMutationObserver.observe(watermarkEl!, { 203 | // 观察目标节点属性是否变动,默认为 true 204 | attributes: true, 205 | // 观察目标子节点是否有添加或者删除,默认为 false 206 | childList: false, 207 | // 是否拓展到观察所有后代节点,默认为 false 208 | subtree: false 209 | }) 210 | observer.parentElMutationObserver.observe(targetNode, { 211 | attributes: false, 212 | childList: true, 213 | subtree: false 214 | }) 215 | } 216 | 217 | /** 监听 DOM 大小变化 */ 218 | const addResizeListener = (targetNode: HTMLElement) => { 219 | // 当 targetNode 元素大小变化时去更新整个水印的大小 220 | const resizeCallback = debounce(() => { 221 | const { clientWidth, clientHeight } = targetNode 222 | updateWatermarkEl({ width: clientWidth, height: clientHeight }) 223 | }, 500) 224 | // 创建一个观察器实例并传入回调 225 | observer.parentElResizeObserver = new ResizeObserver(resizeCallback) 226 | // 开始观察目标节点 227 | observer.parentElResizeObserver.observe(targetNode) 228 | } 229 | 230 | /** 在组件卸载前移除水印以及各种监听 */ 231 | onBeforeUnmount(() => { 232 | clearWatermark() 233 | }) 234 | 235 | return { setWatermark, clearWatermark } 236 | } 237 | -------------------------------------------------------------------------------- /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 | 2 | -------------------------------------------------------------------------------- /src/icons/svg/bug.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/icons/svg/component.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/icons/svg/dashboard.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/icons/svg/fullscreen-exit.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/icons/svg/fullscreen.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/icons/svg/keyboard-down.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/icons/svg/keyboard-enter.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/icons/svg/keyboard-esc.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/icons/svg/keyboard-up.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/icons/svg/link.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/icons/svg/lock.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/icons/svg/menu.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/icons/svg/search.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/icons/svg/unocss.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/layouts/LeftMode.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 48 | 49 | 171 | -------------------------------------------------------------------------------- /src/layouts/LeftTopMode.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 39 | 40 | 112 | -------------------------------------------------------------------------------- /src/layouts/TopMode.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 27 | 28 | 76 | -------------------------------------------------------------------------------- /src/layouts/components/AppMain.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 30 | 31 | 50 | -------------------------------------------------------------------------------- /src/layouts/components/Breadcrumb/index.vue: -------------------------------------------------------------------------------- 1 | 41 | 42 | 54 | 55 | 66 | -------------------------------------------------------------------------------- /src/layouts/components/CompConsumer/index.ts: -------------------------------------------------------------------------------- 1 | import { type VNode, cloneVNode, createVNode, defineComponent, h, KeepAlive } from "vue" 2 | import { useRoute } from "vue-router" 3 | import { useTagsViewStore } from "@/store/modules/tags-view" 4 | 5 | interface CompConsumerProps { 6 | component: VNode 7 | } 8 | 9 | /** 定义 compMap 对象,用于存储路由名称和对应的组件 */ 10 | const compMap = new Map() 11 | 12 | /** 13 | * CompConsumer 组件 14 | * 用法:替换 标签以及内部代码,变成: 15 | * 优点:缓存路由时只需写路由 Name,无需再写组件 Name 16 | * 缺点:当路由表有动态路由匹配时(指向同一个组件),会出现复用组件的情况(例如修改 /info/1 时 /info/2 也会跟着改变) 17 | */ 18 | export const CompConsumer = defineComponent( 19 | (props: CompConsumerProps) => { 20 | const tagsViewStore = useTagsViewStore() 21 | const route = useRoute() 22 | return () => { 23 | // 获取传入的组件 24 | const component = props.component 25 | // 判断当前是否包含 name,如果不包含 name,那就直接处理掉 name 26 | if (!route.name) return component 27 | // 获取当前组件的名称 28 | const compName = (component.type as any)?.name 29 | // 获取当前路由的名称 30 | const routeName = route.name as string 31 | let Comp: VNode 32 | // 检查 compMap 中是否已经存在对应的组件 33 | if (compMap.has(routeName)) { 34 | // 如果存在,则直接使用该组件进行渲染 35 | Comp = compMap.get(routeName)! 36 | } else { 37 | // 如果不存在,则克隆传入的组件并创建一个新的组件,将其添加到 compMap 中 38 | const node = cloneVNode(component) 39 | if (compName && compName === routeName) { 40 | ;(node.type as any).name = `__${compName}__CUSTOM_NAME` 41 | } 42 | // @ts-expect-error this is VNode 43 | Comp = defineComponent({ 44 | name: routeName, 45 | setup() { 46 | return () => node 47 | } 48 | }) 49 | compMap.set(routeName, Comp) 50 | } 51 | // 使用 createVNode 函数创建一个 KeepAlive 组件,并缓存 cachedViews 数组中对应的组件 52 | return createVNode( 53 | KeepAlive, 54 | { 55 | include: tagsViewStore.cachedViews 56 | }, 57 | { 58 | default: () => h(Comp) 59 | } 60 | ) 61 | } 62 | }, 63 | { 64 | name: "CompConsumer", 65 | props: ["component"] 66 | } 67 | ) 68 | -------------------------------------------------------------------------------- /src/layouts/components/Footer/index.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 8 | 9 | 19 | -------------------------------------------------------------------------------- /src/layouts/components/Hamburger/index.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 30 | 31 | 37 | -------------------------------------------------------------------------------- /src/layouts/components/Logo/index.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 30 | 31 | 65 | -------------------------------------------------------------------------------- /src/layouts/components/NavigationBar/index.vue: -------------------------------------------------------------------------------- 1 | 37 | 38 | 72 | 73 | 131 | -------------------------------------------------------------------------------- /src/layouts/components/RightPanel/index.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 27 | 28 | 46 | -------------------------------------------------------------------------------- /src/layouts/components/Settings/SelectLayoutMode.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 40 | 41 | 104 | -------------------------------------------------------------------------------- /src/layouts/components/Settings/index.vue: -------------------------------------------------------------------------------- 1 | 50 | 51 | 64 | 65 | 87 | -------------------------------------------------------------------------------- /src/layouts/components/Sidebar/SidebarItem.vue: -------------------------------------------------------------------------------- 1 | 55 | 56 | 84 | 85 | 98 | -------------------------------------------------------------------------------- /src/layouts/components/Sidebar/SidebarItemLink.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 19 | -------------------------------------------------------------------------------- /src/layouts/components/Sidebar/index.vue: -------------------------------------------------------------------------------- 1 | 51 | 52 | 71 | 72 | 157 | -------------------------------------------------------------------------------- /src/layouts/components/TagsView/ScrollPane.vue: -------------------------------------------------------------------------------- 1 | 103 | 104 | 120 | 121 | 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 | 46 | 47 | 61 | 62 | 71 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | // core 2 | import { createApp } from "vue" 3 | import App from "@/App.vue" 4 | import store from "@/store" 5 | import router from "@/router" 6 | import "@/router/permission" 7 | // load 8 | import { loadSvg } from "@/icons" 9 | import { loadPlugins } from "@/plugins" 10 | import { loadDirectives } from "@/directives" 11 | // css 12 | import "uno.css" 13 | import "normalize.css" 14 | import "element-plus/dist/index.css" 15 | import "element-plus/theme-chalk/dark/css-vars.css" 16 | import "vxe-table/lib/style.css" 17 | import "vxe-table-plugin-element/dist/style.css" 18 | import "@/styles/index.scss" 19 | 20 | const app = createApp(App) 21 | 22 | /** 加载插件 */ 23 | loadPlugins(app) 24 | /** 加载全局 SVG */ 25 | loadSvg(app) 26 | /** 加载自定义指令 */ 27 | loadDirectives(app) 28 | 29 | app.use(store).use(router) 30 | router.isReady().then(() => { 31 | app.mount("#app") 32 | }) 33 | -------------------------------------------------------------------------------- /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/#/table/start/install 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 提示内容,如果为 null 则不显示文本 */ 18 | loadingText: null, 19 | table: { 20 | showHeader: true, 21 | showOverflow: "tooltip", 22 | showHeaderOverflow: "tooltip", 23 | autoResize: true, 24 | // stripe: false, 25 | border: "inner", 26 | // round: false, 27 | emptyText: "暂无数据", 28 | rowConfig: { 29 | isHover: true, 30 | isCurrent: true, 31 | // 行数据的唯一主键字段名 32 | keyField: "_VXE_ID" 33 | }, 34 | columnConfig: { 35 | resizable: false 36 | }, 37 | align: "center", 38 | headerAlign: "center" 39 | }, 40 | pager: { 41 | // size: "medium", 42 | /** 配套的样式 */ 43 | perfect: false, 44 | pageSize: 10, 45 | pagerCount: 7, 46 | pageSizes: [10, 20, 50], 47 | layouts: ["Total", "PrevJump", "PrevPage", "Number", "NextPage", "NextJump", "Sizes", "FullJump"] 48 | }, 49 | modal: { 50 | minWidth: 500, 51 | minHeight: 400, 52 | lockView: true, 53 | mask: true, 54 | // duration: 3000, 55 | // marginSize: 20, 56 | dblclickZoom: false, 57 | showTitleOverflow: true, 58 | transfer: true, 59 | draggable: false 60 | } 61 | }) 62 | 63 | export function loadVxeTable(app: App) { 64 | /** Vxe Table 组件完整引入 */ 65 | app.use(VXETable) 66 | } 67 | -------------------------------------------------------------------------------- /src/router/helper.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type Router, 3 | type RouteRecordNormalized, 4 | type RouteRecordRaw, 5 | createRouter, 6 | createWebHashHistory, 7 | createWebHistory 8 | } from "vue-router" 9 | import { cloneDeep, omit } from "lodash-es" 10 | 11 | /** 路由模式 */ 12 | export const history = 13 | import.meta.env.VITE_ROUTER_HISTORY === "hash" 14 | ? createWebHashHistory(import.meta.env.VITE_PUBLIC_PATH) 15 | : createWebHistory(import.meta.env.VITE_PUBLIC_PATH) 16 | 17 | /** 路由降级(把三级及其以上的路由转化为二级路由) */ 18 | export const flatMultiLevelRoutes = (routes: RouteRecordRaw[]) => { 19 | const routesMirror = cloneDeep(routes) 20 | routesMirror.forEach((route) => { 21 | // 如果路由是三级及其以上路由,对其进行降级处理 22 | isMultipleRoute(route) && promoteRouteLevel(route) 23 | }) 24 | return routesMirror 25 | } 26 | 27 | /** 判断路由层级是否大于 2 */ 28 | const isMultipleRoute = (route: RouteRecordRaw) => { 29 | const children = route.children 30 | if (children?.length) { 31 | // 只要有一个子路由的 children 长度大于 0,就说明是三级及其以上路由 32 | return children.some((child) => child.children?.length) 33 | } 34 | return false 35 | } 36 | 37 | /** 生成二级路由 */ 38 | const promoteRouteLevel = (route: RouteRecordRaw) => { 39 | // 创建 router 实例是为了获取到当前传入的 route 的所有路由信息 40 | let router: Router | null = createRouter({ 41 | history, 42 | routes: [route] 43 | }) 44 | const routes = router.getRoutes() 45 | // 在 addToChildren 函数中使用上面获取到的路由信息来更新 route 的 children 46 | addToChildren(routes, route.children || [], route) 47 | router = null 48 | // 转为二级路由后,去除所有子路由中的 children 49 | route.children = route.children?.map((item) => omit(item, "children") as RouteRecordRaw) 50 | } 51 | 52 | /** 将给定的子路由添加到指定的路由模块中 */ 53 | const addToChildren = (routes: RouteRecordNormalized[], children: RouteRecordRaw[], routeModule: RouteRecordRaw) => { 54 | children.forEach((child) => { 55 | const route = routes.find((item) => item.name === child.name) 56 | if (route) { 57 | // 初始化 routeModule 的 children 58 | routeModule.children = routeModule.children || [] 59 | // 如果 routeModule 的 children 属性中不包含该路由,则将其添加进去 60 | if (!routeModule.children.includes(route)) { 61 | routeModule.children.push(route) 62 | } 63 | // 如果该子路由还有自己的子路由,则递归调用此函数将它们也添加进去 64 | if (child.children?.length) { 65 | addToChildren(routes, child.children, routeModule) 66 | } 67 | } 68 | }) 69 | } 70 | -------------------------------------------------------------------------------- /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/cookies" 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 | const { setTitle } = useTitle() 14 | NProgress.configure({ showSpinner: false }) 15 | 16 | router.beforeEach(async (to, _from, next) => { 17 | NProgress.start() 18 | const userStore = useUserStoreHook() 19 | const permissionStore = usePermissionStoreHook() 20 | const token = getToken() 21 | 22 | // 如果没有登陆 23 | if (!token) { 24 | // 如果在免登录的白名单中,则直接进入 25 | if (isWhiteList(to)) return next() 26 | // 其他没有访问权限的页面将被重定向到登录页面 27 | return next("/login") 28 | } 29 | 30 | // 如果已经登录,并准备进入 Login 页面,则重定向到主页 31 | if (to.path === "/login") { 32 | return next({ path: "/" }) 33 | } 34 | 35 | // 如果用户已经获得其权限角色 36 | if (userStore.roles.length !== 0) return next() 37 | 38 | // 否则要重新获取权限角色 39 | try { 40 | await userStore.getInfo() 41 | // 注意:角色必须是一个数组! 例如: ["admin"] 或 ["developer", "editor"] 42 | const roles = userStore.roles 43 | // 生成可访问的 Routes 44 | routeSettings.dynamic ? permissionStore.setRoutes(roles) : permissionStore.setAllRoutes() 45 | // 将 "有访问权限的动态路由" 添加到 Router 中 46 | permissionStore.addRoutes.forEach((route) => router.addRoute(route)) 47 | // 确保添加路由已完成 48 | // 设置 replace: true, 因此导航将不会留下历史记录 49 | next({ ...to, replace: true }) 50 | } catch (err: any) { 51 | // 过程中发生任何错误,都直接重置 Token,并重定向到登录页面 52 | userStore.resetToken() 53 | ElMessage.error(err.message || "路由守卫过程发生错误") 54 | next("/login") 55 | } 56 | }) 57 | 58 | router.afterEach((to) => { 59 | setRouteChange(to) 60 | setTitle(to.meta.title) 61 | NProgress.done() 62 | }) 63 | -------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | import { createPinia } from "pinia" 2 | 3 | const store = createPinia() 4 | 5 | export default store 6 | -------------------------------------------------------------------------------- /src/store/modules/app.ts: -------------------------------------------------------------------------------- 1 | import { reactive, ref, watch } from "vue" 2 | import { defineStore } from "pinia" 3 | import { getSidebarStatus, setSidebarStatus } from "@/utils/cache/local-storage" 4 | import { DeviceEnum, SIDEBAR_OPENED, SIDEBAR_CLOSED } from "@/constants/app-key" 5 | 6 | interface Sidebar { 7 | opened: boolean 8 | withoutAnimation: boolean 9 | } 10 | 11 | /** 设置侧边栏状态本地缓存 */ 12 | function handleSidebarStatus(opened: boolean) { 13 | opened ? setSidebarStatus(SIDEBAR_OPENED) : setSidebarStatus(SIDEBAR_CLOSED) 14 | } 15 | 16 | export const useAppStore = defineStore("app", () => { 17 | /** 侧边栏状态 */ 18 | const sidebar: Sidebar = reactive({ 19 | opened: getSidebarStatus() !== SIDEBAR_CLOSED, 20 | withoutAnimation: false 21 | }) 22 | /** 设备类型 */ 23 | const device = ref(DeviceEnum.Desktop) 24 | 25 | /** 监听侧边栏 opened 状态 */ 26 | watch( 27 | () => sidebar.opened, 28 | (opened) => handleSidebarStatus(opened) 29 | ) 30 | 31 | /** 切换侧边栏 */ 32 | const toggleSidebar = (withoutAnimation: boolean) => { 33 | sidebar.opened = !sidebar.opened 34 | sidebar.withoutAnimation = withoutAnimation 35 | } 36 | /** 关闭侧边栏 */ 37 | const closeSidebar = (withoutAnimation: boolean) => { 38 | sidebar.opened = false 39 | sidebar.withoutAnimation = withoutAnimation 40 | } 41 | /** 切换设备类型 */ 42 | const toggleDevice = (value: DeviceEnum) => { 43 | device.value = value 44 | } 45 | 46 | return { device, sidebar, toggleSidebar, closeSidebar, toggleDevice } 47 | }) 48 | -------------------------------------------------------------------------------- /src/store/modules/permission.ts: -------------------------------------------------------------------------------- 1 | import { ref } from "vue" 2 | import store 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([]) 31 | /** 有访问权限的动态路由 */ 32 | const addRoutes = ref([]) 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 | /** 在 setup 外使用 */ 54 | export function usePermissionStoreHook() { 55 | return usePermissionStore(store) 56 | } 57 | -------------------------------------------------------------------------------- /src/store/modules/settings.ts: -------------------------------------------------------------------------------- 1 | import { type Ref, ref, watch } from "vue" 2 | import { defineStore } from "pinia" 3 | import { type LayoutSettings, layoutSettings } from "@/config/layouts" 4 | import { setConfigLayout } from "@/utils/cache/local-storage" 5 | 6 | type SettingsStore = { 7 | // 使用映射类型来遍历 layoutSettings 对象的键 8 | [Key in keyof LayoutSettings]: Ref 9 | } 10 | 11 | type SettingsStoreKey = keyof SettingsStore 12 | 13 | export const useSettingsStore = defineStore("settings", () => { 14 | /** 状态对象 */ 15 | const state = {} as SettingsStore 16 | // 遍历 layoutSettings 对象的键值对 17 | for (const [key, value] of Object.entries(layoutSettings)) { 18 | // 使用类型断言来指定 key 的类型,将 value 包装在 ref 函数中,创建一个响应式变量 19 | const refValue = ref(value) 20 | // @ts-ignore 21 | state[key as SettingsStoreKey] = refValue 22 | // 监听每个响应式变量 23 | watch(refValue, () => { 24 | // 缓存 25 | const settings = _getCacheData() 26 | setConfigLayout(settings) 27 | }) 28 | } 29 | /** 获取要缓存的数据:将 state 对象转化为 settings 对象 */ 30 | const _getCacheData = () => { 31 | const settings = {} as LayoutSettings 32 | for (const [key, value] of Object.entries(state)) { 33 | // @ts-ignore 34 | settings[key as SettingsStoreKey] = value.value 35 | } 36 | return settings 37 | } 38 | 39 | return state 40 | }) 41 | -------------------------------------------------------------------------------- /src/store/modules/tags-view.ts: -------------------------------------------------------------------------------- 1 | import { ref, watchEffect } from "vue" 2 | import { defineStore } from "pinia" 3 | import { useSettingsStore } from "./settings" 4 | import { type RouteLocationNormalized } from "vue-router" 5 | import { getVisitedViews, setVisitedViews, getCachedViews, setCachedViews } from "@/utils/cache/local-storage" 6 | 7 | export type TagView = Partial 8 | 9 | export const useTagsViewStore = defineStore("tags-view", () => { 10 | const { cacheTagsView } = useSettingsStore() 11 | const visitedViews = ref(cacheTagsView ? getVisitedViews() : []) 12 | const cachedViews = ref(cacheTagsView ? getCachedViews() : []) 13 | 14 | /** 缓存标签栏数据 */ 15 | watchEffect(() => { 16 | setVisitedViews(visitedViews.value) 17 | setCachedViews(cachedViews.value) 18 | }) 19 | 20 | //#region add 21 | const addVisitedView = (view: TagView) => { 22 | // 检查是否已经存在相同的 visitedView 23 | const index = visitedViews.value.findIndex((v) => v.path === view.path) 24 | if (index !== -1) { 25 | // 防止 query 参数丢失 26 | visitedViews.value[index].fullPath !== view.fullPath && (visitedViews.value[index] = { ...view }) 27 | } else { 28 | // 添加新的 visitedView 29 | visitedViews.value.push({ ...view }) 30 | } 31 | } 32 | 33 | const addCachedView = (view: TagView) => { 34 | if (typeof view.name !== "string") return 35 | if (cachedViews.value.includes(view.name)) return 36 | if (view.meta?.keepAlive) cachedViews.value.push(view.name) 37 | } 38 | //#endregion 39 | 40 | //#region del 41 | const delVisitedView = (view: TagView) => { 42 | const index = visitedViews.value.findIndex((v) => v.path === view.path) 43 | if (index !== -1) visitedViews.value.splice(index, 1) 44 | } 45 | 46 | const delCachedView = (view: TagView) => { 47 | if (typeof view.name !== "string") return 48 | const index = cachedViews.value.indexOf(view.name) 49 | if (index !== -1) cachedViews.value.splice(index, 1) 50 | } 51 | //#endregion 52 | 53 | //#region delOthers 54 | const delOthersVisitedViews = (view: TagView) => { 55 | visitedViews.value = visitedViews.value.filter((v) => { 56 | return v.meta?.affix || v.path === view.path 57 | }) 58 | } 59 | 60 | const delOthersCachedViews = (view: TagView) => { 61 | if (typeof view.name !== "string") return 62 | const index = cachedViews.value.indexOf(view.name) 63 | if (index !== -1) { 64 | cachedViews.value = cachedViews.value.slice(index, index + 1) 65 | } else { 66 | // 如果 index = -1, 没有缓存的 tags 67 | cachedViews.value = [] 68 | } 69 | } 70 | //#endregion 71 | 72 | //#region delAll 73 | const delAllVisitedViews = () => { 74 | // 保留固定的 tags 75 | visitedViews.value = visitedViews.value.filter((tag) => tag.meta?.affix) 76 | } 77 | 78 | const delAllCachedViews = () => { 79 | cachedViews.value = [] 80 | } 81 | //#endregion 82 | 83 | return { 84 | visitedViews, 85 | cachedViews, 86 | addVisitedView, 87 | addCachedView, 88 | delVisitedView, 89 | delCachedView, 90 | delOthersVisitedViews, 91 | delOthersCachedViews, 92 | delAllVisitedViews, 93 | delAllCachedViews 94 | } 95 | }) 96 | -------------------------------------------------------------------------------- /src/store/modules/user.ts: -------------------------------------------------------------------------------- 1 | import { ref } from "vue" 2 | import store 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/cookies" 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(getToken() || "") 14 | const roles = ref([]) 15 | const username = ref("") 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 | /** 在 setup 外使用 */ 67 | export function useUserStoreHook() { 68 | return useUserStore(store) 69 | } 70 | -------------------------------------------------------------------------------- /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 | 27 | body { 28 | height: 100%; 29 | color: var(--v3-body-text-color); 30 | background-color: var(--v3-body-bg-color); 31 | -moz-osx-font-smoothing: grayscale; 32 | -webkit-font-smoothing: antialiased; 33 | font-family: "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "微软雅黑", Arial, 34 | sans-serif; 35 | @extend %scrollbar; 36 | } 37 | 38 | #app { 39 | height: 100%; 40 | } 41 | 42 | *, 43 | *::before, 44 | *::after { 45 | box-sizing: border-box; 46 | } 47 | 48 | a, 49 | a:focus, 50 | a:hover { 51 | color: inherit; 52 | outline: none; 53 | text-decoration: none; 54 | } 55 | 56 | div:focus { 57 | outline: none; 58 | } 59 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/utils/cache/cookies.ts: -------------------------------------------------------------------------------- 1 | /** 统一处理 Cookie */ 2 | 3 | import CacheKey from "@/constants/cache-key" 4 | import Cookies from "js-cookie" 5 | 6 | export const getToken = () => { 7 | return Cookies.get(CacheKey.TOKEN) 8 | } 9 | export const setToken = (token: string) => { 10 | Cookies.set(CacheKey.TOKEN, token) 11 | } 12 | export const removeToken = () => { 13 | Cookies.remove(CacheKey.TOKEN) 14 | } 15 | -------------------------------------------------------------------------------- /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/index.ts: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs" 2 | import { removeConfigLayout } from "@/utils/cache/local-storage" 3 | 4 | /** 格式化时间 */ 5 | export const formatDateTime = (time: string | number | Date) => { 6 | return time ? dayjs(new Date(time)).format("YYYY-MM-DD HH:mm:ss") : "N/A" 7 | } 8 | 9 | /** 用 JS 获取全局 css 变量 */ 10 | export const getCssVariableValue = (cssVariableName: string) => { 11 | let cssVariableValue = "" 12 | try { 13 | // 没有拿到值时,会返回空串 14 | cssVariableValue = getComputedStyle(document.documentElement).getPropertyValue(cssVariableName) 15 | } catch (error) { 16 | console.error(error) 17 | } 18 | return cssVariableValue 19 | } 20 | 21 | /** 用 JS 设置全局 CSS 变量 */ 22 | export const setCssVariableValue = (cssVariableName: string, cssVariableValue: string) => { 23 | try { 24 | document.documentElement.style.setProperty(cssVariableName, cssVariableValue) 25 | } catch (error) { 26 | console.error(error) 27 | } 28 | } 29 | 30 | /** 重置项目配置 */ 31 | export const resetConfigLayout = () => { 32 | removeConfigLayout() 33 | location.reload() 34 | } 35 | -------------------------------------------------------------------------------- /src/utils/permission.ts: -------------------------------------------------------------------------------- 1 | import { useUserStoreHook } 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 } = useUserStoreHook() 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 { useUserStoreHook } from "@/store/modules/user" 3 | import { ElMessage } from "element-plus" 4 | import { get, merge } from "lodash-es" 5 | import { getToken } from "./cache/cookies" 6 | 7 | /** 退出登录并强制刷新页面(会重定向到登录页) */ 8 | function logout() { 9 | useUserStoreHook().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 (config: AxiosRequestConfig): Promise { 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 = (arg: unknown) => { 3 | return Array.isArray ? Array.isArray(arg) : Object.prototype.toString.call(arg) === "[object Array]" 4 | } 5 | 6 | /** 判断是否为字符串 */ 7 | export const isString = (str: unknown) => { 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/views/dashboard/components/Admin.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 15 | -------------------------------------------------------------------------------- /src/views/dashboard/components/Editor.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 15 | -------------------------------------------------------------------------------- /src/views/dashboard/index.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 13 | -------------------------------------------------------------------------------- /src/views/error-page/403.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 11 | -------------------------------------------------------------------------------- /src/views/error-page/404.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 11 | -------------------------------------------------------------------------------- /src/views/error-page/components/ErrorPageLayout.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 25 | -------------------------------------------------------------------------------- /src/views/hook-demo/use-fetch-select.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 21 | -------------------------------------------------------------------------------- /src/views/hook-demo/use-fullscreen-loading.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 48 | -------------------------------------------------------------------------------- /src/views/hook-demo/use-watermark.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 36 | 37 | 48 | -------------------------------------------------------------------------------- /src/views/login/components/Owl.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 18 | 19 | 93 | -------------------------------------------------------------------------------- /src/views/login/hooks/useFocus.ts: -------------------------------------------------------------------------------- 1 | import { ref } from "vue" 2 | 3 | export function useFocus() { 4 | /** 是否有焦点 */ 5 | const isFocus = ref(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/login/index.vue: -------------------------------------------------------------------------------- 1 | 74 | 75 | 140 | 141 | 193 | -------------------------------------------------------------------------------- /src/views/menu/menu1/index.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 24 | -------------------------------------------------------------------------------- /src/views/menu/menu1/menu1-1/index.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 18 | -------------------------------------------------------------------------------- /src/views/menu/menu1/menu1-2/index.vue: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /src/views/menu/menu1/menu1-2/menu1-2-1/index.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 18 | -------------------------------------------------------------------------------- /src/views/menu/menu1/menu1-2/menu1-2-2/index.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 18 | -------------------------------------------------------------------------------- /src/views/menu/menu1/menu1-3/index.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 18 | -------------------------------------------------------------------------------- /src/views/menu/menu2/index.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 18 | -------------------------------------------------------------------------------- /src/views/permission/components/SwitchRoles.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 24 | 25 | 32 | -------------------------------------------------------------------------------- /src/views/permission/directive.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 47 | 48 | 57 | -------------------------------------------------------------------------------- /src/views/permission/page.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 11 | 12 | 17 | -------------------------------------------------------------------------------- /src/views/redirect/index.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 13 | -------------------------------------------------------------------------------- /src/views/table/vxe-table/tsx/RoleColumnSolts.tsx: -------------------------------------------------------------------------------- 1 | import { type VxeColumnPropTypes } from "vxe-table/types/column" 2 | 3 | const solts: VxeColumnPropTypes.Slots = { 4 | default: ({ row, column }) => { 5 | const cellValue = row[column.field] 6 | const type = cellValue === "admin" ? "primary" : "warning" 7 | return [{cellValue}] 8 | } 9 | } 10 | 11 | export default solts 12 | -------------------------------------------------------------------------------- /src/views/table/vxe-table/tsx/StatusColumnSolts.tsx: -------------------------------------------------------------------------------- 1 | import { type VxeColumnPropTypes } from "vxe-table/types/column" 2 | 3 | const solts: VxeColumnPropTypes.Slots = { 4 | default: ({ row, column }) => { 5 | const cellValue = row[column.field] 6 | const [type, value] = cellValue ? ["success", "启用"] : ["danger", "禁用"] 7 | return [{value}] 8 | } 9 | } 10 | 11 | export default solts 12 | -------------------------------------------------------------------------------- /src/views/unocss/index.vue: -------------------------------------------------------------------------------- 1 | 14 | -------------------------------------------------------------------------------- /tests/components/Notify.test.ts: -------------------------------------------------------------------------------- 1 | import { shallowMount } from "@vue/test-utils" 2 | import { describe, expect, it } from "vitest" 3 | import Notify from "@/components/Notify/index.vue" 4 | import NotifyList from "@/components/Notify/NotifyList.vue" 5 | 6 | describe("Notify", () => { 7 | it("正常渲染", () => { 8 | const wrapper = shallowMount(Notify) 9 | expect(wrapper.classes("notify")).toBe(true) 10 | }) 11 | }) 12 | 13 | describe("NotifyList", () => { 14 | it("List 长度为 0", () => { 15 | const wrapper = shallowMount(NotifyList, { 16 | props: { 17 | list: [] 18 | } 19 | }) 20 | expect(wrapper.find("el-empty").exists()).toBe(true) 21 | }) 22 | it("List 长度不为 0", () => { 23 | const wrapper = shallowMount(NotifyList, { 24 | props: { 25 | list: [ 26 | { 27 | title: "" 28 | } 29 | ] 30 | } 31 | }) 32 | expect(wrapper.find("el-empty").exists()).toBe(false) 33 | }) 34 | }) 35 | -------------------------------------------------------------------------------- /tests/demo.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest" 2 | 3 | /** 4 | * @description 该文件所有示例均是为了向你演示 Vitest 最基本的用法 5 | * @link https://cn.vitest.dev/api 6 | * @api describe: 形成一个作用域 7 | * @api test/it: 定义了一组关于测试期望的方法,它接收测试名称和一个含有测试期望的函数 8 | * @api expect: 用来创建断言 9 | * @api toBe: 可以用于断言原始类型是否相等,或者对象是否共享相同的引用 10 | * @api toEqual: 断言实际值是否等于接收到的值或具有相同的结构(如果是对象,则递归比较它们) 11 | */ 12 | 13 | const author1 = { 14 | name: "allan", 15 | email: "allan@gmail.com", 16 | url: "https://github.com/xsf0105" 17 | } 18 | 19 | const author2 = { 20 | name: "allan2", 21 | email: "allan2@gmail.com", 22 | url: "https://github.com/xsf0105" 23 | } 24 | 25 | describe("这里填写作用域名称", () => { 26 | it("测试基础数据类型", () => { 27 | expect(1 + 1).toBe(2) 28 | }) 29 | it("测试引用类型", () => { 30 | expect(author1).toEqual(author2) 31 | }) 32 | }) 33 | -------------------------------------------------------------------------------- /tests/utils/validate.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest" 2 | import { isArray } from "@/utils/validate" 3 | 4 | describe("isArray", () => { 5 | it("String", () => { 6 | expect(isArray("")).toBe(false) 7 | }) 8 | it("Number", () => { 9 | expect(isArray(1)).toBe(false) 10 | }) 11 | it("Boolean", () => { 12 | expect(isArray(true)).toBe(false) 13 | }) 14 | it("Null", () => { 15 | expect(isArray(null)).toBe(false) 16 | }) 17 | it("Undefined", () => { 18 | expect(isArray(undefined)).toBe(false) 19 | }) 20 | it("Symbol", () => { 21 | expect(isArray(Symbol())).toBe(false) 22 | }) 23 | it("BigInt", () => { 24 | expect(isArray(BigInt(1))).toBe(false) 25 | }) 26 | it("Object", () => { 27 | expect(isArray({})).toBe(false) 28 | }) 29 | it("Array Object", () => { 30 | expect(isArray([])).toBe(true) 31 | }) 32 | }) 33 | -------------------------------------------------------------------------------- /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 | "experimentalDecorators": true, 14 | "allowSyntheticDefaultImports": true, 15 | "sourceMap": true, 16 | "resolveJsonModule": true, 17 | /** https://cn.vitejs.dev/guide/features.html#typescript-compiler-options */ 18 | "isolatedModules": true, 19 | "esModuleInterop": true, 20 | "lib": ["esnext", "dom"], 21 | "skipLibCheck": true, 22 | "types": [ 23 | "node", 24 | "vite/client", 25 | /** Element Plus 的 Volar 插件支持 */ 26 | "element-plus/global", 27 | "vitest" 28 | ], 29 | /** baseUrl 用来告诉编译器到哪里去查找模块,使用非相对模块时必须配置此项 */ 30 | "baseUrl": ".", 31 | /** 非相对模块导入的路径映射配置,根据 baseUrl 配置进行路径计算 */ 32 | "paths": { 33 | "@/*": ["src/*"] 34 | } 35 | }, 36 | "include": [ 37 | "src/**/*.ts", 38 | "src/**/*.d.ts", 39 | "src/**/*.tsx", 40 | "src/**/*.vue", 41 | "tests/**/*.ts", 42 | "types/**/*.d.ts", 43 | "vite.config.ts", 44 | "vitest.config.ts" 45 | ], 46 | /** 编译器默认排除的编译文件 */ 47 | "exclude": ["node_modules", "dist"] 48 | } 49 | -------------------------------------------------------------------------------- /types/api.d.ts: -------------------------------------------------------------------------------- 1 | /** 所有 api 接口的响应数据都应该准守该格式 */ 2 | interface ApiResponseData { 3 | code: number 4 | data: T 5 | message: string 6 | } 7 | -------------------------------------------------------------------------------- /types/env.d.ts: -------------------------------------------------------------------------------- 1 | /** 声明 vite 环境变量的类型(如果未声明则默认是 any) */ 2 | interface ImportMetaEnv { 3 | readonly VITE_APP_TITLE: string 4 | readonly VITE_BASE_API: string 5 | readonly VITE_ROUTER_HISTORY: "hash" | "html5" 6 | readonly VITE_PUBLIC_PATH: string 7 | } 8 | 9 | interface ImportMeta { 10 | readonly env: ImportMetaEnv 11 | } 12 | -------------------------------------------------------------------------------- /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 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.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { type ConfigEnv, type UserConfigExport, loadEnv } from "vite" 4 | import path, { resolve } from "path" 5 | import vue from "@vitejs/plugin-vue" 6 | import vueJsx from "@vitejs/plugin-vue-jsx" 7 | import { createSvgIconsPlugin } from "vite-plugin-svg-icons" 8 | import svgLoader from "vite-svg-loader" 9 | import UnoCSS from "unocss/vite" 10 | import inspector from 'vite-plugin-dev-inspector' 11 | 12 | 13 | /** 配置项文档:https://cn.vitejs.dev/config */ 14 | export default ({ mode }: ConfigEnv): UserConfigExport => { 15 | const viteEnv = loadEnv(mode, process.cwd()) as ImportMetaEnv 16 | const { VITE_PUBLIC_PATH } = viteEnv 17 | return { 18 | /** 打包时根据实际情况修改 base */ 19 | base: VITE_PUBLIC_PATH, 20 | resolve: { 21 | alias: { 22 | /** @ 符号指向 src 目录 */ 23 | "@": resolve(__dirname, "./src") 24 | } 25 | }, 26 | server: { 27 | /** 设置 host: true 才可以使用 Network 的形式,以 IP 访问项目 */ 28 | host: true, // host: "0.0.0.0" 29 | /** 端口号 */ 30 | port: 3333, 31 | /** 是否自动打开浏览器 */ 32 | open: false, 33 | /** 跨域设置允许 */ 34 | cors: true, 35 | /** 端口被占用时,是否直接退出 */ 36 | strictPort: false, 37 | /** 接口代理 */ 38 | proxy: { 39 | "/api/v1": { 40 | target: "https://mock.mengxuegu.com/mock/63218b5fb4c53348ed2bc212", 41 | ws: true, 42 | /** 是否允许跨域 */ 43 | changeOrigin: true 44 | } 45 | }, 46 | /** 预热常用文件,提高初始页面加载速度 */ 47 | warmup: { 48 | clientFiles: ["./src/layouts/**/*.vue"] 49 | } 50 | }, 51 | build: { 52 | /** 单个 chunk 文件的大小超过 2048KB 时发出警告 */ 53 | chunkSizeWarningLimit: 2048, 54 | /** 禁用 gzip 压缩大小报告 */ 55 | reportCompressedSize: false, 56 | /** 打包后静态资源目录 */ 57 | assetsDir: "static", 58 | rollupOptions: { 59 | output: { 60 | /** 61 | * 分块策略 62 | * 1. 注意这些包名必须存在,否则打包会报错 63 | * 2. 如果你不想自定义 chunk 分割策略,可以直接移除这段配置 64 | */ 65 | manualChunks: { 66 | vue: ["vue", "vue-router", "pinia"], 67 | element: ["element-plus", "@element-plus/icons-vue"], 68 | vxe: ["vxe-table", "vxe-table-plugin-element", "xe-utils"] 69 | } 70 | } 71 | } 72 | }, 73 | /** 混淆器 */ 74 | esbuild: 75 | mode === "development" 76 | ? undefined 77 | : { 78 | /** 打包时移除 console.log */ 79 | pure: ["console.log"], 80 | /** 打包时移除 debugger */ 81 | drop: ["debugger"], 82 | /** 打包时移除所有注释 */ 83 | legalComments: "none" 84 | }, 85 | /** Vite 插件 */ 86 | plugins: [ 87 | vue(), 88 | vueJsx(), 89 | inspector({ 90 | toggleButtonVisibility: 'always', 91 | }), 92 | /** 将 SVG 静态图转化为 Vue 组件 */ 93 | svgLoader({ defaultImport: "url" }), 94 | /** SVG */ 95 | createSvgIconsPlugin({ 96 | iconDirs: [path.resolve(process.cwd(), "src/icons/svg")], 97 | symbolId: "icon-[dir]-[name]" 98 | }), 99 | /** UnoCSS */ 100 | UnoCSS() 101 | ], 102 | /** Vitest 单元测试配置:https://cn.vitest.dev/config */ 103 | test: { 104 | include: ["tests/**/*.test.ts"], 105 | environment: "jsdom" 106 | } 107 | } 108 | } 109 | --------------------------------------------------------------------------------