analytics.ts 3.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110
  1. /**
  2. * 用户统计 - 静默上报模块
  3. *
  4. * 功能:
  5. * - 软件启动时发送 /open(注册 + 标记在线)
  6. * - 软件关闭时发送 /close(标记离线)
  7. * - 无任何 UI,完全后台运行
  8. * - 失败静默忽略,不影响正常使用
  9. */
  10. import { getStore } from "@/lib/web-store"
  11. import { isTauri } from "@/lib/platform"
  12. // ⚠️ 部署 Worker 后,将此 URL 替换为你的实际 Worker 地址
  13. const ANALYTICS_URL = "https://qmai-analytics.qmai.workers.dev"
  14. const DEVICE_UUID_KEY = "analytics_device_uuid"
  15. const HEARTBEAT_INTERVAL_MS = 60_000
  16. /**
  17. * 获取或生成设备唯一标识
  18. * 存储在本地,重启软件后保持不变
  19. */
  20. async function getDeviceUUID(): Promise<string> {
  21. try {
  22. const store = await getStore()
  23. const existing = await store.get<string>(DEVICE_UUID_KEY)
  24. if (existing) return existing
  25. const uuid = crypto.randomUUID()
  26. await store.set(DEVICE_UUID_KEY, uuid)
  27. return uuid
  28. } catch {
  29. // 降级:每次生成新的(会多计一个用户,但不影响在线数)
  30. return crypto.randomUUID()
  31. }
  32. }
  33. /**
  34. * 发送统计请求(静默,失败不报错)
  35. */
  36. async function sendAnalytics(
  37. endpoint: string,
  38. uuid: string,
  39. ): Promise<void> {
  40. try {
  41. // Tauri 环境使用 plugin-http(避免 CORS 问题)
  42. // 非 Tauri 环境直接用 fetch
  43. if (isTauri()) {
  44. const { fetch: tauriFetch } = await import("@tauri-apps/plugin-http")
  45. await tauriFetch(`${ANALYTICS_URL}${endpoint}`, {
  46. method: "POST",
  47. headers: { "Content-Type": "application/json" },
  48. body: JSON.stringify({ uuid }),
  49. })
  50. } else {
  51. await fetch(`${ANALYTICS_URL}${endpoint}`, {
  52. method: "POST",
  53. headers: { "Content-Type": "application/json" },
  54. body: JSON.stringify({ uuid }),
  55. })
  56. }
  57. } catch {
  58. // 静默失败,不影响软件使用
  59. }
  60. }
  61. /** 缓存 UUID 避免重复读取 */
  62. let cachedUUID: string | null = null
  63. let heartbeatTimer: number | null = null
  64. /**
  65. * 初始化统计 - 在 App 启动时调用一次即可
  66. * 会自动注册 open 事件和 beforeunload close 事件
  67. */
  68. export async function initAnalytics(): Promise<void> {
  69. try {
  70. cachedUUID = await getDeviceUUID()
  71. // 上报启动(在线)
  72. await sendAnalytics("/open", cachedUUID)
  73. await sendAnalytics("/heartbeat", cachedUUID)
  74. // 注册关闭事件
  75. if (typeof window !== "undefined") {
  76. if (heartbeatTimer !== null) {
  77. window.clearInterval(heartbeatTimer)
  78. }
  79. heartbeatTimer = window.setInterval(() => {
  80. if (cachedUUID) void sendAnalytics("/heartbeat", cachedUUID)
  81. }, HEARTBEAT_INTERVAL_MS)
  82. window.addEventListener("beforeunload", () => {
  83. if (!cachedUUID) return
  84. if (heartbeatTimer !== null) {
  85. window.clearInterval(heartbeatTimer)
  86. heartbeatTimer = null
  87. }
  88. // 使用 sendBeacon 确保关闭时请求能发出
  89. const blob = new Blob(
  90. [JSON.stringify({ uuid: cachedUUID })],
  91. { type: "application/json" },
  92. )
  93. navigator.sendBeacon(`${ANALYTICS_URL}/close`, blob)
  94. })
  95. }
  96. } catch {
  97. // 整个统计模块失败也不影响软件运行
  98. }
  99. }