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
+130
View File
@@ -0,0 +1,130 @@
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import type { AskMessage, AskQuota, Profile } from '@yuxingu/types'
import { api } from '../api/client'
export const askScenes = [
{ key: 'self', label: '我想更了解自己的性格与互动风格', prompt: '我想更了解自己的性格与互动风格。' },
{ key: 'relation', label: '和重要的人相处时该如何沟通?', prompt: '和重要的人相处时,我该如何更好地沟通?' },
{ key: 'emotion', label: '最近情绪有点乱,想梳理一下', prompt: '最近情绪有点乱,我想梳理一下。' },
{ key: 'career', label: '职业选择上可以注意什么?', prompt: '从我的特点看,职业选择上可以注意什么?' },
] as const
export const askQuickTools = [
{ to: '/portrait', label: '工具' },
{ to: '/synastry', label: '合盘' },
{ to: '/cards', label: '意象卡' },
{ to: '/relation', label: '匹配' },
]
export const askAdvisors = [
{ name: '心语', mark: '心', bg: 'linear-gradient(145deg,#ffd0c8,#ffb0a4)', meta: '好评率 98% · 情绪整理', tags: '文字沟通', scene: 'emotion' as const },
{ name: '明朗', mark: '明', bg: 'linear-gradient(145deg,#d6e8ff,#b0d0f5)', meta: '好评率 97% · 关系理解', tags: '文字沟通', scene: 'relation' as const },
{ name: '志远', mark: '志', bg: 'linear-gradient(145deg,#ffe6c8,#f5c98a)', meta: '好评率 96% · 职业探索', tags: '文字沟通', scene: 'career' as const },
]
export function useAskPage() {
const router = useRouter()
const rail = ref<'ai' | 'advisor'>('ai')
const bootLoading = ref(true)
const bootError = ref('')
const profiles = ref<Profile[]>([])
const profileId = ref('')
const scene = ref('self')
const threadId = ref('')
const messages = ref<AskMessage[]>([])
const draft = ref('')
const sending = ref(false)
const sendError = ref('')
const quotaExhausted = ref(false)
const quota = ref<AskQuota | null>(null)
async function boot() {
bootLoading.value = true
bootError.value = ''
try {
const [list, q] = await Promise.all([api.listProfiles(), api.getAskQuota()])
profiles.value = list.items || []
quota.value = q
const self = profiles.value.find((p) => p.relation === 'self')
profileId.value = self?.id || profiles.value[0]?.id || ''
threadId.value = ''
messages.value = []
} catch (e) {
bootError.value = e instanceof Error ? e.message : '加载失败'
} finally {
bootLoading.value = false
}
}
function onProfileChange() {
threadId.value = ''
messages.value = []
sendError.value = ''
}
function pickScene(s: { key: string; prompt: string }) {
scene.value = s.key
draft.value = s.prompt
threadId.value = ''
messages.value = []
}
function askAdvisor(a: (typeof askAdvisors)[number]) {
rail.value = 'ai'
const s = askScenes.find((x) => x.key === a.scene) || askScenes[0]
pickScene(s)
}
async function ensureThread(): Promise<string> {
if (threadId.value) return threadId.value
if (!profileId.value) throw new Error('请先选择档案')
const th = await api.createAskThread({ profile_id: profileId.value, scene: scene.value })
threadId.value = th.id
return th.id
}
async function send() {
const content = draft.value.trim()
if (!content || sending.value) return
sending.value = true
sendError.value = ''
quotaExhausted.value = false
try {
const tid = await ensureThread()
const out = await api.sendAskMessage(tid, content)
messages.value = [...messages.value, out.user_message, out.assistant_message]
quota.value = out.quota
draft.value = ''
} catch (e) {
const msg = e instanceof Error ? e.message : '发送失败'
sendError.value = msg
quotaExhausted.value = msg.includes('次数已用完') || msg.includes('成长会员')
} finally {
sending.value = false
}
}
onMounted(boot)
return {
router,
rail,
bootLoading,
bootError,
profiles,
profileId,
messages,
draft,
sending,
sendError,
quotaExhausted,
quota,
onProfileChange,
pickScene,
askAdvisor,
send,
boot,
}
}
@@ -0,0 +1,162 @@
import { computed, onMounted, ref } from 'vue'
import type { GrowthReport } from '@yuxingu/types'
import { validateBirth } from '@yuxingu/utils'
import { api } from '../api/client'
import { AnalyticsEvent, track } from '../lib/analytics'
export const promptRows = [
{ icon: '🌊', label: '最近情绪有点乱,帮我理一理', scene: '情绪整理' },
{ icon: '💬', label: '和 TA 相处时,我总在重复什么模式?', scene: '关系反思' },
{ icon: '🪞', label: '我想更了解自己此刻的状态', scene: '自我觉察' },
{ icon: '🍃', label: '生活节奏太快,我需要一点方向感', scene: '生活节奏' },
]
export function useImageCardPage() {
const scenes = ref<{ key: string; label: string }[]>([])
const scene = ref('情绪整理')
const quota = ref<{ remaining: number; daily_free: number; unlimited: boolean } | null>(null)
const year = ref('')
const month = ref('')
const day = ref('')
const wantDepth = ref(false)
const drawing = ref(false)
const paying = ref(false)
const error = ref('')
const bootError = ref('')
const cards = ref<{ id: string; title: string; imagery: string; prompt: string; tip: string }[]>([])
const report = ref<GrowthReport | null>(null)
const questionInput = ref('')
const showBirth = ref(false)
const summary = computed(() => (report.value?.summary || {}) as Record<string, unknown>)
const detail = computed(() => (report.value?.detail || null) as Record<string, unknown> | null)
const quotaLabel = computed(() => {
if (!quota.value) return '…'
if (quota.value.unlimited) return '不限(会员)'
return `${quota.value.remaining} / ${quota.value.daily_free}`
})
function birthFilled() {
const y = Number(year.value)
const m = Number(month.value)
const d = Number(day.value)
return !validateBirth(y, m, d)
}
function selectScene(key: string) {
scene.value = key
track('cards_scene_selected', { scene: key })
}
function onPromptClick(row: { label: string; scene: string }) {
selectScene(row.scene)
questionInput.value = row.label
if (birthFilled()) {
draw()
} else {
showBirth.value = true
}
}
function sendDraw() {
if (questionInput.value.trim()) {
scene.value = questionInput.value.trim()
}
draw()
}
async function refreshQuota() {
quota.value = await api.getImageCardQuota()
}
async function boot() {
try {
const [sc] = await Promise.all([api.listImageCardScenes(), refreshQuota()])
scenes.value = sc.items || []
if (scenes.value.length && !scenes.value.find((s) => s.key === scene.value)) {
scene.value = scenes.value[0].key
}
} catch (e) {
bootError.value = e instanceof Error ? e.message : '加载失败'
}
}
async function draw() {
error.value = ''
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
showBirth.value = true
return
}
drawing.value = true
try {
const birth = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
const profile = await api.createProfile({ relation: 'self', birth_date: birth, display_name: '我' })
const out = await api.drawImageCard({
profile_id: profile.id,
scene: scene.value,
depth: wantDepth.value,
})
cards.value = out.cards || []
report.value = out.report
quota.value = {
remaining: out.quota_left,
daily_free: quota.value?.daily_free || 3,
unlimited: !!quota.value?.unlimited,
}
track('cards_drawn', { scene: scene.value, depth: wantDepth.value ? 1 : 0 })
} catch (e) {
const m = e instanceof Error ? e.message : '抽取失败'
error.value = m
if (m.includes('次数')) track('cards_quota_exhausted')
} finally {
drawing.value = false
}
}
async function buyDeep() {
if (!report.value) return
paying.value = true
error.value = ''
track(AnalyticsEvent.DeepAccessClicked, { surface: 'cards' })
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(boot)
return {
scene,
quotaLabel,
year,
month,
day,
wantDepth,
drawing,
paying,
error,
bootError,
cards,
report,
questionInput,
showBirth,
summary,
detail,
onPromptClick,
sendDraw,
draw,
buyDeep,
}
}
@@ -0,0 +1,164 @@
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import type { GrowthReport } from '@yuxingu/types'
import { validateBirth } from '@yuxingu/utils'
import { api } from '../api/client'
import type { WuxingBar } from '../components/WuxingBars.vue'
import { AnalyticsEvent, track } from '../lib/analytics'
export const resultTabs = [
{ key: 'overview' as const, label: '概览' },
{ key: 'wuxing' as const, label: '五行' },
{ key: 'tips' as const, label: '建议' },
{ key: 'term' as const, label: '节气' },
]
export function useLifeRhythmPage() {
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 tab = ref<'overview' | 'wuxing' | 'tips' | 'term'>('overview')
const year = ref(String(route.query.y || ''))
const month = ref(String(route.query.m || ''))
const day = ref(String(route.query.d || ''))
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 styleLabel = computed(() => String(summary.value.style_label || ''))
const overviewText = computed(() => String(summary.value.overview || ''))
const weekFocus = computed(() => String(summary.value.week_focus || ''))
const keywords = computed(() =>
Array.isArray(summary.value.keywords) ? (summary.value.keywords as string[]) : [],
)
const solarTip = computed(() => {
const st = summary.value.solar_term
if (st && typeof st === 'object') {
const tip = (st as Record<string, unknown>).tip
if (tip) return String(tip)
}
return String(summary.value.today_tip || summary.value.life_tip || '')
})
const wuxingBars = computed((): WuxingBar[] => {
const wx = summary.value.wuxing
if (!wx || typeof wx !== 'object') return []
const bars = (wx as Record<string, unknown>).bars
if (!Array.isArray(bars)) return []
return bars
.map((b) => {
const o = b as Record<string, unknown>
return {
el: String(o.el || o.element || ''),
pct: Number(o.pct ?? o.score ?? 0),
color: String(o.color || '#E54D42'),
}
})
.filter((b) => b.el)
})
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 profile = await api.createProfile({ relation: 'self', birth_date: birth, display_name: '我' })
report.value = await api.createRhythm(profile.id)
tab.value = 'overview'
track(AnalyticsEvent.PortraitCompleted, { source: 'rhythm' })
} 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)
tab.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: 'rhythm' })
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)
return {
loading,
needBirth,
error,
report,
paying,
tab,
year,
month,
day,
summary,
detail,
headline,
oneLiner,
styleLabel,
overviewText,
weekFocus,
keywords,
solarTip,
wuxingBars,
start,
load,
buyDeep,
}
}
@@ -0,0 +1,302 @@
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import type { Profile } from '@yuxingu/types'
import { api } from '../api/client'
import { copyText } from '../lib/shareLink'
export const profileRelationOptions = [
{ value: 'family', label: '家人' },
{ value: 'friend', label: '朋友' },
{ value: 'partner', label: '伴侣' },
{ value: 'colleague', label: '同事' },
{ value: 'other', label: '其他' },
]
export function formatProfileDate(v: string) {
return (v || '').slice(0, 10)
}
export function avatarInitial(p: Profile) {
const n = p.display_name || (p.relation === 'self' ? '我' : 'T')
return n.slice(0, 1)
}
export function relationLabel(t?: string | null) {
const map: Record<string, string> = {
partner: '伴侣',
friend: '朋友',
family: '家人',
colleague: '同事',
other: '关系对象',
}
return map[t || ''] || '关系对象'
}
function splitBirth(v: string) {
const s = formatProfileDate(v)
const [y, m, d] = s.split('-')
return { y: y || '', m: m ? String(Number(m)) : '', d: d ? String(Number(d)) : '' }
}
function joinBirth(y: string, m: string, d: string) {
const yi = Number(y)
const mi = Number(m)
const di = Number(d)
if (!yi || !mi || !di) return ''
return `${yi}-${String(mi).padStart(2, '0')}-${String(di).padStart(2, '0')}`
}
export function useProfilePage() {
const route = useRoute()
const router = useRouter()
const items = ref<Profile[]>([])
const loading = ref(true)
const error = ref('')
const busyId = ref('')
const editing = ref<Profile | null>(null)
const showAddForm = ref(false)
const showInviteFill = ref(false)
const formName = ref('')
const formY = ref('')
const formM = ref('')
const formD = ref('')
const formPlace = ref('')
const formRelationType = ref('partner')
const formGeoVisible = ref(false)
const formError = ref('')
const saving = ref(false)
const newName = ref('')
const newY = ref('')
const newM = ref('')
const newD = ref('')
const newPlace = ref('')
const newRelationType = ref('family')
const addError = ref('')
const adding = ref(false)
const inviting = ref(false)
const invitePath = ref('')
const inviteError = ref('')
const inviteHint = ref('')
const copyBusy = ref(false)
const selfProfile = computed(() => items.value.find((p) => p.relation === 'self'))
const others = computed(() => items.value.filter((p) => p.relation !== 'self'))
function toggleAdd() {
showAddForm.value = !showAddForm.value
if (showAddForm.value) {
editing.value = null
showInviteFill.value = false
}
}
function openAddForm() {
showAddForm.value = true
showInviteFill.value = false
editing.value = null
}
function openInviteFill() {
showInviteFill.value = true
showAddForm.value = false
editing.value = null
inviteError.value = ''
if (!selfProfile.value) {
inviteHint.value = '请先完成自己的档案,再邀请好友填写。'
} else {
inviteHint.value = ''
}
}
function closeInviteFill() {
showInviteFill.value = false
void router.replace({ path: '/profile', query: {} })
}
function applyQueryFlags() {
if (route.query.add === '1') openAddForm()
if (route.query.inviteFill === '1') openInviteFill()
}
async function createFillInvite() {
if (!selfProfile.value) {
inviteError.value = '请先完成自己的档案'
return
}
inviting.value = true
inviteError.value = ''
inviteHint.value = ''
try {
const res = await api.createSynastryInvite(selfProfile.value.id)
invitePath.value = `${location.origin}${res.path}`
inviteHint.value = '好友打开链接后填写生日即可;也可用于合盘。'
} catch (e) {
inviteError.value = e instanceof Error ? e.message : '生成失败'
} finally {
inviting.value = false
}
}
async function copyInvite() {
if (!invitePath.value) return
copyBusy.value = true
const ok = await copyText(invitePath.value)
inviteHint.value = ok ? '链接已复制,发给家人或朋友即可。' : '复制失败,请长按链接手动复制。'
copyBusy.value = false
}
async function load() {
loading.value = true
error.value = ''
try {
const res = await api.listProfiles()
items.value = res.items || []
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
function startEdit(p: Profile) {
editing.value = p
showAddForm.value = false
formName.value = p.display_name || ''
const b = splitBirth(p.birth_date)
formY.value = b.y
formM.value = b.m
formD.value = b.d
formPlace.value = p.birth_place || ''
formRelationType.value = p.relation_type || 'partner'
formGeoVisible.value = !!p.geo_visible
formError.value = ''
}
async function saveEdit() {
if (!editing.value) return
saving.value = true
formError.value = ''
try {
const birth = joinBirth(formY.value, formM.value, formD.value)
if (!birth) throw new Error('请填写生日')
const body: {
display_name: string
birth_date: string
relation_type?: string
birth_place?: string
geo_visible?: boolean
} = {
display_name: formName.value.trim(),
birth_date: birth,
birth_place: formPlace.value || '',
}
if (editing.value.relation === 'other') body.relation_type = formRelationType.value
if (editing.value.relation === 'self') body.geo_visible = formGeoVisible.value
await api.updateProfile(editing.value.id, body)
editing.value = null
await load()
} catch (e) {
formError.value = e instanceof Error ? e.message : '保存失败'
} finally {
saving.value = false
}
}
async function remove(p: Profile) {
if (!confirm(`确定删除「${p.display_name || '档案'}」?`)) return
busyId.value = p.id
error.value = ''
try {
await api.deleteProfile(p.id)
if (editing.value?.id === p.id) editing.value = null
await load()
} catch (e) {
error.value = e instanceof Error ? e.message : '删除失败'
} finally {
busyId.value = ''
}
}
async function addOther() {
adding.value = true
addError.value = ''
try {
const birth = joinBirth(newY.value, newM.value, newD.value)
if (!birth) throw new Error('请填写生日')
await api.createProfile({
relation: 'other',
birth_date: birth,
display_name: newName.value.trim() || 'TA',
relation_type: newRelationType.value,
birth_place: newPlace.value || undefined,
})
newName.value = ''
newY.value = ''
newM.value = ''
newD.value = ''
newPlace.value = ''
showAddForm.value = false
await load()
if (route.query.add === '1') void router.replace({ path: '/profile', query: {} })
} catch (e) {
addError.value = e instanceof Error ? e.message : '添加失败'
} finally {
adding.value = false
}
}
watch(
() => [route.query.add, route.query.inviteFill],
() => applyQueryFlags(),
)
onMounted(async () => {
await load()
applyQueryFlags()
})
return {
items,
loading,
error,
busyId,
editing,
showAddForm,
showInviteFill,
formName,
formY,
formM,
formD,
formPlace,
formRelationType,
formGeoVisible,
formError,
saving,
newName,
newY,
newM,
newD,
newPlace,
newRelationType,
addError,
adding,
inviting,
invitePath,
inviteError,
inviteHint,
copyBusy,
selfProfile,
others,
toggleAdd,
openInviteFill,
closeInviteFill,
createFillInvite,
copyInvite,
load,
startEdit,
saveEdit,
remove,
addOther,
}
}
@@ -0,0 +1,177 @@
import { computed, ref } from 'vue'
import type { GrowthReport, Profile } from '@yuxingu/types'
import { api } from '../api/client'
import { AnalyticsEvent, track } from '../lib/analytics'
import type { RelationSharePayload } from '../lib/shareLink'
export const focusTabs = [
{ key: 'mode', label: '相处模式' },
{ key: 'personality', label: '性格对照' },
{ key: 'sync', label: '默契指数' },
]
export const relationOptions = [
{ value: 'partner', label: '伴侣' },
{ value: 'friend', label: '朋友' },
{ value: 'family', label: '家人' },
{ value: 'other', label: '其他' },
]
export const resultTabs = [
{ key: 'index' as const, label: '指数' },
{ key: 'compare' as const, label: '对照' },
{ key: 'tips' as const, label: '建议' },
]
export function useRelationPage() {
const otherName = ref('TA')
const relationType = ref('partner')
const oy = ref('1992')
const om = ref('6')
const od = ref('8')
const loading = ref(false)
const paying = ref(false)
const error = ref('')
const report = ref<GrowthReport | null>(null)
const shareOpen = ref(false)
const needSelf = ref(false)
const focus = ref('mode')
const tab = ref<'index' | 'compare' | 'tips'>('index')
const focusHint = computed(() => {
const map: Record<string, string> = {
mode: '看见日常互动里,谁更主动、谁更需要空间',
personality: '对照彼此的性格关键词与表达习惯',
sync: '恋爱 · 友情 · 婚姻维度的默契参考',
}
return map[focus.value] || map.mode
})
const summary = computed(() => (report.value?.summary || {}) as Record<string, unknown>)
const detail = computed(() => (report.value?.detail || null) as Record<string, unknown> | null)
const meStyle = computed(() => String(summary.value.me_style || ''))
const otherStyle = computed(() => String(summary.value.other_style || ''))
const diff = computed(() => String(summary.value.diff_one_liner || ''))
const fitLabel = computed(() => String(summary.value.fit_label || ''))
const harmony = computed(() => {
const n = summary.value.harmony_index
return typeof n === 'number' ? n : null
})
const loveIndex = computed(() => (typeof summary.value.love_index === 'number' ? summary.value.love_index : null))
const friendIndex = computed(() =>
typeof summary.value.friend_index === 'number' ? summary.value.friend_index : null,
)
const marriageIndex = computed(() =>
typeof summary.value.marriage_index === 'number' ? summary.value.marriage_index : null,
)
const starCompare = computed(() => {
const raw = summary.value.star_compare
return Array.isArray(raw) ? (raw as { key: string; title: string; me: string; other: string; note?: string }[]) : []
})
const dimCompare = computed(() => {
const raw = summary.value.dimension_compare
return Array.isArray(raw)
? (raw as { key: string; title: string; me_score?: number; other_score?: number; note?: string }[])
: []
})
const meKeys = computed(() => (Array.isArray(summary.value.me_keywords) ? (summary.value.me_keywords as string[]) : []))
const otherKeys = computed(() =>
Array.isArray(summary.value.other_keywords) ? (summary.value.other_keywords as string[]) : [],
)
const sharePayload = computed<RelationSharePayload | null>(() => {
if (!report.value) return null
return {
type: 'relation',
me: meStyle.value,
other: otherStyle.value,
diff: diff.value,
keywords: [...meKeys.value.slice(0, 3), ...otherKeys.value.slice(0, 3)],
}
})
async function ensureSelf(): Promise<Profile> {
const { items } = await api.listProfiles()
const self = (items || []).find((p) => p.relation === 'self')
if (self) return self
throw new Error('请先在首页完成愈心解码,创建「我」的个人档案')
}
async function run() {
loading.value = true
error.value = ''
needSelf.value = false
report.value = null
try {
const y = Number(oy.value)
const m = Number(om.value)
const d = Number(od.value)
if (!y || !m || !d) throw new Error('请填写 TA 的完整生日')
const self = await ensureSelf()
const birth = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
const other = await api.createProfile({
relation: 'other',
birth_date: birth,
display_name: otherName.value || 'TA',
relation_type: relationType.value,
})
const out = await api.createRelationInsight(self.id, other.id)
report.value = out.report
tab.value = 'index'
track(AnalyticsEvent.RelationCompleted)
} catch (e) {
const msg = e instanceof Error ? e.message : '生成失败'
error.value = msg
needSelf.value = msg.includes('个人档案') || msg.includes('愈心解码') || msg.includes('性格探索')
} finally {
loading.value = false
}
}
async function buyDeep() {
if (!report.value) return
paying.value = true
track(AnalyticsEvent.DeepAccessClicked, { surface: 'relation' })
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
}
}
return {
otherName,
relationType,
oy,
om,
od,
loading,
paying,
error,
report,
shareOpen,
needSelf,
focus,
tab,
focusHint,
summary,
detail,
meStyle,
otherStyle,
diff,
fitLabel,
harmony,
loveIndex,
friendIndex,
marriageIndex,
starCompare,
dimCompare,
sharePayload,
run,
buyDeep,
}
}
@@ -0,0 +1,310 @@
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import type { GrowthReport } from '@yuxingu/types'
import { api } from '../api/client'
import type { WheelAspect, WheelPlanet } from '../components/NatalWheel.vue'
import type { SignCard } from '../components/SignCards.vue'
import { AnalyticsEvent, track } from '../lib/analytics'
import type { PortraitSharePayload, RelationSharePayload, SharePayload } from '../lib/shareLink'
export const reportStarPanels = [
{ key: 'chart', label: '星盘' },
{ key: 'fortune', label: '运势' },
{ key: 'planets', label: '行星' },
]
export const reportSynTabs = [
{ key: 'compare', label: '比较' },
{ key: 'composite', label: '组合' },
{ key: 'davison', label: '时空' },
{ key: 'marks_me', label: '马克斯' },
{ key: 'overlay', label: '配对' },
{ key: 'composite_progressed', label: '组合次限' },
]
export const reportFortuneKeys = ['daily', 'weekly', 'monthly', 'yearly', 'lifetime'] as const
type FortunePeriod = {
title?: string
label?: string
score?: number
tip?: string
dims?: { love?: number; career?: number; money?: number; mood?: number }
}
type PlanetRow = { key: string; title: string; sign: string; degree: string; house: number }
export function useReportPage() {
const route = useRoute()
const loading = ref(true)
const error = ref('')
const report = ref<GrowthReport | null>(null)
const paying = ref(false)
const shareOpen = ref(false)
const panelKey = ref('chart')
const signTab = ref('sun')
const fortuneKey = ref<(typeof reportFortuneKeys)[number]>('daily')
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 || summary.value.diff_one_liner || ''))
const oneLiner = computed(() => String(summary.value.one_liner || summary.value.diff_one_liner || ''))
const keywords = computed(() => {
if (Array.isArray(summary.value.keywords)) return summary.value.keywords as string[]
const a = Array.isArray(summary.value.me_keywords) ? (summary.value.me_keywords as string[]) : []
const b = Array.isArray(summary.value.other_keywords) ? (summary.value.other_keywords as string[]) : []
return [...a, ...b]
})
const typeLabel = computed(() => {
const map: Record<string, string> = {
portrait: '愈心解码',
relation: '人格匹配',
synastry: '合盘',
star: '星座',
rhythm: '身心节律',
image_card: '意象卡片',
}
return map[report.value?.type || ''] || '成长报告'
})
const showModeTabs = computed(() => report.value?.type === 'star' || report.value?.type === 'synastry')
const activePanels = computed(() => (report.value?.type === 'synastry' ? reportSynTabs : reportStarPanels))
const relatedLink = computed(() => {
const t = report.value?.type
if (t === 'star') return { to: '/star', label: '星座' }
if (t === 'synastry') return { to: '/synastry', label: '合盘' }
if (t === 'portrait') return { to: '/portrait', label: '解码' }
if (t === 'relation') return { to: '/relation', label: '匹配' }
if (t === 'rhythm') return { to: '/rhythm', label: '节律' }
return { to: '/reports', label: '全部' }
})
const chartObj = computed(() =>
summary.value.chart && typeof summary.value.chart === 'object'
? (summary.value.chart as Record<string, unknown>)
: {},
)
const ascLon = computed(() => (typeof chartObj.value.asc_lon === 'number' ? chartObj.value.asc_lon : null))
const wheelPlanets = computed<WheelPlanet[]>(() => {
const raw = summary.value.planets
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),
}
})
})
const aspectPreview = computed<WheelAspect[]>(() => {
const raw = summary.value.aspects_preview
if (!Array.isArray(raw)) return []
return raw.map((a) => {
const o = a as Record<string, unknown>
return { a: String(o.a), b: String(o.b), type: String(o.type), orb: Number(o.orb) }
})
})
const transits = computed(() => {
const raw = summary.value.transits
if (Array.isArray(raw)) return raw as { key: string; title: string; aspect: string; tip: string }[]
const f = summary.value.fortune
if (f && typeof f === 'object' && Array.isArray((f as { transits?: unknown }).transits)) {
return (f as { transits: { key: string; title: string; aspect: string; tip: string }[] }).transits
}
return []
})
const synCharts = computed(() =>
summary.value.charts && typeof summary.value.charts === 'object'
? (summary.value.charts as Record<string, unknown>)
: {},
)
const synActiveBlock = computed(() => {
const c = synCharts.value[panelKey.value]
return c && typeof c === 'object' ? (c as Record<string, unknown>) : null
})
const synActiveTip = computed(() => String(synActiveBlock.value?.tip || ''))
const synActivePlanets = computed<WheelPlanet[]>(() => {
const raw = synActiveBlock.value?.planets
if (!Array.isArray(raw)) return []
return raw.map((p) => {
const x = p as Record<string, unknown>
return {
key: String(x.key),
title: String(x.title),
sign: String(x.sign),
degree: String(x.degree),
house: Number(x.house),
lon: Number(x.lon),
}
})
})
const synActiveAsc = computed(() =>
typeof synActiveBlock.value?.asc_lon === 'number' ? (synActiveBlock.value.asc_lon as number) : null,
)
const synOverlayEntries = computed(() => {
const o = synCharts.value.overlay
if (!o || typeof o !== 'object') return []
const raw = (o as { entries?: unknown }).entries
return Array.isArray(raw) ? (raw as { planet: string; house: number; house_tip: string }[]) : []
})
function synChart(key: 'chart_a' | 'chart_b'): { planets: WheelPlanet[]; asc: number | null } {
const c = summary.value[key]
if (!c || typeof c !== 'object') return { planets: [], asc: null }
const o = c as { planets?: unknown; asc_lon?: number }
const planets = Array.isArray(o.planets)
? o.planets.map((p) => {
const x = p as Record<string, unknown>
return {
key: String(x.key),
title: String(x.title),
sign: String(x.sign),
degree: String(x.degree),
house: Number(x.house),
lon: Number(x.lon),
}
})
: []
return { planets, asc: typeof o.asc_lon === 'number' ? o.asc_lon : null }
}
const synPlanetsA = computed(() => synChart('chart_a').planets)
const synPlanetsB = computed(() => synChart('chart_b').planets)
const synAscA = computed(() => synChart('chart_a').asc)
const synAscB = computed(() => synChart('chart_b').asc)
const createdLabel = computed(() => {
const t = report.value?.created_at
if (!t) return ''
try {
return new Date(t).toLocaleDateString('zh-CN')
} catch {
return t
}
})
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 loveIndex = computed(() => (typeof summary.value.love_index === 'number' ? summary.value.love_index : null))
const friendIndex = computed(() =>
typeof summary.value.friend_index === 'number' ? summary.value.friend_index : null,
)
const marriageIndex = computed(() =>
typeof summary.value.marriage_index === 'number' ? summary.value.marriage_index : null,
)
const hasMatchIndex = computed(
() =>
(report.value?.type === 'relation' || report.value?.type === 'synastry') &&
(loveIndex.value != null || friendIndex.value != null || marriageIndex.value != null),
)
const sharePayload = computed<SharePayload | null>(() => {
if (!report.value) return null
if (report.value.type === 'relation') {
return {
type: 'relation',
me: String(summary.value.me_style || ''),
other: String(summary.value.other_style || ''),
diff: String(summary.value.diff_one_liner || ''),
keywords: keywords.value,
} as RelationSharePayload
}
return {
type: 'portrait',
title: headline.value,
line: oneLiner.value,
keywords: keywords.value,
} as PortraitSharePayload
})
async function load() {
loading.value = true
error.value = ''
report.value = null
try {
const id = String(route.params.id || '')
if (!id) throw new Error('缺少报告 id')
report.value = await api.getReport(id)
panelKey.value = report.value.type === 'synastry' ? 'compare' : 'chart'
signTab.value = 'sun'
fortuneKey.value = 'daily'
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
async function buyDeep() {
if (!report.value) return
paying.value = true
track(AnalyticsEvent.DeepAccessClicked, { surface: 'report' })
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
}
}
watch(() => route.params.id, load)
onMounted(load)
return {
loading,
error,
report,
paying,
shareOpen,
panelKey,
signTab,
fortuneKey,
summary,
detail,
headline,
oneLiner,
typeLabel,
showModeTabs,
activePanels,
relatedLink,
ascLon,
wheelPlanets,
aspectPreview,
transits,
synActiveTip,
synActivePlanets,
synActiveAsc,
synOverlayEntries,
synPlanetsA,
synPlanetsB,
synAscA,
synAscB,
createdLabel,
signCards,
activeCard,
activeFortune,
planets,
loveIndex,
friendIndex,
marriageIndex,
hasMatchIndex,
sharePayload,
load,
buyDeep,
}
}
@@ -0,0 +1,199 @@
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { api } from '../api/client'
import { clearScaleDraft, loadScaleDraft, saveScaleDraft } from '../lib/scaleDraft'
import type { PortraitSharePayload } from '../lib/shareLink'
export function useScalePage() {
const route = useRoute()
const slug = String(route.params.slug || '')
const title = ref('')
const description = ref('')
const questions = ref<{ id: string; body: { prompt: string; options: { key: string; text: string }[] } }[]>([])
const answers = reactive<Record<string, string>>({})
const loadingScale = ref(true)
const submitting = ref(false)
const loadError = ref('')
const submitError = ref('')
const needProfile = ref(false)
const phase = ref<'intro' | 'answering' | 'result'>('intro')
const resultLabel = ref('')
const shareLine = ref('')
const shareOpen = ref(false)
const resultRaw = ref<Record<string, unknown> | null>(null)
const draftRestored = ref(false)
const persistReady = ref(false)
const currentIdx = ref(0)
const currentQuestion = computed(() => questions.value[currentIdx.value] || null)
const isLast = computed(() => currentIdx.value >= questions.value.length - 1)
const progressPct = computed(() => {
if (!questions.value.length) return 0
return Math.round(((currentIdx.value + 1) / questions.value.length) * 100)
})
const estMinutes = computed(() => Math.max(1, Math.ceil(questions.value.length / 3)))
watch(
answers,
() => {
if (!persistReady.value || phase.value === 'result') return
saveScaleDraft(slug, { ...answers })
},
{ deep: true },
)
const resultSummaryObj = computed(() => {
const r = resultRaw.value || {}
return {
overview: r.overview || r.summary,
keywords: r.label ? [String(r.label)] : [],
dimensions: r.dimensions,
strengths_preview: r.strengths,
watchouts_preview: r.watchouts,
} as Record<string, unknown>
})
const resultDetailObj = computed(() => {
const r = resultRaw.value || {}
return {
tips: r.tips,
conversation_scripts: r.scripts,
growth_plan: r.growth_plan,
faq: r.faq,
blind_spots: r.watchouts,
strengths: r.strengths,
} as Record<string, unknown>
})
const sharePayload = computed<PortraitSharePayload | null>(() => {
if (phase.value !== 'result') return null
return {
type: 'portrait',
title: resultLabel.value,
line: shareLine.value || String(resultRaw.value?.summary || ''),
keywords: resultLabel.value ? [resultLabel.value] : [],
}
})
function firstUnansweredIdx(): number {
const idx = questions.value.findIndex((q) => !answers[q.id])
return idx >= 0 ? idx : 0
}
function startTest() {
phase.value = 'answering'
currentIdx.value = draftRestored.value ? firstUnansweredIdx() : 0
}
function prevQ() {
if (currentIdx.value > 0) currentIdx.value--
}
function onNext() {
if (isLast.value) {
submit()
} else {
currentIdx.value++
}
}
async function load() {
loadingScale.value = true
loadError.value = ''
needProfile.value = false
try {
const d = await api.getScale(slug)
title.value = d.title
description.value = d.description
questions.value = d.questions.map((q) => ({
id: q.id,
body: typeof q.body === 'string' ? JSON.parse(q.body as unknown as string) : q.body,
}))
const draft = loadScaleDraft(slug)
if (draft) {
const ids = new Set(questions.value.map((q) => q.id))
let n = 0
for (const [id, key] of Object.entries(draft)) {
if (ids.has(id)) {
answers[id] = key
n++
}
}
draftRestored.value = n > 0
}
persistReady.value = true
} catch (e) {
loadError.value = e instanceof Error ? e.message : '加载失败'
} finally {
loadingScale.value = false
}
}
function openShare() {
shareOpen.value = true
}
async function submit() {
submitting.value = true
submitError.value = ''
needProfile.value = false
try {
for (const q of questions.value) {
if (!answers[q.id]) throw new Error('请完成全部题目')
}
const { items } = await api.listProfiles()
const self = (items || []).find((p) => p.relation === 'self')
if (!self) {
needProfile.value = true
throw new Error('请先在首页完成性格探索,创建个人档案')
}
const out = await api.submitScale(slug, self.id, { ...answers })
clearScaleDraft(slug)
draftRestored.value = false
phase.value = 'result'
resultRaw.value = out.result as Record<string, unknown>
resultLabel.value = String(out.result.label || '探索结果')
shareLine.value = String(out.result.share_line || '')
} catch (e) {
submitError.value = e instanceof Error ? e.message : '提交失败'
} finally {
submitting.value = false
}
}
function setAnswer(questionId: string, key: string) {
answers[questionId] = key
}
onMounted(load)
return {
title,
description,
questions,
answers,
loadingScale,
submitting,
loadError,
submitError,
needProfile,
phase,
resultLabel,
shareLine,
shareOpen,
draftRestored,
currentIdx,
currentQuestion,
isLast,
progressPct,
estMinutes,
resultSummaryObj,
resultDetailObj,
sharePayload,
load,
startTest,
prevQ,
onNext,
openShare,
setAnswer,
}
}
@@ -0,0 +1,302 @@
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 type { WheelAspect, WheelPlanet } from '../components/NatalWheel.vue'
import 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'
export const starViewTabs = [
{ key: 'overview' as const, label: '概览' },
{ key: 'chart' as const, label: '星盘' },
{ key: 'fortune' as const, label: '运势' },
{ key: 'planets' as const, label: '行星' },
]
export const starFortuneKeys = ['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 HouseRow = { num: number; sign: string }
export function fortuneLabel(k: string) {
return ({ daily: '今日', weekly: '本周', monthly: '本月', yearly: '今年', lifetime: '一生' } as Record<string, string>)[k] || k
}
export function 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)
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 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 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)
return {
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,
}
}