Browse Source

first commit

wangxx 4 months ago
parent
commit
04851e2aa7

+ 46 - 0
src/hooks/chart.js

@@ -0,0 +1,46 @@
1
+import { onMounted, onUnmounted, ref } from 'vue';
2
+import { useResizeObserver } from '@vueuse/core';
3
+import * as echarts from 'echarts';
4
+
5
+export function useEcharts(domRef) {
6
+  const chartInstance = ref(null);
7
+
8
+  // 初始化图表
9
+  const initChart = () => {
10
+    if (!domRef.value) return;
11
+    chartInstance.value = echarts.init(domRef.value, 'macarons');
12
+  };
13
+
14
+  const setOption = (option) => {
15
+    if (!chartInstance.value) return;
16
+    chartInstance.value.setOption(option);
17
+  };
18
+
19
+  const handleResize = () => {
20
+    if (!chartInstance.value) return;
21
+    chartInstance.value.resize();
22
+  };
23
+
24
+  // 使用vueuse的resizeObserver监听容器大小变化
25
+  useResizeObserver(domRef, () => {
26
+    handleResize();
27
+  });
28
+
29
+  // 组件挂载时初始化
30
+  onMounted(() => {
31
+    initChart();
32
+  });
33
+
34
+  // 组件卸载时销毁
35
+  onUnmounted(() => {
36
+    if (chartInstance.value) {
37
+      chartInstance.value.dispose();
38
+      chartInstance.value = null;
39
+    }
40
+  });
41
+
42
+  return {
43
+    chartInstance,
44
+    setOption,
45
+  };
46
+}

+ 89 - 0
src/layout/components/AppMain.vue

@@ -0,0 +1,89 @@
1
+<template>
2
+  <section class="app-main">
3
+    <router-view v-slot="{ Component, route }">
4
+      <transition name="fade-transform" mode="out-in">
5
+        <keep-alive :include="tagsViewStore.cachedViews">
6
+          <component v-if="!route.meta.link" :is="Component" :key="route.path"/>
7
+        </keep-alive>
8
+      </transition>
9
+    </router-view>
10
+    <iframe-toggle />
11
+    <copyright />
12
+  </section>
13
+</template>
14
+
15
+<script setup>
16
+import copyright from "./Copyright/index"
17
+import iframeToggle from "./IframeToggle/index"
18
+import useTagsViewStore from '@/store/modules/tagsView'
19
+
20
+const route = useRoute()
21
+const tagsViewStore = useTagsViewStore()
22
+
23
+onMounted(() => {
24
+  addIframe()
25
+})
26
+
27
+watchEffect(() => {
28
+  addIframe()
29
+})
30
+
31
+function addIframe() {
32
+  if (route.meta.link) {
33
+    useTagsViewStore().addIframeView(route)
34
+  }
35
+}
36
+</script>
37
+
38
+<style lang="scss" scoped>
39
+.app-main {
40
+  /* 50= navbar  50  */
41
+  min-height: calc(100vh - 50px);
42
+  width: 100%;
43
+  position: relative;
44
+  overflow: hidden;
45
+}
46
+
47
+.app-main:has(.copyright) {
48
+  padding-bottom: 36px;
49
+}
50
+
51
+.fixed-header + .app-main {
52
+  padding-top: 50px;
53
+}
54
+
55
+.hasTagsView {
56
+  .app-main {
57
+    /* 84 = navbar + tags-view = 50 + 34 */
58
+    min-height: calc(100vh - 84px);
59
+  }
60
+
61
+  .fixed-header + .app-main {
62
+    padding-top: 84px;
63
+  }
64
+}
65
+</style>
66
+
67
+<style lang="scss">
68
+// fix css style bug in open el-dialog
69
+.el-popup-parent--hidden {
70
+  .fixed-header {
71
+    padding-right: 6px;
72
+  }
73
+}
74
+
75
+::-webkit-scrollbar {
76
+  width: 6px;
77
+  height: 6px;
78
+}
79
+
80
+::-webkit-scrollbar-track {
81
+  background-color: #f1f1f1;
82
+}
83
+
84
+::-webkit-scrollbar-thumb {
85
+  background-color: #c0c0c0;
86
+  border-radius: 3px;
87
+}
88
+</style>
89
+

+ 31 - 0
src/layout/components/Copyright/index.vue

@@ -0,0 +1,31 @@
1
+<template>
2
+  <footer v-if="visible" class="copyright">
3
+    <span>{{ content }}</span>
4
+  </footer>
5
+</template>
6
+
7
+<script setup>
8
+import useSettingsStore from '@/store/modules/settings'
9
+
10
+const settingsStore = useSettingsStore()
11
+
12
+const visible = computed(() => settingsStore.footerVisible)
13
+const content = computed(() => settingsStore.footerContent)
14
+</script>
15
+
16
+<style scoped>
17
+.copyright {
18
+  position: fixed;
19
+  bottom: 0;
20
+  left: 0;
21
+  right: 0;
22
+  height: 36px;
23
+  padding: 10px 20px;
24
+  text-align: right;
25
+  background-color: #f8f8f8;
26
+  color: #666;
27
+  font-size: 14px;
28
+  border-top: 1px solid #e7e7e7;
29
+  z-index: 999;
30
+}
31
+</style>

+ 25 - 0
src/layout/components/IframeToggle/index.vue

@@ -0,0 +1,25 @@
1
+<template>
2
+  <inner-link
3
+    v-for="(item, index) in tagsViewStore.iframeViews"
4
+    :key="item.path"
5
+    :iframeId="'iframe' + index"
6
+    v-show="route.path === item.path"
7
+    :src="iframeUrl(item.meta.link, item.query)"
8
+  ></inner-link>
9
+</template>
10
+
11
+<script setup>
12
+import InnerLink from "../InnerLink/index"
13
+import useTagsViewStore from "@/store/modules/tagsView"
14
+
15
+const route = useRoute()
16
+const tagsViewStore = useTagsViewStore()
17
+
18
+function iframeUrl(url, query) {
19
+  if (Object.keys(query).length > 0) {
20
+    let params = Object.keys(query).map((key) => key + "=" + query[key]).join("&")
21
+    return url + "?" + params
22
+  }
23
+  return url
24
+}
25
+</script>

+ 35 - 0
src/layout/components/InnerLink/index.vue

@@ -0,0 +1,35 @@
1
+<template>
2
+  <div :style="'height:' + height" v-loading="loading" element-loading-text="正在加载页面,请稍候!">
3
+    <iframe
4
+      :id="iframeId"
5
+      style="width: 100%; height: 100%"
6
+      :src="src"
7
+      ref="iframeRef"
8
+      frameborder="no"
9
+    ></iframe>
10
+  </div>
11
+</template>
12
+
13
+<script setup>
14
+const props = defineProps({
15
+  src: {
16
+    type: String,
17
+    default: "/"
18
+  },
19
+  iframeId: {
20
+    type: String
21
+  }
22
+})
23
+
24
+const loading = ref(true)
25
+const height = ref(document.documentElement.clientHeight - 94.5 + 'px')
26
+const iframeRef = ref(null)
27
+
28
+onMounted(() => {
29
+  if (iframeRef.value) {
30
+    iframeRef.value.onload = () => {
31
+      loading.value = false
32
+    }
33
+  }
34
+})
35
+</script>

