bohui-vue 1.0.1

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.
Files changed (104) hide show
  1. package/README.md +121 -0
  2. package/bin/create-vue-template.js +565 -0
  3. package/package.json +28 -0
  4. package/templates/vue-project/.browserslistrc +3 -0
  5. package/templates/vue-project/.editorconfig +28 -0
  6. package/templates/vue-project/.env.development +2 -0
  7. package/templates/vue-project/.env.production +2 -0
  8. package/templates/vue-project/.eslintrc.cjs +76 -0
  9. package/templates/vue-project/.keep +0 -0
  10. package/templates/vue-project/.node-version +1 -0
  11. package/templates/vue-project/.prettierignore +13 -0
  12. package/templates/vue-project/.prettierrc +20 -0
  13. package/templates/vue-project/.prettierrc.txt +130 -0
  14. package/templates/vue-project/.stylelintrc.json +94 -0
  15. package/templates/vue-project/README.md +24 -0
  16. package/templates/vue-project/babel.config.js +5 -0
  17. package/templates/vue-project/index.html +34 -0
  18. package/templates/vue-project/package.json +75 -0
  19. package/templates/vue-project/public/favicon.ico +0 -0
  20. package/templates/vue-project/public/static/img/ai-default.jpg +0 -0
  21. package/templates/vue-project/public/static/img/image.png +0 -0
  22. package/templates/vue-project/public/static/img/ppt1.png +0 -0
  23. package/templates/vue-project/public/static/img/ppt2.png +0 -0
  24. package/templates/vue-project/public/static/img/ppt3.png +0 -0
  25. package/templates/vue-project/public/static/js/config.js +11 -0
  26. package/templates/vue-project/public/static/js/dataConfig.js +1143 -0
  27. package/templates/vue-project/src/App.vue +10 -0
  28. package/templates/vue-project/src/api/error-handler.ts +60 -0
  29. package/templates/vue-project/src/api/http.ts +254 -0
  30. package/templates/vue-project/src/api/services/aicebd.ts +47 -0
  31. package/templates/vue-project/src/api/services/base.ts +18 -0
  32. package/templates/vue-project/src/api/services/umse.ts +17 -0
  33. package/templates/vue-project/src/assets/font/Alibaba-PuHuiTi-Medium.otf +0 -0
  34. package/templates/vue-project/src/assets/font/Alibaba-PuHuiTi-Regular.otf +0 -0
  35. package/templates/vue-project/src/assets/font/DOUYINSANSBOLD.OTF +0 -0
  36. package/templates/vue-project/src/assets/font/Pangmen-Title.TTF +0 -0
  37. package/templates/vue-project/src/assets/font/font.css +25 -0
  38. package/templates/vue-project/src/assets/iconfont/iconfont.css +402 -0
  39. package/templates/vue-project/src/assets/iconfont/iconfont.js +66 -0
  40. package/templates/vue-project/src/assets/iconfont/iconfont.json +688 -0
  41. package/templates/vue-project/src/assets/iconfont/iconfont.ttf +0 -0
  42. package/templates/vue-project/src/assets/iconfont/iconfont.woff +0 -0
  43. package/templates/vue-project/src/assets/iconfont/iconfont.woff2 +0 -0
  44. package/templates/vue-project/src/assets/images/Click-tap.png +0 -0
  45. package/templates/vue-project/src/assets/images/Effects.png +0 -0
  46. package/templates/vue-project/src/assets/images/bg.png +0 -0
  47. package/templates/vue-project/src/assets/images/erCode.png +0 -0
  48. package/templates/vue-project/src/assets/images/header-bg.png +0 -0
  49. package/templates/vue-project/src/assets/images/logo.png +0 -0
  50. package/templates/vue-project/src/assets/scss/common.scss +530 -0
  51. package/templates/vue-project/src/assets/styles/element-overrides.css +53 -0
  52. package/templates/vue-project/src/assets/styles/reset.css +186 -0
  53. package/templates/vue-project/src/assets/styles/theme.css +100 -0
  54. package/templates/vue-project/src/components/BarChart.vue +238 -0
  55. package/templates/vue-project/src/components/echarts/EChart.vue +140 -0
  56. package/templates/vue-project/src/composables/useTheme.ts +84 -0
  57. package/templates/vue-project/src/main.ts +111 -0
  58. package/templates/vue-project/src/mocks/base.ts +37 -0
  59. package/templates/vue-project/src/mocks/umse.ts +31 -0
  60. package/templates/vue-project/src/router/index.ts +32 -0
  61. package/templates/vue-project/src/shims-vue.d.ts +19 -0
  62. package/templates/vue-project/src/store/index.ts +18 -0
  63. package/templates/vue-project/src/store/modules/user.ts +85 -0
  64. package/templates/vue-project/src/types/DTO/aicebd.d.ts +60 -0
  65. package/templates/vue-project/src/types/DTO/base.d.ts +26 -0
  66. package/templates/vue-project/src/types/DTO/global.d.ts +48 -0
  67. package/templates/vue-project/src/types/VO/teachingLog.d.ts +15 -0
  68. package/templates/vue-project/src/types/auto-imports.d.ts +73 -0
  69. package/templates/vue-project/src/types/components.d.ts +17 -0
  70. package/templates/vue-project/src/types/element-plus.d.ts +15 -0
  71. package/templates/vue-project/src/types/js-cookie.d.ts +1 -0
  72. package/templates/vue-project/src/types/unocss.d.ts +2 -0
  73. package/templates/vue-project/src/types/vite-plugins.d.ts +3 -0
  74. package/templates/vue-project/src/types/vue-router.d.ts +1 -0
  75. package/templates/vue-project/src/types/window-config.d.ts +12 -0
  76. package/templates/vue-project/src/utils/com-methods.ts +307 -0
  77. package/templates/vue-project/src/utils/echarts.ts +111 -0
  78. package/templates/vue-project/src/utils/number.ts +99 -0
  79. package/templates/vue-project/src/utils/rem.ts +82 -0
  80. package/templates/vue-project/src/utils/responsive.ts +103 -0
  81. package/templates/vue-project/src/utils/time.ts +314 -0
  82. package/templates/vue-project/src/utils/tracker.ts +527 -0
  83. package/templates/vue-project/src/utils/validators.ts +85 -0
  84. package/templates/vue-project/src/utils/window.ts +132 -0
  85. package/templates/vue-project/src/views/home/Home.vue +60 -0
  86. package/templates/vue-project/src/views/home/composables/useUserAuth.ts +13 -0
  87. package/templates/vue-project/src/views/teachingLog/TeachingLog.vue +40 -0
  88. package/templates/vue-project/src/views/teachingLog/__tests__/TeachingEffect.test.ts +96 -0
  89. package/templates/vue-project/src/views/teachingLog/__tests__/TeachingHighlight.test.ts +66 -0
  90. package/templates/vue-project/src/views/teachingLog/__tests__/TeachingLog.test.ts +34 -0
  91. package/templates/vue-project/src/views/teachingLog/components/TeachingEffect.vue +458 -0
  92. package/templates/vue-project/src/views/teachingLog/components/TeachingHighlight.vue +181 -0
  93. package/templates/vue-project/src/views/teachingLog/composables/useEffectTooltip.ts +88 -0
  94. package/templates/vue-project/src/views/teachingLog/composables/useEffectTrendChart.ts +160 -0
  95. package/templates/vue-project/tests/setup.ts +27 -0
  96. package/templates/vue-project/tsconfig.json +24 -0
  97. package/templates/vue-project/tsconfig.node.json +41 -0
  98. package/templates/vue-project/uno.config.ts +84 -0
  99. package/templates/vue-project/vite.config.ts +216 -0
  100. package/templates/vue-project/vue3_ai_prompt.md +652 -0
  101. package/templates/vue-project/vue3_ai_prompt_basic.md +722 -0
  102. package/templates/vue-project/vue3_ai_prompt_full.md +1021 -0
  103. package/templates/vue-project/vue3_ai_prompt_unocss.md +768 -0
  104. package/templates/vue-project//345/267/245/347/250/213/345/214/226/346/250/241/346/235/277/344/273/213/347/273/215.md +463 -0
