refactor(ECR-001): 接入 ESS 并完成结构对齐 Phase A–E
绑定 ESS 双轨治理,拆分超大 H5 页与 Go 引擎,抽出 membership 服务, 并将 star/fortune 重命名为 outlook(JSON 双写兼容);同时修复 /psy API 代理与首页 + 菜单层级。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
const moodTexts = [
|
||||
'今天心态平稳,遇到小波折也能慢慢化解,适合把一件小事做完。',
|
||||
'精力在回升,适合温和推进计划,不必一次做完所有事。',
|
||||
'情绪有起伏很正常,给自己一点空隙,会更清楚下一步。',
|
||||
'今天利于沟通与整理思路,可以从身边亲近的人开始。',
|
||||
'节奏偏慢也没关系,把注意力放在身体感受上会更踏实。',
|
||||
]
|
||||
|
||||
const dimColors = ['#ff7a9a', '#ff9a5c', '#5b9cff', '#3ecfcf', '#a78bfa']
|
||||
|
||||
function daySeed(): number {
|
||||
const n = new Date()
|
||||
return n.getFullYear() * 10000 + (n.getMonth() + 1) * 100 + n.getDate()
|
||||
}
|
||||
|
||||
function clamp(n: number) {
|
||||
return Math.max(40, Math.min(95, n))
|
||||
}
|
||||
|
||||
function moodFromSeed(seed: number) {
|
||||
const score = 58 + (seed % 37)
|
||||
const text = moodTexts[seed % moodTexts.length]
|
||||
const base = [70, 62, 68, 64, 66]
|
||||
const dims = [
|
||||
{ key: 'love', label: '爱情', score: clamp(base[0] + (seed % 17) - 8), color: dimColors[0] },
|
||||
{ key: 'wealth', label: '财富', score: clamp(base[1] + ((seed >> 2) % 19) - 9), color: dimColors[1] },
|
||||
{ key: 'career', label: '事业', score: clamp(base[2] + ((seed >> 3) % 15) - 7), color: dimColors[2] },
|
||||
{ key: 'learn', label: '学习', score: clamp(base[3] + ((seed >> 4) % 21) - 10), color: dimColors[3] },
|
||||
{ key: 'social', label: '人际', score: clamp(base[4] + ((seed >> 5) % 13) - 6), color: dimColors[4] },
|
||||
]
|
||||
return { score, text, dims }
|
||||
}
|
||||
|
||||
/** Deterministic daily mood card for home self-card. */
|
||||
export function useHomeMood() {
|
||||
return computed(() => moodFromSeed(daySeed()))
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { AnalyticsEvent, track } from '../lib/analytics'
|
||||
import { homeFeeds, homeGridRow1, homeGridRow2, homeSearchHints } from '../lib/homeCatalog'
|
||||
import { useHomeMood } from './useHomeMood'
|
||||
|
||||
export function useHomePage() {
|
||||
const router = useRouter()
|
||||
const profileLabel = ref('自己')
|
||||
const plusOpen = ref(false)
|
||||
const mood = useHomeMood()
|
||||
|
||||
const searchHint = computed(() => {
|
||||
const i = new Date().getHours() % homeSearchHints.length
|
||||
return homeSearchHints[i]
|
||||
})
|
||||
|
||||
function goPlus(kind: 'inviteFill' | 'add' | 'synastry') {
|
||||
plusOpen.value = false
|
||||
track(AnalyticsEvent.HomeCtaPortrait, { source: 'home_plus', kind })
|
||||
if (kind === 'inviteFill') {
|
||||
router.push({ path: '/profile', query: { inviteFill: '1' } })
|
||||
return
|
||||
}
|
||||
if (kind === 'add') {
|
||||
router.push({ path: '/profile', query: { add: '1' } })
|
||||
return
|
||||
}
|
||||
router.push({ path: '/synastry', query: { invite: '1' } })
|
||||
}
|
||||
|
||||
function trackGrid(label: string) {
|
||||
track(AnalyticsEvent.HomeCtaPortrait, { source: 'home_grid', label })
|
||||
}
|
||||
|
||||
return {
|
||||
router,
|
||||
profileLabel,
|
||||
plusOpen,
|
||||
mood,
|
||||
searchHint,
|
||||
gridRow1: homeGridRow1,
|
||||
gridRow2: homeGridRow2,
|
||||
feeds: homeFeeds,
|
||||
goPlus,
|
||||
trackGrid,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import type { GrowthReport, Profile } from '@yuxingu/types'
|
||||
import { validateBirth } from '@yuxingu/utils'
|
||||
import { api } from '../api/client'
|
||||
import type { WheelPlanet } from '../components/NatalWheel.vue'
|
||||
import { AnalyticsEvent, track } from '../lib/analytics'
|
||||
import type { RelationSharePayload } from '../lib/shareLink'
|
||||
|
||||
export type MainTab = 'compare' | 'composite' | 'davison' | 'marks' | 'overlay'
|
||||
|
||||
export const relationTypes = ['伴侣', '朋友', '家人', '其他']
|
||||
|
||||
export const mainTabs: { key: MainTab; label: string }[] = [
|
||||
{ key: 'compare', label: '比较' },
|
||||
{ key: 'composite', label: '组合' },
|
||||
{ key: 'davison', label: '时空' },
|
||||
{ key: 'marks', label: '马克斯' },
|
||||
{ key: 'overlay', label: '配对' },
|
||||
]
|
||||
|
||||
export function useSynastryPage() {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(false)
|
||||
const adding = ref(false)
|
||||
const paying = ref(false)
|
||||
const inviting = ref(false)
|
||||
const nearbyLoading = ref(false)
|
||||
const error = ref('')
|
||||
const nearbyHint = ref('')
|
||||
const invitePath = ref('')
|
||||
const inviteHighlight = ref(false)
|
||||
const profiles = ref<Profile[]>([])
|
||||
const nearby = ref<{ profile: Profile; distance_km: number }[]>([])
|
||||
const profileA = ref('')
|
||||
const profileB = ref('')
|
||||
const asOf = ref(new Date().toISOString().slice(0, 10))
|
||||
const report = ref<GrowthReport | null>(null)
|
||||
const shareOpen = ref(false)
|
||||
const ty = ref('')
|
||||
const tm = ref('')
|
||||
const td = ref('')
|
||||
const tName = ref('TA')
|
||||
const mainTab = ref<MainTab>('compare')
|
||||
const subTab = ref<'natal' | 'prog'>('natal')
|
||||
const marksWho = ref<'me' | 'other'>('me')
|
||||
const relationType = ref('伴侣')
|
||||
const showClassicSelects = ref(false)
|
||||
const showQuickAdd = ref(false)
|
||||
|
||||
const needsSubTab = computed(() => ['composite', 'davison', 'marks'].includes(mainTab.value))
|
||||
|
||||
const pickableProfiles = computed(() => profiles.value.filter((p) => p.id !== profileA.value))
|
||||
|
||||
const profileAName = computed(() => {
|
||||
const p = profiles.value.find((x) => x.id === profileA.value)
|
||||
return p?.display_name || ''
|
||||
})
|
||||
|
||||
const selfInitial = computed(() => {
|
||||
const name = profileAName.value || '我'
|
||||
return name.slice(0, 1)
|
||||
})
|
||||
|
||||
function profileInitial(p: Profile) {
|
||||
return (p.display_name || 'TA').slice(0, 1)
|
||||
}
|
||||
|
||||
const summary = computed(() => (report.value?.summary || {}) as Record<string, unknown>)
|
||||
const detail = computed(() => (report.value?.detail || null) as Record<string, unknown> | null)
|
||||
const charts = computed(() => (summary.value.charts || {}) as Record<string, unknown>)
|
||||
const headline = computed(() => String(summary.value.headline || ''))
|
||||
const oneLiner = computed(() => String(summary.value.one_liner || ''))
|
||||
const love = computed(() => Number(summary.value.love_index || 0))
|
||||
const friend = computed(() => Number(summary.value.friend_index || 0))
|
||||
const marriage = computed(() => Number(summary.value.marriage_index || 0))
|
||||
const loveNote = computed(() => String(summary.value.love_note || ''))
|
||||
const asOfLabel = computed(() => String(summary.value.as_of || asOf.value))
|
||||
|
||||
function asPlanets(raw: unknown): WheelPlanet[] {
|
||||
if (!Array.isArray(raw)) return []
|
||||
return raw.map((p) => {
|
||||
const o = p as Record<string, unknown>
|
||||
return {
|
||||
key: String(o.key),
|
||||
title: String(o.title),
|
||||
sign: String(o.sign),
|
||||
degree: String(o.degree),
|
||||
house: Number(o.house),
|
||||
lon: Number(o.lon),
|
||||
element: o.element != null ? String(o.element) : undefined,
|
||||
modality: o.modality != null ? String(o.modality) : undefined,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function chartPlanets(key: 'chart_a' | 'chart_b'): WheelPlanet[] {
|
||||
const c = summary.value[key]
|
||||
if (!c || typeof c !== 'object') return []
|
||||
return asPlanets((c as { planets?: unknown }).planets)
|
||||
}
|
||||
|
||||
const planetsA = computed(() => chartPlanets('chart_a'))
|
||||
const planetsB = computed(() => chartPlanets('chart_b'))
|
||||
const ascA = computed(() => {
|
||||
const c = summary.value.chart_a as { asc_lon?: number } | undefined
|
||||
return typeof c?.asc_lon === 'number' ? c.asc_lon : null
|
||||
})
|
||||
const ascB = computed(() => {
|
||||
const c = summary.value.chart_b as { asc_lon?: number } | undefined
|
||||
return typeof c?.asc_lon === 'number' ? c.asc_lon : null
|
||||
})
|
||||
const aspectPreview = computed(() => {
|
||||
const raw = summary.value.aspects_preview
|
||||
return Array.isArray(raw) ? (raw as { label: string }[]) : []
|
||||
})
|
||||
|
||||
function chartBlock(key: string): Record<string, unknown> | null {
|
||||
const c = charts.value[key]
|
||||
if (!c || typeof c !== 'object') return null
|
||||
return c as Record<string, unknown>
|
||||
}
|
||||
|
||||
function chartTip(key: string): string {
|
||||
const c = chartBlock(key)
|
||||
return String(c?.tip || '')
|
||||
}
|
||||
|
||||
const activeChartKey = computed(() => {
|
||||
if (mainTab.value === 'composite') {
|
||||
return subTab.value === 'prog' ? 'composite_progressed' : 'composite'
|
||||
}
|
||||
if (mainTab.value === 'davison') {
|
||||
return subTab.value === 'prog' ? 'davison_progressed' : 'davison'
|
||||
}
|
||||
if (mainTab.value === 'marks') {
|
||||
if (subTab.value === 'prog') return 'marks_progressed'
|
||||
return marksWho.value === 'other' ? 'marks_other' : 'marks_me'
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const activePlanets = computed(() => {
|
||||
const c = chartBlock(activeChartKey.value)
|
||||
return asPlanets(c?.planets)
|
||||
})
|
||||
const activeAsc = computed(() => {
|
||||
const c = chartBlock(activeChartKey.value)
|
||||
return typeof c?.asc_lon === 'number' ? (c.asc_lon as number) : null
|
||||
})
|
||||
const activeAspectPreview = computed(() => {
|
||||
const c = chartBlock(activeChartKey.value)
|
||||
const raw = c?.aspects_preview
|
||||
return Array.isArray(raw) ? (raw as { label: string }[]) : []
|
||||
})
|
||||
const activeChartTip = computed(() => chartTip(activeChartKey.value))
|
||||
|
||||
const overlayTip = computed(() => String(chartBlock('overlay')?.tip || ''))
|
||||
const overlayEntries = computed(() => {
|
||||
const raw = chartBlock('overlay')?.entries
|
||||
return Array.isArray(raw)
|
||||
? (raw as { planet: string; sign: string; house: number; house_tip: string }[])
|
||||
: []
|
||||
})
|
||||
|
||||
const fullAspects = computed(() => {
|
||||
const raw = detail.value?.aspects
|
||||
return Array.isArray(raw) ? (raw as { label: string }[]) : []
|
||||
})
|
||||
const sections = computed(() => {
|
||||
const raw = detail.value?.sections
|
||||
return Array.isArray(raw) ? (raw as { title: string; body: string }[]) : []
|
||||
})
|
||||
const sharePayload = computed<RelationSharePayload | null>(() => {
|
||||
if (!report.value) return null
|
||||
return {
|
||||
type: 'relation',
|
||||
me: String(summary.value.me_name || '我'),
|
||||
other: String(summary.value.other_name || 'TA'),
|
||||
diff: headline.value,
|
||||
keywords: [`恋爱${love.value}`, `友情${friend.value}`, `婚姻${marriage.value}`],
|
||||
}
|
||||
})
|
||||
|
||||
function birthLabel(d?: string) {
|
||||
if (!d) return ''
|
||||
return String(d).slice(0, 10)
|
||||
}
|
||||
|
||||
async function loadProfiles() {
|
||||
try {
|
||||
const res = await api.listProfiles()
|
||||
profiles.value = res.items || []
|
||||
const self = profiles.value.find((p) => p.relation === 'self')
|
||||
if (self && !profileA.value) profileA.value = self.id
|
||||
const other = profiles.value.find((p) => p.id !== profileA.value)
|
||||
if (other && !profileB.value) profileB.value = other.id
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载档案失败'
|
||||
}
|
||||
}
|
||||
|
||||
async function addTemp() {
|
||||
const y = Number(ty.value)
|
||||
const m = Number(tm.value)
|
||||
const d = Number(td.value)
|
||||
const msg = validateBirth(y, m, d)
|
||||
if (msg) {
|
||||
error.value = msg
|
||||
return
|
||||
}
|
||||
adding.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const birth = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
|
||||
const p = await api.createProfile({
|
||||
relation: 'other',
|
||||
birth_date: birth,
|
||||
display_name: tName.value || 'TA',
|
||||
})
|
||||
await loadProfiles()
|
||||
profileB.value = p.id
|
||||
showQuickAdd.value = false
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '添加失败'
|
||||
} finally {
|
||||
adding.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function generate() {
|
||||
if (!profileA.value || !profileB.value) return
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
report.value = await api.createSynastry(profileA.value, profileB.value, asOf.value)
|
||||
mainTab.value = 'compare'
|
||||
track(AnalyticsEvent.SynastryCompleted, { source: 'synastry' })
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '合盘失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createInvite() {
|
||||
if (!profileA.value) return
|
||||
inviting.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const res = await api.createSynastryInvite(profileA.value)
|
||||
invitePath.value = res.path
|
||||
track(AnalyticsEvent.SynastryInviteCreated, { token: res.token })
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '邀请失败'
|
||||
} finally {
|
||||
inviting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadNearby() {
|
||||
nearbyLoading.value = true
|
||||
nearbyHint.value = ''
|
||||
error.value = ''
|
||||
try {
|
||||
const pos = await new Promise<GeolocationPosition>((resolve, reject) => {
|
||||
if (!navigator.geolocation) {
|
||||
reject(new Error('当前环境不支持定位'))
|
||||
return
|
||||
}
|
||||
navigator.geolocation.getCurrentPosition(resolve, reject, { timeout: 8000 })
|
||||
})
|
||||
const lat = pos.coords.latitude
|
||||
const lng = pos.coords.longitude
|
||||
const self = profiles.value.find((p) => p.relation === 'self')
|
||||
if (self) {
|
||||
await api.updateProfile(self.id, { geo_lat: lat, geo_lng: lng })
|
||||
}
|
||||
const res = await api.listSynastryNearby(lat, lng, 50)
|
||||
nearby.value = res.items || []
|
||||
nearbyHint.value = nearby.value.length
|
||||
? `找到 ${nearby.value.length} 位附近可合盘对象。若希望别人看到你,请在档案中开启「位置可见」。`
|
||||
: '附近暂无已开启位置可见的档案;可邀请好友或手动添加。需要被看到时请在档案开启位置可见。'
|
||||
track(AnalyticsEvent.SynastryNearbyOpened, { count: nearby.value.length })
|
||||
} catch (e) {
|
||||
nearbyHint.value = e instanceof Error ? e.message : '定位失败,可手动选城后在档案页开启位置'
|
||||
} finally {
|
||||
nearbyLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function buyDeep() {
|
||||
if (!report.value) return
|
||||
paying.value = true
|
||||
track(AnalyticsEvent.DeepAccessClicked, { surface: 'synastry' })
|
||||
try {
|
||||
const { order_id } = await api.createOrder({ kind: 'deep_access', report_id: report.value.id })
|
||||
await api.payMock(order_id)
|
||||
report.value = await api.getReport(report.value.id)
|
||||
track(AnalyticsEvent.PurchaseCompleted, { kind: 'deep_access' })
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '支付失败'
|
||||
} finally {
|
||||
paying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
report.value = null
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadProfiles()
|
||||
if (route.query.invite === '1') {
|
||||
inviteHighlight.value = true
|
||||
if (profileA.value) {
|
||||
await createInvite()
|
||||
} else {
|
||||
error.value = '请先完成自己的档案,再邀请好友合盘'
|
||||
}
|
||||
void router.replace({ path: '/synastry', query: {} })
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
loading,
|
||||
adding,
|
||||
paying,
|
||||
inviting,
|
||||
nearbyLoading,
|
||||
error,
|
||||
nearbyHint,
|
||||
invitePath,
|
||||
inviteHighlight,
|
||||
profiles,
|
||||
nearby,
|
||||
profileA,
|
||||
profileB,
|
||||
asOf,
|
||||
report,
|
||||
shareOpen,
|
||||
ty,
|
||||
tm,
|
||||
td,
|
||||
tName,
|
||||
mainTab,
|
||||
subTab,
|
||||
marksWho,
|
||||
relationType,
|
||||
showClassicSelects,
|
||||
showQuickAdd,
|
||||
needsSubTab,
|
||||
pickableProfiles,
|
||||
profileAName,
|
||||
selfInitial,
|
||||
profileInitial,
|
||||
headline,
|
||||
oneLiner,
|
||||
love,
|
||||
friend,
|
||||
marriage,
|
||||
loveNote,
|
||||
asOfLabel,
|
||||
planetsA,
|
||||
planetsB,
|
||||
ascA,
|
||||
ascB,
|
||||
aspectPreview,
|
||||
chartTip,
|
||||
activePlanets,
|
||||
activeAsc,
|
||||
activeAspectPreview,
|
||||
activeChartTip,
|
||||
overlayTip,
|
||||
overlayEntries,
|
||||
fullAspects,
|
||||
sections,
|
||||
sharePayload,
|
||||
detail,
|
||||
birthLabel,
|
||||
addTemp,
|
||||
generate,
|
||||
createInvite,
|
||||
loadNearby,
|
||||
buyDeep,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user