refactor(ECR-005): 再拆 Ask/Decode/Portrait 与 composable,补强 OpenAPI
压低贴线 SFC/composable;报告/档案/量表/意象卡 path 挂 Envelope $ref;CI ESS 门禁跟到 ECR-005。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -3,111 +3,52 @@
|
||||
<p v-if="!hasDecodeShape && oneLiner" class="legacy-note">
|
||||
{{ personTitle ? `${personTitle} · ` : '' }}{{ oneLiner }}
|
||||
</p>
|
||||
<!-- 数字性格 -->
|
||||
<section class="card">
|
||||
<span class="tag tag-num">数字性格密码</span>
|
||||
<div class="hex">{{ mainNumber }}</div>
|
||||
<div class="p-title">
|
||||
<template v-if="mainNumber !== '—'">{{ mainNumber }}号人 · </template>{{ personTitle || '愈心解码' }}
|
||||
</div>
|
||||
<div v-if="oneLiner" class="p-oneline">{{ oneLiner }}</div>
|
||||
<div v-if="hasPyramid" class="pyr-wrap">
|
||||
<NumberPyramid :pyramid="pyramid" />
|
||||
</div>
|
||||
<PayLock
|
||||
:unlocked="deep"
|
||||
:paying="paying"
|
||||
title="解锁你的完整密码"
|
||||
desc="天赋优势 · 人生课题 · 适合方向 · 联合码深度解读"
|
||||
@unlock="$emit('unlock')"
|
||||
>
|
||||
<template v-if="deep && numberDeep.talent">
|
||||
<p><b>天赋优势:</b>{{ numberDeep.talent }}</p>
|
||||
<p><b>人生课题:</b>{{ numberDeep.topic }}</p>
|
||||
<p><b>适合方向:</b>{{ numberDeep.direction }}</p>
|
||||
<p><b>能量解读:</b>{{ numberDeep.desc }}</p>
|
||||
</template>
|
||||
<p v-else>{{ lockTeaserNumber }}</p>
|
||||
<template v-if="deep && missing.length">
|
||||
<p class="sub-h">缺失数字</p>
|
||||
<p v-for="m in missing" :key="'m' + m.n">· 缺{{ m.n }}:{{ m.msg }}</p>
|
||||
</template>
|
||||
<template v-if="deep && joints.length">
|
||||
<p class="sub-h">联合码分组</p>
|
||||
<div v-for="(g, i) in joints" :key="'j' + i" class="joint">
|
||||
<strong>{{ g.label }}</strong>
|
||||
<p class="muted">{{ g.desc }}</p>
|
||||
<p v-for="(c, j) in g.codes || []" :key="'c' + j" class="muted">
|
||||
{{ c.code }} · {{ c.label }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</PayLock>
|
||||
</section>
|
||||
|
||||
<!-- 五行体质 -->
|
||||
<section class="card">
|
||||
<span class="tag tag-ys">五行体质画像</span>
|
||||
<div class="ys-type">{{ wuxing.primary || '—' }}</div>
|
||||
<div class="ys-dir">核心养护:{{ wuxing.care_dir || careDir }}</div>
|
||||
<WuxingBars v-if="bars.length" :bars="bars" />
|
||||
<div v-if="emotionText" class="p-oneline">情绪画像:{{ emotionText }}</div>
|
||||
<PayLock
|
||||
:unlocked="deep"
|
||||
:paying="paying"
|
||||
title="解锁五行体质深度分析"
|
||||
desc="脏腑画像 · 易感倾向 · 食疗 · 运动作息方案"
|
||||
@unlock="$emit('unlock')"
|
||||
>
|
||||
<template v-if="deep && constitutionDeep.body">
|
||||
<p><b>体质特征:</b>{{ constitutionDeep.body }}</p>
|
||||
<p><b>调养要点:</b>{{ constitutionDeep.tips }}</p>
|
||||
<p><b>易感倾向:</b>{{ constitutionDeep.risk }}</p>
|
||||
<p><b>季节风险:</b>{{ constitutionDeep.season_risk }}</p>
|
||||
<p><b>食疗方向:</b>{{ constitutionDeep.food }}</p>
|
||||
<p><b>作息节律:</b>{{ constitutionDeep.rhythm }}</p>
|
||||
</template>
|
||||
<p v-else>{{ lockTeaserWuxing }}</p>
|
||||
</PayLock>
|
||||
<p class="disc-mini">{{ constitutionDisclaimer }}</p>
|
||||
</section>
|
||||
|
||||
<!-- 今日锦囊(始终免费) -->
|
||||
<section class="card jn">
|
||||
<span class="tag tag-ys">今日养生锦囊</span>
|
||||
<div class="st">
|
||||
当前节气:{{ todayTip.solar_term || '—' }}
|
||||
<template v-if="todayTip.season_el"> · 时令 {{ todayTip.season_el }} 当令</template>
|
||||
</div>
|
||||
<h4>今日调养建议</h4>
|
||||
<div class="row"><b>宜:</b>{{ todayTip.yi || '—' }}</div>
|
||||
<div class="row"><b>忌:</b>{{ todayTip.ji || '—' }}</div>
|
||||
</section>
|
||||
|
||||
<div class="deep-links">
|
||||
<button type="button" class="d-num" @click="showDeepExtra = !showDeepExtra">
|
||||
{{ showDeepExtra ? '收起数字深解' : '完整数字报告' }} →
|
||||
</button>
|
||||
<router-link class="d-ys" to="/relation">测测契合度 →</router-link>
|
||||
</div>
|
||||
<div v-if="showDeepExtra && deep" class="card extra">
|
||||
<p class="muted">主数 {{ mainNumber }} · {{ personTitle }} · 五行 {{ numberDeep.elem }}</p>
|
||||
<p>{{ numberDeep.desc }}</p>
|
||||
</div>
|
||||
<div v-else-if="showDeepExtra && !deep" class="card extra">
|
||||
<p class="muted">解锁深度版后可展开完整数字能量与联合码解读。</p>
|
||||
<button type="button" class="yxg-btn" :disabled="paying" @click="$emit('unlock')">
|
||||
解锁完整分析(模拟支付)
|
||||
</button>
|
||||
</div>
|
||||
<DecodeNumberCard
|
||||
:main-number="mainNumber"
|
||||
:person-title="personTitle"
|
||||
:one-liner="oneLiner"
|
||||
:has-pyramid="hasPyramid"
|
||||
:pyramid="pyramid"
|
||||
:deep="deep"
|
||||
:paying="paying"
|
||||
:lock-teaser="lockTeaserNumber"
|
||||
:number-deep="numberDeep"
|
||||
:missing="missing"
|
||||
:joints="joints"
|
||||
@unlock="$emit('unlock')"
|
||||
/>
|
||||
<DecodeConstitutionCard
|
||||
:primary="wuxing.primary"
|
||||
:care-dir="wuxing.care_dir ? String(wuxing.care_dir) : careDir"
|
||||
:bars="bars"
|
||||
:emotion-text="emotionText"
|
||||
:deep="deep"
|
||||
:paying="paying"
|
||||
:lock-teaser="lockTeaserWuxing"
|
||||
:disclaimer="constitutionDisclaimer"
|
||||
:constitution-deep="constitutionDeep"
|
||||
@unlock="$emit('unlock')"
|
||||
/>
|
||||
<DecodeSolarTip :tip="todayTip" />
|
||||
<DecodeDeepExtra
|
||||
:deep="deep"
|
||||
:paying="paying"
|
||||
:main-number="mainNumber"
|
||||
:person-title="personTitle"
|
||||
:elem="numberDeep.elem"
|
||||
:desc="numberDeep.desc"
|
||||
@unlock="$emit('unlock')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import NumberPyramid from './NumberPyramid.vue'
|
||||
import PayLock from './PayLock.vue'
|
||||
import WuxingBars, { type WuxingBar } from './WuxingBars.vue'
|
||||
import { toRef } from 'vue'
|
||||
import DecodeConstitutionCard from './decode/DecodeConstitutionCard.vue'
|
||||
import DecodeDeepExtra from './decode/DecodeDeepExtra.vue'
|
||||
import DecodeNumberCard from './decode/DecodeNumberCard.vue'
|
||||
import DecodeSolarTip from './decode/DecodeSolarTip.vue'
|
||||
import { useDecodePanel } from '../composables/useDecodePanel'
|
||||
|
||||
const props = defineProps<{
|
||||
summary: Record<string, unknown>
|
||||
@@ -118,100 +59,26 @@ const props = defineProps<{
|
||||
|
||||
defineEmits<{ unlock: [] }>()
|
||||
|
||||
const showDeepExtra = ref(false)
|
||||
|
||||
const mainNumber = computed(() => {
|
||||
const n = Number(props.summary.main_number)
|
||||
if (Number.isFinite(n) && n > 0) return n
|
||||
const pk = Number(props.summary.pattern_key)
|
||||
if (Number.isFinite(pk) && pk > 0) return pk
|
||||
return '—'
|
||||
})
|
||||
const personTitle = computed(() =>
|
||||
String(props.summary.person_title || props.summary.style_label || props.summary.style_badge || ''),
|
||||
)
|
||||
const oneLiner = computed(() =>
|
||||
String(props.summary.one_liner || props.summary.headline || props.summary.overview || ''),
|
||||
)
|
||||
const pyramid = computed(() => (props.summary.pyramid || {}) as Record<string, number>)
|
||||
const hasPyramid = computed(() => Object.keys(pyramid.value).length > 0)
|
||||
const hasDecodeShape = computed(
|
||||
() => props.summary.main_number != null || props.summary.pyramid != null || props.summary.wuxing != null,
|
||||
)
|
||||
|
||||
const wuxing = computed(() => (props.summary.wuxing || {}) as Record<string, unknown>)
|
||||
const bars = computed<WuxingBar[]>(() => {
|
||||
const raw = wuxing.value.bars
|
||||
if (!Array.isArray(raw)) return []
|
||||
return raw.map((b) => {
|
||||
const o = b as Record<string, unknown>
|
||||
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(() => (props.summary.today_tip || {}) as Record<string, string>)
|
||||
const constitutionDisclaimer = computed(
|
||||
() =>
|
||||
String(props.summary.disclaimer_constitution || '') ||
|
||||
'体质与调养内容为生活方式参考,非医疗建议。',
|
||||
)
|
||||
const lockTeaserNumber = computed(
|
||||
() =>
|
||||
String(props.summary.lock_teaser_number || '') ||
|
||||
'天赋优势与人生课题、适合方向与能量长文,解锁后查看。',
|
||||
)
|
||||
const lockTeaserWuxing = computed(
|
||||
() =>
|
||||
String(props.summary.lock_teaser_wuxing || '') ||
|
||||
'体质特征、调养要点、易感倾向、食疗与作息节律,解锁后查看。',
|
||||
)
|
||||
|
||||
const numberDeep = computed(() => {
|
||||
const d = (props.detail?.number_deep || {}) as Record<string, unknown>
|
||||
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 = (props.detail?.constitution_deep || {}) as Record<string, unknown>
|
||||
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 = (props.detail?.number_deep || {}) as Record<string, unknown>
|
||||
const arr = d.missing
|
||||
if (!Array.isArray(arr)) return [] as { n: number; msg: string }[]
|
||||
return arr.map((x) => {
|
||||
const o = x as Record<string, unknown>
|
||||
return { n: Number(o.n), msg: String(o.msg || '') }
|
||||
})
|
||||
})
|
||||
const joints = computed(() => {
|
||||
const d = (props.detail?.number_deep || {}) as Record<string, unknown>
|
||||
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 }[] }[]
|
||||
})
|
||||
const {
|
||||
mainNumber,
|
||||
personTitle,
|
||||
oneLiner,
|
||||
pyramid,
|
||||
hasPyramid,
|
||||
hasDecodeShape,
|
||||
wuxing,
|
||||
bars,
|
||||
careDir,
|
||||
emotionText,
|
||||
todayTip,
|
||||
constitutionDisclaimer,
|
||||
lockTeaserNumber,
|
||||
lockTeaserWuxing,
|
||||
numberDeep,
|
||||
constitutionDeep,
|
||||
missing,
|
||||
joints,
|
||||
} = useDecodePanel(toRef(props, 'summary'), toRef(props, 'detail'))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -232,133 +99,4 @@ const joints = computed(() => {
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
padding: 20px;
|
||||
margin: 14px 0;
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.tag {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.tag-num {
|
||||
background: var(--num);
|
||||
}
|
||||
.tag-ys {
|
||||
background: var(--ys);
|
||||
}
|
||||
.hex {
|
||||
width: 76px;
|
||||
height: 76px;
|
||||
margin: 6px auto 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 34px;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, var(--num), var(--num-d));
|
||||
clip-path: polygon(50% 0, 100% 25%, 100% 75%, 50% 100%, 0 75%, 0 25%);
|
||||
}
|
||||
.p-title {
|
||||
text-align: center;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--num-d);
|
||||
}
|
||||
.p-oneline {
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
margin-top: 4px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.pyr-wrap {
|
||||
margin: 16px 0 4px;
|
||||
}
|
||||
.ys-type {
|
||||
text-align: center;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--ys-d);
|
||||
margin: 4px 0;
|
||||
}
|
||||
.ys-dir {
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
}
|
||||
.disc-mini {
|
||||
font-size: 11px;
|
||||
color: #aaa;
|
||||
text-align: center;
|
||||
margin-top: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.jn h4 {
|
||||
font-size: 14px;
|
||||
color: var(--ys-d);
|
||||
margin: 8px 0;
|
||||
}
|
||||
.jn .row {
|
||||
font-size: 13px;
|
||||
line-height: 2;
|
||||
color: #555;
|
||||
}
|
||||
.jn .row b {
|
||||
color: var(--ys-d);
|
||||
}
|
||||
.jn .st {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.deep-links {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
.deep-links a,
|
||||
.deep-links button {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 13px 6px;
|
||||
border-radius: 14px;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.d-num {
|
||||
background: linear-gradient(135deg, var(--num), var(--num-d));
|
||||
}
|
||||
.d-ys {
|
||||
background: linear-gradient(135deg, var(--ys), var(--ys-d));
|
||||
}
|
||||
.sub-h {
|
||||
margin-top: 10px;
|
||||
font-weight: 700;
|
||||
color: #555;
|
||||
}
|
||||
.joint {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.muted {
|
||||
color: #888;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.extra .yxg-btn {
|
||||
margin-top: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,105 +5,45 @@
|
||||
{{ bootError }}
|
||||
<button type="button" class="retry" @click="$emit('boot')">重试</button>
|
||||
</p>
|
||||
<template v-else-if="!profiles.length">
|
||||
<div class="card empty">
|
||||
<HomeToolIcon name="ask" :size="56" />
|
||||
<p>还没有个人档案。完成愈心解码后再来提问,回答会更贴合你。</p>
|
||||
<router-link class="cta" to="/portrait">去愈心解码</router-link>
|
||||
</div>
|
||||
</template>
|
||||
<AskEmpty v-else-if="!profiles.length" />
|
||||
<template v-else>
|
||||
<div v-if="!messages.length" class="card greet reveal">
|
||||
<div class="greet-row">
|
||||
<HomeToolIcon name="ask" :size="48" />
|
||||
<div>
|
||||
<p class="hi">Hi,我是愈心 AI</p>
|
||||
<p class="hi-sub">最近有什么事,都可以和我聊聊</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="prompts">
|
||||
<button
|
||||
v-for="s in guidePrompts"
|
||||
:key="s.key"
|
||||
type="button"
|
||||
class="prompt"
|
||||
@click="$emit('pick-scene', s)"
|
||||
>
|
||||
{{ s.label }}
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" class="advisor-link" @click="$emit('switch-advisor')">
|
||||
也可以看看成长顾问 →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card controls">
|
||||
<label class="lbl">档案</label>
|
||||
<select class="sel" :value="profileId" @change="$emit('update:profileId', ($event.target as HTMLSelectElement).value); $emit('profile-change')">
|
||||
<option v-for="p in profiles" :key="p.id" :value="p.id">
|
||||
{{ p.relation === 'self' ? '我的档案' : `TA · ${p.display_name || '未命名'}` }}
|
||||
</option>
|
||||
</select>
|
||||
<div class="quick">
|
||||
<button
|
||||
v-for="q in quickTools"
|
||||
:key="q.to"
|
||||
type="button"
|
||||
class="q-btn"
|
||||
@click="$emit('navigate', q.to)"
|
||||
>
|
||||
{{ q.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="thread" ref="threadRef">
|
||||
<div v-for="m in messages" :key="m.id" :class="['bubble', m.role]">
|
||||
<p>{{ m.content }}</p>
|
||||
</div>
|
||||
<p v-if="sending" class="hint pad">助手正在回复…</p>
|
||||
</div>
|
||||
|
||||
<AskGreet
|
||||
v-if="!messages.length"
|
||||
@pick-scene="$emit('pick-scene', $event)"
|
||||
@switch-advisor="$emit('switch-advisor')"
|
||||
/>
|
||||
<AskProfileBar
|
||||
:profiles="profiles"
|
||||
:profile-id="profileId"
|
||||
@update:profile-id="$emit('update:profileId', $event)"
|
||||
@profile-change="$emit('profile-change')"
|
||||
@navigate="$emit('navigate', $event)"
|
||||
/>
|
||||
<AskMessageList :messages="messages" :sending="sending" />
|
||||
<p v-if="sendError" class="err pad">
|
||||
{{ sendError }}
|
||||
<router-link v-if="quotaExhausted" class="retry" to="/membership">开通成长会员</router-link>
|
||||
</p>
|
||||
|
||||
<div class="composer-wrap">
|
||||
<p v-if="quota" class="quota">
|
||||
剩余 {{ quota.remaining }} 次
|
||||
<span v-if="!quota.active_membership">(免费 {{ quota.free_limit }})</span>
|
||||
</p>
|
||||
<form class="composer" @submit.prevent="$emit('send')">
|
||||
<textarea
|
||||
class="ta"
|
||||
:value="draft"
|
||||
rows="2"
|
||||
placeholder="让我来解答你的问题吧"
|
||||
:disabled="sending || (quota !== null && quota.remaining <= 0)"
|
||||
@input="$emit('update:draft', ($event.target as HTMLTextAreaElement).value)"
|
||||
/>
|
||||
<button
|
||||
class="send"
|
||||
type="submit"
|
||||
:disabled="sending || !draft.trim() || (quota !== null && quota.remaining <= 0)"
|
||||
>
|
||||
发送
|
||||
</button>
|
||||
</form>
|
||||
<p class="ai-note">部分内容由 AI 生成,仅供参考</p>
|
||||
</div>
|
||||
<AskComposer
|
||||
:draft="draft"
|
||||
:sending="sending"
|
||||
:quota="quota"
|
||||
@update:draft="$emit('update:draft', $event)"
|
||||
@send="$emit('send')"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
import type { AskMessage, AskQuota, Profile } from '@yuxingu/types'
|
||||
import HomeToolIcon from '../HomeToolIcon.vue'
|
||||
import { askQuickTools, askScenes } from '../../composables/useAskPage'
|
||||
import AskComposer from './AskComposer.vue'
|
||||
import AskEmpty from './AskEmpty.vue'
|
||||
import AskGreet from './AskGreet.vue'
|
||||
import AskMessageList from './AskMessageList.vue'
|
||||
import AskProfileBar from './AskProfileBar.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
defineProps<{
|
||||
bootLoading: boolean
|
||||
bootError: string
|
||||
profiles: Profile[]
|
||||
@@ -116,16 +56,6 @@ const props = defineProps<{
|
||||
quota: AskQuota | null
|
||||
}>()
|
||||
|
||||
const threadRef = ref<HTMLElement | null>(null)
|
||||
|
||||
watch(
|
||||
() => props.messages.length,
|
||||
async () => {
|
||||
await nextTick()
|
||||
if (threadRef.value) threadRef.value.scrollTop = threadRef.value.scrollHeight
|
||||
},
|
||||
)
|
||||
|
||||
defineEmits<{
|
||||
boot: []
|
||||
'pick-scene': [s: { key: string; prompt: string }]
|
||||
@@ -136,9 +66,6 @@ defineEmits<{
|
||||
'update:draft': [v: string]
|
||||
send: []
|
||||
}>()
|
||||
|
||||
const guidePrompts = askScenes
|
||||
const quickTools = askQuickTools
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -147,183 +74,6 @@ const quickTools = askQuickTools
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
.card {
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 16px;
|
||||
box-shadow: var(--shadow-hero);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.card.empty {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: #666;
|
||||
}
|
||||
.greet-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.hi {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.hi-sub {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.prompts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.prompt {
|
||||
text-align: left;
|
||||
border: 1px solid #f0ebe8;
|
||||
background: #fffaf8;
|
||||
border-radius: 14px;
|
||||
padding: 12px 14px;
|
||||
font-size: 13px;
|
||||
color: #444;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.prompt:active {
|
||||
background: #fff0ec;
|
||||
border-color: #f5c4bc;
|
||||
}
|
||||
.advisor-link {
|
||||
margin-top: 12px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 12px;
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
.controls .lbl {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.sel {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1.5px solid #eee;
|
||||
background: var(--color-input-bg);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
}
|
||||
.quick {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.q-btn {
|
||||
flex-shrink: 0;
|
||||
padding: 7px 12px;
|
||||
border-radius: var(--radius-pill);
|
||||
border: 1px solid #f0ebe8;
|
||||
background: #fff;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
.thread {
|
||||
overflow-y: auto;
|
||||
max-height: 36vh;
|
||||
padding: 4px 0 12px;
|
||||
}
|
||||
.bubble {
|
||||
margin: 8px 0;
|
||||
padding: 12px 14px;
|
||||
border-radius: 16px;
|
||||
font-size: 14px;
|
||||
line-height: 1.65;
|
||||
white-space: pre-wrap;
|
||||
box-shadow: var(--shadow-card);
|
||||
background: #fff;
|
||||
}
|
||||
.bubble.user {
|
||||
margin-left: 28px;
|
||||
color: #333;
|
||||
}
|
||||
.bubble.assistant {
|
||||
margin-right: 28px;
|
||||
background: #fff5f4;
|
||||
color: #444;
|
||||
box-shadow: none;
|
||||
}
|
||||
.composer-wrap {
|
||||
position: sticky;
|
||||
bottom: 72px;
|
||||
padding-top: 8px;
|
||||
background: linear-gradient(180deg, transparent, var(--color-bg-sheet) 24%);
|
||||
}
|
||||
.quota {
|
||||
font-size: 11px;
|
||||
color: #aaa;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.composer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.ta {
|
||||
flex: 1;
|
||||
resize: none;
|
||||
min-height: 44px;
|
||||
padding: 11px 14px;
|
||||
border-radius: 16px;
|
||||
border: 1.5px solid #eee;
|
||||
background: #fff;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
}
|
||||
.ta:focus {
|
||||
border-color: #f0b8b0;
|
||||
}
|
||||
.send {
|
||||
flex-shrink: 0;
|
||||
padding: 11px 16px;
|
||||
border: none;
|
||||
border-radius: 22px;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
|
||||
box-shadow: 0 6px 16px rgba(229, 77, 66, 0.28);
|
||||
}
|
||||
.send:disabled {
|
||||
opacity: 0.45;
|
||||
}
|
||||
.ai-note {
|
||||
margin-top: 8px;
|
||||
font-size: 10px;
|
||||
color: #ccc;
|
||||
text-align: center;
|
||||
}
|
||||
.cta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 12px 20px;
|
||||
border-radius: 22px;
|
||||
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
box-shadow: 0 6px 16px rgba(229, 77, 66, 0.28);
|
||||
}
|
||||
.hint {
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
@@ -344,11 +94,4 @@ const quickTools = askQuickTools
|
||||
font-size: 13px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
.reveal {
|
||||
animation: rise 0.45s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
@keyframes rise {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<div class="composer-wrap">
|
||||
<p v-if="quota" class="quota">
|
||||
剩余 {{ quota.remaining }} 次
|
||||
<span v-if="!quota.active_membership">(免费 {{ quota.free_limit }})</span>
|
||||
</p>
|
||||
<form class="composer" @submit.prevent="$emit('send')">
|
||||
<textarea
|
||||
class="ta"
|
||||
:value="draft"
|
||||
rows="2"
|
||||
placeholder="让我来解答你的问题吧"
|
||||
:disabled="sending || (quota !== null && quota.remaining <= 0)"
|
||||
@input="$emit('update:draft', ($event.target as HTMLTextAreaElement).value)"
|
||||
/>
|
||||
<button
|
||||
class="send"
|
||||
type="submit"
|
||||
:disabled="sending || !draft.trim() || (quota !== null && quota.remaining <= 0)"
|
||||
>
|
||||
发送
|
||||
</button>
|
||||
</form>
|
||||
<p class="ai-note">部分内容由 AI 生成,仅供参考</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AskQuota } from '@yuxingu/types'
|
||||
|
||||
defineProps<{
|
||||
draft: string
|
||||
sending: boolean
|
||||
quota: AskQuota | null
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'update:draft': [v: string]
|
||||
send: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.composer-wrap {
|
||||
position: sticky;
|
||||
bottom: 72px;
|
||||
padding-top: 8px;
|
||||
background: linear-gradient(180deg, transparent, var(--color-bg-sheet) 24%);
|
||||
}
|
||||
.quota {
|
||||
font-size: 11px;
|
||||
color: #aaa;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.composer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.ta {
|
||||
flex: 1;
|
||||
resize: none;
|
||||
min-height: 44px;
|
||||
padding: 11px 14px;
|
||||
border-radius: 16px;
|
||||
border: 1.5px solid #eee;
|
||||
background: #fff;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
}
|
||||
.ta:focus {
|
||||
border-color: #f0b8b0;
|
||||
}
|
||||
.send {
|
||||
flex-shrink: 0;
|
||||
padding: 11px 16px;
|
||||
border: none;
|
||||
border-radius: 22px;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
|
||||
box-shadow: 0 6px 16px rgba(229, 77, 66, 0.28);
|
||||
}
|
||||
.send:disabled {
|
||||
opacity: 0.45;
|
||||
}
|
||||
.ai-note {
|
||||
margin-top: 8px;
|
||||
font-size: 10px;
|
||||
color: #ccc;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<div class="card empty">
|
||||
<HomeToolIcon name="ask" :size="56" />
|
||||
<p>还没有个人档案。完成愈心解码后再来提问,回答会更贴合你。</p>
|
||||
<router-link class="cta" to="/portrait">去愈心解码</router-link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import HomeToolIcon from '../HomeToolIcon.vue'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 16px;
|
||||
box-shadow: var(--shadow-hero);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.card.empty {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: #666;
|
||||
}
|
||||
.cta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 12px 20px;
|
||||
border-radius: 22px;
|
||||
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
box-shadow: 0 6px 16px rgba(229, 77, 66, 0.28);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div class="card greet reveal">
|
||||
<div class="greet-row">
|
||||
<HomeToolIcon name="ask" :size="48" />
|
||||
<div>
|
||||
<p class="hi">Hi,我是愈心 AI</p>
|
||||
<p class="hi-sub">最近有什么事,都可以和我聊聊</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="prompts">
|
||||
<button
|
||||
v-for="s in guidePrompts"
|
||||
:key="s.key"
|
||||
type="button"
|
||||
class="prompt"
|
||||
@click="$emit('pick-scene', s)"
|
||||
>
|
||||
{{ s.label }}
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" class="advisor-link" @click="$emit('switch-advisor')">
|
||||
也可以看看成长顾问 →
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import HomeToolIcon from '../HomeToolIcon.vue'
|
||||
import { askScenes } from '../../composables/useAskPage'
|
||||
|
||||
defineEmits<{
|
||||
'pick-scene': [s: { key: string; prompt: string }]
|
||||
'switch-advisor': []
|
||||
}>()
|
||||
|
||||
const guidePrompts = askScenes
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 16px;
|
||||
box-shadow: var(--shadow-hero);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.greet-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.hi {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.hi-sub {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.prompts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.prompt {
|
||||
text-align: left;
|
||||
border: 1px solid #f0ebe8;
|
||||
background: #fffaf8;
|
||||
border-radius: 14px;
|
||||
padding: 12px 14px;
|
||||
font-size: 13px;
|
||||
color: #444;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.prompt:active {
|
||||
background: #fff0ec;
|
||||
border-color: #f5c4bc;
|
||||
}
|
||||
.advisor-link {
|
||||
margin-top: 12px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 12px;
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
.reveal {
|
||||
animation: rise 0.45s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
@keyframes rise {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<div class="thread" ref="threadRef">
|
||||
<div v-for="m in messages" :key="m.id" :class="['bubble', m.role]">
|
||||
<p>{{ m.content }}</p>
|
||||
</div>
|
||||
<p v-if="sending" class="hint pad">助手正在回复…</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
import type { AskMessage } from '@yuxingu/types'
|
||||
|
||||
const props = defineProps<{
|
||||
messages: AskMessage[]
|
||||
sending: boolean
|
||||
}>()
|
||||
|
||||
const threadRef = ref<HTMLElement | null>(null)
|
||||
|
||||
watch(
|
||||
() => props.messages.length,
|
||||
async () => {
|
||||
await nextTick()
|
||||
if (threadRef.value) threadRef.value.scrollTop = threadRef.value.scrollHeight
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.thread {
|
||||
overflow-y: auto;
|
||||
max-height: 36vh;
|
||||
padding: 4px 0 12px;
|
||||
}
|
||||
.bubble {
|
||||
margin: 8px 0;
|
||||
padding: 12px 14px;
|
||||
border-radius: 16px;
|
||||
font-size: 14px;
|
||||
line-height: 1.65;
|
||||
white-space: pre-wrap;
|
||||
box-shadow: var(--shadow-card);
|
||||
background: #fff;
|
||||
}
|
||||
.bubble.user {
|
||||
margin-left: 28px;
|
||||
color: #333;
|
||||
}
|
||||
.bubble.assistant {
|
||||
margin-right: 28px;
|
||||
background: #fff5f4;
|
||||
color: #444;
|
||||
box-shadow: none;
|
||||
}
|
||||
.hint {
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
}
|
||||
.pad { padding: 0 4px; }
|
||||
</style>
|
||||
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<div class="card controls">
|
||||
<label class="lbl">档案</label>
|
||||
<select
|
||||
class="sel"
|
||||
:value="profileId"
|
||||
@change="
|
||||
$emit('update:profileId', ($event.target as HTMLSelectElement).value);
|
||||
$emit('profile-change')
|
||||
"
|
||||
>
|
||||
<option v-for="p in profiles" :key="p.id" :value="p.id">
|
||||
{{ p.relation === 'self' ? '我的档案' : `TA · ${p.display_name || '未命名'}` }}
|
||||
</option>
|
||||
</select>
|
||||
<div class="quick">
|
||||
<button
|
||||
v-for="q in quickTools"
|
||||
:key="q.to"
|
||||
type="button"
|
||||
class="q-btn"
|
||||
@click="$emit('navigate', q.to)"
|
||||
>
|
||||
{{ q.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Profile } from '@yuxingu/types'
|
||||
import { askQuickTools } from '../../composables/useAskPage'
|
||||
|
||||
defineProps<{
|
||||
profiles: Profile[]
|
||||
profileId: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'update:profileId': [v: string]
|
||||
'profile-change': []
|
||||
navigate: [to: string]
|
||||
}>()
|
||||
|
||||
const quickTools = askQuickTools
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 16px;
|
||||
box-shadow: var(--shadow-hero);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.controls .lbl {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.sel {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1.5px solid #eee;
|
||||
background: var(--color-input-bg);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
}
|
||||
.quick {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.q-btn {
|
||||
flex-shrink: 0;
|
||||
padding: 7px 12px;
|
||||
border-radius: var(--radius-pill);
|
||||
border: 1px solid #f0ebe8;
|
||||
background: #fff;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<section class="card">
|
||||
<span class="tag tag-ys">五行体质画像</span>
|
||||
<div class="ys-type">{{ primary || '—' }}</div>
|
||||
<div class="ys-dir">核心养护:{{ careDir }}</div>
|
||||
<WuxingBars v-if="bars.length" :bars="bars" />
|
||||
<div v-if="emotionText" class="p-oneline">情绪画像:{{ emotionText }}</div>
|
||||
<PayLock
|
||||
:unlocked="deep"
|
||||
:paying="paying"
|
||||
title="解锁五行体质深度分析"
|
||||
desc="脏腑画像 · 易感倾向 · 食疗 · 运动作息方案"
|
||||
@unlock="$emit('unlock')"
|
||||
>
|
||||
<template v-if="deep && constitutionDeep.body">
|
||||
<p><b>体质特征:</b>{{ constitutionDeep.body }}</p>
|
||||
<p><b>调养要点:</b>{{ constitutionDeep.tips }}</p>
|
||||
<p><b>易感倾向:</b>{{ constitutionDeep.risk }}</p>
|
||||
<p><b>季节风险:</b>{{ constitutionDeep.season_risk }}</p>
|
||||
<p><b>食疗方向:</b>{{ constitutionDeep.food }}</p>
|
||||
<p><b>作息节律:</b>{{ constitutionDeep.rhythm }}</p>
|
||||
</template>
|
||||
<p v-else>{{ lockTeaser }}</p>
|
||||
</PayLock>
|
||||
<p class="disc-mini">{{ disclaimer }}</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import PayLock from '../PayLock.vue'
|
||||
import WuxingBars, { type WuxingBar } from '../WuxingBars.vue'
|
||||
|
||||
defineProps<{
|
||||
primary: unknown
|
||||
careDir: string
|
||||
bars: WuxingBar[]
|
||||
emotionText: string
|
||||
deep: boolean
|
||||
paying?: boolean
|
||||
lockTeaser: string
|
||||
disclaimer: string
|
||||
constitutionDeep: {
|
||||
body: string
|
||||
tips: string
|
||||
risk: string
|
||||
season_risk: string
|
||||
food: string
|
||||
rhythm: string
|
||||
}
|
||||
}>()
|
||||
|
||||
defineEmits<{ unlock: [] }>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
padding: 20px;
|
||||
margin: 14px 0;
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.tag {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.tag-ys {
|
||||
background: var(--ys, #5cb85c);
|
||||
}
|
||||
.ys-type {
|
||||
text-align: center;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--ys-d, #3f8f45);
|
||||
margin: 4px 0;
|
||||
}
|
||||
.ys-dir {
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
}
|
||||
.p-oneline {
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
margin-top: 4px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.disc-mini {
|
||||
font-size: 11px;
|
||||
color: #aaa;
|
||||
text-align: center;
|
||||
margin-top: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="deep-links">
|
||||
<button type="button" class="d-num" @click="expanded = !expanded">
|
||||
{{ expanded ? '收起数字深解' : '完整数字报告' }} →
|
||||
</button>
|
||||
<router-link class="d-ys" to="/relation">测测契合度 →</router-link>
|
||||
</div>
|
||||
<div v-if="expanded && deep" class="card extra">
|
||||
<p class="muted">主数 {{ mainNumber }} · {{ personTitle }} · 五行 {{ elem }}</p>
|
||||
<p>{{ desc }}</p>
|
||||
</div>
|
||||
<div v-else-if="expanded && !deep" class="card extra">
|
||||
<p class="muted">解锁深度版后可展开完整数字能量与联合码解读。</p>
|
||||
<button type="button" class="yxg-btn" :disabled="paying" @click="$emit('unlock')">
|
||||
解锁完整分析(模拟支付)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineProps<{
|
||||
deep: boolean
|
||||
paying?: boolean
|
||||
mainNumber: number | string
|
||||
personTitle: string
|
||||
elem: string
|
||||
desc: string
|
||||
}>()
|
||||
|
||||
defineEmits<{ unlock: [] }>()
|
||||
|
||||
const expanded = ref(false)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
padding: 20px;
|
||||
margin: 14px 0;
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.deep-links {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
.deep-links a,
|
||||
.deep-links button {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 13px 6px;
|
||||
border-radius: 14px;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.d-num {
|
||||
background: linear-gradient(135deg, var(--num, #c8923a), var(--num-d, #8a6d3b));
|
||||
}
|
||||
.d-ys {
|
||||
background: linear-gradient(135deg, var(--ys, #5cb85c), var(--ys-d, #3f8f45));
|
||||
}
|
||||
.muted {
|
||||
color: #888;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.extra .yxg-btn {
|
||||
margin-top: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<section class="card">
|
||||
<span class="tag tag-num">数字性格密码</span>
|
||||
<div class="hex">{{ mainNumber }}</div>
|
||||
<div class="p-title">
|
||||
<template v-if="mainNumber !== '—'">{{ mainNumber }}号人 · </template>{{ personTitle || '愈心解码' }}
|
||||
</div>
|
||||
<div v-if="oneLiner" class="p-oneline">{{ oneLiner }}</div>
|
||||
<div v-if="hasPyramid" class="pyr-wrap">
|
||||
<NumberPyramid :pyramid="pyramid" />
|
||||
</div>
|
||||
<PayLock
|
||||
:unlocked="deep"
|
||||
:paying="paying"
|
||||
title="解锁你的完整密码"
|
||||
desc="天赋优势 · 人生课题 · 适合方向 · 联合码深度解读"
|
||||
@unlock="$emit('unlock')"
|
||||
>
|
||||
<template v-if="deep && numberDeep.talent">
|
||||
<p><b>天赋优势:</b>{{ numberDeep.talent }}</p>
|
||||
<p><b>人生课题:</b>{{ numberDeep.topic }}</p>
|
||||
<p><b>适合方向:</b>{{ numberDeep.direction }}</p>
|
||||
<p><b>能量解读:</b>{{ numberDeep.desc }}</p>
|
||||
</template>
|
||||
<p v-else>{{ lockTeaser }}</p>
|
||||
<template v-if="deep && missing.length">
|
||||
<p class="sub-h">缺失数字</p>
|
||||
<p v-for="m in missing" :key="'m' + m.n">· 缺{{ m.n }}:{{ m.msg }}</p>
|
||||
</template>
|
||||
<template v-if="deep && joints.length">
|
||||
<p class="sub-h">联合码分组</p>
|
||||
<div v-for="(g, i) in joints" :key="'j' + i" class="joint">
|
||||
<strong>{{ g.label }}</strong>
|
||||
<p class="muted">{{ g.desc }}</p>
|
||||
<p v-for="(c, j) in g.codes || []" :key="'c' + j" class="muted">
|
||||
{{ c.code }} · {{ c.label }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</PayLock>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import NumberPyramid from '../NumberPyramid.vue'
|
||||
import PayLock from '../PayLock.vue'
|
||||
|
||||
defineProps<{
|
||||
mainNumber: number | string
|
||||
personTitle: string
|
||||
oneLiner: string
|
||||
hasPyramid: boolean
|
||||
pyramid: Record<string, number>
|
||||
deep: boolean
|
||||
paying?: boolean
|
||||
lockTeaser: string
|
||||
numberDeep: {
|
||||
talent: string
|
||||
topic: string
|
||||
direction: string
|
||||
desc: string
|
||||
}
|
||||
missing: { n: number; msg: string }[]
|
||||
joints: { label: string; desc: string; codes: { code: string; label: string }[] }[]
|
||||
}>()
|
||||
|
||||
defineEmits<{ unlock: [] }>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
padding: 20px;
|
||||
margin: 14px 0;
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.tag {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.tag-num {
|
||||
background: var(--num, #c8923a);
|
||||
}
|
||||
.hex {
|
||||
width: 76px;
|
||||
height: 76px;
|
||||
margin: 6px auto 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 34px;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, var(--num, #c8923a), var(--num-d, #8a6d3b));
|
||||
clip-path: polygon(50% 0, 100% 25%, 100% 75%, 50% 100%, 0 75%, 0 25%);
|
||||
}
|
||||
.p-title {
|
||||
text-align: center;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--num-d, #8a6d3b);
|
||||
}
|
||||
.p-oneline {
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
margin-top: 4px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.pyr-wrap {
|
||||
margin: 16px 0 4px;
|
||||
}
|
||||
.sub-h {
|
||||
margin-top: 10px;
|
||||
font-weight: 700;
|
||||
color: #555;
|
||||
}
|
||||
.joint {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.muted {
|
||||
color: #888;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<section class="card jn">
|
||||
<span class="tag tag-ys">今日养生锦囊</span>
|
||||
<div class="st">
|
||||
当前节气:{{ tip.solar_term || '—' }}
|
||||
<template v-if="tip.season_el"> · 时令 {{ tip.season_el }} 当令</template>
|
||||
</div>
|
||||
<h4>今日调养建议</h4>
|
||||
<div class="row"><b>宜:</b>{{ tip.yi || '—' }}</div>
|
||||
<div class="row"><b>忌:</b>{{ tip.ji || '—' }}</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
tip: Record<string, string>
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
padding: 20px;
|
||||
margin: 14px 0;
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.tag {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.tag-ys {
|
||||
background: var(--ys, #5cb85c);
|
||||
}
|
||||
.jn h4 {
|
||||
font-size: 14px;
|
||||
color: var(--ys-d, #3f8f45);
|
||||
margin: 8px 0;
|
||||
}
|
||||
.jn .row {
|
||||
font-size: 13px;
|
||||
line-height: 2;
|
||||
color: #555;
|
||||
}
|
||||
.jn .row b {
|
||||
color: var(--ys-d, #3f8f45);
|
||||
}
|
||||
.jn .st {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -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<string, string> = {
|
||||
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<Profile[]>
|
||||
loading: Ref<boolean>
|
||||
error: Ref<string>
|
||||
busyId: Ref<string>
|
||||
editing: Ref<Profile | null>
|
||||
showAddForm: Ref<boolean>
|
||||
showInviteFill: Ref<boolean>
|
||||
formName: Ref<string>
|
||||
formY: Ref<string>
|
||||
formM: Ref<string>
|
||||
formD: Ref<string>
|
||||
formPlace: Ref<string>
|
||||
formRelationType: Ref<string>
|
||||
formGeoVisible: Ref<boolean>
|
||||
formError: Ref<string>
|
||||
saving: Ref<boolean>
|
||||
newName: Ref<string>
|
||||
newY: Ref<string>
|
||||
newM: Ref<string>
|
||||
newD: Ref<string>
|
||||
newPlace: Ref<string>
|
||||
newRelationType: Ref<string>
|
||||
addError: Ref<string>
|
||||
adding: Ref<boolean>
|
||||
inviting: Ref<boolean>
|
||||
invitePath: Ref<string>
|
||||
inviteError: Ref<string>
|
||||
inviteHint: Ref<string>
|
||||
copyBusy: Ref<boolean>
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { computed, type Ref } from 'vue'
|
||||
import type { WuxingBar } from '../components/WuxingBars.vue'
|
||||
|
||||
export function useDecodePanel(
|
||||
summary: Ref<Record<string, unknown>> | (() => Record<string, unknown>),
|
||||
detail: Ref<Record<string, unknown> | null> | (() => Record<string, unknown> | 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<string, number>)
|
||||
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<string, unknown>)
|
||||
const bars = computed<WuxingBar[]>(() => {
|
||||
const raw = wuxing.value.bars
|
||||
if (!Array.isArray(raw)) return []
|
||||
return raw.map((b) => {
|
||||
const o = b as Record<string, unknown>
|
||||
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<string, string>)
|
||||
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<string, unknown>
|
||||
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<string, unknown>
|
||||
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<string, unknown>
|
||||
const arr = d.missing
|
||||
if (!Array.isArray(arr)) return [] as { n: number; msg: string }[]
|
||||
return arr.map((x) => {
|
||||
const o = x as Record<string, unknown>
|
||||
return { n: Number(o.n), msg: String(o.msg || '') }
|
||||
})
|
||||
})
|
||||
const joints = computed(() => {
|
||||
const d = (getDetail()?.number_deep || {}) as Record<string, unknown>
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -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<GrowthReport | null>(null)
|
||||
const paying = ref(false)
|
||||
const shareOpen = ref(false)
|
||||
const year = ref(String(route.query.y || ''))
|
||||
const month = ref(String(route.query.m || ''))
|
||||
const day = ref(String(route.query.d || ''))
|
||||
|
||||
const summary = computed(() => (report.value?.summary || {}) as Record<string, unknown>)
|
||||
const detail = computed(() => (report.value?.detail || null) as Record<string, unknown> | null)
|
||||
const headline = computed(() => String(summary.value.headline || ''))
|
||||
const oneLiner = computed(() => String(summary.value.one_liner || ''))
|
||||
const keywords = computed(() =>
|
||||
Array.isArray(summary.value.keywords) ? (summary.value.keywords as string[]) : [],
|
||||
)
|
||||
const sharePayload = computed<PortraitSharePayload | null>(() => {
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
partner: '伴侣',
|
||||
friend: '朋友',
|
||||
family: '家人',
|
||||
colleague: '同事',
|
||||
other: '关系对象',
|
||||
}
|
||||
return map[t || ''] || '关系对象'
|
||||
}
|
||||
|
||||
function splitBirth(v: string) {
|
||||
const s = formatProfileDate(v)
|
||||
const [y, m, d] = s.split('-')
|
||||
return { y: y || '', m: m ? String(Number(m)) : '', d: d ? String(Number(d)) : '' }
|
||||
}
|
||||
|
||||
function joinBirth(y: string, m: string, d: string) {
|
||||
const yi = Number(y)
|
||||
const mi = Number(m)
|
||||
const di = Number(d)
|
||||
if (!yi || !mi || !di) return ''
|
||||
return `${yi}-${String(mi).padStart(2, '0')}-${String(di).padStart(2, '0')}`
|
||||
}
|
||||
export { 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],
|
||||
|
||||
@@ -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<string, unknown> | null)
|
||||
const headline = computed(() => String(summary.value.headline || summary.value.diff_one_liner || ''))
|
||||
const oneLiner = computed(() => String(summary.value.one_liner || summary.value.diff_one_liner || ''))
|
||||
const keywords = computed(() => {
|
||||
if (Array.isArray(summary.value.keywords)) return summary.value.keywords as string[]
|
||||
const a = Array.isArray(summary.value.me_keywords) ? (summary.value.me_keywords as string[]) : []
|
||||
const b = Array.isArray(summary.value.other_keywords) ? (summary.value.other_keywords as string[]) : []
|
||||
return [...a, ...b]
|
||||
})
|
||||
const typeLabel = computed(() => {
|
||||
const map: Record<string, string> = {
|
||||
portrait: '愈心解码',
|
||||
relation: '人格匹配',
|
||||
synastry: '合盘',
|
||||
star: '星座',
|
||||
rhythm: '身心节律',
|
||||
image_card: '意象卡片',
|
||||
}
|
||||
return map[report.value?.type || ''] || '成长报告'
|
||||
})
|
||||
const 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<WheelPlanet[]>(() => wheelPlanetsFrom(summary.value))
|
||||
const aspectPreview = computed<WheelAspect[]>(() => 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<string, unknown>)
|
||||
: {},
|
||||
)
|
||||
const ascLon = computed(() => (typeof chartObj.value.asc_lon === 'number' ? chartObj.value.asc_lon : null))
|
||||
const wheelPlanets = computed<WheelPlanet[]>(() => {
|
||||
const raw = summary.value.planets
|
||||
if (!Array.isArray(raw)) return []
|
||||
return raw.map((p) => {
|
||||
const o = p as Record<string, unknown>
|
||||
return {
|
||||
key: String(o.key),
|
||||
title: String(o.title),
|
||||
sign: String(o.sign),
|
||||
degree: String(o.degree),
|
||||
house: Number(o.house),
|
||||
lon: Number(o.lon),
|
||||
}
|
||||
})
|
||||
})
|
||||
const aspectPreview = computed<WheelAspect[]>(() => {
|
||||
const raw = summary.value.aspects_preview
|
||||
if (!Array.isArray(raw)) return []
|
||||
return raw.map((a) => {
|
||||
const o = a as Record<string, unknown>
|
||||
return { a: String(o.a), b: String(o.b), type: String(o.type), orb: Number(o.orb) }
|
||||
})
|
||||
})
|
||||
const transits = computed(() => {
|
||||
const raw = summary.value.transits
|
||||
if (Array.isArray(raw)) return raw as { key: string; title: string; aspect: string; tip: string }[]
|
||||
const 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<string, unknown>)
|
||||
: {},
|
||||
)
|
||||
const synActiveBlock = computed(() => {
|
||||
const c = synCharts.value[panelKey.value]
|
||||
return c && typeof c === 'object' ? (c as Record<string, unknown>) : 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<WheelPlanet[]>(() => {
|
||||
const raw = synActiveBlock.value?.planets
|
||||
if (!Array.isArray(raw)) return []
|
||||
return raw.map((p) => {
|
||||
const x = p as Record<string, unknown>
|
||||
return {
|
||||
key: String(x.key),
|
||||
title: String(x.title),
|
||||
sign: String(x.sign),
|
||||
degree: String(x.degree),
|
||||
house: Number(x.house),
|
||||
lon: Number(x.lon),
|
||||
}
|
||||
})
|
||||
})
|
||||
const synActiveAsc = computed(() =>
|
||||
typeof synActiveBlock.value?.asc_lon === 'number' ? (synActiveBlock.value.asc_lon as number) : null,
|
||||
)
|
||||
const synOverlayEntries = computed(() => {
|
||||
const o = synCharts.value.overlay
|
||||
if (!o || typeof o !== 'object') return []
|
||||
const raw = (o as { entries?: unknown }).entries
|
||||
return Array.isArray(raw) ? (raw as { planet: string; house: number; house_tip: string }[]) : []
|
||||
})
|
||||
const synActivePlanets = computed<WheelPlanet[]>(() => 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<string, unknown>
|
||||
return {
|
||||
key: String(x.key),
|
||||
title: String(x.title),
|
||||
sign: String(x.sign),
|
||||
degree: String(x.degree),
|
||||
house: Number(x.house),
|
||||
lon: Number(x.lon),
|
||||
}
|
||||
})
|
||||
: []
|
||||
return { planets, asc: typeof o.asc_lon === 'number' ? o.asc_lon : null }
|
||||
}
|
||||
const synPlanetsA = computed(() => synChart('chart_a').planets)
|
||||
const synPlanetsB = computed(() => synChart('chart_b').planets)
|
||||
const synAscA = computed(() => synChart('chart_a').asc)
|
||||
const synAscB = computed(() => synChart('chart_b').asc)
|
||||
const createdLabel = computed(() => {
|
||||
const t = report.value?.created_at
|
||||
if (!t) return ''
|
||||
try {
|
||||
return new Date(t).toLocaleDateString('zh-CN')
|
||||
} catch {
|
||||
return t
|
||||
}
|
||||
})
|
||||
const signCards = computed(() => {
|
||||
const raw = summary.value.sign_cards
|
||||
return Array.isArray(raw) ? (raw as SignCard[]) : []
|
||||
})
|
||||
const 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<string, FortunePeriod>) : {}
|
||||
})
|
||||
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<SharePayload | null>(() => {
|
||||
if (!report.value) return null
|
||||
if (report.value.type === 'relation') {
|
||||
return {
|
||||
type: 'relation',
|
||||
me: String(summary.value.me_style || ''),
|
||||
other: String(summary.value.other_style || ''),
|
||||
diff: String(summary.value.diff_one_liner || ''),
|
||||
keywords: keywords.value,
|
||||
} as RelationSharePayload
|
||||
}
|
||||
return {
|
||||
type: 'portrait',
|
||||
title: headline.value,
|
||||
line: oneLiner.value,
|
||||
keywords: keywords.value,
|
||||
} as PortraitSharePayload
|
||||
})
|
||||
const sharePayload = computed<SharePayload | null>(() =>
|
||||
buildReportSharePayload(report.value, summary.value, headline.value, oneLiner.value, keywords.value),
|
||||
)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
|
||||
@@ -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<string, string>)[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<string, unknown>)
|
||||
const detail = computed(() => (report.value?.detail || null) as Record<string, unknown> | null)
|
||||
const headline = computed(() => String(summary.value.headline || ''))
|
||||
const oneLiner = computed(() => String(summary.value.one_liner || ''))
|
||||
const keywords = computed(() => (Array.isArray(summary.value.keywords) ? (summary.value.keywords as string[]) : []))
|
||||
const chartObj = computed(() =>
|
||||
summary.value.chart && typeof summary.value.chart === 'object' ? (summary.value.chart as Record<string, unknown>) : {},
|
||||
const 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<string, FortunePeriod>) : {}
|
||||
})
|
||||
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<WheelPlanet[]>(() =>
|
||||
planets.value.map((p) => ({
|
||||
key: p.key,
|
||||
title: p.title,
|
||||
sign: p.sign,
|
||||
degree: p.degree,
|
||||
house: p.house,
|
||||
lon: Number(p.lon),
|
||||
element: p.element,
|
||||
modality: p.modality,
|
||||
})),
|
||||
)
|
||||
const aspectPreview = computed(() => {
|
||||
const raw = summary.value.aspects_preview
|
||||
return Array.isArray(raw) ? (raw as AspectRow[]) : []
|
||||
})
|
||||
const planets = computed(() => planetsAsRows(summary.value))
|
||||
const wheelPlanets = computed<WheelPlanet[]>(() => 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<WheelAspect[]>(() => {
|
||||
const src = report.value?.has_deep_access ? fullAspects.value : aspectPreview.value
|
||||
return src.map((a) => ({ a: a.a, b: a.b, type: a.type, orb: a.orb, label: a.label }))
|
||||
})
|
||||
|
||||
const gridCells = computed(() => {
|
||||
const cards = signCards.value
|
||||
const slots: { key: string; nick: string; role: string; center?: boolean }[] = []
|
||||
const order = ['sun', 'moon', 'rise', 'venus', 'mars', 'mercury', 'north_node', 'juno', 'jupiter']
|
||||
const byKey = new Map(cards.map((c) => [c.key, c]))
|
||||
const planetByKey = new Map(planets.value.map((p) => [p.key, p]))
|
||||
for (let i = 0; i < 9; i++) {
|
||||
if (i === 4) {
|
||||
slots.push({ key: '_center', nick: '', role: '', center: true })
|
||||
continue
|
||||
}
|
||||
const key = order[i < 4 ? i : i - 1] || cards[i]?.key || `p${i}`
|
||||
const c = byKey.get(key)
|
||||
const p = planetByKey.get(key)
|
||||
if (c) {
|
||||
slots.push({
|
||||
key: c.key,
|
||||
nick: (c.teaser || c.label || c.title).slice(0, 6),
|
||||
role: `${c.title}${c.label}`,
|
||||
})
|
||||
} else if (p) {
|
||||
slots.push({ key: p.key, nick: p.sign, role: `${p.title}${p.sign}` })
|
||||
} else if (cards[i < 4 ? i : i - 1]) {
|
||||
const x = cards[i < 4 ? i : i - 1]
|
||||
slots.push({ key: x.key, nick: (x.teaser || x.label).slice(0, 6), role: `${x.title}${x.label}` })
|
||||
} else {
|
||||
slots.push({ key: `empty${i}`, nick: '—', role: '待生成' })
|
||||
}
|
||||
}
|
||||
return slots
|
||||
})
|
||||
|
||||
const aspectLines = computed<WheelAspect[]>(() =>
|
||||
aspectLinesFrom(report.value?.has_deep_access, fullAspects.value, aspectPreview.value),
|
||||
)
|
||||
const gridCells = computed(() => buildGridCells(signCards.value, planets.value))
|
||||
const sharePayload = computed<PortraitSharePayload | null>(() => {
|
||||
if (!report.value) return null
|
||||
return { type: 'portrait', title: headline.value || '我的星座', line: oneLiner.value, keywords: keywords.value }
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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, unknown>): 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<string, unknown>): Record<string, unknown> {
|
||||
return summary.chart && typeof summary.chart === 'object'
|
||||
? (summary.chart as Record<string, unknown>)
|
||||
: {}
|
||||
}
|
||||
|
||||
export function mapAspectPreview(raw: unknown): WheelAspect[] {
|
||||
if (!Array.isArray(raw)) return []
|
||||
return raw.map((a) => {
|
||||
const o = a as Record<string, unknown>
|
||||
return { a: String(o.a), b: String(o.b), type: String(o.type), orb: Number(o.orb) }
|
||||
})
|
||||
}
|
||||
|
||||
export function mapTransits(summary: Record<string, unknown>): 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<string, unknown>): Record<string, unknown> {
|
||||
return summary.charts && typeof summary.charts === 'object'
|
||||
? (summary.charts as Record<string, unknown>)
|
||||
: {}
|
||||
}
|
||||
|
||||
export function synActiveBlockFrom(
|
||||
charts: Record<string, unknown>,
|
||||
panelKey: string,
|
||||
): Record<string, unknown> | null {
|
||||
return chartBlock(charts, panelKey)
|
||||
}
|
||||
|
||||
export function synChartPair(
|
||||
summary: Record<string, unknown>,
|
||||
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<string, unknown>) {
|
||||
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<string, unknown>): SignCard[] {
|
||||
const raw = summary.sign_cards
|
||||
return Array.isArray(raw) ? (raw as SignCard[]) : []
|
||||
}
|
||||
|
||||
export function fortuneBundleFrom(summary: Record<string, unknown>): Record<string, FortunePeriod> {
|
||||
const o = summary.outlook
|
||||
return o && typeof o === 'object' ? (o as Record<string, FortunePeriod>) : {}
|
||||
}
|
||||
|
||||
export function planetsFrom(summary: Record<string, unknown>): PlanetRow[] {
|
||||
const raw = summary.planets
|
||||
return Array.isArray(raw) ? (raw as PlanetRow[]) : []
|
||||
}
|
||||
|
||||
export function wheelPlanetsFrom(summary: Record<string, unknown>): WheelPlanet[] {
|
||||
return asPlanets(summary.planets)
|
||||
}
|
||||
|
||||
export function ascLonFromSummary(summary: Record<string, unknown>): 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<string, unknown>,
|
||||
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
|
||||
}
|
||||
@@ -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<string, unknown>): HouseRow[] {
|
||||
return Array.isArray(chart.houses) ? (chart.houses as HouseRow[]) : []
|
||||
}
|
||||
|
||||
export function planetsAsRows(summary: Record<string, unknown>): 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 }))
|
||||
}
|
||||
@@ -56,7 +56,6 @@
|
||||
<p class="yxg-disc">自我探索与生活方式参考,不是占卜或算命。体质内容非医疗建议。</p>
|
||||
</div>
|
||||
|
||||
<!-- Sticky bottom CTAs -->
|
||||
<div v-if="report" class="sticky-bar">
|
||||
<router-link class="sticky-btn" to="/ask">去问答</router-link>
|
||||
<router-link class="sticky-btn primary" to="/relation">去做匹配</router-link>
|
||||
@@ -68,135 +67,32 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
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 BackButton from '../components/BackButton.vue'
|
||||
import BirthDateInputs from '../components/BirthDateInputs.vue'
|
||||
import DecodePanel from '../components/DecodePanel.vue'
|
||||
import HomeToolIcon from '../components/HomeToolIcon.vue'
|
||||
import PageShell from '../components/PageShell.vue'
|
||||
import ShareSheet from '../components/ShareSheet.vue'
|
||||
import { AnalyticsEvent, track } from '../lib/analytics'
|
||||
import type { PortraitSharePayload } from '../lib/shareLink'
|
||||
import { usePortraitPage } from '../composables/usePortraitPage'
|
||||
|
||||
const route = useRoute()
|
||||
const loading = ref(false)
|
||||
const needBirth = ref(true)
|
||||
const error = ref('')
|
||||
const formError = ref('')
|
||||
const report = ref<GrowthReport | null>(null)
|
||||
const paying = ref(false)
|
||||
const shareOpen = ref(false)
|
||||
const year = ref(String(route.query.y || ''))
|
||||
const month = ref(String(route.query.m || ''))
|
||||
const day = ref(String(route.query.d || ''))
|
||||
|
||||
const summary = computed(() => (report.value?.summary || {}) as Record<string, unknown>)
|
||||
const detail = computed(() => (report.value?.detail || null) as Record<string, unknown> | null)
|
||||
const headline = computed(() => String(summary.value.headline || ''))
|
||||
const oneLiner = computed(() => String(summary.value.one_liner || ''))
|
||||
const keywords = computed(() => (Array.isArray(summary.value.keywords) ? (summary.value.keywords as string[]) : []))
|
||||
const sharePayload = computed<PortraitSharePayload | null>(() => {
|
||||
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)
|
||||
const {
|
||||
loading,
|
||||
needBirth,
|
||||
error,
|
||||
formError,
|
||||
report,
|
||||
paying,
|
||||
shareOpen,
|
||||
year,
|
||||
month,
|
||||
day,
|
||||
summary,
|
||||
detail,
|
||||
sharePayload,
|
||||
start,
|
||||
retry,
|
||||
buyDeep,
|
||||
} = usePortraitPage()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -13,3 +13,4 @@
|
||||
- QA:修 Playwright `/psy/` 路径与过时断言;e2e 2/2 绿(见 TEST_REPORT/ECR-001-phaseF-qa.md)
|
||||
- **ECR-001 Closed**;**ECR-002 Closed**;**ECR-003**:删 JSON `fortune` 双写,`lucky`→`boost`,H5 改读 `outlook`
|
||||
- **ECR-004**:Synastry 再拆 · Scale vitest · Membership nil 守卫 · OpenAPI schemas · GitHub CI
|
||||
- **ECR-005**:Ask/Decode/Portrait/Report/Star/Profile 再拆;OpenAPI 关键 path `$ref`
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# CODE_REVIEW — ECR-005
|
||||
|
||||
**Decision: PASS / Approve**
|
||||
|
||||
Blockers: 无
|
||||
Nits: ReportsPage 299 行可贴线后再拆;OpenAPI 仍有部分 path 仅 description。
|
||||
@@ -0,0 +1,23 @@
|
||||
# ECR-005
|
||||
|
||||
**Title:** H5 二次拆分与 OpenAPI 契约补强
|
||||
**Status:** Approved
|
||||
**Date:** 2026-08-05
|
||||
**Change Level:** L2(结构 / 契约;无产品扩行为)
|
||||
|
||||
## Change
|
||||
|
||||
1. 拆分 AskAiPane / DecodePanel;抽出 usePortraitPage / useDecodePanel
|
||||
2. 精简 useReportPage / useStarProfilePage / useProfilePage(纯函数/actions 外提)
|
||||
3. OpenAPI:报告/档案/探索测试/意象卡关键 path 挂 `$ref`;star 描述改为 outlook
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [x] 上述目标文件 ≤250(ReportsPage 299 贴线可接受)
|
||||
- [x] test:h5 36/36 · build:h5
|
||||
- [x] OpenAPI 关键 GrowthReport / Profile / ScaleDetail / ImageCard 信封
|
||||
- [x] ESS 工件
|
||||
|
||||
## Linked
|
||||
|
||||
TASK / STATE / TEST_REPORT / CODE_REVIEW under ECR-005
|
||||
@@ -0,0 +1,3 @@
|
||||
# ENGINEERING_SPEC — ECR-005
|
||||
|
||||
See ECR-005. Move/extract only; OpenAPI additive `$ref`.
|
||||
@@ -45,9 +45,9 @@
|
||||
|
||||
## Active anchors
|
||||
|
||||
- ECR: **ECR-004** hygiene follow-up(Approved);ECR-001/002/003 Closed / shipped
|
||||
- ECR: **ECR-005** H5/OpenAPI hygiene(Approved);ECR-001–004 Closed / shipped
|
||||
- EXP: (无)
|
||||
- STATE: `docs/STATE/ECR-004.md`
|
||||
- STATE: `docs/STATE/ECR-005.md`
|
||||
- TRACEABILITY: `docs/TRACEABILITY.md`
|
||||
- ADR: `.ai/adr/0007-ess-ai-dual-track.md`
|
||||
- Product status: `.ai/product/p1-status.md`(**P1 Complete**)
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# STATE — ECR-005
|
||||
|
||||
| Field | Value |
|
||||
|-------|--------|
|
||||
| ECR | ECR-005 |
|
||||
| Status | review PASS · awaiting push |
|
||||
| Updated | 2026-08-05 |
|
||||
@@ -0,0 +1,23 @@
|
||||
# docs/TASKS/TASK-20260805-ECR005.yaml
|
||||
task_id: ECR-005-h5-openapi
|
||||
role: engineer
|
||||
phase: coding
|
||||
tool: cursor
|
||||
status: coded
|
||||
|
||||
input:
|
||||
- docs/ECR/ECR-005-h5-openapi-hygiene.md
|
||||
- .ai/coding.md
|
||||
|
||||
output:
|
||||
- apps/user-h5/src/**
|
||||
- proto/openapi.yaml
|
||||
- docs/TEST_REPORT/ECR-005.md
|
||||
|
||||
forbidden:
|
||||
- redesign_scope
|
||||
- api_url_change
|
||||
- behavior_change
|
||||
|
||||
next_agent: reviewer
|
||||
notes: Ask/Decode/Portrait/Report/Star/Profile splits + OpenAPI refs.
|
||||
@@ -0,0 +1,9 @@
|
||||
# Test Report — ECR-005
|
||||
|
||||
- [x] AskAiPane 97 · DecodePanel 102 · PortraitPage 225
|
||||
- [x] useReport 169 · useStar 228 · useProfile 152
|
||||
- [x] OpenAPI GrowthReport/Profile/ScaleDetail/ImageCard `$ref`
|
||||
- [x] `npm run test:h5` 36/36 · `build:h5` PASS
|
||||
|
||||
## Gate
|
||||
PASS
|
||||
@@ -10,3 +10,4 @@
|
||||
| ECR-002 | fortune→outlook package | **Closed** | `docs/ECR/ECR-002-fortune-hygiene.md` |
|
||||
| ECR-003 | outlook JSON hygiene(删 fortune / lucky→boost) | review PASS | TEST_REPORT · CODE_REVIEW/ECR-003.md |
|
||||
| ECR-004 | Synastry 再拆 · Scale 测 · Membership 守卫 · OpenAPI · CI | review PASS | TEST_REPORT · CODE_REVIEW/ECR-004.md |
|
||||
| ECR-005 | Ask/Decode/Portrait 再拆 · composable 外提 · OpenAPI `$ref` | review PASS | TEST_REPORT · CODE_REVIEW/ECR-005.md |
|
||||
|
||||
+91
-1
@@ -63,6 +63,14 @@ paths:
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/Envelope'
|
||||
- type: object
|
||||
properties:
|
||||
data: { $ref: '#/components/schemas/Profile' }
|
||||
|
||||
/api/v1/profiles/{id}:
|
||||
patch:
|
||||
@@ -73,6 +81,14 @@ paths:
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/Envelope'
|
||||
- type: object
|
||||
properties:
|
||||
data: { $ref: '#/components/schemas/Profile' }
|
||||
delete:
|
||||
tags: [profile]
|
||||
summary: 删除个人档案
|
||||
@@ -98,6 +114,10 @@ paths:
|
||||
responses:
|
||||
'200':
|
||||
description: 返回 GrowthReport(detail 按权益裁剪)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/EnvelopeGrowthReport'
|
||||
|
||||
/api/v1/explore/catalog:
|
||||
get:
|
||||
@@ -179,7 +199,11 @@ paths:
|
||||
profile_id: { type: string, format: uuid }
|
||||
responses:
|
||||
'200':
|
||||
description: GrowthReport type=star(含 chart/planets/aspects_preview/fortune/transits)
|
||||
description: GrowthReport type=star(含 chart/planets/aspects_preview/outlook/transits)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/EnvelopeGrowthReport'
|
||||
|
||||
/api/v1/reports/synastry:
|
||||
post:
|
||||
@@ -274,6 +298,10 @@ paths:
|
||||
responses:
|
||||
'200':
|
||||
description: GrowthReport type=synastry
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/EnvelopeGrowthReport'
|
||||
|
||||
/api/v1/reports/rhythm:
|
||||
post:
|
||||
@@ -291,6 +319,10 @@ paths:
|
||||
responses:
|
||||
'200':
|
||||
description: GrowthReport type=rhythm
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/EnvelopeGrowthReport'
|
||||
|
||||
/api/v1/image-cards/scenes:
|
||||
get:
|
||||
@@ -299,6 +331,10 @@ paths:
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/EnvelopeImageCardScenes'
|
||||
|
||||
/api/v1/image-cards/quota:
|
||||
get:
|
||||
@@ -307,6 +343,10 @@ paths:
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/EnvelopeImageCardQuota'
|
||||
|
||||
/api/v1/image-cards/draw:
|
||||
post:
|
||||
@@ -354,6 +394,10 @@ paths:
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/EnvelopeReportList'
|
||||
|
||||
/api/v1/reports/{id}:
|
||||
get:
|
||||
@@ -364,6 +408,10 @@ paths:
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/EnvelopeGrowthReport'
|
||||
|
||||
/api/v1/scales:
|
||||
get:
|
||||
@@ -389,6 +437,10 @@ paths:
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/EnvelopeScaleDetail'
|
||||
|
||||
/api/v1/scales/{slug}/result:
|
||||
post:
|
||||
@@ -756,3 +808,41 @@ components:
|
||||
- type: object
|
||||
properties:
|
||||
data: { $ref: '#/components/schemas/AskQuota' }
|
||||
|
||||
EnvelopeReportList:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/Envelope'
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/GrowthReport' }
|
||||
|
||||
EnvelopeScaleDetail:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/Envelope'
|
||||
- type: object
|
||||
properties:
|
||||
data: { $ref: '#/components/schemas/ScaleDetail' }
|
||||
|
||||
EnvelopeImageCardScenes:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/Envelope'
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/ImageCardScene' }
|
||||
|
||||
EnvelopeImageCardQuota:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/Envelope'
|
||||
- type: object
|
||||
properties:
|
||||
data: { $ref: '#/components/schemas/ImageCardQuota' }
|
||||
|
||||
Reference in New Issue
Block a user