+ 224 - 0
src/layout/components/Navbar.vue

@@ -0,0 +1,224 @@
1
+<template>
2
+  <div class="navbar">
3
+    <hamburger id="hamburger-container" :is-active="appStore.sidebar.opened" class="hamburger-container" @toggleClick="toggleSideBar" />
4
+    <breadcrumb v-if="!settingsStore.topNav" id="breadcrumb-container" class="breadcrumb-container" />
5
+    <top-nav v-if="settingsStore.topNav" id="topmenu-container" class="topmenu-container" />
6
+
7
+    <div class="right-menu">
8
+      <template v-if="appStore.device !== 'mobile'">
9
+        <header-search id="header-search" class="right-menu-item" />
10
+
11
+        <!-- <el-tooltip content="源码地址" effect="dark" placement="bottom">
12
+          <ruo-yi-git id="ruoyi-git" class="right-menu-item hover-effect" />
13
+        </el-tooltip>
14
+
15
+        <el-tooltip content="文档地址" effect="dark" placement="bottom">
16
+          <ruo-yi-doc id="ruoyi-doc" class="right-menu-item hover-effect" />
17
+        </el-tooltip> -->
18
+
19
+        <screenfull id="screenfull" class="right-menu-item hover-effect" />
20
+
21
+        <el-tooltip content="主题模式" effect="dark" placement="bottom">
22
+          <div class="right-menu-item hover-effect theme-switch-wrapper" @click="toggleTheme">
23
+            <svg-icon v-if="settingsStore.isDark" icon-class="sunny" />
24
+            <svg-icon v-if="!settingsStore.isDark" icon-class="moon" />
25
+          </div>
26
+        </el-tooltip>
27
+
28
+        <el-tooltip content="布局大小" effect="dark" placement="bottom">
29
+          <size-select id="size-select" class="right-menu-item hover-effect" />
30
+        </el-tooltip>
31
+      </template>
32
+
33
+      <el-dropdown @command="handleCommand" class="avatar-container right-menu-item hover-effect" trigger="hover">
34
+        <div class="avatar-wrapper">
35
+          <img :src="userStore.avatar" class="user-avatar" />
36
+          <span class="user-nickname"> {{ userStore.nickName }} </span>
37
+        </div>
38
+        <template #dropdown>
39
+          <el-dropdown-menu>
40
+            <router-link to="/user/profile">
41
+              <el-dropdown-item>个人中心</el-dropdown-item>
42
+            </router-link>
43
+            <el-dropdown-item divided command="logout">
44
+              <span>退出登录</span>
45
+            </el-dropdown-item>
46
+          </el-dropdown-menu>
47
+        </template>
48
+      </el-dropdown>
49
+      <div class="right-menu-item hover-effect setting" @click="setLayout" v-if="settingsStore.showSettings">
50
+        <svg-icon icon-class="more-up" />
51
+      </div>
52
+    </div>
53
+  </div>
54
+</template>
55
+
56
+<script setup>
57
+import { ElMessageBox } from 'element-plus'
58
+import Breadcrumb from '@/components/Breadcrumb'
59
+import TopNav from '@/components/TopNav'
60
+import Hamburger from '@/components/Hamburger'
61
+import Screenfull from '@/components/Screenfull'
62
+import SizeSelect from '@/components/SizeSelect'
63
+import HeaderSearch from '@/components/HeaderSearch'
64
+import RuoYiGit from '@/components/RuoYi/Git'
65
+import RuoYiDoc from '@/components/RuoYi/Doc'
66
+import useAppStore from '@/store/modules/app'
67
+import useUserStore from '@/store/modules/user'
68
+import useSettingsStore from '@/store/modules/settings'
69
+
70
+const appStore = useAppStore()
71
+const userStore = useUserStore()
72
+const settingsStore = useSettingsStore()
73
+
74
+function toggleSideBar() {
75
+  appStore.toggleSideBar()
76
+}
77
+
78
+function handleCommand(command) {
79
+  switch (command) {
80
+    case "setLayout":
81
+      setLayout()
82
+      break
83
+    case "logout":
84
+      logout()
85
+      break
86
+    default:
87
+      break
88
+  }
89
+}
90
+
91
+function logout() {
92
+  ElMessageBox.confirm('确定注销并退出系统吗?', '提示', {
93
+    confirmButtonText: '确定',
94
+    cancelButtonText: '取消',
95
+    type: 'warning'
96
+  }).then(() => {
97
+    userStore.logOut().then(() => {
98
+      location.href = '/index'
99
+    })
100
+  }).catch(() => { })
101
+}
102
+
103
+const emits = defineEmits(['setLayout'])
104
+function setLayout() {
105
+  emits('setLayout')
106
+}
107
+
108
+function toggleTheme() {
109
+  settingsStore.toggleTheme()
110
+}
111
+</script>
112
+
113
+<style lang='scss' scoped>
114
+.navbar {
115
+  height: 50px;
116
+  overflow: hidden;
117
+  position: relative;
118
+  background: var(--navbar-bg);
119
+  box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
120
+
121
+  .hamburger-container {
122
+    line-height: 46px;
123
+    height: 100%;
124
+    float: left;
125
+    cursor: pointer;
126
+    transition: background 0.3s;
127
+    -webkit-tap-highlight-color: transparent;
128
+
129
+    &:hover {
130
+      background: rgba(0, 0, 0, 0.025);
131
+    }
132
+  }
133
+
134
+  .breadcrumb-container {
135
+    float: left;
136
+  }
137
+
138
+  .topmenu-container {
139
+    position: absolute;
140
+    left: 50px;
141
+  }
142
+
143
+  .errLog-container {
144
+    display: inline-block;
145
+    vertical-align: top;
146
+  }
147
+
148
+  .right-menu {
149
+    float: right;
150
+    height: 100%;
151
+    line-height: 50px;
152
+    display: flex;
153
+
154
+    &:focus {
155
+      outline: none;
156
+    }
157
+
158
+    .right-menu-item {
159
+      display: inline-block;
160
+      padding: 0 8px;
161
+      height: 100%;
162
+      font-size: 18px;
163
+      color: #5a5e66;
164
+      vertical-align: text-bottom;
165
+
166
+      &.hover-effect {
167
+        cursor: pointer;
168
+        transition: background 0.3s;
169
+
170
+        &:hover {
171
+          background: rgba(0, 0, 0, 0.025);
172
+        }
173
+      }
174
+
175
+      &.theme-switch-wrapper {
176
+        display: flex;
177
+        align-items: center;
178
+
179
+        svg {
180
+          transition: transform 0.3s;
181
+          
182
+          &:hover {
183
+            transform: scale(1.15);
184
+          }
185
+        }
186
+      }
187
+    }
188
+
189
+    .avatar-container {
190
+      margin-right: 0px;
191
+      padding-right: 0px;
192
+
193
+      .avatar-wrapper {
194
+        margin-top: 10px;
195
+        right: 5px;
196
+        position: relative;
197
+
198
+        .user-avatar {
199
+          cursor: pointer;
200
+          width: 30px;
201
+          height: 30px;
202
+          border-radius: 50%;
203
+        }
204
+
205
+        .user-nickname{
206
+          position: relative;
207
+          left: 5px;
208
+          bottom: 10px;
209
+          font-size: 14px;
210
+          font-weight: bold;
211
+        }
212
+
213
+        i {
214
+          cursor: pointer;
215
+          position: absolute;
216
+          right: -20px;
217
+          top: 25px;
218
+          font-size: 12px;
219
+        }
220
+      }
221
+    }
222
+  }
223
+}
224
+</style>

