index.vue 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259
  1. <script setup lang="ts">
  2. import { useElementSize } from '@vueuse/core'
  3. import useSettingsStore from '@/store/modules/settings'
  4. defineOptions({
  5. name: 'PageLayout',
  6. })
  7. withDefaults(
  8. defineProps<{
  9. /** 是否启用导航栏,默认使用应用配置 `navbar.enable` */
  10. navbar?: boolean
  11. /** 是否启用标签栏,默认使用应用配置 `tabbar.enable` */
  12. tabbar?: boolean
  13. /** 是否展示底部版权信息,默认使用应用配置 `copyright.enable` */
  14. copyright?: boolean
  15. /** 是否启用返回顶部按钮,默认使用应用配置 `app.enableBackTop` */
  16. backTop?: boolean
  17. }>(),
  18. {
  19. navbar: undefined,
  20. tabbar: undefined,
  21. copyright: undefined,
  22. backTop: undefined,
  23. },
  24. )
  25. const emits = defineEmits<{
  26. scroll: [Event]
  27. reachTop: []
  28. reachBottom: []
  29. }>()
  30. const route = useRoute()
  31. const settingsStore = useSettingsStore()
  32. const layoutRef = ref()
  33. defineExpose({
  34. ref: layoutRef,
  35. })
  36. function handleMainScroll(e: Event) {
  37. handleNavbarScroll()
  38. handleTabbarScroll()
  39. handleBackTopScroll()
  40. emits('scroll', e)
  41. if ((e.target as HTMLElement).scrollTop === 0) {
  42. emits('reachTop')
  43. }
  44. if (Math.ceil((e.target as HTMLElement).scrollTop + (e.target as HTMLElement).clientHeight) >= (e.target as HTMLElement).scrollHeight) {
  45. emits('reachBottom')
  46. }
  47. }
  48. onMounted(() => {
  49. handleNavbarScroll()
  50. handleTabbarScroll()
  51. handleBackTopScroll()
  52. })
  53. onActivated(() => {
  54. handleNavbarScroll()
  55. handleTabbarScroll()
  56. handleBackTopScroll()
  57. })
  58. // Navbar
  59. // 计算出左右两侧的最大宽度,让左右两侧的宽度保持一致
  60. const startSideRef = ref()
  61. const endSideRef = ref()
  62. const sideWidth = ref(0)
  63. onMounted(() => {
  64. const { width: startWidth } = useElementSize(startSideRef, undefined, { box: 'border-box' })
  65. const { width: endWidth } = useElementSize(endSideRef, undefined, { box: 'border-box' })
  66. watch([startWidth, endWidth], (val) => {
  67. sideWidth.value = Math.max(...val)
  68. }, {
  69. immediate: true,
  70. })
  71. })
  72. const navbarScrollTop = ref(0)
  73. function handleNavbarScroll() {
  74. navbarScrollTop.value = layoutRef.value.scrollTop
  75. }
  76. // Tabbar
  77. const showTabbarShadow = ref(false)
  78. function handleTabbarScroll() {
  79. const scrollTop = layoutRef.value.scrollTop
  80. const clientHeight = layoutRef.value.clientHeight
  81. const scrollHeight = layoutRef.value.scrollHeight
  82. showTabbarShadow.value = Math.ceil(scrollTop + clientHeight) < scrollHeight
  83. }
  84. const tabbarList = computed(() => {
  85. if (settingsStore.settings.tabbar.list.length > 0) {
  86. return settingsStore.settings.tabbar.list
  87. }
  88. return []
  89. })
  90. function getIcon(item: any) {
  91. if (route.fullPath === item.path) {
  92. return item.activeIcon ?? item.icon ?? undefined
  93. }
  94. else {
  95. return item.icon ?? undefined
  96. }
  97. }
  98. // 返回顶部
  99. const backTopScrollTop = ref(0)
  100. function handleBackTopScroll() {
  101. backTopScrollTop.value = layoutRef.value.scrollTop
  102. }
  103. function handleBackTopClick() {
  104. layoutRef.value.scrollTo({
  105. top: 0,
  106. behavior: 'smooth',
  107. })
  108. }
  109. </script>
  110. <template>
  111. <div ref="layoutRef" class="relative h-vh flex flex-col overflow-auto overscroll-none supports-[(height:100dvh)]:h-dvh" @scroll="handleMainScroll">
  112. <!-- Navbar -->
  113. <header
  114. v-show="navbar ?? settingsStore.settings.navbar.enable" class="navbar w-full flex-center bg-[var(--g-navbar-bg)] text-[var(--g-navbar-color)] transition-all pt-safe h+safe-t-[var(--g-navbar-height)]" :class="{
  115. 'shadow-top': navbarScrollTop,
  116. }"
  117. >
  118. <div
  119. class="h-full flex items-center justify-start" :style="{
  120. ...(sideWidth && { width: `${sideWidth}px` }),
  121. }"
  122. >
  123. <div ref="startSideRef" class="h-full flex-center whitespace-nowrap">
  124. <div class="h-full flex-center whitespace-nowrap px-2">
  125. <slot name="navbar-start" />
  126. </div>
  127. </div>
  128. </div>
  129. <div class="min-w-0 flex-1 text-center text-sm">
  130. <div class="truncate">
  131. {{ settingsStore.title }}
  132. </div>
  133. </div>
  134. <div
  135. class="h-full flex items-center justify-end" :style="{
  136. ...(sideWidth && { width: `${sideWidth}px` }),
  137. }"
  138. >
  139. <div ref="endSideRef" class="h-full flex-center whitespace-nowrap">
  140. <div class="h-full flex-center whitespace-nowrap px-2">
  141. <slot name="navbar-end" />
  142. </div>
  143. </div>
  144. </div>
  145. </header>
  146. <div
  147. class="relative flex flex-1 flex-col transition-margin" :class="{
  148. 'mt+safe-[var(--g-navbar-height)]': navbar ?? settingsStore.settings.navbar.enable,
  149. 'mb+safe-[var(--g-tabbar-height)]': tabbar ?? settingsStore.settings.tabbar.enable,
  150. }"
  151. >
  152. <slot />
  153. <!-- 版权信息 -->
  154. <Transition
  155. v-bind="{
  156. enterActiveClass: 'ease-out',
  157. enterFromClass: 'opacity-0',
  158. enterToClass: 'opacity-100',
  159. leaveActiveClass: 'ease-in',
  160. leaveFromClass: 'opacity-100',
  161. leaveToClass: 'opacity-0',
  162. }"
  163. >
  164. <div v-if="copyright ?? settingsStore.settings.copyright.enable" class="copyright relative flex flex-wrap items-center justify-center p-4 text-sm text-stone-5 mix-blend-difference">
  165. <span class="px-1">Copyright</span>
  166. <SvgIcon name="i-ri:copyright-line" class="text-lg" />
  167. <span v-if="settingsStore.settings.copyright.dates" class="px-1">{{ settingsStore.settings.copyright.dates }}</span>
  168. <template v-if="settingsStore.settings.copyright.company">
  169. <a v-if="settingsStore.settings.copyright.website" :href="settingsStore.settings.copyright.website" target="_blank" rel="noopener" class="px-1 text-center text-stone-5 no-underline">{{ settingsStore.settings.copyright.company }}</a>
  170. <span v-else class="px-1">{{ settingsStore.settings.copyright.company }}</span>
  171. </template>
  172. <a v-if="settingsStore.settings.copyright.beian" href="https://beian.miit.gov.cn/" target="_blank" rel="noopener" class="px-1 text-center text-stone-5 no-underline">{{ settingsStore.settings.copyright.beian }}</a>
  173. </div>
  174. </Transition>
  175. </div>
  176. <!-- Tabbar -->
  177. <footer
  178. v-show="tabbar ?? settingsStore.settings.tabbar.enable" class="tabbar w-full bg-[var(--g-tabbar-bg)] transition-all pb-safe h+safe-b-[calc(var(--g-tabbar-height))]" :class="{
  179. 'shadow-bottom': showTabbarShadow,
  180. }"
  181. >
  182. <div class="h-full flex-center px-4">
  183. <slot name="tabbar">
  184. <RouterLink
  185. v-for="item in tabbarList" :key="JSON.stringify(item)" class="flex flex-1 flex-col items-center gap-[2px] text-[var(--g-tabbar-color)] no-underline transition-all" :class="{
  186. 'text-[var(--g-tabbar-active-color)]!': route.fullPath === item.path,
  187. }" :to="item.path" replace
  188. >
  189. <SvgIcon v-if="getIcon(item)" :name="getIcon(item) ?? ''" :class="item.text ? 'text-6' : 'text-8'" />
  190. <div v-if="item.text" class="text-xs">
  191. {{ item.text }}
  192. </div>
  193. </RouterLink>
  194. </slot>
  195. </div>
  196. </footer>
  197. <!-- 返回顶部 -->
  198. <Transition
  199. v-bind="{
  200. enterActiveClass: 'ease-out duration-300',
  201. enterFromClass: 'opacity-0 translate-y-4',
  202. enterToClass: 'opacity-100 translate-y-0',
  203. leaveActiveClass: 'ease-in duration-200',
  204. leaveFromClass: 'opacity-100 scale-100',
  205. leaveToClass: 'opacity-0 scale-50',
  206. }"
  207. >
  208. <div
  209. v-if="(backTop ?? settingsStore.settings.app.enableBackTop) && backTopScrollTop >= 200" class="backtop h-12 w-12 flex cursor-pointer items-center justify-center rounded-full bg-white shadow-lg ring-1 ring-stone-3 ring-inset active:bg-stone-1 dark-bg-dark dark-ring-stone-7 dark-active:bg-stone-9" :class="{
  210. 'bottom+safe-[calc(var(--g-tabbar-height)+16px)]!': tabbar ?? settingsStore.settings.tabbar.enable,
  211. }" @click="handleBackTopClick"
  212. >
  213. <SvgIcon name="i-icon-park-outline:to-top-one" class="text-6" />
  214. </div>
  215. </Transition>
  216. </div>
  217. </template>
  218. <style scoped>
  219. .navbar {
  220. position: fixed;
  221. top: 0;
  222. left: 0;
  223. z-index: 1000;
  224. width: 100%;
  225. &.shadow-top {
  226. box-shadow: 0 10px 10px -10px var(--g-border-color);
  227. }
  228. }
  229. .tabbar {
  230. position: fixed;
  231. bottom: 0;
  232. left: 0;
  233. z-index: 1000;
  234. width: 100%;
  235. &.shadow-bottom {
  236. box-shadow: 0 -10px 10px -10px var(--g-border-color);
  237. }
  238. }
  239. .backtop {
  240. position: fixed;
  241. right: 16px;
  242. bottom: 16px;
  243. z-index: 1000;
  244. }
  245. </style>