diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 28bd7a2..65ad0a4 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -40,19 +40,19 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- - name: ESS artifact presence (ECR-004)
+ - name: ESS artifact presence (active ECR)
run: |
- test -f docs/ECR/ECR-004-hygiene-followup.md
- test -f docs/TASKS/TASK-20260805-ECR004.yaml
- test -f docs/TEST_REPORT/ECR-004.md
- test -f docs/STATE/ECR-004.md
+ test -f docs/ECR/ECR-005-h5-openapi-hygiene.md
+ test -f docs/TASKS/TASK-20260805-ECR005.yaml
+ test -f docs/TEST_REPORT/ECR-005.md
+ test -f docs/STATE/ECR-005.md
echo "ESS docs gate OK"
- name: ess-validate when ESS_ROOT available
env:
ESS_ROOT: ${{ vars.ESS_ROOT }}
run: |
if [ -n "$ESS_ROOT" ] && [ -f "$ESS_ROOT/scripts/ess-validate.py" ]; then
- python scripts/ess-validate.py --phase coding --ecr ECR-004 --task docs/TASKS/TASK-20260805-ECR004.yaml
+ python scripts/ess-validate.py --phase coding --ecr ECR-005 --task docs/TASKS/TASK-20260805-ECR005.yaml
else
echo "ESS_ROOT not configured in CI — skipped full ess-validate"
fi
diff --git a/apps/user-h5/src/components/DecodePanel.vue b/apps/user-h5/src/components/DecodePanel.vue
index 4ea15b4..c5f001f 100644
--- a/apps/user-h5/src/components/DecodePanel.vue
+++ b/apps/user-h5/src/components/DecodePanel.vue
@@ -3,111 +3,52 @@
{{ personTitle ? `${personTitle} · ` : '' }}{{ oneLiner }}
-
-
- 数字性格密码
- {{ mainNumber }}
-
- {{ mainNumber }}号人 · {{ personTitle || '愈心解码' }}
-
- {{ oneLiner }}
-
-
-
-
-
- 天赋优势:{{ numberDeep.talent }}
- 人生课题:{{ numberDeep.topic }}
- 适合方向:{{ numberDeep.direction }}
- 能量解读:{{ numberDeep.desc }}
-
- {{ lockTeaserNumber }}
-
- 缺失数字
- · 缺{{ m.n }}:{{ m.msg }}
-
-
- 联合码分组
-
-
{{ g.label }}
-
{{ g.desc }}
-
- {{ c.code }} · {{ c.label }}
-
-
-
-
-
-
-
-
- 五行体质画像
- {{ wuxing.primary || '—' }}
- 核心养护:{{ wuxing.care_dir || careDir }}
-
- 情绪画像:{{ emotionText }}
-
-
- 体质特征:{{ constitutionDeep.body }}
- 调养要点:{{ constitutionDeep.tips }}
- 易感倾向:{{ constitutionDeep.risk }}
- 季节风险:{{ constitutionDeep.season_risk }}
- 食疗方向:{{ constitutionDeep.food }}
- 作息节律:{{ constitutionDeep.rhythm }}
-
- {{ lockTeaserWuxing }}
-
- {{ constitutionDisclaimer }}
-
-
-
-
- 今日养生锦囊
-
- 当前节气:{{ todayTip.solar_term || '—' }}
- · 时令 {{ todayTip.season_el }} 当令
-
- 今日调养建议
- 宜:{{ todayTip.yi || '—' }}
- 忌:{{ todayTip.ji || '—' }}
-
-
-
-
- 测测契合度 →
-
-
-
+
+
+
+
diff --git a/apps/user-h5/src/components/ask/AskAiPane.vue b/apps/user-h5/src/components/ask/AskAiPane.vue
index 50b382e..5bcff24 100644
--- a/apps/user-h5/src/components/ask/AskAiPane.vue
+++ b/apps/user-h5/src/components/ask/AskAiPane.vue
@@ -5,105 +5,45 @@
{{ bootError }}
-
-
-
-
还没有个人档案。完成愈心解码后再来提问,回答会更贴合你。
-
去愈心解码
-
-
+
-
-
-
-
-
Hi,我是愈心 AI
-
最近有什么事,都可以和我聊聊
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
{{ sendError }}
开通成长会员
-
-
-
- 剩余 {{ quota.remaining }} 次
- (免费 {{ quota.free_limit }})
-
-
-
部分内容由 AI 生成,仅供参考
-
+
diff --git a/apps/user-h5/src/components/ask/AskComposer.vue b/apps/user-h5/src/components/ask/AskComposer.vue
new file mode 100644
index 0000000..689aa23
--- /dev/null
+++ b/apps/user-h5/src/components/ask/AskComposer.vue
@@ -0,0 +1,95 @@
+
+
+
+ 剩余 {{ quota.remaining }} 次
+ (免费 {{ quota.free_limit }})
+
+
+
部分内容由 AI 生成,仅供参考
+
+
+
+
+
+
diff --git a/apps/user-h5/src/components/ask/AskEmpty.vue b/apps/user-h5/src/components/ask/AskEmpty.vue
new file mode 100644
index 0000000..5102463
--- /dev/null
+++ b/apps/user-h5/src/components/ask/AskEmpty.vue
@@ -0,0 +1,41 @@
+
+
+
+
还没有个人档案。完成愈心解码后再来提问,回答会更贴合你。
+
去愈心解码
+
+
+
+
+
+
diff --git a/apps/user-h5/src/components/ask/AskGreet.vue b/apps/user-h5/src/components/ask/AskGreet.vue
new file mode 100644
index 0000000..5cd694e
--- /dev/null
+++ b/apps/user-h5/src/components/ask/AskGreet.vue
@@ -0,0 +1,97 @@
+
+
+
+
+
+
Hi,我是愈心 AI
+
最近有什么事,都可以和我聊聊
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/user-h5/src/components/ask/AskMessageList.vue b/apps/user-h5/src/components/ask/AskMessageList.vue
new file mode 100644
index 0000000..6254d6e
--- /dev/null
+++ b/apps/user-h5/src/components/ask/AskMessageList.vue
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
diff --git a/apps/user-h5/src/components/ask/AskProfileBar.vue b/apps/user-h5/src/components/ask/AskProfileBar.vue
new file mode 100644
index 0000000..a9e1364
--- /dev/null
+++ b/apps/user-h5/src/components/ask/AskProfileBar.vue
@@ -0,0 +1,87 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/user-h5/src/components/decode/DecodeConstitutionCard.vue b/apps/user-h5/src/components/decode/DecodeConstitutionCard.vue
new file mode 100644
index 0000000..e65bf7f
--- /dev/null
+++ b/apps/user-h5/src/components/decode/DecodeConstitutionCard.vue
@@ -0,0 +1,101 @@
+
+
+ 五行体质画像
+ {{ primary || '—' }}
+ 核心养护:{{ careDir }}
+
+ 情绪画像:{{ emotionText }}
+
+
+ 体质特征:{{ constitutionDeep.body }}
+ 调养要点:{{ constitutionDeep.tips }}
+ 易感倾向:{{ constitutionDeep.risk }}
+ 季节风险:{{ constitutionDeep.season_risk }}
+ 食疗方向:{{ constitutionDeep.food }}
+ 作息节律:{{ constitutionDeep.rhythm }}
+
+ {{ lockTeaser }}
+
+ {{ disclaimer }}
+
+
+
+
+
+
diff --git a/apps/user-h5/src/components/decode/DecodeDeepExtra.vue b/apps/user-h5/src/components/decode/DecodeDeepExtra.vue
new file mode 100644
index 0000000..e61e982
--- /dev/null
+++ b/apps/user-h5/src/components/decode/DecodeDeepExtra.vue
@@ -0,0 +1,80 @@
+
+
+
+
+ 测测契合度 →
+
+
+
+
+
+
+
+
+
diff --git a/apps/user-h5/src/components/decode/DecodeNumberCard.vue b/apps/user-h5/src/components/decode/DecodeNumberCard.vue
new file mode 100644
index 0000000..8f235ea
--- /dev/null
+++ b/apps/user-h5/src/components/decode/DecodeNumberCard.vue
@@ -0,0 +1,132 @@
+
+
+ 数字性格密码
+ {{ mainNumber }}
+
+ {{ mainNumber }}号人 · {{ personTitle || '愈心解码' }}
+
+ {{ oneLiner }}
+
+
+
+
+
+ 天赋优势:{{ numberDeep.talent }}
+ 人生课题:{{ numberDeep.topic }}
+ 适合方向:{{ numberDeep.direction }}
+ 能量解读:{{ numberDeep.desc }}
+
+ {{ lockTeaser }}
+
+ 缺失数字
+ · 缺{{ m.n }}:{{ m.msg }}
+
+
+ 联合码分组
+
+
{{ g.label }}
+
{{ g.desc }}
+
+ {{ c.code }} · {{ c.label }}
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/user-h5/src/components/decode/DecodeSolarTip.vue b/apps/user-h5/src/components/decode/DecodeSolarTip.vue
new file mode 100644
index 0000000..a005728
--- /dev/null
+++ b/apps/user-h5/src/components/decode/DecodeSolarTip.vue
@@ -0,0 +1,58 @@
+
+
+ 今日养生锦囊
+
+ 当前节气:{{ tip.solar_term || '—' }}
+ · 时令 {{ tip.season_el }} 当令
+
+ 今日调养建议
+ 宜:{{ tip.yi || '—' }}
+ 忌:{{ tip.ji || '—' }}
+
+
+
+
+
+
diff --git a/apps/user-h5/src/composables/profilePageActions.ts b/apps/user-h5/src/composables/profilePageActions.ts
new file mode 100644
index 0000000..d5271ea
--- /dev/null
+++ b/apps/user-h5/src/composables/profilePageActions.ts
@@ -0,0 +1,263 @@
+import type { Ref } from 'vue'
+import type { Router } 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 || ''] || '关系对象'
+}
+
+export 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)) : '' }
+}
+
+export 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 type ProfilePageActionRefs = {
+ items: Ref
+ loading: Ref
+ error: Ref
+ busyId: Ref
+ editing: Ref
+ showAddForm: Ref
+ showInviteFill: Ref
+ formName: Ref
+ formY: Ref
+ formM: Ref
+ formD: Ref
+ formPlace: Ref
+ formRelationType: Ref
+ formGeoVisible: Ref
+ formError: Ref
+ saving: Ref
+ newName: Ref
+ newY: Ref
+ newM: Ref
+ newD: Ref
+ newPlace: Ref
+ newRelationType: Ref
+ addError: Ref
+ adding: Ref
+ inviting: Ref
+ invitePath: Ref
+ inviteError: Ref
+ inviteHint: Ref
+ copyBusy: Ref
+ selfProfile: { value: Profile | undefined }
+ routeAdd: () => string | undefined
+ routeInviteFill: () => string | undefined
+ router: Router
+}
+
+export function createProfilePageActions(r: ProfilePageActionRefs) {
+ function toggleAdd() {
+ r.showAddForm.value = !r.showAddForm.value
+ if (r.showAddForm.value) {
+ r.editing.value = null
+ r.showInviteFill.value = false
+ }
+ }
+
+ function openAddForm() {
+ r.showAddForm.value = true
+ r.showInviteFill.value = false
+ r.editing.value = null
+ }
+
+ function openInviteFill() {
+ r.showInviteFill.value = true
+ r.showAddForm.value = false
+ r.editing.value = null
+ r.inviteError.value = ''
+ if (!r.selfProfile.value) {
+ r.inviteHint.value = '请先完成自己的档案,再邀请好友填写。'
+ } else {
+ r.inviteHint.value = ''
+ }
+ }
+
+ function closeInviteFill() {
+ r.showInviteFill.value = false
+ void r.router.replace({ path: '/profile', query: {} })
+ }
+
+ function applyQueryFlags() {
+ if (r.routeAdd() === '1') openAddForm()
+ if (r.routeInviteFill() === '1') openInviteFill()
+ }
+
+ async function createFillInvite() {
+ if (!r.selfProfile.value) {
+ r.inviteError.value = '请先完成自己的档案'
+ return
+ }
+ r.inviting.value = true
+ r.inviteError.value = ''
+ r.inviteHint.value = ''
+ try {
+ const res = await api.createSynastryInvite(r.selfProfile.value.id)
+ r.invitePath.value = `${location.origin}${res.path}`
+ r.inviteHint.value = '好友打开链接后填写生日即可;也可用于合盘。'
+ } catch (e) {
+ r.inviteError.value = e instanceof Error ? e.message : '生成失败'
+ } finally {
+ r.inviting.value = false
+ }
+ }
+
+ async function copyInvite() {
+ if (!r.invitePath.value) return
+ r.copyBusy.value = true
+ const ok = await copyText(r.invitePath.value)
+ r.inviteHint.value = ok ? '链接已复制,发给家人或朋友即可。' : '复制失败,请长按链接手动复制。'
+ r.copyBusy.value = false
+ }
+
+ async function load() {
+ r.loading.value = true
+ r.error.value = ''
+ try {
+ const res = await api.listProfiles()
+ r.items.value = res.items || []
+ } catch (e) {
+ r.error.value = e instanceof Error ? e.message : '加载失败'
+ } finally {
+ r.loading.value = false
+ }
+ }
+
+ function startEdit(p: Profile) {
+ r.editing.value = p
+ r.showAddForm.value = false
+ r.formName.value = p.display_name || ''
+ const b = splitBirth(p.birth_date)
+ r.formY.value = b.y
+ r.formM.value = b.m
+ r.formD.value = b.d
+ r.formPlace.value = p.birth_place || ''
+ r.formRelationType.value = p.relation_type || 'partner'
+ r.formGeoVisible.value = !!p.geo_visible
+ r.formError.value = ''
+ }
+
+ async function saveEdit() {
+ if (!r.editing.value) return
+ r.saving.value = true
+ r.formError.value = ''
+ try {
+ const birth = joinBirth(r.formY.value, r.formM.value, r.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: r.formName.value.trim(),
+ birth_date: birth,
+ birth_place: r.formPlace.value || '',
+ }
+ if (r.editing.value.relation === 'other') body.relation_type = r.formRelationType.value
+ if (r.editing.value.relation === 'self') body.geo_visible = r.formGeoVisible.value
+ await api.updateProfile(r.editing.value.id, body)
+ r.editing.value = null
+ await load()
+ } catch (e) {
+ r.formError.value = e instanceof Error ? e.message : '保存失败'
+ } finally {
+ r.saving.value = false
+ }
+ }
+
+ async function remove(p: Profile) {
+ if (!confirm(`确定删除「${p.display_name || '档案'}」?`)) return
+ r.busyId.value = p.id
+ r.error.value = ''
+ try {
+ await api.deleteProfile(p.id)
+ if (r.editing.value?.id === p.id) r.editing.value = null
+ await load()
+ } catch (e) {
+ r.error.value = e instanceof Error ? e.message : '删除失败'
+ } finally {
+ r.busyId.value = ''
+ }
+ }
+
+ async function addOther() {
+ r.adding.value = true
+ r.addError.value = ''
+ try {
+ const birth = joinBirth(r.newY.value, r.newM.value, r.newD.value)
+ if (!birth) throw new Error('请填写生日')
+ await api.createProfile({
+ relation: 'other',
+ birth_date: birth,
+ display_name: r.newName.value.trim() || 'TA',
+ relation_type: r.newRelationType.value,
+ birth_place: r.newPlace.value || undefined,
+ })
+ r.newName.value = ''
+ r.newY.value = ''
+ r.newM.value = ''
+ r.newD.value = ''
+ r.newPlace.value = ''
+ r.showAddForm.value = false
+ await load()
+ if (r.routeAdd() === '1') void r.router.replace({ path: '/profile', query: {} })
+ } catch (e) {
+ r.addError.value = e instanceof Error ? e.message : '添加失败'
+ } finally {
+ r.adding.value = false
+ }
+ }
+
+ return {
+ toggleAdd,
+ openAddForm,
+ openInviteFill,
+ closeInviteFill,
+ applyQueryFlags,
+ createFillInvite,
+ copyInvite,
+ load,
+ startEdit,
+ saveEdit,
+ remove,
+ addOther,
+ }
+}
diff --git a/apps/user-h5/src/composables/useDecodePanel.ts b/apps/user-h5/src/composables/useDecodePanel.ts
new file mode 100644
index 0000000..230ea2e
--- /dev/null
+++ b/apps/user-h5/src/composables/useDecodePanel.ts
@@ -0,0 +1,128 @@
+import { computed, type Ref } from 'vue'
+import type { WuxingBar } from '../components/WuxingBars.vue'
+
+export function useDecodePanel(
+ summary: Ref> | (() => Record),
+ detail: Ref | null> | (() => Record | null),
+) {
+ const getSummary = typeof summary === 'function' ? summary : () => summary.value
+ const getDetail = typeof detail === 'function' ? detail : () => detail.value
+
+ const mainNumber = computed(() => {
+ const s = getSummary()
+ const n = Number(s.main_number)
+ if (Number.isFinite(n) && n > 0) return n
+ const pk = Number(s.pattern_key)
+ if (Number.isFinite(pk) && pk > 0) return pk
+ return '—'
+ })
+ const personTitle = computed(() => {
+ const s = getSummary()
+ return String(s.person_title || s.style_label || s.style_badge || '')
+ })
+ const oneLiner = computed(() => {
+ const s = getSummary()
+ return String(s.one_liner || s.headline || s.overview || '')
+ })
+ const pyramid = computed(() => (getSummary().pyramid || {}) as Record)
+ const hasPyramid = computed(() => Object.keys(pyramid.value).length > 0)
+ const hasDecodeShape = computed(() => {
+ const s = getSummary()
+ return s.main_number != null || s.pyramid != null || s.wuxing != null
+ })
+
+ const wuxing = computed(() => (getSummary().wuxing || {}) as Record)
+ const bars = computed(() => {
+ const raw = wuxing.value.bars
+ if (!Array.isArray(raw)) return []
+ return raw.map((b) => {
+ const o = b as Record
+ return { el: String(o.el || ''), pct: Number(o.pct || 0), color: String(o.color || '#999') }
+ })
+ })
+ const careDir = computed(() => {
+ const s = wuxing.value.strong
+ const w = wuxing.value.weak
+ if (s && w) return `养${s} · 需呵护${w}`
+ return '—'
+ })
+ const emotionText = computed(() => {
+ const e = wuxing.value.emotion
+ return Array.isArray(e) ? e.join(' · ') : ''
+ })
+
+ const todayTip = computed(() => (getSummary().today_tip || {}) as Record)
+ const constitutionDisclaimer = computed(
+ () =>
+ String(getSummary().disclaimer_constitution || '') ||
+ '体质与调养内容为生活方式参考,非医疗建议。',
+ )
+ const lockTeaserNumber = computed(
+ () =>
+ String(getSummary().lock_teaser_number || '') ||
+ '天赋优势与人生课题、适合方向与能量长文,解锁后查看。',
+ )
+ const lockTeaserWuxing = computed(
+ () =>
+ String(getSummary().lock_teaser_wuxing || '') ||
+ '体质特征、调养要点、易感倾向、食疗与作息节律,解锁后查看。',
+ )
+
+ const numberDeep = computed(() => {
+ const d = (getDetail()?.number_deep || {}) as Record
+ return {
+ talent: String(d.talent || ''),
+ topic: String(d.topic || ''),
+ direction: String(d.direction || ''),
+ desc: String(d.desc || ''),
+ elem: String(d.elem || ''),
+ }
+ })
+ const constitutionDeep = computed(() => {
+ const d = (getDetail()?.constitution_deep || {}) as Record
+ return {
+ body: String(d.body || ''),
+ tips: String(d.tips || ''),
+ risk: String(d.risk || ''),
+ season_risk: String(d.season_risk || ''),
+ food: String(d.food || ''),
+ rhythm: String(d.rhythm || ''),
+ }
+ })
+ const missing = computed(() => {
+ const d = (getDetail()?.number_deep || {}) as Record
+ const arr = d.missing
+ if (!Array.isArray(arr)) return [] as { n: number; msg: string }[]
+ return arr.map((x) => {
+ const o = x as Record
+ return { n: Number(o.n), msg: String(o.msg || '') }
+ })
+ })
+ const joints = computed(() => {
+ const d = (getDetail()?.number_deep || {}) as Record
+ const arr = d.joints
+ if (!Array.isArray(arr)) return [] as { label: string; desc: string; codes: { code: string; label: string }[] }[]
+ return arr as { label: string; desc: string; codes: { code: string; label: string }[] }[]
+ })
+
+ return {
+ mainNumber,
+ personTitle,
+ oneLiner,
+ pyramid,
+ hasPyramid,
+ hasDecodeShape,
+ wuxing,
+ bars,
+ careDir,
+ emotionText,
+ todayTip,
+ constitutionDisclaimer,
+ lockTeaserNumber,
+ lockTeaserWuxing,
+ numberDeep,
+ constitutionDeep,
+ missing,
+ joints,
+ }
+}
diff --git a/apps/user-h5/src/composables/usePortraitPage.ts b/apps/user-h5/src/composables/usePortraitPage.ts
new file mode 100644
index 0000000..233537c
--- /dev/null
+++ b/apps/user-h5/src/composables/usePortraitPage.ts
@@ -0,0 +1,146 @@
+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 { AnalyticsEvent, track } from '../lib/analytics'
+import type { PortraitSharePayload } from '../lib/shareLink'
+
+export function usePortraitPage() {
+ const route = useRoute()
+ const loading = ref(false)
+ const needBirth = ref(true)
+ const error = ref('')
+ const formError = 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 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 sharePayload = computed(() => {
+ if (!report.value) return null
+ return {
+ type: 'portrait',
+ title: headline.value || String(summary.value.person_title || '愈心解码'),
+ line: oneLiner.value,
+ keywords: keywords.value,
+ }
+ })
+
+ async function generate(y: number, m: number, d: number) {
+ loading.value = true
+ error.value = ''
+ formError.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.createPortrait(profile.id)
+ track(AnalyticsEvent.PortraitCompleted, { source: 'form' })
+ } 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) {
+ formError.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
+ error.value = ''
+ try {
+ report.value = await api.getReport(reportId)
+ track(AnalyticsEvent.PortraitCompleted, { source: 'report_id' })
+ } 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)
+ return
+ }
+ needBirth.value = true
+ loading.value = false
+ }
+
+ function retry() {
+ if (report.value) {
+ load()
+ return
+ }
+ needBirth.value = true
+ error.value = ''
+ }
+
+ async function buyDeep() {
+ if (!report.value) return
+ paying.value = true
+ error.value = ''
+ track(AnalyticsEvent.DeepAccessClicked, { surface: 'portrait' })
+ 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,
+ formError,
+ report,
+ paying,
+ shareOpen,
+ year,
+ month,
+ day,
+ summary,
+ detail,
+ sharePayload,
+ start,
+ retry,
+ buyDeep,
+ }
+}
diff --git a/apps/user-h5/src/composables/useProfilePage.ts b/apps/user-h5/src/composables/useProfilePage.ts
index 3e82b49..3fe3127 100644
--- a/apps/user-h5/src/composables/useProfilePage.ts
+++ b/apps/user-h5/src/composables/useProfilePage.ts
@@ -1,50 +1,15 @@
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'
+import {
+ avatarInitial,
+ createProfilePageActions,
+ formatProfileDate,
+ profileRelationOptions,
+ relationLabel,
+} from './profilePageActions'
-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 { profileRelationOptions, formatProfileDate, avatarInitial, relationLabel }
export function useProfilePage() {
const route = useRoute()
@@ -83,168 +48,53 @@ export function useProfilePage() {
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
- }
- }
+ const {
+ toggleAdd,
+ openInviteFill,
+ closeInviteFill,
+ applyQueryFlags,
+ createFillInvite,
+ copyInvite,
+ load,
+ startEdit,
+ saveEdit,
+ remove,
+ addOther,
+ } = createProfilePageActions({
+ 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,
+ routeAdd: () => route.query.add as string | undefined,
+ routeInviteFill: () => route.query.inviteFill as string | undefined,
+ router,
+ })
watch(
() => [route.query.add, route.query.inviteFill],
diff --git a/apps/user-h5/src/composables/useReportPage.ts b/apps/user-h5/src/composables/useReportPage.ts
index 1285c12..eb0d85b 100644
--- a/apps/user-h5/src/composables/useReportPage.ts
+++ b/apps/user-h5/src/composables/useReportPage.ts
@@ -3,34 +3,33 @@ 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: '行星' },
-]
+import {
+ ascLonFromSummary,
+ buildReportSharePayload,
+ formatCreatedAt,
+ fortuneBundleFrom,
+ mapAspectPreview,
+ mapTransits,
+ numOrNull,
+ planetsFrom,
+ relatedLinkForType,
+ reportFortuneKeys,
+ reportStarPanels,
+ reportSynTabs,
+ reportTypeLabel,
+ signCardsFrom,
+ summaryKeywords,
+ synActiveBlockFrom,
+ synChartPair,
+ synChartsFrom,
+ synOverlayFrom,
+ wheelPlanetsFrom,
+} from '../lib/reportSummary'
+import type { SharePayload } from '../lib/shareLink'
+import { asPlanets, ascLonFromChart } from '../lib/synastryChart'
-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 { reportStarPanels, reportSynTabs, reportFortuneKeys }
export function useReportPage() {
const route = useRoute()
@@ -47,186 +46,46 @@ export function useReportPage() {
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 keywords = computed(() => summaryKeywords(summary.value))
+ const typeLabel = computed(() => reportTypeLabel(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(() => relatedLinkForType(report.value?.type))
- 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 ascLon = computed(() => ascLonFromSummary(summary.value))
+ const wheelPlanets = computed(() => wheelPlanetsFrom(summary.value))
+ const aspectPreview = computed(() => mapAspectPreview(summary.value.aspects_preview))
+ const transits = computed(() => mapTransits(summary.value))
- 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 o = summary.value.outlook
- if (o && typeof o === 'object' && Array.isArray((o as { transits?: unknown }).transits)) {
- return (o 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 synCharts = computed(() => synChartsFrom(summary.value))
+ const synActiveBlock = computed(() => synActiveBlockFrom(synCharts.value, panelKey.value))
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 }[]) : []
- })
+ const synActivePlanets = computed(() => asPlanets(synActiveBlock.value?.planets))
+ const synActiveAsc = computed(() => ascLonFromChart(synActiveBlock.value))
+ const synOverlayEntries = computed(() => synOverlayFrom(synCharts.value))
+ const synPlanetsA = computed(() => synChartPair(summary.value, 'chart_a').planets)
+ const synPlanetsB = computed(() => synChartPair(summary.value, 'chart_b').planets)
+ const synAscA = computed(() => synChartPair(summary.value, 'chart_a').asc)
+ const synAscB = computed(() => synChartPair(summary.value, 'chart_b').asc)
- 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 createdLabel = computed(() => formatCreatedAt(report.value?.created_at))
+ const signCards = computed(() => signCardsFrom(summary.value))
const activeCard = computed(() => signCards.value.find((c) => c.key === signTab.value) || signCards.value[0])
- const fortuneBundle = computed(() => {
- const o = summary.value.outlook
- return o && typeof o === 'object' ? (o as Record) : {}
- })
+ const fortuneBundle = computed(() => fortuneBundleFrom(summary.value))
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 planets = computed(() => planetsFrom(summary.value))
+ const loveIndex = computed(() => numOrNull(summary.value.love_index))
+ const friendIndex = computed(() => numOrNull(summary.value.friend_index))
+ const marriageIndex = computed(() => numOrNull(summary.value.marriage_index))
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
- })
+ const sharePayload = computed(() =>
+ buildReportSharePayload(report.value, summary.value, headline.value, oneLiner.value, keywords.value),
+ )
async function load() {
loading.value = true
diff --git a/apps/user-h5/src/composables/useStarProfilePage.ts b/apps/user-h5/src/composables/useStarProfilePage.ts
index 43d6b5c..6d41936 100644
--- a/apps/user-h5/src/composables/useStarProfilePage.ts
+++ b/apps/user-h5/src/composables/useStarProfilePage.ts
@@ -4,38 +4,29 @@ 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 {
+ ascLonFromSummary,
+ fortuneBundleFrom,
+ signCardsFrom,
+} from '../lib/reportSummary'
import type { PortraitSharePayload } from '../lib/shareLink'
+import {
+ aspectLinesFrom,
+ aspectsFrom,
+ buildGridCells,
+ chartObjFrom,
+ fortuneLabel,
+ housesFromChart,
+ loadWheelSettings,
+ planetsAsRows,
+ saveWheelSettings,
+ starFortuneKeys,
+ starViewTabs,
+ toWheelPlanets,
+} from '../lib/starSummary'
-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
- boost?: 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 { starViewTabs, starFortuneKeys, fortuneLabel }
export function useStarProfilePage() {
const route = useRoute()
@@ -52,106 +43,41 @@ export function useStarProfilePage() {
const birthPlace = ref('')
const view = ref<'overview' | 'chart' | 'fortune' | 'planets'>('overview')
const signTab = ref('sun')
- const fortuneKey = ref<'daily' | 'weekly' | 'monthly' | 'yearly' | 'lifetime'>('daily')
- const showOuter = ref(true)
- const tightOrb = ref(false)
+ const fortuneKey = ref<(typeof starFortuneKeys)[number]>('daily')
+ const settings = loadWheelSettings()
+ const showOuter = ref(settings.showOuter)
+ const tightOrb = ref(settings.tightOrb)
- 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 }))
+ saveWheelSettings(showOuter.value, 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 keywords = computed(() =>
+ Array.isArray(summary.value.keywords) ? (summary.value.keywords as string[]) : [],
)
+ const chartObj = computed(() => chartObjFrom(summary.value))
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 ascLon = computed(() => ascLonFromSummary(summary.value))
+ const houses = computed(() => housesFromChart(chartObj.value))
+ const signCards = computed(() => signCardsFrom(summary.value))
const activeCard = computed(() => signCards.value.find((c) => c.key === signTab.value) || signCards.value[0])
- const fortuneBundle = computed(() => {
- const o = summary.value.outlook
- return o && typeof o === 'object' ? (o as Record) : {}
- })
+ const fortuneBundle = computed(() => fortuneBundleFrom(summary.value))
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 planets = computed(() => planetsAsRows(summary.value))
+ const wheelPlanets = computed(() => toWheelPlanets(planets.value))
+ const aspectPreview = computed(() => aspectsFrom(summary.value.aspects_preview))
const fullAspects = computed(() => {
const raw = detail.value?.aspects
- return Array.isArray(raw) ? (raw as AspectRow[]) : aspectPreview.value
+ return Array.isArray(raw) ? aspectsFrom(raw) : 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 aspectLines = computed(() =>
+ aspectLinesFrom(report.value?.has_deep_access, fullAspects.value, aspectPreview.value),
+ )
+ const gridCells = computed(() => buildGridCells(signCards.value, planets.value))
const sharePayload = computed(() => {
if (!report.value) return null
return { type: 'portrait', title: headline.value || '我的星座', line: oneLiner.value, keywords: keywords.value }
diff --git a/apps/user-h5/src/lib/reportSummary.ts b/apps/user-h5/src/lib/reportSummary.ts
new file mode 100644
index 0000000..8b27c5b
--- /dev/null
+++ b/apps/user-h5/src/lib/reportSummary.ts
@@ -0,0 +1,181 @@
+import type { GrowthReport } from '@yuxingu/types'
+import type { WheelAspect, WheelPlanet } from '../components/NatalWheel.vue'
+import type { SignCard } from '../components/SignCards.vue'
+import type { PortraitSharePayload, RelationSharePayload, SharePayload } from './shareLink'
+import { asPlanets, ascLonFromChart, chartBlock, overlayEntriesFrom } from './synastryChart'
+
+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
+
+export type FortunePeriod = {
+ title?: string
+ label?: string
+ score?: number
+ tip?: string
+ focus?: string
+ boost?: string
+ caution?: string
+ dims?: { love?: number; career?: number; money?: number; mood?: number }
+}
+
+export type PlanetRow = { key: string; title: string; sign: string; degree: string; house: number }
+
+export type TransitRow = { key: string; title: string; aspect: string; tip: string }
+
+export function reportTypeLabel(type?: string) {
+ const map: Record = {
+ portrait: '愈心解码',
+ relation: '人格匹配',
+ synastry: '合盘',
+ star: '星座',
+ rhythm: '身心节律',
+ image_card: '意象卡片',
+ }
+ return map[type || ''] || '成长报告'
+}
+
+export function relatedLinkForType(t?: string) {
+ 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: '全部' }
+}
+
+export function summaryKeywords(summary: Record): string[] {
+ if (Array.isArray(summary.keywords)) return summary.keywords as string[]
+ const a = Array.isArray(summary.me_keywords) ? (summary.me_keywords as string[]) : []
+ const b = Array.isArray(summary.other_keywords) ? (summary.other_keywords as string[]) : []
+ return [...a, ...b]
+}
+
+export function chartObjFrom(summary: Record): Record {
+ return summary.chart && typeof summary.chart === 'object'
+ ? (summary.chart as Record)
+ : {}
+}
+
+export function mapAspectPreview(raw: unknown): WheelAspect[] {
+ 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) }
+ })
+}
+
+export function mapTransits(summary: Record): TransitRow[] {
+ const raw = summary.transits
+ if (Array.isArray(raw)) return raw as TransitRow[]
+ const o = summary.outlook
+ if (o && typeof o === 'object' && Array.isArray((o as { transits?: unknown }).transits)) {
+ return (o as { transits: TransitRow[] }).transits
+ }
+ return []
+}
+
+export function synChartsFrom(summary: Record): Record {
+ return summary.charts && typeof summary.charts === 'object'
+ ? (summary.charts as Record)
+ : {}
+}
+
+export function synActiveBlockFrom(
+ charts: Record,
+ panelKey: string,
+): Record | null {
+ return chartBlock(charts, panelKey)
+}
+
+export function synChartPair(
+ summary: Record,
+ key: 'chart_a' | 'chart_b',
+): { planets: WheelPlanet[]; asc: number | null } {
+ const c = summary[key]
+ if (!c || typeof c !== 'object') return { planets: [], asc: null }
+ const o = c as { planets?: unknown; asc_lon?: number }
+ return { planets: asPlanets(o.planets), asc: typeof o.asc_lon === 'number' ? o.asc_lon : null }
+}
+
+export function synOverlayFrom(charts: Record) {
+ return overlayEntriesFrom(charts).map((e) => ({
+ planet: e.planet,
+ house: e.house,
+ house_tip: e.house_tip,
+ }))
+}
+
+export function formatCreatedAt(t?: string) {
+ if (!t) return ''
+ try {
+ return new Date(t).toLocaleDateString('zh-CN')
+ } catch {
+ return t
+ }
+}
+
+export function signCardsFrom(summary: Record): SignCard[] {
+ const raw = summary.sign_cards
+ return Array.isArray(raw) ? (raw as SignCard[]) : []
+}
+
+export function fortuneBundleFrom(summary: Record): Record {
+ const o = summary.outlook
+ return o && typeof o === 'object' ? (o as Record) : {}
+}
+
+export function planetsFrom(summary: Record): PlanetRow[] {
+ const raw = summary.planets
+ return Array.isArray(raw) ? (raw as PlanetRow[]) : []
+}
+
+export function wheelPlanetsFrom(summary: Record): WheelPlanet[] {
+ return asPlanets(summary.planets)
+}
+
+export function ascLonFromSummary(summary: Record): number | null {
+ return ascLonFromChart(chartObjFrom(summary))
+}
+
+export function numOrNull(v: unknown): number | null {
+ return typeof v === 'number' ? v : null
+}
+
+export function buildReportSharePayload(
+ report: GrowthReport | null,
+ summary: Record,
+ headline: string,
+ oneLiner: string,
+ keywords: string[],
+): SharePayload | null {
+ if (!report) return null
+ if (report.type === 'relation') {
+ return {
+ type: 'relation',
+ me: String(summary.me_style || ''),
+ other: String(summary.other_style || ''),
+ diff: String(summary.diff_one_liner || ''),
+ keywords,
+ } as RelationSharePayload
+ }
+ return {
+ type: 'portrait',
+ title: headline,
+ line: oneLiner,
+ keywords,
+ } as PortraitSharePayload
+}
diff --git a/apps/user-h5/src/lib/starSummary.ts b/apps/user-h5/src/lib/starSummary.ts
new file mode 100644
index 0000000..dc2c6f3
--- /dev/null
+++ b/apps/user-h5/src/lib/starSummary.ts
@@ -0,0 +1,119 @@
+import type { WheelAspect, WheelPlanet } from '../components/NatalWheel.vue'
+import type { SignCard } from '../components/SignCards.vue'
+import type { FortunePeriod } from './reportSummary'
+import { chartObjFrom, fortuneBundleFrom, signCardsFrom } from './reportSummary'
+
+export type { FortunePeriod }
+export { chartObjFrom, fortuneBundleFrom, signCardsFrom }
+
+export 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
+
+export type PlanetRow = WheelPlanet & { element?: string; modality?: string }
+export type AspectRow = WheelAspect & { a_title?: string; b_title?: string; label?: string }
+export type HouseRow = { num: number; sign: string }
+
+export type GridCell = { key: string; nick: string; role: string; center?: boolean }
+
+export function fortuneLabel(k: string) {
+ return (
+ ({ daily: '今日', weekly: '本周', monthly: '本月', yearly: '今年', lifetime: '一生' } as Record<
+ string,
+ string
+ >)[k] || k
+ )
+}
+
+export function housesFromChart(chart: Record): HouseRow[] {
+ return Array.isArray(chart.houses) ? (chart.houses as HouseRow[]) : []
+}
+
+export function planetsAsRows(summary: Record): PlanetRow[] {
+ const raw = summary.planets
+ return Array.isArray(raw) ? (raw as PlanetRow[]) : []
+}
+
+export function toWheelPlanets(planets: PlanetRow[]): WheelPlanet[] {
+ return planets.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,
+ }))
+}
+
+export function aspectsFrom(raw: unknown): AspectRow[] {
+ return Array.isArray(raw) ? (raw as AspectRow[]) : []
+}
+
+export function aspectLinesFrom(
+ hasDeep: boolean | undefined,
+ full: AspectRow[],
+ preview: AspectRow[],
+): WheelAspect[] {
+ const src = hasDeep ? full : preview
+ return src.map((a) => ({ a: a.a, b: a.b, type: a.type, orb: a.orb, label: a.label }))
+}
+
+export function buildGridCells(cards: SignCard[], planets: PlanetRow[]): GridCell[] {
+ const slots: GridCell[] = []
+ 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.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
+}
+
+export function loadWheelSettings(): { showOuter: boolean; tightOrb: boolean } {
+ try {
+ const raw = localStorage.getItem(SETTINGS_KEY)
+ if (raw) {
+ const o = JSON.parse(raw) as { showOuter?: boolean; tightOrb?: boolean }
+ return {
+ showOuter: typeof o.showOuter === 'boolean' ? o.showOuter : true,
+ tightOrb: typeof o.tightOrb === 'boolean' ? o.tightOrb : false,
+ }
+ }
+ } catch {
+ /* ignore */
+ }
+ return { showOuter: true, tightOrb: false }
+}
+
+export function saveWheelSettings(showOuter: boolean, tightOrb: boolean) {
+ localStorage.setItem(SETTINGS_KEY, JSON.stringify({ showOuter, tightOrb }))
+}
diff --git a/apps/user-h5/src/pages/PortraitPage.vue b/apps/user-h5/src/pages/PortraitPage.vue
index 0b8e542..6e68dc2 100644
--- a/apps/user-h5/src/pages/PortraitPage.vue
+++ b/apps/user-h5/src/pages/PortraitPage.vue
@@ -56,7 +56,6 @@
自我探索与生活方式参考,不是占卜或算命。体质内容非医疗建议。
-
去问答
去做匹配
@@ -68,135 +67,32 @@