+ 221 - 0
src/layout/components/Settings/index.vue

@@ -0,0 +1,221 @@
1
+<template>
2
+  <el-drawer v-model="showSettings" :withHeader="false" :lock-scroll="false" direction="rtl" size="300px">
3
+    <div class="setting-drawer-title">
4
+      <h3 class="drawer-title">主题风格设置</h3>
5
+    </div>
6
+    <div class="setting-drawer-block-checbox">
7
+      <div class="setting-drawer-block-checbox-item" @click="handleTheme('theme-dark')">
8
+        <img src="@/assets/images/dark.svg" alt="dark" />
9
+        <div v-if="sideTheme === 'theme-dark'" class="setting-drawer-block-checbox-selectIcon" style="display: block;">
10
+          <i aria-label="图标: check" class="anticon anticon-check">
11
+            <svg viewBox="64 64 896 896" data-icon="check" width="1em" height="1em" :fill="theme" aria-hidden="true" focusable="false" class>
12
+              <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" />
13
+            </svg>
14
+          </i>
15
+        </div>
16
+      </div>
17
+      <div class="setting-drawer-block-checbox-item" @click="handleTheme('theme-light')">
18
+        <img src="@/assets/images/light.svg" alt="light" />
19
+        <div v-if="sideTheme === 'theme-light'" class="setting-drawer-block-checbox-selectIcon" style="display: block;">
20
+          <i aria-label="图标: check" class="anticon anticon-check">
21
+            <svg viewBox="64 64 896 896" data-icon="check" width="1em" height="1em" :fill="theme" aria-hidden="true" focusable="false" class>
22
+              <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" />
23
+            </svg>
24
+          </i>
25
+        </div>
26
+      </div>
27
+    </div>
28
+    <div class="drawer-item">
29
+      <span>主题颜色</span>
30
+      <span class="comp-style">
31
+        <el-color-picker v-model="theme" :predefine="predefineColors" @change="themeChange"/>
32
+      </span>
33
+    </div>
34
+    <el-divider />
35
+
36
+    <h3 class="drawer-title">系统布局配置</h3>
37
+
38
+    <div class="drawer-item">
39
+      <span>开启 TopNav</span>
40
+      <span class="comp-style">
41
+        <el-switch v-model="settingsStore.topNav" @change="topNavChange" class="drawer-switch" />
42
+      </span>
43
+    </div>
44
+
45
+    <div class="drawer-item">
46
+      <span>开启 Tags-Views</span>
47
+      <span class="comp-style">
48
+        <el-switch v-model="settingsStore.tagsView" class="drawer-switch" />
49
+      </span>
50
+    </div>
51
+
52
+    <div class="drawer-item">
53
+      <span>显示页签图标</span>
54
+      <span class="comp-style">
55
+        <el-switch v-model="settingsStore.tagsIcon" :disabled="!settingsStore.tagsView" class="drawer-switch" />
56
+      </span>
57
+    </div>
58
+
59
+    <div class="drawer-item">
60
+      <span>固定 Header</span>
61
+      <span class="comp-style">
62
+        <el-switch v-model="settingsStore.fixedHeader" class="drawer-switch" />
63
+      </span>
64
+    </div>
65
+
66
+    <div class="drawer-item">
67
+      <span>显示 Logo</span>
68
+      <span class="comp-style">
69
+        <el-switch v-model="settingsStore.sidebarLogo" class="drawer-switch" />
70
+      </span>
71
+    </div>
72
+
73
+    <div class="drawer-item">
74
+      <span>动态标题</span>
75
+      <span class="comp-style">
76
+        <el-switch v-model="settingsStore.dynamicTitle" @change="dynamicTitleChange" class="drawer-switch" />
77
+      </span>
78
+    </div>
79
+
80
+    <div class="drawer-item">
81
+      <span>底部版权</span>
82
+      <span class="comp-style">
83
+        <el-switch v-model="settingsStore.footerVisible" class="drawer-switch" />
84
+      </span>
85
+    </div>
86
+
87
+    <el-divider />
88
+
89
+    <el-button type="primary" plain icon="DocumentAdd" @click="saveSetting">保存配置</el-button>
90
+    <el-button plain icon="Refresh" @click="resetSetting">重置配置</el-button>
91
+  </el-drawer>
92
+
93
+</template>
94
+
95
+<script setup>
96
+import useAppStore from '@/store/modules/app'
97
+import useSettingsStore from '@/store/modules/settings'
98
+import usePermissionStore from '@/store/modules/permission'
99
+import { handleThemeStyle } from '@/utils/theme'
100
+
101
+const { proxy } = getCurrentInstance()
102
+const appStore = useAppStore()
103
+const settingsStore = useSettingsStore()
104
+const permissionStore = usePermissionStore()
105
+const showSettings = ref(false)
106
+const theme = ref(settingsStore.theme)
107
+const sideTheme = ref(settingsStore.sideTheme)
108
+const storeSettings = computed(() => settingsStore)
109
+const predefineColors = ref(["#409EFF", "#ff4500", "#ff8c00", "#ffd700", "#90ee90", "#00ced1", "#1e90ff", "#c71585"])
110
+
111
+/** 是否需要topnav */
112
+function topNavChange(val) {
113
+  if (!val) {
114
+    appStore.toggleSideBarHide(false)
115
+    permissionStore.setSidebarRouters(permissionStore.defaultRoutes)
116
+  }
117
+}
118
+
119
+/** 是否需要dynamicTitle */
120
+function dynamicTitleChange() {
121
+  useSettingsStore().setTitle(useSettingsStore().title)
122
+}
123
+
124
+function themeChange(val) {
125
+  settingsStore.theme = val
126
+  handleThemeStyle(val)
127
+}
128
+
129
+function handleTheme(val) {
130
+  settingsStore.sideTheme = val
131
+  sideTheme.value = val
132
+}
133
+
134
+function saveSetting() {
135
+  proxy.$modal.loading("正在保存到本地,请稍候...")
136
+  let layoutSetting = {
137
+    "topNav": storeSettings.value.topNav,
138
+    "tagsView": storeSettings.value.tagsView,
139
+    "tagsIcon": storeSettings.value.tagsIcon,
140
+    "fixedHeader": storeSettings.value.fixedHeader,
141
+    "sidebarLogo": storeSettings.value.sidebarLogo,
142
+    "dynamicTitle": storeSettings.value.dynamicTitle,
143
+    "footerVisible": storeSettings.value.footerVisible,
144
+    "sideTheme": storeSettings.value.sideTheme,
145
+    "theme": storeSettings.value.theme
146
+  }
147
+  localStorage.setItem("layout-setting", JSON.stringify(layoutSetting))
148
+  setTimeout(proxy.$modal.closeLoading(), 1000)
149
+}
150
+
151
+function resetSetting() {
152
+  proxy.$modal.loading("正在清除设置缓存并刷新,请稍候...")
153
+  localStorage.removeItem("layout-setting")
154
+  setTimeout("window.location.reload()", 1000)
155
+}
156
+
157
+function openSetting() {
158
+  showSettings.value = true
159
+}
160
+
161
+defineExpose({
162
+  openSetting
163
+})
164
+</script>
165
+
166
+<style lang='scss' scoped>
167
+.setting-drawer-title {
168
+  margin-bottom: 12px;
169
+  color: var(--el-text-color-primary, rgba(0, 0, 0, 0.85));
170
+  line-height: 22px;
171
+  font-weight: bold;
172
+
173
+  .drawer-title {
174
+    font-size: 14px;
175
+  }
176
+}
177
+
178
+.setting-drawer-block-checbox {
179
+  display: flex;
180
+  justify-content: flex-start;
181
+  align-items: center;
182
+  margin-top: 10px;
183
+  margin-bottom: 20px;
184
+
185
+  .setting-drawer-block-checbox-item {
186
+    position: relative;
187
+    margin-right: 16px;
188
+    border-radius: 2px;
189
+    cursor: pointer;
190
+
191
+    img {
192
+      width: 48px;
193
+      height: 48px;
194
+    }
195
+
196
+    .setting-drawer-block-checbox-selectIcon {
197
+      position: absolute;
198
+      top: 0;
199
+      right: 0;
200
+      width: 100%;
201
+      height: 100%;
202
+      padding-top: 15px;
203
+      padding-left: 24px;
204
+      color: #1890ff;
205
+      font-weight: 700;
206
+      font-size: 14px;
207
+    }
208
+  }
209
+}
210
+
211
+.drawer-item {
212
+  color: var(--el-text-color-regular, rgba(0, 0, 0, 0.65));
213
+  padding: 12px 0;
214
+  font-size: 14px;
215
+
216
+  .comp-style {
217
+    float: right;
218
+    margin: -3px 8px 0px 0px;
219
+  }
220
+}
221
+</style>

