feat: 导航体验——顶部进度条 + 页面切换过渡

- NavigationProgress:顶部 3px 主题色进度条(拦截 history API 触发,
  pathname 变化后冲刺 100% 并淡出;跟随深浅主题)
- PageTransition:页面内容容器以 pathname 为 key 重挂载,播放
  page-enter 进入动画(淡入 + 轻微上移 0.3s),消除切换生硬感
- 站内链接(LocaleLink/Header/Footer/Section)切换到 next-view-transitions
  的 Link,支持 VT 的浏览器叠加原生整页淡入淡出(不支持时自动降级)
- prefers-reduced-motion 用户禁用全部动效
- 回归:进度条出现/隐藏、page-enter 动画、VT CSS 注入、zh 站生效、
  多页导航 0 pageerror
This commit is contained in:
AI Directory Platform
2026-09-10 08:27:05 +00:00
parent 55f7ea5a04
commit 935062393c
9 changed files with 173 additions and 5 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
"use client";
import Link from "next/link";
import { Link } from "next-view-transitions";
import { useLocale } from 'next-intl';
import { defaultLocale } from '@/i18n.config';
@@ -0,0 +1,88 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { usePathname, useSearchParams } from "next/navigation";
/**
* # 组件 NavigationProgress
* 说明:顶部导航进度条。拦截 history API(pushState/replaceState/popstate)
* 触发进度条出现,pathname 变化后完成并淡出。
* 纯客户端实现,零依赖;颜色走主题 token(深浅模式自动适配)。
*/
export function NavigationProgress() {
const pathname = usePathname();
const searchParams = useSearchParams();
const [visible, setVisible] = useState(false);
const [progress, setProgress] = useState(0);
const [fading, setFading] = useState(false);
const timers = useRef<{ slow?: ReturnType<typeof setTimeout>; done?: ReturnType<typeof setTimeout> }>({});
// 拦截导航开始
useEffect(() => {
const start = () => {
setVisible(true);
setFading(false);
setProgress(12);
// 缓慢爬升到 70%,营造"进行中"
clearTimeout(timers.current.slow);
timers.current.slow = setTimeout(() => setProgress(70), 180);
};
const origPush = window.history.pushState.bind(window.history);
const origReplace = window.history.replaceState.bind(window.history);
window.history.pushState = function (...args: Parameters<typeof origPush>) {
start();
return origPush(...args);
};
window.history.replaceState = function (...args: Parameters<typeof origReplace>) {
start();
return origReplace(...args);
};
window.addEventListener("popstate", start);
return () => {
window.history.pushState = origPush;
window.history.replaceState = origReplace;
window.removeEventListener("popstate", start);
clearTimeout(timers.current.slow);
clearTimeout(timers.current.done);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 导航完成(pathname/search 变化)→ 冲刺到 100% → 淡出
useEffect(() => {
setVisible((visibleNow) => {
if (!visibleNow) return visibleNow;
setFading(true);
setProgress(100);
clearTimeout(timers.current.done);
timers.current.done = setTimeout(() => {
setVisible(false);
setFading(false);
setProgress(0);
}, 320);
return visibleNow;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pathname, searchParams]);
if (!visible) return null;
return (
<div
role="progressbar"
aria-label="Page loading"
className="fixed left-0 top-0 z-[100] h-[3px] w-full overflow-hidden bg-transparent"
>
<div
className="nav-progress-fill h-full"
style={{
width: `${progress}%`,
opacity: fading ? 0 : 1,
}}
/>
</div>
);
}
+2 -1
View File
@@ -1,6 +1,7 @@
import { getTranslations, getLocale } from "next-intl/server";
import { ThemeHeader } from "./theme-header";
import { ThemeFooter } from "./theme-footer";
import { PageTransition } from "./page-transition";
import type { SiteConfig } from "./types";
/**
@@ -54,7 +55,7 @@ export async function SiteChrome({
navItems={navItems}
searchPlaceholder={th.raw("searchPlaceholder") as string}
/>
{children}
<PageTransition>{children}</PageTransition>
<ThemeFooter brand={brand} groups={footerGroups} />
</div>
);
+20
View File
@@ -0,0 +1,20 @@
"use client";
import { usePathname } from "next/navigation";
/**
* # 组件 PageTransition
* 说明:页面切换过渡容器。以 pathname 为 key —— 路由变化时内容容器
* 重新挂载并播放进入动画(淡入 + 轻微上移),消除"页面跳变"的生硬感。
* 纯 CSS 动画(page-enter),全浏览器支持;支持 View Transitions 的
* 浏览器会额外叠加原生过渡(见 globals.css 的 ::view-transition 规则)。
*/
export function PageTransition({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
return (
<div key={pathname} className="page-enter">
{children}
</div>
);
}
+1 -1
View File
@@ -1,4 +1,4 @@
import Link from "next/link";
import { Link } from "next-view-transitions";
import { useLocale } from "next-intl";
import { defaultLocale } from "@/i18n.config";
+1 -1
View File
@@ -1,6 +1,6 @@
"use client";
import Link from "next/link";
import { Link } from "next-view-transitions";
import { useLocale } from "next-intl";
import { defaultLocale } from "@/i18n.config";
+1 -1
View File
@@ -2,7 +2,7 @@
import { useState } from "react";
import { Menu, Search, X } from "lucide-react";
import Link from "next/link";
import { Link } from "next-view-transitions";
import { ModeToggle } from "@/components/mode-toggle";
import { LanguageSwitcher } from "@/components/language-switcher";
import { UserMenu } from "@/features/navigation/components/user-menu";
@@ -1,3 +1,4 @@
import { Suspense } from "react";
import { NextIntlClientProvider } from "next-intl";
import { getMessages, setRequestLocale } from "next-intl/server";
import { notFound } from "next/navigation";
@@ -8,6 +9,7 @@ import { ViewTransitions } from "next-view-transitions";
import { ThemeProvider } from "@/context/theme-provider";
import { Toaster } from "sonner";
import Analytics from "@/analytics/analytics";
import { NavigationProgress } from "@/components/navigation-progress";
export function generateStaticParams() {
return locales.map((locale) => ({ locale }));
@@ -53,6 +55,9 @@ export default async function LocaleLayout({
defaultTheme={process.env.NEXT_PUBLIC_DEFAULT_THEME || "light"}
>
<NextIntlClientProvider locale={locale} messages={messages}>
<Suspense fallback={null}>
<NavigationProgress />
</Suspense>
{children}
<Toaster position="top-right" richColors />
<Analytics />
+54
View File
@@ -390,3 +390,57 @@ html {
padding-top: 0.75rem;
padding-bottom: 0.75rem;
}
/* ============================================================
导航体验:顶部进度条 + 页面切换过渡 + View Transitions
============================================================ */
/* 顶部进度条:主题色渐变 + 微光晕 */
.nav-progress-fill {
background: linear-gradient(
90deg,
hsl(var(--primary) / 0.4),
hsl(var(--primary)) 60%,
hsl(var(--primary) / 0.9)
);
box-shadow: 0 0 10px hsl(var(--primary) / 0.55);
transition: width 0.22s ease-out, opacity 0.28s ease-in;
}
/* 页面进入动画:淡入 + 轻微上移(每次路由变化播放) */
@keyframes page-enter {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.page-enter {
animation: page-enter 0.3s ease-out both;
}
/* 用户偏好减少动效时禁用 */
@media (prefers-reduced-motion: reduce) {
.page-enter,
.animate-drawer-in {
animation: none;
}
}
/* View Transitions:整页淡出/淡入(支持 VT 的浏览器叠加使用) */
::view-transition-old(root) {
animation: vt-fade-out 0.18s ease-out both;
}
::view-transition-new(root) {
animation: vt-fade-in 0.24s ease-out both;
}
@keyframes vt-fade-out {
from { opacity: 1; }
to { opacity: 0; }
}
@keyframes vt-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}