diff --git a/apps/user-h5/src/components/ask/AskAdvisorPane.vue b/apps/user-h5/src/components/ask/AskAdvisorPane.vue
new file mode 100644
index 0000000..2ad0c7f
--- /dev/null
+++ b/apps/user-h5/src/components/ask/AskAdvisorPane.vue
@@ -0,0 +1,122 @@
+
+
+
+
成长顾问
+
结合档案聊聊卡住的事 · 可先用 AI,或开通会员解锁更多
+
+
+
{{ a.mark }}
+
+ {{ a.name }}
+ {{ a.meta }}
+ {{ a.tags }}
+
+
+
+
查看成长会员权益
+
+
+
+
+
+
diff --git a/apps/user-h5/src/components/ask/AskAiPane.vue b/apps/user-h5/src/components/ask/AskAiPane.vue
new file mode 100644
index 0000000..50b382e
--- /dev/null
+++ b/apps/user-h5/src/components/ask/AskAiPane.vue
@@ -0,0 +1,354 @@
+
+
+
加载中…
+
+ {{ bootError }}
+
+
+
+
+
+
还没有个人档案。完成愈心解码后再来提问,回答会更贴合你。
+
去愈心解码
+
+
+
+
+
+
+
+
Hi,我是愈心 AI
+
最近有什么事,都可以和我聊聊
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ sendError }}
+ 开通成长会员
+
+
+
+
+ 剩余 {{ quota.remaining }} 次
+ (免费 {{ quota.free_limit }})
+
+
+
部分内容由 AI 生成,仅供参考
+
+
+
+
+
+
+
+
diff --git a/apps/user-h5/src/components/ask/AskRails.vue b/apps/user-h5/src/components/ask/AskRails.vue
new file mode 100644
index 0000000..260b04e
--- /dev/null
+++ b/apps/user-h5/src/components/ask/AskRails.vue
@@ -0,0 +1,71 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/user-h5/src/components/imagecard/ImageCardGreeting.vue b/apps/user-h5/src/components/imagecard/ImageCardGreeting.vue
new file mode 100644
index 0000000..78d8d55
--- /dev/null
+++ b/apps/user-h5/src/components/imagecard/ImageCardGreeting.vue
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
Hi,我是愈心 AI
+
来用意念卡片整理问题吧
+
{{ bootError }}(仍可填写后重试抽取)
+
今日剩余 {{ quotaLabel }} · 场景 {{ scene }}
+
+
+
+
+
+
diff --git a/apps/user-h5/src/components/imagecard/ImageCardInputBar.vue b/apps/user-h5/src/components/imagecard/ImageCardInputBar.vue
new file mode 100644
index 0000000..1296202
--- /dev/null
+++ b/apps/user-h5/src/components/imagecard/ImageCardInputBar.vue
@@ -0,0 +1,82 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/user-h5/src/components/imagecard/ImageCardLanding.vue b/apps/user-h5/src/components/imagecard/ImageCardLanding.vue
new file mode 100644
index 0000000..3efbf0c
--- /dev/null
+++ b/apps/user-h5/src/components/imagecard/ImageCardLanding.vue
@@ -0,0 +1,175 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/user-h5/src/components/imagecard/ImageCardResults.vue b/apps/user-h5/src/components/imagecard/ImageCardResults.vue
new file mode 100644
index 0000000..16a24e7
--- /dev/null
+++ b/apps/user-h5/src/components/imagecard/ImageCardResults.vue
@@ -0,0 +1,56 @@
+
+
+
+ {{ c.title }}
+ {{ c.imagery }}
+ 反思 {{ c.prompt }}
+ {{ c.tip }}
+
+
+
+
三卡组合解读需深度版或成长会员。
+
+
+
+ 在报告页打开 →
+
+
+
+
+
+
+
diff --git a/apps/user-h5/src/components/profile/ProfileAddForm.vue b/apps/user-h5/src/components/profile/ProfileAddForm.vue
new file mode 100644
index 0000000..d6cf792
--- /dev/null
+++ b/apps/user-h5/src/components/profile/ProfileAddForm.vue
@@ -0,0 +1,106 @@
+
+
+
+
+
+
+
diff --git a/apps/user-h5/src/components/profile/ProfileBottomCta.vue b/apps/user-h5/src/components/profile/ProfileBottomCta.vue
new file mode 100644
index 0000000..e4d98da
--- /dev/null
+++ b/apps/user-h5/src/components/profile/ProfileBottomCta.vue
@@ -0,0 +1,49 @@
+
+
+
+ 去做合盘 ›
+
+
+
+
+
+
diff --git a/apps/user-h5/src/components/profile/ProfileEditForm.vue b/apps/user-h5/src/components/profile/ProfileEditForm.vue
new file mode 100644
index 0000000..6e0eaf4
--- /dev/null
+++ b/apps/user-h5/src/components/profile/ProfileEditForm.vue
@@ -0,0 +1,96 @@
+
+
+
+
+
+
+
diff --git a/apps/user-h5/src/components/profile/ProfileEmpty.vue b/apps/user-h5/src/components/profile/ProfileEmpty.vue
new file mode 100644
index 0000000..a0487b5
--- /dev/null
+++ b/apps/user-h5/src/components/profile/ProfileEmpty.vue
@@ -0,0 +1,34 @@
+
+
+
+
暂无档案。请先去首页完成性格探索,或添加重要的人。
+
去性格探索
+
+
+
+
+
+
diff --git a/apps/user-h5/src/components/profile/ProfileInviteForm.vue b/apps/user-h5/src/components/profile/ProfileInviteForm.vue
new file mode 100644
index 0000000..02f1d9e
--- /dev/null
+++ b/apps/user-h5/src/components/profile/ProfileInviteForm.vue
@@ -0,0 +1,84 @@
+
+
+
+
+
+
+
diff --git a/apps/user-h5/src/components/profile/ProfileOthersList.vue b/apps/user-h5/src/components/profile/ProfileOthersList.vue
new file mode 100644
index 0000000..c6d7a99
--- /dev/null
+++ b/apps/user-h5/src/components/profile/ProfileOthersList.vue
@@ -0,0 +1,75 @@
+
+
+
+
+
+
+
diff --git a/apps/user-h5/src/components/profile/ProfileSelfCard.vue b/apps/user-h5/src/components/profile/ProfileSelfCard.vue
new file mode 100644
index 0000000..0c3afc1
--- /dev/null
+++ b/apps/user-h5/src/components/profile/ProfileSelfCard.vue
@@ -0,0 +1,76 @@
+
+
+
+ {{ avatarInitial(profile) }}
+
+
+ {{ profile.display_name || '我' }}
+ 我 · {{ formatProfileDate(profile.birth_date) }}
+ {{ profile.birth_place }}
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/user-h5/src/components/relation/RelationLanding.vue b/apps/user-h5/src/components/relation/RelationLanding.vue
new file mode 100644
index 0000000..c5110ff
--- /dev/null
+++ b/apps/user-h5/src/components/relation/RelationLanding.vue
@@ -0,0 +1,186 @@
+
+
+
+
看见彼此的相处模样
+
基于档案与生日,理解相处模式 · 性格对照 · 默契指数
+
+
+
+
+
+
+
+
+
+ ♡
+
+
+
{{ focusHint }}
+
+
+
+
+
+
+
+
+
diff --git a/apps/user-h5/src/components/relation/RelationResult.vue b/apps/user-h5/src/components/relation/RelationResult.vue
new file mode 100644
index 0000000..a67d9d4
--- /dev/null
+++ b/apps/user-h5/src/components/relation/RelationResult.vue
@@ -0,0 +1,237 @@
+
+
+
{{ diff }}
+
+
+
+
+
+
+
+
我{{ meStyle || '—' }}
+
×
+
TA{{ otherStyle || '—' }}
+
+
+
+ 默契指数
+ {{ harmony }}
+
+
+ {{ fitLabel }}
+
+
+
+
恋爱{{ loveIndex ?? '—' }}
+
友情{{ friendIndex ?? '—' }}
+
婚姻{{ marriageIndex ?? '—' }}
+
+
+
+
+
+
+
+
基于你们的档案,以下是相处与沟通建议。
+
+
+
+
+
完整相处建议、冲突修复与本周练习可在深度版或成长会员中查看。
+
+
+
+
+
+ 去问答
+ 合盘
+
+
+
+
+
+
+
diff --git a/apps/user-h5/src/components/report/ReportBodyContent.vue b/apps/user-h5/src/components/report/ReportBodyContent.vue
new file mode 100644
index 0000000..4264913
--- /dev/null
+++ b/apps/user-h5/src/components/report/ReportBodyContent.vue
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
完整分析可在深度版或成长会员中查看。
+
+
+
+
+
+
+
+
diff --git a/apps/user-h5/src/components/report/ReportHeadBlock.vue b/apps/user-h5/src/components/report/ReportHeadBlock.vue
new file mode 100644
index 0000000..5d3bb9c
--- /dev/null
+++ b/apps/user-h5/src/components/report/ReportHeadBlock.vue
@@ -0,0 +1,83 @@
+
+
+
+ {{ typeLabel }}
+ {{ createdLabel }}
+
+
{{ headline || oneLiner || '成长报告' }}
+
{{ oneLiner }}
+
+
+ ★
+
+ 成长会员
+ 深度报告 · 全年节气陪伴 · 解锁完整分析
+
+ 开通 ›
+
+
+
+
+
+
+
diff --git a/apps/user-h5/src/components/report/ReportMatchIndices.vue b/apps/user-h5/src/components/report/ReportMatchIndices.vue
new file mode 100644
index 0000000..1d6e010
--- /dev/null
+++ b/apps/user-h5/src/components/report/ReportMatchIndices.vue
@@ -0,0 +1,33 @@
+
+
+
恋爱{{ loveIndex }}
+
友情{{ friendIndex }}
+
婚姻{{ marriageIndex }}
+
+
+
+
+
+
diff --git a/apps/user-h5/src/components/report/ReportModeTabs.vue b/apps/user-h5/src/components/report/ReportModeTabs.vue
new file mode 100644
index 0000000..cd67e25
--- /dev/null
+++ b/apps/user-h5/src/components/report/ReportModeTabs.vue
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+
+
diff --git a/apps/user-h5/src/components/report/ReportStarPanel.vue b/apps/user-h5/src/components/report/ReportStarPanel.vue
new file mode 100644
index 0000000..5980220
--- /dev/null
+++ b/apps/user-h5/src/components/report/ReportStarPanel.vue
@@ -0,0 +1,117 @@
+
+
+
+
+
+
今日行运
+
{{ t.title }} · {{ t.aspect }}:{{ t.tip }}
+
+
+
+
{{ activeCard.title }} · {{ activeCard.label }}
+
{{ activeCard.teaser }}
+
+
+
+
+
+
+
+
+ {{ activeFortune.title }} · {{ activeFortune.label }} · {{ activeFortune.score }}分
+
+
{{ activeFortune.tip }}
+
+ 暂无运势数据
+
+
+ -
+ {{ p.title }}
+ {{ p.sign }} {{ p.degree }} · 第{{ p.house }}宫
+
+
+
+
+
+
+
+
diff --git a/apps/user-h5/src/components/report/ReportStickyBar.vue b/apps/user-h5/src/components/report/ReportStickyBar.vue
new file mode 100644
index 0000000..e45de5b
--- /dev/null
+++ b/apps/user-h5/src/components/report/ReportStickyBar.vue
@@ -0,0 +1,63 @@
+
+
+
+
+
+ {{ relatedLink.label }}
+
+
+
+
+
+
+
diff --git a/apps/user-h5/src/components/report/ReportSynastryPanel.vue b/apps/user-h5/src/components/report/ReportSynastryPanel.vue
new file mode 100644
index 0000000..7e6d307
--- /dev/null
+++ b/apps/user-h5/src/components/report/ReportSynastryPanel.vue
@@ -0,0 +1,36 @@
+
+
+
{{ synActiveTip }}
+
+
+
+
+
+
+ {{ e.planet }} → {{ e.house }}宫 · {{ e.house_tip }}
+
+
+
+
+
+
+
diff --git a/apps/user-h5/src/components/rhythm/RhythmIntro.vue b/apps/user-h5/src/components/rhythm/RhythmIntro.vue
new file mode 100644
index 0000000..bb364b6
--- /dev/null
+++ b/apps/user-h5/src/components/rhythm/RhythmIntro.vue
@@ -0,0 +1,71 @@
+
+
+
+
+
身心节律
+
填写生日,看见作息 · 饮食 · 活动的节奏建议(生活方式参考,非算命)
+
+
+
+
+
+
+
+
diff --git a/apps/user-h5/src/components/rhythm/RhythmResult.vue b/apps/user-h5/src/components/rhythm/RhythmResult.vue
new file mode 100644
index 0000000..b579e55
--- /dev/null
+++ b/apps/user-h5/src/components/rhythm/RhythmResult.vue
@@ -0,0 +1,235 @@
+
+
+
+
+
+
+
+ {{ headline || '我的身心节律' }}
+ {{ oneLiner }}
+
+ {{ k }}
+
+
+
节律倾向
+
{{ styleLabel }}
+
+ {{ overviewText }}
+
+
+
+
+
+
+
暂无五行分布数据,可从概览与建议了解节律倾向。
+
+
+
+
+
+
+
+
完整习惯方案与本周练习可在深度版或成长会员中查看。
+
+
+
+
+
+
+
+
+
+
+
节气生活陪伴
+
{{ solarTip || '结合当季节气,微调作息与饮食节奏。' }}
+
+ ›
+
+ 本周关注:{{ weekFocus }}
+
+
+
+ 去节气陪伴
+
+ 去问答
+
+
+
+
+
+
+
diff --git a/apps/user-h5/src/components/scale/ScaleAnswering.vue b/apps/user-h5/src/components/scale/ScaleAnswering.vue
new file mode 100644
index 0000000..0db1831
--- /dev/null
+++ b/apps/user-h5/src/components/scale/ScaleAnswering.vue
@@ -0,0 +1,138 @@
+
+
+
已恢复上次未提交的作答
+
+
+
+
+
{{ currentIdx + 1 }} / {{ totalQuestions }}
+
+
+
+
{{ currentQuestion.body.prompt }}
+
+
+
+
{{ submitError }}
+
+
提交结果需要先有「我」的个人档案。
+
去首页创建档案
+
+
+
+
+
+
+
diff --git a/apps/user-h5/src/components/scale/ScaleIntro.vue b/apps/user-h5/src/components/scale/ScaleIntro.vue
new file mode 100644
index 0000000..042d4ea
--- /dev/null
+++ b/apps/user-h5/src/components/scale/ScaleIntro.vue
@@ -0,0 +1,76 @@
+
+
+
+
+
{{ title || '探索测试' }}
+
{{ description }}
+
共 {{ questionCount }} 题 · 约 {{ estMinutes }} 分钟
+
检测到上次未完成的作答,开始后将继续
+
+
+
+
+
+
+
+
diff --git a/apps/user-h5/src/components/scale/ScaleNavBar.vue b/apps/user-h5/src/components/scale/ScaleNavBar.vue
new file mode 100644
index 0000000..3ce73ab
--- /dev/null
+++ b/apps/user-h5/src/components/scale/ScaleNavBar.vue
@@ -0,0 +1,68 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/user-h5/src/components/scale/ScaleResult.vue b/apps/user-h5/src/components/scale/ScaleResult.vue
new file mode 100644
index 0000000..c9f7953
--- /dev/null
+++ b/apps/user-h5/src/components/scale/ScaleResult.vue
@@ -0,0 +1,44 @@
+
+
+
{{ resultLabel }}
+
+
+
{{ shareLine }}
+
+
用结果去做关系理解 →
+
去问答聊聊 →
+
+
+
+
+
+
+
diff --git a/apps/user-h5/src/components/star/StarBirthForm.vue b/apps/user-h5/src/components/star/StarBirthForm.vue
new file mode 100644
index 0000000..6322420
--- /dev/null
+++ b/apps/user-h5/src/components/star/StarBirthForm.vue
@@ -0,0 +1,85 @@
+
+
+
+
+
+
+
diff --git a/apps/user-h5/src/components/star/StarChartPanel.vue b/apps/user-h5/src/components/star/StarChartPanel.vue
new file mode 100644
index 0000000..53826ef
--- /dev/null
+++ b/apps/user-h5/src/components/star/StarChartPanel.vue
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
+
+
宫位
+
第{{ h.num }}宫 · {{ h.sign }}
+
+
+
相位速览
+
+ {{ a.label || `${a.a_title}${a.type}${a.b_title}` }}
+
+
+
+
+
+
+
+
diff --git a/apps/user-h5/src/components/star/StarFortunePanel.vue b/apps/user-h5/src/components/star/StarFortunePanel.vue
new file mode 100644
index 0000000..bd3052e
--- /dev/null
+++ b/apps/user-h5/src/components/star/StarFortunePanel.vue
@@ -0,0 +1,80 @@
+
+
+
+
+
+
+
+ {{ activeFortune.title }} · {{ activeFortune.label }} · {{ activeFortune.score }}分
+
+
焦点:{{ activeFortune.focus }}
+
{{ activeFortune.tip }}
+
+ 感情 {{ activeFortune.dims.love }}
+ 事业 {{ activeFortune.dims.career }}
+ 财运 {{ activeFortune.dims.money }}
+ 心情 {{ activeFortune.dims.mood }}
+
+
幸运:{{ activeFortune.lucky }}
+
注意:{{ activeFortune.caution }}
+
+
+
+
+
+
+
diff --git a/apps/user-h5/src/components/star/StarModeTabs.vue b/apps/user-h5/src/components/star/StarModeTabs.vue
new file mode 100644
index 0000000..12aa5ca
--- /dev/null
+++ b/apps/user-h5/src/components/star/StarModeTabs.vue
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
diff --git a/apps/user-h5/src/components/star/StarOverviewPanel.vue b/apps/user-h5/src/components/star/StarOverviewPanel.vue
new file mode 100644
index 0000000..47daee9
--- /dev/null
+++ b/apps/user-h5/src/components/star/StarOverviewPanel.vue
@@ -0,0 +1,155 @@
+
+
+
{{ headline || '我的星座画像' }}
+
{{ oneLiner }}
+
+
+
+ 星象速览
+ AI 解读 ›
+
+
{{ chartNote || '行星落在不同星座,构成独特的你。' }}
+
+ {{ k }}
+
+
+
+
+
+
+
+
+
{{ activeCard.title }} · {{ activeCard.label }}
+
{{ activeCard.teaser }}
+
{{ activeCard.element }}象 · {{ activeCard.modality }}
+
+
+
+ AI,本周我可以关注什么?
+ ›
+
+
+
+
+
+
+
diff --git a/apps/user-h5/src/components/star/StarPlanetsPanel.vue b/apps/user-h5/src/components/star/StarPlanetsPanel.vue
new file mode 100644
index 0000000..11adb8d
--- /dev/null
+++ b/apps/user-h5/src/components/star/StarPlanetsPanel.vue
@@ -0,0 +1,33 @@
+
+
+ -
+ {{ p.title }}
+ {{ p.sign }} {{ p.degree }} · 第{{ p.house }}宫
+
+
+
+
+
+
+
diff --git a/apps/user-h5/src/components/star/StarReportFooter.vue b/apps/user-h5/src/components/star/StarReportFooter.vue
new file mode 100644
index 0000000..386ace0
--- /dev/null
+++ b/apps/user-h5/src/components/star/StarReportFooter.vue
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+
diff --git a/apps/user-h5/src/composables/useAskPage.ts b/apps/user-h5/src/composables/useAskPage.ts
new file mode 100644
index 0000000..996cb9c
--- /dev/null
+++ b/apps/user-h5/src/composables/useAskPage.ts
@@ -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([])
+ const profileId = ref('')
+ const scene = ref('self')
+ const threadId = ref('')
+ const messages = ref([])
+ const draft = ref('')
+ const sending = ref(false)
+ const sendError = ref('')
+ const quotaExhausted = ref(false)
+ const quota = ref(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 {
+ 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,
+ }
+}
diff --git a/apps/user-h5/src/composables/useImageCardPage.ts b/apps/user-h5/src/composables/useImageCardPage.ts
new file mode 100644
index 0000000..d13b736
--- /dev/null
+++ b/apps/user-h5/src/composables/useImageCardPage.ts
@@ -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(null)
+ const questionInput = ref('')
+ const showBirth = ref(false)
+
+ const summary = computed(() => (report.value?.summary || {}) as Record)
+ const detail = computed(() => (report.value?.detail || null) as Record | 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,
+ }
+}
diff --git a/apps/user-h5/src/composables/useLifeRhythmPage.ts b/apps/user-h5/src/composables/useLifeRhythmPage.ts
new file mode 100644
index 0000000..af6d881
--- /dev/null
+++ b/apps/user-h5/src/composables/useLifeRhythmPage.ts
@@ -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(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)
+ const detail = computed(() => (report.value?.detail || null) as Record | 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).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).bars
+ if (!Array.isArray(bars)) return []
+ return bars
+ .map((b) => {
+ const o = b as Record
+ 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,
+ }
+}
diff --git a/apps/user-h5/src/composables/useProfilePage.ts b/apps/user-h5/src/composables/useProfilePage.ts
new file mode 100644
index 0000000..3e82b49
--- /dev/null
+++ b/apps/user-h5/src/composables/useProfilePage.ts
@@ -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 = {
+ 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([])
+ const loading = ref(true)
+ const error = ref('')
+ const busyId = ref('')
+ const editing = ref(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,
+ }
+}
diff --git a/apps/user-h5/src/composables/useRelationPage.ts b/apps/user-h5/src/composables/useRelationPage.ts
new file mode 100644
index 0000000..c30ec31
--- /dev/null
+++ b/apps/user-h5/src/composables/useRelationPage.ts
@@ -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(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 = {
+ mode: '看见日常互动里,谁更主动、谁更需要空间',
+ personality: '对照彼此的性格关键词与表达习惯',
+ sync: '恋爱 · 友情 · 婚姻维度的默契参考',
+ }
+ return map[focus.value] || map.mode
+ })
+
+ const summary = computed(() => (report.value?.summary || {}) as Record)
+ const detail = computed(() => (report.value?.detail || null) as Record | 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(() => {
+ 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 {
+ 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,
+ }
+}
diff --git a/apps/user-h5/src/composables/useReportPage.ts b/apps/user-h5/src/composables/useReportPage.ts
new file mode 100644
index 0000000..077150c
--- /dev/null
+++ b/apps/user-h5/src/composables/useReportPage.ts
@@ -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(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)
+ const detail = computed(() => (report.value?.detail || null) as Record | 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 = {
+ 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)
+ : {},
+ )
+ const ascLon = computed(() => (typeof chartObj.value.asc_lon === 'number' ? chartObj.value.asc_lon : null))
+ const wheelPlanets = computed(() => {
+ const raw = summary.value.planets
+ if (!Array.isArray(raw)) return []
+ return raw.map((p) => {
+ const o = p as Record
+ 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(() => {
+ const raw = summary.value.aspects_preview
+ if (!Array.isArray(raw)) return []
+ return raw.map((a) => {
+ const o = a as Record
+ 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)
+ : {},
+ )
+ const synActiveBlock = computed(() => {
+ const c = synCharts.value[panelKey.value]
+ return c && typeof c === 'object' ? (c as Record) : null
+ })
+ const synActiveTip = computed(() => String(synActiveBlock.value?.tip || ''))
+ const synActivePlanets = computed(() => {
+ const raw = synActiveBlock.value?.planets
+ if (!Array.isArray(raw)) return []
+ return raw.map((p) => {
+ const x = p as Record
+ 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
+ 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) : {}
+ })
+ 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(() => {
+ 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,
+ }
+}
diff --git a/apps/user-h5/src/composables/useScalePage.ts b/apps/user-h5/src/composables/useScalePage.ts
new file mode 100644
index 0000000..0df9424
--- /dev/null
+++ b/apps/user-h5/src/composables/useScalePage.ts
@@ -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>({})
+ 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 | 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
+ })
+ 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
+ })
+
+ const sharePayload = computed(() => {
+ 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
+ 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,
+ }
+}
diff --git a/apps/user-h5/src/composables/useStarProfilePage.ts b/apps/user-h5/src/composables/useStarProfilePage.ts
new file mode 100644
index 0000000..a31cca0
--- /dev/null
+++ b/apps/user-h5/src/composables/useStarProfilePage.ts
@@ -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)[k] || k
+}
+
+export function useStarProfilePage() {
+ const route = useRoute()
+ const loading = ref(false)
+ const needBirth = ref(true)
+ const error = ref('')
+ const report = ref(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)
+ const detail = computed(() => (report.value?.detail || null) as Record | 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) : {},
+ )
+ 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) : {}
+ })
+ 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(() =>
+ 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(() => {
+ 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(() => {
+ 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,
+ }
+}
diff --git a/apps/user-h5/src/pages/AskPage.spec.ts b/apps/user-h5/src/pages/AskPage.spec.ts
index 9f5cbfc..a8e7720 100644
--- a/apps/user-h5/src/pages/AskPage.spec.ts
+++ b/apps/user-h5/src/pages/AskPage.spec.ts
@@ -37,7 +37,7 @@ describe('AskPage', () => {
const w = mount(AskPage, { global: { stubs: { RouterLink: RouterLinkStub } } })
await flushPromises()
expect(w.text()).toContain('还没有个人档案')
- expect(w.text()).toContain('去首页探索')
+ expect(w.text()).toContain('去愈心解码')
})
it('loads profiles and sends a message', async () => {
@@ -77,7 +77,7 @@ describe('AskPage', () => {
expect(w.text()).toContain('网络错误')
api.listProfiles.mockResolvedValue({ items: [] })
api.getAskQuota.mockResolvedValue({ remaining: 3, free_limit: 3, active_membership: false, source: 'free' })
- await w.find('button.link').trigger('click')
+ await w.find('button.retry').trigger('click')
await flushPromises()
expect(w.text()).toContain('还没有个人档案')
})
diff --git a/apps/user-h5/src/pages/AskPage.vue b/apps/user-h5/src/pages/AskPage.vue
index 4580a7b..2e29754 100644
--- a/apps/user-h5/src/pages/AskPage.vue
+++ b/apps/user-h5/src/pages/AskPage.vue
@@ -1,588 +1,59 @@
-
-
-
-
-
+
-
-
-
加载中…
-
- {{ bootError }}
-
-
-
-
-
-
还没有个人档案。完成愈心解码后再来提问,回答会更贴合你。
-
去愈心解码
-
-
-
-
-
-
-
-
-
Hi,我是愈心 AI
-
最近有什么事,都可以和我聊聊
-
-
-
-
-
-
-
+
-
-
-
-
-
-
-
-
-
-
-
- {{ sendError }}
- 开通成长会员
-
-
-
-
- 剩余 {{ quota.remaining }} 次
- (免费 {{ quota.free_limit }})
-
-
-
部分内容由 AI 生成,仅供参考
-
-
-
-
-
-
-
-
成长顾问
-
结合档案聊聊卡住的事 · 可先用 AI,或开通会员解锁更多
-
-
-
{{ a.mark }}
-
- {{ a.name }}
- {{ a.meta }}
- {{ a.tags }}
-
-
-
-
查看成长会员权益
-
+
-
-
diff --git a/apps/user-h5/src/pages/ImageCardPage.vue b/apps/user-h5/src/pages/ImageCardPage.vue
index 6553ed8..ad552ae 100644
--- a/apps/user-h5/src/pages/ImageCardPage.vue
+++ b/apps/user-h5/src/pages/ImageCardPage.vue
@@ -5,249 +5,79 @@
-
-
-
-
-
-
-
Hi,我是愈心 AI
-
来用意念卡片整理问题吧
-
{{ bootError }}(仍可填写后重试抽取)
-
今日剩余 {{ quotaLabel }} · 场景 {{ scene }}
-
+
-
-
-
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ c.title }}
- {{ c.imagery }}
- 反思 {{ c.prompt }}
- {{ c.tip }}
-
-
-
-
三卡组合解读需深度版或成长会员。
-
-
-
- 在报告页打开 →
-
-
+
意象卡片用于投射与自我反思,不判定好坏,也不预测未来。部分内容由 AI 生成,仅供参考。
{{ error }}
-
-
-
-
-
+
diff --git a/apps/user-h5/src/pages/LifeRhythmPage.vue b/apps/user-h5/src/pages/LifeRhythmPage.vue
index da166ea..a53c0ae 100644
--- a/apps/user-h5/src/pages/LifeRhythmPage.vue
+++ b/apps/user-h5/src/pages/LifeRhythmPage.vue
@@ -9,21 +9,15 @@
-
-
-
-
身心节律
-
填写生日,看见作息 · 饮食 · 活动的节奏建议(生活方式参考,非算命)
-
-
-
+
正在生成节律…
@@ -31,78 +25,23 @@
-
-
-
-
-
-
- {{ headline || '我的身心节律' }}
- {{ oneLiner }}
-
- {{ k }}
-
-
-
节律倾向
-
{{ styleLabel }}
-
- {{ overviewText }}
-
-
-
-
-
-
-
暂无五行分布数据,可从概览与建议了解节律倾向。
-
-
-
-
-
-
-
-
完整习惯方案与本周练习可在深度版或成长会员中查看。
-
-
-
-
-
-
-
-
-
-
-
节气生活陪伴
-
{{ solarTip || '结合当季节气,微调作息与饮食节奏。' }}
-
- ›
-
- 本周关注:{{ weekFocus }}
-
-
-
- 去节气陪伴
-
- 去问答
-
-
+
本内容为自我探索与生活方式参考,不构成医疗建议,亦非占卜预测。
@@ -110,148 +49,36 @@
diff --git a/apps/user-h5/src/pages/MembershipPage.spec.ts b/apps/user-h5/src/pages/MembershipPage.spec.ts
index a2cce95..712b43b 100644
--- a/apps/user-h5/src/pages/MembershipPage.spec.ts
+++ b/apps/user-h5/src/pages/MembershipPage.spec.ts
@@ -26,8 +26,9 @@ describe('MembershipPage', () => {
api.getMembership.mockResolvedValue({ active: false, status: 'none' })
const w = mount(MembershipPage)
await flushPromises()
- expect(w.text()).toContain('尚未开通')
- expect(w.text()).toContain('开通月卡')
+ expect(w.text()).toContain('开通后可查看画像')
+ expect(w.text()).toContain('月卡')
+ expect(w.text()).toContain('模拟开通')
})
it('activates membership after mock pay', async () => {
@@ -45,7 +46,9 @@ describe('MembershipPage', () => {
const w = mount(MembershipPage)
await flushPromises()
- await w.findAll('button').find((b) => b.text().includes('开通月卡'))!.trigger('click')
+ const monthBtn = w.findAll('button').find((b) => b.text().includes('月卡'))
+ expect(monthBtn).toBeTruthy()
+ await monthBtn!.trigger('click')
await flushPromises()
expect(api.createOrder).toHaveBeenCalledWith({ kind: 'membership', plan: 'month' })
expect(w.text()).toContain('会员有效')
@@ -58,8 +61,8 @@ describe('MembershipPage', () => {
await flushPromises()
expect(w.text()).toContain('网络错误')
api.getMembership.mockResolvedValue({ active: false, status: 'none' })
- await w.find('button.link').trigger('click')
+ await w.find('button.retry').trigger('click')
await flushPromises()
- expect(w.text()).toContain('尚未开通')
+ expect(w.text()).toContain('开通后可查看画像')
})
})
diff --git a/apps/user-h5/src/pages/ProfilePage.vue b/apps/user-h5/src/pages/ProfilePage.vue
index ba95760..4efce94 100644
--- a/apps/user-h5/src/pages/ProfilePage.vue
+++ b/apps/user-h5/src/pages/ProfilePage.vue
@@ -16,407 +16,127 @@
-
-
-
暂无档案。请先去首页完成性格探索,或添加重要的人。
-
去性格探索
-
+
-
-
- {{ avatarInitial(selfProfile) }}
-
-
- {{ selfProfile.display_name || '我' }}
- 我 · {{ formatDate(selfProfile.birth_date) }}
- {{ selfProfile.birth_place }}
-
-
-
-
-
-
-
+
+
-
+
-
+
-
+
-
-
- 去做合盘 ›
-
+
diff --git a/apps/user-h5/src/pages/RelationPage.vue b/apps/user-h5/src/pages/RelationPage.vue
index 35b04e9..59bf8ac 100644
--- a/apps/user-h5/src/pages/RelationPage.vue
+++ b/apps/user-h5/src/pages/RelationPage.vue
@@ -10,283 +10,85 @@
-
-
-
看见彼此的相处模样
-
基于档案与生日,理解相处模式 · 性格对照 · 默契指数
-
+
-
-
-
-
-
-
-
- ♡
-
-
-
{{ focusHint }}
-
-
-
-
-
-
- {{ diff }}
-
-
-
-
-
-
-
-
我{{ meStyle || '—' }}
-
×
-
TA{{ otherStyle || '—' }}
-
-
-
- 默契指数
- {{ harmony }}
-
-
- {{ fitLabel }}
-
-
-
-
恋爱{{ loveIndex ?? '—' }}
-
友情{{ friendIndex ?? '—' }}
-
婚姻{{ marriageIndex ?? '—' }}
-
-
-
-
-
-
-
- 基于你们的档案,以下是相处与沟通建议。
-
-
-
-
-
完整相处建议、冲突修复与本周练习可在深度版或成长会员中查看。
-
-
-
-
-
- 去问答
- 合盘
-
-
+
diff --git a/apps/user-h5/src/pages/ReportPage.vue b/apps/user-h5/src/pages/ReportPage.vue
index a62bcd0..2a805d3 100644
--- a/apps/user-h5/src/pages/ReportPage.vue
+++ b/apps/user-h5/src/pages/ReportPage.vue
@@ -17,142 +17,70 @@
-
- {{ typeLabel }}
- {{ createdLabel }}
-
- {{ headline || oneLiner || '成长报告' }}
- {{ oneLiner }}
+
-
- ★
-
- 成长会员
- 深度报告 · 全年节气陪伴 · 解锁完整分析
-
- 开通 ›
-
+
-
-
-
+
-
-
-
-
-
-
今日行运
-
{{ t.title }} · {{ t.aspect }}:{{ t.tip }}
-
-
-
-
{{ activeCard.title }} · {{ activeCard.label }}
-
{{ activeCard.teaser }}
-
-
-
-
-
-
-
-
- {{ activeFortune.title }} · {{ activeFortune.label }} · {{ activeFortune.score }}分
-
-
{{ activeFortune.tip }}
-
- 暂无运势数据
-
-
- -
- {{ p.title }}
- {{ p.sign }} {{ p.degree }} · 第{{ p.house }}宫
-
-
-
+
-
-
-
恋爱{{ loveIndex }}
-
友情{{ friendIndex }}
-
婚姻{{ marriageIndex }}
-
+
-
- {{ synActiveTip }}
-
-
-
-
-
-
- {{ e.planet }} → {{ e.house }}宫 · {{ e.house_tip }}
-
-
-
-
+
-
-
-
-
-
-
-
-
-
完整分析可在深度版或成长会员中查看。
-
-
-
-
-
-
-
- {{ relatedLink.label }}
-
-
+
@@ -160,279 +88,60 @@
diff --git a/apps/user-h5/src/pages/ScalePage.vue b/apps/user-h5/src/pages/ScalePage.vue
index cdbba37..d457f7c 100644
--- a/apps/user-h5/src/pages/ScalePage.vue
+++ b/apps/user-h5/src/pages/ScalePage.vue
@@ -18,243 +18,94 @@
-
-
-
-
-
{{ title || '探索测试' }}
-
{{ description }}
-
共 {{ questions.length }} 题 · 约 {{ estMinutes }} 分钟
-
检测到上次未完成的作答,开始后将继续
-
-
-
+
-
-
-
已恢复上次未提交的作答
-
-
-
-
-
{{ currentIdx + 1 }} / {{ questions.length }}
-
+
-
-
{{ currentQuestion.body.prompt }}
-
-
-
-
{{ submitError }}
-
-
提交结果需要先有「我」的个人档案。
-
去首页创建档案
-
-
-
-
-
-
{{ resultLabel }}
-
-
-
{{ shareLine }}
-
-
用结果去做关系理解 →
-
去问答聊聊 →
-
-
+
-
-
-
-
-
+
diff --git a/apps/user-h5/src/pages/StarProfilePage.spec.ts b/apps/user-h5/src/pages/StarProfilePage.spec.ts
index 94ed671..1c893bb 100644
--- a/apps/user-h5/src/pages/StarProfilePage.spec.ts
+++ b/apps/user-h5/src/pages/StarProfilePage.spec.ts
@@ -55,16 +55,20 @@ describe('StarProfilePage', () => {
const w = mount(StarProfilePage, { global: { stubs: { RouterLink: true } } })
await flushPromises()
expect(api.createStar).toHaveBeenCalled()
- expect(w.find('svg.wheel').exists() || w.text().includes('本命')).toBe(true)
- expect(w.text()).toContain('相位速览')
- expect(w.text()).toContain('今日行运')
+ await w.findAll('button').find((b) => b.text() === '星盘')!.trigger('click')
+ await flushPromises()
expect(w.find('svg.wheel').exists()).toBe(true)
+ expect(w.text()).toContain('相位速览')
+ expect(w.text()).toContain('太阳刑相月亮')
})
- it('shows lifetime fortune tab', async () => {
+ it('shows daily and lifetime fortune tabs', async () => {
const w = mount(StarProfilePage, { global: { stubs: { RouterLink: true } } })
await flushPromises()
await w.findAll('button').find((b) => b.text() === '运势')!.trigger('click')
+ await flushPromises()
+ expect(w.text()).toContain('今日运势')
+ expect(w.text()).toContain('焦点:行动')
await w.findAll('button').find((b) => b.text() === '一生')!.trigger('click')
await flushPromises()
expect(w.text()).toContain('一生运势摘要')
diff --git a/apps/user-h5/src/pages/StarProfilePage.vue b/apps/user-h5/src/pages/StarProfilePage.vue
index b6b9bc5..b38dc1f 100644
--- a/apps/user-h5/src/pages/StarProfilePage.vue
+++ b/apps/user-h5/src/pages/StarProfilePage.vue
@@ -12,26 +12,17 @@
-
-
-
-
星座性格
-
填写生日,看见太阳 · 月亮 · 上升与行星画像
-
-
-
+
正在生成星盘…
@@ -40,141 +31,53 @@
-
-
-
+
-
- {{ headline || '我的星座画像' }}
- {{ oneLiner }}
+
-
-
- 星象速览
- AI 解读 ›
-
-
{{ chartNote || '行星落在不同星座,构成独特的你。' }}
-
- {{ k }}
-
-
+
-
-
-
+
-
-
{{ activeCard.title }} · {{ activeCard.label }}
-
{{ activeCard.teaser }}
-
{{ activeCard.element }}象 · {{ activeCard.modality }}
-
+
-
- AI,本周我可以关注什么?
- ›
-
-
-
-
-
-
-
-
-
-
-
宫位
-
第{{ h.num }}宫 · {{ h.sign }}
-
-
-
相位速览
-
- {{ a.label || `${a.a_title}${a.type}${a.b_title}` }}
-
-
-
-
-
-
-
-
-
-
- {{ activeFortune.title }} · {{ activeFortune.label }} · {{ activeFortune.score }}分
-
-
焦点:{{ activeFortune.focus }}
-
{{ activeFortune.tip }}
-
- 感情 {{ activeFortune.dims.love }}
- 事业 {{ activeFortune.dims.career }}
- 财运 {{ activeFortune.dims.money }}
- 心情 {{ activeFortune.dims.mood }}
-
-
幸运:{{ activeFortune.lucky }}
-
注意:{{ activeFortune.caution }}
-
-
-
-
-
- -
- {{ p.title }}
- {{ p.sign }} {{ p.degree }} · 第{{ p.house }}宫
-
-
-
-
-
-
-
完整相位与年运详解可在深度版或成长会员中查看。
-
-
-
-
- 合盘 ›
- 人格匹配 ›
- 报告 ›
-
+
运势与星盘为自我探索参考,请结合现实判断。
@@ -184,271 +87,63 @@
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index d99d332..551e92b 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -9,3 +9,4 @@
- **ECR-001 Phase B**:拆分 `HomePage` / `SynastryPage`(≤400 行);build + homeCatalog 单测绿
- **ECR-001 Phase A/C/D/E + ECR-002**:membership 服务抽出;relation/star 引擎拆文件;`star/fortune`→`outlook`(JSON 双写);integration 绿
- H5:`/psy` baseURL + vite `/psy/api` 代理;首页 + 菜单 z-index 修复
+- **ECR-001 Phase F + H5**:types/sdk 对齐 OpenAPI;拆分 8 个超标页(均 ≤400);test:h5 35/35 绿
diff --git a/docs/CODE_REVIEW/ECR-001-phaseF-h5.md b/docs/CODE_REVIEW/ECR-001-phaseF-h5.md
new file mode 100644
index 0000000..2423527
--- /dev/null
+++ b/docs/CODE_REVIEW/ECR-001-phaseF-h5.md
@@ -0,0 +1,53 @@
+# CODE_REVIEW — ECR-001 Phase F + H5 splits
+
+**Reviewer:** Cursor Agent (REVIEWER)
+**Date:** 2026-08-05
+**Decision:** **PASS**(Should-fix 已修)
+
+## Scope reviewed
+
+- `packages/types` / `packages/sdk`:DTO 抽出,路径不变
+- H5 拆分:Ask / Report / Profile / StarProfile / Relation / Scale / ImageCard / LifeRhythm
+- 配套 composables×8 + components×~36
+- Spec 对齐:Ask / StarProfile / Membership
+- **Follow-up fix(同切片):** ScaleSummary 收敛 · fortuneKey lifetime · ScaleAnswering emit · StarProfile 单测补强
+
+## Evidence
+
+| Check | Result |
+|-------|--------|
+| API URL / 方法名未改 | PASS |
+| Page → composable → component 接线 | PASS |
+| 页面与子组件 ≤400 行 | PASS |
+| `ScaleSummary` = Go `ScaleListItem`(slug/title/description) | PASS |
+| `fortuneKey` 含 `lifetime` | PASS |
+| Scale 选答经 `@answer` emit,无 prop 深层 v-model | PASS |
+| StarProfile 星盘相位 + 运势今日/一生断言 | PASS |
+| `build:h5` + `test:h5` 35/35 | PASS |
+
+## Findings
+
+### Blockers
+无。
+
+### Should-fix
+已全部修复(见 Follow-up)。
+
+### Nits(不阻断)
+
+1. Relation / ImageCard 单测 `router-link` 未 stub → Vue warn(既有)。
+2. Commit message 需带 `ECR-001`;勿提交 `config.local.yaml` / `test-results/`。
+
+## Risk
+
+**低。**
+
+## Gate
+
+- [x] Architecture / layering PASS
+- [x] No API URL change
+- [x] Tests green
+- [x] Should-fix closed
+- [ ] Human commit
+
+**Decision: PASS**
diff --git a/docs/HANDOFF/ECR-001-phaseF-h5-engineer-to-reviewer.md b/docs/HANDOFF/ECR-001-phaseF-h5-engineer-to-reviewer.md
new file mode 100644
index 0000000..9422880
--- /dev/null
+++ b/docs/HANDOFF/ECR-001-phaseF-h5-engineer-to-reviewer.md
@@ -0,0 +1,30 @@
+# HANDOFF — ECR-001 Phase F + H5 → Reviewer
+
+## From
+Engineer
+
+## To
+Reviewer / Human
+
+## Slice
+ECR-001 Phase F(types/sdk 对齐)+ 超标 H5 页拆分
+
+## Done
+- types/sdk 与 OpenAPI 路径对齐;DTO 补齐
+- 8 个超标页拆到 ≤400 行 + composables/components
+- build:h5 + test:h5 全绿;TEST_REPORT 已写
+
+## Not in scope
+- 产品文案/交互改版
+- API URL / 契约破坏性变更
+- MembershipPage 拆分
+- JSON `fortune` 旧键删除(ECR-002 后续)
+
+## Ask
+- Review 结构拆分是否无行为回归
+- Human commit(勿纳入 `config.local.yaml` / `test-results/`)
+
+## Links
+- TASK: `docs/TASKS/TASK-20260805-ECR001-phaseF-h5.yaml`
+- TEST: `docs/TEST_REPORT/ECR-001-phaseF-h5.md`
+- STATE: `docs/STATE/ECR-001.md`
diff --git a/docs/STATE/ECR-001.md b/docs/STATE/ECR-001.md
index 4196bb2..c6f222d 100644
--- a/docs/STATE/ECR-001.md
+++ b/docs/STATE/ECR-001.md
@@ -4,10 +4,10 @@
|-------|--------|
| ECR | ECR-001 (+ ECR-002) |
| Title | Structural realignment |
-| Active slice | Phase A–E 已编码 |
-| Owner | release-ready(CODE_REVIEW PASS) |
-| Phase | review PASS |
-| Next | Human commit / 可选 Phase F |
+| Active slice | Phase F + H5 超标页拆分 |
+| Owner | reviewer → human |
+| Phase | review PASS · awaiting commit |
+| Next | Human commit |
| Updated | 2026-08-05 |
## History
@@ -17,3 +17,4 @@
| 2026-08-05 | Architect 诊断 + HANDOFF |
| 2026-08-05 | Phase B Home/Synastry;代理 baseURL 热修 |
| 2026-08-05 | Phase A 收口 · C membership · D engine 拆分 · E/ECR-002 outlook |
+| 2026-08-05 | Phase F types/sdk;拆分 Star/Report/Profile/Ask/Relation/Scale/ImageCard/LifeRhythm |
diff --git a/docs/TASKS/TASK-20260805-ECR001-phaseF-h5.yaml b/docs/TASKS/TASK-20260805-ECR001-phaseF-h5.yaml
new file mode 100644
index 0000000..817194f
--- /dev/null
+++ b/docs/TASKS/TASK-20260805-ECR001-phaseF-h5.yaml
@@ -0,0 +1,31 @@
+# docs/TASKS/TASK-20260805-ECR001-phaseF-h5.yaml
+task_id: ECR-001-phaseF-h5
+role: engineer
+phase: coding
+tool: cursor
+
+input:
+ - docs/ECR/ECR-001-structural-realignment.md
+ - docs/ENGINEERING_SPEC/ECR-001-structural-realignment.md
+ - proto/openapi.yaml
+ - .ai/coding.md
+
+output:
+ - packages/types/**
+ - packages/sdk/**
+ - apps/user-h5/src/pages/*
+ - apps/user-h5/src/composables/*
+ - apps/user-h5/src/components/**
+ - docs/TEST_REPORT/ECR-001-phaseF-h5.md
+
+forbidden:
+ - redesign_scope
+ - behavior_change
+ - api_url_change
+
+next_agent: reviewer
+status: coded
+notes: >
+ Phase F packages align + continue H5 page splits for pages >400 lines.
+ No product behavior change. build:h5 + test:h5 PASS. See TEST_REPORT/ECR-001-phaseF-h5.md.
+
diff --git a/docs/TEST_REPORT/ECR-001-phaseF-h5.md b/docs/TEST_REPORT/ECR-001-phaseF-h5.md
new file mode 100644
index 0000000..28836c6
--- /dev/null
+++ b/docs/TEST_REPORT/ECR-001-phaseF-h5.md
@@ -0,0 +1,44 @@
+# Test Report — ECR-001 Phase F + H5 splits
+
+## ECR
+ECR-001 Phase F · H5 page splits(超标页)
+
+## Change Level
+L2(结构 · 无产品行为变更意图)
+
+## Cases
+- [x] Phase F:`packages/types` 补齐 OpenAPI/SDK DTO;`packages/sdk` 引用 types
+- [x] H5 拆分(均 ≤400 行):StarProfile / Report / Profile / Ask / Relation / Scale / ImageCard / LifeRhythm
+- [x] 抽离 `components/{star,report,profile,ask,relation,scale,imagecard,rhythm}/` + `composables/use*Page.ts`
+- [x] 单测对齐现 UI:AskPage / StarProfilePage / MembershipPage
+- [x] `npm run build:h5` PASS
+- [x] `npm run test:h5` PASS(15 files / 35 tests)
+
+## Page line counts(post-split)
+
+| Page | Lines |
+|------|------:|
+| StarProfilePage | 175 |
+| ReportPage | 165 |
+| ProfilePage | 152 |
+| AskPage | 59 |
+| RelationPage | 117 |
+| ScalePage | 134 |
+| ImageCardPage | 96 |
+| LifeRhythmPage | 100 |
+
+## Command
+```bash
+npm run build:h5
+npm run test:h5
+python scripts/ess-validate.py --phase coding --ecr ECR-001
+```
+
+## Residual Risk
+- 拆分后部分子组件仍可能接近 400 行上限,后续可再切
+- MembershipPage 未在本轮拆分(未超标或未纳入本 TASK)
+- `router-link` 在 Relation 单测中有 Vue warn(既有 stub 缺口,非回归阻断)
+
+## Gate
+- [x] Phase F + H5 超标页拆分落地
+- [ ] Reviewer / Human commit
diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md
index 63ec250..9c146ff 100644
--- a/docs/TRACEABILITY.md
+++ b/docs/TRACEABILITY.md
@@ -6,4 +6,5 @@
| ECR-001 | structural realignment | Phase A–E coded | PRODUCT_SPEC · ENGINEERING_SPEC · HANDOFF · ADR-0007 |
| ECR-001-B | H5 Home/Synastry split | PASS | TEST_REPORT/ECR-001-phaseB.md · CODE_REVIEW |
| ECR-001-A/C/D/E | docs · membership · engines · outlook | coded → review | TEST_REPORT/ECR-001-phaseA-E.md |
+| ECR-001-F/H5 | types/sdk + 8 超标页拆分 | review PASS | TEST_REPORT · CODE_REVIEW/ECR-001-phaseF-h5.md |
| ECR-002 | fortune→outlook hygiene | Approved / coded | `docs/ECR/ECR-002-fortune-hygiene.md` |
diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts
index 31eed41..77c9bc0 100644
--- a/packages/sdk/src/index.ts
+++ b/packages/sdk/src/index.ts
@@ -1,11 +1,30 @@
import type {
+ AcceptSynastryInviteBody,
ApiResponse,
AskMessage,
AskQuota,
AskThread,
+ CreateOrderBody,
+ CreateProfileBody,
+ ExploreCategory,
+ GrowthCheckin,
+ GrowthPlan,
GrowthReport,
+ ImageCardDrawn,
+ ImageCardQuota,
+ ImageCardScene,
MembershipMe,
+ MoodEntry,
Profile,
+ RelationInsightResult,
+ ScaleDetail,
+ ScaleSubmitResult,
+ ScaleSummary,
+ SolarTermToday,
+ SynastryInviteCreated,
+ SynastryInviteView,
+ SynastryNearbyItem,
+ UpdateProfileBody,
} from '@yuxingu/types'
/** Platform adapters so H5 and mini-program share one client. */
@@ -29,7 +48,7 @@ export interface CreateClientOptions {
}
/**
- * createClient builds a typed API facade.
+ * createClient builds a typed API facade aligned with proto/openapi.yaml paths.
*/
export function createClient(opts: CreateClientOptions) {
const { baseURL, adapters } = opts
@@ -60,27 +79,10 @@ export function createClient(opts: CreateClientOptions) {
healthz: () => call<{ status: string }>('/api/v1/healthz'),
ping: () => call<{ pong: boolean }>('/api/v1/ping'),
listProfiles: () => call<{ items: Profile[] }>('/api/v1/profiles'),
- createProfile: (body: {
- relation: 'self' | 'other'
- birth_date: string
- display_name?: string
- relation_type?: string
- birth_time?: string
- birth_place?: string
- }) => call
('/api/v1/profiles', { method: 'POST', body }),
- updateProfile: (
- id: string,
- body: {
- display_name?: string
- birth_date?: string
- relation_type?: string
- birth_time?: string
- birth_place?: string
- geo_lat?: number
- geo_lng?: number
- geo_visible?: boolean
- },
- ) => call(`/api/v1/profiles/${id}`, { method: 'PATCH', body }),
+ createProfile: (body: CreateProfileBody) =>
+ call('/api/v1/profiles', { method: 'POST', body }),
+ updateProfile: (id: string, body: UpdateProfileBody) =>
+ call(`/api/v1/profiles/${id}`, { method: 'PATCH', body }),
deleteProfile: (id: string) =>
call<{ deleted: boolean }>(`/api/v1/profiles/${id}`, { method: 'DELETE' }),
createPortrait: (profile_id: string) =>
@@ -93,136 +95,57 @@ export function createClient(opts: CreateClientOptions) {
body: { profile_id_a, profile_id_b, ...(as_of ? { as_of } : {}) },
}),
listSynastryNearby: (lat: number, lng: number, radius_km = 50) =>
- call<{ items: { profile: Profile; distance_km: number }[] }>(
+ call<{ items: SynastryNearbyItem[] }>(
`/api/v1/synastry/nearby?lat=${lat}&lng=${lng}&radius_km=${radius_km}`,
),
createSynastryInvite: (profile_id: string) =>
- call<{ token: string; expires_at: string; path: string }>('/api/v1/synastry/invites', {
+ call('/api/v1/synastry/invites', {
method: 'POST',
body: { profile_id },
}),
getSynastryInvite: (token: string) =>
- call<{
- token: string
- expires_at: string
- host_name: string
- already_accepted: boolean
- }>(`/api/v1/synastry/invites/${token}`),
- acceptSynastryInvite: (token: string, body: {
- display_name?: string
- birth_date: string
- birth_time?: string
- birth_place?: string
- }) =>
+ call(`/api/v1/synastry/invites/${token}`),
+ acceptSynastryInvite: (token: string, body: AcceptSynastryInviteBody) =>
call(`/api/v1/synastry/invites/${token}/accept`, { method: 'POST', body }),
createRhythm: (profile_id: string) =>
call('/api/v1/reports/rhythm', { method: 'POST', body: { profile_id } }),
- listImageCardScenes: () =>
- call<{ items: { key: string; label: string }[] }>('/api/v1/image-cards/scenes'),
- getImageCardQuota: () =>
- call<{ remaining: number; daily_free: number; unlimited: boolean }>('/api/v1/image-cards/quota'),
+ listImageCardScenes: () => call<{ items: ImageCardScene[] }>('/api/v1/image-cards/scenes'),
+ getImageCardQuota: () => call('/api/v1/image-cards/quota'),
drawImageCard: (body: { profile_id: string; scene?: string; depth?: boolean }) =>
- call<{
- report: GrowthReport
- scene: string
- cards: { id: string; title: string; imagery: string; prompt: string; tip: string }[]
- quota_left: number
- }>('/api/v1/image-cards/draw', { method: 'POST', body }),
- getSolarTermsToday: () =>
- call<{ name: string; tip: string; date: string }>('/api/v1/solar-terms/today'),
+ call('/api/v1/image-cards/draw', { method: 'POST', body }),
+ getSolarTermsToday: () => call('/api/v1/solar-terms/today'),
saveMood: (body: { score?: number; note?: string; day?: string }) =>
- call<{ id: string; day: string; score?: number; note?: string }>('/api/v1/moods', {
- method: 'POST',
- body,
- }),
- getMoodToday: () =>
- call<{ mood: { id: string; day: string; score?: number; note?: string } | null }>(
- '/api/v1/moods/today',
- ),
- getMoodsRecent: () =>
- call<{ items: { id: string; day: string; score?: number; note?: string }[] }>(
- '/api/v1/moods/recent',
- ),
- getExploreCatalog: () =>
- call<{
- categories: {
- key: string
- title: string
- description: string
- icon: string
- tone: string
- items: {
- key: string
- title: string
- description: string
- path: string
- icon: string
- tone: string
- badge?: string
- }[]
- }[]
- }>('/api/v1/explore/catalog'),
- getExploreCategory: (key: string) =>
- call<{
- key: string
- title: string
- description: string
- icon: string
- tone: string
- items: {
- key: string
- title: string
- description: string
- path: string
- icon: string
- tone: string
- badge?: string
- }[]
- }>(`/api/v1/explore/catalog/${key}`),
- listGrowthPlans: () =>
- call<{ items: { id: string; title: string; focus: string; status: string }[] }>(
- '/api/v1/growth/plans',
- ),
+ call('/api/v1/moods', { method: 'POST', body }),
+ getMoodToday: () => call<{ mood: MoodEntry | null }>('/api/v1/moods/today'),
+ getMoodsRecent: () => call<{ items: MoodEntry[] }>('/api/v1/moods/recent'),
+ getExploreCatalog: () => call<{ categories: ExploreCategory[] }>('/api/v1/explore/catalog'),
+ getExploreCategory: (key: string) => call(`/api/v1/explore/catalog/${key}`),
+ listGrowthPlans: () => call<{ items: GrowthPlan[] }>('/api/v1/growth/plans'),
createGrowthPlan: (body: { title: string; focus?: string }) =>
- call<{ id: string; title: string; focus: string }>('/api/v1/growth/plans', {
- method: 'POST',
- body,
- }),
+ call('/api/v1/growth/plans', { method: 'POST', body }),
growthCheckin: (planId: string, body?: { note?: string }) =>
call<{ id: string; day: string }>(`/api/v1/growth/plans/${planId}/checkin`, {
method: 'POST',
body: body || {},
}),
listGrowthCheckins: (planId: string) =>
- call<{ items: { id: string; day: string; note?: string }[] }>(
- `/api/v1/growth/plans/${planId}/checkins`,
- ),
+ call<{ items: GrowthCheckin[] }>(`/api/v1/growth/plans/${planId}/checkins`),
listReports: () => call<{ items: GrowthReport[] }>('/api/v1/reports'),
getReport: (id: string) => call(`/api/v1/reports/${id}`),
getMembership: () => call('/api/v1/membership/me'),
- createOrder: (body: { kind: 'membership' | 'deep_access'; plan?: string; report_id?: string }) =>
+ createOrder: (body: CreateOrderBody) =>
call<{ order_id: string }>('/api/v1/orders', { method: 'POST', body }),
payMock: (orderId: string) =>
call<{ paid: boolean }>(`/api/v1/orders/${orderId}/pay-mock`, { method: 'POST' }),
createRelationInsight: (profile_a_id: string, profile_b_id: string) =>
- call<{
- insight: { id: string; report_id?: string }
- report: GrowthReport
- }>('/api/v1/relation/insight', {
+ call('/api/v1/relation/insight', {
method: 'POST',
body: { profile_a_id, profile_b_id },
}),
- listScales: () =>
- call<{ items: { slug: string; title: string; description: string }[] }>('/api/v1/scales'),
- getScale: (slug: string) =>
- call<{
- slug: string
- title: string
- description: string
- questions: { id: string; sort: number; body: { prompt: string; options: { key: string; text: string }[] } }[]
- }>(`/api/v1/scales/${slug}`),
+ listScales: () => call<{ items: ScaleSummary[] }>('/api/v1/scales'),
+ getScale: (slug: string) => call(`/api/v1/scales/${slug}`),
submitScale: (slug: string, profile_id: string, answers: Record) =>
- call<{ id: string; result: Record }>(`/api/v1/scales/${slug}/result`, {
+ call(`/api/v1/scales/${slug}/result`, {
method: 'POST',
body: { profile_id, answers },
}),
diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts
index 33e63dc..2636163 100644
--- a/packages/types/src/index.ts
+++ b/packages/types/src/index.ts
@@ -21,6 +21,26 @@ export interface Profile {
created_at: string
}
+export interface CreateProfileBody {
+ relation: 'self' | 'other'
+ birth_date: string
+ display_name?: string
+ relation_type?: string
+ birth_time?: string
+ birth_place?: string
+}
+
+export interface UpdateProfileBody {
+ display_name?: string
+ birth_date?: string
+ relation_type?: string
+ birth_time?: string
+ birth_place?: string
+ geo_lat?: number
+ geo_lng?: number
+ geo_visible?: boolean
+}
+
/** 成长报告(detail 可能因权益被剥离) */
export interface GrowthReport {
id: string
@@ -33,13 +53,34 @@ export interface GrowthReport {
created_at: string
}
-/** 探索测试列表项 */
+/** 探索测试列表项 — 对齐 Go ScaleListItem / listScales 响应 */
export interface ScaleSummary {
slug: string
- name: string
- category: string
- questionCount: number
- description?: string
+ title: string
+ description: string
+}
+
+export interface ScaleQuestionOption {
+ key: string
+ text: string
+}
+
+export interface ScaleQuestion {
+ id: string
+ sort: number
+ body: { prompt: string; options: ScaleQuestionOption[] }
+}
+
+export interface ScaleDetail {
+ slug: string
+ title: string
+ description: string
+ questions: ScaleQuestion[]
+}
+
+export interface ScaleSubmitResult {
+ id: string
+ result: Record
}
/** 成长会员当前权益 */
@@ -51,6 +92,12 @@ export interface MembershipMe {
ask_quota_left?: number
}
+export interface CreateOrderBody {
+ kind: 'membership' | 'deep_access'
+ plan?: string
+ report_id?: string
+}
+
/** 问答线程 */
export interface AskThread {
id: string
@@ -76,3 +123,96 @@ export interface AskQuota {
free_limit: number
source: 'membership' | 'free' | string
}
+
+export interface SynastryNearbyItem {
+ profile: Profile
+ distance_km: number
+}
+
+export interface SynastryInviteCreated {
+ token: string
+ expires_at: string
+ path: string
+}
+
+export interface SynastryInviteView {
+ token: string
+ expires_at: string
+ host_name: string
+ already_accepted: boolean
+}
+
+export interface AcceptSynastryInviteBody {
+ display_name?: string
+ birth_date: string
+ birth_time?: string
+ birth_place?: string
+}
+
+export interface ImageCardScene {
+ key: string
+ label: string
+}
+
+export interface ImageCardQuota {
+ remaining: number
+ daily_free: number
+ unlimited: boolean
+}
+
+export interface ImageCardDrawn {
+ report: GrowthReport
+ scene: string
+ cards: { id: string; title: string; imagery: string; prompt: string; tip: string }[]
+ quota_left: number
+}
+
+export interface SolarTermToday {
+ name: string
+ tip: string
+ date: string
+}
+
+export interface MoodEntry {
+ id: string
+ day: string
+ score?: number
+ note?: string
+}
+
+export interface ExploreItem {
+ key: string
+ title: string
+ description: string
+ path: string
+ icon: string
+ tone: string
+ badge?: string
+}
+
+export interface ExploreCategory {
+ key: string
+ title: string
+ description: string
+ icon: string
+ tone: string
+ items: ExploreItem[]
+}
+
+export interface GrowthPlan {
+ id: string
+ title: string
+ focus: string
+ status?: string
+}
+
+export interface GrowthCheckin {
+ id: string
+ day: string
+ note?: string
+}
+
+export interface RelationInsightResult {
+ insight: { id: string; report_id?: string }
+ report: GrowthReport
+}