tracking.ts 4.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145
  1. import type { MouseEvent as ReactMouseEvent } from 'react';
  2. import { v4 as uuidv4 } from 'uuid';
  3. import { reportUserEvent, type UserEventPayload } from '@/utils/api';
  4. import { getUserId } from '@/utils/storage';
  5. // 路由 path → page_id 映射,需与 .cursorrules 中的命名约定保持一致
  6. const PATH_TO_PAGE_ID: Record<string, string> = {
  7. '/welcome': 'welcome',
  8. '/begin': 'begin',
  9. '/login-register': 'login_register',
  10. '/loading': 'loading',
  11. '/naming': 'naming',
  12. '/lumi-naming': 'lumi_naming',
  13. '/chatbot': 'chatbot',
  14. '/tutorial-1': 'tutorial_1',
  15. '/tutorial-2': 'tutorial_2',
  16. '/tutorial-3': 'tutorial_3',
  17. '/tutorial-4': 'tutorial_4',
  18. '/tutorial-5': 'tutorial_5',
  19. '/tutorial-6': 'tutorial_6',
  20. '/tutorial-7': 'tutorial_7',
  21. '/tutorial-8': 'tutorial_8',
  22. '/tutorial-9': 'tutorial_9',
  23. '/tutorial-10': 'tutorial_10',
  24. '/level5-welcome': 'level5_welcome',
  25. '/level34-welcome': 'level34_welcome',
  26. '/first-login': 'first_login',
  27. '/verification-code': 'verification_code',
  28. '/verification-input': 'verification_input',
  29. '/login-verification-code': 'login_verification_code',
  30. '/login-verification-input': 'login_verification_input',
  31. '/first-login-reward': 'first_login_reward',
  32. '/share-success': 'share_success',
  33. '/email-login': 'email_login',
  34. '/phone-login': 'phone_login',
  35. '/profile': 'profile',
  36. '/lumi-outfits': 'lumi_outfits',
  37. '/error': 'error',
  38. };
  39. const getPageIdFromPath = (path: string): string | null => {
  40. if (!path) return null;
  41. const clean = path.split('?')[0];
  42. return PATH_TO_PAGE_ID[clean] ?? null;
  43. };
  44. const getPlatform = (): UserEventPayload['platform'] => 'web';
  45. const getTimezoneOffsetMinutes = (): number => -new Date().getTimezoneOffset();
  46. const getAppVersion = (): string | undefined => import.meta.env.VITE_APP_VERSION;
  47. const SESSION_ID_KEY = 'lyrria_session_id';
  48. function getOrCreateSessionId(): string {
  49. try {
  50. let sid = sessionStorage.getItem(SESSION_ID_KEY);
  51. if (!sid) {
  52. sid = uuidv4();
  53. sessionStorage.setItem(SESSION_ID_KEY, sid);
  54. }
  55. return sid;
  56. } catch {
  57. return uuidv4();
  58. }
  59. }
  60. export const trackPageEnter = (path: string): void => {
  61. const userId = getUserId();
  62. const pageId = getPageIdFromPath(path);
  63. if (!userId || !pageId) {
  64. if (import.meta.env.DEV) {
  65. console.log('[tracking] trackPageEnter 跳过:', { path, hasUserId: !!userId, pageId });
  66. }
  67. return;
  68. }
  69. const event: UserEventPayload = {
  70. event_id: uuidv4(),
  71. user_id: userId,
  72. event_name: `page.${pageId}__enter`,
  73. event_label: `进入页面:${pageId}`,
  74. event_time: new Date().toISOString(),
  75. session_id: getOrCreateSessionId(),
  76. platform: getPlatform(),
  77. app_version: getAppVersion(),
  78. timezone_offset_minutes: getTimezoneOffsetMinutes(),
  79. properties: {
  80. page: pageId,
  81. path,
  82. },
  83. };
  84. reportUserEvent(event);
  85. };
  86. export const trackButtonClick = (
  87. pageId: string,
  88. buttonId: string,
  89. options?: {
  90. label?: string;
  91. properties?: Record<string, unknown>;
  92. },
  93. ): void => {
  94. const userId = getUserId();
  95. if (!userId) {
  96. if (import.meta.env.DEV) {
  97. console.log('[tracking] trackButtonClick 跳过: 未登录', { pageId, buttonId });
  98. }
  99. return;
  100. }
  101. const eventName = `button.${pageId}.${buttonId}__click`;
  102. const event: UserEventPayload = {
  103. event_id: uuidv4(),
  104. user_id: userId,
  105. event_name: eventName,
  106. event_label: options?.label ?? `按钮点击:${pageId}.${buttonId}`,
  107. event_time: new Date().toISOString(),
  108. session_id: getOrCreateSessionId(),
  109. platform: getPlatform(),
  110. app_version: getAppVersion(),
  111. timezone_offset_minutes: getTimezoneOffsetMinutes(),
  112. properties: {
  113. page: pageId,
  114. button_id: buttonId,
  115. ...(options?.properties ?? {}),
  116. },
  117. };
  118. reportUserEvent(event);
  119. };
  120. /** 包装点击回调:先上报 button 埋点再执行原 onClick(用于原生 button/div 等) */
  121. export function wrapTrackedClick(
  122. pageId: string,
  123. buttonId: string,
  124. onClick?: (e?: ReactMouseEvent<HTMLElement>) => void,
  125. options?: { label?: string; properties?: Record<string, unknown> },
  126. ): (e?: ReactMouseEvent<HTMLElement>) => void {
  127. return (e?: ReactMouseEvent<HTMLElement>) => {
  128. trackButtonClick(pageId, buttonId, options);
  129. onClick?.(e);
  130. };
  131. }