+ 40 - 0
src/layout/components/Sidebar/Link.vue

@@ -0,0 +1,40 @@
1
+<template>
2
+  <component :is="type" v-bind="linkProps()">
3
+    <slot />
4
+  </component>
5
+</template>
6
+
7
+<script setup>
8
+import { isExternal } from '@/utils/validate'
9
+
10
+const props = defineProps({
11
+  to: {
12
+    type: [String, Object],
13
+    required: true
14
+  }
15
+})
16
+
17
+const isExt = computed(() => {
18
+  return isExternal(props.to)
19
+})
20
+
21
+const type = computed(() => {
22
+  if (isExt.value) {
23
+    return 'a'
24
+  }
25
+  return 'router-link'
26
+})
27
+
28
+function linkProps() {
29
+  if (isExt.value) {
30
+    return {
31
+      href: props.to,
32
+      target: '_blank',
33
+      rel: 'noopener'
34
+    }
35
+  }
36
+  return {
37
+    to: props.to
38
+  }
39
+}
40
+</script>

+ 97 - 0
src/layout/components/Sidebar/Logo.vue

@@ -0,0 +1,97 @@
1
+<template>
2
+  <div class="sidebar-logo-container" :class="{ 'collapse': collapse }">
3
+    <transition name="sidebarLogoFade">
4
+      <router-link v-if="collapse" key="collapse" class="sidebar-logo-link" to="/">
5
+        <img v-if="logo" :src="logo" class="sidebar-logo" />
6
+        <h1 v-else class="sidebar-title">{{ title }}</h1>
7
+      </router-link>
8
+      <router-link v-else key="expand" class="sidebar-logo-link" to="/">
9
+        <img v-if="logo" :src="logo" class="sidebar-logo" />
10
+        <h1 class="sidebar-title">{{ title }}</h1>
11
+      </router-link>
12
+    </transition>
13
+  </div>
14
+</template>
15
+
16
+<script setup>
17
+import logo from '@/assets/logo/logo.png'
18
+import useSettingsStore from '@/store/modules/settings'
19
+import variables from '@/assets/styles/variables.module.scss'
20
+
21
+defineProps({
22
+  collapse: {
23
+    type: Boolean,
24
+    required: true
25
+  }
26
+})
27
+
28
+const title = import.meta.env.VITE_APP_TITLE
29
+const settingsStore = useSettingsStore()
30
+const sideTheme = computed(() => settingsStore.sideTheme)
31
+
32
+// 获取Logo背景色
33
+const getLogoBackground = computed(() => {
34
+  if (settingsStore.isDark) {
35
+    return 'var(--sidebar-bg)'
36
+  }
37
+  return sideTheme.value === 'theme-dark' ? variables.menuBg : variables.menuLightBg
38
+})
39
+
40
+// 获取Logo文字颜色
41
+const getLogoTextColor = computed(() => {
42
+  if (settingsStore.isDark) {
43
+    return 'var(--sidebar-text)'
44
+  }
45
+  return sideTheme.value === 'theme-dark' ? '#fff' : variables.menuLightText
46
+})
47
+</script>
48
+
49
+<style lang="scss" scoped>
50
+.sidebarLogoFade-enter-active {
51
+  transition: opacity 1.5s;
52
+}
53
+
54
+.sidebarLogoFade-enter,
55
+.sidebarLogoFade-leave-to {
56
+  opacity: 0;
57
+}
58
+
59
+.sidebar-logo-container {
60
+  position: relative;
61
+  width: 100%;
62
+  height: 50px;
63
+  line-height: 50px;
64
+  background: v-bind(getLogoBackground);
65
+  text-align: center;
66
+  overflow: hidden;
67
+
68
+  & .sidebar-logo-link {
69
+    height: 100%;
70
+    width: 100%;
71
+
72
+    & .sidebar-logo {
73
+      width: 32px;
74
+      height: 32px;
75
+      vertical-align: middle;
76
+      margin-right: 12px;
77
+    }
78
+
79
+    & .sidebar-title {
80
+      display: inline-block;
81
+      margin: 0;
82
+      color: v-bind(getLogoTextColor);
83
+      font-weight: 600;
84
+      line-height: 50px;
85
+      font-size: 14px;
86
+      font-family: Avenir, Helvetica Neue, Arial, Helvetica, sans-serif;
87
+      vertical-align: middle;
88
+    }
89
+  }
90
+
91
+  &.collapse {
92
+    .sidebar-logo {
93
+      margin-right: 0px;
94
+    }
95
+  }
96
+}
97
+</style>

+ 100 - 0
src/layout/components/Sidebar/SidebarItem.vue

