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:
jackyu66git
2026-08-03 11:37:53 +08:00
co-authored by Cursor
parent 15a9db374a
commit bd22d9dddd
248 changed files with 26309 additions and 842 deletions
+34
View File
@@ -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: '个人画像',
})
})
})
+83
View File
@@ -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,
})
}
+59
View File
@@ -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}`
}
+25
View File
@@ -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' })
})
})
+50
View File
@@ -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 */
}
}
+46
View File
@@ -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()
})
})
+80
View File
@@ -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
}
}