breadcrumb.vue 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441
  1. <template>
  2. <div class="tags-container">
  3. <el-scrollbar class="scroll-container" :vertical="false" @wheel.prevent="handleScroll">
  4. <router-link
  5. ref="tagRef"
  6. v-for="tag in visitedViews"
  7. :key="tag.fullPath"
  8. :class="'tags-item ' + (isActive(tag) ? 'active' : '')"
  9. :to="{ path: tag.path, query: tag.query }"
  10. @click.middle="!isAffix(tag) ? closeSelectedTag(tag) : ''"
  11. @contextmenu.prevent="openContentMenu(tag, $event)"
  12. >
  13. {{ translateRouteTitle(tag.title) }}
  14. <Close class="close-icon" v-if="!isAffix(tag)" @click.prevent.stop="closeSelectedTag(tag)" />
  15. </router-link>
  16. </el-scrollbar>
  17. <!-- tag标签操作菜单 -->
  18. <ul v-show="contentMenuVisible" class="contextmenu" :style="{ left: left + 'px', top: top + 'px' }">
  19. <li @click="refreshSelectedTag(selectedTag)">
  20. <svg-icon icon-class="refresh" />
  21. 刷新
  22. </li>
  23. <li v-if="!isAffix(selectedTag)" @click="closeSelectedTag(selectedTag)">
  24. <svg-icon icon-class="close" />
  25. 关闭
  26. </li>
  27. <li @click="closeOtherTags">
  28. <svg-icon icon-class="close_other" />
  29. 关闭其它
  30. </li>
  31. <li v-if="!isFirstView()" @click="closeLeftTags">
  32. <svg-icon icon-class="close_left" />
  33. 关闭左侧
  34. </li>
  35. <li v-if="!isLastView()" @click="closeRightTags">
  36. <svg-icon icon-class="close_right" />
  37. 关闭右侧
  38. </li>
  39. <li @click="closeAllTags(selectedTag)">
  40. <svg-icon icon-class="close_all" />
  41. 关闭所有
  42. </li>
  43. </ul>
  44. </div>
  45. </template>
  46. <script setup>
  47. import { ref, watch, onMounted, getCurrentInstance, computed } from 'vue'
  48. import { useRoute, useRouter } from 'vue-router'
  49. import { storeToRefs } from 'pinia'
  50. import { resolve } from 'path-browserify'
  51. import { translateRouteTitle } from '@/utils/i18n'
  52. import { usePermissionStore, useTagsViewStore, useSettingsStore, useAppStore } from '@/store'
  53. const { proxy } = getCurrentInstance()
  54. const router = useRouter()
  55. const route = useRoute()
  56. const permissionStore = usePermissionStore()
  57. const tagsViewStore = useTagsViewStore()
  58. const appStore = useAppStore()
  59. const { visitedViews } = storeToRefs(tagsViewStore)
  60. const settingsStore = useSettingsStore()
  61. const layout = computed(() => settingsStore.layout)
  62. const selectedTag = ref({
  63. path: '',
  64. fullPath: '',
  65. name: '',
  66. title: '',
  67. affix: false,
  68. keepAlive: false
  69. })
  70. const affixTags = ref([])
  71. const left = ref(0)
  72. const top = ref(0)
  73. watch(
  74. route,
  75. () => {
  76. addTags()
  77. moveToCurrentTag()
  78. },
  79. {
  80. immediate: true //初始化立即执行
  81. }
  82. )
  83. const contentMenuVisible = ref(false) // 右键菜单是否显示
  84. watch(contentMenuVisible, (value) => {
  85. if (value) {
  86. document.body.addEventListener('click', closeContentMenu)
  87. } else {
  88. document.body.removeEventListener('click', closeContentMenu)
  89. }
  90. })
  91. /**
  92. * 过滤出需要固定的标签
  93. */
  94. function filterAffixTags(routes, basePath = '/') {
  95. let tags = []
  96. routes.forEach((route) => {
  97. const tagPath = resolve(basePath, route.path)
  98. if (route.meta?.affix) {
  99. tags.push({
  100. path: tagPath,
  101. fullPath: tagPath,
  102. name: String(route.name),
  103. title: route.meta?.title || 'no-name',
  104. affix: route.meta?.affix,
  105. keepAlive: route.meta?.keepAlive
  106. })
  107. }
  108. if (route.children) {
  109. const tempTags = filterAffixTags(route.children, basePath + route.path)
  110. if (tempTags.length >= 1) {
  111. tags = [...tags, ...tempTags]
  112. }
  113. }
  114. })
  115. return tags
  116. }
  117. function initTags() {
  118. const tags = filterAffixTags(permissionStore.routes)
  119. affixTags.value = tags
  120. for (const tag of tags) {
  121. // Must have tag name
  122. if (tag.name) {
  123. tagsViewStore.addVisitedView(tag)
  124. }
  125. }
  126. }
  127. function addTags() {
  128. if (route.meta.title) {
  129. tagsViewStore.addView({
  130. name: route.name,
  131. title: route.meta.title,
  132. path: route.path,
  133. fullPath: route.fullPath,
  134. affix: route.meta?.affix,
  135. keepAlive: route.meta?.keepAlive
  136. })
  137. }
  138. }
  139. function moveToCurrentTag() {
  140. // 使用 nextTick() 的目的是确保在更新 tagsView 组件之前,scrollPaneRef 对象已经滚动到了正确的位置。
  141. nextTick(() => {
  142. for (const tag of visitedViews.value) {
  143. if (tag.path === route.path) {
  144. if (tag.fullPath !== route.fullPath) {
  145. tagsViewStore.updateVisitedView({
  146. name: route.name,
  147. title: route.meta.title || '',
  148. path: route.path,
  149. fullPath: route.fullPath,
  150. affix: route.meta?.affix,
  151. keepAlive: route.meta?.keepAlive
  152. })
  153. }
  154. }
  155. }
  156. })
  157. }
  158. function isActive(tag) {
  159. return tag.path === route.path
  160. }
  161. function isAffix(tag) {
  162. return tag?.affix
  163. }
  164. function isFirstView() {
  165. try {
  166. return selectedTag.value.path === '/dashboard' || selectedTag.value.fullPath === tagsViewStore.visitedViews[1].fullPath
  167. } catch (err) {
  168. return false
  169. }
  170. }
  171. function isLastView() {
  172. try {
  173. return selectedTag.value.fullPath === tagsViewStore.visitedViews[tagsViewStore.visitedViews.length - 1].fullPath
  174. } catch (err) {
  175. return false
  176. }
  177. }
  178. function refreshSelectedTag(view) {
  179. tagsViewStore.delCachedView(view)
  180. const { fullPath } = view
  181. nextTick(() => {
  182. router.replace({ path: '/redirect' + fullPath })
  183. })
  184. }
  185. function toLastView(visitedViews, view) {
  186. const latestView = visitedViews.slice(-1)[0]
  187. if (latestView && latestView.fullPath) {
  188. router.push(latestView.fullPath)
  189. } else {
  190. if (view?.name === 'Dashboard') {
  191. router.replace({ path: '/redirect' + view.fullPath })
  192. } else {
  193. router.push('/')
  194. }
  195. }
  196. }
  197. function closeSelectedTag(view) {
  198. tagsViewStore.delView(view).then((res) => {
  199. if (isActive(view)) {
  200. toLastView(res.visitedViews, view)
  201. }
  202. })
  203. }
  204. function closeLeftTags() {
  205. tagsViewStore.delLeftViews(selectedTag.value).then((res) => {
  206. if (!res.visitedViews.find((item) => item.path === route.path)) {
  207. toLastView(res.visitedViews)
  208. }
  209. })
  210. }
  211. function closeRightTags() {
  212. tagsViewStore.delRightViews(selectedTag.value).then((res) => {
  213. if (!res.visitedViews.find((item) => item.path === route.path)) {
  214. toLastView(res.visitedViews)
  215. }
  216. })
  217. }
  218. function closeOtherTags() {
  219. router.push(selectedTag.value)
  220. tagsViewStore.delOtherViews(selectedTag.value).then(() => {
  221. moveToCurrentTag()
  222. })
  223. }
  224. function closeAllTags(view) {
  225. tagsViewStore.delAllViews().then((res) => {
  226. toLastView(res.visitedViews, view)
  227. })
  228. }
  229. /**
  230. * 打开右键菜单
  231. */
  232. function openContentMenu(tag, e) {
  233. const menuMinWidth = 105
  234. const offsetLeft = proxy?.$el.getBoundingClientRect().left // container margin left
  235. const offsetWidth = proxy?.$el.offsetWidth // container width
  236. const maxLeft = offsetWidth - menuMinWidth // left boundary
  237. const l = e.clientX - offsetLeft + 15 // 15: margin right
  238. if (l > maxLeft) {
  239. left.value = maxLeft
  240. } else {
  241. left.value = l
  242. }
  243. // 混合模式下,需要减去顶部菜单(fixed)的高度
  244. if (layout.value === 'mix') {
  245. top.value = e.clientY - 50
  246. } else {
  247. top.value = e.clientY
  248. }
  249. contentMenuVisible.value = true
  250. selectedTag.value = tag
  251. }
  252. /**
  253. * 关闭右键菜单
  254. */
  255. function closeContentMenu() {
  256. contentMenuVisible.value = false
  257. }
  258. /**
  259. * 滚动事件
  260. */
  261. function handleScroll() {
  262. closeContentMenu()
  263. }
  264. function findOutermostParent(tree, findName) {
  265. let parentMap = {}
  266. function buildParentMap(node, parent) {
  267. parentMap[node.name] = parent
  268. if (node.children) {
  269. for (let i = 0; i < node.children.length; i++) {
  270. buildParentMap(node.children[i], node)
  271. }
  272. }
  273. }
  274. for (let i = 0; i < tree.length; i++) {
  275. buildParentMap(tree[i], null)
  276. }
  277. let currentNode = parentMap[findName]
  278. while (currentNode) {
  279. if (!parentMap[currentNode.name]) {
  280. return currentNode
  281. }
  282. currentNode = parentMap[currentNode.name]
  283. }
  284. return null
  285. }
  286. const againActiveTop = (newVal) => {
  287. if (layout.value !== 'mix') return
  288. const parent = findOutermostParent(permissionStore.routes, newVal)
  289. if (appStore.activeTopMenu !== parent.path) {
  290. appStore.activeTopMenu(parent.path)
  291. }
  292. }
  293. // 如果是混合模式,更改selectedTag,需要对应高亮的activeTop
  294. watch(
  295. () => route.name,
  296. (newVal) => {
  297. if (newVal) {
  298. againActiveTop(newVal)
  299. }
  300. },
  301. {
  302. deep: true
  303. }
  304. )
  305. onMounted(() => {
  306. initTags()
  307. })
  308. </script>
  309. <style lang="scss" scoped>
  310. .tags-container {
  311. height: 35px;
  312. background-color: var(--el-bg-color);
  313. border: 1px solid var(--el-border-color-light);
  314. box-shadow: 0 1px 1px var(--el-box-shadow-light);
  315. .tags-item {
  316. display: inline-block;
  317. padding: 3px 8px;
  318. margin: 4px 0 0 5px;
  319. font-size: 12px;
  320. cursor: pointer;
  321. border: 1px solid var(--el-border-color-light);
  322. text-decoration: none;
  323. &:hover {
  324. color: var(--el-color-primary);
  325. }
  326. &:first-of-type {
  327. margin-left: 15px;
  328. }
  329. &:last-of-type {
  330. margin-right: 15px;
  331. }
  332. .close-icon {
  333. width: 1em;
  334. height: 1em;
  335. color: #000;
  336. border-radius: 50%;
  337. &:hover {
  338. color: #fff;
  339. background-color: var(--el-color-primary);
  340. }
  341. }
  342. &.active {
  343. color: #fff;
  344. background-color: var(--el-color-primary);
  345. &::before {
  346. display: inline-block;
  347. width: 8px;
  348. height: 8px;
  349. margin-right: 5px;
  350. content: "";
  351. background: #fff;
  352. border-radius: 50%;
  353. }
  354. .close-icon {
  355. color: #fff;
  356. }
  357. .close-icon:hover {
  358. color: var(--el-color-primary);
  359. background-color: var(--el-fill-color-light);
  360. }
  361. }
  362. }
  363. }
  364. .contextmenu {
  365. position: absolute;
  366. z-index: 99;
  367. font-size: 12px;
  368. background: var(--el-bg-color-overlay);
  369. border-radius: 4px;
  370. box-shadow: var(--el-box-shadow-light);
  371. li {
  372. padding: 8px 16px;
  373. cursor: pointer;
  374. &:hover {
  375. background: var(--el-fill-color-light);
  376. }
  377. }
  378. }
  379. .scroll-container {
  380. position: relative;
  381. width: 100%;
  382. overflow: hidden;
  383. white-space: nowrap;
  384. .el-scrollbar__bar {
  385. bottom: 0;
  386. }
  387. .el-scrollbar__wrap {
  388. height: 49px;
  389. }
  390. }
  391. </style>