feat(admin): add msh_single_admin project and harden ignore rules

Introduce the new Vue admin project into version control while tightening gitignore patterns to keep env files, logs, build artifacts, and test outputs out of commits.

Made-with: Cursor
This commit is contained in:
msh-agent
2026-04-15 19:31:32 +08:00
parent a840045fc1
commit ceac1c0340
713 changed files with 119926 additions and 0 deletions

View File

@@ -0,0 +1,60 @@
<template>
<section class="app-main">
<transition name="fade-transform" mode="out-in">
<keep-alive :include="cachedViews">
<router-view :key="key" />
</keep-alive>
</transition>
</section>
</template>
<script>
export default {
name: 'AppMain',
data() {
return {};
},
computed: {
cachedViews() {
return this.$store.state.tagsView.cachedViews;
},
key() {
return this.$route.path;
},
},
};
</script>
<style lang="scss" scoped>
.footers {
text-align: center;
font-size: 14px;
color: #808695;
.title {
font-size: 14px;
color: #808695;
}
}
.fixed-header + .app-main {
padding-top: 50px;
}
.hasTagsView {
.app-main {
background: #f5f5f5;
/* 84 = navbar + tags-view = 50 + 34 */
min-height: calc(100vh - 36px);
overflow-y: auto;
}
.fixed-header + .app-main {
padding-top: 80px;
}
}
.el-popup-parent--hidden {
.fixed-header {
padding-right: 15px;
}
}
</style>

View File

