From 39209ef5916dd9aba50bab01561dbc1c72b6b78a Mon Sep 17 00:00:00 2001 From: Dai0814 <3490199389@qq.com> Date: Wed, 4 Nov 2026 15:23:37 +0800 Subject: [PATCH] =?UTF-8?q?fix(portal):=20=E5=AF=BC=E8=88=AA=E6=A0=8F?= =?UTF-8?q?=E9=94=9A=E7=82=B9=E9=A1=B9=E7=82=B9=E5=87=BB=E5=90=8E=E6=AD=A3?= =?UTF-8?q?=E7=A1=AE=E9=AB=98=E4=BA=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:点「产品功能 / 核心能力 / 界面预览 / 官方文档」跳转后图标不变蓝。 根因: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 失效 --- .gitignore | 1 + .vitepress/config.mts | 3 +- .vitepress/theme/index.ts | 24 +++++++- .vitepress/theme/nav-anchor.ts | 97 ++++++++++++++++++++++++++++++ .vitepress/theme/styles/portal.css | 24 ++++++++ 5 files changed, 146 insertions(+), 3 deletions(-) create mode 100644 .vitepress/theme/nav-anchor.ts diff --git a/.gitignore b/.gitignore index 25e6314..fa49853 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ dist .vitepress/v3 .vitepress/v4 .vitepress/v5 +.vitepress/v6 diff --git a/.vitepress/config.mts b/.vitepress/config.mts index c971ada..659491e 100644 --- a/.vitepress/config.mts +++ b/.vitepress/config.mts @@ -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' }, ], diff --git a/.vitepress/theme/index.ts b/.vitepress/theme/index.ts index dd0563c..b22cccc 100644 --- a/.vitepress/theme/index.ts +++ b/.vitepress/theme/index.ts @@ -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)) } diff --git a/.vitepress/theme/nav-anchor.ts b/.vitepress/theme/nav-anchor.ts new file mode 100644 index 0000000..828eb74 --- /dev/null +++ b/.vitepress/theme/nav-anchor.ts @@ -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(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 } diff --git a/.vitepress/theme/styles/portal.css b/.vitepress/theme/styles/portal.css index b628980..b1ad7c3 100644 --- a/.vitepress/theme/styles/portal.css +++ b/.vitepress/theme/styles/portal.css @@ -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); +} +