fix(analytics): 系统性修复 Google Analytics 数据采集问题
- 修复城市 (not set): 移除 allow_google_signals: false,启用 Google 信号补充地理数据
- 修复 Consent Mode v2: 补充 ad_user_data / ad_personalization 参数
- 修复 wait_for_update 与横幅延迟不匹配: 500ms → 3000ms
- 修复 static export 兼容性: GA 初始化脚本从 client component 移至 layout.tsx head 原生 script 标签
- 修复 pageview 追踪: GA3 风格 gtag('config') → GA4 风格 gtag('event', 'page_view')
- 修复 CookieConsent: 横幅延迟 2000ms → 500ms,同意后补发 pageview
- 修复 PerformanceTracker: FID → INP (Core Web Vitals 2024 更新)
- 修复环境变量命名: NEXT_PUBLIC_GA_ID → NEXT_PUBLIC_GA_MEASUREMENT_ID
- 清理 deploy-dist.sh 冗余 server/app 分支逻辑
- 新增部署产物 GA 脚本嵌入验证
This commit is contained in:
+1
-1
@@ -1 +1 @@
|
|||||||
NEXT_PUBLIC_GA_ID=G-XXXXXXXXXX
|
NEXT_PUBLIC_GA_MEASUREMENT_ID=G-XXXXXXXXXX
|
||||||
|
|||||||
+14
-10
@@ -51,6 +51,20 @@ fi
|
|||||||
DIST_SIZE=$(du -sh "$DIST_DIR" | cut -f1)
|
DIST_SIZE=$(du -sh "$DIST_DIR" | cut -f1)
|
||||||
echo "✅ dist 目录大小: $DIST_SIZE"
|
echo "✅ dist 目录大小: $DIST_SIZE"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "📋 步骤1.1: 验证构建产物..."
|
||||||
|
if [ -f "$DIST_DIR/index.html" ]; then
|
||||||
|
if grep -q "googletagmanager.com" "$DIST_DIR/index.html"; then
|
||||||
|
GA_ID=$(grep -oP 'id=G-[A-Z0-9]+' "$DIST_DIR/index.html" | head -1 | sed 's/id=//')
|
||||||
|
echo "✅ GA 脚本已嵌入: $GA_ID"
|
||||||
|
else
|
||||||
|
echo "⚠️ 未检测到 GA 脚本,请检查 .env.production 中的 NEXT_PUBLIC_GA_MEASUREMENT_ID"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "❌ index.html 不存在,构建可能失败"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "📋 步骤2: 验证SSH连接..."
|
echo "📋 步骤2: 验证SSH连接..."
|
||||||
if ! ssh -o ConnectTimeout=5 "$SERVER_USER@$SERVER_IP" exit; then
|
if ! ssh -o ConnectTimeout=5 "$SERVER_USER@$SERVER_IP" exit; then
|
||||||
@@ -79,17 +93,7 @@ echo ""
|
|||||||
echo "📋 步骤4: 上传 dist 目录..."
|
echo "📋 步骤4: 上传 dist 目录..."
|
||||||
ssh "$SERVER_USER@$SERVER_IP" "mkdir -p '$DEPLOY_ROOT/$STATIC_DIR'"
|
ssh "$SERVER_USER@$SERVER_IP" "mkdir -p '$DEPLOY_ROOT/$STATIC_DIR'"
|
||||||
|
|
||||||
# 检查dist目录结构并正确处理Next.js静态文件
|
|
||||||
if [ -d "$DIST_DIR/server/app" ]; then
|
|
||||||
echo "🔧 检测到Next.js静态导出结构,正在处理HTML文件..."
|
|
||||||
# 复制HTML文件到根目录
|
|
||||||
rsync -avz --delete "$DIST_DIR/server/app/" "$SERVER_USER@$SERVER_IP:$DEPLOY_ROOT/$STATIC_DIR/"
|
|
||||||
# 复制其他静态资源
|
|
||||||
rsync -avz --delete --exclude='server/app' "$DIST_DIR/" "$SERVER_USER@$SERVER_IP:$DEPLOY_ROOT/$STATIC_DIR/"
|
|
||||||
else
|
|
||||||
# 标准静态文件结构
|
|
||||||
rsync -avz --delete "$DIST_DIR/" "$SERVER_USER@$SERVER_IP:$DEPLOY_ROOT/$STATIC_DIR/"
|
rsync -avz --delete "$DIST_DIR/" "$SERVER_USER@$SERVER_IP:$DEPLOY_ROOT/$STATIC_DIR/"
|
||||||
fi
|
|
||||||
|
|
||||||
echo "✅ dist 目录已上传"
|
echo "✅ dist 目录已上传"
|
||||||
|
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ if [ ! -f .env ]; then
|
|||||||
echo "📝 创建.env文件..."
|
echo "📝 创建.env文件..."
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
echo "⚠️ 请编辑.env文件,填入正确的环境变量"
|
echo "⚠️ 请编辑.env文件,填入正确的环境变量"
|
||||||
echo "⚠️ 可选配置: NEXT_PUBLIC_GA_ID"
|
echo "⚠️ 可选配置: NEXT_PUBLIC_GA_MEASUREMENT_ID"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "🐳 启动Docker容器..."
|
echo "🐳 启动Docker容器..."
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import { ErrorBoundary } from "@/components/ui/error-boundary";
|
|||||||
import { ScrollProgress } from "@/components/ui/scroll-progress";
|
import { ScrollProgress } from "@/components/ui/scroll-progress";
|
||||||
import { BackToTop } from "@/components/ui/back-to-top";
|
import { BackToTop } from "@/components/ui/back-to-top";
|
||||||
|
|
||||||
|
const GA_MEASUREMENT_ID = process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID || '';
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
@@ -127,6 +129,46 @@ export default function RootLayout({
|
|||||||
<link rel="apple-touch-icon" href="/favicon.svg" />
|
<link rel="apple-touch-icon" href="/favicon.svg" />
|
||||||
<OrganizationSchema />
|
<OrganizationSchema />
|
||||||
<WebsiteSchema />
|
<WebsiteSchema />
|
||||||
|
{GA_MEASUREMENT_ID && (
|
||||||
|
<>
|
||||||
|
<script
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: `
|
||||||
|
window.dataLayer = window.dataLayer || [];
|
||||||
|
function gtag(){dataLayer.push(arguments);}
|
||||||
|
|
||||||
|
gtag('consent', 'default', {
|
||||||
|
'analytics_storage': 'denied',
|
||||||
|
'ad_storage': 'denied',
|
||||||
|
'ad_user_data': 'denied',
|
||||||
|
'ad_personalization': 'denied',
|
||||||
|
'functionality_storage': 'granted',
|
||||||
|
'security_storage': 'granted',
|
||||||
|
'wait_for_update': 3000
|
||||||
|
});
|
||||||
|
|
||||||
|
gtag('js', new Date());
|
||||||
|
`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<script
|
||||||
|
async
|
||||||
|
src={`https://www.googletagmanager.com/gtag/js?id=${GA_MEASUREMENT_ID}`}
|
||||||
|
/>
|
||||||
|
<script
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: `
|
||||||
|
gtag('config', '${GA_MEASUREMENT_ID}', {
|
||||||
|
send_page_view: false,
|
||||||
|
anonymize_ip: true,
|
||||||
|
cookie_domain: 'auto',
|
||||||
|
cookie_flags: 'SameSite=None;Secure'
|
||||||
|
});
|
||||||
|
`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</head>
|
</head>
|
||||||
<body
|
<body
|
||||||
className={`${geistSans.variable} ${geistMono.variable} ${notoSansSC.variable} ${maShanZheng.variable} ${aoyagiReisho.variable} font-sans antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} ${notoSansSC.variable} ${maShanZheng.variable} ${aoyagiReisho.variable} font-sans antialiased`}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from 'react';
|
|||||||
import {
|
import {
|
||||||
updateConsentDetailed,
|
updateConsentDetailed,
|
||||||
trackButtonClick,
|
trackButtonClick,
|
||||||
|
trackPageView,
|
||||||
CookiePreferences,
|
CookiePreferences,
|
||||||
getStoredPreferences,
|
getStoredPreferences,
|
||||||
storePreferences,
|
storePreferences,
|
||||||
@@ -38,7 +39,7 @@ export function CookieConsent() {
|
|||||||
} else {
|
} else {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
setShowConsent(true);
|
setShowConsent(true);
|
||||||
}, 2000);
|
}, 500);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -51,6 +52,11 @@ export function CookieConsent() {
|
|||||||
storePreferences(finalPrefs);
|
storePreferences(finalPrefs);
|
||||||
updateConsentDetailed(finalPrefs);
|
updateConsentDetailed(finalPrefs);
|
||||||
trackButtonClick('save_cookie_preferences', 'consent_banner');
|
trackButtonClick('save_cookie_preferences', 'consent_banner');
|
||||||
|
if (prefs.analytics) {
|
||||||
|
setTimeout(() => {
|
||||||
|
trackPageView(document.title, window.location.pathname);
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setShowConsent(false);
|
setShowConsent(false);
|
||||||
setShowSettings(false);
|
setShowSettings(false);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Script from 'next/script';
|
|
||||||
import { usePathname, useSearchParams } from 'next/navigation';
|
import { usePathname, useSearchParams } from 'next/navigation';
|
||||||
import { useEffect, Suspense } from 'react';
|
import { useEffect, Suspense } from 'react';
|
||||||
|
import { hasAnalyticsConsent } from '@/lib/analytics';
|
||||||
|
|
||||||
const GA_MEASUREMENT_ID = process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID || '';
|
const GA_MEASUREMENT_ID = process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID || '';
|
||||||
|
|
||||||
@@ -12,50 +12,18 @@ function GoogleAnalyticsContent() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!GA_MEASUREMENT_ID || typeof window === 'undefined') {return;}
|
if (!GA_MEASUREMENT_ID || typeof window === 'undefined') {return;}
|
||||||
|
if (!hasAnalyticsConsent()) {return;}
|
||||||
|
|
||||||
const url = pathname + (searchParams.toString() ? `?${searchParams.toString()}` : '');
|
const url = pathname + (searchParams.toString() ? `?${searchParams.toString()}` : '');
|
||||||
|
|
||||||
if (window.gtag) {
|
window.gtag('event', 'page_view', {
|
||||||
window.gtag('config', GA_MEASUREMENT_ID, {
|
|
||||||
page_path: url,
|
|
||||||
page_title: document.title,
|
page_title: document.title,
|
||||||
page_location: window.location.origin + url,
|
page_location: window.location.origin + url,
|
||||||
|
page_path: url,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
}, [pathname, searchParams]);
|
}, [pathname, searchParams]);
|
||||||
|
|
||||||
if (!GA_MEASUREMENT_ID) {return null;}
|
return null;
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Script
|
|
||||||
src={`https://www.googletagmanager.com/gtag/js?id=${GA_MEASUREMENT_ID}`}
|
|
||||||
strategy="afterInteractive"
|
|
||||||
/>
|
|
||||||
<Script id="google-analytics" strategy="afterInteractive">
|
|
||||||
{`
|
|
||||||
window.dataLayer = window.dataLayer || [];
|
|
||||||
function gtag(){dataLayer.push(arguments);}
|
|
||||||
gtag('js', new Date());
|
|
||||||
|
|
||||||
// 默认禁用存储,等待用户同意
|
|
||||||
gtag('consent', 'default', {
|
|
||||||
'analytics_storage': 'denied',
|
|
||||||
'ad_storage': 'denied',
|
|
||||||
'wait_for_update': 500
|
|
||||||
});
|
|
||||||
|
|
||||||
gtag('config', '${GA_MEASUREMENT_ID}', {
|
|
||||||
send_page_view: false,
|
|
||||||
anonymize_ip: true,
|
|
||||||
allow_google_signals: false,
|
|
||||||
allow_ad_personalization_signals: false,
|
|
||||||
cookie_flags: 'SameSite=None;Secure'
|
|
||||||
});
|
|
||||||
`}
|
|
||||||
</Script>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GoogleAnalytics() {
|
export function GoogleAnalytics() {
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ import { trackPerformance } from '@/lib/analytics';
|
|||||||
export function PerformanceTracker() {
|
export function PerformanceTracker() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') {return;}
|
if (typeof window === 'undefined') {return;}
|
||||||
|
if (!('PerformanceObserver' in window)) {return;}
|
||||||
|
|
||||||
|
const observers: PerformanceObserver[] = [];
|
||||||
|
|
||||||
const reportWebVitals = (): (() => void) | undefined => {
|
|
||||||
if ('PerformanceObserver' in window) {
|
|
||||||
const lcpObserver = new PerformanceObserver((list) => {
|
const lcpObserver = new PerformanceObserver((list) => {
|
||||||
const entries = list.getEntries();
|
const entries = list.getEntries();
|
||||||
const lastEntry = entries[entries.length - 1];
|
const lastEntry = entries[entries.length - 1];
|
||||||
@@ -17,12 +18,11 @@ export function PerformanceTracker() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const fidObserver = new PerformanceObserver((list) => {
|
const inpObserver = new PerformanceObserver((list) => {
|
||||||
const entries = list.getEntries();
|
const entries = list.getEntries();
|
||||||
const firstEntry = entries[0];
|
const lastEntry = entries[entries.length - 1];
|
||||||
if (firstEntry && 'processingStart' in firstEntry) {
|
if (lastEntry && 'duration' in lastEntry) {
|
||||||
const fidEntry = firstEntry as PerformanceEventTiming;
|
trackPerformance('INP', (lastEntry as PerformanceEventTiming).duration);
|
||||||
trackPerformance('FID', fidEntry.processingStart - fidEntry.startTime);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -40,29 +40,35 @@ export function PerformanceTracker() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true });
|
lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true });
|
||||||
fidObserver.observe({ type: 'first-input', buffered: true });
|
observers.push(lcpObserver);
|
||||||
clsObserver.observe({ type: 'layout-shift', buffered: true });
|
|
||||||
} catch {
|
} catch {
|
||||||
// Observer not supported
|
// LCP observer not supported
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
inpObserver.observe({ type: 'event', buffered: true });
|
||||||
|
observers.push(inpObserver);
|
||||||
|
} catch {
|
||||||
|
// INP observer not supported
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
clsObserver.observe({ type: 'layout-shift', buffered: true });
|
||||||
|
observers.push(clsObserver);
|
||||||
|
} catch {
|
||||||
|
// CLS observer not supported
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
lcpObserver.disconnect();
|
observers.forEach((o) => o.disconnect());
|
||||||
fidObserver.disconnect();
|
|
||||||
clsObserver.disconnect();
|
|
||||||
};
|
};
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const cleanup = reportWebVitals();
|
|
||||||
return cleanup;
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PerformanceEventTiming extends PerformanceEntry {
|
interface PerformanceEventTiming extends PerformanceEntry {
|
||||||
|
duration: number;
|
||||||
processingStart: number;
|
processingStart: number;
|
||||||
startTime: number;
|
startTime: number;
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-12
@@ -10,14 +10,6 @@ declare global {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const pageview = (url: string) => {
|
|
||||||
if (typeof window !== 'undefined' && window.gtag && GA_MEASUREMENT_ID) {
|
|
||||||
window.gtag('config', GA_MEASUREMENT_ID, {
|
|
||||||
page_path: url,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const event = (action: string, category: string, label?: string, value?: number) => {
|
export const event = (action: string, category: string, label?: string, value?: number) => {
|
||||||
if (typeof window !== 'undefined' && window.gtag && GA_MEASUREMENT_ID) {
|
if (typeof window !== 'undefined' && window.gtag && GA_MEASUREMENT_ID) {
|
||||||
window.gtag('event', action, {
|
window.gtag('event', action, {
|
||||||
@@ -28,6 +20,30 @@ export const event = (action: string, category: string, label?: string, value?:
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const hasAnalyticsConsent = (): boolean => {
|
||||||
|
if (typeof window === 'undefined') {return false;}
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem('cookie_preferences');
|
||||||
|
if (stored) {
|
||||||
|
const prefs = JSON.parse(stored) as CookiePreferences;
|
||||||
|
return prefs.analytics === true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const trackPageView = (pageTitle: string, pagePath: string) => {
|
||||||
|
if (typeof window !== 'undefined' && window.gtag && GA_MEASUREMENT_ID) {
|
||||||
|
window.gtag('event', 'page_view', {
|
||||||
|
page_title: pageTitle,
|
||||||
|
page_location: window.location.origin + pagePath,
|
||||||
|
page_path: pagePath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const trackContactForm = (formData: Record<string, string>) => {
|
export const trackContactForm = (formData: Record<string, string>) => {
|
||||||
event('generate_lead', 'engagement', 'contact_form_submission');
|
event('generate_lead', 'engagement', 'contact_form_submission');
|
||||||
|
|
||||||
@@ -44,10 +60,6 @@ export const trackButtonClick = (buttonName: string, location: string) => {
|
|||||||
event('click', 'button', `${location}_${buttonName}`);
|
event('click', 'button', `${location}_${buttonName}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const trackPageView = (pageTitle: string, _pagePath: string) => {
|
|
||||||
event('page_view', 'navigation', pageTitle);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const trackConversion = (conversionName: string, value?: number) => {
|
export const trackConversion = (conversionName: string, value?: number) => {
|
||||||
if (typeof window !== 'undefined' && window.gtag && GA_MEASUREMENT_ID) {
|
if (typeof window !== 'undefined' && window.gtag && GA_MEASUREMENT_ID) {
|
||||||
window.gtag('event', 'conversion', {
|
window.gtag('event', 'conversion', {
|
||||||
@@ -161,6 +173,8 @@ export const updateConsent = (granted: boolean) => {
|
|||||||
window.gtag('consent', 'update', {
|
window.gtag('consent', 'update', {
|
||||||
analytics_storage: granted ? 'granted' : 'denied',
|
analytics_storage: granted ? 'granted' : 'denied',
|
||||||
ad_storage: 'denied',
|
ad_storage: 'denied',
|
||||||
|
ad_user_data: 'denied',
|
||||||
|
ad_personalization: 'denied',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -170,6 +184,8 @@ export const updateConsentDetailed = (preferences: CookiePreferences) => {
|
|||||||
window.gtag('consent', 'update', {
|
window.gtag('consent', 'update', {
|
||||||
analytics_storage: preferences.analytics ? 'granted' : 'denied',
|
analytics_storage: preferences.analytics ? 'granted' : 'denied',
|
||||||
ad_storage: preferences.marketing ? 'granted' : 'denied',
|
ad_storage: preferences.marketing ? 'granted' : 'denied',
|
||||||
|
ad_user_data: preferences.marketing ? 'granted' : 'denied',
|
||||||
|
ad_personalization: preferences.marketing ? 'granted' : 'denied',
|
||||||
functionality_storage: 'granted',
|
functionality_storage: 'granted',
|
||||||
personalization_storage: preferences.marketing ? 'granted' : 'denied',
|
personalization_storage: preferences.marketing ? 'granted' : 'denied',
|
||||||
security_storage: 'granted',
|
security_storage: 'granted',
|
||||||
|
|||||||
Reference in New Issue
Block a user