feat: P1 合盘/星座/问答与测测完整设计包及模拟器取证工具
落地 synastry/star/ask API 与 H5 页面,补齐 cece-frontend-re complete-design 证据文档,并加入 Android 模拟器截图抓取脚本。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AnalyticsEvent, track, trackPageView } from './analytics'
|
||||
|
||||
describe('analytics.track', () => {
|
||||
beforeEach(() => {
|
||||
window.gtag = vi.fn()
|
||||
})
|
||||
afterEach(() => {
|
||||
delete window.gtag
|
||||
})
|
||||
|
||||
it('calls gtag with event name and params', () => {
|
||||
track(AnalyticsEvent.HomeCtaPortrait)
|
||||
expect(window.gtag).toHaveBeenCalledWith('event', 'home_cta_portrait', {})
|
||||
})
|
||||
|
||||
it('omits undefined params', () => {
|
||||
track(AnalyticsEvent.PortraitCompleted, { source: 'query', report_id: undefined })
|
||||
expect(window.gtag).toHaveBeenCalledWith('event', 'portrait_completed', { source: 'query' })
|
||||
})
|
||||
|
||||
it('does not throw when gtag missing', () => {
|
||||
delete window.gtag
|
||||
expect(() => track(AnalyticsEvent.RelationCompleted)).not.toThrow()
|
||||
})
|
||||
|
||||
it('trackPageView sends page_path', () => {
|
||||
trackPageView('/portrait', '个人画像')
|
||||
expect(window.gtag).toHaveBeenCalledWith('event', 'page_view', {
|
||||
page_path: '/portrait',
|
||||
page_title: '个人画像',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* P1 growth analytics — see .ai/product/feature-spec/analytics.md
|
||||
* Pages MUST use track(); do not call window.gtag directly.
|
||||
*/
|
||||
|
||||
export type AnalyticsParams = Record<string, string | number | boolean | undefined>
|
||||
|
||||
/** Frozen P1 core funnel events (+ page_view via router). */
|
||||
export const AnalyticsEvent = {
|
||||
HomeCtaPortrait: 'home_cta_portrait',
|
||||
PortraitCompleted: 'portrait_completed',
|
||||
DeepAccessClicked: 'deep_access_clicked',
|
||||
PurchaseCompleted: 'purchase_completed',
|
||||
RelationCompleted: 'relation_completed',
|
||||
SynastryCompleted: 'synastry_completed',
|
||||
SynastryInviteCreated: 'synastry_invite_created',
|
||||
SynastryInviteAccepted: 'synastry_invite_accepted',
|
||||
SynastryNearbyOpened: 'synastry_nearby_opened',
|
||||
StarWheelViewed: 'star_wheel_viewed',
|
||||
PageView: 'page_view',
|
||||
} as const
|
||||
|
||||
export type CoreAnalyticsEvent = (typeof AnalyticsEvent)[keyof typeof AnalyticsEvent]
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
dataLayer?: unknown[]
|
||||
gtag?: (...args: unknown[]) => void
|
||||
}
|
||||
}
|
||||
|
||||
let gaReady = false
|
||||
|
||||
/** Load GA4 when VITE_GA_MEASUREMENT_ID is set. Safe to call once from main. */
|
||||
export function initAnalytics(): 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)}`
|
||||
document.head.appendChild(s)
|
||||
}
|
||||
|
||||
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. */
|
||||
export function track(event: string, params?: AnalyticsParams): void {
|
||||
try {
|
||||
const clean: Record<string, string | number | boolean> = {}
|
||||
if (params) {
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v !== undefined) clean[k] = v
|
||||
}
|
||||
}
|
||||
if (debugEnabled()) {
|
||||
console.debug('[analytics]', event, clean)
|
||||
}
|
||||
if (typeof window !== 'undefined' && typeof window.gtag === 'function') {
|
||||
window.gtag('event', event, clean)
|
||||
}
|
||||
} catch {
|
||||
/* never block UX */
|
||||
}
|
||||
}
|
||||
|
||||
export function trackPageView(path: string, title?: string): void {
|
||||
track(AnalyticsEvent.PageView, {
|
||||
page_path: path,
|
||||
page_title: title || document.title,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Icon glyphs + tones from legacy yuxingu.html / css/home.css
|
||||
* (geometric marks — no emoji)
|
||||
*/
|
||||
|
||||
export type IconTone =
|
||||
| 'pink'
|
||||
| 'blue'
|
||||
| 'purple'
|
||||
| 'orange'
|
||||
| 'green'
|
||||
| 'rose'
|
||||
| 'teal'
|
||||
| 'gold'
|
||||
| 'night'
|
||||
| 'mute'
|
||||
|
||||
export const FeatureIcon = {
|
||||
home: { mark: '⌂', tone: 'mute' as IconTone },
|
||||
portrait: { mark: '△', tone: 'gold' as IconTone }, // 愈心解码
|
||||
star: { mark: '✦', tone: 'night' as IconTone }, // 星座星盘
|
||||
rhythm: { mark: '☯', tone: 'green' as IconTone },
|
||||
cards: { mark: '◈', tone: 'teal' as IconTone },
|
||||
relation: { mark: '♡', tone: 'rose' as IconTone },
|
||||
ask: { mark: '◎', tone: 'gold' as IconTone },
|
||||
companion: { mark: '♡', tone: 'green' as IconTone },
|
||||
explore: { mark: '◆', tone: 'blue' as IconTone },
|
||||
more: { mark: '⋯', tone: 'mute' as IconTone },
|
||||
membership: { mark: '⑨', tone: 'purple' as IconTone },
|
||||
profile: { mark: '◍', tone: 'mute' as IconTone },
|
||||
reports: { mark: '▣', tone: 'orange' as IconTone },
|
||||
scale: { mark: '◆', tone: 'blue' as IconTone },
|
||||
mood: { mark: '○', tone: 'green' as IconTone },
|
||||
tip: { mark: '※', tone: 'gold' as IconTone },
|
||||
share: { mark: '↗', tone: 'pink' as IconTone },
|
||||
growth: { mark: '▽', tone: 'teal' as IconTone },
|
||||
} as const
|
||||
|
||||
export type FeatureIconKey = keyof typeof FeatureIcon
|
||||
|
||||
/** Section titles inside ReportRich — tones stay soft red via CSS default */
|
||||
export function sectionMark(title: string): string {
|
||||
if (/总览|摘要/.test(title)) return '◈'
|
||||
if (/多维|维度/.test(title)) return '◆'
|
||||
if (/优势/.test(title)) return '△'
|
||||
if (/留意|盲区|摩擦/.test(title)) return '◇'
|
||||
if (/成长|路径|计划/.test(title)) return '▽'
|
||||
if (/可以这样说|对话|沟通/.test(title)) return '◎'
|
||||
if (/建议|练习|本周/.test(title)) return '※'
|
||||
if (/疑问|FAQ/i.test(title)) return '?'
|
||||
if (/太阳|星象|月亮|上升/.test(title)) return '✦'
|
||||
if (/节律|五行|作息/.test(title)) return '☯'
|
||||
if (/意象|卡片/.test(title)) return '◈'
|
||||
return '·'
|
||||
}
|
||||
|
||||
export function toneClass(tone: IconTone): string {
|
||||
return `icon-${tone}`
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { clearScaleDraft, loadScaleDraft, saveScaleDraft, scaleDraftKey } from './scaleDraft'
|
||||
|
||||
describe('scaleDraft', () => {
|
||||
afterEach(() => {
|
||||
clearScaleDraft('demo')
|
||||
})
|
||||
|
||||
it('saves and loads answers', () => {
|
||||
saveScaleDraft('demo', { q1: 'A', q2: 'B' })
|
||||
expect(loadScaleDraft('demo')).toEqual({ q1: 'A', q2: 'B' })
|
||||
expect(localStorage.getItem(scaleDraftKey('demo'))).toBeTruthy()
|
||||
})
|
||||
|
||||
it('clears draft', () => {
|
||||
saveScaleDraft('demo', { q1: 'A' })
|
||||
clearScaleDraft('demo')
|
||||
expect(loadScaleDraft('demo')).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores empty values', () => {
|
||||
saveScaleDraft('demo', { q1: 'A', q2: '' })
|
||||
expect(loadScaleDraft('demo')).toEqual({ q1: 'A' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
/** Local draft for exploration-test answers — see feature-spec/explore-test.md */
|
||||
|
||||
const prefix = 'yuxingu_scale_draft_v1:'
|
||||
|
||||
export function scaleDraftKey(slug: string): string {
|
||||
return prefix + slug
|
||||
}
|
||||
|
||||
export function loadScaleDraft(slug: string): Record<string, string> | null {
|
||||
if (!slug || typeof localStorage === 'undefined') return null
|
||||
try {
|
||||
const raw = localStorage.getItem(scaleDraftKey(slug))
|
||||
if (!raw) return null
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null
|
||||
const out: Record<string, string> = {}
|
||||
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
|
||||
if (typeof v === 'string' && v) out[k] = v
|
||||
}
|
||||
return Object.keys(out).length ? out : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function saveScaleDraft(slug: string, answers: Record<string, string>): void {
|
||||
if (!slug || typeof localStorage === 'undefined') return
|
||||
try {
|
||||
const clean: Record<string, string> = {}
|
||||
for (const [k, v] of Object.entries(answers)) {
|
||||
if (v) clean[k] = v
|
||||
}
|
||||
if (!Object.keys(clean).length) {
|
||||
localStorage.removeItem(scaleDraftKey(slug))
|
||||
return
|
||||
}
|
||||
localStorage.setItem(scaleDraftKey(slug), JSON.stringify(clean))
|
||||
} catch {
|
||||
/* quota / private mode */
|
||||
}
|
||||
}
|
||||
|
||||
export function clearScaleDraft(slug: string): void {
|
||||
if (!slug || typeof localStorage === 'undefined') return
|
||||
try {
|
||||
localStorage.removeItem(scaleDraftKey(slug))
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { buildSharePath, parseShareQuery } from './shareLink'
|
||||
|
||||
describe('shareLink', () => {
|
||||
it('round-trips portrait payload', () => {
|
||||
const path = buildSharePath({
|
||||
type: 'portrait',
|
||||
title: '偏稳的互动风格',
|
||||
line: '一句画像',
|
||||
keywords: ['稳', '细'],
|
||||
})
|
||||
expect(path.startsWith('/share?')).toBe(true)
|
||||
const q = Object.fromEntries(new URLSearchParams(path.slice(path.indexOf('?'))))
|
||||
const parsed = parseShareQuery(q)
|
||||
expect(parsed).toEqual({
|
||||
type: 'portrait',
|
||||
title: '偏稳的互动风格',
|
||||
line: '一句画像',
|
||||
keywords: ['稳', '细'],
|
||||
})
|
||||
})
|
||||
|
||||
it('round-trips relation payload', () => {
|
||||
const path = buildSharePath({
|
||||
type: 'relation',
|
||||
me: '理性型',
|
||||
other: '感受型',
|
||||
diff: '节奏不同',
|
||||
keywords: ['我·稳'],
|
||||
})
|
||||
const q = Object.fromEntries(new URLSearchParams(path.slice(path.indexOf('?'))))
|
||||
const parsed = parseShareQuery(q)
|
||||
expect(parsed).toEqual({
|
||||
type: 'relation',
|
||||
me: '理性型',
|
||||
other: '感受型',
|
||||
diff: '节奏不同',
|
||||
keywords: ['我·稳'],
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null for empty/invalid', () => {
|
||||
expect(parseShareQuery({})).toBeNull()
|
||||
expect(parseShareQuery({ type: 'portrait' })).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
/** Build / parse client-side share landing query (no server). */
|
||||
|
||||
export type PortraitSharePayload = {
|
||||
type: 'portrait'
|
||||
title: string
|
||||
line: string
|
||||
keywords: string[]
|
||||
}
|
||||
|
||||
export type RelationSharePayload = {
|
||||
type: 'relation'
|
||||
me: string
|
||||
other: string
|
||||
diff: string
|
||||
keywords: string[]
|
||||
}
|
||||
|
||||
export type SharePayload = PortraitSharePayload | RelationSharePayload
|
||||
|
||||
export function buildSharePath(payload: SharePayload): string {
|
||||
const q = new URLSearchParams()
|
||||
q.set('type', payload.type)
|
||||
if (payload.type === 'portrait') {
|
||||
if (payload.title) q.set('t', payload.title)
|
||||
if (payload.line) q.set('l', payload.line)
|
||||
if (payload.keywords.length) q.set('k', payload.keywords.slice(0, 6).join(','))
|
||||
} else {
|
||||
if (payload.me) q.set('me', payload.me)
|
||||
if (payload.other) q.set('ot', payload.other)
|
||||
if (payload.diff) q.set('d', payload.diff)
|
||||
if (payload.keywords.length) q.set('k', payload.keywords.slice(0, 6).join(','))
|
||||
}
|
||||
return `/share?${q.toString()}`
|
||||
}
|
||||
|
||||
export function parseShareQuery(query: Record<string, unknown>): SharePayload | null {
|
||||
const type = String(query.type || '')
|
||||
const keywords = String(query.k || '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
if (type === 'portrait') {
|
||||
const title = String(query.t || '')
|
||||
const line = String(query.l || '')
|
||||
if (!title && !line && !keywords.length) return null
|
||||
return { type: 'portrait', title, line, keywords }
|
||||
}
|
||||
if (type === 'relation') {
|
||||
const me = String(query.me || '')
|
||||
const other = String(query.ot || '')
|
||||
const diff = String(query.d || '')
|
||||
if (!me && !other && !diff) return null
|
||||
return { type: 'relation', me, other, diff, keywords }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export async function copyText(text: string): Promise<boolean> {
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
return true
|
||||
}
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
try {
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = text
|
||||
ta.style.position = 'fixed'
|
||||
ta.style.left = '-9999px'
|
||||
document.body.appendChild(ta)
|
||||
ta.select()
|
||||
const ok = document.execCommand('copy')
|
||||
document.body.removeChild(ta)
|
||||
return ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user