|
|
|
@ -13,11 +13,11 @@ |
|
|
|
./assets/img/slice-3-black-3x.png 3x |
|
|
|
" |
|
|
|
/> |
|
|
|
<span class="text_1 nav-link" data-spy="top" @click="scrollTo('top')">首页</span> |
|
|
|
<span class="text_2 nav-link" data-spy="about" @click="scrollTo('about')">WithMind</span> |
|
|
|
<span class="text_3 nav-link" data-spy="skills" @click="scrollTo('skills')">Skills</span> |
|
|
|
<span class="text_4 nav-link" data-spy="platform" @click="scrollTo('platform')">创作平台</span> |
|
|
|
<span class="text_5 nav-link" data-spy="gateway" @click="scrollTo('gateway')">模型网关</span> |
|
|
|
<span class="text_1 nav-link" :class="{ 'nav-active': activeSection === 'top' }" data-spy="top" @click="scrollTo('top')">首页</span> |
|
|
|
<span class="text_2 nav-link" :class="{ 'nav-active': activeSection === 'about' }" data-spy="about" @click="scrollTo('about')">WithMind</span> |
|
|
|
<span class="text_3 nav-link" :class="{ 'nav-active': activeSection === 'skills' }" data-spy="skills" @click="scrollTo('skills')">Skills</span> |
|
|
|
<span class="text_4 nav-link" :class="{ 'nav-active': activeSection === 'platform' }" data-spy="platform" @click="scrollTo('platform')">创作平台</span> |
|
|
|
<span class="text_5 nav-link" :class="{ 'nav-active': activeSection === 'gateway' }" data-spy="gateway" @click="scrollTo('gateway')">模型网关</span> |
|
|
|
<div class="group_3 flex-col"></div> |
|
|
|
</div> |
|
|
|
<img |
|
|
|
@ -530,70 +530,113 @@ export default { |
|
|
|
this.initScrollSpy(); |
|
|
|
}, |
|
|
|
methods: { |
|
|
|
/* scroll-spy:用 IntersectionObserver 监听每个 section, |
|
|
|
进入视口时把对应 nav 链接加 .nav-active 类(蓝色高亮) */ |
|
|
|
/* scroll-spy:用 Vue 响应式 data(activeSection) 驱动 nav-link 的 :class 绑定。 |
|
|
|
关键设计:点击 nav-link 后,锁定 activeSection = id,屏蔽一切自动 update, |
|
|
|
直到平滑滚动结束再校准。这样彻底避免"点击瞬间设的 activeSection |
|
|
|
被 IO/scroll 回调里的 compute() 改掉"的竞态。 */ |
|
|
|
initScrollSpy() { |
|
|
|
const sections = ['top', 'about', 'skills', 'platform', 'agent', 'media', 'gateway']; |
|
|
|
const navLinks = document.querySelectorAll('.section_2 [data-spy]'); |
|
|
|
const sectionIds = ['about', 'skills', 'platform', 'agent', 'media', 'gateway']; |
|
|
|
|
|
|
|
const observer = new IntersectionObserver( |
|
|
|
(entries) => { |
|
|
|
/* 多个 entry 同时触发时,选"离顶部最近"的那一个作为 active */ |
|
|
|
let best = null; |
|
|
|
entries.forEach((entry) => { |
|
|
|
if (entry.isIntersecting) { |
|
|
|
if (!best || entry.boundingClientRect.top < best.boundingClientRect.top) { |
|
|
|
best = entry; |
|
|
|
} |
|
|
|
} |
|
|
|
}); |
|
|
|
if (best) { |
|
|
|
const id = best.target.id; |
|
|
|
this.activeSection = id; |
|
|
|
/* 同步 DOM class(因为 nav-link class 在外部 CSS 不可用,直接 toggle class) */ |
|
|
|
navLinks.forEach((link) => { |
|
|
|
if (link.getAttribute('data-spy') === id) { |
|
|
|
link.classList.add('nav-active'); |
|
|
|
// 单一 compute:section 顶 ≤ 视口高度的 50%(即"section 顶已过视口中线")就算激活 |
|
|
|
// 这避开 about 段高度只有 ~144px 的边界问题: |
|
|
|
// 点 skills 后,skills 顶 ≈ 260px < 375px(50% vh),platform 顶 > 375 → 选 skills |
|
|
|
// 而 about 顶 ≈ 116px 也 ≤ 375,但 for 循环从上到下遍历,后一个 skills 覆盖前一个 |
|
|
|
const compute = () => { |
|
|
|
const scrollY = window.scrollY || window.pageYOffset || 0; |
|
|
|
if (scrollY < 100) return 'top'; |
|
|
|
const vh = window.innerHeight || document.documentElement.clientHeight; |
|
|
|
const trigger = vh * 0.5; |
|
|
|
let activeId = 'top'; |
|
|
|
for (const id of sectionIds) { |
|
|
|
const el = document.getElementById(id); |
|
|
|
if (!el) continue; |
|
|
|
const top = el.getBoundingClientRect().top; |
|
|
|
if (top <= trigger) { |
|
|
|
activeId = id; |
|
|
|
} else { |
|
|
|
link.classList.remove('nav-active'); |
|
|
|
break; |
|
|
|
} |
|
|
|
}); |
|
|
|
} |
|
|
|
}, |
|
|
|
{ |
|
|
|
/* 视口上 30% / 下 40% 区域作为"激活区",让 section 滚到屏幕上半部时高亮 */ |
|
|
|
rootMargin: '-30% 0px -40% 0px', |
|
|
|
threshold: 0 |
|
|
|
} |
|
|
|
); |
|
|
|
return activeId; |
|
|
|
}; |
|
|
|
|
|
|
|
sections.forEach((id) => { |
|
|
|
const update = () => { |
|
|
|
// 抑制期(scrollTo 触发的平滑滚动过程中)不响应任何 update |
|
|
|
if (Date.now() < (this.scrollSuppressUntil || 0)) return; |
|
|
|
this.activeSection = compute(); |
|
|
|
}; |
|
|
|
|
|
|
|
// IO 兜底:section 进入/退出视口时重算 |
|
|
|
const observer = new IntersectionObserver(() => update(), { |
|
|
|
rootMargin: '0px 0px -50% 0px', |
|
|
|
threshold: 0 |
|
|
|
}); |
|
|
|
sectionIds.forEach((id) => { |
|
|
|
const el = document.getElementById(id); |
|
|
|
if (el) observer.observe(el); |
|
|
|
}); |
|
|
|
|
|
|
|
let ticking = false; |
|
|
|
window.addEventListener('scroll', () => { |
|
|
|
if (ticking) return; |
|
|
|
ticking = true; |
|
|
|
requestAnimationFrame(() => { |
|
|
|
ticking = false; |
|
|
|
update(); |
|
|
|
}); |
|
|
|
}, { passive: true }); |
|
|
|
|
|
|
|
update(); |
|
|
|
}, |
|
|
|
scrollTo(id) { |
|
|
|
// 1) 立即同步响应式状态(不等滚动动画) |
|
|
|
this.activeSection = id; |
|
|
|
// 2) 抑制一切自动 update 1.2s(覆盖 smooth 滚动动画) |
|
|
|
const suppressMs = 1200; |
|
|
|
this.scrollSuppressUntil = Date.now() + suppressMs; |
|
|
|
// 3) 抑制期结束后,按当前 scrollY 校准一次 |
|
|
|
setTimeout(() => { |
|
|
|
this.scrollSuppressUntil = 0; // 解除抑制 |
|
|
|
// 此时页面已稳定,scrollY 是最终值,compute() 必然正确 |
|
|
|
this.activeSection = (() => { |
|
|
|
const sectionIds = ['about', 'skills', 'platform', 'agent', 'media', 'gateway']; |
|
|
|
const scrollY = window.scrollY || window.pageYOffset || 0; |
|
|
|
if (scrollY < 100) return 'top'; |
|
|
|
const vh = window.innerHeight || document.documentElement.clientHeight; |
|
|
|
const trigger = vh * 0.5; |
|
|
|
let activeId = 'top'; |
|
|
|
for (const sid of sectionIds) { |
|
|
|
const el = document.getElementById(sid); |
|
|
|
if (!el) continue; |
|
|
|
const top = el.getBoundingClientRect().top; |
|
|
|
if (top <= trigger) { |
|
|
|
activeId = sid; |
|
|
|
} else { |
|
|
|
break; |
|
|
|
} |
|
|
|
} |
|
|
|
return activeId; |
|
|
|
})(); |
|
|
|
}, suppressMs); |
|
|
|
// 4) 触发滚动 |
|
|
|
if (id === 'top') { |
|
|
|
window.scrollTo({ top: 0, behavior: 'smooth' }); |
|
|
|
return; |
|
|
|
} |
|
|
|
const el = document.getElementById(id); |
|
|
|
if (el) { |
|
|
|
// 模型网关特殊处理:滚到网关模块底部,避免露出 footer 黑色背景 |
|
|
|
if (id === 'gateway') { |
|
|
|
// 让网关底部对齐到"视口底部上方 250px"位置,留更大余量,footer 黑色背景完全在视口外 |
|
|
|
const rect = el.getBoundingClientRect(); |
|
|
|
const elBottom = rect.bottom + window.pageYOffset; |
|
|
|
const target = elBottom - window.innerHeight + 250; |
|
|
|
window.scrollTo({ top: Math.max(0, target), behavior: 'smooth' }); |
|
|
|
return; |
|
|
|
} |
|
|
|
// 顶部 fixed 导航栏高度 2.134rem(80px),加 180px 余量,共 260px 偏移,完全避开导航遮住 |
|
|
|
} else { |
|
|
|
const top = el.getBoundingClientRect().top + window.pageYOffset - 260; |
|
|
|
window.scrollTo({ top: top, behavior: 'smooth' }); |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
}; |
|
|
|
</script> |
|
|
|
<style scoped lang="css" src="./assets/index.rem.css" /> |
|
|
|
|