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:
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import { Link } from "next-view-transitions";
|
||||||
import { useLocale } from 'next-intl';
|
import { useLocale } from 'next-intl';
|
||||||
import { defaultLocale } from '@/i18n.config';
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { getTranslations, getLocale } from "next-intl/server";
|
import { getTranslations, getLocale } from "next-intl/server";
|
||||||
import { ThemeHeader } from "./theme-header";
|
import { ThemeHeader } from "./theme-header";
|
||||||
import { ThemeFooter } from "./theme-footer";
|
import { ThemeFooter } from "./theme-footer";
|
||||||
|
import { PageTransition } from "./page-transition";
|
||||||
import type { SiteConfig } from "./types";
|
import type { SiteConfig } from "./types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -54,7 +55,7 @@ export async function SiteChrome({
|
|||||||
navItems={navItems}
|
navItems={navItems}
|
||||||
searchPlaceholder={th.raw("searchPlaceholder") as string}
|
searchPlaceholder={th.raw("searchPlaceholder") as string}
|
||||||
/>
|
/>
|
||||||
{children}
|
<PageTransition>{children}</PageTransition>
|
||||||
<ThemeFooter brand={brand} groups={footerGroups} />
|
<ThemeFooter brand={brand} groups={footerGroups} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,4 +1,4 @@
|
|||||||
import Link from "next/link";
|
import { Link } from "next-view-transitions";
|
||||||
import { useLocale } from "next-intl";
|
import { useLocale } from "next-intl";
|
||||||
import { defaultLocale } from "@/i18n.config";
|
import { defaultLocale } from "@/i18n.config";
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import { Link } from "next-view-transitions";
|
||||||
import { useLocale } from "next-intl";
|
import { useLocale } from "next-intl";
|
||||||
import { defaultLocale } from "@/i18n.config";
|
import { defaultLocale } from "@/i18n.config";
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Menu, Search, X } from "lucide-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 { ModeToggle } from "@/components/mode-toggle";
|
||||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||||
import { UserMenu } from "@/features/navigation/components/user-menu";
|
import { UserMenu } from "@/features/navigation/components/user-menu";
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Suspense } from "react";
|
||||||
import { NextIntlClientProvider } from "next-intl";
|
import { NextIntlClientProvider } from "next-intl";
|
||||||
import { getMessages, setRequestLocale } from "next-intl/server";
|
import { getMessages, setRequestLocale } from "next-intl/server";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
@@ -8,6 +9,7 @@ import { ViewTransitions } from "next-view-transitions";
|
|||||||
import { ThemeProvider } from "@/context/theme-provider";
|
import { ThemeProvider } from "@/context/theme-provider";
|
||||||
import { Toaster } from "sonner";
|
import { Toaster } from "sonner";
|
||||||
import Analytics from "@/analytics/analytics";
|
import Analytics from "@/analytics/analytics";
|
||||||
|
import { NavigationProgress } from "@/components/navigation-progress";
|
||||||
|
|
||||||
export function generateStaticParams() {
|
export function generateStaticParams() {
|
||||||
return locales.map((locale) => ({ locale }));
|
return locales.map((locale) => ({ locale }));
|
||||||
@@ -53,6 +55,9 @@ export default async function LocaleLayout({
|
|||||||
defaultTheme={process.env.NEXT_PUBLIC_DEFAULT_THEME || "light"}
|
defaultTheme={process.env.NEXT_PUBLIC_DEFAULT_THEME || "light"}
|
||||||
>
|
>
|
||||||
<NextIntlClientProvider locale={locale} messages={messages}>
|
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<NavigationProgress />
|
||||||
|
</Suspense>
|
||||||
{children}
|
{children}
|
||||||
<Toaster position="top-right" richColors />
|
<Toaster position="top-right" richColors />
|
||||||
<Analytics />
|
<Analytics />
|
||||||
|
|||||||
@@ -390,3 +390,57 @@ html {
|
|||||||
padding-top: 0.75rem;
|
padding-top: 0.75rem;
|
||||||
padding-bottom: 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; }
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user