diff --git a/libs/yggdrasil-core/src/anchor-click.test.ts b/libs/yggdrasil-core/src/anchor-click.test.ts
new file mode 100644
index 0000000..11db598
--- /dev/null
+++ b/libs/yggdrasil-core/src/anchor-click.test.ts
@@ -0,0 +1,125 @@
+/**
+ * anchor-click 测试:钉住 hash 锚点点击的拦截、滚动与 URL 更新。
+ * 黑盒驱动:只通过 window.__initAnchorClick 入口 + 真实 DOM 事件派发。
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import './index';
+
+// 模拟 Dioxus 委托监听器:挂在 document 的 bubble 阶段 click,模拟 handleClickNavigate
+// 会被触发时的副作用(preventDefault + 整页跳转)。测试用它验证拦截器确实阻止了冒泡。
+let dioxusInterceptorCalled = false;
+
+describe('initAnchorClick', () => {
+ beforeEach(() => {
+ document.body.innerHTML = '';
+ history.replaceState(null, '', '#');
+ vi.restoreAllMocks();
+ dioxusInterceptorCalled = false;
+ // 模拟 Dioxus 在 document bubble 阶段的 click 委托监听器。
+ // 拦截器在 window capture 阶段 stopPropagation 后,这个监听器不应被触发。
+ document.addEventListener(
+ 'click',
+ () => {
+ dioxusInterceptorCalled = true;
+ },
+ false,
+ );
+ window.__initAnchorClick();
+ });
+ afterEach(() => {
+ document.body.innerHTML = '';
+ history.replaceState(null, '', '#');
+ });
+
+ it('点击 hash 锚点滚动到目标并阻止 Dioxus 委托监听器', () => {
+ const heading = document.createElement('h2');
+ heading.id = 'ru-men-zhi-nan';
+ document.body.appendChild(heading);
+
+ const anchor = document.createElement('a');
+ anchor.setAttribute('href', '#ru-men-zhi-nan');
+ document.body.appendChild(anchor);
+
+ const spy = vi.spyOn(heading, 'scrollIntoView');
+ anchor.click();
+
+ expect(spy).toHaveBeenCalledOnce();
+ expect(spy).toHaveBeenCalledWith({ behavior: 'smooth', block: 'start' });
+ // 关键:Dioxus 委托监听器未被触发 → handleClickNavigate/browser_open 不会执行。
+ expect(dioxusInterceptorCalled).toBe(false);
+ });
+
+ it('点击后 URL hash 更新为锚点 id(不触发整页刷新)', () => {
+ const heading = document.createElement('h2');
+ heading.id = 'section-2';
+ document.body.appendChild(heading);
+
+ const anchor = document.createElement('a');
+ anchor.setAttribute('href', '#section-2');
+ document.body.appendChild(anchor);
+
+ anchor.click();
+
+ expect(window.location.hash).toBe('#section-2');
+ });
+
+ it('目标元素不存在时放行,不阻止 Dioxus 原行为', () => {
+ const anchor = document.createElement('a');
+ anchor.setAttribute('href', '#no-such-heading');
+ document.body.appendChild(anchor);
+
+ anchor.click();
+
+ // 目标不存在:拦截器不接管,事件继续冒泡到 Dioxus 监听器。
+ expect(dioxusInterceptorCalled).toBe(true);
+ });
+
+ it('非 hash 链接不拦截(外链/路径链接交给原行为)', () => {
+ const anchor = document.createElement('a');
+ anchor.setAttribute('href', '/post/other');
+ document.body.appendChild(anchor);
+
+ anchor.click();
+
+ expect(dioxusInterceptorCalled).toBe(true);
+ });
+
+ it('带修饰键的点击不拦截(新窗口等交给浏览器)', () => {
+ const heading = document.createElement('h2');
+ heading.id = 'target';
+ document.body.appendChild(heading);
+
+ const anchor = document.createElement('a');
+ anchor.setAttribute('href', '#target');
+ document.body.appendChild(anchor);
+
+ const spy = vi.spyOn(heading, 'scrollIntoView');
+
+ // 模拟 ctrl+click(新标签页):dispatchEvent 携带 ctrlKey。
+ const event = new MouseEvent('click', { bubbles: true, ctrlKey: true });
+ anchor.dispatchEvent(event);
+
+ expect(spy).not.toHaveBeenCalled();
+ expect(dioxusInterceptorCalled).toBe(true);
+ });
+
+ it('重复调用 initAnchorClick 幂等,不会重复注册监听器', () => {
+ const heading = document.createElement('h2');
+ heading.id = 'dup-target';
+ document.body.appendChild(heading);
+
+ const anchor = document.createElement('a');
+ anchor.setAttribute('href', '#dup-target');
+ document.body.appendChild(anchor);
+
+ // 再次调用(模拟 PostContent 多次挂载)。
+ window.__initAnchorClick();
+ window.__initAnchorClick();
+
+ const spy = vi.spyOn(heading, 'scrollIntoView');
+ anchor.click();
+
+ // 即使重复调用,scrollIntoView 仍只触发一次(监听器只注册一份)。
+ expect(spy).toHaveBeenCalledOnce();
+ });
+});
diff --git a/libs/yggdrasil-core/src/anchor-click.ts b/libs/yggdrasil-core/src/anchor-click.ts
new file mode 100644
index 0000000..4780984
--- /dev/null
+++ b/libs/yggdrasil-core/src/anchor-click.ts
@@ -0,0 +1,73 @@
+/**
+ * 拦截文章正文/目录里的 hash 锚点点击,阻止 Dioxus 接管导致整页刷新。
+ *
+ * 背景:Dioxus 0.7 的 NativeInterpreter 通过事件委托在根节点上监听 click。
+ * 只要 app 任意位置用了 onclick(本项目 post_nav_links.rs 有),Dioxus 就启用
+ * 全局 click 委托。点击 dangerous_inner_html 注入的原生 时:
+ * 1. click 冒泡到根 → handleEvent 同步 POST /__events
+ * 2. 目标 没有 Dioxus onclick → response 不含 preventDefault
+ * 3. 兜底分支 handleClickNavigate: event.preventDefault() + browser_open IPC
+ * 4. browser_open 走 History::external → window.location.set_href("#id") → 整页刷新
+ *
+ * 结果:原生 fragment-scroll 被 preventDefault 拦掉,IPC 又把 hash 当外部 URL
+ * 整页加载,表现为「点击锚点刷新文章页且不滚动」。WASM 未下载完时是纯 SSR HTML,
+ * 走浏览器原生 fragment-scroll 正常——所以问题只在 hydration 后出现。
+ *
+ * 修复:在 capture 阶段(早于 Dioxus 的 bubble 委托监听器)拦截指向同页 hash 的
+ * 点击,stopPropagation 阻止事件到达 Dioxus 的委托监听器,自行 scrollIntoView
+ * 并用 history.replaceState 更新 URL hash(不触发 popstate/hashchange,避免抖动)。
+ */
+
+let installed = false;
+
+/**
+ * 安装全局 hash 锚点点击拦截器。幂等:重复调用安全。
+ *
+ * 用 capture 阶段 + 在 window 上注册,保证在 Dioxus 的根监听器之前执行;
+ * stopPropagation 阻止事件继续传播,Dioxus 的 handleEvent/handleClickNavigate
+ * 不会收到这个事件,browser_open IPC 不触发,整页刷新被避免。
+ */
+export function initAnchorClick(): void {
+ if (installed) return;
+ installed = true;
+
+ window.addEventListener(
+ 'click',
+ (event) => {
+ // 只处理无修饰键的左键点击(中键/ctrl/cmd 点击交给浏览器新窗口行为)。
+ if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
+ return;
+ }
+
+ const target = event.target;
+ if (!(target instanceof Element)) return;
+
+ // 从点击点向上找最近的 (点击可能落在 内的子元素上)。
+ const anchor = target.closest('a');
+ if (!anchor) return;
+
+ const href = anchor.getAttribute('href');
+ // 只接管同页 hash 锚点:href 以 # 开头且非空(排除纯 "#" 和 "#top" 之外的真锚点)。
+ // 外链、路径链接、空 hash 都交给浏览器/Dioxus 原行为。
+ if (!href?.startsWith('#') || href === '#') return;
+
+ const id = decodeURIComponent(href.slice(1));
+ const el = document.getElementById(id);
+ if (!el) return; // 目标不存在:放行,交还原生行为(不滚动但不刷新)
+
+ // 阻止事件继续传播到 Dioxus 的委托监听器 → 避免 handleClickNavigate。
+ event.stopPropagation();
+ event.preventDefault();
+
+ // 平滑滚动到目标标题(scroll-margin-top 在 input.css 里设为 6rem 避开 sticky header)。
+ el.scrollIntoView({ behavior: 'smooth', block: 'start' });
+
+ // 更新 URL hash 但不产生历史记录抖动:replaceState 不触发 popstate/hashchange。
+ // 这样地址栏显示当前章节,刷新页面也能 scrollToHash 回到原位。
+ if (window.location.hash !== href) {
+ history.replaceState(null, '', href);
+ }
+ },
+ true, // capture:在 Dioxus 的 bubble 监听器之前执行
+ );
+}
diff --git a/libs/yggdrasil-core/src/index.ts b/libs/yggdrasil-core/src/index.ts
index 27bd4ee..0ffc3f1 100644
--- a/libs/yggdrasil-core/src/index.ts
+++ b/libs/yggdrasil-core/src/index.ts
@@ -1,3 +1,4 @@
+import { initAnchorClick } from './anchor-click';
import { scrollToHash } from './hash-scroll';
import { initPostContent } from './post-content';
import { applyResolvedTheme, startThemeTransition } from './theme-transition';
@@ -6,6 +7,7 @@ import './style.css';
declare global {
interface Window {
__initPostContent: (selector: string) => void;
+ __initAnchorClick: () => void;
__scrollToHash: () => void;
__startThemeTransition: (x: number, y: number) => void;
__applyResolvedTheme: (isDark: boolean) => void;
@@ -13,6 +15,7 @@ declare global {
}
window.__initPostContent = initPostContent;
+window.__initAnchorClick = initAnchorClick;
window.__scrollToHash = scrollToHash;
window.__startThemeTransition = startThemeTransition;
window.__applyResolvedTheme = applyResolvedTheme;
diff --git a/src/components/post/post_content.rs b/src/components/post/post_content.rs
index 1e7390a..73079f9 100644
--- a/src/components/post/post_content.rs
+++ b/src/components/post/post_content.rs
@@ -154,6 +154,12 @@ pub fn PostContent(content_html: String) -> Element {
let _ = js_sys::Reflect::set(&window, &"__lightboxSelectors".into(), &selectors_val);
invoke_optional_global(&window, "__initLightbox", &[selectors_val]);
+ // 安装 hash 锚点点击拦截器(幂等)。
+ // Dioxus hydration 后其事件委托会接管所有 click(见 handleClickNavigate),
+ // 把 hash 锚点当外部 URL 整页刷新。拦截器在 capture 阶段阻止事件到达 Dioxus,
+ // 自行 scrollIntoView。initAnchorClick 内部幂等,PostContent 多次挂载也安全。
+ invoke_optional_global(&window, "__initAnchorClick", &[]);
+
// 内容挂载后若 URL 带 hash,滚动到对应标题。
// 解决骨架屏阶段标题 DOM 缺失导致浏览器原生 fragment-scroll 失效的问题:
// PostDetail 用 use_server_future 异步取数,首屏渲染骨架屏,此时标题 DOM