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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-05 18:19:34 +08:00
co-authored by Cursor
parent 19d3cd5945
commit e735aa43e3
64 changed files with 6198 additions and 4157 deletions
@@ -0,0 +1,122 @@
<template>
<div class="adv-pane">
<div class="card">
<p class="adv-title">成长顾问</p>
<p class="adv-sub">结合档案聊聊卡住的事 · 可先用 AI或开通会员解锁更多</p>
</div>
<div
v-for="a in advisors"
:key="a.name"
class="adv-card"
>
<div class="av" :style="{ background: a.bg }">{{ a.mark }}</div>
<div class="adv-body">
<strong>{{ a.name }}</strong>
<span class="meta">{{ a.meta }}</span>
<span class="tags">{{ a.tags }}</span>
</div>
<button type="button" class="ask-btn" @click="$emit('ask', a)">去提问</button>
</div>
<router-link class="cta soft" to="/membership">查看成长会员权益</router-link>
</div>
</template>
<script setup lang="ts">
import { askAdvisors } from '../../composables/useAskPage'
defineEmits<{
ask: [a: (typeof askAdvisors)[number]]
}>()
const advisors = askAdvisors
</script>
<style scoped>
.adv-pane {
padding: 12px 16px 100px;
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;
}
.adv-title {
font-size: 16px;
font-weight: 700;
color: #222;
}
.adv-sub {
font-size: 12px;
color: #888;
margin-top: 4px;
line-height: 1.45;
}
.adv-card {
display: flex;
align-items: center;
gap: 12px;
padding: 14px;
background: #fff;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
margin-bottom: 10px;
}
.av {
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
color: #c23b32;
flex-shrink: 0;
}
.adv-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.adv-body strong {
font-size: 14px;
color: #222;
}
.meta {
font-size: 11px;
color: #999;
}
.tags {
font-size: 11px;
color: var(--color-primary);
}
.ask-btn {
flex-shrink: 0;
padding: 8px 12px;
border: none;
border-radius: 18px;
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
color: #fff;
font-size: 12px;
font-weight: 600;
}
.cta.soft {
display: flex;
margin-top: 8px;
padding: 12px 20px;
border-radius: 22px;
background: #fff;
color: var(--color-primary);
border: 1.5px solid #f5c4bc;
box-shadow: none;
font-weight: 600;
font-size: 14px;
align-items: center;
justify-content: center;
}
</style>
@@ -0,0 +1,354 @@
<template>
<div class="ai-pane">
<p v-if="bootLoading" class="hint">加载中</p>
<p v-else-if="bootError" class="err">
{{ 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>
<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>
<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>
</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'
const props = defineProps<{
bootLoading: boolean
bootError: string
profiles: Profile[]
profileId: string
messages: AskMessage[]
draft: string
sending: boolean
sendError: string
quotaExhausted: boolean
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 }]
'switch-advisor': []
'update:profileId': [v: string]
'profile-change': []
navigate: [to: string]
'update:draft': [v: string]
send: []
}>()
const guidePrompts = askScenes
const quickTools = askQuickTools
</script>
<style scoped>
.ai-pane {
padding: 12px 16px 100px;
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;
text-align: center;
padding: 24px;
}
.err {
font-size: 13px;
color: var(--color-primary);
padding: 8px 0;
}
.pad { padding: 0 4px; }
.retry {
border: none;
background: transparent;
color: var(--color-accent-blue);
text-decoration: underline;
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,71 @@
<template>
<div class="rails">
<button
type="button"
class="rail"
:class="{ on: modelValue === 'ai' }"
@click="$emit('update:modelValue', 'ai')"
>
愈心 AI
</button>
<button
type="button"
class="rail"
:class="{ on: modelValue === 'advisor' }"
@click="$emit('update:modelValue', 'advisor')"
>
顾问
<span class="rail-tag">推荐</span>
</button>
</div>
</template>
<script setup lang="ts">
defineProps<{
modelValue: 'ai' | 'advisor'
}>()
defineEmits<{
'update:modelValue': [v: 'ai' | 'advisor']
}>()
</script>
<style scoped>
.rails {
display: flex;
gap: 0;
background: rgba(255, 255, 255, 0.72);
border-radius: var(--radius-pill);
padding: 4px;
border: 1px solid rgba(255, 255, 255, 0.95);
box-shadow: 0 4px 16px rgba(180, 90, 70, 0.08);
backdrop-filter: blur(10px);
}
.rail {
flex: 1;
border: none;
background: transparent;
padding: 10px 12px;
border-radius: var(--radius-pill);
font-size: 14px;
font-weight: 600;
color: #888;
position: relative;
}
.rail.on {
background: #fff;
color: var(--color-primary);
box-shadow: 0 2px 8px rgba(229, 77, 66, 0.12);
}
.rail-tag {
position: absolute;
top: -2px;
right: 8px;
font-size: 9px;
font-weight: 700;
color: #fff;
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
padding: 1px 5px;
border-radius: 6px;
}
</style>
@@ -0,0 +1,58 @@
<template>
<div class="greet">
<div class="greet-icons" aria-hidden="true">
<HomeToolIcon name="cards" :size="44" />
<HomeToolIcon name="ask" :size="36" class="icon-offset" />
</div>
<h1 class="greet-title">Hi我是愈心 AI</h1>
<p class="greet-sub">来用意念卡片整理问题吧</p>
<p v-if="bootError" class="yxg-err">{{ bootError }}仍可填写后重试抽取</p>
<p class="quota">今日剩余 {{ quotaLabel }} · 场景 {{ scene }}</p>
</div>
</template>
<script setup lang="ts">
import HomeToolIcon from '../HomeToolIcon.vue'
defineProps<{
bootError: string
quotaLabel: string
scene: string
}>()
</script>
<style scoped>
.greet {
text-align: center;
padding: 8px 0 20px;
}
.greet-icons {
display: flex;
align-items: flex-end;
justify-content: center;
gap: 4px;
margin-bottom: 12px;
}
.icon-offset {
margin-bottom: -4px;
opacity: 0.85;
}
.greet-title {
font-family: var(--font-display);
font-size: 22px;
font-weight: 700;
color: var(--color-text-primary);
letter-spacing: 0.04em;
margin: 0;
}
.greet-sub {
font-size: 14px;
color: var(--color-text-secondary);
margin: 6px 0 10px;
}
.quota {
font-size: 11px;
color: var(--color-text-tertiary);
margin: 0;
}
</style>
@@ -0,0 +1,82 @@
<template>
<div class="input-bar">
<input
:value="questionInput"
type="text"
class="question-in"
placeholder="让我来解答你的问题吧"
@input="$emit('update:questionInput', ($event.target as HTMLInputElement).value)"
@keydown.enter="$emit('send')"
/>
<button type="button" class="send-btn" :disabled="drawing" aria-label="发送并抽取" @click="$emit('send')">
<span v-if="drawing" class="send-spin"></span>
<span v-else class="send-ico" aria-hidden="true"></span>
</button>
</div>
</template>
<script setup lang="ts">
defineProps<{
questionInput: string
drawing: boolean
}>()
defineEmits<{
'update:questionInput': [value: string]
send: []
}>()
</script>
<style scoped>
.input-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 20;
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px calc(10px + env(safe-area-inset-bottom, 0));
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-top: 1px solid rgba(0, 0, 0, 0.06);
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.06);
}
.question-in {
flex: 1;
border: 1.5px solid #eee;
border-radius: 22px;
padding: 11px 16px;
font-size: 14px;
background: #fdfaf8;
outline: none;
}
.question-in:focus {
border-color: rgba(229, 77, 66, 0.35);
}
.send-btn {
width: 40px;
height: 40px;
flex-shrink: 0;
border: none;
border-radius: 50%;
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
color: #fff;
font-size: 18px;
font-weight: 700;
cursor: pointer;
box-shadow: 0 4px 12px rgba(229, 77, 66, 0.28);
display: flex;
align-items: center;
justify-content: center;
}
.send-btn:disabled {
opacity: 0.6;
}
.send-ico {
line-height: 1;
margin-top: -2px;
}
</style>
@@ -0,0 +1,175 @@
<template>
<div>
<div class="prompt-list">
<button
v-for="(row, i) in promptRows"
:key="i"
type="button"
class="prompt-row"
@click="$emit('promptClick', row)"
>
<span class="prompt-ico" aria-hidden="true">{{ row.icon }}</span>
<span class="prompt-text">{{ row.label }}</span>
<span class="prompt-go" aria-hidden="true"></span>
</button>
</div>
<div class="mode-row" role="group" aria-label="抽卡模式">
<button
type="button"
class="mode-chip"
:class="{ on: !wantDepth }"
@click="$emit('update:wantDepth', false)"
>
单卡
</button>
<button
type="button"
class="mode-chip"
:class="{ on: wantDepth }"
@click="$emit('update:wantDepth', true)"
>
三卡组合
</button>
</div>
<div class="birth-fold">
<button type="button" class="birth-toggle" @click="$emit('update:showBirth', !showBirth)">
<span>关联生日</span>
<span class="caret" :class="{ open: showBirth }"></span>
</button>
<div v-show="showBirth" class="birth-panel yxg-card soft">
<BirthDateInputs
:year="year"
:month="month"
:day="day"
compact
@update:year="$emit('update:year', $event)"
@update:month="$emit('update:month', $event)"
@update:day="$emit('update:day', $event)"
/>
<button class="yxg-btn yxg-btn-block" type="button" :disabled="drawing" @click="$emit('draw')">
{{ drawing ? '抽取中' : '抽取卡片' }}
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import BirthDateInputs from '../BirthDateInputs.vue'
import { promptRows } from '../../composables/useImageCardPage'
defineProps<{
wantDepth: boolean
showBirth: boolean
year: string
month: string
day: string
drawing: boolean
}>()
defineEmits<{
promptClick: [row: { icon: string; label: string; scene: string }]
'update:wantDepth': [value: boolean]
'update:showBirth': [value: boolean]
'update:year': [value: string]
'update:month': [value: string]
'update:day': [value: string]
draw: []
}>()
</script>
<style scoped>
.prompt-list {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 18px;
}
.prompt-row {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 16px 14px;
border: none;
border-radius: var(--radius-xl);
background: var(--color-surface);
box-shadow: var(--shadow-card);
text-align: left;
cursor: pointer;
transition: transform var(--duration-fast) ease;
}
.prompt-row:active {
transform: scale(0.985);
}
.prompt-ico {
font-size: 22px;
flex-shrink: 0;
width: 36px;
text-align: center;
}
.prompt-text {
flex: 1;
font-size: 14px;
font-weight: 500;
color: var(--color-text-primary);
line-height: 1.45;
}
.prompt-go {
font-size: 14px;
color: var(--color-primary);
opacity: 0.6;
}
.mode-row {
display: flex;
gap: 8px;
justify-content: center;
margin-bottom: 16px;
}
.mode-chip {
border: 1.5px solid #eee;
background: #fff;
border-radius: 999px;
padding: 8px 20px;
font-size: 13px;
color: #666;
cursor: pointer;
}
.mode-chip.on {
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
color: #fff;
border-color: transparent;
box-shadow: 0 4px 12px rgba(229, 77, 66, 0.22);
}
.birth-fold {
margin-bottom: 12px;
}
.birth-toggle {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 12px 14px;
border: none;
border-radius: var(--radius-lg);
background: rgba(255, 255, 255, 0.7);
font-size: 13px;
font-weight: 600;
color: var(--color-text-secondary);
cursor: pointer;
}
.caret {
transition: transform 0.2s ease;
}
.caret.open {
transform: rotate(180deg);
}
.birth-panel {
margin-top: 8px;
}
.birth-panel :deep(.bd) {
margin: 8px 0 12px;
}
</style>
@@ -0,0 +1,56 @@
<template>
<div>
<article v-for="c in cards" :key="c.id" class="yxg-card card-result">
<h2 class="card-title">{{ c.title }}</h2>
<p class="imagery">{{ c.imagery }}</p>
<p><strong>反思</strong> {{ c.prompt }}</p>
<p class="tip">{{ c.tip }}</p>
</article>
<ReportRich v-if="report" :summary="summary" :detail="detail" :deep="!!report.has_deep_access" />
<div v-if="report && !report.has_deep_access && wantDepth" class="yxg-card lock">
<p>三卡组合解读需深度版或成长会员</p>
<button class="yxg-btn" type="button" :disabled="paying" @click="$emit('buyDeep')">解锁组合模拟支付</button>
</div>
<router-link v-if="report" class="yxg-link-plain report-link" :to="`/reports/${report.id}`">
在报告页打开
</router-link>
</div>
</template>
<script setup lang="ts">
import type { GrowthReport } from '@yuxingu/types'
import ReportRich from '../ReportRich.vue'
defineProps<{
cards: { id: string; title: string; imagery: string; prompt: string; tip: string }[]
report: GrowthReport | null
summary: Record<string, unknown>
detail: Record<string, unknown> | null
wantDepth: boolean
paying: boolean
}>()
defineEmits<{ buyDeep: [] }>()
</script>
<style scoped>
.card-result {
margin-bottom: 12px;
}
.imagery {
color: #666;
margin-bottom: 8px;
}
.tip {
color: #3d6b45;
margin-top: 6px;
}
.lock .yxg-btn {
margin-top: 12px;
width: 100%;
}
.report-link {
display: block;
margin-top: 12px;
}
</style>
@@ -0,0 +1,106 @@
<template>
<div class="yxg-card form-panel">
<div class="panel-head">
<h2 class="panel-title">添加档案</h2>
<button type="button" class="yxg-link linkbtn" @click="$emit('invite')">邀请好友</button>
</div>
<label class="yxg-label">昵称</label>
<input class="yxg-input" :value="newName" placeholder="请输入昵称(16 个字)" maxlength="6" @input="$emit('update:newName', ($event.target as HTMLInputElement).value)" />
<label class="yxg-label">关系</label>
<div class="rel-chips" role="group" aria-label="关系类型">
<button
v-for="r in relationOptions"
:key="r.value"
type="button"
class="rel-chip"
:class="{ on: newRelationType === r.value }"
@click="$emit('update:newRelationType', r.value)"
>
{{ r.label }}
</button>
</div>
<label class="yxg-label">生日</label>
<BirthDateInputs :year="newY" :month="newM" :day="newD" compact @update:year="$emit('update:newY', $event)" @update:month="$emit('update:newM', $event)" @update:day="$emit('update:newD', $event)" />
<label class="yxg-label">出生地可选</label>
<BirthPlaceSelect :model-value="newPlace" @update:model-value="$emit('update:newPlace', $event)" />
<p v-if="addError" class="yxg-err">{{ addError }}</p>
<button class="cta" type="button" :disabled="adding" @click="$emit('save')">保存</button>
</div>
</template>
<script setup lang="ts">
import BirthDateInputs from '../BirthDateInputs.vue'
import BirthPlaceSelect from '../BirthPlaceSelect.vue'
import { profileRelationOptions } from '../../composables/useProfilePage'
defineProps<{
newName: string
newRelationType: string
newY: string
newM: string
newD: string
newPlace: string
addError: string
adding: boolean
}>()
defineEmits<{
'update:newName': [v: string]
'update:newRelationType': [v: string]
'update:newY': [v: string]
'update:newM': [v: string]
'update:newD': [v: string]
'update:newPlace': [v: string]
invite: []
save: []
}>()
const relationOptions = profileRelationOptions
</script>
<style scoped>
.form-panel { margin: 14px 0; }
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 4px;
}
.panel-title { font-size: 16px; font-weight: 700; margin: 0 0 12px; color: #222; }
.panel-head .panel-title { margin-bottom: 0; }
.rel-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 4px;
}
.rel-chip {
padding: 7px 14px;
border-radius: 999px;
border: 1.5px solid #eee;
background: #fff;
font-size: 13px;
font-weight: 600;
color: #666;
}
.rel-chip.on {
border-color: rgba(229, 77, 66, 0.45);
background: #fff5f4;
color: var(--color-primary);
}
.cta {
margin-top: 14px;
width: 100%;
padding: 12px;
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);
}
.yxg-label { display: block; margin: 12px 0 6px; font-size: 12px; color: #999; }
.yxg-label:first-of-type { margin-top: 0; }
</style>
@@ -0,0 +1,49 @@
<template>
<div class="bottom-cta">
<button type="button" class="fab" @click="$emit('toggle-add')">
{{ showAddForm ? '收起' : '添加档案' }}
</button>
<router-link class="link-row" to="/synastry">去做合盘 </router-link>
</div>
</template>
<script setup lang="ts">
defineProps<{
showAddForm: boolean
}>()
defineEmits<{
'toggle-add': []
}>()
</script>
<style scoped>
.bottom-cta {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 20;
padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(12px);
border-top: 1px solid #f0ebe8;
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
}
.fab {
width: 100%;
max-width: 360px;
padding: 13px;
border: none;
border-radius: 22px;
color: #fff;
font-size: 15px;
font-weight: 600;
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
box-shadow: 0 6px 16px rgba(229, 77, 66, 0.28);
}
.link-row { font-size: 13px; color: var(--color-primary); font-weight: 600; }
</style>
@@ -0,0 +1,96 @@
<template>
<div class="yxg-card form-panel">
<h2 class="panel-title">编辑档案</h2>
<label class="yxg-label">称呼</label>
<input class="yxg-input" :value="formName" @input="$emit('update:formName', ($event.target as HTMLInputElement).value)" />
<label class="yxg-label">生日</label>
<BirthDateInputs :year="formY" :month="formM" :day="formD" compact @update:year="$emit('update:formY', $event)" @update:month="$emit('update:formM', $event)" @update:day="$emit('update:formD', $event)" />
<label class="yxg-label">出生地可选</label>
<BirthPlaceSelect :model-value="formPlace" @update:model-value="$emit('update:formPlace', $event)" />
<label v-if="editing.relation === 'other'" class="yxg-label">关系类型</label>
<select v-if="editing.relation === 'other'" class="yxg-select" :value="formRelationType" @change="$emit('update:formRelationType', ($event.target as HTMLSelectElement).value)">
<option value="partner">伴侣</option>
<option value="friend">朋友</option>
<option value="family">家人</option>
<option value="colleague">同事</option>
<option value="other">其他</option>
</select>
<label v-if="editing.relation === 'self'" class="check">
<input :checked="formGeoVisible" type="checkbox" @change="$emit('update:formGeoVisible', ($event.target as HTMLInputElement).checked)" />
位置可见开启后可出现在他人附近的人合盘列表默认关闭
</label>
<p v-if="formError" class="yxg-err">{{ formError }}</p>
<div class="form-actions">
<button class="cta" type="button" :disabled="saving" @click="$emit('save')">保存</button>
<button class="cta soft" type="button" @click="$emit('cancel')">取消</button>
</div>
</div>
</template>
<script setup lang="ts">
import type { Profile } from '@yuxingu/types'
import BirthDateInputs from '../BirthDateInputs.vue'
import BirthPlaceSelect from '../BirthPlaceSelect.vue'
defineProps<{
editing: Profile
formName: string
formY: string
formM: string
formD: string
formPlace: string
formRelationType: string
formGeoVisible: boolean
formError: string
saving: boolean
}>()
defineEmits<{
'update:formName': [v: string]
'update:formY': [v: string]
'update:formM': [v: string]
'update:formD': [v: string]
'update:formPlace': [v: string]
'update:formRelationType': [v: string]
'update:formGeoVisible': [v: boolean]
save: []
cancel: []
}>()
</script>
<style scoped>
.form-panel { margin: 14px 0; }
.panel-title { font-size: 16px; font-weight: 700; margin: 0 0 12px; color: #222; }
.form-actions { display: flex; gap: 8px; margin-top: 12px; }
.cta {
margin-top: 14px;
width: 100%;
padding: 12px;
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);
}
.cta.soft {
background: #fff;
color: #666;
border: 1.5px solid #eee;
box-shadow: none;
}
.form-actions .cta { flex: 1; margin-top: 0; }
.check {
display: flex;
gap: 8px;
align-items: flex-start;
margin: 12px 0;
font-size: 13px;
color: #555;
line-height: 1.4;
}
.check input { margin-top: 3px; }
.yxg-label { display: block; margin: 12px 0 6px; font-size: 12px; color: #999; }
.yxg-label:first-of-type { margin-top: 0; }
</style>
@@ -0,0 +1,34 @@
<template>
<div class="empty-card">
<HomeToolIcon name="portrait" :size="56" />
<p>暂无档案请先去首页完成性格探索或添加重要的人</p>
<router-link class="cta" to="/">去性格探索</router-link>
</div>
</template>
<script setup lang="ts">
import HomeToolIcon from '../HomeToolIcon.vue'
</script>
<style scoped>
.empty-card {
text-align: center;
padding: 32px 16px;
border-radius: var(--radius-xl);
background: #fafafa;
color: #888;
}
.empty-card p { margin: 12px 0; font-size: 14px; line-height: 1.5; }
.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,84 @@
<template>
<div class="yxg-card form-panel invite-panel">
<h2 class="panel-title">邀请好友填档案</h2>
<p class="invite-desc">
生成链接发给家人或朋友对方填写生日后可出现在你的合盘与关系理解中
</p>
<p v-if="inviteHint" class="yxg-meta">{{ inviteHint }}</p>
<p v-if="invitePath" class="invite-link">{{ invitePath }}</p>
<p v-if="inviteError" class="yxg-err">{{ inviteError }}</p>
<div class="form-actions">
<button class="cta" type="button" :disabled="inviting || !hasSelf" @click="$emit('create')">
{{ inviting ? '生成中' : invitePath ? '重新生成' : '生成填写链接' }}
</button>
<button
v-if="invitePath"
class="cta soft"
type="button"
:disabled="copyBusy"
@click="$emit('copy')"
>
{{ copyBusy ? '复制中…' : '复制链接' }}
</button>
</div>
<button type="button" class="yxg-link linkbtn" @click="$emit('close')">关闭</button>
</div>
</template>
<script setup lang="ts">
defineProps<{
inviteHint: string
invitePath: string
inviteError: string
inviting: boolean
copyBusy: boolean
hasSelf: boolean
}>()
defineEmits<{
create: []
copy: []
close: []
}>()
</script>
<style scoped>
.form-panel { margin: 14px 0; }
.panel-title { font-size: 16px; font-weight: 700; margin: 0 0 12px; color: #222; }
.invite-desc {
margin: 0 0 10px;
font-size: 13px;
line-height: 1.55;
color: #666;
}
.invite-link {
margin: 8px 0;
padding: 10px 12px;
border-radius: 12px;
background: #faf7f5;
font-size: 12px;
line-height: 1.4;
color: #444;
word-break: break-all;
}
.form-actions { display: flex; gap: 8px; margin-top: 12px; }
.cta {
margin-top: 14px;
width: 100%;
padding: 12px;
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);
}
.cta.soft {
background: #fff;
color: #666;
border: 1.5px solid #eee;
box-shadow: none;
}
.form-actions .cta { flex: 1; margin-top: 0; }
</style>
@@ -0,0 +1,75 @@
<template>
<section class="section">
<h2 class="sec-title">重要的人</h2>
<div class="others-list">
<div v-for="p in others" :key="p.id" class="other-row">
<ListRow
:title="p.display_name || 'TA'"
:desc="`${relationLabel(p.relation_type)} · ${formatProfileDate(p.birth_date)}`"
:icon="'relation'"
:chevron="false"
/>
<div class="row-ops">
<button type="button" class="op-btn" @click="$emit('edit', p)">编辑</button>
<button
type="button"
class="op-btn danger"
:disabled="busyId === p.id"
@click="$emit('remove', p)"
>
删除
</button>
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import type { Profile } from '@yuxingu/types'
import ListRow from '../ListRow.vue'
import { formatProfileDate, relationLabel } from '../../composables/useProfilePage'
defineProps<{
others: Profile[]
busyId: string
}>()
defineEmits<{
edit: [p: Profile]
remove: [p: Profile]
}>()
</script>
<style scoped>
.sec-title {
font-size: 15px;
font-weight: 700;
color: #222;
margin-bottom: 10px;
}
.other-row {
margin-bottom: 8px;
border-radius: var(--radius-lg);
background: #fff;
box-shadow: var(--shadow-card);
border: 1px solid rgba(0, 0, 0, 0.03);
overflow: hidden;
}
.other-row :deep(.lr) { padding: 12px 14px 8px; }
.row-ops {
display: flex;
gap: 8px;
padding: 0 14px 12px;
}
.op-btn {
padding: 6px 14px;
border-radius: var(--radius-pill);
border: 1px solid #f0ebe8;
background: #fafafa;
font-size: 12px;
font-weight: 600;
color: #555;
}
.op-btn.danger { color: var(--color-primary); border-color: #ffe0dc; background: #fff5f4; }
</style>
@@ -0,0 +1,76 @@
<template>
<div class="self-card">
<div class="av-ring">
<span class="av-inner">{{ avatarInitial(profile) }}</span>
</div>
<div class="self-body">
<strong>{{ profile.display_name || '我' }}</strong>
<span class="meta"> · {{ formatProfileDate(profile.birth_date) }}</span>
<span v-if="profile.birth_place" class="meta sub">{{ profile.birth_place }}</span>
</div>
<div class="self-ops">
<button type="button" class="op-btn" @click="$emit('edit', profile)">编辑</button>
</div>
</div>
</template>
<script setup lang="ts">
import type { Profile } from '@yuxingu/types'
import { avatarInitial, formatProfileDate } from '../../composables/useProfilePage'
defineProps<{
profile: Profile
}>()
defineEmits<{
edit: [p: Profile]
}>()
</script>
<style scoped>
.self-card {
display: flex;
align-items: center;
gap: 14px;
padding: 16px;
margin-bottom: 16px;
border-radius: var(--radius-xl);
background: linear-gradient(145deg, #fff0ec, #ffe4dc);
box-shadow: var(--shadow-hero);
}
.av-ring {
flex-shrink: 0;
width: 64px;
height: 64px;
border-radius: 50%;
padding: 3px;
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
box-shadow: 0 4px 14px rgba(229, 77, 66, 0.25);
}
.av-inner {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
border-radius: 50%;
background: #fff;
font-size: 22px;
font-weight: 700;
color: var(--color-primary);
}
.self-body { flex: 1; min-width: 0; }
.self-body strong { display: block; font-size: 17px; color: #222; }
.meta { display: block; font-size: 12px; color: #888; margin-top: 4px; }
.meta.sub { color: #aaa; }
.self-ops { flex-shrink: 0; }
.op-btn {
padding: 6px 14px;
border-radius: var(--radius-pill);
border: 1px solid #f0ebe8;
background: #fafafa;
font-size: 12px;
font-weight: 600;
color: #555;
}
</style>
@@ -0,0 +1,186 @@
<template>
<div>
<div class="hero-block">
<h1 class="hero-title">看见彼此的相处模样</h1>
<p class="hero-sub">基于档案与生日理解相处模式 · 性格对照 · 默契指数</p>
</div>
<div class="focus-chips" role="tablist">
<button
v-for="f in focusTabs"
:key="f.key"
type="button"
class="fchip"
:class="{ on: focus === f.key }"
@click="$emit('update:focus', f.key)"
>
{{ f.label }}
</button>
</div>
<div class="deco-card" aria-hidden="true">
<div class="deco-blur">
<span class="deco-av me" />
<span class="deco-heart"></span>
<span class="deco-av ta" />
</div>
<p class="deco-hint">{{ focusHint }}</p>
</div>
<div class="yxg-card form-card">
<label class="yxg-label">关系类型</label>
<div class="rel-chips">
<button
v-for="r in relationOptions"
:key="r.value"
type="button"
class="rchip"
:class="{ on: relationType === r.value }"
@click="$emit('update:relationType', r.value)"
>
{{ r.label }}
</button>
</div>
<label class="yxg-label">TA 的称呼</label>
<input
class="yxg-input"
:value="otherName"
placeholder="例如:伴侣"
@input="$emit('update:otherName', ($event.target as HTMLInputElement).value)"
/>
<label class="yxg-label">TA 的生日</label>
<BirthDateInputs
:year="oy"
:month="om"
:day="od"
compact
@update:year="$emit('update:oy', $event)"
@update:month="$emit('update:om', $event)"
@update:day="$emit('update:od', $event)"
/>
<p class="yxg-hint">将使用你最近一份的档案进行匹配</p>
<button class="cta" type="button" :disabled="loading" @click="$emit('run')">
{{ loading ? '生成中' : '去匹配' }}
</button>
<p v-if="error" class="yxg-err">
{{ error }}
<router-link v-if="needSelf" class="yxg-link" to="/">去首页做愈心解码</router-link>
</p>
</div>
</div>
</template>
<script setup lang="ts">
import BirthDateInputs from '../BirthDateInputs.vue'
import { focusTabs, relationOptions } from '../../composables/useRelationPage'
defineProps<{
focus: string
focusHint: string
relationType: string
otherName: string
oy: string
om: string
od: string
loading: boolean
error: string
needSelf: boolean
}>()
defineEmits<{
'update:focus': [value: string]
'update:relationType': [value: string]
'update:otherName': [value: string]
'update:oy': [value: string]
'update:om': [value: string]
'update:od': [value: string]
run: []
}>()
</script>
<style scoped>
.hero-block { text-align: center; padding: 12px 8px 4px; }
.hero-title {
font-family: var(--font-display);
font-size: 22px;
font-weight: 700;
letter-spacing: 0.04em;
color: #222;
}
.hero-sub { font-size: 13px; color: #888; margin-top: 8px; line-height: 1.5; }
.focus-chips {
display: flex;
gap: 8px;
margin: 14px 0;
justify-content: center;
}
.fchip {
padding: 8px 14px;
border-radius: var(--radius-pill);
border: 1px solid #f0ebe8;
background: #fafafa;
font-size: 12px;
color: #666;
font-weight: 600;
}
.fchip.on {
background: #fff5f4;
border-color: #f5c4bc;
color: var(--color-primary);
}
.deco-card {
margin-bottom: 14px;
padding: 20px 16px;
border-radius: var(--radius-xl);
background: linear-gradient(145deg, #fff0ec, #ffe4dc);
text-align: center;
overflow: hidden;
}
.deco-blur {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
filter: blur(0.5px);
}
.deco-av {
width: 56px;
height: 56px;
border-radius: 50%;
background: linear-gradient(145deg, #ffd0c4, #ffb8a8);
opacity: 0.85;
}
.deco-av.ta { background: linear-gradient(145deg, #d8eaff, #b4d2f5); }
.deco-heart { font-size: 20px; color: var(--color-primary); opacity: 0.7; }
.deco-hint { margin-top: 12px; font-size: 12px; color: #8a6a5a; line-height: 1.45; }
.form-card { margin-top: 4px; }
.rel-chips { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 4px; }
.rchip {
padding: 8px 16px;
border-radius: var(--radius-pill);
border: 1px solid #eee;
background: #fafafa;
font-size: 13px;
color: #666;
}
.rchip.on {
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
color: #fff;
border-color: transparent;
}
.cta {
margin-top: 14px;
width: 100%;
padding: 13px;
border: none;
border-radius: 22px;
color: #fff;
font-size: 15px;
font-weight: 600;
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
box-shadow: 0 6px 16px rgba(229, 77, 66, 0.28);
}
.cta:disabled { opacity: 0.45; }
.yxg-label { display: block; margin: 12px 0 6px; font-size: 12px; color: #999; }
.yxg-label:first-child { margin-top: 0; }
</style>
@@ -0,0 +1,237 @@
<template>
<div>
<p class="diff-lead">{{ diff }}</p>
<div class="mode-tabs" role="tablist">
<button
v-for="t in resultTabs"
:key="t.key"
type="button"
class="mode-tab"
:class="{ on: tab === t.key }"
@click="$emit('update:tab', t.key)"
>
{{ t.label }}
</button>
</div>
<template v-if="tab === 'index'">
<div class="pair-spot">
<div class="who"><span class="tag"></span><strong>{{ meStyle || '—' }}</strong></div>
<span class="vs">×</span>
<div class="who"><span class="tag">TA</span><strong>{{ otherStyle || '—' }}</strong></div>
</div>
<div class="spotlight-row">
<div v-if="harmony != null" class="spot big">
<em>默契指数</em>
<strong>{{ harmony }}</strong>
</div>
<div v-if="fitLabel" class="spot badge-spot">
<span class="fit-badge">{{ fitLabel }}</span>
</div>
</div>
<div class="indices">
<div class="idx"><em>恋爱</em><strong>{{ loveIndex ?? '—' }}</strong></div>
<div class="idx"><em>友情</em><strong>{{ friendIndex ?? '—' }}</strong></div>
<div class="idx"><em>婚姻</em><strong>{{ marriageIndex ?? '—' }}</strong></div>
</div>
</template>
<template v-else-if="tab === 'compare'">
<MatchCompare
:me-style="meStyle"
:other-style="otherStyle"
:fit-label="fitLabel"
:harmony="harmony"
:love="loveIndex"
:friend="friendIndex"
:marriage="marriageIndex"
:star-compare="starCompare"
:dimensions="dimCompare"
/>
</template>
<p v-else class="tips-intro">基于你们的档案以下是相处与沟通建议</p>
<ReportRich :summary="summary" :detail="detail" :deep="!!report.has_deep_access" />
<div v-if="!report.has_deep_access" class="lock-card">
<p>完整相处建议冲突修复与本周练习可在深度版或成长会员中查看</p>
<button class="cta soft" type="button" :disabled="paying" @click="$emit('buyDeep')">解锁完整分析</button>
</div>
<div class="sticky-bar">
<button type="button" class="sb-btn" @click="$emit('share')">分享</button>
<router-link class="sb-btn primary" to="/ask">去问答</router-link>
<router-link class="sb-btn" to="/synastry">合盘</router-link>
</div>
</div>
</template>
<script setup lang="ts">
import type { GrowthReport } from '@yuxingu/types'
import MatchCompare from '../MatchCompare.vue'
import ReportRich from '../ReportRich.vue'
import { resultTabs } from '../../composables/useRelationPage'
defineProps<{
tab: 'index' | 'compare' | 'tips'
diff: string
meStyle: string
otherStyle: string
fitLabel: string
harmony: number | null
loveIndex: number | null
friendIndex: number | null
marriageIndex: number | null
starCompare: { key: string; title: string; me: string; other: string; note?: string }[]
dimCompare: { key: string; title: string; me_score?: number; other_score?: number; note?: string }[]
summary: Record<string, unknown>
detail: Record<string, unknown> | null
report: GrowthReport
paying: boolean
}>()
defineEmits<{
'update:tab': [value: 'index' | 'compare' | 'tips']
buyDeep: []
share: []
}>()
</script>
<style scoped>
.diff-lead {
font-size: 15px;
font-weight: 600;
color: #444;
line-height: 1.5;
margin-bottom: 12px;
}
.pair-spot {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 12px;
padding: 14px;
border-radius: var(--radius-xl);
background: #fafafa;
}
.pair-spot .who { text-align: center; flex: 1; }
.pair-spot .tag {
display: block;
font-size: 11px;
color: #999;
margin-bottom: 4px;
}
.pair-spot strong { font-size: 14px; color: #222; }
.pair-spot .vs { font-size: 18px; color: var(--color-primary); opacity: 0.6; }
.mode-tabs {
display: flex;
gap: 4px;
margin-bottom: 14px;
background: #fafafa;
border-radius: var(--radius-pill);
padding: 4px;
}
.mode-tab {
flex: 1;
border: none;
background: transparent;
padding: 9px 4px;
border-radius: var(--radius-pill);
font-size: 13px;
color: #888;
font-weight: 600;
}
.mode-tab.on {
background: #fff;
color: var(--color-primary);
box-shadow: 0 2px 8px rgba(229, 77, 66, 0.1);
}
.spotlight-row { display: flex; gap: 10px; margin-bottom: 12px; }
.spot {
flex: 1;
padding: 16px;
border-radius: var(--radius-xl);
background: linear-gradient(145deg, #fff0ec, #ffe4dc);
text-align: center;
box-shadow: var(--shadow-card);
}
.spot em { display: block; font-style: normal; font-size: 12px; color: #999; margin-bottom: 4px; }
.spot strong { font-size: 32px; font-family: var(--font-display); color: var(--color-primary); }
.badge-spot { display: flex; align-items: center; justify-content: center; }
.fit-badge {
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
color: #fff;
font-size: 13px;
font-weight: 700;
padding: 8px 14px;
border-radius: var(--radius-pill);
}
.indices {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin-bottom: 12px;
}
.idx {
background: #fff5f4;
border: 1px solid #ffe0dc;
border-radius: 14px;
padding: 12px 8px;
text-align: center;
}
.idx em { display: block; font-style: normal; font-size: 11px; color: #999; margin-bottom: 4px; }
.idx strong { font-size: 22px; color: var(--color-primary); font-family: var(--font-display); }
.lock-card {
margin-top: 12px;
padding: 16px;
background: linear-gradient(145deg, #fff8e8, #ffeec8);
border-radius: var(--radius-xl);
}
.cta.soft {
margin-top: 14px;
width: 100%;
padding: 13px;
border: 1.5px solid #f5c4bc;
border-radius: 22px;
color: var(--color-primary);
font-size: 15px;
font-weight: 600;
background: #fff;
box-shadow: none;
}
.cta:disabled { opacity: 0.45; }
.tips-intro { font-size: 13px; color: #888; margin-bottom: 12px; line-height: 1.5; }
.sticky-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 20;
display: flex;
gap: 8px;
padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(12px);
border-top: 1px solid #f0ebe8;
}
.sb-btn {
flex: 1;
padding: 11px 8px;
border-radius: 22px;
border: 1.5px solid #f0ebe8;
background: #fff;
font-size: 13px;
font-weight: 600;
color: #555;
text-align: center;
text-decoration: none;
}
.sb-btn.primary {
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
color: #fff;
border: none;
}
</style>
@@ -0,0 +1,56 @@
<template>
<div class="report-body">
<DecodePanel
v-if="reportType === 'portrait'"
:summary="summary"
:detail="detail"
:deep="deep"
:paying="paying"
@unlock="$emit('buy-deep')"
/>
<ReportRich v-else :summary="summary" :detail="detail" :deep="deep" />
<div v-if="!deep && reportType !== 'portrait'" class="lock-card">
<p>完整分析可在深度版或成长会员中查看</p>
<button class="cta soft" type="button" :disabled="paying" @click="$emit('buy-deep')">解锁深度版</button>
</div>
</div>
</template>
<script setup lang="ts">
import DecodePanel from '../DecodePanel.vue'
import ReportRich from '../ReportRich.vue'
defineProps<{
reportType: string
summary: Record<string, unknown>
detail: Record<string, unknown> | null
deep: boolean
paying: boolean
}>()
defineEmits<{
'buy-deep': []
}>()
</script>
<style scoped>
.lock-card {
margin-top: 12px;
padding: 16px;
background: linear-gradient(145deg, #fff8e8, #ffeec8);
border-radius: var(--radius-xl);
}
.cta.soft {
margin-top: 12px;
width: 100%;
padding: 12px;
border: 1.5px solid #f5c4bc;
border-radius: 22px;
background: #fff;
color: var(--color-primary);
font-size: 14px;
font-weight: 600;
}
.cta.soft:disabled { opacity: 0.45; }
</style>
@@ -0,0 +1,83 @@
<template>
<div class="report-head-block">
<div class="report-head">
<span class="type-badge">{{ typeLabel }}</span>
<span class="date-badge">{{ createdLabel }}</span>
</div>
<h2 class="hl">{{ headline || oneLiner || '成长报告' }}</h2>
<p v-if="oneLiner && headline" class="lead">{{ oneLiner }}</p>
<router-link class="vip-strip" to="/membership">
<span class="vip-ico" aria-hidden="true"></span>
<span class="vip-copy">
<strong>成长会员</strong>
<em>深度报告 · 全年节气陪伴 · 解锁完整分析</em>
</span>
<span class="vip-go">开通 </span>
</router-link>
</div>
</template>
<script setup lang="ts">
defineProps<{
typeLabel: string
createdLabel: string
headline: string
oneLiner: string
}>()
</script>
<style scoped>
.report-head { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; }
.type-badge {
font-size: 11px;
font-weight: 700;
color: #fff;
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
padding: 4px 10px;
border-radius: var(--radius-pill);
}
.date-badge {
font-size: 11px;
color: #999;
padding: 4px 10px;
border-radius: var(--radius-pill);
background: #fafafa;
border: 1px solid #f0ebe8;
}
.hl {
font-family: var(--font-display);
font-size: 20px;
font-weight: 700;
line-height: 1.35;
color: #222;
}
.lead { margin-top: 6px; font-size: 13px; color: #777; line-height: 1.55; }
.vip-strip {
display: flex;
align-items: center;
gap: 10px;
margin: 14px 0;
padding: 12px 14px;
border-radius: var(--radius-lg);
background: linear-gradient(145deg, #fff4e0, #ffe8b8);
box-shadow: 0 4px 14px rgba(200, 146, 58, 0.12);
color: inherit;
}
.vip-ico {
width: 32px;
height: 32px;
border-radius: 10px;
background: linear-gradient(135deg, #f0c86a, #e8a830);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
flex-shrink: 0;
}
.vip-copy { flex: 1; min-width: 0; }
.vip-copy strong { display: block; font-size: 14px; color: #222; }
.vip-copy em { display: block; font-style: normal; font-size: 11px; color: #8a7040; margin-top: 2px; }
.vip-go { font-size: 13px; font-weight: 600; color: #b07a28; flex-shrink: 0; }
</style>
@@ -0,0 +1,33 @@
<template>
<div class="indices">
<div class="idx"><em>恋爱</em><strong>{{ loveIndex }}</strong></div>
<div class="idx"><em>友情</em><strong>{{ friendIndex }}</strong></div>
<div class="idx"><em>婚姻</em><strong>{{ marriageIndex }}</strong></div>
</div>
</template>
<script setup lang="ts">
defineProps<{
loveIndex: number | null
friendIndex: number | null
marriageIndex: number | null
}>()
</script>
<style scoped>
.indices {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin: 12px 0;
}
.idx {
background: #fff5f4;
border: 1px solid #ffe0dc;
border-radius: 14px;
padding: 12px 8px;
text-align: center;
}
.idx em { display: block; font-style: normal; font-size: 11px; color: #999; margin-bottom: 4px; }
.idx strong { font-size: 22px; color: var(--color-primary); font-family: var(--font-display); }
</style>
@@ -0,0 +1,56 @@
<template>
<div class="mode-tabs" role="tablist">
<button
v-for="p in panels"
:key="p.key"
type="button"
class="mode-tab"
:class="{ on: modelValue === p.key }"
@click="$emit('update:modelValue', p.key)"
>
{{ p.label }}
</button>
</div>
</template>
<script setup lang="ts">
defineProps<{
modelValue: string
panels: { key: string; label: string }[]
}>()
defineEmits<{
'update:modelValue': [v: string]
}>()
</script>
<style scoped>
.mode-tabs {
display: flex;
gap: 4px;
margin-bottom: 14px;
background: #fafafa;
border-radius: var(--radius-pill);
padding: 4px;
overflow-x: auto;
scrollbar-width: none;
}
.mode-tabs::-webkit-scrollbar { display: none; }
.mode-tab {
flex: 1;
min-width: 52px;
border: none;
background: transparent;
padding: 9px 6px;
border-radius: var(--radius-pill);
font-size: 12px;
color: #888;
font-weight: 600;
white-space: nowrap;
}
.mode-tab.on {
background: #fff;
color: var(--color-primary);
box-shadow: 0 2px 8px rgba(229, 77, 66, 0.1);
}
</style>
@@ -0,0 +1,117 @@
<template>
<div class="report-star">
<template v-if="panelKey === 'chart'">
<NatalWheel
v-if="wheelPlanets.length"
:planets="wheelPlanets"
:aspects="aspectPreview"
:asc-lon="ascLon"
/>
<div v-if="transits.length" class="yxg-card soft">
<h2 class="card-title">今日行运</h2>
<p v-for="t in transits" :key="t.key" class="yxg-meta">{{ t.title }} · {{ t.aspect }}{{ t.tip }}</p>
</div>
<SignCards v-if="signCards.length" :model-value="signTab" :cards="signCards" @update:model-value="$emit('update:signTab', $event)" />
<div v-if="activeCard" class="spotlight">
<h3>{{ activeCard.title }} · {{ activeCard.label }}</h3>
<p>{{ activeCard.teaser }}</p>
</div>
</template>
<template v-else-if="panelKey === 'fortune'">
<div class="fortune-tabs">
<button
v-for="k in fortuneKeys"
:key="k"
type="button"
class="ftab"
:class="{ on: fortuneKey === k }"
@click="$emit('update:fortuneKey', k)"
>
{{ fortuneLabel(k) }}
</button>
</div>
<div v-if="activeFortune" class="spotlight">
<p class="ft-title">
{{ activeFortune.title }} · {{ activeFortune.label }} · {{ activeFortune.score }}
</p>
<p>{{ activeFortune.tip }}</p>
</div>
<p v-else class="yxg-hint">暂无运势数据</p>
</template>
<ul v-else class="planet-list">
<li v-for="p in planets" :key="p.key" class="planet-row">
<strong>{{ p.title }}</strong>
<span>{{ p.sign }} {{ p.degree }} · {{ p.house }}</span>
</li>
</ul>
</div>
</template>
<script setup lang="ts">
import NatalWheel, { type WheelAspect, type WheelPlanet } from '../NatalWheel.vue'
import SignCards, { type SignCard } from '../SignCards.vue'
import { fortuneLabel } from '../../composables/useStarProfilePage'
import { reportFortuneKeys } from '../../composables/useReportPage'
type FortunePeriod = { title?: string; label?: string; score?: number; tip?: string }
type PlanetRow = { key: string; title: string; sign: string; degree: string; house: number }
defineProps<{
panelKey: string
wheelPlanets: WheelPlanet[]
aspectPreview: WheelAspect[]
ascLon: number | null
transits: { key: string; title: string; aspect: string; tip: string }[]
signCards: SignCard[]
signTab: string
activeCard?: SignCard
fortuneKey: string
activeFortune: FortunePeriod | null
planets: PlanetRow[]
}>()
defineEmits<{
'update:signTab': [v: string]
'update:fortuneKey': [v: string]
}>()
const fortuneKeys = reportFortuneKeys
</script>
<style scoped>
.spotlight {
margin: 12px 0;
padding: 14px;
border-radius: var(--radius-xl);
background: linear-gradient(145deg, #fff0ec, #ffe4dc);
box-shadow: var(--shadow-card);
}
.spotlight h3 { font-size: 15px; margin-bottom: 6px; color: #222; }
.spotlight p { font-size: 13px; color: #555; line-height: 1.6; }
.fortune-tabs { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; }
.ftab {
flex: 1;
min-width: 52px;
border: 1px solid #eee;
background: #fafafa;
border-radius: 12px;
padding: 10px 4px;
font-size: 12px;
color: #666;
}
.ftab.on { border-color: var(--color-primary); background: #fff5f4; color: #222; font-weight: 600; }
.ft-title { font-weight: 700; color: #222; margin-bottom: 6px; }
.planet-list { list-style: none; padding: 0; margin: 0; }
.planet-row {
display: flex;
flex-direction: column;
gap: 4px;
padding: 14px;
margin-bottom: 8px;
background: #fafafa;
border-radius: 14px;
}
.planet-row strong { font-size: 14px; color: #222; }
.planet-row span { font-size: 12px; color: #888; }
.card-title { font-size: 16px; margin: 0 0 8px; }
</style>
@@ -0,0 +1,63 @@
<template>
<div class="sticky-bar">
<button type="button" class="sb-btn" @click="$emit('share')">分享</button>
<button
v-if="!deep"
class="sb-btn primary"
type="button"
:disabled="paying"
@click="$emit('buy-deep')"
>
解锁
</button>
<router-link v-if="relatedLink" class="sb-btn" :class="{ primary: deep }" :to="relatedLink.to">
{{ relatedLink.label }}
</router-link>
</div>
</template>
<script setup lang="ts">
defineProps<{
deep: boolean
paying: boolean
relatedLink: { to: string; label: string } | null
}>()
defineEmits<{
share: []
'buy-deep': []
}>()
</script>
<style scoped>
.sticky-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 20;
display: flex;
gap: 8px;
padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(12px);
border-top: 1px solid #f0ebe8;
}
.sb-btn {
flex: 1;
padding: 11px 8px;
border-radius: 22px;
border: 1.5px solid #f0ebe8;
background: #fff;
font-size: 13px;
font-weight: 600;
color: #555;
text-align: center;
text-decoration: none;
}
.sb-btn.primary {
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
color: #fff;
border: none;
}
</style>
@@ -0,0 +1,36 @@
<template>
<div class="report-synastry">
<p v-if="synActiveTip" class="yxg-meta">{{ synActiveTip }}</p>
<template v-if="panelKey === 'compare'">
<NatalWheel v-if="synPlanetsA.length" :planets="synPlanetsA" :aspects="[]" :asc-lon="synAscA" />
<NatalWheel v-if="synPlanetsB.length" :planets="synPlanetsB" :aspects="[]" :asc-lon="synAscB" />
</template>
<template v-else-if="panelKey === 'overlay'">
<p v-for="(e, i) in synOverlayEntries" :key="i" class="yxg-meta">
{{ e.planet }} {{ e.house }} · {{ e.house_tip }}
</p>
</template>
<NatalWheel
v-else-if="synActivePlanets.length"
:planets="synActivePlanets"
:aspects="[]"
:asc-lon="synActiveAsc"
/>
</div>
</template>
<script setup lang="ts">
import NatalWheel, { type WheelPlanet } from '../NatalWheel.vue'
defineProps<{
panelKey: string
synActiveTip: string
synPlanetsA: WheelPlanet[]
synPlanetsB: WheelPlanet[]
synAscA: number | null
synAscB: number | null
synOverlayEntries: { planet: string; house: number; house_tip: string }[]
synActivePlanets: WheelPlanet[]
synActiveAsc: number | null
}>()
</script>
@@ -0,0 +1,71 @@
<template>
<div>
<div class="intro-card">
<HomeToolIcon name="rhythm" :size="64" />
<h1 class="intro-title">身心节律</h1>
<p class="intro-sub">填写生日看见作息 · 饮食 · 活动的节奏建议生活方式参考非算命</p>
</div>
<div class="yxg-card form-card">
<label class="yxg-label">生日</label>
<BirthDateInputs
:year="year"
:month="month"
:day="day"
@update:year="$emit('update:year', $event)"
@update:month="$emit('update:month', $event)"
@update:day="$emit('update:day', $event)"
/>
<button class="cta" type="button" :disabled="loading" @click="$emit('start')">
{{ loading ? '生成中' : '生成我的节律' }}
</button>
<p v-if="error" class="yxg-err">{{ error }}</p>
</div>
</div>
</template>
<script setup lang="ts">
import BirthDateInputs from '../BirthDateInputs.vue'
import HomeToolIcon from '../HomeToolIcon.vue'
defineProps<{
year: string
month: string
day: string
loading: boolean
error: string
}>()
defineEmits<{
'update:year': [value: string]
'update:month': [value: string]
'update:day': [value: string]
start: []
}>()
</script>
<style scoped>
.intro-card { text-align: center; padding: 20px 12px 8px; }
.intro-title {
font-family: var(--font-display);
font-size: 24px;
font-weight: 700;
margin-top: 8px;
letter-spacing: 0.08em;
}
.intro-sub { font-size: 13px; color: #888; margin-top: 6px; line-height: 1.5; }
.form-card { margin-top: 12px; }
.cta {
margin-top: 14px;
width: 100%;
padding: 13px;
border: none;
border-radius: 22px;
color: #fff;
font-size: 15px;
font-weight: 600;
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
box-shadow: 0 6px 16px rgba(229, 77, 66, 0.28);
}
.cta:disabled { opacity: 0.45; }
.yxg-label { display: block; margin-bottom: 8px; font-size: 12px; color: #999; }
</style>
@@ -0,0 +1,235 @@
<template>
<div>
<div class="mode-tabs" role="tablist">
<button
v-for="t in resultTabs"
:key="t.key"
type="button"
class="mode-tab"
:class="{ on: tab === t.key }"
@click="$emit('update:tab', t.key)"
>
{{ t.label }}
</button>
</div>
<template v-if="tab === 'overview'">
<h2 class="hl">{{ headline || '我的身心节律' }}</h2>
<p v-if="oneLiner" class="lead">{{ oneLiner }}</p>
<div v-if="keywords.length" class="tags">
<span v-for="k in keywords" :key="k" class="tag">{{ k }}</span>
</div>
<div v-if="styleLabel" class="spotlight">
<strong>节律倾向</strong>
<p>{{ styleLabel }}</p>
</div>
<p v-if="overviewText" class="overview">{{ overviewText }}</p>
</template>
<template v-else-if="tab === 'wuxing'">
<WuxingBars v-if="wuxingBars.length" :bars="wuxingBars" />
<div v-else class="empty-soft">
<HomeToolIcon name="rhythm" :size="48" />
<p>暂无五行分布数据可从概览与建议了解节律倾向</p>
</div>
<ReportRich :summary="summary" :detail="null" :deep="false" />
</template>
<template v-else-if="tab === 'tips'">
<ReportRich :summary="summary" :detail="detail" :deep="!!report.has_deep_access" />
<div v-if="!report.has_deep_access" class="lock-card">
<p>完整习惯方案与本周练习可在深度版或成长会员中查看</p>
<button class="cta soft" type="button" :disabled="paying" @click="$emit('buyDeep')">解锁深度版</button>
</div>
</template>
<template v-else>
<router-link class="term-card" to="/companion">
<div class="term-ico" aria-hidden="true">
<HomeToolIcon name="companion" :size="40" />
</div>
<div class="term-body">
<strong>节气生活陪伴</strong>
<p>{{ solarTip || '结合当季节气,微调作息与饮食节奏。' }}</p>
</div>
<span class="term-go" aria-hidden="true"></span>
</router-link>
<p v-if="weekFocus" class="focus-tip">本周关注{{ weekFocus }}</p>
</template>
<div class="sticky-bar">
<router-link class="sb-btn primary" to="/companion">去节气陪伴</router-link>
<button
v-if="!report.has_deep_access"
class="sb-btn"
type="button"
:disabled="paying"
@click="$emit('buyDeep')"
>
解锁深度
</button>
<router-link class="sb-btn" to="/ask">去问答</router-link>
</div>
</div>
</template>
<script setup lang="ts">
import type { GrowthReport } from '@yuxingu/types'
import HomeToolIcon from '../HomeToolIcon.vue'
import ReportRich from '../ReportRich.vue'
import WuxingBars, { type WuxingBar } from '../WuxingBars.vue'
import { resultTabs } from '../../composables/useLifeRhythmPage'
defineProps<{
tab: 'overview' | 'wuxing' | 'tips' | 'term'
headline: string
oneLiner: string
styleLabel: string
overviewText: string
weekFocus: string
keywords: string[]
solarTip: string
wuxingBars: WuxingBar[]
summary: Record<string, unknown>
detail: Record<string, unknown> | null
report: GrowthReport
paying: boolean
}>()
defineEmits<{
'update:tab': [value: 'overview' | 'wuxing' | 'tips' | 'term']
buyDeep: []
}>()
</script>
<style scoped>
.mode-tabs {
display: flex;
gap: 4px;
margin-bottom: 14px;
background: #fafafa;
border-radius: var(--radius-pill);
padding: 4px;
}
.mode-tab {
flex: 1;
border: none;
background: transparent;
padding: 9px 4px;
border-radius: var(--radius-pill);
font-size: 13px;
color: #888;
font-weight: 600;
}
.mode-tab.on {
background: #fff;
color: var(--color-primary);
box-shadow: 0 2px 8px rgba(229, 77, 66, 0.1);
}
.hl {
font-family: var(--font-display);
font-size: 20px;
font-weight: 700;
line-height: 1.35;
color: #222;
}
.lead { margin-top: 8px; font-size: 13px; color: #777; line-height: 1.55; }
.tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
.tag {
font-size: 11px;
padding: 4px 10px;
border-radius: var(--radius-pill);
background: rgba(255, 255, 255, 0.8);
color: #8a4a3a;
font-weight: 600;
border: 1px solid #f5e8e4;
}
.spotlight {
margin-top: 14px;
padding: 14px;
border-radius: var(--radius-xl);
background: linear-gradient(145deg, #fff0ec, #ffe4dc);
box-shadow: var(--shadow-card);
}
.spotlight strong { font-size: 14px; color: #222; }
.spotlight p { margin-top: 6px; font-size: 13px; color: #666; line-height: 1.5; }
.overview { margin-top: 12px; font-size: 13px; color: #555; line-height: 1.65; }
.empty-soft {
text-align: center;
padding: 24px 16px;
border-radius: var(--radius-xl);
background: #fafafa;
color: #999;
font-size: 13px;
line-height: 1.5;
margin-bottom: 12px;
}
.empty-soft p { margin-top: 10px; }
.lock-card {
margin-top: 12px;
padding: 16px;
background: linear-gradient(145deg, #fff8e8, #ffeec8);
border-radius: var(--radius-xl);
}
.cta.soft {
margin-top: 14px;
width: 100%;
padding: 13px;
border: 1.5px solid #f5c4bc;
border-radius: 22px;
color: var(--color-primary);
font-size: 15px;
font-weight: 600;
background: #fff;
box-shadow: none;
}
.cta:disabled { opacity: 0.45; }
.term-card {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border-radius: var(--radius-xl);
background: linear-gradient(145deg, #fff0ec, #ffe4dc);
box-shadow: var(--shadow-card);
color: inherit;
}
.term-body { flex: 1; min-width: 0; }
.term-body strong { font-size: 15px; color: #222; }
.term-body p { margin-top: 4px; font-size: 12px; color: #666; line-height: 1.45; }
.term-go { font-size: 20px; color: var(--color-primary); }
.focus-tip { margin-top: 12px; font-size: 13px; color: #888; }
.sticky-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 20;
display: flex;
gap: 8px;
padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(12px);
border-top: 1px solid #f0ebe8;
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.06);
}
.sb-btn {
flex: 1;
padding: 11px 8px;
border-radius: 22px;
border: 1.5px solid #f0ebe8;
background: #fff;
font-size: 13px;
font-weight: 600;
color: #555;
text-align: center;
text-decoration: none;
}
.sb-btn.primary {
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
color: #fff;
border: none;
box-shadow: 0 4px 12px rgba(229, 77, 66, 0.25);
}
.sb-btn:disabled { opacity: 0.45; }
</style>
@@ -0,0 +1,138 @@
<template>
<div class="answer-screen">
<p v-if="draftRestored && currentIdx === 0" class="yxg-hint draft">已恢复上次未提交的作答</p>
<div class="progress-wrap">
<div class="progress-bar">
<i class="progress-fill" :style="{ width: progressPct + '%' }" />
</div>
<span class="progress-label">{{ currentIdx + 1 }} / {{ totalQuestions }}</span>
</div>
<div v-if="currentQuestion" class="q-card yxg-card">
<p class="prompt">{{ currentQuestion.body.prompt }}</p>
<label
v-for="opt in currentQuestion.body.options"
:key="opt.key"
class="opt"
:class="{ picked: answers[currentQuestion.id] === opt.key }"
>
<input
type="radio"
:name="`q-${currentQuestion.id}`"
:value="opt.key"
:checked="answers[currentQuestion.id] === opt.key"
@change="onPick(opt.key)"
/>
<span class="opt-text">{{ opt.text }}</span>
</label>
</div>
<p v-if="submitError" class="yxg-err">{{ submitError }}</p>
<div v-if="needProfile" class="need">
<p>提交结果需要先有的个人档案</p>
<router-link class="yxg-btn" to="/">去首页创建档案</router-link>
</div>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
draftRestored: boolean
currentIdx: number
progressPct: number
totalQuestions: number
currentQuestion: { id: string; body: { prompt: string; options: { key: string; text: string }[] } } | null
answers: Record<string, string>
submitError: string
needProfile: boolean
}>()
const emit = defineEmits<{
answer: [questionId: string, key: string]
}>()
function onPick(key: string) {
if (!props.currentQuestion) return
emit('answer', props.currentQuestion.id, key)
}
</script>
<style scoped>
.answer-screen {
padding-bottom: 8px;
}
.progress-wrap {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
}
.progress-bar {
flex: 1;
height: 6px;
border-radius: 999px;
background: #f0ebe7;
overflow: hidden;
}
.progress-fill {
display: block;
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, #ff7a6e, var(--color-primary));
transition: width 0.3s ease;
}
.progress-label {
font-size: 12px;
font-weight: 600;
color: var(--color-text-tertiary);
flex-shrink: 0;
}
.q-card {
margin: 0 0 12px;
}
.prompt {
font-weight: 650;
font-size: 17px;
color: #222;
margin-bottom: 16px;
line-height: 1.5;
}
.opt {
display: flex;
align-items: flex-start;
gap: 10px;
margin: 10px 0;
padding: 14px 12px;
border-radius: 14px;
border: 1.5px solid #eee;
background: #fdfaf8;
cursor: pointer;
line-height: 1.45;
transition: border-color 0.15s ease, background 0.15s ease;
}
.opt.picked {
border-color: var(--color-primary);
background: #fff5f3;
}
.opt input {
margin-top: 3px;
accent-color: var(--color-primary);
}
.opt-text {
flex: 1;
font-size: 14px;
}
.need {
margin-top: 10px;
padding: 12px;
background: #fff8f7;
border-radius: 12px;
}
.need .yxg-btn {
margin-top: 10px;
}
.draft {
color: var(--yxg-gold);
margin-bottom: 8px;
}
</style>
@@ -0,0 +1,76 @@
<template>
<div class="intro-screen">
<div class="intro-card">
<HomeToolIcon name="mbti" :size="64" />
<h2 class="intro-title">{{ title || '探索测试' }}</h2>
<p v-if="description" class="intro-desc">{{ description }}</p>
<p v-if="questionCount" class="intro-count"> {{ questionCount }} · {{ estMinutes }} 分钟</p>
<p v-if="draftRestored" class="draft-hint">检测到上次未完成的作答开始后将继续</p>
<button class="yxg-btn yxg-btn-block intro-start" type="button" :disabled="!questionCount" @click="$emit('start')">
开始
</button>
</div>
</div>
</template>
<script setup lang="ts">
import HomeToolIcon from '../HomeToolIcon.vue'
defineProps<{
title: string
description: string
questionCount: number
estMinutes: number
draftRestored: boolean
}>()
defineEmits<{ start: [] }>()
</script>
<style scoped>
.intro-screen {
display: flex;
justify-content: center;
padding: 24px 0 32px;
}
.intro-card {
width: 100%;
max-width: 340px;
text-align: center;
padding: 32px 24px;
background: var(--color-surface);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-hero);
}
.intro-title {
font-family: var(--font-display);
font-size: 22px;
font-weight: 700;
color: var(--color-text-primary);
margin: 16px 0 8px;
letter-spacing: 0.04em;
}
.intro-desc {
font-size: 14px;
color: var(--color-text-secondary);
line-height: 1.55;
margin: 0 0 12px;
}
.intro-count {
font-size: 12px;
color: var(--color-text-tertiary);
margin: 0 0 20px;
}
.draft-hint {
font-size: 12px;
color: var(--color-accent-gold);
margin: 0 0 12px;
}
.intro-start {
font-size: 16px;
font-weight: 700;
padding: 14px;
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
box-shadow: 0 6px 18px rgba(229, 77, 66, 0.28);
}
</style>
@@ -0,0 +1,68 @@
<template>
<div class="nav-bar">
<button type="button" class="nav-btn prev" :disabled="currentIdx === 0" @click="$emit('prev')">上一题</button>
<button
type="button"
class="nav-btn next"
:disabled="!hasAnswer || submitting"
@click="$emit('next')"
>
{{ isLast ? (submitting ? '提交中…' : '查看探索结果') : '下一题' }}
</button>
</div>
</template>
<script setup lang="ts">
defineProps<{
currentIdx: number
hasAnswer: boolean
isLast: boolean
submitting: boolean
}>()
defineEmits<{ prev: []; next: [] }>()
</script>
<style scoped>
.nav-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 20;
display: flex;
gap: 10px;
padding: 10px 16px calc(10px + env(safe-area-inset-bottom, 0));
background: rgba(255, 255, 255, 0.94);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-top: 1px solid rgba(0, 0, 0, 0.06);
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.06);
}
.nav-btn {
flex: 1;
padding: 13px;
border-radius: 22px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
border: none;
}
.nav-btn.prev {
background: #f3eeea;
color: #555;
}
.nav-btn.prev:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.nav-btn.next {
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
color: #fff;
box-shadow: 0 4px 14px rgba(229, 77, 66, 0.25);
}
.nav-btn.next:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>
@@ -0,0 +1,44 @@
<template>
<div>
<p class="result-lead">{{ resultLabel }}</p>
<ReportRich :summary="resultSummaryObj" :detail="resultDetailObj" :deep="true" />
<div class="yxg-card">
<p v-if="shareLine" class="share">{{ shareLine }}</p>
<button type="button" class="yxg-btn yxg-btn-ghost" @click="$emit('share')">生成分享卡</button>
<router-link class="yxg-link-plain link" to="/relation">用结果去做关系理解 </router-link>
<router-link class="yxg-link-plain link" to="/ask">去问答聊聊 </router-link>
</div>
</div>
</template>
<script setup lang="ts">
import ReportRich from '../ReportRich.vue'
defineProps<{
resultLabel: string
shareLine: string
resultSummaryObj: Record<string, unknown>
resultDetailObj: Record<string, unknown>
}>()
defineEmits<{ share: [] }>()
</script>
<style scoped>
.result-lead {
font-family: var(--font-display);
font-size: 20px;
font-weight: 700;
color: #222;
margin: 4px 0 12px;
letter-spacing: 0.04em;
}
.share {
margin: 0 0 10px;
color: var(--yxg-gold);
}
.link {
display: block;
margin-top: 12px;
}
</style>
@@ -0,0 +1,85 @@
<template>
<div class="star-birth-form">
<div class="intro-card">
<HomeToolIcon name="star" :size="64" />
<h1 class="intro-title">星座性格</h1>
<p class="intro-sub">填写生日看见太阳 · 月亮 · 上升与行星画像</p>
</div>
<div class="yxg-card form-card">
<label class="yxg-label">生日 / 出生时可选</label>
<div class="birth-row">
<BirthDateInputs :year="year" :month="month" :day="day" compact @update:year="$emit('update:year', $event)" @update:month="$emit('update:month', $event)" @update:day="$emit('update:day', $event)" />
<input class="bd-time" :value="birthTime" type="time" aria-label="出生时" @input="$emit('update:birthTime', ($event.target as HTMLInputElement).value)" />
</div>
<label class="yxg-label">出生地可选</label>
<BirthPlaceSelect :model-value="birthPlace" @update:model-value="$emit('update:birthPlace', $event)" />
<button class="cta" type="button" :disabled="loading" @click="$emit('start')">
{{ loading ? '生成中' : '生成我的星座' }}
</button>
<p v-if="error" class="yxg-err">{{ error }}</p>
</div>
</div>
</template>
<script setup lang="ts">
import BirthDateInputs from '../BirthDateInputs.vue'
import BirthPlaceSelect from '../BirthPlaceSelect.vue'
import HomeToolIcon from '../HomeToolIcon.vue'
defineProps<{
year: string
month: string
day: string
birthTime: string
birthPlace: string
loading: boolean
error: string
}>()
defineEmits<{
'update:year': [v: string]
'update:month': [v: string]
'update:day': [v: string]
'update:birthTime': [v: string]
'update:birthPlace': [v: string]
start: []
}>()
</script>
<style scoped>
.intro-card { text-align: center; padding: 20px 12px 8px; }
.intro-title {
font-family: var(--font-display);
font-size: 24px;
font-weight: 700;
margin-top: 8px;
letter-spacing: 0.08em;
}
.intro-sub { font-size: 13px; color: #888; margin-top: 6px; line-height: 1.5; }
.form-card { margin-top: 12px; }
.cta {
margin-top: 14px;
width: 100%;
padding: 13px;
border: none;
border-radius: 22px;
color: #fff;
font-size: 15px;
font-weight: 600;
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
box-shadow: 0 6px 16px rgba(229, 77, 66, 0.28);
}
.cta:disabled { opacity: 0.45; }
.birth-row { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
.bd-time {
width: 108px;
padding: 10px 6px;
border: 1.5px solid #eee;
border-radius: 12px;
font-size: 14px;
text-align: center;
background: #fdfaf8;
}
.yxg-label { display: block; margin: 12px 0 6px; font-size: 12px; color: #999; }
.yxg-label:first-child { margin-top: 0; }
</style>
@@ -0,0 +1,63 @@
<template>
<div class="star-chart">
<div class="wheel-settings">
<label><input :checked="showOuter" type="checkbox" @change="$emit('update:showOuter', ($event.target as HTMLInputElement).checked)" /> 外行星</label>
<label><input :checked="tightOrb" type="checkbox" @change="$emit('update:tightOrb', ($event.target as HTMLInputElement).checked)" /> 紧容许度</label>
</div>
<NatalWheel
v-if="wheelPlanets.length"
:planets="wheelPlanets"
:aspects="aspectLines"
:asc-lon="ascLon"
:show-outer="showOuter"
:tight-orb="tightOrb"
@select="$emit('wheel-select', $event)"
/>
<div v-if="houses.length" class="yxg-card soft">
<h2 class="card-title">宫位</h2>
<p class="house-row" v-for="h in houses" :key="h.num">{{ h.num }} · {{ h.sign }}</p>
</div>
<div v-if="aspectPreview.length" class="yxg-card soft">
<h2 class="card-title">相位速览</h2>
<p v-for="(a, i) in aspectPreview" :key="i" class="aspect-line">
{{ a.label || `${a.a_title}${a.type}${a.b_title}` }}
</p>
</div>
</div>
</template>
<script setup lang="ts">
import NatalWheel, { type WheelAspect, type WheelPlanet } from '../NatalWheel.vue'
type AspectRow = WheelAspect & { a_title?: string; b_title?: string; label?: string }
type HouseRow = { num: number; sign: string }
defineProps<{
showOuter: boolean
tightOrb: boolean
wheelPlanets: WheelPlanet[]
aspectLines: WheelAspect[]
ascLon: number | null
houses: HouseRow[]
aspectPreview: AspectRow[]
}>()
defineEmits<{
'update:showOuter': [v: boolean]
'update:tightOrb': [v: boolean]
'wheel-select': [key: string]
}>()
</script>
<style scoped>
.wheel-settings {
display: flex;
gap: 16px;
font-size: 12px;
color: #666;
margin: 4px 0 8px;
justify-content: center;
}
.house-row, .aspect-line { font-size: 13px; color: #555; margin: 4px 0; }
.card-title { font-size: 16px; margin: 0 0 8px; }
</style>
@@ -0,0 +1,80 @@
<template>
<div class="star-fortune">
<div class="fortune-tabs">
<button
v-for="k in fortuneKeys"
:key="k"
type="button"
class="ftab"
:class="{ on: fortuneKey === k }"
@click="$emit('update:fortuneKey', k)"
>
{{ fortuneLabel(k) }}
</button>
</div>
<div v-if="activeFortune" class="fortune-card">
<p class="daily-title">
{{ activeFortune.title }} · {{ activeFortune.label }} · {{ activeFortune.score }}
</p>
<p class="focus">焦点{{ activeFortune.focus }}</p>
<p>{{ activeFortune.tip }}</p>
<div v-if="activeFortune.dims" class="dims">
<span>感情 {{ activeFortune.dims.love }}</span>
<span>事业 {{ activeFortune.dims.career }}</span>
<span>财运 {{ activeFortune.dims.money }}</span>
<span>心情 {{ activeFortune.dims.mood }}</span>
</div>
<p class="yxg-meta">幸运{{ activeFortune.lucky }}</p>
<p class="yxg-meta">注意{{ activeFortune.caution }}</p>
</div>
</div>
</template>
<script setup lang="ts">
import { fortuneLabel } from '../../composables/useStarProfilePage'
type FortunePeriod = {
title?: string
label?: string
score?: number
tip?: string
focus?: string
lucky?: string
caution?: string
dims?: { love?: number; career?: number; money?: number; mood?: number }
}
defineProps<{
fortuneKey: string
fortuneKeys: readonly string[]
activeFortune: FortunePeriod | null
}>()
defineEmits<{
'update:fortuneKey': [v: string]
}>()
</script>
<style scoped>
.fortune-tabs { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; }
.ftab {
flex: 1;
min-width: 52px;
border: 1px solid #eee;
background: #fafafa;
border-radius: 12px;
padding: 10px 4px;
font-size: 12px;
color: #666;
}
.ftab.on {
border-color: var(--color-primary);
background: #fff5f4;
color: #222;
font-weight: 600;
}
.fortune-card { padding: 16px; border-radius: var(--radius-xl); background: #fafafa; }
.daily-title { font-weight: 700; color: #222; margin-bottom: 6px; }
.focus { margin: 0 0 8px; color: #666; }
.dims { display: flex; flex-wrap: wrap; gap: 10px; margin: 10px 0; font-size: 12px; color: #555; }
</style>
@@ -0,0 +1,51 @@
<template>
<div class="mode-tabs" role="tablist">
<button
v-for="p in tabs"
:key="p.key"
type="button"
class="mode-tab"
:class="{ on: modelValue === p.key }"
@click="$emit('update:modelValue', p.key)"
>
{{ p.label }}
</button>
</div>
</template>
<script setup lang="ts">
defineProps<{
modelValue: string
tabs: { key: string; label: string }[]
}>()
defineEmits<{
'update:modelValue': [v: string]
}>()
</script>
<style scoped>
.mode-tabs {
display: flex;
gap: 4px;
margin-bottom: 14px;
background: #fafafa;
border-radius: var(--radius-pill);
padding: 4px;
}
.mode-tab {
flex: 1;
border: none;
background: transparent;
padding: 9px 4px;
border-radius: var(--radius-pill);
font-size: 13px;
color: #888;
font-weight: 600;
}
.mode-tab.on {
background: #fff;
color: var(--color-primary);
box-shadow: 0 2px 8px rgba(229, 77, 66, 0.1);
}
</style>
@@ -0,0 +1,155 @@
<template>
<div class="star-overview">
<h2 class="hl">{{ headline || '我的星座画像' }}</h2>
<p v-if="oneLiner" class="lead">{{ oneLiner }}</p>
<div class="spotlight">
<div class="sp-head">
<strong>星象速览</strong>
<router-link class="sp-ai" to="/ask">AI 解读 </router-link>
</div>
<p class="sp-text">{{ chartNote || '行星落在不同星座,构成独特的你。' }}</p>
<div v-if="keywords.length" class="tags">
<span v-for="k in keywords.slice(0, 4)" :key="k" class="tag">{{ k }}</span>
</div>
</div>
<div class="planet-grid">
<button
v-for="(cell, i) in gridCells"
:key="cell.key + i"
type="button"
class="cell"
:class="{ center: cell.center, on: signTab === cell.key }"
@click="$emit('grid-tap', cell)"
>
<template v-if="cell.center">
<HomeToolIcon name="star" :size="40" />
</template>
<template v-else>
<strong class="nick">{{ cell.nick }}</strong>
<span class="role">{{ cell.role }}</span>
</template>
</button>
</div>
<div v-if="activeCard" class="detail-card">
<h3>{{ activeCard.title }} · {{ activeCard.label }}</h3>
<p>{{ activeCard.teaser }}</p>
<p v-if="activeCard.element" class="yxg-meta">{{ activeCard.element }} · {{ activeCard.modality }}</p>
</div>
<router-link class="ask-bar" to="/ask">
<span>AI本周我可以关注什么</span>
<span aria-hidden="true"></span>
</router-link>
</div>
</template>
<script setup lang="ts">
import HomeToolIcon from '../HomeToolIcon.vue'
import type { SignCard } from '../SignCards.vue'
defineProps<{
headline: string
oneLiner: string
chartNote: string
keywords: string[]
gridCells: { key: string; nick: string; role: string; center?: boolean }[]
signTab: string
activeCard?: SignCard
}>()
defineEmits<{
'grid-tap': [cell: { key: string; center?: boolean }]
}>()
</script>
<style scoped>
.hl {
font-family: var(--font-display);
font-size: 20px;
font-weight: 700;
line-height: 1.35;
color: #222;
}
.lead { margin-top: 8px; font-size: 13px; color: #777; line-height: 1.55; }
.spotlight {
margin-top: 14px;
padding: 14px;
border-radius: var(--radius-xl);
background: linear-gradient(145deg, #fff0ec, #ffe4dc);
box-shadow: var(--shadow-card);
}
.sp-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.sp-head strong { font-size: 14px; color: #222; }
.sp-ai { font-size: 12px; color: var(--color-primary); font-weight: 600; }
.sp-text { font-size: 12px; color: #666; line-height: 1.5; }
.tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
.tag {
font-size: 11px;
padding: 4px 10px;
border-radius: var(--radius-pill);
background: rgba(255, 255, 255, 0.8);
color: #8a4a3a;
font-weight: 600;
}
.planet-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin-top: 14px;
}
.cell {
min-height: 88px;
border: 1px solid #f0ebe8;
background: #fafafa;
border-radius: 16px;
padding: 12px 8px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
text-align: center;
}
.cell.on {
border-color: #f5c4bc;
background: #fff7f4;
box-shadow: var(--shadow-card);
}
.cell.center {
background: linear-gradient(160deg, #eef4ff, #ffe8e0);
border-color: transparent;
}
.nick { font-size: 14px; font-weight: 700; color: #222; line-height: 1.2; }
.role { font-size: 11px; color: var(--color-primary); font-weight: 600; }
.detail-card {
margin-top: 12px;
padding: 14px;
background: #fff;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
}
.detail-card h3 { font-size: 15px; margin-bottom: 6px; color: #222; }
.detail-card p { font-size: 13px; color: #555; line-height: 1.6; }
.ask-bar {
margin-top: 14px;
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 16px;
border-radius: var(--radius-pill);
background: #fff;
border: 1.5px solid #f0ebe8;
font-size: 13px;
font-weight: 600;
color: #555;
box-shadow: var(--shadow-card);
}
</style>
@@ -0,0 +1,33 @@
<template>
<ul class="planet-list">
<li v-for="p in planets" :key="p.key" class="planet-row" @click="$emit('select', p.key)">
<strong>{{ p.title }}</strong>
<span>{{ p.sign }} {{ p.degree }} · {{ p.house }}</span>
</li>
</ul>
</template>
<script setup lang="ts">
defineProps<{
planets: { key: string; title: string; sign: string; degree: string; house: number }[]
}>()
defineEmits<{
select: [key: string]
}>()
</script>
<style scoped>
.planet-list { list-style: none; margin: 0; padding: 0; }
.planet-row {
display: flex;
flex-direction: column;
gap: 4px;
padding: 14px;
margin-bottom: 8px;
background: #fafafa;
border-radius: 14px;
}
.planet-row strong { font-size: 14px; color: #222; }
.planet-row span { font-size: 12px; color: #888; }
</style>
@@ -0,0 +1,60 @@
<template>
<div class="star-footer">
<ReportRich :summary="summary" :detail="detail" :deep="deep" />
<div v-if="!deep" class="lock-card">
<p>完整相位与年运详解可在深度版或成长会员中查看</p>
<button class="cta soft" type="button" :disabled="paying" @click="$emit('buy-deep')">解锁完整分析</button>
</div>
<div class="bottom-links">
<router-link to="/synastry">合盘 </router-link>
<router-link to="/relation">人格匹配 </router-link>
<router-link v-if="reportId" :to="`/reports/${reportId}`">报告 </router-link>
</div>
</div>
</template>
<script setup lang="ts">
import ReportRich from '../ReportRich.vue'
defineProps<{
summary: Record<string, unknown>
detail: Record<string, unknown> | null
deep: boolean
paying: boolean
reportId?: string
}>()
defineEmits<{
'buy-deep': []
}>()
</script>
<style scoped>
.cta.soft {
margin-top: 14px;
width: 100%;
padding: 13px;
border: 1.5px solid #f5c4bc;
border-radius: 22px;
background: #fff;
color: var(--color-primary);
font-size: 15px;
font-weight: 600;
box-shadow: none;
}
.cta.soft:disabled { opacity: 0.45; }
.lock-card {
margin-top: 12px;
padding: 16px;
background: linear-gradient(145deg, #fff8e8, #ffeec8);
border-radius: var(--radius-xl);
}
.bottom-links {
display: flex;
gap: 16px;
margin: 16px 0 8px;
font-size: 13px;
font-weight: 600;
color: var(--color-primary);
}
</style>
+130
View File
@@ -0,0 +1,130 @@
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import type { AskMessage, AskQuota, Profile } from '@yuxingu/types'
import { api } from '../api/client'
export const askScenes = [
{ key: 'self', label: '我想更了解自己的性格与互动风格', prompt: '我想更了解自己的性格与互动风格。' },
{ key: 'relation', label: '和重要的人相处时该如何沟通?', prompt: '和重要的人相处时,我该如何更好地沟通?' },
{ key: 'emotion', label: '最近情绪有点乱,想梳理一下', prompt: '最近情绪有点乱,我想梳理一下。' },
{ key: 'career', label: '职业选择上可以注意什么?', prompt: '从我的特点看,职业选择上可以注意什么?' },
] as const
export const askQuickTools = [
{ to: '/portrait', label: '工具' },
{ to: '/synastry', label: '合盘' },
{ to: '/cards', label: '意象卡' },
{ to: '/relation', label: '匹配' },
]
export const askAdvisors = [
{ name: '心语', mark: '心', bg: 'linear-gradient(145deg,#ffd0c8,#ffb0a4)', meta: '好评率 98% · 情绪整理', tags: '文字沟通', scene: 'emotion' as const },
{ name: '明朗', mark: '明', bg: 'linear-gradient(145deg,#d6e8ff,#b0d0f5)', meta: '好评率 97% · 关系理解', tags: '文字沟通', scene: 'relation' as const },
{ name: '志远', mark: '志', bg: 'linear-gradient(145deg,#ffe6c8,#f5c98a)', meta: '好评率 96% · 职业探索', tags: '文字沟通', scene: 'career' as const },
]
export function useAskPage() {
const router = useRouter()
const rail = ref<'ai' | 'advisor'>('ai')
const bootLoading = ref(true)
const bootError = ref('')
const profiles = ref<Profile[]>([])
const profileId = ref('')
const scene = ref('self')
const threadId = ref('')
const messages = ref<AskMessage[]>([])
const draft = ref('')
const sending = ref(false)
const sendError = ref('')
const quotaExhausted = ref(false)
const quota = ref<AskQuota | null>(null)
async function boot() {
bootLoading.value = true
bootError.value = ''
try {
const [list, q] = await Promise.all([api.listProfiles(), api.getAskQuota()])
profiles.value = list.items || []
quota.value = q
const self = profiles.value.find((p) => p.relation === 'self')
profileId.value = self?.id || profiles.value[0]?.id || ''
threadId.value = ''
messages.value = []
} catch (e) {
bootError.value = e instanceof Error ? e.message : '加载失败'
} finally {
bootLoading.value = false
}
}
function onProfileChange() {
threadId.value = ''
messages.value = []
sendError.value = ''
}
function pickScene(s: { key: string; prompt: string }) {
scene.value = s.key
draft.value = s.prompt
threadId.value = ''
messages.value = []
}
function askAdvisor(a: (typeof askAdvisors)[number]) {
rail.value = 'ai'
const s = askScenes.find((x) => x.key === a.scene) || askScenes[0]
pickScene(s)
}
async function ensureThread(): Promise<string> {
if (threadId.value) return threadId.value
if (!profileId.value) throw new Error('请先选择档案')
const th = await api.createAskThread({ profile_id: profileId.value, scene: scene.value })
threadId.value = th.id
return th.id
}
async function send() {
const content = draft.value.trim()
if (!content || sending.value) return
sending.value = true
sendError.value = ''
quotaExhausted.value = false
try {
const tid = await ensureThread()
const out = await api.sendAskMessage(tid, content)
messages.value = [...messages.value, out.user_message, out.assistant_message]
quota.value = out.quota
draft.value = ''
} catch (e) {
const msg = e instanceof Error ? e.message : '发送失败'
sendError.value = msg
quotaExhausted.value = msg.includes('次数已用完') || msg.includes('成长会员')
} finally {
sending.value = false
}
}
onMounted(boot)
return {
router,
rail,
bootLoading,
bootError,
profiles,
profileId,
messages,
draft,
sending,
sendError,
quotaExhausted,
quota,
onProfileChange,
pickScene,
askAdvisor,
send,
boot,
}
}
@@ -0,0 +1,162 @@
import { computed, onMounted, ref } from 'vue'
import type { GrowthReport } from '@yuxingu/types'
import { validateBirth } from '@yuxingu/utils'
import { api } from '../api/client'
import { AnalyticsEvent, track } from '../lib/analytics'
export const promptRows = [
{ icon: '🌊', label: '最近情绪有点乱,帮我理一理', scene: '情绪整理' },
{ icon: '💬', label: '和 TA 相处时,我总在重复什么模式?', scene: '关系反思' },
{ icon: '🪞', label: '我想更了解自己此刻的状态', scene: '自我觉察' },
{ icon: '🍃', label: '生活节奏太快,我需要一点方向感', scene: '生活节奏' },
]
export function useImageCardPage() {
const scenes = ref<{ key: string; label: string }[]>([])
const scene = ref('情绪整理')
const quota = ref<{ remaining: number; daily_free: number; unlimited: boolean } | null>(null)
const year = ref('')
const month = ref('')
const day = ref('')
const wantDepth = ref(false)
const drawing = ref(false)
const paying = ref(false)
const error = ref('')
const bootError = ref('')
const cards = ref<{ id: string; title: string; imagery: string; prompt: string; tip: string }[]>([])
const report = ref<GrowthReport | null>(null)
const questionInput = ref('')
const showBirth = ref(false)
const summary = computed(() => (report.value?.summary || {}) as Record<string, unknown>)
const detail = computed(() => (report.value?.detail || null) as Record<string, unknown> | null)
const quotaLabel = computed(() => {
if (!quota.value) return '…'
if (quota.value.unlimited) return '不限(会员)'
return `${quota.value.remaining} / ${quota.value.daily_free}`
})
function birthFilled() {
const y = Number(year.value)
const m = Number(month.value)
const d = Number(day.value)
return !validateBirth(y, m, d)
}
function selectScene(key: string) {
scene.value = key
track('cards_scene_selected', { scene: key })
}
function onPromptClick(row: { label: string; scene: string }) {
selectScene(row.scene)
questionInput.value = row.label
if (birthFilled()) {
draw()
} else {
showBirth.value = true
}
}
function sendDraw() {
if (questionInput.value.trim()) {
scene.value = questionInput.value.trim()
}
draw()
}
async function refreshQuota() {
quota.value = await api.getImageCardQuota()
}
async function boot() {
try {
const [sc] = await Promise.all([api.listImageCardScenes(), refreshQuota()])
scenes.value = sc.items || []
if (scenes.value.length && !scenes.value.find((s) => s.key === scene.value)) {
scene.value = scenes.value[0].key
}
} catch (e) {
bootError.value = e instanceof Error ? e.message : '加载失败'
}
}
async function draw() {
error.value = ''
const y = Number(year.value)
const m = Number(month.value)
const d = Number(day.value)
const msg = validateBirth(y, m, d)
if (msg) {
error.value = msg
showBirth.value = true
return
}
drawing.value = true
try {
const birth = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
const profile = await api.createProfile({ relation: 'self', birth_date: birth, display_name: '我' })
const out = await api.drawImageCard({
profile_id: profile.id,
scene: scene.value,
depth: wantDepth.value,
})
cards.value = out.cards || []
report.value = out.report
quota.value = {
remaining: out.quota_left,
daily_free: quota.value?.daily_free || 3,
unlimited: !!quota.value?.unlimited,
}
track('cards_drawn', { scene: scene.value, depth: wantDepth.value ? 1 : 0 })
} catch (e) {
const m = e instanceof Error ? e.message : '抽取失败'
error.value = m
if (m.includes('次数')) track('cards_quota_exhausted')
} finally {
drawing.value = false
}
}
async function buyDeep() {
if (!report.value) return
paying.value = true
error.value = ''
track(AnalyticsEvent.DeepAccessClicked, { surface: 'cards' })
try {
const { order_id } = await api.createOrder({ kind: 'deep_access', report_id: report.value.id })
await api.payMock(order_id)
report.value = await api.getReport(report.value.id)
track(AnalyticsEvent.PurchaseCompleted, { kind: 'deep_access' })
} catch (e) {
error.value = e instanceof Error ? e.message : '支付失败'
} finally {
paying.value = false
}
}
onMounted(boot)
return {
scene,
quotaLabel,
year,
month,
day,
wantDepth,
drawing,
paying,
error,
bootError,
cards,
report,
questionInput,
showBirth,
summary,
detail,
onPromptClick,
sendDraw,
draw,
buyDeep,
}
}
@@ -0,0 +1,164 @@
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import type { GrowthReport } from '@yuxingu/types'
import { validateBirth } from '@yuxingu/utils'
import { api } from '../api/client'
import type { WuxingBar } from '../components/WuxingBars.vue'
import { AnalyticsEvent, track } from '../lib/analytics'
export const resultTabs = [
{ key: 'overview' as const, label: '概览' },
{ key: 'wuxing' as const, label: '五行' },
{ key: 'tips' as const, label: '建议' },
{ key: 'term' as const, label: '节气' },
]
export function useLifeRhythmPage() {
const route = useRoute()
const loading = ref(false)
const needBirth = ref(true)
const error = ref('')
const report = ref<GrowthReport | null>(null)
const paying = ref(false)
const tab = ref<'overview' | 'wuxing' | 'tips' | 'term'>('overview')
const year = ref(String(route.query.y || ''))
const month = ref(String(route.query.m || ''))
const day = ref(String(route.query.d || ''))
const summary = computed(() => (report.value?.summary || {}) as Record<string, unknown>)
const detail = computed(() => (report.value?.detail || null) as Record<string, unknown> | null)
const headline = computed(() => String(summary.value.headline || ''))
const oneLiner = computed(() => String(summary.value.one_liner || ''))
const styleLabel = computed(() => String(summary.value.style_label || ''))
const overviewText = computed(() => String(summary.value.overview || ''))
const weekFocus = computed(() => String(summary.value.week_focus || ''))
const keywords = computed(() =>
Array.isArray(summary.value.keywords) ? (summary.value.keywords as string[]) : [],
)
const solarTip = computed(() => {
const st = summary.value.solar_term
if (st && typeof st === 'object') {
const tip = (st as Record<string, unknown>).tip
if (tip) return String(tip)
}
return String(summary.value.today_tip || summary.value.life_tip || '')
})
const wuxingBars = computed((): WuxingBar[] => {
const wx = summary.value.wuxing
if (!wx || typeof wx !== 'object') return []
const bars = (wx as Record<string, unknown>).bars
if (!Array.isArray(bars)) return []
return bars
.map((b) => {
const o = b as Record<string, unknown>
return {
el: String(o.el || o.element || ''),
pct: Number(o.pct ?? o.score ?? 0),
color: String(o.color || '#E54D42'),
}
})
.filter((b) => b.el)
})
async function generate(y: number, m: number, d: number) {
loading.value = true
error.value = ''
needBirth.value = false
try {
const birth = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
const profile = await api.createProfile({ relation: 'self', birth_date: birth, display_name: '我' })
report.value = await api.createRhythm(profile.id)
tab.value = 'overview'
track(AnalyticsEvent.PortraitCompleted, { source: 'rhythm' })
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
needBirth.value = true
} finally {
loading.value = false
}
}
async function start() {
const y = Number(year.value)
const m = Number(month.value)
const d = Number(day.value)
const msg = validateBirth(y, m, d)
if (msg) {
error.value = msg
return
}
await generate(y, m, d)
}
async function load() {
const reportId = String(route.query.report_id || '')
if (reportId) {
loading.value = true
needBirth.value = false
try {
report.value = await api.getReport(reportId)
tab.value = 'overview'
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
needBirth.value = true
} finally {
loading.value = false
}
return
}
const y = Number(route.query.y)
const m = Number(route.query.m)
const d = Number(route.query.d)
if (y && m && d && !validateBirth(y, m, d)) {
year.value = String(y)
month.value = String(m)
day.value = String(d)
await generate(y, m, d)
}
}
async function buyDeep() {
if (!report.value) return
paying.value = true
error.value = ''
track(AnalyticsEvent.DeepAccessClicked, { surface: 'rhythm' })
try {
const { order_id } = await api.createOrder({ kind: 'deep_access', report_id: report.value.id })
await api.payMock(order_id)
report.value = await api.getReport(report.value.id)
track(AnalyticsEvent.PurchaseCompleted, { kind: 'deep_access' })
} catch (e) {
error.value = e instanceof Error ? e.message : '支付失败'
} finally {
paying.value = false
}
}
onMounted(load)
return {
loading,
needBirth,
error,
report,
paying,
tab,
year,
month,
day,
summary,
detail,
headline,
oneLiner,
styleLabel,
overviewText,
weekFocus,
keywords,
solarTip,
wuxingBars,
start,
load,
buyDeep,
}
}
@@ -0,0 +1,302 @@
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import type { Profile } from '@yuxingu/types'
import { api } from '../api/client'
import { copyText } from '../lib/shareLink'
export const profileRelationOptions = [
{ value: 'family', label: '家人' },
{ value: 'friend', label: '朋友' },
{ value: 'partner', label: '伴侣' },
{ value: 'colleague', label: '同事' },
{ value: 'other', label: '其他' },
]
export function formatProfileDate(v: string) {
return (v || '').slice(0, 10)
}
export function avatarInitial(p: Profile) {
const n = p.display_name || (p.relation === 'self' ? '我' : 'T')
return n.slice(0, 1)
}
export function relationLabel(t?: string | null) {
const map: Record<string, string> = {
partner: '伴侣',
friend: '朋友',
family: '家人',
colleague: '同事',
other: '关系对象',
}
return map[t || ''] || '关系对象'
}
function splitBirth(v: string) {
const s = formatProfileDate(v)
const [y, m, d] = s.split('-')
return { y: y || '', m: m ? String(Number(m)) : '', d: d ? String(Number(d)) : '' }
}
function joinBirth(y: string, m: string, d: string) {
const yi = Number(y)
const mi = Number(m)
const di = Number(d)
if (!yi || !mi || !di) return ''
return `${yi}-${String(mi).padStart(2, '0')}-${String(di).padStart(2, '0')}`
}
export function useProfilePage() {
const route = useRoute()
const router = useRouter()
const items = ref<Profile[]>([])
const loading = ref(true)
const error = ref('')
const busyId = ref('')
const editing = ref<Profile | null>(null)
const showAddForm = ref(false)
const showInviteFill = ref(false)
const formName = ref('')
const formY = ref('')
const formM = ref('')
const formD = ref('')
const formPlace = ref('')
const formRelationType = ref('partner')
const formGeoVisible = ref(false)
const formError = ref('')
const saving = ref(false)
const newName = ref('')
const newY = ref('')
const newM = ref('')
const newD = ref('')
const newPlace = ref('')
const newRelationType = ref('family')
const addError = ref('')
const adding = ref(false)
const inviting = ref(false)
const invitePath = ref('')
const inviteError = ref('')
const inviteHint = ref('')
const copyBusy = ref(false)
const selfProfile = computed(() => items.value.find((p) => p.relation === 'self'))
const others = computed(() => items.value.filter((p) => p.relation !== 'self'))
function toggleAdd() {
showAddForm.value = !showAddForm.value
if (showAddForm.value) {
editing.value = null
showInviteFill.value = false
}
}
function openAddForm() {
showAddForm.value = true
showInviteFill.value = false
editing.value = null
}
function openInviteFill() {
showInviteFill.value = true
showAddForm.value = false
editing.value = null
inviteError.value = ''
if (!selfProfile.value) {
inviteHint.value = '请先完成自己的档案,再邀请好友填写。'
} else {
inviteHint.value = ''
}
}
function closeInviteFill() {
showInviteFill.value = false
void router.replace({ path: '/profile', query: {} })
}
function applyQueryFlags() {
if (route.query.add === '1') openAddForm()
if (route.query.inviteFill === '1') openInviteFill()
}
async function createFillInvite() {
if (!selfProfile.value) {
inviteError.value = '请先完成自己的档案'
return
}
inviting.value = true
inviteError.value = ''
inviteHint.value = ''
try {
const res = await api.createSynastryInvite(selfProfile.value.id)
invitePath.value = `${location.origin}${res.path}`
inviteHint.value = '好友打开链接后填写生日即可;也可用于合盘。'
} catch (e) {
inviteError.value = e instanceof Error ? e.message : '生成失败'
} finally {
inviting.value = false
}
}
async function copyInvite() {
if (!invitePath.value) return
copyBusy.value = true
const ok = await copyText(invitePath.value)
inviteHint.value = ok ? '链接已复制,发给家人或朋友即可。' : '复制失败,请长按链接手动复制。'
copyBusy.value = false
}
async function load() {
loading.value = true
error.value = ''
try {
const res = await api.listProfiles()
items.value = res.items || []
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
function startEdit(p: Profile) {
editing.value = p
showAddForm.value = false
formName.value = p.display_name || ''
const b = splitBirth(p.birth_date)
formY.value = b.y
formM.value = b.m
formD.value = b.d
formPlace.value = p.birth_place || ''
formRelationType.value = p.relation_type || 'partner'
formGeoVisible.value = !!p.geo_visible
formError.value = ''
}
async function saveEdit() {
if (!editing.value) return
saving.value = true
formError.value = ''
try {
const birth = joinBirth(formY.value, formM.value, formD.value)
if (!birth) throw new Error('请填写生日')
const body: {
display_name: string
birth_date: string
relation_type?: string
birth_place?: string
geo_visible?: boolean
} = {
display_name: formName.value.trim(),
birth_date: birth,
birth_place: formPlace.value || '',
}
if (editing.value.relation === 'other') body.relation_type = formRelationType.value
if (editing.value.relation === 'self') body.geo_visible = formGeoVisible.value
await api.updateProfile(editing.value.id, body)
editing.value = null
await load()
} catch (e) {
formError.value = e instanceof Error ? e.message : '保存失败'
} finally {
saving.value = false
}
}
async function remove(p: Profile) {
if (!confirm(`确定删除「${p.display_name || '档案'}」?`)) return
busyId.value = p.id
error.value = ''
try {
await api.deleteProfile(p.id)
if (editing.value?.id === p.id) editing.value = null
await load()
} catch (e) {
error.value = e instanceof Error ? e.message : '删除失败'
} finally {
busyId.value = ''
}
}
async function addOther() {
adding.value = true
addError.value = ''
try {
const birth = joinBirth(newY.value, newM.value, newD.value)
if (!birth) throw new Error('请填写生日')
await api.createProfile({
relation: 'other',
birth_date: birth,
display_name: newName.value.trim() || 'TA',
relation_type: newRelationType.value,
birth_place: newPlace.value || undefined,
})
newName.value = ''
newY.value = ''
newM.value = ''
newD.value = ''
newPlace.value = ''
showAddForm.value = false
await load()
if (route.query.add === '1') void router.replace({ path: '/profile', query: {} })
} catch (e) {
addError.value = e instanceof Error ? e.message : '添加失败'
} finally {
adding.value = false
}
}
watch(
() => [route.query.add, route.query.inviteFill],
() => applyQueryFlags(),
)
onMounted(async () => {
await load()
applyQueryFlags()
})
return {
items,
loading,
error,
busyId,
editing,
showAddForm,
showInviteFill,
formName,
formY,
formM,
formD,
formPlace,
formRelationType,
formGeoVisible,
formError,
saving,
newName,
newY,
newM,
newD,
newPlace,
newRelationType,
addError,
adding,
inviting,
invitePath,
inviteError,
inviteHint,
copyBusy,
selfProfile,
others,
toggleAdd,
openInviteFill,
closeInviteFill,
createFillInvite,
copyInvite,
load,
startEdit,
saveEdit,
remove,
addOther,
}
}
@@ -0,0 +1,177 @@
import { computed, ref } from 'vue'
import type { GrowthReport, Profile } from '@yuxingu/types'
import { api } from '../api/client'
import { AnalyticsEvent, track } from '../lib/analytics'
import type { RelationSharePayload } from '../lib/shareLink'
export const focusTabs = [
{ key: 'mode', label: '相处模式' },
{ key: 'personality', label: '性格对照' },
{ key: 'sync', label: '默契指数' },
]
export const relationOptions = [
{ value: 'partner', label: '伴侣' },
{ value: 'friend', label: '朋友' },
{ value: 'family', label: '家人' },
{ value: 'other', label: '其他' },
]
export const resultTabs = [
{ key: 'index' as const, label: '指数' },
{ key: 'compare' as const, label: '对照' },
{ key: 'tips' as const, label: '建议' },
]
export function useRelationPage() {
const otherName = ref('TA')
const relationType = ref('partner')
const oy = ref('1992')
const om = ref('6')
const od = ref('8')
const loading = ref(false)
const paying = ref(false)
const error = ref('')
const report = ref<GrowthReport | null>(null)
const shareOpen = ref(false)
const needSelf = ref(false)
const focus = ref('mode')
const tab = ref<'index' | 'compare' | 'tips'>('index')
const focusHint = computed(() => {
const map: Record<string, string> = {
mode: '看见日常互动里,谁更主动、谁更需要空间',
personality: '对照彼此的性格关键词与表达习惯',
sync: '恋爱 · 友情 · 婚姻维度的默契参考',
}
return map[focus.value] || map.mode
})
const summary = computed(() => (report.value?.summary || {}) as Record<string, unknown>)
const detail = computed(() => (report.value?.detail || null) as Record<string, unknown> | null)
const meStyle = computed(() => String(summary.value.me_style || ''))
const otherStyle = computed(() => String(summary.value.other_style || ''))
const diff = computed(() => String(summary.value.diff_one_liner || ''))
const fitLabel = computed(() => String(summary.value.fit_label || ''))
const harmony = computed(() => {
const n = summary.value.harmony_index
return typeof n === 'number' ? n : null
})
const loveIndex = computed(() => (typeof summary.value.love_index === 'number' ? summary.value.love_index : null))
const friendIndex = computed(() =>
typeof summary.value.friend_index === 'number' ? summary.value.friend_index : null,
)
const marriageIndex = computed(() =>
typeof summary.value.marriage_index === 'number' ? summary.value.marriage_index : null,
)
const starCompare = computed(() => {
const raw = summary.value.star_compare
return Array.isArray(raw) ? (raw as { key: string; title: string; me: string; other: string; note?: string }[]) : []
})
const dimCompare = computed(() => {
const raw = summary.value.dimension_compare
return Array.isArray(raw)
? (raw as { key: string; title: string; me_score?: number; other_score?: number; note?: string }[])
: []
})
const meKeys = computed(() => (Array.isArray(summary.value.me_keywords) ? (summary.value.me_keywords as string[]) : []))
const otherKeys = computed(() =>
Array.isArray(summary.value.other_keywords) ? (summary.value.other_keywords as string[]) : [],
)
const sharePayload = computed<RelationSharePayload | null>(() => {
if (!report.value) return null
return {
type: 'relation',
me: meStyle.value,
other: otherStyle.value,
diff: diff.value,
keywords: [...meKeys.value.slice(0, 3), ...otherKeys.value.slice(0, 3)],
}
})
async function ensureSelf(): Promise<Profile> {
const { items } = await api.listProfiles()
const self = (items || []).find((p) => p.relation === 'self')
if (self) return self
throw new Error('请先在首页完成愈心解码,创建「我」的个人档案')
}
async function run() {
loading.value = true
error.value = ''
needSelf.value = false
report.value = null
try {
const y = Number(oy.value)
const m = Number(om.value)
const d = Number(od.value)
if (!y || !m || !d) throw new Error('请填写 TA 的完整生日')
const self = await ensureSelf()
const birth = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
const other = await api.createProfile({
relation: 'other',
birth_date: birth,
display_name: otherName.value || 'TA',
relation_type: relationType.value,
})
const out = await api.createRelationInsight(self.id, other.id)
report.value = out.report
tab.value = 'index'
track(AnalyticsEvent.RelationCompleted)
} catch (e) {
const msg = e instanceof Error ? e.message : '生成失败'
error.value = msg
needSelf.value = msg.includes('个人档案') || msg.includes('愈心解码') || msg.includes('性格探索')
} finally {
loading.value = false
}
}
async function buyDeep() {
if (!report.value) return
paying.value = true
track(AnalyticsEvent.DeepAccessClicked, { surface: 'relation' })
try {
const { order_id } = await api.createOrder({ kind: 'deep_access', report_id: report.value.id })
await api.payMock(order_id)
report.value = await api.getReport(report.value.id)
track(AnalyticsEvent.PurchaseCompleted, { kind: 'deep_access' })
} catch (e) {
error.value = e instanceof Error ? e.message : '支付失败'
} finally {
paying.value = false
}
}
return {
otherName,
relationType,
oy,
om,
od,
loading,
paying,
error,
report,
shareOpen,
needSelf,
focus,
tab,
focusHint,
summary,
detail,
meStyle,
otherStyle,
diff,
fitLabel,
harmony,
loveIndex,
friendIndex,
marriageIndex,
starCompare,
dimCompare,
sharePayload,
run,
buyDeep,
}
}
@@ -0,0 +1,310 @@
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import type { GrowthReport } from '@yuxingu/types'
import { api } from '../api/client'
import type { WheelAspect, WheelPlanet } from '../components/NatalWheel.vue'
import type { SignCard } from '../components/SignCards.vue'
import { AnalyticsEvent, track } from '../lib/analytics'
import type { PortraitSharePayload, RelationSharePayload, SharePayload } from '../lib/shareLink'
export const reportStarPanels = [
{ key: 'chart', label: '星盘' },
{ key: 'fortune', label: '运势' },
{ key: 'planets', label: '行星' },
]
export const reportSynTabs = [
{ key: 'compare', label: '比较' },
{ key: 'composite', label: '组合' },
{ key: 'davison', label: '时空' },
{ key: 'marks_me', label: '马克斯' },
{ key: 'overlay', label: '配对' },
{ key: 'composite_progressed', label: '组合次限' },
]
export const reportFortuneKeys = ['daily', 'weekly', 'monthly', 'yearly', 'lifetime'] as const
type FortunePeriod = {
title?: string
label?: string
score?: number
tip?: string
dims?: { love?: number; career?: number; money?: number; mood?: number }
}
type PlanetRow = { key: string; title: string; sign: string; degree: string; house: number }
export function useReportPage() {
const route = useRoute()
const loading = ref(true)
const error = ref('')
const report = ref<GrowthReport | null>(null)
const paying = ref(false)
const shareOpen = ref(false)
const panelKey = ref('chart')
const signTab = ref('sun')
const fortuneKey = ref<(typeof reportFortuneKeys)[number]>('daily')
const summary = computed(() => (report.value?.summary || {}) as Record<string, unknown>)
const detail = computed(() => (report.value?.detail || null) as Record<string, unknown> | null)
const headline = computed(() => String(summary.value.headline || summary.value.diff_one_liner || ''))
const oneLiner = computed(() => String(summary.value.one_liner || summary.value.diff_one_liner || ''))
const keywords = computed(() => {
if (Array.isArray(summary.value.keywords)) return summary.value.keywords as string[]
const a = Array.isArray(summary.value.me_keywords) ? (summary.value.me_keywords as string[]) : []
const b = Array.isArray(summary.value.other_keywords) ? (summary.value.other_keywords as string[]) : []
return [...a, ...b]
})
const typeLabel = computed(() => {
const map: Record<string, string> = {
portrait: '愈心解码',
relation: '人格匹配',
synastry: '合盘',
star: '星座',
rhythm: '身心节律',
image_card: '意象卡片',
}
return map[report.value?.type || ''] || '成长报告'
})
const showModeTabs = computed(() => report.value?.type === 'star' || report.value?.type === 'synastry')
const activePanels = computed(() => (report.value?.type === 'synastry' ? reportSynTabs : reportStarPanels))
const relatedLink = computed(() => {
const t = report.value?.type
if (t === 'star') return { to: '/star', label: '星座' }
if (t === 'synastry') return { to: '/synastry', label: '合盘' }
if (t === 'portrait') return { to: '/portrait', label: '解码' }
if (t === 'relation') return { to: '/relation', label: '匹配' }
if (t === 'rhythm') return { to: '/rhythm', label: '节律' }
return { to: '/reports', label: '全部' }
})
const chartObj = computed(() =>
summary.value.chart && typeof summary.value.chart === 'object'
? (summary.value.chart as Record<string, unknown>)
: {},
)
const ascLon = computed(() => (typeof chartObj.value.asc_lon === 'number' ? chartObj.value.asc_lon : null))
const wheelPlanets = computed<WheelPlanet[]>(() => {
const raw = summary.value.planets
if (!Array.isArray(raw)) return []
return raw.map((p) => {
const o = p as Record<string, unknown>
return {
key: String(o.key),
title: String(o.title),
sign: String(o.sign),
degree: String(o.degree),
house: Number(o.house),
lon: Number(o.lon),
}
})
})
const aspectPreview = computed<WheelAspect[]>(() => {
const raw = summary.value.aspects_preview
if (!Array.isArray(raw)) return []
return raw.map((a) => {
const o = a as Record<string, unknown>
return { a: String(o.a), b: String(o.b), type: String(o.type), orb: Number(o.orb) }
})
})
const transits = computed(() => {
const raw = summary.value.transits
if (Array.isArray(raw)) return raw as { key: string; title: string; aspect: string; tip: string }[]
const f = summary.value.fortune
if (f && typeof f === 'object' && Array.isArray((f as { transits?: unknown }).transits)) {
return (f as { transits: { key: string; title: string; aspect: string; tip: string }[] }).transits
}
return []
})
const synCharts = computed(() =>
summary.value.charts && typeof summary.value.charts === 'object'
? (summary.value.charts as Record<string, unknown>)
: {},
)
const synActiveBlock = computed(() => {
const c = synCharts.value[panelKey.value]
return c && typeof c === 'object' ? (c as Record<string, unknown>) : null
})
const synActiveTip = computed(() => String(synActiveBlock.value?.tip || ''))
const synActivePlanets = computed<WheelPlanet[]>(() => {
const raw = synActiveBlock.value?.planets
if (!Array.isArray(raw)) return []
return raw.map((p) => {
const x = p as Record<string, unknown>
return {
key: String(x.key),
title: String(x.title),
sign: String(x.sign),
degree: String(x.degree),
house: Number(x.house),
lon: Number(x.lon),
}
})
})
const synActiveAsc = computed(() =>
typeof synActiveBlock.value?.asc_lon === 'number' ? (synActiveBlock.value.asc_lon as number) : null,
)
const synOverlayEntries = computed(() => {
const o = synCharts.value.overlay
if (!o || typeof o !== 'object') return []
const raw = (o as { entries?: unknown }).entries
return Array.isArray(raw) ? (raw as { planet: string; house: number; house_tip: string }[]) : []
})
function synChart(key: 'chart_a' | 'chart_b'): { planets: WheelPlanet[]; asc: number | null } {
const c = summary.value[key]
if (!c || typeof c !== 'object') return { planets: [], asc: null }
const o = c as { planets?: unknown; asc_lon?: number }
const planets = Array.isArray(o.planets)
? o.planets.map((p) => {
const x = p as Record<string, unknown>
return {
key: String(x.key),
title: String(x.title),
sign: String(x.sign),
degree: String(x.degree),
house: Number(x.house),
lon: Number(x.lon),
}
})
: []
return { planets, asc: typeof o.asc_lon === 'number' ? o.asc_lon : null }
}
const synPlanetsA = computed(() => synChart('chart_a').planets)
const synPlanetsB = computed(() => synChart('chart_b').planets)
const synAscA = computed(() => synChart('chart_a').asc)
const synAscB = computed(() => synChart('chart_b').asc)
const createdLabel = computed(() => {
const t = report.value?.created_at
if (!t) return ''
try {
return new Date(t).toLocaleDateString('zh-CN')
} catch {
return t
}
})
const signCards = computed(() => {
const raw = summary.value.sign_cards
return Array.isArray(raw) ? (raw as SignCard[]) : []
})
const activeCard = computed(() => signCards.value.find((c) => c.key === signTab.value) || signCards.value[0])
const fortuneBundle = computed(() => {
const f = summary.value.fortune
return f && typeof f === 'object' ? (f as Record<string, FortunePeriod>) : {}
})
const activeFortune = computed(() => fortuneBundle.value[fortuneKey.value] || null)
const planets = computed(() => {
const raw = summary.value.planets
return Array.isArray(raw) ? (raw as PlanetRow[]) : []
})
const loveIndex = computed(() => (typeof summary.value.love_index === 'number' ? summary.value.love_index : null))
const friendIndex = computed(() =>
typeof summary.value.friend_index === 'number' ? summary.value.friend_index : null,
)
const marriageIndex = computed(() =>
typeof summary.value.marriage_index === 'number' ? summary.value.marriage_index : null,
)
const hasMatchIndex = computed(
() =>
(report.value?.type === 'relation' || report.value?.type === 'synastry') &&
(loveIndex.value != null || friendIndex.value != null || marriageIndex.value != null),
)
const sharePayload = computed<SharePayload | null>(() => {
if (!report.value) return null
if (report.value.type === 'relation') {
return {
type: 'relation',
me: String(summary.value.me_style || ''),
other: String(summary.value.other_style || ''),
diff: String(summary.value.diff_one_liner || ''),
keywords: keywords.value,
} as RelationSharePayload
}
return {
type: 'portrait',
title: headline.value,
line: oneLiner.value,
keywords: keywords.value,
} as PortraitSharePayload
})
async function load() {
loading.value = true
error.value = ''
report.value = null
try {
const id = String(route.params.id || '')
if (!id) throw new Error('缺少报告 id')
report.value = await api.getReport(id)
panelKey.value = report.value.type === 'synastry' ? 'compare' : 'chart'
signTab.value = 'sun'
fortuneKey.value = 'daily'
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
async function buyDeep() {
if (!report.value) return
paying.value = true
track(AnalyticsEvent.DeepAccessClicked, { surface: 'report' })
try {
const { order_id } = await api.createOrder({ kind: 'deep_access', report_id: report.value.id })
await api.payMock(order_id)
report.value = await api.getReport(report.value.id)
track(AnalyticsEvent.PurchaseCompleted, { kind: 'deep_access' })
} catch (e) {
error.value = e instanceof Error ? e.message : '支付失败'
} finally {
paying.value = false
}
}
watch(() => route.params.id, load)
onMounted(load)
return {
loading,
error,
report,
paying,
shareOpen,
panelKey,
signTab,
fortuneKey,
summary,
detail,
headline,
oneLiner,
typeLabel,
showModeTabs,
activePanels,
relatedLink,
ascLon,
wheelPlanets,
aspectPreview,
transits,
synActiveTip,
synActivePlanets,
synActiveAsc,
synOverlayEntries,
synPlanetsA,
synPlanetsB,
synAscA,
synAscB,
createdLabel,
signCards,
activeCard,
activeFortune,
planets,
loveIndex,
friendIndex,
marriageIndex,
hasMatchIndex,
sharePayload,
load,
buyDeep,
}
}
@@ -0,0 +1,199 @@
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { api } from '../api/client'
import { clearScaleDraft, loadScaleDraft, saveScaleDraft } from '../lib/scaleDraft'
import type { PortraitSharePayload } from '../lib/shareLink'
export function useScalePage() {
const route = useRoute()
const slug = String(route.params.slug || '')
const title = ref('')
const description = ref('')
const questions = ref<{ id: string; body: { prompt: string; options: { key: string; text: string }[] } }[]>([])
const answers = reactive<Record<string, string>>({})
const loadingScale = ref(true)
const submitting = ref(false)
const loadError = ref('')
const submitError = ref('')
const needProfile = ref(false)
const phase = ref<'intro' | 'answering' | 'result'>('intro')
const resultLabel = ref('')
const shareLine = ref('')
const shareOpen = ref(false)
const resultRaw = ref<Record<string, unknown> | null>(null)
const draftRestored = ref(false)
const persistReady = ref(false)
const currentIdx = ref(0)
const currentQuestion = computed(() => questions.value[currentIdx.value] || null)
const isLast = computed(() => currentIdx.value >= questions.value.length - 1)
const progressPct = computed(() => {
if (!questions.value.length) return 0
return Math.round(((currentIdx.value + 1) / questions.value.length) * 100)
})
const estMinutes = computed(() => Math.max(1, Math.ceil(questions.value.length / 3)))
watch(
answers,
() => {
if (!persistReady.value || phase.value === 'result') return
saveScaleDraft(slug, { ...answers })
},
{ deep: true },
)
const resultSummaryObj = computed(() => {
const r = resultRaw.value || {}
return {
overview: r.overview || r.summary,
keywords: r.label ? [String(r.label)] : [],
dimensions: r.dimensions,
strengths_preview: r.strengths,
watchouts_preview: r.watchouts,
} as Record<string, unknown>
})
const resultDetailObj = computed(() => {
const r = resultRaw.value || {}
return {
tips: r.tips,
conversation_scripts: r.scripts,
growth_plan: r.growth_plan,
faq: r.faq,
blind_spots: r.watchouts,
strengths: r.strengths,
} as Record<string, unknown>
})
const sharePayload = computed<PortraitSharePayload | null>(() => {
if (phase.value !== 'result') return null
return {
type: 'portrait',
title: resultLabel.value,
line: shareLine.value || String(resultRaw.value?.summary || ''),
keywords: resultLabel.value ? [resultLabel.value] : [],
}
})
function firstUnansweredIdx(): number {
const idx = questions.value.findIndex((q) => !answers[q.id])
return idx >= 0 ? idx : 0
}
function startTest() {
phase.value = 'answering'
currentIdx.value = draftRestored.value ? firstUnansweredIdx() : 0
}
function prevQ() {
if (currentIdx.value > 0) currentIdx.value--
}
function onNext() {
if (isLast.value) {
submit()
} else {
currentIdx.value++
}
}
async function load() {
loadingScale.value = true
loadError.value = ''
needProfile.value = false
try {
const d = await api.getScale(slug)
title.value = d.title
description.value = d.description
questions.value = d.questions.map((q) => ({
id: q.id,
body: typeof q.body === 'string' ? JSON.parse(q.body as unknown as string) : q.body,
}))
const draft = loadScaleDraft(slug)
if (draft) {
const ids = new Set(questions.value.map((q) => q.id))
let n = 0
for (const [id, key] of Object.entries(draft)) {
if (ids.has(id)) {
answers[id] = key
n++
}
}
draftRestored.value = n > 0
}
persistReady.value = true
} catch (e) {
loadError.value = e instanceof Error ? e.message : '加载失败'
} finally {
loadingScale.value = false
}
}
function openShare() {
shareOpen.value = true
}
async function submit() {
submitting.value = true
submitError.value = ''
needProfile.value = false
try {
for (const q of questions.value) {
if (!answers[q.id]) throw new Error('请完成全部题目')
}
const { items } = await api.listProfiles()
const self = (items || []).find((p) => p.relation === 'self')
if (!self) {
needProfile.value = true
throw new Error('请先在首页完成性格探索,创建个人档案')
}
const out = await api.submitScale(slug, self.id, { ...answers })
clearScaleDraft(slug)
draftRestored.value = false
phase.value = 'result'
resultRaw.value = out.result as Record<string, unknown>
resultLabel.value = String(out.result.label || '探索结果')
shareLine.value = String(out.result.share_line || '')
} catch (e) {
submitError.value = e instanceof Error ? e.message : '提交失败'
} finally {
submitting.value = false
}
}
function setAnswer(questionId: string, key: string) {
answers[questionId] = key
}
onMounted(load)
return {
title,
description,
questions,
answers,
loadingScale,
submitting,
loadError,
submitError,
needProfile,
phase,
resultLabel,
shareLine,
shareOpen,
draftRestored,
currentIdx,
currentQuestion,
isLast,
progressPct,
estMinutes,
resultSummaryObj,
resultDetailObj,
sharePayload,
load,
startTest,
prevQ,
onNext,
openShare,
setAnswer,
}
}
@@ -0,0 +1,302 @@
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import type { GrowthReport } from '@yuxingu/types'
import { validateBirth } from '@yuxingu/utils'
import { api } from '../api/client'
import type { WheelAspect, WheelPlanet } from '../components/NatalWheel.vue'
import type { SignCard } from '../components/SignCards.vue'
import { AnalyticsEvent, track } from '../lib/analytics'
import type { PortraitSharePayload } from '../lib/shareLink'
const SETTINGS_KEY = 'yuxingu_star_wheel_settings'
export const starViewTabs = [
{ key: 'overview' as const, label: '概览' },
{ key: 'chart' as const, label: '星盘' },
{ key: 'fortune' as const, label: '运势' },
{ key: 'planets' as const, label: '行星' },
]
export const starFortuneKeys = ['daily', 'weekly', 'monthly', 'yearly', 'lifetime'] as const
type FortunePeriod = {
title?: string
label?: string
score?: number
tip?: string
focus?: string
lucky?: string
caution?: string
dims?: { love?: number; career?: number; money?: number; mood?: number }
}
type PlanetRow = WheelPlanet & { element?: string; modality?: string }
type AspectRow = WheelAspect & { a_title?: string; b_title?: string; label?: string }
type HouseRow = { num: number; sign: string }
export function fortuneLabel(k: string) {
return ({ daily: '今日', weekly: '本周', monthly: '本月', yearly: '今年', lifetime: '一生' } as Record<string, string>)[k] || k
}
export function useStarProfilePage() {
const route = useRoute()
const loading = ref(false)
const needBirth = ref(true)
const error = ref('')
const report = ref<GrowthReport | null>(null)
const paying = ref(false)
const shareOpen = ref(false)
const year = ref(String(route.query.y || ''))
const month = ref(String(route.query.m || ''))
const day = ref(String(route.query.d || ''))
const birthTime = ref('')
const birthPlace = ref('')
const view = ref<'overview' | 'chart' | 'fortune' | 'planets'>('overview')
const signTab = ref('sun')
const fortuneKey = ref<'daily' | 'weekly' | 'monthly' | 'yearly' | 'lifetime'>('daily')
const showOuter = ref(true)
const tightOrb = ref(false)
try {
const raw = localStorage.getItem(SETTINGS_KEY)
if (raw) {
const o = JSON.parse(raw) as { showOuter?: boolean; tightOrb?: boolean }
if (typeof o.showOuter === 'boolean') showOuter.value = o.showOuter
if (typeof o.tightOrb === 'boolean') tightOrb.value = o.tightOrb
}
} catch {
/* ignore */
}
watch([showOuter, tightOrb], () => {
localStorage.setItem(SETTINGS_KEY, JSON.stringify({ showOuter: showOuter.value, tightOrb: tightOrb.value }))
})
const summary = computed(() => (report.value?.summary || {}) as Record<string, unknown>)
const detail = computed(() => (report.value?.detail || null) as Record<string, unknown> | null)
const headline = computed(() => String(summary.value.headline || ''))
const oneLiner = computed(() => String(summary.value.one_liner || ''))
const keywords = computed(() => (Array.isArray(summary.value.keywords) ? (summary.value.keywords as string[]) : []))
const chartObj = computed(() =>
summary.value.chart && typeof summary.value.chart === 'object' ? (summary.value.chart as Record<string, unknown>) : {},
)
const chartNote = computed(() => String(chartObj.value.note || ''))
const ascLon = computed(() => (typeof chartObj.value.asc_lon === 'number' ? chartObj.value.asc_lon : null))
const houses = computed(() => (Array.isArray(chartObj.value.houses) ? (chartObj.value.houses as HouseRow[]) : []))
const signCards = computed(() => {
const raw = summary.value.sign_cards
return Array.isArray(raw) ? (raw as SignCard[]) : []
})
const activeCard = computed(() => signCards.value.find((c) => c.key === signTab.value) || signCards.value[0])
const fortuneBundle = computed(() => {
const f = summary.value.fortune
return f && typeof f === 'object' ? (f as Record<string, FortunePeriod>) : {}
})
const activeFortune = computed(() => fortuneBundle.value[fortuneKey.value] || null)
const planets = computed(() => {
const raw = summary.value.planets
return Array.isArray(raw) ? (raw as PlanetRow[]) : []
})
const wheelPlanets = computed<WheelPlanet[]>(() =>
planets.value.map((p) => ({
key: p.key,
title: p.title,
sign: p.sign,
degree: p.degree,
house: p.house,
lon: Number(p.lon),
element: p.element,
modality: p.modality,
})),
)
const aspectPreview = computed(() => {
const raw = summary.value.aspects_preview
return Array.isArray(raw) ? (raw as AspectRow[]) : []
})
const fullAspects = computed(() => {
const raw = detail.value?.aspects
return Array.isArray(raw) ? (raw as AspectRow[]) : aspectPreview.value
})
const aspectLines = computed<WheelAspect[]>(() => {
const src = report.value?.has_deep_access ? fullAspects.value : aspectPreview.value
return src.map((a) => ({ a: a.a, b: a.b, type: a.type, orb: a.orb, label: a.label }))
})
const gridCells = computed(() => {
const cards = signCards.value
const slots: { key: string; nick: string; role: string; center?: boolean }[] = []
const order = ['sun', 'moon', 'rise', 'venus', 'mars', 'mercury', 'north_node', 'juno', 'jupiter']
const byKey = new Map(cards.map((c) => [c.key, c]))
const planetByKey = new Map(planets.value.map((p) => [p.key, p]))
for (let i = 0; i < 9; i++) {
if (i === 4) {
slots.push({ key: '_center', nick: '', role: '', center: true })
continue
}
const key = order[i < 4 ? i : i - 1] || cards[i]?.key || `p${i}`
const c = byKey.get(key)
const p = planetByKey.get(key)
if (c) {
slots.push({
key: c.key,
nick: (c.teaser || c.label || c.title).slice(0, 6),
role: `${c.title}${c.label}`,
})
} else if (p) {
slots.push({ key: p.key, nick: p.sign, role: `${p.title}${p.sign}` })
} else if (cards[i < 4 ? i : i - 1]) {
const x = cards[i < 4 ? i : i - 1]
slots.push({ key: x.key, nick: (x.teaser || x.label).slice(0, 6), role: `${x.title}${x.label}` })
} else {
slots.push({ key: `empty${i}`, nick: '—', role: '待生成' })
}
}
return slots
})
const sharePayload = computed<PortraitSharePayload | null>(() => {
if (!report.value) return null
return { type: 'portrait', title: headline.value || '我的星座', line: oneLiner.value, keywords: keywords.value }
})
function onWheelSelect(key: string) {
if (['sun', 'moon', 'rise'].includes(key)) signTab.value = key
track(AnalyticsEvent.StarWheelViewed, { planet: key })
}
function onGridTap(cell: { key: string; center?: boolean }) {
if (cell.center) {
view.value = 'chart'
return
}
if (cell.key.startsWith('empty')) return
signTab.value = cell.key
}
function resetForm() {
needBirth.value = true
report.value = null
}
async function generate(y: number, m: number, d: number) {
loading.value = true
error.value = ''
needBirth.value = false
try {
const birth = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
const bt = birthTime.value ? birthTime.value.slice(0, 5) : undefined
const profile = await api.createProfile({
relation: 'self',
birth_date: birth,
display_name: '我',
birth_time: bt,
birth_place: birthPlace.value || undefined,
})
report.value = await api.createStar(profile.id)
view.value = 'overview'
track(AnalyticsEvent.PortraitCompleted, { source: 'star' })
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
needBirth.value = true
} finally {
loading.value = false
}
}
async function start() {
const y = Number(year.value)
const m = Number(month.value)
const d = Number(day.value)
const msg = validateBirth(y, m, d)
if (msg) {
error.value = msg
return
}
await generate(y, m, d)
}
async function load() {
const reportId = String(route.query.report_id || '')
if (reportId) {
loading.value = true
needBirth.value = false
try {
report.value = await api.getReport(reportId)
view.value = 'overview'
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
needBirth.value = true
} finally {
loading.value = false
}
return
}
const y = Number(route.query.y)
const m = Number(route.query.m)
const d = Number(route.query.d)
if (y && m && d && !validateBirth(y, m, d)) {
year.value = String(y)
month.value = String(m)
day.value = String(d)
await generate(y, m, d)
}
}
async function buyDeep() {
if (!report.value) return
paying.value = true
error.value = ''
track(AnalyticsEvent.DeepAccessClicked, { surface: 'star' })
try {
const { order_id } = await api.createOrder({ kind: 'deep_access', report_id: report.value.id })
await api.payMock(order_id)
report.value = await api.getReport(report.value.id)
track(AnalyticsEvent.PurchaseCompleted, { kind: 'deep_access' })
} catch (e) {
error.value = e instanceof Error ? e.message : '支付失败'
} finally {
paying.value = false
}
}
onMounted(load)
return {
loading,
needBirth,
error,
report,
paying,
shareOpen,
year,
month,
day,
birthTime,
birthPlace,
view,
signTab,
fortuneKey,
showOuter,
tightOrb,
summary,
detail,
headline,
oneLiner,
keywords,
chartNote,
ascLon,
houses,
activeCard,
activeFortune,
planets,
wheelPlanets,
aspectPreview,
aspectLines,
gridCells,
sharePayload,
onWheelSelect,
onGridTap,
resetForm,
start,
load,
buyDeep,
}
}
+2 -2
View File
@@ -37,7 +37,7 @@ describe('AskPage', () => {
const w = mount(AskPage, { global: { stubs: { RouterLink: RouterLinkStub } } })
await flushPromises()
expect(w.text()).toContain('还没有个人档案')
expect(w.text()).toContain('去首页探索')
expect(w.text()).toContain('去愈心解码')
})
it('loads profiles and sends a message', async () => {
@@ -77,7 +77,7 @@ describe('AskPage', () => {
expect(w.text()).toContain('网络错误')
api.listProfiles.mockResolvedValue({ items: [] })
api.getAskQuota.mockResolvedValue({ remaining: 3, free_limit: 3, active_membership: false, source: 'free' })
await w.find('button.link').trigger('click')
await w.find('button.retry').trigger('click')
await flushPromises()
expect(w.text()).toContain('还没有个人档案')
})
+46 -575
View File
@@ -1,588 +1,59 @@
<template>
<PageShell :sheet="false">
<template #hero>
<!-- 双轨顶栏 · 对标测测测测AI真人1v1 -->
<div class="rails">
<button
type="button"
class="rail"
:class="{ on: rail === 'ai' }"
@click="rail = 'ai'"
>
愈心 AI
</button>
<button
type="button"
class="rail"
:class="{ on: rail === 'advisor' }"
@click="rail = 'advisor'"
>
顾问
<span class="rail-tag">推荐</span>
</button>
</div>
<AskRails v-model="rail" />
</template>
<!-- AI -->
<div v-show="rail === 'ai'" class="ai-pane">
<p v-if="bootLoading" class="hint">加载中</p>
<p v-else-if="bootError" class="err">
{{ bootError }}
<button type="button" class="retry" @click="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>
<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="pickScene(s)"
>
{{ s.label }}
</button>
</div>
<button type="button" class="advisor-link" @click="rail = 'advisor'">
也可以看看成长顾问
</button>
</div>
<AskAiPane
v-show="rail === 'ai'"
:boot-loading="bootLoading"
:boot-error="bootError"
:profiles="profiles"
:profile-id="profileId"
:messages="messages"
:draft="draft"
:sending="sending"
:send-error="sendError"
:quota-exhausted="quotaExhausted"
:quota="quota"
@boot="boot"
@pick-scene="pickScene"
@switch-advisor="rail = 'advisor'"
@update:profile-id="profileId = $event"
@profile-change="onProfileChange"
@navigate="router.push($event)"
@update:draft="draft = $event"
@send="send"
/>
<div class="card controls">
<label class="lbl">档案</label>
<select class="sel" v-model="profileId" @change="onProfileChange">
<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="router.push(q.to)"
>
{{ q.label }}
</button>
</div>
</div>
<div class="thread" ref="threadEl">
<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>
<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="send">
<textarea
class="ta"
v-model="draft"
rows="2"
placeholder="让我来解答你的问题吧"
:disabled="sending || (quota !== null && quota.remaining <= 0)"
/>
<button
class="send"
type="submit"
:disabled="sending || !draft.trim() || (quota !== null && quota.remaining <= 0)"
>
发送
</button>
</form>
<p class="ai-note">部分内容由 AI 生成仅供参考</p>
</div>
</template>
</div>
<!-- 顾问轨对标真人1v1无连麦则引导提问/会员 -->
<div v-show="rail === 'advisor'" class="adv-pane">
<div class="card">
<p class="adv-title">成长顾问</p>
<p class="adv-sub">结合档案聊聊卡住的事 · 可先用 AI或开通会员解锁更多</p>
</div>
<div
v-for="a in advisors"
:key="a.name"
class="adv-card"
>
<div class="av" :style="{ background: a.bg }">{{ a.mark }}</div>
<div class="adv-body">
<strong>{{ a.name }}</strong>
<span class="meta">{{ a.meta }}</span>
<span class="tags">{{ a.tags }}</span>
</div>
<button type="button" class="ask-btn" @click="askAdvisor(a)">去提问</button>
</div>
<router-link class="cta soft" to="/membership">查看成长会员权益</router-link>
</div>
<AskAdvisorPane v-show="rail === 'advisor'" @ask="askAdvisor" />
</PageShell>
</template>
<script setup lang="ts">
import { nextTick, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import type { AskMessage, AskQuota, Profile } from '@yuxingu/types'
import { api } from '../api/client'
import HomeToolIcon from '../components/HomeToolIcon.vue'
import AskAdvisorPane from '../components/ask/AskAdvisorPane.vue'
import AskAiPane from '../components/ask/AskAiPane.vue'
import AskRails from '../components/ask/AskRails.vue'
import PageShell from '../components/PageShell.vue'
import { useAskPage } from '../composables/useAskPage'
const router = useRouter()
const rail = ref<'ai' | 'advisor'>('ai')
const scenes = [
{ key: 'self', label: '我想更了解自己的性格与互动风格', prompt: '我想更了解自己的性格与互动风格。' },
{ key: 'relation', label: '和重要的人相处时该如何沟通?', prompt: '和重要的人相处时,我该如何更好地沟通?' },
{ key: 'emotion', label: '最近情绪有点乱,想梳理一下', prompt: '最近情绪有点乱,我想梳理一下。' },
{ key: 'career', label: '职业选择上可以注意什么?', prompt: '从我的特点看,职业选择上可以注意什么?' },
] as const
const guidePrompts = scenes
const quickTools = [
{ to: '/portrait', label: '工具' },
{ to: '/synastry', label: '合盘' },
{ to: '/cards', label: '意象卡' },
{ to: '/relation', label: '匹配' },
]
const advisors = [
{ name: '心语', mark: '心', bg: 'linear-gradient(145deg,#ffd0c8,#ffb0a4)', meta: '好评率 98% · 情绪整理', tags: '文字沟通', scene: 'emotion' as const },
{ name: '明朗', mark: '明', bg: 'linear-gradient(145deg,#d6e8ff,#b0d0f5)', meta: '好评率 97% · 关系理解', tags: '文字沟通', scene: 'relation' as const },
{ name: '志远', mark: '志', bg: 'linear-gradient(145deg,#ffe6c8,#f5c98a)', meta: '好评率 96% · 职业探索', tags: '文字沟通', scene: 'career' as const },
]
const bootLoading = ref(true)
const bootError = ref('')
const profiles = ref<Profile[]>([])
const profileId = ref('')
const scene = ref('self')
const threadId = ref('')
const messages = ref<AskMessage[]>([])
const draft = ref('')
const sending = ref(false)
const sendError = ref('')
const quotaExhausted = ref(false)
const quota = ref<AskQuota | null>(null)
const threadEl = ref<HTMLElement | null>(null)
async function boot() {
bootLoading.value = true
bootError.value = ''
try {
const [list, q] = await Promise.all([api.listProfiles(), api.getAskQuota()])
profiles.value = list.items || []
quota.value = q
const self = profiles.value.find((p) => p.relation === 'self')
profileId.value = self?.id || profiles.value[0]?.id || ''
threadId.value = ''
messages.value = []
} catch (e) {
bootError.value = e instanceof Error ? e.message : '加载失败'
} finally {
bootLoading.value = false
}
}
function onProfileChange() {
threadId.value = ''
messages.value = []
sendError.value = ''
}
function pickScene(s: { key: string; prompt: string }) {
scene.value = s.key
draft.value = s.prompt
threadId.value = ''
messages.value = []
}
function askAdvisor(a: (typeof advisors)[number]) {
rail.value = 'ai'
const s = scenes.find((x) => x.key === a.scene) || scenes[0]
pickScene(s)
}
async function ensureThread(): Promise<string> {
if (threadId.value) return threadId.value
if (!profileId.value) throw new Error('请先选择档案')
const th = await api.createAskThread({ profile_id: profileId.value, scene: scene.value })
threadId.value = th.id
return th.id
}
async function scrollBottom() {
await nextTick()
const el = threadEl.value
if (el) el.scrollTop = el.scrollHeight
}
async function send() {
const content = draft.value.trim()
if (!content || sending.value) return
sending.value = true
sendError.value = ''
quotaExhausted.value = false
try {
const tid = await ensureThread()
const out = await api.sendAskMessage(tid, content)
messages.value = [...messages.value, out.user_message, out.assistant_message]
quota.value = out.quota
draft.value = ''
await scrollBottom()
} catch (e) {
const msg = e instanceof Error ? e.message : '发送失败'
sendError.value = msg
quotaExhausted.value = msg.includes('次数已用完') || msg.includes('成长会员')
} finally {
sending.value = false
}
}
onMounted(boot)
const {
router,
rail,
bootLoading,
bootError,
profiles,
profileId,
messages,
draft,
sending,
sendError,
quotaExhausted,
quota,
onProfileChange,
pickScene,
askAdvisor,
send,
boot,
} = useAskPage()
</script>
<style scoped>
.rails {
display: flex;
gap: 0;
background: rgba(255, 255, 255, 0.72);
border-radius: var(--radius-pill);
padding: 4px;
border: 1px solid rgba(255, 255, 255, 0.95);
box-shadow: 0 4px 16px rgba(180, 90, 70, 0.08);
backdrop-filter: blur(10px);
}
.rail {
flex: 1;
border: none;
background: transparent;
padding: 10px 12px;
border-radius: var(--radius-pill);
font-size: 14px;
font-weight: 600;
color: #888;
position: relative;
}
.rail.on {
background: #fff;
color: var(--color-primary);
box-shadow: 0 2px 8px rgba(229, 77, 66, 0.12);
}
.rail-tag {
position: absolute;
top: -2px;
right: 8px;
font-size: 9px;
font-weight: 700;
color: #fff;
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
padding: 1px 5px;
border-radius: 6px;
}
.ai-pane,
.adv-pane {
padding: 12px 16px 100px;
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;
}
.adv-title {
font-size: 16px;
font-weight: 700;
color: #222;
}
.adv-sub {
font-size: 12px;
color: #888;
margin-top: 4px;
line-height: 1.45;
}
.adv-card {
display: flex;
align-items: center;
gap: 12px;
padding: 14px;
background: #fff;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
margin-bottom: 10px;
}
.av {
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
color: #c23b32;
flex-shrink: 0;
}
.adv-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.adv-body strong {
font-size: 14px;
color: #222;
}
.meta {
font-size: 11px;
color: #999;
}
.tags {
font-size: 11px;
color: var(--color-primary);
}
.ask-btn {
flex-shrink: 0;
padding: 8px 12px;
border: none;
border-radius: 18px;
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
color: #fff;
font-size: 12px;
font-weight: 600;
}
.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);
}
.cta.soft {
display: flex;
margin-top: 8px;
background: #fff;
color: var(--color-primary);
border: 1.5px solid #f5c4bc;
box-shadow: none;
}
.hint {
font-size: 13px;
color: #999;
text-align: center;
padding: 24px;
}
.err {
font-size: 13px;
color: var(--color-primary);
padding: 8px 0;
}
.pad { padding: 0 4px; }
.retry {
border: none;
background: transparent;
color: var(--color-accent-blue);
text-decoration: underline;
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>
+59 -429
View File
@@ -5,249 +5,79 @@
</template>
<div class="body" :class="{ 'has-results': cards.length }">
<!-- Greeting -->
<div class="greet">
<div class="greet-icons" aria-hidden="true">
<HomeToolIcon name="cards" :size="44" />
<HomeToolIcon name="ask" :size="36" class="icon-offset" />
</div>
<h1 class="greet-title">Hi我是愈心 AI</h1>
<p class="greet-sub">来用意念卡片整理问题吧</p>
<p v-if="bootError" class="yxg-err">{{ bootError }}仍可填写后重试抽取</p>
<p class="quota">今日剩余 {{ quotaLabel }} · 场景 {{ scene }}</p>
</div>
<ImageCardGreeting
:boot-error="bootError"
:quota-label="quotaLabel"
:scene="scene"
/>
<!-- Prompt rows (landing) -->
<div v-if="!cards.length" class="prompt-list">
<button
v-for="(row, i) in promptRows"
:key="i"
type="button"
class="prompt-row"
@click="onPromptClick(row)"
>
<span class="prompt-ico" aria-hidden="true">{{ row.icon }}</span>
<span class="prompt-text">{{ row.label }}</span>
<span class="prompt-go" aria-hidden="true"></span>
</button>
</div>
<ImageCardLanding
v-if="!cards.length"
v-model:want-depth="wantDepth"
v-model:show-birth="showBirth"
v-model:year="year"
v-model:month="month"
v-model:day="day"
:drawing="drawing"
@prompt-click="onPromptClick"
@draw="draw"
/>
<!-- Mode chips -->
<div v-if="!cards.length" class="mode-row" role="group" aria-label="抽卡模式">
<button
type="button"
class="mode-chip"
:class="{ on: !wantDepth }"
@click="wantDepth = false"
>
单卡
</button>
<button
type="button"
class="mode-chip"
:class="{ on: wantDepth }"
@click="wantDepth = true"
>
三卡组合
</button>
</div>
<!-- Collapsible birth -->
<div v-if="!cards.length" class="birth-fold">
<button type="button" class="birth-toggle" @click="showBirth = !showBirth">
<span>关联生日</span>
<span class="caret" :class="{ open: showBirth }"></span>
</button>
<div v-show="showBirth" class="birth-panel yxg-card soft">
<BirthDateInputs v-model:year="year" v-model:month="month" v-model:day="day" compact />
<button class="yxg-btn yxg-btn-block" type="button" :disabled="drawing" @click="draw">
{{ drawing ? '抽取中' : '抽取卡片' }}
</button>
</div>
</div>
<!-- Results -->
<template v-if="cards.length">
<article v-for="c in cards" :key="c.id" class="yxg-card card-result">
<h2 class="card-title">{{ c.title }}</h2>
<p class="imagery">{{ c.imagery }}</p>
<p><strong>反思</strong> {{ c.prompt }}</p>
<p class="tip">{{ c.tip }}</p>
</article>
<ReportRich v-if="report" :summary="summary" :detail="detail" :deep="!!report.has_deep_access" />
<div v-if="report && !report.has_deep_access && wantDepth" class="yxg-card lock">
<p>三卡组合解读需深度版或成长会员</p>
<button class="yxg-btn" type="button" :disabled="paying" @click="buyDeep">解锁组合模拟支付</button>
</div>
<router-link v-if="report" class="yxg-link-plain report-link" :to="`/reports/${report.id}`">
在报告页打开
</router-link>
</template>
<ImageCardResults
v-if="cards.length"
:cards="cards"
:report="report"
:summary="summary"
:detail="detail"
:want-depth="wantDepth"
:paying="paying"
@buy-deep="buyDeep"
/>
<p class="yxg-disc ai-note">意象卡片用于投射与自我反思不判定好坏也不预测未来部分内容由 AI 生成仅供参考</p>
<p v-if="error" class="yxg-err err-float">{{ error }}</p>
</div>
<!-- Bottom input bar -->
<div v-if="!cards.length" class="input-bar">
<input
v-model="questionInput"
type="text"
class="question-in"
placeholder="让我来解答你的问题吧"
@keydown.enter="sendDraw"
/>
<button type="button" class="send-btn" :disabled="drawing" aria-label="发送并抽取" @click="sendDraw">
<span v-if="drawing" class="send-spin"></span>
<span v-else class="send-ico" aria-hidden="true"></span>
</button>
</div>
<ImageCardInputBar
v-if="!cards.length"
v-model:question-input="questionInput"
:drawing="drawing"
@send="sendDraw"
/>
</PageShell>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
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 HomeToolIcon from '../components/HomeToolIcon.vue'
import PageShell from '../components/PageShell.vue'
import ReportRich from '../components/ReportRich.vue'
import { AnalyticsEvent, track } from '../lib/analytics'
import ImageCardGreeting from '../components/imagecard/ImageCardGreeting.vue'
import ImageCardInputBar from '../components/imagecard/ImageCardInputBar.vue'
import ImageCardLanding from '../components/imagecard/ImageCardLanding.vue'
import ImageCardResults from '../components/imagecard/ImageCardResults.vue'
import { useImageCardPage } from '../composables/useImageCardPage'
const scenes = ref<{ key: string; label: string }[]>([])
const scene = ref('情绪整理')
const quota = ref<{ remaining: number; daily_free: number; unlimited: boolean } | null>(null)
const year = ref('')
const month = ref('')
const day = ref('')
const wantDepth = ref(false)
const drawing = ref(false)
const paying = ref(false)
const error = ref('')
const bootError = ref('')
const cards = ref<{ id: string; title: string; imagery: string; prompt: string; tip: string }[]>([])
const report = ref<GrowthReport | null>(null)
const questionInput = ref('')
const showBirth = ref(false)
const promptRows = [
{ icon: '🌊', label: '最近情绪有点乱,帮我理一理', scene: '情绪整理' },
{ icon: '💬', label: '和 TA 相处时,我总在重复什么模式?', scene: '关系反思' },
{ icon: '🪞', label: '我想更了解自己此刻的状态', scene: '自我觉察' },
{ icon: '🍃', label: '生活节奏太快,我需要一点方向感', scene: '生活节奏' },
]
const summary = computed(() => (report.value?.summary || {}) as Record<string, unknown>)
const detail = computed(() => (report.value?.detail || null) as Record<string, unknown> | null)
const quotaLabel = computed(() => {
if (!quota.value) return '…'
if (quota.value.unlimited) return '不限(会员)'
return `${quota.value.remaining} / ${quota.value.daily_free}`
})
function birthFilled() {
const y = Number(year.value)
const m = Number(month.value)
const d = Number(day.value)
return !validateBirth(y, m, d)
}
function selectScene(key: string) {
scene.value = key
track('cards_scene_selected', { scene: key })
}
function onPromptClick(row: { label: string; scene: string }) {
selectScene(row.scene)
questionInput.value = row.label
if (birthFilled()) {
draw()
} else {
showBirth.value = true
}
}
function sendDraw() {
if (questionInput.value.trim()) {
scene.value = questionInput.value.trim()
}
draw()
}
async function refreshQuota() {
quota.value = await api.getImageCardQuota()
}
async function boot() {
try {
const [sc] = await Promise.all([api.listImageCardScenes(), refreshQuota()])
scenes.value = sc.items || []
if (scenes.value.length && !scenes.value.find((s) => s.key === scene.value)) {
scene.value = scenes.value[0].key
}
} catch (e) {
bootError.value = e instanceof Error ? e.message : '加载失败'
}
}
async function draw() {
error.value = ''
const y = Number(year.value)
const m = Number(month.value)
const d = Number(day.value)
const msg = validateBirth(y, m, d)
if (msg) {
error.value = msg
showBirth.value = true
return
}
drawing.value = true
try {
const birth = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
const profile = await api.createProfile({ relation: 'self', birth_date: birth, display_name: '我' })
const out = await api.drawImageCard({
profile_id: profile.id,
scene: scene.value,
depth: wantDepth.value,
})
cards.value = out.cards || []
report.value = out.report
quota.value = {
remaining: out.quota_left,
daily_free: quota.value?.daily_free || 3,
unlimited: !!quota.value?.unlimited,
}
track('cards_drawn', { scene: scene.value, depth: wantDepth.value ? 1 : 0 })
} catch (e) {
const m = e instanceof Error ? e.message : '抽取失败'
error.value = m
if (m.includes('次数')) track('cards_quota_exhausted')
} finally {
drawing.value = false
}
}
async function buyDeep() {
if (!report.value) return
paying.value = true
error.value = ''
track(AnalyticsEvent.DeepAccessClicked, { surface: 'cards' })
try {
const { order_id } = await api.createOrder({ kind: 'deep_access', report_id: report.value.id })
await api.payMock(order_id)
report.value = await api.getReport(report.value.id)
track(AnalyticsEvent.PurchaseCompleted, { kind: 'deep_access' })
} catch (e) {
error.value = e instanceof Error ? e.message : '支付失败'
} finally {
paying.value = false
}
}
onMounted(boot)
const {
scene,
quotaLabel,
year,
month,
day,
wantDepth,
drawing,
paying,
error,
bootError,
cards,
report,
questionInput,
showBirth,
summary,
detail,
onPromptClick,
sendDraw,
draw,
buyDeep,
} = useImageCardPage()
</script>
<style scoped>
@@ -257,210 +87,10 @@ onMounted(boot)
.body.has-results {
padding-bottom: 24px;
}
.greet {
text-align: center;
padding: 8px 0 20px;
}
.greet-icons {
display: flex;
align-items: flex-end;
justify-content: center;
gap: 4px;
margin-bottom: 12px;
}
.icon-offset {
margin-bottom: -4px;
opacity: 0.85;
}
.greet-title {
font-family: var(--font-display);
font-size: 22px;
font-weight: 700;
color: var(--color-text-primary);
letter-spacing: 0.04em;
margin: 0;
}
.greet-sub {
font-size: 14px;
color: var(--color-text-secondary);
margin: 6px 0 10px;
}
.quota {
font-size: 11px;
color: var(--color-text-tertiary);
margin: 0;
}
.prompt-list {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 18px;
}
.prompt-row {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 16px 14px;
border: none;
border-radius: var(--radius-xl);
background: var(--color-surface);
box-shadow: var(--shadow-card);
text-align: left;
cursor: pointer;
transition: transform var(--duration-fast) ease;
}
.prompt-row:active {
transform: scale(0.985);
}
.prompt-ico {
font-size: 22px;
flex-shrink: 0;
width: 36px;
text-align: center;
}
.prompt-text {
flex: 1;
font-size: 14px;
font-weight: 500;
color: var(--color-text-primary);
line-height: 1.45;
}
.prompt-go {
font-size: 14px;
color: var(--color-primary);
opacity: 0.6;
}
.mode-row {
display: flex;
gap: 8px;
justify-content: center;
margin-bottom: 16px;
}
.mode-chip {
border: 1.5px solid #eee;
background: #fff;
border-radius: 999px;
padding: 8px 20px;
font-size: 13px;
color: #666;
cursor: pointer;
}
.mode-chip.on {
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
color: #fff;
border-color: transparent;
box-shadow: 0 4px 12px rgba(229, 77, 66, 0.22);
}
.birth-fold {
margin-bottom: 12px;
}
.birth-toggle {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 12px 14px;
border: none;
border-radius: var(--radius-lg);
background: rgba(255, 255, 255, 0.7);
font-size: 13px;
font-weight: 600;
color: var(--color-text-secondary);
cursor: pointer;
}
.caret {
transition: transform 0.2s ease;
}
.caret.open {
transform: rotate(180deg);
}
.birth-panel {
margin-top: 8px;
}
.birth-panel :deep(.bd) {
margin: 8px 0 12px;
}
.card-result {
margin-bottom: 12px;
}
.imagery {
color: #666;
margin-bottom: 8px;
}
.tip {
color: #3d6b45;
margin-top: 6px;
}
.lock .yxg-btn {
margin-top: 12px;
width: 100%;
}
.report-link {
display: block;
margin-top: 12px;
}
.ai-note {
margin-top: 16px;
}
.err-float {
margin-top: 8px;
}
.input-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 20;
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px calc(10px + env(safe-area-inset-bottom, 0));
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-top: 1px solid rgba(0, 0, 0, 0.06);
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.06);
}
.question-in {
flex: 1;
border: 1.5px solid #eee;
border-radius: 22px;
padding: 11px 16px;
font-size: 14px;
background: #fdfaf8;
outline: none;
}
.question-in:focus {
border-color: rgba(229, 77, 66, 0.35);
}
.send-btn {
width: 40px;
height: 40px;
flex-shrink: 0;
border: none;
border-radius: 50%;
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
color: #fff;
font-size: 18px;
font-weight: 700;
cursor: pointer;
box-shadow: 0 4px 12px rgba(229, 77, 66, 0.28);
display: flex;
align-items: center;
justify-content: center;
}
.send-btn:disabled {
opacity: 0.6;
}
.send-ico {
line-height: 1;
margin-top: -2px;
}
</style>
+53 -372
View File
@@ -9,21 +9,15 @@
</template>
<div class="body">
<template v-if="needBirth && !report">
<div class="intro-card">
<HomeToolIcon name="rhythm" :size="64" />
<h1 class="intro-title">身心节律</h1>
<p class="intro-sub">填写生日看见作息 · 饮食 · 活动的节奏建议生活方式参考非算命</p>
</div>
<div class="yxg-card form-card">
<label class="yxg-label">生日</label>
<BirthDateInputs v-model:year="year" v-model:month="month" v-model:day="day" />
<button class="cta" type="button" :disabled="loading" @click="start">
{{ loading ? '生成中' : '生成我的节律' }}
</button>
<p v-if="error" class="yxg-err">{{ error }}</p>
</div>
</template>
<RhythmIntro
v-if="needBirth && !report"
v-model:year="year"
v-model:month="month"
v-model:day="day"
:loading="loading"
:error="error"
@start="start"
/>
<p v-else-if="loading" class="yxg-hint">正在生成节律</p>
<p v-else-if="error && !report" class="yxg-err">
@@ -31,78 +25,23 @@
<button type="button" class="yxg-link" @click="load">重试</button>
</p>
<template v-else-if="report">
<div class="mode-tabs" role="tablist">
<button
v-for="t in resultTabs"
:key="t.key"
type="button"
class="mode-tab"
:class="{ on: tab === t.key }"
@click="tab = t.key"
>
{{ t.label }}
</button>
</div>
<template v-if="tab === 'overview'">
<h2 class="hl">{{ headline || '我的身心节律' }}</h2>
<p v-if="oneLiner" class="lead">{{ oneLiner }}</p>
<div v-if="keywords.length" class="tags">
<span v-for="k in keywords" :key="k" class="tag">{{ k }}</span>
</div>
<div v-if="styleLabel" class="spotlight">
<strong>节律倾向</strong>
<p>{{ styleLabel }}</p>
</div>
<p v-if="overviewText" class="overview">{{ overviewText }}</p>
</template>
<template v-else-if="tab === 'wuxing'">
<WuxingBars v-if="wuxingBars.length" :bars="wuxingBars" />
<div v-else class="empty-soft">
<HomeToolIcon name="rhythm" :size="48" />
<p>暂无五行分布数据可从概览与建议了解节律倾向</p>
</div>
<ReportRich :summary="summary" :detail="null" :deep="false" />
</template>
<template v-else-if="tab === 'tips'">
<ReportRich :summary="summary" :detail="detail" :deep="!!report.has_deep_access" />
<div v-if="!report.has_deep_access" class="lock-card">
<p>完整习惯方案与本周练习可在深度版或成长会员中查看</p>
<button class="cta soft" type="button" :disabled="paying" @click="buyDeep">解锁深度版</button>
</div>
</template>
<template v-else>
<router-link class="term-card" to="/companion">
<div class="term-ico" aria-hidden="true">
<HomeToolIcon name="companion" :size="40" />
</div>
<div class="term-body">
<strong>节气生活陪伴</strong>
<p>{{ solarTip || '结合当季节气,微调作息与饮食节奏。' }}</p>
</div>
<span class="term-go" aria-hidden="true"></span>
</router-link>
<p v-if="weekFocus" class="focus-tip">本周关注{{ weekFocus }}</p>
</template>
<div class="sticky-bar">
<router-link class="sb-btn primary" to="/companion">去节气陪伴</router-link>
<button
v-if="!report.has_deep_access"
class="sb-btn"
type="button"
:disabled="paying"
@click="buyDeep"
>
解锁深度
</button>
<router-link class="sb-btn" to="/ask">去问答</router-link>
</div>
</template>
<RhythmResult
v-else-if="report"
v-model:tab="tab"
:headline="headline"
:one-liner="oneLiner"
:style-label="styleLabel"
:overview-text="overviewText"
:week-focus="weekFocus"
:keywords="keywords"
:solar-tip="solarTip"
:wuxing-bars="wuxingBars"
:summary="summary"
:detail="detail"
:report="report"
:paying="paying"
@buy-deep="buyDeep"
/>
<p class="yxg-disc">本内容为自我探索与生活方式参考不构成医疗建议亦非占卜预测</p>
</div>
@@ -110,148 +49,36 @@
</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 HomeToolIcon from '../components/HomeToolIcon.vue'
import PageShell from '../components/PageShell.vue'
import ReportRich from '../components/ReportRich.vue'
import WuxingBars, { type WuxingBar } from '../components/WuxingBars.vue'
import { AnalyticsEvent, track } from '../lib/analytics'
import RhythmIntro from '../components/rhythm/RhythmIntro.vue'
import RhythmResult from '../components/rhythm/RhythmResult.vue'
import { useLifeRhythmPage } from '../composables/useLifeRhythmPage'
const route = useRoute()
const loading = ref(false)
const needBirth = ref(true)
const error = ref('')
const report = ref<GrowthReport | null>(null)
const paying = ref(false)
const tab = ref<'overview' | 'wuxing' | 'tips' | 'term'>('overview')
const year = ref(String(route.query.y || ''))
const month = ref(String(route.query.m || ''))
const day = ref(String(route.query.d || ''))
const resultTabs = [
{ key: 'overview' as const, label: '概览' },
{ key: 'wuxing' as const, label: '五行' },
{ key: 'tips' as const, label: '建议' },
{ key: 'term' as const, label: '节气' },
]
const summary = computed(() => (report.value?.summary || {}) as Record<string, unknown>)
const detail = computed(() => (report.value?.detail || null) as Record<string, unknown> | null)
const headline = computed(() => String(summary.value.headline || ''))
const oneLiner = computed(() => String(summary.value.one_liner || ''))
const styleLabel = computed(() => String(summary.value.style_label || ''))
const overviewText = computed(() => String(summary.value.overview || ''))
const weekFocus = computed(() => String(summary.value.week_focus || ''))
const keywords = computed(() =>
Array.isArray(summary.value.keywords) ? (summary.value.keywords as string[]) : [],
)
const solarTip = computed(() => {
const st = summary.value.solar_term
if (st && typeof st === 'object') {
const tip = (st as Record<string, unknown>).tip
if (tip) return String(tip)
}
return String(summary.value.today_tip || summary.value.life_tip || '')
})
const wuxingBars = computed((): WuxingBar[] => {
const wx = summary.value.wuxing
if (!wx || typeof wx !== 'object') return []
const bars = (wx as Record<string, unknown>).bars
if (!Array.isArray(bars)) return []
return bars
.map((b) => {
const o = b as Record<string, unknown>
return {
el: String(o.el || o.element || ''),
pct: Number(o.pct ?? o.score ?? 0),
color: String(o.color || '#E54D42'),
}
})
.filter((b) => b.el)
})
async function generate(y: number, m: number, d: number) {
loading.value = true
error.value = ''
needBirth.value = false
try {
const birth = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
const profile = await api.createProfile({ relation: 'self', birth_date: birth, display_name: '我' })
report.value = await api.createRhythm(profile.id)
tab.value = 'overview'
track(AnalyticsEvent.PortraitCompleted, { source: 'rhythm' })
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
needBirth.value = true
} finally {
loading.value = false
}
}
async function start() {
const y = Number(year.value)
const m = Number(month.value)
const d = Number(day.value)
const msg = validateBirth(y, m, d)
if (msg) {
error.value = msg
return
}
await generate(y, m, d)
}
async function load() {
const reportId = String(route.query.report_id || '')
if (reportId) {
loading.value = true
needBirth.value = false
try {
report.value = await api.getReport(reportId)
tab.value = 'overview'
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
needBirth.value = true
} finally {
loading.value = false
}
return
}
const y = Number(route.query.y)
const m = Number(route.query.m)
const d = Number(route.query.d)
if (y && m && d && !validateBirth(y, m, d)) {
year.value = String(y)
month.value = String(m)
day.value = String(d)
await generate(y, m, d)
}
}
async function buyDeep() {
if (!report.value) return
paying.value = true
error.value = ''
track(AnalyticsEvent.DeepAccessClicked, { surface: 'rhythm' })
try {
const { order_id } = await api.createOrder({ kind: 'deep_access', report_id: report.value.id })
await api.payMock(order_id)
report.value = await api.getReport(report.value.id)
track(AnalyticsEvent.PurchaseCompleted, { kind: 'deep_access' })
} catch (e) {
error.value = e instanceof Error ? e.message : '支付失败'
} finally {
paying.value = false
}
}
onMounted(load)
const {
loading,
needBirth,
error,
report,
paying,
tab,
year,
month,
day,
summary,
detail,
headline,
oneLiner,
styleLabel,
overviewText,
weekFocus,
keywords,
solarTip,
wuxingBars,
start,
load,
buyDeep,
} = useLifeRhythmPage()
</script>
<style scoped>
@@ -270,150 +97,4 @@ onMounted(load)
}
.spacer { width: 36px; }
.body { padding: 8px 16px 88px; }
.intro-card { text-align: center; padding: 20px 12px 8px; }
.intro-title {
font-family: var(--font-display);
font-size: 24px;
font-weight: 700;
margin-top: 8px;
letter-spacing: 0.08em;
}
.intro-sub { font-size: 13px; color: #888; margin-top: 6px; line-height: 1.5; }
.form-card { margin-top: 12px; }
.cta {
margin-top: 14px;
width: 100%;
padding: 13px;
border: none;
border-radius: 22px;
color: #fff;
font-size: 15px;
font-weight: 600;
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
box-shadow: 0 6px 16px rgba(229, 77, 66, 0.28);
}
.cta.soft {
background: #fff;
color: var(--color-primary);
border: 1.5px solid #f5c4bc;
box-shadow: none;
}
.cta:disabled { opacity: 0.45; }
.mode-tabs {
display: flex;
gap: 4px;
margin-bottom: 14px;
background: #fafafa;
border-radius: var(--radius-pill);
padding: 4px;
}
.mode-tab {
flex: 1;
border: none;
background: transparent;
padding: 9px 4px;
border-radius: var(--radius-pill);
font-size: 13px;
color: #888;
font-weight: 600;
}
.mode-tab.on {
background: #fff;
color: var(--color-primary);
box-shadow: 0 2px 8px rgba(229, 77, 66, 0.1);
}
.hl {
font-family: var(--font-display);
font-size: 20px;
font-weight: 700;
line-height: 1.35;
color: #222;
}
.lead { margin-top: 8px; font-size: 13px; color: #777; line-height: 1.55; }
.tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
.tag {
font-size: 11px;
padding: 4px 10px;
border-radius: var(--radius-pill);
background: rgba(255, 255, 255, 0.8);
color: #8a4a3a;
font-weight: 600;
border: 1px solid #f5e8e4;
}
.spotlight {
margin-top: 14px;
padding: 14px;
border-radius: var(--radius-xl);
background: linear-gradient(145deg, #fff0ec, #ffe4dc);
box-shadow: var(--shadow-card);
}
.spotlight strong { font-size: 14px; color: #222; }
.spotlight p { margin-top: 6px; font-size: 13px; color: #666; line-height: 1.5; }
.overview { margin-top: 12px; font-size: 13px; color: #555; line-height: 1.65; }
.empty-soft {
text-align: center;
padding: 24px 16px;
border-radius: var(--radius-xl);
background: #fafafa;
color: #999;
font-size: 13px;
line-height: 1.5;
margin-bottom: 12px;
}
.empty-soft p { margin-top: 10px; }
.lock-card {
margin-top: 12px;
padding: 16px;
background: linear-gradient(145deg, #fff8e8, #ffeec8);
border-radius: var(--radius-xl);
}
.term-card {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border-radius: var(--radius-xl);
background: linear-gradient(145deg, #fff0ec, #ffe4dc);
box-shadow: var(--shadow-card);
color: inherit;
}
.term-body { flex: 1; min-width: 0; }
.term-body strong { font-size: 15px; color: #222; }
.term-body p { margin-top: 4px; font-size: 12px; color: #666; line-height: 1.45; }
.term-go { font-size: 20px; color: var(--color-primary); }
.focus-tip { margin-top: 12px; font-size: 13px; color: #888; }
.sticky-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 20;
display: flex;
gap: 8px;
padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(12px);
border-top: 1px solid #f0ebe8;
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.06);
}
.sb-btn {
flex: 1;
padding: 11px 8px;
border-radius: 22px;
border: 1.5px solid #f0ebe8;
background: #fff;
font-size: 13px;
font-weight: 600;
color: #555;
text-align: center;
text-decoration: none;
}
.sb-btn.primary {
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
color: #fff;
border: none;
box-shadow: 0 4px 12px rgba(229, 77, 66, 0.25);
}
.sb-btn:disabled { opacity: 0.45; }
.yxg-label { display: block; margin-bottom: 8px; font-size: 12px; color: #999; }
</style>
@@ -26,8 +26,9 @@ describe('MembershipPage', () => {
api.getMembership.mockResolvedValue({ active: false, status: 'none' })
const w = mount(MembershipPage)
await flushPromises()
expect(w.text()).toContain('尚未开通')
expect(w.text()).toContain('开通月卡')
expect(w.text()).toContain('开通后可查看画像')
expect(w.text()).toContain('月卡')
expect(w.text()).toContain('模拟开通')
})
it('activates membership after mock pay', async () => {
@@ -45,7 +46,9 @@ describe('MembershipPage', () => {
const w = mount(MembershipPage)
await flushPromises()
await w.findAll('button').find((b) => b.text().includes('开通月卡'))!.trigger('click')
const monthBtn = w.findAll('button').find((b) => b.text().includes('月卡'))
expect(monthBtn).toBeTruthy()
await monthBtn!.trigger('click')
await flushPromises()
expect(api.createOrder).toHaveBeenCalledWith({ kind: 'membership', plan: 'month' })
expect(w.text()).toContain('会员有效')
@@ -58,8 +61,8 @@ describe('MembershipPage', () => {
await flushPromises()
expect(w.text()).toContain('网络错误')
api.getMembership.mockResolvedValue({ active: false, status: 'none' })
await w.find('button.link').trigger('click')
await w.find('button.retry').trigger('click')
await flushPromises()
expect(w.text()).toContain('尚未开通')
expect(w.text()).toContain('开通后可查看画像')
})
})
+105 -565
View File
@@ -16,407 +16,127 @@
</p>
<template v-else>
<div v-if="!items.length" class="empty-card">
<HomeToolIcon name="portrait" :size="56" />
<p>暂无档案请先去首页完成性格探索或添加重要的人</p>
<router-link class="cta" to="/">去性格探索</router-link>
</div>
<ProfileEmpty v-if="!items.length" />
<template v-else>
<div v-if="selfProfile" class="self-card">
<div class="av-ring">
<span class="av-inner">{{ avatarInitial(selfProfile) }}</span>
</div>
<div class="self-body">
<strong>{{ selfProfile.display_name || '我' }}</strong>
<span class="meta"> · {{ formatDate(selfProfile.birth_date) }}</span>
<span v-if="selfProfile.birth_place" class="meta sub">{{ selfProfile.birth_place }}</span>
</div>
<div class="self-ops">
<button type="button" class="op-btn" @click="startEdit(selfProfile)">编辑</button>
</div>
</div>
<section v-if="others.length" class="section">
<h2 class="sec-title">重要的人</h2>
<div class="others-list">
<div v-for="p in others" :key="p.id" class="other-row">
<ListRow
:title="p.display_name || 'TA'"
:desc="`${relationLabel(p.relation_type)} · ${formatDate(p.birth_date)}`"
:icon="'relation'"
:chevron="false"
/>
<div class="row-ops">
<button type="button" class="op-btn" @click="startEdit(p)">编辑</button>
<button
type="button"
class="op-btn danger"
:disabled="busyId === p.id"
@click="remove(p)"
>
删除
</button>
</div>
</div>
</div>
</section>
<ProfileSelfCard
v-if="selfProfile"
:profile="selfProfile"
@edit="startEdit"
/>
<ProfileOthersList
v-if="others.length"
:others="others"
:busy-id="busyId"
@edit="startEdit"
@remove="remove"
/>
</template>
<div v-if="editing" class="yxg-card form-panel">
<h2 class="panel-title">编辑档案</h2>
<label class="yxg-label">称呼</label>
<input class="yxg-input" v-model="formName" />
<label class="yxg-label">生日</label>
<BirthDateInputs v-model:year="formY" v-model:month="formM" v-model:day="formD" compact />
<label class="yxg-label">出生地可选</label>
<BirthPlaceSelect v-model="formPlace" />
<label v-if="editing.relation === 'other'" class="yxg-label">关系类型</label>
<select v-if="editing.relation === 'other'" class="yxg-select" v-model="formRelationType">
<option value="partner">伴侣</option>
<option value="friend">朋友</option>
<option value="family">家人</option>
<option value="colleague">同事</option>
<option value="other">其他</option>
</select>
<label v-if="editing.relation === 'self'" class="check">
<input v-model="formGeoVisible" type="checkbox" />
位置可见开启后可出现在他人附近的人合盘列表默认关闭
</label>
<p v-if="formError" class="yxg-err">{{ formError }}</p>
<div class="form-actions">
<button class="cta" type="button" :disabled="saving" @click="saveEdit">保存</button>
<button class="cta soft" type="button" @click="editing = null">取消</button>
</div>
</div>
<ProfileEditForm
v-if="editing"
:editing="editing"
v-model:form-name="formName"
v-model:form-y="formY"
v-model:form-m="formM"
v-model:form-d="formD"
v-model:form-place="formPlace"
v-model:form-relation-type="formRelationType"
v-model:form-geo-visible="formGeoVisible"
:form-error="formError"
:saving="saving"
@save="saveEdit"
@cancel="editing = null"
/>
<div v-if="showInviteFill" class="yxg-card form-panel invite-panel">
<h2 class="panel-title">邀请好友填档案</h2>
<p class="invite-desc">
生成链接发给家人或朋友对方填写生日后可出现在你的合盘与关系理解中
</p>
<p v-if="inviteHint" class="yxg-meta">{{ inviteHint }}</p>
<p v-if="invitePath" class="invite-link">{{ invitePath }}</p>
<p v-if="inviteError" class="yxg-err">{{ inviteError }}</p>
<div class="form-actions">
<button class="cta" type="button" :disabled="inviting || !selfProfile" @click="createFillInvite">
{{ inviting ? '生成中' : invitePath ? '重新生成' : '生成填写链接' }}
</button>
<button
v-if="invitePath"
class="cta soft"
type="button"
:disabled="copyBusy"
@click="copyInvite"
>
{{ copyBusy ? '复制中…' : '复制链接' }}
</button>
</div>
<button type="button" class="yxg-link linkbtn" @click="closeInviteFill">关闭</button>
</div>
<ProfileInviteForm
v-if="showInviteFill"
:invite-hint="inviteHint"
:invite-path="invitePath"
:invite-error="inviteError"
:inviting="inviting"
:copy-busy="copyBusy"
:has-self="!!selfProfile"
@create="createFillInvite"
@copy="copyInvite"
@close="closeInviteFill"
/>
<div v-if="showAddForm" class="yxg-card form-panel">
<div class="panel-head">
<h2 class="panel-title">添加档案</h2>
<button type="button" class="yxg-link linkbtn" @click="openInviteFill">邀请好友</button>
</div>
<label class="yxg-label">昵称</label>
<input class="yxg-input" v-model="newName" placeholder="请输入昵称(16 个字)" maxlength="6" />
<label class="yxg-label">关系</label>
<div class="rel-chips" role="group" aria-label="关系类型">
<button
v-for="r in relationOptions"
:key="r.value"
type="button"
class="rel-chip"
:class="{ on: newRelationType === r.value }"
@click="newRelationType = r.value"
>
{{ r.label }}
</button>
</div>
<label class="yxg-label">生日</label>
<BirthDateInputs v-model:year="newY" v-model:month="newM" v-model:day="newD" compact />
<label class="yxg-label">出生地可选</label>
<BirthPlaceSelect v-model="newPlace" />
<p v-if="addError" class="yxg-err">{{ addError }}</p>
<button class="cta" type="button" :disabled="adding" @click="addOther">保存</button>
</div>
<ProfileAddForm
v-if="showAddForm"
v-model:new-name="newName"
v-model:new-relation-type="newRelationType"
v-model:new-y="newY"
v-model:new-m="newM"
v-model:new-d="newD"
v-model:new-place="newPlace"
:add-error="addError"
:adding="adding"
@invite="openInviteFill"
@save="addOther"
/>
<div class="bottom-cta">
<button type="button" class="fab" @click="toggleAdd">
{{ showAddForm ? '收起' : '添加档案' }}
</button>
<router-link class="link-row" to="/synastry">去做合盘 </router-link>
</div>
<ProfileBottomCta :show-add-form="showAddForm" @toggle-add="toggleAdd" />
</template>
</div>
</PageShell>
</template>
<script setup lang="ts">
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 BackButton from '../components/BackButton.vue'
import BirthDateInputs from '../components/BirthDateInputs.vue'
import BirthPlaceSelect from '../components/BirthPlaceSelect.vue'
import HomeToolIcon from '../components/HomeToolIcon.vue'
import ListRow from '../components/ListRow.vue'
import PageShell from '../components/PageShell.vue'
import { copyText } from '../lib/shareLink'
import ProfileAddForm from '../components/profile/ProfileAddForm.vue'
import ProfileBottomCta from '../components/profile/ProfileBottomCta.vue'
import ProfileEditForm from '../components/profile/ProfileEditForm.vue'
import ProfileEmpty from '../components/profile/ProfileEmpty.vue'
import ProfileInviteForm from '../components/profile/ProfileInviteForm.vue'
import ProfileOthersList from '../components/profile/ProfileOthersList.vue'
import ProfileSelfCard from '../components/profile/ProfileSelfCard.vue'
import { useProfilePage } from '../composables/useProfilePage'
const route = useRoute()
const router = useRouter()
const items = ref<Profile[]>([])
const loading = ref(true)
const error = ref('')
const busyId = ref('')
const editing = ref<Profile | null>(null)
const showAddForm = ref(false)
const showInviteFill = ref(false)
const formName = ref('')
const formY = ref('')
const formM = ref('')
const formD = ref('')
const formPlace = ref('')
const formRelationType = ref('partner')
const formGeoVisible = ref(false)
const formError = ref('')
const saving = ref(false)
const newName = ref('')
const newY = ref('')
const newM = ref('')
const newD = ref('')
const newPlace = ref('')
const newRelationType = ref('family')
const addError = ref('')
const adding = ref(false)
const inviting = ref(false)
const invitePath = ref('')
const inviteError = ref('')
const inviteHint = ref('')
const copyBusy = ref(false)
const relationOptions = [
{ value: 'family', label: '家人' },
{ value: 'friend', label: '朋友' },
{ value: 'partner', label: '伴侣' },
{ value: 'colleague', label: '同事' },
{ value: 'other', label: '其他' },
]
const selfProfile = computed(() => items.value.find((p) => p.relation === 'self'))
const others = computed(() => items.value.filter((p) => p.relation !== 'self'))
function formatDate(v: string) {
return (v || '').slice(0, 10)
}
function avatarInitial(p: Profile) {
const n = p.display_name || (p.relation === 'self' ? '我' : 'T')
return n.slice(0, 1)
}
function splitBirth(v: string) {
const s = formatDate(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')}`
}
function relationLabel(t?: string | null) {
const map: Record<string, string> = {
partner: '伴侣',
friend: '朋友',
family: '家人',
colleague: '同事',
other: '关系对象',
}
return map[t || ''] || '关系对象'
}
function toggleAdd() {
showAddForm.value = !showAddForm.value
if (showAddForm.value) {
editing.value = null
showInviteFill.value = false
}
}
function openAddForm() {
showAddForm.value = true
showInviteFill.value = false
editing.value = null
}
function openInviteFill() {
showInviteFill.value = true
showAddForm.value = false
editing.value = null
inviteError.value = ''
if (!selfProfile.value) {
inviteHint.value = '请先完成自己的档案,再邀请好友填写。'
} else {
inviteHint.value = ''
}
}
function closeInviteFill() {
showInviteFill.value = false
void router.replace({ path: '/profile', query: {} })
}
function applyQueryFlags() {
if (route.query.add === '1') openAddForm()
if (route.query.inviteFill === '1') openInviteFill()
}
async function createFillInvite() {
if (!selfProfile.value) {
inviteError.value = '请先完成自己的档案'
return
}
inviting.value = true
inviteError.value = ''
inviteHint.value = ''
try {
const res = await api.createSynastryInvite(selfProfile.value.id)
invitePath.value = `${location.origin}${res.path}`
inviteHint.value = '好友打开链接后填写生日即可;也可用于合盘。'
} catch (e) {
inviteError.value = e instanceof Error ? e.message : '生成失败'
} finally {
inviting.value = false
}
}
async function copyInvite() {
if (!invitePath.value) return
copyBusy.value = true
const ok = await copyText(invitePath.value)
inviteHint.value = ok ? '链接已复制,发给家人或朋友即可。' : '复制失败,请长按链接手动复制。'
copyBusy.value = false
}
async function load() {
loading.value = true
error.value = ''
try {
const res = await api.listProfiles()
items.value = res.items || []
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
function startEdit(p: Profile) {
editing.value = p
showAddForm.value = false
formName.value = p.display_name || ''
const b = splitBirth(p.birth_date)
formY.value = b.y
formM.value = b.m
formD.value = b.d
formPlace.value = p.birth_place || ''
formRelationType.value = p.relation_type || 'partner'
formGeoVisible.value = !!p.geo_visible
formError.value = ''
}
async function saveEdit() {
if (!editing.value) return
saving.value = true
formError.value = ''
try {
const birth = joinBirth(formY.value, formM.value, formD.value)
if (!birth) throw new Error('请填写生日')
const body: {
display_name: string
birth_date: string
relation_type?: string
birth_place?: string
geo_visible?: boolean
} = {
display_name: formName.value.trim(),
birth_date: birth,
birth_place: formPlace.value || '',
}
if (editing.value.relation === 'other') body.relation_type = formRelationType.value
if (editing.value.relation === 'self') body.geo_visible = formGeoVisible.value
await api.updateProfile(editing.value.id, body)
editing.value = null
await load()
} catch (e) {
formError.value = e instanceof Error ? e.message : '保存失败'
} finally {
saving.value = false
}
}
async function remove(p: Profile) {
if (!confirm(`确定删除「${p.display_name || '档案'}」?`)) return
busyId.value = p.id
error.value = ''
try {
await api.deleteProfile(p.id)
if (editing.value?.id === p.id) editing.value = null
await load()
} catch (e) {
error.value = e instanceof Error ? e.message : '删除失败'
} finally {
busyId.value = ''
}
}
async function addOther() {
adding.value = true
addError.value = ''
try {
const birth = joinBirth(newY.value, newM.value, newD.value)
if (!birth) throw new Error('请填写生日')
await api.createProfile({
relation: 'other',
birth_date: birth,
display_name: newName.value.trim() || 'TA',
relation_type: newRelationType.value,
birth_place: newPlace.value || undefined,
})
newName.value = ''
newY.value = ''
newM.value = ''
newD.value = ''
newPlace.value = ''
showAddForm.value = false
await load()
if (route.query.add === '1') void router.replace({ path: '/profile', query: {} })
} catch (e) {
addError.value = e instanceof Error ? e.message : '添加失败'
} finally {
adding.value = false
}
}
watch(
() => [route.query.add, route.query.inviteFill],
() => applyQueryFlags(),
)
onMounted(async () => {
await load()
applyQueryFlags()
})
const {
items,
loading,
error,
busyId,
editing,
showAddForm,
showInviteFill,
formName,
formY,
formM,
formD,
formPlace,
formRelationType,
formGeoVisible,
formError,
saving,
newName,
newY,
newM,
newD,
newPlace,
newRelationType,
addError,
adding,
inviting,
invitePath,
inviteError,
inviteHint,
copyBusy,
selfProfile,
others,
toggleAdd,
openInviteFill,
closeInviteFill,
createFillInvite,
copyInvite,
load,
startEdit,
saveEdit,
remove,
addOther,
} = useProfilePage()
</script>
<style scoped>
@@ -429,184 +149,4 @@ onMounted(async () => {
.who-static { flex: 1; font-size: 16px; font-weight: 700; text-align: center; }
.spacer { width: 36px; }
.body { padding: 8px 16px 100px; }
.empty-card {
text-align: center;
padding: 32px 16px;
border-radius: var(--radius-xl);
background: #fafafa;
color: #888;
}
.empty-card p { margin: 12px 0; font-size: 14px; line-height: 1.5; }
.self-card {
display: flex;
align-items: center;
gap: 14px;
padding: 16px;
margin-bottom: 16px;
border-radius: var(--radius-xl);
background: linear-gradient(145deg, #fff0ec, #ffe4dc);
box-shadow: var(--shadow-hero);
}
.av-ring {
flex-shrink: 0;
width: 64px;
height: 64px;
border-radius: 50%;
padding: 3px;
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
box-shadow: 0 4px 14px rgba(229, 77, 66, 0.25);
}
.av-inner {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
border-radius: 50%;
background: #fff;
font-size: 22px;
font-weight: 700;
color: var(--color-primary);
}
.self-body { flex: 1; min-width: 0; }
.self-body strong { display: block; font-size: 17px; color: #222; }
.meta { display: block; font-size: 12px; color: #888; margin-top: 4px; }
.meta.sub { color: #aaa; }
.self-ops { flex-shrink: 0; }
.sec-title {
font-size: 15px;
font-weight: 700;
color: #222;
margin-bottom: 10px;
}
.other-row {
margin-bottom: 8px;
border-radius: var(--radius-lg);
background: #fff;
box-shadow: var(--shadow-card);
border: 1px solid rgba(0, 0, 0, 0.03);
overflow: hidden;
}
.other-row :deep(.lr) { padding: 12px 14px 8px; }
.row-ops {
display: flex;
gap: 8px;
padding: 0 14px 12px;
}
.op-btn {
padding: 6px 14px;
border-radius: var(--radius-pill);
border: 1px solid #f0ebe8;
background: #fafafa;
font-size: 12px;
font-weight: 600;
color: #555;
}
.op-btn.danger { color: var(--color-primary); border-color: #ffe0dc; background: #fff5f4; }
.form-panel { margin: 14px 0; }
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 4px;
}
.panel-title { font-size: 16px; font-weight: 700; margin: 0 0 12px; color: #222; }
.panel-head .panel-title { margin-bottom: 0; }
.invite-panel .invite-desc {
margin: 0 0 10px;
font-size: 13px;
line-height: 1.55;
color: #666;
}
.invite-link {
margin: 8px 0;
padding: 10px 12px;
border-radius: 12px;
background: #faf7f5;
font-size: 12px;
line-height: 1.4;
color: #444;
word-break: break-all;
}
.rel-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 4px;
}
.rel-chip {
padding: 7px 14px;
border-radius: 999px;
border: 1.5px solid #eee;
background: #fff;
font-size: 13px;
font-weight: 600;
color: #666;
}
.rel-chip.on {
border-color: rgba(229, 77, 66, 0.45);
background: #fff5f4;
color: var(--color-primary);
}
.cta {
margin-top: 14px;
width: 100%;
padding: 12px;
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);
}
.cta.soft {
background: #fff;
color: #666;
border: 1.5px solid #eee;
box-shadow: none;
}
.form-actions { display: flex; gap: 8px; margin-top: 12px; }
.form-actions .cta { flex: 1; margin-top: 0; }
.check {
display: flex;
gap: 8px;
align-items: flex-start;
margin: 12px 0;
font-size: 13px;
color: #555;
line-height: 1.4;
}
.check input { margin-top: 3px; }
.yxg-label { display: block; margin: 12px 0 6px; font-size: 12px; color: #999; }
.yxg-label:first-of-type { margin-top: 0; }
.bottom-cta {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 20;
padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(12px);
border-top: 1px solid #f0ebe8;
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
}
.fab {
width: 100%;
max-width: 360px;
padding: 13px;
border: none;
border-radius: 22px;
color: #fff;
font-size: 15px;
font-weight: 600;
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
box-shadow: 0 6px 16px rgba(229, 77, 66, 0.28);
}
.link-row { font-size: 13px; color: var(--color-primary); font-weight: 600; }
</style>
+68 -477
View File
@@ -10,283 +10,85 @@
</template>
<div class="body">
<template v-if="!report">
<div class="hero-block">
<h1 class="hero-title">看见彼此的相处模样</h1>
<p class="hero-sub">基于档案与生日理解相处模式 · 性格对照 · 默契指数</p>
</div>
<RelationLanding
v-if="!report"
v-model:focus="focus"
v-model:relation-type="relationType"
v-model:other-name="otherName"
v-model:oy="oy"
v-model:om="om"
v-model:od="od"
:focus-hint="focusHint"
:loading="loading"
:error="error"
:need-self="needSelf"
@run="run"
/>
<div class="focus-chips" role="tablist">
<button
v-for="f in focusTabs"
:key="f.key"
type="button"
class="fchip"
:class="{ on: focus === f.key }"
@click="focus = f.key"
>
{{ f.label }}
</button>
</div>
<div class="deco-card" aria-hidden="true">
<div class="deco-blur">
<span class="deco-av me" />
<span class="deco-heart"></span>
<span class="deco-av ta" />
</div>
<p class="deco-hint">{{ focusHint }}</p>
</div>
<div class="yxg-card form-card">
<label class="yxg-label">关系类型</label>
<div class="rel-chips">
<button
v-for="r in relationOptions"
:key="r.value"
type="button"
class="rchip"
:class="{ on: relationType === r.value }"
@click="relationType = r.value"
>
{{ r.label }}
</button>
</div>
<label class="yxg-label">TA 的称呼</label>
<input class="yxg-input" v-model="otherName" placeholder="例如:伴侣" />
<label class="yxg-label">TA 的生日</label>
<BirthDateInputs v-model:year="oy" v-model:month="om" v-model:day="od" compact />
<p class="yxg-hint">将使用你最近一份的档案进行匹配</p>
<button class="cta" type="button" :disabled="loading" @click="run">
{{ loading ? '生成中…' : '去匹配' }}
</button>
<p v-if="error" class="yxg-err">
{{ error }}
<router-link v-if="needSelf" class="yxg-link" to="/">去首页做愈心解码</router-link>
</p>
</div>
</template>
<template v-else>
<p class="diff-lead">{{ diff }}</p>
<div class="mode-tabs" role="tablist">
<button
v-for="t in resultTabs"
:key="t.key"
type="button"
class="mode-tab"
:class="{ on: tab === t.key }"
@click="tab = t.key"
>
{{ t.label }}
</button>
</div>
<template v-if="tab === 'index'">
<div class="pair-spot">
<div class="who"><span class="tag"></span><strong>{{ meStyle || '—' }}</strong></div>
<span class="vs">×</span>
<div class="who"><span class="tag">TA</span><strong>{{ otherStyle || '—' }}</strong></div>
</div>
<div class="spotlight-row">
<div v-if="harmony != null" class="spot big">
<em>默契指数</em>
<strong>{{ harmony }}</strong>
</div>
<div v-if="fitLabel" class="spot badge-spot">
<span class="fit-badge">{{ fitLabel }}</span>
</div>
</div>
<div class="indices">
<div class="idx"><em>恋爱</em><strong>{{ loveIndex ?? '—' }}</strong></div>
<div class="idx"><em>友情</em><strong>{{ friendIndex ?? '—' }}</strong></div>
<div class="idx"><em>婚姻</em><strong>{{ marriageIndex ?? '—' }}</strong></div>
</div>
</template>
<template v-else-if="tab === 'compare'">
<MatchCompare
:me-style="meStyle"
:other-style="otherStyle"
:fit-label="fitLabel"
:harmony="harmony"
:love="loveIndex"
:friend="friendIndex"
:marriage="marriageIndex"
:star-compare="starCompare"
:dimensions="dimCompare"
/>
</template>
<p v-else class="tips-intro">基于你们的档案以下是相处与沟通建议</p>
<ReportRich :summary="summary" :detail="detail" :deep="!!report.has_deep_access" />
<div v-if="!report.has_deep_access" class="lock-card">
<p>完整相处建议冲突修复与本周练习可在深度版或成长会员中查看</p>
<button class="cta soft" type="button" :disabled="paying" @click="buyDeep">解锁完整分析</button>
</div>
<div class="sticky-bar">
<button type="button" class="sb-btn" @click="shareOpen = true">分享</button>
<router-link class="sb-btn primary" to="/ask">去问答</router-link>
<router-link class="sb-btn" to="/synastry">合盘</router-link>
</div>
</template>
<RelationResult
v-else
v-model:tab="tab"
:diff="diff"
:me-style="meStyle"
:other-style="otherStyle"
:fit-label="fitLabel"
:harmony="harmony"
:love-index="loveIndex"
:friend-index="friendIndex"
:marriage-index="marriageIndex"
:star-compare="starCompare"
:dim-compare="dimCompare"
:summary="summary"
:detail="detail"
:report="report"
:paying="paying"
@buy-deep="buyDeep"
@share="shareOpen = true"
/>
</div>
<ShareSheet :open="shareOpen" :payload="sharePayload" @close="shareOpen = false" />
</PageShell>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { GrowthReport, Profile } from '@yuxingu/types'
import { api } from '../api/client'
import BackButton from '../components/BackButton.vue'
import BirthDateInputs from '../components/BirthDateInputs.vue'
import MatchCompare from '../components/MatchCompare.vue'
import PageShell from '../components/PageShell.vue'
import ReportRich from '../components/ReportRich.vue'
import RelationLanding from '../components/relation/RelationLanding.vue'
import RelationResult from '../components/relation/RelationResult.vue'
import ShareSheet from '../components/ShareSheet.vue'
import { AnalyticsEvent, track } from '../lib/analytics'
import type { RelationSharePayload } from '../lib/shareLink'
import { useRelationPage } from '../composables/useRelationPage'
const otherName = ref('TA')
const relationType = ref('partner')
const oy = ref('1992')
const om = ref('6')
const od = ref('8')
const loading = ref(false)
const paying = ref(false)
const error = ref('')
const report = ref<GrowthReport | null>(null)
const shareOpen = ref(false)
const needSelf = ref(false)
const focus = ref('mode')
const tab = ref<'index' | 'compare' | 'tips'>('index')
const focusTabs = [
{ key: 'mode', label: '相处模式' },
{ key: 'personality', label: '性格对照' },
{ key: 'sync', label: '默契指数' },
]
const relationOptions = [
{ value: 'partner', label: '伴侣' },
{ value: 'friend', label: '朋友' },
{ value: 'family', label: '家人' },
{ value: 'other', label: '其他' },
]
const resultTabs = [
{ key: 'index' as const, label: '指数' },
{ key: 'compare' as const, label: '对照' },
{ key: 'tips' as const, label: '建议' },
]
const focusHint = computed(() => {
const map: Record<string, string> = {
mode: '看见日常互动里,谁更主动、谁更需要空间',
personality: '对照彼此的性格关键词与表达习惯',
sync: '恋爱 · 友情 · 婚姻维度的默契参考',
}
return map[focus.value] || map.mode
})
const summary = computed(() => (report.value?.summary || {}) as Record<string, unknown>)
const detail = computed(() => (report.value?.detail || null) as Record<string, unknown> | null)
const meStyle = computed(() => String(summary.value.me_style || ''))
const otherStyle = computed(() => String(summary.value.other_style || ''))
const diff = computed(() => String(summary.value.diff_one_liner || ''))
const fitLabel = computed(() => String(summary.value.fit_label || ''))
const harmony = computed(() => {
const n = summary.value.harmony_index
return typeof n === 'number' ? n : null
})
const loveIndex = computed(() => (typeof summary.value.love_index === 'number' ? summary.value.love_index : null))
const friendIndex = computed(() =>
typeof summary.value.friend_index === 'number' ? summary.value.friend_index : null,
)
const marriageIndex = computed(() =>
typeof summary.value.marriage_index === 'number' ? summary.value.marriage_index : null,
)
const starCompare = computed(() => {
const raw = summary.value.star_compare
return Array.isArray(raw) ? (raw as { key: string; title: string; me: string; other: string; note?: string }[]) : []
})
const dimCompare = computed(() => {
const raw = summary.value.dimension_compare
return Array.isArray(raw)
? (raw as { key: string; title: string; me_score?: number; other_score?: number; note?: string }[])
: []
})
const meKeys = computed(() => (Array.isArray(summary.value.me_keywords) ? (summary.value.me_keywords as string[]) : []))
const otherKeys = computed(() =>
Array.isArray(summary.value.other_keywords) ? (summary.value.other_keywords as string[]) : [],
)
const sharePayload = computed<RelationSharePayload | null>(() => {
if (!report.value) return null
return {
type: 'relation',
me: meStyle.value,
other: otherStyle.value,
diff: diff.value,
keywords: [...meKeys.value.slice(0, 3), ...otherKeys.value.slice(0, 3)],
}
})
async function ensureSelf(): Promise<Profile> {
const { items } = await api.listProfiles()
const self = (items || []).find((p) => p.relation === 'self')
if (self) return self
throw new Error('请先在首页完成愈心解码,创建「我」的个人档案')
}
async function run() {
loading.value = true
error.value = ''
needSelf.value = false
report.value = null
try {
const y = Number(oy.value)
const m = Number(om.value)
const d = Number(od.value)
if (!y || !m || !d) throw new Error('请填写 TA 的完整生日')
const self = await ensureSelf()
const birth = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
const other = await api.createProfile({
relation: 'other',
birth_date: birth,
display_name: otherName.value || 'TA',
relation_type: relationType.value,
})
const out = await api.createRelationInsight(self.id, other.id)
report.value = out.report
tab.value = 'index'
track(AnalyticsEvent.RelationCompleted)
} catch (e) {
const msg = e instanceof Error ? e.message : '生成失败'
error.value = msg
needSelf.value = msg.includes('个人档案') || msg.includes('愈心解码') || msg.includes('性格探索')
} finally {
loading.value = false
}
}
async function buyDeep() {
if (!report.value) return
paying.value = true
track(AnalyticsEvent.DeepAccessClicked, { surface: 'relation' })
try {
const { order_id } = await api.createOrder({ kind: 'deep_access', report_id: report.value.id })
await api.payMock(order_id)
report.value = await api.getReport(report.value.id)
track(AnalyticsEvent.PurchaseCompleted, { kind: 'deep_access' })
} catch (e) {
error.value = e instanceof Error ? e.message : '支付失败'
} finally {
paying.value = false
}
}
const {
otherName,
relationType,
oy,
om,
od,
loading,
paying,
error,
report,
shareOpen,
needSelf,
focus,
tab,
focusHint,
summary,
detail,
meStyle,
otherStyle,
diff,
fitLabel,
harmony,
loveIndex,
friendIndex,
marriageIndex,
starCompare,
dimCompare,
sharePayload,
run,
buyDeep,
} = useRelationPage()
</script>
<style scoped>
@@ -312,215 +114,4 @@ async function buyDeep() {
font-size: 16px;
}
.body { padding: 8px 16px 88px; }
.hero-block { text-align: center; padding: 12px 8px 4px; }
.hero-title {
font-family: var(--font-display);
font-size: 22px;
font-weight: 700;
letter-spacing: 0.04em;
color: #222;
}
.hero-sub { font-size: 13px; color: #888; margin-top: 8px; line-height: 1.5; }
.focus-chips {
display: flex;
gap: 8px;
margin: 14px 0;
justify-content: center;
}
.fchip {
padding: 8px 14px;
border-radius: var(--radius-pill);
border: 1px solid #f0ebe8;
background: #fafafa;
font-size: 12px;
color: #666;
font-weight: 600;
}
.fchip.on {
background: #fff5f4;
border-color: #f5c4bc;
color: var(--color-primary);
}
.deco-card {
margin-bottom: 14px;
padding: 20px 16px;
border-radius: var(--radius-xl);
background: linear-gradient(145deg, #fff0ec, #ffe4dc);
text-align: center;
overflow: hidden;
}
.deco-blur {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
filter: blur(0.5px);
}
.deco-av {
width: 56px;
height: 56px;
border-radius: 50%;
background: linear-gradient(145deg, #ffd0c4, #ffb8a8);
opacity: 0.85;
}
.deco-av.ta { background: linear-gradient(145deg, #d8eaff, #b4d2f5); }
.deco-heart { font-size: 20px; color: var(--color-primary); opacity: 0.7; }
.deco-hint { margin-top: 12px; font-size: 12px; color: #8a6a5a; line-height: 1.45; }
.form-card { margin-top: 4px; }
.rel-chips { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 4px; }
.rchip {
padding: 8px 16px;
border-radius: var(--radius-pill);
border: 1px solid #eee;
background: #fafafa;
font-size: 13px;
color: #666;
}
.rchip.on {
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
color: #fff;
border-color: transparent;
}
.cta {
margin-top: 14px;
width: 100%;
padding: 13px;
border: none;
border-radius: 22px;
color: #fff;
font-size: 15px;
font-weight: 600;
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
box-shadow: 0 6px 16px rgba(229, 77, 66, 0.28);
}
.cta.soft {
background: #fff;
color: var(--color-primary);
border: 1.5px solid #f5c4bc;
box-shadow: none;
}
.cta:disabled { opacity: 0.45; }
.diff-lead {
font-size: 15px;
font-weight: 600;
color: #444;
line-height: 1.5;
margin-bottom: 12px;
}
.pair-spot {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 12px;
padding: 14px;
border-radius: var(--radius-xl);
background: #fafafa;
}
.pair-spot .who { text-align: center; flex: 1; }
.pair-spot .tag {
display: block;
font-size: 11px;
color: #999;
margin-bottom: 4px;
}
.pair-spot strong { font-size: 14px; color: #222; }
.pair-spot .vs { font-size: 18px; color: var(--color-primary); opacity: 0.6; }
.mode-tabs {
display: flex;
gap: 4px;
margin-bottom: 14px;
background: #fafafa;
border-radius: var(--radius-pill);
padding: 4px;
}
.mode-tab {
flex: 1;
border: none;
background: transparent;
padding: 9px 4px;
border-radius: var(--radius-pill);
font-size: 13px;
color: #888;
font-weight: 600;
}
.mode-tab.on {
background: #fff;
color: var(--color-primary);
box-shadow: 0 2px 8px rgba(229, 77, 66, 0.1);
}
.spotlight-row { display: flex; gap: 10px; margin-bottom: 12px; }
.spot {
flex: 1;
padding: 16px;
border-radius: var(--radius-xl);
background: linear-gradient(145deg, #fff0ec, #ffe4dc);
text-align: center;
box-shadow: var(--shadow-card);
}
.spot em { display: block; font-style: normal; font-size: 12px; color: #999; margin-bottom: 4px; }
.spot strong { font-size: 32px; font-family: var(--font-display); color: var(--color-primary); }
.badge-spot { display: flex; align-items: center; justify-content: center; }
.fit-badge {
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
color: #fff;
font-size: 13px;
font-weight: 700;
padding: 8px 14px;
border-radius: var(--radius-pill);
}
.indices {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin-bottom: 12px;
}
.idx {
background: #fff5f4;
border: 1px solid #ffe0dc;
border-radius: 14px;
padding: 12px 8px;
text-align: center;
}
.idx em { display: block; font-style: normal; font-size: 11px; color: #999; margin-bottom: 4px; }
.idx strong { font-size: 22px; color: var(--color-primary); font-family: var(--font-display); }
.lock-card {
margin-top: 12px;
padding: 16px;
background: linear-gradient(145deg, #fff8e8, #ffeec8);
border-radius: var(--radius-xl);
}
.tips-intro { font-size: 13px; color: #888; margin-bottom: 12px; line-height: 1.5; }
.sticky-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 20;
display: flex;
gap: 8px;
padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(12px);
border-top: 1px solid #f0ebe8;
}
.sb-btn {
flex: 1;
padding: 11px 8px;
border-radius: 22px;
border: 1.5px solid #f0ebe8;
background: #fff;
font-size: 13px;
font-weight: 600;
color: #555;
text-align: center;
text-decoration: none;
}
.sb-btn.primary {
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
color: #fff;
border: none;
}
.yxg-label { display: block; margin: 12px 0 6px; font-size: 12px; color: #999; }
.yxg-label:first-child { margin-top: 0; }
</style>
+108 -577
View File
@@ -17,142 +17,70 @@
</p>
<template v-else-if="report">
<div class="report-head">
<span class="type-badge">{{ typeLabel }}</span>
<span class="date-badge">{{ createdLabel }}</span>
</div>
<h2 class="hl">{{ headline || oneLiner || '成长报告' }}</h2>
<p v-if="oneLiner && headline" class="lead">{{ oneLiner }}</p>
<ReportHeadBlock
:type-label="typeLabel"
:created-label="createdLabel"
:headline="headline"
:one-liner="oneLiner"
/>
<router-link class="vip-strip" to="/membership">
<span class="vip-ico" aria-hidden="true"></span>
<span class="vip-copy">
<strong>成长会员</strong>
<em>深度报告 · 全年节气陪伴 · 解锁完整分析</em>
</span>
<span class="vip-go">开通 </span>
</router-link>
<ReportModeTabs
v-if="showModeTabs"
v-model="panelKey"
:panels="activePanels"
/>
<div v-if="showModeTabs" class="mode-tabs" role="tablist">
<button
v-for="p in activePanels"
:key="p.key"
type="button"
class="mode-tab"
:class="{ on: panelKey === p.key }"
@click="panelKey = p.key"
>
{{ p.label }}
</button>
</div>
<ReportStarPanel
v-if="report.type === 'star'"
v-model:sign-tab="signTab"
v-model:fortune-key="fortuneKey"
:panel-key="panelKey"
:wheel-planets="wheelPlanets"
:aspect-preview="aspectPreview"
:asc-lon="ascLon"
:transits="transits"
:sign-cards="signCards"
:active-card="activeCard"
:active-fortune="activeFortune"
:planets="planets"
/>
<!-- 星座 -->
<template v-if="report.type === 'star'">
<template v-if="panelKey === 'chart'">
<NatalWheel
v-if="wheelPlanets.length"
:planets="wheelPlanets"
:aspects="aspectPreview"
:asc-lon="ascLon"
/>
<div v-if="transits.length" class="yxg-card soft">
<h2 class="card-title">今日行运</h2>
<p v-for="t in transits" :key="t.key" class="yxg-meta">{{ t.title }} · {{ t.aspect }}{{ t.tip }}</p>
</div>
<SignCards v-if="signCards.length" v-model="signTab" :cards="signCards" />
<div v-if="activeCard" class="spotlight">
<h3>{{ activeCard.title }} · {{ activeCard.label }}</h3>
<p>{{ activeCard.teaser }}</p>
</div>
</template>
<template v-else-if="panelKey === 'fortune'">
<div class="fortune-tabs">
<button
v-for="k in fortuneKeys"
:key="k"
type="button"
class="ftab"
:class="{ on: fortuneKey === k }"
@click="fortuneKey = k"
>
{{ fortuneLabel(k) }}
</button>
</div>
<div v-if="activeFortune" class="spotlight">
<p class="ft-title">
{{ activeFortune.title }} · {{ activeFortune.label }} · {{ activeFortune.score }}
</p>
<p>{{ activeFortune.tip }}</p>
</div>
<p v-else class="yxg-hint">暂无运势数据</p>
</template>
<ul v-else class="planet-list">
<li v-for="p in planets" :key="p.key" class="planet-row">
<strong>{{ p.title }}</strong>
<span>{{ p.sign }} {{ p.degree }} · {{ p.house }}</span>
</li>
</ul>
</template>
<ReportMatchIndices
v-if="hasMatchIndex"
:love-index="loveIndex"
:friend-index="friendIndex"
:marriage-index="marriageIndex"
/>
<!-- 合盘 / 匹配指数 -->
<div v-if="hasMatchIndex" class="indices">
<div class="idx"><em>恋爱</em><strong>{{ loveIndex }}</strong></div>
<div class="idx"><em>友情</em><strong>{{ friendIndex }}</strong></div>
<div class="idx"><em>婚姻</em><strong>{{ marriageIndex }}</strong></div>
</div>
<ReportSynastryPanel
v-if="report.type === 'synastry'"
:panel-key="panelKey"
:syn-active-tip="synActiveTip"
:syn-planets-a="synPlanetsA"
:syn-planets-b="synPlanetsB"
:syn-asc-a="synAscA"
:syn-asc-b="synAscB"
:syn-overlay-entries="synOverlayEntries"
:syn-active-planets="synActivePlanets"
:syn-active-asc="synActiveAsc"
/>
<template v-if="report.type === 'synastry'">
<p v-if="synActiveTip" class="yxg-meta">{{ synActiveTip }}</p>
<template v-if="panelKey === 'compare'">
<NatalWheel v-if="synPlanetsA.length" :planets="synPlanetsA" :aspects="[]" :asc-lon="synAscA" />
<NatalWheel v-if="synPlanetsB.length" :planets="synPlanetsB" :aspects="[]" :asc-lon="synAscB" />
</template>
<template v-else-if="panelKey === 'overlay'">
<p v-for="(e, i) in synOverlayEntries" :key="i" class="yxg-meta">
{{ e.planet }} {{ e.house }} · {{ e.house_tip }}
</p>
</template>
<NatalWheel
v-else-if="synActivePlanets.length"
:planets="synActivePlanets"
:aspects="[]"
:asc-lon="synActiveAsc"
/>
</template>
<ReportBodyContent
:report-type="report.type"
:summary="summary"
:detail="detail"
:deep="!!report.has_deep_access"
:paying="paying"
@buy-deep="buyDeep"
/>
<template v-if="report.type === 'portrait'">
<DecodePanel
:summary="summary"
:detail="detail"
:deep="!!report.has_deep_access"
:paying="paying"
@unlock="buyDeep"
/>
</template>
<template v-else>
<ReportRich :summary="summary" :detail="detail" :deep="!!report.has_deep_access" />
</template>
<div v-if="!report.has_deep_access && report.type !== 'portrait'" class="lock-card">
<p>完整分析可在深度版或成长会员中查看</p>
<button class="cta soft" type="button" :disabled="paying" @click="buyDeep">解锁深度版</button>
</div>
<div class="sticky-bar">
<button type="button" class="sb-btn" @click="shareOpen = true">分享</button>
<button
v-if="!report.has_deep_access"
class="sb-btn primary"
type="button"
:disabled="paying"
@click="buyDeep"
>
解锁
</button>
<router-link v-if="relatedLink" class="sb-btn" :class="{ primary: report.has_deep_access }" :to="relatedLink.to">
{{ relatedLink.label }}
</router-link>
</div>
<ReportStickyBar
:deep="!!report.has_deep_access"
:paying="paying"
:related-link="relatedLink"
@share="shareOpen = true"
@buy-deep="buyDeep"
/>
</template>
</div>
<ShareSheet :open="shareOpen" :payload="sharePayload" @close="shareOpen = false" />
@@ -160,279 +88,60 @@
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import type { GrowthReport } from '@yuxingu/types'
import { api } from '../api/client'
import BackButton from '../components/BackButton.vue'
import PageShell from '../components/PageShell.vue'
import DecodePanel from '../components/DecodePanel.vue'
import NatalWheel, { type WheelAspect, type WheelPlanet } from '../components/NatalWheel.vue'
import ReportRich from '../components/ReportRich.vue'
import ShareSheet from '../components/ShareSheet.vue'
import SignCards, { type SignCard } from '../components/SignCards.vue'
import { AnalyticsEvent, track } from '../lib/analytics'
import type { PortraitSharePayload, RelationSharePayload, SharePayload } from '../lib/shareLink'
import ReportBodyContent from '../components/report/ReportBodyContent.vue'
import ReportHeadBlock from '../components/report/ReportHeadBlock.vue'
import ReportMatchIndices from '../components/report/ReportMatchIndices.vue'
import ReportModeTabs from '../components/report/ReportModeTabs.vue'
import ReportStarPanel from '../components/report/ReportStarPanel.vue'
import ReportStickyBar from '../components/report/ReportStickyBar.vue'
import ReportSynastryPanel from '../components/report/ReportSynastryPanel.vue'
import { useReportPage } from '../composables/useReportPage'
const route = useRoute()
const loading = ref(true)
const error = ref('')
const report = ref<GrowthReport | null>(null)
const paying = ref(false)
const shareOpen = ref(false)
const panelKey = ref('chart')
const signTab = ref('sun')
const fortuneKey = ref<'daily' | 'weekly' | 'monthly' | 'yearly'>('daily')
const starPanels = [
{ key: 'chart', label: '星盘' },
{ key: 'fortune', label: '运势' },
{ key: 'planets', label: '行星' },
]
const synTabs = [
{ key: 'compare', label: '比较' },
{ key: 'composite', label: '组合' },
{ key: 'davison', label: '时空' },
{ key: 'marks_me', label: '马克斯' },
{ key: 'overlay', label: '配对' },
{ key: 'composite_progressed', label: '组合次限' },
]
const fortuneKeys = ['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 }
const summary = computed(() => (report.value?.summary || {}) as Record<string, unknown>)
const detail = computed(() => (report.value?.detail || null) as Record<string, unknown> | null)
const headline = computed(() => String(summary.value.headline || summary.value.diff_one_liner || ''))
const oneLiner = computed(() => String(summary.value.one_liner || summary.value.diff_one_liner || ''))
const keywords = computed(() => {
if (Array.isArray(summary.value.keywords)) return summary.value.keywords as string[]
const a = Array.isArray(summary.value.me_keywords) ? (summary.value.me_keywords as string[]) : []
const b = Array.isArray(summary.value.other_keywords) ? (summary.value.other_keywords as string[]) : []
return [...a, ...b]
})
const typeLabel = computed(() => {
const map: Record<string, string> = {
portrait: '愈心解码',
relation: '人格匹配',
synastry: '合盘',
star: '星座',
rhythm: '身心节律',
image_card: '意象卡片',
}
return map[report.value?.type || ''] || '成长报告'
})
const showModeTabs = computed(() => report.value?.type === 'star' || report.value?.type === 'synastry')
const activePanels = computed(() => (report.value?.type === 'synastry' ? synTabs : starPanels))
const relatedLink = computed(() => {
const t = report.value?.type
if (t === 'star') return { to: '/star', label: '星座' }
if (t === 'synastry') return { to: '/synastry', label: '合盘' }
if (t === 'portrait') return { to: '/portrait', label: '解码' }
if (t === 'relation') return { to: '/relation', label: '匹配' }
if (t === 'rhythm') return { to: '/rhythm', label: '节律' }
return { to: '/reports', label: '全部' }
})
const chartObj = computed(() =>
summary.value.chart && typeof summary.value.chart === 'object'
? (summary.value.chart as Record<string, unknown>)
: {},
)
const ascLon = computed(() => (typeof chartObj.value.asc_lon === 'number' ? chartObj.value.asc_lon : null))
const wheelPlanets = computed<WheelPlanet[]>(() => {
const raw = summary.value.planets
if (!Array.isArray(raw)) return []
return raw.map((p) => {
const o = p as Record<string, unknown>
return {
key: String(o.key),
title: String(o.title),
sign: String(o.sign),
degree: String(o.degree),
house: Number(o.house),
lon: Number(o.lon),
}
})
})
const aspectPreview = computed<WheelAspect[]>(() => {
const raw = summary.value.aspects_preview
if (!Array.isArray(raw)) return []
return raw.map((a) => {
const o = a as Record<string, unknown>
return { a: String(o.a), b: String(o.b), type: String(o.type), orb: Number(o.orb) }
})
})
const transits = computed(() => {
const raw = summary.value.transits
if (Array.isArray(raw)) return raw as { key: string; title: string; aspect: string; tip: string }[]
const f = summary.value.fortune
if (f && typeof f === 'object' && Array.isArray((f as { transits?: unknown }).transits)) {
return (f as { transits: { key: string; title: string; aspect: string; tip: string }[] }).transits
}
return []
})
const synCharts = computed(() =>
summary.value.charts && typeof summary.value.charts === 'object'
? (summary.value.charts as Record<string, unknown>)
: {},
)
const synActiveBlock = computed(() => {
const c = synCharts.value[panelKey.value]
return c && typeof c === 'object' ? (c as Record<string, unknown>) : null
})
const synActiveTip = computed(() => String(synActiveBlock.value?.tip || ''))
const synActivePlanets = computed<WheelPlanet[]>(() => {
const raw = synActiveBlock.value?.planets
if (!Array.isArray(raw)) return []
return raw.map((p) => {
const x = p as Record<string, unknown>
return {
key: String(x.key),
title: String(x.title),
sign: String(x.sign),
degree: String(x.degree),
house: Number(x.house),
lon: Number(x.lon),
}
})
})
const synActiveAsc = computed(() =>
typeof synActiveBlock.value?.asc_lon === 'number' ? (synActiveBlock.value.asc_lon as number) : null,
)
const synOverlayEntries = computed(() => {
const o = synCharts.value.overlay
if (!o || typeof o !== 'object') return []
const raw = (o as { entries?: unknown }).entries
return Array.isArray(raw) ? (raw as { planet: string; house: number; house_tip: string }[]) : []
})
function synChart(key: 'chart_a' | 'chart_b'): { planets: WheelPlanet[]; asc: number | null } {
const c = summary.value[key]
if (!c || typeof c !== 'object') return { planets: [], asc: null }
const o = c as { planets?: unknown; asc_lon?: number }
const planets = Array.isArray(o.planets)
? o.planets.map((p) => {
const x = p as Record<string, unknown>
return {
key: String(x.key),
title: String(x.title),
sign: String(x.sign),
degree: String(x.degree),
house: Number(x.house),
lon: Number(x.lon),
}
})
: []
return { planets, asc: typeof o.asc_lon === 'number' ? o.asc_lon : null }
}
const synPlanetsA = computed(() => synChart('chart_a').planets)
const synPlanetsB = computed(() => synChart('chart_b').planets)
const synAscA = computed(() => synChart('chart_a').asc)
const synAscB = computed(() => synChart('chart_b').asc)
const createdLabel = computed(() => {
const t = report.value?.created_at
if (!t) return ''
try {
return new Date(t).toLocaleDateString('zh-CN')
} catch {
return t
}
})
const signCards = computed(() => {
const raw = summary.value.sign_cards
return Array.isArray(raw) ? (raw as SignCard[]) : []
})
const activeCard = computed(() => signCards.value.find((c) => c.key === signTab.value) || signCards.value[0])
const fortuneBundle = computed(() => {
const f = summary.value.fortune
return f && typeof f === 'object' ? (f as Record<string, FortunePeriod>) : {}
})
const activeFortune = computed(() => fortuneBundle.value[fortuneKey.value] || null)
const planets = computed(() => {
const raw = summary.value.planets
return Array.isArray(raw) ? (raw as PlanetRow[]) : []
})
const loveIndex = computed(() => (typeof summary.value.love_index === 'number' ? summary.value.love_index : null))
const friendIndex = computed(() =>
typeof summary.value.friend_index === 'number' ? summary.value.friend_index : null,
)
const marriageIndex = computed(() =>
typeof summary.value.marriage_index === 'number' ? summary.value.marriage_index : null,
)
const hasMatchIndex = computed(
() =>
(report.value?.type === 'relation' || report.value?.type === 'synastry') &&
(loveIndex.value != null || friendIndex.value != null || marriageIndex.value != null),
)
const sharePayload = computed<SharePayload | null>(() => {
if (!report.value) return null
if (report.value.type === 'relation') {
return {
type: 'relation',
me: String(summary.value.me_style || ''),
other: String(summary.value.other_style || ''),
diff: String(summary.value.diff_one_liner || ''),
keywords: keywords.value,
} as RelationSharePayload
}
return {
type: 'portrait',
title: headline.value,
line: oneLiner.value,
keywords: keywords.value,
} as PortraitSharePayload
})
function fortuneLabel(k: string) {
return ({ daily: '今日', weekly: '本周', monthly: '本月', yearly: '今年', lifetime: '一生' } as Record<string, string>)[k] || k
}
async function load() {
loading.value = true
error.value = ''
report.value = null
try {
const id = String(route.params.id || '')
if (!id) throw new Error('缺少报告 id')
report.value = await api.getReport(id)
panelKey.value = report.value.type === 'synastry' ? 'compare' : 'chart'
signTab.value = 'sun'
fortuneKey.value = 'daily'
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
async function buyDeep() {
if (!report.value) return
paying.value = true
track(AnalyticsEvent.DeepAccessClicked, { surface: 'report' })
try {
const { order_id } = await api.createOrder({ kind: 'deep_access', report_id: report.value.id })
await api.payMock(order_id)
report.value = await api.getReport(report.value.id)
track(AnalyticsEvent.PurchaseCompleted, { kind: 'deep_access' })
} catch (e) {
error.value = e instanceof Error ? e.message : '支付失败'
} finally {
paying.value = false
}
}
watch(() => route.params.id, load)
onMounted(load)
const {
loading,
error,
report,
paying,
shareOpen,
panelKey,
signTab,
fortuneKey,
summary,
detail,
headline,
oneLiner,
typeLabel,
showModeTabs,
activePanels,
relatedLink,
ascLon,
wheelPlanets,
aspectPreview,
transits,
synActiveTip,
synActivePlanets,
synActiveAsc,
synOverlayEntries,
synPlanetsA,
synPlanetsB,
synAscA,
synAscB,
createdLabel,
signCards,
activeCard,
activeFortune,
planets,
loveIndex,
friendIndex,
marriageIndex,
hasMatchIndex,
sharePayload,
load,
buyDeep,
} = useReportPage()
</script>
<style scoped>
@@ -453,182 +162,4 @@ onMounted(load)
font-size: 16px;
}
.body { padding: 8px 16px 88px; }
.report-head { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; }
.type-badge {
font-size: 11px;
font-weight: 700;
color: #fff;
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
padding: 4px 10px;
border-radius: var(--radius-pill);
}
.date-badge {
font-size: 11px;
color: #999;
padding: 4px 10px;
border-radius: var(--radius-pill);
background: #fafafa;
border: 1px solid #f0ebe8;
}
.hl {
font-family: var(--font-display);
font-size: 20px;
font-weight: 700;
line-height: 1.35;
color: #222;
}
.lead { margin-top: 6px; font-size: 13px; color: #777; line-height: 1.55; }
.vip-strip {
display: flex;
align-items: center;
gap: 10px;
margin: 14px 0;
padding: 12px 14px;
border-radius: var(--radius-lg);
background: linear-gradient(145deg, #fff4e0, #ffe8b8);
box-shadow: 0 4px 14px rgba(200, 146, 58, 0.12);
color: inherit;
}
.vip-ico {
width: 32px;
height: 32px;
border-radius: 10px;
background: linear-gradient(135deg, #f0c86a, #e8a830);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
flex-shrink: 0;
}
.vip-copy { flex: 1; min-width: 0; }
.vip-copy strong { display: block; font-size: 14px; color: #222; }
.vip-copy em { display: block; font-style: normal; font-size: 11px; color: #8a7040; margin-top: 2px; }
.vip-go { font-size: 13px; font-weight: 600; color: #b07a28; flex-shrink: 0; }
.mode-tabs {
display: flex;
gap: 4px;
margin-bottom: 14px;
background: #fafafa;
border-radius: var(--radius-pill);
padding: 4px;
overflow-x: auto;
scrollbar-width: none;
}
.mode-tabs::-webkit-scrollbar { display: none; }
.mode-tab {
flex: 1;
min-width: 52px;
border: none;
background: transparent;
padding: 9px 6px;
border-radius: var(--radius-pill);
font-size: 12px;
color: #888;
font-weight: 600;
white-space: nowrap;
}
.mode-tab.on {
background: #fff;
color: var(--color-primary);
box-shadow: 0 2px 8px rgba(229, 77, 66, 0.1);
}
.spotlight {
margin: 12px 0;
padding: 14px;
border-radius: var(--radius-xl);
background: linear-gradient(145deg, #fff0ec, #ffe4dc);
box-shadow: var(--shadow-card);
}
.spotlight h3 { font-size: 15px; margin-bottom: 6px; color: #222; }
.spotlight p { font-size: 13px; color: #555; line-height: 1.6; }
.fortune-tabs { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; }
.ftab {
flex: 1;
min-width: 52px;
border: 1px solid #eee;
background: #fafafa;
border-radius: 12px;
padding: 10px 4px;
font-size: 12px;
color: #666;
}
.ftab.on { border-color: var(--color-primary); background: #fff5f4; color: #222; font-weight: 600; }
.ft-title { font-weight: 700; color: #222; margin-bottom: 6px; }
.planet-list { list-style: none; padding: 0; margin: 0; }
.planet-row {
display: flex;
flex-direction: column;
gap: 4px;
padding: 14px;
margin-bottom: 8px;
background: #fafafa;
border-radius: 14px;
}
.planet-row strong { font-size: 14px; color: #222; }
.planet-row span { font-size: 12px; color: #888; }
.indices {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin: 12px 0;
}
.idx {
background: #fff5f4;
border: 1px solid #ffe0dc;
border-radius: 14px;
padding: 12px 8px;
text-align: center;
}
.idx em { display: block; font-style: normal; font-size: 11px; color: #999; margin-bottom: 4px; }
.idx strong { font-size: 22px; color: var(--color-primary); font-family: var(--font-display); }
.lock-card {
margin-top: 12px;
padding: 16px;
background: linear-gradient(145deg, #fff8e8, #ffeec8);
border-radius: var(--radius-xl);
}
.cta.soft {
margin-top: 12px;
width: 100%;
padding: 12px;
border: 1.5px solid #f5c4bc;
border-radius: 22px;
background: #fff;
color: var(--color-primary);
font-size: 14px;
font-weight: 600;
}
.cta.soft:disabled { opacity: 0.45; }
.sticky-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 20;
display: flex;
gap: 8px;
padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(12px);
border-top: 1px solid #f0ebe8;
}
.sb-btn {
flex: 1;
padding: 11px 8px;
border-radius: 22px;
border: 1.5px solid #f0ebe8;
background: #fff;
font-size: 13px;
font-weight: 600;
color: #555;
text-align: center;
text-decoration: none;
}
.sb-btn.primary {
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
color: #fff;
border: none;
}
.card-title { font-size: 16px; margin: 0 0 8px; }
</style>
+72 -405
View File
@@ -18,243 +18,94 @@
<button type="button" class="yxg-link" @click="load">重试</button>
</p>
<!-- Intro -->
<div v-else-if="phase === 'intro'" class="intro-screen">
<div class="intro-card">
<HomeToolIcon name="mbti" :size="64" />
<h2 class="intro-title">{{ title || '探索测试' }}</h2>
<p v-if="description" class="intro-desc">{{ description }}</p>
<p v-if="questions.length" class="intro-count"> {{ questions.length }} · {{ estMinutes }} 分钟</p>
<p v-if="draftRestored" class="draft-hint">检测到上次未完成的作答开始后将继续</p>
<button class="yxg-btn yxg-btn-block intro-start" type="button" :disabled="!questions.length" @click="startTest">
开始
</button>
</div>
</div>
<ScaleIntro
v-else-if="phase === 'intro'"
:title="title"
:description="description"
:question-count="questions.length"
:est-minutes="estMinutes"
:draft-restored="draftRestored"
@start="startTest"
/>
<!-- Answering -->
<div v-else-if="phase === 'answering'" class="answer-screen">
<p v-if="draftRestored && currentIdx === 0" class="yxg-hint draft">已恢复上次未提交的作答</p>
<div class="progress-wrap">
<div class="progress-bar">
<i class="progress-fill" :style="{ width: progressPct + '%' }" />
</div>
<span class="progress-label">{{ currentIdx + 1 }} / {{ questions.length }}</span>
</div>
<ScaleAnswering
v-else-if="phase === 'answering'"
:draft-restored="draftRestored"
:current-idx="currentIdx"
:progress-pct="progressPct"
:total-questions="questions.length"
:current-question="currentQuestion"
:answers="answers"
:submit-error="submitError"
:need-profile="needProfile"
@answer="setAnswer"
/>
<div v-if="currentQuestion" class="q-card yxg-card">
<p class="prompt">{{ currentQuestion.body.prompt }}</p>
<label v-for="opt in currentQuestion.body.options" :key="opt.key" class="opt" :class="{ picked: answers[currentQuestion.id] === opt.key }">
<input v-model="answers[currentQuestion.id]" type="radio" :value="opt.key" />
<span class="opt-text">{{ opt.text }}</span>
</label>
</div>
<p v-if="submitError" class="yxg-err">{{ submitError }}</p>
<div v-if="needProfile" class="need">
<p>提交结果需要先有的个人档案</p>
<router-link class="yxg-btn" to="/">去首页创建档案</router-link>
</div>
</div>
<!-- Result -->
<div v-else-if="phase === 'result'">
<p class="result-lead">{{ resultLabel }}</p>
<ReportRich :summary="resultSummaryObj" :detail="resultDetailObj" :deep="true" />
<div class="yxg-card">
<p v-if="shareLine" class="share">{{ shareLine }}</p>
<button type="button" class="yxg-btn yxg-btn-ghost" @click="openShare">生成分享卡</button>
<router-link class="yxg-link-plain link" to="/relation">用结果去做关系理解 </router-link>
<router-link class="yxg-link-plain link" to="/ask">去问答聊聊 </router-link>
</div>
</div>
<ScaleResult
v-else-if="phase === 'result'"
:result-label="resultLabel"
:share-line="shareLine"
:result-summary-obj="resultSummaryObj"
:result-detail-obj="resultDetailObj"
@share="openShare"
/>
</div>
<!-- Nav bar -->
<div v-if="phase === 'answering'" class="nav-bar">
<button type="button" class="nav-btn prev" :disabled="currentIdx === 0" @click="prevQ">上一题</button>
<button
type="button"
class="nav-btn next"
:disabled="!answers[currentQuestion?.id || ''] || submitting"
@click="onNext"
>
{{ isLast ? (submitting ? '提交中…' : '查看探索结果') : '下一题' }}
</button>
</div>
<ScaleNavBar
v-if="phase === 'answering'"
:current-idx="currentIdx"
:has-answer="!!answers[currentQuestion?.id || '']"
:is-last="isLast"
:submitting="submitting"
@prev="prevQ"
@next="onNext"
/>
<ShareSheet :open="shareOpen" :payload="sharePayload" @close="shareOpen = false" />
</PageShell>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { api } from '../api/client'
import BackButton from '../components/BackButton.vue'
import HomeToolIcon from '../components/HomeToolIcon.vue'
import PageShell from '../components/PageShell.vue'
import ReportRich from '../components/ReportRich.vue'
import ScaleAnswering from '../components/scale/ScaleAnswering.vue'
import ScaleIntro from '../components/scale/ScaleIntro.vue'
import ScaleNavBar from '../components/scale/ScaleNavBar.vue'
import ScaleResult from '../components/scale/ScaleResult.vue'
import ShareSheet from '../components/ShareSheet.vue'
import { clearScaleDraft, loadScaleDraft, saveScaleDraft } from '../lib/scaleDraft'
import type { PortraitSharePayload } from '../lib/shareLink'
import { useScalePage } from '../composables/useScalePage'
const route = useRoute()
const slug = String(route.params.slug || '')
const title = ref('')
const description = ref('')
const questions = ref<{ id: string; body: { prompt: string; options: { key: string; text: string }[] } }[]>([])
const answers = reactive<Record<string, string>>({})
const loadingScale = ref(true)
const submitting = ref(false)
const loadError = ref('')
const submitError = ref('')
const needProfile = ref(false)
const phase = ref<'intro' | 'answering' | 'result'>('intro')
const resultLabel = ref('')
const shareLine = ref('')
const shareOpen = ref(false)
const resultRaw = ref<Record<string, unknown> | null>(null)
const draftRestored = ref(false)
const persistReady = ref(false)
const currentIdx = ref(0)
const currentQuestion = computed(() => questions.value[currentIdx.value] || null)
const isLast = computed(() => currentIdx.value >= questions.value.length - 1)
const progressPct = computed(() => {
if (!questions.value.length) return 0
return Math.round(((currentIdx.value + 1) / questions.value.length) * 100)
})
const estMinutes = computed(() => Math.max(1, Math.ceil(questions.value.length / 3)))
watch(
const {
title,
description,
questions,
answers,
() => {
if (!persistReady.value || phase.value === 'result') return
saveScaleDraft(slug, { ...answers })
},
{ deep: true },
)
const resultSummaryObj = computed(() => {
const r = resultRaw.value || {}
return {
overview: r.overview || r.summary,
keywords: r.label ? [String(r.label)] : [],
dimensions: r.dimensions,
strengths_preview: r.strengths,
watchouts_preview: r.watchouts,
} as Record<string, unknown>
})
const resultDetailObj = computed(() => {
const r = resultRaw.value || {}
return {
tips: r.tips,
conversation_scripts: r.scripts,
growth_plan: r.growth_plan,
faq: r.faq,
blind_spots: r.watchouts,
strengths: r.strengths,
} as Record<string, unknown>
})
const sharePayload = computed<PortraitSharePayload | null>(() => {
if (phase.value !== 'result') return null
return {
type: 'portrait',
title: resultLabel.value,
line: shareLine.value || String(resultRaw.value?.summary || ''),
keywords: resultLabel.value ? [resultLabel.value] : [],
}
})
function firstUnansweredIdx(): number {
const idx = questions.value.findIndex((q) => !answers[q.id])
return idx >= 0 ? idx : 0
}
function startTest() {
phase.value = 'answering'
currentIdx.value = draftRestored.value ? firstUnansweredIdx() : 0
}
function prevQ() {
if (currentIdx.value > 0) currentIdx.value--
}
function onNext() {
if (isLast.value) {
submit()
} else {
currentIdx.value++
}
}
async function load() {
loadingScale.value = true
loadError.value = ''
needProfile.value = false
try {
const d = await api.getScale(slug)
title.value = d.title
description.value = d.description
questions.value = d.questions.map((q) => ({
id: q.id,
body: typeof q.body === 'string' ? JSON.parse(q.body as unknown as string) : q.body,
}))
const draft = loadScaleDraft(slug)
if (draft) {
const ids = new Set(questions.value.map((q) => q.id))
let n = 0
for (const [id, key] of Object.entries(draft)) {
if (ids.has(id)) {
answers[id] = key
n++
}
}
draftRestored.value = n > 0
}
persistReady.value = true
} catch (e) {
loadError.value = e instanceof Error ? e.message : '加载失败'
} finally {
loadingScale.value = false
}
}
function openShare() {
shareOpen.value = true
}
async function submit() {
submitting.value = true
submitError.value = ''
needProfile.value = false
try {
for (const q of questions.value) {
if (!answers[q.id]) throw new Error('请完成全部题目')
}
const { items } = await api.listProfiles()
const self = (items || []).find((p) => p.relation === 'self')
if (!self) {
needProfile.value = true
throw new Error('请先在首页完成性格探索,创建个人档案')
}
const out = await api.submitScale(slug, self.id, { ...answers })
clearScaleDraft(slug)
draftRestored.value = false
phase.value = 'result'
resultRaw.value = out.result as Record<string, unknown>
resultLabel.value = String(out.result.label || '探索结果')
shareLine.value = String(out.result.share_line || '')
} catch (e) {
submitError.value = e instanceof Error ? e.message : '提交失败'
} finally {
submitting.value = false
}
}
onMounted(load)
loadingScale,
submitting,
loadError,
submitError,
needProfile,
phase,
resultLabel,
shareLine,
shareOpen,
draftRestored,
currentIdx,
currentQuestion,
isLast,
progressPct,
estMinutes,
resultSummaryObj,
resultDetailObj,
sharePayload,
load,
startTest,
prevQ,
onNext,
openShare,
setAnswer,
} = useScalePage()
</script>
<style scoped>
@@ -282,188 +133,4 @@ onMounted(load)
.body.has-bar {
padding-bottom: 72px;
}
.intro-screen {
display: flex;
justify-content: center;
padding: 24px 0 32px;
}
.intro-card {
width: 100%;
max-width: 340px;
text-align: center;
padding: 32px 24px;
background: var(--color-surface);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-hero);
}
.intro-title {
font-family: var(--font-display);
font-size: 22px;
font-weight: 700;
color: var(--color-text-primary);
margin: 16px 0 8px;
letter-spacing: 0.04em;
}
.intro-desc {
font-size: 14px;
color: var(--color-text-secondary);
line-height: 1.55;
margin: 0 0 12px;
}
.intro-count {
font-size: 12px;
color: var(--color-text-tertiary);
margin: 0 0 20px;
}
.draft-hint {
font-size: 12px;
color: var(--color-accent-gold);
margin: 0 0 12px;
}
.intro-start {
font-size: 16px;
font-weight: 700;
padding: 14px;
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
box-shadow: 0 6px 18px rgba(229, 77, 66, 0.28);
}
.answer-screen {
padding-bottom: 8px;
}
.progress-wrap {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
}
.progress-bar {
flex: 1;
height: 6px;
border-radius: 999px;
background: #f0ebe7;
overflow: hidden;
}
.progress-fill {
display: block;
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, #ff7a6e, var(--color-primary));
transition: width 0.3s ease;
}
.progress-label {
font-size: 12px;
font-weight: 600;
color: var(--color-text-tertiary);
flex-shrink: 0;
}
.q-card {
margin: 0 0 12px;
}
.prompt {
font-weight: 650;
font-size: 17px;
color: #222;
margin-bottom: 16px;
line-height: 1.5;
}
.opt {
display: flex;
align-items: flex-start;
gap: 10px;
margin: 10px 0;
padding: 14px 12px;
border-radius: 14px;
border: 1.5px solid #eee;
background: #fdfaf8;
cursor: pointer;
line-height: 1.45;
transition: border-color 0.15s ease, background 0.15s ease;
}
.opt.picked {
border-color: var(--color-primary);
background: #fff5f3;
}
.opt input {
margin-top: 3px;
accent-color: var(--color-primary);
}
.opt-text {
flex: 1;
font-size: 14px;
}
.nav-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 20;
display: flex;
gap: 10px;
padding: 10px 16px calc(10px + env(safe-area-inset-bottom, 0));
background: rgba(255, 255, 255, 0.94);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-top: 1px solid rgba(0, 0, 0, 0.06);
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.06);
}
.nav-btn {
flex: 1;
padding: 13px;
border-radius: 22px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
border: none;
}
.nav-btn.prev {
background: #f3eeea;
color: #555;
}
.nav-btn.prev:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.nav-btn.next {
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
color: #fff;
box-shadow: 0 4px 14px rgba(229, 77, 66, 0.25);
}
.nav-btn.next:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.result-lead {
font-family: var(--font-display);
font-size: 20px;
font-weight: 700;
color: #222;
margin: 4px 0 12px;
letter-spacing: 0.04em;
}
.share {
margin: 0 0 10px;
color: var(--yxg-gold);
}
.link {
display: block;
margin-top: 12px;
}
.need {
margin-top: 10px;
padding: 12px;
background: #fff8f7;
border-radius: 12px;
}
.need .yxg-btn {
margin-top: 10px;
}
.draft {
color: var(--yxg-gold);
margin-bottom: 8px;
}
</style>
@@ -55,16 +55,20 @@ describe('StarProfilePage', () => {
const w = mount(StarProfilePage, { global: { stubs: { RouterLink: true } } })
await flushPromises()
expect(api.createStar).toHaveBeenCalled()
expect(w.find('svg.wheel').exists() || w.text().includes('本命')).toBe(true)
expect(w.text()).toContain('相位速览')
expect(w.text()).toContain('今日行运')
await w.findAll('button').find((b) => b.text() === '星盘')!.trigger('click')
await flushPromises()
expect(w.find('svg.wheel').exists()).toBe(true)
expect(w.text()).toContain('相位速览')
expect(w.text()).toContain('太阳刑相月亮')
})
it('shows lifetime fortune tab', async () => {
it('shows daily and lifetime fortune tabs', async () => {
const w = mount(StarProfilePage, { global: { stubs: { RouterLink: true } } })
await flushPromises()
await w.findAll('button').find((b) => b.text() === '运势')!.trigger('click')
await flushPromises()
expect(w.text()).toContain('今日运势')
expect(w.text()).toContain('焦点:行动')
await w.findAll('button').find((b) => b.text() === '一生')!.trigger('click')
await flushPromises()
expect(w.text()).toContain('一生运势摘要')
+104 -615
View File
@@ -12,26 +12,17 @@
</template>
<div class="body">
<template v-if="needBirth && !report">
<div class="intro-card">
<HomeToolIcon name="star" :size="64" />
<h1 class="intro-title">星座性格</h1>
<p class="intro-sub">填写生日看见太阳 · 月亮 · 上升与行星画像</p>
</div>
<div class="yxg-card form-card">
<label class="yxg-label">生日 / 出生时可选</label>
<div class="birth-row">
<BirthDateInputs v-model:year="year" v-model:month="month" v-model:day="day" compact />
<input class="bd-time" v-model="birthTime" type="time" aria-label="出生时" />
</div>
<label class="yxg-label">出生地可选</label>
<BirthPlaceSelect v-model="birthPlace" />
<button class="cta" type="button" :disabled="loading" @click="start">
{{ loading ? '生成中…' : '生成我的星座' }}
</button>
<p v-if="error" class="yxg-err">{{ error }}</p>
</div>
</template>
<StarBirthForm
v-if="needBirth && !report"
v-model:year="year"
v-model:month="month"
v-model:day="day"
v-model:birth-time="birthTime"
v-model:birth-place="birthPlace"
:loading="loading"
:error="error"
@start="start"
/>
<p v-else-if="loading" class="yxg-hint">正在生成星盘</p>
<p v-else-if="error && !report" class="yxg-err">
@@ -40,141 +31,53 @@
</p>
<template v-else-if="report">
<div class="mode-tabs" role="tablist">
<button
v-for="p in viewTabs"
:key="p.key"
type="button"
class="mode-tab"
:class="{ on: view === p.key }"
@click="view = p.key"
>
{{ p.label }}
</button>
</div>
<StarModeTabs v-model="view" :tabs="starViewTabs" />
<template v-if="view === 'overview'">
<h2 class="hl">{{ headline || '我的星座画像' }}</h2>
<p v-if="oneLiner" class="lead">{{ oneLiner }}</p>
<StarOverviewPanel
v-if="view === 'overview'"
:headline="headline"
:one-liner="oneLiner"
:chart-note="chartNote"
:keywords="keywords"
:grid-cells="gridCells"
:sign-tab="signTab"
:active-card="activeCard"
@grid-tap="onGridTap"
/>
<div class="spotlight">
<div class="sp-head">
<strong>星象速览</strong>
<router-link class="sp-ai" to="/ask">AI 解读 </router-link>
</div>
<p class="sp-text">{{ chartNote || '行星落在不同星座,构成独特的你。' }}</p>
<div v-if="keywords.length" class="tags">
<span v-for="k in keywords.slice(0, 4)" :key="k" class="tag">{{ k }}</span>
</div>
</div>
<StarChartPanel
v-else-if="view === 'chart'"
v-model:show-outer="showOuter"
v-model:tight-orb="tightOrb"
:wheel-planets="wheelPlanets"
:aspect-lines="aspectLines"
:asc-lon="ascLon"
:houses="houses"
:aspect-preview="aspectPreview"
@wheel-select="onWheelSelect"
/>
<div class="planet-grid">
<button
v-for="(cell, i) in gridCells"
:key="cell.key + i"
type="button"
class="cell"
:class="{ center: cell.center, on: signTab === cell.key }"
@click="onGridTap(cell)"
>
<template v-if="cell.center">
<HomeToolIcon name="star" :size="40" />
</template>
<template v-else>
<strong class="nick">{{ cell.nick }}</strong>
<span class="role">{{ cell.role }}</span>
</template>
</button>
</div>
<StarFortunePanel
v-else-if="view === 'fortune'"
v-model:fortune-key="fortuneKey"
:fortune-keys="starFortuneKeys"
:active-fortune="activeFortune"
/>
<div v-if="activeCard" class="detail-card">
<h3>{{ activeCard.title }} · {{ activeCard.label }}</h3>
<p>{{ activeCard.teaser }}</p>
<p v-if="activeCard.element" class="yxg-meta">{{ activeCard.element }} · {{ activeCard.modality }}</p>
</div>
<StarPlanetsPanel
v-else
:planets="planets"
@select="onPlanetSelect"
/>
<router-link class="ask-bar" to="/ask">
<span>AI本周我可以关注什么</span>
<span aria-hidden="true"></span>
</router-link>
</template>
<template v-else-if="view === 'chart'">
<div class="wheel-settings">
<label><input v-model="showOuter" type="checkbox" /> 外行星</label>
<label><input v-model="tightOrb" type="checkbox" /> 紧容许度</label>
</div>
<NatalWheel
v-if="wheelPlanets.length"
:planets="wheelPlanets"
:aspects="aspectLines"
:asc-lon="ascLon"
:show-outer="showOuter"
:tight-orb="tightOrb"
@select="onWheelSelect"
/>
<div v-if="houses.length" class="yxg-card soft">
<h2 class="card-title">宫位</h2>
<p class="house-row" v-for="h in houses" :key="h.num">{{ h.num }} · {{ h.sign }}</p>
</div>
<div v-if="aspectPreview.length" class="yxg-card soft">
<h2 class="card-title">相位速览</h2>
<p v-for="(a, i) in aspectPreview" :key="i" class="aspect-line">
{{ a.label || `${a.a_title}${a.type}${a.b_title}` }}
</p>
</div>
</template>
<template v-else-if="view === 'fortune'">
<div class="fortune-tabs">
<button
v-for="k in fortuneKeys"
:key="k"
type="button"
class="ftab"
:class="{ on: fortuneKey === k }"
@click="fortuneKey = k"
>
{{ fortuneLabel(k) }}
</button>
</div>
<div v-if="activeFortune" class="fortune-card">
<p class="daily-title">
{{ activeFortune.title }} · {{ activeFortune.label }} · {{ activeFortune.score }}
</p>
<p class="focus">焦点{{ activeFortune.focus }}</p>
<p>{{ activeFortune.tip }}</p>
<div v-if="activeFortune.dims" class="dims">
<span>感情 {{ activeFortune.dims.love }}</span>
<span>事业 {{ activeFortune.dims.career }}</span>
<span>财运 {{ activeFortune.dims.money }}</span>
<span>心情 {{ activeFortune.dims.mood }}</span>
</div>
<p class="yxg-meta">幸运{{ activeFortune.lucky }}</p>
<p class="yxg-meta">注意{{ activeFortune.caution }}</p>
</div>
</template>
<template v-else>
<ul class="planet-list">
<li v-for="p in planets" :key="p.key" class="planet-row" @click="signTab = p.key; view = 'overview'">
<strong>{{ p.title }}</strong>
<span>{{ p.sign }} {{ p.degree }} · {{ p.house }}</span>
</li>
</ul>
</template>
<ReportRich :summary="summary" :detail="detail" :deep="!!report.has_deep_access" />
<div v-if="!report.has_deep_access" class="lock-card">
<p>完整相位与年运详解可在深度版或成长会员中查看</p>
<button class="cta soft" type="button" :disabled="paying" @click="buyDeep">解锁完整分析</button>
</div>
<div class="bottom-links">
<router-link to="/synastry">合盘 </router-link>
<router-link to="/relation">人格匹配 </router-link>
<router-link v-if="report.id" :to="`/reports/${report.id}`">报告 </router-link>
</div>
<StarReportFooter
:summary="summary"
:detail="detail"
:deep="!!report.has_deep_access"
:paying="paying"
:report-id="report.id"
@buy-deep="buyDeep"
/>
</template>
<p class="yxg-disc">运势与星盘为自我探索参考请结合现实判断</p>
@@ -184,271 +87,63 @@
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import type { GrowthReport } from '@yuxingu/types'
import { validateBirth } from '@yuxingu/utils'
import { api } from '../api/client'
import BackButton from '../components/BackButton.vue'
import BirthDateInputs from '../components/BirthDateInputs.vue'
import BirthPlaceSelect from '../components/BirthPlaceSelect.vue'
import HomeToolIcon from '../components/HomeToolIcon.vue'
import NatalWheel, { type WheelAspect, type WheelPlanet } from '../components/NatalWheel.vue'
import PageShell from '../components/PageShell.vue'
import ReportRich from '../components/ReportRich.vue'
import ShareSheet from '../components/ShareSheet.vue'
import type { SignCard } from '../components/SignCards.vue'
import { AnalyticsEvent, track } from '../lib/analytics'
import type { PortraitSharePayload } from '../lib/shareLink'
import StarBirthForm from '../components/star/StarBirthForm.vue'
import StarChartPanel from '../components/star/StarChartPanel.vue'
import StarFortunePanel from '../components/star/StarFortunePanel.vue'
import StarModeTabs from '../components/star/StarModeTabs.vue'
import StarOverviewPanel from '../components/star/StarOverviewPanel.vue'
import StarPlanetsPanel from '../components/star/StarPlanetsPanel.vue'
import StarReportFooter from '../components/star/StarReportFooter.vue'
import { starFortuneKeys, starViewTabs, useStarProfilePage } from '../composables/useStarProfilePage'
const SETTINGS_KEY = 'yuxingu_star_wheel_settings'
const {
loading,
needBirth,
error,
report,
paying,
shareOpen,
year,
month,
day,
birthTime,
birthPlace,
view,
signTab,
fortuneKey,
showOuter,
tightOrb,
summary,
detail,
headline,
oneLiner,
keywords,
chartNote,
ascLon,
houses,
activeCard,
activeFortune,
planets,
wheelPlanets,
aspectPreview,
aspectLines,
gridCells,
sharePayload,
onWheelSelect,
onGridTap,
resetForm,
start,
load,
buyDeep,
} = useStarProfilePage()
const route = useRoute()
const loading = ref(false)
const needBirth = ref(true)
const error = ref('')
const report = ref<GrowthReport | null>(null)
const paying = ref(false)
const shareOpen = ref(false)
const year = ref(String(route.query.y || ''))
const month = ref(String(route.query.m || ''))
const day = ref(String(route.query.d || ''))
const birthTime = ref('')
const birthPlace = ref('')
const view = ref<'overview' | 'chart' | 'fortune' | 'planets'>('overview')
const signTab = ref('sun')
const fortuneKey = ref<'daily' | 'weekly' | 'monthly' | 'yearly' | 'lifetime'>('daily')
const showOuter = ref(true)
const tightOrb = ref(false)
const viewTabs = [
{ key: 'overview' as const, label: '概览' },
{ key: 'chart' as const, label: '星盘' },
{ key: 'fortune' as const, label: '运势' },
{ key: 'planets' as const, label: '行星' },
]
const fortuneKeys = ['daily', 'weekly', 'monthly', 'yearly', 'lifetime'] as const
try {
const raw = localStorage.getItem(SETTINGS_KEY)
if (raw) {
const o = JSON.parse(raw) as { showOuter?: boolean; tightOrb?: boolean }
if (typeof o.showOuter === 'boolean') showOuter.value = o.showOuter
if (typeof o.tightOrb === 'boolean') tightOrb.value = o.tightOrb
}
} catch {
/* ignore */
function onPlanetSelect(key: string) {
signTab.value = key
view.value = 'overview'
}
watch([showOuter, tightOrb], () => {
localStorage.setItem(SETTINGS_KEY, JSON.stringify({ showOuter: showOuter.value, tightOrb: tightOrb.value }))
})
type FortunePeriod = {
title?: string
label?: string
score?: number
tip?: string
focus?: string
lucky?: string
caution?: string
dims?: { love?: number; career?: number; money?: number; mood?: number }
}
type PlanetRow = WheelPlanet & { element?: string; modality?: string }
type AspectRow = WheelAspect & { a_title?: string; b_title?: string; label?: string }
type HouseRow = { num: number; sign: string }
const summary = computed(() => (report.value?.summary || {}) as Record<string, unknown>)
const detail = computed(() => (report.value?.detail || null) as Record<string, unknown> | null)
const headline = computed(() => String(summary.value.headline || ''))
const oneLiner = computed(() => String(summary.value.one_liner || ''))
const keywords = computed(() => (Array.isArray(summary.value.keywords) ? (summary.value.keywords as string[]) : []))
const chartObj = computed(() =>
summary.value.chart && typeof summary.value.chart === 'object' ? (summary.value.chart as Record<string, unknown>) : {},
)
const chartNote = computed(() => String(chartObj.value.note || ''))
const ascLon = computed(() => (typeof chartObj.value.asc_lon === 'number' ? chartObj.value.asc_lon : null))
const houses = computed(() => (Array.isArray(chartObj.value.houses) ? (chartObj.value.houses as HouseRow[]) : []))
const signCards = computed(() => {
const raw = summary.value.sign_cards
return Array.isArray(raw) ? (raw as SignCard[]) : []
})
const activeCard = computed(() => signCards.value.find((c) => c.key === signTab.value) || signCards.value[0])
const fortuneBundle = computed(() => {
const f = summary.value.fortune
return f && typeof f === 'object' ? (f as Record<string, FortunePeriod>) : {}
})
const activeFortune = computed(() => fortuneBundle.value[fortuneKey.value] || null)
const planets = computed(() => {
const raw = summary.value.planets
return Array.isArray(raw) ? (raw as PlanetRow[]) : []
})
const wheelPlanets = computed<WheelPlanet[]>(() =>
planets.value.map((p) => ({
key: p.key,
title: p.title,
sign: p.sign,
degree: p.degree,
house: p.house,
lon: Number(p.lon),
element: p.element,
modality: p.modality,
})),
)
const aspectPreview = computed(() => {
const raw = summary.value.aspects_preview
return Array.isArray(raw) ? (raw as AspectRow[]) : []
})
const fullAspects = computed(() => {
const raw = detail.value?.aspects
return Array.isArray(raw) ? (raw as AspectRow[]) : aspectPreview.value
})
const aspectLines = computed<WheelAspect[]>(() => {
const src = report.value?.has_deep_access ? fullAspects.value : aspectPreview.value
return src.map((a) => ({ a: a.a, b: a.b, type: a.type, orb: a.orb, label: a.label }))
})
const gridCells = computed(() => {
const cards = signCards.value
const slots: { key: string; nick: string; role: string; center?: boolean }[] = []
const order = ['sun', 'moon', 'rise', 'venus', 'mars', 'mercury', 'north_node', 'juno', 'jupiter']
const byKey = new Map(cards.map((c) => [c.key, c]))
const planetByKey = new Map(planets.value.map((p) => [p.key, p]))
for (let i = 0; i < 9; i++) {
if (i === 4) {
slots.push({ key: '_center', nick: '', role: '', center: true })
continue
}
const key = order[i < 4 ? i : i - 1] || cards[i]?.key || `p${i}`
const c = byKey.get(key)
const p = planetByKey.get(key)
if (c) {
slots.push({
key: c.key,
nick: (c.teaser || c.label || c.title).slice(0, 6),
role: `${c.title}${c.label}`,
})
} else if (p) {
slots.push({ key: p.key, nick: p.sign, role: `${p.title}${p.sign}` })
} else if (cards[i < 4 ? i : i - 1]) {
const x = cards[i < 4 ? i : i - 1]
slots.push({ key: x.key, nick: (x.teaser || x.label).slice(0, 6), role: `${x.title}${x.label}` })
} else {
slots.push({ key: `empty${i}`, nick: '—', role: '待生成' })
}
}
return slots
})
const sharePayload = computed<PortraitSharePayload | null>(() => {
if (!report.value) return null
return { type: 'portrait', title: headline.value || '我的星座', line: oneLiner.value, keywords: keywords.value }
})
function fortuneLabel(k: string) {
return ({ daily: '今日', weekly: '本周', monthly: '本月', yearly: '今年', lifetime: '一生' } as Record<string, string>)[k] || k
}
function onWheelSelect(key: string) {
if (['sun', 'moon', 'rise'].includes(key)) signTab.value = key
track(AnalyticsEvent.StarWheelViewed, { planet: key })
}
function onGridTap(cell: { key: string; center?: boolean }) {
if (cell.center) {
view.value = 'chart'
return
}
if (cell.key.startsWith('empty')) return
signTab.value = cell.key
}
function resetForm() {
needBirth.value = true
report.value = null
}
async function generate(y: number, m: number, d: number) {
loading.value = true
error.value = ''
needBirth.value = false
try {
const birth = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
const bt = birthTime.value ? birthTime.value.slice(0, 5) : undefined
const profile = await api.createProfile({
relation: 'self',
birth_date: birth,
display_name: '我',
birth_time: bt,
birth_place: birthPlace.value || undefined,
})
report.value = await api.createStar(profile.id)
view.value = 'overview'
track(AnalyticsEvent.PortraitCompleted, { source: 'star' })
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
needBirth.value = true
} finally {
loading.value = false
}
}
async function start() {
const y = Number(year.value)
const m = Number(month.value)
const d = Number(day.value)
const msg = validateBirth(y, m, d)
if (msg) {
error.value = msg
return
}
await generate(y, m, d)
}
async function load() {
const reportId = String(route.query.report_id || '')
if (reportId) {
loading.value = true
needBirth.value = false
try {
report.value = await api.getReport(reportId)
view.value = 'overview'
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
needBirth.value = true
} finally {
loading.value = false
}
return
}
const y = Number(route.query.y)
const m = Number(route.query.m)
const d = Number(route.query.d)
if (y && m && d && !validateBirth(y, m, d)) {
year.value = String(y)
month.value = String(m)
day.value = String(d)
await generate(y, m, d)
}
}
async function buyDeep() {
if (!report.value) return
paying.value = true
error.value = ''
track(AnalyticsEvent.DeepAccessClicked, { surface: 'star' })
try {
const { order_id } = await api.createOrder({ kind: 'deep_access', report_id: report.value.id })
await api.payMock(order_id)
report.value = await api.getReport(report.value.id)
track(AnalyticsEvent.PurchaseCompleted, { kind: 'deep_access' })
} catch (e) {
error.value = e instanceof Error ? e.message : '支付失败'
} finally {
paying.value = false
}
}
onMounted(load)
</script>
<style scoped>
@@ -477,210 +172,4 @@ onMounted(load)
font-size: 16px;
}
.body { padding: 8px 16px 24px; }
.intro-card { text-align: center; padding: 20px 12px 8px; }
.intro-title {
font-family: var(--font-display);
font-size: 24px;
font-weight: 700;
margin-top: 8px;
letter-spacing: 0.08em;
}
.intro-sub { font-size: 13px; color: #888; margin-top: 6px; line-height: 1.5; }
.form-card { margin-top: 12px; }
.cta {
margin-top: 14px;
width: 100%;
padding: 13px;
border: none;
border-radius: 22px;
color: #fff;
font-size: 15px;
font-weight: 600;
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
box-shadow: 0 6px 16px rgba(229, 77, 66, 0.28);
}
.cta.soft {
background: #fff;
color: var(--color-primary);
border: 1.5px solid #f5c4bc;
box-shadow: none;
}
.cta:disabled { opacity: 0.45; }
.birth-row { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
.bd-time {
width: 108px;
padding: 10px 6px;
border: 1.5px solid #eee;
border-radius: 12px;
font-size: 14px;
text-align: center;
background: #fdfaf8;
}
.mode-tabs {
display: flex;
gap: 4px;
margin-bottom: 14px;
background: #fafafa;
border-radius: var(--radius-pill);
padding: 4px;
}
.mode-tab {
flex: 1;
border: none;
background: transparent;
padding: 9px 4px;
border-radius: var(--radius-pill);
font-size: 13px;
color: #888;
font-weight: 600;
}
.mode-tab.on {
background: #fff;
color: var(--color-primary);
box-shadow: 0 2px 8px rgba(229, 77, 66, 0.1);
}
.hl {
font-family: var(--font-display);
font-size: 20px;
font-weight: 700;
line-height: 1.35;
color: #222;
}
.lead { margin-top: 8px; font-size: 13px; color: #777; line-height: 1.55; }
.spotlight {
margin-top: 14px;
padding: 14px;
border-radius: var(--radius-xl);
background: linear-gradient(145deg, #fff0ec, #ffe4dc);
box-shadow: var(--shadow-card);
}
.sp-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.sp-head strong { font-size: 14px; color: #222; }
.sp-ai { font-size: 12px; color: var(--color-primary); font-weight: 600; }
.sp-text { font-size: 12px; color: #666; line-height: 1.5; }
.tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
.tag {
font-size: 11px;
padding: 4px 10px;
border-radius: var(--radius-pill);
background: rgba(255, 255, 255, 0.8);
color: #8a4a3a;
font-weight: 600;
}
.planet-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin-top: 14px;
}
.cell {
min-height: 88px;
border: 1px solid #f0ebe8;
background: #fafafa;
border-radius: 16px;
padding: 12px 8px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
text-align: center;
}
.cell.on {
border-color: #f5c4bc;
background: #fff7f4;
box-shadow: var(--shadow-card);
}
.cell.center {
background: linear-gradient(160deg, #eef4ff, #ffe8e0);
border-color: transparent;
}
.nick { font-size: 14px; font-weight: 700; color: #222; line-height: 1.2; }
.role { font-size: 11px; color: var(--color-primary); font-weight: 600; }
.detail-card {
margin-top: 12px;
padding: 14px;
background: #fff;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
}
.detail-card h3 { font-size: 15px; margin-bottom: 6px; color: #222; }
.detail-card p { font-size: 13px; color: #555; line-height: 1.6; }
.ask-bar {
margin-top: 14px;
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 16px;
border-radius: var(--radius-pill);
background: #fff;
border: 1.5px solid #f0ebe8;
font-size: 13px;
font-weight: 600;
color: #555;
box-shadow: var(--shadow-card);
}
.fortune-tabs { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; }
.ftab {
flex: 1;
min-width: 52px;
border: 1px solid #eee;
background: #fafafa;
border-radius: 12px;
padding: 10px 4px;
font-size: 12px;
color: #666;
}
.ftab.on {
border-color: var(--color-primary);
background: #fff5f4;
color: #222;
font-weight: 600;
}
.fortune-card { padding: 16px; border-radius: var(--radius-xl); background: #fafafa; }
.daily-title { font-weight: 700; color: #222; margin-bottom: 6px; }
.focus { margin: 0 0 8px; color: #666; }
.dims { display: flex; flex-wrap: wrap; gap: 10px; margin: 10px 0; font-size: 12px; color: #555; }
.planet-list { list-style: none; margin: 0; padding: 0; }
.planet-row {
display: flex;
flex-direction: column;
gap: 4px;
padding: 14px;
margin-bottom: 8px;
background: #fafafa;
border-radius: 14px;
}
.planet-row strong { font-size: 14px; color: #222; }
.planet-row span { font-size: 12px; color: #888; }
.lock-card {
margin-top: 12px;
padding: 16px;
background: linear-gradient(145deg, #fff8e8, #ffeec8);
border-radius: var(--radius-xl);
}
.bottom-links {
display: flex;
gap: 16px;
margin: 16px 0 8px;
font-size: 13px;
font-weight: 600;
color: var(--color-primary);
}
.wheel-settings {
display: flex;
gap: 16px;
font-size: 12px;
color: #666;
margin: 4px 0 8px;
justify-content: center;
}
.house-row, .aspect-line { font-size: 13px; color: #555; margin: 4px 0; }
.yxg-label { display: block; margin: 12px 0 6px; font-size: 12px; color: #999; }
.yxg-label:first-child { margin-top: 0; }
</style>