@@ -0,0 +1,100 @@
1
+<template>
2
+  <div v-if="!item.hidden">
3
+    <template v-if="hasOneShowingChild(item.children, item) && (!onlyOneChild.children || onlyOneChild.noShowingChildren) && !item.alwaysShow">
4
+      <app-link v-if="onlyOneChild.meta" :to="resolvePath(onlyOneChild.path, onlyOneChild.query)">
5
+        <el-menu-item :index="resolvePath(onlyOneChild.path)" :class="{ 'submenu-title-noDropdown': !isNest }">
6
+          <svg-icon :icon-class="onlyOneChild.meta.icon || (item.meta && item.meta.icon)"/>
7
+          <template #title><span class="menu-title" :title="hasTitle(onlyOneChild.meta.title)">{{ onlyOneChild.meta.title }}</span></template>
8
+        </el-menu-item>
9
+      </app-link>
10
+    </template>
11
+
12
+    <el-sub-menu v-else ref="subMenu" :index="resolvePath(item.path)" teleported>
13
+      <template v-if="item.meta" #title>
14
+        <svg-icon :icon-class="item.meta && item.meta.icon" />
15
+        <span class="menu-title" :title="hasTitle(item.meta.title)">{{ item.meta.title }}</span>
16
+      </template>
17
+
18
+      <sidebar-item
19
+        v-for="(child, index) in item.children"
20
+        :key="child.path + index"
21
+        :is-nest="true"
22
+        :item="child"
23
+        :base-path="resolvePath(child.path)"
24
+        class="nest-menu"
25
+      />
26
+    </el-sub-menu>
27
+  </div>
28
+</template>
29
+
30
+<script setup>
31
+import { isExternal } from '@/utils/validate'
32
+import AppLink from './Link'
33
+import { getNormalPath } from '@/utils/ruoyi'
34
+
35
+const props = defineProps({
36
+  // route object
37
+  item: {
38
+    type: Object,
39
+    required: true
40
+  },
41
+  isNest: {
42
+    type: Boolean,
43
+    default: false
44
+  },
45
+  basePath: {
46
+    type: String,
47
+    default: ''
48
+  }
49
+})
50
+
51
+const onlyOneChild = ref({})
52
+
53
+function hasOneShowingChild(children = [], parent) {
54
+  if (!children) {
55
+    children = []
56
+  }
57
+  const showingChildren = children.filter(item => {
58
+    if (item.hidden) {
59
+      return false
60
+    }
61
+    onlyOneChild.value = item
62
+    return true
63
+  })
64
+
65
+  // When there is only one child router, the child router is displayed by default
66
+  if (showingChildren.length === 1) {
67
+    return true
68
+  }
69
+
70
+  // Show parent if there are no child router to display
71
+  if (showingChildren.length === 0) {
72
+    onlyOneChild.value = { ...parent, path: '', noShowingChildren: true }
73
+    return true
74
+  }
75
+
76
+  return false
77
+}
78
+
79
+function resolvePath(routePath, routeQuery) {
80
+  if (isExternal(routePath)) {
81
+    return routePath
82
+  }
83
+  if (isExternal(props.basePath)) {
84
+    return props.basePath
85
+  }
86
+  if (routeQuery) {
87
+    let query = JSON.parse(routeQuery)
88
+    return { path: getNormalPath(props.basePath + '/' + routePath), query: query }
89
+  }
90
+  return getNormalPath(props.basePath + '/' + routePath)
91
+}
92
+
93
+function hasTitle(title){
94
+  if (title.length > 5) {
95
+    return title
96
+  } else {
97
+    return ""
98
+  }
99
+}
100
+</script>

+ 104 - 0
src/layout/components/Sidebar/index.vue

