feat(ops): ECR-007 行为分析与 ECR-008 内容运营后台
落地埋点 ingest/数据看板、首页宫格 CMS 与测评上下架;含账号引导、问答流式与免责声明去重,以及 review P1 审计同事务修复。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,12 +1,31 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AnalyticsEvent, track, trackPageView } from './analytics'
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
api: {
|
||||
postAnalyticsEvents: vi.fn().mockResolvedValue({ accepted: 1 }),
|
||||
},
|
||||
}))
|
||||
|
||||
import {
|
||||
AnalyticsEvent,
|
||||
track,
|
||||
trackPageView,
|
||||
_resetAnalyticsForTests,
|
||||
_peekQueueForTests,
|
||||
_setOwnEnabledForTests,
|
||||
ensureSession,
|
||||
} from './analytics'
|
||||
|
||||
describe('analytics.track', () => {
|
||||
beforeEach(() => {
|
||||
_resetAnalyticsForTests()
|
||||
_setOwnEnabledForTests(true)
|
||||
sessionStorage.clear()
|
||||
window.gtag = vi.fn()
|
||||
})
|
||||
afterEach(() => {
|
||||
delete window.gtag
|
||||
_resetAnalyticsForTests()
|
||||
})
|
||||
|
||||
it('calls gtag with event name and params', () => {
|
||||
@@ -24,11 +43,31 @@ describe('analytics.track', () => {
|
||||
expect(() => track(AnalyticsEvent.RelationCompleted)).not.toThrow()
|
||||
})
|
||||
|
||||
it('trackPageView sends page_path', () => {
|
||||
it('trackPageView sends page_path and queues page_leave on next path', () => {
|
||||
trackPageView('/portrait', '个人画像')
|
||||
expect(window.gtag).toHaveBeenCalledWith('event', 'page_view', {
|
||||
expect(window.gtag).toHaveBeenCalledWith('event', 'page_view', expect.objectContaining({
|
||||
page_path: '/portrait',
|
||||
page_title: '个人画像',
|
||||
})
|
||||
}))
|
||||
trackPageView('/ask', 'ask')
|
||||
const names = _peekQueueForTests().map((q) => q.name)
|
||||
expect(names).toContain('page_leave')
|
||||
expect(names).toContain('page_view')
|
||||
})
|
||||
|
||||
it('ensureSession returns stable id within idle window', () => {
|
||||
const a = ensureSession(true)
|
||||
const b = ensureSession()
|
||||
expect(a).toBe(b)
|
||||
expect(a.startsWith('s_')).toBe(true)
|
||||
})
|
||||
|
||||
it('queues own events with session_id', () => {
|
||||
track(AnalyticsEvent.UiClick, { element_id: 'home_cta' })
|
||||
const q = _peekQueueForTests()
|
||||
expect(q.length).toBe(1)
|
||||
expect(q[0].name).toBe('ui_click')
|
||||
expect(q[0].session_id).toBeTruthy()
|
||||
expect(q[0].props?.element_id).toBe('home_cta')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
/**
|
||||
* P1 growth analytics — see .ai/product/feature-spec/analytics.md
|
||||
* P1 growth analytics + Ops-B own pipeline — see feature-spec analytics.md / ops-analytics.md
|
||||
* Pages MUST use track(); do not call window.gtag directly.
|
||||
*/
|
||||
|
||||
import { api } from '@/api/client'
|
||||
|
||||
export type AnalyticsParams = Record<string, string | number | boolean | undefined>
|
||||
|
||||
/** Frozen P1 core funnel events (+ page_view via router). */
|
||||
@@ -18,6 +20,10 @@ export const AnalyticsEvent = {
|
||||
SynastryNearbyOpened: 'synastry_nearby_opened',
|
||||
StarWheelViewed: 'star_wheel_viewed',
|
||||
PageView: 'page_view',
|
||||
SessionStart: 'session_start',
|
||||
SessionEnd: 'session_end',
|
||||
PageLeave: 'page_leave',
|
||||
UiClick: 'ui_click',
|
||||
} as const
|
||||
|
||||
export type CoreAnalyticsEvent = (typeof AnalyticsEvent)[keyof typeof AnalyticsEvent]
|
||||
@@ -29,22 +35,50 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
let gaReady = false
|
||||
const SESSION_KEY = 'yxg_analytics_sid'
|
||||
const LAST_ACTIVE_KEY = 'yxg_analytics_active'
|
||||
const SESSION_START_KEY = 'yxg_analytics_started'
|
||||
const SESSION_IDLE_MS = 30 * 60 * 1000
|
||||
const FLUSH_MS = 8000
|
||||
const MAX_QUEUE = 80
|
||||
|
||||
/** Load GA4 when VITE_GA_MEASUREMENT_ID is set. Safe to call once from main. */
|
||||
type QueueItem = {
|
||||
name: string
|
||||
session_id: string
|
||||
page_path?: string
|
||||
client_ts: string
|
||||
props?: Record<string, string | number | boolean>
|
||||
}
|
||||
|
||||
let gaReady = false
|
||||
let queue: QueueItem[] = []
|
||||
let flushTimer: ReturnType<typeof setInterval> | null = null
|
||||
let pageEnteredAt = 0
|
||||
let currentPath = ''
|
||||
let referrerPath = ''
|
||||
let listenersBound = false
|
||||
let ownEnabled = true
|
||||
|
||||
/** Load GA4 when VITE_GA_MEASUREMENT_ID is set. Starts own session pipeline. */
|
||||
export function initAnalytics(): void {
|
||||
initGA()
|
||||
ensureSession(true)
|
||||
bindLifecycle()
|
||||
startFlushLoop()
|
||||
track(AnalyticsEvent.SessionStart, { cold: true, app_ver: String(import.meta.env.VITE_APP_VER || 'h5') })
|
||||
}
|
||||
|
||||
function initGA(): void {
|
||||
const id = (import.meta.env.VITE_GA_MEASUREMENT_ID || '').trim()
|
||||
if (!id || typeof document === 'undefined') return
|
||||
if (gaReady) return
|
||||
gaReady = true
|
||||
|
||||
window.dataLayer = window.dataLayer || []
|
||||
window.gtag = function gtag(...args: unknown[]) {
|
||||
window.dataLayer!.push(args)
|
||||
}
|
||||
window.gtag('js', new Date())
|
||||
window.gtag('config', id, { send_page_view: false })
|
||||
|
||||
const s = document.createElement('script')
|
||||
s.async = true
|
||||
s.src = `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(id)}`
|
||||
@@ -55,7 +89,133 @@ function debugEnabled(): boolean {
|
||||
return import.meta.env.DEV && String(import.meta.env.VITE_ANALYTICS_DEBUG || '') === '1'
|
||||
}
|
||||
|
||||
/** Fire a custom event. No-op when gtag missing; never throws. */
|
||||
function nowISO(): string {
|
||||
return new Date().toISOString()
|
||||
}
|
||||
|
||||
function touchActive(): void {
|
||||
try {
|
||||
sessionStorage.setItem(LAST_ACTIVE_KEY, String(Date.now()))
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/** session_id in sessionStorage; refresh after 30min idle. */
|
||||
export function ensureSession(forceNew = false): string {
|
||||
try {
|
||||
const last = Number(sessionStorage.getItem(LAST_ACTIVE_KEY) || 0)
|
||||
let sid = sessionStorage.getItem(SESSION_KEY) || ''
|
||||
const idle = !last || Date.now() - last > SESSION_IDLE_MS
|
||||
if (forceNew || !sid || idle) {
|
||||
sid = `s_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`
|
||||
sessionStorage.setItem(SESSION_KEY, sid)
|
||||
sessionStorage.setItem(SESSION_START_KEY, String(Date.now()))
|
||||
}
|
||||
touchActive()
|
||||
return sid
|
||||
} catch {
|
||||
return `s_${Date.now().toString(36)}`
|
||||
}
|
||||
}
|
||||
|
||||
function enqueue(name: string, params?: AnalyticsParams): void {
|
||||
if (!ownEnabled) return
|
||||
const sid = ensureSession()
|
||||
const clean: Record<string, string | number | boolean> = {}
|
||||
let pagePath = currentPath
|
||||
if (params) {
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v === undefined) continue
|
||||
clean[k] = v
|
||||
if (k === 'page_path' && typeof v === 'string') pagePath = v
|
||||
}
|
||||
}
|
||||
queue.push({
|
||||
name,
|
||||
session_id: sid,
|
||||
page_path: pagePath || undefined,
|
||||
client_ts: nowISO(),
|
||||
props: Object.keys(clean).length ? clean : undefined,
|
||||
})
|
||||
if (queue.length > MAX_QUEUE) queue = queue.slice(-MAX_QUEUE)
|
||||
touchActive()
|
||||
}
|
||||
|
||||
async function flushQueue(beacon = false): Promise<void> {
|
||||
if (!queue.length) return
|
||||
const items = queue.splice(0, MAX_QUEUE)
|
||||
try {
|
||||
if (beacon) {
|
||||
await api.postAnalyticsEvents({ items }, { keepalive: true })
|
||||
} else {
|
||||
await api.postAnalyticsEvents({ items })
|
||||
}
|
||||
} catch {
|
||||
queue = items.concat(queue).slice(0, MAX_QUEUE)
|
||||
}
|
||||
}
|
||||
|
||||
function startFlushLoop(): void {
|
||||
if (typeof window === 'undefined' || flushTimer) return
|
||||
flushTimer = setInterval(() => {
|
||||
void flushQueue(false)
|
||||
}, FLUSH_MS)
|
||||
}
|
||||
|
||||
function emitPageLeave(path: string): void {
|
||||
if (!path || !pageEnteredAt) return
|
||||
const dwell = Math.max(0, Date.now() - pageEnteredAt)
|
||||
track(AnalyticsEvent.PageLeave, { page_path: path, dwell_ms: dwell })
|
||||
}
|
||||
|
||||
function endSession(exitPage: string): void {
|
||||
ensureSession()
|
||||
let started = Date.now()
|
||||
try {
|
||||
started = Number(sessionStorage.getItem(SESSION_START_KEY) || Date.now())
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const duration = Math.max(0, Date.now() - started)
|
||||
track(AnalyticsEvent.SessionEnd, {
|
||||
exit_page: exitPage,
|
||||
duration_ms: duration,
|
||||
page_path: exitPage,
|
||||
})
|
||||
void flushQueue(true)
|
||||
}
|
||||
|
||||
function bindLifecycle(): void {
|
||||
if (typeof document === 'undefined' || listenersBound) return
|
||||
listenersBound = true
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
emitPageLeave(currentPath)
|
||||
void flushQueue(true)
|
||||
} else {
|
||||
ensureSession()
|
||||
}
|
||||
})
|
||||
window.addEventListener('pagehide', () => {
|
||||
emitPageLeave(currentPath)
|
||||
endSession(currentPath || '/')
|
||||
})
|
||||
document.addEventListener(
|
||||
'click',
|
||||
(ev) => {
|
||||
const t = ev.target as HTMLElement | null
|
||||
const el = t?.closest?.('[data-track]') as HTMLElement | null
|
||||
if (!el) return
|
||||
const id = el.getAttribute('data-track') || ''
|
||||
if (!id) return
|
||||
track(AnalyticsEvent.UiClick, { element_id: id, page_path: currentPath })
|
||||
},
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
/** Fire a custom event. Dual-write GA + own queue; never throws. */
|
||||
export function track(event: string, params?: AnalyticsParams): void {
|
||||
try {
|
||||
const clean: Record<string, string | number | boolean> = {}
|
||||
@@ -64,20 +224,47 @@ export function track(event: string, params?: AnalyticsParams): void {
|
||||
if (v !== undefined) clean[k] = v
|
||||
}
|
||||
}
|
||||
if (debugEnabled()) {
|
||||
console.debug('[analytics]', event, clean)
|
||||
}
|
||||
if (debugEnabled()) console.debug('[analytics]', event, clean)
|
||||
if (typeof window !== 'undefined' && typeof window.gtag === 'function') {
|
||||
window.gtag('event', event, clean)
|
||||
}
|
||||
enqueue(event, params)
|
||||
} catch {
|
||||
/* never block UX */
|
||||
}
|
||||
}
|
||||
|
||||
export function trackPageView(path: string, title?: string): void {
|
||||
if (currentPath && currentPath !== path) {
|
||||
emitPageLeave(currentPath)
|
||||
referrerPath = currentPath
|
||||
}
|
||||
currentPath = path
|
||||
pageEnteredAt = Date.now()
|
||||
track(AnalyticsEvent.PageView, {
|
||||
page_path: path,
|
||||
page_title: title || document.title,
|
||||
page_title: title || (typeof document !== 'undefined' ? document.title : ''),
|
||||
referrer_path: referrerPath || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
/** Test helpers */
|
||||
export function _resetAnalyticsForTests(): void {
|
||||
queue = []
|
||||
pageEnteredAt = 0
|
||||
currentPath = ''
|
||||
referrerPath = ''
|
||||
listenersBound = false
|
||||
if (flushTimer) {
|
||||
clearInterval(flushTimer)
|
||||
flushTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
export function _peekQueueForTests(): QueueItem[] {
|
||||
return queue.slice()
|
||||
}
|
||||
|
||||
export function _setOwnEnabledForTests(v: boolean): void {
|
||||
ownEnabled = v
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { AuthUser, GrowthReport, Profile } from '@yuxingu/types'
|
||||
import type { Router } from 'vue-router'
|
||||
import { api } from '../api/client'
|
||||
|
||||
const TOKEN_KEY = 'yxg_token'
|
||||
|
||||
export function setAuthToken(token: string) {
|
||||
localStorage.setItem(TOKEN_KEY, token)
|
||||
}
|
||||
|
||||
export function clearAuthToken() {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
export function getAuthToken() {
|
||||
return localStorage.getItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
/** Ensure logged-in account; redirect to /login on failure. */
|
||||
export async function ensureAccount(router: Router, redirect: string): Promise<AuthUser | null> {
|
||||
try {
|
||||
return await api.authMe()
|
||||
} catch {
|
||||
clearAuthToken()
|
||||
await router.replace({ path: '/login', query: { redirect } })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function findSelfProfile(): Promise<Profile | null> {
|
||||
const { items } = await api.listProfiles()
|
||||
return items.find((p) => p.relation === 'self') || null
|
||||
}
|
||||
|
||||
/** Load cached solo report for self profile; null report means need archive. */
|
||||
export async function loadSelfLatest(
|
||||
type: 'portrait' | 'star' | 'rhythm',
|
||||
): Promise<{ profile: Profile | null; report: GrowthReport | null }> {
|
||||
const profile = await findSelfProfile()
|
||||
if (!profile) return { profile: null, report: null }
|
||||
try {
|
||||
const report = await api.getLatestReport(profile.id, type)
|
||||
return { profile, report }
|
||||
} catch {
|
||||
return { profile, report: null }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user