feat:vue-element-admin升级改造vue3
This commit is contained in:
8
src/api/system/menu.ts
Normal file
8
src/api/system/menu.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export function getRouteList() {
|
||||
return request({
|
||||
url: '/youlai-admin/api/v1/menus/route',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
@@ -14,12 +14,11 @@
|
||||
|
||||
<script lang="ts">
|
||||
|
||||
import {defineComponent, onBeforeMount, reactive, toRefs} from "vue";
|
||||
import {defineComponent, onBeforeMount, reactive, toRefs,watch} from "vue";
|
||||
import {compile} from 'path-to-regexp'
|
||||
import {useRoute, RouteLocationMatched} from "vue-router";
|
||||
import router from "@router";
|
||||
|
||||
|
||||
export default defineComponent({
|
||||
setup() {
|
||||
const currentRoute = useRoute();
|
||||
@@ -29,65 +28,50 @@ export default defineComponent({
|
||||
return toPath(params)
|
||||
}
|
||||
const state = reactive({
|
||||
breadcrumbs: [] as Array<RouteLocationMatched>,
|
||||
getBreadcrumb:()=>{
|
||||
|
||||
levelList: [] as Array<RouteLocationMatched>,
|
||||
getBreadcrumb: () => {
|
||||
let matched = currentRoute.matched.filter((item) => item.meta && item.meta.title)
|
||||
const first = matched[0]
|
||||
if (!state.isDashboard(first)) {
|
||||
matched = [{path: '/dashboard', meta: {title: 'dashboard'}} as any].concat(matched)
|
||||
}
|
||||
state.levelList = matched.filter((item) => {
|
||||
return item.mate && item.meta.title && item.meta.breadcrumb !== false
|
||||
})
|
||||
},
|
||||
isDashboard(route: RouteLocationMatched) {
|
||||
const name = route && route.name
|
||||
if (!name) {
|
||||
return false
|
||||
}
|
||||
return name.trim().toLocaleLowerCase() === 'Dashboard'.toLocaleLowerCase()
|
||||
},
|
||||
handleLink(item: any) {
|
||||
const {redirect, path} = item
|
||||
if (redirect) {
|
||||
router.push(redirect)
|
||||
return
|
||||
}
|
||||
router.push(pathCompile(path))
|
||||
}
|
||||
})
|
||||
watch(() => currentRoute.path, (path) => {
|
||||
if (path.startsWith('/redirect/')) {
|
||||
return
|
||||
}
|
||||
state.getBreadcrumb()
|
||||
})
|
||||
onBeforeMount(() => {
|
||||
state.getBreadcrumb()
|
||||
})
|
||||
|
||||
return {
|
||||
...toRefs(state)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
levelList: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
$route() {
|
||||
this.getBreadcrumb()
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.getBreadcrumb()
|
||||
},
|
||||
methods: {
|
||||
getBreadcrumb() {
|
||||
// only show routes with meta.title
|
||||
let matched = this.$route.matched.filter(item => item.meta && item.meta.title)
|
||||
const first = matched[0]
|
||||
|
||||
if (!this.isDashboard(first)) {
|
||||
matched = [{path: '/dashboard', meta: {title: 'Dashboard'}}].concat(matched)
|
||||
}
|
||||
|
||||
this.levelList = matched.filter(item => item.meta && item.meta.title && item.meta.breadcrumb !== false)
|
||||
},
|
||||
isDashboard(route) {
|
||||
const name = route && route.name
|
||||
if (!name) {
|
||||
return false
|
||||
}
|
||||
return name.trim().toLocaleLowerCase() === 'Dashboard'.toLocaleLowerCase()
|
||||
},
|
||||
pathCompile(path) {
|
||||
// To solve this problem https://github.com/PanJiaChen/vue-element-admin/issues/561
|
||||
const {params} = this.$route
|
||||
var toPath = pathToRegexp.compile(path)
|
||||
return toPath(params)
|
||||
},
|
||||
handleLink(item) {
|
||||
const {redirect, path} = item
|
||||
if (redirect) {
|
||||
this.$router.push(redirect)
|
||||
return
|
||||
}
|
||||
this.$router.push(this.pathCompile(path))
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.app-breadcrumb.el-breadcrumb {
|
||||
display: inline-block;
|
||||
|
||||
175
src/components/ThemePicker/index.vue
Normal file
175
src/components/ThemePicker/index.vue
Normal file
@@ -0,0 +1,175 @@
|
||||
<template>
|
||||
<el-color-picker
|
||||
v-model="theme"
|
||||
:predefine="['#409EFF', '#1890ff', '#304156','#212121','#11a983', '#13c2c2', '#6959CD', '#f5222d', ]"
|
||||
class="theme-picker"
|
||||
popper-class="theme-picker-dropdown"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
const version = require('element-ui/package.json').version // element-ui version from node_modules
|
||||
const ORIGINAL_THEME = '#409EFF' // default color
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
chalk: '', // content of theme-chalk css
|
||||
theme: ''
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
defaultTheme() {
|
||||
return this.$store.state.settings.theme
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
defaultTheme: {
|
||||
handler: function(val, oldVal) {
|
||||
this.theme = val
|
||||
},
|
||||
immediate: true
|
||||
},
|
||||
async theme(val) {
|
||||
const oldVal = this.chalk ? this.theme : ORIGINAL_THEME
|
||||
if (typeof val !== 'string') return
|
||||
const themeCluster = this.getThemeCluster(val.replace('#', ''))
|
||||
const originalCluster = this.getThemeCluster(oldVal.replace('#', ''))
|
||||
console.log(themeCluster, originalCluster)
|
||||
|
||||
const $message = this.$message({
|
||||
message: ' Compiling the theme',
|
||||
customClass: 'theme-message',
|
||||
type: 'success',
|
||||
duration: 0,
|
||||
iconClass: 'el-icon-loading'
|
||||
})
|
||||
|
||||
const getHandler = (variable, id) => {
|
||||
return () => {
|
||||
const originalCluster = this.getThemeCluster(ORIGINAL_THEME.replace('#', ''))
|
||||
const newStyle = this.updateStyle(this[variable], originalCluster, themeCluster)
|
||||
|
||||
let styleTag = document.getElementById(id)
|
||||
if (!styleTag) {
|
||||
styleTag = document.createElement('style')
|
||||
styleTag.setAttribute('id', id)
|
||||
document.head.appendChild(styleTag)
|
||||
}
|
||||
styleTag.innerText = newStyle
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.chalk) {
|
||||
const url = `https://unpkg.com/element-ui@${version}/lib/theme-chalk/index.css`
|
||||
await this.getCSSString(url, 'chalk')
|
||||
}
|
||||
|
||||
const chalkHandler = getHandler('chalk', 'chalk-style')
|
||||
|
||||
chalkHandler()
|
||||
|
||||
const styles = [].slice.call(document.querySelectorAll('style'))
|
||||
.filter(style => {
|
||||
const text = style.innerText
|
||||
return new RegExp(oldVal, 'i').test(text) && !/Chalk Variables/.test(text)
|
||||
})
|
||||
styles.forEach(style => {
|
||||
const { innerText } = style
|
||||
if (typeof innerText !== 'string') return
|
||||
style.innerText = this.updateStyle(innerText, originalCluster, themeCluster)
|
||||
})
|
||||
|
||||
this.$emit('change', val)
|
||||
|
||||
$message.close()
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
updateStyle(style, oldCluster, newCluster) {
|
||||
let newStyle = style
|
||||
oldCluster.forEach((color, index) => {
|
||||
newStyle = newStyle.replace(new RegExp(color, 'ig'), newCluster[index])
|
||||
})
|
||||
return newStyle
|
||||
},
|
||||
|
||||
getCSSString(url, variable) {
|
||||
return new Promise(resolve => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
xhr.onreadystatechange = () => {
|
||||
if (xhr.readyState === 4 && xhr.status === 200) {
|
||||
this[variable] = xhr.responseText.replace(/@font-face{[^}]+}/, '')
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
xhr.open('GET', url)
|
||||
xhr.send()
|
||||
})
|
||||
},
|
||||
|
||||
getThemeCluster(theme) {
|
||||
const tintColor = (color, tint) => {
|
||||
let red = parseInt(color.slice(0, 2), 16)
|
||||
let green = parseInt(color.slice(2, 4), 16)
|
||||
let blue = parseInt(color.slice(4, 6), 16)
|
||||
|
||||
if (tint === 0) { // when primary color is in its rgb space
|
||||
return [red, green, blue].join(',')
|
||||
} else {
|
||||
red += Math.round(tint * (255 - red))
|
||||
green += Math.round(tint * (255 - green))
|
||||
blue += Math.round(tint * (255 - blue))
|
||||
|
||||
red = red.toString(16)
|
||||
green = green.toString(16)
|
||||
blue = blue.toString(16)
|
||||
|
||||
return `#${red}${green}${blue}`
|
||||
}
|
||||
}
|
||||
|
||||
const shadeColor = (color, shade) => {
|
||||
let red = parseInt(color.slice(0, 2), 16)
|
||||
let green = parseInt(color.slice(2, 4), 16)
|
||||
let blue = parseInt(color.slice(4, 6), 16)
|
||||
|
||||
red = Math.round((1 - shade) * red)
|
||||
green = Math.round((1 - shade) * green)
|
||||
blue = Math.round((1 - shade) * blue)
|
||||
|
||||
red = red.toString(16)
|
||||
green = green.toString(16)
|
||||
blue = blue.toString(16)
|
||||
|
||||
return `#${red}${green}${blue}`
|
||||
}
|
||||
|
||||
const clusters = [theme]
|
||||
for (let i = 0; i <= 9; i++) {
|
||||
clusters.push(tintColor(theme, Number((i / 10).toFixed(2))))
|
||||
}
|
||||
clusters.push(shadeColor(theme, 0.1))
|
||||
return clusters
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.theme-message,
|
||||
.theme-picker-dropdown {
|
||||
z-index: 99999 !important;
|
||||
}
|
||||
|
||||
.theme-picker .el-color-picker__trigger {
|
||||
height: 26px !important;
|
||||
width: 26px !important;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.theme-picker-dropdown .el-color-dropdown__link-btn {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -1,20 +1,29 @@
|
||||
<template>
|
||||
<section class="app-main">
|
||||
<transition name="fade-transform" mode="out-in">
|
||||
<router-view :key="key" />
|
||||
<router-view :key="key"/>
|
||||
</transition>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'AppMain',
|
||||
computed: {
|
||||
key() {
|
||||
return this.$route.path
|
||||
<script lang="ts">
|
||||
|
||||
import {useRoute} from "vue-router";
|
||||
import {defineComponent} from "vue";
|
||||
|
||||
export default defineComponent({
|
||||
setup() {
|
||||
const route = useRoute()
|
||||
const key = () => {
|
||||
return route.path
|
||||
}
|
||||
return {
|
||||
key
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -25,7 +34,8 @@ export default {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.fixed-header+.app-main {
|
||||
|
||||
.fixed-header + .app-main {
|
||||
padding-top: 50px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<template>
|
||||
<div class="navbar">
|
||||
<hamburger :is-active="sidebar.opened" class="hamburger-container" @toggleClick="toggleSideBar" />
|
||||
<hamburger :is-active="sidebar.opened" class="hamburger-container" @toggleClick="toggleSideBar"/>
|
||||
|
||||
<breadcrumb class="breadcrumb-container" />
|
||||
<breadcrumb class="breadcrumb-container"/>
|
||||
|
||||
<div class="right-menu">
|
||||
<el-dropdown class="avatar-container" trigger="click">
|
||||
<div class="avatar-wrapper">
|
||||
<img :src="avatar+'?imageView2/1/w/80/h/80'" class="user-avatar">
|
||||
<i class="el-icon-caret-bottom" />
|
||||
<i class="el-icon-caret-bottom"/>
|
||||
</div>
|
||||
<el-dropdown-menu slot="dropdown" class="user-dropdown">
|
||||
<router-link to="/">
|
||||
@@ -32,7 +32,9 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex'
|
||||
import {reactive,computed,toRefs} from "vue";
|
||||
import {useStore} from "@/store";
|
||||
import {useRoute,useRouter} from "vue-router"
|
||||
import Breadcrumb from '@/components/Breadcrumb/index.vue'
|
||||
import Hamburger from '@/components/Hamburger/index.vue'
|
||||
|
||||
@@ -41,19 +43,35 @@ export default {
|
||||
Breadcrumb,
|
||||
Hamburger
|
||||
},
|
||||
computed: {
|
||||
...mapGetters([
|
||||
'sidebar',
|
||||
'avatar'
|
||||
])
|
||||
},
|
||||
methods: {
|
||||
toggleSideBar() {
|
||||
this.$store.dispatch('app/toggleSideBar')
|
||||
},
|
||||
async logout() {
|
||||
await this.$store.dispatch('user/logout')
|
||||
this.$router.push(`/login?redirect=${this.$route.fullPath}`)
|
||||
setup() {
|
||||
const store = useStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const sidebar = computed(() => {
|
||||
return store.state.app.sidebar
|
||||
})
|
||||
const device = computed(() => {
|
||||
return store.state.app.device.toString()
|
||||
})
|
||||
const avatar = computed(() => {
|
||||
return store.state.user.avatar
|
||||
})
|
||||
|
||||
const state=reactive({
|
||||
toggleSideBar:()=>{
|
||||
store.dispatch('app/toggleSideBar', false)
|
||||
},
|
||||
logout:()=>{
|
||||
store.dispatch('user/logout')
|
||||
router.push(`/login?redirect=${route.fullPath}`)
|
||||
}
|
||||
})
|
||||
return{
|
||||
sidebar,
|
||||
device,
|
||||
avatar,
|
||||
...toRefs(state)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,7 +83,7 @@ export default {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 4px rgba(0,21,41,.08);
|
||||
box-shadow: 0 1px 4px rgba(0, 21, 41, .08);
|
||||
|
||||
.hamburger-container {
|
||||
line-height: 46px;
|
||||
@@ -73,7 +91,7 @@ export default {
|
||||
float: left;
|
||||
cursor: pointer;
|
||||
transition: background .3s;
|
||||
-webkit-tap-highlight-color:transparent;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, .025)
|
||||
|
||||
97
src/layout/components/Settings/index.vue
Normal file
97
src/layout/components/Settings/index.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div class="drawer-container">
|
||||
<div>
|
||||
<h3 class="drawer-title">系统布局配置</h3>
|
||||
|
||||
<div class="drawer-item">
|
||||
<span>主题色</span>
|
||||
<theme-picker style="float: right;height: 26px;margin: -3px 8px 0 0;" @change="themeChange"/>
|
||||
</div>
|
||||
|
||||
<div class="drawer-item">
|
||||
<span>开启 Tags-View</span>
|
||||
<el-switch v-model="tagsView" class="drawer-switch"/>
|
||||
</div>
|
||||
|
||||
<div class="drawer-item">
|
||||
<span>固定 Header</span>
|
||||
<el-switch v-model="fixedHeader" class="drawer-switch"/>
|
||||
</div>
|
||||
|
||||
<div class="drawer-item">
|
||||
<span>侧边栏 Logo</span>
|
||||
<el-switch v-model="sidebarLogo" class="drawer-switch"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ThemePicker from '@/components/ThemePicker/index.vue'
|
||||
import {defineComponent, reactive, toRefs, watch} from "vue"
|
||||
import {useStore} from '@/store'
|
||||
|
||||
export default defineComponent({
|
||||
components: {ThemePicker},
|
||||
setup() {
|
||||
const store = useStore()
|
||||
const state = reactive({
|
||||
fixedHeader:store.state.settings.fixedHeader,
|
||||
tagsView:store.state.settings.tagsView,
|
||||
sidebarLogo:store.state.settings.sidebarLogo,
|
||||
themeChange: (val) => {
|
||||
store.dispatch('settings/changeSetting', { key: 'theme', val })
|
||||
}
|
||||
})
|
||||
watch(()=>state.fixedHeader,(value)=>{
|
||||
store.dispatch('settings/changeSetting',{ key: 'fixedHeader', value })
|
||||
})
|
||||
|
||||
watch(() => state.tagsView, (value) => {
|
||||
store.dispatch('settings/changeSetting', { key: 'showTagsView', value })
|
||||
})
|
||||
|
||||
watch(() => state.sidebarLogo, (value) => {
|
||||
store.dispatch('settings/changeSetting', { key: 'showSidebarLogo', value })
|
||||
})
|
||||
|
||||
return {
|
||||
...toRefs(state)
|
||||
}
|
||||
}
|
||||
})
|
||||
</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: rgba(0, 0, 0, .85);
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.drawer-item {
|
||||
color: rgba(0, 0, 0, .65);
|
||||
font-size: 14px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.drawer-switch {
|
||||
float: right
|
||||
}
|
||||
|
||||
.job-link {
|
||||
display: block;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,56 +1,67 @@
|
||||
<template>
|
||||
<div :class="{'has-logo':showLogo}">
|
||||
<logo v-if="showLogo" :collapse="isCollapse" />
|
||||
<logo v-if="showLogo" :collapse="isCollapse"/>
|
||||
<el-scrollbar wrap-class="scrollbar-wrapper">
|
||||
<el-menu
|
||||
:default-active="activeMenu"
|
||||
:collapse="isCollapse"
|
||||
:background-color="variables.menuBg"
|
||||
:text-color="variables.menuText"
|
||||
:unique-opened="false"
|
||||
:active-text-color="variables.menuActiveText"
|
||||
:collapse-transition="false"
|
||||
mode="vertical"
|
||||
:default-active="activeMenu"
|
||||
:collapse="isCollapse"
|
||||
:background-color="variables.menuBg"
|
||||
:text-color="variables.menuText"
|
||||
:unique-opened="false"
|
||||
:active-text-color="variables.menuActiveText"
|
||||
:collapse-transition="false"
|
||||
mode="vertical"
|
||||
>
|
||||
<sidebar-item v-for="route in routes" :key="route.path" :item="route" :base-path="route.path" />
|
||||
<sidebar-item v-for="route in routes" :key="route.path" :item="route"
|
||||
:base-path="route.path"/>
|
||||
</el-menu>
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex'
|
||||
import Logo from './Logo.vue'
|
||||
import {computed, defineComponent} from "vue";
|
||||
import SidebarItem from './SidebarItem.vue'
|
||||
import SidebarLogo from './Logo.vue'
|
||||
import variables from '@styles/variables.scss'
|
||||
import {useStore} from '@/store'
|
||||
import {useRoute} from 'vue-router'
|
||||
|
||||
export default {
|
||||
components: { SidebarItem, Logo },
|
||||
computed: {
|
||||
...mapGetters([
|
||||
'sidebar'
|
||||
]),
|
||||
routes() {
|
||||
return this.$router.options.routes
|
||||
},
|
||||
activeMenu() {
|
||||
const route = this.$route
|
||||
const { meta, path } = route
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
SidebarItem,
|
||||
SidebarLogo
|
||||
},
|
||||
setup() {
|
||||
const store = useStore()
|
||||
const route = useRoute()
|
||||
const sidebar = computed(() => {
|
||||
return store.state.app.sidebar
|
||||
})
|
||||
const showLogo = computed(() => {
|
||||
return store.state.settings.sidebarLogo
|
||||
})
|
||||
const activeMenu = computed(() => {
|
||||
const {meta, path} = route
|
||||
// if set path, the sidebar will highlight the path you set
|
||||
if (meta.activeMenu) {
|
||||
return meta.activeMenu
|
||||
}
|
||||
return path
|
||||
},
|
||||
showLogo() {
|
||||
return this.$store.state.settings.sidebarLogo
|
||||
},
|
||||
variables() {
|
||||
return variables
|
||||
},
|
||||
isCollapse() {
|
||||
return !this.sidebar.opened
|
||||
})
|
||||
const isCollapse = computed(() => {
|
||||
return !sidebar.value.opened
|
||||
})
|
||||
|
||||
return {
|
||||
sidebar,
|
||||
route,
|
||||
showLogo,
|
||||
variables,
|
||||
activeMenu,
|
||||
isCollapse
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
94
src/layout/components/TagsView/ScrollPane.vue
Normal file
94
src/layout/components/TagsView/ScrollPane.vue
Normal file
@@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<el-scrollbar ref="scrollContainer" :vertical="false" class="scroll-container" @wheel.native.prevent="handleScroll">
|
||||
<slot />
|
||||
</el-scrollbar>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
const tagAndTagSpacing = 4 // tagAndTagSpacing
|
||||
|
||||
export default {
|
||||
name: 'ScrollPane',
|
||||
data() {
|
||||
return {
|
||||
left: 0
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
scrollWrapper() {
|
||||
return this.$refs.scrollContainer.$refs.wrap
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.scrollWrapper.addEventListener('scroll', this.emitScroll, true)
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.scrollWrapper.removeEventListener('scroll', this.emitScroll)
|
||||
},
|
||||
methods: {
|
||||
handleScroll(e) {
|
||||
const eventDelta = e.wheelDelta || -e.deltaY * 40
|
||||
const $scrollWrapper = this.scrollWrapper
|
||||
$scrollWrapper.scrollLeft = $scrollWrapper.scrollLeft + eventDelta / 4
|
||||
},
|
||||
emitScroll() {
|
||||
this.$emit('scroll')
|
||||
},
|
||||
moveToTarget(currentTag) {
|
||||
const $container = this.$refs.scrollContainer.$el
|
||||
const $containerWidth = $container.offsetWidth
|
||||
const $scrollWrapper = this.scrollWrapper
|
||||
const tagList = this.$parent.$refs.tag
|
||||
|
||||
let firstTag = null
|
||||
let lastTag = null
|
||||
|
||||
// find first tag and last tag
|
||||
if (tagList.length > 0) {
|
||||
firstTag = tagList[0]
|
||||
lastTag = tagList[tagList.length - 1]
|
||||
}
|
||||
|
||||
if (firstTag === currentTag) {
|
||||
$scrollWrapper.scrollLeft = 0
|
||||
} else if (lastTag === currentTag) {
|
||||
$scrollWrapper.scrollLeft = $scrollWrapper.scrollWidth - $containerWidth
|
||||
} else {
|
||||
// find preTag and nextTag
|
||||
const currentIndex = tagList.findIndex(item => item === currentTag)
|
||||
const prevTag = tagList[currentIndex - 1]
|
||||
const nextTag = tagList[currentIndex + 1]
|
||||
|
||||
// the tag's offsetLeft after of nextTag
|
||||
const afterNextTagOffsetLeft = nextTag.$el.offsetLeft + nextTag.$el.offsetWidth + tagAndTagSpacing
|
||||
|
||||
// the tag's offsetLeft before of prevTag
|
||||
const beforePrevTagOffsetLeft = prevTag.$el.offsetLeft - tagAndTagSpacing
|
||||
|
||||
if (afterNextTagOffsetLeft > $scrollWrapper.scrollLeft + $containerWidth) {
|
||||
$scrollWrapper.scrollLeft = afterNextTagOffsetLeft - $containerWidth
|
||||
} else if (beforePrevTagOffsetLeft < $scrollWrapper.scrollLeft) {
|
||||
$scrollWrapper.scrollLeft = beforePrevTagOffsetLeft
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.scroll-container {
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
::v-deep {
|
||||
.el-scrollbar__bar {
|
||||
bottom: 0px;
|
||||
}
|
||||
.el-scrollbar__wrap {
|
||||
height: 49px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
294
src/layout/components/TagsView/index.vue
Normal file
294
src/layout/components/TagsView/index.vue
Normal file
@@ -0,0 +1,294 @@
|
||||
<template>
|
||||
<div id="tags-view-container" class="tags-view-container">
|
||||
<scroll-pane ref="scrollPane" class="tags-view-wrapper" @scroll="handleScroll">
|
||||
<router-link
|
||||
v-for="tag in visitedViews"
|
||||
ref="tag"
|
||||
:key="tag.path"
|
||||
:class="isActive(tag)?'active':''"
|
||||
:to="{ path: tag.path, query: tag.query, fullPath: tag.fullPath }"
|
||||
tag="span"
|
||||
class="tags-view-item"
|
||||
@click.middle.native="!isAffix(tag)?closeSelectedTag(tag):''"
|
||||
@contextmenu.prevent.native="openMenu(tag,$event)"
|
||||
>
|
||||
{{ generateTitle(tag.title) }}
|
||||
<span v-if="!isAffix(tag)" class="el-icon-close" @click.prevent.stop="closeSelectedTag(tag)" />
|
||||
</router-link>
|
||||
</scroll-pane>
|
||||
<ul v-show="visible" :style="{left:left+'px',top:top+'px'}" class="contextmenu">
|
||||
<li @click="refreshSelectedTag(selectedTag)">{{ $t('tagsView.refresh') }}</li>
|
||||
<li v-if="!isAffix(selectedTag)" @click="closeSelectedTag(selectedTag)">{{ $t('tagsView.close') }}</li>
|
||||
<li @click="closeOthersTags">{{ $t('tagsView.closeOthers') }}</li>
|
||||
<li @click="closeAllTags(selectedTag)">{{ $t('tagsView.closeAll') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ScrollPane from './ScrollPane'
|
||||
import { generateTitle } from '@/utils/i18n'
|
||||
import path from 'path'
|
||||
|
||||
export default {
|
||||
components: { ScrollPane },
|
||||
data() {
|
||||
return {
|
||||
visible: false,
|
||||
top: 0,
|
||||
left: 0,
|
||||
selectedTag: {},
|
||||
affixTags: []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
visitedViews() {
|
||||
return this.$store.state.tagsView.visitedViews
|
||||
},
|
||||
routes() {
|
||||
return this.$store.state.permission.routes
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
$route() {
|
||||
this.addTags()
|
||||
this.moveToCurrentTag()
|
||||
},
|
||||
visible(value) {
|
||||
if (value) {
|
||||
document.body.addEventListener('click', this.closeMenu)
|
||||
} else {
|
||||
document.body.removeEventListener('click', this.closeMenu)
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.initTags()
|
||||
this.addTags()
|
||||
},
|
||||
methods: {
|
||||
generateTitle, // generateTitle by vue-i18n
|
||||
isActive(route) {
|
||||
return route.path === this.$route.path
|
||||
},
|
||||
isAffix(tag) {
|
||||
return tag.meta && tag.meta.affix
|
||||
},
|
||||
filterAffixTags(routes, basePath = '/') {
|
||||
let tags = []
|
||||
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 tempTags = this.filterAffixTags(route.children, route.path)
|
||||
if (tempTags.length >= 1) {
|
||||
tags = [...tags, ...tempTags]
|
||||
}
|
||||
}
|
||||
})
|
||||
return tags
|
||||
},
|
||||
initTags() {
|
||||
const affixTags = this.affixTags = this.filterAffixTags(this.routes)
|
||||
for (const tag of affixTags) {
|
||||
// Must have tag name
|
||||
if (tag.name) {
|
||||
this.$store.dispatch('tagsView/addVisitedView', tag)
|
||||
}
|
||||
}
|
||||
},
|
||||
addTags() {
|
||||
const { name } = this.$route
|
||||
if (name) {
|
||||
this.$store.dispatch('tagsView/addView', this.$route)
|
||||
}
|
||||
return false
|
||||
},
|
||||
moveToCurrentTag() {
|
||||
const tags = this.$refs.tag
|
||||
this.$nextTick(() => {
|
||||
for (const tag of tags) {
|
||||
if (tag.to.path === this.$route.path) {
|
||||
this.$refs.scrollPane.moveToTarget(tag)
|
||||
// when query is different then update
|
||||
if (tag.to.fullPath !== this.$route.fullPath) {
|
||||
this.$store.dispatch('tagsView/updateVisitedView', this.$route)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
refreshSelectedTag(view) {
|
||||
this.$store.dispatch('tagsView/delCachedView', view).then(() => {
|
||||
const { fullPath } = view
|
||||
this.$nextTick(() => {
|
||||
this.$router.replace({
|
||||
path: '/redirect' + fullPath
|
||||
})
|
||||
})
|
||||
})
|
||||
},
|
||||
closeSelectedTag(view) {
|
||||
this.$store.dispatch('tagsView/delView', view).then(({ visitedViews }) => {
|
||||
if (this.isActive(view)) {
|
||||
this.toLastView(visitedViews, view)
|
||||
}
|
||||
})
|
||||
},
|
||||
closeOthersTags() {
|
||||
this.$router.push(this.selectedTag)
|
||||
this.$store.dispatch('tagsView/delOthersViews', this.selectedTag).then(() => {
|
||||
this.moveToCurrentTag()
|
||||
})
|
||||
},
|
||||
closeAllTags(view) {
|
||||
this.$store.dispatch('tagsView/delAllViews').then(({ visitedViews }) => {
|
||||
if (this.affixTags.some(tag => tag.path === view.path)) {
|
||||
return
|
||||
}
|
||||
this.toLastView(visitedViews, view)
|
||||
})
|
||||
},
|
||||
toLastView(visitedViews, view) {
|
||||
const latestView = visitedViews.slice(-1)[0]
|
||||
if (latestView) {
|
||||
this.$router.push(latestView.fullPath)
|
||||
} else {
|
||||
// now the default is to redirect to the home page if there is no tags-view,
|
||||
// you can adjust it according to your needs.
|
||||
if (view.name === 'Dashboard') {
|
||||
// to reload home page
|
||||
this.$router.replace({ path: '/redirect' + view.fullPath })
|
||||
} else {
|
||||
this.$router.push('/')
|
||||
}
|
||||
}
|
||||
},
|
||||
openMenu(tag, e) {
|
||||
const menuMinWidth = 105
|
||||
const offsetLeft = this.$el.getBoundingClientRect().left // container margin left
|
||||
const offsetWidth = this.$el.offsetWidth // container width
|
||||
const maxLeft = offsetWidth - menuMinWidth // left boundary
|
||||
const left = e.clientX - offsetLeft + 15 // 15: margin right
|
||||
|
||||
if (left > maxLeft) {
|
||||
this.left = maxLeft
|
||||
} else {
|
||||
this.left = left
|
||||
}
|
||||
|
||||
this.top = e.clientY
|
||||
this.visible = true
|
||||
this.selectedTag = tag
|
||||
},
|
||||
closeMenu() {
|
||||
this.visible = false
|
||||
},
|
||||
handleScroll() {
|
||||
this.closeMenu()
|
||||
}
|
||||
}
|
||||
}
|
||||
</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 rgba(0, 0, 0, .12), 0 0 3px 0 rgba(0, 0, 0, .04);
|
||||
.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: #42b983;
|
||||
color: #fff;
|
||||
border-color: #42b983;
|
||||
&::before {
|
||||
content: '';
|
||||
background: #fff;
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
position: relative;
|
||||
margin-right: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.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 rgba(0, 0, 0, .3);
|
||||
li {
|
||||
margin: 0;
|
||||
padding: 7px 16px;
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
background: #eee;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
//reset element css of el-icon-close
|
||||
.tags-view-wrapper {
|
||||
.tags-view-item {
|
||||
.el-icon-close {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
vertical-align: 2px;
|
||||
border-radius: 50%;
|
||||
text-align: center;
|
||||
transition: all .3s cubic-bezier(.645, .045, .355, 1);
|
||||
transform-origin: 100% 50%;
|
||||
&:before {
|
||||
transform: scale(.6);
|
||||
display: inline-block;
|
||||
vertical-align: -3px;
|
||||
}
|
||||
&:hover {
|
||||
background-color: #b4bccc;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -2,54 +2,63 @@
|
||||
<div :class="classObj" class="app-wrapper">
|
||||
<div v-if="device==='mobile'&&sidebar.opened" class="drawer-bg" @click="handleClickOutside" />
|
||||
<sidebar class="sidebar-container" />
|
||||
<div class="main-container">
|
||||
<div :class="{hasTagsView:needTagsView}" class="main-container">
|
||||
<div :class="{'fixed-header':fixedHeader}">
|
||||
<navbar />
|
||||
<tags-view v-if="needTagsView" />
|
||||
</div>
|
||||
<app-main />
|
||||
<right-panel v-if="showSettings">
|
||||
<settings />
|
||||
</right-panel>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Navbar, Sidebar, AppMain } from './components'
|
||||
import ResizeMixin from './mixin/ResizeHandler'
|
||||
|
||||
export default {
|
||||
<script>
|
||||
import {computed, defineComponent, onBeforeMount, onBeforeUnmount, onMounted, reactive, toRefs} from "vue";
|
||||
import {Navbar, Sidebar, AppMain} from './components'
|
||||
import {
|
||||
sidebar,
|
||||
device,
|
||||
resizeMounted,
|
||||
addEventListenerOnResize,
|
||||
removeEventListenerResize,
|
||||
watchRouter
|
||||
} from './mixin/ResizeHandler'
|
||||
import {useStore} from "@store";
|
||||
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Layout',
|
||||
components: {
|
||||
Navbar,
|
||||
Sidebar,
|
||||
AppMain
|
||||
},
|
||||
mixins: [ResizeMixin],
|
||||
computed: {
|
||||
sidebar() {
|
||||
return this.$store.state.app.sidebar
|
||||
},
|
||||
device() {
|
||||
return this.$store.state.app.device
|
||||
},
|
||||
fixedHeader() {
|
||||
return true
|
||||
},
|
||||
classObj() {
|
||||
return {
|
||||
hideSidebar: !this.sidebar.opened,
|
||||
openSidebar: this.sidebar.opened,
|
||||
withoutAnimation: this.sidebar.withoutAnimation,
|
||||
mobile: this.device === 'mobile'
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleClickOutside() {
|
||||
this.$store.dispatch('app/closeSideBar', { withoutAnimation: false })
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
setup() {
|
||||
const store = useStore()
|
||||
|
||||
const state = reactive({
|
||||
handleClickOutside: () => {
|
||||
store.dispatch('app/closeSideBar', false)
|
||||
}
|
||||
})
|
||||
|
||||
const classObj = computed(() => {
|
||||
return {
|
||||
hideSidebar: !sidebar.opened,
|
||||
openSidebar: sidebar.opened,
|
||||
withoutAnimation: sidebar.withoutAnimation,
|
||||
mobile: device === 'mobile'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
@import "@styles/mixin.scss";
|
||||
@import "@styles/variables.scss";
|
||||
@@ -59,11 +68,13 @@ export default {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
&.mobile.openSidebar{
|
||||
|
||||
&.mobile.openSidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.drawer-bg {
|
||||
background: #000;
|
||||
opacity: 0.3;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {userStore} from '@/store'
|
||||
import {useStore} from '@/store'
|
||||
import {computed, watch} from "vue";
|
||||
import { useRoute } from 'vue-router'
|
||||
const store = userStore()
|
||||
const store = useStore()
|
||||
|
||||
const {body} = document
|
||||
const WIDTH = 992 // refer to Bootstrap's responsive design
|
||||
|
||||
59
src/permission.ts
Normal file
59
src/permission.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import router from "@router";
|
||||
import NProgress from 'nprogress';
|
||||
import {Local} from "@utils/storage";
|
||||
import {useStore} from "@store";
|
||||
import {ElMessage} from "element-plus";
|
||||
|
||||
NProgress.configure({showSpinner: false})
|
||||
|
||||
// 白名单
|
||||
const whiteList = ['/login', '/auth-redirect']
|
||||
|
||||
router.beforeEach(async (to, form, next) => {
|
||||
|
||||
NProgress.start()
|
||||
const store = useStore()
|
||||
|
||||
const hasToken = Local.get(`token`)
|
||||
if (hasToken) {
|
||||
// 如果登录成功,跳转到首页
|
||||
if (to.path === '/login') {
|
||||
next({path: '/'})
|
||||
NProgress.done()
|
||||
} else {
|
||||
const hasGetUserInfo = store.state.user.roles.length > 0
|
||||
if (hasGetUserInfo) {
|
||||
next()
|
||||
} else {
|
||||
try {
|
||||
await store.dispatch('user/getUserInfo')
|
||||
const roles = store.state.user.roles
|
||||
await store.dispatch('permission/generateRoutes', roles)
|
||||
store.state.permission.addRoutes.forEach(route => {
|
||||
router.addRoute(route)
|
||||
})
|
||||
next({...to, replace: true})
|
||||
} catch (error) {
|
||||
// remove token and go to login page to re-login
|
||||
await store.dispatch('user/resetToken')
|
||||
ElMessage.error(error as any || 'Has Error')
|
||||
next(`/login?redirect=${to.path}`)
|
||||
NProgress.done()
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
/* has no token*/
|
||||
if (whiteList.indexOf(to.path) !== -1) {
|
||||
next()
|
||||
} else {
|
||||
next(`/login?redirect=${to.path}`)
|
||||
NProgress.done()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
router.afterEach(() => {
|
||||
NProgress.done()
|
||||
})
|
||||
@@ -1,13 +1,35 @@
|
||||
import {createRouter, createWebHashHistory, RouteRecordRaw} from 'vue-router'
|
||||
import Layout from '@/layout/index.vue'
|
||||
|
||||
const routes: Array<RouteRecordRaw> = [
|
||||
export const constantRoutes: Array<RouteRecordRaw> = [
|
||||
{
|
||||
path: '/redirect',
|
||||
component: Layout,
|
||||
meta: {hidden: true},
|
||||
children: [
|
||||
{
|
||||
path: '/redirect/:path(.*)',
|
||||
component: () => import('@/views/redirect/index.vue')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('@views/login/index.vue'),
|
||||
component: () => import('@/views/login/index.vue'),
|
||||
meta: {title: '登录'}
|
||||
}, {
|
||||
},
|
||||
{
|
||||
path: '/404',
|
||||
component: () => import('@/views/error-page/404.vue'),
|
||||
meta: {hidden: true}
|
||||
},
|
||||
{
|
||||
path: '/401',
|
||||
component: () => import('@/views/error-page/401.vue'),
|
||||
meta: {hidden: true}
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: Layout,
|
||||
redirect: '/dashboard',
|
||||
@@ -22,9 +44,15 @@ const routes: Array<RouteRecordRaw> = [
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes: routes
|
||||
routes: constantRoutes
|
||||
})
|
||||
|
||||
export function resetRouter() {
|
||||
const newRouter = router;
|
||||
(router as any).matcher = (newRouter as any).matcher
|
||||
}
|
||||
|
||||
export default router
|
||||
20
src/settings.ts
Normal file
20
src/settings.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
interface DefaultSettings {
|
||||
title: string,
|
||||
showSettings: boolean,
|
||||
tagsView: boolean,
|
||||
fixedHeader: boolean,
|
||||
sidebarLogo: boolean,
|
||||
errorLog: string
|
||||
}
|
||||
|
||||
|
||||
const defaultSettings: DefaultSettings = {
|
||||
title: 'vue3-element-admin',
|
||||
showSettings: false,
|
||||
tagsView: true,
|
||||
fixedHeader: false,
|
||||
sidebarLogo: false,
|
||||
errorLog: 'production'
|
||||
}
|
||||
|
||||
export default defaultSettings
|
||||
@@ -21,7 +21,7 @@ export const key: InjectionKey<Store<RootStateTypes>> = Symbol()
|
||||
|
||||
export const store = createStore<RootStateTypes>({modules})
|
||||
|
||||
export function userStore(){
|
||||
export function useStore(){
|
||||
return baseUseStore(key)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import {RouteRecordRaw} from "vue-router";
|
||||
|
||||
// 接口类型声明
|
||||
export interface UserState {
|
||||
token: string,
|
||||
@@ -16,8 +18,24 @@ export interface AppState {
|
||||
}
|
||||
}
|
||||
|
||||
export interface SettingState {
|
||||
theme: string,
|
||||
tagsView: boolean,
|
||||
fixedHeader: boolean,
|
||||
showSettings: boolean,
|
||||
sidebarLogo: boolean
|
||||
}
|
||||
|
||||
|
||||
export interface PermissionState{
|
||||
routes:RouteRecordRaw[]
|
||||
addRoutes: RouteRecordRaw[]
|
||||
}
|
||||
|
||||
// 顶级类型声明
|
||||
export interface RootStateTypes {
|
||||
user: UserState,
|
||||
app:AppState
|
||||
app: AppState,
|
||||
setting: SettingState,
|
||||
permission:PermissionState
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import {Module} from "vuex";
|
||||
import {RootStateTypes, AppState} from "@store/interface";
|
||||
import {AppState,RootStateTypes} from "@store/interface";
|
||||
import {Local} from "@utils/storage";
|
||||
|
||||
const appModule: Module<AppState, RootStateTypes> = {
|
||||
|
||||
67
src/store/modules/permission.ts
Normal file
67
src/store/modules/permission.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import {Module} from "vuex";
|
||||
import {PermissionState, RootStateTypes} from "@store/interface";
|
||||
import {RouteRecordRaw} from 'vue-router'
|
||||
import {constantRoutes} from '@/router'
|
||||
import {getRouteList} from "@api/system/menu";
|
||||
|
||||
|
||||
const hasPermission = (roles: string[], route: RouteRecordRaw) => {
|
||||
// 超级管理员放行
|
||||
if (roles.includes('ROOT')) {
|
||||
return true
|
||||
}
|
||||
if (route.meta && route.meta.roles) {
|
||||
return roles.some(role => {
|
||||
if (route.meta?.roles !== undefined) {
|
||||
return (route.meta.roles as string[]).includes(role);
|
||||
}
|
||||
})
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export const filterAsyncRoutes = (routes: RouteRecordRaw[], roles: string[]) => {
|
||||
const res: RouteRecordRaw[] = []
|
||||
routes.forEach(route => {
|
||||
const tmp = {...route}
|
||||
if (hasPermission(roles, tmp)) {
|
||||
if (tmp.children) {
|
||||
tmp.children = filterAsyncRoutes(tmp.children, roles)
|
||||
}
|
||||
res.push(tmp)
|
||||
}
|
||||
})
|
||||
return res
|
||||
}
|
||||
|
||||
|
||||
const permissionModule: Module<PermissionState, RootStateTypes> = {
|
||||
namespaced: true,
|
||||
state: {
|
||||
routes: [],
|
||||
addRoutes: []
|
||||
},
|
||||
mutations: {
|
||||
SET_ROUTES: (state: PermissionState, routes: RouteRecordRaw[]) => {
|
||||
state.addRoutes = routes
|
||||
state.routes = constantRoutes.concat(routes)
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
generateRoutes({commit}, roles: string[]) {
|
||||
return new Promise((resolve, reject) => {
|
||||
getRouteList().then(response => {
|
||||
const asyncRoutes = response.data
|
||||
let accessedRoutes = filterAsyncRoutes(asyncRoutes, roles)
|
||||
commit('SET_ROUTES', accessedRoutes)
|
||||
resolve(accessedRoutes)
|
||||
}).catch(error => {
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
export default permissionModule;
|
||||
49
src/store/modules/settings.ts
Normal file
49
src/store/modules/settings.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import {Module} from "vuex";
|
||||
import {SettingState, RootStateTypes} from "@store/interface";
|
||||
import defaultSettings from '../../settings'
|
||||
|
||||
const {showSettings, tagsView, fixedHeader, sidebarLogo} = defaultSettings
|
||||
|
||||
const settingModule: Module<SettingState, RootStateTypes> = {
|
||||
namespaced: true,
|
||||
state: {
|
||||
theme: '',
|
||||
showSettings: showSettings,
|
||||
tagsView: tagsView,
|
||||
fixedHeader: fixedHeader,
|
||||
sidebarLogo: sidebarLogo,
|
||||
},
|
||||
mutations: {
|
||||
CHANGE_SETTING: (state: SettingState, payload: { key: string, value: any }) => {
|
||||
const {key, value} = payload
|
||||
switch (key) {
|
||||
case 'theme':
|
||||
state.theme = value
|
||||
break
|
||||
case 'showSettings':
|
||||
state.showSettings = value
|
||||
break
|
||||
case 'fixedHeader':
|
||||
state.fixedHeader = value
|
||||
break
|
||||
case 'tagsView':
|
||||
state.tagsView = value
|
||||
break
|
||||
case 'sidebarLogo':
|
||||
state.sidebarLogo = value
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
changeSetting({commit}, data) {
|
||||
commit('CHANGE_SETTING', data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default settingModule;
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import {Module} from "vuex";
|
||||
import {UserState, RootStateTypes} from "@store/interface";
|
||||
import {Local} from "@utils/storage";
|
||||
import {getUserInfo, login, logout} from "@api/login"
|
||||
import {getUserInfo, login, logout} from "@api/login";
|
||||
import {resetRouter} from "@router";
|
||||
|
||||
|
||||
const getDefaultState = () => {
|
||||
return {
|
||||
@@ -107,13 +109,24 @@ const userModule: Module<UserState, RootStateTypes> = {
|
||||
logout().then(() => {
|
||||
Local.remove('token')
|
||||
commit('RESET_STATE')
|
||||
resetRouter()
|
||||
resolve(null)
|
||||
}).catch(error => {
|
||||
reject(error)
|
||||
})
|
||||
}))
|
||||
},
|
||||
|
||||
/**
|
||||
* 清除 Token
|
||||
*/
|
||||
resetToken({commit}){
|
||||
return new Promise(resolve=>{
|
||||
Local.remove('token')
|
||||
commit('RESET_STATE')
|
||||
resolve(null)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
99
src/views/error-page/401.vue
Normal file
99
src/views/error-page/401.vue
Normal file
@@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<div class="errPage-container">
|
||||
<el-button icon="el-icon-arrow-left" class="pan-back-btn" @click="back">
|
||||
返回
|
||||
</el-button>
|
||||
<el-row>
|
||||
<el-col :span="12">
|
||||
<h1 class="text-jumbo text-ginormous">
|
||||
Oops!
|
||||
</h1>
|
||||
gif来源<a href="https://zh.airbnb.com/" target="_blank">airbnb</a> 页面
|
||||
<h2>你没有权限去该页面</h2>
|
||||
<h6>如有不满请联系你领导</h6>
|
||||
<ul class="list-unstyled">
|
||||
<li>或者你可以去:</li>
|
||||
<li class="link-type">
|
||||
<router-link to="/dashboard">
|
||||
回首页
|
||||
</router-link>
|
||||
</li>
|
||||
<li class="link-type">
|
||||
<a href="https://www.taobao.com/">随便看看</a>
|
||||
</li>
|
||||
<li><a href="#" @click.prevent="dialogVisible=true">点我看图</a></li>
|
||||
</ul>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<img :src="errGif" width="313" height="428" alt="Girl has dropped her ice cream.">
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-dialog :visible.sync="dialogVisible" title="随便看">
|
||||
<img :src="ewizardClap" class="pan-img">
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import errGif from '@/assets/401_images/401.gif'
|
||||
|
||||
export default {
|
||||
name: 'Page401',
|
||||
data() {
|
||||
return {
|
||||
errGif: errGif + '?' + +new Date(),
|
||||
ewizardClap: 'https://wpimg.wallstcn.com/007ef517-bafd-4066-aae4-6883632d9646',
|
||||
dialogVisible: false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
back() {
|
||||
if (this.$route.query.noGoBack) {
|
||||
this.$router.push({ path: '/dashboard' })
|
||||
} else {
|
||||
this.$router.go(-1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.errPage-container {
|
||||
width: 800px;
|
||||
max-width: 100%;
|
||||
margin: 100px auto;
|
||||
.pan-back-btn {
|
||||
background: #008489;
|
||||
color: #fff;
|
||||
border: none!important;
|
||||
}
|
||||
.pan-gif {
|
||||
margin: 0 auto;
|
||||
display: block;
|
||||
}
|
||||
.pan-img {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
.text-jumbo {
|
||||
font-size: 60px;
|
||||
font-weight: 700;
|
||||
color: #484848;
|
||||
}
|
||||
.list-unstyled {
|
||||
font-size: 14px;
|
||||
li {
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
a {
|
||||
color: #008489;
|
||||
text-decoration: none;
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
228
src/views/error-page/404.vue
Normal file
228
src/views/error-page/404.vue
Normal file
@@ -0,0 +1,228 @@
|
||||
<template>
|
||||
<div class="wscn-http404-container">
|
||||
<div class="wscn-http404">
|
||||
<div class="pic-404">
|
||||
<img class="pic-404__parent" src="@/assets/404_images/404.png" alt="404">
|
||||
<img class="pic-404__child left" src="@/assets/404_images/404_cloud.png" alt="404">
|
||||
<img class="pic-404__child mid" src="@/assets/404_images/404_cloud.png" alt="404">
|
||||
<img class="pic-404__child right" src="@/assets/404_images/404_cloud.png" alt="404">
|
||||
</div>
|
||||
<div class="bullshit">
|
||||
<div class="bullshit__oops">OOPS!</div>
|
||||
<div class="bullshit__info">All rights reserved
|
||||
<a style="color:#20a0ff" href="https://wallstreetcn.com" target="_blank">wallstreetcn</a>
|
||||
</div>
|
||||
<div class="bullshit__headline">{{ message }}</div>
|
||||
<div class="bullshit__info">Please check that the URL you entered is correct, or click the button below to return to the homepage.</div>
|
||||
<a href="" class="bullshit__return-home">Back to home</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
export default {
|
||||
name: 'Page404',
|
||||
computed: {
|
||||
message() {
|
||||
return 'The webmaster said that you can not enter this page...'
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.wscn-http404-container{
|
||||
transform: translate(-50%,-50%);
|
||||
position: absolute;
|
||||
top: 40%;
|
||||
left: 50%;
|
||||
}
|
||||
.wscn-http404 {
|
||||
position: relative;
|
||||
width: 1200px;
|
||||
padding: 0 50px;
|
||||
overflow: hidden;
|
||||
.pic-404 {
|
||||
position: relative;
|
||||
float: left;
|
||||
width: 600px;
|
||||
overflow: hidden;
|
||||
&__parent {
|
||||
width: 100%;
|
||||
}
|
||||
&__child {
|
||||
position: absolute;
|
||||
&.left {
|
||||
width: 80px;
|
||||
top: 17px;
|
||||
left: 220px;
|
||||
opacity: 0;
|
||||
animation-name: cloudLeft;
|
||||
animation-duration: 2s;
|
||||
animation-timing-function: linear;
|
||||
animation-fill-mode: forwards;
|
||||
animation-delay: 1s;
|
||||
}
|
||||
&.mid {
|
||||
width: 46px;
|
||||
top: 10px;
|
||||
left: 420px;
|
||||
opacity: 0;
|
||||
animation-name: cloudMid;
|
||||
animation-duration: 2s;
|
||||
animation-timing-function: linear;
|
||||
animation-fill-mode: forwards;
|
||||
animation-delay: 1.2s;
|
||||
}
|
||||
&.right {
|
||||
width: 62px;
|
||||
top: 100px;
|
||||
left: 500px;
|
||||
opacity: 0;
|
||||
animation-name: cloudRight;
|
||||
animation-duration: 2s;
|
||||
animation-timing-function: linear;
|
||||
animation-fill-mode: forwards;
|
||||
animation-delay: 1s;
|
||||
}
|
||||
@keyframes cloudLeft {
|
||||
0% {
|
||||
top: 17px;
|
||||
left: 220px;
|
||||
opacity: 0;
|
||||
}
|
||||
20% {
|
||||
top: 33px;
|
||||
left: 188px;
|
||||
opacity: 1;
|
||||
}
|
||||
80% {
|
||||
top: 81px;
|
||||
left: 92px;
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
top: 97px;
|
||||
left: 60px;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@keyframes cloudMid {
|
||||
0% {
|
||||
top: 10px;
|
||||
left: 420px;
|
||||
opacity: 0;
|
||||
}
|
||||
20% {
|
||||
top: 40px;
|
||||
left: 360px;
|
||||
opacity: 1;
|
||||
}
|
||||
70% {
|
||||
top: 130px;
|
||||
left: 180px;
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
top: 160px;
|
||||
left: 120px;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@keyframes cloudRight {
|
||||
0% {
|
||||
top: 100px;
|
||||
left: 500px;
|
||||
opacity: 0;
|
||||
}
|
||||
20% {
|
||||
top: 120px;
|
||||
left: 460px;
|
||||
opacity: 1;
|
||||
}
|
||||
80% {
|
||||
top: 180px;
|
||||
left: 340px;
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
top: 200px;
|
||||
left: 300px;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.bullshit {
|
||||
position: relative;
|
||||
float: left;
|
||||
width: 300px;
|
||||
padding: 30px 0;
|
||||
overflow: hidden;
|
||||
&__oops {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
line-height: 40px;
|
||||
color: #1482f0;
|
||||
opacity: 0;
|
||||
margin-bottom: 20px;
|
||||
animation-name: slideUp;
|
||||
animation-duration: 0.5s;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
&__headline {
|
||||
font-size: 20px;
|
||||
line-height: 24px;
|
||||
color: #222;
|
||||
font-weight: bold;
|
||||
opacity: 0;
|
||||
margin-bottom: 10px;
|
||||
animation-name: slideUp;
|
||||
animation-duration: 0.5s;
|
||||
animation-delay: 0.1s;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
&__info {
|
||||
font-size: 13px;
|
||||
line-height: 21px;
|
||||
color: grey;
|
||||
opacity: 0;
|
||||
margin-bottom: 30px;
|
||||
animation-name: slideUp;
|
||||
animation-duration: 0.5s;
|
||||
animation-delay: 0.2s;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
&__return-home {
|
||||
display: block;
|
||||
float: left;
|
||||
width: 110px;
|
||||
height: 36px;
|
||||
background: #1482f0;
|
||||
border-radius: 100px;
|
||||
text-align: center;
|
||||
color: #ffffff;
|
||||
opacity: 0;
|
||||
font-size: 14px;
|
||||
line-height: 36px;
|
||||
cursor: pointer;
|
||||
animation-name: slideUp;
|
||||
animation-duration: 0.5s;
|
||||
animation-delay: 0.3s;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
@keyframes slideUp {
|
||||
0% {
|
||||
transform: translateY(60px);
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
15
src/views/redirect/index.vue
Normal file
15
src/views/redirect/index.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import {defineComponent} from "vue";
|
||||
import {useRoute, useRouter} from "vue-router";
|
||||
|
||||
export default defineComponent({
|
||||
created() {
|
||||
const {params, query} = useRoute()
|
||||
const {path} = params
|
||||
useRouter().replace({path: '/' + path, query})
|
||||
},
|
||||
render(h) {
|
||||
return h()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user