diff --git a/.env.example b/.env.example
index a56f659..89c8dde 100644
--- a/.env.example
+++ b/.env.example
@@ -1 +1 @@
-NEXT_PUBLIC_GA_ID=G-XXXXXXXXXX
+NEXT_PUBLIC_GA_MEASUREMENT_ID=G-XXXXXXXXXX
diff --git a/deploy-dist.sh b/deploy-dist.sh
index 7b83f97..e030747 100755
--- a/deploy-dist.sh
+++ b/deploy-dist.sh
@@ -51,6 +51,20 @@ fi
DIST_SIZE=$(du -sh "$DIST_DIR" | cut -f1)
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 "📋 步骤2: 验证SSH连接..."
if ! ssh -o ConnectTimeout=5 "$SERVER_USER@$SERVER_IP" exit; then
@@ -79,17 +93,7 @@ echo ""
echo "📋 步骤4: 上传 dist 目录..."
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/"
-fi
+rsync -avz --delete "$DIST_DIR/" "$SERVER_USER@$SERVER_IP:$DEPLOY_ROOT/$STATIC_DIR/"
echo "✅ dist 目录已上传"
diff --git a/deploy.sh b/deploy.sh
index e1abd9e..15449a2 100755
--- a/deploy.sh
+++ b/deploy.sh
@@ -102,7 +102,7 @@ if [ ! -f .env ]; then
echo "📝 创建.env文件..."
cp .env.example .env
echo "⚠️ 请编辑.env文件,填入正确的环境变量"
- echo "⚠️ 可选配置: NEXT_PUBLIC_GA_ID"
+ echo "⚠️ 可选配置: NEXT_PUBLIC_GA_MEASUREMENT_ID"
fi
echo "🐳 启动Docker容器..."
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 319f8d0..e410ff8 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -15,6 +15,8 @@ import { ErrorBoundary } from "@/components/ui/error-boundary";
import { ScrollProgress } from "@/components/ui/scroll-progress";
import { BackToTop } from "@/components/ui/back-to-top";
+const GA_MEASUREMENT_ID = process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID || '';
+
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
@@ -127,6 +129,46 @@ export default function RootLayout({
+ {GA_MEASUREMENT_ID && (
+ <>
+
+
+
+ >
+ )}
{
setShowConsent(true);
- }, 2000);
+ }, 500);
return () => clearTimeout(timer);
}
}
@@ -51,6 +52,11 @@ export function CookieConsent() {
storePreferences(finalPrefs);
updateConsentDetailed(finalPrefs);
trackButtonClick('save_cookie_preferences', 'consent_banner');
+ if (prefs.analytics) {
+ setTimeout(() => {
+ trackPageView(document.title, window.location.pathname);
+ }, 100);
+ }
setTimeout(() => {
setShowConsent(false);
setShowSettings(false);
diff --git a/src/components/analytics/GoogleAnalytics.tsx b/src/components/analytics/GoogleAnalytics.tsx
index 4fed77b..a860851 100644
--- a/src/components/analytics/GoogleAnalytics.tsx
+++ b/src/components/analytics/GoogleAnalytics.tsx
@@ -1,8 +1,8 @@
'use client';
-import Script from 'next/script';
import { usePathname, useSearchParams } from 'next/navigation';
import { useEffect, Suspense } from 'react';
+import { hasAnalyticsConsent } from '@/lib/analytics';
const GA_MEASUREMENT_ID = process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID || '';
@@ -12,50 +12,18 @@ function GoogleAnalyticsContent() {
useEffect(() => {
if (!GA_MEASUREMENT_ID || typeof window === 'undefined') {return;}
-
+ if (!hasAnalyticsConsent()) {return;}
+
const url = pathname + (searchParams.toString() ? `?${searchParams.toString()}` : '');
-
- if (window.gtag) {
- window.gtag('config', GA_MEASUREMENT_ID, {
- page_path: url,
- page_title: document.title,
- page_location: window.location.origin + url,
- });
- }
+
+ window.gtag('event', 'page_view', {
+ page_title: document.title,
+ page_location: window.location.origin + url,
+ page_path: url,
+ });
}, [pathname, searchParams]);
- if (!GA_MEASUREMENT_ID) {return null;}
-
- return (
- <>
-
-
- >
- );
+ return null;
}
export function GoogleAnalytics() {
diff --git a/src/components/analytics/PerformanceTracker.tsx b/src/components/analytics/PerformanceTracker.tsx
index 4f56f41..e06f14a 100644
--- a/src/components/analytics/PerformanceTracker.tsx
+++ b/src/components/analytics/PerformanceTracker.tsx
@@ -6,63 +6,69 @@ import { trackPerformance } from '@/lib/analytics';
export function PerformanceTracker() {
useEffect(() => {
if (typeof window === 'undefined') {return;}
+ if (!('PerformanceObserver' in window)) {return;}
- const reportWebVitals = (): (() => void) | undefined => {
- if ('PerformanceObserver' in window) {
- const lcpObserver = new PerformanceObserver((list) => {
- const entries = list.getEntries();
- const lastEntry = entries[entries.length - 1];
- if (lastEntry) {
- trackPerformance('LCP', lastEntry.startTime);
- }
- });
+ const observers: PerformanceObserver[] = [];
- const fidObserver = new PerformanceObserver((list) => {
- const entries = list.getEntries();
- const firstEntry = entries[0];
- if (firstEntry && 'processingStart' in firstEntry) {
- const fidEntry = firstEntry as PerformanceEventTiming;
- trackPerformance('FID', fidEntry.processingStart - fidEntry.startTime);
- }
- });
-
- const clsObserver = new PerformanceObserver((list) => {
- let clsValue = 0;
- for (const entry of list.getEntries()) {
- if ('value' in entry && !(entry as LayoutShift).hadRecentInput) {
- clsValue += (entry as LayoutShift).value;
- }
- }
- if (clsValue > 0) {
- trackPerformance('CLS', clsValue * 1000);
- }
- });
-
- try {
- lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true });
- fidObserver.observe({ type: 'first-input', buffered: true });
- clsObserver.observe({ type: 'layout-shift', buffered: true });
- } catch {
- // Observer not supported
- }
-
- return () => {
- lcpObserver.disconnect();
- fidObserver.disconnect();
- clsObserver.disconnect();
- };
+ const lcpObserver = new PerformanceObserver((list) => {
+ const entries = list.getEntries();
+ const lastEntry = entries[entries.length - 1];
+ if (lastEntry) {
+ trackPerformance('LCP', lastEntry.startTime);
}
- return undefined;
- };
+ });
- const cleanup = reportWebVitals();
- return cleanup;
+ const inpObserver = new PerformanceObserver((list) => {
+ const entries = list.getEntries();
+ const lastEntry = entries[entries.length - 1];
+ if (lastEntry && 'duration' in lastEntry) {
+ trackPerformance('INP', (lastEntry as PerformanceEventTiming).duration);
+ }
+ });
+
+ const clsObserver = new PerformanceObserver((list) => {
+ let clsValue = 0;
+ for (const entry of list.getEntries()) {
+ if ('value' in entry && !(entry as LayoutShift).hadRecentInput) {
+ clsValue += (entry as LayoutShift).value;
+ }
+ }
+ if (clsValue > 0) {
+ trackPerformance('CLS', clsValue * 1000);
+ }
+ });
+
+ try {
+ lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true });
+ observers.push(lcpObserver);
+ } catch {
+ // 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 () => {
+ observers.forEach((o) => o.disconnect());
+ };
}, []);
return null;
}
interface PerformanceEventTiming extends PerformanceEntry {
+ duration: number;
processingStart: number;
startTime: number;
}
diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts
index 2709eaa..ffca557 100644
--- a/src/lib/analytics.ts
+++ b/src/lib/analytics.ts
@@ -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) => {
if (typeof window !== 'undefined' && window.gtag && GA_MEASUREMENT_ID) {
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) => {
event('generate_lead', 'engagement', 'contact_form_submission');
@@ -44,10 +60,6 @@ export const trackButtonClick = (buttonName: string, location: string) => {
event('click', 'button', `${location}_${buttonName}`);
};
-export const trackPageView = (pageTitle: string, _pagePath: string) => {
- event('page_view', 'navigation', pageTitle);
-};
-
export const trackConversion = (conversionName: string, value?: number) => {
if (typeof window !== 'undefined' && window.gtag && GA_MEASUREMENT_ID) {
window.gtag('event', 'conversion', {
@@ -161,6 +173,8 @@ export const updateConsent = (granted: boolean) => {
window.gtag('consent', 'update', {
analytics_storage: granted ? 'granted' : 'denied',
ad_storage: 'denied',
+ ad_user_data: 'denied',
+ ad_personalization: 'denied',
});
}
};
@@ -170,6 +184,8 @@ export const updateConsentDetailed = (preferences: CookiePreferences) => {
window.gtag('consent', 'update', {
analytics_storage: preferences.analytics ? 'granted' : 'denied',
ad_storage: preferences.marketing ? 'granted' : 'denied',
+ ad_user_data: preferences.marketing ? 'granted' : 'denied',
+ ad_personalization: preferences.marketing ? 'granted' : 'denied',
functionality_storage: 'granted',
personalization_storage: preferences.marketing ? 'granted' : 'denied',
security_storage: 'granted',