@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.
- package/core/compat.js +344 -0
- package/core/config.js +132 -0
- package/core/queue.js +461 -0
- package/core/session.js +128 -0
- package/core/tracker.js +287 -0
- package/customize/index.js +92 -0
- package/customize/modules/common.js +43 -0
- package/customize/modules/store-manager.js +56 -0
- package/index.js +403 -0
- package/package.json +21 -0
- package/platforms/app.js +36 -0
- package/platforms/weapp.js +218 -0
- package/platforms/web.js +483 -0
- package/plugins/api-watch.js +240 -0
- package/plugins/app-lifecycle.js +90 -0
- package/plugins/click.js +557 -0
- package/plugins/exposure.js +376 -0
- package/plugins/js-error.js +232 -0
- package/plugins/page-stay.js +44 -0
- package/plugins/page-view.js +172 -0
- package/plugins/performance.js +157 -0
- package/plugins/scroll-depth.js +256 -0
package/plugins/click.js
ADDED
|
@@ -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
|
+
}
|