@rk911/tracker-sdk 1.6.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.
@@ -0,0 +1,557 @@
1
+ export class ClickPlugin {
2
+ constructor() {
3
+ this.name = 'click'
4
+ this.tracker = null
5
+ this._handler = null
6
+ this._lastClickKey = ''
7
+ this._lastClickTime = 0
8
+ }
9
+
10
+ install(tracker) {
11
+ this.tracker = tracker
12
+ var adapter = tracker.getAdapter()
13
+
14
+ if (!adapter.supportsDOM()) return
15
+
16
+ var self = this
17
+ this._handler = function (e) {
18
+ self._handleClick(e)
19
+ }
20
+ adapter.addClickListener(this._handler)
21
+ }
22
+
23
+ _handleClick(e) {
24
+ var adapter = this.tracker.getAdapter()
25
+ var rawTarget = e.target
26
+ if (!rawTarget || !rawTarget.tagName) return
27
+
28
+ // 底部导航/tabbar 区域的点击跳过自动采集
29
+ // 原因:click 在导航前触发,拿不到目标页的 pageRoute
30
+ // 由业务代码(如 footer-h.vue)的手动 $track 负责上报
31
+ if (this._isInsideTabbar(rawTarget)) return
32
+
33
+ // 第一步:找到可点击容器(有事件绑定的最近祖先)
34
+ var clickable = this._findClickableContainer(rawTarget)
35
+
36
+ // 第二步:从容器内容推断点击标识
37
+ var text = this._extractIdentity(clickable, rawTarget)
38
+
39
+ // 基于容器+文本的去重(同一个按钮 1s 内不重复上报)
40
+ var clickKey = (adapter.getXPath(clickable) || '') + '|' + (text || '')
41
+ var now = Date.now()
42
+ if (clickKey === this._lastClickKey && now - this._lastClickTime < 1000) return
43
+ this._lastClickKey = clickKey
44
+ this._lastClickTime = now
45
+
46
+ // 第三步:构造上报数据
47
+ var trackData = {
48
+ tagName: (clickable.tagName || '').toLowerCase(),
49
+ text: text,
50
+ xpath: adapter.getXPath(clickable),
51
+ pageX: e.pageX,
52
+ pageY: e.pageY,
53
+ }
54
+
55
+ if (clickable.id) trackData.containerId = clickable.id
56
+ if (clickable.dataset) {
57
+ if (clickable.dataset.trackId) trackData.trackId = clickable.dataset.trackId
58
+ if (clickable.dataset.trackName) trackData.trackName = clickable.dataset.trackName
59
+ if (clickable.dataset.trackExtKey) trackData.extKey = clickable.dataset.trackExtKey
60
+ if (clickable.dataset.trackExtValue) trackData.extValue = clickable.dataset.trackExtValue
61
+ if (clickable.dataset.trackExtInt) trackData.extIntValue = Number(clickable.dataset.trackExtInt)
62
+ if (clickable.dataset.trackParam) trackData.paramName = clickable.dataset.trackParam
63
+ // 脚本注入的 A 路线:收集所有 data-track-post-* 组装成 postParam(真实字段名)
64
+ var post = null
65
+ for (var dk in clickable.dataset) {
66
+ if (dk.indexOf('trackPost') === 0 && dk.length > 9) {
67
+ if (!post) post = {}
68
+ var fname = dk.charAt(9).toLowerCase() + dk.slice(10)
69
+ post[fname] = clickable.dataset[dk]
70
+ }
71
+ }
72
+ if (post) trackData.postParam = post
73
+ }
74
+ if (clickable.tagName === 'A' && clickable.href) {
75
+ trackData.href = clickable.href
76
+ }
77
+
78
+ var trackPayload = {
79
+ elementId: trackData.trackId || trackData.containerId || trackData.xpath || null,
80
+ elementText: trackData.text || null,
81
+ extraData: trackData,
82
+ }
83
+
84
+ if (trackData.extKey) trackPayload.extKey = trackData.extKey
85
+ if (trackData.extValue) trackPayload.extValue = trackData.extValue
86
+ if (trackData.extIntValue !== undefined) trackPayload.extIntValue = trackData.extIntValue
87
+ if (trackData.postParam) trackPayload.postParam = trackData.postParam
88
+ if (trackData.paramName) {
89
+ trackPayload._paramName = trackData.paramName
90
+ trackPayload._element = clickable
91
+ }
92
+
93
+ // 脚本注入的精准点位(有 data-track-id):上报成 custom 且塞 frontUrl,
94
+ // 与手动 $dadian 打点对齐 —— 令注册表(event_type=custom) 与运行时一致,
95
+ // 触发后端"element_id 命中 page_path 且 event_type 匹配"的双写。
96
+ // 普通自动采集点击(无 data-track-id)仍上报 click,不受影响。
97
+ if (trackData.trackId) {
98
+ trackPayload.frontUrl = trackData.trackId
99
+ trackPayload.eventName = trackData.trackId
100
+ this.tracker.track('custom', trackPayload)
101
+ } else {
102
+ this.tracker.track('click', trackPayload)
103
+ }
104
+ }
105
+
106
+ /**
107
+ * 检测点击是否在 tabbar/底部导航 区域内
108
+ * 优先查项目级 isTabbarClick(如店长端 .footers 检测),
109
+ * 再查通用级 isNativeTabbar(原生 uni-tabbar 检测)
110
+ */
111
+ _isInsideTabbar(el) {
112
+ var rules = this.tracker.getConfig().projectRules
113
+ if (rules && typeof rules.isTabbarClick === 'function') {
114
+ if (rules.isTabbarClick(null, el)) return true
115
+ }
116
+ if (rules && typeof rules.isNativeTabbar === 'function') {
117
+ return rules.isNativeTabbar(null, el)
118
+ }
119
+ return false
120
+ }
121
+
122
+ /**
123
+ * 找到可点击容器:从点击目标向上遍历,找到第一个有点击事件绑定的元素
124
+ */
125
+ _findClickableContainer(el) {
126
+ var current = el
127
+ var depth = 0
128
+
129
+ while (current && current !== document.body && depth < 15) {
130
+ // 有 data-track-id 标记的优先
131
+ if (current.dataset && current.dataset.trackId) return current
132
+
133
+ var tag = current.tagName ? current.tagName.toUpperCase() : ''
134
+
135
+ // 标准可交互元素
136
+ if (tag === 'A' || tag === 'BUTTON') return current
137
+
138
+ // [PROJECT-ADAPT] SVG容器策略
139
+ // 店长端使用内联SVG图标,点击事件在SVG父元素上
140
+ // 其他项目如使用 iconfont/image 图标则此规则可能不触发
141
+ if (tag === 'SVG') {
142
+ var svgParent = current.parentElement || current.parentNode
143
+ if (svgParent && svgParent !== document.body) return svgParent
144
+ return current
145
+ }
146
+
147
+ // [PROJECT-ADAPT] Vue2 事件检测
148
+ // 依赖 __vue__ 内部属性和 $listeners(Vue2 + uni-app H5 编译产物)
149
+ // Vue3 项目需改为检查 __vueParentComponent 或其他方式
150
+ if (current.__vue__) {
151
+ var vm = current.__vue__
152
+ if (vm.$listeners && vm.$listeners.click) return current
153
+ if (vm._events && vm._events.click && vm._events.click.length) return current
154
+ }
155
+
156
+ // 原生 onclick
157
+ if (current.onclick) return current
158
+
159
+ // 有 id 的元素通常是有意义的容器(如 #floating-button)
160
+ if (current.id && tag === 'DIV') return current
161
+
162
+ current = current.parentElement || current.parentNode
163
+ depth++
164
+ }
165
+
166
+ // 兜底:返回原始元素
167
+ return el
168
+ }
169
+
170
+ /**
171
+ * 从可点击容器的内容推断点击标识
172
+ * 优先级:文字 > tel链接 > image文件名 > uni-icons type > SVG id >
173
+ * placeholder > 表单label > aria-label > 背景图 > 容器id/class > 语义化xpath缩写
174
+ */
175
+ _extractIdentity(container, rawTarget) {
176
+ var text = ''
177
+
178
+ try {
179
+ // 1. 检查是否 <a href="tel:">
180
+ text = this._checkTelLink(container, rawTarget)
181
+ if (text) return text
182
+
183
+ // 2. 检查容器内是否有简短有意义的文字
184
+ text = this._getShortText(container)
185
+ if (text) return text
186
+
187
+ // 3. 检查 <image> 或 <img> 的 src 文件名
188
+ text = this._checkImageSrc(container)
189
+ if (text) return text
190
+
191
+ // 4. 检查 <uni-icons> 的 type 属性
192
+ text = this._checkUniIcons(container)
193
+ if (text) return text
194
+
195
+ // 5. 检查 SVG 内有意义的 <g id>
196
+ text = this._checkSvgId(container)
197
+ if (text) return text
198
+
199
+ // 6. 检查 input/textarea 的 placeholder
200
+ text = this._checkPlaceholder(container, rawTarget)
201
+ if (text) return text
202
+
203
+ // 7. 从表单行容器中找关联 label 文字
204
+ text = this._findFormLabel(container, rawTarget)
205
+ if (text) return text
206
+
207
+ // 8. 检查 aria-label / title 属性
208
+ text = this._checkAccessibility(container, rawTarget)
209
+ if (text) return text
210
+
211
+ // 9. 检查 CSS 背景图 URL 中的文件名
212
+ text = this._checkBackgroundImage(container)
213
+ if (text) return text
214
+
215
+ // 10. 容器自身的 id
216
+ if (container.id) return '[#' + container.id + ']'
217
+
218
+ // 11. 从 class 提取关键词
219
+ text = this._checkClassName(container)
220
+ if (text) return text
221
+
222
+ // 12. 最终 fallback:生成简短语义化位置描述
223
+ text = this._buildPositionHint(container, rawTarget)
224
+ if (text) return text
225
+
226
+ } catch (err) {}
227
+
228
+ return ''
229
+ }
230
+
231
+ /**
232
+ * 检查 tel: 链接
233
+ */
234
+ _checkTelLink(container, rawTarget) {
235
+ // 容器本身是 tel 链接
236
+ if (container.tagName === 'A' && container.href && container.href.indexOf('tel:') === 0) {
237
+ return '[拨打电话:' + container.href.replace('tel:', '') + ']'
238
+ }
239
+ // 从点击目标向上找 tel 链接
240
+ var current = rawTarget
241
+ var depth = 0
242
+ while (current && current !== container.parentElement && depth < 5) {
243
+ if (current.tagName === 'A' && current.href && current.href.indexOf('tel:') === 0) {
244
+ return '[拨打电话:' + current.href.replace('tel:', '') + ']'
245
+ }
246
+ current = current.parentElement
247
+ depth++
248
+ }
249
+ // 在容器内查找
250
+ var telLink = container.querySelector && container.querySelector('a[href^="tel:"]')
251
+ if (telLink && telLink.href) {
252
+ return '[拨打电话:' + telLink.href.replace('tel:', '') + ']'
253
+ }
254
+ return ''
255
+ }
256
+
257
+ /**
258
+ * 获取容器内的简短文字(排除图标字体等无意义字符)
259
+ */
260
+ _getShortText(container) {
261
+ var text = (container.innerText || container.textContent || '').trim()
262
+ // 排除空文本
263
+ if (!text) return ''
264
+ // 排除图标字体单字符(Unicode 私用区)
265
+ if (text.length === 1 && text.charCodeAt(0) > 0xe000) return ''
266
+ // [PROJECT-ADAPT] 设计工具命名过滤
267
+ // 当前规则基于 Sketch/Figma 中文命名导出,英文命名项目需调整
268
+ if (/^[\s\S]*(?:编组|矩形|备份|椭圆|路径)[\s\S]*$/.test(text) && text.indexOf('编组') > -1) return ''
269
+ // 文字太长说明是整个区域的内容,不是按钮文字
270
+ if (text.length > 30) return ''
271
+ return text.substring(0, 50)
272
+ }
273
+
274
+ /**
275
+ * 检查容器内 image/img 标签的 src,提取文件名
276
+ */
277
+ _checkImageSrc(container) {
278
+ var img = container.querySelector && (
279
+ container.querySelector('image[src]') || container.querySelector('img[src]')
280
+ )
281
+ if (!img) return ''
282
+ var src = img.getAttribute('src') || ''
283
+ // 提取文件名(去掉路径和后缀)
284
+ var match = src.match(/\/([^/]+)\.\w+$/)
285
+ if (match && match[1]) {
286
+ return '[img:' + match[1] + ']'
287
+ }
288
+ return ''
289
+ }
290
+
291
+ /**
292
+ * [PROJECT-ADAPT] 图标组件检测
293
+ * 当前支持: uni-icons / u-icon (uView)
294
+ * 其他项目如使用 vant-icon / iconfont 需扩展 selector
295
+ */
296
+ _checkUniIcons(container) {
297
+ var icon = container.querySelector && (
298
+ container.querySelector('uni-icons') ||
299
+ container.querySelector('[class*="uni-icons"]') ||
300
+ container.querySelector('u-icon') ||
301
+ container.querySelector('[class*="u-icon"]')
302
+ )
303
+ if (!icon) return ''
304
+ // 取 type 或 name 属性(uni-icons 用 type,u-icon 用 name)
305
+ var type = icon.getAttribute('type') || icon.getAttribute('name') || ''
306
+ if (!type) {
307
+ // 尝试从 Vue 实例取 props
308
+ if (icon.__vue__) {
309
+ type = icon.__vue__.type || icon.__vue__.name || ''
310
+ }
311
+ }
312
+ if (type) return '[icon:' + type + ']'
313
+ return ''
314
+ }
315
+
316
+ /**
317
+ * [PROJECT-ADAPT] SVG g[id] 识别规则
318
+ * ignorePattern: 过滤设计工具自动命名(中英文)
319
+ * 长度阈值 6: 基于店长端图标命名("返回""关闭"等2-4字)
320
+ * 其他项目需根据实际 SVG 资产调整 ignorePattern 和长度阈值
321
+ */
322
+ _checkSvgId(container) {
323
+ var svg = container.querySelector && container.querySelector('svg')
324
+ if (!svg) return ''
325
+ var gList = svg.querySelectorAll('g[id]')
326
+ var ignorePattern = /编组|矩形|备份|椭圆|路径|蒙版|形状|图层|Mask|Group|Rectangle|Path|Shape|Layer/i
327
+ var best = ''
328
+ for (var i = 0; i < gList.length; i++) {
329
+ var gId = gList[i].id || ''
330
+ if (!gId || ignorePattern.test(gId)) continue
331
+ // 跳过含括号的(设计工具图层描述)
332
+ if (/[(())]/.test(gId)) continue
333
+ // 跳过超长名称(设计工具自动命名通常很长)
334
+ if (gId.length > 6) continue
335
+ // 取最短的
336
+ if (!best || gId.length < best.length) {
337
+ best = gId
338
+ }
339
+ }
340
+ if (best) return '[svg:' + best + ']'
341
+ return ''
342
+ }
343
+
344
+ /**
345
+ * [PROJECT-ADAPT] class 关键词识别
346
+ * 当前关键词列表基于店长端 CSS 命名规范
347
+ * 其他项目如使用 BEM 或不同命名风格需扩展正则
348
+ */
349
+ _checkClassName(container) {
350
+ var cls = container.className || ''
351
+ if (typeof cls !== 'string') return ''
352
+ var match = cls.match(/\b(back|close|phone|call|delete|del|edit|search|submit|confirm|cancel|download|add|save|refresh|share|copy|more|menu|nav|tab|toggle|switch|check|select|input|login|register|send|pay|order|cart|collect|like|follow|btn[-_]?\w*|button[-_]?\w*)\b/i)
353
+ if (match) return '[.' + match[0] + ']'
354
+ return ''
355
+ }
356
+
357
+ /**
358
+ * 检查 input/textarea 的 placeholder 文本
359
+ * 用户点击输入框时,placeholder 提供了语义
360
+ */
361
+ _checkPlaceholder(container, rawTarget) {
362
+ // 直接点击的是 input/textarea
363
+ var input = null
364
+ if (rawTarget.tagName === 'INPUT' || rawTarget.tagName === 'TEXTAREA') {
365
+ input = rawTarget
366
+ } else {
367
+ // 容器内找 input
368
+ input = container.querySelector && (
369
+ container.querySelector('input[placeholder]') ||
370
+ container.querySelector('textarea[placeholder]')
371
+ )
372
+ }
373
+ if (input) {
374
+ var ph = input.getAttribute('placeholder')
375
+ if (ph && ph.trim()) return '[输入:' + ph.trim().substring(0, 20) + ']'
376
+ }
377
+ // uni-app 的 input 组件渲染为 uni-input,placeholder 在子元素上
378
+ var uniInput = container.querySelector && container.querySelector('uni-input')
379
+ if (!uniInput && container.tagName === 'UNI-INPUT') uniInput = container
380
+ if (uniInput) {
381
+ var phEl = uniInput.querySelector('.uni-input-placeholder')
382
+ if (phEl && phEl.textContent && phEl.textContent.trim()) {
383
+ return '[输入:' + phEl.textContent.trim().substring(0, 20) + ']'
384
+ }
385
+ // 尝试从 Vue 实例读 placeholder prop
386
+ if (uniInput.__vue__ && uniInput.__vue__.placeholder) {
387
+ return '[输入:' + uniInput.__vue__.placeholder.substring(0, 20) + ']'
388
+ }
389
+ }
390
+ return ''
391
+ }
392
+
393
+ /**
394
+ * 从表单行容器中找关联的 label 文字
395
+ * 典型结构:<view class="form-item"><text>直播标题</text><input/></view>
396
+ */
397
+ _findFormLabel(container, rawTarget) {
398
+ var FORM_TAGS = ['INPUT', 'TEXTAREA', 'SELECT',
399
+ 'UNI-INPUT', 'UNI-TEXTAREA', 'UNI-SWITCH', 'SWITCH',
400
+ 'UNI-CHECKBOX', 'CHECKBOX', 'RADIO', 'UNI-PICKER', 'PICKER']
401
+
402
+ var formEl = null
403
+ if (FORM_TAGS.indexOf(container.tagName) >= 0) {
404
+ formEl = container
405
+ } else if (FORM_TAGS.indexOf(rawTarget.tagName) >= 0) {
406
+ formEl = rawTarget
407
+ } else if (container.querySelector) {
408
+ for (var i = 0; i < FORM_TAGS.length; i++) {
409
+ var found = container.querySelector(FORM_TAGS[i].toLowerCase())
410
+ if (found) { formEl = found; break }
411
+ }
412
+ }
413
+ if (!formEl) return ''
414
+
415
+ // 向上找表单行容器
416
+ var ROW_PATTERN = /\b(form|item|row|cell|field|group|section|line|li)\b/i
417
+ var current = formEl.parentElement
418
+ var rowContainer = null
419
+ var depth = 0
420
+ while (current && current !== document.body && depth < 6) {
421
+ var cls = typeof current.className === 'string' ? current.className : ''
422
+ if (ROW_PATTERN.test(cls)) { rowContainer = current; break }
423
+ current = current.parentElement
424
+ depth++
425
+ }
426
+ if (!rowContainer) {
427
+ rowContainer = formEl.parentElement && formEl.parentElement.parentElement
428
+ }
429
+ if (!rowContainer) return ''
430
+
431
+ // 策略 1: 有 class 含 label/title 的子元素
432
+ var labelEl = rowContainer.querySelector && (
433
+ rowContainer.querySelector('[class*="label"]') ||
434
+ rowContainer.querySelector('[class*="title"]') ||
435
+ rowContainer.querySelector('label')
436
+ )
437
+ if (labelEl && (!formEl.contains || !formEl.contains(labelEl))) {
438
+ var t = (labelEl.innerText || labelEl.textContent || '').trim()
439
+ if (t && t.length <= 15) return t
440
+ }
441
+
442
+ // 策略 2: 表单元素之前的兄弟元素中找文字
443
+ var children = rowContainer.children
444
+ if (children) {
445
+ for (var j = 0; j < children.length; j++) {
446
+ var child = children[j]
447
+ if (child === formEl || (child.contains && child.contains(formEl))) break
448
+ var t2 = (child.innerText || child.textContent || '').trim()
449
+ if (t2 && t2.length > 0 && t2.length <= 15) return t2
450
+ }
451
+ }
452
+
453
+ // 策略 3: Vue 组件 props
454
+ if (formEl.__vue__) {
455
+ var vm = formEl.__vue__
456
+ var propLabel = vm.label || vm.title || ''
457
+ if (propLabel && typeof propLabel === 'string' && propLabel.length <= 15) return propLabel
458
+ }
459
+
460
+ return ''
461
+ }
462
+
463
+ /**
464
+ * 检查无障碍属性: aria-label, title
465
+ */
466
+ _checkAccessibility(container, rawTarget) {
467
+ // 从点击目标到容器逐级查找
468
+ var current = rawTarget
469
+ var depth = 0
470
+ while (current && current !== container.parentElement && depth < 5) {
471
+ var label = current.getAttribute && (
472
+ current.getAttribute('aria-label') ||
473
+ current.getAttribute('title') ||
474
+ current.getAttribute('alt')
475
+ )
476
+ if (label && label.trim() && label.length <= 30) {
477
+ return label.trim()
478
+ }
479
+ current = current.parentElement
480
+ depth++
481
+ }
482
+ return ''
483
+ }
484
+
485
+ /**
486
+ * 检查 CSS background-image 中的文件名
487
+ * 覆盖用图片做按钮背景但不用 <img> 标签的场景
488
+ */
489
+ _checkBackgroundImage(container) {
490
+ if (typeof getComputedStyle === 'undefined') return ''
491
+ // 检查容器及其直接子元素
492
+ var targets = [container]
493
+ if (container.children && container.children.length <= 3) {
494
+ for (var i = 0; i < container.children.length; i++) {
495
+ targets.push(container.children[i])
496
+ }
497
+ }
498
+ for (var j = 0; j < targets.length; j++) {
499
+ try {
500
+ var style = getComputedStyle(targets[j])
501
+ var bg = style.backgroundImage || ''
502
+ if (bg && bg !== 'none') {
503
+ var match = bg.match(/url\(["']?.*?\/([^/?"']+)\.\w+["']?\)/)
504
+ if (match && match[1]) {
505
+ var name = match[1]
506
+ // 过滤 base64 和过长/无意义的 hash 文件名
507
+ if (name.length > 32 || /^[a-f0-9]{16,}$/i.test(name)) return ''
508
+ return '[bg:' + name + ']'
509
+ }
510
+ }
511
+ } catch (e) {}
512
+ }
513
+ return ''
514
+ }
515
+
516
+ /**
517
+ * 最终 fallback:生成简短的位置描述
518
+ * 当所有识别方式都失败时,至少给出元素在页面中的大致位置
519
+ */
520
+ _buildPositionHint(container, rawTarget) {
521
+ var tag = (container.tagName || '').toLowerCase()
522
+ // 过滤纯布局标签,这些通常是误触
523
+ if (tag === 'body' || tag === 'uni-page-body' || tag === 'uni-page-wrapper') return ''
524
+
525
+ var hint = ''
526
+ // 尝试找到最近的有意义的祖先
527
+ var parent = container.parentElement
528
+ var depth = 0
529
+ while (parent && depth < 5) {
530
+ var parentCls = (parent.className && typeof parent.className === 'string') ? parent.className : ''
531
+ var parentId = parent.id
532
+ // 找到有意义的容器名称
533
+ if (parentId && parentId.length <= 20) {
534
+ hint = '#' + parentId
535
+ break
536
+ }
537
+ var clsMatch = parentCls.match(/\b(header|footer|sidebar|nav|menu|toolbar|modal|dialog|popup|card|list|item|cell|row|panel|section|content|wrapper|container|page)[-_]?(\w{0,10})\b/i)
538
+ if (clsMatch) {
539
+ hint = clsMatch[0]
540
+ break
541
+ }
542
+ parent = parent.parentElement
543
+ depth++
544
+ }
545
+
546
+ if (hint) return '[区域:' + hint + ']'
547
+ return ''
548
+ }
549
+
550
+ destroy() {
551
+ if (this._handler && this.tracker) {
552
+ this.tracker.getAdapter().removeClickListener(this._handler)
553
+ }
554
+ this._handler = null
555
+ this.tracker = null
556
+ }
557
+ }