|
- <template>
- <Teleport to="body">
- <Transition name="b2t">
- <button
- v-if="visible"
- type="button"
- aria-label="回到顶部"
- title="回到顶部"
- class="b2t-btn"
- @click="toTop"
- >
- <UIcon name="i-lucide-arrow-up" class="size-5" />
- </button>
- </Transition>
- </Teleport>
- </template>
-
- <script setup>
- /**
- * 全局「回到顶部」按钮。
- * 挂在 layouts/default.vue 里,所有页面共用一份,不需要各页面单独引入。
- *
- * 几个取舍:
- * - 滚动超过一屏(600px)才出现,避免短页面上多一个悬浮元素;
- * - z-index 取 90,压在内容之上、但低于登录弹窗(120)、详情弹窗(120)、历史抽屉(110)、对比弹层(100),
- * 弹层打开时不会盖在上面;
- * - 移动端工具页底部有固定操作条(z-50,高约 100px),所以小屏下按钮上移,避免互相遮挡;
- * - 系统开启「减少动效」时直接跳转,不做平滑滚动。
- */
- const SHOW_OFFSET = 600
-
- const visible = ref(false)
- let ticking = false
-
- function onScroll() {
- // 滚动事件用 rAF 节流,避免每帧都读 scrollY 触发重排
- if (ticking) return
- ticking = true
- requestAnimationFrame(() => {
- visible.value = window.scrollY > SHOW_OFFSET
- ticking = false
- })
- }
-
- function prefersReducedMotion() {
- return window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
- }
-
- function toTop() {
- window.scrollTo({ top: 0, behavior: prefersReducedMotion() ? 'auto' : 'smooth' })
- }
-
- onMounted(() => {
- onScroll()
- window.addEventListener('scroll', onScroll, { passive: true })
- })
- onUnmounted(() => window.removeEventListener('scroll', onScroll))
- </script>
-
- <style scoped>
- /*
- 用 CSS 变量而不是 Tailwind 类,保证 Teleport 到 body 后仍带上 scoped 属性选择器时样式生效,
- 颜色全部走主题变量,暗色模式自动适配。
- */
- .b2t-btn {
- position: fixed;
- right: 24px;
- bottom: 112px;
- z-index: 90;
- display: flex;
- align-items: center;
- justify-content: center;
- width: 44px;
- height: 44px;
- border: 1px solid var(--c-e8eef7);
- border-radius: 9999px;
- color: var(--c-5b6b80);
- background: var(--c-ffffff);
- box-shadow: 0 8px 24px var(--sh-23-32-51-140);
- cursor: pointer;
- transition: color 0.2s ease, background-color 0.2s ease, border-color 0.2s ease;
- }
-
- /* 桌面端底部没有固定操作条,可以贴近底部 */
- @media (min-width: 1024px) {
- .b2t-btn {
- right: 32px;
- bottom: 32px;
- width: 48px;
- height: 48px;
- }
- }
-
- .b2t-btn:hover {
- color: var(--c-0b8cff);
- border-color: var(--c-0b8cff);
- background: var(--c-eef7ff);
- }
-
- .b2t-btn:focus-visible {
- outline: 2px solid var(--c-0b8cff);
- outline-offset: 2px;
- }
-
- .b2t-enter-active,
- .b2t-leave-active {
- transition: opacity 0.2s ease, transform 0.2s ease;
- }
- .b2t-enter-from,
- .b2t-leave-to {
- opacity: 0;
- transform: translateY(8px);
- }
-
- @media (prefers-reduced-motion: reduce) {
- .b2t-btn,
- .b2t-enter-active,
- .b2t-leave-active {
- transition: none;
- }
- .b2t-enter-from,
- .b2t-leave-to {
- transform: none;
- }
- }
- </style>
|