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:
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user