From fe1882ec4e38c1f1d30c1b7059a884e3932bb45c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BD=A0=E5=A5=BD=E5=91=80?= <431761794@qq.com> Date: Sat, 16 May 2026 16:51:06 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20heo=20=E4=BA=A4?= =?UTF-8?q?=E4=BA=92=E6=B8=B2=E6=9F=93=E4=B8=8E=20Notion=20=E8=AF=B7?= =?UTF-8?q?=E6=B1=82=E9=98=9F=E5=88=97=E5=BC=82=E5=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复内容: - 修复 heo Header 渲染阶段直接访问 document 的 SSR / hydration 风险,并用 useMemo 持有 throttle 实例,清理监听时同步 cancel pending 调用。 - 修复 heo 文章卡片 className 模板字符串三元表达式写错位的问题,避免渲染脏 class。 - 将加密文章密码错误提示从 innerHTML 拼接改为 React 状态渲染,并为输入框 focus 增加空保护。 - 将搜索输入框输入法组合锁从模块级变量改为组件实例 ref,避免多个实例共享状态。 - 稳定 heo 侧边栏路由监听回调,并优化 Swipe 轮播定时器,避免索引变化时反复重建 interval。 - 修复 heo 搜索高亮与 404 延迟跳转 effect 的依赖和定时器清理。 - 收敛 Notion API inflight Promise 清理逻辑,避免 rejected Promise 触发未处理拒绝;同步修复 RateLimiter 中浮动 Promise 与 any 类型。 验证: - yarn next lint --file <本次改动文件> 通过,0 warning / 0 error。 - yarn type-check 通过。 - yarn test --runInBand 通过,12 个测试套件 / 84 个测试全部通过。 --- lib/db/notion/RateLimiter.ts | 39 +++++++++++------ lib/db/notion/getNotionAPI.js | 10 +++-- pages/[prefix]/index.js | 6 ++- themes/heo/components/BlogPostCard.js | 2 +- themes/heo/components/Header.js | 60 +++++++++++++++----------- themes/heo/components/PostHeader.js | 10 +++-- themes/heo/components/PostLock.js | 34 +++++++++------ themes/heo/components/SearchInput.js | 10 +++-- themes/heo/components/SideBarDrawer.js | 47 +++++++++++--------- themes/heo/components/Swipe.js | 6 ++- themes/heo/index.js | 12 +++--- 11 files changed, 147 insertions(+), 89 deletions(-) diff --git a/lib/db/notion/RateLimiter.ts b/lib/db/notion/RateLimiter.ts index df91844e70c..ea3501f9645 100644 --- a/lib/db/notion/RateLimiter.ts +++ b/lib/db/notion/RateLimiter.ts @@ -1,14 +1,17 @@ import fs from 'fs' -import path from 'path' interface QueueItem { requestFunc: () => Promise resolve: (value: T) => void - reject: (err: any) => void + reject: (err: unknown) => void +} + +interface NodeError extends Error { + code?: string } export class RateLimiter { - private queue: QueueItem[] = [] + private queue: QueueItem[] = [] private inflight = new Set() private isProcessing = false private lastRequestTime = 0 @@ -39,8 +42,9 @@ export class RateLimiter { try { fs.writeFileSync(this.lockFilePath, process.pid.toString(), { flag: 'wx' }) return - } catch (err: any) { - if (err.code === 'EEXIST') await new Promise(res => setTimeout(res, 100)) + } catch (err) { + const e = err as NodeError + if (e.code === 'EEXIST') await new Promise(res => setTimeout(res, 100)) else throw err } } @@ -54,19 +58,27 @@ export class RateLimiter { public enqueue(key: string, requestFunc: () => Promise): Promise { if (this.inflight.has(key)) { - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const interval = setInterval(() => { if (!this.inflight.has(key)) { clearInterval(interval) - this.enqueue(key, requestFunc).then(resolve).catch(reject) + // 用 void 显式标记忽略:递归 enqueue 自身已串接 resolve/reject + void this.enqueue(key, requestFunc).then(resolve).catch(reject) } }, 50) }) } - return new Promise((resolve, reject) => { - this.queue.push({ requestFunc, resolve, reject }) - if (!this.isProcessing) this.processQueue() + return new Promise((resolve, reject) => { + this.queue.push({ + requestFunc: requestFunc as () => Promise, + resolve: resolve as (value: unknown) => void, + reject + }) + if (!this.isProcessing) { + // processQueue 是 async 但这里不 await,需要兜底捕获 + void this.processQueue() + } }) } @@ -96,7 +108,7 @@ export class RateLimiter { this.inflight.add(key) try { - const result = await requestFunc() + const result: unknown = await requestFunc() this.lastRequestTime = Date.now() this.requestCount++ resolve(result) @@ -107,7 +119,10 @@ export class RateLimiter { console.error('限流队列异常', err) } finally { this.releaseLock() - setTimeout(() => this.processQueue(), 0) + // 显式忽略下一轮的返回 Promise + setTimeout(() => { + void this.processQueue() + }, 0) } } } diff --git a/lib/db/notion/getNotionAPI.js b/lib/db/notion/getNotionAPI.js index 29e03760582..4b4162966a8 100644 --- a/lib/db/notion/getNotionAPI.js +++ b/lib/db/notion/getNotionAPI.js @@ -48,13 +48,17 @@ async function callNotion(methodName, ...args) { if (globalStore.inflight.has(key)) return globalStore.inflight.get(key) - const execute = async () => original.apply(notion, args) + // 注意:原函数已返回 Promise,不需要再 async 包一层 + const execute = () => original.apply(notion, args) const promise = useRateLimiter ? rateLimiter.enqueue(key, execute) - : execute() + : Promise.resolve().then(execute) globalStore.inflight.set(key, promise) - promise.finally(() => globalStore.inflight.delete(key)) + // 始终把 inflight 清掉;即便上层不消费 reject 也不抛 unhandledRejection + promise + .catch(() => {}) + .finally(() => globalStore.inflight.delete(key)) return promise } diff --git a/pages/[prefix]/index.js b/pages/[prefix]/index.js index 66092c07e80..9e25bd1bcc4 100644 --- a/pages/[prefix]/index.js +++ b/pages/[prefix]/index.js @@ -75,7 +75,9 @@ const Slug = props => { } } } - }, [post]) + // validPassword 内部依赖 post / router 同时也已在依赖里 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [post, router.asPath]) // 文章加载 useEffect(() => { @@ -89,7 +91,7 @@ const Slug = props => { ) post.toc = getPageTableOfContents(post, post.blockMap) } - }, [router, lock]) + }, [router, lock, post]) props = { ...props, lock, validPassword } const theme = siteConfig('THEME', BLOG.THEME, props.NOTION_CONFIG) diff --git a/themes/heo/components/BlogPostCard.js b/themes/heo/components/BlogPostCard.js index ebbb62382fa..398d59cc60c 100644 --- a/themes/heo/components/BlogPostCard.js +++ b/themes/heo/components/BlogPostCard.js @@ -29,7 +29,7 @@ const BlogPostCard = ({ index, post, showSummary, siteInfo }) => { return (
+ className={`${COVER_HOVER_ENLARGE ? 'hover:transition-all duration-150' : ''}`}>
{ const [textWhite, setTextWhite] = useState(false) const [navBgWhite, setBgWhite] = useState(false) const [activeIndex, setActiveIndex] = useState(0) + // 是否存在文章页背景图(仅客户端检测) + const [hasPostBg, setHasPostBg] = useState(false) const router = useRouter() const slideOverRef = useRef() + // 缓存 #post-bg 节点的引用,避免每次滚动都重新查询 DOM + const postBgRef = useRef(null) const toggleMenuOpen = () => { slideOverRef?.current?.toggleSlideOvers() @@ -31,40 +35,48 @@ const Header = props => { /** * 根据滚动条,切换导航栏样式 + * 用 useMemo 持有 throttle 实例,避免每次渲染重建 */ - const scrollTrigger = useCallback( - throttle(() => { - const scrollS = window.scrollY - // 导航栏设置 白色背景 - if (scrollS <= 1) { - setFixedNav(false) - setBgWhite(false) - setTextWhite(false) - - // 文章详情页特殊处理 - if (document?.querySelector('#post-bg')) { + const scrollTrigger = useMemo( + () => + throttle(() => { + const scrollS = window.scrollY + // 导航栏设置 白色背景 + if (scrollS <= 1) { + setFixedNav(false) + setBgWhite(false) + setTextWhite(false) + + // 文章详情页特殊处理 + if (postBgRef.current) { + setFixedNav(true) + setTextWhite(true) + } + } else { + // 向下滚动后的导航样式 setFixedNav(true) - setTextWhite(true) + setTextWhite(false) + setBgWhite(true) } - } else { - // 向下滚动后的导航样式 - setFixedNav(true) - setTextWhite(false) - setBgWhite(true) - } - }, 100) + }, 100), + [] ) + + // 路由变化后重新探测 #post-bg 与初始化导航状态 useEffect(() => { + postBgRef.current = document.querySelector('#post-bg') + setHasPostBg(!!postBgRef.current) scrollTrigger() - }, [router]) + }, [router.asPath, scrollTrigger]) // 监听滚动 useEffect(() => { - window.addEventListener('scroll', scrollTrigger) + window.addEventListener('scroll', scrollTrigger, { passive: true }) return () => { window.removeEventListener('scroll', scrollTrigger) + scrollTrigger.cancel() } - }, []) + }, [scrollTrigger]) // 导航栏根据滚动轮播菜单内容 useEffect(() => { @@ -133,7 +145,7 @@ const Header = props => { `} {/* fixed时留白高度 */} - {fixedNav && !document?.querySelector('#post-bg') && ( + {fixedNav && !hasPostBg && (
)} diff --git a/themes/heo/components/PostHeader.js b/themes/heo/components/PostHeader.js index cbb137faee0..aaf36f0e54b 100644 --- a/themes/heo/components/PostHeader.js +++ b/themes/heo/components/PostHeader.js @@ -22,7 +22,10 @@ export default function PostHeader({ post, siteInfo, isDarkMode }) { return (
+ className='md:mb-0 -mb-5 w-full h-[30rem] relative md:flex-shrink-0 overflow-hidden bg-cover bg-center bg-no-repeat z-10' + style={{ + '--heo-post-bg-accent': isDarkMode ? '#CA8A04' : '#0060e0' + }}>
+ className='absolute top-0 w-full h-full py-10 flex justify-center items-center' + style={{ backgroundColor: 'var(--heo-post-bg-accent)' }}> {/* 文章背景图 */}
{ const { validPassword } = props const { locale } = useGlobal() + const [showError, setShowError] = useState(false) + const passwordInputRef = useRef(null) + const submitPassword = () => { - const p = document.getElementById('password') - if (!validPassword(p?.value)) { - const tips = document.getElementById('tips') - if (tips) { - tips.innerHTML = '' - tips.innerHTML = `
${locale.COMMON.PASSWORD_ERROR}
` - } + const value = passwordInputRef.current?.value + if (!validPassword(value)) { + // 触发抖动动画:先取消再加上,让 CSS 动画重新跑 + setShowError(false) + // 下一帧再设 true,确保动画重启 + requestAnimationFrame(() => setShowError(true)) + } else { + setShowError(false) } } - const passwordInputRef = useRef(null) + useEffect(() => { - // 选中密码输入框并将其聚焦 - passwordInputRef.current.focus() + // 选中密码输入框并将其聚焦(带空保护,组件未挂载时不会崩) + passwordInputRef.current?.focus?.() }, []) return ( @@ -54,7 +58,13 @@ export const PostLock = props => {
-
+
+ {showError && ( +
+ {locale.COMMON.PASSWORD_ERROR} +
+ )} +
) diff --git a/themes/heo/components/SearchInput.js b/themes/heo/components/SearchInput.js index 6e577bbac10..91e8043f02e 100644 --- a/themes/heo/components/SearchInput.js +++ b/themes/heo/components/SearchInput.js @@ -1,7 +1,6 @@ import { useRouter } from 'next/router' import { useImperativeHandle, useRef, useState } from 'react' import { useGlobal } from '@/lib/global' -let lock = false const SearchInput = props => { const { currentSearch, cRef, className } = props @@ -9,6 +8,9 @@ const SearchInput = props => { const router = useRouter() const searchInputRef = useRef() const { locale } = useGlobal() + // 输入法组合期锁定 — 用 useRef 持有,避免使用模块级变量在 SSR + // 多请求 / 多实例间互相污染(之前 `let lock = false` 是模块作用域)。 + const lockRef = useRef(false) useImperativeHandle(cRef, () => { return { focus: () => { @@ -44,7 +46,7 @@ const SearchInput = props => { const [showClean, setShowClean] = useState(false) const updateSearchKey = val => { - if (lock) { + if (lockRef.current) { return } searchInputRef.current.value = val @@ -56,11 +58,11 @@ const SearchInput = props => { } } function lockSearchInput () { - lock = true + lockRef.current = true } function unLockSearchInput () { - lock = false + lockRef.current = false } return ( diff --git a/themes/heo/components/SideBarDrawer.js b/themes/heo/components/SideBarDrawer.js index 87125c0523d..e53ae623534 100644 --- a/themes/heo/components/SideBarDrawer.js +++ b/themes/heo/components/SideBarDrawer.js @@ -1,5 +1,5 @@ import { useRouter } from 'next/router' -import { useEffect } from 'react' +import { useCallback, useEffect } from 'react' /** * 侧边栏抽屉面板,可以从侧面拉出 @@ -8,6 +8,30 @@ import { useEffect } from 'react' */ const SideBarDrawer = ({ children, isOpen, onOpen, onClose, className }) => { const router = useRouter() + + // 点击按钮更改侧边抽屉状态 + // 用 useCallback 稳定引用,下方 useEffect 才能正确放进依赖 + const switchSideDrawerVisible = useCallback( + (showStatus) => { + if (showStatus) { + onOpen && onOpen() + } else { + onClose && onClose() + } + const sideBarDrawer = window.document.getElementById('sidebar-drawer') + const sideBarDrawerBackground = window.document.getElementById('sidebar-drawer-background') + + if (showStatus) { + sideBarDrawer?.classList.replace('-mr-72', 'mr-0') + sideBarDrawerBackground?.classList.replace('hidden', 'block') + } else { + sideBarDrawer?.classList.replace('mr-0', '-mr-72') + sideBarDrawerBackground?.classList.replace('block', 'hidden') + } + }, + [onOpen, onClose] + ) + useEffect(() => { const sideBarDrawerRouteListener = () => { switchSideDrawerVisible(false) @@ -16,26 +40,7 @@ const SideBarDrawer = ({ children, isOpen, onOpen, onClose, className }) => { return () => { router.events.off('routeChangeComplete', sideBarDrawerRouteListener) } - }, [router.events]) - - // 点击按钮更改侧边抽屉状态 - const switchSideDrawerVisible = (showStatus) => { - if (showStatus) { - onOpen && onOpen() - } else { - onClose && onClose() - } - const sideBarDrawer = window.document.getElementById('sidebar-drawer') - const sideBarDrawerBackground = window.document.getElementById('sidebar-drawer-background') - - if (showStatus) { - sideBarDrawer?.classList.replace('-mr-72', 'mr-0') - sideBarDrawerBackground?.classList.replace('hidden', 'block') - } else { - sideBarDrawer?.classList.replace('mr-0', '-mr-72') - sideBarDrawerBackground?.classList.replace('block', 'hidden') - } - } + }, [router.events, switchSideDrawerVisible]) return