bohui-vue 1.0.2 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -54,11 +54,6 @@ function parseFlags(args) {
54
54
  : flags.has("--unocss")
55
55
  ? true
56
56
  : null,
57
- tracker: flags.has("--no-tracker")
58
- ? false
59
- : flags.has("--tracker")
60
- ? true
61
- : null,
62
57
  test:
63
58
  flags.has("--no-test") || flags.has("--no-tests")
64
59
  ? false
@@ -193,7 +188,7 @@ function removeScripts(obj, scriptsToRemove) {
193
188
  }
194
189
 
195
190
  function applyFeatureSelection(targetDir, features) {
196
- const { echarts, unocss, tracker, test } = features;
191
+ const { echarts, unocss, test } = features;
197
192
 
198
193
  const packageJsonPath = path.join(targetDir, "package.json");
199
194
  const pkg = readJsonIfExists(packageJsonPath);
@@ -234,10 +229,6 @@ function applyFeatureSelection(targetDir, features) {
234
229
  }
235
230
  }
236
231
 
237
- if (!tracker) {
238
- rmIfExists(path.join(targetDir, "src", "utils", "tracker.ts"));
239
- }
240
-
241
232
  if (!test) {
242
233
  rmIfExists(path.join(targetDir, "tests"));
243
234
  rmIfExists(
@@ -256,7 +247,7 @@ function applyFeatureSelection(targetDir, features) {
256
247
  /^\s*import\s+\{\s*defineConfig\s*\}\s+from\s+"vitest\/config";\s*\r?\n/m,
257
248
  'import { defineConfig } from "vite";\n',
258
249
  );
259
- next = next.replace(/^\s*test:\s*\{[\s\S]*?\r?\n\s*\},\s*\r?\n/m, "");
250
+ next = next.replace(/^(\s*)test:\s*\{[\s\S]*?\r?\n\1\},\s*\r?\n/m, "");
260
251
  writeText(viteConfigPath, next);
261
252
  }
262
253
  }
@@ -362,37 +353,6 @@ function applyFeatureSelection(targetDir, features) {
362
353
  next = next.replace(/^\s*import\s+"uno\.css";\s*\r?\n/m, "");
363
354
  }
364
355
 
365
- if (!tracker) {
366
- next = next.replace(
367
- /^\s*import\s+\{\s*createTrackerPlugin,\s*getTracker\s*\}\s+from\s+"@\/utils\/tracker";\s*\r?\n/m,
368
- "",
369
- );
370
- next = next.replace(
371
- /^\s*import\s+\{\s*abortAllRequests,\s*http,\s*formDataHttp\s*\}\s+from\s+"@\/api\/http";\s*\r?\n/m,
372
- 'import { abortAllRequests } from "@/api/http";\n',
373
- );
374
- next = next.replace(
375
- /^\s*\.use\(createTrackerPlugin\(\{ appId: "vue3-template" \}\)\)\s*\r?\n/m,
376
- "",
377
- );
378
- next = next.replace(
379
- /\r?\n\s*\/\/ 监听用户信息变化,更新 tracker 用户 ID[\s\S]*?\r?\n\s*\{ immediate: true \}\r?\n\s*\);\r?\n/m,
380
- "\n",
381
- );
382
- next = next.replace(
383
- /^\s*getTracker\(\)\.bindRouter\(router\);\s*\/\/ 绑定路由\s*\r?\n/m,
384
- "",
385
- );
386
- next = next.replace(
387
- /^\s*getTracker\(\)\.bindRouterExposure\(router\);\s*\/\/ 绑定路由曝光事件\s*\r?\n/m,
388
- "",
389
- );
390
- next = next.replace(
391
- /^\s*getTracker\(\)\.attachAxios\(\[http,\s*formDataHttp\]\);\s*\/\/ 绑定 axios 实例\s*\r?\n/m,
392
- "",
393
- );
394
- }
395
-
396
356
  writeText(mainPath, next);
397
357
  }
398
358
  }
