Files
2026-07-16 16:42:45 +08:00

178 lines
4.3 KiB
Vue

<script setup>
const route = useRoute()
const progress = ref(0)
const showBackToTop = ref(false)
let observer
let frame
function updateScrollState() {
const scrollTop = window.scrollY || document.documentElement.scrollTop
const scrollable = Math.max(document.documentElement.scrollHeight - window.innerHeight, 1)
progress.value = Math.min(100, Math.max(0, (scrollTop / scrollable) * 100))
showBackToTop.value = scrollTop > Math.min(window.innerHeight * 0.72, 620)
}
function onScroll() {
if (frame) return
frame = window.requestAnimationFrame(() => {
frame = 0
updateScrollState()
})
}
function prepareSectionReveals() {
observer?.disconnect()
const page = document.querySelector(
'.about-page, .advantages-page, .services-page, .solutions-page, .cases-page, .case-detail-page, .insights-page, .article-page'
)
if (!page || window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
page.classList.add('interior-reveal-ready')
const sections = [
...page.querySelectorAll('main > section, main > article > div, main > article > header')
]
sections.forEach((section, index) => {
section.classList.add('interior-reveal')
if (index === 0 || section.getBoundingClientRect().top < window.innerHeight * 0.92) {
section.classList.add('is-revealed')
}
})
observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return
entry.target.classList.add('is-revealed')
observer?.unobserve(entry.target)
})
},
{ rootMargin: '0px 0px -9% 0px', threshold: 0.08 }
)
sections
.filter((section) => !section.classList.contains('is-revealed'))
.forEach((section) => observer.observe(section))
}
function backToTop() {
window.scrollTo({ top: 0, behavior: 'smooth' })
}
onMounted(async () => {
window.addEventListener('scroll', onScroll, { passive: true })
window.addEventListener('resize', onScroll, { passive: true })
await nextTick()
updateScrollState()
prepareSectionReveals()
})
watch(
() => route.fullPath,
async () => {
await nextTick()
updateScrollState()
prepareSectionReveals()
}
)
onBeforeUnmount(() => {
observer?.disconnect()
if (frame) window.cancelAnimationFrame(frame)
window.removeEventListener('scroll', onScroll)
window.removeEventListener('resize', onScroll)
})
</script>
<template>
<div class="reading-progress" aria-hidden="true">
<span :style="{ transform: `scaleX(${progress / 100})` }"></span>
</div>
<Transition name="back-top">
<button
v-if="showBackToTop"
class="back-to-top"
type="button"
aria-label="回到页面顶部"
title="回到顶部"
@click="backToTop"
>
<AppIcon name="bottom" />
</button>
</Transition>
</template>
<style scoped>
.reading-progress {
position: fixed;
z-index: 45;
top: 0;
right: 0;
left: 0;
height: 2px;
pointer-events: none;
}
.reading-progress span {
width: 100%;
height: 100%;
display: block;
background: linear-gradient(90deg, #166ff3, #62c8f1);
box-shadow: 0 1px 8px rgba(27, 124, 240, 0.34);
transform-origin: left center;
will-change: transform;
}
.back-to-top {
position: fixed;
z-index: 28;
right: clamp(18px, 2.7vw, 42px);
bottom: clamp(18px, 3vw, 40px);
width: 44px;
height: 44px;
display: grid;
place-items: center;
border: 1px solid rgba(91, 151, 224, 0.22);
border-radius: 6px;
color: #2379e8;
background: rgba(255, 255, 255, 0.9);
box-shadow: 0 14px 34px rgba(37, 94, 160, 0.16);
backdrop-filter: blur(14px);
transition:
color 0.22s ease,
background 0.22s ease,
transform 0.22s ease,
box-shadow 0.22s ease;
}
.back-to-top :deep(.app-icon) {
width: 18px;
height: 18px;
transform: rotate(180deg);
}
.back-to-top:hover {
color: #fff;
background: #207bf2;
box-shadow: 0 17px 36px rgba(31, 121, 236, 0.25);
transform: translateY(-3px);
}
.back-to-top:active {
transform: translateY(1px) scale(0.97);
}
.back-top-enter-active,
.back-top-leave-active {
transition:
opacity 0.2s ease,
transform 0.24s ease;
}
.back-top-enter-from,
.back-top-leave-to {
opacity: 0;
transform: translateY(12px);
}
@media (max-width: 680px) {
.back-to-top {
right: 16px;
bottom: 16px;
width: 42px;
height: 42px;
}
}
</style>