feat(ui): 营销站 UI/UE/UX 治理 P0→P3 收官

复评(impeccable critique)全部 Priority Issues 与 Minor Observations 落地:
- P0 数字口径:MetricsBasisNote 单一真源 + metrics-basis.test fs 防线,接入
  products/solutions/services/home/about;纯渲染端加角注,不动 DB/seed 值
- P1 转化断头路:敬请期待态 + 404 语境出口 + services 空态双出口
- P1 首页并卡:TrustSection 早鸟横幅并入 CasesSection 单张深色共创卡
- P2 CTA 治理:CONSULT_CTA_ALLOWLIST 白名单 + 逐字符扫描器,/contact CTA 全归一
- P3 polish:footer 补 服务/公司列、contact 死三元/空槽/toast、GlobalErrorTracker 幂等日志
- 文档同源:PRODUCT/CONTEXT 动效带统一 180–280ms;DESIGN.md 立 The 10px Floor;
  font-floor.test 守卫禁止再引入 <10px(保留 10/11px 有意 Bain 微标签)

Gates: type-check 0 · jest 1581/132 · lint 0e/126w · contrast 7/7 · headings 10/10
This commit is contained in:
2026-09-20 09:17:07 +08:00
parent ec56a2211f
commit 611b6b87fe
66 changed files with 1448 additions and 1313 deletions
+1 -1
View File
@@ -277,7 +277,7 @@ export function AdminLayout({ children }: { children: React.ReactNode }) {
</header>
{/* Page content */}
<main className="p-4 lg:p-6">{children}</main>
<div className="p-4 lg:p-6">{children}</div>
</div>
</div>
);
@@ -19,6 +19,9 @@ function shouldIgnoreError(message: string): boolean {
return IGNORED_ERRORS.some((pattern) => pattern.test(message));
}
// StrictMode 下 effect 双跑会让开发日志重复;用模块级标志保证只打一次。
let initLogEmitted = false;
export function GlobalErrorTracker() {
useEffect(() => {
if (typeof window === 'undefined') return;
@@ -66,7 +69,8 @@ export function GlobalErrorTracker() {
window.addEventListener('unhandledrejection', handleUnhandledRejection);
document.addEventListener('error', handleResourceError, true);
if (process.env.NODE_ENV === 'development') {
if (process.env.NODE_ENV === 'development' && !initLogEmitted) {
initLogEmitted = true;
console.log('[GA4] Global error tracker initialized');
}
@@ -29,7 +29,7 @@ jest.mock('next/script', () => {
const MockScript = (props: { id?: string; src?: string; children?: string; strategy?: string; [key: string]: unknown }) => {
const { children, src, ...rest } = props;
if (children) {
return <script data-testid="next-script" data-inline="true" {...rest} />;
return <script data-testid="next-script" data-inline="true" {...rest} dangerouslySetInnerHTML={{ __html: children }} />;
}
return <script data-testid="next-script" src={src} {...rest} />;
};
@@ -87,36 +87,87 @@ describe('GoogleAnalytics', () => {
});
});
describe('gtag configuration', () => {
it('should call gtag config on mount with pathname and searchParams', () => {
describe('single gtag initialization', () => {
it('should send exactly one config from the inline snippet, with default pageview enabled', () => {
setupGtag();
const { GoogleAnalytics } = require('./GoogleAnalytics');
const { container } = render(<GoogleAnalytics />);
const inline = container.querySelector('script[data-inline="true"]');
const snippet = inline?.textContent ?? '';
expect(snippet).toContain("gtag('config', 'G-TEST123'");
expect(snippet.match(/gtag\('config'/g)).toHaveLength(1);
// 复评 P2:snippet config 承担首屏 pageview,不得再出现 send_page_view:false + effect 双发
expect(snippet).not.toContain('send_page_view');
});
it('should NOT call config on initial mount (snippet config owns the load pageview)', () => {
setupGtag();
mockPathname.mockReturnValue('/products');
mockSearchParams.mockReturnValue(new URLSearchParams('page=1'));
const { GoogleAnalytics } = require('./GoogleAnalytics');
render(<GoogleAnalytics />);
expect(gtagMock).toHaveBeenCalledWith('config', 'G-TEST123', {
page_path: '/products?page=1',
page_title: document.title,
page_location: window.location.origin + '/products?page=1',
});
expect(gtagMock).not.toHaveBeenCalled();
});
it('should call gtag config on mount without searchParams when empty', () => {
it('should call config only when navigating to a different URL', () => {
setupGtag();
mockPathname.mockReturnValue('/about');
const { GoogleAnalytics } = require('./GoogleAnalytics');
render(<GoogleAnalytics />);
const { rerender } = render(<GoogleAnalytics />);
expect(gtagMock).not.toHaveBeenCalled();
mockPathname.mockReturnValue('/contact');
act(() => {
rerender(<GoogleAnalytics />);
});
expect(gtagMock).toHaveBeenCalledTimes(1);
expect(gtagMock).toHaveBeenCalledWith('config', 'G-TEST123', {
page_path: '/about',
page_path: '/contact',
page_title: document.title,
page_location: window.location.origin + '/about',
page_location: window.location.origin + '/contact',
});
});
it('should include searchParams in navigation page_path', () => {
setupGtag();
const { GoogleAnalytics } = require('./GoogleAnalytics');
const { rerender } = render(<GoogleAnalytics />);
mockPathname.mockReturnValue('/news');
mockSearchParams.mockReturnValue(new URLSearchParams('page=2'));
act(() => {
rerender(<GoogleAnalytics />);
});
expect(gtagMock).toHaveBeenCalledWith('config', 'G-TEST123', expect.objectContaining({
page_path: '/news?page=2',
}));
});
it('should skip duplicate config when URL is unchanged across re-renders', () => {
setupGtag();
const { GoogleAnalytics } = require('./GoogleAnalytics');
const { rerender } = render(<GoogleAnalytics />);
mockPathname.mockReturnValue('/cases');
act(() => {
rerender(<GoogleAnalytics />);
});
expect(gtagMock).toHaveBeenCalledTimes(1);
act(() => {
rerender(<GoogleAnalytics />);
});
expect(gtagMock).toHaveBeenCalledTimes(1);
});
});
describe('rendering guards', () => {
it('should not call gtag when gtag is not available on window', () => {
const { GoogleAnalytics } = require('./GoogleAnalytics');
render(<GoogleAnalytics />);
@@ -125,7 +176,7 @@ describe('GoogleAnalytics', () => {
it('should not call gtag when GA_MEASUREMENT_ID is empty', () => {
// The component checks for GA_MEASUREMENT_ID at the top level
// When it's empty, the component returns null and no scripts render
// When it is empty, the component returns null and no scripts render
setupGtag();
const { GoogleAnalytics } = require('./GoogleAnalytics');
const { container } = render(<GoogleAnalytics />);
@@ -135,29 +186,4 @@ describe('GoogleAnalytics', () => {
expect(scripts.length).toBeGreaterThan(0);
});
});
describe('Pathname changes', () => {
it('should re-call gtag config when pathname changes', () => {
setupGtag();
const { GoogleAnalytics } = require('./GoogleAnalytics');
const { rerender } = render(<GoogleAnalytics />);
// Initial call
expect(gtagMock).toHaveBeenCalledTimes(1);
// Change pathname
mockPathname.mockReturnValue('/contact');
act(() => {
rerender(<GoogleAnalytics />);
});
expect(gtagMock).toHaveBeenCalledTimes(2);
expect(gtagMock).toHaveBeenLastCalledWith('config', 'G-TEST123', {
page_path: '/contact',
page_title: document.title,
page_location: window.location.origin + '/contact',
});
});
});
});
+11 -2
View File
@@ -2,7 +2,7 @@
import Script from 'next/script';
import { usePathname, useSearchParams } from 'next/navigation';
import { useEffect, Suspense, useSyncExternalStore } from 'react';
import { useEffect, useRef, Suspense, useSyncExternalStore } from 'react';
const GA_MEASUREMENT_ID = process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID || '';
@@ -21,12 +21,22 @@ function GoogleAnalyticsContent() {
const pathname = usePathname();
const searchParams = useSearchParams();
const mounted = useIsMounted();
// null = 尚未导航过:首屏 pageview 由 snippet 内唯一一次 config 发送(GA 用真实 URL)。
// effect 只在「路由变化且 URL 与上次不同」时再发 config,消除复评指出的双 config。
const lastNavigationUrlRef = useRef<string | null>(null);
useEffect(() => {
if (!GA_MEASUREMENT_ID || !mounted || typeof window === 'undefined') {return;}
const url = pathname + (searchParams.toString() ? `?${searchParams.toString()}` : '');
if (lastNavigationUrlRef.current === null) {
lastNavigationUrlRef.current = url;
return;
}
if (lastNavigationUrlRef.current === url) {return;}
lastNavigationUrlRef.current = url;
if (window.gtag) {
window.gtag('config', GA_MEASUREMENT_ID, {
page_path: url,
@@ -60,7 +70,6 @@ function GoogleAnalyticsContent() {
});
gtag('config', '${GA_MEASUREMENT_ID}', {
send_page_view: false,
anonymize_ip: true,
allow_google_signals: true,
allow_ad_personalization_signals: false,
+29 -18
View File
@@ -13,24 +13,35 @@ export function CertificationList({ certifications }: CertificationListProps) {
return (
<div className="flex flex-wrap gap-3">
{certifications.map((cert, index) => (
<motion.a
key={cert.name}
href={cert.link || '#'}
initial={{ opacity: 0, scale: 0.95 }}
whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true, margin: '-50px' }}
transition={{ duration: 0.3, delay: index * 0.05 }}
className="inline-flex items-center gap-2 rounded-full border border-border-primary bg-bg-elevated px-4 py-2 text-sm text-text-secondary shadow-sm transition-all hover:border-border-secondary hover:shadow-md"
>
<Award className="h-4 w-4 text-brand-ink" />
<span className="font-medium">{cert.name}</span>
<span className="text-xs text-text-muted">{cert.issuer}</span>
{cert.link && cert.link !== '#' && (
<ExternalLink className="h-3 w-3 text-text-muted" />
)}
</motion.a>
))}
{certifications.map((cert, index) => {
const link = cert.link && cert.link !== '#' ? cert.link : null;
const className =
'inline-flex items-center gap-2 rounded-full border border-border-primary bg-bg-elevated px-4 py-2 text-sm text-text-secondary shadow-sm transition-all hover:border-border-secondary hover:shadow-md';
const motionProps = {
initial: { opacity: 0, scale: 0.95 },
whileInView: { opacity: 1, scale: 1 },
viewport: { once: true, margin: '-50px' },
transition: { duration: 0.3, delay: index * 0.05 },
className,
};
const inner = (
<>
<Award className="h-4 w-4 text-brand-ink" />
<span className="font-medium">{cert.name}</span>
<span className="text-xs text-text-muted">{cert.issuer}</span>
{link && <ExternalLink className="h-3 w-3 text-text-muted" />}
</>
);
return link ? (
<motion.a key={cert.name} href={link} {...motionProps}>
{inner}
</motion.a>
) : (
<motion.span key={cert.name} {...motionProps}>
{inner}
</motion.span>
);
})}
</div>
);
}
+29
View File
@@ -63,6 +63,16 @@ jest.mock('@/lib/site-config', () => ({
],
},
],
services: [
{
id: 'professional-services',
title: '专业服务',
items: [
{ id: 'consulting', title: '战略咨询', description: '数字化转型咨询规划', href: '/services/consulting' },
{ id: 'software', title: '企业软件', description: 'ERP/CRM/BI 系统实施', href: '/services/software' },
],
},
],
},
}),
SiteConfigProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
@@ -155,6 +165,25 @@ describe('Footer', () => {
expect(screen.getByText('零售业')).toBeInTheDocument();
});
it('should render services column from megaDropdown.services (P3 footer)', () => {
render(<Footer />);
expect(screen.getByTestId('card-services')).toBeInTheDocument();
expect(screen.getByText('服务', { exact: true })).toBeInTheDocument();
expect(screen.getByText('战略咨询')).toBeInTheDocument();
expect(screen.getByText('企业软件')).toBeInTheDocument();
});
it('should render company column with verified routes (P3 footer)', () => {
render(<Footer />);
const company = screen.getByTestId('card-company');
expect(company).toBeInTheDocument();
expect(screen.getByText('公司', { exact: true })).toBeInTheDocument();
expect(screen.getByText('关于我们').closest('a')).toHaveAttribute('href', '/about');
expect(screen.getByText('方法论').closest('a')).toHaveAttribute('href', '/methodology');
expect(screen.getByText('案例').closest('a')).toHaveAttribute('href', '/cases');
expect(screen.getByText('新闻动态').closest('a')).toHaveAttribute('href', '/news');
});
it('should render contact details', () => {
render(<Footer />);
expect(screen.getByText('contact@novalon.cn')).toBeInTheDocument();
+106 -60
View File
@@ -5,6 +5,14 @@ import Image from 'next/image';
import { Mail, MapPin } from 'lucide-react';
import { useSiteConfig } from '@/lib/site-config';
// 公司栏目为固定信息架构,路由均已验证存在(/about · /methodology · /cases · /news),与 NAVIGATION_V2 对齐。
const COMPANY_LINKS: ReadonlyArray<{ id: string; label: string; href: string }> = [
{ id: 'about', label: '关于我们', href: '/about' },
{ id: 'methodology', label: '方法论', href: '/methodology' },
{ id: 'cases', label: '案例', href: '/cases' },
{ id: 'news', label: '新闻动态', href: '/news' },
];
export function Footer() {
const config = useSiteConfig();
const productItems = (config.megaDropdown.products ?? [])
@@ -12,13 +20,18 @@ export function Footer() {
.filter(item => item.href !== '#');
const solutionItems = (config.megaDropdown.solutions ?? [])
.flatMap(group => group.items);
.flatMap(group => group.items)
.filter(item => item.href !== '#');
const serviceItems = (config.megaDropdown.services ?? [])
.flatMap(group => group.items)
.filter(item => item.href !== '#');
return (
<footer className="bg-dark-bg text-white relative overflow-hidden [&_a]:inline-flex [&_a]:items-center [&_a]:min-h-6" data-testid="footer" role="contentinfo">
<footer className="bg-dark-bg text-dark-text-primary relative overflow-hidden [&_a]:inline-flex [&_a]:items-center [&_a]:min-h-6" data-testid="footer" role="contentinfo">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10">
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-12 gap-10 lg:gap-12 py-16 lg:py-20">
<div data-testid="card-brand" className="col-span-2 lg:col-span-4">
<div data-testid="card-brand" className="col-span-2 md:col-span-4 lg:col-span-4">
<div className="mb-6">
<StaticLink href="/" className="inline-block group" aria-label="返回首页">
<Image
@@ -32,24 +45,70 @@ export function Footer() {
/>
</StaticLink>
</div>
<p className="text-gray-300 text-sm leading-relaxed max-w-md">
<p className="text-dark-text-secondary text-sm leading-relaxed max-w-md">
{config.slogan && (
<>
<span className="block text-gray-200 font-medium mb-2">{config.slogan}</span>
<span className="block text-dark-text-primary font-medium mb-2">{config.slogan}</span>
</>
)}
{config.description}
</p>
<div data-testid="card-contact" className="mt-8">
<div className="font-semibold text-sm mb-5 text-dark-text-primary tracking-widest uppercase">联系我们</div>
<ul className="space-y-4 mb-8 [&_li]:p-0 [&_li]:m-0 [&_li]:static [&_li::before]:hidden">
<li className="flex items-start gap-3">
<MapPin className="w-4 h-4 text-dark-text-muted mt-0.5 shrink-0" />
<span className="text-dark-text-secondary text-sm leading-relaxed">{config.address}</span>
</li>
<li className="flex items-start gap-3">
<Mail className="w-4 h-4 text-dark-text-muted mt-0.5 shrink-0" />
<a
href={`mailto:${config.email}`}
className="text-dark-text-secondary hover:text-dark-text-primary transition-colors duration-200 text-sm"
>
{config.email}
</a>
</li>
</ul>
<div className="flex gap-6">
<div>
<p className="text-xs text-dark-text-muted mb-3 tracking-wide">关注公众号</p>
<div className="inline-block p-2 border border-dark-border bg-dark-bg-secondary">
<Image
src="/images/qrcode.webp"
alt="微信公众号二维码"
width={96}
height={96}
className="w-24 h-24"
loading="lazy"
/>
</div>
</div>
<div>
<p className="text-xs text-dark-text-muted mb-3 tracking-wide">业务咨询</p>
<div className="inline-block p-2 border border-dark-border bg-dark-bg-secondary">
<Image
src="/images/wechat-business-qr.webp"
alt="业务咨询微信二维码"
width={96}
height={96}
className="w-24 h-24"
loading="lazy"
/>
</div>
</div>
</div>
</div>
</div>
<div data-testid="card-products" className="lg:col-span-2">
<div className="font-semibold text-sm mb-5 text-white tracking-widest uppercase">产品</div>
<div className="font-semibold text-sm mb-5 text-dark-text-primary tracking-widest uppercase">产品</div>
<ul className="space-y-3 [&_li]:p-0 [&_li]:m-0 [&_li]:static [&_li::before]:hidden">
{productItems.map((item) => (
<li key={item.id}>
<StaticLink
href={item.href}
className="text-gray-300 hover:text-white transition-colors duration-200 text-sm whitespace-nowrap"
className="text-dark-text-secondary hover:text-dark-text-primary transition-colors duration-200 text-sm whitespace-nowrap"
>
{item.title}
</StaticLink>
@@ -59,13 +118,13 @@ export function Footer() {
</div>
<div data-testid="card-solutions" className="lg:col-span-2">
<div className="font-semibold text-sm mb-5 text-white tracking-widest uppercase">解决方案</div>
<div className="font-semibold text-sm mb-5 text-dark-text-primary tracking-widest uppercase">解决方案</div>
<ul className="space-y-3 [&_li]:p-0 [&_li]:m-0 [&_li]:static [&_li::before]:hidden">
{solutionItems.map((item) => (
<li key={item.id}>
<StaticLink
href={item.href}
className="text-gray-300 hover:text-white transition-colors duration-200 text-sm whitespace-nowrap"
className="text-dark-text-secondary hover:text-dark-text-primary transition-colors duration-200 text-sm whitespace-nowrap"
>
{item.title}
</StaticLink>
@@ -74,64 +133,51 @@ export function Footer() {
</ul>
</div>
<div data-testid="card-contact" className="col-span-2 lg:col-span-4">
<div className="font-semibold text-sm mb-5 text-white tracking-widest uppercase">联系我们</div>
<ul className="space-y-4 mb-8 [&_li]:p-0 [&_li]:m-0 [&_li]:static [&_li::before]:hidden">
<li className="flex items-start gap-3">
<MapPin className="w-4 h-4 text-gray-400 mt-0.5 shrink-0" />
<span className="text-gray-300 text-sm leading-relaxed">{config.address}</span>
</li>
<li className="flex items-start gap-3">
<Mail className="w-4 h-4 text-gray-400 mt-0.5 shrink-0" />
<a
href={`mailto:${config.email}`}
className="text-gray-300 hover:text-white transition-colors duration-200 text-sm"
>
{config.email}
</a>
</li>
</ul>
<div className="flex gap-6">
<div>
<p className="text-xs text-gray-400 mb-3 tracking-wide">关注公众号</p>
<div className="inline-block p-2 border border-gray-800 bg-gray-900">
<Image
src="/images/qrcode.webp"
alt="微信公众号二维码"
width={96}
height={96}
className="w-24 h-24"
loading="lazy"
/>
</div>
</div>
<div>
<p className="text-xs text-gray-400 mb-3 tracking-wide">业务咨询</p>
<div className="inline-block p-2 border border-gray-800 bg-gray-900">
<Image
src="/images/wechat-business-qr.webp"
alt="业务咨询微信二维码"
width={96}
height={96}
className="w-24 h-24"
loading="lazy"
/>
</div>
</div>
{serviceItems.length > 0 && (
<div data-testid="card-services" className="lg:col-span-2">
<div className="font-semibold text-sm mb-5 text-dark-text-primary tracking-widest uppercase">服务</div>
<ul className="space-y-3 [&_li]:p-0 [&_li]:m-0 [&_li]:static [&_li::before]:hidden">
{serviceItems.map((item) => (
<li key={item.id}>
<StaticLink
href={item.href}
className="text-dark-text-secondary hover:text-dark-text-primary transition-colors duration-200 text-sm whitespace-nowrap"
>
{item.title}
</StaticLink>
</li>
))}
</ul>
</div>
)}
<div data-testid="card-company" className="lg:col-span-2">
<div className="font-semibold text-sm mb-5 text-dark-text-primary tracking-widest uppercase">公司</div>
<ul className="space-y-3 [&_li]:p-0 [&_li]:m-0 [&_li]:static [&_li::before]:hidden">
{COMPANY_LINKS.map((item) => (
<li key={item.id}>
<StaticLink
href={item.href}
className="text-dark-text-secondary hover:text-dark-text-primary transition-colors duration-200 text-sm whitespace-nowrap"
>
{item.label}
</StaticLink>
</li>
))}
</ul>
</div>
</div>
<div className="border-t border-gray-800 pt-6 pb-[calc(8rem+env(safe-area-inset-bottom,0px))] md:pb-8">
<div className="border-t border-dark-border pt-6 pb-[calc(8rem+env(safe-area-inset-bottom,0px))] md:pb-8">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-6 mb-6">
<p className="text-gray-300 text-xs">
<p className="text-dark-text-secondary text-xs">
© {new Date().getFullYear()} {config.name}. 版权所有.
</p>
<div className="flex gap-6">
<StaticLink href="/privacy" className="text-gray-300 hover:text-white text-xs transition-colors duration-200">
<StaticLink href="/privacy" className="text-dark-text-secondary hover:text-dark-text-primary text-xs transition-colors duration-200">
隐私政策
</StaticLink>
<StaticLink href="/terms" className="text-gray-300 hover:text-white text-xs transition-colors duration-200">
<StaticLink href="/terms" className="text-dark-text-secondary hover:text-dark-text-primary text-xs transition-colors duration-200">
服务条款
</StaticLink>
</div>
@@ -142,16 +188,16 @@ export function Footer() {
href="https://beian.miit.gov.cn/"
target="_blank"
rel="noopener noreferrer"
className="text-gray-300 hover:text-white transition-colors duration-200 inline-flex items-center"
className="text-dark-text-secondary hover:text-dark-text-primary transition-colors duration-200 inline-flex items-center"
>
{config.icp}
</a>
<span className="hidden sm:inline text-text-hint">|</span>
<span className="hidden sm:inline text-dark-text-muted">|</span>
<a
href="https://beian.mps.gov.cn/#/query/webSearch?code=51010602003285"
target="_blank"
rel="noreferrer"
className="text-gray-300 hover:text-white transition-colors duration-200 inline-flex items-center gap-1.5"
className="text-dark-text-secondary hover:text-dark-text-primary transition-colors duration-200 inline-flex items-center gap-1.5"
>
<Image
src="/images/beian-icon.png"
+8 -8
View File
@@ -51,10 +51,10 @@ function HeaderContent() {
const config = useSiteConfig();
const handleNavClick = useCallback((e: React.MouseEvent<HTMLAnchorElement>, item: NavigationItem) => {
e.preventDefault();
const handleNavClick = useCallback(() => {
// StaticLink 新契约:onClick 先于导航执行,不调 preventDefault 即由
// next/link 完成客户端跳转,此处只负责关闭抽屉。
setIsOpen(false);
window.location.href = item.href;
}, []);
const isActive = useCallback((item: NavigationItem) => {
@@ -138,7 +138,7 @@ function HeaderContent() {
<div key={item.id} className="relative -mx-2 px-2 py-2">
<StaticLink
href={item.href}
onClick={(e) => handleNavClick(e, item)}
onClick={handleNavClick}
className={cn(
'relative inline-flex items-center px-3 py-2 text-sm font-medium',
'transition-all duration-300 ease-out',
@@ -171,9 +171,9 @@ function HeaderContent() {
asChild
className=""
>
<StaticLink href="/contact" data-testid="consult-button" aria-label="立即咨询">
<StaticLink href="/contact" data-testid="consult-button" aria-label="预约咨询">
<MessageCircle className="w-4 h-4" />
<span className="hidden lg:inline">立即咨询</span>
<span className="hidden lg:inline">预约咨询</span>
<span className="lg:hidden" aria-hidden="true">咨询</span>
</StaticLink>
</Button>
@@ -231,7 +231,7 @@ function HeaderContent() {
>
<StaticLink
href={item.href}
onClick={(e) => handleNavClick(e, item)}
onClick={handleNavClick}
className={`
block px-4 py-4 text-base font-medium
transition-all duration-300 ease-out
@@ -254,7 +254,7 @@ function HeaderContent() {
>
<StaticLink href="/contact" onClick={() => setIsOpen(false)}>
<MessageCircle className="w-4 h-4" />
咨询专家
预约咨询
</StaticLink>
</Button>
</div>
-234
View File
@@ -1,234 +0,0 @@
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import { MobileMenu } from './mobile-menu';
jest.mock('@/hooks/use-focus-trap', () => ({
useFocusTrap: () => ({ current: null }),
}));
jest.mock('@/lib/site-config', () => ({
useSiteConfig: () => ({
mainNav: [
{ id: 'products', label: '产品', href: '/products', hasDropdown: true, dropdownKey: 'products' },
{ id: 'solutions', label: '解决方案', href: '/solutions', hasDropdown: true, dropdownKey: 'solutions' },
{ id: 'services', label: '服务', href: '/services' },
{ id: 'about', label: '关于我们', href: '/about' },
{ id: 'contact', label: '联系我们', href: '/contact' },
],
megaDropdown: {
products: [
{ id: 'erp', title: 'ERP 管理系统', description: '财务·采购·销售·库存·生产', href: '/products/erp' },
],
solutions: [
{ id: 'manufacturing', title: '制造业', description: '智能制造·MES·质量管控', href: '/solutions/manufacturing' },
],
},
}),
SiteConfigProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
jest.mock('@/lib/constants', () => ({
NAVIGATION_V2: [
{ id: 'products', label: '产品', href: '/products', hasDropdown: true, dropdownKey: 'products' },
{ id: 'solutions', label: '解决方案', href: '/solutions', hasDropdown: true, dropdownKey: 'solutions' },
{ id: 'services', label: '服务', href: '/services' },
{ id: 'about', label: '关于我们', href: '/about' },
{ id: 'contact', label: '联系我们', href: '/contact' },
],
MEGA_DROPDOWN_DATA: {
products: [
{ id: 'erp', title: 'ERP 管理系统', description: '财务·采购·销售·库存·生产', href: '/products/erp' },
],
solutions: [
{ id: 'manufacturing', title: '制造业', description: '智能制造·MES·质量管控', href: '/solutions/manufacturing' },
],
},
}));
jest.mock('next/link', () => {
const MockLink = ({ children, href, ...props }: { children: React.ReactNode; href: string; [key: string]: unknown }) => (
<a href={href} {...props}>{children}</a>
);
MockLink.displayName = 'MockLink';
return MockLink;
});
jest.mock('framer-motion', () => ({
AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}</>,
motion: {
div: ({ children, className, ...props }: { children: React.ReactNode; className?: string; [key: string]: unknown }) => (
<div className={className} {...props}>{children}</div>
),
},
}));
jest.mock('lucide-react', () => ({
ChevronDown: () => <span data-testid="chevron-down" />,
Menu: () => <span data-testid="menu-icon" />,
X: () => <span data-testid="x-icon" />,
}));
jest.mock('@/components/ui/static-link', () => ({
StaticLink: ({ children, href, ...props }: { children: React.ReactNode; href: string; [key: string]: unknown }) => (
<a href={href} {...props}>{children}</a>
),
}));
jest.mock('@/lib/utils', () => ({
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
}));
describe('MobileMenu', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('Rendering', () => {
it('should render menu button', () => {
render(<MobileMenu />);
expect(screen.getByRole('button', { name: '打开菜单' })).toBeInTheDocument();
});
it('should render menu icon when closed', () => {
render(<MobileMenu />);
const button = screen.getByRole('button');
expect(button).toBeInTheDocument();
});
it('should not render menu panel when closed', () => {
render(<MobileMenu />);
expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
});
});
describe('Opening Menu', () => {
it('should open menu when button clicked', () => {
render(<MobileMenu />);
const button = screen.getByRole('button', { name: '打开菜单' });
fireEvent.click(button);
expect(screen.getByRole('navigation')).toBeInTheDocument();
});
it('should change button label when open', () => {
render(<MobileMenu />);
const button = screen.getByRole('button', { name: '打开菜单' });
fireEvent.click(button);
expect(screen.getByRole('button', { name: '关闭菜单' })).toBeInTheDocument();
});
it('should render navigation items', () => {
render(<MobileMenu />);
const button = screen.getByRole('button', { name: '打开菜单' });
fireEvent.click(button);
expect(screen.getByText('产品')).toBeInTheDocument();
expect(screen.getByText('服务')).toBeInTheDocument();
});
});
describe('Closing Menu', () => {
it('should close menu when button clicked again', () => {
render(<MobileMenu />);
const button = screen.getByRole('button', { name: '打开菜单' });
fireEvent.click(button);
const closeButton = screen.getByRole('button', { name: '关闭菜单' });
fireEvent.click(closeButton);
expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
});
it('should close menu when overlay clicked', () => {
render(<MobileMenu />);
const button = screen.getByRole('button', { name: '打开菜单' });
fireEvent.click(button);
const overlay = document.querySelector('.fixed.inset-0');
if (overlay) {
fireEvent.click(overlay);
}
expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
});
});
describe('Keyboard Navigation', () => {
it('should open menu with button click', () => {
render(<MobileMenu />);
const button = screen.getByRole('button', { name: '打开菜单' });
fireEvent.click(button);
expect(screen.getByRole('navigation')).toBeInTheDocument();
});
it('should not double-toggle menu on Enter or Space keydown', () => {
render(<MobileMenu />);
const button = screen.getByRole('button', { name: '打开菜单' });
// Enter/Space 由原生 button 的 click 处理,keydown 不应再次 toggle
fireEvent.keyDown(button, { key: 'Enter' });
expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
fireEvent.keyDown(button, { key: ' ' });
expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
});
it('should close menu with Escape key', () => {
render(<MobileMenu />);
const button = screen.getByRole('button', { name: '打开菜单' });
fireEvent.click(button);
fireEvent.keyDown(button, { key: 'Escape' });
expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
});
});
describe('Accessibility', () => {
it('should have aria-expanded attribute', () => {
render(<MobileMenu />);
const button = screen.getByRole('button');
expect(button).toHaveAttribute('aria-expanded', 'false');
});
it('should update aria-expanded when open', () => {
render(<MobileMenu />);
const button = screen.getByRole('button', { name: '打开菜单' });
fireEvent.click(button);
expect(button).toHaveAttribute('aria-expanded', 'true');
});
it('should have aria-controls attribute', () => {
render(<MobileMenu />);
const button = screen.getByRole('button');
expect(button).toHaveAttribute('aria-controls', 'mobile-menu-panel');
});
it('should have navigation role', () => {
render(<MobileMenu />);
const button = screen.getByRole('button', { name: '打开菜单' });
fireEvent.click(button);
const nav = screen.getByRole('navigation');
expect(nav).toHaveAttribute('aria-label', '移动端导航');
});
});
describe('Styling', () => {
it('should have responsive visibility', () => {
const { container } = render(<MobileMenu />);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper).toHaveClass('lg:hidden');
});
it('should apply custom className', () => {
const { container } = render(<MobileMenu className="custom-class" />);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper).toHaveClass('custom-class');
});
});
});
-129
View File
@@ -1,129 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import { Menu, X, ChevronDown } from 'lucide-react';
import { StaticLink } from '@/components/ui/static-link';
import { useSiteConfig } from '@/lib/site-config';
import { cn } from '@/lib/utils';
import { useFocusTrap } from '@/hooks/use-focus-trap';
interface MobileMenuProps {
className?: string;
}
export function MobileMenu({ className }: MobileMenuProps) {
const config = useSiteConfig();
const [isOpen, setIsOpen] = useState(false);
const [expandedDropdown, setExpandedDropdown] = useState<string | null>(null);
const focusTrapRef = useFocusTrap<HTMLDivElement>(isOpen);
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = 'unset';
}
return () => {
document.body.style.overflow = 'unset';
};
}, [isOpen]);
const handleKeyDown = (event: React.KeyboardEvent) => {
// Escape 关闭菜单;Enter/Space 已由原生 button 的 onClick 处理,
// 此处不再拦截,避免键盘操作时重复触发 toggle。
if (event.key === 'Escape' && isOpen) {
event.preventDefault();
setIsOpen(false);
}
};
const toggleDropdown = (key: string) => {
setExpandedDropdown(expandedDropdown === key ? null : key);
};
return (
<div className={cn('lg:hidden', className)} ref={focusTrapRef}>
<button
onClick={() => setIsOpen(!isOpen)}
onKeyDown={handleKeyDown}
className="p-3 rounded-md hover:bg-[var(--color-brand-bg)] transition-colors focus:outline-none focus:ring-2 focus:ring-[var(--color-brand)] focus:ring-offset-2 min-w-[48px] min-h-[48px] flex items-center justify-center"
aria-label={isOpen ? '关闭菜单' : '打开菜单'}
aria-expanded={isOpen}
aria-controls="mobile-menu-panel"
>
{isOpen ? (
<X className="w-6 h-6 text-[var(--color-text-primary)]" />
) : (
<Menu className="w-6 h-6 text-[var(--color-text-primary)]" />
)}
</button>
{isOpen && (
<>
<div
className="fixed inset-0 bg-[var(--color-brand)]/20 backdrop-blur-sm z-40"
onClick={() => setIsOpen(false)}
aria-hidden="true"
/>
<nav
id="mobile-menu-panel"
className="fixed top-16 left-0 right-0 bg-[var(--color-bg-primary)] border-b border-[var(--color-border-primary)] z-50 shadow-lg max-h-[calc(100vh-4rem)] overflow-y-auto"
role="navigation"
aria-label="移动端导航"
>
<div className="container-wide py-4">
<ul className="space-y-1" role="list">
{config.mainNav.map((item) => (
<li key={item.id}>
{item.hasDropdown && item.dropdownKey ? (
<div>
<button
onClick={() => toggleDropdown(item.dropdownKey!)}
className="flex items-center justify-between w-full text-left px-4 py-4 text-[var(--color-text-primary)] hover:bg-[var(--color-brand-bg)] hover:text-[var(--color-brand)] rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-[var(--color-brand)] focus:ring-inset min-h-[48px]"
aria-expanded={expandedDropdown === item.dropdownKey}
>
{item.label}
<ChevronDown
className={cn(
'w-4 h-4 transition-transform duration-200',
expandedDropdown === item.dropdownKey && 'rotate-180'
)}
/>
</button>
{expandedDropdown === item.dropdownKey && (
<ul className="pl-4 space-y-1 mt-1 mb-2" role="list">
{(config.megaDropdown[item.dropdownKey] ?? []).flatMap(group => group.items).filter(sub => sub.href !== '#').map((sub) => (
<li key={sub.id}>
<StaticLink
href={sub.href}
onClick={() => setIsOpen(false)}
className="block px-4 py-3 text-sm text-[var(--color-text-muted)] hover:text-[var(--color-brand)] hover:bg-[var(--color-brand-bg)] rounded-md transition-colors"
>
<span className="font-medium text-[var(--color-text-primary)]">{sub.title}</span>
<span className="block text-xs text-[var(--color-text-muted)] mt-0.5">{sub.description}</span>
</StaticLink>
</li>
))}
</ul>
)}
</div>
) : (
<StaticLink
href={item.href}
onClick={() => setIsOpen(false)}
className="block px-4 py-4 text-[var(--color-text-primary)] hover:bg-[var(--color-brand-bg)] hover:text-[var(--color-brand)] rounded-md transition-colors min-h-[48px]"
>
{item.label}
</StaticLink>
)}
</li>
))}
</ul>
</div>
</nav>
</>
)}
</div>
);
}
@@ -41,7 +41,7 @@ describe('MobileTabBar', () => {
expect(screen.getByText('首页')).toBeInTheDocument();
expect(screen.getByText('产品')).toBeInTheDocument();
expect(screen.getByText('方案')).toBeInTheDocument();
expect(screen.getByText('关于')).toBeInTheDocument();
expect(screen.getByText('案例')).toBeInTheDocument();
expect(screen.getByText('联系')).toBeInTheDocument();
});
@@ -86,10 +86,10 @@ describe('MobileTabBar', () => {
expect(productsLink).toHaveAttribute('href', '/products');
});
it('should have correct href for about', () => {
it('should have correct href for cases', () => {
render(<MobileTabBar />);
const aboutLink = screen.getByText('关于').closest('a');
expect(aboutLink).toHaveAttribute('href', '/about');
const casesLink = screen.getByText('案例').closest('a');
expect(casesLink).toHaveAttribute('href', '/cases');
});
it('should have correct href for contact', () => {
+10 -13
View File
@@ -2,15 +2,17 @@
import { StaticLink } from '@/components/ui/static-link';
import { usePathname } from 'next/navigation';
import { Home, Lightbulb, Package, FileText, User } from 'lucide-react';
import { Home, Lightbulb, Package, FolderOpen, User } from 'lucide-react';
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils';
// 关于页让位给案例页:转化路径上「看实证」优先于「看介绍」,
// 关于/新闻等仍可通过汉堡抽屉(header)到达。
const tabs = [
{ id: 'home', label: '首页', href: '/', icon: Home },
{ id: 'products', label: '产品', href: '/products', icon: Package },
{ id: 'solutions', label: '方案', href: '/solutions', icon: Lightbulb },
{ id: 'about', label: '关于', href: '/about', icon: FileText },
{ id: 'cases', label: '案例', href: '/cases', icon: FolderOpen },
{ id: 'contact', label: '联系', href: '/contact', icon: User },
];
@@ -30,8 +32,8 @@ export function MobileTabBar() {
if (id === 'solutions') {
return pathname === '/solutions' || pathname.startsWith('/solutions/');
}
if (id === 'about') {
return pathname === '/about';
if (id === 'cases') {
return pathname === '/cases' || pathname.startsWith('/cases/');
}
return false;
};
@@ -50,13 +52,6 @@ export function MobileTabBar() {
className="flex flex-col items-center justify-center flex-1 h-full relative group min-h-12"
>
<div className="relative flex flex-col items-center justify-center py-2">
{active && (
<motion.div
layoutId="activeTabTop"
className="absolute -top-0 w-6 h-[3px] bg-brand"
transition={{ type: 'spring', stiffness: 380, damping: 30 }}
/>
)}
<Icon
className={cn(
'w-6 h-6 transition-colors duration-300',
@@ -72,10 +67,12 @@ export function MobileTabBar() {
{tab.label}
</span>
{active && (
/* critique P2(2026-09-19):双下划线为同状态冗余编码,只保留 tab 惯例的
底部指示条;spring 违反「内容入场禁回弹」,改 ease-ink 200ms。 */
<motion.div
layoutId="activeTabBottom"
layoutId="activeTab"
className="absolute -bottom-1 w-8 h-0.5 bg-brand"
transition={{ type: 'spring', stiffness: 380, damping: 30 }}
transition={{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }}
/>
)}
</div>
+5 -4
View File
@@ -4,6 +4,7 @@ import { motion, useScroll, useTransform } from 'framer-motion';
import { useRef, useState, useEffect } from 'react';
import { ScrollReveal, StaggerReveal } from '@/components/ui/scroll-reveal';
import { StaticLink } from '@/components/ui/static-link';
import { CTA_LABELS } from '@/lib/constants/cta-labels';
import { SectionHeader } from '@/components/sections/section-header';
import { ServiceCard } from '@/components/sections/service-card';
import { ScrollProgress } from '@/components/ui/scroll-progress';
@@ -177,7 +178,7 @@ function CaseDetailHero({ data }: CaseDetailHeroProps) {
>
<Button size="lg" asChild>
<StaticLink href="/contact">
咨询类似方案
{CTA_LABELS.consult}
<ArrowRight className="w-4 h-4 ml-2" />
</StaticLink>
</Button>
@@ -514,7 +515,7 @@ function CTASection() {
<div className="flex flex-col sm:flex-row justify-center gap-4">
<Button size="lg" asChild>
<StaticLink href="/contact">
免费咨询
{CTA_LABELS.consult}
<ArrowRight className="w-4 h-4 ml-2" />
</StaticLink>
</Button>
@@ -530,7 +531,7 @@ function CTASection() {
export default function CaseDetailPage({ data }: { data: CaseDetailData }) {
return (
<main className="min-h-screen">
<div className="min-h-screen">
<ScrollProgress />
<CaseDetailHero data={data} />
<ChallengeSection data={data} />
@@ -538,6 +539,6 @@ export default function CaseDetailPage({ data }: { data: CaseDetailData }) {
<ResultsSection data={data} />
<RelatedServicesSection services={data.services} />
<CTASection />
</main>
</div>
);
}
+2 -1
View File
@@ -3,6 +3,7 @@
import { useRef } from 'react';
import { motion } from 'framer-motion';
import { StaticLink } from '@/components/ui/static-link';
import { CTA_LABELS } from '@/lib/constants/cta-labels';
import { Button } from '@/components/ui/button';
import { BrandStamp } from '@/components/ui/brand-visuals';
import { ArrowRight, Sparkles, MessageCircle, Clock, ShieldCheck } from 'lucide-react';
@@ -20,7 +21,7 @@ interface CTASectionProps {
export function CTASection({
title = '一起聊聊您的数字化需求',
description = `无论您处于数字化转型的哪个阶段,我们都愿意坐下来一起想办法。首次咨询免费,无任何销售压力。`,
primaryLabel = '预约免费咨询',
primaryLabel = CTA_LABELS.consult,
primaryHref = '/contact',
secondaryLabel = '了解我们的方法',
secondaryHref = '/team',
+2 -1
View File
@@ -3,6 +3,7 @@
import { useCallback, useRef, useState } from 'react';
import { motion } from 'framer-motion';
import { StaticLink } from '@/components/ui/static-link';
import { CTA_LABELS } from '@/lib/constants/cta-labels';
import { Button } from '@/components/ui/button';
import { COMPANY_INFO } from '@/lib/constants';
@@ -93,7 +94,7 @@ export function HeroSectionV2() {
>
<Button size="lg" asChild className="min-h-[52px] px-8 text-base font-semibold shadow-lg shadow-[var(--color-brand)]/20">
<StaticLink href="/contact">
免费获取定制方案
{CTA_LABELS.consult}
<ArrowRight className="w-4 h-4 ml-2" />
</StaticLink>
</Button>
+2 -2
View File
@@ -296,7 +296,7 @@ describe('CTASection', () => {
const { CTASection } = await import('./cta-section');
render(<CTASection />);
expect(screen.getByText('一起聊聊您的数字化需求')).toBeInTheDocument();
expect(screen.getByText('预约免费咨询')).toBeInTheDocument();
expect(screen.getByText('预约咨询')).toBeInTheDocument();
});
it('should render custom title and description', async () => {
@@ -336,7 +336,7 @@ describe('HeroSectionV2', () => {
const { HeroSectionV2 } = await import('./hero-section-v2');
render(<HeroSectionV2 />);
expect(screen.getByText('企业数字化转型服务商')).toBeInTheDocument();
expect(screen.getByText('免费获取定制方案')).toBeInTheDocument();
expect(screen.getByText('预约咨询')).toBeInTheDocument();
expect(screen.getByText('探索产品')).toBeInTheDocument();
});
+4 -2
View File
@@ -97,10 +97,12 @@ export function ThemeToggle({ className }: { className?: string }) {
const mounted = resolved !== null;
const Icon = preference === 'light' ? Sun : preference === 'dark' ? Moon : Monitor;
// 显式判空而非复用 mounted 布尔值:TS 无法从 mounted 反推出 preference/resolved 非空
// 显式判空而非复用 mounted 布尔值:TS 无法从 preference/resolved 反推非空。
// 「显示」与「偏好」分开表述:跟随系统且系统恰为浅色时,旧文案会出现
// 「当前浅色,点击切换为浅色」的歧义(偏好变了但画面不变)。
const label =
preference && resolved
? `主题:${THEME_PREFERENCE_LABELS[preference]}(当前${RESOLVED_THEME_LABELS[resolved]}),点击切换为${THEME_PREFERENCE_LABELS[nextPreference(preference)]}`
? `主题偏好:${THEME_PREFERENCE_LABELS[preference]}(页面当前显示${RESOLVED_THEME_LABELS[resolved]}),点击将偏好切换为${THEME_PREFERENCE_LABELS[nextPreference(preference)]}`
: '切换主题';
return (
+47
View File
@@ -0,0 +1,47 @@
'use client';
import { CTAButton } from '@/components/ui/cta-button';
import { StaticLink } from '@/components/ui/static-link';
const SUGGESTED_LINKS = [
{ label: '解决方案', href: '/solutions' },
{ label: '方法论', href: '/methodology' },
{ label: '联系我们', href: '/contact' },
];
/**
* CMS 内容未发布时的兜底态:说明现状 + 提供导航出口,
* 避免访客(尤其是转化路径上的企业决策者)被困在无出口的空白页。
*/
export function ContentUnavailableState({ pageName }: { pageName: string }) {
return (
<div className="min-h-screen bg-bg-primary flex items-center justify-center">
<div className="max-w-xl mx-auto px-4 py-20 text-center">
<h1 className="text-2xl sm:text-3xl font-bold text-ink mb-4">内容暂未发布</h1>
<p className="text-base text-text-secondary leading-relaxed mb-10">
「{pageName}」的内容正在整理发布中。您可以先浏览其他页面,或直接与我们聊聊。
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center mb-12">
<CTAButton href="/">返回首页</CTAButton>
<CTAButton href="/products" variant="secondary">
浏览产品
</CTAButton>
</div>
<nav aria-label="推荐入口" className="flex flex-wrap justify-center gap-x-8 gap-y-3 text-sm">
{SUGGESTED_LINKS.map((link) => (
<StaticLink
key={link.href}
href={link.href}
className="text-brand-ink font-medium transition-colors hover:text-brand"
>
{link.label}
</StaticLink>
))}
</nav>
</div>
</div>
);
}
+3 -2
View File
@@ -3,6 +3,7 @@
import * as React from 'react';
import { ArrowRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import { StaticLink } from '@/components/ui/static-link';
/**
* CTAButton — 全站统一的「行动召唤」组件
@@ -65,7 +66,7 @@ export function CTAButton({
}: CTAButtonProps) {
const isPill = variant !== 'inline';
return (
<a
<StaticLink
href={href}
data-testid={dataTestId}
className={cn(
@@ -77,7 +78,7 @@ export function CTAButton({
>
<span className={cn(innerClassName)}>{children}</span>
<ArrowRight className={ARROW_STYLES[variant]} />
</a>
</StaticLink>
);
}
+14
View File
@@ -0,0 +1,14 @@
import { cn } from '@/lib/utils';
import { METRICS_BASIS_NOTE } from '@/lib/constants/metrics-basis';
/**
* 结果型数字(提升百分比 / 可用性 / 达标率等)下方的口径角注。
* 全站统一文案,见 METRICS_BASIS_NOTE。供 solutions / services / home / about / products 复用。
*/
export function MetricsBasisNote({ className }: { className?: string }) {
return (
<p className={cn('text-xs text-text-muted', className)} data-testid="metrics-basis-note">
{METRICS_BASIS_NOTE}
</p>
);
}
+79 -31
View File
@@ -1,17 +1,24 @@
// @ts-nocheck
import { describe, it, expect, jest } from '@jest/globals';
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import { StaticLink } from './static-link';
// Note: jsdom's window.location is non-configurable, so we cannot use
// jest.spyOn or Object.defineProperty to mock the entire location object.
// However, window.location.href IS writable in jsdom, but navigation
// (setting href to a different page) is not implemented — see skipped tests below.
// next/link mock:断言「站内无 hash 链接交给 next/link 客户端导航」,
// jsdom 无法真实执行 RSC 导航,mock 为透传 props 的 <a>。
jest.mock('next/link', () => {
const MockLink = ({ children, href, ...props }: { children: React.ReactNode; href: string; [key: string]: unknown }) => (
<a href={href} {...props}>
{children}
</a>
);
MockLink.displayName = 'MockLink';
return MockLink;
});
import { StaticLink } from './static-link';
describe('StaticLink', () => {
beforeEach(() => {
// Reset location to a known state before each test
window.location.href = '/';
});
@@ -26,29 +33,64 @@ describe('StaticLink', () => {
expect(link).toHaveAttribute('href', '/products/erp');
});
// ── 导航行为测试(jsdom 限制跳过) ──────────────────────────────
//
// 以下测试涉及 window.location.href 赋值导航,在 jsdom 中不可用:
// Error: Not implemented: navigation (except hash changes)
//
// 后期补全方案(任选其一):
// A. 升级至 @playwright/test 做 E2E 验证(推荐 — 可真实模拟浏览器导航)
// B. 在 jest 中 mock window.location(需升级 jsdom 或使用 jest-environment-jsdom 29+)
// C. 将 StaticLink 的导航逻辑抽取为独立函数,单独测试该函数
it.skip('should navigate to internal link on click', () => {
it('should route internal no-hash links through next/link (client nav + prefetch)', () => {
render(<StaticLink href="/about">关于</StaticLink>);
fireEvent.click(screen.getByText('关于'));
expect(window.location.href).toBe('/about');
// MockLink 渲染的 <a> 即代表走了 next/link 分支(而非 window.location 兜底)
expect(screen.getByText('关于').closest('a')).toHaveAttribute('href', '/about');
});
it.skip('should handle hash link (same page scroll)', () => {
// hash 导航中 scrollIntoView 部分可在 jsdom 中测试,但需先 mock
// Element.prototype.scrollIntoView = jest.fn()
render(<StaticLink href="/about#section">Hash Link</StaticLink>);
fireEvent.click(screen.getByText('Hash Link'));
// 期望:window.location.href 被设置为 '/about#section'
// 但 jsdom 导航未实现,此断言无法通过
it('should call custom onClick handler for internal links', () => {
const handleClick = jest.fn<() => void>();
render(
<StaticLink href="/about" onClick={handleClick as any}>
Click
</StaticLink>
);
fireEvent.click(screen.getByText('Click'));
expect(handleClick).toHaveBeenCalled();
});
it('should smooth-scroll for same-page hash links and prevent default', () => {
const target = document.createElement('div');
target.id = 'cases';
const scrollIntoView = jest.fn();
target.scrollIntoView = scrollIntoView;
document.body.appendChild(target);
let prevented = false;
const { container } = render(
<div onClick={(e) => { prevented = e.defaultPrevented; }}>
<StaticLink href="#cases">看案例</StaticLink>
</div>
);
fireEvent.click(screen.getByText('看案例'));
expect(prevented).toBe(true);
expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth' });
container.remove();
});
it('should not navigate for bare href="#" placeholder', () => {
let prevented = false;
const { container } = render(
<div onClick={(e) => { prevented = e.defaultPrevented; }}>
<StaticLink href="#">占位</StaticLink>
</div>
);
fireEvent.click(screen.getByText('占位'));
expect(prevented).toBe(true);
container.remove();
});
it('should leave cross-page hash links to browser default navigation', () => {
let prevented = true;
const { container } = render(
<div onClick={(e) => { prevented = e.defaultPrevented; }}>
<StaticLink href="/about#section">跳板块</StaticLink>
</div>
);
fireEvent.click(screen.getByText('跳板块'));
expect(prevented).toBe(false);
container.remove();
});
it('should add noopener noreferrer for external links', () => {
@@ -57,11 +99,17 @@ describe('StaticLink', () => {
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
});
it('should call custom onClick handler', () => {
it('should not intercept external link clicks (caller onClick still fires)', () => {
const handleClick = jest.fn<() => void>();
render(<StaticLink href="/about" onClick={handleClick as any}>Click</StaticLink>);
fireEvent.click(screen.getByText('Click'));
render(
<StaticLink href="https://example.com" onClick={handleClick as any}>
External
</StaticLink>
);
fireEvent.click(screen.getByText('External'));
expect(handleClick).toHaveBeenCalled();
const link = screen.getByText('External');
expect(link).toHaveAttribute('href', 'https://example.com');
});
it('should render with custom className', () => {
@@ -76,4 +124,4 @@ describe('StaticLink', () => {
expect(link).toHaveAttribute('href', 'mailto:test@test.com');
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
});
});
});
+37 -40
View File
@@ -1,20 +1,25 @@
'use client';
import NextLink from 'next/link';
import { useCallback, type AnchorHTMLAttributes, type MouseEventHandler, type ReactNode } from 'react';
/**
* StaticLink - 纯静态站点专用链接组件
* StaticLink - 全站统一站内链接
*
* 在 output: 'export' 模式下,Next.js 的客户端路由会拦截所有站内 <a> 标签的点击,
* 尝试发送 RSC 请求,导致 "Failed to fetch RSC payload" 错误。
* 历史背景:早期 output: 'export' 模式下需用 window.location.href 规避
* Next.js RSC payload 拉取失败(https://github.com/vercel/next.js/issues/85374)。
* 项目现为 output: 'standalone',该限制已不存在,站内无 hash 链接改走
* next/link 客户端跳转 + RSC 预取,避免整页重载闪烁。
*
* 本组件通过 e.preventDefault() 阻止 Next.js 拦截,然后根据情况导航:
* - 有外部 onClick:只阻止拦截,由外部 onClick 控制导航
* - 外部链接 / 新窗口:不拦截,保持默认行为
* - Hash 链接:平滑滚动
* - 站内链接:window.location.href 完整页面导航
* 跳转策略:
* - 站内无 hash:next/link 客户端导航(默认视口内预取)
* - hash 链接:同页 preventDefault + 平滑滚动;跨页 hash 走浏览器默认整页
* 导航(加载后浏览器自动滚动到锚点,RSC 渲染完成前锚点不存在,客户端跳转不可靠)
* - 外部链接(http/https/mailto/tel)与 target="_blank":原生 <a> 行为,
* 外链自动附加 rel="noopener noreferrer"
*
* @see https://github.com/vercel/next.js/issues/85374
* onClick 契约:调用方 onClick 先于导航执行;若调用方 e.preventDefault()
* 则跳过本组件导航(闭抽屉等纯副作用场景不要 preventDefault)。
*/
interface StaticLinkProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
children: ReactNode;
@@ -26,47 +31,39 @@ function isExternalLink(href: string): boolean {
}
export function StaticLink({ children, href, onClick, target, rel, ...props }: StaticLinkProps) {
const external = isExternalLink(href) || target === '_blank';
const hasHash = href.includes('#');
const useClientNav = !external && !hasHash;
const handleClick: MouseEventHandler<HTMLAnchorElement> = useCallback(
(e) => {
// 外部链接或新窗口打开:不拦截,保持默认行为
if (isExternalLink(href) || target === '_blank') {
onClick?.(e);
return;
}
onClick?.(e);
if (e.defaultPrevented) return;
// 阻止 Next.js 客户端路由拦截
e.preventDefault();
if (external) return;
// 如果有外部 onClick,由它完全控制导航行为
if (onClick) {
onClick(e);
return;
}
// Hash 链接:平滑滚动
if (href.includes('#')) {
if (hasHash) {
const [path, hash] = href.split('#');
if (path && path !== window.location.pathname) {
window.location.href = href;
} else if (hash) {
const el = document.getElementById(hash);
if (el) {
el.scrollIntoView({ behavior: 'smooth' });
}
if (!path || path === window.location.pathname) {
e.preventDefault();
if (hash) document.getElementById(hash)?.scrollIntoView({ behavior: 'smooth' });
}
return;
// 跨页 hash:不拦截,浏览器默认整页导航并在加载后滚动到锚点
//(RSC 渲染完成前锚点不存在,客户端跳转不可靠)
}
// 站内页面链接:完整页面导航
window.location.href = href;
},
[href, onClick, target]
[href, onClick, external, hasHash]
);
// 外部链接自动添加安全属性
const linkRel = isExternalLink(href)
? 'noopener noreferrer'
: rel;
if (useClientNav) {
return (
<NextLink href={href} onClick={onClick} target={target} {...props}>
{children}
</NextLink>
);
}
const linkRel = isExternalLink(href) ? 'noopener noreferrer' : rel;
return (
<a href={href} onClick={handleClick} target={target} rel={linkRel} {...props}>