@@ -453,12 +413,6 @@ async function main() {
453
413
  : nonInteractive
454
414
  ? true
455
415
  : await questionYesNo("是否需要 UnoCSS 原子化 CSS 能力", true);
456
- const useTracker =
457
- parsed.tracker != null
458
- ? parsed.tracker
459
- : nonInteractive
460
- ? true
461
- : await questionYesNo("是否需要数据埋点(Tracker)能力", true);
462
416
  const useTest =
463
417
  parsed.test != null
464
418
  ? parsed.test
@@ -523,7 +477,6 @@ async function main() {
523
477
  applyFeatureSelection(targetDir, {
524
478
  echarts: useECharts,
525
479
  unocss: useUnoCSS,
526
- tracker: useTracker,
527
480
  test: useTest,
528
481
  });
529
482
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bohui-vue",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "Vue 项目模板创建工具,用于快速创建标准化的 Vue 项目",
5
5
  "main": "bin/create-vue-template.js",
6
6
  "bin": {
@@ -46,7 +46,6 @@ import pinia from "./store";
46
46
  import { useUserStore } from "./store";
47
47
 
48
48
  import { abortAllRequests, http, formDataHttp } from "@/api/http";
49
- import { createTrackerPlugin, getTracker } from "@/utils/tracker";
50
49
 
51
50
  import "@unocss/reset/tailwind.css";
52
51
  import "uno.css";
@@ -85,7 +84,6 @@ app.use(ElButton)
85
84
  .use(ElCollapseItem)
86
85
  .use(pinia)
87
86
  .use(router)
88
- .use(createTrackerPlugin({ appId: "vue3-template" }))
89
87
  .mount("#app");
90
88
 
91
89
  const userStore = useUserStore();
@@ -93,19 +91,6 @@ router.isReady().then(() => {
93
91
  userStore.setUserInfo();
94
92
  });
95
93
 
96
- // 监听用户信息变化,更新 tracker 用户 ID
97
- watch(
98
- () => userStore.userInfo,
99
- (u) => {
100
- getTracker().setUserId(u?.id ?? null);
101
- },
102
- { immediate: true }
103
- );
104
-
105
- getTracker().bindRouter(router); // 绑定路由
106
- getTracker().bindRouterExposure(router); // 绑定路由曝光事件
107
- getTracker().attachAxios([http, formDataHttp]); // 绑定 axios 实例
108
-
109
94
  window.addEventListener("beforeunload", () => {
110
95
  abortAllRequests();
111
96
  });
@@ -79,7 +79,28 @@ src/
79
79
  main.ts # 应用入口(插件注册、全局样式引入)
80
80
  ```
81
81
 
82
- ### 4.1 `components/`(全局复用组件)
82
+ ### 4.1 `views/`(页面组织规范)
83
+
84
+ 建议采用 “按业务域拆分 + 复杂度分级” 的混合策略,不强制所有页面都创建独立文件夹:
85
+
86
+ #### 4.1.1 简单页面(扁平化):
87
+
88
+ 如果页面功能单一、无私有组件、逻辑简单,直接在 views/ 下创建 .vue 文件。
89
+
90
+ 示例: src/views/Login.vue
91
+
92
+ #### 4.1.2 复杂页面(目录化):
93
+
94
+ 如果页面包含多个私有组件、独立的业务逻辑(hooks/composables)或拆分了子视图,则创建一个同名目录。
95
+
96
+ - 每个页面一个目录:`src/views/<feature>/`
97
+ - 页面入口:`<Feature>.vue`
98
+ - 私有组件:`components/`
99
+ - 私有逻辑:`composables/`
100
+
101
+ 示例:`src/views/teachingLog/`。
102
+
103
+ ### 4.2 `components/`(全局复用组件)
83
104
 
84
105
  用于沉淀“跨页面可复用”的组件,避免被单个页面目录绑定(例如 ECharts 封装、通用图表组件等)。
85
106
 
@@ -88,7 +109,7 @@ src/
88
109
  - 组件目录按领域拆分(如 `components/echarts/`)
89
110
  - 组件尽量“纯展示 + 明确输入输出”,避免直接依赖某个页面的 store/router
90
111
 
91
- #### 4.1.1 图表(ECharts)使用规范
112
+ #### 4.2.1 图表(ECharts)使用规范
92
113
 
93
114
  模板内对 ECharts 做了“按需引入 + 统一渲染容器”的工程化封装,目标是:减少打包体积、统一初始化/销毁、降低业务图表组件的复杂度。
94
115
 
@@ -118,17 +139,6 @@ src/
118
139
  - `categories.length` 必须与 `series[].values.length` 一一对应
119
140
  - `values` 建议约束在 `0~100`(如百分比口径),超出范围需要在组件内部做钳制或规则说明
120
141
 
121
- ### 4.2 `views/`(页面组织规范)
122
-
123
- 建议按“业务域 / 页面模块”拆分:
124
-
125
- - 每个页面一个目录:`src/views/<feature>/`
126
- - 页面入口:`<Feature>.vue`
127
- - 页面内组件:`components/`
128
- - 页面内组合式逻辑:`composables/`
129
-
130
- 示例:`src/views/teachingLog/`。
131
-
132
142
  ### 4.3 `api/`(请求层规范)
133
143
 
134
144
  - `src/api/http.ts`:提供两个实例
@@ -168,45 +178,7 @@ src/
168
178
  - 输入输出明确、易测试
169
179
  - 不直接操作 UI(比如弹窗提示)与路由(除非工具目标就是封装这些)
170
180
 
171
- #### 4.6.1 数据埋点(Tracker)使用规范
172
-
173
- 模板提供了轻量级埋点/采集工具:`src/utils/tracker.ts`,并已在入口 `src/main.ts` 完成初始化接入,目标是统一采集页面访问、点击、接口请求结果等事件。
174
-
175
- - 上报地址
176
- - 默认读取 `window.ConfigInfo.baseUrl` 拼接 `${baseUrl}/analytics/collect`
177
- - 若未注入 `baseUrl`,回退到相对路径 `/analytics/collect`
178
- - 可在初始化时通过 `createTrackerPlugin({ endpoint })` 覆盖
179
- - 接入方式(项目入口)
180
- - Vue 插件:`createTrackerPlugin({ appId: "vue3-template" })` 注册全局能力
181
- - 路由埋点:`getTracker().bindRouter(router)` 采集 `page_view`
182
- - 页面曝光:`getTracker().bindRouterExposure(router)` 采集 `page_exposure`(仅统计页面可见时长)
183
- - 接口埋点:`getTracker().attachAxios([http, formDataHttp])` 采集 `api_success/api_error`
184
- - 用户关联:监听用户信息变化后 `getTracker().setUserId(u?.id ?? null)`,将后续事件与用户关联
185
- - 事件采集调用规范
186
- - `$track(name, data)`:入队缓冲,适合高频/非关键事件(减少上报频率)
187
- - `v-track` 指令:点击事件立即上报(避免跳转/关闭页面导致丢失)
188
- - 手动即时上报:`getTracker().trackNow(name, data)`,适合关键动作
189
-
190
- 组件内用法示例:
191
-
192
- ```ts
193
- import { getTracker } from "@/utils/tracker";
194
-
195
- getTracker().track("search_submit", { keyword: "xxx" });
196
- getTracker().trackNow("pay_click", { orderId: "o_001" });
197
- ```
198
-
199
- 模板内置事件语义(建议统一沿用):
200
-
201
- - `page_view`:路由切换后的页面访问
202
- - `page_exposure`:页面曝光时长结算(route_change / hidden / beforeunload)
203
- - `api_success` / `api_error`:接口成功/失败与耗时(自动从 axios 拦截器采集)
204
- - `click` / 自定义 click 事件:通过 `v-track` 或 `trackNow` 采集
205
-
206
- 数据约定建议:
207
-
208
- - `name` 统一使用 `snake_case` 或 `kebab-case`,并在团队内固定一种风格
209
- - `data` 不写入敏感信息(token、密码、身份证号等),用户标识使用 `setUserId` 管理
181
+ #### 4.6.1 数据埋点(tracker-vue-plugin)使用规范
210
182
 
211
183
  ### 4.7 `assets/`(样式与主题规范)
212
184
 
@@ -1,527 +0,0 @@
1
- import type { App, DirectiveBinding } from "vue";
2
- /**
3
- * 轻量级埋点/采集工具
4
- *
5
- * 目标:
6
- * - 统一收集页面访问、点击、接口请求结果等事件
7
- * - 支持队列缓冲与批量上报(降低上报频率、减少网络开销)
8
- * - 提供 Vue 插件(全局 $track)与指令(v-track)两种接入方式
9
- *
10
- * 说明:
11
- * - 本文件只负责“采集与发送”,不强依赖业务代码与路由/状态方案
12
- * - 上报请求统一通过项目内的 http 实例发送(便于复用拦截器、基础配置等)
13
- */
14
-
15
- /**
16
- * 使用简化的路由类型以避免依赖外部类型声明(如 vue-router 的类型)。
17
- * 这里只需要 fullPath/name 这两项字段即可完成 page_view 事件采集。
18
- */
19
- type SimpleRoute = { fullPath: string; name?: string | symbol | null };
20
- import type { AxiosInstance, InternalAxiosRequestConfig, AxiosResponse, AxiosError } from "axios";
21
- import { http } from "@/api/http";
22
-
23
- /**
24
- * 单条事件结构
25
- * - name: 事件名(如 page_view/click/api_success/api_error)
26
- * - ts: 事件产生时间戳(毫秒)
27
- * - data: 事件附加信息(会在 track/trackNow 内被 enrich 填充通用字段)
28
- */
29
- type TrackEvent = {
30
- name: string;
31
- ts: number;
32
- data?: Record<string, unknown>;
33
- };
34
-
35
- /**
36
- * 初始化配置
37
- * - endpoint: 上报接口地址(默认 /analytics/collect 或 {baseUrl}/analytics/collect)
38
- * - appId: 应用标识(用于区分不同应用/站点)
39
- * - userId: 用户标识(可为空;登录后可 setUserId 更新)
40
- * - flushIntervalMs: 定时上报间隔(毫秒)
41
- * - maxBatchSize: 单次批量上报最大事件数(队列达到该值会立即 flush)
42
- */
43
- type TrackerInitOptions = {
44
- endpoint?: string;
45
- appId?: string;
46
- userId?: string | null;
47
- flushIntervalMs?: number;
48
- maxBatchSize?: number;
49
- };
50
-
51
- /**
52
- * 页面曝光时长上报触发原因
53
- * - route_change: 路由切换离开当前页面
54
- * - hidden: 页面进入后台/不可见
55
- * - beforeunload: 页面关闭/刷新
56
- */
57
- type PageExposureReason = "route_change" | "hidden" | "beforeunload";
58
-
59
- class Tracker {
60
- /**
61
- * 上报地址
62
- * - 默认尝试读取 window.ConfigInfo.baseUrl(适配通过全局配置注入后端地址的场景)
63
- * - 若不存在 baseUrl,则回落到相对路径 /analytics/collect
64
- */
65
- private endpoint: string;
66
- /** 应用标识 */
67
- private appId: string;
68
- /** 用户标识(可为空) */
69
- private userId: string | null;
70
- /**
71
- * 会话标识
72
- * - 在实例创建时生成,用于将同一次访问过程中的事件串联起来
73
- * - 若需要跨刷新/重开浏览器持久化,可改造为写入 localStorage(当前未做)
74
- */
75
- private sessionId: string;
76
- /** 事件队列(用于批量上报) */
77
- private queue: TrackEvent[] = [];
78
- /** 定时 flush 的 interval 句柄 */
79
- private timer: number | null = null;
80
- /** flush 的时间间隔(毫秒) */
81
- private flushIntervalMs: number;
82
- /** 单次 flush 的最大事件数 */
83
- private maxBatchSize: number;
84
- /**
85
- * 页面曝光埋点是否已绑定
86
- * - 防止 bindRouterExposure 被重复调用时重复注册监听器
87
- */
88
- private pageExposureBound = false;
89
- /**
90
- * 页面曝光统计状态
91
- * - visibleStartTs: 当前可见时间片段起点;为 null 表示当前不可见/未计时
92
- * - accumulatedMs: 当前页面累计曝光时长(只累计可见时间片段)
93
- */
94
- private pageExposureState: {
95
- path: string;
96
- name: string | null;
97
- title: string | null;
98
- visibleStartTs: number | null;
99
- accumulatedMs: number;
100
- } | null = null;
101
-
102
- constructor() {
103
- const base = (window as any).ConfigInfo?.baseUrl || "";
104
- this.endpoint = base ? `${base}/analytics/collect` : "/analytics/collect";
105
- this.appId = "app";
106
- this.userId = null;
107
- this.sessionId = this.createId();
108
- this.flushIntervalMs = 5000;
109
- this.maxBatchSize = 50;
110
- }
111
-
112
- /**
113
- * 初始化 tracker
114
- * - 可覆盖默认配置
115
- * - 启动定时 flush,并在页面卸载/切后台时尽量把队列发出去
116
- *
117
- * 注意:
118
- * - 这里对 flushIntervalMs/maxBatchSize 使用了“真值判断”,意味着传入 0 会被忽略
119
- * (一般不需要 0;若确需支持,可将判断改为 typeof === "number")
120
- */
121
- init(opts?: TrackerInitOptions) {
122
- if (opts?.endpoint) this.endpoint = opts.endpoint;
123
- if (opts?.appId) this.appId = opts.appId;
124
- if (typeof opts?.userId !== "undefined") this.userId = opts.userId;
125
- if (opts?.flushIntervalMs) this.flushIntervalMs = opts.flushIntervalMs;
126
- if (opts?.maxBatchSize) this.maxBatchSize = opts.maxBatchSize;
127
- this.startTimer();
128
- }
129
-
130
- /**
131
- * 入队采集(异步批量上报)
132
- * - 适合高频事件(如 page_view、接口成功/失败等)
133
- * - 到达 maxBatchSize 时会触发一次立即 flush
134
- */
135
- track(name: string, data?: Record<string, unknown>) {
136
- const e: TrackEvent = { name, ts: Date.now(), data: this.enrich(data) };
137
- this.queue.push(e);
138
- if (this.queue.length >= this.maxBatchSize) this.flush();
139
- }
140
-
141
- /**
142
- * 立即采集并上报(不入队)
143
- * - 适合对“及时性”要求较高的事件(如点击/关键操作)
144
- * - 失败时静默吞掉错误,避免影响业务流程
145
- */
146
- trackNow(name: string, data?: Record<string, unknown>) {
147
- const e: TrackEvent = { name, ts: Date.now(), data: this.enrich(data) };
148
- const payload = {
149
- appId: this.appId,
150
- userId: this.userId,
151
- sessionId: this.sessionId,
152
- ua: navigator.userAgent,
153
- events: [e],
154
- };
155
- http.post<any>(this.endpoint, payload).catch(() => void 0);
156
- }
157
-
158
- /**
159
- * 更新 userId
160
- * - 可在登录/退出时调用
161
- * - 后续事件会携带新的 userId
162
- */
163
- setUserId(userId: string | null) {
164
- this.userId = userId;
165
- }
166
-
167
- /**
168
- * 绑定路由(采集页面浏览)
169
- * - 在每次路由切换后上报 page_view
170
- * - 记录当前路径、来源路径、路由 name 与 document.title
171
- */
172
- bindRouter(router: any) {
173
- router.afterEach((to: SimpleRoute, from: SimpleRoute) => {
174
- this.track("page_view", {
175
- path: to.fullPath,
176
- from: from.fullPath,
177
- name: to.name || null,
178
- title: document.title || null,
179
- });
180
- });
181
- }
182
-
183
- /**
184
- * 绑定路由页面曝光时长采集
185
- *
186
- * 统计口径:
187
- * - 只统计页面处于 visible 的时间片段
188
- * - hidden/beforeunload/route_change 时进行结算并上报 page_exposure
189
- * - route_change 还会附带 toPath/toName,用于分析跳转去向
190
- *
191
- * 幂等性:
192
- * - 内部通过 pageExposureBound 做去重,多次调用不会重复绑定监听
193
- */
194
- bindRouterExposure(router: any) {
195
- if (this.pageExposureBound) return;
196
- this.pageExposureBound = true;
197
-
198
- const initRoute = (router.currentRoute?.value || null) as SimpleRoute | null;
199
- const initPath =
200
- initRoute?.fullPath ||
201
- (typeof window.location.hash === "string" && window.location.hash
202
- ? window.location.hash.replace(/^#/, "")
203
- : window.location.pathname);
204
-
205
- this.pageExposureState = {
206
- path: initPath,
207
- name: this.serializeRouteName(initRoute?.name),
208
- title: document.title || null,
209
- visibleStartTs: document.visibilityState === "hidden" ? null : Date.now(),
210
- accumulatedMs: 0,
211
- };
212
-
213
- router.afterEach((to: SimpleRoute, _from: SimpleRoute) => {
214
- this.endPageExposure("route_change", {
215
- toPath: to.fullPath,
216
- toName: this.serializeRouteName(to.name),
217
- });
218
- this.pageExposureState = {
219
- path: to.fullPath,
220
- name: this.serializeRouteName(to.name),
221
- title: document.title || null,
222
- visibleStartTs: document.visibilityState === "hidden" ? null : Date.now(),
223
- accumulatedMs: 0,
224
- };
225
- });
226
-
227
- document.addEventListener("visibilitychange", () => {
228
- if (!this.pageExposureState) return;
229
- if (document.visibilityState === "hidden") {
230
- this.endPageExposure("hidden");
231
- return;
232
- }
233
- if (this.pageExposureState.visibleStartTs === null) {
234
- this.pageExposureState.visibleStartTs = Date.now();
235
- }
236
- });
237
-
238
- window.addEventListener("beforeunload", () => {
239
- this.endPageExposure("beforeunload");
240
- });
241
- }
242
-
243
- /**
244
- * 接入 axios 实例(采集接口成功/失败)
245
- * - request 拦截器写入 __startTs 用于耗时计算
246
- * - response 拦截器采集 api_success / api_error
247
- *
248
- * 注意:
249
- * - 会跳过上报接口自身的请求,避免“上报产生上报”导致递归/噪音
250
- * - 建议传入你项目中实际使用的 axios 实例数组(如 http、其他业务实例)
251
- */
252
- attachAxios(instances: AxiosInstance[]) {
253
- instances.forEach((inst) => {
254
- const instAny = inst as any;
255
- if (instAny.__trackerAxiosBound) return;
256
- instAny.__trackerAxiosBound = true;
257
-
258
- inst.interceptors.request.use((cfg: InternalAxiosRequestConfig) => {
259
- (cfg as any).__startTs = Date.now();
260
- return cfg;
261
- });
262
-
263
- const onFulfilled = (res: AxiosResponse) => {
264
- const s = (res.config as any)?.__startTs as number | undefined;
265
- const d = typeof s === "number" ? Date.now() - s : null;
266
- const url = res.config?.url || null;
267
- const method = ((res.config?.method || "get") as string).toUpperCase();
268
- if (!this.isAnalyticsUrl(url, res.config?.baseURL)) {
269
- this.track("api_success", {
270
- url,
271
- method,
272
- status: res.status,
273
- duration: d,
274
- });
275
- }
276
- return res;
277
- };
278
-
279
- const onRejected = (err: unknown) => {
280
- const axiosErr = err as AxiosError;
281
- const cfg = axiosErr.config;
282
- const s = (cfg as any)?.__startTs as number | undefined;
283
- const d = typeof s === "number" ? Date.now() - s : null;
284
- if (!this.isAnalyticsUrl(cfg?.url || null, cfg?.baseURL)) {
285
- this.track("api_error", {
286
- url: cfg?.url || null,
287
- method: ((cfg?.method || "get") as string).toUpperCase(),
288
- status: axiosErr.response?.status || null,
289
- message: axiosErr.message,
290
- duration: d,
291
- });
292
- }
293
- return Promise.reject(err);
294
- };
295
-
296
- inst.interceptors.response.use(onFulfilled, onRejected);
297
-
298
- const handlers = (inst.interceptors.response as any)?.handlers as
299
- | Array<{ fulfilled?: unknown; rejected?: unknown }>
300
- | undefined;
301
- if (Array.isArray(handlers)) {
302
- const idx = handlers.findIndex(
303
- (h) => h?.fulfilled === onFulfilled && h?.rejected === onRejected
304
- );
305
- if (idx > 0) {
306
- const [h] = handlers.splice(idx, 1);
307
- handlers.unshift(h);
308
- }
309
- }
310
- });
311
- }
312
-
313
- /**
314
- * 生成 v-track 指令
315
- *
316
- * 用法示例:
317
- * - v-track="'buy_click'" // 直接传事件名
318
- * - v-track="{ name: 'buy_click', data: {...}}" // 同时携带自定义 data
319
- *
320
- * 行为:
321
- * - 仅监听 click 事件
322
- * - 默认会携带当前元素 tagName/id,便于定位点击来源
323
- * - 使用 trackNow 立即上报(避免用户跳转/关闭页面导致丢失)
324
- */
325
- directive() {
326
- return {
327
- mounted: (el: HTMLElement, binding: DirectiveBinding) => {
328
- const handler = (_evt: Event) => {
329
- const v = binding.value;
330
- if (typeof v === "string") {
331
- this.trackNow(v, { tag: el.tagName, id: el.id || null });
332
- } else if (v && typeof v === "object") {
333
- const name = (v as any).name || "click";
334
- const data = (v as any).data || {};
335
- this.trackNow(name, { ...data, tag: el.tagName, id: el.id || null });
336
- } else {
337
- this.trackNow("click", { tag: el.tagName, id: el.id || null });
338
- }
339
- };
340
- (el as any).__trackHandler = handler;
341
- el.addEventListener("click", handler);
342
- },
343
- beforeUnmount: (el: HTMLElement) => {
344
- const h = (el as any).__trackHandler as ((e: Event) => void) | undefined;
345
- if (h) el.removeEventListener("click", h);
346
- (el as any).__trackHandler = null;
347
- },
348
- } as any;
349
- }
350
-
351
- /**
352
- * 批量上报队列事件
353
- * - 每次最多发送 maxBatchSize 条
354
- * - 失败时静默吞掉错误,避免影响业务
355
- *
356
- * 注意:
357
- * - 当前实现使用 http.post 上报,默认是异步请求;在 beforeunload 中可能来不及完成
358
- * 若对离开页面的可靠性要求更高,可考虑改用 navigator.sendBeacon(需后端支持)
359
- */
360
- flush() {
361
- if (!this.queue.length) return;
362
- const batch = this.queue.splice(0, this.maxBatchSize);
363
- const payload = {
364
- appId: this.appId,
365
- userId: this.userId,
366
- sessionId: this.sessionId,
367
- ua: navigator.userAgent,
368
- events: batch,
369
- };
370
- http.post<any>(this.endpoint, payload).catch(() => void 0);
371
- }
372
-
373
- /**
374
- * 注入通用字段(每个事件都会带上)
375
- * - url: 当前完整地址(含 hash/query)
376
- * - viewport: 当前视口大小
377
- * - ts: enrich 时刻的时间戳(与 TrackEvent.ts 可能接近但不完全相同)
378
- *
379
- * 说明:
380
- * - TrackEvent.ts 表示“事件产生时间”,enrich.ts 表示“入队/上报时刻”
381
- * - 若你不希望重复时间字段,可移除 enrich.ts 或移除 TrackEvent.ts(二选一)
382
- */
383
- private enrich(data?: Record<string, unknown>) {
384
- const base = {
385
- url: window.location.href,
386
- viewport: { w: window.innerWidth, h: window.innerHeight },
387
- ts: Date.now(),
388
- };
389
- return data ? { ...base, ...data } : base;
390
- }
391
-
392
- /**
393
- * 判断某个请求 URL 是否为埋点上报接口
394
- * - 用于避免对上报接口本身再产生 api_success/api_error 事件
395
- * - 兼容相对/绝对 URL 的情况(URL 构造失败时回退到字符串包含判断)
396
- */
397
- private isAnalyticsUrl(u?: string | null, baseURL?: string) {
398
- if (!u) return false;
399
-
400
- const normalizePathname = (p: string) => p.replace(/\/+$/, "");
401
- const toUrl = (raw: string, base: string) => {
402
- try {
403
- return new URL(raw, base);
404
- } catch {
405
- return null;
406
- }
407
- };
408
-
409
- try {
410
- const b =
411
- toUrl(u, baseURL || window.location.origin) || toUrl(u, window.location.origin);
412
- const a =
413
- toUrl(this.endpoint, baseURL || window.location.origin) ||
414
- toUrl(this.endpoint, window.location.origin);
415
-
416
- if (a && b) {
417
- const ap = normalizePathname(a.pathname);
418
- const bp = normalizePathname(b.pathname);
419
- if (ap === bp) return true;
420
- if (ap.endsWith("/analytics/collect") && bp.endsWith("/analytics/collect"))
421
- return true;
422
- }
423
-
424
- return false;
425
- } catch {
426
- return u.includes("/analytics/collect");
427
- }
428
- }
429
-
430
- /**
431
- * 启动定时器并注册兜底 flush
432
- * - interval: 定期 flush
433
- * - beforeunload: 关闭/刷新前尽量 flush(可靠性依赖浏览器与网络环境)
434
- * - visibilitychange: 切到后台时 flush(移动端/低内存场景更容易被挂起)
435
- */
436
- private startTimer() {
437
- if (this.timer) window.clearInterval(this.timer);
438
- this.timer = window.setInterval(() => this.flush(), this.flushIntervalMs);
439
- window.addEventListener("beforeunload", () => this.flush());
440
- document.addEventListener("visibilitychange", () => {
441
- if (document.visibilityState === "hidden") this.flush();
442
- });
443
- }
444
-
445
- /**
446
- * 将路由 name 统一序列化为 string
447
- * - 兼容 vue-router 允许 name 为 symbol 的情况
448
- */
449
- private serializeRouteName(name?: string | symbol | null) {
450
- if (!name) return null;
451
- if (typeof name === "string") return name;
452
- return name.toString();
453
- }
454
-
455
- /**
456
- * 结算并上报当前页面曝光时长
457
- * - 在 route_change/hidden/beforeunload 时调用
458
- * - 只在 durationMs > 0 时上报,避免产生无意义噪音数据
459
- */
460
- private endPageExposure(reason: PageExposureReason, extra?: Record<string, unknown>) {
461
- if (!this.pageExposureState) return;
462
-
463
- const now = Date.now();
464
- if (this.pageExposureState.visibleStartTs !== null) {
465
- this.pageExposureState.accumulatedMs += now - this.pageExposureState.visibleStartTs;
466
- this.pageExposureState.visibleStartTs = null;
467
- }
468
-
469
- const durationMs = this.pageExposureState.accumulatedMs;
470
- if (durationMs > 0) {
471
- this.track("page_exposure", {
472
- path: this.pageExposureState.path,
473
- name: this.pageExposureState.name,
474
- title: this.pageExposureState.title,
475
- durationMs,
476
- reason,
477
- ...(extra || {}),
478
- });
479
- }
480
-
481
- this.pageExposureState.accumulatedMs = 0;
482
- }
483
-
484
- /**
485
- * 生成会话/事件相关的唯一标识
486
- * - 优先使用 crypto.randomUUID(更可靠)
487
- * - 回退到时间戳 + 随机串(兼容老环境)
488
- */
489
- private createId() {
490
- if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID();
491
- return `${Date.now()}_${Math.random().toString(36).slice(2)}`;
492
- }
493
- }
494
-
495
- /**
496
- * 单例 tracker
497
- * - 便于在全局复用(插件、指令、手动调用 getTracker 都指向同一个实例)
498
- */
499
- const tracker = new Tracker();
500
-
501
- /**
502
- * 创建 Vue 插件
503
- * - install 时注入全局方法 $track(走队列,适合非关键事件)
504
- * - 注册 v-track 指令(走 trackNow,适合点击等关键事件)
505
- */
506
- export function createTrackerPlugin(opts?: TrackerInitOptions) {
507
- tracker.init(opts);
508
- return {
509
- install(app: App) {
510
- (app.config.globalProperties as any).$track = (
511
- name: string,
512
- data?: Record<string, unknown>
513
- ) => tracker.track(name, data);
514
- app.directive("track", tracker.directive());
515
- },
516
- };
517
- }
518
-
519
- /**
520
- * 获取 tracker 单例
521
- * - 可用于在任意位置手动调用(如 getTracker().bindRouter(router))
522
- */
523
- export function getTracker() {
524
- return tracker;
525
- }
526
-
527
- export type { TrackerInitOptions };