@@ -0,0 +1,527 @@
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 };
@@ -0,0 +1,85 @@
1
+ type Trigger = "blur" | "change";
2
+
3
+ type FormRule = {
4
+ validator: (rule: unknown, value: unknown, callback: (error?: Error | string) => void) => void;
5
+ trigger?: Trigger | Trigger[];
6
+ required?: boolean;
7
+ message?: string;
8
+ };
9
+
10
+ function isIPv4(value: unknown): boolean {
11
+ if (typeof value !== "string") return false;
12
+ const parts = value.split(".");
13
+ if (parts.length !== 4) return false;
14
+ for (const p of parts) {
15
+ if (p.length === 0 || p.length > 3) return false;
16
+ if (!/^[0-9]+$/.test(p)) return false;
17
+ const n = Number(p);
18
+ if (!Number.isInteger(n)) return false;
19
+ if (n < 0 || n > 255) return false;
20
+ }
21
+ return true;
22
+ }
23
+
24
+ function ipRule(message?: string, trigger: Trigger = "blur"): FormRule {
25
+ return {
26
+ trigger,
27
+ validator: (_rule, value, callback) => {
28
+ if (value === undefined || value === null || value === "") {
29
+ callback(new Error(message || "请输入IP地址"));
30
+ return;
31
+ }
32
+ if (!isIPv4(value)) {
33
+ callback(new Error(message || "IP地址格式不正确"));
34
+ return;
35
+ }
36
+ callback();
37
+ },
38
+ };
39
+ }
40
+
41
+ type NumberRangeOptions = {
42
+ min?: number;
43
+ max?: number;
44
+ integer?: boolean;
45
+ allowEmpty?: boolean;
46
+ message?: string;
47
+ trigger?: Trigger;
48
+ };
49
+
50
+ function numberRangeRule(options?: NumberRangeOptions): FormRule {
51
+ const { min, max, integer, allowEmpty, message, trigger = "blur" } = options || {};
52
+ return {
53
+ trigger,
54
+ validator: (_rule, value, callback) => {
55
+ if (value === undefined || value === null || value === "") {
56
+ if (allowEmpty) {
57
+ callback();
58
+ } else {
59
+ callback(new Error(message || "请输入数字"));
60
+ }
61
+ return;
62
+ }
63
+ const num = typeof value === "number" ? value : Number(value);
64
+ if (!Number.isFinite(num)) {
65
+ callback(new Error(message || "请输入有效的数字"));
66
+ return;
67
+ }
68
+ if (integer && !Number.isInteger(num)) {
69
+ callback(new Error(message || "请输入整数"));
70
+ return;
71
+ }
72
+ if (typeof min === "number" && num < min) {
73
+ callback(new Error(message || `最小值为${min}`));
74
+ return;
75
+ }
76
+ if (typeof max === "number" && num > max) {
77
+ callback(new Error(message || `最大值为${max}`));
78
+ return;
79
+ }
80
+ callback();
81
+ },
82
+ };
83
+ }
84
+
85
+ export { ipRule, numberRangeRule, isIPv4 };