| | |
| | | </el-menu> |
| | | </el-aside> |
| | | <el-main class="layout-main"> |
| | | <transition name="fade" mode="out-in"> |
| | | <router-view></router-view> |
| | | </transition> |
| | | <div class="mdi-tabbar"> |
| | | <div class="mdi-caption"><i class="el-icon-document-copy"></i><span>工作区</span></div> |
| | | <div ref="tabScroll" class="mdi-tabs-scroll"> |
| | | <div class="mdi-tabs"> |
| | | <div |
| | | v-for="doc in openDocs" |
| | | :key="doc.fullPath" |
| | | class="mdi-tab" |
| | | :class="{ 'is-active': isActiveDoc(doc) }" |
| | | :title="doc.title" |
| | | @click="activateDoc(doc)" |
| | | > |
| | | <i :class="doc.icon"></i> |
| | | <span class="mdi-tab-title">{{ doc.title }}</span> |
| | | <i v-if="doc.pinned" class="el-icon-lock mdi-tab-pin"></i> |
| | | <i v-else class="el-icon-close mdi-tab-close" title="关闭" @click.stop="closeDoc(doc)"></i> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | <div class="mdi-actions"> |
| | | <el-dropdown trigger="click" @command="handleCommand"> |
| | | <button class="mdi-actions-button" type="button" title="文档操作"><i class="el-icon-more"></i></button> |
| | | <el-dropdown-menu slot="dropdown"> |
| | | <el-dropdown-item command="closeCurrent" :disabled="!activeDoc || activeDoc.pinned">关闭当前</el-dropdown-item> |
| | | <el-dropdown-item command="closeOthers" :disabled="!canCloseOthers">关闭其他</el-dropdown-item> |
| | | <el-dropdown-item command="closeAll" :disabled="openDocs.length <= 1">关闭全部</el-dropdown-item> |
| | | </el-dropdown-menu> |
| | | </el-dropdown> |
| | | </div> |
| | | </div> |
| | | <div ref="workspace" class="mdi-workspace" @scroll="rememberScroll"> |
| | | <router-view v-slot="{ Component, route }"> |
| | | <keep-alive :include="cachedNames" :max="20"> |
| | | <component :is="Component" :key="viewKey(route)" /> |
| | | </keep-alive> |
| | | </router-view> |
| | | </div> |
| | | </el-main> |
| | | </el-container> |
| | | </el-container> |
| | |
| | | import AppHeader from '@/components/AppHeader.vue' |
| | | import MenuNode from '@/components/MenuNode.vue' |
| | | |
| | | const HOME_DOC = { |
| | | fullPath: '/dashboard', |
| | | title: '首页门户', |
| | | icon: 'el-icon-s-home', |
| | | cacheName: 'Dashboard', |
| | | pinned: true, |
| | | version: 1 |
| | | } |
| | | |
| | | const REPORT_TITLES = { |
| | | overview: '装备故障总体分析', |
| | | 'top-equipment': '故障高发设备Top', |
| | | 'by-team': '按T统计', |
| | | contractors: '厂家维修情况', |
| | | trend: '年度趋势' |
| | | } |
| | | |
| | | export default { |
| | | name: 'AppLayout', |
| | | components: { AppHeader, MenuNode }, |
| | | data() { |
| | | return { menus: [] } |
| | | return { |
| | | menus: [], |
| | | openDocs: [], |
| | | currentFullPath: '', |
| | | cacheVersions: {}, |
| | | scrollTops: {} |
| | | } |
| | | }, |
| | | computed: { |
| | | activeDoc() { |
| | | return this.openDocs.find(doc => doc.fullPath === this.currentFullPath) || null |
| | | }, |
| | | cachedNames() { |
| | | return [...new Set(this.openDocs.map(doc => doc.cacheName).filter(Boolean))] |
| | | }, |
| | | canCloseOthers() { |
| | | const keepCount = this.activeDoc && this.activeDoc.pinned ? 1 : 2 |
| | | return this.openDocs.length > keepCount |
| | | } |
| | | }, |
| | | watch: { |
| | | '$route': { |
| | | immediate: true, |
| | | handler(route) { this.syncRoute(route) } |
| | | } |
| | | }, |
| | | created() { |
| | | this.loadMenus() |
| | |
| | | api.get('/system/menu/nav').then(res => { |
| | | this.menus = (res.data || []).filter(item => item.visible !== 0) |
| | | }).catch(() => {}) |
| | | }, |
| | | syncRoute(route) { |
| | | if (!route || route.path === '/login') return |
| | | if (this.currentFullPath && this.$refs.workspace) { |
| | | this.scrollTops[this.currentFullPath] = this.$refs.workspace.scrollTop |
| | | } |
| | | this.ensureHomeDoc() |
| | | const fullPath = route.fullPath |
| | | let doc = this.openDocs.find(item => item.fullPath === fullPath) |
| | | if (!doc) { |
| | | doc = { |
| | | fullPath, |
| | | title: this.resolveTitle(route), |
| | | icon: this.resolveIcon(route.path), |
| | | cacheName: route.meta.cacheName || route.path, |
| | | pinned: false, |
| | | version: (this.cacheVersions[fullPath] || 0) + 1 |
| | | } |
| | | this.openDocs.push(doc) |
| | | } else { |
| | | doc.title = this.resolveTitle(route) |
| | | doc.icon = this.resolveIcon(route.path) |
| | | doc.cacheName = route.meta.cacheName || doc.cacheName |
| | | } |
| | | this.currentFullPath = fullPath |
| | | this.$nextTick(() => { |
| | | this.restoreScroll() |
| | | this.scrollActiveTab() |
| | | }) |
| | | }, |
| | | ensureHomeDoc() { |
| | | if (!this.openDocs.some(doc => doc.pinned)) { |
| | | this.openDocs.unshift({ ...HOME_DOC }) |
| | | } |
| | | }, |
| | | resolveTitle(route) { |
| | | if (route.path.startsWith('/reports/')) { |
| | | return REPORT_TITLES[route.params.type] || route.meta.title || '统计报表' |
| | | } |
| | | return route.meta.title || route.name || route.path |
| | | }, |
| | | resolveIcon(path) { |
| | | if (path === '/dashboard') return 'el-icon-s-home' |
| | | if (path === '/fault/report') return 'el-icon-edit-outline' |
| | | if (path.includes('/approval/') || path === '/fault/review') return 'el-icon-s-check' |
| | | if (path === '/fault/dispatch') return 'el-icon-s-promotion' |
| | | if (path.startsWith('/fault/completion') || path === '/completion/report') return 'el-icon-finished' |
| | | if (path.startsWith('/acceptance/')) return 'el-icon-circle-check' |
| | | if (path.startsWith('/reports/')) return 'el-icon-data-analysis' |
| | | if (path.startsWith('/system/')) return 'el-icon-setting' |
| | | return 'el-icon-document' |
| | | }, |
| | | isActiveDoc(doc) { return doc.fullPath === this.currentFullPath }, |
| | | viewKey(route) { |
| | | const doc = this.openDocs.find(item => item.fullPath === route.fullPath) |
| | | return route.fullPath + '#' + (doc ? doc.version : 0) |
| | | }, |
| | | activateDoc(doc) { |
| | | if (!this.isActiveDoc(doc)) this.$router.push(doc.fullPath).catch(() => {}) |
| | | }, |
| | | closeDoc(doc) { |
| | | if (!doc || doc.pinned) return |
| | | const index = this.openDocs.findIndex(item => item.fullPath === doc.fullPath) |
| | | if (index < 0) return |
| | | const wasActive = this.isActiveDoc(doc) |
| | | this.cacheVersions[doc.fullPath] = doc.version + 1 |
| | | this.openDocs.splice(index, 1) |
| | | if (wasActive) { |
| | | const target = this.openDocs[index - 1] || this.openDocs[index] || this.openDocs[0] |
| | | this.$router.push(target.fullPath).catch(() => {}) |
| | | } else { |
| | | this.$nextTick(this.scrollActiveTab) |
| | | } |
| | | }, |
| | | handleCommand(command) { |
| | | if (command === 'closeCurrent') this.closeDoc(this.activeDoc) |
| | | if (command === 'closeOthers') this.closeOtherDocs() |
| | | if (command === 'closeAll') this.closeAllDocs() |
| | | }, |
| | | closeOtherDocs() { |
| | | if (!this.activeDoc) return |
| | | const keepPath = this.activeDoc.fullPath |
| | | this.openDocs.filter(doc => !doc.pinned && doc.fullPath !== keepPath).forEach(doc => { |
| | | this.cacheVersions[doc.fullPath] = doc.version + 1 |
| | | }) |
| | | this.openDocs = this.openDocs.filter(doc => doc.pinned || doc.fullPath === keepPath) |
| | | this.$nextTick(this.scrollActiveTab) |
| | | }, |
| | | closeAllDocs() { |
| | | this.openDocs.filter(doc => !doc.pinned).forEach(doc => { |
| | | this.cacheVersions[doc.fullPath] = doc.version + 1 |
| | | }) |
| | | this.openDocs = this.openDocs.filter(doc => doc.pinned) |
| | | this.$router.push('/dashboard').catch(() => {}) |
| | | }, |
| | | rememberScroll() { |
| | | if (this.currentFullPath && this.$refs.workspace) { |
| | | this.scrollTops[this.currentFullPath] = this.$refs.workspace.scrollTop |
| | | } |
| | | }, |
| | | restoreScroll() { |
| | | if (this.$refs.workspace) this.$refs.workspace.scrollTop = this.scrollTops[this.currentFullPath] || 0 |
| | | }, |
| | | scrollActiveTab() { |
| | | const active = this.$refs.tabScroll && this.$refs.tabScroll.querySelector('.mdi-tab.is-active') |
| | | if (active) active.scrollIntoView({ block: 'nearest', inline: 'nearest' }) |
| | | } |
| | | } |
| | | } |
| | |
| | | .layout-body { flex: 1; min-height: 0; } |
| | | .layout-aside { border-right: 1px solid #d9e2eb; background: #f5f8fb; overflow-x: hidden; overflow-y: auto; } |
| | | .aside-caption { padding: 14px 18px 8px; color: #8090a2; font-size: 12px; letter-spacing: .08em; } |
| | | .layout-main { min-width: 0; padding: 0; overflow: auto; background: #eef2f6; } |
| | | .layout-main { display: flex; flex-direction: column; min-width: 0; min-height: 0; padding: 0; overflow: hidden; background: #eef2f6; } |
| | | .el-menu { border-right: 0; } |
| | | .layout-aside ::v-deep .el-menu-item, |
| | | .layout-aside ::v-deep .el-submenu__title { |
| | |
| | | overflow-wrap: anywhere; |
| | | word-break: break-word; |
| | | } |
| | | .fade-enter-active, .fade-leave-active { transition: opacity .12s ease; } |
| | | .fade-enter, .fade-leave-to { opacity: 0; } |
| | | .mdi-tabbar { display: flex; flex: 0 0 40px; align-items: stretch; height: 40px; padding-top: 5px; border-bottom: 1px solid #cfdae5; background: #e3eaf1; } |
| | | .mdi-caption { display: flex; flex: 0 0 auto; align-items: center; gap: 6px; padding: 0 12px; border-right: 1px solid rgba(125, 147, 169, .22); color: #64788d; font-size: 12px; } |
| | | .mdi-caption i { color: #2c7fb8; font-size: 15px; } |
| | | .mdi-tabs-scroll { flex: 1; min-width: 0; overflow-x: auto; overflow-y: hidden; } |
| | | .mdi-tabs { display: inline-flex; align-items: flex-end; min-width: 100%; height: 100%; padding: 0 4px; } |
| | | .mdi-tab { display: inline-flex; flex: 0 0 auto; align-items: center; gap: 6px; max-width: 220px; height: 34px; margin-right: 2px; padding: 0 9px; border: 1px solid transparent; border-bottom: 0; border-radius: 4px 4px 0 0; color: #5c6f82; background: rgba(255, 255, 255, .42); cursor: pointer; font-size: 13px; user-select: none; } |
| | | .mdi-tab:hover { color: #245275; background: rgba(255, 255, 255, .72); } |
| | | .mdi-tab.is-active { height: 35px; color: #1f3348; background: #eef2f6; border-color: #cfdae5; box-shadow: inset 0 2px 0 #2c7fb8; } |
| | | .mdi-tab > i { flex: 0 0 auto; } |
| | | .mdi-tab-title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
| | | .mdi-tab-close, .mdi-tab-pin { width: 16px; color: #8191a2; text-align: center; } |
| | | .mdi-tab-close { border-radius: 50%; font-size: 12px; } |
| | | .mdi-tab-close:hover { color: #fff; background: #d9534f; } |
| | | .mdi-tab-pin { font-size: 12px; } |
| | | .mdi-actions { display: flex; flex: 0 0 auto; align-items: center; padding: 0 6px; border-left: 1px solid rgba(125, 147, 169, .22); } |
| | | .mdi-actions-button { width: 30px; height: 28px; padding: 0; border: 0; border-radius: 3px; color: #587087; background: transparent; cursor: pointer; font-size: 17px; } |
| | | .mdi-actions-button:hover { color: #1f5f91; background: rgba(255, 255, 255, .68); } |
| | | .mdi-workspace { position: relative; flex: 1; min-height: 0; overflow: auto; background: #eef2f6; } |
| | | </style> |