修改框架

This commit is contained in:
2026-04-22 10:20:53 +08:00
parent e4b54e749f
commit 611cad2672
629 changed files with 1129 additions and 1129 deletions
+140
View File
@@ -0,0 +1,140 @@
<template>
<section class="app-main">
<transition name="fade-transform" mode="out-in">
<keep-alive :include="cachedViews">
<router-view v-if="!$route.meta.link" :key="key" />
</keep-alive>
</transition>
<iframe-toggle />
<copyright />
</section>
</template>
<script>
import copyright from "./Copyright/index"
import iframeToggle from "./IframeToggle/index"
export default {
name: 'AppMain',
components: { iframeToggle, copyright },
computed: {
cachedViews() {
return this.$store.state.tagsView.cachedViews
},
key() {
return this.$route.path
}
},
watch: {
$route() {
this.addIframe()
}
},
mounted() {
this.addIframe()
},
methods: {
addIframe() {
const { name } = this.$route
if (name && this.$route.meta.link) {
this.$store.dispatch('tagsView/addIframeView', this.$route)
}
}
}
}
</script>
<style lang="scss" scoped>
.app-main {
/* 50= navbar 50 */
min-height: calc(100vh - 50px);
width: 100%;
position: relative;
overflow: hidden;
&:fullscreen,
&:-webkit-full-screen,
&:-moz-full-screen,
&:-ms-fullscreen {
background: #fff;
overflow-y: auto;
}
}
.fixed-header + .app-main {
overflow-y: auto;
scrollbar-gutter: auto;
height: calc(100vh - 50px);
min-height: 0px;
}
.app-main:has(.copyright) {
padding-bottom: 36px;
}
.fixed-header + .app-main {
margin-top: 50px;
}
.hasTagsView {
.app-main {
/* 84 = navbar + tags-view = 50 + 34 */
min-height: calc(100vh - 84px);
}
.fixed-header + .app-main {
margin-top: 84px;
height: calc(100vh - 84px);
min-height: 0px;
}
}
/* 移动端fixed-header优化 */
@media screen and (max-width: 991px) {
.fixed-header + .app-main {
padding-bottom: max(60px, calc(constant(safe-area-inset-bottom) + 40px));
padding-bottom: max(60px, calc(env(safe-area-inset-bottom) + 40px));
overscroll-behavior-y: none;
}
.hasTagsView .fixed-header + .app-main {
padding-bottom: max(60px, calc(constant(safe-area-inset-bottom) + 40px));
padding-bottom: max(60px, calc(env(safe-area-inset-bottom) + 40px));
overscroll-behavior-y: none;
}
}
@supports (-webkit-touch-callout: none) {
@media screen and (max-width: 991px) {
.fixed-header + .app-main {
padding-bottom: max(17px, calc(constant(safe-area-inset-bottom) + 10px));
padding-bottom: max(17px, calc(env(safe-area-inset-bottom) + 10px));
height: calc(100svh - 50px);
height: calc(100dvh - 50px);
}
.hasTagsView .fixed-header + .app-main {
padding-bottom: max(17px, calc(constant(safe-area-inset-bottom) + 10px));
padding-bottom: max(17px, calc(env(safe-area-inset-bottom) + 10px));
height: calc(100svh - 84px);
height: calc(100dvh - 84px);
}
}
}
</style>
<style lang="scss">
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background-color: #f1f1f1;
}
::-webkit-scrollbar-thumb {
background-color: #c0c0c0;
border-radius: 3px;
}
</style>
@@ -0,0 +1,35 @@
<template>
<footer v-if="visible" class="copyright">
<span>{{ content }}</span>
</footer>
</template>
<script>
export default {
computed: {
visible() {
return this.$store.state.settings.footerVisible
},
content() {
return this.$store.state.settings.footerContent
}
}
}
</script>
<style scoped>
.copyright {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 36px;
padding: 10px 20px;
text-align: right;
background-color: #f8f8f8;
color: #666;
font-size: 14px;
border-top: 1px solid #e7e7e7;
z-index: 999;
}
</style>
@@ -0,0 +1,362 @@
<template>
<el-drawer title="公告详情" :visible.sync="visible" direction="rtl" size="50%" append-to-body :before-close="handleClose" custom-class="notice-detail-drawer">
<div v-loading="loading" class="notice-detail-drawer__body">
<div v-if="!detail" class="notice-empty">
<i class="el-icon-document"></i>
<span>暂无数据</span>
</div>
<div v-else class="notice-page">
<div class="notice-type-wrap">
<span v-if="detail.noticeType === '1'" class="notice-type-tag type-notify">
<i class="el-icon-bell"></i> 通知
</span>
<span v-else-if="detail.noticeType === '2'" class="notice-type-tag type-announce">
<i class="el-icon-message"></i> 公告
</span>
<span v-else class="notice-type-tag type-notify">
<i class="el-icon-document"></i> 消息
</span>
</div>
<h1 class="notice-title">{{ detail.noticeTitle }}</h1>
<div class="notice-meta">
<span class="meta-item">
<i class="el-icon-user"></i>
<span>{{ detail.createBy || '—' }}</span>
</span>
<span class="meta-item">
<i class="el-icon-time"></i>
<span>{{ detail.createTime || '—' }}</span>
</span>
<span class="meta-item">
<span :class="['status-dot', isStatusNormal ? 'status-ok' : 'status-off']"></span>
<span>{{ isStatusNormal ? '正常' : '已关闭' }}</span>
</span>
</div>
<div class="notice-divider">
<span class="notice-divider-dot"></span>
<span class="notice-divider-dot"></span>
<span class="notice-divider-dot"></span>
</div>
<div class="notice-body">
<div v-if="hasContent" class="notice-content" v-html="detail.noticeContent" />
<div v-else class="notice-empty notice-empty--inner">
<i class="el-icon-document"></i> 暂无内容
</div>
</div>
</div>
</div>
</el-drawer>
</template>
<script>
import { getNotice } from '@/api/system/notice'
export default {
name: 'NoticeDetailView',
data() {
return {
visible: false,
loading: false,
detail: null
}
},
computed: {
isStatusNormal() {
const s = this.detail && this.detail.status
return s === '0' || s === 0
},
hasContent() {
const c = this.detail && this.detail.noticeContent
return c != null && String(c).trim() !== ''
}
},
methods: {
open(payload) {
let id = null
let preset = null
if (payload != null && typeof payload === 'object') {
id = payload.noticeId
if (payload.noticeContent != null) {
preset = payload
}
} else {
id = payload
}
this.visible = true
if (preset) {
this.detail = preset
return
}
if (id == null || id === '') {
this.detail = null
return
}
this.loading = true
this.detail = null
getNotice(id).then(res => {
this.detail = res.data
}).catch(() => {
this.detail = null
}).finally(() => {
this.loading = false
})
},
handleClose() {
this.visible = false
this.detail = null
this.loading = false
}
}
}
</script>
<style lang="scss" scoped>
.notice-page {
max-width: 760px;
margin: 0 auto;
padding: 8px 8px 20px;
animation: notice-fade-up 0.28s ease both;
}
@keyframes notice-fade-up {
from {
opacity: 0;
transform: translateY(14px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.notice-type-tag {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 12px;
border-radius: 2px;
font-size: 11px;
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
margin-bottom: 14px;
}
.type-notify {
background: #fff8e6;
color: #b7791f;
border-left: 3px solid #d97706;
}
.type-announce {
background: #e8f5e9;
color: #276749;
border-left: 3px solid #38a169;
}
.notice-title {
font-size: 22px;
font-weight: 700;
color: #1a202c;
line-height: 1.45;
margin: 0 0 16px;
letter-spacing: -0.2px;
}
.notice-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 16px;
padding: 12px 0;
border-top: 1px solid #e9ecef;
border-bottom: 1px solid #e9ecef;
margin-bottom: 28px;
}
.meta-item {
display: flex;
align-items: center;
gap: 5px;
font-size: 12px;
color: #718096;
}
.meta-item i {
font-size: 12px;
color: #a0aec0;
}
.status-dot {
display: inline-block;
width: 7px;
height: 7px;
border-radius: 50%;
margin-right: 4px;
}
.status-ok {
background: #38a169;
}
.status-off {
background: #e53e3e;
}
.notice-divider {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 24px;
}
.notice-divider::before,
.notice-divider::after {
content: '';
flex: 1;
height: 1px;
background: linear-gradient(to right, transparent, #dee2e6, transparent);
}
.notice-divider-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #cbd5e0;
}
.notice-body {
background: #fff;
border-radius: 6px;
padding: 28px 32px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(0, 0, 0, 0.04);
min-height: 120px;
}
.notice-content {
font-size: 14px;
line-height: 1.85;
color: #2d3748;
word-break: break-word;
}
.notice-content ::v-deep p {
margin: 0 0 1em;
}
.notice-content ::v-deep h1,
.notice-content ::v-deep h2,
.notice-content ::v-deep h3 {
font-weight: 700;
color: #1a202c;
margin: 1.4em 0 0.6em;
}
.notice-content ::v-deep h1 {
font-size: 18px;
}
.notice-content ::v-deep h2 {
font-size: 16px;
}
.notice-content ::v-deep h3 {
font-size: 14px;
}
.notice-content ::v-deep a {
color: #3182ce;
text-decoration: underline;
}
.notice-content ::v-deep a:hover {
color: #2b6cb0;
}
.notice-content ::v-deep img {
max-width: 100%;
border-radius: 4px;
margin: 8px 0;
}
.notice-content ::v-deep ul,
.notice-content ::v-deep ol {
padding-left: 20px;
margin: 0 0 1em;
}
.notice-content ::v-deep li {
margin-bottom: 4px;
}
.notice-content ::v-deep blockquote {
border-left: 3px solid #cbd5e0;
margin: 1em 0;
padding: 6px 16px;
color: #718096;
background: #f7fafc;
}
.notice-content ::v-deep table {
border-collapse: collapse;
width: 100%;
margin: 1em 0;
font-size: 13px;
}
.notice-content ::v-deep table th,
.notice-content ::v-deep table td {
border: 1px solid #e2e8f0;
padding: 7px 12px;
}
.notice-content ::v-deep table th {
background: #f7fafc;
font-weight: 600;
}
.notice-empty {
text-align: center;
padding: 40px 0;
color: #a0aec0;
font-size: 13px;
}
.notice-empty i {
font-size: 28px;
display: block;
margin-bottom: 10px;
}
.notice-empty--inner {
padding: 32px 0;
}
.notice-empty--inner i {
font-size: 28px;
}
::v-deep .notice-detail-drawer {
.el-drawer__header {
margin-bottom: 0;
padding: 16px 20px;
border-bottom: 1px solid #ebeef5;
font-size: 16px;
font-weight: 600;
color: #303133;
}
.el-drawer__body {
background: #f5f6f8;
}
}
.notice-detail-drawer__body {
height: 100%;
overflow: auto;
padding: 10px 16px 22px;
}
</style>
@@ -0,0 +1,181 @@
<template>
<div>
<el-popover ref="noticePopover" placement="bottom-end" width="320" trigger="manual" :value="noticeVisible" popper-class="notice-popover">
<div class="notice-header">
<span class="notice-title">通知公告</span>
<span class="notice-mark-all" @click="markAllRead">全部已读</span>
</div>
<div v-if="noticeLoading" class="notice-loading"><i class="el-icon-loading"></i> 加载中...</div>
<div v-else-if="noticeList.length === 0" class="notice-empty"><i class="el-icon-inbox"></i><br>暂无公告</div>
<div v-else>
<div v-for="item in noticeList" :key="item.noticeId" class="notice-item" :class="{ 'is-read': item.isRead }" @click="previewNotice(item)">
<el-tag size="mini" :type="item.noticeType === '1' ? 'warning' : 'success'" class="notice-tag">
{{ item.noticeType === '1' ? '通知' : '公告' }}
</el-tag>
<span class="notice-item-title">{{ item.noticeTitle }}</span>
<span class="notice-item-date">{{ item.createTime }}</span>
</div>
</div>
</el-popover>
<div v-popover:noticePopover class="right-menu-item hover-effect notice-trigger" @mouseenter="onNoticeEnter" @mouseleave="onNoticeLeave">
<svg-icon icon-class="bell" />
<span v-if="unreadCount > 0" class="notice-badge">{{ unreadCount }}</span>
</div>
<notice-detail-view ref="noticeViewRef" />
</div>
</template>
<script>
import NoticeDetailView from './DetailView'
import { listNoticeTop, markNoticeRead, markNoticeReadAll } from '@/api/system/notice'
export default {
name: 'HeaderNotice',
components: { NoticeDetailView },
data() {
return {
noticeList: [], // 通知列表
unreadCount: 0, // 未读数量
noticeLoading: false, // 加载状态
noticeVisible: false, // 弹出层显示状态
noticeLeaveTimer: null // 鼠标离开计时器
}
},
mounted() {
this.loadNoticeTop()
},
methods: {
// 鼠标移入铃铛区域
onNoticeEnter() {
clearTimeout(this.noticeLeaveTimer)
this.noticeVisible = true
this.$nextTick(() => {
const popper = this.$refs.noticePopover.$refs.popper
if (popper && !popper._noticeBound) {
popper._noticeBound = true
popper.addEventListener('mouseenter', () => clearTimeout(this.noticeLeaveTimer))
popper.addEventListener('mouseleave', () => {
this.noticeLeaveTimer = setTimeout(() => { this.noticeVisible = false }, 100)
})
}
})
},
// 鼠标离开铃铛区域
onNoticeLeave() {
this.noticeLeaveTimer = setTimeout(() => { this.noticeVisible = false }, 150)
},
// 加载顶部公告列表
loadNoticeTop() {
this.noticeLoading = true
listNoticeTop().then(res => {
this.noticeList = res.data || []
this.unreadCount = res.unreadCount !== undefined ? res.unreadCount : this.noticeList.filter(n => !n.isRead).length
}).finally(() => {
this.noticeLoading = false
})
},
// 预览公告详情
previewNotice(item) {
if (!item.isRead) {
markNoticeRead(item.noticeId).catch(() => {})
item.isRead = true
const idx = this.noticeList.indexOf(item)
if (idx !== -1) this.$set(this.noticeList, idx, { ...item, isRead: true })
this.unreadCount = Math.max(0, this.unreadCount - 1)
}
this.$refs.noticeViewRef.open(item.noticeId)
},
// 全部已读
markAllRead() {
const ids = this.noticeList.map(n => n.noticeId).join(',')
if (!ids) return
markNoticeReadAll(ids).catch(() => {})
this.noticeList = this.noticeList.map(n => ({ ...n, isRead: true }))
this.unreadCount = 0
}
}
}
</script>
<style lang="scss" scoped>
.notice-trigger {
position: relative;
transform: translateX(-6px);
.svg-icon { width: 1.2em; height: 1.2em; vertical-align: -0.2em; }
.notice-badge {
position: absolute;
top: 7px;
right: -3px;
background: #f56c6c;
color: #fff;
border-radius: 10px;
font-size: 10px;
height: 16px;
line-height: 16px;
padding: 0 4px;
min-width: 16px;
text-align: center;
white-space: nowrap;
pointer-events: none;
}
}
.notice-popover {
padding: 0 !important;
}
.notice-popover .notice-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
background: #f7f9fb;
border-bottom: 1px solid #eee;
font-size: 13px;
font-weight: 600;
color: #333;
}
.notice-popover .notice-mark-all {
font-size: 12px;
color: #409EFF;
font-weight: normal;
cursor: pointer;
}
.notice-popover .notice-mark-all:hover { color: #2b7cc1; }
.notice-popover .notice-loading,
.notice-popover .notice-empty {
padding: 24px;
text-align: center;
color: #bbb;
font-size: 12px;
line-height: 1.8;
}
.notice-popover .notice-item {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-bottom: 1px solid #f5f5f5;
cursor: pointer;
transition: background 0.15s;
}
.notice-popover .notice-item:last-child { border-bottom: none; }
.notice-popover .notice-item:hover { background: #f7f9fb; }
.notice-popover .notice-item.is-read .notice-tag,
.notice-popover .notice-item.is-read .notice-item-title,
.notice-popover .notice-item.is-read .notice-item-date { opacity: 0.45; filter: grayscale(1); color: #999; }
.notice-popover .notice-tag { flex-shrink: 0; }
.notice-popover .notice-item-title {
flex: 1;
font-size: 12px;
color: #333;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.notice-popover .notice-item-date {
flex-shrink: 0;
font-size: 11px;
color: #bbb;
}
</style>
@@ -0,0 +1,33 @@
<template>
<transition-group name="fade-transform" mode="out-in">
<inner-link
v-for="(item, index) in iframeViews"
:key="item.path"
:iframeId="'iframe' + index"
v-show="$route.path === item.path"
:src="iframeUrl(item.meta.link, item.query)"
></inner-link>
</transition-group>
</template>
<script>
import InnerLink from "../InnerLink/index"
export default {
components: { InnerLink },
computed: {
iframeViews() {
return this.$store.state.tagsView.iframeViews
}
},
methods: {
iframeUrl(url, query) {
if (Object.keys(query).length > 0) {
let params = Object.keys(query).map((key) => key + "=" + query[key]).join("&")
return url + "?" + params
}
return url
}
}
}
</script>
@@ -0,0 +1,47 @@
<template>
<div :style="'height:' + height" v-loading="loading" element-loading-text="正在加载页面,请稍候!">
<iframe
:id="iframeId"
style="width: 100%; height: 100%"
:src="src"
frameborder="no"
></iframe>
</div>
</template>
<script>
export default {
props: {
src: {
type: String,
default: "/"
},
iframeId: {
type: String
}
},
data() {
return {
loading: false,
height: document.documentElement.clientHeight - 94.5 + "px;"
}
},
mounted() {
var _this = this
const iframeId = ("#" + this.iframeId).replace(/\//g, "\\/")
const iframe = document.querySelector(iframeId)
// iframe页面loading控制
if (iframe.attachEvent) {
this.loading = true
iframe.attachEvent("onload", function () {
_this.loading = false
})
} else {
this.loading = true
iframe.onload = function () {
_this.loading = false
}
}
}
}
</script>
+254
View File
@@ -0,0 +1,254 @@
<template>
<div class="navbar" :class="'nav' + navType">
<hamburger id="hamburger-container" :is-active="sidebar.opened" class="hamburger-container" @toggleClick="toggleSideBar" />
<breadcrumb v-if="navType == 1" id="breadcrumb-container" class="breadcrumb-container" />
<top-nav v-if="navType == 2" id="topmenu-container" class="topmenu-container" />
<template v-if="navType == 3">
<logo v-show="showLogo" :collapse="false"></logo>
<top-bar id="topbar-container" class="topbar-container" />
</template>
<div class="right-menu">
<template v-if="device!=='mobile'">
<search id="header-search" class="right-menu-item" />
<el-tooltip content="源码地址" effect="dark" placement="bottom">
<ruo-yi-git id="ruoyi-git" class="right-menu-item hover-effect" />
</el-tooltip>
<el-tooltip content="文档地址" effect="dark" placement="bottom">
<ruo-yi-doc id="ruoyi-doc" class="right-menu-item hover-effect" />
</el-tooltip>
<screenfull id="screenfull" class="right-menu-item hover-effect" />
<el-tooltip content="布局大小" effect="dark" placement="bottom">
<size-select id="size-select" class="right-menu-item hover-effect" />
</el-tooltip>
<el-tooltip content="消息通知" effect="dark" placement="bottom">
<header-notice id="header-notice" class="right-menu-item hover-effect" />
</el-tooltip>
</template>
<el-dropdown class="avatar-container right-menu-item hover-effect" trigger="hover">
<div class="avatar-wrapper">
<img :src="avatar" class="user-avatar">
<span class="user-nickname"> {{ nickName }} </span>
</div>
<el-dropdown-menu slot="dropdown">
<router-link to="/user/profile">
<el-dropdown-item>个人中心</el-dropdown-item>
</router-link>
<el-dropdown-item @click.native="setLayout" v-if="setting">
<span>布局设置</span>
</el-dropdown-item>
<el-dropdown-item @click.native="lockScreen">
<span>锁定屏幕</span>
</el-dropdown-item>
<el-dropdown-item divided @click.native="logout">
<span>退出登录</span>
</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
import Breadcrumb from '@/components/Breadcrumb'
import TopNav from './TopNav'
import TopBar from './TopBar'
import Logo from './Sidebar/Logo'
import Hamburger from '@/components/Hamburger'
import Screenfull from '@/components/Screenfull'
import SizeSelect from '@/components/SizeSelect'
import Search from '@/components/HeaderSearch'
import RuoYiGit from '@/components/RuoYi/Git'
import RuoYiDoc from '@/components/RuoYi/Doc'
import HeaderNotice from './HeaderNotice'
export default {
components: {
Breadcrumb,
Logo,
TopNav,
TopBar,
Hamburger,
Screenfull,
SizeSelect,
Search,
RuoYiGit,
RuoYiDoc,
HeaderNotice
},
computed: {
...mapGetters([
'sidebar',
'avatar',
'device',
'nickName'
]),
setting: {
get() {
return this.$store.state.settings.showSettings
}
},
navType: {
get() {
return this.$store.state.settings.navType
}
},
showLogo: {
get() {
return this.$store.state.settings.sidebarLogo
}
}
},
methods: {
toggleSideBar() {
this.$store.dispatch('app/toggleSideBar')
},
setLayout(event) {
this.$emit('setLayout')
},
lockScreen() {
const currentPath = this.$route.fullPath
this.$store.dispatch('lock/lockScreen', currentPath).then(() => {
this.$router.push('/lock')
})
},
logout() {
this.$confirm('确定注销并退出系统吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
this.$store.dispatch('LogOut').then(() => {
location.href = '/index'
})
}).catch(() => {})
}
}
}
</script>
<style lang="scss" scoped>
.navbar.nav3 {
.hamburger-container {
display: none !important;
}
}
.navbar {
height: 50px;
overflow: hidden;
position: relative;
background: #fff;
box-shadow: 0 1px 4px rgba(0,21,41,.08);
display: flex;
align-items: center;
// padding: 0 8px;
box-sizing: border-box;
.hamburger-container {
line-height: 46px;
height: 100%;
cursor: pointer;
transition: background .3s;
-webkit-tap-highlight-color:transparent;
display: flex;
align-items: center;
flex-shrink: 0;
margin-right: 8px;
&:hover {
background: rgba(0, 0, 0, .025)
}
}
.breadcrumb-container {
flex-shrink: 0;
}
.topmenu-container {
position: absolute;
left: 50px;
}
.topbar-container {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
overflow: hidden;
margin-left: 8px;
}
.right-menu {
height: 100%;
line-height: 50px;
display: flex;
align-items: center;
margin-left: auto;
&:focus {
outline: none;
}
.right-menu-item {
display: inline-block;
padding: 0 8px;
height: 100%;
font-size: 18px;
color: #5a5e66;
vertical-align: text-bottom;
&.hover-effect {
cursor: pointer;
transition: background .3s;
&:hover {
background: rgba(0, 0, 0, .025)
}
}
}
.avatar-container {
margin-right: 0px;
padding-right: 0px;
.avatar-wrapper {
margin-top: 10px;
right: 8px;
position: relative;
.user-avatar {
cursor: pointer;
width: 30px;
height: 30px;
border-radius: 50%;
}
.user-nickname{
position: relative;
bottom: 10px;
left: 2px;
font-size: 14px;
font-weight: bold;
}
.el-icon-caret-bottom {
cursor: pointer;
position: absolute;
right: -20px;
top: 25px;
font-size: 12px;
}
}
}
}
}
</style>
@@ -0,0 +1,430 @@
<template>
<el-drawer size="280px" :visible="showSettings" :with-header="false" :append-to-body="true" :before-close="closeSetting" :lock-scroll="false">
<div class="drawer-container">
<div>
<div class="setting-drawer-content">
<div class="setting-drawer-title">
<h3 class="drawer-title">菜单导航设置</h3>
</div>
<div class="nav-wrap">
<el-tooltip content="左侧菜单" placement="bottom">
<div class="item left" @click="handleNavType(1)" :style="{'--theme': theme}" :class="{ activeItem: navType == 1 }">
<b></b><b></b>
</div>
</el-tooltip>
<el-tooltip content="混合菜单" placement="bottom">
<div class="item mix" @click="handleNavType(2)" :style="{'--theme': theme}" :class="{ activeItem: navType == 2 }">
<b></b><b></b>
</div>
</el-tooltip>
<el-tooltip content="顶部菜单" placement="bottom">
<div class="item top" @click="handleNavType(3)" :style="{'--theme': theme}" :class="{ activeItem: navType == 3 }">
<b></b><b></b>
</div>
</el-tooltip>
</div>
<div class="setting-drawer-title">
<h3 class="drawer-title">主题风格设置</h3>
</div>
<div class="setting-drawer-block-checbox">
<div class="setting-drawer-block-checbox-item" @click="handleTheme('theme-dark')">
<img src="@/assets/images/dark.svg" alt="dark">
<div v-if="sideTheme === 'theme-dark'" class="setting-drawer-block-checbox-selectIcon" style="display: block;">
<i aria-label="图标: check" class="anticon anticon-check">
<svg viewBox="64 64 896 896" data-icon="check" width="1em" height="1em" :fill="theme" aria-hidden="true" focusable="false" class="">
<path d="M912 190h-69.9c-9.8 0-19.1 4.5-25.1 12.2L404.7 724.5 207 474a32 32 0 0 0-25.1-12.2H112c-6.7 0-10.4 7.7-6.3 12.9l273.9 347c12.8 16.2 37.4 16.2 50.3 0l488.4-618.9c4.1-5.1.4-12.8-6.3-12.8z"/>
</svg>
</i>
</div>
</div>
<div class="setting-drawer-block-checbox-item" @click="handleTheme('theme-light')">
<img src="@/assets/images/light.svg" alt="light">
<div v-if="sideTheme === 'theme-light'" class="setting-drawer-block-checbox-selectIcon" style="display: block;">
<i aria-label="图标: check" class="anticon anticon-check">
<svg viewBox="64 64 896 896" data-icon="check" width="1em" height="1em" :fill="theme" aria-hidden="true" focusable="false" class="">
<path d="M912 190h-69.9c-9.8 0-19.1 4.5-25.1 12.2L404.7 724.5 207 474a32 32 0 0 0-25.1-12.2H112c-6.7 0-10.4 7.7-6.3 12.9l273.9 347c12.8 16.2 37.4 16.2 50.3 0l488.4-618.9c4.1-5.1.4-12.8-6.3-12.8z"/>
</svg>
</i>
</div>
</div>
</div>
<div class="drawer-item">
<span>主题颜色</span>
<theme-picker style="float: right;height: 26px;margin: -3px 8px 0 0;" @change="themeChange" />
</div>
</div>
<el-divider/>
<h3 class="drawer-title">系统布局配置</h3>
<div class="drawer-item">
<span>开启页签</span>
<el-switch v-model="tagsView" class="drawer-switch" />
</div>
<div class="drawer-item">
<span>持久化标签页</span>
<el-switch v-model="tagsViewPersist" :disabled="!tagsView" class="drawer-switch" />
</div>
<div class="drawer-item">
<span>显示页签图标</span>
<el-switch v-model="tagsIcon" :disabled="!tagsView" class="drawer-switch" />
</div>
<div class="drawer-item">
<span>标签页样式</span>
<el-radio-group v-model="tagsViewStyle" :disabled="!tagsView" size="mini" class="drawer-switch">
<el-radio-button label="card">卡片</el-radio-button>
<el-radio-button label="chrome">谷歌</el-radio-button>
</el-radio-group>
</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 class="drawer-item">
<span>动态标题</span>
<el-switch v-model="dynamicTitle" class="drawer-switch" />
</div>
<div class="drawer-item">
<span>底部版权</span>
<el-switch v-model="footerVisible" class="drawer-switch" />
</div>
<el-divider/>
<el-button size="small" type="primary" plain icon="el-icon-document-add" @click="saveSetting">保存配置</el-button>
<el-button size="small" plain icon="el-icon-refresh" @click="resetSetting">重置配置</el-button>
</div>
</div>
</el-drawer>
</template>
<script>
import ThemePicker from '@/components/ThemePicker'
export default {
components: { ThemePicker },
expose: ['openSetting'],
data() {
return {
theme: this.$store.state.settings.theme,
sideTheme: this.$store.state.settings.sideTheme,
navType: this.$store.state.settings.navType,
showSettings: false
}
},
computed: {
fixedHeader: {
get() {
return this.$store.state.settings.fixedHeader
},
set(val) {
this.$store.dispatch('settings/changeSetting', {
key: 'fixedHeader',
value: val
})
}
},
tagsViewPersist: {
get() {
return this.$store.state.settings.tagsViewPersist
},
set(val) {
this.$store.dispatch('settings/changeSetting', {
key: 'tagsViewPersist',
value: val
})
}
},
tagsView: {
get() {
return this.$store.state.settings.tagsView
},
set(val) {
this.$store.dispatch('settings/changeSetting', {
key: 'tagsView',
value: val
})
}
},
tagsIcon: {
get() {
return this.$store.state.settings.tagsIcon
},
set(val) {
this.$store.dispatch('settings/changeSetting', {
key: 'tagsIcon',
value: val
})
}
},
tagsViewStyle: {
get() {
return this.$store.state.settings.tagsViewStyle
},
set(val) {
this.$store.dispatch('settings/changeSetting', {
key: 'tagsViewStyle',
value: val
})
}
},
sidebarLogo: {
get() {
return this.$store.state.settings.sidebarLogo
},
set(val) {
this.$store.dispatch('settings/changeSetting', {
key: 'sidebarLogo',
value: val
})
}
},
dynamicTitle: {
get() {
return this.$store.state.settings.dynamicTitle
},
set(val) {
this.$store.dispatch('settings/changeSetting', {
key: 'dynamicTitle',
value: val
})
this.$store.dispatch('settings/setTitle', this.$store.state.settings.title)
}
},
footerVisible: {
get() {
return this.$store.state.settings.footerVisible
},
set(val) {
this.$store.dispatch('settings/changeSetting', {
key: 'footerVisible',
value: val
})
}
}
},
watch: {
navType: {
handler(val) {
if (val == 1) {
this.$store.dispatch("app/toggleSideBarHide", false)
}
if (val == 2) {
}
if (val == 3) {
this.$store.dispatch("app/toggleSideBarHide", true)
}
if ([1, 3].includes(val)) {
this.$store.commit("SET_SIDEBAR_ROUTERS",this.$store.state.permission.defaultRoutes)
}
},
immediate: true,
deep: true
}
},
methods: {
themeChange(val) {
this.$store.dispatch('settings/changeSetting', {
key: 'theme',
value: val
})
this.theme = val
},
handleTheme(val) {
this.$store.dispatch('settings/changeSetting', {
key: 'sideTheme',
value: val
})
this.sideTheme = val
},
handleNavType(val) {
this.$store.dispatch('settings/changeSetting', {
key: 'navType',
value: val
})
this.navType = val
},
openSetting() {
this.showSettings = true
},
closeSetting(){
this.showSettings = false
},
saveSetting() {
this.$modal.loading("正在保存到本地,请稍候...")
if (!this.tagsViewPersist) {
this.$cache.local.remove('tags-view-visited')
}
this.$cache.local.set(
"layout-setting",
`{
"navType":${this.navType},
"tagsView":${this.tagsView},
"tagsIcon":${this.tagsIcon},
"tagsViewStyle":"${this.tagsViewStyle}",
"tagsViewPersist":${this.tagsViewPersist},
"fixedHeader":${this.fixedHeader},
"sidebarLogo":${this.sidebarLogo},
"dynamicTitle":${this.dynamicTitle},
"footerVisible":${this.footerVisible},
"sideTheme":"${this.sideTheme}",
"theme":"${this.theme}"
}`
)
setTimeout(this.$modal.closeLoading(), 1000)
},
resetSetting() {
this.$modal.loading("正在清除设置缓存并刷新,请稍候...")
this.$cache.local.remove('tags-view-visited')
this.$cache.local.remove("layout-setting")
setTimeout("window.location.reload()", 1000)
}
}
}
</script>
<style lang="scss" scoped>
.setting-drawer-content {
.setting-drawer-title {
margin-bottom: 12px;
color: rgba(0, 0, 0, .85);
font-size: 14px;
line-height: 22px;
font-weight: bold;
}
.setting-drawer-block-checbox {
display: flex;
justify-content: flex-start;
align-items: center;
margin-top: 10px;
margin-bottom: 20px;
.setting-drawer-block-checbox-item {
position: relative;
margin-right: 16px;
border-radius: 2px;
cursor: pointer;
img {
width: 48px;
height: 48px;
}
.setting-drawer-block-checbox-selectIcon {
position: absolute;
top: 0;
right: 0;
width: 100%;
height: 100%;
padding-top: 15px;
padding-left: 24px;
color: #1890ff;
font-weight: 700;
font-size: 14px;
}
}
}
}
.drawer-container {
padding: 20px;
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
}
}
// 导航模式
.nav-wrap {
display: flex;
justify-content: flex-start;
align-items: center;
margin-top: 10px;
margin-bottom: 20px;
.activeItem {
border: 2px solid #{'var(--theme)'} !important;
}
.item {
position: relative;
margin-right: 16px;
cursor: pointer;
width: 56px;
height: 48px;
border-radius: 4px;
background: #f0f2f5;
border: 2px solid transparent;
}
.left {
b:first-child {
display: block;
height: 30%;
background: #fff;
}
b:last-child {
width: 30%;
background: #1b2a47;
position: absolute;
height: 100%;
top: 0;
border-radius: 4px 0 0 4px;
}
}
.mix {
b:first-child {
border-radius: 4px 4px 0 0;
display: block;
height: 30%;
background: #1b2a47;
}
b:last-child {
width: 30%;
background: #1b2a47;
position: absolute;
height: 70%;
border-radius: 0 0 0 4px;
}
}
.top {
b:first-child {
display: block;
height: 30%;
background: #1b2a47;
border-radius: 4px 4px 0 0;
}
}
}
</style>
@@ -0,0 +1,25 @@
export default {
computed: {
device() {
return this.$store.state.app.device
}
},
mounted() {
// In order to fix the click on menu on the ios device will trigger the mouseleave bug
this.fixBugIniOS()
},
methods: {
fixBugIniOS() {
const $subMenu = this.$refs.subMenu
if ($subMenu) {
const handleMouseleave = $subMenu.handleMouseleave
$subMenu.handleMouseleave = (e) => {
if (this.device === 'mobile') {
return
}
handleMouseleave(e)
}
}
}
}
}
@@ -0,0 +1,33 @@
<script>
export default {
name: 'MenuItem',
functional: true,
props: {
icon: {
type: String,
default: ''
},
title: {
type: String,
default: ''
}
},
render(h, context) {
const { icon, title } = context.props
const vnodes = []
if (icon) {
vnodes.push(<svg-icon icon-class={icon}/>)
}
if (title) {
if (title.length > 5) {
vnodes.push(<span slot='title' title={(title)}>{(title)}</span>)
} else {
vnodes.push(<span slot='title'>{(title)}</span>)
}
}
return vnodes
}
}
</script>
@@ -0,0 +1,43 @@
<template>
<component :is="type" v-bind="linkProps(to)">
<slot />
</component>
</template>
<script>
import { isExternal } from '@/utils/validate'
export default {
props: {
to: {
type: [String, Object],
required: true
}
},
computed: {
isExternal() {
return isExternal(this.to)
},
type() {
if (this.isExternal) {
return 'a'
}
return 'router-link'
}
},
methods: {
linkProps(to) {
if (this.isExternal) {
return {
href: to,
target: '_blank',
rel: 'noopener'
}
}
return {
to: to
}
}
}
}
</script>
@@ -0,0 +1,95 @@
<template>
<div class="sidebar-logo-container" :class="{'collapse':collapse}" :style="{ backgroundColor: sideTheme === 'theme-dark' && navType !== 3 ? variables.menuBackground : variables.menuLightBackground }">
<transition name="sidebarLogoFade">
<router-link v-if="collapse" key="collapse" class="sidebar-logo-link" to="/">
<img v-if="logo" :src="logo" class="sidebar-logo" />
<h1 v-else class="sidebar-title" :style="{ color: sideTheme === 'theme-dark' && navType !== 3 ? variables.logoTitleColor : variables.logoLightTitleColor }">{{ title }} </h1>
</router-link>
<router-link v-else key="expand" class="sidebar-logo-link" to="/">
<img v-if="logo" :src="logo" class="sidebar-logo" />
<h1 class="sidebar-title" :style="{ color: sideTheme === 'theme-dark' && navType !== 3 ? variables.logoTitleColor : variables.logoLightTitleColor }">{{ title }} </h1>
</router-link>
</transition>
</div>
</template>
<script>
import logoImg from '@/assets/logo/logo.png'
import variables from '@/assets/styles/variables.scss'
export default {
name: 'SidebarLogo',
props: {
collapse: {
type: Boolean,
required: true
}
},
computed: {
variables() {
return variables
},
sideTheme() {
return this.$store.state.settings.sideTheme
},
navType() {
return this.$store.state.settings.navType
}
},
data() {
return {
title: process.env.VUE_APP_TITLE,
logo: logoImg
}
}
}
</script>
<style lang="scss" scoped>
.sidebarLogoFade-enter-active {
transition: opacity 1.5s;
}
.sidebarLogoFade-enter,
.sidebarLogoFade-leave-to {
opacity: 0;
}
.sidebar-logo-container {
position: relative;
height: 50px;
line-height: 50px;
background: #2b2f3a;
text-align: center;
overflow: hidden;
& .sidebar-logo-link {
height: 100%;
width: 100%;
& .sidebar-logo {
width: 32px;
height: 32px;
vertical-align: middle;
margin-right: 12px;
}
& .sidebar-title {
display: inline-block;
margin: 0;
color: #fff;
font-weight: 600;
line-height: 50px;
font-size: 14px;
font-family: Avenir, Helvetica Neue, Arial, Helvetica, sans-serif;
vertical-align: middle;
}
}
&.collapse {
.sidebar-logo {
margin-right: 0px;
}
}
}
</style>
@@ -0,0 +1,99 @@
<template>
<div v-if="!item.hidden">
<template v-if="hasOneShowingChild(item.children,item) && (!onlyOneChild.children||onlyOneChild.noShowingChildren)&&!item.alwaysShow">
<app-link v-if="onlyOneChild.meta" :to="resolvePath(onlyOneChild.path, onlyOneChild.query)">
<el-menu-item :index="resolvePath(onlyOneChild.path)" :class="{'submenu-title-noDropdown':!isNest}">
<item :icon="onlyOneChild.meta.icon||(item.meta&&item.meta.icon)" :title="onlyOneChild.meta.title" />
</el-menu-item>
</app-link>
</template>
<el-submenu v-else ref="subMenu" :index="resolvePath(item.path)" popper-append-to-body>
<template slot="title">
<item v-if="item.meta" :icon="item.meta && item.meta.icon" :title="item.meta.title" />
</template>
<sidebar-item
v-for="(child, index) in item.children"
:key="child.path + index"
:is-nest="true"
:item="child"
:base-path="resolvePath(child.path)"
class="nest-menu"
/>
</el-submenu>
</div>
</template>
<script>
import path from 'path'
import { isExternal } from '@/utils/validate'
import Item from './Item'
import AppLink from './Link'
import FixiOSBug from './FixiOSBug'
export default {
name: 'SidebarItem',
components: { Item, AppLink },
mixins: [FixiOSBug],
props: {
// route object
item: {
type: Object,
required: true
},
isNest: {
type: Boolean,
default: false
},
basePath: {
type: String,
default: ''
}
},
data() {
this.onlyOneChild = null
return {}
},
methods: {
hasOneShowingChild(children = [], parent) {
if (!children) {
children = []
}
const showingChildren = children.filter(item => {
if (item.hidden) {
return false
}
// Temp set(will be used if only has one showing child)
this.onlyOneChild = item
return true
})
// When there is only one child router, the child router is displayed by default
if (showingChildren.length === 1) {
return true
}
// Show parent if there are no child router to display
if (showingChildren.length === 0) {
this.onlyOneChild = { ... parent, path: '', noShowingChildren: true }
return true
}
return false
},
resolvePath(routePath, routeQuery) {
if (isExternal(routePath)) {
return routePath
}
if (isExternal(this.basePath)) {
return this.basePath
}
if (routeQuery) {
let query = JSON.parse(routeQuery)
return { path: path.resolve(this.basePath, routePath), query: query }
}
return path.resolve(this.basePath, routePath)
}
}
}
</script>
@@ -0,0 +1,57 @@
<template>
<div :class="['sidebar-theme-wrapper', {'has-logo':showLogo}, settings.sideTheme]" :style="{ backgroundColor: settings.sideTheme === 'theme-dark' ? variables.menuBackground : variables.menuLightBackground }">
<logo v-if="showLogo" :collapse="isCollapse" />
<el-scrollbar :class="settings.sideTheme" wrap-class="scrollbar-wrapper">
<el-menu
:default-active="activeMenu"
:collapse="isCollapse"
:background-color="settings.sideTheme === 'theme-dark' ? variables.menuBackground : variables.menuLightBackground"
:text-color="settings.sideTheme === 'theme-dark' ? variables.menuColor : variables.menuLightColor"
:unique-opened="true"
:active-text-color="settings.theme"
:collapse-transition="false"
mode="vertical"
>
<sidebar-item
v-for="(route, index) in sidebarRouters"
:key="route.path + index"
:item="route"
:base-path="route.path"
/>
</el-menu>
</el-scrollbar>
</div>
</template>
<script>
import { mapGetters, mapState } from "vuex"
import Logo from "./Logo"
import SidebarItem from "./SidebarItem"
import variables from "@/assets/styles/variables.scss"
export default {
components: { SidebarItem, Logo },
computed: {
...mapState(["settings"]),
...mapGetters(["sidebarRouters", "sidebar"]),
activeMenu() {
const route = this.$route
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
}
}
}
</script>
@@ -0,0 +1,149 @@
<template>
<el-scrollbar ref="scrollContainer" :vertical="false" class="scroll-container" @wheel.native.prevent="handleScroll">
<slot />
</el-scrollbar>
</template>
<script>
const tagAndTagSpacing = 4
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: {
smoothScrollTo(target) {
const $scrollWrapper = this.scrollWrapper
const start = $scrollWrapper.scrollLeft
const distance = target - start
const duration = 300
let startTime = null
function ease(t, b, c, d) {
t /= d / 2
if (t < 1) return c / 2 * t * t + b
t--
return -c / 2 * (t * (t - 2) - 1) + b
}
const emit = this.$emit.bind(this)
function step(timestamp) {
if (!startTime) startTime = timestamp
const elapsed = timestamp - startTime
$scrollWrapper.scrollLeft = ease(elapsed, start, distance, duration)
if (elapsed < duration) {
requestAnimationFrame(step)
} else {
$scrollWrapper.scrollLeft = target
emit('updateArrows')
}
}
requestAnimationFrame(step)
},
handleScroll(e) {
const eventDelta = e.wheelDelta || -e.deltaY * 40
const $scrollWrapper = this.scrollWrapper
$scrollWrapper.scrollLeft = $scrollWrapper.scrollLeft + eventDelta / 4
this.$emit('updateArrows')
},
emitScroll() {
this.$emit('scroll')
this.$emit('updateArrows')
},
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
if (tagList.length > 0) {
firstTag = tagList[0]
lastTag = tagList[tagList.length - 1]
}
if (firstTag === currentTag) {
this.smoothScrollTo(0)
} else if (lastTag === currentTag) {
this.smoothScrollTo($scrollWrapper.scrollWidth - $containerWidth)
} else {
const currentIndex = tagList.findIndex(item => item === currentTag)
const prevTag = tagList[currentIndex - 1]
const nextTag = tagList[currentIndex + 1]
const afterNextTagOffsetLeft = nextTag.$el.offsetLeft + nextTag.$el.offsetWidth + tagAndTagSpacing
const beforePrevTagOffsetLeft = prevTag.$el.offsetLeft - tagAndTagSpacing
if (afterNextTagOffsetLeft > $scrollWrapper.scrollLeft + $containerWidth) {
this.smoothScrollTo(afterNextTagOffsetLeft - $containerWidth)
} else if (beforePrevTagOffsetLeft < $scrollWrapper.scrollLeft) {
this.smoothScrollTo(beforePrevTagOffsetLeft)
}
}
},
// 向左滚动固定距离
scrollLeft() {
const $scrollWrapper = this.scrollWrapper
this.smoothScrollTo(Math.max(0, $scrollWrapper.scrollLeft - 200))
},
// 向右滚动固定距离
scrollRight() {
const $scrollWrapper = this.scrollWrapper
const maxScroll = $scrollWrapper.scrollWidth - $scrollWrapper.clientWidth
this.smoothScrollTo(Math.min(maxScroll, $scrollWrapper.scrollLeft + 200))
},
// 直接平滑滚动到最左端
scrollToStart() {
this.smoothScrollTo(0)
},
// 直接平滑滚动到最右端
scrollToEnd() {
const $scrollWrapper = this.scrollWrapper
this.smoothScrollTo($scrollWrapper.scrollWidth - $scrollWrapper.clientWidth)
},
// 获取是否可以继续向左/右滚动
getScrollState() {
const $scrollWrapper = this.scrollWrapper
return {
canLeft: $scrollWrapper.scrollLeft > 0,
canRight: $scrollWrapper.scrollLeft < $scrollWrapper.scrollWidth - $scrollWrapper.clientWidth - 1
}
}
}
}
</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: 34px;
display: flex;
align-items: center;
}
}
}
</style>
@@ -0,0 +1,760 @@
<template>
<div id="tags-view-container" class="tags-view-container" :class="{ 'tags-view-container--chrome': tagsViewStyle === 'chrome' }" :style="chromeVars">
<!-- 左切换箭头 -->
<span class="tags-nav-btn tags-nav-btn--left" :class="{ disabled: !canScrollLeft }" @click="scrollLeft">
<i class="el-icon-arrow-left" />
</span>
<!-- 标签滚动区 -->
<scroll-pane ref="scrollPane" class="tags-view-wrapper" @scroll="handleScroll" @updateArrows="updateArrowState">
<router-link
v-for="tag in visitedViews"
ref="tag"
:key="tag.path"
:class="{ 'active': isActive(tag), 'has-icon': tagsIcon }"
:to="{ path: tag.path, query: tag.query, fullPath: tag.fullPath }"
tag="span"
class="tags-view-item"
:style="tagActiveStyle(tag)"
@click.middle.native="!isAffix(tag) ? closeSelectedTag(tag) : ''"
@contextmenu.prevent.native="openMenu(tag, $event)"
>
<svg-icon v-if="tagsIcon && tag.meta && tag.meta.icon && tag.meta.icon !== '#'" :icon-class="tag.meta.icon" style="margin-right: 3px;" />
{{ tag.title }}
<span v-if="!isAffix(tag)" class="el-icon-close" @click.prevent.stop="closeSelectedTag(tag)" />
</router-link>
</scroll-pane>
<!-- 右切换箭头 -->
<span class="tags-nav-btn tags-nav-btn--right" :class="{ disabled: !canScrollRight }" @click="scrollRight">
<i class="el-icon-arrow-right" />
</span>
<!-- 下拉操作菜单 -->
<el-dropdown class="tags-action-dropdown" trigger="click" placement="bottom-end" @command="handleDropdownCommand">
<span class="tags-action-btn">
<i class="el-icon-arrow-down" />
</span>
<el-dropdown-menu slot="dropdown" class="tags-dropdown-menu">
<el-dropdown-item v-if="!isAffix(selectedDropdownTag)" command="close" icon="el-icon-close">关闭当前</el-dropdown-item>
<el-dropdown-item command="closeOthers" icon="el-icon-circle-close">关闭其他</el-dropdown-item>
<el-dropdown-item command="closeLeft" :disabled="isFirstView()" icon="el-icon-back">关闭左侧</el-dropdown-item>
<el-dropdown-item command="closeRight" :disabled="isLastView()" icon="el-icon-right">关闭右侧</el-dropdown-item>
<el-dropdown-item command="closeAll" icon="el-icon-circle-close">全部关闭</el-dropdown-item>
<el-dropdown-item command="fullscreen" divided>
<template v-if="!isFullscreen"><i class="el-icon-full-screen"></i>全屏显示</template>
<template v-else><i class="el-icon-close"></i>退出全屏</template>
</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
<!-- 刷新按钮 -->
<span class="tags-action-btn tags-refresh-btn" title="刷新页面" @click="refreshSelectedTag(selectedDropdownTag)">
<i class="el-icon-refresh-right" /> 刷新
</span>
<!-- 右键上下文菜单 -->
<ul v-show="visible" :style="{ left: left + 'px', top: top + 'px' }" class="contextmenu">
<li @click="refreshSelectedTag(selectedTag)"><i class="el-icon-refresh-right"></i> 刷新页面</li>
<li v-if="!isAffix(selectedTag)" @click="closeSelectedTag(selectedTag)"><i class="el-icon-close"></i> 关闭当前</li>
<li @click="closeOthersTags"><i class="el-icon-circle-close"></i> 关闭其他</li>
<li v-if="!isFirstView()" @click="closeLeftTags"><i class="el-icon-back"></i> 关闭左侧</li>
<li v-if="!isLastView()" @click="closeRightTags"><i class="el-icon-right"></i> 关闭右侧</li>
<li @click="closeAllTags(selectedTag)"><i class="el-icon-circle-close"></i> 全部关闭</li>
</ul>
</div>
</template>
<script>
import ScrollPane from './ScrollPane'
import path from 'path'
export default {
components: { ScrollPane },
data() {
return {
visible: false,
top: 0,
left: 0,
selectedTag: {},
affixTags: [],
canScrollLeft: false,
canScrollRight: false,
isFullscreen: false,
hiddenElements: []
}
},
computed: {
visitedViews() {
return this.$store.state.tagsView.visitedViews
},
routes() {
return this.$store.state.permission.routes
},
theme() {
return this.$store.state.settings.theme
},
tagsIcon() {
return this.$store.state.settings.tagsIcon
},
tagsViewStyle() {
return this.$store.state.settings.tagsViewStyle
},
selectedDropdownTag() {
return this.visitedViews.find(v => this.isActive(v)) || {}
},
chromeVars() {
if (this.tagsViewStyle !== 'chrome') return {}
const primary = this.theme || '#409EFF'
return {
'--chrome-tab-active-bg': this.mixHexWithWhite(primary, 0.15),
'--chrome-tab-text-active': primary,
'--chrome-wing-r': '14px'
}
}
},
watch: {
$route() {
this.addTags()
this.moveToCurrentTag()
},
visible(value) {
if (value) {
document.body.addEventListener('click', this.closeMenu)
} else {
document.body.removeEventListener('click', this.closeMenu)
}
},
visitedViews() {
this.$nextTick(() => {
this.updateArrowState()
})
}
},
mounted() {
this.initTags()
this.addTags()
window.addEventListener('resize', this.updateArrowState)
window.addEventListener('keydown', this.handleKeyDown)
},
beforeDestroy() {
window.removeEventListener('resize', this.updateArrowState)
window.removeEventListener('keydown', this.handleKeyDown)
},
methods: {
handleKeyDown(event) {
// 当按下Esc键且处于全屏状态时,退出全屏
if (event.key === 'Escape' && this.isFullscreen) {
this.toggleFullscreen()
}
},
mixHexWithWhite(hex, ratio) {
const clean = hex.replace('#', '')
const r = parseInt(clean.substring(0, 2), 16)
const g = parseInt(clean.substring(2, 4), 16)
const b = parseInt(clean.substring(4, 6), 16)
const mr = Math.round(r * ratio + 255 * (1 - ratio))
const mg = Math.round(g * ratio + 255 * (1 - ratio))
const mb = Math.round(b * ratio + 255 * (1 - ratio))
return `rgb(${mr}, ${mg}, ${mb})`
},
isActive(route) {
return route.path === this.$route.path
},
tagActiveStyle(tag) {
if (!this.isActive(tag) || this.tagsViewStyle !== 'card') return {}
return {
"background-color": this.theme,
"border-color": this.theme
}
},
isAffix(tag) {
return tag && tag.meta && tag.meta.affix
},
isFirstView() {
try {
const tag = this.selectedTag && this.selectedTag.fullPath ? this.selectedTag : this.selectedDropdownTag
return tag.fullPath === '/index' || tag.fullPath === this.visitedViews[1].fullPath
} catch (err) {
return false
}
},
isLastView() {
try {
const tag = this.selectedTag && this.selectedTag.fullPath ? this.selectedTag : this.selectedDropdownTag
return tag.fullPath === this.visitedViews[this.visitedViews.length - 1].fullPath
} catch (err) {
return false
}
},
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() {
if (this.$store.state.settings.tagsViewPersist) {
this.$store.dispatch('tagsView/loadPersistedViews')
}
const affixTags = this.affixTags = this.filterAffixTags(this.routes)
for (const tag of affixTags) {
if (tag.name) {
this.$store.dispatch('tagsView/addAffixView', tag)
}
}
},
addTags() {
const { name } = this.$route
if (name) {
this.$store.dispatch('tagsView/addView', this.$route)
}
},
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)
if (tag.to.fullPath !== this.$route.fullPath) {
this.$store.dispatch('tagsView/updateVisitedView', this.$route)
}
break
}
}
})
},
scrollLeft() {
if (!this.canScrollLeft) return
this.$refs.scrollPane.scrollToStart()
},
scrollRight() {
if (!this.canScrollRight) return
this.$refs.scrollPane.scrollToEnd()
},
updateArrowState() {
this.$nextTick(() => {
if (this.$refs.scrollPane) {
const state = this.$refs.scrollPane.getScrollState()
this.canScrollLeft = state.canLeft
this.canScrollRight = state.canRight
}
})
},
toggleFullscreen() {
const mainContainer = document.querySelector('.main-container')
const navbar = document.querySelector('.navbar')
const sidebar = document.querySelector('.sidebar-container')
if (!mainContainer) return
if (!this.isFullscreen) {
mainContainer.classList.add('fullscreen-mode')
document.body.style.overflow = 'hidden'
const elementsToHide = [
{ el: navbar, originalDisplay: (navbar && navbar.style.display) || '' },
{ el: sidebar, originalDisplay: (sidebar && sidebar.style.display) || '' }
]
elementsToHide.forEach(item => {
if (item.el && item.el.style.display !== 'none') {
item.originalDisplay = item.el.style.display
item.el.style.display = 'none'
this.hiddenElements.push(item)
}
})
this.isFullscreen = true
} else {
mainContainer.classList.remove('fullscreen-mode')
document.body.style.overflow = ''
this.hiddenElements.forEach(item => {
if (item.el) {
item.el.style.display = item.originalDisplay
}
})
this.hiddenElements = []
const btn = document.querySelector('.tags-action-btn')
if (btn) btn.blur()
this.isFullscreen = false
}
},
handleDropdownCommand(command) {
const tag = this.selectedDropdownTag
this.selectedTag = tag
switch (command) {
case 'refresh':
this.refreshSelectedTag(tag)
break
case 'fullscreen':
this.toggleFullscreen()
break
case 'close':
this.closeSelectedTag(tag)
break
case 'closeOthers':
this.closeOthersTags()
break
case 'closeLeft':
this.closeLeftTags()
break
case 'closeRight':
this.closeRightTags()
break
case 'closeAll':
this.closeAllTags(tag)
break
}
},
refreshSelectedTag(view) {
this.$tab.refreshPage(view)
if (this.$route.meta.link) {
this.$store.dispatch('tagsView/delIframeView', this.$route)
}
},
closeSelectedTag(view) {
this.$tab.closePage(view).then(({ visitedViews }) => {
if (this.isActive(view)) {
this.toLastView(visitedViews, view)
}
})
},
closeRightTags() {
this.$tab.closeRightPage(this.selectedTag).then(visitedViews => {
if (!visitedViews.find(i => i.fullPath === this.$route.fullPath)) {
this.toLastView(visitedViews)
}
})
},
closeLeftTags() {
this.$tab.closeLeftPage(this.selectedTag).then(visitedViews => {
if (!visitedViews.find(i => i.fullPath === this.$route.fullPath)) {
this.toLastView(visitedViews)
}
})
},
closeOthersTags() {
this.$router.push(this.selectedTag.fullPath).catch(() => {})
this.$tab.closeOtherPage(this.selectedTag).then(() => {
this.moveToCurrentTag()
})
},
closeAllTags(view) {
this.$tab.closeAllPage().then(({ visitedViews }) => {
if (this.affixTags.some(tag => tag.path === this.$route.path)) {
return
}
this.toLastView(visitedViews, view)
})
},
toLastView(visitedViews, view) {
const latestView = visitedViews.slice(-1)[0]
if (latestView) {
this.$router.push(latestView.fullPath)
} else {
if (view && view.name === 'Dashboard') {
this.$router.replace({ path: '/redirect' + view.fullPath })
} else {
this.$router.push('/')
}
}
},
openMenu(tag, e) {
this.left = e.clientX
this.top = e.clientY
this.visible = true
this.selectedTag = tag
},
closeMenu() {
this.visible = false
},
handleScroll() {
this.closeMenu()
this.updateArrowState()
}
}
}
</script>
<style lang="scss" scoped>
$tags-bar-height: 34px;
.tags-view-container {
height: $tags-bar-height;
width: 100%;
background: #fff;
border-bottom: 1px solid #d8dce5;
display: flex;
align-items: center;
overflow: hidden;
$btn-width: 28px;
$btn-color: #71717a;
$btn-hover-bg: #f0f2f5;
$btn-hover-color: #303133;
$btn-disabled-color: #c0c4cc;
$divider: 1px solid #d8dce5;
.tags-nav-btn {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: $btn-width;
height: $tags-bar-height;
cursor: pointer;
color: $btn-color;
font-size: 13px;
user-select: none;
transition: background 0.15s, color 0.15s;
&:hover:not(.disabled) {
background: $btn-hover-bg;
color: $btn-hover-color;
}
&.disabled {
color: $btn-disabled-color;
cursor: not-allowed;
}
&--left { border-right: $divider; }
&--right { border-left: $divider; }
}
.tags-view-wrapper {
flex: 1;
min-width: 0;
height: 100%;
.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;
border-radius: 3px;
&:first-of-type { margin-left: 6px; }
&:last-of-type { margin-right: 15px; }
}
}
&:not(.tags-view-container--chrome) .tags-view-wrapper .tags-view-item.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;
}
}
&:not(.tags-view-container--chrome) .tags-view-wrapper .tags-view-item.active.has-icon::before {
content: none !important;
}
.tags-action-dropdown {
flex-shrink: 0;
display: flex;
align-items: center;
}
.tags-action-btn {
display: flex;
align-items: center;
justify-content: center;
width: $btn-width;
height: $tags-bar-height;
cursor: pointer;
color: $btn-color;
font-size: 13px;
border-left: $divider;
user-select: none;
transition: background 0.15s, color 0.15s;
&:hover {
background: $btn-hover-bg;
color: $btn-hover-color;
}
}
.tags-refresh-btn {
width: 60px;
}
.contextmenu {
margin: 0;
background: #fff;
z-index: 3000;
position: fixed;
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;
}
}
}
&.tags-view-container--chrome {
--chrome-strip-bg: #ffffff;
--chrome-strip-border: #e4e7ed;
--chrome-tab-text: #606266;
overflow: visible;
background: var(--chrome-strip-bg);
border-bottom: 1px solid var(--chrome-strip-border);
align-items: flex-end;
.tags-nav-btn {
align-self: stretch;
height: auto;
min-height: $tags-bar-height;
border-color: var(--chrome-strip-border);
}
.tags-action-btn {
border-color: var(--chrome-strip-border);
}
.tags-view-wrapper {
.tags-view-item {
display: inline-flex !important;
align-items: center;
justify-content: center;
position: relative;
z-index: 1;
height: 30px;
min-height: 30px;
margin: 0 0 -1px;
padding: 0 12px;
font-size: 13px;
font-weight: 400;
line-height: 1.2;
border: none !important;
border-radius: 0;
background: transparent !important;
color: var(--chrome-tab-text) !important;
padding-top: 0 !important;
box-shadow: none !important;
transition: background 0.12s ease, color 0.12s ease, border-radius 0.12s ease;
&::before,
&::after {
content: '' !important;
display: block !important;
position: absolute;
bottom: 0;
width: var(--chrome-wing-r);
height: var(--chrome-wing-r);
margin: 0 !important;
pointer-events: none;
background: transparent !important;
border-radius: 0 !important;
transition: box-shadow 0.12s ease;
}
&::before {
left: calc(-1 * var(--chrome-wing-r));
border-bottom-right-radius: var(--chrome-wing-r) !important;
box-shadow: none;
}
&::after {
right: calc(-1 * var(--chrome-wing-r));
border-bottom-left-radius: var(--chrome-wing-r) !important;
box-shadow: none;
}
&:first-of-type { margin-left: 6px; }
&:last-of-type { margin-right: 10px; }
&:not(.active) + .tags-view-item:not(.active) {
border-left: 1px solid #e4e7ed;
padding-left: 11px;
}
&:hover:not(.active) {
background: #f5f7fa !important;
border-radius: 6px 6px 0 0;
color: #303133 !important;
}
&.active {
height: 31px;
min-height: 31px;
padding: 0 14px;
color: var(--chrome-tab-text-active) !important;
font-weight: 500;
background: var(--chrome-tab-active-bg) !important;
border: none !important;
border-radius: var(--chrome-wing-r) var(--chrome-wing-r) 0 0;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
&::before {
box-shadow: calc(var(--chrome-wing-r) * 0.5) calc(var(--chrome-wing-r) * 0.5) 0 calc(var(--chrome-wing-r) * 0.5) var(--chrome-tab-active-bg);
}
&::after {
box-shadow: calc(var(--chrome-wing-r) * -0.5) calc(var(--chrome-wing-r) * 0.5) 0 calc(var(--chrome-wing-r) * 0.5) var(--chrome-tab-active-bg);
}
}
.el-icon-close {
margin-left: 3px;
&:before {
vertical-align: -2px;
}
}
}
}
}
}
</style>
<style lang="scss">
.tags-view-wrapper {
.el-scrollbar {
height: 100%;
overflow: hidden;
}
.el-scrollbar__wrap {
height: 34px !important;
display: flex;
align-items: center;
overflow-x: auto;
overflow-y: hidden;
&::-webkit-scrollbar {
width: 0;
height: 0;
}
.tags-view-container:hover & {
&::-webkit-scrollbar {
width: 6px;
height: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0.2);
border-radius: 3px;
transition: background-color 0.2s;
&:hover {
background-color: rgba(0, 0, 0, 0.4);
}
}
}
scrollbar-width: none;
-ms-overflow-style: none;
}
.el-scrollbar__bar {
opacity: 0;
transition: opacity 0.3s;
.tags-view-container:hover & {
opacity: 1;
}
}
.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;
}
}
}
}
/* 页签全屏模式样式 */
.main-container.fullscreen-mode {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
overflow: hidden;
margin-left: 0 !important;
transition: none !important;
}
.main-container.fullscreen-mode .fixed-header {
display: block !important;
position: fixed;
top: 0;
left: 0;
right: 0;
width: 100% !important;
z-index: 1000;
transition: none !important;
}
.main-container.fullscreen-mode .fixed-header .navbar {
display: none !important;
}
.main-container.fullscreen-mode .app-main {
position: fixed;
top: 34px;
left: 0;
right: 0;
bottom: 0;
margin: 0 !important;
padding: 0 !important;
height: calc(100vh - 34px) !important;
min-height: calc(100vh - 34px) !important;
overflow: auto;
}
</style>
@@ -0,0 +1,98 @@
<template>
<el-menu class="topbar-menu" :default-active="activeMenu" :active-text-color="theme" mode="horizontal">
<sidebar-item :key="route.path + index" v-for="(route, index) in topMenus" :item="route" :base-path="route.path" />
<el-submenu index="more" class="el-submenu__hide-arrow" v-if="moreRoutes.length > 0">
<template slot="title">更多菜单</template>
<sidebar-item :key="route.path + index" v-for="(route, index) in moreRoutes" :item="route" :base-path="route.path" />
</el-submenu>
</el-menu>
</template>
<script>
import SidebarItem from '../Sidebar/SidebarItem'
export default {
components: { SidebarItem },
data() {
return {
// 顶部栏初始数
visibleNumber: 5
}
},
computed: {
theme() {
return this.$store.state.settings.theme
},
topMenus() {
return this.$store.state.permission.sidebarRouters.filter((f) => !f.hidden).slice(0, this.visibleNumber)
},
moreRoutes() {
const sidebarRouters = this.$store.state.permission.sidebarRouters;
return sidebarRouters.filter((f) => !f.hidden).slice(this.visibleNumber, sidebarRouters.length - this.visibleNumber)
},
// 默认激活的菜单
activeMenu() {
const { meta, path } = this.$route
if (meta.activeMenu) {
return meta.activeMenu
}
return path
},
},
beforeMount() {
window.addEventListener('resize', this.setVisibleNumber)
},
beforeDestroy() {
window.removeEventListener('resize', this.setVisibleNumber)
},
mounted() {
this.setVisibleNumber()
},
methods: {
// 根据宽度计算设置显示栏数
setVisibleNumber() {
const width = document.body.getBoundingClientRect().width / 3
this.visibleNumber = parseInt(width / 85)
}
}
}
</script>
<style lang="scss">
/* menu item */
.topbar-menu.el-menu--horizontal .el-submenu__title, .topbar-menu.el-menu--horizontal .el-menu-item {
padding: 0 10px !important;
}
.el-menu--horizontal .el-menu--popup .el-menu-item:hover {
background-color: #f5f7fa !important;
}
/* submenu item */
.topbar-menu.el-menu--horizontal > .el-submenu .el-submenu__title {
float: left;
height: 47px !important;
line-height: 50px !important;
color: #303133;
margin: 0 15px !important;
}
/* topbar more arrow */
.topbar-menu .el-submenu .el-submenu__icon-arrow {
position: static;
vertical-align: middle;
margin-left: 8px;
margin-top: 0px;
}
/* menu__title el-menu-item */
.topbar-menu.el-menu--horizontal .el-submenu__title, .topbar-menu.el-menu--horizontal .el-menu-item {
height: 55px;
}
.el-menu--horizontal .el-menu .el-menu-item, .el-menu--horizontal .el-menu .el-submenu__title{
color: #303133;
}
</style>
@@ -0,0 +1,193 @@
<template>
<el-menu
:default-active="activeMenu"
mode="horizontal"
@select="handleSelect"
>
<template v-for="(item, index) in topMenus">
<el-menu-item :style="{'--theme': theme}" :index="item.path" :key="index" v-if="index < visibleNumber">
<svg-icon
v-if="item.meta && item.meta.icon && item.meta.icon !== '#'"
:icon-class="item.meta.icon"/>
{{ item.meta.title }}
</el-menu-item>
</template>
<!-- 顶部菜单超出数量折叠 -->
<el-submenu :style="{'--theme': theme}" index="more" :key="visibleNumber" v-if="topMenus.length > visibleNumber">
<template slot="title">更多菜单</template>
<template v-for="(item, index) in topMenus">
<el-menu-item
:index="item.path"
:key="index"
v-if="index >= visibleNumber">
<svg-icon
v-if="item.meta && item.meta.icon && item.meta.icon !== '#'"
:icon-class="item.meta.icon"/>
{{ item.meta.title }}
</el-menu-item>
</template>
</el-submenu>
</el-menu>
</template>
<script>
import { constantRoutes } from "@/router"
import { isHttp } from "@/utils/validate"
// 隐藏侧边栏路由
const hideList = ['/index', '/user/profile']
export default {
data() {
return {
// 顶部栏初始数
visibleNumber: 5,
// 当前激活菜单的 index
currentIndex: undefined
}
},
computed: {
theme() {
return this.$store.state.settings.theme
},
// 顶部显示菜单
topMenus() {
let topMenus = []
this.routers.map((menu) => {
if (menu.hidden !== true) {
// 兼容顶部栏一级菜单内部跳转
if (menu.path === '/' && menu.children) {
topMenus.push(menu.children[0])
} else {
topMenus.push(menu)
}
}
})
return topMenus
},
// 所有的路由信息
routers() {
return this.$store.state.permission.topbarRouters
},
// 设置子路由
childrenMenus() {
var childrenMenus = []
this.routers.map((router) => {
for (var item in router.children) {
if (router.children[item].parentPath === undefined) {
if(router.path === "/") {
router.children[item].path = "/" + router.children[item].path
} else {
if(!isHttp(router.children[item].path)) {
router.children[item].path = router.path + "/" + router.children[item].path
}
}
router.children[item].parentPath = router.path
}
childrenMenus.push(router.children[item])
}
})
return constantRoutes.concat(childrenMenus)
},
// 默认激活的菜单
activeMenu() {
const path = this.$route.path
let activePath = path
if (path !== undefined && path.lastIndexOf("/") > 0 && hideList.indexOf(path) === -1) {
const tmpPath = path.substring(1, path.length)
if (!this.$route.meta.link) {
activePath = "/" + tmpPath.substring(0, tmpPath.indexOf("/"))
this.$store.dispatch('app/toggleSideBarHide', false)
}
} else if(!this.$route.children) {
activePath = path
this.$store.dispatch('app/toggleSideBarHide', true)
}
this.activeRoutes(activePath)
return activePath
},
},
beforeMount() {
window.addEventListener('resize', this.setVisibleNumber)
},
beforeDestroy() {
window.removeEventListener('resize', this.setVisibleNumber)
},
mounted() {
this.setVisibleNumber()
},
methods: {
// 根据宽度计算设置显示栏数
setVisibleNumber() {
const width = document.body.getBoundingClientRect().width / 3
this.visibleNumber = parseInt(width / 85)
},
// 菜单选择事件
handleSelect(key, keyPath) {
this.currentIndex = key
const route = this.routers.find(item => item.path === key)
if (isHttp(key)) {
// http(s):// 路径新窗口打开
window.open(key, "_blank")
} else if (!route || !route.children) {
// 没有子路由路径内部打开
const routeMenu = this.childrenMenus.find(item => item.path === key)
if (routeMenu && routeMenu.query) {
let query = JSON.parse(routeMenu.query)
this.$router.push({ path: key, query: query })
} else {
this.$router.push({ path: key })
}
this.$store.dispatch('app/toggleSideBarHide', true)
} else {
// 显示左侧联动菜单
this.activeRoutes(key)
this.$store.dispatch('app/toggleSideBarHide', false)
}
},
// 当前激活的路由
activeRoutes(key) {
var routes = []
if (this.childrenMenus && this.childrenMenus.length > 0) {
this.childrenMenus.map((item) => {
if (key == item.parentPath || (key == "index" && "" == item.path)) {
routes.push(item)
}
})
}
if(routes.length > 0) {
this.$store.commit("SET_SIDEBAR_ROUTERS", routes)
} else {
this.$store.dispatch('app/toggleSideBarHide', true)
}
}
}
}
</script>
<style lang="scss">
.topmenu-container.el-menu--horizontal > .el-menu-item {
float: left;
height: 50px !important;
line-height: 50px !important;
color: #303133 !important;
padding: 0 5px !important;
margin: 0 10px !important;
}
.topmenu-container.el-menu--horizontal > .el-menu-item.is-active, .el-menu--horizontal > .el-submenu.is-active .el-submenu__title {
border-bottom: 2px solid #{'var(--theme)'} !important;
color: #303133;
}
/* submenu item */
.topmenu-container.el-menu--horizontal > .el-submenu .el-submenu__title {
float: left;
height: 50px !important;
line-height: 50px !important;
color: #303133 !important;
padding: 0 5px !important;
margin: 0 10px !important;
}
</style>
+5
View File
@@ -0,0 +1,5 @@
export { default as AppMain } from './AppMain'
export { default as Navbar } from './Navbar'
export { default as Settings } from './Settings'
export { default as Sidebar } from './Sidebar/index.vue'
export { default as TagsView } from './TagsView/index.vue'
+115
View File
@@ -0,0 +1,115 @@
<template>
<div :class="classObj" class="app-wrapper" :style="{'--current-color': theme, '--current-color-light': theme + '1a', '--current-color-dark-bg': theme + '33'}">
<div v-if="device==='mobile'&&sidebar.opened" class="drawer-bg" @click="handleClickOutside"/>
<sidebar v-if="!sidebar.hide" class="sidebar-container"/>
<div :class="{hasTagsView:needTagsView,sidebarHide:sidebar.hide}" class="main-container">
<div :class="{'fixed-header':fixedHeader}">
<navbar @setLayout="setLayout"/>
<tags-view v-if="needTagsView"/>
</div>
<app-main/>
<settings ref="settingRef"/>
</div>
</div>
</template>
<script>
import { AppMain, Navbar, Settings, Sidebar, TagsView } from './components'
import ResizeMixin from './mixin/ResizeHandler'
import { mapState } from 'vuex'
import variables from '@/assets/styles/variables.scss'
export default {
name: 'Layout',
components: {
AppMain,
Navbar,
Settings,
Sidebar,
TagsView
},
mixins: [ResizeMixin],
computed: {
...mapState({
theme: state => state.settings.theme,
sideTheme: state => state.settings.sideTheme,
sidebar: state => state.app.sidebar,
device: state => state.app.device,
needTagsView: state => state.settings.tagsView,
fixedHeader: state => state.settings.fixedHeader
}),
classObj() {
return {
hideSidebar: !this.sidebar.opened,
openSidebar: this.sidebar.opened,
withoutAnimation: this.sidebar.withoutAnimation,
mobile: this.device === 'mobile'
}
},
variables() {
return variables
}
},
methods: {
handleClickOutside() {
this.$store.dispatch('app/closeSideBar', { withoutAnimation: false })
},
setLayout() {
this.$refs.settingRef.openSetting()
}
}
}
</script>
<style lang="scss" scoped>
@import "~@/assets/styles/mixin.scss";
@import "~@/assets/styles/variables.scss";
.app-wrapper {
@include clearfix;
position: relative;
height: 100%;
width: 100%;
&.mobile.openSidebar {
position: fixed;
top: 0;
}
}
.main-container:has(.fixed-header) {
height: 100vh;
overflow: hidden;
}
.drawer-bg {
background: #000;
opacity: 0.3;
width: 100%;
top: 0;
height: 100%;
position: absolute;
z-index: 999;
}
.fixed-header {
position: fixed;
top: 0;
right: 0;
z-index: 9;
width: calc(100% - #{$base-sidebar-width});
transition: width 0.28s;
}
.hideSidebar .fixed-header {
width: calc(100% - 54px);
}
.sidebarHide .fixed-header {
width: 100%;
}
.mobile .fixed-header {
width: 100%;
}
</style>
@@ -0,0 +1,45 @@
import store from '@/store'
const { body } = document
const WIDTH = 992 // refer to Bootstrap's responsive design
export default {
watch: {
$route(route) {
if (this.device === 'mobile' && this.sidebar.opened) {
store.dispatch('app/closeSideBar', { withoutAnimation: false })
}
}
},
beforeMount() {
window.addEventListener('resize', this.$_resizeHandler)
},
beforeDestroy() {
window.removeEventListener('resize', this.$_resizeHandler)
},
mounted() {
const isMobile = this.$_isMobile()
if (isMobile) {
store.dispatch('app/toggleDevice', 'mobile')
store.dispatch('app/closeSideBar', { withoutAnimation: true })
}
},
methods: {
// use $_ for mixins properties
// https://vuejs.org/v2/style-guide/index.html#Private-property-names-essential
$_isMobile() {
const rect = body.getBoundingClientRect()
return rect.width - 1 < WIDTH
},
$_resizeHandler() {
if (!document.hidden) {
const isMobile = this.$_isMobile()
store.dispatch('app/toggleDevice', isMobile ? 'mobile' : 'desktop')
if (isMobile) {
store.dispatch('app/closeSideBar', { withoutAnimation: true })
}
}
}
}
}