@@ -0,0 +1,104 @@
1
+<template>
2
+  <div :class="{ 'has-logo': showLogo }" class="sidebar-container">
3
+    <logo v-if="showLogo" :collapse="isCollapse" />
4
+    <el-scrollbar wrap-class="scrollbar-wrapper">
5
+      <el-menu
6
+        :default-active="activeMenu"
7
+        :collapse="isCollapse"
8
+        :background-color="getMenuBackground"
9
+        :text-color="getMenuTextColor"
10
+        :unique-opened="true"
11
+        :active-text-color="theme"
12
+        :collapse-transition="false"
13
+        mode="vertical"
14
+        :class="sideTheme"
15
+      >
16
+        <sidebar-item
17
+          v-for="(route, index) in sidebarRouters"
18
+          :key="route.path + index"
19
+          :item="route"
20
+          :base-path="route.path"
21
+        />
22
+      </el-menu>
23
+    </el-scrollbar>
24
+  </div>
25
+</template>
26
+
27
+<script setup>
28
+import Logo from './Logo'
29
+import SidebarItem from './SidebarItem'
30
+import variables from '@/assets/styles/variables.module.scss'
31
+import useAppStore from '@/store/modules/app'
32
+import useSettingsStore from '@/store/modules/settings'
33
+import usePermissionStore from '@/store/modules/permission'
34
+
35
+const route = useRoute()
36
+const appStore = useAppStore()
37
+const settingsStore = useSettingsStore()
38
+const permissionStore = usePermissionStore()
39
+
40
+const sidebarRouters = computed(() => permissionStore.sidebarRouters)
41
+const showLogo = computed(() => settingsStore.sidebarLogo)
42
+const sideTheme = computed(() => settingsStore.sideTheme)
43
+const theme = computed(() => settingsStore.theme)
44
+const isCollapse = computed(() => !appStore.sidebar.opened)
45
+
46
+// 获取菜单背景色
47
+const getMenuBackground = computed(() => {
48
+  if (settingsStore.isDark) {
49
+    return 'var(--sidebar-bg)'
50
+  }
51
+  return sideTheme.value === 'theme-dark' ? variables.menuBg : variables.menuLightBg
52
+})
53
+
54
+// 获取菜单文字颜色
55
+const getMenuTextColor = computed(() => {
56
+  if (settingsStore.isDark) {
57
+    return 'var(--sidebar-text)'
58
+  }
59
+  return sideTheme.value === 'theme-dark' ? variables.menuText : variables.menuLightText
60
+})
61
+
62
+const activeMenu = computed(() => {
63
+  const { meta, path } = route
64
+  if (meta.activeMenu) {
65
+    return meta.activeMenu
66
+  }
67
+  return path
68
+})
69
+</script>
70
+
71
+<style lang="scss" scoped>
72
+.sidebar-container {
73
+  background-color: v-bind(getMenuBackground);
74
+  
75
+  .scrollbar-wrapper {
76
+    background-color: v-bind(getMenuBackground);
77
+  }
78
+
79
+  .el-menu {
80
+    border: none;
81
+    height: 100%;
82
+    width: 100% !important;
83
+    
84
+    .el-menu-item, .el-sub-menu__title {
85
+      &:hover {
86
+        background-color: var(--menu-hover, rgba(0, 0, 0, 0.06)) !important;
87
+      }
88
+    }
89
+
90
+    .el-menu-item {
91
+      color: v-bind(getMenuTextColor);
92
+      
93
+      &.is-active {
94
+        color: var(--menu-active-text, #409eff);
95
+        background-color: var(--menu-hover, rgba(0, 0, 0, 0.06)) !important;
96
+      }
97
+    }
98
+
99
+    .el-sub-menu__title {
100
+      color: v-bind(getMenuTextColor);
101
+    }
102
+  }
103
+}
104
+</style>

+ 107 - 0
src/layout/components/TagsView/ScrollPane.vue

@@ -0,0 +1,107 @@
1
+<template>
2
+  <el-scrollbar
3
+    ref="scrollContainer"
4
+    :vertical="false"
5
+    class="scroll-container"
6
+    @wheel.prevent="handleScroll"
7
+  >
8
+    <slot />
9
+  </el-scrollbar>
10
+</template>
11
+
12
+<script setup>
13
+import useTagsViewStore from '@/store/modules/tagsView'
14
+
15
+const tagAndTagSpacing = ref(4)
16
+const { proxy } = getCurrentInstance()
17
+
18
+const scrollWrapper = computed(() => proxy.$refs.scrollContainer.$refs.wrapRef)
19
+
20
+onMounted(() => {
21
+  scrollWrapper.value.addEventListener('scroll', emitScroll, true)
22
+})
23
+
24
+onBeforeUnmount(() => {
25
+  scrollWrapper.value.removeEventListener('scroll', emitScroll)
26
+})
27
+
28
+function handleScroll(e) {
29
+  const eventDelta = e.wheelDelta || -e.deltaY * 40
30
+  const $scrollWrapper = scrollWrapper.value
31
+  $scrollWrapper.scrollLeft = $scrollWrapper.scrollLeft + eventDelta / 4
32
+}
33
+
34
+const emits = defineEmits()
35
+const emitScroll = () => {
36
+  emits('scroll')
37
+}
38
+
39
+const tagsViewStore = useTagsViewStore()
40
+const visitedViews = computed(() => tagsViewStore.visitedViews)
41
+
42
+function moveToTarget(currentTag) {
43
+  const $container = proxy.$refs.scrollContainer.$el
44
+  const $containerWidth = $container.offsetWidth
45
+  const $scrollWrapper = scrollWrapper.value
46
+
47
+  let firstTag = null
48
+  let lastTag = null
49
+
50
+  // find first tag and last tag
51
+  if (visitedViews.value.length > 0) {
52
+    firstTag = visitedViews.value[0]
53
+    lastTag = visitedViews.value[visitedViews.value.length - 1]
54
+  }
55
+
56
+  if (firstTag === currentTag) {
57
+    $scrollWrapper.scrollLeft = 0
58
+  } else if (lastTag === currentTag) {
59
+    $scrollWrapper.scrollLeft = $scrollWrapper.scrollWidth - $containerWidth
60
+  } else {
61
+    const tagListDom = document.getElementsByClassName('tags-view-item')
62
+    const currentIndex = visitedViews.value.findIndex(item => item === currentTag)
63
+    let prevTag = null
64
+    let nextTag = null
65
+    for (const k in tagListDom) {
66
+      if (k !== 'length' && Object.hasOwnProperty.call(tagListDom, k)) {
67
+        if (tagListDom[k].dataset.path === visitedViews.value[currentIndex - 1].path) {
68
+          prevTag = tagListDom[k]
69
+        }
70
+        if (tagListDom[k].dataset.path === visitedViews.value[currentIndex + 1].path) {
71
+          nextTag = tagListDom[k]
72
+        }
73
+      }
74
+    }
75
+
76
+    // the tag's offsetLeft after of nextTag
77
+    const afterNextTagOffsetLeft = nextTag.offsetLeft + nextTag.offsetWidth + tagAndTagSpacing.value
78
+
79
+    // the tag's offsetLeft before of prevTag
80
+    const beforePrevTagOffsetLeft = prevTag.offsetLeft - tagAndTagSpacing.value
81
+    if (afterNextTagOffsetLeft > $scrollWrapper.scrollLeft + $containerWidth) {
82
+      $scrollWrapper.scrollLeft = afterNextTagOffsetLeft - $containerWidth
83
+    } else if (beforePrevTagOffsetLeft < $scrollWrapper.scrollLeft) {
84
+      $scrollWrapper.scrollLeft = beforePrevTagOffsetLeft
85
+    }
86
+  }
87
+}
88
+
89
+defineExpose({
90
+  moveToTarget,
91
+})
92
+</script>
93
+
94
+<style lang='scss' scoped>
95
+.scroll-container {
96
+  white-space: nowrap;
97
+  position: relative;
98
+  overflow: hidden;
99
+  width: 100%;
100
+  :deep(.el-scrollbar__bar) {
101
+    bottom: 0px;
102
+  }
103
+  :deep(.el-scrollbar__wrap) {
104
+    height: 39px;
105
+  }
106
+}
107
+</style>

+ 371 - 0
src/layout/components/TagsView/index.vue

@@ -0,0 +1,371 @@
1
+<template>
2
+  <div id="tags-view-container" class="tags-view-container">
3
+    <scroll-pane ref="scrollPaneRef" class="tags-view-wrapper" @scroll="handleScroll">
4
+      <router-link
5
+        v-for="tag in visitedViews"
6
+        :key="tag.path"
7
+        :data-path="tag.path"
8
+        :class="{ 'active': isActive(tag), 'has-icon': tagsIcon }"
9
+        :to="{ path: tag.path, query: tag.query, fullPath: tag.fullPath }"
10
+        class="tags-view-item"
11
+        :style="activeStyle(tag)"
12
+        @click.middle="!isAffix(tag) ? closeSelectedTag(tag) : ''"
13
+        @contextmenu.prevent="openMenu(tag, $event)"
14
+      >
15
+        <svg-icon v-if="tagsIcon && tag.meta && tag.meta.icon && tag.meta.icon !== '#'" :icon-class="tag.meta.icon" />
16
+        {{ tag.title }}
17
+        <span v-if="!isAffix(tag)" @click.prevent.stop="closeSelectedTag(tag)">
18
+          <close class="el-icon-close" style="width: 1em; height: 1em;vertical-align: middle;" />
19
+        </span>
20
+      </router-link>
21
+    </scroll-pane>
22
+    <ul v-show="visible" :style="{ left: left + 'px', top: top + 'px' }" class="contextmenu">
23
+      <li @click="refreshSelectedTag(selectedTag)">
24
+        <refresh-right style="width: 1em; height: 1em;" /> 刷新页面
25
+      </li>
26
+      <li v-if="!isAffix(selectedTag)" @click="closeSelectedTag(selectedTag)">
27
+        <close style="width: 1em; height: 1em;" /> 关闭当前
28
+      </li>
29
+      <li @click="closeOthersTags">
30
+        <circle-close style="width: 1em; height: 1em;" /> 关闭其他
31
+      </li>
32
+      <li v-if="!isFirstView()" @click="closeLeftTags">
33
+        <back style="width: 1em; height: 1em;" /> 关闭左侧
34
+      </li>
35
+      <li v-if="!isLastView()" @click="closeRightTags">
36
+        <right style="width: 1em; height: 1em;" /> 关闭右侧
37
+      </li>
38
+      <li @click="closeAllTags(selectedTag)">
39
+        <circle-close style="width: 1em; height: 1em;" /> 全部关闭
40
+      </li>
41
+    </ul>
42
+  </div>
43
+</template>
44
+
45
+<script setup>
46
+import ScrollPane from './ScrollPane'
47
+import { getNormalPath } from '@/utils/ruoyi'
48
+import useTagsViewStore from '@/store/modules/tagsView'
49
+import useSettingsStore from '@/store/modules/settings'
50
+import usePermissionStore from '@/store/modules/permission'
51
+
52
+const visible = ref(false)
53
+const top = ref(0)
54
+const left = ref(0)
55
+const selectedTag = ref({})
56
+const affixTags = ref([])
57
+const scrollPaneRef = ref(null)
58
+
59
+const { proxy } = getCurrentInstance()
60
+const route = useRoute()
61
+const router = useRouter()
62
+
63
+const visitedViews = computed(() => useTagsViewStore().visitedViews)
64
+const routes = computed(() => usePermissionStore().routes)
65
+const theme = computed(() => useSettingsStore().theme)
66
+const tagsIcon = computed(() => useSettingsStore().tagsIcon)
67
+
68
+watch(route, () => {
69
+  addTags()
70
+  moveToCurrentTag()
71
+})
72
+
73
+watch(visible, (value) => {
74
+  if (value) {
75
+    document.body.addEventListener('click', closeMenu)
76
+  } else {
77
+    document.body.removeEventListener('click', closeMenu)
78
+  }
79
+})
80
+
81
+onMounted(() => {
82
+  initTags()
83
+  addTags()
84
+})
85
+
86
+function isActive(r) {
87
+  return r.path === route.path
88
+}
89
+
90
+function activeStyle(tag) {
91
+  if (!isActive(tag)) return {}
92
+  return {
93
+    "background-color": theme.value,
94
+    "border-color": theme.value
95
+  }
96
+}
97
+
98
+function isAffix(tag) {
99
+  return tag.meta && tag.meta.affix
100
+}
101
+
102
+function isFirstView() {
103
+  try {
104
+    return selectedTag.value.fullPath === '/index' || selectedTag.value.fullPath === visitedViews.value[1].fullPath
105
+  } catch (err) {
106
+    return false
107
+  }
108
+}
109
+
110
+function isLastView() {
111
+  try {
112
+    return selectedTag.value.fullPath === visitedViews.value[visitedViews.value.length - 1].fullPath
113
+  } catch (err) {
114
+    return false
115
+  }
116
+}
117
+
118
+function filterAffixTags(routes, basePath = '') {
119
+  let tags = []
120
+  routes.forEach(route => {
121
+    if (route.meta && route.meta.affix) {
122
+      const tagPath = getNormalPath(basePath + '/' + route.path)
123
+      tags.push({
124
+        fullPath: tagPath,
125
+        path: tagPath,
126
+        name: route.name,
127
+        meta: { ...route.meta }
128
+      })
129
+    }
130
+    if (route.children) {
131
+      const tempTags = filterAffixTags(route.children, route.path)
132
+      if (tempTags.length >= 1) {
133
+        tags = [...tags, ...tempTags]
134
+      }
135
+    }
136
+  })
137
+  return tags
138
+}
139
+
140
+function initTags() {
141
+  const res = filterAffixTags(routes.value)
142
+  affixTags.value = res
143
+  for (const tag of res) {
144
+    // Must have tag name
145
+    if (tag.name) {
146
+       useTagsViewStore().addVisitedView(tag)
147
+    }
148
+  }
149
+}
150
+
151
+function addTags() {
152
+  const { name } = route
153
+  if (name) {
154
+    useTagsViewStore().addView(route)
155
+  }
156
+}
157
+
158
+function moveToCurrentTag() {
159
+  nextTick(() => {
160
+    for (const r of visitedViews.value) {
161
+      if (r.path === route.path) {
162
+        scrollPaneRef.value.moveToTarget(r)
163
+        // when query is different then update
164
+        if (r.fullPath !== route.fullPath) {
165
+          useTagsViewStore().updateVisitedView(route)
166
+        }
167
+      }
168
+    }
169
+  })
170
+}
171
+
172
+function refreshSelectedTag(view) {
173
+  proxy.$tab.refreshPage(view)
174
+  if (route.meta.link) {
175
+    useTagsViewStore().delIframeView(route)
176
+  }
177
+}
178
+
179
+function closeSelectedTag(view) {
180
+  proxy.$tab.closePage(view).then(({ visitedViews }) => {
181
+    if (isActive(view)) {
182
+      toLastView(visitedViews, view)
183
+    }
184
+  })
185
+}
186
+
187
+function closeRightTags() {
188
+  proxy.$tab.closeRightPage(selectedTag.value).then(visitedViews => {
189
+    if (!visitedViews.find(i => i.fullPath === route.fullPath)) {
190
+      toLastView(visitedViews)
191
+    }
192
+  })
193
+}
194
+
195
+function closeLeftTags() {
196
+  proxy.$tab.closeLeftPage(selectedTag.value).then(visitedViews => {
197
+    if (!visitedViews.find(i => i.fullPath === route.fullPath)) {
198
+      toLastView(visitedViews)
199
+    }
200
+  })
201
+}
202
+
203
+function closeOthersTags() {
204
+  router.push(selectedTag.value).catch(() => { })
205
+  proxy.$tab.closeOtherPage(selectedTag.value).then(() => {
206
+    moveToCurrentTag()
207
+  })
208
+}
209
+
210
+function closeAllTags(view) {
211
+  proxy.$tab.closeAllPage().then(({ visitedViews }) => {
212
+    if (affixTags.value.some(tag => tag.path === route.path)) {
213
+      return
214
+    }
215
+    toLastView(visitedViews, view)
216
+  })
217
+}
218
+
219
+function toLastView(visitedViews, view) {
220
+  const latestView = visitedViews.slice(-1)[0]
221
+  if (latestView) {
222
+    router.push(latestView.fullPath)
223
+  } else {
224
+    // now the default is to redirect to the home page if there is no tags-view,
225
+    // you can adjust it according to your needs.
226
+    if (view.name === 'Dashboard') {
227
+      // to reload home page
228
+      router.replace({ path: '/redirect' + view.fullPath })
229
+    } else {
230
+      router.push('/')
231
+    }
232
+  }
233
+}
234
+
235
+function openMenu(tag, e) {
236
+  const menuMinWidth = 105
237
+  const offsetLeft = proxy.$el.getBoundingClientRect().left // container margin left
238
+  const offsetWidth = proxy.$el.offsetWidth // container width
239
+  const maxLeft = offsetWidth - menuMinWidth // left boundary
240
+  const l = e.clientX - offsetLeft + 15 // 15: margin right
241
+
242
+  if (l > maxLeft) {
243
+    left.value = maxLeft
244
+  } else {
245
+    left.value = l
246
+  }
247
+
248
+  top.value = e.clientY
249
+  visible.value = true
250
+  selectedTag.value = tag
251
+}
252
+
253
+function closeMenu() {
254
+  visible.value = false
255
+}
256
+
257
+function handleScroll() {
258
+  closeMenu()
259
+}
260
+</script>
261
+
262
+<style lang="scss" scoped>
263
+.tags-view-container {
264
+  height: 34px;
265
+  width: 100%;
266
+  background: var(--tags-bg, #fff);
267
+  border-bottom: 1px solid var(--tags-item-border, #d8dce5);
268
+  box-shadow: 0 1px 3px 0 rgba(0, 0, 0, .12), 0 0 3px 0 rgba(0, 0, 0, .04);
269
+
270
+  .tags-view-wrapper {
271
+    .tags-view-item {
272
+      display: inline-block;
273
+      position: relative;
274
+      cursor: pointer;
275
+      height: 26px;
276
+      line-height: 26px;
277
+      border: 1px solid var(--tags-item-border, #d8dce5);
278
+      color: var(--tags-item-text, #495060);
279
+      background: var(--tags-item-bg, #fff);
280
+      padding: 0 8px;
281
+      font-size: 12px;
282
+      margin-left: 5px;
283
+      margin-top: 4px;
284
+
285
+      &:first-of-type {
286
+        margin-left: 15px;
287
+      }
288
+
289
+      &:last-of-type {
290
+        margin-right: 15px;
291
+      }
292
+
293
+      &.active {
294
+        background-color: #42b983;
295
+        color: #fff;
296
+        border-color: #42b983;
297
+
298
+        &::before {
299
+          content: '';
300
+          background: #fff;
301
+          display: inline-block;
302
+          width: 8px;
303
+          height: 8px;
304
+          border-radius: 50%;
305
+          position: relative;
306
+          margin-right: 5px;
307
+        }
308
+      }
309
+    }
310
+  }
311
+
312
+  .tags-view-item.active.has-icon::before {
313
+    content: none !important;
314
+  }
315
+
316
+  .contextmenu {
317
+    margin: 0;
318
+    background: var(--el-bg-color-overlay, #fff);
319
+    z-index: 3000;
320
+    position: absolute;
321
+    list-style-type: none;
322
+    padding: 5px 0;
323
+    border-radius: 4px;
324
+    font-size: 12px;
325
+    font-weight: 400;
326
+    color: var(--tags-item-text, #333);
327
+    box-shadow: 2px 2px 3px 0 rgba(0, 0, 0, .3);
328
+    border: 1px solid var(--el-border-color-light, #e4e7ed);
329
+
330
+    li {
331
+      margin: 0;
332
+      padding: 7px 16px;
333
+      cursor: pointer;
334
+
335
+      &:hover {
336
+        background: var(--tags-item-hover, #eee);
337
+      }
338
+    }
339
+  }
340
+}
341
+</style>
342
+
343
+<style lang="scss">
344
+//reset element css of el-icon-close
345
+.tags-view-wrapper {
346
+  .tags-view-item {
347
+    .el-icon-close {
348
+      width: 16px;
349
+      height: 16px;
350
+      vertical-align: 2px;
351
+      border-radius: 50%;
352
+      text-align: center;
353
+      transition: all .3s cubic-bezier(.645, .045, .355, 1);
354
+      transform-origin: 100% 50%;
355
+
356
+      &:before {
357
+        transform: scale(.6);
358
+        display: inline-block;
359
+        vertical-align: -3px;
360
+      }
361
+
362
+      &:hover {
363
+        background-color: var(--tags-close-hover, #b4bccc);
364
+        color: #fff;
365
+        width: 12px !important;
366
+        height: 12px !important;
367
+      }
368
+    }
369
+  }
370
+}
371
+</style>

+ 4 - 0
src/layout/components/index.js

@@ -0,0 +1,4 @@
1
+export { default as AppMain } from './AppMain'
2
+export { default as Navbar } from './Navbar'
3
+export { default as Settings } from './Settings'
4
+export { default as TagsView } from './TagsView/index.vue'

+ 112 - 0
src/layout/index.vue

@@ -0,0 +1,112 @@
1
+<template>
2
+  <div :class="classObj" class="app-wrapper" :style="{ '--current-color': theme }">
3
+    <div v-if="device === 'mobile' && sidebar.opened" class="drawer-bg" @click="handleClickOutside"/>
4
+    <sidebar v-if="!sidebar.hide" class="sidebar-container" />
5
+    <div :class="{ hasTagsView: needTagsView, sidebarHide: sidebar.hide }" class="main-container">
6
+      <div :class="{ 'fixed-header': fixedHeader }">
7
+        <navbar @setLayout="setLayout" />
8
+        <tags-view v-if="needTagsView" />
9
+      </div>
10
+      <app-main />
11
+      <settings ref="settingRef" />
12
+    </div>
13
+  </div>
14
+</template>
15
+
16
+<script setup>
17
+import { useWindowSize } from '@vueuse/core'
18
+import Sidebar from './components/Sidebar/index.vue'
19
+import { AppMain, Navbar, Settings, TagsView } from './components'
20
+import useAppStore from '@/store/modules/app'
21
+import useSettingsStore from '@/store/modules/settings'
22
+
23
+const settingsStore = useSettingsStore()
24
+const theme = computed(() => settingsStore.theme)
25
+const sideTheme = computed(() => settingsStore.sideTheme)
26
+const sidebar = computed(() => useAppStore().sidebar)
27
+const device = computed(() => useAppStore().device)
28
+const needTagsView = computed(() => settingsStore.tagsView)
29
+const fixedHeader = computed(() => settingsStore.fixedHeader)
30
+
31
+const classObj = computed(() => ({
32
+  hideSidebar: !sidebar.value.opened,
33
+  openSidebar: sidebar.value.opened,
34
+  withoutAnimation: sidebar.value.withoutAnimation,
35
+  mobile: device.value === 'mobile'
36
+}))
37
+
38
+const { width, height } = useWindowSize()
39
+const WIDTH = 992 // refer to Bootstrap's responsive design
40
+
41
+watch(() => device.value, () => {
42
+  if (device.value === 'mobile' && sidebar.value.opened) {
43
+    useAppStore().closeSideBar({ withoutAnimation: false })
44
+  }
45
+})
46
+
47
+watchEffect(() => {
48
+  if (width.value - 1 < WIDTH) {
49
+    useAppStore().toggleDevice('mobile')
50
+    useAppStore().closeSideBar({ withoutAnimation: true })
51
+  } else {
52
+    useAppStore().toggleDevice('desktop')
53
+  }
54
+})
55
+
56
+function handleClickOutside() {
57
+  useAppStore().closeSideBar({ withoutAnimation: false })
58
+}
59
+
60
+const settingRef = ref(null)
61
+function setLayout() {
62
+  settingRef.value.openSetting()
63
+}
64
+</script>
65
+
66
+<style lang="scss" scoped>
67
+@use "@/assets/styles/mixin.scss" as mix;
68
+@use "@/assets/styles/variables.module.scss" as vars;
69
+
70
+.app-wrapper {
71
+  @include mix.clearfix;
72
+  position: relative;
73
+  height: 100%;
74
+  width: 100%;
75
+
76
+  &.mobile.openSidebar {
77
+    position: fixed;
78
+    top: 0;
79
+  }
80
+}
81
+
82
+.drawer-bg {
83
+  background: #000;
84
+  opacity: 0.3;
85
+  width: 100%;
86
+  top: 0;
87
+  height: 100%;
88
+  position: absolute;
89
+  z-index: 999;
90
+}
91
+
92
+.fixed-header {
93
+  position: fixed;
94
+  top: 0;
95
+  right: 0;
96
+  z-index: 9;
97
+  width: calc(100% - #{vars.$base-sidebar-width});
98
+  transition: width 0.28s;
99
+}
100
+
101
+.hideSidebar .fixed-header {
102
+  width: calc(100% - 54px);
103
+}
104
+
105
+.sidebarHide .fixed-header {
106
+  width: 100%;
107
+}
108
+
109
+.mobile .fixed-header {
110
+  width: 100%;
111
+}
112
+</style>