refactor(ECR-001): Phase F types/sdk 对齐并拆分超标 H5 页

抽出 OpenAPI DTO;将 Ask/Report/Profile/Star 等 8 页拆到 ≤400 行,并修 ScaleSummary、fortuneKey、Scale 选答与单测。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-05 18:19:34 +08:00
co-authored by Cursor
parent 19d3cd5945
commit e735aa43e3
64 changed files with 6198 additions and 4157 deletions
+104 -615
View File
@@ -12,26 +12,17 @@
</template>
<div class="body">
<template v-if="needBirth && !report">
<div class="intro-card">
<HomeToolIcon name="star" :size="64" />
<h1 class="intro-title">星座性格</h1>
<p class="intro-sub">填写生日看见太阳 · 月亮 · 上升与行星画像</p>
</div>
<div class="yxg-card form-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="出生时" />
</div>
<label class="yxg-label">出生地可选</label>
<BirthPlaceSelect v-model="birthPlace" />
<button class="cta" type="button" :disabled="loading" @click="start">
{{ loading ? '生成中' : '生成我的星座' }}
</button>
<p v-if="error" class="yxg-err">{{ error }}</p>
</div>
</template>
<StarBirthForm
v-if="needBirth && !report"
v-model:year="year"
v-model:month="month"
v-model:day="day"
v-model:birth-time="birthTime"
v-model:birth-place="birthPlace"
:loading="loading"
:error="error"
@start="start"
/>
<p v-else-if="loading" class="yxg-hint">正在生成星盘</p>
<p v-else-if="error && !report" class="yxg-err">
@@ -40,141 +31,53 @@
</p>
<template v-else-if="report">
<div class="mode-tabs" role="tablist">
<button
v-for="p in viewTabs"
:key="p.key"
type="button"
class="mode-tab"
:class="{ on: view === p.key }"
@click="view = p.key"
>
{{ p.label }}
</button>
</div>
<StarModeTabs v-model="view" :tabs="starViewTabs" />
<template v-if="view === 'overview'">
<h2 class="hl">{{ headline || '我的星座画像' }}</h2>
<p v-if="oneLiner" class="lead">{{ oneLiner }}</p>
<StarOverviewPanel
v-if="view === 'overview'"
:headline="headline"
:one-liner="oneLiner"
:chart-note="chartNote"
:keywords="keywords"
:grid-cells="gridCells"
:sign-tab="signTab"
:active-card="activeCard"
@grid-tap="onGridTap"
/>
<div class="spotlight">
<div class="sp-head">
<strong>星象速览</strong>
<router-link class="sp-ai" to="/ask">AI 解读 </router-link>
</div>
<p class="sp-text">{{ chartNote || '行星落在不同星座,构成独特的你。' }}</p>
<div v-if="keywords.length" class="tags">
<span v-for="k in keywords.slice(0, 4)" :key="k" class="tag">{{ k }}</span>
</div>
</div>
<StarChartPanel
v-else-if="view === 'chart'"
v-model:show-outer="showOuter"
v-model:tight-orb="tightOrb"
:wheel-planets="wheelPlanets"
:aspect-lines="aspectLines"
:asc-lon="ascLon"
:houses="houses"
:aspect-preview="aspectPreview"
@wheel-select="onWheelSelect"
/>
<div class="planet-grid">
<button
v-for="(cell, i) in gridCells"
:key="cell.key + i"
type="button"
class="cell"
:class="{ center: cell.center, on: signTab === cell.key }"
@click="onGridTap(cell)"
>
<template v-if="cell.center">
<HomeToolIcon name="star" :size="40" />
</template>
<template v-else>
<strong class="nick">{{ cell.nick }}</strong>
<span class="role">{{ cell.role }}</span>
</template>
</button>
</div>
<StarFortunePanel
v-else-if="view === 'fortune'"
v-model:fortune-key="fortuneKey"
:fortune-keys="starFortuneKeys"
:active-fortune="activeFortune"
/>
<div v-if="activeCard" class="detail-card">
<h3>{{ activeCard.title }} · {{ activeCard.label }}</h3>
<p>{{ activeCard.teaser }}</p>
<p v-if="activeCard.element" class="yxg-meta">{{ activeCard.element }} · {{ activeCard.modality }}</p>
</div>
<StarPlanetsPanel
v-else
:planets="planets"
@select="onPlanetSelect"
/>
<router-link class="ask-bar" to="/ask">
<span>AI本周我可以关注什么</span>
<span aria-hidden="true"></span>
</router-link>
</template>
<template v-else-if="view === '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"
/>
<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>
</div>
</template>
<template v-else-if="view === '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="fortune-card">
<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="planet-row" @click="signTab = p.key; view = 'overview'">
<strong>{{ p.title }}</strong>
<span>{{ p.sign }} {{ p.degree }} · {{ p.house }}</span>
</li>
</ul>
</template>
<ReportRich :summary="summary" :detail="detail" :deep="!!report.has_deep_access" />
<div v-if="!report.has_deep_access" class="lock-card">
<p>完整相位与年运详解可在深度版或成长会员中查看</p>
<button class="cta soft" type="button" :disabled="paying" @click="buyDeep">解锁完整分析</button>
</div>
<div class="bottom-links">
<router-link to="/synastry">合盘 </router-link>
<router-link to="/relation">人格匹配 </router-link>
<router-link v-if="report.id" :to="`/reports/${report.id}`">报告 </router-link>
</div>
<StarReportFooter
:summary="summary"
:detail="detail"
:deep="!!report.has_deep_access"
:paying="paying"
:report-id="report.id"
@buy-deep="buyDeep"
/>
</template>
<p class="yxg-disc">运势与星盘为自我探索参考请结合现实判断</p>
@@ -184,271 +87,63 @@
</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 HomeToolIcon from '../components/HomeToolIcon.vue'
import NatalWheel, { type WheelAspect, type WheelPlanet } from '../components/NatalWheel.vue'
import PageShell from '../components/PageShell.vue'
import ReportRich from '../components/ReportRich.vue'
import ShareSheet from '../components/ShareSheet.vue'
import type { SignCard } from '../components/SignCards.vue'
import { AnalyticsEvent, track } from '../lib/analytics'
import type { PortraitSharePayload } from '../lib/shareLink'
import StarBirthForm from '../components/star/StarBirthForm.vue'
import StarChartPanel from '../components/star/StarChartPanel.vue'
import StarFortunePanel from '../components/star/StarFortunePanel.vue'
import StarModeTabs from '../components/star/StarModeTabs.vue'
import StarOverviewPanel from '../components/star/StarOverviewPanel.vue'
import StarPlanetsPanel from '../components/star/StarPlanetsPanel.vue'
import StarReportFooter from '../components/star/StarReportFooter.vue'
import { starFortuneKeys, starViewTabs, useStarProfilePage } from '../composables/useStarProfilePage'
const SETTINGS_KEY = 'yuxingu_star_wheel_settings'
const {
loading,
needBirth,
error,
report,
paying,
shareOpen,
year,
month,
day,
birthTime,
birthPlace,
view,
signTab,
fortuneKey,
showOuter,
tightOrb,
summary,
detail,
headline,
oneLiner,
keywords,
chartNote,
ascLon,
houses,
activeCard,
activeFortune,
planets,
wheelPlanets,
aspectPreview,
aspectLines,
gridCells,
sharePayload,
onWheelSelect,
onGridTap,
resetForm,
start,
load,
buyDeep,
} = useStarProfilePage()
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 view = ref<'overview' | 'chart' | 'fortune' | 'planets'>('overview')
const signTab = ref('sun')
const fortuneKey = ref<'daily' | 'weekly' | 'monthly' | 'yearly' | 'lifetime'>('daily')
const showOuter = ref(true)
const tightOrb = ref(false)
const viewTabs = [
{ key: 'overview' as const, label: '概览' },
{ key: 'chart' as const, label: '星盘' },
{ key: 'fortune' as const, label: '运势' },
{ key: 'planets' as const, label: '行星' },
]
const fortuneKeys = ['daily', 'weekly', 'monthly', 'yearly', 'lifetime'] as const
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 */
function onPlanetSelect(key: string) {
signTab.value = key
view.value = 'overview'
}
watch([showOuter, tightOrb], () => {
localStorage.setItem(SETTINGS_KEY, JSON.stringify({ showOuter: showOuter.value, tightOrb: tightOrb.value }))
})
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 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 gridCells = computed(() => {
const cards = signCards.value
const slots: { key: string; nick: string; role: string; center?: boolean }[] = []
const order = ['sun', 'moon', 'rise', 'venus', 'mars', 'mercury', 'north_node', 'juno', 'jupiter']
const byKey = new Map(cards.map((c) => [c.key, c]))
const planetByKey = new Map(planets.value.map((p) => [p.key, p]))
for (let i = 0; i < 9; i++) {
if (i === 4) {
slots.push({ key: '_center', nick: '', role: '', center: true })
continue
}
const key = order[i < 4 ? i : i - 1] || cards[i]?.key || `p${i}`
const c = byKey.get(key)
const p = planetByKey.get(key)
if (c) {
slots.push({
key: c.key,
nick: (c.teaser || c.label || c.title).slice(0, 6),
role: `${c.title}${c.label}`,
})
} else if (p) {
slots.push({ key: p.key, nick: p.sign, role: `${p.title}${p.sign}` })
} else if (cards[i < 4 ? i : i - 1]) {
const x = cards[i < 4 ? i : i - 1]
slots.push({ key: x.key, nick: (x.teaser || x.label).slice(0, 6), role: `${x.title}${x.label}` })
} else {
slots.push({ key: `empty${i}`, nick: '—', role: '待生成' })
}
}
return slots
})
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 })
}
function onGridTap(cell: { key: string; center?: boolean }) {
if (cell.center) {
view.value = 'chart'
return
}
if (cell.key.startsWith('empty')) return
signTab.value = cell.key
}
function resetForm() {
needBirth.value = true
report.value = null
}
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)
view.value = 'overview'
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)
view.value = 'overview'
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
needBirth.value = true
} 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)) {
year.value = String(y)
month.value = String(m)
day.value = String(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>
@@ -477,210 +172,4 @@ onMounted(load)
font-size: 16px;
}
.body { padding: 8px 16px 24px; }
.intro-card { text-align: center; padding: 20px 12px 8px; }
.intro-title {
font-family: var(--font-display);
font-size: 24px;
font-weight: 700;
margin-top: 8px;
letter-spacing: 0.08em;
}
.intro-sub { font-size: 13px; color: #888; margin-top: 6px; line-height: 1.5; }
.form-card { margin-top: 12px; }
.cta {
margin-top: 14px;
width: 100%;
padding: 13px;
border: none;
border-radius: 22px;
color: #fff;
font-size: 15px;
font-weight: 600;
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
box-shadow: 0 6px 16px rgba(229, 77, 66, 0.28);
}
.cta.soft {
background: #fff;
color: var(--color-primary);
border: 1.5px solid #f5c4bc;
box-shadow: none;
}
.cta:disabled { opacity: 0.45; }
.birth-row { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
.bd-time {
width: 108px;
padding: 10px 6px;
border: 1.5px solid #eee;
border-radius: 12px;
font-size: 14px;
text-align: center;
background: #fdfaf8;
}
.mode-tabs {
display: flex;
gap: 4px;
margin-bottom: 14px;
background: #fafafa;
border-radius: var(--radius-pill);
padding: 4px;
}
.mode-tab {
flex: 1;
border: none;
background: transparent;
padding: 9px 4px;
border-radius: var(--radius-pill);
font-size: 13px;
color: #888;
font-weight: 600;
}
.mode-tab.on {
background: #fff;
color: var(--color-primary);
box-shadow: 0 2px 8px rgba(229, 77, 66, 0.1);
}
.hl {
font-family: var(--font-display);
font-size: 20px;
font-weight: 700;
line-height: 1.35;
color: #222;
}
.lead { margin-top: 8px; font-size: 13px; color: #777; line-height: 1.55; }
.spotlight {
margin-top: 14px;
padding: 14px;
border-radius: var(--radius-xl);
background: linear-gradient(145deg, #fff0ec, #ffe4dc);
box-shadow: var(--shadow-card);
}
.sp-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.sp-head strong { font-size: 14px; color: #222; }
.sp-ai { font-size: 12px; color: var(--color-primary); font-weight: 600; }
.sp-text { font-size: 12px; color: #666; line-height: 1.5; }
.tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
.tag {
font-size: 11px;
padding: 4px 10px;
border-radius: var(--radius-pill);
background: rgba(255, 255, 255, 0.8);
color: #8a4a3a;
font-weight: 600;
}
.planet-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin-top: 14px;
}
.cell {
min-height: 88px;
border: 1px solid #f0ebe8;
background: #fafafa;
border-radius: 16px;
padding: 12px 8px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
text-align: center;
}
.cell.on {
border-color: #f5c4bc;
background: #fff7f4;
box-shadow: var(--shadow-card);
}
.cell.center {
background: linear-gradient(160deg, #eef4ff, #ffe8e0);
border-color: transparent;
}
.nick { font-size: 14px; font-weight: 700; color: #222; line-height: 1.2; }
.role { font-size: 11px; color: var(--color-primary); font-weight: 600; }
.detail-card {
margin-top: 12px;
padding: 14px;
background: #fff;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
}
.detail-card h3 { font-size: 15px; margin-bottom: 6px; color: #222; }
.detail-card p { font-size: 13px; color: #555; line-height: 1.6; }
.ask-bar {
margin-top: 14px;
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 16px;
border-radius: var(--radius-pill);
background: #fff;
border: 1.5px solid #f0ebe8;
font-size: 13px;
font-weight: 600;
color: #555;
box-shadow: var(--shadow-card);
}
.fortune-tabs { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; }
.ftab {
flex: 1;
min-width: 52px;
border: 1px solid #eee;
background: #fafafa;
border-radius: 12px;
padding: 10px 4px;
font-size: 12px;
color: #666;
}
.ftab.on {
border-color: var(--color-primary);
background: #fff5f4;
color: #222;
font-weight: 600;
}
.fortune-card { padding: 16px; border-radius: var(--radius-xl); background: #fafafa; }
.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; margin: 0; padding: 0; }
.planet-row {
display: flex;
flex-direction: column;
gap: 4px;
padding: 14px;
margin-bottom: 8px;
background: #fafafa;
border-radius: 14px;
}
.planet-row strong { font-size: 14px; color: #222; }
.planet-row span { font-size: 12px; color: #888; }
.lock-card {
margin-top: 12px;
padding: 16px;
background: linear-gradient(145deg, #fff8e8, #ffeec8);
border-radius: var(--radius-xl);
}
.bottom-links {
display: flex;
gap: 16px;
margin: 16px 0 8px;
font-size: 13px;
font-weight: 600;
color: var(--color-primary);
}
.wheel-settings {
display: flex;
gap: 16px;
font-size: 12px;
color: #666;
margin: 4px 0 8px;
justify-content: center;
}
.house-row, .aspect-line { font-size: 13px; color: #555; margin: 4px 0; }
.yxg-label { display: block; margin: 12px 0 6px; font-size: 12px; color: #999; }
.yxg-label:first-child { margin-top: 0; }
</style>