Files
SpineParticlesWeb/src/editor/analytics.ts
T
2026-10-31 15:26:31 +08:00

134 lines
5.3 KiB
TypeScript

import { i18n, SUPPORTED_LOCALES } from '../i18n'
type MatomoWindow = Window & { _paq?: unknown[][] }
type EventCategory = '欢迎页' | '页面导航' | '时间轴' | '系统设置' | '属性面板' | '使用说明'
type EventAction = '点击按钮' | '点击开关' | '调整数值' | '点击下载'
type MessageTree = Record<string, unknown>
let chineseLabels: Map<string, string> | null = null
function flattenMessages(value: unknown, prefix = '', output: Record<string, string> = {}) {
if (!value || typeof value !== 'object') return output
for (const [key, child] of Object.entries(value as MessageTree)) {
const path = prefix ? `${prefix}.${key}` : key
if (typeof child === 'string') output[path] = child
else flattenMessages(child, path, output)
}
return output
}
function localizedToChinese(value: string | null | undefined) {
const label = value?.replace(/\s+/g, ' ').trim()
if (!label) return ''
if (!chineseLabels) {
chineseLabels = new Map()
const chinese = flattenMessages(i18n.global.getLocaleMessage('zh-CN'))
for (const locale of SUPPORTED_LOCALES) {
const messages = flattenMessages(i18n.global.getLocaleMessage(locale.code))
for (const [key, text] of Object.entries(messages)) {
const translated = chinese[key]
if (translated) chineseLabels.set(text.replace(/\s+/g, ' ').trim(), translated)
}
}
}
return chineseLabels.get(label) || ''
}
function categoryFor(element: Element): EventCategory {
const section = element.closest<HTMLElement>('[data-matomo-section]')?.dataset.matomoSection
if (section === 'welcome') return '欢迎页'
if (section === 'timeline') return '时间轴'
if (section === 'settings') return '系统设置'
return '属性面板'
}
function controlName(control: Element, fallback: string) {
const candidates = [
(control as HTMLElement).dataset.matomoName,
control.closest<HTMLElement>('[data-matomo-name]')?.dataset.matomoName,
control.getAttribute('aria-label'),
control.getAttribute('title'),
control.textContent,
control.closest('label')?.textContent,
control.parentElement?.querySelector('.ns-label, .pac-label')?.textContent,
]
for (const candidate of candidates) {
const chinese = localizedToChinese(candidate)
if (chinese) return chinese
}
return fallback
}
function buttonName(button: HTMLButtonElement) {
const section = button.closest('[data-matomo-section]') || document
const className = [...button.classList]
.filter((name) => !['active', 'collapsed', 'danger', 'ghost', 'off', 'on', 'primary', 'selected'].includes(name))
.sort()
.join('-') || button.type || 'button'
const peers = [...section.querySelectorAll<HTMLButtonElement>('button')]
.filter((peer) => ([...peer.classList]
.filter((name) => !['active', 'collapsed', 'danger', 'ghost', 'off', 'on', 'primary', 'selected'].includes(name))
.sort()
.join('-') || peer.type || 'button') === className)
return controlName(button, `按钮${Math.max(1, peers.indexOf(button) + 1)}`)
}
export function trackMatomoEvent(category: EventCategory, action: EventAction, name: string) {
if (!import.meta.env.PROD) return
const target = window as MatomoWindow
;(target._paq ||= []).push(['trackEvent', category, action, name])
}
export function trackMatomoPageView() {
if (!import.meta.env.PROD) return
const target = window as MatomoWindow
;(target._paq ||= []).push(
['setCustomUrl', window.location.href],
['setDocumentTitle', document.title],
['trackPageView'],
)
}
export function installMatomoEventTracking() {
if (!import.meta.env.PROD) return
document.addEventListener('click', (event) => {
const target = event.target
if (!(target instanceof Element)) return
const download = target.closest<HTMLAnchorElement>('a[data-matomo-category][data-matomo-action][data-matomo-name]')
if (download) {
trackMatomoEvent(
download.dataset.matomoCategory as EventCategory,
download.dataset.matomoAction as EventAction,
download.dataset.matomoName || '',
)
return
}
const tracked = target.closest<HTMLElement>('[data-matomo-event]')
if (tracked) {
const category = tracked.dataset.matomoCategory as EventCategory | undefined
const action = tracked.dataset.matomoAction as EventAction | undefined
trackMatomoEvent(category || categoryFor(tracked), action || '点击按钮', tracked.dataset.matomoEvent || '')
return
}
const button = target.closest<HTMLButtonElement>('button')
if (!button) return
trackMatomoEvent(categoryFor(button), '点击按钮', buttonName(button))
}, true)
document.addEventListener('change', (event) => {
const target = event.target
if (!(target instanceof HTMLInputElement)) return
if (target.type === 'checkbox') {
const name = controlName(target, '开关')
trackMatomoEvent(categoryFor(target), '点击开关', `${name}=${target.checked ? '开启' : '关闭'}`)
return
}
if (target.type !== 'range' && target.type !== 'number') return
const pairedRange = target.type === 'range'
|| !!target.parentElement?.querySelector('input[type="range"]')
if (!pairedRange) return
const name = controlName(target, '数值')
trackMatomoEvent(categoryFor(target), '调整数值', `${name}=${target.value}`)
}, true)
}