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:
@@ -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="请输入昵称(1–6 个字)" 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>
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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('还没有个人档案')
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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('开通后可查看画像')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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="请输入昵称(1–6 个字)" 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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('一生运势摘要')
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -9,3 +9,4 @@
|
||||
- **ECR-001 Phase B**:拆分 `HomePage` / `SynastryPage`(≤400 行);build + homeCatalog 单测绿
|
||||
- **ECR-001 Phase A/C/D/E + ECR-002**:membership 服务抽出;relation/star 引擎拆文件;`star/fortune`→`outlook`(JSON 双写);integration 绿
|
||||
- H5:`/psy` baseURL + vite `/psy/api` 代理;首页 + 菜单 z-index 修复
|
||||
- **ECR-001 Phase F + H5**:types/sdk 对齐 OpenAPI;拆分 8 个超标页(均 ≤400);test:h5 35/35 绿
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# CODE_REVIEW — ECR-001 Phase F + H5 splits
|
||||
|
||||
**Reviewer:** Cursor Agent (REVIEWER)
|
||||
**Date:** 2026-08-05
|
||||
**Decision:** **PASS**(Should-fix 已修)
|
||||
|
||||
## Scope reviewed
|
||||
|
||||
- `packages/types` / `packages/sdk`:DTO 抽出,路径不变
|
||||
- H5 拆分:Ask / Report / Profile / StarProfile / Relation / Scale / ImageCard / LifeRhythm
|
||||
- 配套 composables×8 + components×~36
|
||||
- Spec 对齐:Ask / StarProfile / Membership
|
||||
- **Follow-up fix(同切片):** ScaleSummary 收敛 · fortuneKey lifetime · ScaleAnswering emit · StarProfile 单测补强
|
||||
|
||||
## Evidence
|
||||
|
||||
| Check | Result |
|
||||
|-------|--------|
|
||||
| API URL / 方法名未改 | PASS |
|
||||
| Page → composable → component 接线 | PASS |
|
||||
| 页面与子组件 ≤400 行 | PASS |
|
||||
| `ScaleSummary` = Go `ScaleListItem`(slug/title/description) | PASS |
|
||||
| `fortuneKey` 含 `lifetime` | PASS |
|
||||
| Scale 选答经 `@answer` emit,无 prop 深层 v-model | PASS |
|
||||
| StarProfile 星盘相位 + 运势今日/一生断言 | PASS |
|
||||
| `build:h5` + `test:h5` 35/35 | PASS |
|
||||
|
||||
## Findings
|
||||
|
||||
### Blockers
|
||||
无。
|
||||
|
||||
### Should-fix
|
||||
已全部修复(见 Follow-up)。
|
||||
|
||||
### Nits(不阻断)
|
||||
|
||||
1. Relation / ImageCard 单测 `router-link` 未 stub → Vue warn(既有)。
|
||||
2. Commit message 需带 `ECR-001`;勿提交 `config.local.yaml` / `test-results/`。
|
||||
|
||||
## Risk
|
||||
|
||||
**低。**
|
||||
|
||||
## Gate
|
||||
|
||||
- [x] Architecture / layering PASS
|
||||
- [x] No API URL change
|
||||
- [x] Tests green
|
||||
- [x] Should-fix closed
|
||||
- [ ] Human commit
|
||||
|
||||
**Decision: PASS**
|
||||
@@ -0,0 +1,30 @@
|
||||
# HANDOFF — ECR-001 Phase F + H5 → Reviewer
|
||||
|
||||
## From
|
||||
Engineer
|
||||
|
||||
## To
|
||||
Reviewer / Human
|
||||
|
||||
## Slice
|
||||
ECR-001 Phase F(types/sdk 对齐)+ 超标 H5 页拆分
|
||||
|
||||
## Done
|
||||
- types/sdk 与 OpenAPI 路径对齐;DTO 补齐
|
||||
- 8 个超标页拆到 ≤400 行 + composables/components
|
||||
- build:h5 + test:h5 全绿;TEST_REPORT 已写
|
||||
|
||||
## Not in scope
|
||||
- 产品文案/交互改版
|
||||
- API URL / 契约破坏性变更
|
||||
- MembershipPage 拆分
|
||||
- JSON `fortune` 旧键删除(ECR-002 后续)
|
||||
|
||||
## Ask
|
||||
- Review 结构拆分是否无行为回归
|
||||
- Human commit(勿纳入 `config.local.yaml` / `test-results/`)
|
||||
|
||||
## Links
|
||||
- TASK: `docs/TASKS/TASK-20260805-ECR001-phaseF-h5.yaml`
|
||||
- TEST: `docs/TEST_REPORT/ECR-001-phaseF-h5.md`
|
||||
- STATE: `docs/STATE/ECR-001.md`
|
||||
@@ -4,10 +4,10 @@
|
||||
|-------|--------|
|
||||
| ECR | ECR-001 (+ ECR-002) |
|
||||
| Title | Structural realignment |
|
||||
| Active slice | Phase A–E 已编码 |
|
||||
| Owner | release-ready(CODE_REVIEW PASS) |
|
||||
| Phase | review PASS |
|
||||
| Next | Human commit / 可选 Phase F |
|
||||
| Active slice | Phase F + H5 超标页拆分 |
|
||||
| Owner | reviewer → human |
|
||||
| Phase | review PASS · awaiting commit |
|
||||
| Next | Human commit |
|
||||
| Updated | 2026-08-05 |
|
||||
|
||||
## History
|
||||
@@ -17,3 +17,4 @@
|
||||
| 2026-08-05 | Architect 诊断 + HANDOFF |
|
||||
| 2026-08-05 | Phase B Home/Synastry;代理 baseURL 热修 |
|
||||
| 2026-08-05 | Phase A 收口 · C membership · D engine 拆分 · E/ECR-002 outlook |
|
||||
| 2026-08-05 | Phase F types/sdk;拆分 Star/Report/Profile/Ask/Relation/Scale/ImageCard/LifeRhythm |
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# docs/TASKS/TASK-20260805-ECR001-phaseF-h5.yaml
|
||||
task_id: ECR-001-phaseF-h5
|
||||
role: engineer
|
||||
phase: coding
|
||||
tool: cursor
|
||||
|
||||
input:
|
||||
- docs/ECR/ECR-001-structural-realignment.md
|
||||
- docs/ENGINEERING_SPEC/ECR-001-structural-realignment.md
|
||||
- proto/openapi.yaml
|
||||
- .ai/coding.md
|
||||
|
||||
output:
|
||||
- packages/types/**
|
||||
- packages/sdk/**
|
||||
- apps/user-h5/src/pages/*
|
||||
- apps/user-h5/src/composables/*
|
||||
- apps/user-h5/src/components/**
|
||||
- docs/TEST_REPORT/ECR-001-phaseF-h5.md
|
||||
|
||||
forbidden:
|
||||
- redesign_scope
|
||||
- behavior_change
|
||||
- api_url_change
|
||||
|
||||
next_agent: reviewer
|
||||
status: coded
|
||||
notes: >
|
||||
Phase F packages align + continue H5 page splits for pages >400 lines.
|
||||
No product behavior change. build:h5 + test:h5 PASS. See TEST_REPORT/ECR-001-phaseF-h5.md.
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# Test Report — ECR-001 Phase F + H5 splits
|
||||
|
||||
## ECR
|
||||
ECR-001 Phase F · H5 page splits(超标页)
|
||||
|
||||
## Change Level
|
||||
L2(结构 · 无产品行为变更意图)
|
||||
|
||||
## Cases
|
||||
- [x] Phase F:`packages/types` 补齐 OpenAPI/SDK DTO;`packages/sdk` 引用 types
|
||||
- [x] H5 拆分(均 ≤400 行):StarProfile / Report / Profile / Ask / Relation / Scale / ImageCard / LifeRhythm
|
||||
- [x] 抽离 `components/{star,report,profile,ask,relation,scale,imagecard,rhythm}/` + `composables/use*Page.ts`
|
||||
- [x] 单测对齐现 UI:AskPage / StarProfilePage / MembershipPage
|
||||
- [x] `npm run build:h5` PASS
|
||||
- [x] `npm run test:h5` PASS(15 files / 35 tests)
|
||||
|
||||
## Page line counts(post-split)
|
||||
|
||||
| Page | Lines |
|
||||
|------|------:|
|
||||
| StarProfilePage | 175 |
|
||||
| ReportPage | 165 |
|
||||
| ProfilePage | 152 |
|
||||
| AskPage | 59 |
|
||||
| RelationPage | 117 |
|
||||
| ScalePage | 134 |
|
||||
| ImageCardPage | 96 |
|
||||
| LifeRhythmPage | 100 |
|
||||
|
||||
## Command
|
||||
```bash
|
||||
npm run build:h5
|
||||
npm run test:h5
|
||||
python scripts/ess-validate.py --phase coding --ecr ECR-001
|
||||
```
|
||||
|
||||
## Residual Risk
|
||||
- 拆分后部分子组件仍可能接近 400 行上限,后续可再切
|
||||
- MembershipPage 未在本轮拆分(未超标或未纳入本 TASK)
|
||||
- `router-link` 在 Relation 单测中有 Vue warn(既有 stub 缺口,非回归阻断)
|
||||
|
||||
## Gate
|
||||
- [x] Phase F + H5 超标页拆分落地
|
||||
- [ ] Reviewer / Human commit
|
||||
@@ -6,4 +6,5 @@
|
||||
| ECR-001 | structural realignment | Phase A–E coded | PRODUCT_SPEC · ENGINEERING_SPEC · HANDOFF · ADR-0007 |
|
||||
| ECR-001-B | H5 Home/Synastry split | PASS | TEST_REPORT/ECR-001-phaseB.md · CODE_REVIEW |
|
||||
| ECR-001-A/C/D/E | docs · membership · engines · outlook | coded → review | TEST_REPORT/ECR-001-phaseA-E.md |
|
||||
| ECR-001-F/H5 | types/sdk + 8 超标页拆分 | review PASS | TEST_REPORT · CODE_REVIEW/ECR-001-phaseF-h5.md |
|
||||
| ECR-002 | fortune→outlook hygiene | Approved / coded | `docs/ECR/ECR-002-fortune-hygiene.md` |
|
||||
|
||||
+45
-122
@@ -1,11 +1,30 @@
|
||||
import type {
|
||||
AcceptSynastryInviteBody,
|
||||
ApiResponse,
|
||||
AskMessage,
|
||||
AskQuota,
|
||||
AskThread,
|
||||
CreateOrderBody,
|
||||
CreateProfileBody,
|
||||
ExploreCategory,
|
||||
GrowthCheckin,
|
||||
GrowthPlan,
|
||||
GrowthReport,
|
||||
ImageCardDrawn,
|
||||
ImageCardQuota,
|
||||
ImageCardScene,
|
||||
MembershipMe,
|
||||
MoodEntry,
|
||||
Profile,
|
||||
RelationInsightResult,
|
||||
ScaleDetail,
|
||||
ScaleSubmitResult,
|
||||
ScaleSummary,
|
||||
SolarTermToday,
|
||||
SynastryInviteCreated,
|
||||
SynastryInviteView,
|
||||
SynastryNearbyItem,
|
||||
UpdateProfileBody,
|
||||
} from '@yuxingu/types'
|
||||
|
||||
/** Platform adapters so H5 and mini-program share one client. */
|
||||
@@ -29,7 +48,7 @@ export interface CreateClientOptions {
|
||||
}
|
||||
|
||||
/**
|
||||
* createClient builds a typed API facade.
|
||||
* createClient builds a typed API facade aligned with proto/openapi.yaml paths.
|
||||
*/
|
||||
export function createClient(opts: CreateClientOptions) {
|
||||
const { baseURL, adapters } = opts
|
||||
@@ -60,27 +79,10 @@ export function createClient(opts: CreateClientOptions) {
|
||||
healthz: () => call<{ status: string }>('/api/v1/healthz'),
|
||||
ping: () => call<{ pong: boolean }>('/api/v1/ping'),
|
||||
listProfiles: () => call<{ items: Profile[] }>('/api/v1/profiles'),
|
||||
createProfile: (body: {
|
||||
relation: 'self' | 'other'
|
||||
birth_date: string
|
||||
display_name?: string
|
||||
relation_type?: string
|
||||
birth_time?: string
|
||||
birth_place?: string
|
||||
}) => call<Profile>('/api/v1/profiles', { method: 'POST', body }),
|
||||
updateProfile: (
|
||||
id: string,
|
||||
body: {
|
||||
display_name?: string
|
||||
birth_date?: string
|
||||
relation_type?: string
|
||||
birth_time?: string
|
||||
birth_place?: string
|
||||
geo_lat?: number
|
||||
geo_lng?: number
|
||||
geo_visible?: boolean
|
||||
},
|
||||
) => call<Profile>(`/api/v1/profiles/${id}`, { method: 'PATCH', body }),
|
||||
createProfile: (body: CreateProfileBody) =>
|
||||
call<Profile>('/api/v1/profiles', { method: 'POST', body }),
|
||||
updateProfile: (id: string, body: UpdateProfileBody) =>
|
||||
call<Profile>(`/api/v1/profiles/${id}`, { method: 'PATCH', body }),
|
||||
deleteProfile: (id: string) =>
|
||||
call<{ deleted: boolean }>(`/api/v1/profiles/${id}`, { method: 'DELETE' }),
|
||||
createPortrait: (profile_id: string) =>
|
||||
@@ -93,136 +95,57 @@ export function createClient(opts: CreateClientOptions) {
|
||||
body: { profile_id_a, profile_id_b, ...(as_of ? { as_of } : {}) },
|
||||
}),
|
||||
listSynastryNearby: (lat: number, lng: number, radius_km = 50) =>
|
||||
call<{ items: { profile: Profile; distance_km: number }[] }>(
|
||||
call<{ items: SynastryNearbyItem[] }>(
|
||||
`/api/v1/synastry/nearby?lat=${lat}&lng=${lng}&radius_km=${radius_km}`,
|
||||
),
|
||||
createSynastryInvite: (profile_id: string) =>
|
||||
call<{ token: string; expires_at: string; path: string }>('/api/v1/synastry/invites', {
|
||||
call<SynastryInviteCreated>('/api/v1/synastry/invites', {
|
||||
method: 'POST',
|
||||
body: { profile_id },
|
||||
}),
|
||||
getSynastryInvite: (token: string) =>
|
||||
call<{
|
||||
token: string
|
||||
expires_at: string
|
||||
host_name: string
|
||||
already_accepted: boolean
|
||||
}>(`/api/v1/synastry/invites/${token}`),
|
||||
acceptSynastryInvite: (token: string, body: {
|
||||
display_name?: string
|
||||
birth_date: string
|
||||
birth_time?: string
|
||||
birth_place?: string
|
||||
}) =>
|
||||
call<SynastryInviteView>(`/api/v1/synastry/invites/${token}`),
|
||||
acceptSynastryInvite: (token: string, body: AcceptSynastryInviteBody) =>
|
||||
call<GrowthReport>(`/api/v1/synastry/invites/${token}/accept`, { method: 'POST', body }),
|
||||
createRhythm: (profile_id: string) =>
|
||||
call<GrowthReport>('/api/v1/reports/rhythm', { method: 'POST', body: { profile_id } }),
|
||||
listImageCardScenes: () =>
|
||||
call<{ items: { key: string; label: string }[] }>('/api/v1/image-cards/scenes'),
|
||||
getImageCardQuota: () =>
|
||||
call<{ remaining: number; daily_free: number; unlimited: boolean }>('/api/v1/image-cards/quota'),
|
||||
listImageCardScenes: () => call<{ items: ImageCardScene[] }>('/api/v1/image-cards/scenes'),
|
||||
getImageCardQuota: () => call<ImageCardQuota>('/api/v1/image-cards/quota'),
|
||||
drawImageCard: (body: { profile_id: string; scene?: string; depth?: boolean }) =>
|
||||
call<{
|
||||
report: GrowthReport
|
||||
scene: string
|
||||
cards: { id: string; title: string; imagery: string; prompt: string; tip: string }[]
|
||||
quota_left: number
|
||||
}>('/api/v1/image-cards/draw', { method: 'POST', body }),
|
||||
getSolarTermsToday: () =>
|
||||
call<{ name: string; tip: string; date: string }>('/api/v1/solar-terms/today'),
|
||||
call<ImageCardDrawn>('/api/v1/image-cards/draw', { method: 'POST', body }),
|
||||
getSolarTermsToday: () => call<SolarTermToday>('/api/v1/solar-terms/today'),
|
||||
saveMood: (body: { score?: number; note?: string; day?: string }) =>
|
||||
call<{ id: string; day: string; score?: number; note?: string }>('/api/v1/moods', {
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
getMoodToday: () =>
|
||||
call<{ mood: { id: string; day: string; score?: number; note?: string } | null }>(
|
||||
'/api/v1/moods/today',
|
||||
),
|
||||
getMoodsRecent: () =>
|
||||
call<{ items: { id: string; day: string; score?: number; note?: string }[] }>(
|
||||
'/api/v1/moods/recent',
|
||||
),
|
||||
getExploreCatalog: () =>
|
||||
call<{
|
||||
categories: {
|
||||
key: string
|
||||
title: string
|
||||
description: string
|
||||
icon: string
|
||||
tone: string
|
||||
items: {
|
||||
key: string
|
||||
title: string
|
||||
description: string
|
||||
path: string
|
||||
icon: string
|
||||
tone: string
|
||||
badge?: string
|
||||
}[]
|
||||
}[]
|
||||
}>('/api/v1/explore/catalog'),
|
||||
getExploreCategory: (key: string) =>
|
||||
call<{
|
||||
key: string
|
||||
title: string
|
||||
description: string
|
||||
icon: string
|
||||
tone: string
|
||||
items: {
|
||||
key: string
|
||||
title: string
|
||||
description: string
|
||||
path: string
|
||||
icon: string
|
||||
tone: string
|
||||
badge?: string
|
||||
}[]
|
||||
}>(`/api/v1/explore/catalog/${key}`),
|
||||
listGrowthPlans: () =>
|
||||
call<{ items: { id: string; title: string; focus: string; status: string }[] }>(
|
||||
'/api/v1/growth/plans',
|
||||
),
|
||||
call<MoodEntry>('/api/v1/moods', { method: 'POST', body }),
|
||||
getMoodToday: () => call<{ mood: MoodEntry | null }>('/api/v1/moods/today'),
|
||||
getMoodsRecent: () => call<{ items: MoodEntry[] }>('/api/v1/moods/recent'),
|
||||
getExploreCatalog: () => call<{ categories: ExploreCategory[] }>('/api/v1/explore/catalog'),
|
||||
getExploreCategory: (key: string) => call<ExploreCategory>(`/api/v1/explore/catalog/${key}`),
|
||||
listGrowthPlans: () => call<{ items: GrowthPlan[] }>('/api/v1/growth/plans'),
|
||||
createGrowthPlan: (body: { title: string; focus?: string }) =>
|
||||
call<{ id: string; title: string; focus: string }>('/api/v1/growth/plans', {
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
call<GrowthPlan>('/api/v1/growth/plans', { method: 'POST', body }),
|
||||
growthCheckin: (planId: string, body?: { note?: string }) =>
|
||||
call<{ id: string; day: string }>(`/api/v1/growth/plans/${planId}/checkin`, {
|
||||
method: 'POST',
|
||||
body: body || {},
|
||||
}),
|
||||
listGrowthCheckins: (planId: string) =>
|
||||
call<{ items: { id: string; day: string; note?: string }[] }>(
|
||||
`/api/v1/growth/plans/${planId}/checkins`,
|
||||
),
|
||||
call<{ items: GrowthCheckin[] }>(`/api/v1/growth/plans/${planId}/checkins`),
|
||||
listReports: () => call<{ items: GrowthReport[] }>('/api/v1/reports'),
|
||||
getReport: (id: string) => call<GrowthReport>(`/api/v1/reports/${id}`),
|
||||
getMembership: () => call<MembershipMe>('/api/v1/membership/me'),
|
||||
createOrder: (body: { kind: 'membership' | 'deep_access'; plan?: string; report_id?: string }) =>
|
||||
createOrder: (body: CreateOrderBody) =>
|
||||
call<{ order_id: string }>('/api/v1/orders', { method: 'POST', body }),
|
||||
payMock: (orderId: string) =>
|
||||
call<{ paid: boolean }>(`/api/v1/orders/${orderId}/pay-mock`, { method: 'POST' }),
|
||||
createRelationInsight: (profile_a_id: string, profile_b_id: string) =>
|
||||
call<{
|
||||
insight: { id: string; report_id?: string }
|
||||
report: GrowthReport
|
||||
}>('/api/v1/relation/insight', {
|
||||
call<RelationInsightResult>('/api/v1/relation/insight', {
|
||||
method: 'POST',
|
||||
body: { profile_a_id, profile_b_id },
|
||||
}),
|
||||
listScales: () =>
|
||||
call<{ items: { slug: string; title: string; description: string }[] }>('/api/v1/scales'),
|
||||
getScale: (slug: string) =>
|
||||
call<{
|
||||
slug: string
|
||||
title: string
|
||||
description: string
|
||||
questions: { id: string; sort: number; body: { prompt: string; options: { key: string; text: string }[] } }[]
|
||||
}>(`/api/v1/scales/${slug}`),
|
||||
listScales: () => call<{ items: ScaleSummary[] }>('/api/v1/scales'),
|
||||
getScale: (slug: string) => call<ScaleDetail>(`/api/v1/scales/${slug}`),
|
||||
submitScale: (slug: string, profile_id: string, answers: Record<string, string>) =>
|
||||
call<{ id: string; result: Record<string, unknown> }>(`/api/v1/scales/${slug}/result`, {
|
||||
call<ScaleSubmitResult>(`/api/v1/scales/${slug}/result`, {
|
||||
method: 'POST',
|
||||
body: { profile_id, answers },
|
||||
}),
|
||||
|
||||
+145
-5
@@ -21,6 +21,26 @@ export interface Profile {
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface CreateProfileBody {
|
||||
relation: 'self' | 'other'
|
||||
birth_date: string
|
||||
display_name?: string
|
||||
relation_type?: string
|
||||
birth_time?: string
|
||||
birth_place?: string
|
||||
}
|
||||
|
||||
export interface UpdateProfileBody {
|
||||
display_name?: string
|
||||
birth_date?: string
|
||||
relation_type?: string
|
||||
birth_time?: string
|
||||
birth_place?: string
|
||||
geo_lat?: number
|
||||
geo_lng?: number
|
||||
geo_visible?: boolean
|
||||
}
|
||||
|
||||
/** 成长报告(detail 可能因权益被剥离) */
|
||||
export interface GrowthReport {
|
||||
id: string
|
||||
@@ -33,13 +53,34 @@ export interface GrowthReport {
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/** 探索测试列表项 */
|
||||
/** 探索测试列表项 — 对齐 Go ScaleListItem / listScales 响应 */
|
||||
export interface ScaleSummary {
|
||||
slug: string
|
||||
name: string
|
||||
category: string
|
||||
questionCount: number
|
||||
description?: string
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface ScaleQuestionOption {
|
||||
key: string
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface ScaleQuestion {
|
||||
id: string
|
||||
sort: number
|
||||
body: { prompt: string; options: ScaleQuestionOption[] }
|
||||
}
|
||||
|
||||
export interface ScaleDetail {
|
||||
slug: string
|
||||
title: string
|
||||
description: string
|
||||
questions: ScaleQuestion[]
|
||||
}
|
||||
|
||||
export interface ScaleSubmitResult {
|
||||
id: string
|
||||
result: Record<string, unknown>
|
||||
}
|
||||
|
||||
/** 成长会员当前权益 */
|
||||
@@ -51,6 +92,12 @@ export interface MembershipMe {
|
||||
ask_quota_left?: number
|
||||
}
|
||||
|
||||
export interface CreateOrderBody {
|
||||
kind: 'membership' | 'deep_access'
|
||||
plan?: string
|
||||
report_id?: string
|
||||
}
|
||||
|
||||
/** 问答线程 */
|
||||
export interface AskThread {
|
||||
id: string
|
||||
@@ -76,3 +123,96 @@ export interface AskQuota {
|
||||
free_limit: number
|
||||
source: 'membership' | 'free' | string
|
||||
}
|
||||
|
||||
export interface SynastryNearbyItem {
|
||||
profile: Profile
|
||||
distance_km: number
|
||||
}
|
||||
|
||||
export interface SynastryInviteCreated {
|
||||
token: string
|
||||
expires_at: string
|
||||
path: string
|
||||
}
|
||||
|
||||
export interface SynastryInviteView {
|
||||
token: string
|
||||
expires_at: string
|
||||
host_name: string
|
||||
already_accepted: boolean
|
||||
}
|
||||
|
||||
export interface AcceptSynastryInviteBody {
|
||||
display_name?: string
|
||||
birth_date: string
|
||||
birth_time?: string
|
||||
birth_place?: string
|
||||
}
|
||||
|
||||
export interface ImageCardScene {
|
||||
key: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface ImageCardQuota {
|
||||
remaining: number
|
||||
daily_free: number
|
||||
unlimited: boolean
|
||||
}
|
||||
|
||||
export interface ImageCardDrawn {
|
||||
report: GrowthReport
|
||||
scene: string
|
||||
cards: { id: string; title: string; imagery: string; prompt: string; tip: string }[]
|
||||
quota_left: number
|
||||
}
|
||||
|
||||
export interface SolarTermToday {
|
||||
name: string
|
||||
tip: string
|
||||
date: string
|
||||
}
|
||||
|
||||
export interface MoodEntry {
|
||||
id: string
|
||||
day: string
|
||||
score?: number
|
||||
note?: string
|
||||
}
|
||||
|
||||
export interface ExploreItem {
|
||||
key: string
|
||||
title: string
|
||||
description: string
|
||||
path: string
|
||||
icon: string
|
||||
tone: string
|
||||
badge?: string
|
||||
}
|
||||
|
||||
export interface ExploreCategory {
|
||||
key: string
|
||||
title: string
|
||||
description: string
|
||||
icon: string
|
||||
tone: string
|
||||
items: ExploreItem[]
|
||||
}
|
||||
|
||||
export interface GrowthPlan {
|
||||
id: string
|
||||
title: string
|
||||
focus: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
export interface GrowthCheckin {
|
||||
id: string
|
||||
day: string
|
||||
note?: string
|
||||
}
|
||||
|
||||
export interface RelationInsightResult {
|
||||
insight: { id: string; report_id?: string }
|
||||
report: GrowthReport
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user