Commit 185027a0 by yuzhenWang

切换分支

parent cca1d392
...@@ -6,6 +6,10 @@ ...@@ -6,6 +6,10 @@
import api from './api/api'; import api from './api/api';
import {hshare} from '@/util/fiveshare'; import {hshare} from '@/util/fiveshare';
import dataHandling from "@/util/dataHandling"; import dataHandling from "@/util/dataHandling";
import { useTracker, syncTrackerFromQuery } from '@/util/tracker/index.js'
export default { export default {
data() { data() {
return { return {
...@@ -14,8 +18,10 @@ ...@@ -14,8 +18,10 @@
loginType:'' loginType:''
} }
}, },
onLaunch: function() { onLaunch: function(options) {
console.log('App Launch'); console.log('App Launch');
//操作日志上传:宿主 webview 外链带过来的 trackerSessionId / trackerVisitorCode 优先
syncTrackerFromQuery((options && options.query) || {})
if(uni.getStorageSync('loginType')){ if(uni.getStorageSync('loginType')){
this.loginType = uni.getStorageSync('loginType') this.loginType = uni.getStorageSync('loginType')
} }
...@@ -85,6 +91,7 @@ ...@@ -85,6 +91,7 @@
}, },
onHide: function() { onHide: function() {
console.log('App Hide') console.log('App Hide')
useTracker()?.flush(true) // 切后台立即上报
}, },
methods: { methods: {
......
import App from './App'; import App from './App';
import { scrmUrl } from "./environments/environment";
import { initTracker, trackerMixin } from '@/util/tracker/index.js'
import pagesJson from '@/pages.json'
import {
EVENTS, FUNCTIONS, MODULES,
buildTrackParams
} from '@/util/tracker/track-events'
// ====== 构建期直接从 pages.json 生成模块映射(一次执行,运行时零开销)======
function buildModuleMap() {
const map = {}
// // #ifndef VUE3 function add(path, meta = {}, title = '') {
// import Vue from 'vue' if (!path) return
// App.mpType = 'app' const key = path[0] === '/' ? path : '/' + path
// const app = new Vue({ map[key] = {
// ...App moduleCode: meta.moduleCode || '',
// }) moduleName: meta.moduleName || meta.moduleCode || '',
title: title || ''
}
}
function processPages(pages, root = '') {
(pages || []).forEach((page) => {
const style = page.style || {}
const prefix = root ? (root.endsWith('/') ? root : root + '/') : ''
add(prefix + (page.path || ''), style.meta || {}, style.navigationBarTitleText)
})
}
try {
processPages(pagesJson.pages)
;(pagesJson.subPackages || []).forEach((sub) => {
processPages(sub.pages, sub.root || '')
})
} catch (e) {
console.warn('[Tracker] 构建页面模块映射失败:', e)
}
return map
}
// app.$mount() // #ifndef VUE3
// // #endif import Vue from 'vue'
App.mpType = 'app'
// Vue 2 全局挂载操作日志
const trackerVue2 = initTracker({
enabled: true,
projectCode: 'CFFP',
api: `${scrmUrl}/scrm-api/log/client/batch/save`,
pageMetaMap: buildModuleMap() // ← 注入 pages.json 映射表
})
Vue.mixin(trackerMixin) // 三端通用:页面级自动采集
// 业务统一入口:传 funcCode 即可
Vue.prototype.$trackFunc = (funcCode, extra = {}, overrides = {}) =>
trackerVue2.trackFunc(funcCode, extra, overrides)
// 完全自定义入口
Vue.prototype.$track = (eventCode, params = {}) =>
trackerVue2.track(Object.assign({ eventCode }, params))
// 常量暴露,业务代码免 import
Vue.prototype.$EVENTS = EVENTS
Vue.prototype.$FUNCTIONS = FUNCTIONS
Vue.prototype.$MODULES = MODULES
const app = new Vue({
...App
})
app.$mount()
// #endif
// #ifdef VUE3 // #ifdef VUE3
import { createSSRApp } from 'vue' import { createSSRApp } from 'vue'
export function createApp() { export function createApp() {
const app = createSSRApp(App) const app = createSSRApp(App)
const tracker = initTracker({
enabled: true, // 操作日志上报给后端的全局开关,
projectCode: 'CFFP',
api: `${scrmUrl}/scrm-api/log/client/batch/save`,
pageMetaMap: buildModuleMap() // ← 注入 pages.json 映射表
})
app.mixin(trackerMixin) // 三端通用:页面级自动采集
// 业务统一入口:传 funcCode 即可
app.config.globalProperties.$trackFunc = (funcCode, extra = {}, overrides = {}) =>
tracker.trackFunc(funcCode, extra, overrides)
// 完全自定义入口
app.config.globalProperties.$track = (eventCode, params = {}) =>
tracker.track(Object.assign({ eventCode }, params))
// 常量暴露,业务代码免 import
app.config.globalProperties.$EVENTS = EVENTS
app.config.globalProperties.$FUNCTIONS = FUNCTIONS
app.config.globalProperties.$MODULES = MODULES
return { return {
app app
} }
......
import { resolvePageInfo } from './utils'
import { EVENTS, normalizeEventCode } from './track-events'
let _tracker = null
export function bindTracker(t) { _tracker = t }
const MP_EVENT_MAP = {
tap: EVENTS.CLICK,
longpress: EVENTS.CLICK,
change: EVENTS.INPUT,
blur: EVENTS.INPUT,
input: EVENTS.INPUT
}
/** 小程序里判断是不是输入框:靠 dataset 标记或占位符兜底 */
function isInputNode(node) {
if (!node || !node.dataset) return false
const ds = node.dataset
if (ds.trackType === 'input') return true
if (ds.trackInput === 'true') return true
// 兜底:没有 textContent、有 placeholder 的通常是 input
return !node.dataset.trackName && !node.dataset.trackId && false
}
function parseDataset(ds = {}, evtType = 'tap') {
let extraParams = {}
if (ds.trackExtra) {
try { extraParams = JSON.parse(ds.trackExtra) } catch (e) {}
}
let eventCode = ds.trackEvent || ''
if (!eventCode && !ds.func) {
eventCode = MP_EVENT_MAP[evtType] || EVENTS.CLICK
}
if (eventCode) eventCode = normalizeEventCode(eventCode)
return {
eventCode,
funcCode: ds.func || '',
moduleCode: ds.trackModule || '',
actionTargetType: ds.trackType || 'button',
actionTarget: ds.trackId || '',
actionTargetName: ds.trackName || '',
extraParams
}
}
export const trackerMixin = {
onShow() { _tracker && _tracker.enterPage() }, //this
onHide() { _tracker && _tracker.leavePage('hide') },
onUnload() { _tracker && _tracker.leavePage('unload') },
methods: {
__trackTap(e) {
const t = _tracker
if (!t || !t.enabled) return
const ds = (e.currentTarget && e.currentTarget.dataset) || {}
// ★★★ 关键:输入框的 tap 跳过,等 __trackBlur 再上报 ★★★
if (ds.trackType === 'input' || ds.trackInput === 'true') return
// 无任何标注的点击不采集
if (!ds.trackId && !ds.trackName && !ds.trackType && !ds.func) return
t.track(parseDataset(ds, 'tap'))
},
__trackBlur(e) {
const t = _tracker
if (!t || !t.enabled) return
const ds = (e.currentTarget && e.currentTarget.dataset) || {}
const val = (e.detail && e.detail.value) || ''
// ★ 值没变化不报(空点一下再离开)
const key = e.currentTarget
if (!key._trackLastVal === undefined) key._trackLastVal = undefined
if (key._trackLastVal === val) return
key._trackLastVal = val
const info = parseDataset(ds, 'blur')
info.eventCode = EVENTS.INPUT
info.actionTargetType = 'input'
info.actionTargetName = ds.trackName || ds.placeholder || '输入框'
info.extraParams.valueLen = val.length
t.track(info)
},
$trackFunc(funcCode, extra = {}, overrides = {}) {
_tracker && _tracker.trackFunc(funcCode, extra, overrides)
}
}
}
export function setupMpCollector(tracker) { bindTracker(tracker) }
\ No newline at end of file
export default {
enabled: false, // 全局开关(可由后端配置下发动态修改)
projectCode: 'CFFP',
api: '/api/log/batch',
batchSize: 10, // 攒多少条触发上报
maxBatch: 50, // 单次请求最多携带条数
flushInterval: 5000, // 定时上报间隔 ms
maxQueue: 200, // 队列上限
maxRetry: 3, // 失败重试次数
sessionTimeout: 30 * 60 * 1000, // 30 分钟无操作开新会话
sampleRate: 1, // 采样率 0~1
maxExtraSize: 2048, // extraParams 最大字节
dedupeWindow: 300, // 元素去重窗口 ms
initDelay: 800 ,// 延迟初始化 ms
reportPageLeave: false, // ← 是否上报 PAGE_LEAVE(默认 false = 不上报)
keepStayTime: true,// ← 关闭 LEAVE 时,是否把停留时长挂到下一次 PAGE_VIEW
// H5:全自动埋点(点击/输入由 DOM 采集器自动上报,页面无需写任何埋点代码)
// APP / 小程序:无可靠 DOM,维持手动埋点
// #ifdef H5
autoClickCapture: true,
// #endif
// #ifndef H5
autoClickCapture: false,
// #endif
}
\ No newline at end of file
import Tracker from './core'
import config from './config'
import { setupDomCollector } from './collector-dom'
import { setupMpCollector, trackerMixin, bindTracker } from './collector-mp'
import { syncTrackerIdentity } from './utils'
let _instance = null
export function initTracker(options = {}) {
if (_instance) return _instance
Object.assign(config, options)
_instance = new Tracker(config)
bindTracker(_instance)
// 延迟初始化,避免抢占首屏资源
setTimeout(() => {
// #ifdef H5 || APP-PLUS
setupDomCollector(_instance)
// #endif
// #ifdef MP
setupMpCollector(_instance)
// #endif
}, config.initDelay)
return _instance
}
/**
* 宿主(微信小程序 / APP 的 webview)通过 URL 带入 trackerSessionId / trackerVisitorCode 时调用。
* 优先级:入参 query > URL 参数。实例未初始化时先落缓存,初始化时自动读取。
* @param {Object} query 一般传 App.vue onLaunch 的 options.query
*/
export function syncTrackerFromQuery(query = {}) {
const { visitorCode, sessionId } = syncTrackerIdentity({
visitorCode: query.trackerVisitorCode || '',
sessionId: query.trackerSessionId || ''
})
if (!visitorCode && !sessionId) return false
if (_instance) _instance.setIdentity({ visitorCode, sessionId })
return true
}
export function useTracker() { return _instance }
export function setTrackerEnabled(v) { _instance && _instance.setEnabled(v) }
export { trackerMixin }
/**
* 手动埋点统一入口(业务页面唯一该调的埋点 API)
* ============================================================
* 定位:业务层 → 上报协议 的防腐层。这里只做三件事:
* 1. 收口参数(funcCode / eventCode / actionTarget* / remark 的组装规则)
* 2. 统一 try/catch —— 埋点异常绝不允许打断业务(跳转、赋值、请求)
* 3. 统一口径 —— 敏感值(金额/手机号等)只传长度,不传明文
*
* 不在这里做的事:队列、批量、重试、sendBeacon —— 那些在 core.js
*
* 用法:
* trackClick(FUNCTIONS.TOOLS_PENSION, 'pension_calc', '开始计算')
* trackField(FUNCTIONS.TOOLS_PENSION, 'monthPaymentWage', val, { fieldName: '本年月缴费工资', actionType: 'input' })
* trackView(FUNCTIONS.TOOLS_PENSION_DETAIL, 'pension_result', '养老金测算结果', { hasResult: '1' })
* trackEvent(FUNCTIONS.WECHAT_LOGIN, { target: 'login_phone_success', name: '手机号快捷登录成功' }) // eventCode 由 funcCode 推导
*/
import { useTracker } from './index'
import { EVENTS } from './track-events'
function _report(funcCode, extra, overrides) {
try {
const t = useTracker()
if (!t || !t.enabled) return
t.trackFunc(funcCode, extra, overrides)
} catch (e) {
console.warn('[Tracker] 手动埋点上报失败', e)
}
}
/**
* 底层入口:eventCode 不传时沿用 core 的推导规则(funcCode → FUNCTION_EVENT_MAP)
* @param {string} funcCode FUNCTIONS.xxx
* @param {Object} opts { target, name, targetType, eventCode, extra, remark }
*/
export function trackEvent(funcCode, opts = {}) {
try {
const { target = '', name = '', targetType = 'button', eventCode, extra = {}, remark } = opts
const overrides = {
actionTargetType: targetType,
actionTarget: target,
actionTargetName: name || target,
remark: remark || name
}
if (eventCode) overrides.eventCode = eventCode
_report(funcCode, Object.assign({ actionTarget: target }, extra), overrides)
} catch (e) {
console.warn('[Tracker] 手动埋点上报失败', e)
}
}
/**
* 点击类:按钮、图标、展开收起等
*/
export function trackClick(funcCode, target, name, extra = {}) {
trackEvent(funcCode, {
target,
name,
targetType: 'button',
eventCode: EVENTS.CLICK,
extra,
remark: `点击${name}`
})
}
/**
* 值变更类:下拉选中、输入框失焦
* @param {*} value 用户输入/选择的值(不会明文上报,只取长度)
* @param {Object} opts { fieldName, actionType: 'select'|'input', target, extra, includeValue }
* includeValue=true 时才把明文放进 extraParams.value(仅限非敏感字段)
*/
export function trackField(funcCode, field, value, opts = {}) {
try {
const { fieldName = field, actionType = 'select', target, extra = {}, includeValue = false } = opts
const v = value === undefined || value === null ? '' : String(value)
const payload = Object.assign({
field,
actionType,
valueLen: v.length,
hasValue: v ? '1' : '0'
}, includeValue ? { value: v } : {}, extra)
trackEvent(funcCode, {
target: target || `field_${field}`,
name: fieldName,
targetType: actionType === 'input' ? 'input' : 'picker',
eventCode: EVENTS.INPUT,
extra: payload,
remark: `${actionType === 'input' ? '编辑' : '选择'}${fieldName}`
})
} catch (e) {
console.warn('[Tracker] 手动埋点上报失败', e)
}
}
/**
* 曝光类:详情页/结果页展示
*/
export function trackView(funcCode, target, name, extra = {}, targetType = 'page') {
trackEvent(funcCode, {
target,
name,
targetType,
eventCode: EVENTS.VIEW_DETAIL,
extra,
remark: `查看${name}`
})
}
let _device = null
let _clientType = null
export function formatTime(ts = Date.now()) {
const d = new Date(ts)
const p = (n, l = 2) => String(n).padStart(l, '0')
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ` +
`${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
}
/** 设备信息只取一次,同步 API 不重复调用 */
export function getDeviceInfo() {
if (_device) return _device
const info = { platform: '', osVersion: '', brand: '', model: '', screenWidth: 0, screenHeight: 0 }
try {
const s = uni.getSystemInfoSync()
info.platform = s.platform || ''
info.osVersion = s.osVersion || ''
info.brand = s.brand || ''
info.model = s.model || ''
info.screenWidth = s.screenWidth || 0
info.screenHeight = s.screenHeight || 0
} catch (e) {
console.warn('[Tracker] 获取设备信息失败:', e)
}
_device = info
return info
}
export function detectClientType() {
if (_clientType) return _clientType
let type = 'UNKNOWN'
// #ifdef APP-PLUS
try {
type = 'APP_' + (plus.os.name === 'iOS' ? 'IOS' : 'ANDROID')
} catch (e) { type = 'APP' }
// #endif
// #ifdef MP-WEIXIN
type = 'MP'
// #endif
// #ifdef H5
const ua = (navigator.userAgent || '').toLowerCase()
if (ua.includes('micromessenger')) {
type = 'MP'
} else {
const isMobile = /android|iphone|ipad|ipod|blackberry|windows phone/i.test(ua)
type = isMobile ? 'H5' : 'PC'
}
// #endif
_clientType = type
return type
}
export const STORAGE_KEY_VISITOR = 'tracker_visitor_code'
export const STORAGE_KEY_SESSION = 'tracker_sessionId'
/**
* 读取 URL 上的 query 参数(宿主 webview 内嵌时由外部传入)
* 同时兼容 hash 路由:/#/pages/index?a=1 与 /?a=1#/pages/index
*/
export function getUrlParam(name) {
// #ifdef H5
try {
const raws = []
const search = (typeof window !== 'undefined' && window.location.search) || ''
const hash = (typeof window !== 'undefined' && window.location.hash) || ''
if (search) raws.push(search)
const i = hash.indexOf('?')
if (i > -1) raws.push(hash.slice(i))
const reg = new RegExp('[?&]' + name + '=([^&]*)')
for (let k = 0; k < raws.length; k++) {
const m = raws[k].match(reg)
if (m && m[1]) return decodeURIComponent(m[1])
}
} catch (e) {}
// #endif
// #ifdef APP-PLUS
try {
const wv = typeof plus !== 'undefined' && plus.webview && plus.webview.currentWebview()
const q = (wv && wv.query) || {}
if (q[name]) return String(q[name])
} catch (e) {}
// #endif
return ''
}
/**
* 运行时同步身份标识(优先级:显式入参 > URL 参数)
* @returns {Object} 最终生效的 { visitorCode, sessionId }(未变更的字段为 '')
*/
export function syncTrackerIdentity({ visitorCode = '', sessionId = '' } = {}) {
const code = visitorCode || getUrlParam('trackerVisitorCode')
const sess = sessionId || getUrlParam('trackerSessionId')
if (code) uni.setStorageSync(STORAGE_KEY_VISITOR, code)
if (sess) uni.setStorageSync(STORAGE_KEY_SESSION, sess)
return { visitorCode: code, sessionId: sess }
}
export function getVisitorCode() {
// URL 参数优先,且必须覆盖旧缓存(宿主每次外链都会带最新值)
let code = getUrlParam('trackerVisitorCode') || uni.getStorageSync(STORAGE_KEY_VISITOR)
if (!code) code = 'vis_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8)
uni.setStorageSync(STORAGE_KEY_VISITOR, code)
return code
}
/**
* 生成/读取 sessionId
* @param {boolean} forceNew true = 强制生成新会话(空闲超时续期用,忽略 URL/缓存)
* false = URL 参数 > 缓存 > 生成
*/
export function genSessionId(forceNew = false) {
if (!forceNew) {
const fromUrl = getUrlParam('trackerSessionId')
if (fromUrl) {
uni.setStorageSync(STORAGE_KEY_SESSION, fromUrl)
return fromUrl
}
const stored = uni.getStorageSync(STORAGE_KEY_SESSION)
if (stored) return stored
}
const sessionId = 'sess_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6)
uni.setStorageSync(STORAGE_KEY_SESSION, sessionId)
return sessionId
}
/**
* 三端通用:解析当前页面的 route / title / moduleCode / moduleName
* @param {Object} page 可选,页面实例
* @param {Object} pageMetaMap 可选,main.js 注入的 pages.json 映射表
*/
export function resolvePageInfo(page, pageMetaMap = {}) {
let route = ''
let pageInstance = page
try {
const pages = getCurrentPages()
const p = page || pages[pages.length - 1]
if (p) {
pageInstance = p
route = p.route || (p.$page && p.$page.path) || ''
}
} catch (e) {}
if (!route) return { url: '', title: '', moduleCode: '', moduleName: '' }
const key = route[0] === '/' ? route : '/' + route
// 1. 运行时 $page.meta(部分端/场景可用,作为覆盖)
let runtimeMeta = {}
try {
runtimeMeta = (pageInstance && pageInstance.$page && pageInstance.$page.meta) || {}
} catch (e) {}
// 2. main.js 注入的 pages.json 映射
const cfg = pageMetaMap[key] || {}
return {
url: key,
title: runtimeMeta.navigationBarTitleText || cfg.title || '',
moduleCode: runtimeMeta.moduleCode || cfg.moduleCode || '',
moduleName: runtimeMeta.moduleName || cfg.moduleName || ''
}
}
/** 安全 JSON 序列化(带大小校验) */
export function safeStringify(obj, maxSize) {
try {
const s = JSON.stringify(obj || {})
return s.length > maxSize ? null : s
} catch (e) {
return null
}
}
// #ifdef H5
export function sendBeacon(url, payload) {
try {
if (navigator.sendBeacon) {
const blob = new Blob([JSON.stringify(payload)], { type: 'application/json' })
return navigator.sendBeacon(url, blob)
}
} catch (e) {}
return false
}
// #endif
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment