12.4 概念模块功能完善
This commit is contained in:
72
src/utils/posthog/constants.js
Normal file
72
src/utils/posthog/constants.js
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* 存储Key常量
|
||||
*/
|
||||
export const STORAGE_KEYS = {
|
||||
// 用户标识
|
||||
DISTINCT_ID: 'ph_distinct_id',
|
||||
ANONYMOUS_ID: 'ph_anonymous_id',
|
||||
|
||||
// 会话
|
||||
SESSION_ID: 'ph_session_id',
|
||||
SESSION_START_TIME: 'ph_session_start_time',
|
||||
LAST_ACTIVE_TIME: 'ph_last_active_time',
|
||||
|
||||
// 离线事件缓存
|
||||
OFFLINE_EVENTS: 'ph_offline_events',
|
||||
|
||||
// 用户属性缓存
|
||||
USER_PROPERTIES: 'ph_user_properties',
|
||||
|
||||
// 设备ID
|
||||
DEVICE_ID: 'ph_device_id',
|
||||
}
|
||||
|
||||
/**
|
||||
* PostHog保留事件名
|
||||
*/
|
||||
export const RESERVED_EVENTS = {
|
||||
IDENTIFY: '$identify',
|
||||
PAGE_VIEW: '$pageview',
|
||||
PAGE_LEAVE: '$pageleave',
|
||||
AUTOCAPTURE: '$autocapture',
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认配置
|
||||
*/
|
||||
export const DEFAULT_CONFIG = {
|
||||
// 队列配置
|
||||
queue: {
|
||||
maxSize: 20,
|
||||
flushInterval: 30000,
|
||||
},
|
||||
|
||||
// 请求配置
|
||||
request: {
|
||||
timeout: 10000,
|
||||
maxRetries: 3,
|
||||
retryDelay: 1000,
|
||||
},
|
||||
|
||||
// 会话配置
|
||||
session: {
|
||||
timeout: 30 * 60 * 1000, // 30分钟
|
||||
},
|
||||
|
||||
// 离线缓存配置
|
||||
offline: {
|
||||
maxEvents: 100,
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP状态码
|
||||
*/
|
||||
export const HTTP_STATUS = {
|
||||
OK: 200,
|
||||
CREATED: 201,
|
||||
BAD_REQUEST: 400,
|
||||
UNAUTHORIZED: 401,
|
||||
NOT_FOUND: 404,
|
||||
SERVER_ERROR: 500,
|
||||
}
|
||||
289
src/utils/posthog/core.js
Normal file
289
src/utils/posthog/core.js
Normal file
@@ -0,0 +1,289 @@
|
||||
import http from './http'
|
||||
import storage from './storage'
|
||||
import device from './device'
|
||||
import identity from './identity'
|
||||
import session from './session'
|
||||
import queue from './queue'
|
||||
import { RESERVED_EVENTS } from './constants'
|
||||
|
||||
/**
|
||||
* PostHog Analytics 核心类
|
||||
*/
|
||||
class PostHogCore {
|
||||
constructor() {
|
||||
this.initialized = false
|
||||
this.config = null
|
||||
this.debug = false
|
||||
this.superProperties = {} // 全局公共属性
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化SDK
|
||||
*/
|
||||
init(config) {
|
||||
if (this.initialized) {
|
||||
this._log('Already initialized')
|
||||
return this
|
||||
}
|
||||
|
||||
this.config = config
|
||||
this.debug = config.debug || false
|
||||
|
||||
// 初始化各模块
|
||||
http.init(config)
|
||||
device.init()
|
||||
identity.init()
|
||||
session.init(config)
|
||||
queue.init(config)
|
||||
|
||||
this.initialized = true
|
||||
this._log('Initialized with config:', config)
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始会话(小程序启动/恢复时调用)
|
||||
*/
|
||||
startSession() {
|
||||
const result = session.start()
|
||||
|
||||
if (result.isNewSession) {
|
||||
this.track('session_start', {
|
||||
session_id: result.sessionId,
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 结束会话(小程序退出/挂起时调用)
|
||||
*/
|
||||
endSession() {
|
||||
const result = session.end()
|
||||
|
||||
if (result) {
|
||||
this.track('session_end', {
|
||||
session_id: result.sessionId,
|
||||
session_duration: result.duration,
|
||||
})
|
||||
|
||||
// 强制刷新队列
|
||||
queue.forceFlush()
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置用户标识(登录时调用)
|
||||
*/
|
||||
identify(userId, userProperties = {}) {
|
||||
this._checkInit()
|
||||
|
||||
const identifyData = identity.identify(userId, userProperties)
|
||||
|
||||
// 发送$identify事件
|
||||
const event = {
|
||||
event: RESERVED_EVENTS.IDENTIFY,
|
||||
distinct_id: identifyData.distinctId,
|
||||
properties: {
|
||||
...this._getCommonProperties(),
|
||||
...identifyData.properties,
|
||||
},
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
|
||||
queue.add(event)
|
||||
this._log('User identified:', userId)
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置用户标识(登出时调用)
|
||||
*/
|
||||
reset() {
|
||||
this._checkInit()
|
||||
|
||||
identity.reset()
|
||||
session.reset()
|
||||
this.superProperties = {}
|
||||
|
||||
this._log('User reset')
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* 追踪事件
|
||||
*/
|
||||
track(eventName, properties = {}) {
|
||||
this._checkInit()
|
||||
|
||||
const event = {
|
||||
event: eventName,
|
||||
distinct_id: identity.getDistinctId(),
|
||||
properties: {
|
||||
...this._getCommonProperties(),
|
||||
...this.superProperties,
|
||||
...properties,
|
||||
},
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
|
||||
queue.add(event)
|
||||
this._log('Event tracked:', eventName, properties)
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* 追踪页面浏览
|
||||
*/
|
||||
trackPageView(pagePath, pageTitle = '', properties = {}) {
|
||||
return this.track(RESERVED_EVENTS.PAGE_VIEW, {
|
||||
$current_url: pagePath,
|
||||
$title: pageTitle,
|
||||
page_path: pagePath,
|
||||
page_title: pageTitle,
|
||||
...properties,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 追踪页面离开
|
||||
*/
|
||||
trackPageLeave(pagePath, duration = 0, properties = {}) {
|
||||
return this.track(RESERVED_EVENTS.PAGE_LEAVE, {
|
||||
$current_url: pagePath,
|
||||
page_path: pagePath,
|
||||
duration: duration,
|
||||
...properties,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置全局公共属性
|
||||
*/
|
||||
setSuperProperties(properties) {
|
||||
this.superProperties = {
|
||||
...this.superProperties,
|
||||
...properties,
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除全局公共属性
|
||||
*/
|
||||
clearSuperProperties() {
|
||||
this.superProperties = {}
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置用户属性
|
||||
*/
|
||||
setUserProperties(properties) {
|
||||
this._checkInit()
|
||||
|
||||
identity.setUserProperties(properties)
|
||||
|
||||
// 发送更新用户属性的事件
|
||||
this.track(RESERVED_EVENTS.IDENTIFY, {
|
||||
$set: properties,
|
||||
})
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置仅首次生效的用户属性
|
||||
*/
|
||||
setUserPropertiesOnce(properties) {
|
||||
this._checkInit()
|
||||
|
||||
this.track(RESERVED_EVENTS.IDENTIFY, {
|
||||
$set_once: properties,
|
||||
})
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取公共属性
|
||||
*/
|
||||
_getCommonProperties() {
|
||||
const deviceProps = device.getProperties()
|
||||
const sessionInfo = session.getInfo()
|
||||
|
||||
return {
|
||||
...deviceProps,
|
||||
session_id: sessionInfo.sessionId,
|
||||
$timestamp: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新活跃时间
|
||||
*/
|
||||
touch() {
|
||||
session.touch()
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制刷新事件队列
|
||||
*/
|
||||
flush() {
|
||||
queue.flush()
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户ID
|
||||
*/
|
||||
getDistinctId() {
|
||||
return identity.getDistinctId()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话ID
|
||||
*/
|
||||
getSessionId() {
|
||||
return session.getSessionId()
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否已初始化
|
||||
*/
|
||||
_checkInit() {
|
||||
if (!this.initialized) {
|
||||
console.warn('[PostHog] SDK not initialized. Call init() first.')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 调试日志
|
||||
*/
|
||||
_log(...args) {
|
||||
if (this.debug) {
|
||||
console.log('[PostHog Core]', ...args)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁SDK
|
||||
*/
|
||||
destroy() {
|
||||
queue.destroy()
|
||||
this.initialized = false
|
||||
this._log('Destroyed')
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
export default new PostHogCore()
|
||||
205
src/utils/posthog/device.js
Normal file
205
src/utils/posthog/device.js
Normal file
@@ -0,0 +1,205 @@
|
||||
import storage from './storage'
|
||||
import { STORAGE_KEYS } from './constants'
|
||||
|
||||
/**
|
||||
* 设备信息采集模块
|
||||
*/
|
||||
class Device {
|
||||
constructor() {
|
||||
this.info = null
|
||||
this.deviceId = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化设备信息
|
||||
*/
|
||||
init() {
|
||||
this._collectDeviceInfo()
|
||||
this._ensureDeviceId()
|
||||
}
|
||||
|
||||
/**
|
||||
* 采集设备信息
|
||||
*/
|
||||
_collectDeviceInfo() {
|
||||
try {
|
||||
const systemInfo = uni.getSystemInfoSync()
|
||||
|
||||
this.info = {
|
||||
// 操作系统
|
||||
os: systemInfo.osName || systemInfo.platform || 'unknown',
|
||||
osVersion: systemInfo.osVersion || 'unknown',
|
||||
|
||||
// 设备信息
|
||||
brand: systemInfo.brand || 'unknown',
|
||||
model: systemInfo.model || 'unknown',
|
||||
deviceType: this._getDeviceType(systemInfo),
|
||||
|
||||
// 屏幕信息
|
||||
screenWidth: systemInfo.screenWidth || 0,
|
||||
screenHeight: systemInfo.screenHeight || 0,
|
||||
windowWidth: systemInfo.windowWidth || 0,
|
||||
windowHeight: systemInfo.windowHeight || 0,
|
||||
pixelRatio: systemInfo.pixelRatio || 1,
|
||||
|
||||
// 小程序信息
|
||||
appVersion: systemInfo.appVersion || 'unknown',
|
||||
appLanguage: systemInfo.appLanguage || systemInfo.language || 'zh-CN',
|
||||
SDKVersion: systemInfo.SDKVersion || 'unknown',
|
||||
|
||||
// 平台信息
|
||||
platform: systemInfo.uniPlatform || 'mp-weixin',
|
||||
hostName: systemInfo.hostName || 'unknown',
|
||||
|
||||
// 网络类型(初始值,后续会更新)
|
||||
networkType: 'unknown',
|
||||
}
|
||||
|
||||
// 异步获取网络类型
|
||||
this._updateNetworkType()
|
||||
} catch (error) {
|
||||
console.warn('[PostHog Device] Collect info error:', error)
|
||||
this.info = this._getDefaultInfo()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取设备类型
|
||||
*/
|
||||
_getDeviceType(systemInfo) {
|
||||
const model = (systemInfo.model || '').toLowerCase()
|
||||
if (model.includes('ipad') || model.includes('tablet')) {
|
||||
return 'tablet'
|
||||
}
|
||||
if (model.includes('iphone') || model.includes('android')) {
|
||||
return 'mobile'
|
||||
}
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取默认设备信息
|
||||
*/
|
||||
_getDefaultInfo() {
|
||||
return {
|
||||
os: 'unknown',
|
||||
osVersion: 'unknown',
|
||||
brand: 'unknown',
|
||||
model: 'unknown',
|
||||
deviceType: 'unknown',
|
||||
screenWidth: 0,
|
||||
screenHeight: 0,
|
||||
windowWidth: 0,
|
||||
windowHeight: 0,
|
||||
pixelRatio: 1,
|
||||
appVersion: 'unknown',
|
||||
appLanguage: 'zh-CN',
|
||||
SDKVersion: 'unknown',
|
||||
platform: 'mp-weixin',
|
||||
hostName: 'unknown',
|
||||
networkType: 'unknown',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新网络类型
|
||||
*/
|
||||
_updateNetworkType() {
|
||||
uni.getNetworkType({
|
||||
success: (res) => {
|
||||
if (this.info) {
|
||||
this.info.networkType = res.networkType || 'unknown'
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保设备ID存在
|
||||
*/
|
||||
_ensureDeviceId() {
|
||||
let deviceId = storage.get(STORAGE_KEYS.DEVICE_ID)
|
||||
|
||||
if (!deviceId) {
|
||||
deviceId = this._generateDeviceId()
|
||||
storage.set(STORAGE_KEYS.DEVICE_ID, deviceId)
|
||||
}
|
||||
|
||||
this.deviceId = deviceId
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成设备ID
|
||||
*/
|
||||
_generateDeviceId() {
|
||||
const timestamp = Date.now().toString(36)
|
||||
const random = Math.random().toString(36).substring(2, 10)
|
||||
return `device_${timestamp}_${random}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取设备ID
|
||||
*/
|
||||
getDeviceId() {
|
||||
return this.deviceId
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取设备信息
|
||||
*/
|
||||
getInfo() {
|
||||
return this.info || this._getDefaultInfo()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取PostHog格式的设备属性
|
||||
*/
|
||||
getProperties() {
|
||||
const info = this.getInfo()
|
||||
return {
|
||||
$os: info.os,
|
||||
$os_version: info.osVersion,
|
||||
$device_type: info.deviceType,
|
||||
$device_id: this.deviceId,
|
||||
$screen_width: info.screenWidth,
|
||||
$screen_height: info.screenHeight,
|
||||
$viewport_width: info.windowWidth,
|
||||
$viewport_height: info.windowHeight,
|
||||
$lib: 'uniapp-posthog',
|
||||
$lib_version: '1.0.0',
|
||||
|
||||
// 扩展属性
|
||||
mp_platform: info.platform,
|
||||
mp_sdk_version: info.SDKVersion,
|
||||
device_brand: info.brand,
|
||||
device_model: info.model,
|
||||
network_type: info.networkType,
|
||||
app_language: info.appLanguage,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听网络状态变化
|
||||
*/
|
||||
onNetworkChange(callback) {
|
||||
uni.onNetworkStatusChange((res) => {
|
||||
if (this.info) {
|
||||
this.info.networkType = res.networkType || 'unknown'
|
||||
}
|
||||
if (typeof callback === 'function') {
|
||||
callback(res)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否在线
|
||||
*/
|
||||
isOnline() {
|
||||
const networkType = this.info?.networkType || 'unknown'
|
||||
return networkType !== 'none' && networkType !== 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
export default new Device()
|
||||
229
src/utils/posthog/error-tracker.js
Normal file
229
src/utils/posthog/error-tracker.js
Normal file
@@ -0,0 +1,229 @@
|
||||
import analytics from './index'
|
||||
import { ERROR_EVENTS } from '../../constants/events'
|
||||
|
||||
/**
|
||||
* 错误追踪器
|
||||
*/
|
||||
class ErrorTracker {
|
||||
constructor() {
|
||||
this.initialized = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化错误监控
|
||||
*/
|
||||
init() {
|
||||
if (this.initialized) return
|
||||
this.initialized = true
|
||||
|
||||
// 监听小程序全局错误
|
||||
this._setupGlobalErrorHandler()
|
||||
|
||||
console.log('[ErrorTracker] Initialized')
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置全局错误处理
|
||||
*/
|
||||
_setupGlobalErrorHandler() {
|
||||
// 监听小程序错误
|
||||
uni.onError((error) => {
|
||||
this.trackJSError({
|
||||
message: error,
|
||||
source: 'uni.onError',
|
||||
})
|
||||
})
|
||||
|
||||
// 监听未处理的Promise错误
|
||||
uni.onUnhandledRejection?.((res) => {
|
||||
this.trackJSError({
|
||||
message: res.reason?.message || String(res.reason),
|
||||
stack: res.reason?.stack,
|
||||
source: 'unhandledRejection',
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 追踪API错误
|
||||
* @param {object} error - 错误信息
|
||||
*/
|
||||
trackApiError(error) {
|
||||
const {
|
||||
path,
|
||||
method = 'GET',
|
||||
statusCode,
|
||||
message,
|
||||
duration,
|
||||
params,
|
||||
} = error
|
||||
|
||||
analytics.track(ERROR_EVENTS.API_ERROR, {
|
||||
api_path: path,
|
||||
api_method: method,
|
||||
error_code: statusCode,
|
||||
error_message: message,
|
||||
request_duration: duration,
|
||||
// 脱敏后的参数
|
||||
request_params: this._sanitizeParams(params),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 追踪JS错误
|
||||
* @param {object} error - 错误信息
|
||||
*/
|
||||
trackJSError(error) {
|
||||
const {
|
||||
message,
|
||||
stack,
|
||||
file,
|
||||
line,
|
||||
column,
|
||||
source = 'unknown',
|
||||
} = error
|
||||
|
||||
analytics.track(ERROR_EVENTS.JS_ERROR, {
|
||||
error_message: this._truncate(message, 500),
|
||||
error_stack: this._truncate(stack, 1000),
|
||||
error_file: file,
|
||||
error_line: line,
|
||||
error_column: column,
|
||||
error_source: source,
|
||||
page_path: this._getCurrentPagePath(),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 追踪页面错误
|
||||
* @param {object} error - 错误信息
|
||||
*/
|
||||
trackPageError(error) {
|
||||
const {
|
||||
type,
|
||||
message,
|
||||
pagePath,
|
||||
} = error
|
||||
|
||||
analytics.track(ERROR_EVENTS.PAGE_ERROR, {
|
||||
error_type: type,
|
||||
error_message: message,
|
||||
page_path: pagePath || this._getCurrentPagePath(),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 追踪网络错误
|
||||
* @param {object} error - 错误信息
|
||||
*/
|
||||
trackNetworkError(error) {
|
||||
const {
|
||||
type = 'unknown',
|
||||
message,
|
||||
url,
|
||||
} = error
|
||||
|
||||
analytics.track(ERROR_EVENTS.NETWORK_ERROR, {
|
||||
error_type: type,
|
||||
error_message: message,
|
||||
request_url: this._sanitizeUrl(url),
|
||||
network_type: this._getNetworkType(),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 追踪资源加载失败
|
||||
* @param {object} error - 错误信息
|
||||
*/
|
||||
trackResourceError(error) {
|
||||
const {
|
||||
resourceType,
|
||||
resourceUrl,
|
||||
message,
|
||||
} = error
|
||||
|
||||
analytics.track(ERROR_EVENTS.RESOURCE_LOAD_FAILED, {
|
||||
resource_type: resourceType,
|
||||
resource_url: this._sanitizeUrl(resourceUrl),
|
||||
error_message: message,
|
||||
page_path: this._getCurrentPagePath(),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前页面路径
|
||||
*/
|
||||
_getCurrentPagePath() {
|
||||
try {
|
||||
const pages = getCurrentPages()
|
||||
if (pages.length > 0) {
|
||||
return '/' + pages[pages.length - 1].route
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取网络类型
|
||||
*/
|
||||
_getNetworkType() {
|
||||
try {
|
||||
const res = uni.getNetworkType()
|
||||
return res?.networkType || 'unknown'
|
||||
} catch (e) {
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 参数脱敏
|
||||
*/
|
||||
_sanitizeParams(params) {
|
||||
if (!params) return {}
|
||||
|
||||
const sensitiveKeys = ['password', 'token', 'secret', 'key', 'auth']
|
||||
const sanitized = { ...params }
|
||||
|
||||
for (const key of Object.keys(sanitized)) {
|
||||
if (sensitiveKeys.some((k) => key.toLowerCase().includes(k))) {
|
||||
sanitized[key] = '***'
|
||||
}
|
||||
}
|
||||
|
||||
return sanitized
|
||||
}
|
||||
|
||||
/**
|
||||
* URL脱敏
|
||||
*/
|
||||
_sanitizeUrl(url) {
|
||||
if (!url) return ''
|
||||
// 移除查询参数中的敏感信息
|
||||
try {
|
||||
const urlObj = new URL(url)
|
||||
const sensitiveParams = ['token', 'key', 'secret', 'password']
|
||||
sensitiveParams.forEach((param) => {
|
||||
if (urlObj.searchParams.has(param)) {
|
||||
urlObj.searchParams.set(param, '***')
|
||||
}
|
||||
})
|
||||
return urlObj.toString()
|
||||
} catch (e) {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 截断字符串
|
||||
*/
|
||||
_truncate(str, maxLength) {
|
||||
if (!str) return ''
|
||||
if (str.length <= maxLength) return str
|
||||
return str.substring(0, maxLength) + '...'
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
export default new ErrorTracker()
|
||||
188
src/utils/posthog/http.js
Normal file
188
src/utils/posthog/http.js
Normal file
@@ -0,0 +1,188 @@
|
||||
import { DEFAULT_CONFIG, HTTP_STATUS } from './constants'
|
||||
|
||||
/**
|
||||
* PostHog HTTP请求封装
|
||||
* 专门用于PostHog事件上报,与业务请求隔离
|
||||
*/
|
||||
class PostHogHttp {
|
||||
constructor() {
|
||||
this.baseUrl = ''
|
||||
this.apiKey = ''
|
||||
this.timeout = DEFAULT_CONFIG.request.timeout
|
||||
this.maxRetries = DEFAULT_CONFIG.request.maxRetries
|
||||
this.retryDelay = DEFAULT_CONFIG.request.retryDelay
|
||||
this.debug = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化配置
|
||||
*/
|
||||
init(config) {
|
||||
this.baseUrl = config.apiHost || ''
|
||||
this.apiKey = config.apiKey || ''
|
||||
this.timeout = config.request?.timeout || this.timeout
|
||||
this.maxRetries = config.request?.maxRetries || this.maxRetries
|
||||
this.retryDelay = config.request?.retryDelay || this.retryDelay
|
||||
this.debug = config.debug || false
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送POST请求
|
||||
*/
|
||||
async post(path, data, options = {}) {
|
||||
const url = `${this.baseUrl}${path}`
|
||||
const retries = options.retries ?? this.maxRetries
|
||||
|
||||
return this._requestWithRetry(url, data, retries)
|
||||
}
|
||||
|
||||
/**
|
||||
* 带重试的请求
|
||||
*/
|
||||
async _requestWithRetry(url, data, retriesLeft) {
|
||||
try {
|
||||
return await this._doRequest(url, data)
|
||||
} catch (error) {
|
||||
// 如果还有重试次数且错误可重试
|
||||
if (retriesLeft > 0 && this._isRetryableError(error)) {
|
||||
this._log(`Request failed, retrying... (${retriesLeft} left)`)
|
||||
|
||||
// 等待一段时间后重试
|
||||
await this._delay(this.retryDelay)
|
||||
|
||||
return this._requestWithRetry(url, data, retriesLeft - 1)
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行实际请求
|
||||
*/
|
||||
_doRequest(url, data) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this._log('Sending request:', url, data)
|
||||
|
||||
uni.request({
|
||||
url,
|
||||
method: 'POST',
|
||||
header: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
api_key: this.apiKey,
|
||||
...data,
|
||||
},
|
||||
timeout: this.timeout,
|
||||
success: (res) => {
|
||||
this._log('Response:', res.statusCode, res.data)
|
||||
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
resolve({
|
||||
success: true,
|
||||
statusCode: res.statusCode,
|
||||
data: res.data,
|
||||
})
|
||||
} else {
|
||||
reject({
|
||||
success: false,
|
||||
statusCode: res.statusCode,
|
||||
message: `HTTP ${res.statusCode}`,
|
||||
data: res.data,
|
||||
})
|
||||
}
|
||||
},
|
||||
fail: (error) => {
|
||||
this._log('Request failed:', error)
|
||||
reject({
|
||||
success: false,
|
||||
statusCode: 0,
|
||||
message: error.errMsg || 'Network error',
|
||||
error,
|
||||
})
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为可重试的错误
|
||||
*/
|
||||
_isRetryableError(error) {
|
||||
// 网络错误可重试
|
||||
if (error.statusCode === 0) return true
|
||||
|
||||
// 服务器错误可重试
|
||||
if (error.statusCode >= 500) return true
|
||||
|
||||
// 请求超时可重试
|
||||
if (error.message?.includes('timeout')) return true
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 延迟函数
|
||||
*/
|
||||
_delay(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
/**
|
||||
* 调试日志
|
||||
*/
|
||||
_log(...args) {
|
||||
if (this.debug) {
|
||||
console.log('[PostHog HTTP]', ...args)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送单个事件
|
||||
*/
|
||||
async capture(event, distinctId, properties = {}, timestamp = null) {
|
||||
const payload = {
|
||||
event,
|
||||
distinct_id: distinctId,
|
||||
properties,
|
||||
timestamp: timestamp || new Date().toISOString(),
|
||||
}
|
||||
|
||||
return this.post('/capture/', payload)
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量发送事件
|
||||
*/
|
||||
async batch(events) {
|
||||
if (!events || events.length === 0) {
|
||||
return { success: true, message: 'No events to send' }
|
||||
}
|
||||
|
||||
const payload = {
|
||||
batch: events,
|
||||
}
|
||||
|
||||
return this.post('/batch/', payload)
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送用户标识
|
||||
*/
|
||||
async identify(distinctId, userProperties = {}) {
|
||||
const payload = {
|
||||
event: '$identify',
|
||||
distinct_id: distinctId,
|
||||
properties: {
|
||||
$set: userProperties.$set || {},
|
||||
$set_once: userProperties.$set_once || {},
|
||||
},
|
||||
}
|
||||
|
||||
return this.post('/capture/', payload)
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
export default new PostHogHttp()
|
||||
151
src/utils/posthog/identity.js
Normal file
151
src/utils/posthog/identity.js
Normal file
@@ -0,0 +1,151 @@
|
||||
import storage from './storage'
|
||||
import { STORAGE_KEYS } from './constants'
|
||||
|
||||
/**
|
||||
* 用户标识管理模块
|
||||
*/
|
||||
class Identity {
|
||||
constructor() {
|
||||
this.distinctId = null
|
||||
this.anonymousId = null
|
||||
this.userId = null // 真实用户ID(如OpenID)
|
||||
this.userProperties = {}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化
|
||||
*/
|
||||
init() {
|
||||
this._loadFromStorage()
|
||||
}
|
||||
|
||||
/**
|
||||
* 从本地存储加载标识
|
||||
*/
|
||||
_loadFromStorage() {
|
||||
// 加载匿名ID
|
||||
this.anonymousId = storage.get(STORAGE_KEYS.ANONYMOUS_ID)
|
||||
if (!this.anonymousId) {
|
||||
this.anonymousId = this._generateAnonymousId()
|
||||
storage.set(STORAGE_KEYS.ANONYMOUS_ID, this.anonymousId)
|
||||
}
|
||||
|
||||
// 加载distinct_id(可能是匿名ID或用户ID)
|
||||
this.distinctId = storage.get(STORAGE_KEYS.DISTINCT_ID) || this.anonymousId
|
||||
|
||||
// 加载用户属性缓存
|
||||
this.userProperties = storage.getJSON(STORAGE_KEYS.USER_PROPERTIES, {})
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成匿名ID
|
||||
*/
|
||||
_generateAnonymousId() {
|
||||
const timestamp = Date.now().toString(36)
|
||||
const random = Math.random().toString(36).substring(2, 10)
|
||||
return `anon_${timestamp}_${random}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前distinct_id
|
||||
*/
|
||||
getDistinctId() {
|
||||
return this.distinctId || this.anonymousId
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取匿名ID
|
||||
*/
|
||||
getAnonymousId() {
|
||||
return this.anonymousId
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置用户标识(登录时调用)
|
||||
* @param {string} userId - 用户ID(如OpenID)
|
||||
* @param {object} properties - 用户属性
|
||||
* @returns {object} - 返回用于$identify事件的数据
|
||||
*/
|
||||
identify(userId, properties = {}) {
|
||||
const previousDistinctId = this.distinctId
|
||||
|
||||
this.userId = userId
|
||||
this.distinctId = userId
|
||||
storage.set(STORAGE_KEYS.DISTINCT_ID, userId)
|
||||
|
||||
// 更新用户属性
|
||||
this.setUserProperties(properties)
|
||||
|
||||
// 返回identify事件所需数据
|
||||
return {
|
||||
distinctId: userId,
|
||||
previousDistinctId: previousDistinctId !== userId ? previousDistinctId : null,
|
||||
properties: {
|
||||
$set: properties,
|
||||
$anon_distinct_id: this.anonymousId,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置用户属性
|
||||
*/
|
||||
setUserProperties(properties) {
|
||||
this.userProperties = {
|
||||
...this.userProperties,
|
||||
...properties,
|
||||
}
|
||||
storage.setJSON(STORAGE_KEYS.USER_PROPERTIES, this.userProperties)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户属性
|
||||
*/
|
||||
getUserProperties() {
|
||||
return this.userProperties
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置用户标识(登出时调用)
|
||||
*/
|
||||
reset() {
|
||||
this.userId = null
|
||||
this.userProperties = {}
|
||||
|
||||
// 生成新的匿名ID
|
||||
this.anonymousId = this._generateAnonymousId()
|
||||
this.distinctId = this.anonymousId
|
||||
|
||||
// 更新存储
|
||||
storage.set(STORAGE_KEYS.ANONYMOUS_ID, this.anonymousId)
|
||||
storage.set(STORAGE_KEYS.DISTINCT_ID, this.distinctId)
|
||||
storage.remove(STORAGE_KEYS.USER_PROPERTIES)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户是否已登录
|
||||
*/
|
||||
isIdentified() {
|
||||
return this.userId !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户ID(OpenID等)
|
||||
*/
|
||||
getUserId() {
|
||||
return this.userId
|
||||
}
|
||||
|
||||
/**
|
||||
* 关联匿名ID与用户ID(用于$identify事件)
|
||||
*/
|
||||
getAliasData() {
|
||||
return {
|
||||
distinct_id: this.distinctId,
|
||||
$anon_distinct_id: this.anonymousId,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
export default new Identity()
|
||||
162
src/utils/posthog/index.js
Normal file
162
src/utils/posthog/index.js
Normal file
@@ -0,0 +1,162 @@
|
||||
import core from './core'
|
||||
import config from '../../config/posthog.config'
|
||||
|
||||
/**
|
||||
* PostHog Analytics 单例
|
||||
*
|
||||
* 使用方式:
|
||||
* import analytics from '@/utils/posthog'
|
||||
*
|
||||
* // 在App.vue的onLaunch中初始化
|
||||
* analytics.init()
|
||||
*
|
||||
* // 追踪事件
|
||||
* analytics.track('button_click', { button_id: 'submit' })
|
||||
*
|
||||
* // 页面浏览
|
||||
* analytics.trackPageView('/pages/index/index', '首页')
|
||||
*
|
||||
* // 用户登录
|
||||
* analytics.identify('user_openid', { nickname: '张三' })
|
||||
*
|
||||
* // 用户登出
|
||||
* analytics.reset()
|
||||
*/
|
||||
class Analytics {
|
||||
constructor() {
|
||||
this._core = core
|
||||
this._initialized = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化(使用默认配置)
|
||||
*/
|
||||
init(customConfig = {}) {
|
||||
if (this._initialized) return this
|
||||
|
||||
const finalConfig = {
|
||||
...config,
|
||||
...customConfig,
|
||||
}
|
||||
|
||||
this._core.init(finalConfig)
|
||||
this._initialized = true
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始会话
|
||||
*/
|
||||
startSession() {
|
||||
return this._core.startSession()
|
||||
}
|
||||
|
||||
/**
|
||||
* 结束会话
|
||||
*/
|
||||
endSession() {
|
||||
return this._core.endSession()
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户登录标识
|
||||
*/
|
||||
identify(userId, userProperties = {}) {
|
||||
return this._core.identify(userId, userProperties)
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户登出重置
|
||||
*/
|
||||
reset() {
|
||||
return this._core.reset()
|
||||
}
|
||||
|
||||
/**
|
||||
* 追踪事件
|
||||
*/
|
||||
track(eventName, properties = {}) {
|
||||
return this._core.track(eventName, properties)
|
||||
}
|
||||
|
||||
/**
|
||||
* 追踪页面浏览
|
||||
*/
|
||||
trackPageView(pagePath, pageTitle = '', properties = {}) {
|
||||
return this._core.trackPageView(pagePath, pageTitle, properties)
|
||||
}
|
||||
|
||||
/**
|
||||
* 追踪页面离开
|
||||
*/
|
||||
trackPageLeave(pagePath, duration = 0, properties = {}) {
|
||||
return this._core.trackPageLeave(pagePath, duration, properties)
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置全局公共属性
|
||||
*/
|
||||
setSuperProperties(properties) {
|
||||
return this._core.setSuperProperties(properties)
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除全局公共属性
|
||||
*/
|
||||
clearSuperProperties() {
|
||||
return this._core.clearSuperProperties()
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置用户属性
|
||||
*/
|
||||
setUserProperties(properties) {
|
||||
return this._core.setUserProperties(properties)
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置仅首次生效的用户属性
|
||||
*/
|
||||
setUserPropertiesOnce(properties) {
|
||||
return this._core.setUserPropertiesOnce(properties)
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新活跃时间
|
||||
*/
|
||||
touch() {
|
||||
return this._core.touch()
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制刷新事件队列
|
||||
*/
|
||||
flush() {
|
||||
return this._core.flush()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户ID
|
||||
*/
|
||||
getDistinctId() {
|
||||
return this._core.getDistinctId()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话ID
|
||||
*/
|
||||
getSessionId() {
|
||||
return this._core.getSessionId()
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁SDK
|
||||
*/
|
||||
destroy() {
|
||||
return this._core.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
export default new Analytics()
|
||||
162
src/utils/posthog/page-tracker.js
Normal file
162
src/utils/posthog/page-tracker.js
Normal file
@@ -0,0 +1,162 @@
|
||||
import analytics from './index'
|
||||
import { PAGE_EVENTS } from '../../constants/events'
|
||||
|
||||
/**
|
||||
* 页面追踪器
|
||||
* 自动追踪页面浏览、停留时长、滚动行为
|
||||
*/
|
||||
class PageTracker {
|
||||
constructor() {
|
||||
// 当前页面信息
|
||||
this.currentPage = null
|
||||
// 页面进入时间
|
||||
this.enterTime = null
|
||||
// 上一个页面路径
|
||||
this.referrerPage = null
|
||||
// 滚动追踪状态
|
||||
this.scrollTracked = false
|
||||
// 最大滚动位置
|
||||
this.maxScrollTop = 0
|
||||
// 页面高度
|
||||
this.pageHeight = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面进入
|
||||
* @param {string} pagePath - 页面路径
|
||||
* @param {string} pageTitle - 页面标题
|
||||
* @param {object} pageQuery - 页面参数
|
||||
*/
|
||||
onPageEnter(pagePath, pageTitle = '', pageQuery = {}) {
|
||||
// 保存当前页面信息
|
||||
this.currentPage = {
|
||||
path: pagePath,
|
||||
title: pageTitle,
|
||||
query: pageQuery,
|
||||
}
|
||||
|
||||
// 记录进入时间
|
||||
this.enterTime = Date.now()
|
||||
|
||||
// 重置滚动追踪
|
||||
this.scrollTracked = false
|
||||
this.maxScrollTop = 0
|
||||
|
||||
// 发送页面浏览事件
|
||||
analytics.track(PAGE_EVENTS.PAGE_VIEW, {
|
||||
page_path: pagePath,
|
||||
page_title: pageTitle,
|
||||
page_query: pageQuery,
|
||||
referrer_page: this.referrerPage || '',
|
||||
$current_url: pagePath,
|
||||
$title: pageTitle,
|
||||
})
|
||||
|
||||
// 更新来源页面
|
||||
this.referrerPage = pagePath
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面离开
|
||||
*/
|
||||
onPageLeave() {
|
||||
if (!this.currentPage || !this.enterTime) return
|
||||
|
||||
// 计算停留时长(秒)
|
||||
const duration = Math.floor((Date.now() - this.enterTime) / 1000)
|
||||
|
||||
// 计算滚动百分比
|
||||
const scrollPercentage = this.pageHeight > 0
|
||||
? Math.min(100, Math.round((this.maxScrollTop / this.pageHeight) * 100))
|
||||
: 0
|
||||
|
||||
// 发送页面离开事件
|
||||
analytics.track(PAGE_EVENTS.PAGE_LEAVE, {
|
||||
page_path: this.currentPage.path,
|
||||
page_title: this.currentPage.title,
|
||||
duration: duration,
|
||||
max_scroll_top: this.maxScrollTop,
|
||||
scroll_percentage: scrollPercentage,
|
||||
$current_url: this.currentPage.path,
|
||||
})
|
||||
|
||||
// 重置状态
|
||||
this.currentPage = null
|
||||
this.enterTime = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面滚动
|
||||
* @param {object} scrollInfo - 滚动信息
|
||||
*/
|
||||
onPageScroll(scrollInfo) {
|
||||
const { scrollTop, scrollHeight } = scrollInfo
|
||||
|
||||
// 更新最大滚动位置
|
||||
if (scrollTop > this.maxScrollTop) {
|
||||
this.maxScrollTop = scrollTop
|
||||
}
|
||||
|
||||
// 更新页面高度
|
||||
if (scrollHeight) {
|
||||
this.pageHeight = scrollHeight
|
||||
}
|
||||
|
||||
// 首次滚动追踪
|
||||
if (!this.scrollTracked && scrollTop > 100) {
|
||||
this.scrollTracked = true
|
||||
|
||||
analytics.track(PAGE_EVENTS.PAGE_SCROLLED, {
|
||||
page_path: this.currentPage?.path || '',
|
||||
scroll_top: scrollTop,
|
||||
scroll_direction: 'down',
|
||||
first_scroll: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下拉刷新
|
||||
*/
|
||||
onPullDownRefresh() {
|
||||
analytics.track(PAGE_EVENTS.PAGE_REFRESH, {
|
||||
page_path: this.currentPage?.path || '',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 触底加载更多
|
||||
* @param {number} pageNum - 当前页码
|
||||
*/
|
||||
onReachBottom(pageNum = 1) {
|
||||
analytics.track(PAGE_EVENTS.PAGE_LOAD_MORE, {
|
||||
page_path: this.currentPage?.path || '',
|
||||
page_number: pageNum,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前页面信息
|
||||
*/
|
||||
getCurrentPage() {
|
||||
return this.currentPage
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取停留时长(秒)
|
||||
*/
|
||||
getDuration() {
|
||||
if (!this.enterTime) return 0
|
||||
return Math.floor((Date.now() - this.enterTime) / 1000)
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置页面高度(用于计算滚动百分比)
|
||||
*/
|
||||
setPageHeight(height) {
|
||||
this.pageHeight = height
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
export default new PageTracker()
|
||||
74
src/utils/posthog/performance-tracker.js
Normal file
74
src/utils/posthog/performance-tracker.js
Normal file
@@ -0,0 +1,74 @@
|
||||
import analytics from './index'
|
||||
import { PERFORMANCE_EVENTS } from '../../constants/events'
|
||||
|
||||
/**
|
||||
* 性能追踪器
|
||||
*/
|
||||
class PerformanceTracker {
|
||||
constructor() {
|
||||
// 页面加载开始时间
|
||||
this.pageLoadStartTimes = new Map()
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面开始加载
|
||||
* @param {string} pagePath - 页面路径
|
||||
*/
|
||||
startPageLoad(pagePath) {
|
||||
this.pageLoadStartTimes.set(pagePath, Date.now())
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面加载完成
|
||||
* @param {string} pagePath - 页面路径
|
||||
*/
|
||||
endPageLoad(pagePath) {
|
||||
const startTime = this.pageLoadStartTimes.get(pagePath)
|
||||
if (!startTime) return
|
||||
|
||||
const loadTime = Date.now() - startTime
|
||||
this.pageLoadStartTimes.delete(pagePath)
|
||||
|
||||
analytics.track(PERFORMANCE_EVENTS.PAGE_LOAD_PERFORMANCE, {
|
||||
page_path: pagePath,
|
||||
load_time: loadTime,
|
||||
load_time_range: this._getLoadTimeRange(loadTime),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 追踪API响应时间
|
||||
* @param {object} params - 参数
|
||||
*/
|
||||
trackApiPerformance(params) {
|
||||
const {
|
||||
path,
|
||||
method,
|
||||
duration,
|
||||
success,
|
||||
} = params
|
||||
|
||||
analytics.track(PERFORMANCE_EVENTS.API_PERFORMANCE, {
|
||||
api_path: path,
|
||||
api_method: method,
|
||||
response_time: duration,
|
||||
response_time_range: this._getLoadTimeRange(duration),
|
||||
is_success: success,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取加载时间范围
|
||||
*/
|
||||
_getLoadTimeRange(ms) {
|
||||
if (ms < 500) return '0-500ms'
|
||||
if (ms < 1000) return '500ms-1s'
|
||||
if (ms < 2000) return '1-2s'
|
||||
if (ms < 3000) return '2-3s'
|
||||
if (ms < 5000) return '3-5s'
|
||||
return '5s+'
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
export default new PerformanceTracker()
|
||||
218
src/utils/posthog/queue.js
Normal file
218
src/utils/posthog/queue.js
Normal file
@@ -0,0 +1,218 @@
|
||||
import storage from './storage'
|
||||
import http from './http'
|
||||
import device from './device'
|
||||
import { STORAGE_KEYS, DEFAULT_CONFIG } from './constants'
|
||||
|
||||
/**
|
||||
* 事件队列管理
|
||||
* 支持批量上报、离线缓存、失败重试
|
||||
*/
|
||||
class EventQueue {
|
||||
constructor() {
|
||||
this.queue = []
|
||||
this.maxSize = DEFAULT_CONFIG.queue.maxSize
|
||||
this.flushInterval = DEFAULT_CONFIG.queue.flushInterval
|
||||
this.maxOfflineEvents = DEFAULT_CONFIG.offline.maxEvents
|
||||
this.flushTimer = null
|
||||
this.isFlushing = false
|
||||
this.debug = false
|
||||
this.immediateEvents = []
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化配置
|
||||
*/
|
||||
init(config = {}) {
|
||||
this.maxSize = config.queue?.maxSize || this.maxSize
|
||||
this.flushInterval = config.queue?.flushInterval || this.flushInterval
|
||||
this.maxOfflineEvents = config.offline?.maxEvents || this.maxOfflineEvents
|
||||
this.debug = config.debug || false
|
||||
this.immediateEvents = config.immediateEvents || []
|
||||
|
||||
// 加载离线缓存的事件
|
||||
this._loadOfflineEvents()
|
||||
|
||||
// 启动定时上报
|
||||
this._startFlushTimer()
|
||||
|
||||
// 监听网络变化,联网时尝试上报
|
||||
this._watchNetworkChange()
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载离线缓存的事件
|
||||
*/
|
||||
_loadOfflineEvents() {
|
||||
const offlineEvents = storage.getJSON(STORAGE_KEYS.OFFLINE_EVENTS, [])
|
||||
if (offlineEvents.length > 0) {
|
||||
this._log(`Loaded ${offlineEvents.length} offline events`)
|
||||
this.queue = [...offlineEvents, ...this.queue]
|
||||
// 清除存储
|
||||
storage.remove(STORAGE_KEYS.OFFLINE_EVENTS)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存事件到离线缓存
|
||||
*/
|
||||
_saveOfflineEvents(events) {
|
||||
const existingEvents = storage.getJSON(STORAGE_KEYS.OFFLINE_EVENTS, [])
|
||||
const allEvents = [...existingEvents, ...events]
|
||||
|
||||
// 限制离线缓存数量
|
||||
const trimmedEvents = allEvents.slice(-this.maxOfflineEvents)
|
||||
storage.setJSON(STORAGE_KEYS.OFFLINE_EVENTS, trimmedEvents)
|
||||
|
||||
this._log(`Saved ${events.length} events to offline cache`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动定时上报
|
||||
*/
|
||||
_startFlushTimer() {
|
||||
if (this.flushTimer) {
|
||||
clearInterval(this.flushTimer)
|
||||
}
|
||||
|
||||
this.flushTimer = setInterval(() => {
|
||||
this.flush()
|
||||
}, this.flushInterval)
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止定时上报
|
||||
*/
|
||||
_stopFlushTimer() {
|
||||
if (this.flushTimer) {
|
||||
clearInterval(this.flushTimer)
|
||||
this.flushTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听网络变化
|
||||
*/
|
||||
_watchNetworkChange() {
|
||||
device.onNetworkChange((res) => {
|
||||
if (res.isConnected) {
|
||||
this._log('Network connected, attempting to flush')
|
||||
this.flush()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加事件到队列
|
||||
*/
|
||||
add(event) {
|
||||
// 检查是否为需要立即上报的事件
|
||||
if (this.immediateEvents.includes(event.event)) {
|
||||
this._log(`Immediate event: ${event.event}`)
|
||||
this._sendImmediately(event)
|
||||
return
|
||||
}
|
||||
|
||||
this.queue.push(event)
|
||||
this._log(`Event added to queue: ${event.event} (queue size: ${this.queue.length})`)
|
||||
|
||||
// 检查是否达到上报阈值
|
||||
if (this.queue.length >= this.maxSize) {
|
||||
this.flush()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 立即发送单个事件
|
||||
*/
|
||||
async _sendImmediately(event) {
|
||||
if (!device.isOnline()) {
|
||||
this._saveOfflineEvents([event])
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await http.capture(event.event, event.distinct_id, event.properties, event.timestamp)
|
||||
this._log(`Immediate event sent: ${event.event}`)
|
||||
} catch (error) {
|
||||
this._log(`Immediate event failed: ${event.event}`, error)
|
||||
this._saveOfflineEvents([event])
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新队列,批量上报
|
||||
*/
|
||||
async flush() {
|
||||
// 防止并发刷新
|
||||
if (this.isFlushing || this.queue.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// 检查网络状态
|
||||
if (!device.isOnline()) {
|
||||
this._log('Offline, saving events to cache')
|
||||
this._saveOfflineEvents([...this.queue])
|
||||
this.queue = []
|
||||
return
|
||||
}
|
||||
|
||||
this.isFlushing = true
|
||||
const eventsToSend = [...this.queue]
|
||||
this.queue = []
|
||||
|
||||
this._log(`Flushing ${eventsToSend.length} events`)
|
||||
|
||||
try {
|
||||
await http.batch(eventsToSend)
|
||||
this._log(`Successfully sent ${eventsToSend.length} events`)
|
||||
} catch (error) {
|
||||
this._log('Batch send failed, saving to offline cache', error)
|
||||
this._saveOfflineEvents(eventsToSend)
|
||||
} finally {
|
||||
this.isFlushing = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制刷新(应用退出时调用)
|
||||
*/
|
||||
async forceFlush() {
|
||||
this._stopFlushTimer()
|
||||
await this.flush()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取队列长度
|
||||
*/
|
||||
getQueueSize() {
|
||||
return this.queue.length
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空队列
|
||||
*/
|
||||
clear() {
|
||||
this.queue = []
|
||||
storage.remove(STORAGE_KEYS.OFFLINE_EVENTS)
|
||||
}
|
||||
|
||||
/**
|
||||
* 调试日志
|
||||
*/
|
||||
_log(...args) {
|
||||
if (this.debug) {
|
||||
console.log('[PostHog Queue]', ...args)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁队列
|
||||
*/
|
||||
destroy() {
|
||||
this._stopFlushTimer()
|
||||
this.forceFlush()
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
export default new EventQueue()
|
||||
253
src/utils/posthog/reading-tracker.js
Normal file
253
src/utils/posthog/reading-tracker.js
Normal file
@@ -0,0 +1,253 @@
|
||||
import analytics from './index'
|
||||
import { CONTENT_EVENTS } from '../../constants/events'
|
||||
|
||||
/**
|
||||
* 阅读时长追踪器
|
||||
* 精确追踪用户阅读行为,包括:
|
||||
* - 有效阅读时长(排除切后台、锁屏等无效时间)
|
||||
* - 阅读进度
|
||||
* - 阅读完成判断
|
||||
*/
|
||||
class ReadingTracker {
|
||||
constructor() {
|
||||
// 当前追踪的内容
|
||||
this.content = null
|
||||
// 开始阅读时间
|
||||
this.startTime = null
|
||||
// 累计阅读时长(毫秒)
|
||||
this.totalDuration = 0
|
||||
// 最后活跃时间
|
||||
this.lastActiveTime = null
|
||||
// 是否正在阅读
|
||||
this.isReading = false
|
||||
// 阅读进度(百分比)
|
||||
this.progress = 0
|
||||
// 内容总高度
|
||||
this.contentHeight = 0
|
||||
// 已滚动的最大位置
|
||||
this.maxScrollPosition = 0
|
||||
// 心跳定时器
|
||||
this.heartbeatTimer = null
|
||||
// 心跳间隔(毫秒)
|
||||
this.heartbeatInterval = 5000
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始阅读追踪
|
||||
* @param {object} content - 内容信息
|
||||
*/
|
||||
start(content) {
|
||||
this.content = {
|
||||
id: content.id,
|
||||
title: content.title,
|
||||
category: content.category || '',
|
||||
author: content.author || '',
|
||||
publishTime: content.publishTime || '',
|
||||
wordCount: content.wordCount || 0,
|
||||
}
|
||||
|
||||
this.startTime = Date.now()
|
||||
this.lastActiveTime = Date.now()
|
||||
this.totalDuration = 0
|
||||
this.isReading = true
|
||||
this.progress = 0
|
||||
this.maxScrollPosition = 0
|
||||
|
||||
// 启动心跳
|
||||
this._startHeartbeat()
|
||||
|
||||
console.log('[ReadingTracker] Started tracking:', this.content.title)
|
||||
}
|
||||
|
||||
/**
|
||||
* 暂停阅读(切后台、跳转页面等)
|
||||
*/
|
||||
pause() {
|
||||
if (!this.isReading) return
|
||||
|
||||
// 累计有效阅读时长
|
||||
if (this.lastActiveTime) {
|
||||
this.totalDuration += Date.now() - this.lastActiveTime
|
||||
}
|
||||
|
||||
this.isReading = false
|
||||
this._stopHeartbeat()
|
||||
|
||||
console.log('[ReadingTracker] Paused, duration:', this.totalDuration)
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复阅读
|
||||
*/
|
||||
resume() {
|
||||
if (this.isReading) return
|
||||
if (!this.content) return
|
||||
|
||||
this.lastActiveTime = Date.now()
|
||||
this.isReading = true
|
||||
this._startHeartbeat()
|
||||
|
||||
console.log('[ReadingTracker] Resumed')
|
||||
}
|
||||
|
||||
/**
|
||||
* 结束阅读追踪并上报
|
||||
* @param {string} exitType - 退出类型:normal/back/close
|
||||
*/
|
||||
end(exitType = 'normal') {
|
||||
if (!this.content) return null
|
||||
|
||||
// 如果还在阅读状态,先暂停
|
||||
if (this.isReading) {
|
||||
this.pause()
|
||||
}
|
||||
|
||||
// 计算最终阅读时长(秒)
|
||||
const durationSeconds = Math.floor(this.totalDuration / 1000)
|
||||
|
||||
// 判断是否完成阅读
|
||||
const isCompleted = this._isReadingCompleted(durationSeconds)
|
||||
|
||||
// 上报阅读事件
|
||||
const eventData = {
|
||||
news_id: this.content.id,
|
||||
news_title: this.content.title,
|
||||
news_category: this.content.category,
|
||||
news_author: this.content.author,
|
||||
reading_duration: durationSeconds,
|
||||
reading_duration_range: this._getDurationRange(durationSeconds),
|
||||
reading_progress: this.progress,
|
||||
is_completed: isCompleted,
|
||||
exit_type: exitType,
|
||||
word_count: this.content.wordCount,
|
||||
}
|
||||
|
||||
analytics.track(CONTENT_EVENTS.NEWS_VIEWED, eventData)
|
||||
|
||||
// 如果完成阅读,额外上报完成事件
|
||||
if (isCompleted) {
|
||||
analytics.track(CONTENT_EVENTS.NEWS_READ_COMPLETED, eventData)
|
||||
}
|
||||
|
||||
console.log('[ReadingTracker] Ended:', eventData)
|
||||
|
||||
// 重置状态
|
||||
const result = { ...eventData }
|
||||
this._reset()
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新阅读进度
|
||||
* @param {number} scrollTop - 滚动位置
|
||||
* @param {number} viewportHeight - 可视区域高度
|
||||
*/
|
||||
updateProgress(scrollTop, viewportHeight) {
|
||||
if (!this.contentHeight || !this.isReading) return
|
||||
|
||||
// 更新最大滚动位置
|
||||
const currentPosition = scrollTop + viewportHeight
|
||||
if (currentPosition > this.maxScrollPosition) {
|
||||
this.maxScrollPosition = currentPosition
|
||||
}
|
||||
|
||||
// 计算阅读进度
|
||||
this.progress = Math.min(100, Math.round((this.maxScrollPosition / this.contentHeight) * 100))
|
||||
|
||||
// 更新活跃时间
|
||||
this.lastActiveTime = Date.now()
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置内容高度
|
||||
*/
|
||||
setContentHeight(height) {
|
||||
this.contentHeight = height
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前阅读时长(秒)
|
||||
*/
|
||||
getCurrentDuration() {
|
||||
let duration = this.totalDuration
|
||||
if (this.isReading && this.lastActiveTime) {
|
||||
duration += Date.now() - this.lastActiveTime
|
||||
}
|
||||
return Math.floor(duration / 1000)
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动心跳
|
||||
*/
|
||||
_startHeartbeat() {
|
||||
this._stopHeartbeat()
|
||||
this.heartbeatTimer = setInterval(() => {
|
||||
if (this.isReading) {
|
||||
// 定期更新活跃时间,确保时长统计准确
|
||||
this.lastActiveTime = Date.now()
|
||||
}
|
||||
}, this.heartbeatInterval)
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止心跳
|
||||
*/
|
||||
_stopHeartbeat() {
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer)
|
||||
this.heartbeatTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否完成阅读
|
||||
* 规则:阅读进度>=80% 或 阅读时长>=预估阅读时间的60%
|
||||
*/
|
||||
_isReadingCompleted(durationSeconds) {
|
||||
// 进度判断
|
||||
if (this.progress >= 80) return true
|
||||
|
||||
// 时长判断(假设阅读速度为300字/分钟)
|
||||
if (this.content.wordCount) {
|
||||
const estimatedReadTime = (this.content.wordCount / 300) * 60 // 秒
|
||||
if (durationSeconds >= estimatedReadTime * 0.6) return true
|
||||
}
|
||||
|
||||
// 最低阅读时长判断(超过60秒视为有效阅读)
|
||||
if (durationSeconds >= 60 && this.progress >= 50) return true
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取阅读时长范围
|
||||
*/
|
||||
_getDurationRange(seconds) {
|
||||
if (seconds < 10) return '0-10s'
|
||||
if (seconds < 30) return '10-30s'
|
||||
if (seconds < 60) return '30-60s'
|
||||
if (seconds < 180) return '1-3min'
|
||||
if (seconds < 300) return '3-5min'
|
||||
if (seconds < 600) return '5-10min'
|
||||
return '10min+'
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置状态
|
||||
*/
|
||||
_reset() {
|
||||
this._stopHeartbeat()
|
||||
this.content = null
|
||||
this.startTime = null
|
||||
this.totalDuration = 0
|
||||
this.lastActiveTime = null
|
||||
this.isReading = false
|
||||
this.progress = 0
|
||||
this.contentHeight = 0
|
||||
this.maxScrollPosition = 0
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
export default new ReadingTracker()
|
||||
170
src/utils/posthog/search-tracker.js
Normal file
170
src/utils/posthog/search-tracker.js
Normal file
@@ -0,0 +1,170 @@
|
||||
import analytics from './index'
|
||||
import { SEARCH_EVENTS } from '../../constants/events'
|
||||
|
||||
/**
|
||||
* 搜索行为追踪器
|
||||
*/
|
||||
class SearchTracker {
|
||||
constructor() {
|
||||
// 当前搜索会话
|
||||
this.currentSearch = null
|
||||
// 搜索开始时间
|
||||
this.searchStartTime = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索框聚焦
|
||||
* @param {string} pagePath - 页面路径
|
||||
*/
|
||||
onSearchFocus(pagePath = '') {
|
||||
analytics.track(SEARCH_EVENTS.SEARCH_FOCUS, {
|
||||
page_path: pagePath,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行搜索
|
||||
* @param {object} params - 搜索参数
|
||||
*/
|
||||
onSearch(params) {
|
||||
const {
|
||||
query,
|
||||
type = 'keyword',
|
||||
source = 'input', // input/suggestion/history/hot
|
||||
} = params
|
||||
|
||||
this.currentSearch = {
|
||||
query,
|
||||
type,
|
||||
source,
|
||||
}
|
||||
this.searchStartTime = Date.now()
|
||||
|
||||
console.log('[SearchTracker] Search started:', query)
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索结果返回
|
||||
* @param {object} result - 搜索结果
|
||||
*/
|
||||
onSearchResult(result) {
|
||||
if (!this.currentSearch) return
|
||||
|
||||
const searchTime = this.searchStartTime
|
||||
? Date.now() - this.searchStartTime
|
||||
: 0
|
||||
|
||||
const eventData = {
|
||||
search_query: this.currentSearch.query,
|
||||
search_type: this.currentSearch.type,
|
||||
search_source: this.currentSearch.source,
|
||||
result_count: result.total || result.list?.length || 0,
|
||||
search_time: searchTime,
|
||||
has_results: (result.total || result.list?.length || 0) > 0,
|
||||
}
|
||||
|
||||
analytics.track(SEARCH_EVENTS.SEARCH_EXECUTED, eventData)
|
||||
|
||||
// 如果无结果,额外上报
|
||||
if (!eventData.has_results) {
|
||||
analytics.track(SEARCH_EVENTS.SEARCH_NO_RESULTS, {
|
||||
search_query: this.currentSearch.query,
|
||||
search_type: this.currentSearch.type,
|
||||
})
|
||||
}
|
||||
|
||||
console.log('[SearchTracker] Search completed:', eventData)
|
||||
}
|
||||
|
||||
/**
|
||||
* 点击搜索结果
|
||||
* @param {object} item - 点击的结果项
|
||||
* @param {number} position - 结果位置
|
||||
*/
|
||||
onResultClick(item, position) {
|
||||
if (!this.currentSearch) return
|
||||
|
||||
analytics.track(SEARCH_EVENTS.SEARCH_RESULT_CLICKED, {
|
||||
search_query: this.currentSearch.query,
|
||||
result_id: item.id,
|
||||
result_title: item.title,
|
||||
result_position: position,
|
||||
result_type: item.type || 'news',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 点击搜索建议
|
||||
* @param {string} suggestion - 建议词
|
||||
* @param {number} position - 位置
|
||||
*/
|
||||
onSuggestionClick(suggestion, position) {
|
||||
analytics.track(SEARCH_EVENTS.SEARCH_SUGGESTION_CLICKED, {
|
||||
suggestion_text: suggestion,
|
||||
suggestion_position: position,
|
||||
})
|
||||
|
||||
// 设置为新的搜索
|
||||
this.onSearch({
|
||||
query: suggestion,
|
||||
type: 'keyword',
|
||||
source: 'suggestion',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 点击热门搜索
|
||||
* @param {string} keyword - 热门词
|
||||
* @param {number} position - 位置
|
||||
*/
|
||||
onHotSearchClick(keyword, position) {
|
||||
analytics.track(SEARCH_EVENTS.HOT_SEARCH_CLICKED, {
|
||||
keyword: keyword,
|
||||
position: position,
|
||||
})
|
||||
|
||||
// 设置为新的搜索
|
||||
this.onSearch({
|
||||
query: keyword,
|
||||
type: 'keyword',
|
||||
source: 'hot',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 点击搜索历史
|
||||
* @param {string} keyword - 历史词
|
||||
* @param {number} position - 位置
|
||||
*/
|
||||
onHistoryClick(keyword, position) {
|
||||
analytics.track(SEARCH_EVENTS.SEARCH_HISTORY_CLICKED, {
|
||||
keyword: keyword,
|
||||
position: position,
|
||||
})
|
||||
|
||||
// 设置为新的搜索
|
||||
this.onSearch({
|
||||
query: keyword,
|
||||
type: 'keyword',
|
||||
source: 'history',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空搜索历史
|
||||
*/
|
||||
onHistoryClear() {
|
||||
analytics.track(SEARCH_EVENTS.SEARCH_HISTORY_CLEARED, {})
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置搜索会话
|
||||
*/
|
||||
reset() {
|
||||
this.currentSearch = null
|
||||
this.searchStartTime = null
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
export default new SearchTracker()
|
||||
182
src/utils/posthog/session.js
Normal file
182
src/utils/posthog/session.js
Normal file
@@ -0,0 +1,182 @@
|
||||
import storage from './storage'
|
||||
import { STORAGE_KEYS, DEFAULT_CONFIG } from './constants'
|
||||
|
||||
/**
|
||||
* 会话管理模块
|
||||
*/
|
||||
class Session {
|
||||
constructor() {
|
||||
this.sessionId = null
|
||||
this.startTime = null
|
||||
this.lastActiveTime = null
|
||||
this.timeout = DEFAULT_CONFIG.session.timeout
|
||||
this.isActive = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化配置
|
||||
*/
|
||||
init(config = {}) {
|
||||
this.timeout = config.session?.timeout || this.timeout
|
||||
this._loadFromStorage()
|
||||
}
|
||||
|
||||
/**
|
||||
* 从存储加载会话信息
|
||||
*/
|
||||
_loadFromStorage() {
|
||||
this.sessionId = storage.get(STORAGE_KEYS.SESSION_ID)
|
||||
this.startTime = storage.get(STORAGE_KEYS.SESSION_START_TIME)
|
||||
this.lastActiveTime = storage.get(STORAGE_KEYS.LAST_ACTIVE_TIME)
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存到存储
|
||||
*/
|
||||
_saveToStorage() {
|
||||
storage.set(STORAGE_KEYS.SESSION_ID, this.sessionId)
|
||||
storage.set(STORAGE_KEYS.SESSION_START_TIME, this.startTime)
|
||||
storage.set(STORAGE_KEYS.LAST_ACTIVE_TIME, this.lastActiveTime)
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成会话ID
|
||||
*/
|
||||
_generateSessionId() {
|
||||
const timestamp = Date.now().toString(36)
|
||||
const random = Math.random().toString(36).substring(2, 8)
|
||||
return `session_${timestamp}_${random}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始新会话
|
||||
*/
|
||||
start() {
|
||||
const now = Date.now()
|
||||
|
||||
// 检查是否需要新会话
|
||||
if (this._shouldStartNewSession()) {
|
||||
this.sessionId = this._generateSessionId()
|
||||
this.startTime = now
|
||||
this.isActive = true
|
||||
this.lastActiveTime = now
|
||||
this._saveToStorage()
|
||||
|
||||
return {
|
||||
isNewSession: true,
|
||||
sessionId: this.sessionId,
|
||||
startTime: this.startTime,
|
||||
}
|
||||
}
|
||||
|
||||
// 恢复现有会话
|
||||
this.isActive = true
|
||||
this.lastActiveTime = now
|
||||
storage.set(STORAGE_KEYS.LAST_ACTIVE_TIME, now)
|
||||
|
||||
return {
|
||||
isNewSession: false,
|
||||
sessionId: this.sessionId,
|
||||
startTime: this.startTime,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否需要开始新会话
|
||||
*/
|
||||
_shouldStartNewSession() {
|
||||
// 没有现有会话
|
||||
if (!this.sessionId) return true
|
||||
|
||||
// 超过会话超时时间
|
||||
if (this.lastActiveTime) {
|
||||
const elapsed = Date.now() - this.lastActiveTime
|
||||
if (elapsed > this.timeout) return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 结束会话
|
||||
*/
|
||||
end() {
|
||||
if (!this.isActive) return null
|
||||
|
||||
const now = Date.now()
|
||||
const duration = this.startTime ? now - this.startTime : 0
|
||||
|
||||
this.isActive = false
|
||||
this.lastActiveTime = now
|
||||
storage.set(STORAGE_KEYS.LAST_ACTIVE_TIME, now)
|
||||
|
||||
return {
|
||||
sessionId: this.sessionId,
|
||||
duration: Math.floor(duration / 1000), // 转换为秒
|
||||
startTime: this.startTime,
|
||||
endTime: now,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新活跃时间
|
||||
*/
|
||||
touch() {
|
||||
if (!this.isActive) return
|
||||
|
||||
this.lastActiveTime = Date.now()
|
||||
storage.set(STORAGE_KEYS.LAST_ACTIVE_TIME, this.lastActiveTime)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话ID
|
||||
*/
|
||||
getSessionId() {
|
||||
return this.sessionId
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话时长(秒)
|
||||
*/
|
||||
getDuration() {
|
||||
if (!this.startTime) return 0
|
||||
return Math.floor((Date.now() - this.startTime) / 1000)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话信息
|
||||
*/
|
||||
getInfo() {
|
||||
return {
|
||||
sessionId: this.sessionId,
|
||||
startTime: this.startTime,
|
||||
lastActiveTime: this.lastActiveTime,
|
||||
duration: this.getDuration(),
|
||||
isActive: this.isActive,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查会话是否活跃
|
||||
*/
|
||||
isSessionActive() {
|
||||
return this.isActive
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置会话
|
||||
*/
|
||||
reset() {
|
||||
this.sessionId = null
|
||||
this.startTime = null
|
||||
this.lastActiveTime = null
|
||||
this.isActive = false
|
||||
|
||||
storage.remove(STORAGE_KEYS.SESSION_ID)
|
||||
storage.remove(STORAGE_KEYS.SESSION_START_TIME)
|
||||
storage.remove(STORAGE_KEYS.LAST_ACTIVE_TIME)
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
export default new Session()
|
||||
135
src/utils/posthog/storage.js
Normal file
135
src/utils/posthog/storage.js
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* 本地存储封装
|
||||
* 统一封装 uni.setStorage / uni.getStorage 等API
|
||||
*/
|
||||
class Storage {
|
||||
constructor(prefix = 'ph_') {
|
||||
this.prefix = prefix
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取完整的存储Key
|
||||
*/
|
||||
_getKey(key) {
|
||||
// 如果key已经包含前缀,直接返回
|
||||
if (key.startsWith(this.prefix)) {
|
||||
return key
|
||||
}
|
||||
return `${this.prefix}${key}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步获取数据
|
||||
*/
|
||||
get(key, defaultValue = null) {
|
||||
try {
|
||||
const data = uni.getStorageSync(this._getKey(key))
|
||||
if (data === '' || data === undefined || data === null) {
|
||||
return defaultValue
|
||||
}
|
||||
return data
|
||||
} catch (error) {
|
||||
console.warn('[PostHog Storage] Get error:', error)
|
||||
return defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步设置数据
|
||||
*/
|
||||
set(key, value) {
|
||||
try {
|
||||
uni.setStorageSync(this._getKey(key), value)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.warn('[PostHog Storage] Set error:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步删除数据
|
||||
*/
|
||||
remove(key) {
|
||||
try {
|
||||
uni.removeStorageSync(this._getKey(key))
|
||||
return true
|
||||
} catch (error) {
|
||||
console.warn('[PostHog Storage] Remove error:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步获取数据
|
||||
*/
|
||||
async getAsync(key, defaultValue = null) {
|
||||
return new Promise((resolve) => {
|
||||
uni.getStorage({
|
||||
key: this._getKey(key),
|
||||
success: (res) => {
|
||||
resolve(res.data ?? defaultValue)
|
||||
},
|
||||
fail: () => {
|
||||
resolve(defaultValue)
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步设置数据
|
||||
*/
|
||||
async setAsync(key, value) {
|
||||
return new Promise((resolve) => {
|
||||
uni.setStorage({
|
||||
key: this._getKey(key),
|
||||
data: value,
|
||||
success: () => resolve(true),
|
||||
fail: () => resolve(false),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有PostHog相关存储
|
||||
*/
|
||||
clear() {
|
||||
try {
|
||||
const res = uni.getStorageInfoSync()
|
||||
const keys = res.keys || []
|
||||
keys.forEach((key) => {
|
||||
if (key.startsWith(this.prefix)) {
|
||||
uni.removeStorageSync(key)
|
||||
}
|
||||
})
|
||||
return true
|
||||
} catch (error) {
|
||||
console.warn('[PostHog Storage] Clear error:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取JSON数据
|
||||
*/
|
||||
getJSON(key, defaultValue = null) {
|
||||
const data = this.get(key)
|
||||
if (data === null) return defaultValue
|
||||
try {
|
||||
return typeof data === 'string' ? JSON.parse(data) : data
|
||||
} catch {
|
||||
return defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置JSON数据
|
||||
*/
|
||||
setJSON(key, value) {
|
||||
return this.set(key, JSON.stringify(value))
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
export default new Storage('ph_')
|
||||
141
src/utils/posthog/user-tracker.js
Normal file
141
src/utils/posthog/user-tracker.js
Normal file
@@ -0,0 +1,141 @@
|
||||
import analytics from './index'
|
||||
import { USER_EVENTS, CONTENT_EVENTS } from '../../constants/events'
|
||||
|
||||
/**
|
||||
* 用户行为追踪器
|
||||
*/
|
||||
class UserTracker {
|
||||
/**
|
||||
* 用户登录
|
||||
* @param {object} params - 登录参数
|
||||
*/
|
||||
onLogin(params) {
|
||||
const {
|
||||
userId,
|
||||
method = 'wechat',
|
||||
success = true,
|
||||
errorCode = '',
|
||||
errorMessage = '',
|
||||
userInfo = {},
|
||||
} = params
|
||||
|
||||
// 上报登录事件
|
||||
analytics.track(USER_EVENTS.USER_LOGIN, {
|
||||
login_method: method,
|
||||
login_success: success,
|
||||
login_error_code: errorCode,
|
||||
login_error_message: errorMessage,
|
||||
})
|
||||
|
||||
// 如果登录成功,设置用户标识
|
||||
if (success && userId) {
|
||||
analytics.identify(userId, {
|
||||
nickname: userInfo.nickname,
|
||||
avatar_url: userInfo.avatarUrl,
|
||||
gender: userInfo.gender,
|
||||
city: userInfo.city,
|
||||
province: userInfo.province,
|
||||
country: userInfo.country,
|
||||
})
|
||||
|
||||
// 设置仅首次生效的属性
|
||||
analytics.setUserPropertiesOnce({
|
||||
first_login_time: new Date().toISOString(),
|
||||
register_source: method,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户登出
|
||||
*/
|
||||
onLogout() {
|
||||
analytics.track(USER_EVENTS.USER_LOGOUT, {})
|
||||
analytics.reset()
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信授权
|
||||
* @param {object} params - 授权参数
|
||||
*/
|
||||
onWechatAuth(params) {
|
||||
const {
|
||||
scope = 'userInfo',
|
||||
success = true,
|
||||
errorCode = '',
|
||||
} = params
|
||||
|
||||
analytics.track(USER_EVENTS.WECHAT_AUTH, {
|
||||
auth_scope: scope,
|
||||
auth_success: success,
|
||||
auth_error_code: errorCode,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 内容收藏
|
||||
* @param {object} content - 内容信息
|
||||
*/
|
||||
onFavorite(content) {
|
||||
analytics.track(CONTENT_EVENTS.CONTENT_FAVORITED, {
|
||||
content_id: content.id,
|
||||
content_type: content.type || 'news',
|
||||
content_title: content.title,
|
||||
content_category: content.category || '',
|
||||
})
|
||||
|
||||
// 更新用户收藏计数
|
||||
analytics.setUserProperties({
|
||||
last_favorite_time: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消收藏
|
||||
* @param {object} content - 内容信息
|
||||
*/
|
||||
onUnfavorite(content) {
|
||||
analytics.track(CONTENT_EVENTS.CONTENT_UNFAVORITED, {
|
||||
content_id: content.id,
|
||||
content_type: content.type || 'news',
|
||||
content_title: content.title,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 内容分享
|
||||
* @param {object} params - 分享参数
|
||||
*/
|
||||
onShare(params) {
|
||||
const {
|
||||
content,
|
||||
channel = 'wechat_friend',
|
||||
success = true,
|
||||
} = params
|
||||
|
||||
analytics.track(CONTENT_EVENTS.CONTENT_SHARED, {
|
||||
content_id: content.id,
|
||||
content_type: content.type || 'news',
|
||||
content_title: content.title,
|
||||
share_channel: channel,
|
||||
share_success: success,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 内容点赞
|
||||
* @param {object} content - 内容信息
|
||||
* @param {boolean} isLike - 是否点赞(false为取消)
|
||||
*/
|
||||
onLike(content, isLike = true) {
|
||||
analytics.track(CONTENT_EVENTS.CONTENT_LIKED, {
|
||||
content_id: content.id,
|
||||
content_type: content.type || 'news',
|
||||
content_title: content.title,
|
||||
is_like: isLike,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
export default new UserTracker()
|
||||
114
src/utils/posthog/validator.js
Normal file
114
src/utils/posthog/validator.js
Normal file
@@ -0,0 +1,114 @@
|
||||
import { EVENTS } from '@/constants/events'
|
||||
|
||||
/**
|
||||
* 事件属性验证器
|
||||
* 确保事件属性符合预定义规范
|
||||
*/
|
||||
|
||||
// 事件属性Schema定义
|
||||
const eventSchemas = {
|
||||
[EVENTS.NEWS_VIEWED]: {
|
||||
required: ['news_id', 'news_title'],
|
||||
optional: ['news_category', 'reading_duration', 'entry_source'],
|
||||
types: {
|
||||
news_id: 'string',
|
||||
news_title: 'string',
|
||||
news_category: 'string',
|
||||
reading_duration: 'number',
|
||||
entry_source: 'string',
|
||||
},
|
||||
},
|
||||
|
||||
[EVENTS.SEARCH_EXECUTED]: {
|
||||
required: ['search_query'],
|
||||
optional: ['search_type', 'result_count', 'search_time'],
|
||||
types: {
|
||||
search_query: 'string',
|
||||
search_type: 'string',
|
||||
result_count: 'number',
|
||||
search_time: 'number',
|
||||
},
|
||||
},
|
||||
|
||||
[EVENTS.CONTENT_FAVORITED]: {
|
||||
required: ['content_id', 'content_type'],
|
||||
optional: ['content_title', 'content_category'],
|
||||
types: {
|
||||
content_id: 'string',
|
||||
content_type: 'string',
|
||||
content_title: 'string',
|
||||
content_category: 'string',
|
||||
},
|
||||
},
|
||||
|
||||
[EVENTS.API_ERROR]: {
|
||||
required: ['api_path', 'error_code', 'error_message'],
|
||||
optional: ['api_method', 'request_duration'],
|
||||
types: {
|
||||
api_path: 'string',
|
||||
api_method: 'string',
|
||||
error_code: ['number', 'string'],
|
||||
error_message: 'string',
|
||||
request_duration: 'number',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证事件属性
|
||||
* @param {string} eventName - 事件名称
|
||||
* @param {object} properties - 事件属性
|
||||
* @returns {object} - { valid: boolean, errors: string[] }
|
||||
*/
|
||||
export function validateEventProperties(eventName, properties) {
|
||||
const schema = eventSchemas[eventName]
|
||||
|
||||
// 没有定义schema的事件,跳过验证
|
||||
if (!schema) {
|
||||
return { valid: true, errors: [] }
|
||||
}
|
||||
|
||||
const errors = []
|
||||
|
||||
// 检查必需属性
|
||||
for (const field of schema.required || []) {
|
||||
if (!(field in properties)) {
|
||||
errors.push(`Missing required property: ${field}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 检查属性类型
|
||||
for (const [field, value] of Object.entries(properties)) {
|
||||
const expectedType = schema.types?.[field]
|
||||
if (expectedType) {
|
||||
const actualType = typeof value
|
||||
const validTypes = Array.isArray(expectedType) ? expectedType : [expectedType]
|
||||
|
||||
if (!validTypes.includes(actualType)) {
|
||||
errors.push(`Invalid type for ${field}: expected ${validTypes.join(' or ')}, got ${actualType}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证并警告(开发模式使用)
|
||||
*/
|
||||
export function validateAndWarn(eventName, properties, debug = false) {
|
||||
if (!debug) return
|
||||
|
||||
const result = validateEventProperties(eventName, properties)
|
||||
if (!result.valid) {
|
||||
console.warn(`[PostHog Validator] Event "${eventName}" validation failed:`, result.errors)
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
validateEventProperties,
|
||||
validateAndWarn,
|
||||
}
|
||||
Reference in New Issue
Block a user