Initial commit
13
.editorconfig
Normal file
@ -0,0 +1,13 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
insert_final_newline = false
|
||||
trim_trailing_whitespace = false
|
13
.env.development
Normal file
@ -0,0 +1,13 @@
|
||||
# 请勿改动这一项,该项也不可以通过 import.meta.env.NODE_ENV 调用
|
||||
NODE_ENV = development
|
||||
|
||||
# 下面是自定义的环境变量,可以修改(命名必须以 VITE_ 开头)
|
||||
|
||||
## 后端接口公共路径(如果解决跨域问题采用反向代理就只需写公共路径)
|
||||
VITE_BASE_API = '/api/v1'
|
||||
|
||||
## 路由模式 hash 或 html5
|
||||
VITE_ROUTER_HISTORY = 'hash'
|
||||
|
||||
## 开发环境地址前缀(一般 '/','./' 都可以)
|
||||
VITE_PUBLIC_PATH = '/'
|
13
.env.production
Normal file
@ -0,0 +1,13 @@
|
||||
# 请勿改动这一项,该项也不可以通过 import.meta.env.NODE_ENV 调用
|
||||
NODE_ENV = production
|
||||
|
||||
# 下面是自定义的环境变量,可以修改(命名必须以 VITE_ 开头)
|
||||
|
||||
## 后端接口公共路径(如果解决跨域问题采用 CORS 就需要写全路径)
|
||||
VITE_BASE_API = 'https://mock.mengxuegu.com/mock/63218b5fb4c53348ed2bc212/api/v1'
|
||||
|
||||
## 路由模式 hash 或 html5
|
||||
VITE_ROUTER_HISTORY = 'hash'
|
||||
|
||||
## 打包路径(就是网站前缀,例如部署到 https://un-pany.github.io/v3-admin-vite/ 域名下,就需要填写 /v3-admin-vite/)
|
||||
VITE_PUBLIC_PATH = '/v3-admin-vite/'
|
13
.env.staging
Normal file
@ -0,0 +1,13 @@
|
||||
# 请勿改动这一项,该项也不可以通过 import.meta.env.NODE_ENV 调用
|
||||
NODE_ENV = production
|
||||
|
||||
# 下面是自定义的环境变量,可以修改(命名必须以 VITE_ 开头)
|
||||
|
||||
## 后端接口公共路径(如果解决跨域问题采用 CORS 就需要写全路径)
|
||||
VITE_BASE_API = 'https://mock.mengxuegu.com/mock/63218b5fb4c53348ed2bc212/api/v1'
|
||||
|
||||
## 路由模式 hash 或 html5
|
||||
VITE_ROUTER_HISTORY = 'hash'
|
||||
|
||||
## 打包路径(就是网站前缀,例如部署到 https://un-pany.github.io/v3-admin-vite/ 域名下,就需要填写 /v3-admin-vite/)
|
||||
VITE_PUBLIC_PATH = '/v3-admin-vite/'
|
7
.eslintignore
Normal file
@ -0,0 +1,7 @@
|
||||
# Eslint 会忽略的文件
|
||||
|
||||
.DS_Store
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
85
.eslintrc.js
Normal file
@ -0,0 +1,85 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
browser: true,
|
||||
node: true,
|
||||
es6: true
|
||||
},
|
||||
globals: {
|
||||
// script setup
|
||||
defineProps: "readonly",
|
||||
defineEmits: "readonly",
|
||||
defineExpose: "readonly",
|
||||
withDefaults: "readonly",
|
||||
// unplugin-vue-define-options
|
||||
defineOptions: "readonly"
|
||||
},
|
||||
extends: [
|
||||
"plugin:vue/vue3-essential",
|
||||
"eslint:recommended",
|
||||
"@vue/typescript/recommended",
|
||||
"@vue/prettier",
|
||||
"@vue/eslint-config-typescript"
|
||||
// unplugin-auto-import 自动生成的文件
|
||||
// "./types/.eslintrc-auto-import.json"
|
||||
],
|
||||
parser: "vue-eslint-parser",
|
||||
parserOptions: {
|
||||
parser: "@typescript-eslint/parser",
|
||||
ecmaVersion: 2020,
|
||||
sourceType: "module",
|
||||
jsxPragma: "React",
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
tsx: true
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
// TS
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"no-debugger": "off",
|
||||
"@typescript-eslint/explicit-module-boundary-types": "off",
|
||||
"@typescript-eslint/ban-types": "off",
|
||||
"@typescript-eslint/ban-ts-comment": "off",
|
||||
"@typescript-eslint/no-empty-function": "off",
|
||||
"@typescript-eslint/no-non-null-assertion": "off",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
argsIgnorePattern: "^_",
|
||||
varsIgnorePattern: "^_"
|
||||
}
|
||||
],
|
||||
"no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
argsIgnorePattern: "^_",
|
||||
varsIgnorePattern: "^_"
|
||||
}
|
||||
],
|
||||
// Vue
|
||||
"vue/no-v-html": "off",
|
||||
"vue/require-default-prop": "off",
|
||||
"vue/require-explicit-emits": "off",
|
||||
"vue/multi-word-component-names": "off",
|
||||
"vue/html-self-closing": [
|
||||
"error",
|
||||
{
|
||||
html: {
|
||||
void: "always",
|
||||
normal: "always",
|
||||
component: "always"
|
||||
},
|
||||
svg: "always",
|
||||
math: "always"
|
||||
}
|
||||
],
|
||||
// Prettier
|
||||
"prettier/prettier": [
|
||||
"error",
|
||||
{
|
||||
endOfLine: "auto"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
35
.github/workflows/deploy.yml
vendored
Normal file
@ -0,0 +1,35 @@
|
||||
name: Build And Deploy v3-admin-vite
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js 16.17.0
|
||||
uses: actions/setup-node@master
|
||||
with:
|
||||
node-version: "16.17.0"
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: "7.30.5"
|
||||
|
||||
- name: Build
|
||||
run: pnpm install && pnpm build:prod
|
||||
|
||||
- name: Deploy
|
||||
uses: JamesIves/github-pages-deploy-action@releases/v3
|
||||
with:
|
||||
ACCESS_TOKEN: ${{ secrets.V3_ADMIN_VITE }}
|
||||
BRANCH: gh-pages
|
||||
FOLDER: dist
|
35
.gitignore
vendored
Normal file
@ -0,0 +1,35 @@
|
||||
# Git 会忽略的文件
|
||||
|
||||
.DS_Store
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
.eslintcache
|
||||
|
||||
# Local env files
|
||||
*.local
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
!.vscode/settings.json
|
||||
!.vscode/*.code-snippets
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Use the PNPM
|
||||
package-lock.json
|
||||
yarn.lock
|
4
.husky/pre-commit
Normal file
@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
npx lint-staged
|
8
.prettierignore
Normal file
@ -0,0 +1,8 @@
|
||||
# Prettier 会忽略的文件
|
||||
|
||||
.DS_Store
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
*.d.ts
|
12
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"editorconfig.editorconfig",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"esbenp.prettier-vscode",
|
||||
"vue.vscode-typescript-vue-plugin",
|
||||
"vue.volar",
|
||||
"antfu.unocss",
|
||||
"zixuanchen.vitest-explorer",
|
||||
"wiensss.region-highlighter"
|
||||
]
|
||||
}
|
16
.vscode/hook.code-snippets
vendored
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"Vue3 Hook 代码结构一键生成": {
|
||||
"prefix": "Vue3 Hook",
|
||||
"body": [
|
||||
"import { ref } from \"vue\"\n",
|
||||
"const refName1 = ref<string>(\"这是一个响应式变量\")\n",
|
||||
"export function useHookName() {",
|
||||
"\tconst refName2 = ref<string>(\"这是一个响应式变量\")\n",
|
||||
"\tconst fnName = () => {}\n",
|
||||
"\treturn { refName1, refName2, fnName }",
|
||||
"}",
|
||||
"$1"
|
||||
],
|
||||
"description": "Vue3 Hook"
|
||||
}
|
||||
}
|
30
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,30 @@
|
||||
{
|
||||
"editor.tabSize": 2,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": true
|
||||
},
|
||||
"[vue]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[jsonc]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[html]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[css]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[scss]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
}
|
||||
}
|
14
.vscode/vue.code-snippets
vendored
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"Vue3 SFC 代码结构一键生成": {
|
||||
"prefix": "Vue3 SFC",
|
||||
"body": [
|
||||
"<script lang=\"ts\" setup></script>\n",
|
||||
"<template>",
|
||||
"\t<div class=\"app-container\">...</div>",
|
||||
"</template>\n",
|
||||
"<style scoped></style>",
|
||||
"$1"
|
||||
],
|
||||
"description": "Vue3 SFC"
|
||||
}
|
||||
}
|
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 pany <https://github.com/pany-ang>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
160
README.md
Normal file
@ -0,0 +1,160 @@
|
||||
<div align="center">
|
||||
<img alt="V3 Admin Vite Logo" width="120" height="120" src="./src/assets/layout/logo.png">
|
||||
<h1>V3 Admin Vite</h1>
|
||||
<span>English | <a href="./README.zh-CN.md">中文</a></span>
|
||||
</div>
|
||||
|
||||
## ⚡ Introduction
|
||||
|
||||
v3-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.
|
||||
|
||||
- Vue-Cli 5.x: [v3-admin](https://github.com/un-pany/v3-admin)
|
||||
- Electron desktop: [v3-electron-vite](https://github.com/un-pany/v3-electron-vite)
|
||||
|
||||
## Feature
|
||||
|
||||
- **Vue3**:The latest Vue3 composition API using Vue3 + script setup
|
||||
- **Element Plus**:Vue3 version of Element UI
|
||||
- **Pinia**: An alternative to Vuex in Vue3
|
||||
- **Vite**:Really fast
|
||||
- **Vue Router**:router
|
||||
- **TypeScript**:JavaScript With Syntax For Types
|
||||
- **PNPM**:Faster, disk space saving package management tool
|
||||
- **Scss**:Consistent with Element Plus
|
||||
- **CSS variable**:Mainly controls the layout and color of the item
|
||||
- **ESlint**:Code verification
|
||||
- **Prettier**: Code formatting
|
||||
- **Axios**: Promise based HTTP client (encapsulated)
|
||||
- **UnoCSS**: Real-time atomized CSS engine with high performance and flexibility
|
||||
- **Annotation**:Each configuration item is written with as detailed comments as possible
|
||||
- **Mobile Compatible**: The layout is compatible with mobile page resolution
|
||||
|
||||
## Functions
|
||||
|
||||
- **User management**: log in, log out of the demo
|
||||
- **Authority management**: Built-in page permissions (dynamic routing), instruction permissions, permission functions
|
||||
- **Multiple Environments**: Development, Staging, Production
|
||||
- **Multiple themes**: Normal, Dark, Dark Blue, theme modes
|
||||
- **Error page**: 403, 404
|
||||
- **Dashboard**: Display different Dashboard pages according to different users
|
||||
- **Other functions**:SVG, Dynamic Sidebar, Dynamic Breadcrumb Navigation, Tabbed Navigation, Screenfull, Adaptive Shrink Sidebar
|
||||
|
||||
## 📚 Document
|
||||
|
||||
[Chinese documentation](https://juejin.cn/post/7089377403717287972)
|
||||
|
||||
[Chinese getting started tutorial](https://juejin.cn/column/7207659644487139387)
|
||||
|
||||
## Gitee repository
|
||||
|
||||
[Gitee](https://gitee.com/un-pany/v3-admin-vite)
|
||||
|
||||
## Online preview
|
||||
|
||||
| Location | account | Link |
|
||||
| ------------ | ------------------- | ----------------------------------------------- |
|
||||
| github-pages | `admin` or `editor` | [Link](https://un-pany.github.io/v3-admin-vite) |
|
||||
|
||||
## 🚀 Development
|
||||
|
||||
```bash
|
||||
# configure
|
||||
1. installation of the recommended plugins in the .vscode directory
|
||||
3. node version 16+
|
||||
4. pnpm version 7.x
|
||||
|
||||
# clone
|
||||
git clone https://github.com/un-pany/v3-admin-vite.git
|
||||
|
||||
# enter the project directory
|
||||
cd v3-admin-vite
|
||||
|
||||
# install dependencies
|
||||
pnpm i
|
||||
|
||||
# start the service
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
## ✔️ Preview
|
||||
|
||||
```bash
|
||||
# stage environment
|
||||
pnpm preview:stage
|
||||
|
||||
# prod environment
|
||||
pnpm preview:prod
|
||||
```
|
||||
|
||||
## 📦️ Multi-environment packaging
|
||||
|
||||
```bash
|
||||
# build the stage environment
|
||||
pnpm build:stage
|
||||
|
||||
# build the prod environment
|
||||
pnpm build:prod
|
||||
```
|
||||
|
||||
## 🔧 Code inspection
|
||||
|
||||
```bash
|
||||
# code formatting
|
||||
pnpm lint
|
||||
|
||||
# unit test
|
||||
pnpm test
|
||||
```
|
||||
|
||||
## Git commit specification reference
|
||||
|
||||
- `feat` add new functions
|
||||
- `fix` Fix issues/bugs
|
||||
- `perf` Optimize performance
|
||||
- `style` Change the code style without affecting the running result
|
||||
- `refactor` Re-factor code
|
||||
- `revert` Undo changes
|
||||
- `test` Test related, does not involve changes to business code
|
||||
- `docs` Documentation and Annotation
|
||||
- `chore` Updating dependencies/modifying scaffolding configuration, etc.
|
||||
- `workflow` Work flow Improvements
|
||||
- `ci` CICD
|
||||
- `types` Type definition
|
||||
- `wip` In development
|
||||
|
||||
## Project preview
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
## 💕 Contributors
|
||||
|
||||
Thanks to all the contributors!
|
||||
|
||||
<a href="https://github.com/un-pany/v3-admin-vite/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=un-pany/v3-admin-vite" />
|
||||
</a>
|
||||
|
||||
## 💕 Thanks for the sponsorship (the cost of sponsorship was used to send red envelopes in the group~)
|
||||
|
||||
| Name | Avatar |
|
||||
| -------------------------------------------------- | --------------------------------------------------------------------------------------------- |
|
||||
| <a href="https://github.com/a3305278">a3305278</a> | <img src="https://avatars.githubusercontent.com/u/30458650?v=4" width="64px" height="64px" /> |
|
||||
|
||||
## 💕 Thanks star
|
||||
|
||||
Small projects are not easy to get a star, if you like this project, welcome to support a star! This is the only motivation for the author to maintain it on an ongoing basis (whisper: it's free after all)
|
||||
|
||||
## Group
|
||||
|
||||
QQ group:1014374415 (left) && add me on WeChat,Invite you to join WeChat group (right)
|
||||
|
||||

|
||||

|
||||
|
||||
## 📄 License
|
||||
|
||||
[MIT](./LICENSE)
|
||||
|
||||
Copyright (c) 2022 [pany](https://github.com/pany-ang)
|
160
README.zh-CN.md
Normal file
@ -0,0 +1,160 @@
|
||||
<div align="center">
|
||||
<img alt="V3 Admin Vite Logo" width="120" height="120" src="./src/assets/layout/logo.png">
|
||||
<h1>V3 Admin Vite</h1>
|
||||
<span><a href="./README.md">English</a> | 中文</span>
|
||||
</div>
|
||||
|
||||
## ⚡ 简介
|
||||
|
||||
一个免费开源的中后台管理系统基础解决方案,基于 Vue3、TypeScript、Element Plus、Pinia 和 Vite 等主流技术.
|
||||
|
||||
- Vue-Cli 5.x 版: [v3-admin](https://github.com/un-pany/v3-admin)
|
||||
- Electron 桌面版: [v3-electron-vite](https://github.com/un-pany/v3-electron-vite)
|
||||
|
||||
## 特性
|
||||
|
||||
- **Vue3**:采用 Vue3 + script setup 最新的 Vue3 组合式 API
|
||||
- **Element Plus**:Element UI 的 Vue3 版本
|
||||
- **Pinia**: 传说中的 Vuex5
|
||||
- **Vite**:真的很快
|
||||
- **Vue Router**:路由路由
|
||||
- **TypeScript**:JavaScript 语言的超集
|
||||
- **PNPM**:更快速的,节省磁盘空间的包管理工具
|
||||
- **Scss**:和 Element Plus 保持一致
|
||||
- **CSS 变量**:主要控制项目的布局和颜色
|
||||
- **ESlint**:代码校验
|
||||
- **Prettier**:代码格式化
|
||||
- **Axios**:发送网络请求(已封装好)
|
||||
- **UnoCSS**:具有高性能且极具灵活性的即时原子化 CSS 引擎
|
||||
- **注释**:各个配置项都写有尽可能详细的注释
|
||||
- **兼容移动端**: 布局兼容移动端页面分辨率
|
||||
|
||||
## 功能
|
||||
|
||||
- **用户管理**:登录、登出演示
|
||||
- **权限管理**:内置页面权限(动态路由)、指令权限、权限函数、路由守卫
|
||||
- **多环境**:开发环境(development)、预发布环境(staging)、正式环境(production)
|
||||
- **多主题**:内置普通、黑暗、深蓝三种主题模式
|
||||
- **错误页面**: 403、404
|
||||
- **Dashboard**:根据不同用户显示不同的 Dashboard 页面
|
||||
- **其他内置功能**:SVG、动态侧边栏、动态面包屑、标签页快捷导航、Screenfull 全屏、自适应收缩侧边栏
|
||||
|
||||
## 📚 文档
|
||||
|
||||
[中文文档](https://juejin.cn/post/7089377403717287972)
|
||||
|
||||
[手摸手教程](https://juejin.cn/column/7207659644487139387)
|
||||
|
||||
## 国内仓库
|
||||
|
||||
[Gitee](https://gitee.com/un-pany/v3-admin-vite)
|
||||
|
||||
## 在线预览
|
||||
|
||||
| 位置 | 账号 | 链接 |
|
||||
| ------------ | --------------- | ----------------------------------------------- |
|
||||
| github-pages | admin 或 editor | [链接](https://un-pany.github.io/v3-admin-vite) |
|
||||
|
||||
## 🚀 开发
|
||||
|
||||
```bash
|
||||
# 配置
|
||||
1. 一键安装 .vscode 目录中推荐的插件
|
||||
3. node 版本 16+
|
||||
4. pnpm 版本 7.x
|
||||
|
||||
# 克隆项目
|
||||
git clone https://github.com/un-pany/v3-admin-vite.git
|
||||
|
||||
# 进入项目目录
|
||||
cd v3-admin-vite
|
||||
|
||||
# 安装依赖
|
||||
pnpm i
|
||||
|
||||
# 启动服务
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
## ✔️ 预览
|
||||
|
||||
```bash
|
||||
# 预览预发布环境
|
||||
pnpm preview:stage
|
||||
|
||||
# 预览正式环境
|
||||
pnpm preview:prod
|
||||
```
|
||||
|
||||
## 📦️ 多环境打包
|
||||
|
||||
```bash
|
||||
# 构建预发布环境
|
||||
pnpm build:stage
|
||||
|
||||
# 构建正式环境
|
||||
pnpm build:prod
|
||||
```
|
||||
|
||||
## 🔧 代码检查
|
||||
|
||||
```bash
|
||||
# 代码格式化
|
||||
pnpm lint
|
||||
|
||||
# 单元测试
|
||||
pnpm test
|
||||
```
|
||||
|
||||
## Git 提交规范参考
|
||||
|
||||
- `feat` 增加新的业务功能
|
||||
- `fix` 修复业务问题/BUG
|
||||
- `perf` 优化性能
|
||||
- `style` 更改代码风格, 不影响运行结果
|
||||
- `refactor` 重构代码
|
||||
- `revert` 撤销更改
|
||||
- `test` 测试相关, 不涉及业务代码的更改
|
||||
- `docs` 文档和注释相关
|
||||
- `chore` 更新依赖/修改脚手架配置等琐事
|
||||
- `workflow` 工作流改进
|
||||
- `ci` 持续集成相关
|
||||
- `types` 类型定义文件更改
|
||||
- `wip` 开发中
|
||||
|
||||
## 项目预览图
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
## 💕 贡献者
|
||||
|
||||
感谢所有的贡献者!
|
||||
|
||||
<a href="https://github.com/un-pany/v3-admin-vite/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=un-pany/v3-admin-vite" />
|
||||
</a>
|
||||
|
||||
## 💕 感谢赞助(赞助的费用拿来在群里发红包了~)
|
||||
|
||||
| 账号 | 头像 |
|
||||
| -------------------------------------------------- | --------------------------------------------------------------------------------------------- |
|
||||
| <a href="https://github.com/a3305278">a3305278</a> | <img src="https://avatars.githubusercontent.com/u/30458650?v=4" width="64px" height="64px" /> |
|
||||
|
||||
## 💕 感谢 Star
|
||||
|
||||
小项目获取 star 不易,如果你喜欢这个项目的话,欢迎支持一个 star !这是作者持续维护的唯一动力(小声:毕竟是免费的)
|
||||
|
||||
## 可有可无的群
|
||||
|
||||
QQ 群:1014374415(左)&& 加我微信,拉你进微信群(右)
|
||||
|
||||

|
||||

|
||||
|
||||
## 📄 License
|
||||
|
||||
[MIT](./LICENSE)
|
||||
|
||||
Copyright (c) 2022 [pany](https://github.com/pany-ang)
|
16
index.html
Normal file
@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="stylesheet" href="/app-loading.css" />
|
||||
<title>V3 Admin Vite</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div id="app-loading"></div>
|
||||
</div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
106
package.json
Normal file
@ -0,0 +1,106 @@
|
||||
{
|
||||
"name": "v3-admin-vite",
|
||||
"version": "3.3.4",
|
||||
"description": "一个免费开源的中后台管理系统基础解决方案,基于 Vue3、TypeScript、Element Plus、Pinia 和 Vite 等主流技术.",
|
||||
"author": {
|
||||
"name": "pany",
|
||||
"email": "939630029@qq.com",
|
||||
"url": "https://github.com/pany-ang"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/un-pany/v3-admin-vite.git"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build:stage": "vue-tsc --noEmit && vite build --mode staging",
|
||||
"build:prod": "vue-tsc --noEmit && vite build",
|
||||
"preview:stage": "pnpm build:stage && vite preview",
|
||||
"preview:prod": "pnpm build:prod && vite preview",
|
||||
"lint:eslint": "eslint --cache --max-warnings 0 \"src/**/*.{vue,js,ts,tsx}\" --fix",
|
||||
"lint:prettier": "prettier --write \"src/**/*.{js,ts,json,tsx,css,less,scss,vue,html,md}\"",
|
||||
"lint": "pnpm lint:eslint && pnpm lint:prettier",
|
||||
"prepare": "husky install",
|
||||
"test": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.1.0",
|
||||
"axios": "^1.3.4",
|
||||
"dayjs": "^1.11.7",
|
||||
"element-plus": "^2.3.0",
|
||||
"js-cookie": "^3.0.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
"normalize.css": "^8.0.1",
|
||||
"nprogress": "^0.2.0",
|
||||
"path-browserify": "^1.0.1",
|
||||
"path-to-regexp": "^6.2.1",
|
||||
"pinia": "^2.0.33",
|
||||
"screenfull": "^6.0.2",
|
||||
"vue": "^3.2.47",
|
||||
"vue-router": "^4.1.6",
|
||||
"vxe-table": "^4.3.10",
|
||||
"vxe-table-plugin-element": "^3.0.6",
|
||||
"xe-utils": "^3.5.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/js-cookie": "^3.0.3",
|
||||
"@types/lodash-es": "^4.17.7",
|
||||
"@types/node": "^18.15.3",
|
||||
"@types/nprogress": "^0.2.0",
|
||||
"@types/path-browserify": "^1.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^5.55.0",
|
||||
"@typescript-eslint/parser": "^5.55.0",
|
||||
"@vitejs/plugin-vue": "^4.0.0",
|
||||
"@vitejs/plugin-vue-jsx": "^3.0.0",
|
||||
"@vue/eslint-config-prettier": "^7.1.0",
|
||||
"@vue/eslint-config-typescript": "^11.0.2",
|
||||
"@vue/test-utils": "^2.3.1",
|
||||
"eslint": "^8.36.0",
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"eslint-plugin-vue": "^9.9.0",
|
||||
"husky": "^8.0.3",
|
||||
"jsdom": "^21.1.1",
|
||||
"lint-staged": "^13.2.0",
|
||||
"prettier": "^2.8.4",
|
||||
"sass": "^1.59.3",
|
||||
"terser": "^5.16.6",
|
||||
"typescript": "^4.9.5",
|
||||
"unocss": "^0.50.4",
|
||||
"unplugin-vue-define-options": "^1.2.4",
|
||||
"vite": "^4.1.4",
|
||||
"vite-plugin-svg-icons": "^2.0.1",
|
||||
"vite-svg-loader": "^4.0.0",
|
||||
"vitest": "^0.29.3",
|
||||
"vue-eslint-parser": "^9.1.0",
|
||||
"vue-tsc": "^1.2.0"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,vue,ts,tsx}": [
|
||||
"eslint --fix",
|
||||
"prettier --write"
|
||||
],
|
||||
"*.{scss,less,css,html,md}": [
|
||||
"prettier --write"
|
||||
],
|
||||
"package.json": [
|
||||
"prettier --write"
|
||||
],
|
||||
"{!(package)*.json,.!(browserslist)*rc}": [
|
||||
"prettier --write--parser json"
|
||||
]
|
||||
},
|
||||
"keywords": [
|
||||
"vue",
|
||||
"vue3",
|
||||
"admin",
|
||||
"vue-admin",
|
||||
"vue3-admin",
|
||||
"vite",
|
||||
"vite-admin",
|
||||
"element-plus",
|
||||
"element-plus-admin",
|
||||
"ts",
|
||||
"typescript"
|
||||
],
|
||||
"license": "MIT"
|
||||
}
|
5285
pnpm-lock.yaml
generated
Normal file
21
prettier.config.js
Normal file
@ -0,0 +1,21 @@
|
||||
/** 配置项文档:https://prettier.io/docs/en/configuration.html */
|
||||
module.exports = {
|
||||
/** 每一行的宽度 */
|
||||
printWidth: 120,
|
||||
/** Tab 键的空格数 */
|
||||
tabWidth: 2,
|
||||
/** 在对象中的括号之间是否用空格来间隔 */
|
||||
bracketSpacing: true,
|
||||
/** 箭头函数的参数无论有几个,都要括号包裹 */
|
||||
arrowParens: "always",
|
||||
/** 换行符的使用 */
|
||||
endOfLine: "auto",
|
||||
/** 是否采用单引号 */
|
||||
singleQuote: false,
|
||||
/** 对象或者数组的最后一个元素后面不要加逗号 */
|
||||
trailingComma: "none",
|
||||
/** 是否加分号 */
|
||||
semi: false,
|
||||
/** 是否使用 Tab 格式化 */
|
||||
useTabs: false
|
||||
}
|
65
public/app-loading.css
Normal file
@ -0,0 +1,65 @@
|
||||
#app-loading,
|
||||
#app-loading:before,
|
||||
#app-loading:after {
|
||||
border-radius: 50%;
|
||||
width: 2.5em;
|
||||
height: 2.5em;
|
||||
-webkit-animation-fill-mode: both;
|
||||
animation-fill-mode: both;
|
||||
-webkit-animation: loadingAnimation 1.8s infinite ease-in-out;
|
||||
animation: loadingAnimation 1.8s infinite ease-in-out;
|
||||
}
|
||||
|
||||
#app-loading {
|
||||
color: #409eff;
|
||||
font-size: 10px;
|
||||
margin: 80px auto;
|
||||
position: relative;
|
||||
text-indent: -9999em;
|
||||
-webkit-transform: translateZ(0);
|
||||
-ms-transform: translateZ(0);
|
||||
transform: translateZ(0);
|
||||
-webkit-animation-delay: -0.16s;
|
||||
animation-delay: -0.16s;
|
||||
top: 0;
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
|
||||
#app-loading:before,
|
||||
#app-loading:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
#app-loading:before {
|
||||
left: -3.5em;
|
||||
-webkit-animation-delay: -0.32s;
|
||||
animation-delay: -0.32s;
|
||||
}
|
||||
|
||||
#app-loading:after {
|
||||
left: 3.5em;
|
||||
}
|
||||
|
||||
@-webkit-keyframes loadingAnimation {
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
box-shadow: 0 2.5em 0 -1.3em;
|
||||
}
|
||||
40% {
|
||||
box-shadow: 0 2.5em 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes loadingAnimation {
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
box-shadow: 0 2.5em 0 -1.3em;
|
||||
}
|
||||
40% {
|
||||
box-shadow: 0 2.5em 0 0;
|
||||
}
|
||||
}
|
BIN
public/favicon.ico
Normal file
After Width: | Height: | Size: 66 KiB |
30
src/App.vue
Normal file
@ -0,0 +1,30 @@
|
||||
<script lang="ts" setup>
|
||||
import { h } from "vue"
|
||||
import { useTheme } from "@/hooks/useTheme"
|
||||
import { ElNotification } from "element-plus"
|
||||
import zhCn from "element-plus/lib/locale/lang/zh-cn"
|
||||
|
||||
const { initTheme } = useTheme()
|
||||
|
||||
/** 初始化主题 */
|
||||
initTheme()
|
||||
/** 将 Element Plus 的语言设置为中文 */
|
||||
const locale = zhCn
|
||||
|
||||
ElNotification({
|
||||
title: "Hello",
|
||||
message: h(
|
||||
"a",
|
||||
{ style: "color: teal", target: "_blank", href: "https://github.com/un-pany/v3-admin-vite" },
|
||||
"小项目获取 star 不易,如果你喜欢这个项目的话,欢迎点击这里支持一个 star !这是作者持续维护的唯一动力(小声:毕竟是免费的)"
|
||||
),
|
||||
duration: 0,
|
||||
position: "bottom-right"
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElConfigProvider :locale="locale">
|
||||
<router-view />
|
||||
</ElConfigProvider>
|
||||
</template>
|
36
src/api/hook-demo/use-fetch-select.ts
Normal file
@ -0,0 +1,36 @@
|
||||
/** 模拟接口响应数据 */
|
||||
const SELECT_RESPONSE_DATA = {
|
||||
code: 0,
|
||||
data: [
|
||||
{
|
||||
label: "苹果",
|
||||
value: 1
|
||||
},
|
||||
{
|
||||
label: "香蕉",
|
||||
value: 2
|
||||
},
|
||||
{
|
||||
label: "橘子",
|
||||
value: 3,
|
||||
disabled: true
|
||||
}
|
||||
],
|
||||
message: "获取 Select 数据成功"
|
||||
}
|
||||
|
||||
/** 模拟接口 */
|
||||
export function getSelectDataApi() {
|
||||
return new Promise<typeof SELECT_RESPONSE_DATA>((resolve, reject) => {
|
||||
// 模拟接口响应时间 2s
|
||||
setTimeout(() => {
|
||||
// 模拟接口调用成功
|
||||
if (Math.random() < 0.8) {
|
||||
resolve(SELECT_RESPONSE_DATA)
|
||||
} else {
|
||||
// 模拟接口调用出错
|
||||
reject(new Error("接口发生错误"))
|
||||
}
|
||||
}, 2000)
|
||||
})
|
||||
}
|
24
src/api/hook-demo/use-fullscreen-loading.ts
Normal file
@ -0,0 +1,24 @@
|
||||
/** 模拟接口响应数据 */
|
||||
const SUCCESS_RESPONSE_DATA = {
|
||||
code: 0,
|
||||
data: {},
|
||||
message: "获取成功"
|
||||
}
|
||||
|
||||
/** 模拟请求接口成功 */
|
||||
export function getSuccessApi() {
|
||||
return new Promise<typeof SUCCESS_RESPONSE_DATA>((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve(SUCCESS_RESPONSE_DATA)
|
||||
}, 1000)
|
||||
})
|
||||
}
|
||||
|
||||
/** 模拟请求接口失败 */
|
||||
export function getErrorApi() {
|
||||
return new Promise((_resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
reject(new Error("发生错误"))
|
||||
}, 1000)
|
||||
})
|
||||
}
|
27
src/api/login/index.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { request } from "@/utils/service"
|
||||
import type * as Login from "./types/login"
|
||||
|
||||
/** 获取登录验证码 */
|
||||
export function getLoginCodeApi() {
|
||||
return request<Login.LoginCodeResponseData>({
|
||||
url: "login/code",
|
||||
method: "get"
|
||||
})
|
||||
}
|
||||
|
||||
/** 登录并返回 Token */
|
||||
export function loginApi(data: Login.ILoginRequestData) {
|
||||
return request<Login.LoginResponseData>({
|
||||
url: "users/login",
|
||||
method: "post",
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/** 获取用户详情 */
|
||||
export function getUserInfoApi() {
|
||||
return request<Login.UserInfoResponseData>({
|
||||
url: "users/info",
|
||||
method: "get"
|
||||
})
|
||||
}
|
14
src/api/login/types/login.ts
Normal file
@ -0,0 +1,14 @@
|
||||
export interface ILoginRequestData {
|
||||
/** admin 或 editor */
|
||||
username: "admin" | "editor"
|
||||
/** 密码 */
|
||||
password: string
|
||||
/** 验证码 */
|
||||
code: string
|
||||
}
|
||||
|
||||
export type LoginCodeResponseData = IApiResponseData<string>
|
||||
|
||||
export type LoginResponseData = IApiResponseData<{ token: string }>
|
||||
|
||||
export type UserInfoResponseData = IApiResponseData<{ username: string; roles: string[] }>
|
37
src/api/table/index.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { request } from "@/utils/service"
|
||||
import type * as Table from "./types/table"
|
||||
|
||||
/** 增 */
|
||||
export function createTableDataApi(data: Table.ICreateTableRequestData) {
|
||||
return request({
|
||||
url: "table",
|
||||
method: "post",
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/** 删 */
|
||||
export function deleteTableDataApi(id: string) {
|
||||
return request({
|
||||
url: `table/${id}`,
|
||||
method: "delete"
|
||||
})
|
||||
}
|
||||
|
||||
/** 改 */
|
||||
export function updateTableDataApi(data: Table.IUpdateTableRequestData) {
|
||||
return request({
|
||||
url: "table",
|
||||
method: "put",
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/** 查 */
|
||||
export function getTableDataApi(params: Table.IGetTableRequestData) {
|
||||
return request<Table.GetTableResponseData>({
|
||||
url: "table",
|
||||
method: "get",
|
||||
params
|
||||
})
|
||||
}
|
36
src/api/table/types/table.ts
Normal file
@ -0,0 +1,36 @@
|
||||
export interface ICreateTableRequestData {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface IUpdateTableRequestData {
|
||||
id: string
|
||||
username: string
|
||||
password?: string
|
||||
}
|
||||
|
||||
export interface IGetTableRequestData {
|
||||
/** 当前页码 */
|
||||
currentPage: number
|
||||
/** 查询条数 */
|
||||
size: number
|
||||
/** 查询参数:用户名 */
|
||||
username?: string
|
||||
/** 查询参数:手机号 */
|
||||
phone?: string
|
||||
}
|
||||
|
||||
export interface IGetTableData {
|
||||
createTime: string
|
||||
email: string
|
||||
id: string
|
||||
phone: string
|
||||
roles: string
|
||||
status: boolean
|
||||
username: string
|
||||
}
|
||||
|
||||
export type GetTableResponseData = IApiResponseData<{
|
||||
list: IGetTableData[]
|
||||
total: number
|
||||
}>
|
BIN
src/assets/docs/preview1.png
Normal file
After Width: | Height: | Size: 101 KiB |
BIN
src/assets/docs/preview2.png
Normal file
After Width: | Height: | Size: 103 KiB |
BIN
src/assets/docs/preview3.png
Normal file
After Width: | Height: | Size: 104 KiB |
BIN
src/assets/docs/qq.png
Normal file
After Width: | Height: | Size: 14 KiB |
BIN
src/assets/docs/wechat.png
Normal file
After Width: | Height: | Size: 28 KiB |
1
src/assets/error-page/403.svg
Normal file
After Width: | Height: | Size: 18 KiB |
1
src/assets/error-page/404.svg
Normal file
After Width: | Height: | Size: 60 KiB |
BIN
src/assets/layout/logo-text-1.png
Normal file
After Width: | Height: | Size: 373 KiB |
BIN
src/assets/layout/logo-text-2.png
Normal file
After Width: | Height: | Size: 407 KiB |
BIN
src/assets/layout/logo.png
Normal file
After Width: | Height: | Size: 26 KiB |
60
src/components/Notify/NotifyList.vue
Normal file
@ -0,0 +1,60 @@
|
||||
<script lang="ts" setup>
|
||||
import { type PropType } from "vue"
|
||||
import { type IListItem } from "./data"
|
||||
|
||||
const props = defineProps({
|
||||
list: {
|
||||
type: Object as PropType<IListItem[]>,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-empty v-if="props.list.length === 0" />
|
||||
<el-card v-else v-for="(item, index) in props.list" :key="index" shadow="never" class="card-container">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<span>
|
||||
<span class="card-title">{{ item.title }}</span>
|
||||
<el-tag v-if="item.extra" :type="item.status" effect="plain" size="small">{{ item.extra }}</el-tag>
|
||||
</span>
|
||||
<div class="card-time">{{ item.datetime }}</div>
|
||||
</div>
|
||||
<div v-if="item.avatar" class="card-avatar">
|
||||
<img :src="item.avatar" width="34" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="card-body">
|
||||
{{ item.description ?? "No Data" }}
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.card-container {
|
||||
margin-bottom: 10px;
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
.card-title {
|
||||
font-weight: bold;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.card-time {
|
||||
font-size: 12px;
|
||||
color: grey;
|
||||
}
|
||||
.card-avatar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
.card-body {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
66
src/components/Notify/data.ts
Normal file
@ -0,0 +1,66 @@
|
||||
export interface IListItem {
|
||||
avatar?: string
|
||||
title: string
|
||||
datetime?: string
|
||||
description?: string
|
||||
status?: "" | "success" | "info" | "warning" | "danger"
|
||||
extra?: string
|
||||
}
|
||||
|
||||
export const notifyData: IListItem[] = [
|
||||
{
|
||||
avatar: "https://gw.alipayobjects.com/zos/rmsportal/OKJXDXrmkNshAMvwtvhu.png",
|
||||
title: "V3 Admin Vite 上线啦",
|
||||
datetime: "半年前",
|
||||
description:
|
||||
"一个免费开源的中后台管理系统基础解决方案,基于 Vue3、TypeScript、Element Plus、Pinia 和 Vite 等主流技术"
|
||||
},
|
||||
{
|
||||
avatar: "https://gw.alipayobjects.com/zos/rmsportal/OKJXDXrmkNshAMvwtvhu.png",
|
||||
title: "V3 Admin 上线啦",
|
||||
datetime: "一年前",
|
||||
description: "一个中后台管理系统基础解决方案,基于 Vue3、TypeScript、Element Plus 和 Pinia"
|
||||
}
|
||||
]
|
||||
|
||||
export const messageData: IListItem[] = [
|
||||
{
|
||||
avatar: "https://gw.alipayobjects.com/zos/rmsportal/ThXAXghbEsBCCSDihZxY.png",
|
||||
title: "来自楚门的世界",
|
||||
description: "如果再也不能见到你,祝你早安、午安和晚安",
|
||||
datetime: "1998-06-05"
|
||||
},
|
||||
{
|
||||
avatar: "https://gw.alipayobjects.com/zos/rmsportal/ThXAXghbEsBCCSDihZxY.png",
|
||||
title: "来自大话西游",
|
||||
description: "如果非要在这份爱上加上一个期限,我希望是一万年",
|
||||
datetime: "1995-02-04"
|
||||
},
|
||||
{
|
||||
avatar: "https://gw.alipayobjects.com/zos/rmsportal/ThXAXghbEsBCCSDihZxY.png",
|
||||
title: "来自龙猫",
|
||||
description: "心存善意,定能途遇天使",
|
||||
datetime: "1988-04-16"
|
||||
}
|
||||
]
|
||||
|
||||
export const todoData: IListItem[] = [
|
||||
{
|
||||
title: "任务名称",
|
||||
description: "这家伙很懒,什么都没留下",
|
||||
extra: "未开始",
|
||||
status: "info"
|
||||
},
|
||||
{
|
||||
title: "任务名称",
|
||||
description: "这家伙很懒,什么都没留下",
|
||||
extra: "进行中",
|
||||
status: ""
|
||||
},
|
||||
{
|
||||
title: "任务名称",
|
||||
description: "这家伙很懒,什么都没留下",
|
||||
extra: "已超时",
|
||||
status: "danger"
|
||||
}
|
||||
]
|
99
src/components/Notify/index.vue
Normal file
@ -0,0 +1,99 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed } from "vue"
|
||||
import { ElMessage } from "element-plus"
|
||||
import { Bell } from "@element-plus/icons-vue"
|
||||
import NotifyList from "./NotifyList.vue"
|
||||
import { type IListItem, notifyData, messageData, todoData } from "./data"
|
||||
|
||||
type TabNameType = "通知" | "消息" | "待办"
|
||||
|
||||
interface IDataItem {
|
||||
name: TabNameType
|
||||
type: "primary" | "success" | "warning" | "danger" | "info"
|
||||
list: IListItem[]
|
||||
}
|
||||
|
||||
/** 角标当前值 */
|
||||
const badgeValue = computed(() => {
|
||||
let value = 0
|
||||
for (let i = 0; i < data.value.length; i++) {
|
||||
value += data.value[i].list.length
|
||||
}
|
||||
return value
|
||||
})
|
||||
/** 角标最大值 */
|
||||
const badgeMax = 99
|
||||
/** 面板宽度 */
|
||||
const popoverWidth = 350
|
||||
/** 当前 Tab */
|
||||
const activeName = ref<TabNameType>("通知")
|
||||
/** 所有数据 */
|
||||
const data = ref<IDataItem[]>([
|
||||
// 通知数据
|
||||
{
|
||||
name: "通知",
|
||||
type: "primary",
|
||||
list: notifyData
|
||||
},
|
||||
// 消息数据
|
||||
{
|
||||
name: "消息",
|
||||
type: "danger",
|
||||
list: messageData
|
||||
},
|
||||
// 待办数据
|
||||
{
|
||||
name: "待办",
|
||||
type: "warning",
|
||||
list: todoData
|
||||
}
|
||||
])
|
||||
|
||||
const handleHistory = () => {
|
||||
ElMessage.success(`跳转到${activeName.value}历史页面`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="notify">
|
||||
<el-popover placement="bottom" :width="popoverWidth" trigger="click">
|
||||
<template #reference>
|
||||
<el-badge :value="badgeValue" :max="badgeMax" :hidden="badgeValue === 0">
|
||||
<el-tooltip effect="dark" content="消息通知" placement="bottom">
|
||||
<el-icon :size="20">
|
||||
<Bell />
|
||||
</el-icon>
|
||||
</el-tooltip>
|
||||
</el-badge>
|
||||
</template>
|
||||
<template #default>
|
||||
<el-tabs v-model="activeName" class="demo-tabs" stretch>
|
||||
<el-tab-pane v-for="(item, index) in data" :name="item.name" :key="index">
|
||||
<template #label>
|
||||
{{ item.name }}
|
||||
<el-badge :value="item.list.length" :max="badgeMax" :type="item.type" />
|
||||
</template>
|
||||
<el-scrollbar height="400px">
|
||||
<NotifyList :list="item.list" />
|
||||
</el-scrollbar>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
<div class="notify-history">
|
||||
<el-button link @click="handleHistory">查看{{ activeName }}历史</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-popover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.notify {
|
||||
margin-right: 10px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
.notify-history {
|
||||
text-align: center;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--el-border-color);
|
||||
}
|
||||
</style>
|
65
src/components/Screenfull/index.vue
Normal file
@ -0,0 +1,65 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref, onUnmounted } from "vue"
|
||||
import { ElMessage } from "element-plus"
|
||||
import screenfull from "screenfull"
|
||||
|
||||
const props = defineProps({
|
||||
/** 全屏的元素,默认是 html */
|
||||
element: {
|
||||
type: String,
|
||||
default: "html"
|
||||
},
|
||||
/** 打开全屏提示语 */
|
||||
openTips: {
|
||||
type: String,
|
||||
default: "全屏"
|
||||
},
|
||||
/** 关闭全屏提示语 */
|
||||
exitTips: {
|
||||
type: String,
|
||||
default: "退出全屏"
|
||||
}
|
||||
})
|
||||
|
||||
const tips = ref<string>(props.openTips)
|
||||
const isFullscreen = ref<boolean>(false)
|
||||
|
||||
const click = () => {
|
||||
const dom = document.querySelector(props.element) || undefined
|
||||
if (!screenfull.isEnabled) {
|
||||
ElMessage.warning("您的浏览器无法工作")
|
||||
return
|
||||
}
|
||||
screenfull.toggle(dom)
|
||||
}
|
||||
|
||||
const change = () => {
|
||||
isFullscreen.value = screenfull.isFullscreen
|
||||
tips.value = screenfull.isFullscreen ? props.exitTips : props.openTips
|
||||
}
|
||||
|
||||
screenfull.on("change", change)
|
||||
|
||||
onUnmounted(() => {
|
||||
if (screenfull.isEnabled) {
|
||||
screenfull.off("change", change)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div @click="click">
|
||||
<el-tooltip effect="dark" :content="tips" placement="bottom">
|
||||
<svg-icon :name="isFullscreen ? 'fullscreen-exit' : 'fullscreen'" />
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.svg-icon {
|
||||
font-size: 20px;
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
</style>
|
31
src/components/SvgIcon/index.vue
Normal file
@ -0,0 +1,31 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue"
|
||||
|
||||
const props = defineProps({
|
||||
prefix: {
|
||||
type: String,
|
||||
default: "icon"
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const symbolId = computed(() => `#${props.prefix}-${props.name}`)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg class="svg-icon" aria-hidden="true">
|
||||
<use :href="symbolId" />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.svg-icon {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
fill: currentColor;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
34
src/components/ThemeSwitch/index.vue
Normal file
@ -0,0 +1,34 @@
|
||||
<script lang="ts" setup>
|
||||
import { type ThemeName, useTheme } from "@/hooks/useTheme"
|
||||
import { MagicStick } from "@element-plus/icons-vue"
|
||||
|
||||
const { themeList, activeThemeName, setTheme } = useTheme()
|
||||
|
||||
const handleSetTheme = (name: ThemeName) => {
|
||||
setTheme(name)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dropdown trigger="click" @command="handleSetTheme">
|
||||
<div>
|
||||
<el-tooltip effect="dark" content="主题模式" placement="bottom">
|
||||
<el-icon :size="20">
|
||||
<MagicStick />
|
||||
</el-icon>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item
|
||||
v-for="(theme, index) in themeList"
|
||||
:key="index"
|
||||
:disabled="activeThemeName === theme.name"
|
||||
:command="theme.name"
|
||||
>
|
||||
<span>{{ theme.title }}</span>
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</template>
|
21
src/config/async-route.ts
Normal file
@ -0,0 +1,21 @@
|
||||
/** 动态路由配置 */
|
||||
interface IAsyncRouteSettings {
|
||||
/**
|
||||
* 是否开启动态路由功能?
|
||||
* 1. 开启后需要后端配合,在查询用户详情接口返回当前用户可以用来判断并加载动态路由的字段(该项目用的是角色 roles 字段)
|
||||
* 2. 假如项目不需要根据不同的用户来显示不同的页面,则应该将 open: false
|
||||
*/
|
||||
open: boolean
|
||||
/** 当动态路由功能关闭时:
|
||||
* 1. 应该将所有路由都写到常驻路由里面(表明所有登陆的用户能访问的页面都是一样的)
|
||||
* 2. 系统自动给当前登录用户赋值一个没有任何作用的默认角色
|
||||
*/
|
||||
defaultRoles: Array<string>
|
||||
}
|
||||
|
||||
const asyncRouteSettings: IAsyncRouteSettings = {
|
||||
open: true,
|
||||
defaultRoles: ["DEFAULT_ROLE"]
|
||||
}
|
||||
|
||||
export default asyncRouteSettings
|
35
src/config/layout.ts
Normal file
@ -0,0 +1,35 @@
|
||||
/** 布局配置 */
|
||||
interface ILayoutSettings {
|
||||
/** 是否显示 Settings Panel */
|
||||
showSettings: boolean
|
||||
/** 是否显示标签栏 */
|
||||
showTagsView: boolean
|
||||
/** 是否显示侧边栏 Logo */
|
||||
showSidebarLogo: boolean
|
||||
/** 是否固定 Header */
|
||||
fixedHeader: boolean
|
||||
/** 是否显示消息通知 */
|
||||
showNotify: boolean
|
||||
/** 是否显示切换主题按钮 */
|
||||
showThemeSwitch: boolean
|
||||
/** 是否显示全屏按钮 */
|
||||
showScreenfull: boolean
|
||||
/** 是否显示灰色模式 */
|
||||
showGreyMode: boolean
|
||||
/** 是否显示色弱模式 */
|
||||
showColorWeakness: boolean
|
||||
}
|
||||
|
||||
const layoutSettings: ILayoutSettings = {
|
||||
showSettings: true,
|
||||
showTagsView: true,
|
||||
fixedHeader: true,
|
||||
showSidebarLogo: true,
|
||||
showNotify: true,
|
||||
showThemeSwitch: true,
|
||||
showScreenfull: true,
|
||||
showGreyMode: false,
|
||||
showColorWeakness: false
|
||||
}
|
||||
|
||||
export default layoutSettings
|
4
src/config/white-list.ts
Normal file
@ -0,0 +1,4 @@
|
||||
/** 免登录白名单 */
|
||||
const whiteList = ["/login"]
|
||||
|
||||
export { whiteList }
|
10
src/constants/cacheKey.ts
Normal file
@ -0,0 +1,10 @@
|
||||
const SYSTEM_NAME = "v3-admin-vite"
|
||||
|
||||
/** 缓存数据时用到的 Key */
|
||||
class CacheKey {
|
||||
static TOKEN = `${SYSTEM_NAME}-token-key`
|
||||
static SIDEBAR_STATUS = `${SYSTEM_NAME}-sidebar-status-key`
|
||||
static ACTIVE_THEME_NAME = `${SYSTEM_NAME}-active-theme-name-key`
|
||||
}
|
||||
|
||||
export default CacheKey
|
7
src/directives/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { type App } from "vue"
|
||||
import { permission } from "./permission"
|
||||
|
||||
/** 挂载自定义指令 */
|
||||
export function loadDirectives(app: App) {
|
||||
app.directive("permission", permission)
|
||||
}
|
21
src/directives/permission/index.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { type Directive } from "vue"
|
||||
import { useUserStoreHook } from "@/store/modules/user"
|
||||
|
||||
/** 权限指令 */
|
||||
export const permission: Directive = {
|
||||
mounted(el, binding) {
|
||||
const { value } = binding
|
||||
const roles = useUserStoreHook().roles
|
||||
if (value && value instanceof Array && value.length > 0) {
|
||||
const permissionRoles = value
|
||||
const hasPermission = roles.some((role) => {
|
||||
return permissionRoles.includes(role)
|
||||
})
|
||||
if (!hasPermission) {
|
||||
el.style.display = "none"
|
||||
}
|
||||
} else {
|
||||
throw new Error(`need roles! Like v-permission="['admin','editor']"`)
|
||||
}
|
||||
}
|
||||
}
|
53
src/hooks/useFetchSelect.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import { ref, onMounted } from "vue"
|
||||
|
||||
type OptionValueType = string | number
|
||||
|
||||
/** Select 需要的数据格式 */
|
||||
interface ISelectOption {
|
||||
value: OptionValueType
|
||||
label: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
/** 接口响应格式 */
|
||||
interface IApiData {
|
||||
code: number
|
||||
data: ISelectOption[]
|
||||
message: string
|
||||
}
|
||||
|
||||
/** 入参格式,暂时只需要传递 api 函数即可 */
|
||||
interface IFetchSelectProps {
|
||||
api: () => Promise<IApiData>
|
||||
}
|
||||
|
||||
export function useFetchSelect(props: IFetchSelectProps) {
|
||||
const { api } = props
|
||||
|
||||
const loading = ref<boolean>(false)
|
||||
const options = ref<ISelectOption[]>([])
|
||||
const value = ref<OptionValueType>("")
|
||||
|
||||
/** 调用接口获取数据 */
|
||||
const loadData = () => {
|
||||
loading.value = true
|
||||
options.value = []
|
||||
api()
|
||||
.then((res) => {
|
||||
options.value = res.data
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
|
||||
return {
|
||||
loading,
|
||||
options,
|
||||
value
|
||||
}
|
||||
}
|
63
src/hooks/useFullscreenLoading.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import { type LoadingOptions, ElLoading } from "element-plus"
|
||||
|
||||
const defaultOptions = {
|
||||
lock: true,
|
||||
text: "加载中..."
|
||||
}
|
||||
|
||||
interface ILoadingInstance {
|
||||
close: () => void
|
||||
}
|
||||
|
||||
interface IUseFullscreenLoading {
|
||||
<T extends (...args: any[]) => ReturnType<T>>(fn: T, options?: LoadingOptions): (
|
||||
...args: Parameters<T>
|
||||
) => Promise<ReturnType<T>> | ReturnType<T>
|
||||
}
|
||||
|
||||
/**
|
||||
* 传入一个函数 fn,在它执行周期内,加上「全屏」loading,
|
||||
* 如果:
|
||||
* 1. fn 如果是同步函数,执行结束后隐藏 loading
|
||||
* 2. fn 如果是 Promise,resolve 或 reject 后隐藏 loading
|
||||
* 3. 报错后隐藏 loading 并抛出错误
|
||||
* @param {*} fn 要执行的函数
|
||||
* @param options LoadingOptions
|
||||
* @returns Function 一个新的函数,去执行它吧
|
||||
*/
|
||||
export const useFullscreenLoading: IUseFullscreenLoading = (fn, options = {}) => {
|
||||
let loadingInstance: ILoadingInstance
|
||||
const showLoading = (options: LoadingOptions) => {
|
||||
loadingInstance = ElLoading.service(options)
|
||||
}
|
||||
const hideLoading = () => {
|
||||
loadingInstance && loadingInstance.close()
|
||||
}
|
||||
const _options = { ...defaultOptions, ...options }
|
||||
return (...args) => {
|
||||
try {
|
||||
showLoading(_options)
|
||||
const result = fn(...args)
|
||||
const isPromise = result instanceof Promise
|
||||
// 同步函数
|
||||
if (!isPromise) {
|
||||
hideLoading()
|
||||
return Promise.resolve(result)
|
||||
}
|
||||
// Promise
|
||||
return result
|
||||
.then((res) => {
|
||||
return res
|
||||
})
|
||||
.catch((err) => {
|
||||
throw err
|
||||
})
|
||||
.finally(() => {
|
||||
hideLoading()
|
||||
})
|
||||
} catch (err) {
|
||||
hideLoading()
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
43
src/hooks/usePagination.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { reactive } from "vue"
|
||||
|
||||
interface IDefaultPaginationData {
|
||||
total: number
|
||||
currentPage: number
|
||||
pageSizes: number[]
|
||||
pageSize: number
|
||||
layout: string
|
||||
}
|
||||
|
||||
interface IPaginationData {
|
||||
total?: number
|
||||
currentPage?: number
|
||||
pageSizes?: number[]
|
||||
pageSize?: number
|
||||
layout?: string
|
||||
}
|
||||
|
||||
/** 默认的分页参数 */
|
||||
const defaultPaginationData: IDefaultPaginationData = {
|
||||
total: 0,
|
||||
currentPage: 1,
|
||||
pageSizes: [10, 20, 50],
|
||||
pageSize: 10,
|
||||
layout: "total, sizes, prev, pager, next, jumper"
|
||||
}
|
||||
|
||||
export function usePagination(_paginationData: IPaginationData = {}) {
|
||||
/** 合并分页参数 */
|
||||
const paginationData = reactive(Object.assign({ ...defaultPaginationData }, _paginationData))
|
||||
|
||||
/** 改变当前页码 */
|
||||
const handleCurrentChange = (value: number) => {
|
||||
paginationData.currentPage = value
|
||||
}
|
||||
|
||||
/** 改变页面大小 */
|
||||
const handleSizeChange = (value: number) => {
|
||||
paginationData.pageSize = value
|
||||
}
|
||||
|
||||
return { paginationData, handleCurrentChange, handleSizeChange }
|
||||
}
|
54
src/hooks/useTheme.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import { ref, watchEffect } from "vue"
|
||||
import { getActiveThemeName, setActiveThemeName } from "@/utils/cache/localStorage"
|
||||
|
||||
const DEFAULT_THEME_NAME = "normal"
|
||||
type DefaultThemeNameType = typeof DEFAULT_THEME_NAME
|
||||
|
||||
/** 注册的主题名称, 其中 DefaultThemeNameType 是必填的 */
|
||||
export type ThemeName = DefaultThemeNameType | "dark" | "dark-blue"
|
||||
|
||||
interface IThemeList {
|
||||
title: string
|
||||
name: ThemeName
|
||||
}
|
||||
|
||||
/** 主题列表 */
|
||||
const themeList: IThemeList[] = [
|
||||
{
|
||||
title: "默认",
|
||||
name: DEFAULT_THEME_NAME
|
||||
},
|
||||
{
|
||||
title: "黑暗",
|
||||
name: "dark"
|
||||
},
|
||||
{
|
||||
title: "深蓝",
|
||||
name: "dark-blue"
|
||||
}
|
||||
]
|
||||
|
||||
/** 正在应用的主题名称 */
|
||||
const activeThemeName = ref<ThemeName>(getActiveThemeName() || DEFAULT_THEME_NAME)
|
||||
|
||||
const setTheme = (value: ThemeName) => {
|
||||
activeThemeName.value = value
|
||||
}
|
||||
|
||||
/** 在 html 根元素上挂载 class */
|
||||
const setHtmlClassName = (value: ThemeName) => {
|
||||
document.documentElement.className = value
|
||||
}
|
||||
|
||||
const initTheme = () => {
|
||||
watchEffect(() => {
|
||||
const value = activeThemeName.value
|
||||
setHtmlClassName(value)
|
||||
setActiveThemeName(value)
|
||||
})
|
||||
}
|
||||
|
||||
/** 主题 hook */
|
||||
export function useTheme() {
|
||||
return { themeList, activeThemeName, initTheme, setTheme }
|
||||
}
|
7
src/icons/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { type App } from "vue"
|
||||
import SvgIcon from "@/components/SvgIcon/index.vue" // Svg Component
|
||||
import "virtual:svg-icons-register"
|
||||
|
||||
export function loadSvg(app: App) {
|
||||
app.component("SvgIcon", SvgIcon)
|
||||
}
|
1
src/icons/svg/404.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg t="1651119499039" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9021" width="200" height="200"><path d="M512 720m-48 0a48 48 0 1 0 96 0 48 48 0 1 0-96 0Z" p-id="9022"></path><path d="M480 416v184c0 4.4 3.6 8 8 8h48c4.4 0 8-3.6 8-8V416c0-4.4-3.6-8-8-8h-48c-4.4 0-8 3.6-8 8z" p-id="9023"></path><path d="M955.7 856l-416-720c-6.2-10.7-16.9-16-27.7-16s-21.6 5.3-27.7 16l-416 720C56 877.4 71.4 904 96 904h832c24.6 0 40-26.6 27.7-48z m-783.5-27.9L512 239.9l339.8 588.2H172.2z" p-id="9024"></path></svg>
|
After Width: | Height: | Size: 548 B |
1
src/icons/svg/bug.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg t="1651119031318" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8881" width="200" height="200"><path d="M940 512H792V412c76.8 0 139-62.2 139-139 0-4.4-3.6-8-8-8h-60c-4.4 0-8 3.6-8 8 0 34.8-28.2 63-63 63H232c-34.8 0-63-28.2-63-63 0-4.4-3.6-8-8-8h-60c-4.4 0-8 3.6-8 8 0 76.8 62.2 139 139 139v100H84c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h148v96c0 6.5 0.2 13 0.7 19.3C164.1 728.6 116 796.7 116 876c0 4.4 3.6 8 8 8h56c4.4 0 8-3.6 8-8 0-44.2 23.9-82.9 59.6-103.7 6 17.2 13.6 33.6 22.7 49 24.3 41.5 59 76.2 100.5 100.5S460.5 960 512 960s99.8-13.9 141.3-38.2c41.5-24.3 76.2-59 100.5-100.5 9.1-15.5 16.7-31.9 22.7-49C812.1 793.1 836 831.8 836 876c0 4.4 3.6 8 8 8h56c4.4 0 8-3.6 8-8 0-79.3-48.1-147.4-116.7-176.7 0.4-6.4 0.7-12.8 0.7-19.3v-96h148c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8zM716 680c0 36.8-9.7 72-27.8 102.9-17.7 30.3-43 55.6-73.3 73.3-20.1 11.8-42 20-64.9 24.3V484c0-4.4-3.6-8-8-8h-60c-4.4 0-8 3.6-8 8v396.5c-22.9-4.3-44.8-12.5-64.9-24.3-30.3-17.7-55.6-43-73.3-73.3C317.7 752 308 716.8 308 680V412h408v268z" p-id="8882"></path><path d="M304 280h56c4.4 0 8-3.6 8-8 0-28.3 5.9-53.2 17.1-73.5 10.6-19.4 26-34.8 45.4-45.4C450.9 142 475.7 136 504 136h16c28.3 0 53.2 5.9 73.5 17.1 19.4 10.6 34.8 26 45.4 45.4C650 218.9 656 243.7 656 272c0 4.4 3.6 8 8 8h56c4.4 0 8-3.6 8-8 0-40-8.8-76.7-25.9-108.1-17.2-31.5-42.5-56.8-74-74C596.7 72.8 560 64 520 64h-16c-40 0-76.7 8.8-108.1 25.9-31.5 17.2-56.8 42.5-74 74C304.8 195.3 296 232 296 272c0 4.4 3.6 8 8 8z" p-id="8883"></path></svg>
|
After Width: | Height: | Size: 1.5 KiB |
1
src/icons/svg/component.svg
Normal file
@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1672728665955" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3482" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16"><path d="M64 64h384v384H64V64z m0 512h384v384H64V576z m512 0h384v384H576V576z m192-128c106.039 0 192-85.961 192-192S874.039 64 768 64s-192 85.961-192 192 85.961 192 192 192z" p-id="3483"></path></svg>
|
After Width: | Height: | Size: 522 B |
1
src/icons/svg/dashboard.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg t="1651118937898" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8601" width="200" height="200"><path d="M924.8 385.6c-22.6-53.4-54.9-101.3-96-142.4-41.1-41.1-89-73.4-142.4-96C631.1 123.8 572.5 112 512 112s-119.1 11.8-174.4 35.2c-53.4 22.6-101.3 54.9-142.4 96-41.1 41.1-73.4 89-96 142.4C75.8 440.9 64 499.5 64 560c0 132.7 58.3 257.7 159.9 343.1l1.7 1.4c5.8 4.8 13.1 7.5 20.6 7.5h531.7c7.5 0 14.8-2.7 20.6-7.5l1.7-1.4C901.7 817.7 960 692.7 960 560c0-60.5-11.9-119.1-35.2-174.4zM761.4 836H262.6C184.5 765.5 140 665.6 140 560c0-99.4 38.7-192.8 109-263 70.3-70.3 163.7-109 263-109 99.4 0 192.8 38.7 263 109 70.3 70.3 109 163.7 109 263 0 105.6-44.5 205.5-122.6 276z" p-id="8602"></path><path d="M623.5 421.5c-3.1-3.1-8.2-3.1-11.3 0L527.7 506c-18.7-5-39.4-0.2-54.1 14.5-21.9 21.9-21.9 57.3 0 79.2 21.9 21.9 57.3 21.9 79.2 0 14.7-14.7 19.5-35.4 14.5-54.1l84.5-84.5c3.1-3.1 3.1-8.2 0-11.3l-28.3-28.3zM490 320h44c4.4 0 8-3.6 8-8v-80c0-4.4-3.6-8-8-8h-44c-4.4 0-8 3.6-8 8v80c0 4.4 3.6 8 8 8zM750 538v44c0 4.4 3.6 8 8 8h80c4.4 0 8-3.6 8-8v-44c0-4.4-3.6-8-8-8h-80c-4.4 0-8 3.6-8 8zM762.7 340.8l-31.1-31.1c-3.1-3.1-8.2-3.1-11.3 0l-56.6 56.6c-3.1 3.1-3.1 8.2 0 11.3l31.1 31.1c3.1 3.1 8.2 3.1 11.3 0l56.6-56.6c3.1-3.1 3.1-8.2 0-11.3zM304.1 309.7c-3.1-3.1-8.2-3.1-11.3 0l-31.1 31.1c-3.1 3.1-3.1 8.2 0 11.3l56.6 56.6c3.1 3.1 8.2 3.1 11.3 0l31.1-31.1c3.1-3.1 3.1-8.2 0-11.3l-56.6-56.6zM262 530h-80c-4.4 0-8 3.6-8 8v44c0 4.4 3.6 8 8 8h80c4.4 0 8-3.6 8-8v-44c0-4.4-3.6-8-8-8z" p-id="8603"></path></svg>
|
After Width: | Height: | Size: 1.5 KiB |
1
src/icons/svg/fullscreen-exit.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg t="1661153147729" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3352" width="200" height="200"><path d="M704 864v-96c0-54.4 41.6-96 96-96h96c19.2 0 32-12.8 32-32s-12.8-32-32-32h-96c-89.6 0-160 70.4-160 160v96c0 19.2 12.8 32 32 32s32-12.8 32-32z m-64-704v96c0 89.6 70.4 160 160 160h96c19.2 0 32-12.8 32-32s-12.8-32-32-32h-96c-54.4 0-96-41.6-96-96v-96c0-19.2-12.8-32-32-32s-32 12.8-32 32z m-256 704v-96c0-89.6-70.4-160-160-160h-96c-19.2 0-32 12.8-32 32s12.8 32 32 32h96c54.4 0 96 41.6 96 96v96c0 19.2 12.8 32 32 32s32-12.8 32-32z m-64-704v96c0 54.4-41.6 96-96 96h-96c-19.2 0-32 12.8-32 32s12.8 32 32 32h96c89.6 0 160-70.4 160-160v-96c0-19.2-12.8-32-32-32s-32 12.8-32 32z" p-id="3353"></path></svg>
|
After Width: | Height: | Size: 747 B |
1
src/icons/svg/fullscreen.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg t="1661151768669" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3212" width="200" height="200"><path d="M192 384v-96c0-54.4 41.6-96 96-96h96c19.2 0 32-12.8 32-32s-12.8-32-32-32h-96c-89.6 0-160 70.4-160 160v96c0 19.2 12.8 32 32 32s32-12.8 32-32z m-64 256v96c0 89.6 70.4 160 160 160h96c19.2 0 32-12.8 32-32s-12.8-32-32-32h-96c-54.4 0-96-41.6-96-96v-96c0-19.2-12.8-32-32-32s-32 12.8-32 32z m768-256v-96c0-89.6-70.4-160-160-160h-96c-19.2 0-32 12.8-32 32s12.8 32 32 32h96c54.4 0 96 41.6 96 96v96c0 19.2 12.8 32 32 32s32-12.8 32-32z m-64 256v96c0 54.4-41.6 96-96 96h-96c-19.2 0-32 12.8-32 32s12.8 32 32 32h96c89.6 0 160-70.4 160-160v-96c0-19.2-12.8-32-32-32s-32 12.8-32 32z" p-id="3213"></path></svg>
|
After Width: | Height: | Size: 746 B |
1
src/icons/svg/link.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg t="1651118878747" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8461" width="200" height="200"><path d="M574 665.4c-3.1-3.1-8.2-3.1-11.3 0L446.5 781.6c-53.8 53.8-144.6 59.5-204 0-59.5-59.5-53.8-150.2 0-204l116.2-116.2c3.1-3.1 3.1-8.2 0-11.3l-39.8-39.8c-3.1-3.1-8.2-3.1-11.3 0L191.4 526.5c-84.6 84.6-84.6 221.5 0 306s221.5 84.6 306 0l116.2-116.2c3.1-3.1 3.1-8.2 0-11.3L574 665.4zM832.6 191.4c-84.6-84.6-221.5-84.6-306 0L410.3 307.6c-3.1 3.1-3.1 8.2 0 11.3l39.7 39.7c3.1 3.1 8.2 3.1 11.3 0l116.2-116.2c53.8-53.8 144.6-59.5 204 0 59.5 59.5 53.8 150.2 0 204L665.3 562.6c-3.1 3.1-3.1 8.2 0 11.3l39.8 39.8c3.1 3.1 8.2 3.1 11.3 0l116.2-116.2c84.5-84.6 84.5-221.5 0-306.1z" p-id="8462"></path><path d="M610.1 372.3c-3.1-3.1-8.2-3.1-11.3 0L372.3 598.7c-3.1 3.1-3.1 8.2 0 11.3l39.6 39.6c3.1 3.1 8.2 3.1 11.3 0l226.4-226.4c3.1-3.1 3.1-8.2 0-11.3l-39.5-39.6z" p-id="8463"></path></svg>
|
After Width: | Height: | Size: 925 B |
1
src/icons/svg/lock.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg t="1651119007904" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8741" width="200" height="200"><path d="M832 464h-68V240c0-70.7-57.3-128-128-128H388c-70.7 0-128 57.3-128 128v224h-68c-17.7 0-32 14.3-32 32v384c0 17.7 14.3 32 32 32h640c17.7 0 32-14.3 32-32V496c0-17.7-14.3-32-32-32zM332 240c0-30.9 25.1-56 56-56h248c30.9 0 56 25.1 56 56v224H332V240z m460 600H232V536h560v304z" p-id="8742"></path><path d="M484 701v53c0 4.4 3.6 8 8 8h40c4.4 0 8-3.6 8-8v-53c12.1-8.7 20-22.9 20-39 0-26.5-21.5-48-48-48s-48 21.5-48 48c0 16.1 7.9 30.3 20 39z" p-id="8743"></path></svg>
|
After Width: | Height: | Size: 613 B |
1
src/icons/svg/menu.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg t="1651750906395" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9162" width="200" height="200"><path d="M904 158H408c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h496c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8zM904 582H408c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h496c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8zM904 794H120c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8zM904 370H120c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8z" p-id="9163"></path></svg>
|
After Width: | Height: | Size: 539 B |
11
src/icons/svg/unocss.svg
Normal file
@ -0,0 +1,11 @@
|
||||
<svg width="220" height="220" viewBox="0 0 220 220" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M117.722 167.444C117.722 139.83 140.108 117.444 167.722 117.444V117.444C195.336 117.444 217.722 139.83 217.722 167.444V167.444C217.722 195.058 195.336 217.444 167.722 217.444V217.444C140.108 217.444 117.722 195.058 117.722 167.444V167.444Z"
|
||||
fill="#fff" fill-opacity="0.6" />
|
||||
<path
|
||||
d="M117.722 52.5561C117.722 24.9419 140.108 2.55614 167.722 2.55614V2.55614C195.336 2.55614 217.722 24.9419 217.722 52.5561V97.5561C217.722 100.318 215.483 102.556 212.722 102.556H122.722C119.961 102.556 117.722 100.318 117.722 97.5561V52.5561Z"
|
||||
fill="#fff" fill-opacity="0.3" />
|
||||
<path
|
||||
d="M102.278 167.444C102.278 195.058 79.8922 217.444 52.278 217.444V217.444C24.6637 217.444 2.27796 195.058 2.27796 167.444L2.27796 122.444C2.27796 119.682 4.51654 117.444 7.27796 117.444L97.278 117.444C100.039 117.444 102.278 119.682 102.278 122.444L102.278 167.444Z"
|
||||
fill="#fff" />
|
||||
</svg>
|
After Width: | Height: | Size: 996 B |
49
src/layout/components/AppMain.vue
Normal file
@ -0,0 +1,49 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue"
|
||||
import { useRoute } from "vue-router"
|
||||
import { useTagsViewStore } from "@/store/modules/tags-view"
|
||||
|
||||
const route = useRoute()
|
||||
const tagsViewStore = useTagsViewStore()
|
||||
|
||||
const key = computed(() => {
|
||||
return route.path
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="app-main">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="fade-transform" mode="out-in">
|
||||
<keep-alive :include="tagsViewStore.cachedViews">
|
||||
<component :is="Component" :key="key" />
|
||||
</keep-alive>
|
||||
</transition>
|
||||
</router-view>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.app-main {
|
||||
min-height: calc(100vh - var(--v3-navigationbar-height));
|
||||
width: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background-color: var(--v3-body-bg-color);
|
||||
}
|
||||
|
||||
.fixed-header + .app-main {
|
||||
padding-top: var(--v3-navigationbar-height);
|
||||
height: 100vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.hasTagsView {
|
||||
.app-main {
|
||||
min-height: calc(100vh - var(--v3-header-height));
|
||||
}
|
||||
.fixed-header + .app-main {
|
||||
padding-top: var(--v3-header-height);
|
||||
}
|
||||
}
|
||||
</style>
|
74
src/layout/components/Breadcrumb/index.vue
Normal file
@ -0,0 +1,74 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch } from "vue"
|
||||
import { type RouteLocationMatched, useRoute, useRouter } from "vue-router"
|
||||
import { compile } from "path-to-regexp"
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const breadcrumbs = ref<RouteLocationMatched[]>([])
|
||||
|
||||
const getBreadcrumb = () => {
|
||||
breadcrumbs.value = route.matched.filter((item) => {
|
||||
return item.meta && item.meta.title && item.meta.breadcrumb !== false
|
||||
})
|
||||
}
|
||||
|
||||
const pathCompile = (path: string) => {
|
||||
const { params } = route
|
||||
const toPath = compile(path)
|
||||
return toPath(params)
|
||||
}
|
||||
|
||||
const handleLink = (item: RouteLocationMatched) => {
|
||||
const { redirect, path } = item
|
||||
if (redirect) {
|
||||
router.push(redirect as string)
|
||||
return
|
||||
}
|
||||
router.push(pathCompile(path))
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.path,
|
||||
(path) => {
|
||||
if (path.startsWith("/redirect/")) {
|
||||
return
|
||||
}
|
||||
getBreadcrumb()
|
||||
}
|
||||
)
|
||||
|
||||
getBreadcrumb()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-breadcrumb class="app-breadcrumb">
|
||||
<el-breadcrumb-item v-for="(item, index) in breadcrumbs" :key="item.path">
|
||||
<span v-if="item.redirect === 'noRedirect' || index === breadcrumbs.length - 1" class="no-redirect">
|
||||
{{ item.meta.title }}
|
||||
</span>
|
||||
<a v-else @click.prevent="handleLink(item)">
|
||||
{{ item.meta.title }}
|
||||
</a>
|
||||
</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.el-breadcrumb__inner,
|
||||
.el-breadcrumb__inner a {
|
||||
font-weight: 400 !important;
|
||||
}
|
||||
|
||||
.app-breadcrumb.el-breadcrumb {
|
||||
display: inline-block;
|
||||
font-size: 14px;
|
||||
line-height: var(--v3-navigationbar-height);
|
||||
margin-left: 8px;
|
||||
.no-redirect {
|
||||
color: #97a8be;
|
||||
cursor: text;
|
||||
}
|
||||
}
|
||||
</style>
|
33
src/layout/components/Hamburger/index.vue
Normal file
@ -0,0 +1,33 @@
|
||||
<script lang="ts" setup>
|
||||
import { Expand, Fold } from "@element-plus/icons-vue"
|
||||
|
||||
const props = defineProps({
|
||||
isActive: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "toggle-click"): void
|
||||
}>()
|
||||
|
||||
const toggleClick = () => {
|
||||
emit("toggle-click")
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div @click="toggleClick">
|
||||
<el-icon :size="20" class="icon">
|
||||
<Fold v-if="props.isActive" />
|
||||
<Expand v-else />
|
||||
</el-icon>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.icon {
|
||||
vertical-align: middle;
|
||||
}
|
||||
</style>
|
118
src/layout/components/NavigationBar/index.vue
Normal file
@ -0,0 +1,118 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue"
|
||||
import { useRouter } from "vue-router"
|
||||
import { useAppStore } from "@/store/modules/app"
|
||||
import { useSettingsStore } from "@/store/modules/settings"
|
||||
import { useUserStore } from "@/store/modules/user"
|
||||
import { UserFilled } from "@element-plus/icons-vue"
|
||||
import Breadcrumb from "../Breadcrumb/index.vue"
|
||||
import Hamburger from "../Hamburger/index.vue"
|
||||
import ThemeSwitch from "@/components/ThemeSwitch/index.vue"
|
||||
import Screenfull from "@/components/Screenfull/index.vue"
|
||||
import Notify from "@/components/Notify/index.vue"
|
||||
|
||||
const router = useRouter()
|
||||
const appStore = useAppStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const sidebar = computed(() => {
|
||||
return appStore.sidebar
|
||||
})
|
||||
const showNotify = computed(() => {
|
||||
return settingsStore.showNotify
|
||||
})
|
||||
const showThemeSwitch = computed(() => {
|
||||
return settingsStore.showThemeSwitch
|
||||
})
|
||||
const showScreenfull = computed(() => {
|
||||
return settingsStore.showScreenfull
|
||||
})
|
||||
|
||||
const toggleSidebar = () => {
|
||||
appStore.toggleSidebar(false)
|
||||
}
|
||||
const logout = () => {
|
||||
userStore.logout()
|
||||
router.push("/login")
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="navigation-bar">
|
||||
<Hamburger :is-active="sidebar.opened" class="hamburger" @toggle-click="toggleSidebar" />
|
||||
<Breadcrumb class="breadcrumb" />
|
||||
<div class="right-menu">
|
||||
<Screenfull v-if="showScreenfull" class="right-menu-item" />
|
||||
<ThemeSwitch v-if="showThemeSwitch" class="right-menu-item" />
|
||||
<Notify v-if="showNotify" class="right-menu-item" />
|
||||
<el-dropdown class="right-menu-item">
|
||||
<div class="right-menu-avatar">
|
||||
<el-avatar :icon="UserFilled" :size="30" />
|
||||
<span>{{ userStore.username }}</span>
|
||||
</div>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<a target="_blank" href="https://juejin.cn/post/7089377403717287972">
|
||||
<el-dropdown-item>中文文档</el-dropdown-item>
|
||||
</a>
|
||||
<a target="_blank" href="https://github.com/un-pany/v3-admin-vite">
|
||||
<el-dropdown-item>GitHub</el-dropdown-item>
|
||||
</a>
|
||||
<a target="_blank" href="https://gitee.com/un-pany/v3-admin-vite">
|
||||
<el-dropdown-item>Gitee</el-dropdown-item>
|
||||
</a>
|
||||
<el-dropdown-item divided @click="logout">
|
||||
<span style="display: block">退出登录</span>
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.navigation-bar {
|
||||
height: var(--v3-navigationbar-height);
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
.hamburger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
float: left;
|
||||
padding: 0 15px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.breadcrumb {
|
||||
float: left;
|
||||
// 参考 Bootstrap 的响应式设计 WIDTH = 576
|
||||
@media screen and (max-width: 576px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.right-menu {
|
||||
float: right;
|
||||
margin-right: 10px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #606266;
|
||||
.right-menu-item {
|
||||
padding: 0 10px;
|
||||
cursor: pointer;
|
||||
.right-menu-avatar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
.el-avatar {
|
||||
margin-right: 10px;
|
||||
}
|
||||
span {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
44
src/layout/components/RightPanel/index.vue
Normal file
@ -0,0 +1,44 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from "vue"
|
||||
import { Setting } from "@element-plus/icons-vue"
|
||||
|
||||
const props = defineProps({
|
||||
buttonTop: {
|
||||
type: Number,
|
||||
default: 350
|
||||
}
|
||||
})
|
||||
|
||||
const buttonTopCss = props.buttonTop + "px"
|
||||
const show = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="handle-button" @click="show = true">
|
||||
<el-icon :size="24">
|
||||
<Setting />
|
||||
</el-icon>
|
||||
</div>
|
||||
<el-drawer v-model="show" size="300px" :with-header="false">
|
||||
<slot />
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.handle-button {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background-color: var(--v3-rightpanel-button-bg-color);
|
||||
position: fixed;
|
||||
top: v-bind(buttonTopCss);
|
||||
right: 0px;
|
||||
border-radius: 6px 0 0 6px;
|
||||
z-index: 10;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
66
src/layout/components/Settings/index.vue
Normal file
@ -0,0 +1,66 @@
|
||||
<script lang="ts" setup>
|
||||
import { useSettingsStore } from "@/store/modules/settings"
|
||||
|
||||
const settingsStore = useSettingsStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="drawer-container">
|
||||
<div>
|
||||
<h3 class="drawer-title">系统布局配置</h3>
|
||||
<div class="drawer-item">
|
||||
<span>显示标签栏</span>
|
||||
<el-switch v-model="settingsStore.showTagsView" class="drawer-switch" />
|
||||
</div>
|
||||
<div class="drawer-item">
|
||||
<span>显示侧边栏 Logo</span>
|
||||
<el-switch v-model="settingsStore.showSidebarLogo" class="drawer-switch" />
|
||||
</div>
|
||||
<div class="drawer-item">
|
||||
<span>固定 Header</span>
|
||||
<el-switch v-model="settingsStore.fixedHeader" class="drawer-switch" />
|
||||
</div>
|
||||
<div class="drawer-item">
|
||||
<span>显示消息通知</span>
|
||||
<el-switch v-model="settingsStore.showNotify" class="drawer-switch" />
|
||||
</div>
|
||||
<div class="drawer-item">
|
||||
<span>显示切换主题按钮</span>
|
||||
<el-switch v-model="settingsStore.showThemeSwitch" class="drawer-switch" />
|
||||
</div>
|
||||
<div class="drawer-item">
|
||||
<span>显示全屏按钮</span>
|
||||
<el-switch v-model="settingsStore.showScreenfull" class="drawer-switch" />
|
||||
</div>
|
||||
<div class="drawer-item">
|
||||
<span>显示灰色模式</span>
|
||||
<el-switch v-model="settingsStore.showGreyMode" class="drawer-switch" />
|
||||
</div>
|
||||
<div class="drawer-item">
|
||||
<span>显示色弱模式</span>
|
||||
<el-switch v-model="settingsStore.showColorWeakness" class="drawer-switch" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.drawer-container {
|
||||
padding: 24px;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
word-wrap: break-word;
|
||||
.drawer-title {
|
||||
margin-bottom: 12px;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
}
|
||||
.drawer-item {
|
||||
font-size: 14px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
.drawer-switch {
|
||||
float: right;
|
||||
}
|
||||
}
|
||||
</style>
|
126
src/layout/components/Sidebar/SidebarItem.vue
Normal file
@ -0,0 +1,126 @@
|
||||
<script lang="ts" setup>
|
||||
import { type PropType, computed } from "vue"
|
||||
import { type RouteRecordRaw } from "vue-router"
|
||||
import SidebarItemLink from "./SidebarItemLink.vue"
|
||||
import { isExternal } from "@/utils/validate"
|
||||
import path from "path-browserify"
|
||||
|
||||
const props = defineProps({
|
||||
item: {
|
||||
type: Object as PropType<RouteRecordRaw>,
|
||||
required: true
|
||||
},
|
||||
isCollapse: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
isFirstLevel: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
basePath: {
|
||||
type: String,
|
||||
default: ""
|
||||
}
|
||||
})
|
||||
|
||||
const alwaysShowRootMenu = computed(() => {
|
||||
return props.item.meta && props.item.meta.alwaysShow
|
||||
})
|
||||
|
||||
const showingChildNumber = computed(() => {
|
||||
if (props.item.children) {
|
||||
const showingChildren = props.item.children.filter((item) => {
|
||||
return !(item.meta && item.meta.hidden)
|
||||
})
|
||||
return showingChildren.length
|
||||
}
|
||||
return 0
|
||||
})
|
||||
|
||||
const theOnlyOneChild = computed(() => {
|
||||
if (showingChildNumber.value > 1) {
|
||||
return null
|
||||
}
|
||||
if (props.item.children) {
|
||||
for (const child of props.item.children) {
|
||||
if (!child.meta || !child.meta.hidden) {
|
||||
return child
|
||||
}
|
||||
}
|
||||
}
|
||||
// If there is no children, return itself with path removed,
|
||||
// because this.basePath already contains item's path information
|
||||
return { ...props.item, path: "" }
|
||||
})
|
||||
|
||||
const resolvePath = (routePath: string) => {
|
||||
if (isExternal(routePath)) {
|
||||
return routePath
|
||||
}
|
||||
if (isExternal(props.basePath)) {
|
||||
return props.basePath
|
||||
}
|
||||
return path.resolve(props.basePath, routePath)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!props.item.meta?.hidden" :class="{ 'simple-mode': props.isCollapse, 'first-level': props.isFirstLevel }">
|
||||
<template v-if="!alwaysShowRootMenu && theOnlyOneChild && !theOnlyOneChild.children">
|
||||
<SidebarItemLink v-if="theOnlyOneChild.meta" :to="resolvePath(theOnlyOneChild.path)">
|
||||
<el-menu-item :index="resolvePath(theOnlyOneChild.path)">
|
||||
<svg-icon v-if="theOnlyOneChild.meta.svgIcon" :name="theOnlyOneChild.meta.svgIcon" />
|
||||
<component v-else-if="theOnlyOneChild.meta.elIcon" :is="theOnlyOneChild.meta.elIcon" class="el-icon" />
|
||||
<template v-if="theOnlyOneChild.meta.title" #title>
|
||||
{{ theOnlyOneChild.meta.title }}
|
||||
</template>
|
||||
</el-menu-item>
|
||||
</SidebarItemLink>
|
||||
</template>
|
||||
<el-sub-menu v-else :index="resolvePath(props.item.path)" teleported>
|
||||
<template #title>
|
||||
<svg-icon v-if="props.item.meta && props.item.meta.svgIcon" :name="props.item.meta.svgIcon" />
|
||||
<component v-else-if="props.item.meta && props.item.meta.elIcon" :is="props.item.meta.elIcon" class="el-icon" />
|
||||
<span v-if="props.item.meta && props.item.meta.title">{{ props.item.meta.title }}</span>
|
||||
</template>
|
||||
<template v-if="props.item.children">
|
||||
<sidebar-item
|
||||
v-for="child in props.item.children"
|
||||
:key="child.path"
|
||||
:item="child"
|
||||
:is-collapse="props.isCollapse"
|
||||
:is-first-level="false"
|
||||
:base-path="resolvePath(child.path)"
|
||||
/>
|
||||
</template>
|
||||
</el-sub-menu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.svg-icon {
|
||||
min-width: 1em;
|
||||
margin-right: 12px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.el-icon {
|
||||
width: 1em;
|
||||
margin-right: 12px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.simple-mode {
|
||||
&.first-level {
|
||||
:deep(.el-sub-menu) {
|
||||
.el-sub-menu__icon-arrow {
|
||||
display: none;
|
||||
}
|
||||
span {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
19
src/layout/components/Sidebar/SidebarItemLink.vue
Normal file
@ -0,0 +1,19 @@
|
||||
<script lang="ts" setup>
|
||||
import { isExternal } from "@/utils/validate"
|
||||
|
||||
const props = defineProps({
|
||||
to: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a v-if="isExternal(props.to)" :href="props.to" target="_blank" rel="noopener">
|
||||
<slot />
|
||||
</a>
|
||||
<router-link v-else :to="props.to">
|
||||
<slot />
|
||||
</router-link>
|
||||
</template>
|
52
src/layout/components/Sidebar/SidebarLogo.vue
Normal file
@ -0,0 +1,52 @@
|
||||
<script lang="ts" setup>
|
||||
const props = defineProps({
|
||||
collapse: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="sidebar-logo-container" :class="{ collapse: props.collapse }">
|
||||
<transition name="sidebar-logo-fade">
|
||||
<router-link v-if="props.collapse" key="collapse" to="/">
|
||||
<img src="@/assets/layout/logo.png" class="sidebar-logo" />
|
||||
</router-link>
|
||||
<router-link v-else key="expand" to="/">
|
||||
<img src="@/assets/layout/logo-text-1.png" class="sidebar-logo-text" />
|
||||
</router-link>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sidebar-logo-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: var(--v3-header-height);
|
||||
line-height: var(--v3-header-height);
|
||||
background-color: var(--v3-sidebarlogo-bg-color);
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
.sidebar-logo {
|
||||
display: none;
|
||||
}
|
||||
.sidebar-logo-text {
|
||||
height: 100%;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
.collapse {
|
||||
.sidebar-logo {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
vertical-align: middle;
|
||||
display: inline-block;
|
||||
}
|
||||
.sidebar-logo-text {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
136
src/layout/components/Sidebar/index.vue
Normal file
@ -0,0 +1,136 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue"
|
||||
import { useRoute } from "vue-router"
|
||||
import { storeToRefs } from "pinia"
|
||||
import { useAppStore } from "@/store/modules/app"
|
||||
import { usePermissionStore } from "@/store/modules/permission"
|
||||
import { useSettingsStore } from "@/store/modules/settings"
|
||||
import SidebarItem from "./SidebarItem.vue"
|
||||
import SidebarLogo from "./SidebarLogo.vue"
|
||||
import { getCssVariableValue } from "@/utils"
|
||||
|
||||
const v3SidebarMenuBgColor = getCssVariableValue("--v3-sidebar-menu-bg-color")
|
||||
const v3SidebarMenuTextColor = getCssVariableValue("--v3-sidebar-menu-text-color")
|
||||
const v3SidebarMenuActiveTextColor = getCssVariableValue("--v3-sidebar-menu-active-text-color")
|
||||
|
||||
const route = useRoute()
|
||||
const appStore = useAppStore()
|
||||
const permissionStore = usePermissionStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
|
||||
const { showSidebarLogo } = storeToRefs(settingsStore)
|
||||
|
||||
const activeMenu = computed(() => {
|
||||
const { meta, path } = route
|
||||
if (meta?.activeMenu) {
|
||||
return meta.activeMenu
|
||||
}
|
||||
return path
|
||||
})
|
||||
|
||||
const isCollapse = computed(() => {
|
||||
return !appStore.sidebar.opened
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="{ 'has-logo': showSidebarLogo }">
|
||||
<SidebarLogo v-if="showSidebarLogo" :collapse="isCollapse" />
|
||||
<el-scrollbar wrap-class="scrollbar-wrapper">
|
||||
<el-menu
|
||||
:default-active="activeMenu"
|
||||
:collapse="isCollapse"
|
||||
:background-color="v3SidebarMenuBgColor"
|
||||
:text-color="v3SidebarMenuTextColor"
|
||||
:active-text-color="v3SidebarMenuActiveTextColor"
|
||||
:unique-opened="true"
|
||||
:collapse-transition="false"
|
||||
mode="vertical"
|
||||
>
|
||||
<SidebarItem
|
||||
v-for="route in permissionStore.routes"
|
||||
:key="route.path"
|
||||
:item="route"
|
||||
:base-path="route.path"
|
||||
:is-collapse="isCollapse"
|
||||
/>
|
||||
</el-menu>
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@mixin tip-line {
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 2px;
|
||||
height: 100%;
|
||||
background-color: var(--v3-sidebar-menu-tip-line-bg-color);
|
||||
}
|
||||
}
|
||||
|
||||
.has-logo {
|
||||
.el-scrollbar {
|
||||
height: calc(100% - var(--v3-header-height));
|
||||
}
|
||||
}
|
||||
|
||||
.el-scrollbar {
|
||||
height: 100%;
|
||||
:deep(.scrollbar-wrapper) {
|
||||
// 限制水平宽度
|
||||
overflow-x: hidden !important;
|
||||
.el-scrollbar__view {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
// 滚动条
|
||||
:deep(.el-scrollbar__bar) {
|
||||
&.is-horizontal {
|
||||
// 隐藏水平滚动条
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.el-menu {
|
||||
border: none;
|
||||
min-height: 100%;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
:deep(.el-menu-item),
|
||||
:deep(.el-sub-menu__title),
|
||||
:deep(.el-sub-menu .el-menu-item) {
|
||||
height: var(--v3-sidebar-menu-item-height);
|
||||
line-height: var(--v3-sidebar-menu-item-height);
|
||||
&.is-active,
|
||||
&:hover {
|
||||
background-color: var(--v3-sidebar-menu-hover-bg-color);
|
||||
}
|
||||
display: block;
|
||||
* {
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-menu-item) {
|
||||
&.is-active {
|
||||
@include tip-line;
|
||||
}
|
||||
}
|
||||
|
||||
.el-menu--collapse {
|
||||
:deep(.el-sub-menu) {
|
||||
&.is-active {
|
||||
.el-sub-menu__title {
|
||||
color: var(--v3-sidebar-menu-active-text-color) !important;
|
||||
@include tip-line;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
160
src/layout/components/TagsView/ScrollPane.vue
Normal file
@ -0,0 +1,160 @@
|
||||
<script lang="ts" setup>
|
||||
import { type PropType, computed, ref, watch, nextTick } from "vue"
|
||||
import { RouterLink, useRoute } from "vue-router"
|
||||
import { ElScrollbar } from "element-plus"
|
||||
import { ArrowLeft, ArrowRight } from "@element-plus/icons-vue"
|
||||
import { useSettingsStore } from "@/store/modules/settings"
|
||||
import Screenfull from "@/components/Screenfull/index.vue"
|
||||
|
||||
const props = defineProps({
|
||||
tagRefs: {
|
||||
type: Object as PropType<InstanceType<typeof RouterLink>[]>,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const settingsStore = useSettingsStore()
|
||||
|
||||
const scrollbarRef = ref<InstanceType<typeof ElScrollbar>>()
|
||||
const scrollbarContentRef = ref<HTMLDivElement>()
|
||||
|
||||
/** 当前滚动条距离左边的距离 */
|
||||
let currentScrollLeft = 0
|
||||
/** 每次滚动距离 */
|
||||
const translateDistance = 200
|
||||
|
||||
/** 滚动时触发 */
|
||||
const scroll = ({ scrollLeft }: { scrollLeft: number }) => {
|
||||
currentScrollLeft = scrollLeft
|
||||
}
|
||||
|
||||
/** 鼠标滚轮滚动时触发 */
|
||||
const wheelScroll = ({ deltaY }: WheelEvent) => {
|
||||
if (/^-/.test(deltaY.toString())) {
|
||||
scrollTo("left")
|
||||
} else {
|
||||
scrollTo("right")
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取可能需要的宽度 */
|
||||
const getWidth = () => {
|
||||
/** 可滚动内容的长度 */
|
||||
const scrollbarContentRefWidth = scrollbarContentRef.value!.clientWidth
|
||||
/** 滚动可视区宽度 */
|
||||
const scrollbarRefWidth = scrollbarRef.value!.wrapRef!.clientWidth
|
||||
/** 最后剩余可滚动的宽度 */
|
||||
const lastDistance = scrollbarContentRefWidth - scrollbarRefWidth - currentScrollLeft
|
||||
|
||||
return { scrollbarContentRefWidth, scrollbarRefWidth, lastDistance }
|
||||
}
|
||||
|
||||
/** 左右滚动 */
|
||||
const scrollTo = (direction: "left" | "right", distance: number = translateDistance) => {
|
||||
let scrollLeft = 0
|
||||
const { scrollbarContentRefWidth, scrollbarRefWidth, lastDistance } = getWidth()
|
||||
// 没有横向滚动条,直接结束
|
||||
if (scrollbarRefWidth > scrollbarContentRefWidth) return
|
||||
if (direction === "left") {
|
||||
scrollLeft = Math.max(0, currentScrollLeft - distance)
|
||||
} else {
|
||||
scrollLeft = Math.min(currentScrollLeft + distance, currentScrollLeft + lastDistance)
|
||||
}
|
||||
scrollbarRef.value!.setScrollLeft(scrollLeft)
|
||||
}
|
||||
|
||||
/** 移动到目标位置 */
|
||||
const moveTo = () => {
|
||||
const tagRefs = props.tagRefs
|
||||
for (let i = 0; i < tagRefs.length; i++) {
|
||||
// @ts-ignore
|
||||
if (route.path === tagRefs[i].$props.to.path) {
|
||||
// @ts-ignore
|
||||
const el: HTMLElement = tagRefs[i].$el
|
||||
const offsetWidth = el.offsetWidth
|
||||
const offsetLeft = el.offsetLeft
|
||||
const { scrollbarRefWidth } = getWidth()
|
||||
// 当前 tag 在可视区域左边时
|
||||
if (offsetLeft < currentScrollLeft) {
|
||||
const distance = currentScrollLeft - offsetLeft
|
||||
scrollTo("left", distance)
|
||||
return
|
||||
}
|
||||
// 当前 tag 在可视区域右边时
|
||||
const width = scrollbarRefWidth + currentScrollLeft - offsetWidth
|
||||
if (offsetLeft > width) {
|
||||
const distance = offsetLeft - width
|
||||
scrollTo("right", distance)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
route,
|
||||
() => {
|
||||
nextTick(moveTo)
|
||||
},
|
||||
{
|
||||
deep: true
|
||||
}
|
||||
)
|
||||
|
||||
const showScreenfull = computed(() => {
|
||||
return settingsStore.showScreenfull
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="scroll-container">
|
||||
<el-icon class="arrow left" @click="scrollTo('left')">
|
||||
<ArrowLeft />
|
||||
</el-icon>
|
||||
<el-scrollbar ref="scrollbarRef" @wheel.prevent="wheelScroll" @scroll="scroll">
|
||||
<div ref="scrollbarContentRef" class="scrollbar-content">
|
||||
<slot />
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
<el-icon class="arrow right" @click="scrollTo('right')">
|
||||
<ArrowRight />
|
||||
</el-icon>
|
||||
<Screenfull v-if="showScreenfull" element=".app-main" openTips="内容区全屏" class="screenfull" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.scroll-container {
|
||||
height: 100%;
|
||||
user-select: none;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
.arrow {
|
||||
width: 40px;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
&.left {
|
||||
box-shadow: 5px 0 5px -6px #ccc;
|
||||
}
|
||||
&.right {
|
||||
box-shadow: -5px 0 5px -6px #ccc;
|
||||
}
|
||||
}
|
||||
.el-scrollbar {
|
||||
flex: 1;
|
||||
// 横向超出窗口长度时,显示滚动条
|
||||
white-space: nowrap;
|
||||
.scrollbar-content {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
.screenfull {
|
||||
width: 40px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
</style>
|
268
src/layout/components/TagsView/index.vue
Normal file
@ -0,0 +1,268 @@
|
||||
<script lang="ts" setup>
|
||||
import { getCurrentInstance, onMounted, ref, watch } from "vue"
|
||||
import { type RouteRecordRaw, RouterLink, useRoute, useRouter } from "vue-router"
|
||||
import { type ITagView, useTagsViewStore } from "@/store/modules/tags-view"
|
||||
import { usePermissionStore } from "@/store/modules/permission"
|
||||
import ScrollPane from "./ScrollPane.vue"
|
||||
import path from "path-browserify"
|
||||
import { Close } from "@element-plus/icons-vue"
|
||||
|
||||
const instance = getCurrentInstance()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const tagsViewStore = useTagsViewStore()
|
||||
const permissionStore = usePermissionStore()
|
||||
|
||||
const tagRefs = ref<InstanceType<typeof RouterLink>[]>([])
|
||||
|
||||
const visible = ref(false)
|
||||
const top = ref(0)
|
||||
const left = ref(0)
|
||||
const selectedTag = ref<ITagView>({})
|
||||
let affixTags: ITagView[] = []
|
||||
|
||||
const isActive = (tag: ITagView) => {
|
||||
return tag.path === route.path
|
||||
}
|
||||
|
||||
const isAffix = (tag: ITagView) => {
|
||||
return tag.meta?.affix
|
||||
}
|
||||
|
||||
const filterAffixTags = (routes: RouteRecordRaw[], basePath = "/") => {
|
||||
let tags: ITagView[] = []
|
||||
routes.forEach((route) => {
|
||||
if (route.meta?.affix) {
|
||||
const tagPath = path.resolve(basePath, route.path)
|
||||
tags.push({
|
||||
fullPath: tagPath,
|
||||
path: tagPath,
|
||||
name: route.name,
|
||||
meta: { ...route.meta }
|
||||
})
|
||||
}
|
||||
if (route.children) {
|
||||
const childTags = filterAffixTags(route.children, route.path)
|
||||
if (childTags.length >= 1) {
|
||||
tags = tags.concat(childTags)
|
||||
}
|
||||
}
|
||||
})
|
||||
return tags
|
||||
}
|
||||
|
||||
const initTags = () => {
|
||||
affixTags = filterAffixTags(permissionStore.routes)
|
||||
for (const tag of affixTags) {
|
||||
// 必须含有 name 属性
|
||||
if (tag.name) {
|
||||
tagsViewStore.addVisitedView(tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const addTags = () => {
|
||||
if (route.name) {
|
||||
tagsViewStore.addVisitedView(route)
|
||||
tagsViewStore.addCachedView(route)
|
||||
}
|
||||
}
|
||||
|
||||
const refreshSelectedTag = (view: ITagView) => {
|
||||
tagsViewStore.delCachedView(view)
|
||||
router.replace({ path: "/redirect" + view.path, query: view.query })
|
||||
}
|
||||
|
||||
const closeSelectedTag = (view: ITagView) => {
|
||||
tagsViewStore.delVisitedView(view)
|
||||
tagsViewStore.delCachedView(view)
|
||||
if (isActive(view)) {
|
||||
toLastView(tagsViewStore.visitedViews, view)
|
||||
}
|
||||
}
|
||||
|
||||
const closeOthersTags = () => {
|
||||
if (selectedTag.value.fullPath !== route.path && selectedTag.value.fullPath !== undefined) {
|
||||
router.push(selectedTag.value.fullPath)
|
||||
}
|
||||
tagsViewStore.delOthersVisitedViews(selectedTag.value)
|
||||
tagsViewStore.delOthersCachedViews(selectedTag.value)
|
||||
}
|
||||
|
||||
const closeAllTags = (view: ITagView) => {
|
||||
tagsViewStore.delAllVisitedViews()
|
||||
tagsViewStore.delAllCachedViews()
|
||||
if (affixTags.some((tag) => tag.path === route.path)) {
|
||||
return
|
||||
}
|
||||
toLastView(tagsViewStore.visitedViews, view)
|
||||
}
|
||||
|
||||
const toLastView = (visitedViews: ITagView[], view: ITagView) => {
|
||||
const latestView = visitedViews.slice(-1)[0]
|
||||
if (latestView !== undefined && latestView.fullPath !== undefined) {
|
||||
router.push(latestView.fullPath)
|
||||
} else {
|
||||
// 如果 TagsView 全部被关闭了,则默认重定向到主页
|
||||
if (view.name === "Dashboard") {
|
||||
// 重新加载主页
|
||||
router.push({ path: "/redirect" + view.path, query: view.query })
|
||||
} else {
|
||||
router.push("/")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const openMenu = (tag: ITagView, e: MouseEvent) => {
|
||||
const menuMinWidth = 105
|
||||
// container margin left
|
||||
const offsetLeft = instance!.proxy!.$el.getBoundingClientRect().left
|
||||
// container width
|
||||
const offsetWidth = instance!.proxy!.$el.offsetWidth
|
||||
// left boundary
|
||||
const maxLeft = offsetWidth - menuMinWidth
|
||||
// 15: margin right
|
||||
const left15 = e.clientX - offsetLeft + 15
|
||||
if (left15 > maxLeft) {
|
||||
left.value = maxLeft
|
||||
} else {
|
||||
left.value = left15
|
||||
}
|
||||
top.value = e.clientY
|
||||
visible.value = true
|
||||
selectedTag.value = tag
|
||||
}
|
||||
|
||||
const closeMenu = () => {
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
watch(
|
||||
route,
|
||||
() => {
|
||||
addTags()
|
||||
},
|
||||
{
|
||||
deep: true
|
||||
}
|
||||
)
|
||||
|
||||
watch(visible, (value) => {
|
||||
if (value) {
|
||||
document.body.addEventListener("click", closeMenu)
|
||||
} else {
|
||||
document.body.removeEventListener("click", closeMenu)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
initTags()
|
||||
addTags()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tags-view-container">
|
||||
<ScrollPane class="tags-view-wrapper" :tagRefs="tagRefs">
|
||||
<router-link
|
||||
ref="tagRefs"
|
||||
v-for="tag in tagsViewStore.visitedViews"
|
||||
:key="tag.path"
|
||||
:class="isActive(tag) ? 'active' : ''"
|
||||
:to="{ path: tag.path, query: tag.query }"
|
||||
class="tags-view-item"
|
||||
@click.middle="!isAffix(tag) ? closeSelectedTag(tag) : ''"
|
||||
@contextmenu.prevent="openMenu(tag, $event)"
|
||||
>
|
||||
{{ tag.meta?.title }}
|
||||
<el-icon v-if="!isAffix(tag)" :size="12" @click.prevent.stop="closeSelectedTag(tag)">
|
||||
<Close />
|
||||
</el-icon>
|
||||
</router-link>
|
||||
</ScrollPane>
|
||||
<ul v-show="visible" :style="{ left: left + 'px', top: top + 'px' }" class="contextmenu">
|
||||
<li @click="refreshSelectedTag(selectedTag)">刷新</li>
|
||||
<li v-if="!isAffix(selectedTag)" @click="closeSelectedTag(selectedTag)">关闭</li>
|
||||
<li @click="closeOthersTags">关闭其它</li>
|
||||
<li @click="closeAllTags(selectedTag)">关闭所有</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.tags-view-container {
|
||||
height: var(--v3-tagsview-height);
|
||||
width: 100%;
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #d8dce5;
|
||||
box-shadow: 0 1px 3px 0 #00000010, 0 0 3px 0 #00000010;
|
||||
.tags-view-wrapper {
|
||||
.tags-view-item {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
height: 26px;
|
||||
line-height: 26px;
|
||||
border: 1px solid var(--v3-tagsview-tag-border-color);
|
||||
border-radius: var(--v3-tagsview-tag-border-radius);
|
||||
color: var(--v3-tagsview-tag-text-color);
|
||||
background-color: var(--v3-tagsview-tag-bg-color);
|
||||
padding: 0 8px;
|
||||
font-size: 12px;
|
||||
margin-left: 5px;
|
||||
margin-top: 4px;
|
||||
&:first-of-type {
|
||||
margin-left: 5px;
|
||||
}
|
||||
&:last-of-type {
|
||||
margin-right: 5px;
|
||||
}
|
||||
&.active {
|
||||
background-color: var(--v3-tagsview-tag-active-bg-color);
|
||||
color: var(--v3-tagsview-tag-active-text-color);
|
||||
border-color: var(--v3-tagsview-tag-active-border-color);
|
||||
&::before {
|
||||
content: "";
|
||||
background-color: var(--v3-tagsview-tag-active-before-color);
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
position: relative;
|
||||
margin-right: 2px;
|
||||
}
|
||||
}
|
||||
.el-icon {
|
||||
margin: 0 2px;
|
||||
vertical-align: middle;
|
||||
border-radius: 50%;
|
||||
&:hover {
|
||||
background-color: var(--v3-tagsview-tag-icon-hover-bg-color);
|
||||
color: var(--v3-tagsview-tag-icon-hover-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.contextmenu {
|
||||
margin: 0;
|
||||
background-color: #fff;
|
||||
z-index: 3000;
|
||||
position: absolute;
|
||||
list-style-type: none;
|
||||
padding: 5px 0;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: #333;
|
||||
box-shadow: 2px 2px 3px 0 #00000030;
|
||||
li {
|
||||
margin: 0;
|
||||
padding: 7px 16px;
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
background-color: #eee;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
6
src/layout/components/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export { default as AppMain } from "./AppMain.vue"
|
||||
export { default as NavigationBar } from "./NavigationBar/index.vue"
|
||||
export { default as Settings } from "./Settings/index.vue"
|
||||
export { default as Sidebar } from "./Sidebar/index.vue"
|
||||
export { default as TagsView } from "./TagsView/index.vue"
|
||||
export { default as RightPanel } from "./RightPanel/index.vue"
|
51
src/layout/hooks/useResize.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { watch, onBeforeMount, onMounted, onBeforeUnmount } from "vue"
|
||||
import { useRoute } from "vue-router"
|
||||
import { useAppStore, DeviceType } from "@/store/modules/app"
|
||||
|
||||
/** 参考 Bootstrap 的响应式设计 WIDTH = 992 */
|
||||
const WIDTH = 992
|
||||
|
||||
/** 根据大小变化重新布局 */
|
||||
export default () => {
|
||||
const route = useRoute()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const _isMobile = () => {
|
||||
const rect = document.body.getBoundingClientRect()
|
||||
return rect.width - 1 < WIDTH
|
||||
}
|
||||
|
||||
const _resizeHandler = () => {
|
||||
if (!document.hidden) {
|
||||
const isMobile = _isMobile()
|
||||
appStore.toggleDevice(isMobile ? DeviceType.Mobile : DeviceType.Desktop)
|
||||
if (isMobile) {
|
||||
appStore.closeSidebar(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.name,
|
||||
() => {
|
||||
if (appStore.device === DeviceType.Mobile && appStore.sidebar.opened) {
|
||||
appStore.closeSidebar(false)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
onBeforeMount(() => {
|
||||
window.addEventListener("resize", _resizeHandler)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (_isMobile()) {
|
||||
appStore.toggleDevice(DeviceType.Mobile)
|
||||
appStore.closeSidebar(true)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("resize", _resizeHandler)
|
||||
})
|
||||
}
|
162
src/layout/index.vue
Normal file
@ -0,0 +1,162 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue"
|
||||
import { useAppStore, DeviceType } from "@/store/modules/app"
|
||||
import { useSettingsStore } from "@/store/modules/settings"
|
||||
import { AppMain, NavigationBar, Settings, Sidebar, TagsView, RightPanel } from "./components"
|
||||
import useResize from "./hooks/useResize"
|
||||
|
||||
const appStore = useAppStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
|
||||
/** Layout 布局响应式 */
|
||||
useResize()
|
||||
|
||||
const classObj = computed(() => {
|
||||
return {
|
||||
hideSidebar: !appStore.sidebar.opened,
|
||||
openSidebar: appStore.sidebar.opened,
|
||||
withoutAnimation: appStore.sidebar.withoutAnimation,
|
||||
mobile: appStore.device === DeviceType.Mobile,
|
||||
showGreyMode: showGreyMode.value,
|
||||
showColorWeakness: showColorWeakness.value
|
||||
}
|
||||
})
|
||||
|
||||
const showSettings = computed(() => {
|
||||
return settingsStore.showSettings
|
||||
})
|
||||
const showTagsView = computed(() => {
|
||||
return settingsStore.showTagsView
|
||||
})
|
||||
const fixedHeader = computed(() => {
|
||||
return settingsStore.fixedHeader
|
||||
})
|
||||
const showGreyMode = computed(() => {
|
||||
return settingsStore.showGreyMode
|
||||
})
|
||||
const showColorWeakness = computed(() => {
|
||||
return settingsStore.showColorWeakness
|
||||
})
|
||||
const handleClickOutside = () => {
|
||||
appStore.closeSidebar(false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="classObj" class="app-wrapper">
|
||||
<div v-if="classObj.mobile && classObj.openSidebar" class="drawer-bg" @click="handleClickOutside" />
|
||||
<Sidebar class="sidebar-container" />
|
||||
<div :class="{ hasTagsView: showTagsView }" class="main-container">
|
||||
<div :class="{ 'fixed-header': fixedHeader }">
|
||||
<NavigationBar />
|
||||
<TagsView v-show="showTagsView" />
|
||||
</div>
|
||||
<AppMain />
|
||||
<RightPanel v-if="showSettings">
|
||||
<Settings />
|
||||
</RightPanel>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "@/styles/mixins.scss";
|
||||
|
||||
.app-wrapper {
|
||||
@include clearfix;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.showGreyMode {
|
||||
filter: grayscale(1);
|
||||
}
|
||||
|
||||
.showColorWeakness {
|
||||
filter: invert(0.8);
|
||||
}
|
||||
|
||||
.drawer-bg {
|
||||
background-color: #000;
|
||||
opacity: 0.3;
|
||||
width: 100%;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.main-container {
|
||||
min-height: 100%;
|
||||
transition: margin-left 0.28s;
|
||||
margin-left: var(--v3-sidebar-width);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sidebar-container {
|
||||
transition: width 0.28s;
|
||||
width: var(--v3-sidebar-width) !important;
|
||||
height: 100%;
|
||||
position: fixed;
|
||||
font-size: 0px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 1001;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fixed-header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 9;
|
||||
width: calc(100% - var(--v3-sidebar-width));
|
||||
transition: width 0.28s;
|
||||
}
|
||||
|
||||
.hideSidebar {
|
||||
.main-container {
|
||||
margin-left: var(--v3-sidebar-hide-width);
|
||||
}
|
||||
.sidebar-container {
|
||||
width: var(--v3-sidebar-hide-width) !important;
|
||||
}
|
||||
.fixed-header {
|
||||
width: calc(100% - var(--v3-sidebar-hide-width));
|
||||
}
|
||||
}
|
||||
|
||||
// for mobile response 适配移动端
|
||||
.mobile {
|
||||
.main-container {
|
||||
margin-left: 0px;
|
||||
}
|
||||
.sidebar-container {
|
||||
transition: transform 0.28s;
|
||||
width: var(--v3-sidebar-width) !important;
|
||||
}
|
||||
&.openSidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
}
|
||||
&.hideSidebar {
|
||||
.sidebar-container {
|
||||
pointer-events: none;
|
||||
transition-duration: 0.3s;
|
||||
transform: translate3d(calc(0px - var(--v3-sidebar-width)), 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.fixed-header {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.withoutAnimation {
|
||||
.main-container,
|
||||
.sidebar-container {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
29
src/main.ts
Normal file
@ -0,0 +1,29 @@
|
||||
// core
|
||||
import { createApp } from "vue"
|
||||
import App from "@/App.vue"
|
||||
import store from "@/store"
|
||||
import router from "@/router"
|
||||
import "@/router/permission"
|
||||
// load
|
||||
import { loadSvg } from "@/icons"
|
||||
import { loadPlugins } from "@/plugins"
|
||||
import { loadDirectives } from "@/directives"
|
||||
// css
|
||||
import "uno.css"
|
||||
import "normalize.css"
|
||||
import "element-plus/dist/index.css"
|
||||
import "element-plus/theme-chalk/dark/css-vars.css"
|
||||
import "vxe-table/lib/style.css"
|
||||
import "vxe-table-plugin-element/dist/style.css"
|
||||
import "@/styles/index.scss"
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
/** 加载插件 */
|
||||
loadPlugins(app)
|
||||
/** 加载全局 SVG */
|
||||
loadSvg(app)
|
||||
/** 加载自定义指令 */
|
||||
loadDirectives(app)
|
||||
|
||||
app.use(store).use(router).mount("#app")
|
9
src/plugins/element-plus-icon/index.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { type App } from "vue"
|
||||
import * as ElementPlusIconsVue from "@element-plus/icons-vue"
|
||||
|
||||
export function loadElementPlusIcon(app: App) {
|
||||
/** 注册所有 Element Plus Icon */
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component)
|
||||
}
|
||||
}
|
7
src/plugins/element-plus/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { type App } from "vue"
|
||||
import ElementPlus from "element-plus"
|
||||
|
||||
export function loadElementPlus(app: App) {
|
||||
/** Element Plus 组件完整引入 */
|
||||
app.use(ElementPlus)
|
||||
}
|
10
src/plugins/index.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { type App } from "vue"
|
||||
import { loadElementPlus } from "./element-plus"
|
||||
import { loadElementPlusIcon } from "./element-plus-icon"
|
||||
import { loadVxeTable } from "./vxe-table"
|
||||
|
||||
export function loadPlugins(app: App) {
|
||||
loadElementPlus(app)
|
||||
loadElementPlusIcon(app)
|
||||
loadVxeTable(app)
|
||||
}
|
66
src/plugins/vxe-table/index.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import { type App } from "vue"
|
||||
// https://vxetable.cn/#/table/start/install
|
||||
import VXETable from "vxe-table"
|
||||
// https://github.com/x-extends/vxe-table-plugin-element
|
||||
import VXETablePluginElement from "vxe-table-plugin-element"
|
||||
|
||||
VXETable.use(VXETablePluginElement)
|
||||
|
||||
/** 全局默认参数 */
|
||||
VXETable.setup({
|
||||
/** 全局尺寸 */
|
||||
size: "medium",
|
||||
/** 全局 zIndex 起始值,如果项目的的 z-index 样式值过大时就需要跟随设置更大,避免被遮挡 */
|
||||
zIndex: 9999,
|
||||
/** 版本号,对于某些带数据缓存的功能有用到,上升版本号可以用于重置数据 */
|
||||
version: 0,
|
||||
/** 全局 loading 提示内容,如果为 null 则不显示文本 */
|
||||
loadingText: null,
|
||||
table: {
|
||||
showHeader: true,
|
||||
showOverflow: "tooltip",
|
||||
showHeaderOverflow: "tooltip",
|
||||
autoResize: true,
|
||||
// stripe: false,
|
||||
border: "inner",
|
||||
// round: false,
|
||||
emptyText: "暂无数据",
|
||||
rowConfig: {
|
||||
isHover: true,
|
||||
isCurrent: true
|
||||
},
|
||||
columnConfig: {
|
||||
resizable: false
|
||||
},
|
||||
align: "center",
|
||||
headerAlign: "center",
|
||||
/** 行数据的唯一主键字段名 */
|
||||
rowId: "_VXE_ID"
|
||||
},
|
||||
pager: {
|
||||
// size: "medium",
|
||||
/** 配套的样式 */
|
||||
perfect: false,
|
||||
pageSize: 10,
|
||||
pagerCount: 7,
|
||||
pageSizes: [10, 20, 50],
|
||||
layouts: ["Total", "PrevJump", "PrevPage", "Number", "NextPage", "NextJump", "Sizes", "FullJump"]
|
||||
},
|
||||
modal: {
|
||||
minWidth: 500,
|
||||
minHeight: 400,
|
||||
lockView: true,
|
||||
mask: true,
|
||||
// duration: 3000,
|
||||
// marginSize: 20,
|
||||
dblclickZoom: false,
|
||||
showTitleOverflow: true,
|
||||
transfer: true,
|
||||
draggable: false
|
||||
}
|
||||
})
|
||||
|
||||
export function loadVxeTable(app: App) {
|
||||
/** Vxe Table 组件完整引入 */
|
||||
app.use(VXETable)
|
||||
}
|
296
src/router/index.ts
Normal file
@ -0,0 +1,296 @@
|
||||
import { type RouteRecordRaw, createRouter, createWebHashHistory, createWebHistory } from "vue-router"
|
||||
|
||||
const Layout = () => import("@/layout/index.vue")
|
||||
|
||||
/** 常驻路由 */
|
||||
export const constantRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: "/redirect",
|
||||
component: Layout,
|
||||
meta: {
|
||||
hidden: true
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: "/redirect/:path(.*)",
|
||||
component: () => import("@/views/redirect/index.vue")
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: "/403",
|
||||
component: () => import("@/views/error-page/403.vue"),
|
||||
meta: {
|
||||
hidden: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: "/404",
|
||||
component: () => import("@/views/error-page/404.vue"),
|
||||
meta: {
|
||||
hidden: true
|
||||
},
|
||||
alias: "/:pathMatch(.*)*"
|
||||
},
|
||||
{
|
||||
path: "/login",
|
||||
component: () => import("@/views/login/index.vue"),
|
||||
meta: {
|
||||
hidden: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: "/",
|
||||
component: Layout,
|
||||
redirect: "/dashboard",
|
||||
children: [
|
||||
{
|
||||
path: "dashboard",
|
||||
component: () => import("@/views/dashboard/index.vue"),
|
||||
name: "Dashboard",
|
||||
meta: {
|
||||
title: "首页",
|
||||
svgIcon: "dashboard",
|
||||
affix: true
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: "/unocss",
|
||||
component: Layout,
|
||||
redirect: "/unocss/index",
|
||||
children: [
|
||||
{
|
||||
path: "index",
|
||||
component: () => import("@/views/unocss/index.vue"),
|
||||
name: "UnoCSS",
|
||||
meta: {
|
||||
title: "unocss",
|
||||
svgIcon: "unocss"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: "/link",
|
||||
component: Layout,
|
||||
children: [
|
||||
{
|
||||
path: "https://juejin.cn/post/7089377403717287972",
|
||||
component: () => {},
|
||||
name: "Link",
|
||||
meta: {
|
||||
title: "外链",
|
||||
svgIcon: "link"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: "/table",
|
||||
component: Layout,
|
||||
redirect: "/table/element-plus",
|
||||
name: "Table",
|
||||
meta: {
|
||||
title: "表格",
|
||||
elIcon: "Grid"
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: "element-plus",
|
||||
component: () => import("@/views/table/element-plus/index.vue"),
|
||||
name: "ElementPlus",
|
||||
meta: {
|
||||
title: "Element Plus",
|
||||
keepAlive: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: "vxe-table",
|
||||
component: () => import("@/views/table/vxe-table/index.vue"),
|
||||
name: "VxeTable",
|
||||
meta: {
|
||||
title: "Vxe Table",
|
||||
keepAlive: true
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: "/menu",
|
||||
component: Layout,
|
||||
redirect: "/menu/menu1",
|
||||
name: "Menu",
|
||||
meta: {
|
||||
title: "多级菜单",
|
||||
svgIcon: "menu"
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: "menu1",
|
||||
component: () => import("@/views/menu/menu1/index.vue"),
|
||||
redirect: "/menu/menu1/menu1-1",
|
||||
name: "Menu1",
|
||||
meta: {
|
||||
title: "menu1"
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: "menu1-1",
|
||||
component: () => import("@/views/menu/menu1/menu1-1/index.vue"),
|
||||
name: "Menu1-1",
|
||||
meta: {
|
||||
title: "menu1-1"
|
||||
}
|
||||
},
|
||||
{
|
||||
path: "menu1-2",
|
||||
component: () => import("@/views/menu/menu1/menu1-2/index.vue"),
|
||||
redirect: "/menu/menu1/menu1-2/menu1-2-1",
|
||||
name: "Menu1-2",
|
||||
meta: {
|
||||
title: "menu1-2"
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: "menu1-2-1",
|
||||
component: () => import("@/views/menu/menu1/menu1-2/menu1-2-1/index.vue"),
|
||||
name: "Menu1-2-1",
|
||||
meta: {
|
||||
title: "menu1-2-1"
|
||||
}
|
||||
},
|
||||
{
|
||||
path: "menu1-2-2",
|
||||
component: () => import("@/views/menu/menu1/menu1-2/menu1-2-2/index.vue"),
|
||||
name: "Menu1-2-2",
|
||||
meta: {
|
||||
title: "menu1-2-2"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: "menu1-3",
|
||||
component: () => import("@/views/menu/menu1/menu1-3/index.vue"),
|
||||
name: "Menu1-3",
|
||||
meta: {
|
||||
title: "menu1-3"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: "menu2",
|
||||
component: () => import("@/views/menu/menu2/index.vue"),
|
||||
name: "Menu2",
|
||||
meta: {
|
||||
title: "menu2"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: "/hook-demo",
|
||||
component: Layout,
|
||||
redirect: "/hook-demo/use-fetch-select",
|
||||
name: "HookDemo",
|
||||
meta: {
|
||||
title: "hook 示例",
|
||||
elIcon: "Menu",
|
||||
alwaysShow: true
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: "use-fetch-select",
|
||||
component: () => import("@/views/hook-demo/use-fetch-select.vue"),
|
||||
name: "UseFetchSelect",
|
||||
meta: {
|
||||
title: "useFetchSelect"
|
||||
}
|
||||
},
|
||||
{
|
||||
path: "use-fullscreen-loading",
|
||||
component: () => import("@/views/hook-demo/use-fullscreen-loading.vue"),
|
||||
name: "UseFullscreenLoading",
|
||||
meta: {
|
||||
title: "useFullscreenLoading"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
/**
|
||||
* 动态路由
|
||||
* 用来放置有权限 (Roles 属性) 的路由
|
||||
* 必须带有 Name 属性
|
||||
*/
|
||||
export const asyncRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: "/permission",
|
||||
component: Layout,
|
||||
redirect: "/permission/page",
|
||||
name: "Permission",
|
||||
meta: {
|
||||
title: "权限管理",
|
||||
svgIcon: "lock",
|
||||
roles: ["admin", "editor"], // 可以在根路由中设置角色
|
||||
alwaysShow: true // 将始终显示根菜单
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: "page",
|
||||
component: () => import("@/views/permission/page.vue"),
|
||||
name: "PagePermission",
|
||||
meta: {
|
||||
title: "页面权限",
|
||||
roles: ["admin"] // 或者在子导航中设置角色
|
||||
}
|
||||
},
|
||||
{
|
||||
path: "directive",
|
||||
component: () => import("@/views/permission/directive.vue"),
|
||||
name: "DirectivePermission",
|
||||
meta: {
|
||||
title: "指令权限" // 如果未设置角色,则表示:该页面不需要权限,但会继承根路由的角色
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: "/:pathMatch(.*)*", // Must put the 'ErrorPage' route at the end, 必须将 'ErrorPage' 路由放在最后
|
||||
redirect: "/404",
|
||||
name: "ErrorPage",
|
||||
meta: {
|
||||
hidden: true
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history:
|
||||
import.meta.env.VITE_ROUTER_HISTORY === "hash"
|
||||
? createWebHashHistory(import.meta.env.VITE_PUBLIC_PATH)
|
||||
: createWebHistory(import.meta.env.VITE_PUBLIC_PATH),
|
||||
routes: constantRoutes
|
||||
})
|
||||
|
||||
/** 重置路由 */
|
||||
export function resetRouter() {
|
||||
// 注意:所有动态路由路由必须带有 Name 属性,否则可能会不能完全重置干净
|
||||
try {
|
||||
router.getRoutes().forEach((route) => {
|
||||
const { name, meta } = route
|
||||
if (name && meta.roles?.length) {
|
||||
router.hasRoute(name) && router.removeRoute(name)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
// 强制刷新浏览器也行,只是交互体验不是很好
|
||||
window.location.reload()
|
||||
}
|
||||
}
|
||||
|
||||
export default router
|
71
src/router/permission.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import router from "@/router"
|
||||
import { useUserStoreHook } from "@/store/modules/user"
|
||||
import { usePermissionStoreHook } from "@/store/modules/permission"
|
||||
import { ElMessage } from "element-plus"
|
||||
import { whiteList } from "@/config/white-list"
|
||||
import { getToken } from "@/utils/cache/cookies"
|
||||
import asyncRouteSettings from "@/config/async-route"
|
||||
import NProgress from "nprogress"
|
||||
import "nprogress/nprogress.css"
|
||||
|
||||
NProgress.configure({ showSpinner: false })
|
||||
|
||||
router.beforeEach(async (to, _from, next) => {
|
||||
NProgress.start()
|
||||
const userStore = useUserStoreHook()
|
||||
const permissionStore = usePermissionStoreHook()
|
||||
// 判断该用户是否登录
|
||||
if (getToken()) {
|
||||
if (to.path === "/login") {
|
||||
// 如果已经登录,并准备进入 Login 页面,则重定向到主页
|
||||
next({ path: "/" })
|
||||
NProgress.done()
|
||||
} else {
|
||||
// 检查用户是否已获得其权限角色
|
||||
if (userStore.roles.length === 0) {
|
||||
try {
|
||||
if (asyncRouteSettings.open) {
|
||||
// 注意:角色必须是一个数组! 例如: ['admin'] 或 ['developer', 'editor']
|
||||
await userStore.getInfo()
|
||||
const roles = userStore.roles
|
||||
// 根据角色生成可访问的 Routes(可访问路由 = 常驻路由 + 有访问权限的动态路由)
|
||||
permissionStore.setRoutes(roles)
|
||||
} else {
|
||||
// 没有开启动态路由功能,则启用默认角色
|
||||
userStore.setRoles(asyncRouteSettings.defaultRoles)
|
||||
permissionStore.setRoutes(asyncRouteSettings.defaultRoles)
|
||||
}
|
||||
// 将'有访问权限的动态路由' 添加到 Router 中
|
||||
permissionStore.dynamicRoutes.forEach((route) => {
|
||||
router.addRoute(route)
|
||||
})
|
||||
// 确保添加路由已完成
|
||||
// 设置 replace: true, 因此导航将不会留下历史记录
|
||||
next({ ...to, replace: true })
|
||||
} catch (err: any) {
|
||||
// 过程中发生任何错误,都直接重置 Token,并重定向到登录页面
|
||||
userStore.resetToken()
|
||||
ElMessage.error(err.message || "路由守卫过程发生错误")
|
||||
next("/login")
|
||||
NProgress.done()
|
||||
}
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 如果没有 Token
|
||||
if (whiteList.indexOf(to.path) !== -1) {
|
||||
// 如果在免登录的白名单中,则直接进入
|
||||
next()
|
||||
} else {
|
||||
// 其他没有访问权限的页面将被重定向到登录页面
|
||||
next("/login")
|
||||
NProgress.done()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
router.afterEach(() => {
|
||||
NProgress.done()
|
||||
})
|
5
src/store/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { createPinia } from "pinia"
|
||||
|
||||
const store = createPinia()
|
||||
|
||||
export default store
|
41
src/store/modules/app.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { reactive, ref } from "vue"
|
||||
import { defineStore } from "pinia"
|
||||
import { getSidebarStatus, setSidebarStatus } from "@/utils/cache/localStorage"
|
||||
|
||||
export enum DeviceType {
|
||||
Mobile,
|
||||
Desktop
|
||||
}
|
||||
|
||||
interface ISidebar {
|
||||
opened: boolean
|
||||
withoutAnimation: boolean
|
||||
}
|
||||
|
||||
export const useAppStore = defineStore("app", () => {
|
||||
const sidebar: ISidebar = reactive({
|
||||
opened: getSidebarStatus() !== "closed",
|
||||
withoutAnimation: false
|
||||
})
|
||||
const device = ref<DeviceType>(DeviceType.Desktop)
|
||||
|
||||
const toggleSidebar = (withoutAnimation: boolean) => {
|
||||
sidebar.opened = !sidebar.opened
|
||||
sidebar.withoutAnimation = withoutAnimation
|
||||
if (sidebar.opened) {
|
||||
setSidebarStatus("opened")
|
||||
} else {
|
||||
setSidebarStatus("closed")
|
||||
}
|
||||
}
|
||||
const closeSidebar = (withoutAnimation: boolean) => {
|
||||
sidebar.opened = false
|
||||
sidebar.withoutAnimation = withoutAnimation
|
||||
setSidebarStatus("closed")
|
||||
}
|
||||
const toggleDevice = (value: DeviceType) => {
|
||||
device.value = value
|
||||
}
|
||||
|
||||
return { device, sidebar, toggleSidebar, closeSidebar, toggleDevice }
|
||||
})
|
57
src/store/modules/permission.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import { ref } from "vue"
|
||||
import store from "@/store"
|
||||
import { defineStore } from "pinia"
|
||||
import { type RouteRecordRaw } from "vue-router"
|
||||
import { constantRoutes, asyncRoutes } from "@/router"
|
||||
import asyncRouteSettings from "@/config/async-route"
|
||||
|
||||
const hasPermission = (roles: string[], route: RouteRecordRaw) => {
|
||||
if (route.meta && route.meta.roles) {
|
||||
return roles.some((role) => {
|
||||
if (route.meta?.roles !== undefined) {
|
||||
return route.meta.roles.includes(role)
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
})
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const filterAsyncRoutes = (routes: RouteRecordRaw[], roles: string[]) => {
|
||||
const res: RouteRecordRaw[] = []
|
||||
routes.forEach((route) => {
|
||||
const r = { ...route }
|
||||
if (hasPermission(roles, r)) {
|
||||
if (r.children) {
|
||||
r.children = filterAsyncRoutes(r.children, roles)
|
||||
}
|
||||
res.push(r)
|
||||
}
|
||||
})
|
||||
return res
|
||||
}
|
||||
|
||||
export const usePermissionStore = defineStore("permission", () => {
|
||||
const routes = ref<RouteRecordRaw[]>([])
|
||||
const dynamicRoutes = ref<RouteRecordRaw[]>([])
|
||||
|
||||
const setRoutes = (roles: string[]) => {
|
||||
let accessedRoutes
|
||||
if (!asyncRouteSettings.open) {
|
||||
accessedRoutes = asyncRoutes
|
||||
} else {
|
||||
accessedRoutes = filterAsyncRoutes(asyncRoutes, roles)
|
||||
}
|
||||
routes.value = constantRoutes.concat(accessedRoutes)
|
||||
dynamicRoutes.value = accessedRoutes
|
||||
}
|
||||
|
||||
return { routes, dynamicRoutes, setRoutes }
|
||||
})
|
||||
|
||||
/** 在 setup 外使用 */
|
||||
export function usePermissionStoreHook() {
|
||||
return usePermissionStore(store)
|
||||
}
|
27
src/store/modules/settings.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { ref } from "vue"
|
||||
import { defineStore } from "pinia"
|
||||
import layoutSettings from "@/config/layout"
|
||||
|
||||
export const useSettingsStore = defineStore("settings", () => {
|
||||
const fixedHeader = ref<boolean>(layoutSettings.fixedHeader)
|
||||
const showSettings = ref<boolean>(layoutSettings.showSettings)
|
||||
const showTagsView = ref<boolean>(layoutSettings.showTagsView)
|
||||
const showSidebarLogo = ref<boolean>(layoutSettings.showSidebarLogo)
|
||||
const showNotify = ref<boolean>(layoutSettings.showNotify)
|
||||
const showThemeSwitch = ref<boolean>(layoutSettings.showThemeSwitch)
|
||||
const showScreenfull = ref<boolean>(layoutSettings.showScreenfull)
|
||||
const showGreyMode = ref<boolean>(layoutSettings.showGreyMode)
|
||||
const showColorWeakness = ref<boolean>(layoutSettings.showColorWeakness)
|
||||
|
||||
return {
|
||||
fixedHeader,
|
||||
showSettings,
|
||||
showTagsView,
|
||||
showSidebarLogo,
|
||||
showNotify,
|
||||
showThemeSwitch,
|
||||
showScreenfull,
|
||||
showGreyMode,
|
||||
showColorWeakness
|
||||
}
|
||||
})
|
94
src/store/modules/tags-view.ts
Normal file
@ -0,0 +1,94 @@
|
||||
import { ref } from "vue"
|
||||
import { defineStore } from "pinia"
|
||||
import { type RouteLocationNormalized } from "vue-router"
|
||||
|
||||
export type ITagView = Partial<RouteLocationNormalized>
|
||||
|
||||
export const useTagsViewStore = defineStore("tags-view", () => {
|
||||
const visitedViews = ref<ITagView[]>([])
|
||||
const cachedViews = ref<string[]>([])
|
||||
|
||||
//#region add
|
||||
const addVisitedView = (view: ITagView) => {
|
||||
if (
|
||||
visitedViews.value.some((v, index) => {
|
||||
if (v.path === view.path) {
|
||||
if (v.fullPath !== view.fullPath) {
|
||||
// 防止 query 参数丢失
|
||||
visitedViews.value[index] = Object.assign({}, view)
|
||||
}
|
||||
return true
|
||||
}
|
||||
})
|
||||
) {
|
||||
return
|
||||
}
|
||||
visitedViews.value.push(Object.assign({}, view))
|
||||
}
|
||||
const addCachedView = (view: ITagView) => {
|
||||
if (typeof view.name !== "string") return
|
||||
if (cachedViews.value.includes(view.name)) return
|
||||
if (view.meta?.keepAlive) {
|
||||
cachedViews.value.push(view.name)
|
||||
}
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region del
|
||||
const delVisitedView = (view: ITagView) => {
|
||||
for (const [i, v] of visitedViews.value.entries()) {
|
||||
if (v.path === view.path) {
|
||||
visitedViews.value.splice(i, 1)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
const delCachedView = (view: ITagView) => {
|
||||
if (typeof view.name !== "string") return
|
||||
const index = cachedViews.value.indexOf(view.name)
|
||||
index > -1 && cachedViews.value.splice(index, 1)
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region delOthers
|
||||
const delOthersVisitedViews = (view: ITagView) => {
|
||||
visitedViews.value = visitedViews.value.filter((v) => {
|
||||
return v.meta?.affix || v.path === view.path
|
||||
})
|
||||
}
|
||||
const delOthersCachedViews = (view: ITagView) => {
|
||||
if (typeof view.name !== "string") return
|
||||
const index = cachedViews.value.indexOf(view.name)
|
||||
if (index > -1) {
|
||||
cachedViews.value = cachedViews.value.slice(index, index + 1)
|
||||
} else {
|
||||
// 如果 index = -1, 没有缓存的 tags
|
||||
cachedViews.value = []
|
||||
}
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region delAll
|
||||
const delAllVisitedViews = () => {
|
||||
// keep affix tags
|
||||
const affixTags = visitedViews.value.filter((tag) => tag.meta?.affix)
|
||||
visitedViews.value = affixTags
|
||||
}
|
||||
const delAllCachedViews = () => {
|
||||
cachedViews.value = []
|
||||
}
|
||||
//#endregion
|
||||
|
||||
return {
|
||||
visitedViews,
|
||||
cachedViews,
|
||||
addVisitedView,
|
||||
addCachedView,
|
||||
delVisitedView,
|
||||
delCachedView,
|
||||
delOthersVisitedViews,
|
||||
delOthersCachedViews,
|
||||
delAllVisitedViews,
|
||||
delAllCachedViews
|
||||
}
|
||||
})
|
103
src/store/modules/user.ts
Normal file
@ -0,0 +1,103 @@
|
||||
import { ref } from "vue"
|
||||
import store from "@/store"
|
||||
import { defineStore } from "pinia"
|
||||
import { usePermissionStore } from "./permission"
|
||||
import { useTagsViewStore } from "./tags-view"
|
||||
import { getToken, removeToken, setToken } from "@/utils/cache/cookies"
|
||||
import router, { resetRouter } from "@/router"
|
||||
import { loginApi, getUserInfoApi } from "@/api/login"
|
||||
import { type ILoginRequestData } from "@/api/login/types/login"
|
||||
import { type RouteRecordRaw } from "vue-router"
|
||||
import asyncRouteSettings from "@/config/async-route"
|
||||
|
||||
export const useUserStore = defineStore("user", () => {
|
||||
const token = ref<string>(getToken() || "")
|
||||
const roles = ref<string[]>([])
|
||||
const username = ref<string>("")
|
||||
|
||||
const permissionStore = usePermissionStore()
|
||||
const tagsViewStore = useTagsViewStore()
|
||||
|
||||
/** 设置角色数组 */
|
||||
const setRoles = (value: string[]) => {
|
||||
roles.value = value
|
||||
}
|
||||
/** 登录 */
|
||||
const login = (loginData: ILoginRequestData) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
loginApi({
|
||||
username: loginData.username,
|
||||
password: loginData.password,
|
||||
code: loginData.code
|
||||
})
|
||||
.then((res) => {
|
||||
setToken(res.data.token)
|
||||
token.value = res.data.token
|
||||
resolve(true)
|
||||
})
|
||||
.catch((error) => {
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
/** 获取用户详情 */
|
||||
const getInfo = () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
getUserInfoApi()
|
||||
.then((res) => {
|
||||
const data = res.data
|
||||
username.value = data.username
|
||||
// 验证返回的 roles 是否是一个非空数组
|
||||
if (data.roles && data.roles.length > 0) {
|
||||
roles.value = data.roles
|
||||
} else {
|
||||
// 塞入一个没有任何作用的默认角色,不然路由守卫逻辑会无限循环
|
||||
roles.value = asyncRouteSettings.defaultRoles
|
||||
}
|
||||
resolve(res)
|
||||
})
|
||||
.catch((error) => {
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
/** 切换角色 */
|
||||
const changeRoles = async (role: string) => {
|
||||
const newToken = "token-" + role
|
||||
token.value = newToken
|
||||
setToken(newToken)
|
||||
await getInfo()
|
||||
permissionStore.setRoutes(roles.value)
|
||||
resetRouter()
|
||||
permissionStore.dynamicRoutes.forEach((item: RouteRecordRaw) => {
|
||||
router.addRoute(item)
|
||||
})
|
||||
_resetTagsView()
|
||||
}
|
||||
/** 登出 */
|
||||
const logout = () => {
|
||||
removeToken()
|
||||
token.value = ""
|
||||
roles.value = []
|
||||
resetRouter()
|
||||
_resetTagsView()
|
||||
}
|
||||
/** 重置 Token */
|
||||
const resetToken = () => {
|
||||
removeToken()
|
||||
token.value = ""
|
||||
roles.value = []
|
||||
}
|
||||
/** 重置 visited views 和 cached views */
|
||||
const _resetTagsView = () => {
|
||||
tagsViewStore.delAllVisitedViews()
|
||||
tagsViewStore.delAllCachedViews()
|
||||
}
|
||||
|
||||
return { token, roles, username, setRoles, login, getInfo, changeRoles, logout, resetToken }
|
||||
})
|
||||
|
||||
/** 在 setup 外使用 */
|
||||
export function useUserStoreHook() {
|
||||
return useUserStore(store)
|
||||
}
|
23
src/styles/element-plus.scss
Normal file
@ -0,0 +1,23 @@
|
||||
/** 自定义 Element Plus 样式 */
|
||||
|
||||
// 表格
|
||||
.el-table {
|
||||
// 表头
|
||||
th.el-table__cell {
|
||||
background-color: var(--el-fill-color-light) !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 分页
|
||||
.el-pagination {
|
||||
// 参考 Bootstrap 的响应式设计 WIDTH = 768
|
||||
@media screen and (max-width: 768px) {
|
||||
.el-pagination__total,
|
||||
.el-pagination__sizes,
|
||||
.el-pagination__jump,
|
||||
.btn-prev,
|
||||
.btn-next {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
}
|
50
src/styles/index.scss
Normal file
@ -0,0 +1,50 @@
|
||||
// 全局 CSS 变量
|
||||
@import "./variables.css";
|
||||
// Transition
|
||||
@import "./transition.scss";
|
||||
// Element Plus
|
||||
@import "./element-plus.scss";
|
||||
// Vxe Table
|
||||
@import "./vxe-table.scss";
|
||||
// 注册多主题
|
||||
@import "./theme/register.scss";
|
||||
|
||||
// 业务页面几乎都应该在根元素上挂载 class="app-container",以保持页面美观
|
||||
.app-container {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
html {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
height: 100%;
|
||||
background-color: var(--v3-body-bg-color);
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
font-family: "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "微软雅黑", Arial,
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
*,
|
||||
*:before,
|
||||
*:after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
a,
|
||||
a:focus,
|
||||
a:hover {
|
||||
color: inherit;
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
div:focus {
|
||||
outline: none;
|
||||
}
|
7
src/styles/mixins.scss
Normal file
@ -0,0 +1,7 @@
|
||||
@mixin clearfix {
|
||||
&:after {
|
||||
content: "";
|
||||
display: table;
|
||||
clear: both;
|
||||
}
|
||||
}
|
20
src/styles/theme/core/element-plus.scss
Normal file
@ -0,0 +1,20 @@
|
||||
/** Element Plus 相关 */
|
||||
|
||||
// 侧边栏的 item 的 popper
|
||||
.el-popper {
|
||||
border: none !important;
|
||||
.el-menu {
|
||||
background-color: lighten($theme-bg-color, 4%) !important;
|
||||
.el-menu-item {
|
||||
background-color: lighten($theme-bg-color, 4%) !important;
|
||||
&.is-active,
|
||||
&:hover {
|
||||
background-color: lighten($theme-bg-color, 8%) !important;
|
||||
color: $active-font-color !important;
|
||||
}
|
||||
}
|
||||
.el-sub-menu__title {
|
||||
background-color: lighten($theme-bg-color, 4%) !important;
|
||||
}
|
||||
}
|
||||
}
|
5
src/styles/theme/core/error-page.scss
Normal file
@ -0,0 +1,5 @@
|
||||
/** ErrorPage 页面相关 */
|
||||
|
||||
.error-page {
|
||||
background-color: $theme-bg-color;
|
||||
}
|