diff --git a/.env.ideship-test b/.env.ideship-test index 898d3eb..458f63b 100644 --- a/.env.ideship-test +++ b/.env.ideship-test @@ -10,3 +10,6 @@ VITE_API_ASSET_ORIGIN=https://api.test.ideship.com/admin VITE_OMS_ADMIN_URL=https://api.test.ideship.com/admin VITE_WECOM_KF_URL=https://work.weixin.qq.com/kfid/kfcae09c206f6c7045a + +# 测试站可选:填同一 GA4 或单独测试媒体资源;空则不加载 +# VITE_GA_MEASUREMENT_ID= diff --git a/.env.production b/.env.production index c828691..32d4a93 100644 --- a/.env.production +++ b/.env.production @@ -11,3 +11,7 @@ VITE_API_ASSET_ORIGIN=https://api.ideship.com/admin VITE_OMS_ADMIN_URL=https://api.ideship.com/admin VITE_WECOM_KF_URL=https://work.weixin.qq.com/kfid/kfcae09c206f6c7045a + +# Google Analytics 4(官网 https://ideship.com) +# 地域/设备/来源自动有;首页点击见事件 home_* +VITE_GA_MEASUREMENT_ID=G-1XWZ7LL1PW diff --git a/src/env.d.ts b/src/env.d.ts index 6e251ef..fdadd38 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -53,4 +53,8 @@ interface ImportMetaEnv { readonly VITE_API_ORIGIN?: string; /** 静态资源 origin;本地默认测试 admin */ readonly VITE_API_ASSET_ORIGIN?: string; + /** 企微客服链接 */ + readonly VITE_WECOM_KF_URL?: string; + /** Google Analytics 4 Measurement ID,如 G-XXXXXXXX;空则不加载 */ + readonly VITE_GA_MEASUREMENT_ID?: string; } diff --git a/src/main.ts b/src/main.ts index 05d3af2..d76aa58 100644 --- a/src/main.ts +++ b/src/main.ts @@ -19,6 +19,7 @@ import { import { initAuthKeepAlive } from '@/utils/auth-keepalive'; import { installWeChatH5HistoryGuard } from '@/utils/device'; import { installChunkReloadHandlers, reloadOnChunkLoadError } from '@/utils/chunkReload'; +import { initGa } from '@/utils/ga'; import { setAuthToken } from './api/rbac'; import { useRbacUserStore } from '@/store/modules/rbac-user'; import { applyRbacUserToUserStore } from '@/utils/enter-system'; @@ -138,6 +139,8 @@ async function bootstrap() { ;(window as any).__APP_MOUNTED__ = true; installWeChatH5HistoryGuard(router); initAuthKeepAlive(); + // GA4:有 VITE_GA_MEASUREMENT_ID 才加载;地域/设备等自动采集 + initGa(); // 登录页勿预热 VXE/Element:体积大且会注入全局 CSS,和首屏抢带宽 if (isHomePath(entryPath) && !isH5LikePath(entryPath) && !light) { diff --git a/src/router/guard/index.ts b/src/router/guard/index.ts index 25722b5..52b4bba 100644 --- a/src/router/guard/index.ts +++ b/src/router/guard/index.ts @@ -10,7 +10,12 @@ function setupPageGuard(router: Router) { setRouteEmitter(to); }); - router.afterEach(() => { + router.afterEach((to) => { + // SPA 页面浏览(未配置 VITE_GA_MEASUREMENT_ID 时为空操作) + const fullPath = to.fullPath || to.path || '/'; + void import('@/utils/ga').then(({ trackPageView }) => { + trackPageView(fullPath); + }); NProgress.done(); }); } diff --git a/src/utils/ga.ts b/src/utils/ga.ts new file mode 100644 index 0000000..7e77bb0 --- /dev/null +++ b/src/utils/ga.ts @@ -0,0 +1,80 @@ +/** + * Google Analytics 4(gtag) + * 通过 VITE_GA_MEASUREMENT_ID(如 G-XXXXXXXX)启用;未配置则整段空操作。 + * 地域分布、设备、来源等由 GA4 自动采集,业务点击用 trackEvent / trackSiteEvent。 + */ + +declare global { + interface Window { + dataLayer: unknown[]; + gtag?: (...args: unknown[]) => void; + } +} + +const MEASUREMENT_ID = String(import.meta.env.VITE_GA_MEASUREMENT_ID || '').trim(); + +let booted = false; + +export function isGaEnabled(): boolean { + return /^G-[A-Z0-9]+$/i.test(MEASUREMENT_ID); +} + +export function getGaMeasurementId(): string { + return isGaEnabled() ? MEASUREMENT_ID : ''; +} + +/** 注入 gtag 脚本并 config;可重复调用,仅首次生效 */ +export function initGa(): void { + if (!isGaEnabled() || typeof window === 'undefined' || booted) return; + booted = true; + + window.dataLayer = window.dataLayer || []; + window.gtag = function gtag(...args: unknown[]) { + window.dataLayer.push(args); + }; + + window.gtag('js', new Date()); + window.gtag('config', MEASUREMENT_ID, { + // SPA 由路由 afterEach 主动发 page_view,避免首屏重复 + send_page_view: false, + anonymize_ip: true, + }); + + const script = document.createElement('script'); + script.async = true; + script.src = `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(MEASUREMENT_ID)}`; + document.head.appendChild(script); +} + +export function trackPageView(path: string, title?: string): void { + if (!isGaEnabled() || !window.gtag) return; + const pagePath = path || '/'; + window.gtag('event', 'page_view', { + page_path: pagePath, + page_title: title || document.title, + page_location: typeof window !== 'undefined' ? window.location.href : undefined, + }); +} + +export function trackEvent( + name: string, + params?: Record, +): void { + if (!isGaEnabled() || !window.gtag) return; + const cleaned: Record = {}; + if (params) { + Object.entries(params).forEach(([key, value]) => { + if (value === undefined || value === null || value === '') return; + cleaned[key] = value; + }); + } + window.gtag('event', name, cleaned); +} + +/** 官网营销事件(统一带 surface,方便在探索里筛选) */ +export function trackSiteEvent( + name: string, + params?: Record, +): void { + trackEvent(name, { surface: 'official_site', ...params }); +} diff --git a/src/views/site/index.vue b/src/views/site/index.vue index de091ca..26e66ea 100644 --- a/src/views/site/index.vue +++ b/src/views/site/index.vue @@ -76,7 +76,7 @@ :active-thread-id="activeThreadId" :routes-pending="routesPending" @submit="onSubmit" - @intent="advisor.askIntent" + @intent="onIntent" @human="onNav('contact')" @wechat="openWecom" @prefill="draft = $event" @@ -150,6 +150,7 @@ import { shouldOpenWecomDirect, WECOM_KF_URL } from './lib/site-wecom'; import { provideSiteLiquidGlass } from './lib/site-liquid-glass'; import { useSiteAdvisor } from './useSiteAdvisor'; + import { trackSiteEvent } from '@/utils/ga'; import './styles/vendor.css'; import './styles/icons.css'; import './styles/main.css'; @@ -181,9 +182,17 @@ function onOfficialLoginCapture(e: MouseEvent) { const target = e.target as Element | null; const anchor = target?.closest?.('a[href]') as HTMLAnchorElement | null; - if (!anchor || anchor.classList.contains('site-nav-login')) return; + if (!anchor) return; const href = anchor.getAttribute('href') || ''; if (!href.includes('from=official-site')) return; + trackSiteEvent('home_login_click', { + link_role: anchor.classList.contains('site-nav-login') + ? 'nav_login' + : anchor.classList.contains('ai-welcome-cta-link') + ? 'hero_cta' + : 'other', + }); + if (anchor.classList.contains('site-nav-login')) return; goOfficialLoginFromHref(router, href, e); } @@ -292,14 +301,22 @@ panel.value = id; nav.value = id; syncHash(id); + trackSiteEvent('home_panel_open', { panel_id: id }); } function openRoutes(dest?: string) { + const destId = dest || 'all'; advisor.clearRoutesPending(); - routesDest.value = dest || 'all'; + routesDest.value = destId; + trackSiteEvent('home_dest_click', { dest_id: destId }); openPanel('routes'); } + function onIntent(intentId: string) { + trackSiteEvent('home_intent_click', { intent_id: intentId }); + advisor.askIntent(intentId); + } + function closePanel(id?: string) { if (id && panel.value && panel.value !== id) return; if (!panel.value) return; @@ -314,6 +331,7 @@ historyOpen.value = false; nav.value = 'home'; syncHash('home'); + trackSiteEvent('home_nav', { nav_id: 'home' }); advisor.startNewChat(); } @@ -323,6 +341,7 @@ onHome(); return; } + trackSiteEvent('home_nav', { nav_id: id }); if (NAV_PANELS.includes(id as PanelId)) { openPanel(id as Exclude); return; @@ -336,6 +355,10 @@ const fromRoutes = panel.value === 'routes'; closePanel(); draft.value = ''; + trackSiteEvent('home_ask', { + intent_id: payload.intentId, + from_routes: fromRoutes, + }); advisor.ask(payload.text, { intentId: payload.intentId, fromRoutes }); } @@ -343,11 +366,16 @@ const text = draft.value.trim(); if (!text) return; draft.value = ''; + trackSiteEvent('home_ask', { intent_id: 'free_text' }); advisor.ask(text); } function onChoice(payload: { id: string; label: string; flowId: string }) { draft.value = ''; + trackSiteEvent('home_choice', { + choice_id: payload.id, + flow_id: payload.flowId, + }); advisor.ask(payload.label, { kind: 'choice', flowId: payload.flowId, choice: payload.id }); } @@ -357,6 +385,7 @@ } function openWecom() { + trackSiteEvent('home_wecom_click', { entry: 'advisor' }); if (shouldOpenWecomDirect()) { window.location.href = WECOM_KF_URL; return; @@ -365,6 +394,7 @@ } function onTopicWechat() { + trackSiteEvent('home_wecom_click', { entry: 'topic' }); if (shouldOpenWecomDirect()) { window.location.href = WECOM_KF_URL; return; @@ -376,6 +406,7 @@ const next = lang.value === 'zh' ? 'en' : 'zh'; lang.value = next; localStorage.setItem('ide-language', next); + trackSiteEvent('home_lang_toggle', { lang: next }); } function onKeydown(e: KeyboardEvent) {