fix(portal): 导航栏锚点项点击后正确高亮
mirror-to-gitee / mirror (push) Successful in 3s

问题:点「产品功能 / 核心能力 / 界面预览 / 官方文档」跳转后图标不变蓝。
根因:VitePress 的 isActive() 只比较 path,不看 hash。nav 里
「首页 /」「产品功能 /#features」「核心能力 /#core」的路径部分都是 `/`,
默认逻辑要么把首页和当前锚点同时点亮,要么只亮首页。

改动
- 新增 .vitepress/theme/nav-anchor.ts 接管首页内锚点项的激活判定:
  用 data-anchor-active 属性标记,规则为
  在首页且有 hash → 只有 hash 匹配项亮;在首页无 hash → 只有「首页」亮;
  不在首页 → 清标记交回 VitePress 默认逻辑
- 样式侧用 html.portal-nav-home 限定压制默认 active 的作用域,
  只影响首页,不影响 /projects/ 与 /about 的正常高亮
- 导航「项目」加 activeMatch: '^/projects/',
  使 /projects/iotlcm/ 等详情页也保持高亮
- MutationObserver 监听导航 DOM 变化(SPA 切换、主题切换会重建)

踩的坑
- Layout 是函数式组件,onMounted 在其中无效(Vue 报"no active component
  instance"),改用 nextTick 调度
- 初版用 hashOf(a) 判空来识别「首页」,但 /projects/ 与 /about 也没有 hash,
  被误判成首页项一起点亮;改为判断 href 解析后是否为 '/'
- 抑制规则最初没加作用域,导致非首页页面的正常 active 也被压成黑字

验证(8 个场景逐个实测)
首页顶部→首页 / 四个锚点→各自高亮 / 项目页→项目 /
关于页→关于 / iotLCM 详情→项目;构建 13 页 274 引用 0 失效
This commit is contained in:
Dai0814
2026-11-04 15:23:37 +08:00
parent 9955e830ce
commit 39209ef591
5 changed files with 146 additions and 3 deletions
+2 -1
View File
@@ -38,7 +38,8 @@ export default defineConfig({
{ text: '产品功能', link: '/#features' },
{ text: '核心能力', link: '/#core' },
{ text: '界面预览', link: '/#preview' },
{ text: '项目', link: '/projects/' },
// activeMatch:进入任一项目详情页时「项目」保持高亮
{ text: '项目', link: '/projects/', activeMatch: '^/projects/' },
{ text: '官方文档', link: '/#docs' },
{ text: '关于', link: '/about' },
],
+22 -2
View File
@@ -1,7 +1,7 @@
import DefaultTheme from 'vitepress/theme'
import type { Theme } from 'vitepress'
import { h } from 'vue'
import { useData } from 'vitepress'
import { h, watch, nextTick } from 'vue'
import { useData, useRoute } from 'vitepress'
import ProjectList from './components/ProjectList.vue'
import HomePage from './components/HomePage.vue'
@@ -9,15 +9,35 @@ import CapabilityTabs from './components/CapabilityTabs.vue'
import FeatureGrid from './components/FeatureGrid.vue'
import ScreenshotPreview from './components/ScreenshotPreview.vue'
import { setupNavAnchorHighlight, applyNavAnchorHighlight } from './nav-anchor'
import './styles/portal.css'
/**
* 首页用 layout: page 承载自定义整页排版,
* 这里给它套一层 .portal-page,把默认窄栏与 padding 去掉。
* 同时接管导航栏锚点项的高亮(VitePress 默认只认 path,不认 hash)。
*/
const PortalLayout = () => {
const { frontmatter } = useData()
const route = useRoute()
const isPortal = frontmatter.value.layout === 'page'
/**
* Layout 是函数式组件,onMounted 在这里不生效(Vue 会告警),
* 因此直接在渲染期间调度:等 DOM 落地后跑一次,再靠 MutationObserver 兜住后续变化。
*/
if (typeof window !== 'undefined') {
nextTick(() => setupNavAnchorHighlight())
}
// SPA 路由切换不触发 hashchange,这里补一次
watch(
() => [route.path, route.hash],
() => nextTick(() => applyNavAnchorHighlight())
)
return h('div', { class: isPortal ? 'portal-page' : '' }, h(DefaultTheme.Layout))
}
+97
View File
@@ -0,0 +1,97 @@
/**
* 导航栏锚点高亮
*
* 问题:VitePress 的 isActive() 只比较 path,不看 hash。
* nav 里「首页 /」「产品功能 /#features」「核心能力 /#core」路径部分都是 `/`,
* 于是停在 #features 时,首页和产品功能会同时被标蓝。
*
* 解决:接管首页内锚点项的激活判定,用 data-anchor-active 标记,
* 样式侧压掉 VitePress 默认的 .active。
* - 在首页且有 hash:只有 hash 匹配的那项亮,首页项不亮
* - 在首页且无 hash:只有「首页」亮
* - 不在首页:不干预,清掉标记交回默认逻辑
*/
const SEL = '.VPNavBarMenuLink'
function hashOf(a: HTMLAnchorElement): string {
const href = a.getAttribute('href') ?? ''
const i = href.indexOf('#')
return i === -1 ? '' : href.slice(i)
}
function apply(): void {
const links = Array.from(document.querySelectorAll<HTMLAnchorElement>(SEL))
if (!links.length) return
const path = location.pathname
const onHome = path === '/' || path === '' || /^\/index\.html?$/.test(path)
// 标记首页状态:样式侧据此压掉 VitePress 对「项目 / 关于」的前缀匹配高亮
document.documentElement.classList.toggle('portal-nav-home', onHome)
if (!onHome) {
links.forEach((a) => a.removeAttribute('data-anchor-active'))
return
}
// 兼容 VitePress 的 cleanUrls:路径可能是 / 或 /index.html
const h = location.hash
links.forEach((a) => {
const href = a.getAttribute('href') ?? ''
const itemHash = hashOf(a)
if (itemHash) {
// 锚点项:只有 hash 完全匹配时才亮
a.toggleAttribute('data-anchor-active', h === itemHash)
return
}
// 没有 hash 的项:只有「指向首页本身」的才算首页项
// (/projects/ 与 /about 也没有 hash,不能一并当作首页)
const bare = href.split('#')[0].replace(/\/index\.html?$/, '/')
const isHomeItem = bare === '/' || bare === ''
if (isHomeItem) {
a.toggleAttribute('data-anchor-active', h === '')
} else {
a.removeAttribute('data-anchor-active')
}
})
}
let raf = 0
function schedule(): void {
if (raf) return
raf = requestAnimationFrame(() => {
raf = 0
apply()
})
}
/** 首屏导航是异步渲染的,用 MutationObserver 等它出现再判定 */
export function setupNavAnchorHighlight(): void {
if (typeof window === 'undefined') return
const run = () => {
apply()
// 导航可能是分块渲染的,多补两次
requestAnimationFrame(apply)
setTimeout(apply, 80)
setTimeout(apply, 300)
}
run()
window.addEventListener('hashchange', schedule)
window.addEventListener('popstate', schedule)
window.addEventListener('load', schedule)
// 导航栏内容变化(SPA 切换、暗色切换重建)时重新判定
const nav = document.querySelector('.VPNavBar') ?? document.body
const mo = new MutationObserver(() => schedule())
mo.observe(nav, { childList: true, subtree: true })
}
export { apply as applyNavAnchorHighlight }
+24
View File
@@ -1026,3 +1026,27 @@ html.dark .p-section--deep .p-title {
html.dark .p-section--deep .p-card-text {
color: #c2cbdb;
}
/* ============================================================
导航栏锚点高亮
VitePress 只按 path 判定 active,同一路径下的多个锚点会一起亮。
这里用 data-anchor-active 属性接管,并压掉错误的默认 active。
选择器用 :not([data-anchor-active]) 精确排除正确项。
============================================================ */
/* 压制 VitePress 默认 active 只在首页生效:
首页上「/」「/#features」等路径部分相同,默认逻辑会把多个项同时点亮;
其它页面(/projects/、/about)必须保留正常高亮。 */
html.portal-nav-home .VPNavBarMenuLink.active:not([data-anchor-active]) {
color: var(--vp-c-text-1);
}
.VPNavBarMenuLink[data-anchor-active],
.VPNavBarMenuLink.active[data-anchor-active] {
color: var(--vp-c-brand-1);
}
/* 首页内锚点项未激活时保持常态色,并补一个 hover 反馈 */
.VPNavBarMenuLink[href*='#']:not([data-anchor-active]):hover {
color: var(--vp-c-brand-1);
}