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
@@ -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,