@@ -0,0 +1,206 @@
<template>
<div class="navbar">
<hamburger
id="hamburger-container"
:is-active="sidebar.opened"
class="hamburger-container"
@toggleClick="toggleSideBar"
/>
<breadcrumb id="breadcrumb-container" class="breadcrumb-container" v-if="!topNav" />
<top-nav id="topmenu-container" class="topmenu-container" v-if="topNav" />
<div class="right-menu">
<template v-if="device !== 'mobile'">
<search id="header-search" class="right-menu-item" />
<!-- <error-log class="errLog-container right-menu-item hover-effect" /> -->
<screenfull id="screenfull" class="right-menu-item hover-effect" />
<span
class="iconfont iconios-notifications-outline right-menu-item"
style="font-size: 22px; opacity: 0.5"
></span>
</template>
<el-dropdown class="avatar-container right-menu-item hover-effect" trigger="click">
<div class="avatar-wrapper">{{ JavaInfo.realName }}<i class="el-icon-arrow-down el-icon--right"></i></div>
<el-dropdown-menu slot="dropdown">
<router-link to="/">
<el-dropdown-item>主页</el-dropdown-item>
</router-link>
<router-link :to="{ path: '/maintain/user' }" v-if="!isPhone">
<el-dropdown-item>个人中心</el-dropdown-item>
</router-link>
<router-link :to="{ path: '/maintain/update' }" v-if="!isPhone">
<el-dropdown-item>修改密码</el-dropdown-item>
</router-link>
<el-dropdown-item @click.native="setting = true">
<span>布局设置</span>
</el-dropdown-item>
<el-dropdown-item @click.native="logout">
<span>退出</span>
</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import TopNav from '@/components/TopNav';
import Breadcrumb from '@/components/Breadcrumb';
import Hamburger from '@/components/Hamburger';
import ErrorLog from '@/components/ErrorLog';
import Screenfull from '@/components/Screenfull';
import Search from '@/components/HeaderSearch';
import { unbindApi } from '@/api/wxApi';
import Cookies from 'js-cookie';
export default {
components: {
TopNav,
Breadcrumb,
Hamburger,
ErrorLog,
Screenfull,
Search,
},
data() {
return {
isPhone: this.$wechat.isPhone(),
JavaInfo: JSON.parse(Cookies.get('JavaInfo')),
};
},
watch: {
show(value) {
if (value && !this.clickNotClose) {
this.addEventClick();
}
if (value) {
addClass(document.body, 'showRightPanel');
} else {
removeClass(document.body, 'showRightPanel');
}
},
},
computed: {
...mapGetters(['sidebar', 'avatar', 'device']),
topNav: {
get() {
return this.$store.state.settings.topNav;
},
},
setting: {
get() {
return this.$store.state.settings.showSettings;
},
set(val) {
this.$store.dispatch('settings/changeSetting', {
key: 'showSettings',
value: val,
});
},
},
},
methods: {
onUnbundling() {
this.$modalSure('解绑微信吗').then(() => {
unbindApi().then(() => {
this.$message.success('解绑成功');
});
});
},
toggleSideBar() {
this.$store.dispatch('app/toggleSideBar');
},
async logout() {
await this.$store.dispatch('user/logout');
this.$router.push(`/login?redirect=${this.$route.fullPath}`);
},
},
};
</script>
<style lang="scss" scoped>
.navbar {
height: 50px;
overflow: hidden;
position: relative;
background: #fff;
// box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
.hamburger-container {
line-height: 46px;
height: 100%;
float: left;
cursor: pointer;
transition: background 0.3s;
-webkit-tap-highlight-color: transparent;
&:hover {
background: rgba(0, 0, 0, 0.025);
}
}
.breadcrumb-container {
float: left;
}
.topmenu-container {
position: absolute;
left: 50px;
}
.errLog-container {
display: inline-block;
vertical-align: top;
}
.right-menu {
float: right;
height: 100%;
line-height: 50px;
&: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 0.3s;
&:hover {
background: rgba(0, 0, 0, 0.025);
}
}
}
.avatar-container {
margin-right: 30px;
.avatar-wrapper {
position: relative;
font-size: 14px;
.user-avatar {
cursor: pointer;
width: 40px;
height: 40px;
border-radius: 10px;
}
.el-icon-caret-bottom {
cursor: pointer;
position: absolute;
right: -20px;
top: 25px;
font-size: 12px;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,265 @@
<template>
<div class="drawer-container">
<div>
<h3 class="drawer-title">主题风格设置</h3>
<div class="setting-drawer-block-checbox">
<div class="setting-drawer-block-checbox-item" @click="handleTheme('theme-dark')">
<img src="@/assets/imgs/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/imgs/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>
<el-divider />
<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>开启 TopNav</span>
<el-switch v-model="topNav" class="drawer-switch" />
</div>
<div class="drawer-item" v-if="topNav">
<span>开启 Icon</span>
<el-switch v-model="navIcon" class="drawer-switch" />
</div>
<div class="drawer-item">
<span>开启 Tags-Views</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>
<el-divider />
<el-button type="primary" plain icon="el-icon-document-add" @click="saveSetting">保存配置</el-button>
<el-button plain icon=" el-icon-refresh" @click="resetSetting">重置配置</el-button>
</div>
</div>
</template>
<script>
import ThemePicker from '@/components/ThemePicker';
export default {
components: { ThemePicker },
data() {
return {
sideTheme: this.$store.state.settings.sideTheme,
routers: this.$store.state.permission.routes,
};
},
computed: {
theme: {
get() {
return this.$store.state.settings.theme;
},
},
fixedHeader: {
get() {
return this.$store.state.settings.fixedHeader;
},
set(val) {
this.$store.dispatch('settings/changeSetting', {
key: 'fixedHeader',
value: val,
});
},
},
topNav: {
get() {
//回调函数 当需要读取当前属性值是执行,根据相关数据计算并返回当前属性的值
return this.$store.state.settings.topNav;
},
set(val) {
//监视当前属性值的变化,当属性值发生变化时执行,更新相关的属性数据
//val就是topNav的最新属性值
this.$store.dispatch('settings/changeSetting', {
key: 'topNav',
value: val,
});
if (val) {
let key = this.$route.path.split('/')[1];
//通过截取当前路由的第一级目录跟顶部一级菜单选中项的val值做匹配
key = '/' + key;
this.routers.map((item) => {
if (key == item.url && item.child) {
//如果匹配,就给侧边导航赋值为选中项的子级数组
this.$store.commit('permission/SET_SIDEBAR_ROUTERS', item.child);
} else if (key == item.url && !item.child) {
//如果遍历以后val值等于item的url但是有没有子级就把它子级赋值给侧边导航的数组这里针对dashboard控制台
this.$store.commit('permission/SET_SIDEBAR_ROUTERS', [item]);
}
});
}
if (!val) {
//关闭的时候侧边导航的取值还是取默认的routes数组
this.$store.commit('permission/SET_SIDEBAR_ROUTERS', this.$store.state.permission.routes);
}
},
},
navIcon: {
get() {
return this.$store.state.settings.navIcon;
},
set(val) {
this.$store.dispatch('settings/changeSetting', {
//dispatch含有异步操作例如向后台提交数据写法 this.$store.dispatch('action方法名',值)
//commit同步操作写法this.$store.commit('mutations方法名',值)
key: 'navIcon',
value: val,
});
},
},
tagsView: {
get() {
return this.$store.state.settings.tagsView;
},
set(val) {
this.$store.dispatch('settings/changeSetting', {
key: 'tagsView',
value: val,
});
},
},
sidebarLogo: {
get() {
return this.$store.state.settings.sidebarLogo;
},
set(val) {
this.$store.dispatch('settings/changeSetting', {
key: 'sidebarLogo',
value: val,
});
},
},
},
methods: {
themeChange(val) {
this.$store.dispatch('settings/changeSetting', {
key: 'theme',
value: val,
});
},
handleTheme(val) {
this.$store.dispatch('settings/changeSetting', {
key: 'sideTheme',
value: val,
});
this.sideTheme = val;
},
saveSetting() {
this.$modal.loading('正在保存到本地,请稍候...');
//将设置写入缓存
this.$cache.local.setJSON('layout-setting', {
topNav: this.topNav,
tagsView: this.tagsView,
fixedHeader: this.fixedHeader,
sidebarLogo: this.sidebarLogo,
dynamicTitle: this.dynamicTitle,
sideTheme: this.sideTheme,
theme: this.theme,
navIcon: this.navIcon,
});
setTimeout(this.$modal.closeLoading(), 1000);
},
resetSetting() {
this.$modal.loading('正在清除设置缓存并刷新,请稍候...');
this.$cache.local.remove('layout-setting');
setTimeout('window.location.reload()', 1000);
},
},
};
</script>
<style lang="scss" scoped>
::v-deep .el-switch {
width: 40px;
}
.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, 0.85);
font-size: 14px;
line-height: 22px;
}
.drawer-item {
color: rgba(0, 0, 0, 0.65);
font-size: 14px;
padding: 12px 0;
}
.drawer-switch {
float: right;
}
.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;
}
}
}
}
</style>

