wip: 迁移 v3-admin v3.1.3 版本代码
27
.eslintrc.js
@ -19,8 +19,7 @@ module.exports = {
|
||||
'plugin:vue/vue3-strongly-recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'@vue/standard',
|
||||
'@vue/typescript/recommended',
|
||||
'plugin:prettier/recommended' // 添加 prettier 插件,必须放在数组最后
|
||||
'@vue/typescript/recommended'
|
||||
],
|
||||
rules: {
|
||||
'vue/multi-word-component-names': 'off',
|
||||
@ -40,18 +39,18 @@ module.exports = {
|
||||
}
|
||||
}
|
||||
],
|
||||
'vue/html-self-closing': [
|
||||
'error',
|
||||
{
|
||||
html: {
|
||||
void: 'always',
|
||||
normal: 'always',
|
||||
component: 'always'
|
||||
},
|
||||
svg: 'always',
|
||||
math: 'always'
|
||||
}
|
||||
],
|
||||
// 'vue/html-self-closing': [
|
||||
// 'error',
|
||||
// {
|
||||
// html: {
|
||||
// void: 'always',
|
||||
// normal: 'always',
|
||||
// component: 'always'
|
||||
// },
|
||||
// svg: 'always',
|
||||
// math: 'always'
|
||||
// }
|
||||
// ],
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-var-requires': 'off',
|
||||
'prefer-regex-literals': 'off',
|
||||
|
@ -8,9 +8,7 @@
|
||||
"build:prod": "vue-tsc --noEmit && vite build",
|
||||
"preview:stage": "pnpm build:stage && vite preview",
|
||||
"preview:prod": "pnpm build:prod && vite preview",
|
||||
"lint:eslint": "eslint \"{src,mock}/**/*.{vue,ts,tsx}\" --fix",
|
||||
"lint:prettier": "prettier --write \"src/**/*.{js,json,tsx,css,less,scss,vue,html,md}\"",
|
||||
"lint": "pnpm lint:eslint && pnpm lint:prettier"
|
||||
"lint": "eslint \"{src,mock}/**/*.{vue,ts,tsx}\" --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^1.1.4",
|
||||
@ -38,14 +36,11 @@
|
||||
"@vue/eslint-config-standard": "^6.1.0",
|
||||
"@vue/eslint-config-typescript": "^10.0.0",
|
||||
"eslint": "^8.13.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-plugin-import": "^2.26.0",
|
||||
"eslint-plugin-node": "^11.1.0",
|
||||
"eslint-plugin-prettier": "^4.0.0",
|
||||
"eslint-plugin-promise": "^6.0.0",
|
||||
"eslint-plugin-vue": "^8.6.0",
|
||||
"lint-staged": "^12.4.0",
|
||||
"prettier": "^2.6.2",
|
||||
"sass": "^1.50.1",
|
||||
"typescript": "^4.6.3",
|
||||
"unplugin-auto-import": "^0.7.1",
|
||||
|
49
pnpm-lock.yaml
generated
@ -15,10 +15,8 @@ specifiers:
|
||||
dayjs: ^1.11.1
|
||||
element-plus: ^2.1.10
|
||||
eslint: ^8.13.0
|
||||
eslint-config-prettier: ^8.5.0
|
||||
eslint-plugin-import: ^2.26.0
|
||||
eslint-plugin-node: ^11.1.0
|
||||
eslint-plugin-prettier: ^4.0.0
|
||||
eslint-plugin-promise: ^6.0.0
|
||||
eslint-plugin-vue: ^8.6.0
|
||||
js-cookie: ^3.0.1
|
||||
@ -28,7 +26,6 @@ specifiers:
|
||||
nprogress: ^0.2.0
|
||||
path-to-regexp: ^6.2.0
|
||||
pinia: ^2.0.13
|
||||
prettier: ^2.6.2
|
||||
sass: ^1.50.1
|
||||
screenfull: ^6.0.1
|
||||
typescript: ^4.6.3
|
||||
@ -66,14 +63,11 @@ devDependencies:
|
||||
'@vue/eslint-config-standard': 6.1.0_4095fd5e90f154d95a11e7814517bad8
|
||||
'@vue/eslint-config-typescript': 10.0.0_a62cbc2f4797496d74696b1f6538012a
|
||||
eslint: 8.13.0
|
||||
eslint-config-prettier: 8.5.0_eslint@8.13.0
|
||||
eslint-plugin-import: 2.26.0_eslint@8.13.0
|
||||
eslint-plugin-node: 11.1.0_eslint@8.13.0
|
||||
eslint-plugin-prettier: 4.0.0_1815ac95b7fb26c13c7d48a8eef62d0f
|
||||
eslint-plugin-promise: 6.0.0_eslint@8.13.0
|
||||
eslint-plugin-vue: 8.6.0_eslint@8.13.0
|
||||
lint-staged: 12.4.0
|
||||
prettier: 2.6.2
|
||||
sass: 1.50.1
|
||||
typescript: 4.6.3
|
||||
unplugin-auto-import: 0.7.1_vite@2.9.5
|
||||
@ -1450,15 +1444,6 @@ packages:
|
||||
engines: {node: '>=10'}
|
||||
dev: true
|
||||
|
||||
/eslint-config-prettier/8.5.0_eslint@8.13.0:
|
||||
resolution: {integrity: sha512-obmWKLUNCnhtQRKc+tmnYuQl0pFU1ibYJQ5BGhTVB08bHe9wC8qUeG7c08dj9XX+AuPj1YSGSQIHl1pnDHZR0Q==}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
eslint: '>=7.0.0'
|
||||
dependencies:
|
||||
eslint: 8.13.0
|
||||
dev: true
|
||||
|
||||
/eslint-config-standard/16.0.3_4e3767bb061a43bc3978ec6596bc3350:
|
||||
resolution: {integrity: sha512-x4fmJL5hGqNJKGHSjnLdgA6U6h1YW/G2dW9fA+cyVur4SK6lyue8+UgNKWlZtUDTXvgKDD/Oa3GQjmB5kjtVvg==}
|
||||
peerDependencies:
|
||||
@ -1557,23 +1542,6 @@ packages:
|
||||
semver: 6.3.0
|
||||
dev: true
|
||||
|
||||
/eslint-plugin-prettier/4.0.0_1815ac95b7fb26c13c7d48a8eef62d0f:
|
||||
resolution: {integrity: sha512-98MqmCJ7vJodoQK359bqQWaxOE0CS8paAz/GgjaZLyex4TTk3g9HugoO89EqWCrFiOqn9EVvcoo7gZzONCWVwQ==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
peerDependencies:
|
||||
eslint: '>=7.28.0'
|
||||
eslint-config-prettier: '*'
|
||||
prettier: '>=2.0.0'
|
||||
peerDependenciesMeta:
|
||||
eslint-config-prettier:
|
||||
optional: true
|
||||
dependencies:
|
||||
eslint: 8.13.0
|
||||
eslint-config-prettier: 8.5.0_eslint@8.13.0
|
||||
prettier: 2.6.2
|
||||
prettier-linter-helpers: 1.0.0
|
||||
dev: true
|
||||
|
||||
/eslint-plugin-promise/6.0.0_eslint@8.13.0:
|
||||
resolution: {integrity: sha512-7GPezalm5Bfi/E22PnQxDWH2iW9GTvAlUNTztemeHb6c1BniSyoeTrM87JkC0wYdi6aQrZX9p2qEiAno8aTcbw==}
|
||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||
@ -1797,10 +1765,6 @@ packages:
|
||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||
dev: true
|
||||
|
||||
/fast-diff/1.2.0:
|
||||
resolution: {integrity: sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==}
|
||||
dev: true
|
||||
|
||||
/fast-glob/3.2.11:
|
||||
resolution: {integrity: sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew==}
|
||||
engines: {node: '>=8.6.0'}
|
||||
@ -2984,19 +2948,6 @@ packages:
|
||||
engines: {node: '>= 0.8.0'}
|
||||
dev: true
|
||||
|
||||
/prettier-linter-helpers/1.0.0:
|
||||
resolution: {integrity: sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
dependencies:
|
||||
fast-diff: 1.2.0
|
||||
dev: true
|
||||
|
||||
/prettier/2.6.2:
|
||||
resolution: {integrity: sha512-PkUpF+qoXTqhOeWL9fu7As8LXsIUZ1WYaJiY/a7McAQzxjk82OF0tibkFXVCDImZtWxbvojFjerkiLb0/q8mew==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
hasBin: true
|
||||
dev: true
|
||||
|
||||
/punycode/2.1.1:
|
||||
resolution: {integrity: sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==}
|
||||
engines: {node: '>=6'}
|
||||
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 66 KiB |
39
src/App.vue
@ -1,30 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
// This starter template is using Vue 3 <script setup> SFCs
|
||||
// Check out https://vuejs.org/api/sfc-script-setup.html#script-setup
|
||||
import HelloWorld from './components/HelloWorld.vue'
|
||||
console.log('测试 console.log')
|
||||
console.info('测试 console.info')
|
||||
console.error('测试 console.error')
|
||||
console.info('测试 VITE_BASE_API', import.meta.env.VITE_BASE_API)
|
||||
console.info('测试 MODE', import.meta.env.MODE)
|
||||
console.info('测试 BASE_URL', import.meta.env.BASE_URL)
|
||||
console.info('测试 DEV', import.meta.env.DEV)
|
||||
console.info('测试 PROD', import.meta.env.PROD)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<img alt="Vue logo" src="./assets/logo.png" />
|
||||
<HelloWorld msg="Hello Vue 3 + TypeScript + Vite" />
|
||||
<el-button>测试 element-plus 按钮</el-button>
|
||||
<ElConfigProvider :locale="locale">
|
||||
<router-view />
|
||||
</ElConfigProvider>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
#app {
|
||||
font-family: Avenir, Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-align: center;
|
||||
color: #2c3e50;
|
||||
margin-top: 60px;
|
||||
}
|
||||
</style>
|
||||
<script lang="ts" setup>
|
||||
import { ElConfigProvider } from 'element-plus'
|
||||
import zhCn from 'element-plus/lib/locale/lang/zh-cn'
|
||||
import { useAppStore } from '@/store/modules/app'
|
||||
|
||||
const locale = zhCn // element-plus 设置为中文
|
||||
|
||||
useAppStore().initTheme() // 初始化 Theme
|
||||
</script>
|
||||
|
22
src/api/login.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { request } from '@/utils/service'
|
||||
|
||||
interface IUserRequestData {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
/** 登录,返回 token */
|
||||
export function accountLogin(data: IUserRequestData) {
|
||||
return request({
|
||||
url: 'users/login',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
/** 获取用户详情 */
|
||||
export function userInfoRequest() {
|
||||
return request({
|
||||
url: 'users/info',
|
||||
method: 'post'
|
||||
})
|
||||
}
|
BIN
src/assets/docs/qq.png
Normal file
After Width: | Height: | Size: 14 KiB |
BIN
src/assets/layout/discard1.png
Normal file
After Width: | Height: | Size: 317 KiB |
BIN
src/assets/layout/discard2.png
Normal file
After Width: | Height: | Size: 38 KiB |
BIN
src/assets/layout/discard3.png
Normal file
After Width: | Height: | Size: 3.6 KiB |
BIN
src/assets/layout/logo-text-1.png
Normal file
After Width: | Height: | Size: 352 KiB |
BIN
src/assets/layout/logo-text-2.png
Normal file
After Width: | Height: | Size: 379 KiB |
BIN
src/assets/layout/logo-text-3.png
Normal file
After Width: | Height: | Size: 370 KiB |
BIN
src/assets/layout/logo.png
Normal file
After Width: | Height: | Size: 3.4 KiB |
1
src/assets/layout/logo.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="1646185144620" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3301" xmlns:xlink="http://www.w3.org/1999/xlink" width="128" height="128"><defs><style type="text/css"></style></defs><path d="M899.7 116.1 645 116.1 296.8 440.7 296.8 116.1 65 116.1 65 208.8 157.7 208.8 157.7 904.4Z" p-id="3302" fill="#409EFF"></path><path d="M603.9 626.7 603.9 517c23.7-13.9 49.9-24.6 78.5-31.9 28.6-7.3 58.1-10.9 88.7-10.9 53 0 93.5 11.4 121.5 34.1 28 22.7 42 55.4 42 98.1 0 29.4-7.1 53.1-21.3 71.1-14.2 18.1-35.9 31-65.1 38.9 30.1 4.2 52.7 16.1 67.7 35.5 15.1 19.4 22.6 46.3 22.6 80.5 0 47.5-14.5 83.4-43.5 107.5C866 964 822.9 976 765.8 976c-28.1 0-56.5-3.8-85-11.4-28.6-7.6-56.1-18.6-82.4-33L598.4 816.9l114 0 0 3.8c0 19 4.6 33.5 13.9 43.2 9.2 9.9 22.8 14.8 40.9 14.8 16.9 0 29.8-4.9 38.7-14.8 8.9-9.8 13.4-24 13.4-42.6 0-21.3-5.1-36.5-15.3-45.5-10.2-9-28.3-13.4-54.2-13.4l-63 0 0-81.8 66.9 0c21.6 0 38.1-4.8 49.6-14.4 11.5-9.6 17.3-23.2 17.3-40.7 0-17.3-5-30.7-15.2-40.3-10.1-9.5-24.2-14.3-42.2-14.3-15.6 0-27.6 4.7-35.9 14.1-8.4 9.4-13.1 23.3-14.2 41.7L603.9 626.7z" p-id="3303" fill="#409EFF"></path></svg>
|
After Width: | Height: | Size: 1.3 KiB |
Before Width: | Height: | Size: 6.7 KiB |
@ -1,40 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ msg: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>{{ msg }}</h1>
|
||||
|
||||
<p>
|
||||
Recommended IDE setup:
|
||||
<a href="https://code.visualstudio.com/" target="_blank">VS Code</a>
|
||||
+
|
||||
<a href="https://github.com/johnsoncodehk/volar" target="_blank">Volar</a>
|
||||
</p>
|
||||
|
||||
<p>See <code>README.md</code> for more information.</p>
|
||||
|
||||
<p>
|
||||
<a href="https://vitejs.dev/guide/features.html" target="_blank"> Vite Docs </a>
|
||||
|
|
||||
<a href="https://v3.vuejs.org/" target="_blank">Vue 3 Docs</a>
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
a {
|
||||
color: #42b983;
|
||||
}
|
||||
|
||||
label {
|
||||
margin: 0 0.5em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
code {
|
||||
background-color: #eee;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
color: #304455;
|
||||
}
|
||||
</style>
|
23
src/components/Screenfull/index.vue
Normal file
@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<div @click="click">
|
||||
<el-tooltip effect="dark" content="全屏" placement="bottom">
|
||||
<el-icon :size="20">
|
||||
<full-screen />
|
||||
</el-icon>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { FullScreen } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import screenfull from 'screenfull'
|
||||
|
||||
const click = () => {
|
||||
if (!screenfull.isEnabled) {
|
||||
ElMessage.warning('您的浏览器无法工作')
|
||||
return
|
||||
}
|
||||
screenfull.toggle()
|
||||
}
|
||||
</script>
|
38
src/components/ThemeSwitch/index.vue
Normal file
@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<el-dropdown trigger="click" @command="handleSetTheme">
|
||||
<el-tooltip effect="dark" content="主题模式" placement="bottom">
|
||||
<el-icon :size="20">
|
||||
<magic-stick />
|
||||
</el-icon>
|
||||
</el-tooltip>
|
||||
<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>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { MagicStick } from '@element-plus/icons-vue'
|
||||
import { computed } from 'vue'
|
||||
import { useAppStore } from '@/store/modules/app'
|
||||
|
||||
const appStore = useAppStore()
|
||||
const themeList = computed(() => {
|
||||
return appStore.themeList
|
||||
})
|
||||
const activeThemeName = computed(() => {
|
||||
return appStore.activeThemeName
|
||||
})
|
||||
const handleSetTheme = (name: string) => {
|
||||
appStore.setTheme(name)
|
||||
}
|
||||
</script>
|
26
src/config/layout.ts
Normal file
@ -0,0 +1,26 @@
|
||||
/** 布局配置 */
|
||||
interface ILayoutSettings {
|
||||
/** 控制 settings panel 显示 */
|
||||
showSettings: boolean
|
||||
/** 控制 tagsview 显示 */
|
||||
showTagsView: boolean
|
||||
/** 控制 siderbar logo 显示 */
|
||||
showSidebarLogo: boolean
|
||||
/** 如果为真,将固定 header */
|
||||
fixedHeader: boolean
|
||||
/** 控制 换肤按钮 显示 */
|
||||
showThemeSwitch: boolean
|
||||
/** 控制 全屏按钮 显示 */
|
||||
showScreenfull: boolean
|
||||
}
|
||||
|
||||
const layoutSettings: ILayoutSettings = {
|
||||
showSettings: true,
|
||||
showTagsView: true,
|
||||
fixedHeader: false,
|
||||
showSidebarLogo: true,
|
||||
showThemeSwitch: true,
|
||||
showScreenfull: true
|
||||
}
|
||||
|
||||
export default layoutSettings
|
14
src/config/roles.ts
Normal file
@ -0,0 +1,14 @@
|
||||
/** 角色配置 */
|
||||
interface IRolesSettings {
|
||||
/** 是否开启角色功能(开启后需要后端配合,在查询用户详情接口返回当前用户的所属角色) */
|
||||
openRoles: boolean
|
||||
/** 当角色功能关闭时,当前登录用户的默认角色将生效(默认为admin,拥有所有权限) */
|
||||
defaultRoles: Array<string>
|
||||
}
|
||||
|
||||
const rolesSettings: IRolesSettings = {
|
||||
openRoles: true,
|
||||
defaultRoles: ['admin']
|
||||
}
|
||||
|
||||
export default rolesSettings
|
13
src/config/theme.ts
Normal file
@ -0,0 +1,13 @@
|
||||
/** 注册的主题 */
|
||||
const themeList = [
|
||||
{
|
||||
title: '默认',
|
||||
name: 'normal'
|
||||
},
|
||||
{
|
||||
title: '黑暗',
|
||||
name: 'dark'
|
||||
}
|
||||
]
|
||||
|
||||
export default themeList
|
4
src/config/white-list.ts
Normal file
@ -0,0 +1,4 @@
|
||||
/** 免登录白名单 */
|
||||
const whiteList = ['/login']
|
||||
|
||||
export { whiteList }
|
8
src/constant/key.ts
Normal file
@ -0,0 +1,8 @@
|
||||
class Keys {
|
||||
static sidebarStatus = 'v3-admin-sidebar-status-key'
|
||||
static language = 'v3-admin-language-key'
|
||||
static token = 'v3-admin-token-key'
|
||||
static activeThemeName = 'v3-admin-active-theme-name'
|
||||
}
|
||||
|
||||
export default Keys
|
1
src/directives/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './permission'
|
21
src/directives/permission/index.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { useUserStoreHook } from '@/store/modules/user'
|
||||
import { Directive } from 'vue'
|
||||
|
||||
/** 权限指令 */
|
||||
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: any) => {
|
||||
return permissionRoles.includes(role)
|
||||
})
|
||||
if (!hasPermission) {
|
||||
el.style.display = 'none'
|
||||
}
|
||||
} else {
|
||||
throw new Error("need roles! Like v-permission=\"['admin','editor']\"")
|
||||
}
|
||||
}
|
||||
}
|
48
src/layout/components/AppMain.vue
Normal file
@ -0,0 +1,48 @@
|
||||
<!-- 主视图 -->
|
||||
<template>
|
||||
<section class="app-main">
|
||||
<router-view v-slot="{Component}">
|
||||
<transition name="fade-transform" mode="out-in">
|
||||
<!-- <keep-alive> -->
|
||||
<component :is="Component" :key="key" />
|
||||
<!-- </keep-alive> -->
|
||||
</transition>
|
||||
</router-view>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
const key = computed(() => {
|
||||
return route.path
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.app-main {
|
||||
/* 50 = navbar height */
|
||||
min-height: calc(100vh - 50px);
|
||||
width: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fixed-header + .app-main {
|
||||
padding-top: 50px;
|
||||
height: 100vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.hasTagsView {
|
||||
.app-main {
|
||||
/* 84 = navbar + tags-view = 50 + 34 */
|
||||
min-height: calc(100vh - 84px);
|
||||
}
|
||||
.fixed-header + .app-main {
|
||||
padding-top: 84px;
|
||||
}
|
||||
}
|
||||
</style>
|
83
src/layout/components/BreadCrumb/index.vue
Normal file
@ -0,0 +1,83 @@
|
||||
<!-- 面包屑组件 -->
|
||||
<template>
|
||||
<el-breadcrumb class="app-breadcrumb">
|
||||
<transition-group name="breadcrumb">
|
||||
<el-breadcrumb-item v-for="(item, index) in state.breadcrumbs" :key="item.path">
|
||||
<span v-if="item.redirect === 'noRedirect' || index === state.breadcrumbs.length - 1" class="no-redirect">{{
|
||||
item.meta.title
|
||||
}}</span>
|
||||
<a v-else @click.prevent="state.handleLink(item)">
|
||||
{{ item.meta.title }}
|
||||
</a>
|
||||
</el-breadcrumb-item>
|
||||
</transition-group>
|
||||
</el-breadcrumb>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onBeforeMount, reactive, watch } from 'vue'
|
||||
import { useRoute, useRouter, RouteLocationMatched } from 'vue-router'
|
||||
import { compile } from 'path-to-regexp'
|
||||
|
||||
const currentRoute = useRoute()
|
||||
const router = useRouter()
|
||||
const pathCompile = (path: string) => {
|
||||
const { params } = currentRoute
|
||||
const toPath = compile(path)
|
||||
return toPath(params)
|
||||
}
|
||||
|
||||
const state = reactive({
|
||||
breadcrumbs: [] as Array<RouteLocationMatched>,
|
||||
getBreadcrumb: () => {
|
||||
const matched = currentRoute.matched.filter((item) => item.meta && item.meta.title)
|
||||
state.breadcrumbs = matched.filter((item) => {
|
||||
return item.meta && item.meta.title && item.meta.breadcrumb !== false
|
||||
})
|
||||
},
|
||||
handleLink(item: any) {
|
||||
const { redirect, path } = item
|
||||
if (redirect) {
|
||||
router.push(redirect).catch((err) => {
|
||||
console.warn(err)
|
||||
})
|
||||
return
|
||||
}
|
||||
router.push(pathCompile(path)).catch((err) => {
|
||||
console.warn(err)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => currentRoute.path,
|
||||
(path) => {
|
||||
if (path.startsWith('/redirect/')) {
|
||||
return
|
||||
}
|
||||
state.getBreadcrumb()
|
||||
}
|
||||
)
|
||||
|
||||
onBeforeMount(() => {
|
||||
state.getBreadcrumb()
|
||||
})
|
||||
</script>
|
||||
|
||||
<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: 50px;
|
||||
margin-left: 8px;
|
||||
.no-redirect {
|
||||
color: #97a8be;
|
||||
cursor: text;
|
||||
}
|
||||
}
|
||||
</style>
|
32
src/layout/components/Hamburger/index.vue
Normal file
@ -0,0 +1,32 @@
|
||||
<!-- 折叠边栏按钮 -->
|
||||
<template>
|
||||
<div @click="toggleClick">
|
||||
<el-icon :size="20" class="icon">
|
||||
<fold v-if="isActive" />
|
||||
<expand v-else />
|
||||
</el-icon>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Expand, Fold } from '@element-plus/icons-vue'
|
||||
|
||||
defineProps({
|
||||
isActive: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['toggle-click'])
|
||||
|
||||
const toggleClick = () => {
|
||||
emit('toggle-click')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.icon {
|
||||
vertical-align: middle;
|
||||
}
|
||||
</style>
|
102
src/layout/components/NavigationBar/index.vue
Normal file
@ -0,0 +1,102 @@
|
||||
<!-- 导航栏 -->
|
||||
<template>
|
||||
<div class="navbar">
|
||||
<Hamburger :is-active="sidebar.opened" class="hamburger" @toggle-click="state.toggleSideBar" />
|
||||
<BreadCrumb class="breadcrumb" />
|
||||
<div class="right-menu">
|
||||
<Screenfull v-if="showScreenfull" class="right-menu-item" />
|
||||
<ThemeSwitch v-if="showThemeSwitch" class="right-menu-item" />
|
||||
<el-dropdown class="right-menu-item">
|
||||
<el-avatar :icon="UserFilled" :size="34" />
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<a target="_blank" href="https://juejin.cn/post/6963876125428678693">
|
||||
<el-dropdown-item>中文文档</el-dropdown-item>
|
||||
</a>
|
||||
<a target="_blank" href="https://github.com/un-pany/v3-admin/blob/master/README.en.md">
|
||||
<el-dropdown-item>English Docs</el-dropdown-item>
|
||||
</a>
|
||||
<a target="_blank" href="https://github.com/un-pany/v3-admin">
|
||||
<el-dropdown-item>GitHub</el-dropdown-item>
|
||||
</a>
|
||||
<a target="_blank" href="https://gitee.com/un-pany/v3-admin">
|
||||
<el-dropdown-item>Gitee</el-dropdown-item>
|
||||
</a>
|
||||
<el-dropdown-item divided @click="state.logout">
|
||||
<span style="display: block">退出登录</span>
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { UserFilled } from '@element-plus/icons-vue'
|
||||
import { computed, reactive } 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 BreadCrumb from '../bread-crumb/index.vue'
|
||||
import Hamburger from '../hamburger/index.vue'
|
||||
import ThemeSwitch from '@/components/ThemeSwitch/index.vue'
|
||||
import Screenfull from '@/components/Screenfull/index.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const appStore = useAppStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
const userStore = useUserStore()
|
||||
const sidebar = computed(() => {
|
||||
return appStore.sidebar
|
||||
})
|
||||
const showThemeSwitch = computed(() => {
|
||||
return settingsStore.showThemeSwitch
|
||||
})
|
||||
const showScreenfull = computed(() => {
|
||||
return settingsStore.showScreenfull
|
||||
})
|
||||
const state = reactive({
|
||||
toggleSideBar: () => {
|
||||
appStore.toggleSidebar(false)
|
||||
},
|
||||
logout: () => {
|
||||
userStore.logout()
|
||||
router.push('/login').catch((err) => {
|
||||
console.warn(err)
|
||||
})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.navbar {
|
||||
height: 50px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
.hamburger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
float: left;
|
||||
padding: 0 15px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.breadcrumb {
|
||||
float: left;
|
||||
}
|
||||
.right-menu {
|
||||
float: right;
|
||||
margin-right: 10px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #5a5e66;
|
||||
.right-menu-item {
|
||||
padding: 0 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
45
src/layout/components/RightPanel/index.vue
Normal file
@ -0,0 +1,45 @@
|
||||
<!-- 右侧悬浮设置面板 -->
|
||||
<template>
|
||||
<div class="handle-button" :style="{top: buttonTop + 'px'}" @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>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
import { Setting } from '@element-plus/icons-vue'
|
||||
|
||||
defineProps({
|
||||
buttonTop: {
|
||||
type: Number,
|
||||
default: 250
|
||||
}
|
||||
})
|
||||
|
||||
const show = ref(false)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.handle-button {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background-color: #152d3d;
|
||||
position: absolute;
|
||||
right: 0px;
|
||||
text-align: center;
|
||||
font-size: 24px;
|
||||
border-radius: 6px 0 0 6px !important;
|
||||
z-index: 10;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
118
src/layout/components/Settings/index.vue
Normal file
@ -0,0 +1,118 @@
|
||||
<!-- 设置页面 -->
|
||||
<template>
|
||||
<div class="drawer-container">
|
||||
<div>
|
||||
<h3 class="drawer-title">
|
||||
系统布局配置
|
||||
</h3>
|
||||
<div class="drawer-item">
|
||||
<span>显示 Tags-View</span>
|
||||
<el-switch v-model="state.showTagsView" class="drawer-switch" />
|
||||
</div>
|
||||
<div class="drawer-item">
|
||||
<span>显示侧边栏 Logo</span>
|
||||
<el-switch v-model="state.showSidebarLogo" class="drawer-switch" />
|
||||
</div>
|
||||
<div class="drawer-item">
|
||||
<span>固定 Header</span>
|
||||
<el-switch v-model="state.fixedHeader" class="drawer-switch" />
|
||||
</div>
|
||||
<div class="drawer-item">
|
||||
<span>显示换肤按钮</span>
|
||||
<el-switch v-model="state.showThemeSwitch" class="drawer-switch" />
|
||||
</div>
|
||||
<div class="drawer-item">
|
||||
<span>显示全屏按钮</span>
|
||||
<el-switch v-model="state.showScreenfull" class="drawer-switch" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useSettingsStore } from '@/store/modules/settings'
|
||||
import { reactive, watch } from 'vue'
|
||||
|
||||
const settingsStore = useSettingsStore()
|
||||
|
||||
const state = reactive({
|
||||
fixedHeader: settingsStore.fixedHeader,
|
||||
showTagsView: settingsStore.showTagsView,
|
||||
showSidebarLogo: settingsStore.showSidebarLogo,
|
||||
showThemeSwitch: settingsStore.showThemeSwitch,
|
||||
showScreenfull: settingsStore.showScreenfull
|
||||
})
|
||||
|
||||
watch(
|
||||
() => state.fixedHeader,
|
||||
(value) => {
|
||||
settingsStore.changeSetting({
|
||||
key: 'fixedHeader',
|
||||
value
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => state.showTagsView,
|
||||
(value) => {
|
||||
settingsStore.changeSetting({
|
||||
key: 'showTagsView',
|
||||
value
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => state.showSidebarLogo,
|
||||
(value) => {
|
||||
settingsStore.changeSetting({
|
||||
key: 'showSidebarLogo',
|
||||
value
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => state.showThemeSwitch,
|
||||
(value) => {
|
||||
settingsStore.changeSetting({
|
||||
key: 'showThemeSwitch',
|
||||
value
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => state.showScreenfull,
|
||||
(value) => {
|
||||
settingsStore.changeSetting({
|
||||
key: 'showScreenfull',
|
||||
value
|
||||
})
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.drawer-container {
|
||||
padding: 24px;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
word-wrap: break-word;
|
||||
.drawer-title {
|
||||
margin-bottom: 12px;
|
||||
color: #303133;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
}
|
||||
.drawer-item {
|
||||
color: #303133;
|
||||
font-size: 14px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
.drawer-switch {
|
||||
float: right;
|
||||
}
|
||||
}
|
||||
</style>
|
118
src/layout/components/Sidebar/SidebarItem.vue
Normal file
@ -0,0 +1,118 @@
|
||||
<!-- 侧边栏 Item -->
|
||||
<template>
|
||||
<div v-if="!item.meta || !item.meta.hidden" :class="{'simple-mode': isCollapse, 'first-level': 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.icon" :name="theOnlyOneChild.meta.icon" />
|
||||
<template v-if="theOnlyOneChild.meta.title" #title>
|
||||
{{ theOnlyOneChild.meta.title }}
|
||||
</template>
|
||||
</el-menu-item>
|
||||
</SidebarItemLink>
|
||||
</template>
|
||||
<el-sub-menu v-else :index="resolvePath(item.path)" popper-append-to-body>
|
||||
<template #title>
|
||||
<svg-icon v-if="item.meta && item.meta.icon" :name="item.meta.icon" />
|
||||
<span v-if="item.meta && item.meta.title">{{ item.meta.title }}</span>
|
||||
</template>
|
||||
<template v-if="item.children">
|
||||
<sidebar-item
|
||||
v-for="child in item.children"
|
||||
:key="child.path"
|
||||
:item="child"
|
||||
:is-collapse="isCollapse"
|
||||
:is-first-level="false"
|
||||
:base-path="resolvePath(child.path)"
|
||||
/>
|
||||
</template>
|
||||
</el-sub-menu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import path from 'path'
|
||||
import { computed, PropType } from 'vue'
|
||||
import { RouteRecordRaw } from 'vue-router'
|
||||
import { isExternal } from '@/utils/validate'
|
||||
import SidebarItemLink from './sidebar-item-link.vue'
|
||||
|
||||
const props = defineProps({
|
||||
item: {
|
||||
type: Object as PropType<RouteRecordRaw>,
|
||||
required: true
|
||||
},
|
||||
isCollapse: {
|
||||
type: Boolean,
|
||||
required: false
|
||||
},
|
||||
isFirstLevel: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
basePath: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
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>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.svg-icon {
|
||||
margin-right: 20px;
|
||||
min-width: 1em;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.simple-mode {
|
||||
&.first-level {
|
||||
::v-deep(.el-sub-menu) {
|
||||
.el-sub-menu__icon-arrow {
|
||||
display: none;
|
||||
}
|
||||
span {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
27
src/layout/components/Sidebar/SidebarItemLink.vue
Normal file
@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<a v-if="isExternal(to)" :href="to" target="_blank" rel="noopener">
|
||||
<slot />
|
||||
</a>
|
||||
<div v-else @click="push">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { isExternal } from '@/utils/validate'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const props = defineProps({
|
||||
to: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const push = () => {
|
||||
router.push(props.to).catch((err) => {
|
||||
console.warn(err)
|
||||
})
|
||||
}
|
||||
</script>
|
77
src/layout/components/Sidebar/SidebarLogo.vue
Normal file
@ -0,0 +1,77 @@
|
||||
<!-- 侧边栏 Logo(需要跟随侧边栏折叠) -->
|
||||
<template>
|
||||
<div class="sidebar-logo-container" :class="{collapse: collapse}">
|
||||
<transition name="sidebarLogoFade">
|
||||
<router-link v-if="collapse" key="collapse" class="sidebar-logo-link" to="/">
|
||||
<img src="@/assets/layout/logo.png" class="sidebar-logo">
|
||||
</router-link>
|
||||
<router-link v-else key="expand" class="sidebar-logo-link" to="/">
|
||||
<img src="@/assets/layout/logo-text-1.png" class="sidebar-logo-text">
|
||||
</router-link>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineProps({
|
||||
collapse: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sidebarLogoFade-enter-active,
|
||||
.sidebarLogoFade-leave-active {
|
||||
transition: opacity 1.5s;
|
||||
}
|
||||
|
||||
.sidebarLogoFade-enter-from,
|
||||
.sidebarLogoFade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.sidebar-logo-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 84px;
|
||||
line-height: 84px;
|
||||
background: #0c202b;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
.sidebar-logo {
|
||||
display: none;
|
||||
}
|
||||
& .sidebar-logo-link {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
& .sidebar-logo-text {
|
||||
height: 100%;
|
||||
vertical-align: middle;
|
||||
}
|
||||
& .sidebar-title {
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
line-height: 50px;
|
||||
font-size: 16px;
|
||||
font-family: Avenir, Helvetica Neue, Arial, Helvetica, sans-serif;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
&.collapse {
|
||||
.sidebar-logo {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
vertical-align: middle;
|
||||
margin-right: 0;
|
||||
display: inline-block;
|
||||
}
|
||||
.sidebar-logo-text {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
142
src/layout/components/Sidebar/index.vue
Normal file
@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<div :class="{'has-logo': showLogo}">
|
||||
<SidebarLogo v-if="showLogo" :collapse="isCollapse" />
|
||||
<el-scrollbar wrap-class="scrollbar-wrapper">
|
||||
<el-menu
|
||||
:collapse="isCollapse"
|
||||
:unique-opened="true"
|
||||
:default-active="activeMenu"
|
||||
background-color="#152d3d"
|
||||
text-color="#C0C4CC"
|
||||
active-text-color="#fff"
|
||||
mode="vertical"
|
||||
>
|
||||
<SidebarItem
|
||||
v-for="routeItem in routes"
|
||||
:key="routeItem.path"
|
||||
:item="routeItem"
|
||||
:base-path="routeItem.path"
|
||||
:is-collapse="isCollapse"
|
||||
/>
|
||||
</el-menu>
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useAppStore } from '@/store/modules/app'
|
||||
import { usePermissionStore } from '@/store/modules/permission'
|
||||
import { useSettingsStore } from '@/store/modules/settings'
|
||||
import SidebarItem from './sidebar-item.vue'
|
||||
import SidebarLogo from './sidebar-logo.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const sidebar = computed(() => {
|
||||
return useAppStore().sidebar
|
||||
})
|
||||
const routes = computed(() => {
|
||||
return usePermissionStore().routes
|
||||
})
|
||||
const showLogo = computed(() => {
|
||||
return useSettingsStore().showSidebarLogo
|
||||
})
|
||||
const activeMenu = computed(() => {
|
||||
const { meta, path } = route
|
||||
if (meta !== null || meta !== undefined) {
|
||||
if (meta.activeMenu) {
|
||||
return meta.activeMenu
|
||||
}
|
||||
}
|
||||
return path
|
||||
})
|
||||
const isCollapse = computed(() => {
|
||||
return !sidebar.value.opened
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.sidebar-container {
|
||||
// 重置当前页面的 element-plus css, ,注意,虽然没有加 scoped 标识,但是被该页面的 sidebar-container 类名包裹,所以不会影响其他页面
|
||||
.horizontal-collapse-transition {
|
||||
transition: 0s width ease-in-out, 0s padding-left ease-in-out, 0s padding-right ease-in-out;
|
||||
}
|
||||
.scrollbar-wrapper {
|
||||
overflow-x: hidden !important;
|
||||
}
|
||||
.el-scrollbar__view {
|
||||
height: 100%;
|
||||
}
|
||||
.el-scrollbar__bar {
|
||||
&.is-vertical {
|
||||
right: 0;
|
||||
}
|
||||
&.is-horizontal {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@mixin tip-line {
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 4px;
|
||||
height: 100%;
|
||||
background-color: #66b1ff;
|
||||
}
|
||||
}
|
||||
|
||||
.el-scrollbar {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.has-logo {
|
||||
.el-scrollbar {
|
||||
// 84px 是 logo 区域的 height
|
||||
height: calc(100% - 84px);
|
||||
}
|
||||
}
|
||||
|
||||
.el-menu {
|
||||
border: none;
|
||||
height: 100%;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
::v-deep(.el-menu-item),
|
||||
::v-deep(.el-sub-menu__title),
|
||||
::v-deep(.el-sub-menu .el-menu-item) {
|
||||
height: 60px;
|
||||
line-height: 60px;
|
||||
&:hover {
|
||||
background-color: #ffffff10;
|
||||
}
|
||||
display: block;
|
||||
* {
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
::v-deep(.el-menu-item) {
|
||||
&.is-active {
|
||||
@include tip-line;
|
||||
}
|
||||
}
|
||||
|
||||
.el-menu--collapse {
|
||||
::v-deep(.el-sub-menu) {
|
||||
&.is-active {
|
||||
.el-sub-menu__title {
|
||||
color: #fff !important;
|
||||
@include tip-line;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
15
src/layout/components/TagsView/ScrollPane.vue
Normal file
@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<el-scrollbar :vertical="false" class="scroll-container">
|
||||
<slot />
|
||||
</el-scrollbar>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.scroll-container {
|
||||
// 超出窗口长度时,显示滚动条
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
298
src/layout/components/TagsView/index.vue
Normal file
@ -0,0 +1,298 @@
|
||||
<template>
|
||||
<div class="tags-view-container">
|
||||
<ScrollPane class="tags-view-wrapper">
|
||||
<router-link
|
||||
v-for="tag in visitedViews"
|
||||
ref="tag"
|
||||
:key="tag.path"
|
||||
:class="state.isActive(tag) ? 'active' : ''"
|
||||
:to="{path: tag.path, query: tag.query, fullPath: tag.fullPath}"
|
||||
class="tags-view-item"
|
||||
@click.middle="!state.isAffix(tag) ? state.closeSelectedTag(tag) : ''"
|
||||
@contextmenu.prevent="state.openMenu(tag, $event)"
|
||||
>
|
||||
{{ tag.meta?.title }}
|
||||
<el-icon v-if="!state.isAffix(tag)" :size="12" @click.prevent.stop="state.closeSelectedTag(tag)">
|
||||
<Close />
|
||||
</el-icon>
|
||||
</router-link>
|
||||
</ScrollPane>
|
||||
<ul v-show="state.visible" :style="{left: state.left + 'px', top: state.top + 'px'}" class="contextmenu">
|
||||
<li @click="state.refreshSelectedTag(state.selectedTag)">
|
||||
刷新
|
||||
</li>
|
||||
<li v-if="!state.isAffix(state.selectedTag)" @click="state.closeSelectedTag(state.selectedTag)">
|
||||
关闭
|
||||
</li>
|
||||
<li @click="state.closeOthersTags">
|
||||
关闭其它
|
||||
</li>
|
||||
<li @click="state.closeAllTags(state.selectedTag)">
|
||||
关闭所有
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import path from 'path'
|
||||
import { useTagsViewStore, ITagView } from '@/store/modules/tags-view'
|
||||
import { usePermissionStore } from '@/store/modules/permission'
|
||||
import { computed, getCurrentInstance, nextTick, onBeforeMount, reactive, watch } from 'vue'
|
||||
import { RouteRecordRaw, useRoute, useRouter } from 'vue-router'
|
||||
import ScrollPane from './scroll-pane.vue'
|
||||
import { Close } from '@element-plus/icons-vue'
|
||||
|
||||
const tagsViewStore = useTagsViewStore()
|
||||
const permissionStore = usePermissionStore()
|
||||
const router = useRouter()
|
||||
const instance = getCurrentInstance()
|
||||
const currentRoute = useRoute()
|
||||
const { proxy } = instance as any
|
||||
|
||||
const toLastView = (visitedViews: ITagView[], view: ITagView) => {
|
||||
const latestView = visitedViews.slice(-1)[0]
|
||||
if (latestView !== undefined && latestView.fullPath !== undefined) {
|
||||
router.push(latestView.fullPath).catch((err) => {
|
||||
console.warn(err)
|
||||
})
|
||||
} else {
|
||||
// 如果没有 tags-view,请默认重定向到主页,如果你需要,可以自行调整它
|
||||
if (view.name === 'Dashboard') {
|
||||
// 重新加载主页
|
||||
router.push({ path: '/redirect' + view.fullPath }).catch((err) => {
|
||||
console.warn(err)
|
||||
})
|
||||
} else {
|
||||
router.push('/').catch((err) => {
|
||||
console.warn(err)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const state = reactive({
|
||||
visible: false,
|
||||
top: 0,
|
||||
left: 0,
|
||||
selectedTag: {} as ITagView,
|
||||
affixTags: [] as ITagView[],
|
||||
isActive: (route: ITagView) => {
|
||||
return route.path === currentRoute.path
|
||||
},
|
||||
isAffix: (tag: ITagView) => {
|
||||
return tag.meta && tag.meta.affix
|
||||
},
|
||||
refreshSelectedTag: (view: ITagView) => {
|
||||
const { fullPath } = view
|
||||
nextTick(() => {
|
||||
router.replace({ path: '/redirect' + fullPath }).catch((err) => {
|
||||
console.warn(err)
|
||||
})
|
||||
})
|
||||
},
|
||||
closeSelectedTag: (view: ITagView) => {
|
||||
tagsViewStore.delVisitedView(view)
|
||||
if (state.isActive(view)) {
|
||||
toLastView(tagsViewStore.visitedViews, view)
|
||||
}
|
||||
},
|
||||
closeOthersTags: () => {
|
||||
if (state.selectedTag.fullPath !== currentRoute.path && state.selectedTag.fullPath !== undefined) {
|
||||
router.push(state.selectedTag.fullPath).catch((err) => {
|
||||
console.warn(err)
|
||||
})
|
||||
}
|
||||
tagsViewStore.delOthersVisitedViews(state.selectedTag as ITagView)
|
||||
},
|
||||
closeAllTags: (view: ITagView) => {
|
||||
tagsViewStore.delAllVisitedViews()
|
||||
if (state.affixTags.some((tag) => tag.path === currentRoute.path)) {
|
||||
return
|
||||
}
|
||||
toLastView(tagsViewStore.visitedViews, view)
|
||||
},
|
||||
openMenu: (tag: ITagView, e: MouseEvent) => {
|
||||
const menuMinWidth = 105
|
||||
const offsetLeft = proxy.$el.getBoundingClientRect().left // container margin left
|
||||
const offsetWidth = proxy.$el.offsetWidth // container width
|
||||
const maxLeft = offsetWidth - menuMinWidth // left boundary
|
||||
const left = e.clientX - offsetLeft + 15 // 15: margin right
|
||||
if (left > maxLeft) {
|
||||
state.left = maxLeft
|
||||
} else {
|
||||
state.left = left
|
||||
}
|
||||
state.top = e.clientY
|
||||
state.visible = true
|
||||
state.selectedTag = tag
|
||||
},
|
||||
closeMenu: () => {
|
||||
state.visible = false
|
||||
}
|
||||
})
|
||||
|
||||
const visitedViews = computed(() => {
|
||||
return tagsViewStore.visitedViews
|
||||
})
|
||||
const routes = computed(() => permissionStore.routes)
|
||||
|
||||
const filterAffixTags = (routes: RouteRecordRaw[], basePath = '/') => {
|
||||
let tags: ITagView[] = []
|
||||
|
||||
routes.forEach((route) => {
|
||||
if (route.meta && 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 = () => {
|
||||
state.affixTags = filterAffixTags(routes.value)
|
||||
for (const tag of state.affixTags) {
|
||||
// 必须含有 name 属性
|
||||
if (tag.name) {
|
||||
tagsViewStore.addVisitedView(tag as ITagView)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const addTags = () => {
|
||||
if (currentRoute.name) {
|
||||
tagsViewStore.addVisitedView(currentRoute)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const moveToCurrentTag = () => {
|
||||
const tags = instance?.refs.tag as any[]
|
||||
if (tags === null || tags === undefined || !Array.isArray(tags)) {
|
||||
return
|
||||
}
|
||||
for (const tag of tags) {
|
||||
if ((tag.to as ITagView).path === currentRoute.path) {
|
||||
// When query is different then update
|
||||
if ((tag.to as ITagView).fullPath !== currentRoute.fullPath) {
|
||||
tagsViewStore.updateVisitedView(currentRoute)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => currentRoute.name,
|
||||
() => {
|
||||
addTags()
|
||||
moveToCurrentTag()
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => state.visible,
|
||||
(value) => {
|
||||
if (value) {
|
||||
document.body.addEventListener('click', state.closeMenu)
|
||||
} else {
|
||||
document.body.removeEventListener('click', state.closeMenu)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// life cricle
|
||||
onBeforeMount(() => {
|
||||
initTags()
|
||||
addTags()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.tags-view-container {
|
||||
height: 34px;
|
||||
width: 100%;
|
||||
background: #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 #d8dce5;
|
||||
color: #495060;
|
||||
background: #fff;
|
||||
padding: 0 8px;
|
||||
font-size: 12px;
|
||||
margin-left: 5px;
|
||||
margin-top: 4px;
|
||||
&:first-of-type {
|
||||
margin-left: 15px;
|
||||
}
|
||||
&:last-of-type {
|
||||
margin-right: 15px;
|
||||
}
|
||||
&.active {
|
||||
background-color: #409eff;
|
||||
color: #fff;
|
||||
border-color: #409eff;
|
||||
&::before {
|
||||
content: '';
|
||||
background: #fff;
|
||||
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: #00000030;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.contextmenu {
|
||||
margin: 0;
|
||||
background: #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: #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 './navigation-bar/index.vue'
|
||||
export { default as Settings } from './settings/index.vue'
|
||||
export { default as Sidebar } from './sidebar/index.vue'
|
||||
export { default as TagsView } from './tags-view/index.vue'
|
||||
export { default as RightPanel } from './right-panel/index.vue'
|
159
src/layout/index.vue
Normal file
@ -0,0 +1,159 @@
|
||||
<!-- 布局入口 -->
|
||||
<template>
|
||||
<div :class="classObj" class="app-wrapper">
|
||||
<div v-if="classObj.mobile && sidebar.opened" class="drawer-bg" @click="state.handleClickOutside" />
|
||||
<Sidebar class="sidebar-container" />
|
||||
<div :class="{hasTagsView: showTagsView}" class="main-container">
|
||||
<div :class="{'fixed-header': fixedHeader}">
|
||||
<NavigationBar />
|
||||
<TagsView v-if="showTagsView" />
|
||||
</div>
|
||||
<AppMain />
|
||||
<RightPanel v-if="showSettings">
|
||||
<Settings />
|
||||
</RightPanel>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useAppStore, DeviceType } from '@/store/modules/app'
|
||||
import { useSettingsStore } from '@/store/modules/settings'
|
||||
import { computed, onBeforeMount, onBeforeUnmount, onMounted, reactive } from 'vue'
|
||||
import { AppMain, NavigationBar, Settings, Sidebar, TagsView, RightPanel } from './components'
|
||||
import useResize from './useResize'
|
||||
|
||||
const { sidebar, device, addEventListenerOnResize, resizeMounted, removeEventListenerResize, watchRouter } = useResize()
|
||||
const appStore = useAppStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
|
||||
const state = reactive({
|
||||
handleClickOutside: () => {
|
||||
appStore.closeSidebar(false)
|
||||
}
|
||||
})
|
||||
|
||||
const classObj = computed(() => {
|
||||
return {
|
||||
hideSidebar: !sidebar.value.opened,
|
||||
openSidebar: sidebar.value.opened,
|
||||
withoutAnimation: sidebar.value.withoutAnimation,
|
||||
mobile: device.value === DeviceType.Mobile
|
||||
}
|
||||
})
|
||||
const showSettings = computed(() => {
|
||||
return settingsStore.showSettings
|
||||
})
|
||||
const showTagsView = computed(() => {
|
||||
return settingsStore.showTagsView
|
||||
})
|
||||
const fixedHeader = computed(() => {
|
||||
return settingsStore.fixedHeader
|
||||
})
|
||||
|
||||
watchRouter()
|
||||
onBeforeMount(() => {
|
||||
addEventListenerOnResize()
|
||||
})
|
||||
onMounted(() => {
|
||||
resizeMounted()
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
removeEventListenerResize()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/styles/mixins.scss';
|
||||
$sideBarWidth: 220px;
|
||||
|
||||
.app-wrapper {
|
||||
@include clearfix;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.drawer-bg {
|
||||
background: #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: $sideBarWidth;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sidebar-container {
|
||||
transition: width 0.28s;
|
||||
width: $sideBarWidth !important;
|
||||
height: 100%;
|
||||
position: fixed;
|
||||
font-size: 0;
|
||||
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% - #{$sideBarWidth});
|
||||
transition: width 0.28s;
|
||||
}
|
||||
|
||||
.hideSidebar {
|
||||
.main-container {
|
||||
margin-left: 54px;
|
||||
}
|
||||
.sidebar-container {
|
||||
width: 54px !important;
|
||||
}
|
||||
.fixed-header {
|
||||
width: calc(100% - 54px);
|
||||
}
|
||||
}
|
||||
|
||||
// for mobile response 适配移动端
|
||||
.mobile {
|
||||
.main-container {
|
||||
margin-left: 0;
|
||||
}
|
||||
.sidebar-container {
|
||||
transition: transform 0.28s;
|
||||
width: $sideBarWidth !important;
|
||||
}
|
||||
&.openSidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
}
|
||||
&.hideSidebar {
|
||||
.sidebar-container {
|
||||
pointer-events: none;
|
||||
transition-duration: 0.3s;
|
||||
transform: translate3d(-$sideBarWidth, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.fixed-header {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.withoutAnimation {
|
||||
.main-container,
|
||||
.sidebar-container {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
68
src/layout/useResize.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import { useAppStore, DeviceType } from '@/store/modules/app'
|
||||
import { computed, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
/** 参考 Bootstrap 的响应式设计 width = 992 */
|
||||
const WIDTH = 992
|
||||
|
||||
/** 根据大小变化重新布局 */
|
||||
export default function() {
|
||||
const appStore = useAppStore()
|
||||
|
||||
const device = computed(() => {
|
||||
return appStore.device
|
||||
})
|
||||
|
||||
const sidebar = computed(() => {
|
||||
return appStore.sidebar
|
||||
})
|
||||
|
||||
const currentRoute = useRoute()
|
||||
|
||||
const watchRouter = watch(
|
||||
() => currentRoute.name,
|
||||
() => {
|
||||
if (appStore.device === DeviceType.Mobile && appStore.sidebar.opened) {
|
||||
appStore.closeSidebar(false)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const isMobile = () => {
|
||||
const rect = document.body.getBoundingClientRect()
|
||||
return rect.width - 1 < WIDTH
|
||||
}
|
||||
|
||||
const resizeMounted = () => {
|
||||
if (isMobile()) {
|
||||
appStore.toggleDevice(DeviceType.Mobile)
|
||||
appStore.closeSidebar(true)
|
||||
}
|
||||
}
|
||||
|
||||
const resizeHandler = () => {
|
||||
if (!document.hidden) {
|
||||
appStore.toggleDevice(isMobile() ? DeviceType.Mobile : DeviceType.Desktop)
|
||||
if (isMobile()) {
|
||||
appStore.closeSidebar(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const addEventListenerOnResize = () => {
|
||||
window.addEventListener('resize', resizeHandler)
|
||||
}
|
||||
|
||||
const removeEventListenerResize = () => {
|
||||
window.removeEventListener('resize', resizeHandler)
|
||||
}
|
||||
|
||||
return {
|
||||
device,
|
||||
sidebar,
|
||||
resizeMounted,
|
||||
addEventListenerOnResize,
|
||||
removeEventListenerResize,
|
||||
watchRouter
|
||||
}
|
||||
}
|
19
src/main.ts
@ -1,4 +1,19 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createApp, Directive } from 'vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import store from './store'
|
||||
import '@/styles/index.scss'
|
||||
import 'normalize.css'
|
||||
import * as directives from '@/directives'
|
||||
import '@/router/permission'
|
||||
import loadSvg from '@/icons'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
const app = createApp(App)
|
||||
// 加载全局 SVG
|
||||
loadSvg(app)
|
||||
// 自定义指令
|
||||
Object.keys(directives).forEach((key) => {
|
||||
app.directive(key, (directives as { [key: string]: Directive })[key])
|
||||
})
|
||||
|
||||
app.use(store).use(router).mount('#app')
|
||||
|
134
src/router/index.ts
Normal file
@ -0,0 +1,134 @@
|
||||
import { createRouter, createWebHashHistory, RouteRecordRaw } from 'vue-router'
|
||||
const Layout = () => import('@/layout/index.vue')
|
||||
|
||||
/** 常驻路由 */
|
||||
export const constantRoutes: Array<RouteRecordRaw> = [
|
||||
{
|
||||
path: '/redirect',
|
||||
component: Layout,
|
||||
meta: {
|
||||
hidden: true
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: '/redirect/:path(.*)',
|
||||
component: () => import('@/views/redirect/index.vue')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
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: '首页',
|
||||
icon: 'dashboard',
|
||||
affix: true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
/**
|
||||
* 动态路由
|
||||
* 用来放置有权限的路由
|
||||
* 必须带有 name 属性
|
||||
*/
|
||||
export const asyncRoutes: Array<RouteRecordRaw> = [
|
||||
{
|
||||
path: '/permission',
|
||||
component: Layout,
|
||||
redirect: '/permission/page',
|
||||
name: 'Permission',
|
||||
meta: {
|
||||
title: '权限管理',
|
||||
icon: '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(.*)*', // 必须将 'ErrorPage' 路由放在最后, Must put the 'ErrorPage' route at the end
|
||||
component: Layout,
|
||||
redirect: '/404',
|
||||
name: 'ErrorPage',
|
||||
meta: {
|
||||
title: '错误页面',
|
||||
icon: '404',
|
||||
hidden: true
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: '401',
|
||||
component: () => import('@/views/error-page/401.vue'),
|
||||
name: '401',
|
||||
meta: {
|
||||
title: '401'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '404',
|
||||
component: () => import('@/views/error-page/404.vue'),
|
||||
name: '404',
|
||||
meta: {
|
||||
title: '404'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
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
|
73
src/router/permission.ts
Normal file
@ -0,0 +1,73 @@
|
||||
import NProgress from 'nprogress'
|
||||
import 'nprogress/nprogress.css'
|
||||
import router from '@/router'
|
||||
import { RouteLocationNormalized } from 'vue-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 rolesSettings from '@/config/roles'
|
||||
import { getToken } from '@/utils/cookies'
|
||||
|
||||
const userStore = useUserStoreHook()
|
||||
const permissionStore = usePermissionStoreHook()
|
||||
NProgress.configure({ showSpinner: false })
|
||||
|
||||
router.beforeEach(async(to: RouteLocationNormalized, _: RouteLocationNormalized, next: any) => {
|
||||
NProgress.start()
|
||||
// 判断该用户是否登录
|
||||
if (getToken()) {
|
||||
if (to.path === '/login') {
|
||||
// 如果登录,并准备进入 login 页面,则重定向到主页
|
||||
next({ path: '/' })
|
||||
NProgress.done()
|
||||
} else {
|
||||
// 检查用户是否已获得其权限角色
|
||||
if (userStore.roles.length === 0) {
|
||||
try {
|
||||
if (rolesSettings.openRoles) {
|
||||
// 注意:角色必须是一个数组! 例如: ['admin'] 或 ['developer', 'editor']
|
||||
await userStore.getInfo()
|
||||
// 获取接口返回的 roles
|
||||
const roles = userStore.roles
|
||||
// 根据角色生成可访问的 routes
|
||||
permissionStore.setRoutes(roles)
|
||||
} else {
|
||||
// 没有开启角色功能,则启用默认角色
|
||||
userStore.setRoles(rolesSettings.defaultRoles)
|
||||
permissionStore.setRoutes(rolesSettings.defaultRoles)
|
||||
}
|
||||
// 动态地添加可访问的 routes
|
||||
permissionStore.dynamicRoutes.forEach((route) => {
|
||||
router.addRoute(route)
|
||||
})
|
||||
// 确保添加路由已完成
|
||||
// 设置 replace: true, 因此导航将不会留下历史记录
|
||||
next({ ...to, replace: true })
|
||||
} catch (err: any) {
|
||||
// 删除 token,并重定向到登录页面
|
||||
userStore.resetToken()
|
||||
ElMessage.error(err.message || 'Has Error')
|
||||
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
|
68
src/store/modules/app.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { getSidebarStatus, getActiveThemeName, setSidebarStatus, setActiveThemeName } from '@/utils/cookies'
|
||||
import themeList from '@/config/theme'
|
||||
|
||||
export enum DeviceType {
|
||||
Mobile,
|
||||
Desktop
|
||||
}
|
||||
|
||||
interface IAppState {
|
||||
device: DeviceType
|
||||
sidebar: {
|
||||
opened: boolean
|
||||
withoutAnimation: boolean
|
||||
}
|
||||
/** 主题列表 */
|
||||
themeList: { title: string, name: string }[]
|
||||
/** 正在应用的主题的名字 */
|
||||
activeThemeName: string
|
||||
}
|
||||
|
||||
export const useAppStore = defineStore({
|
||||
id: 'app',
|
||||
state: (): IAppState => {
|
||||
return {
|
||||
device: DeviceType.Desktop,
|
||||
sidebar: {
|
||||
opened: getSidebarStatus() !== 'closed',
|
||||
withoutAnimation: false
|
||||
},
|
||||
themeList: themeList,
|
||||
activeThemeName: getActiveThemeName() || 'normal'
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
toggleSidebar(withoutAnimation: boolean) {
|
||||
this.sidebar.opened = !this.sidebar.opened
|
||||
this.sidebar.withoutAnimation = withoutAnimation
|
||||
if (this.sidebar.opened) {
|
||||
setSidebarStatus('opened')
|
||||
} else {
|
||||
setSidebarStatus('closed')
|
||||
}
|
||||
},
|
||||
closeSidebar(withoutAnimation: boolean) {
|
||||
this.sidebar.opened = false
|
||||
this.sidebar.withoutAnimation = withoutAnimation
|
||||
setSidebarStatus('closed')
|
||||
},
|
||||
toggleDevice(device: DeviceType) {
|
||||
this.device = device
|
||||
},
|
||||
setTheme(activeThemeName: string) {
|
||||
// 检查这个主题在主题列表里是否存在
|
||||
this.activeThemeName = this.themeList.find((theme) => theme.name === activeThemeName)
|
||||
? activeThemeName
|
||||
: this.themeList[0].name
|
||||
// 应用到 dom
|
||||
document.body.className = `theme-${this.activeThemeName}`
|
||||
// 持久化
|
||||
setActiveThemeName(this.activeThemeName)
|
||||
},
|
||||
initTheme() {
|
||||
// 初始化
|
||||
document.body.className = `theme-${this.activeThemeName}`
|
||||
}
|
||||
}
|
||||
})
|
64
src/store/modules/permission.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import store from '@/store'
|
||||
import { defineStore } from 'pinia'
|
||||
import { RouteRecordRaw } from 'vue-router'
|
||||
import { constantRoutes, asyncRoutes } from '@/router'
|
||||
|
||||
interface IPermissionState {
|
||||
routes: RouteRecordRaw[]
|
||||
dynamicRoutes: RouteRecordRaw[]
|
||||
}
|
||||
|
||||
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({
|
||||
id: 'permission',
|
||||
state: (): IPermissionState => {
|
||||
return {
|
||||
routes: [],
|
||||
dynamicRoutes: []
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
setRoutes(roles: string[]) {
|
||||
let accessedRoutes
|
||||
if (roles.includes('admin')) {
|
||||
accessedRoutes = asyncRoutes
|
||||
} else {
|
||||
accessedRoutes = filterAsyncRoutes(asyncRoutes, roles)
|
||||
}
|
||||
this.routes = constantRoutes.concat(accessedRoutes)
|
||||
this.dynamicRoutes = accessedRoutes
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/** 在 setup 外使用 */
|
||||
export function usePermissionStoreHook() {
|
||||
return usePermissionStore(store)
|
||||
}
|
52
src/store/modules/settings.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import layoutSettings from '@/config/layout'
|
||||
|
||||
interface ISettingsState {
|
||||
fixedHeader: boolean
|
||||
showSettings: boolean
|
||||
showTagsView: boolean
|
||||
showSidebarLogo: boolean
|
||||
showThemeSwitch: boolean
|
||||
showScreenfull: boolean
|
||||
}
|
||||
|
||||
export const useSettingsStore = defineStore({
|
||||
id: 'settings',
|
||||
state: (): ISettingsState => {
|
||||
return {
|
||||
fixedHeader: layoutSettings.fixedHeader,
|
||||
showSettings: layoutSettings.showSettings,
|
||||
showTagsView: layoutSettings.showTagsView,
|
||||
showSidebarLogo: layoutSettings.showSidebarLogo,
|
||||
showThemeSwitch: layoutSettings.showThemeSwitch,
|
||||
showScreenfull: layoutSettings.showScreenfull
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
changeSetting(payload: { key: string, value: any }) {
|
||||
const { key, value } = payload
|
||||
switch (key) {
|
||||
case 'fixedHeader':
|
||||
this.fixedHeader = value
|
||||
break
|
||||
case 'showSettings':
|
||||
this.showSettings = value
|
||||
break
|
||||
case 'showSidebarLogo':
|
||||
this.showSidebarLogo = value
|
||||
break
|
||||
case 'showTagsView':
|
||||
this.showTagsView = value
|
||||
break
|
||||
case 'showThemeSwitch':
|
||||
this.showThemeSwitch = value
|
||||
break
|
||||
case 'showScreenfull':
|
||||
this.showScreenfull = value
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
56
src/store/modules/tags-view.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { _RouteLocationBase, RouteLocationNormalized } from 'vue-router'
|
||||
|
||||
export interface ITagView extends Partial<RouteLocationNormalized> {
|
||||
title?: string
|
||||
to?: _RouteLocationBase
|
||||
}
|
||||
|
||||
interface ITagsViewState {
|
||||
visitedViews: ITagView[]
|
||||
}
|
||||
|
||||
export const useTagsViewStore = defineStore({
|
||||
id: 'tags-view',
|
||||
state: (): ITagsViewState => {
|
||||
return {
|
||||
visitedViews: []
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
addVisitedView(view: ITagView) {
|
||||
if (this.visitedViews.some((v) => v.path === view.path)) return
|
||||
this.visitedViews.push(
|
||||
Object.assign({}, view, {
|
||||
title: view.meta?.title || 'no-name'
|
||||
})
|
||||
)
|
||||
},
|
||||
delVisitedView(view: ITagView) {
|
||||
for (const [i, v] of this.visitedViews.entries()) {
|
||||
if (v.path === view.path) {
|
||||
this.visitedViews.splice(i, 1)
|
||||
break
|
||||
}
|
||||
}
|
||||
},
|
||||
delOthersVisitedViews(view: ITagView) {
|
||||
this.visitedViews = this.visitedViews.filter((v) => {
|
||||
return v.meta?.affix || v.path === view.path
|
||||
})
|
||||
},
|
||||
delAllVisitedViews() {
|
||||
// keep affix tags
|
||||
const affixTags = this.visitedViews.filter((tag) => tag.meta?.affix)
|
||||
this.visitedViews = affixTags
|
||||
},
|
||||
updateVisitedView(view: ITagView) {
|
||||
for (let v of this.visitedViews) {
|
||||
if (v.path === view.path) {
|
||||
v = Object.assign(v, view)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
89
src/store/modules/user.ts
Normal file
@ -0,0 +1,89 @@
|
||||
import store from '@/store'
|
||||
import { defineStore } from 'pinia'
|
||||
import { usePermissionStore } from './permission'
|
||||
import { getToken, removeToken, setToken } from '@/utils/cookies'
|
||||
import router, { resetRouter } from '@/router'
|
||||
import { accountLogin, userInfoRequest } from '@/api/login'
|
||||
import { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
interface IUserState {
|
||||
token: string
|
||||
roles: string[]
|
||||
}
|
||||
|
||||
export const useUserStore = defineStore({
|
||||
id: 'user',
|
||||
state: (): IUserState => {
|
||||
return {
|
||||
token: getToken() || '',
|
||||
roles: []
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
/** 设置角色数组 */
|
||||
setRoles(roles: string[]) {
|
||||
this.roles = roles
|
||||
},
|
||||
/** 登录 */
|
||||
login(userInfo: { username: string, password: string }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
accountLogin({
|
||||
username: userInfo.username.trim(),
|
||||
password: userInfo.password
|
||||
})
|
||||
.then((res: any) => {
|
||||
setToken(res.data.accessToken)
|
||||
this.token = res.data.accessToken
|
||||
resolve(true)
|
||||
})
|
||||
.catch((error) => {
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
},
|
||||
/** 获取用户详情 */
|
||||
getInfo() {
|
||||
return new Promise((resolve, reject) => {
|
||||
userInfoRequest()
|
||||
.then((res: any) => {
|
||||
this.roles = res.data.user.roles
|
||||
resolve(res)
|
||||
})
|
||||
.catch((error) => {
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
},
|
||||
/** 切换角色 */
|
||||
async changeRoles(role: string) {
|
||||
const token = role + '-token'
|
||||
this.token = token
|
||||
setToken(token)
|
||||
await this.getInfo()
|
||||
const permissionStore = usePermissionStore()
|
||||
permissionStore.setRoutes(this.roles)
|
||||
resetRouter()
|
||||
permissionStore.dynamicRoutes.forEach((item: RouteRecordRaw) => {
|
||||
router.addRoute(item)
|
||||
})
|
||||
},
|
||||
/** 登出 */
|
||||
logout() {
|
||||
removeToken()
|
||||
this.token = ''
|
||||
this.roles = []
|
||||
resetRouter()
|
||||
},
|
||||
/** 重置 token */
|
||||
resetToken() {
|
||||
removeToken()
|
||||
this.token = ''
|
||||
this.roles = []
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/** 在 setup 外使用 */
|
||||
export function useUserStoreHook() {
|
||||
return useUserStore(store)
|
||||
}
|
42
src/styles/index.scss
Normal file
@ -0,0 +1,42 @@
|
||||
@import './mixins.scss'; // mixins
|
||||
@import './transition.scss'; // transition
|
||||
@import './theme/register.scss'; // 注册主题
|
||||
|
||||
.app-container {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
html {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
height: 100%;
|
||||
background-color: #f0f2f5; // 全局背景色
|
||||
-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;
|
||||
}
|
||||
}
|
2
src/styles/theme/dark/index.scss
Normal file
@ -0,0 +1,2 @@
|
||||
@import './setting.scss';
|
||||
@import '../theme.scss';
|
14
src/styles/theme/dark/setting.scss
Normal file
@ -0,0 +1,14 @@
|
||||
// 主题名称
|
||||
$theme-name: 'dark';
|
||||
// 主题背景颜色
|
||||
$theme-bg-color: #151515;
|
||||
// active 状态下主题背景颜色
|
||||
$active-theme-bg-color: #409eff;
|
||||
// 默认文字颜色
|
||||
$font-color: #c0c4cc;
|
||||
// active 状态下文字颜色
|
||||
$active-font-color: #fff;
|
||||
// hover 状态下文字颜色
|
||||
$hover-color: #fff;
|
||||
// 边框颜色
|
||||
$border-color: #303133;
|
2
src/styles/theme/register.scss
Normal file
@ -0,0 +1,2 @@
|
||||
// 注册的主题
|
||||
@import '@/styles/theme/dark/index.scss';
|
214
src/styles/theme/theme.scss
Normal file
@ -0,0 +1,214 @@
|
||||
.theme-#{$theme-name} {
|
||||
/** Layout */
|
||||
|
||||
.app-wrapper {
|
||||
background-color: $theme-bg-color;
|
||||
color: $font-color;
|
||||
// 侧边栏
|
||||
.sidebar-container {
|
||||
.sidebar-logo-container {
|
||||
background-color: lighten($theme-bg-color, 2%) !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;
|
||||
}
|
||||
}
|
||||
|
||||
// 顶部导航栏
|
||||
.navbar {
|
||||
background-color: $theme-bg-color;
|
||||
.el-breadcrumb__inner {
|
||||
a {
|
||||
color: $font-color;
|
||||
&:hover {
|
||||
color: $hover-color;
|
||||
}
|
||||
}
|
||||
.no-redirect {
|
||||
color: $font-color;
|
||||
}
|
||||
}
|
||||
.right-menu {
|
||||
.el-icon {
|
||||
color: $font-color;
|
||||
}
|
||||
.el-avatar {
|
||||
background: lighten($theme-bg-color, 20%);
|
||||
.el-icon {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tags-view
|
||||
.tags-view-container {
|
||||
background-color: $theme-bg-color !important;
|
||||
border-bottom: 1px solid lighten($theme-bg-color, 10%) !important;
|
||||
.tags-view-item {
|
||||
background-color: $theme-bg-color !important;
|
||||
color: $font-color !important;
|
||||
border: 1px solid $border-color !important;
|
||||
&.active {
|
||||
background-color: $active-theme-bg-color !important;
|
||||
color: $active-font-color !important;
|
||||
border-color: $border-color !important;
|
||||
}
|
||||
}
|
||||
.contextmenu {
|
||||
// 右键菜单
|
||||
background-color: lighten($theme-bg-color, 8%);
|
||||
color: $font-color;
|
||||
li:hover {
|
||||
background-color: lighten($theme-bg-color, 16%);
|
||||
color: $active-font-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 右侧设置面板
|
||||
.handle-button {
|
||||
background-color: lighten($theme-bg-color, 20%) !important;
|
||||
}
|
||||
.el-drawer.rtl {
|
||||
background-color: $theme-bg-color;
|
||||
.drawer-title,
|
||||
.drawer-item {
|
||||
color: $font-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** app-main 主要写 view 页面的黑暗样式 */
|
||||
|
||||
.app-main {
|
||||
// 指令权限页面 /permission/directive
|
||||
.permission-alert {
|
||||
background-color: lighten($theme-bg-color, 8%);
|
||||
}
|
||||
// 监控页面 /monitor
|
||||
.monitor {
|
||||
background-color: $theme-bg-color;
|
||||
}
|
||||
}
|
||||
|
||||
/** login 页面 */
|
||||
|
||||
.login-container {
|
||||
background-color: $theme-bg-color;
|
||||
color: $font-color;
|
||||
.login-card {
|
||||
background-color: lighten($theme-bg-color, 4%) !important;
|
||||
}
|
||||
.el-icon {
|
||||
color: $font-color;
|
||||
}
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 下拉菜单
|
||||
.el-dropdown__popper .el-dropdown__list {
|
||||
background-color: lighten($theme-bg-color, 8%);
|
||||
.el-dropdown-menu {
|
||||
background-color: lighten($theme-bg-color, 8%);
|
||||
.el-dropdown-menu__item {
|
||||
color: $font-color;
|
||||
&.is-disabled {
|
||||
color: #606266;
|
||||
}
|
||||
&:not(.is-disabled):hover {
|
||||
background-color: lighten($theme-bg-color, 16%);
|
||||
color: $active-font-color;
|
||||
}
|
||||
}
|
||||
.el-dropdown-menu__item--divided:before {
|
||||
background-color: lighten($theme-bg-color, 8%);
|
||||
}
|
||||
}
|
||||
}
|
||||
.el-popper__arrow::before {
|
||||
// 下拉菜单顶部三角区域
|
||||
background-color: lighten($theme-bg-color, 8%) !important;
|
||||
border: lighten($theme-bg-color, 8%) !important;
|
||||
}
|
||||
|
||||
// 单选框按钮样式
|
||||
.el-radio-button__inner {
|
||||
background-color: lighten($theme-bg-color, 8%);
|
||||
color: $active-font-color;
|
||||
border: 1px solid $border-color;
|
||||
}
|
||||
.el-radio-button:first-child .el-radio-button__inner {
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
// el-tag
|
||||
.el-tag {
|
||||
background-color: lighten($theme-bg-color, 8%);
|
||||
border-color: $border-color;
|
||||
color: $active-font-color;
|
||||
&.el-tag--info {
|
||||
background-color: lighten($theme-bg-color, 8%);
|
||||
border-color: $border-color;
|
||||
color: $active-font-color;
|
||||
}
|
||||
}
|
||||
|
||||
// tabs 标签页
|
||||
.el-tabs--border-card {
|
||||
background: lighten($theme-bg-color, 8%);
|
||||
border: 1px solid $border-color;
|
||||
.el-tabs__header {
|
||||
background-color: lighten($theme-bg-color, 8%);
|
||||
border-bottom: 1px solid $border-color;
|
||||
.el-tabs__item.is-active {
|
||||
background-color: lighten($theme-bg-color, 8%);
|
||||
border-right-color: $border-color;
|
||||
border-left-color: $border-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 卡片 card
|
||||
.el-card {
|
||||
background: lighten($theme-bg-color, 8%);
|
||||
border: 1px solid $border-color;
|
||||
color: $font-color;
|
||||
}
|
||||
|
||||
// 输入框 input
|
||||
.el-input__wrapper {
|
||||
background: lighten($theme-bg-color, 8%) !important;
|
||||
}
|
||||
}
|
48
src/styles/transition.scss
Normal file
@ -0,0 +1,48 @@
|
||||
// See https://vuejs.org/v2/guide/transitions.html for detail
|
||||
|
||||
// fade
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.28s;
|
||||
}
|
||||
|
||||
.fade-enter,
|
||||
.fade-leave-active {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
// fade-transform
|
||||
.fade-transform-leave-active,
|
||||
.fade-transform-enter-active {
|
||||
transition: all 0.5s;
|
||||
}
|
||||
|
||||
.fade-transform-enter {
|
||||
opacity: 0;
|
||||
transform: translateX(-30px);
|
||||
}
|
||||
|
||||
.fade-transform-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(30px);
|
||||
}
|
||||
|
||||
// breadcrumb
|
||||
.breadcrumb-enter-active,
|
||||
.breadcrumb-leave-active {
|
||||
transition: all 0.5s;
|
||||
}
|
||||
|
||||
.breadcrumb-enter,
|
||||
.breadcrumb-leave-active {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
.breadcrumb-move {
|
||||
transition: all 0.5s;
|
||||
}
|
||||
|
||||
.breadcrumb-leave-active {
|
||||
position: absolute;
|
||||
}
|
11
src/types/components.d.ts
vendored
@ -6,10 +6,19 @@ import '@vue/runtime-core'
|
||||
declare module '@vue/runtime-core' {
|
||||
export interface GlobalComponents {
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
HelloWorld: typeof import('./../components/HelloWorld.vue')['default']
|
||||
ElDropdown: typeof import('element-plus/es')['ElDropdown']
|
||||
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
|
||||
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
|
||||
ElForm: typeof import('element-plus/es')['ElForm']
|
||||
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||
ElIcon: typeof import('element-plus/es')['ElIcon']
|
||||
ElInput: typeof import('element-plus/es')['ElInput']
|
||||
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
Screenfull: typeof import('./../components/Screenfull/index.vue')['default']
|
||||
SvgIcon: typeof import('./../components/SvgIcon/index.vue')['default']
|
||||
ThemeSwitch: typeof import('./../components/ThemeSwitch/index.vue')['default']
|
||||
}
|
||||
}
|
||||
|
||||
|
8
src/types/env.d.ts
vendored
@ -1,13 +1,5 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
/** 声明自动引入的 vue 组件 */
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
|
||||
/** 声明 vite 环境变量的类型(如果未声明则默认是 any) */
|
||||
declare interface ImportMetaEnv {
|
||||
readonly VITE_BASE_API: string
|
||||
|
10
src/types/index.d.ts
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
/** 项目类型声明 */
|
||||
declare module '*.svg'
|
||||
declare module '*.png'
|
||||
declare module '*.jpg'
|
||||
declare module '*.jpeg'
|
||||
declare module '*.gif'
|
||||
declare module '*.bmp'
|
||||
declare module '*.tiff'
|
||||
declare module '*.yaml'
|
||||
declare module '*.json'
|
15
src/types/shims-vue.d.ts
vendored
Normal file
@ -0,0 +1,15 @@
|
||||
/** 声明自动引入的 vue 组件 */
|
||||
declare module '*.vue' {
|
||||
import { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
|
||||
declare module '*.gif' {
|
||||
export const gif: any
|
||||
}
|
||||
|
||||
declare module '*.svg' {
|
||||
const content: any
|
||||
export default content
|
||||
}
|
14
src/types/vue-proptery.d.ts
vendored
Normal file
@ -0,0 +1,14 @@
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
declare module '@vue/runtime-core' {
|
||||
interface ComponentCustomProperties {
|
||||
$message: ElMessage
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'vue-router' {
|
||||
interface RouteMeta {
|
||||
roles?: string[]
|
||||
activeMenu?: string
|
||||
}
|
||||
}
|
16
src/utils/cookies.ts
Normal file
@ -0,0 +1,16 @@
|
||||
/** cookies 封装 */
|
||||
|
||||
import Keys from '@/constant/key'
|
||||
import Cookies from 'js-cookie'
|
||||
|
||||
export const getSidebarStatus = () => Cookies.get(Keys.sidebarStatus)
|
||||
export const setSidebarStatus = (sidebarStatus: string) => Cookies.set(Keys.sidebarStatus, sidebarStatus)
|
||||
|
||||
export const getToken = () => Cookies.get(Keys.token)
|
||||
export const setToken = (token: string) => Cookies.set(Keys.token, token)
|
||||
export const removeToken = () => Cookies.remove(Keys.token)
|
||||
|
||||
export const getActiveThemeName = () => Cookies.get(Keys.activeThemeName)
|
||||
export const setActiveThemeName = (themeName: string) => {
|
||||
Cookies.set(Keys.activeThemeName, themeName)
|
||||
}
|
10
src/utils/index.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
/** 格式化时间 */
|
||||
export const formatDateTime = (time: any) => {
|
||||
if (time == null || time === '') {
|
||||
return 'N/A'
|
||||
}
|
||||
const date = new Date(time)
|
||||
return dayjs(date).format('YYYY-MM-DD HH:mm:ss')
|
||||
}
|
15
src/utils/permission.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { useUserStoreHook } from '@/store/modules/user'
|
||||
|
||||
/** 全局权限判断函数,和指令 v-permission 功能类似 */
|
||||
export const checkPermission = (value: string[]): boolean => {
|
||||
if (value && value instanceof Array && value.length > 0) {
|
||||
const roles = useUserStoreHook().roles
|
||||
const permissionRoles = value
|
||||
return roles.some((role) => {
|
||||
return permissionRoles.includes(role)
|
||||
})
|
||||
} else {
|
||||
console.error("need roles! Like v-permission=\"['admin','editor']\"")
|
||||
return false
|
||||
}
|
||||
}
|
112
src/utils/service.ts
Normal file
@ -0,0 +1,112 @@
|
||||
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios'
|
||||
import { get } from 'lodash-es'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getToken } from '@/utils/cookies'
|
||||
import { useUserStoreHook } from '@/store/modules/user'
|
||||
|
||||
/** 创建请求实例 */
|
||||
function createService() {
|
||||
// 创建一个 axios 实例
|
||||
const service = axios.create()
|
||||
// 请求拦截
|
||||
service.interceptors.request.use(
|
||||
(config) => config,
|
||||
// 发送失败
|
||||
(error) => Promise.reject(error)
|
||||
)
|
||||
// 响应拦截(可根据具体业务作出相应的调整)
|
||||
service.interceptors.response.use(
|
||||
(response) => {
|
||||
// apiData 是 api 返回的数据
|
||||
const apiData = response.data as any
|
||||
// 这个 code 是和后端约定的业务 code
|
||||
const code = apiData.code
|
||||
// 如果没有 code, 代表这不是项目后端开发的 api
|
||||
if (code === undefined) {
|
||||
ElMessage.error('非本系统的接口')
|
||||
return Promise.reject(new Error('非本系统的接口'))
|
||||
} else {
|
||||
switch (code) {
|
||||
case 0:
|
||||
// code === 0 代表没有错误
|
||||
return apiData
|
||||
case 20000:
|
||||
// code === 20000 代表没有错误
|
||||
return apiData
|
||||
default:
|
||||
// 不是正确的 code
|
||||
ElMessage.error(apiData.msg || 'Error')
|
||||
return Promise.reject(new Error('Error'))
|
||||
}
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
// status 是 HTTP 状态码
|
||||
const status = get(error, 'response.status')
|
||||
switch (status) {
|
||||
case 400:
|
||||
error.message = '请求错误'
|
||||
break
|
||||
case 401:
|
||||
error.message = '未授权,请登录'
|
||||
break
|
||||
case 403:
|
||||
// token 过期时,直接退出登录并强制刷新页面(会重定向到登录页)
|
||||
useUserStoreHook().logout()
|
||||
location.reload()
|
||||
break
|
||||
case 404:
|
||||
error.message = '请求地址出错'
|
||||
break
|
||||
case 408:
|
||||
error.message = '请求超时'
|
||||
break
|
||||
case 500:
|
||||
error.message = '服务器内部错误'
|
||||
break
|
||||
case 501:
|
||||
error.message = '服务未实现'
|
||||
break
|
||||
case 502:
|
||||
error.message = '网关错误'
|
||||
break
|
||||
case 503:
|
||||
error.message = '服务不可用'
|
||||
break
|
||||
case 504:
|
||||
error.message = '网关超时'
|
||||
break
|
||||
case 505:
|
||||
error.message = 'HTTP版本不受支持'
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
ElMessage.error(error.message)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
return service
|
||||
}
|
||||
|
||||
/** 创建请求方法 */
|
||||
function createRequestFunction(service: AxiosInstance) {
|
||||
return function(config: AxiosRequestConfig) {
|
||||
const configDefault = {
|
||||
headers: {
|
||||
// 携带 token
|
||||
'X-Access-Token': getToken(),
|
||||
'Content-Type': get(config, 'headers.Content-Type', 'application/json')
|
||||
},
|
||||
timeout: 5000,
|
||||
baseURL: process.env.VUE_APP_BASE_API,
|
||||
data: {}
|
||||
}
|
||||
return service(Object.assign(configDefault, config))
|
||||
}
|
||||
}
|
||||
|
||||
/** 用于网络请求的实例 */
|
||||
export const service = createService()
|
||||
/** 用于网络请求的方法 */
|
||||
export const request = createRequestFunction(service)
|
14
src/utils/validate.ts
Normal file
@ -0,0 +1,14 @@
|
||||
export const isExternal = (path: string) => /^(https?:|mailto:|tel:)/.test(path)
|
||||
|
||||
export const isArray = (arg: any) => {
|
||||
if (typeof Array.isArray === 'undefined') {
|
||||
return Object.prototype.toString.call(arg) === '[object Array]'
|
||||
}
|
||||
return Array.isArray(arg)
|
||||
}
|
||||
|
||||
export const isValidURL = (url: string) => {
|
||||
const reg =
|
||||
/^(https?|ftp):\/\/([a-zA-Z0-9.-]+(:[a-zA-Z0-9.&%$-]+)*@)*((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9][0-9]?)(\.(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3}|([a-zA-Z0-9-]+\.)*[a-zA-Z0-9-]+\.(com|edu|gov|int|mil|net|org|biz|arpa|info|name|pro|aero|coop|museum|[a-zA-Z]{2}))(:[0-9]+)*(\/($|[a-zA-Z0-9.,?'\\+&%$#=~_-]+))*$/
|
||||
return reg.test(url)
|
||||
}
|
6
src/views/dashboard/admin/index.vue
Normal file
@ -0,0 +1,6 @@
|
||||
<!-- admin 权限主页 -->
|
||||
<template>
|
||||
<div class="app-container">
|
||||
Admin 权限可见
|
||||
</div>
|
||||
</template>
|
6
src/views/dashboard/editor/index.vue
Normal file
@ -0,0 +1,6 @@
|
||||
<!-- editor 权限主页 -->
|
||||
<template>
|
||||
<div class="app-container">
|
||||
Editor 权限可见
|
||||
</div>
|
||||
</template>
|
20
src/views/dashboard/index.vue
Normal file
@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<component :is="currentRole === 'admin' ? AdminDashboard : EditorDashboard" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { computed, onBeforeMount, ref } from 'vue'
|
||||
import AdminDashboard from './admin/index.vue'
|
||||
import EditorDashboard from './editor/index.vue'
|
||||
|
||||
const currentRole = ref('admin')
|
||||
const roles = computed(() => {
|
||||
return useUserStore().roles
|
||||
})
|
||||
onBeforeMount(() => {
|
||||
if (!roles.value.includes('admin')) {
|
||||
currentRole.value = 'editor'
|
||||
}
|
||||
})
|
||||
</script>
|
10
src/views/error-page/401.vue
Normal file
@ -0,0 +1,10 @@
|
||||
<template>
|
||||
<div>
|
||||
<p style="text-align: center; font-size: 140px; margin-bottom: 50px">
|
||||
401
|
||||
</p>
|
||||
<p style="text-align: center; font-size: 40px">
|
||||
你没有权限去该页面
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
10
src/views/error-page/404.vue
Normal file
@ -0,0 +1,10 @@
|
||||
<template>
|
||||
<div>
|
||||
<p style="text-align: center; font-size: 140px; margin-bottom: 50px">
|
||||
404
|
||||
</p>
|
||||
<p style="text-align: center; font-size: 40px">
|
||||
未找到你想要的页面
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
220
src/views/login/index.vue
Normal file
@ -0,0 +1,220 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<ThemeSwitch class="theme-switch" />
|
||||
<div class="login-card">
|
||||
<div class="title">
|
||||
<img src="@/assets/layout/logo-text-2.png">
|
||||
</div>
|
||||
<div class="content">
|
||||
<el-form ref="loginFormDom" :model="loginForm" :rules="loginRules" @keyup.enter="handleLogin">
|
||||
<el-form-item prop="username">
|
||||
<el-input
|
||||
v-model="loginForm.username"
|
||||
placeholder="用户名"
|
||||
type="text"
|
||||
tabindex="1"
|
||||
:prefix-icon="User"
|
||||
size="large"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item prop="password">
|
||||
<el-input
|
||||
v-model="loginForm.password"
|
||||
placeholder="密码"
|
||||
type="password"
|
||||
tabindex="2"
|
||||
:prefix-icon="Lock"
|
||||
size="large"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item prop="code">
|
||||
<el-input
|
||||
v-model="loginForm.code"
|
||||
placeholder="验证码"
|
||||
type="text"
|
||||
tabindex="3"
|
||||
:prefix-icon="Key"
|
||||
maxlength="4"
|
||||
size="large"
|
||||
/>
|
||||
<span class="show-code">
|
||||
<img :src="codeUrl" @click="createCode">
|
||||
</span>
|
||||
</el-form-item>
|
||||
<el-button :loading="loading" type="primary" size="large" @click.prevent="handleLogin">
|
||||
登 录
|
||||
</el-button>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { User, Lock, Key } from '@element-plus/icons-vue'
|
||||
import ThemeSwitch from '@/components/ThemeSwitch/index.vue'
|
||||
|
||||
interface ILoginForm {
|
||||
/** admin 或 editor */
|
||||
username: string
|
||||
/** 密码 */
|
||||
password: string
|
||||
/** 验证码 */
|
||||
code: string
|
||||
/** 随机数 */
|
||||
codeToken: string
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref<boolean>(false)
|
||||
const loginFormDom = ref<any>()
|
||||
const codeUrl = ref<string>('')
|
||||
const loginForm = reactive<ILoginForm>({
|
||||
username: 'admin',
|
||||
password: '123456',
|
||||
code: '1234',
|
||||
codeToken: ''
|
||||
})
|
||||
const loginRules = reactive({
|
||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||
password: [
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||
{ min: 6, max: 18, message: '长度在 6 到 18 个字符', trigger: 'blur' }
|
||||
],
|
||||
code: [{ required: true, message: '请输入验证码', trigger: 'blur' }]
|
||||
})
|
||||
const handleLogin = () => {
|
||||
loginFormDom.value.validate((valid: boolean) => {
|
||||
if (valid) {
|
||||
loading.value = true
|
||||
useUserStore()
|
||||
.login({
|
||||
username: loginForm.username,
|
||||
password: loginForm.password
|
||||
})
|
||||
.then(() => {
|
||||
loading.value = false
|
||||
router.push({ path: '/' }).catch((err) => {
|
||||
console.warn(err)
|
||||
})
|
||||
})
|
||||
.catch(() => {
|
||||
loading.value = false
|
||||
createCode()
|
||||
})
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
})
|
||||
}
|
||||
/** 创建验证码 */
|
||||
const createCode: () => void = () => {
|
||||
// 先清空验证码的输入
|
||||
loginForm.code = ''
|
||||
let codeToken = ''
|
||||
const codeLength = 12
|
||||
// 随机数
|
||||
const random: Array<number | string> = [
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
6,
|
||||
7,
|
||||
8,
|
||||
9,
|
||||
'A',
|
||||
'B',
|
||||
'C',
|
||||
'D',
|
||||
'E',
|
||||
'F',
|
||||
'G',
|
||||
'H',
|
||||
'I',
|
||||
'J',
|
||||
'K',
|
||||
'L',
|
||||
'M',
|
||||
'N',
|
||||
'O',
|
||||
'P',
|
||||
'Q',
|
||||
'R',
|
||||
'S',
|
||||
'T',
|
||||
'U',
|
||||
'V',
|
||||
'W',
|
||||
'X',
|
||||
'Y',
|
||||
'Z'
|
||||
]
|
||||
for (let i = 0; i < codeLength; i++) {
|
||||
const index = Math.floor(Math.random() * 36)
|
||||
codeToken += random[index]
|
||||
}
|
||||
loginForm.codeToken = codeToken
|
||||
// 实际开发中,可替换成自己的地址,这里只是提供一个参考
|
||||
codeUrl.value = `/api/v1/login/authcode?token=${codeToken}`
|
||||
}
|
||||
// 需要验证码的时候,需打开下方注释
|
||||
// createCode()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.login-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
.theme-switch {
|
||||
position: fixed;
|
||||
top: 5%;
|
||||
right: 5%;
|
||||
cursor: pointer;
|
||||
}
|
||||
.login-card {
|
||||
width: 480px;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 0 10px #dcdfe6;
|
||||
background-color: #fff;
|
||||
overflow: hidden;
|
||||
.title {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 150px;
|
||||
img {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
.content {
|
||||
padding: 20px 50px 50px 50px;
|
||||
.show-code {
|
||||
position: absolute;
|
||||
right: 0px;
|
||||
top: 0px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
img {
|
||||
width: 100px;
|
||||
height: 40px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
.el-button {
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
29
src/views/permission/components/switch-roles.vue
Normal file
@ -0,0 +1,29 @@
|
||||
<!-- 切换角色 -->
|
||||
<template>
|
||||
<div>
|
||||
<div style="margin-bottom: 15px">
|
||||
你的权限:{{ roles }}
|
||||
</div>
|
||||
<div style="display: flex; align-items: center">
|
||||
<span>切换权限:</span>
|
||||
<el-radio-group v-model="currentRole">
|
||||
<el-radio-button label="editor" />
|
||||
<el-radio-button label="admin" />
|
||||
</el-radio-group>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const emit = defineEmits(['change'])
|
||||
const roles = computed(() => userStore.roles)
|
||||
const currentRole = ref(roles.value[0])
|
||||
watch(currentRole, async(value) => {
|
||||
await userStore.changeRoles(value)
|
||||
emit('change')
|
||||
})
|
||||
</script>
|
92
src/views/permission/directive.vue
Normal file
@ -0,0 +1,92 @@
|
||||
<!-- 指令权限、权限判断函数的使用 -->
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<SwitchRoles @change="state.handleRolesChange" />
|
||||
<div :key="state.key" style="margin-top: 30px">
|
||||
<div>
|
||||
<span v-permission="['admin']" class="permission-alert">
|
||||
只有
|
||||
<el-tag>admin</el-tag>可以看见这个
|
||||
</span>
|
||||
<el-tag v-permission="['admin']" class="permission-sourceCode" type="info" size="large">
|
||||
v-permission="['admin']"
|
||||
</el-tag>
|
||||
</div>
|
||||
<div>
|
||||
<span v-permission="['editor']" class="permission-alert">
|
||||
只有
|
||||
<el-tag>editor</el-tag>可以看见这个
|
||||
</span>
|
||||
<el-tag v-permission="['editor']" class="permission-sourceCode" type="info" size="large">
|
||||
v-permission="['editor']"
|
||||
</el-tag>
|
||||
</div>
|
||||
<div>
|
||||
<span v-permission="['admin', 'editor']" class="permission-alert">
|
||||
两者
|
||||
<el-tag>admin</el-tag>和 <el-tag>editor</el-tag>都可以看见这个
|
||||
</span>
|
||||
<el-tag v-permission="['admin', 'editor']" class="permission-sourceCode" type="info" size="large">
|
||||
v-permission="['admin', 'editor']"
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
<div :key="'checkPermission' + state.key" style="margin-top: 60px">
|
||||
<el-tag type="info" size="large">
|
||||
在某些情况下,不适合使用 v-permission。例如:element-plus 的 el-tab 或 el-table-column 以及其它动态渲染 dom
|
||||
的场景。你只能通过手动设置 v-if 来实现。
|
||||
</el-tag>
|
||||
<el-tabs type="border-card" style="width: 550px; margin-top: 60px">
|
||||
<el-tab-pane v-if="checkPermission(['admin'])" label="admin">
|
||||
admin 可以看见这个
|
||||
<el-tag class="permission-sourceCode" type="info">
|
||||
v-if="checkPermission(['admin'])"
|
||||
</el-tag>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane v-if="checkPermission(['editor'])" label="editor">
|
||||
editor 可以看见这个
|
||||
<el-tag class="permission-sourceCode" type="info">
|
||||
v-if="checkPermission(['editor'])"
|
||||
</el-tag>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane v-if="checkPermission(['admin', 'editor'])" label="admin 和 editor">
|
||||
两者 admin 和 editor 都可以看见这个
|
||||
<el-tag class="permission-sourceCode" type="info">
|
||||
v-if="checkPermission(['admin', 'editor'])"
|
||||
</el-tag>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { reactive } from 'vue'
|
||||
import { checkPermission } from '@/utils/permission' // 权限判断函数
|
||||
import SwitchRoles from './components/switch-roles.vue'
|
||||
|
||||
const state = reactive({
|
||||
key: 1,
|
||||
handleRolesChange: () => {
|
||||
state.key++
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.permission-alert {
|
||||
width: 320px;
|
||||
margin-top: 15px;
|
||||
background-color: #f0f9eb;
|
||||
color: #67c23a;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.permission-sourceCode {
|
||||
margin-left: 15px;
|
||||
}
|
||||
</style>
|
21
src/views/permission/page.vue
Normal file
@ -0,0 +1,21 @@
|
||||
<!-- 页面权限测试页 -->
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<el-tag type="success" size="large" style="margin-bottom: 15px">
|
||||
当前页面只有 admin 权限可见
|
||||
</el-tag>
|
||||
<SwitchRoles @change="handleRolesChange" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
import SwitchRoles from './components/switch-roles.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const handleRolesChange = () => {
|
||||
router.push({ path: '/401' }).catch((err) => {
|
||||
console.warn(err)
|
||||
})
|
||||
}
|
||||
</script>
|
16
src/views/redirect/index.vue
Normal file
@ -0,0 +1,16 @@
|
||||
<!-- 重定向 -->
|
||||
<template>
|
||||
<div />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
const { params, query } = useRoute()
|
||||
const { path } = params
|
||||
useRouter()
|
||||
.replace({ path: '/' + path, query })
|
||||
.catch((err) => {
|
||||
console.warn(err)
|
||||
})
|
||||
</script>
|
@ -1,4 +1,4 @@
|
||||
import { ConfigEnv, UserConfigExport } from 'vite'
|
||||
import { UserConfigExport } from 'vite'
|
||||
import path, { resolve } from 'path'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import AutoImport from 'unplugin-auto-import/vite'
|
||||
@ -7,7 +7,7 @@ import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
|
||||
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons'
|
||||
|
||||
/** 配置项文档:https://vitejs.dev/config */
|
||||
export default (env: ConfigEnv): UserConfigExport => {
|
||||
export default (): UserConfigExport => {
|
||||
return {
|
||||
/** build 打包时根据实际情况修改 base */
|
||||
base: '/',
|
||||
|