feat(ops): ECR-007 行为分析与 ECR-008 内容运营后台
ci / h5 (push) Canceled after 0s
ci / api (push) Canceled after 0s
ci / ess-docs (push) Canceled after 0s

落地埋点 ingest/数据看板、首页宫格 CMS 与测评上下架;含账号引导、问答流式与免责声明去重,以及 review P1 审计同事务修复。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-07 02:26:16 +08:00
co-authored by Cursor
parent 7e9023f0a8
commit 7ab9add5dd
132 changed files with 8276 additions and 491 deletions
+43 -4
View File
@@ -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')
})
})
+197 -10
View File
@@ -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
}
+47
View File
@@ -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 }
}
}