View File

@@ -0,0 +1,26 @@
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
// https://github.com/PanJiaChen/vue-element-admin/issues/1135
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);
};
}
},
},
};

View File

@@ -0,0 +1,30 @@
<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) {
const ic = 'el-icon-' + icon;
vnodes.push(<i style="color:#ffffff;" class={ic} />);
}
if (title) {
vnodes.push(<span slot="title">{title}</span>);
}
return vnodes;
},
};
</script>

View File

@@ -0,0 +1,35 @@
<template>
<!-- eslint-disable vue/require-component-is -->
<component v-bind="linkProps(to)">
<slot />
</component>
</template>
<script>
import { isExternal } from '@/utils/validate';
export default {
props: {
to: {
type: String,
required: true,
},
},
methods: {
linkProps(url) {
if (isExternal(url)) {
return {
is: 'a',
href: url,
target: '_blank',
rel: 'noopener',
};
}
return {
is: 'router-link',
to: url,
};
},
},
};
</script>

View File

@@ -0,0 +1,99 @@
<template>
<div class="sidebar-logo-container" :class="{ collapse: collapse }">
<transition name="sidebarLogoFade">
<router-link v-if="collapse" key="collapse" class="sidebar-logo-link" to="/">
<img v-if="siteLogoSquare" :src="siteLogoSquare" class="sidebar-logo-small" />
</router-link>
<router-link v-else key="expand" class="sidebar-logo-link" to="/">
<img v-if="siteLogoLeftTop" :src="siteLogoLeftTop" class="sidebar-logo-big" />
</router-link>
</transition>
</div>
</template>
<script>
import * as systemConfigApi from '@/api/systemConfig.js';
export default {
name: 'SidebarLogo',
props: {
collapse: {
type: Boolean,
required: true,
},
},
data() {
return {
title: 'Vue Element Admin',
siteLogoLeftTop: '', //左上角logo大
siteLogoSquare: '', //左上角logo小
};
},
mounted() {
this.getLogo();
},
methods: {
//获取左上角菜单logo
getLogo() {
systemConfigApi.getSiteLogoApi().then((data) => {
this.siteLogoLeftTop = data.siteLogoLeftTop;
this.siteLogoSquare = data.siteLogoSquare;
});
},
},
};
</script>
<style lang="scss" scoped>
.sidebarLogoFade-enter-active {
transition: opacity 1.5s;
}
.sidebar-logo-big {
width: auto;
height: 40px;
vertical-align: middle;
margin-right: 12px;
}
.sidebarLogoFade-enter,
.sidebarLogoFade-leave-to {
opacity: 0;
}
.sidebar-logo-container {
position: relative;
width: 100%;
height: 65px;
line-height: 65px;
text-align: center;
overflow: hidden;
& .sidebar-logo-link {
height: 100%;
width: 100%;
& .sidebar-logo-small {
width: 35px;
height: 35px;
vertical-align: middle;
}
& .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>

View File

@@ -0,0 +1,100 @@
<template>
<div v-if="!item.hidden">
<template
v-if="
hasOneShowingChild(item.child, item) &&
(!onlyOneChild.child || onlyOneChild.noShowingChildren) &&
!item.alwaysShow
"
>
<app-link v-if="onlyOneChild" :to="resolvePath(onlyOneChild.url)">
<el-menu-item :index="resolvePath(onlyOneChild.url)" :class="{ 'submenu-title-noDropdown': !isNest }">
<item :icon="onlyOneChild.extra || (item.meta && item.extra)" :title="onlyOneChild.name" />
</el-menu-item>
</app-link>
</template>
<el-submenu v-else ref="subMenu" :index="resolvePath(item.url)" popper-append-to-body>
<template slot="title">
<item v-if="item" :icon="item && item.extra" :title="item.name" />
</template>
<sidebar-item
v-for="childs in item.child"
:key="childs.url"
:is-nest="true"
:item="childs"
:base-path="resolvePath(childs.url)"
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() {
// To fix https://github.com/PanJiaChen/vue-admin-template/issues/237
// TODO: refactor with render function
this.onlyOneChild = null;
return {};
},
methods: {
hasOneShowingChild(child = [], parent) {
const showingChildren = child.filter((item) => {
if (item.hidden) {
return false;
} else {
// 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) {
if (isExternal(routePath)) {
return routePath;
}
if (isExternal(this.basePath)) {
return this.basePath;
}
return path.resolve(this.basePath, routePath);
},
},
};
</script>

View File

@@ -0,0 +1,63 @@
<template>
<div
:class="{ 'has-logo': showLogo }"
:style="{
backgroundColor: settings.sideTheme === 'theme-light' ? variables.menuLightBackground : variables.menuBackground,
}"
>
<logo v-if="showLogo" :collapse="isCollapse" />
<el-scrollbar wrap-class="scrollbar-wrapper">
<el-menu
:default-active="activeMenu"
:collapse="isCollapse"
:background-color="
settings.sideTheme === 'theme-light' ? variables.menuLightBackground : variables.menuBackground
"
:text-color="settings.sideTheme === 'theme-light' ? variables.menuLightColor : variables.menuColor"
:unique-opened="true"
:active-text-color="variables.menuActiveText"
:collapse-transition="true"
mode="vertical"
>
<sidebar-item v-for="route in sidebarRouters" :key="route.url" :item="route" :base-path="route.url" />
</el-menu>
</el-scrollbar>
</div>
</template>
<script>
import { mapGetters, mapState } from 'vuex';
import Logo from './Logo';
import SidebarItem from './SidebarItem';
import variables from '@/styles/variables.scss';
export default {
components: { SidebarItem, Logo },
computed: {
...mapState(['settings']),
...mapGetters(['permission_routes', '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>
<style lang="scss">
.el-submenu__icon-arrow {
color: #fff !important;
}
</style>

View File

@@ -0,0 +1,85 @@
<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;
},
},
methods: {
handleScroll(e) {
const eventDelta = e.wheelDelta || -e.deltaY * 40;
const $scrollWrapper = this.scrollWrapper;
$scrollWrapper.scrollLeft = $scrollWrapper.scrollLeft + eventDelta / 4;
},
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>

View File

@@ -0,0 +1,299 @@
<template>
<div id="tags-view-container" class="tags-view-container" v-if="!isPhone">
<scroll-pane ref="scrollPane" class="tags-view-wrapper">
<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)"
>
{{ 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)">刷新</li>
<li v-if="!isAffix(selectedTag)" @click="closeSelectedTag(selectedTag)">关闭</li>
<li @click="closeOthersTags">关闭其他</li>
<li @click="closeAllTags(selectedTag)">关闭所有</li>
</ul>
</div>
</template>
<script>
import ScrollPane from './ScrollPane';
import path from 'path';
export default {
components: { ScrollPane },
data() {
return {
fullWidth: document.body.clientWidth,
visible: false,
top: 0,
left: 0,
selectedTag: {},
affixTags: [],
isPhone: this.$wechat.isPhone(),
};
},
computed: {
visitedViews() {
return this.$store.state.tagsView.visitedViews;
},
routes() {
return this.$store.state.permission.routes;
},
theme() {
return this.$store.state.settings.theme;
},
},
watch: {
$route() {
this.addTags();
if (!this.isPhone) this.moveToCurrentTag();
},
visible(value) {
if (value) {
document.body.addEventListener('click', this.closeMenu);
} else {
document.body.removeEventListener('click', this.closeMenu);
}
},
},
mounted() {
window.addEventListener('resize', this.handleResize);
this.initTags();
this.addTags();
},
methods: {
handleResize(event) {
this.fullWidth = document.body.clientWidth;
},
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;
},
},
};
</script>
<style lang="scss" scoped>
.tags-view-container {
padding: 10px 0;
width: 100%;
// background: #f5f7f9;
background: #f5f5f5;
// 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: 30px;
line-height: 30px;
border: 1px solid #fff;
color: #495060;
background: #fff;
padding: 0 8px;
font-size: 12px;
margin-left: 10px;
border-radius: 3px;
&:first-of-type {
margin-left: 20px;
}
&:last-of-type {
margin-right: 20px;
}
&.active {
background-color: #fff;
color: #1890ff;
border-color: #fff;
// &::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, 0.3);
li {
margin: 0;
padding: 7px 16px;
cursor: pointer;
&:hover {
background: #eee;
}
}
}
}
</style>
<style lang="scss" scoped>
//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 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
transform-origin: 100% 50%;
&:before {
transform: scale(0.6);
display: inline-block;
vertical-align: -3px;
}
&:hover {
background-color: #b4bccc;
color: #fff;
}
}
}
}
</style>

View File

@@ -0,0 +1,82 @@
<template>
<div class="footers acea-row">
<!-- <div class="title mb15" v-text="copyrightNew">
<el-divider direction="vertical"></el-divider>
<el-link v-for="item in links" :key="item.key" :href="item.href" target="_blank" class="mr15 mb20">{{
item.title
}}</el-link> -->
</div>
</template>
<script>
// +----------------------------------------------------------------------
// | CRMEB [ CRMEB赋能开发者助力企业发展 ]
// +----------------------------------------------------------------------
// | Copyright (c) 2016~2025 https://www.crmeb.com All rights reserved.
// +----------------------------------------------------------------------
// | Licensed CRMEB并不是自由软件未经许可不能去掉CRMEB相关版权
// +----------------------------------------------------------------------
// | Author: CRMEB Team <admin@crmeb.com>
// +----------------------------------------------------------------------
import { copyrightInfoApi } from '@/api/authInformation';
import { checkPermi } from '@/utils/permission'; // 权限判断函数
export default {
name: 'i-copyright',
data() {
return {
links: [
{
title: '官网',
key: '1',
href: 'https://www.crmeb.com',
blankTarget: true,
},
{
title: '社区',
key: '2',
href: 'https://q.crmeb.net/?categoryId=122&sequence=0',
blankTarget: true,
},
{
title: '文档',
key: '3',
href: 'https://help.crmeb.net/crmeb_java/1748037',
blankTarget: true,
},
],
copyright: 'Copyright © 2023 西安众邦网络科技有限公司',
copyrightNew: '',
};
},
mounted() {
if (checkPermi(['platform:copyright:get:info'])) this.getVersion();
},
methods: {
getVersion() {
copyrightInfoApi().then((res) => {
const data = res || {};
this.copyrightNew = data.companyName ? data.companyName : this.copyright;
});
},
},
};
</script>
<style lang="scss" scoped>
.footers {
text-align: center;
font-size: 14px;
color: #808695;
justify-content: center;
.title {
font-size: 14px;
color: #808695;
}
}
.ivu-global-footer {
/* margin: 48px 0 24px 0; */
/* padding: 0 16px; */
margin: 15px 0px;
text-align: center;
box-sizing: border-box;
margin-left: 210px;
}
</style>

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';

View File

@@ -0,0 +1,125 @@
<template>
<div :class="classObj" class="app-wrapper">
<div v-if="device === 'mobile' && sidebar.opened" class="drawer-bg" @click="handleClickOutside" />
<sidebar class="sidebar-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>
<copy-right />
</div>
<!-- <div class="open-image" @click="clear" v-if="openImage"><img src="@/assets/imgs/pc1.jpg" alt=""></div>-->
</div>
</template>
<script>
import RightPanel from '@/components/RightPanel';
import copyRight from './components/copyright';
import { AppMain, Navbar, Settings, Sidebar, TagsView } from './components';
import ResizeMixin from './mixin/ResizeHandler';
import { mapState } from 'vuex';
export default {
name: 'Layout',
data() {
return {
openImage: true,
};
},
components: {
AppMain,
Navbar,
RightPanel,
Settings,
Sidebar,
TagsView,
copyRight,
},
mixins: [ResizeMixin],
computed: {
...mapState({
sidebar: (state) => state.app.sidebar,
device: (state) => state.app.device,
showSettings: (state) => state.settings.showSettings,
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',
};
},
},
methods: {
clear() {
this.openImage = false;
},
handleClickOutside() {
this.$store.dispatch('app/closeSideBar', { withoutAnimation: false });
},
},
};
</script>
<style lang="scss" scoped>
@import '~@/styles/mixin.scss';
@import '~@/styles/variables.scss';
.open-image {
display: flex;
align-items: center;
justify-content: center;
position: fixed;
background-color: rgba(0, 0, 0, 0.6);
height: 100%;
width: 100%;
top: 0;
left: 0;
z-index: 999999;
}
.app-wrapper {
@include clearfix;
position: relative;
height: 100%;
width: 100%;
&.mobile.openSidebar {
position: fixed;
top: 0;
}
}
.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);
}
.mobile .fixed-header {
width: 100%;
}
</style>

View File

@@ -0,0 +1,55 @@
// +----------------------------------------------------------------------
// | CRMEB [ CRMEB赋能开发者助力企业发展 ]
// +----------------------------------------------------------------------
// | Copyright (c) 2016~2025 https://www.crmeb.com All rights reserved.
// +----------------------------------------------------------------------
// | Licensed CRMEB并不是自由软件未经许可不能去掉CRMEB相关版权
// +----------------------------------------------------------------------
// | Author: CRMEB Team <admin@crmeb.com>
// +----------------------------------------------------------------------
import store from '@/store';
const { body } = document;
const WIDTH = 992; // refer to Bootstrap's responsive design
export default {
watch: {
$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 });
}
}
},
},
};