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
+404
View File
@@ -0,0 +1,404 @@
<template>
<main class="yxg-page">
<div class="yxg-hero">
<BackButton />
<PageTitle feature="star" title="星座" :sub="needBirth && !report ? '填写生日(可选出生时/地),生成星盘与运势' : undefined" />
</div>
<div class="yxg-sheet sheet-pad">
<template v-if="needBirth && !report">
<div class="yxg-card">
<label class="yxg-label">生日 / 出生时可选</label>
<div class="birth-row">
<BirthDateInputs v-model:year="year" v-model:month="month" v-model:day="day" compact />
<input class="bd-time" v-model="birthTime" type="time" aria-label="出生时" />
<span class="bd-sep"></span>
</div>
<label class="yxg-label">出生地可选 / / 区县</label>
<BirthPlaceSelect v-model="birthPlace" />
<button class="yxg-btn yxg-btn-block" type="button" :disabled="loading" @click="start">生成星座</button>
<p v-if="error" class="yxg-err">{{ error }}</p>
</div>
</template>
<p v-else-if="loading" class="yxg-hint">正在生成</p>
<p v-else-if="error" class="yxg-err">
{{ error }}
<button type="button" class="yxg-link" @click="load">重试</button>
</p>
<template v-else-if="report">
<p class="yxg-meta head">{{ headline }}</p>
<p v-if="oneLiner" class="yxg-lead">{{ oneLiner }}</p>
<p v-if="chartNote" class="yxg-meta">{{ chartNote }}</p>
<div class="panel-tabs" role="tablist">
<button
v-for="p in panels"
:key="p.key"
type="button"
class="panel-tab"
:class="{ on: panel === p.key }"
@click="panel = p.key"
>
{{ p.label }}
</button>
</div>
<template v-if="panel === 'chart'">
<div class="wheel-settings">
<label><input v-model="showOuter" type="checkbox" /> 显示外行星</label>
<label><input v-model="tightOrb" type="checkbox" /> 紧容许度</label>
</div>
<NatalWheel
v-if="wheelPlanets.length"
:planets="wheelPlanets"
:aspects="aspectLines"
:asc-lon="ascLon"
:show-outer="showOuter"
:tight-orb="tightOrb"
@select="onWheelSelect"
/>
<SignCards v-model="signTab" :cards="signCards" />
<div v-if="activeCard" class="yxg-card soft tab-body">
<h2 class="card-title">{{ activeCard.title }} · {{ activeCard.label }}</h2>
<p>{{ activeCard.teaser }}</p>
<p v-if="activeCard.element" class="yxg-meta">{{ activeCard.element }} · {{ activeCard.modality }}</p>
</div>
<div v-if="houses.length" class="yxg-card soft">
<h2 class="card-title">宫位</h2>
<p class="house-row" v-for="h in houses" :key="h.num">{{ h.num }} · {{ h.sign }}</p>
</div>
<div v-if="aspectPreview.length" class="yxg-card soft">
<h2 class="card-title">相位速览</h2>
<p v-for="(a, i) in aspectPreview" :key="i" class="aspect-line">{{ a.label || `${a.a_title}${a.type}${a.b_title}` }}</p>
<p v-if="!report.has_deep_access" class="yxg-meta">完整相位表见深度版</p>
<ul v-else class="aspect-full">
<li v-for="(a, i) in fullAspects" :key="'f' + i">{{ a.label }}</li>
</ul>
</div>
<div v-if="transits.length" class="yxg-card soft">
<h2 class="card-title">今日行运</h2>
<div v-for="t in transits" :key="t.key" class="transit">
<strong>{{ t.title }}</strong>
<span class="yxg-meta">{{ t.aspect }}</span>
<p>{{ t.tip }}</p>
</div>
</div>
</template>
<template v-else-if="panel === 'fortune'">
<div class="fortune-tabs">
<button
v-for="k in fortuneKeys"
:key="k"
type="button"
class="ftab"
:class="{ on: fortuneKey === k }"
@click="fortuneKey = k"
>
{{ fortuneLabel(k) }}
</button>
</div>
<div v-if="activeFortune" class="yxg-card daily">
<p class="daily-title">
{{ activeFortune.title }} · {{ activeFortune.label }} · {{ activeFortune.score }}
</p>
<p class="focus">焦点{{ activeFortune.focus }}</p>
<p>{{ activeFortune.tip }}</p>
<div v-if="activeFortune.dims" class="dims">
<span>感情 {{ activeFortune.dims.love }}</span>
<span>事业 {{ activeFortune.dims.career }}</span>
<span>财运 {{ activeFortune.dims.money }}</span>
<span>心情 {{ activeFortune.dims.mood }}</span>
</div>
<p class="yxg-meta">幸运{{ activeFortune.lucky }}</p>
<p class="yxg-meta">注意{{ activeFortune.caution }}</p>
</div>
</template>
<template v-else>
<ul class="planet-list">
<li v-for="p in planets" :key="p.key" class="yxg-card soft planet">
<strong>{{ p.title }}</strong>
<span>{{ p.sign }} {{ p.degree }} · {{ p.house }}</span>
<span class="yxg-meta">{{ p.element }} · {{ p.modality }}</span>
</li>
</ul>
</template>
<ReportRich :summary="summary" :detail="detail" :deep="!!report.has_deep_access" />
<div v-if="!report.has_deep_access" class="yxg-card lock">
<p>完整相位与年运详解可在深度版或成长会员中查看</p>
<button class="yxg-btn" type="button" :disabled="paying" @click="buyDeep">解锁完整分析模拟支付</button>
</div>
<div class="links">
<router-link class="yxg-link-plain" to="/synastry">去做合盘比较盘</router-link>
<router-link class="yxg-link-plain" to="/relation">去做人格匹配 </router-link>
<router-link class="yxg-link-plain" to="/ask">去问答聊聊星座 </router-link>
<router-link v-if="report.id" class="yxg-link-plain" :to="`/reports/${report.id}`">在报告页打开 </router-link>
</div>
<button type="button" class="yxg-btn yxg-btn-ghost share-btn" @click="shareOpen = true">生成分享卡</button>
</template>
<p class="yxg-disc">运势与星盘为自我探索参考请结合现实判断</p>
</div>
<ShareSheet :open="shareOpen" :payload="sharePayload" @close="shareOpen = false" />
</main>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import type { GrowthReport } from '@yuxingu/types'
import { validateBirth } from '@yuxingu/utils'
import { api } from '../api/client'
import BackButton from '../components/BackButton.vue'
import BirthDateInputs from '../components/BirthDateInputs.vue'
import BirthPlaceSelect from '../components/BirthPlaceSelect.vue'
import NatalWheel, { type WheelAspect, type WheelPlanet } from '../components/NatalWheel.vue'
import PageTitle from '../components/PageTitle.vue'
import ReportRich from '../components/ReportRich.vue'
import ShareSheet from '../components/ShareSheet.vue'
import SignCards, { type SignCard } from '../components/SignCards.vue'
import { AnalyticsEvent, track } from '../lib/analytics'
import type { PortraitSharePayload } from '../lib/shareLink'
const SETTINGS_KEY = 'yuxingu_star_wheel_settings'
const route = useRoute()
const loading = ref(false)
const needBirth = ref(true)
const error = ref('')
const report = ref<GrowthReport | null>(null)
const paying = ref(false)
const shareOpen = ref(false)
const year = ref(String(route.query.y || ''))
const month = ref(String(route.query.m || ''))
const day = ref(String(route.query.d || ''))
const birthTime = ref('')
const birthPlace = ref('')
const panel = ref<'chart' | 'fortune' | 'planets'>('chart')
const signTab = ref('sun')
const fortuneKey = ref<'daily' | 'weekly' | 'monthly' | 'yearly' | 'lifetime'>('daily')
const showOuter = ref(true)
const tightOrb = ref(false)
try {
const raw = localStorage.getItem(SETTINGS_KEY)
if (raw) {
const o = JSON.parse(raw) as { showOuter?: boolean; tightOrb?: boolean }
if (typeof o.showOuter === 'boolean') showOuter.value = o.showOuter
if (typeof o.tightOrb === 'boolean') tightOrb.value = o.tightOrb
}
} catch {
/* ignore */
}
watch([showOuter, tightOrb], () => {
localStorage.setItem(SETTINGS_KEY, JSON.stringify({ showOuter: showOuter.value, tightOrb: tightOrb.value }))
})
const panels = [
{ key: 'chart' as const, label: '星盘' },
{ key: 'fortune' as const, label: '运势' },
{ key: 'planets' as const, label: '行星' },
]
const fortuneKeys = ['daily', 'weekly', 'monthly', 'yearly', 'lifetime'] as const
type FortunePeriod = {
title?: string
label?: string
score?: number
tip?: string
focus?: string
lucky?: string
caution?: string
dims?: { love?: number; career?: number; money?: number; mood?: number }
}
type PlanetRow = WheelPlanet & { element?: string; modality?: string }
type AspectRow = WheelAspect & { a_title?: string; b_title?: string; label?: string }
type TransitRow = { key: string; title: string; aspect: string; tip: string }
type HouseRow = { num: number; sign: string }
const summary = computed(() => (report.value?.summary || {}) as Record<string, unknown>)
const detail = computed(() => (report.value?.detail || null) as Record<string, unknown> | null)
const headline = computed(() => String(summary.value.headline || ''))
const oneLiner = computed(() => String(summary.value.one_liner || ''))
const keywords = computed(() => (Array.isArray(summary.value.keywords) ? (summary.value.keywords as string[]) : []))
const chartObj = computed(() => (summary.value.chart && typeof summary.value.chart === 'object' ? (summary.value.chart as Record<string, unknown>) : {}))
const chartNote = computed(() => String(chartObj.value.note || ''))
const ascLon = computed(() => (typeof chartObj.value.asc_lon === 'number' ? chartObj.value.asc_lon : null))
const houses = computed(() => (Array.isArray(chartObj.value.houses) ? (chartObj.value.houses as HouseRow[]) : []))
const signCards = computed(() => {
const raw = summary.value.sign_cards
return Array.isArray(raw) ? (raw as SignCard[]) : []
})
const activeCard = computed(() => signCards.value.find((c) => c.key === signTab.value) || signCards.value[0])
const fortuneBundle = computed(() => {
const f = summary.value.fortune
return f && typeof f === 'object' ? (f as Record<string, FortunePeriod>) : {}
})
const activeFortune = computed(() => fortuneBundle.value[fortuneKey.value] || null)
const planets = computed(() => {
const raw = summary.value.planets
return Array.isArray(raw) ? (raw as PlanetRow[]) : []
})
const wheelPlanets = computed<WheelPlanet[]>(() =>
planets.value.map((p) => ({
key: p.key,
title: p.title,
sign: p.sign,
degree: p.degree,
house: p.house,
lon: Number(p.lon),
element: p.element,
modality: p.modality,
})),
)
const aspectPreview = computed(() => {
const raw = summary.value.aspects_preview
return Array.isArray(raw) ? (raw as AspectRow[]) : []
})
const fullAspects = computed(() => {
const raw = detail.value?.aspects
return Array.isArray(raw) ? (raw as AspectRow[]) : aspectPreview.value
})
const aspectLines = computed<WheelAspect[]>(() => {
const src = report.value?.has_deep_access ? fullAspects.value : aspectPreview.value
return src.map((a) => ({ a: a.a, b: a.b, type: a.type, orb: a.orb, label: a.label }))
})
const transits = computed(() => {
const fromSummary = summary.value.transits
if (Array.isArray(fromSummary)) return fromSummary as TransitRow[]
const f = fortuneBundle.value as Record<string, unknown>
if (Array.isArray(f.transits)) return f.transits as TransitRow[]
return []
})
const sharePayload = computed<PortraitSharePayload | null>(() => {
if (!report.value) return null
return { type: 'portrait', title: headline.value || '我的星座', line: oneLiner.value, keywords: keywords.value }
})
function fortuneLabel(k: string) {
return ({ daily: '今日', weekly: '本周', monthly: '本月', yearly: '今年', lifetime: '一生' } as Record<string, string>)[k] || k
}
function onWheelSelect(key: string) {
if (['sun', 'moon', 'rise'].includes(key)) signTab.value = key
track(AnalyticsEvent.StarWheelViewed, { planet: key })
}
async function generate(y: number, m: number, d: number) {
loading.value = true
error.value = ''
needBirth.value = false
try {
const birth = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
const bt = birthTime.value ? birthTime.value.slice(0, 5) : undefined
const profile = await api.createProfile({
relation: 'self',
birth_date: birth,
display_name: '我',
birth_time: bt,
birth_place: birthPlace.value || undefined,
})
report.value = await api.createStar(profile.id)
track(AnalyticsEvent.PortraitCompleted, { source: 'star' })
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
needBirth.value = true
} finally {
loading.value = false
}
}
async function start() {
const y = Number(year.value)
const m = Number(month.value)
const d = Number(day.value)
const msg = validateBirth(y, m, d)
if (msg) {
error.value = msg
return
}
await generate(y, m, d)
}
async function load() {
const reportId = String(route.query.report_id || '')
if (reportId) {
loading.value = true
needBirth.value = false
try {
report.value = await api.getReport(reportId)
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
return
}
const y = Number(route.query.y)
const m = Number(route.query.m)
const d = Number(route.query.d)
if (y && m && d && !validateBirth(y, m, d)) {
await generate(y, m, d)
}
}
async function buyDeep() {
if (!report.value) return
paying.value = true
error.value = ''
track(AnalyticsEvent.DeepAccessClicked, { surface: 'star' })
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
}
}
onMounted(load)
</script>
<style scoped>
.yxg-card{margin:8px 0 12px}
.yxg-label{margin-top:12px}
.yxg-label:first-child{margin-top:0}
.birth-row{
display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-bottom:4px;
}
.birth-row :deep(.bd){margin:0;flex-wrap:nowrap}
.bd-time{
width:108px;padding:10px 6px;border:1.5px solid #eee;border-radius:12px;
font-size:14px;text-align:center;outline:none;background:#fdfaf8;color:#333;
}
.bd-time:focus{border-color:#f0b8b0;background:#fff}
.bd-sep{font-size:12px;color:#bbb;flex-shrink:0}
:deep(.bp){margin-bottom:10px}
.head{margin:4px 0 8px;font-size:13px}
.tab-body p{margin:0 0 6px;line-height:1.65}
.panel-tabs,.fortune-tabs{display:flex;gap:8px;margin:10px 0 12px;flex-wrap:wrap}
.panel-tab,.ftab{
flex:1;min-width:56px;border:1px solid #eee;background:#fafafa;border-radius:12px;
padding:10px 6px;font-size:13px;color:#666;cursor:pointer;
}
.panel-tab.on,.ftab.on{border-color:var(--yxg-pri);background:#fff5f4;color:#222;font-weight:600}
.daily-title{font-weight:700;color:#222;margin-bottom:6px}
.focus{margin:0 0 8px;color:#666}
.dims{display:flex;flex-wrap:wrap;gap:10px;margin:10px 0;font-size:12px;color:#555}
.planet-list{list-style:none;padding:0;margin:0}
.planet{display:flex;flex-direction:column;gap:4px;padding:12px}
.lock .yxg-btn,.share-btn{margin-top:12px;width:100%}
.links{display:flex;flex-direction:column;gap:8px;margin:12px 0}
.wheel-settings{
display:flex;gap:16px;font-size:12px;color:#666;margin:4px 0 8px;justify-content:center;
}
.wheel-settings label{display:flex;align-items:center;gap:4px;cursor:pointer}
.house-row{font-size:13px;color:#555;margin:4px 0}
.aspect-line{font-size:13px;color:#555;margin:4px 0;line-height:1.5}
.aspect-full{margin:8px 0 0;padding-left:18px;font-size:12px;color:#666}
.transit{margin:8px 0;font-size:13px}
.transit p{margin:2px 0 0;color:#555}
</style>