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 } } })
|
const w = mount(AskPage, { global: { stubs: { RouterLink: RouterLinkStub } } })
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(w.text()).toContain('还没有个人档案')
|
expect(w.text()).toContain('还没有个人档案')
|
||||||
expect(w.text()).toContain('去首页探索')
|
expect(w.text()).toContain('去愈心解码')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('loads profiles and sends a message', async () => {
|
it('loads profiles and sends a message', async () => {
|
||||||
@@ -77,7 +77,7 @@ describe('AskPage', () => {
|
|||||||
expect(w.text()).toContain('网络错误')
|
expect(w.text()).toContain('网络错误')
|
||||||
api.listProfiles.mockResolvedValue({ items: [] })
|
api.listProfiles.mockResolvedValue({ items: [] })
|
||||||
api.getAskQuota.mockResolvedValue({ remaining: 3, free_limit: 3, active_membership: false, source: 'free' })
|
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()
|
await flushPromises()
|
||||||
expect(w.text()).toContain('还没有个人档案')
|
expect(w.text()).toContain('还没有个人档案')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,588 +1,59 @@
|
|||||||
<template>
|
<template>
|
||||||
<PageShell :sheet="false">
|
<PageShell :sheet="false">
|
||||||
<template #hero>
|
<template #hero>
|
||||||
<!-- 双轨顶栏 · 对标测测「测测AI|真人1v1」 -->
|
<AskRails v-model="rail" />
|
||||||
<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>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- AI 轨 -->
|
<AskAiPane
|
||||||
<div v-show="rail === 'ai'" class="ai-pane">
|
v-show="rail === 'ai'"
|
||||||
<p v-if="bootLoading" class="hint">加载中…</p>
|
:boot-loading="bootLoading"
|
||||||
<p v-else-if="bootError" class="err">
|
:boot-error="bootError"
|
||||||
{{ bootError }}
|
:profiles="profiles"
|
||||||
<button type="button" class="retry" @click="boot">重试</button>
|
:profile-id="profileId"
|
||||||
</p>
|
:messages="messages"
|
||||||
<template v-else-if="!profiles.length">
|
:draft="draft"
|
||||||
<div class="card empty">
|
:sending="sending"
|
||||||
<HomeToolIcon name="ask" :size="56" />
|
:send-error="sendError"
|
||||||
<p>还没有个人档案。完成愈心解码后再来提问,回答会更贴合你。</p>
|
:quota-exhausted="quotaExhausted"
|
||||||
<router-link class="cta" to="/portrait">去愈心解码</router-link>
|
:quota="quota"
|
||||||
</div>
|
@boot="boot"
|
||||||
</template>
|
@pick-scene="pickScene"
|
||||||
<template v-else>
|
@switch-advisor="rail = 'advisor'"
|
||||||
<!-- 冷启动引导 -->
|
@update:profile-id="profileId = $event"
|
||||||
<div v-if="!messages.length" class="card greet reveal">
|
@profile-change="onProfileChange"
|
||||||
<div class="greet-row">
|
@navigate="router.push($event)"
|
||||||
<HomeToolIcon name="ask" :size="48" />
|
@update:draft="draft = $event"
|
||||||
<div>
|
@send="send"
|
||||||
<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>
|
|
||||||
|
|
||||||
<div class="card controls">
|
<AskAdvisorPane v-show="rail === 'advisor'" @ask="askAdvisor" />
|
||||||
<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>
|
|
||||||
</PageShell>
|
</PageShell>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { nextTick, onMounted, ref } from 'vue'
|
import AskAdvisorPane from '../components/ask/AskAdvisorPane.vue'
|
||||||
import { useRouter } from 'vue-router'
|
import AskAiPane from '../components/ask/AskAiPane.vue'
|
||||||
import type { AskMessage, AskQuota, Profile } from '@yuxingu/types'
|
import AskRails from '../components/ask/AskRails.vue'
|
||||||
import { api } from '../api/client'
|
|
||||||
import HomeToolIcon from '../components/HomeToolIcon.vue'
|
|
||||||
import PageShell from '../components/PageShell.vue'
|
import PageShell from '../components/PageShell.vue'
|
||||||
|
import { useAskPage } from '../composables/useAskPage'
|
||||||
|
|
||||||
const router = useRouter()
|
const {
|
||||||
const rail = ref<'ai' | 'advisor'>('ai')
|
router,
|
||||||
|
rail,
|
||||||
const scenes = [
|
bootLoading,
|
||||||
{ key: 'self', label: '我想更了解自己的性格与互动风格', prompt: '我想更了解自己的性格与互动风格。' },
|
bootError,
|
||||||
{ key: 'relation', label: '和重要的人相处时该如何沟通?', prompt: '和重要的人相处时,我该如何更好地沟通?' },
|
profiles,
|
||||||
{ key: 'emotion', label: '最近情绪有点乱,想梳理一下', prompt: '最近情绪有点乱,我想梳理一下。' },
|
profileId,
|
||||||
{ key: 'career', label: '职业选择上可以注意什么?', prompt: '从我的特点看,职业选择上可以注意什么?' },
|
messages,
|
||||||
] as const
|
draft,
|
||||||
|
sending,
|
||||||
const guidePrompts = scenes
|
sendError,
|
||||||
|
quotaExhausted,
|
||||||
const quickTools = [
|
quota,
|
||||||
{ to: '/portrait', label: '工具' },
|
onProfileChange,
|
||||||
{ to: '/synastry', label: '合盘' },
|
pickScene,
|
||||||
{ to: '/cards', label: '意象卡' },
|
askAdvisor,
|
||||||
{ to: '/relation', label: '匹配' },
|
send,
|
||||||
]
|
boot,
|
||||||
|
} = useAskPage()
|
||||||
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)
|
|
||||||
</script>
|
</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>
|
</template>
|
||||||
|
|
||||||
<div class="body" :class="{ 'has-results': cards.length }">
|
<div class="body" :class="{ 'has-results': cards.length }">
|
||||||
<!-- Greeting -->
|
<ImageCardGreeting
|
||||||
<div class="greet">
|
:boot-error="bootError"
|
||||||
<div class="greet-icons" aria-hidden="true">
|
:quota-label="quotaLabel"
|
||||||
<HomeToolIcon name="cards" :size="44" />
|
:scene="scene"
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- Prompt rows (landing) -->
|
<ImageCardLanding
|
||||||
<div v-if="!cards.length" class="prompt-list">
|
v-if="!cards.length"
|
||||||
<button
|
v-model:want-depth="wantDepth"
|
||||||
v-for="(row, i) in promptRows"
|
v-model:show-birth="showBirth"
|
||||||
:key="i"
|
v-model:year="year"
|
||||||
type="button"
|
v-model:month="month"
|
||||||
class="prompt-row"
|
v-model:day="day"
|
||||||
@click="onPromptClick(row)"
|
:drawing="drawing"
|
||||||
>
|
@prompt-click="onPromptClick"
|
||||||
<span class="prompt-ico" aria-hidden="true">{{ row.icon }}</span>
|
@draw="draw"
|
||||||
<span class="prompt-text">{{ row.label }}</span>
|
/>
|
||||||
<span class="prompt-go" aria-hidden="true">→</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Mode chips -->
|
<ImageCardResults
|
||||||
<div v-if="!cards.length" class="mode-row" role="group" aria-label="抽卡模式">
|
v-if="cards.length"
|
||||||
<button
|
:cards="cards"
|
||||||
type="button"
|
:report="report"
|
||||||
class="mode-chip"
|
:summary="summary"
|
||||||
:class="{ on: !wantDepth }"
|
:detail="detail"
|
||||||
@click="wantDepth = false"
|
:want-depth="wantDepth"
|
||||||
>
|
:paying="paying"
|
||||||
单卡
|
@buy-deep="buyDeep"
|
||||||
</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>
|
|
||||||
|
|
||||||
<p class="yxg-disc ai-note">意象卡片用于投射与自我反思,不判定好坏,也不预测未来。部分内容由 AI 生成,仅供参考。</p>
|
<p class="yxg-disc ai-note">意象卡片用于投射与自我反思,不判定好坏,也不预测未来。部分内容由 AI 生成,仅供参考。</p>
|
||||||
<p v-if="error" class="yxg-err err-float">{{ error }}</p>
|
<p v-if="error" class="yxg-err err-float">{{ error }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Bottom input bar -->
|
<ImageCardInputBar
|
||||||
<div v-if="!cards.length" class="input-bar">
|
v-if="!cards.length"
|
||||||
<input
|
v-model:question-input="questionInput"
|
||||||
v-model="questionInput"
|
:drawing="drawing"
|
||||||
type="text"
|
@send="sendDraw"
|
||||||
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>
|
|
||||||
</PageShell>
|
</PageShell>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 BackButton from '../components/BackButton.vue'
|
||||||
import BirthDateInputs from '../components/BirthDateInputs.vue'
|
|
||||||
import HomeToolIcon from '../components/HomeToolIcon.vue'
|
|
||||||
import PageShell from '../components/PageShell.vue'
|
import PageShell from '../components/PageShell.vue'
|
||||||
import ReportRich from '../components/ReportRich.vue'
|
import ImageCardGreeting from '../components/imagecard/ImageCardGreeting.vue'
|
||||||
import { AnalyticsEvent, track } from '../lib/analytics'
|
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 {
|
||||||
const scene = ref('情绪整理')
|
scene,
|
||||||
const quota = ref<{ remaining: number; daily_free: number; unlimited: boolean } | null>(null)
|
quotaLabel,
|
||||||
const year = ref('')
|
year,
|
||||||
const month = ref('')
|
month,
|
||||||
const day = ref('')
|
day,
|
||||||
const wantDepth = ref(false)
|
wantDepth,
|
||||||
const drawing = ref(false)
|
drawing,
|
||||||
const paying = ref(false)
|
paying,
|
||||||
const error = ref('')
|
error,
|
||||||
const bootError = ref('')
|
bootError,
|
||||||
const cards = ref<{ id: string; title: string; imagery: string; prompt: string; tip: string }[]>([])
|
cards,
|
||||||
const report = ref<GrowthReport | null>(null)
|
report,
|
||||||
const questionInput = ref('')
|
questionInput,
|
||||||
const showBirth = ref(false)
|
showBirth,
|
||||||
|
summary,
|
||||||
const promptRows = [
|
detail,
|
||||||
{ icon: '🌊', label: '最近情绪有点乱,帮我理一理', scene: '情绪整理' },
|
onPromptClick,
|
||||||
{ icon: '💬', label: '和 TA 相处时,我总在重复什么模式?', scene: '关系反思' },
|
sendDraw,
|
||||||
{ icon: '🪞', label: '我想更了解自己此刻的状态', scene: '自我觉察' },
|
draw,
|
||||||
{ icon: '🍃', label: '生活节奏太快,我需要一点方向感', scene: '生活节奏' },
|
buyDeep,
|
||||||
]
|
} = useImageCardPage()
|
||||||
|
|
||||||
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)
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -257,210 +87,10 @@ onMounted(boot)
|
|||||||
.body.has-results {
|
.body.has-results {
|
||||||
padding-bottom: 24px;
|
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 {
|
.ai-note {
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
}
|
}
|
||||||
.err-float {
|
.err-float {
|
||||||
margin-top: 8px;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -9,21 +9,15 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div class="body">
|
<div class="body">
|
||||||
<template v-if="needBirth && !report">
|
<RhythmIntro
|
||||||
<div class="intro-card">
|
v-if="needBirth && !report"
|
||||||
<HomeToolIcon name="rhythm" :size="64" />
|
v-model:year="year"
|
||||||
<h1 class="intro-title">身心节律</h1>
|
v-model:month="month"
|
||||||
<p class="intro-sub">填写生日,看见作息 · 饮食 · 活动的节奏建议(生活方式参考,非算命)</p>
|
v-model:day="day"
|
||||||
</div>
|
:loading="loading"
|
||||||
<div class="yxg-card form-card">
|
:error="error"
|
||||||
<label class="yxg-label">生日</label>
|
@start="start"
|
||||||
<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>
|
|
||||||
|
|
||||||
<p v-else-if="loading" class="yxg-hint">正在生成节律…</p>
|
<p v-else-if="loading" class="yxg-hint">正在生成节律…</p>
|
||||||
<p v-else-if="error && !report" class="yxg-err">
|
<p v-else-if="error && !report" class="yxg-err">
|
||||||
@@ -31,78 +25,23 @@
|
|||||||
<button type="button" class="yxg-link" @click="load">重试</button>
|
<button type="button" class="yxg-link" @click="load">重试</button>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<template v-else-if="report">
|
<RhythmResult
|
||||||
<div class="mode-tabs" role="tablist">
|
v-else-if="report"
|
||||||
<button
|
v-model:tab="tab"
|
||||||
v-for="t in resultTabs"
|
:headline="headline"
|
||||||
:key="t.key"
|
:one-liner="oneLiner"
|
||||||
type="button"
|
:style-label="styleLabel"
|
||||||
class="mode-tab"
|
:overview-text="overviewText"
|
||||||
:class="{ on: tab === t.key }"
|
:week-focus="weekFocus"
|
||||||
@click="tab = t.key"
|
:keywords="keywords"
|
||||||
>
|
:solar-tip="solarTip"
|
||||||
{{ t.label }}
|
:wuxing-bars="wuxingBars"
|
||||||
</button>
|
:summary="summary"
|
||||||
</div>
|
:detail="detail"
|
||||||
|
:report="report"
|
||||||
<template v-if="tab === 'overview'">
|
:paying="paying"
|
||||||
<h2 class="hl">{{ headline || '我的身心节律' }}</h2>
|
@buy-deep="buyDeep"
|
||||||
<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>
|
|
||||||
|
|
||||||
<p class="yxg-disc">本内容为自我探索与生活方式参考,不构成医疗建议,亦非占卜预测。</p>
|
<p class="yxg-disc">本内容为自我探索与生活方式参考,不构成医疗建议,亦非占卜预测。</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -110,148 +49,36 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 BackButton from '../components/BackButton.vue'
|
||||||
import BirthDateInputs from '../components/BirthDateInputs.vue'
|
|
||||||
import HomeToolIcon from '../components/HomeToolIcon.vue'
|
|
||||||
import PageShell from '../components/PageShell.vue'
|
import PageShell from '../components/PageShell.vue'
|
||||||
import ReportRich from '../components/ReportRich.vue'
|
import RhythmIntro from '../components/rhythm/RhythmIntro.vue'
|
||||||
import WuxingBars, { type WuxingBar } from '../components/WuxingBars.vue'
|
import RhythmResult from '../components/rhythm/RhythmResult.vue'
|
||||||
import { AnalyticsEvent, track } from '../lib/analytics'
|
import { useLifeRhythmPage } from '../composables/useLifeRhythmPage'
|
||||||
|
|
||||||
const route = useRoute()
|
const {
|
||||||
const loading = ref(false)
|
loading,
|
||||||
const needBirth = ref(true)
|
needBirth,
|
||||||
const error = ref('')
|
error,
|
||||||
const report = ref<GrowthReport | null>(null)
|
report,
|
||||||
const paying = ref(false)
|
paying,
|
||||||
const tab = ref<'overview' | 'wuxing' | 'tips' | 'term'>('overview')
|
tab,
|
||||||
const year = ref(String(route.query.y || ''))
|
year,
|
||||||
const month = ref(String(route.query.m || ''))
|
month,
|
||||||
const day = ref(String(route.query.d || ''))
|
day,
|
||||||
|
summary,
|
||||||
const resultTabs = [
|
detail,
|
||||||
{ key: 'overview' as const, label: '概览' },
|
headline,
|
||||||
{ key: 'wuxing' as const, label: '五行' },
|
oneLiner,
|
||||||
{ key: 'tips' as const, label: '建议' },
|
styleLabel,
|
||||||
{ key: 'term' as const, label: '节气' },
|
overviewText,
|
||||||
]
|
weekFocus,
|
||||||
|
keywords,
|
||||||
const summary = computed(() => (report.value?.summary || {}) as Record<string, unknown>)
|
solarTip,
|
||||||
const detail = computed(() => (report.value?.detail || null) as Record<string, unknown> | null)
|
wuxingBars,
|
||||||
const headline = computed(() => String(summary.value.headline || ''))
|
start,
|
||||||
const oneLiner = computed(() => String(summary.value.one_liner || ''))
|
load,
|
||||||
const styleLabel = computed(() => String(summary.value.style_label || ''))
|
buyDeep,
|
||||||
const overviewText = computed(() => String(summary.value.overview || ''))
|
} = useLifeRhythmPage()
|
||||||
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)
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -270,150 +97,4 @@ onMounted(load)
|
|||||||
}
|
}
|
||||||
.spacer { width: 36px; }
|
.spacer { width: 36px; }
|
||||||
.body { padding: 8px 16px 88px; }
|
.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>
|
</style>
|
||||||
|
|||||||
@@ -26,8 +26,9 @@ describe('MembershipPage', () => {
|
|||||||
api.getMembership.mockResolvedValue({ active: false, status: 'none' })
|
api.getMembership.mockResolvedValue({ active: false, status: 'none' })
|
||||||
const w = mount(MembershipPage)
|
const w = mount(MembershipPage)
|
||||||
await flushPromises()
|
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 () => {
|
it('activates membership after mock pay', async () => {
|
||||||
@@ -45,7 +46,9 @@ describe('MembershipPage', () => {
|
|||||||
|
|
||||||
const w = mount(MembershipPage)
|
const w = mount(MembershipPage)
|
||||||
await flushPromises()
|
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()
|
await flushPromises()
|
||||||
expect(api.createOrder).toHaveBeenCalledWith({ kind: 'membership', plan: 'month' })
|
expect(api.createOrder).toHaveBeenCalledWith({ kind: 'membership', plan: 'month' })
|
||||||
expect(w.text()).toContain('会员有效')
|
expect(w.text()).toContain('会员有效')
|
||||||
@@ -58,8 +61,8 @@ describe('MembershipPage', () => {
|
|||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(w.text()).toContain('网络错误')
|
expect(w.text()).toContain('网络错误')
|
||||||
api.getMembership.mockResolvedValue({ active: false, status: 'none' })
|
api.getMembership.mockResolvedValue({ active: false, status: 'none' })
|
||||||
await w.find('button.link').trigger('click')
|
await w.find('button.retry').trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(w.text()).toContain('尚未开通')
|
expect(w.text()).toContain('开通后可查看画像')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -16,407 +16,127 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div v-if="!items.length" class="empty-card">
|
<ProfileEmpty v-if="!items.length" />
|
||||||
<HomeToolIcon name="portrait" :size="56" />
|
|
||||||
<p>暂无档案。请先去首页完成性格探索,或添加重要的人。</p>
|
|
||||||
<router-link class="cta" to="/">去性格探索</router-link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div v-if="selfProfile" class="self-card">
|
<ProfileSelfCard
|
||||||
<div class="av-ring">
|
v-if="selfProfile"
|
||||||
<span class="av-inner">{{ avatarInitial(selfProfile) }}</span>
|
:profile="selfProfile"
|
||||||
</div>
|
@edit="startEdit"
|
||||||
<div class="self-body">
|
/>
|
||||||
<strong>{{ selfProfile.display_name || '我' }}</strong>
|
<ProfileOthersList
|
||||||
<span class="meta">我 · {{ formatDate(selfProfile.birth_date) }}</span>
|
v-if="others.length"
|
||||||
<span v-if="selfProfile.birth_place" class="meta sub">{{ selfProfile.birth_place }}</span>
|
:others="others"
|
||||||
</div>
|
:busy-id="busyId"
|
||||||
<div class="self-ops">
|
@edit="startEdit"
|
||||||
<button type="button" class="op-btn" @click="startEdit(selfProfile)">编辑</button>
|
@remove="remove"
|
||||||
</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>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div v-if="editing" class="yxg-card form-panel">
|
<ProfileEditForm
|
||||||
<h2 class="panel-title">编辑档案</h2>
|
v-if="editing"
|
||||||
<label class="yxg-label">称呼</label>
|
:editing="editing"
|
||||||
<input class="yxg-input" v-model="formName" />
|
v-model:form-name="formName"
|
||||||
<label class="yxg-label">生日</label>
|
v-model:form-y="formY"
|
||||||
<BirthDateInputs v-model:year="formY" v-model:month="formM" v-model:day="formD" compact />
|
v-model:form-m="formM"
|
||||||
<label class="yxg-label">出生地(可选)</label>
|
v-model:form-d="formD"
|
||||||
<BirthPlaceSelect v-model="formPlace" />
|
v-model:form-place="formPlace"
|
||||||
<label v-if="editing.relation === 'other'" class="yxg-label">关系类型</label>
|
v-model:form-relation-type="formRelationType"
|
||||||
<select v-if="editing.relation === 'other'" class="yxg-select" v-model="formRelationType">
|
v-model:form-geo-visible="formGeoVisible"
|
||||||
<option value="partner">伴侣</option>
|
:form-error="formError"
|
||||||
<option value="friend">朋友</option>
|
:saving="saving"
|
||||||
<option value="family">家人</option>
|
@save="saveEdit"
|
||||||
<option value="colleague">同事</option>
|
@cancel="editing = null"
|
||||||
<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>
|
|
||||||
|
|
||||||
<div v-if="showInviteFill" class="yxg-card form-panel invite-panel">
|
<ProfileInviteForm
|
||||||
<h2 class="panel-title">邀请好友填档案</h2>
|
v-if="showInviteFill"
|
||||||
<p class="invite-desc">
|
:invite-hint="inviteHint"
|
||||||
生成链接发给家人或朋友。对方填写生日后,可出现在你的合盘与关系理解中。
|
:invite-path="invitePath"
|
||||||
</p>
|
:invite-error="inviteError"
|
||||||
<p v-if="inviteHint" class="yxg-meta">{{ inviteHint }}</p>
|
:inviting="inviting"
|
||||||
<p v-if="invitePath" class="invite-link">{{ invitePath }}</p>
|
:copy-busy="copyBusy"
|
||||||
<p v-if="inviteError" class="yxg-err">{{ inviteError }}</p>
|
:has-self="!!selfProfile"
|
||||||
<div class="form-actions">
|
@create="createFillInvite"
|
||||||
<button class="cta" type="button" :disabled="inviting || !selfProfile" @click="createFillInvite">
|
@copy="copyInvite"
|
||||||
{{ inviting ? '生成中…' : invitePath ? '重新生成' : '生成填写链接' }}
|
@close="closeInviteFill"
|
||||||
</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>
|
|
||||||
|
|
||||||
<div v-if="showAddForm" class="yxg-card form-panel">
|
<ProfileAddForm
|
||||||
<div class="panel-head">
|
v-if="showAddForm"
|
||||||
<h2 class="panel-title">添加档案</h2>
|
v-model:new-name="newName"
|
||||||
<button type="button" class="yxg-link linkbtn" @click="openInviteFill">邀请好友</button>
|
v-model:new-relation-type="newRelationType"
|
||||||
</div>
|
v-model:new-y="newY"
|
||||||
<label class="yxg-label">昵称</label>
|
v-model:new-m="newM"
|
||||||
<input class="yxg-input" v-model="newName" placeholder="请输入昵称(1–6 个字)" maxlength="6" />
|
v-model:new-d="newD"
|
||||||
<label class="yxg-label">关系</label>
|
v-model:new-place="newPlace"
|
||||||
<div class="rel-chips" role="group" aria-label="关系类型">
|
:add-error="addError"
|
||||||
<button
|
:adding="adding"
|
||||||
v-for="r in relationOptions"
|
@invite="openInviteFill"
|
||||||
:key="r.value"
|
@save="addOther"
|
||||||
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>
|
|
||||||
|
|
||||||
<div class="bottom-cta">
|
<ProfileBottomCta :show-add-form="showAddForm" @toggle-add="toggleAdd" />
|
||||||
<button type="button" class="fab" @click="toggleAdd">
|
|
||||||
{{ showAddForm ? '收起' : '添加档案' }}
|
|
||||||
</button>
|
|
||||||
<router-link class="link-row" to="/synastry">去做合盘 ›</router-link>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 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 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 {
|
||||||
const router = useRouter()
|
items,
|
||||||
|
loading,
|
||||||
const items = ref<Profile[]>([])
|
error,
|
||||||
const loading = ref(true)
|
busyId,
|
||||||
const error = ref('')
|
editing,
|
||||||
const busyId = ref('')
|
showAddForm,
|
||||||
const editing = ref<Profile | null>(null)
|
showInviteFill,
|
||||||
const showAddForm = ref(false)
|
formName,
|
||||||
const showInviteFill = ref(false)
|
formY,
|
||||||
const formName = ref('')
|
formM,
|
||||||
const formY = ref('')
|
formD,
|
||||||
const formM = ref('')
|
formPlace,
|
||||||
const formD = ref('')
|
formRelationType,
|
||||||
const formPlace = ref('')
|
formGeoVisible,
|
||||||
const formRelationType = ref('partner')
|
formError,
|
||||||
const formGeoVisible = ref(false)
|
saving,
|
||||||
const formError = ref('')
|
newName,
|
||||||
const saving = ref(false)
|
newY,
|
||||||
const newName = ref('')
|
newM,
|
||||||
const newY = ref('')
|
newD,
|
||||||
const newM = ref('')
|
newPlace,
|
||||||
const newD = ref('')
|
newRelationType,
|
||||||
const newPlace = ref('')
|
addError,
|
||||||
const newRelationType = ref('family')
|
adding,
|
||||||
const addError = ref('')
|
inviting,
|
||||||
const adding = ref(false)
|
invitePath,
|
||||||
const inviting = ref(false)
|
inviteError,
|
||||||
const invitePath = ref('')
|
inviteHint,
|
||||||
const inviteError = ref('')
|
copyBusy,
|
||||||
const inviteHint = ref('')
|
selfProfile,
|
||||||
const copyBusy = ref(false)
|
others,
|
||||||
|
toggleAdd,
|
||||||
const relationOptions = [
|
openInviteFill,
|
||||||
{ value: 'family', label: '家人' },
|
closeInviteFill,
|
||||||
{ value: 'friend', label: '朋友' },
|
createFillInvite,
|
||||||
{ value: 'partner', label: '伴侣' },
|
copyInvite,
|
||||||
{ value: 'colleague', label: '同事' },
|
load,
|
||||||
{ value: 'other', label: '其他' },
|
startEdit,
|
||||||
]
|
saveEdit,
|
||||||
|
remove,
|
||||||
const selfProfile = computed(() => items.value.find((p) => p.relation === 'self'))
|
addOther,
|
||||||
const others = computed(() => items.value.filter((p) => p.relation !== 'self'))
|
} = useProfilePage()
|
||||||
|
|
||||||
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()
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -429,184 +149,4 @@ onMounted(async () => {
|
|||||||
.who-static { flex: 1; font-size: 16px; font-weight: 700; text-align: center; }
|
.who-static { flex: 1; font-size: 16px; font-weight: 700; text-align: center; }
|
||||||
.spacer { width: 36px; }
|
.spacer { width: 36px; }
|
||||||
.body { padding: 8px 16px 100px; }
|
.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>
|
</style>
|
||||||
|
|||||||
@@ -10,283 +10,85 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div class="body">
|
<div class="body">
|
||||||
<template v-if="!report">
|
<RelationLanding
|
||||||
<div class="hero-block">
|
v-if="!report"
|
||||||
<h1 class="hero-title">看见彼此的相处模样</h1>
|
v-model:focus="focus"
|
||||||
<p class="hero-sub">基于档案与生日,理解相处模式 · 性格对照 · 默契指数</p>
|
v-model:relation-type="relationType"
|
||||||
</div>
|
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">
|
<RelationResult
|
||||||
<button
|
v-else
|
||||||
v-for="f in focusTabs"
|
v-model:tab="tab"
|
||||||
:key="f.key"
|
:diff="diff"
|
||||||
type="button"
|
:me-style="meStyle"
|
||||||
class="fchip"
|
:other-style="otherStyle"
|
||||||
:class="{ on: focus === f.key }"
|
:fit-label="fitLabel"
|
||||||
@click="focus = f.key"
|
:harmony="harmony"
|
||||||
>
|
:love-index="loveIndex"
|
||||||
{{ f.label }}
|
:friend-index="friendIndex"
|
||||||
</button>
|
:marriage-index="marriageIndex"
|
||||||
</div>
|
:star-compare="starCompare"
|
||||||
|
:dim-compare="dimCompare"
|
||||||
<div class="deco-card" aria-hidden="true">
|
:summary="summary"
|
||||||
<div class="deco-blur">
|
:detail="detail"
|
||||||
<span class="deco-av me" />
|
:report="report"
|
||||||
<span class="deco-heart">♡</span>
|
:paying="paying"
|
||||||
<span class="deco-av ta" />
|
@buy-deep="buyDeep"
|
||||||
</div>
|
@share="shareOpen = true"
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
<ShareSheet :open="shareOpen" :payload="sharePayload" @close="shareOpen = false" />
|
<ShareSheet :open="shareOpen" :payload="sharePayload" @close="shareOpen = false" />
|
||||||
</PageShell>
|
</PageShell>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 BackButton from '../components/BackButton.vue'
|
||||||
import BirthDateInputs from '../components/BirthDateInputs.vue'
|
|
||||||
import MatchCompare from '../components/MatchCompare.vue'
|
|
||||||
import PageShell from '../components/PageShell.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 ShareSheet from '../components/ShareSheet.vue'
|
||||||
import { AnalyticsEvent, track } from '../lib/analytics'
|
import { useRelationPage } from '../composables/useRelationPage'
|
||||||
import type { RelationSharePayload } from '../lib/shareLink'
|
|
||||||
|
|
||||||
const otherName = ref('TA')
|
const {
|
||||||
const relationType = ref('partner')
|
otherName,
|
||||||
const oy = ref('1992')
|
relationType,
|
||||||
const om = ref('6')
|
oy,
|
||||||
const od = ref('8')
|
om,
|
||||||
const loading = ref(false)
|
od,
|
||||||
const paying = ref(false)
|
loading,
|
||||||
const error = ref('')
|
paying,
|
||||||
const report = ref<GrowthReport | null>(null)
|
error,
|
||||||
const shareOpen = ref(false)
|
report,
|
||||||
const needSelf = ref(false)
|
shareOpen,
|
||||||
const focus = ref('mode')
|
needSelf,
|
||||||
const tab = ref<'index' | 'compare' | 'tips'>('index')
|
focus,
|
||||||
|
tab,
|
||||||
const focusTabs = [
|
focusHint,
|
||||||
{ key: 'mode', label: '相处模式' },
|
summary,
|
||||||
{ key: 'personality', label: '性格对照' },
|
detail,
|
||||||
{ key: 'sync', label: '默契指数' },
|
meStyle,
|
||||||
]
|
otherStyle,
|
||||||
const relationOptions = [
|
diff,
|
||||||
{ value: 'partner', label: '伴侣' },
|
fitLabel,
|
||||||
{ value: 'friend', label: '朋友' },
|
harmony,
|
||||||
{ value: 'family', label: '家人' },
|
loveIndex,
|
||||||
{ value: 'other', label: '其他' },
|
friendIndex,
|
||||||
]
|
marriageIndex,
|
||||||
const resultTabs = [
|
starCompare,
|
||||||
{ key: 'index' as const, label: '指数' },
|
dimCompare,
|
||||||
{ key: 'compare' as const, label: '对照' },
|
sharePayload,
|
||||||
{ key: 'tips' as const, label: '建议' },
|
run,
|
||||||
]
|
buyDeep,
|
||||||
|
} = useRelationPage()
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -312,215 +114,4 @@ async function buyDeep() {
|
|||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
.body { padding: 8px 16px 88px; }
|
.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>
|
</style>
|
||||||
|
|||||||
@@ -17,142 +17,70 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<template v-else-if="report">
|
<template v-else-if="report">
|
||||||
<div class="report-head">
|
<ReportHeadBlock
|
||||||
<span class="type-badge">{{ typeLabel }}</span>
|
:type-label="typeLabel"
|
||||||
<span class="date-badge">{{ createdLabel }}</span>
|
:created-label="createdLabel"
|
||||||
</div>
|
:headline="headline"
|
||||||
<h2 class="hl">{{ headline || oneLiner || '成长报告' }}</h2>
|
:one-liner="oneLiner"
|
||||||
<p v-if="oneLiner && headline" class="lead">{{ oneLiner }}</p>
|
/>
|
||||||
|
|
||||||
<router-link class="vip-strip" to="/membership">
|
<ReportModeTabs
|
||||||
<span class="vip-ico" aria-hidden="true">★</span>
|
v-if="showModeTabs"
|
||||||
<span class="vip-copy">
|
v-model="panelKey"
|
||||||
<strong>成长会员</strong>
|
:panels="activePanels"
|
||||||
<em>深度报告 · 全年节气陪伴 · 解锁完整分析</em>
|
/>
|
||||||
</span>
|
|
||||||
<span class="vip-go">开通 ›</span>
|
|
||||||
</router-link>
|
|
||||||
|
|
||||||
<div v-if="showModeTabs" class="mode-tabs" role="tablist">
|
<ReportStarPanel
|
||||||
<button
|
v-if="report.type === 'star'"
|
||||||
v-for="p in activePanels"
|
v-model:sign-tab="signTab"
|
||||||
:key="p.key"
|
v-model:fortune-key="fortuneKey"
|
||||||
type="button"
|
:panel-key="panelKey"
|
||||||
class="mode-tab"
|
:wheel-planets="wheelPlanets"
|
||||||
:class="{ on: panelKey === p.key }"
|
:aspect-preview="aspectPreview"
|
||||||
@click="panelKey = p.key"
|
:asc-lon="ascLon"
|
||||||
>
|
:transits="transits"
|
||||||
{{ p.label }}
|
:sign-cards="signCards"
|
||||||
</button>
|
:active-card="activeCard"
|
||||||
</div>
|
:active-fortune="activeFortune"
|
||||||
|
:planets="planets"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- 星座 -->
|
<ReportMatchIndices
|
||||||
<template v-if="report.type === 'star'">
|
v-if="hasMatchIndex"
|
||||||
<template v-if="panelKey === 'chart'">
|
:love-index="loveIndex"
|
||||||
<NatalWheel
|
:friend-index="friendIndex"
|
||||||
v-if="wheelPlanets.length"
|
:marriage-index="marriageIndex"
|
||||||
: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>
|
|
||||||
|
|
||||||
<!-- 合盘 / 匹配指数 -->
|
<ReportSynastryPanel
|
||||||
<div v-if="hasMatchIndex" class="indices">
|
v-if="report.type === 'synastry'"
|
||||||
<div class="idx"><em>恋爱</em><strong>{{ loveIndex }}</strong></div>
|
:panel-key="panelKey"
|
||||||
<div class="idx"><em>友情</em><strong>{{ friendIndex }}</strong></div>
|
:syn-active-tip="synActiveTip"
|
||||||
<div class="idx"><em>婚姻</em><strong>{{ marriageIndex }}</strong></div>
|
:syn-planets-a="synPlanetsA"
|
||||||
</div>
|
: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'">
|
<ReportBodyContent
|
||||||
<p v-if="synActiveTip" class="yxg-meta">{{ synActiveTip }}</p>
|
:report-type="report.type"
|
||||||
<template v-if="panelKey === 'compare'">
|
:summary="summary"
|
||||||
<NatalWheel v-if="synPlanetsA.length" :planets="synPlanetsA" :aspects="[]" :asc-lon="synAscA" />
|
:detail="detail"
|
||||||
<NatalWheel v-if="synPlanetsB.length" :planets="synPlanetsB" :aspects="[]" :asc-lon="synAscB" />
|
:deep="!!report.has_deep_access"
|
||||||
</template>
|
:paying="paying"
|
||||||
<template v-else-if="panelKey === 'overlay'">
|
@buy-deep="buyDeep"
|
||||||
<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>
|
|
||||||
|
|
||||||
<template v-if="report.type === 'portrait'">
|
<ReportStickyBar
|
||||||
<DecodePanel
|
:deep="!!report.has_deep_access"
|
||||||
:summary="summary"
|
:paying="paying"
|
||||||
:detail="detail"
|
:related-link="relatedLink"
|
||||||
:deep="!!report.has_deep_access"
|
@share="shareOpen = true"
|
||||||
:paying="paying"
|
@buy-deep="buyDeep"
|
||||||
@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>
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<ShareSheet :open="shareOpen" :payload="sharePayload" @close="shareOpen = false" />
|
<ShareSheet :open="shareOpen" :payload="sharePayload" @close="shareOpen = false" />
|
||||||
@@ -160,279 +88,60 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 BackButton from '../components/BackButton.vue'
|
||||||
import PageShell from '../components/PageShell.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 ShareSheet from '../components/ShareSheet.vue'
|
||||||
import SignCards, { type SignCard } from '../components/SignCards.vue'
|
import ReportBodyContent from '../components/report/ReportBodyContent.vue'
|
||||||
import { AnalyticsEvent, track } from '../lib/analytics'
|
import ReportHeadBlock from '../components/report/ReportHeadBlock.vue'
|
||||||
import type { PortraitSharePayload, RelationSharePayload, SharePayload } from '../lib/shareLink'
|
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 {
|
||||||
const loading = ref(true)
|
loading,
|
||||||
const error = ref('')
|
error,
|
||||||
const report = ref<GrowthReport | null>(null)
|
report,
|
||||||
const paying = ref(false)
|
paying,
|
||||||
const shareOpen = ref(false)
|
shareOpen,
|
||||||
const panelKey = ref('chart')
|
panelKey,
|
||||||
const signTab = ref('sun')
|
signTab,
|
||||||
const fortuneKey = ref<'daily' | 'weekly' | 'monthly' | 'yearly'>('daily')
|
fortuneKey,
|
||||||
|
summary,
|
||||||
const starPanels = [
|
detail,
|
||||||
{ key: 'chart', label: '星盘' },
|
headline,
|
||||||
{ key: 'fortune', label: '运势' },
|
oneLiner,
|
||||||
{ key: 'planets', label: '行星' },
|
typeLabel,
|
||||||
]
|
showModeTabs,
|
||||||
const synTabs = [
|
activePanels,
|
||||||
{ key: 'compare', label: '比较' },
|
relatedLink,
|
||||||
{ key: 'composite', label: '组合' },
|
ascLon,
|
||||||
{ key: 'davison', label: '时空' },
|
wheelPlanets,
|
||||||
{ key: 'marks_me', label: '马克斯' },
|
aspectPreview,
|
||||||
{ key: 'overlay', label: '配对' },
|
transits,
|
||||||
{ key: 'composite_progressed', label: '组合次限' },
|
synActiveTip,
|
||||||
]
|
synActivePlanets,
|
||||||
const fortuneKeys = ['daily', 'weekly', 'monthly', 'yearly', 'lifetime'] as const
|
synActiveAsc,
|
||||||
|
synOverlayEntries,
|
||||||
type FortunePeriod = {
|
synPlanetsA,
|
||||||
title?: string
|
synPlanetsB,
|
||||||
label?: string
|
synAscA,
|
||||||
score?: number
|
synAscB,
|
||||||
tip?: string
|
createdLabel,
|
||||||
dims?: { love?: number; career?: number; money?: number; mood?: number }
|
signCards,
|
||||||
}
|
activeCard,
|
||||||
type PlanetRow = { key: string; title: string; sign: string; degree: string; house: number }
|
activeFortune,
|
||||||
|
planets,
|
||||||
const summary = computed(() => (report.value?.summary || {}) as Record<string, unknown>)
|
loveIndex,
|
||||||
const detail = computed(() => (report.value?.detail || null) as Record<string, unknown> | null)
|
friendIndex,
|
||||||
const headline = computed(() => String(summary.value.headline || summary.value.diff_one_liner || ''))
|
marriageIndex,
|
||||||
const oneLiner = computed(() => String(summary.value.one_liner || summary.value.diff_one_liner || ''))
|
hasMatchIndex,
|
||||||
const keywords = computed(() => {
|
sharePayload,
|
||||||
if (Array.isArray(summary.value.keywords)) return summary.value.keywords as string[]
|
load,
|
||||||
const a = Array.isArray(summary.value.me_keywords) ? (summary.value.me_keywords as string[]) : []
|
buyDeep,
|
||||||
const b = Array.isArray(summary.value.other_keywords) ? (summary.value.other_keywords as string[]) : []
|
} = useReportPage()
|
||||||
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)
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -453,182 +162,4 @@ onMounted(load)
|
|||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
.body { padding: 8px 16px 88px; }
|
.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>
|
</style>
|
||||||
|
|||||||
@@ -18,243 +18,94 @@
|
|||||||
<button type="button" class="yxg-link" @click="load">重试</button>
|
<button type="button" class="yxg-link" @click="load">重试</button>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Intro -->
|
<ScaleIntro
|
||||||
<div v-else-if="phase === 'intro'" class="intro-screen">
|
v-else-if="phase === 'intro'"
|
||||||
<div class="intro-card">
|
:title="title"
|
||||||
<HomeToolIcon name="mbti" :size="64" />
|
:description="description"
|
||||||
<h2 class="intro-title">{{ title || '探索测试' }}</h2>
|
:question-count="questions.length"
|
||||||
<p v-if="description" class="intro-desc">{{ description }}</p>
|
:est-minutes="estMinutes"
|
||||||
<p v-if="questions.length" class="intro-count">共 {{ questions.length }} 题 · 约 {{ estMinutes }} 分钟</p>
|
:draft-restored="draftRestored"
|
||||||
<p v-if="draftRestored" class="draft-hint">检测到上次未完成的作答,开始后将继续</p>
|
@start="startTest"
|
||||||
<button class="yxg-btn yxg-btn-block intro-start" type="button" :disabled="!questions.length" @click="startTest">
|
/>
|
||||||
开始
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Answering -->
|
<ScaleAnswering
|
||||||
<div v-else-if="phase === 'answering'" class="answer-screen">
|
v-else-if="phase === 'answering'"
|
||||||
<p v-if="draftRestored && currentIdx === 0" class="yxg-hint draft">已恢复上次未提交的作答</p>
|
:draft-restored="draftRestored"
|
||||||
<div class="progress-wrap">
|
:current-idx="currentIdx"
|
||||||
<div class="progress-bar">
|
:progress-pct="progressPct"
|
||||||
<i class="progress-fill" :style="{ width: progressPct + '%' }" />
|
:total-questions="questions.length"
|
||||||
</div>
|
:current-question="currentQuestion"
|
||||||
<span class="progress-label">{{ currentIdx + 1 }} / {{ questions.length }}</span>
|
:answers="answers"
|
||||||
</div>
|
:submit-error="submitError"
|
||||||
|
:need-profile="needProfile"
|
||||||
|
@answer="setAnswer"
|
||||||
|
/>
|
||||||
|
|
||||||
<div v-if="currentQuestion" class="q-card yxg-card">
|
<ScaleResult
|
||||||
<p class="prompt">{{ currentQuestion.body.prompt }}</p>
|
v-else-if="phase === 'result'"
|
||||||
<label v-for="opt in currentQuestion.body.options" :key="opt.key" class="opt" :class="{ picked: answers[currentQuestion.id] === opt.key }">
|
:result-label="resultLabel"
|
||||||
<input v-model="answers[currentQuestion.id]" type="radio" :value="opt.key" />
|
:share-line="shareLine"
|
||||||
<span class="opt-text">{{ opt.text }}</span>
|
:result-summary-obj="resultSummaryObj"
|
||||||
</label>
|
:result-detail-obj="resultDetailObj"
|
||||||
</div>
|
@share="openShare"
|
||||||
|
/>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Nav bar -->
|
<ScaleNavBar
|
||||||
<div v-if="phase === 'answering'" class="nav-bar">
|
v-if="phase === 'answering'"
|
||||||
<button type="button" class="nav-btn prev" :disabled="currentIdx === 0" @click="prevQ">上一题</button>
|
:current-idx="currentIdx"
|
||||||
<button
|
:has-answer="!!answers[currentQuestion?.id || '']"
|
||||||
type="button"
|
:is-last="isLast"
|
||||||
class="nav-btn next"
|
:submitting="submitting"
|
||||||
:disabled="!answers[currentQuestion?.id || ''] || submitting"
|
@prev="prevQ"
|
||||||
@click="onNext"
|
@next="onNext"
|
||||||
>
|
/>
|
||||||
{{ isLast ? (submitting ? '提交中…' : '查看探索结果') : '下一题' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ShareSheet :open="shareOpen" :payload="sharePayload" @close="shareOpen = false" />
|
<ShareSheet :open="shareOpen" :payload="sharePayload" @close="shareOpen = false" />
|
||||||
</PageShell>
|
</PageShell>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 BackButton from '../components/BackButton.vue'
|
||||||
import HomeToolIcon from '../components/HomeToolIcon.vue'
|
import HomeToolIcon from '../components/HomeToolIcon.vue'
|
||||||
import PageShell from '../components/PageShell.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 ShareSheet from '../components/ShareSheet.vue'
|
||||||
import { clearScaleDraft, loadScaleDraft, saveScaleDraft } from '../lib/scaleDraft'
|
import { useScalePage } from '../composables/useScalePage'
|
||||||
import type { PortraitSharePayload } from '../lib/shareLink'
|
|
||||||
|
|
||||||
const route = useRoute()
|
const {
|
||||||
const slug = String(route.params.slug || '')
|
title,
|
||||||
const title = ref('')
|
description,
|
||||||
const description = ref('')
|
questions,
|
||||||
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,
|
answers,
|
||||||
() => {
|
loadingScale,
|
||||||
if (!persistReady.value || phase.value === 'result') return
|
submitting,
|
||||||
saveScaleDraft(slug, { ...answers })
|
loadError,
|
||||||
},
|
submitError,
|
||||||
{ deep: true },
|
needProfile,
|
||||||
)
|
phase,
|
||||||
|
resultLabel,
|
||||||
const resultSummaryObj = computed(() => {
|
shareLine,
|
||||||
const r = resultRaw.value || {}
|
shareOpen,
|
||||||
return {
|
draftRestored,
|
||||||
overview: r.overview || r.summary,
|
currentIdx,
|
||||||
keywords: r.label ? [String(r.label)] : [],
|
currentQuestion,
|
||||||
dimensions: r.dimensions,
|
isLast,
|
||||||
strengths_preview: r.strengths,
|
progressPct,
|
||||||
watchouts_preview: r.watchouts,
|
estMinutes,
|
||||||
} as Record<string, unknown>
|
resultSummaryObj,
|
||||||
})
|
resultDetailObj,
|
||||||
const resultDetailObj = computed(() => {
|
sharePayload,
|
||||||
const r = resultRaw.value || {}
|
load,
|
||||||
return {
|
startTest,
|
||||||
tips: r.tips,
|
prevQ,
|
||||||
conversation_scripts: r.scripts,
|
onNext,
|
||||||
growth_plan: r.growth_plan,
|
openShare,
|
||||||
faq: r.faq,
|
setAnswer,
|
||||||
blind_spots: r.watchouts,
|
} = useScalePage()
|
||||||
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)
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -282,188 +133,4 @@ onMounted(load)
|
|||||||
.body.has-bar {
|
.body.has-bar {
|
||||||
padding-bottom: 72px;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -55,16 +55,20 @@ describe('StarProfilePage', () => {
|
|||||||
const w = mount(StarProfilePage, { global: { stubs: { RouterLink: true } } })
|
const w = mount(StarProfilePage, { global: { stubs: { RouterLink: true } } })
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(api.createStar).toHaveBeenCalled()
|
expect(api.createStar).toHaveBeenCalled()
|
||||||
expect(w.find('svg.wheel').exists() || w.text().includes('本命')).toBe(true)
|
await w.findAll('button').find((b) => b.text() === '星盘')!.trigger('click')
|
||||||
expect(w.text()).toContain('相位速览')
|
await flushPromises()
|
||||||
expect(w.text()).toContain('今日行运')
|
|
||||||
expect(w.find('svg.wheel').exists()).toBe(true)
|
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 } } })
|
const w = mount(StarProfilePage, { global: { stubs: { RouterLink: true } } })
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
await w.findAll('button').find((b) => b.text() === '运势')!.trigger('click')
|
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 w.findAll('button').find((b) => b.text() === '一生')!.trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(w.text()).toContain('一生运势摘要')
|
expect(w.text()).toContain('一生运势摘要')
|
||||||
|
|||||||
@@ -12,26 +12,17 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div class="body">
|
<div class="body">
|
||||||
<template v-if="needBirth && !report">
|
<StarBirthForm
|
||||||
<div class="intro-card">
|
v-if="needBirth && !report"
|
||||||
<HomeToolIcon name="star" :size="64" />
|
v-model:year="year"
|
||||||
<h1 class="intro-title">星座性格</h1>
|
v-model:month="month"
|
||||||
<p class="intro-sub">填写生日,看见太阳 · 月亮 · 上升与行星画像</p>
|
v-model:day="day"
|
||||||
</div>
|
v-model:birth-time="birthTime"
|
||||||
<div class="yxg-card form-card">
|
v-model:birth-place="birthPlace"
|
||||||
<label class="yxg-label">生日 / 出生时(可选)</label>
|
:loading="loading"
|
||||||
<div class="birth-row">
|
:error="error"
|
||||||
<BirthDateInputs v-model:year="year" v-model:month="month" v-model:day="day" compact />
|
@start="start"
|
||||||
<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>
|
|
||||||
|
|
||||||
<p v-else-if="loading" class="yxg-hint">正在生成星盘…</p>
|
<p v-else-if="loading" class="yxg-hint">正在生成星盘…</p>
|
||||||
<p v-else-if="error && !report" class="yxg-err">
|
<p v-else-if="error && !report" class="yxg-err">
|
||||||
@@ -40,141 +31,53 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<template v-else-if="report">
|
<template v-else-if="report">
|
||||||
<div class="mode-tabs" role="tablist">
|
<StarModeTabs v-model="view" :tabs="starViewTabs" />
|
||||||
<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>
|
|
||||||
|
|
||||||
<template v-if="view === 'overview'">
|
<StarOverviewPanel
|
||||||
<h2 class="hl">{{ headline || '我的星座画像' }}</h2>
|
v-if="view === 'overview'"
|
||||||
<p v-if="oneLiner" class="lead">{{ oneLiner }}</p>
|
: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">
|
<StarChartPanel
|
||||||
<div class="sp-head">
|
v-else-if="view === 'chart'"
|
||||||
<strong>星象速览</strong>
|
v-model:show-outer="showOuter"
|
||||||
<router-link class="sp-ai" to="/ask">AI 解读 ›</router-link>
|
v-model:tight-orb="tightOrb"
|
||||||
</div>
|
:wheel-planets="wheelPlanets"
|
||||||
<p class="sp-text">{{ chartNote || '行星落在不同星座,构成独特的你。' }}</p>
|
:aspect-lines="aspectLines"
|
||||||
<div v-if="keywords.length" class="tags">
|
:asc-lon="ascLon"
|
||||||
<span v-for="k in keywords.slice(0, 4)" :key="k" class="tag">{{ k }}</span>
|
:houses="houses"
|
||||||
</div>
|
:aspect-preview="aspectPreview"
|
||||||
</div>
|
@wheel-select="onWheelSelect"
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="planet-grid">
|
<StarFortunePanel
|
||||||
<button
|
v-else-if="view === 'fortune'"
|
||||||
v-for="(cell, i) in gridCells"
|
v-model:fortune-key="fortuneKey"
|
||||||
:key="cell.key + i"
|
:fortune-keys="starFortuneKeys"
|
||||||
type="button"
|
:active-fortune="activeFortune"
|
||||||
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>
|
|
||||||
|
|
||||||
<div v-if="activeCard" class="detail-card">
|
<StarPlanetsPanel
|
||||||
<h3>{{ activeCard.title }} · {{ activeCard.label }}</h3>
|
v-else
|
||||||
<p>{{ activeCard.teaser }}</p>
|
:planets="planets"
|
||||||
<p v-if="activeCard.element" class="yxg-meta">{{ activeCard.element }}象 · {{ activeCard.modality }}</p>
|
@select="onPlanetSelect"
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
<router-link class="ask-bar" to="/ask">
|
<StarReportFooter
|
||||||
<span>AI,本周我可以关注什么?</span>
|
:summary="summary"
|
||||||
<span aria-hidden="true">›</span>
|
:detail="detail"
|
||||||
</router-link>
|
:deep="!!report.has_deep_access"
|
||||||
</template>
|
:paying="paying"
|
||||||
|
:report-id="report.id"
|
||||||
<template v-else-if="view === 'chart'">
|
@buy-deep="buyDeep"
|
||||||
<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>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<p class="yxg-disc">运势与星盘为自我探索参考,请结合现实判断。</p>
|
<p class="yxg-disc">运势与星盘为自我探索参考,请结合现实判断。</p>
|
||||||
@@ -184,271 +87,63 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 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 PageShell from '../components/PageShell.vue'
|
||||||
import ReportRich from '../components/ReportRich.vue'
|
|
||||||
import ShareSheet from '../components/ShareSheet.vue'
|
import ShareSheet from '../components/ShareSheet.vue'
|
||||||
import type { SignCard } from '../components/SignCards.vue'
|
import StarBirthForm from '../components/star/StarBirthForm.vue'
|
||||||
import { AnalyticsEvent, track } from '../lib/analytics'
|
import StarChartPanel from '../components/star/StarChartPanel.vue'
|
||||||
import type { PortraitSharePayload } from '../lib/shareLink'
|
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()
|
function onPlanetSelect(key: string) {
|
||||||
const loading = ref(false)
|
signTab.value = key
|
||||||
const needBirth = ref(true)
|
view.value = 'overview'
|
||||||
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 */
|
|
||||||
}
|
}
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -477,210 +172,4 @@ onMounted(load)
|
|||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
.body { padding: 8px 16px 24px; }
|
.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>
|
</style>
|
||||||
|
|||||||
@@ -9,3 +9,4 @@
|
|||||||
- **ECR-001 Phase B**:拆分 `HomePage` / `SynastryPage`(≤400 行);build + homeCatalog 单测绿
|
- **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 绿
|
- **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 修复
|
- 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) |
|
| ECR | ECR-001 (+ ECR-002) |
|
||||||
| Title | Structural realignment |
|
| Title | Structural realignment |
|
||||||
| Active slice | Phase A–E 已编码 |
|
| Active slice | Phase F + H5 超标页拆分 |
|
||||||
| Owner | release-ready(CODE_REVIEW PASS) |
|
| Owner | reviewer → human |
|
||||||
| Phase | review PASS |
|
| Phase | review PASS · awaiting commit |
|
||||||
| Next | Human commit / 可选 Phase F |
|
| Next | Human commit |
|
||||||
| Updated | 2026-08-05 |
|
| Updated | 2026-08-05 |
|
||||||
|
|
||||||
## History
|
## History
|
||||||
@@ -17,3 +17,4 @@
|
|||||||
| 2026-08-05 | Architect 诊断 + HANDOFF |
|
| 2026-08-05 | Architect 诊断 + HANDOFF |
|
||||||
| 2026-08-05 | Phase B Home/Synastry;代理 baseURL 热修 |
|
| 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 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 | 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-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-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` |
|
| ECR-002 | fortune→outlook hygiene | Approved / coded | `docs/ECR/ECR-002-fortune-hygiene.md` |
|
||||||
|
|||||||
+45
-122
@@ -1,11 +1,30 @@
|
|||||||
import type {
|
import type {
|
||||||
|
AcceptSynastryInviteBody,
|
||||||
ApiResponse,
|
ApiResponse,
|
||||||
AskMessage,
|
AskMessage,
|
||||||
AskQuota,
|
AskQuota,
|
||||||
AskThread,
|
AskThread,
|
||||||
|
CreateOrderBody,
|
||||||
|
CreateProfileBody,
|
||||||
|
ExploreCategory,
|
||||||
|
GrowthCheckin,
|
||||||
|
GrowthPlan,
|
||||||
GrowthReport,
|
GrowthReport,
|
||||||
|
ImageCardDrawn,
|
||||||
|
ImageCardQuota,
|
||||||
|
ImageCardScene,
|
||||||
MembershipMe,
|
MembershipMe,
|
||||||
|
MoodEntry,
|
||||||
Profile,
|
Profile,
|
||||||
|
RelationInsightResult,
|
||||||
|
ScaleDetail,
|
||||||
|
ScaleSubmitResult,
|
||||||
|
ScaleSummary,
|
||||||
|
SolarTermToday,
|
||||||
|
SynastryInviteCreated,
|
||||||
|
SynastryInviteView,
|
||||||
|
SynastryNearbyItem,
|
||||||
|
UpdateProfileBody,
|
||||||
} from '@yuxingu/types'
|
} from '@yuxingu/types'
|
||||||
|
|
||||||
/** Platform adapters so H5 and mini-program share one client. */
|
/** 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) {
|
export function createClient(opts: CreateClientOptions) {
|
||||||
const { baseURL, adapters } = opts
|
const { baseURL, adapters } = opts
|
||||||
@@ -60,27 +79,10 @@ export function createClient(opts: CreateClientOptions) {
|
|||||||
healthz: () => call<{ status: string }>('/api/v1/healthz'),
|
healthz: () => call<{ status: string }>('/api/v1/healthz'),
|
||||||
ping: () => call<{ pong: boolean }>('/api/v1/ping'),
|
ping: () => call<{ pong: boolean }>('/api/v1/ping'),
|
||||||
listProfiles: () => call<{ items: Profile[] }>('/api/v1/profiles'),
|
listProfiles: () => call<{ items: Profile[] }>('/api/v1/profiles'),
|
||||||
createProfile: (body: {
|
createProfile: (body: CreateProfileBody) =>
|
||||||
relation: 'self' | 'other'
|
call<Profile>('/api/v1/profiles', { method: 'POST', body }),
|
||||||
birth_date: string
|
updateProfile: (id: string, body: UpdateProfileBody) =>
|
||||||
display_name?: string
|
call<Profile>(`/api/v1/profiles/${id}`, { method: 'PATCH', body }),
|
||||||
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 }),
|
|
||||||
deleteProfile: (id: string) =>
|
deleteProfile: (id: string) =>
|
||||||
call<{ deleted: boolean }>(`/api/v1/profiles/${id}`, { method: 'DELETE' }),
|
call<{ deleted: boolean }>(`/api/v1/profiles/${id}`, { method: 'DELETE' }),
|
||||||
createPortrait: (profile_id: string) =>
|
createPortrait: (profile_id: string) =>
|
||||||
@@ -93,136 +95,57 @@ export function createClient(opts: CreateClientOptions) {
|
|||||||
body: { profile_id_a, profile_id_b, ...(as_of ? { as_of } : {}) },
|
body: { profile_id_a, profile_id_b, ...(as_of ? { as_of } : {}) },
|
||||||
}),
|
}),
|
||||||
listSynastryNearby: (lat: number, lng: number, radius_km = 50) =>
|
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}`,
|
`/api/v1/synastry/nearby?lat=${lat}&lng=${lng}&radius_km=${radius_km}`,
|
||||||
),
|
),
|
||||||
createSynastryInvite: (profile_id: string) =>
|
createSynastryInvite: (profile_id: string) =>
|
||||||
call<{ token: string; expires_at: string; path: string }>('/api/v1/synastry/invites', {
|
call<SynastryInviteCreated>('/api/v1/synastry/invites', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { profile_id },
|
body: { profile_id },
|
||||||
}),
|
}),
|
||||||
getSynastryInvite: (token: string) =>
|
getSynastryInvite: (token: string) =>
|
||||||
call<{
|
call<SynastryInviteView>(`/api/v1/synastry/invites/${token}`),
|
||||||
token: string
|
acceptSynastryInvite: (token: string, body: AcceptSynastryInviteBody) =>
|
||||||
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<GrowthReport>(`/api/v1/synastry/invites/${token}/accept`, { method: 'POST', body }),
|
call<GrowthReport>(`/api/v1/synastry/invites/${token}/accept`, { method: 'POST', body }),
|
||||||
createRhythm: (profile_id: string) =>
|
createRhythm: (profile_id: string) =>
|
||||||
call<GrowthReport>('/api/v1/reports/rhythm', { method: 'POST', body: { profile_id } }),
|
call<GrowthReport>('/api/v1/reports/rhythm', { method: 'POST', body: { profile_id } }),
|
||||||
listImageCardScenes: () =>
|
listImageCardScenes: () => call<{ items: ImageCardScene[] }>('/api/v1/image-cards/scenes'),
|
||||||
call<{ items: { key: string; label: string }[] }>('/api/v1/image-cards/scenes'),
|
getImageCardQuota: () => call<ImageCardQuota>('/api/v1/image-cards/quota'),
|
||||||
getImageCardQuota: () =>
|
|
||||||
call<{ remaining: number; daily_free: number; unlimited: boolean }>('/api/v1/image-cards/quota'),
|
|
||||||
drawImageCard: (body: { profile_id: string; scene?: string; depth?: boolean }) =>
|
drawImageCard: (body: { profile_id: string; scene?: string; depth?: boolean }) =>
|
||||||
call<{
|
call<ImageCardDrawn>('/api/v1/image-cards/draw', { method: 'POST', body }),
|
||||||
report: GrowthReport
|
getSolarTermsToday: () => call<SolarTermToday>('/api/v1/solar-terms/today'),
|
||||||
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'),
|
|
||||||
saveMood: (body: { score?: number; note?: string; day?: string }) =>
|
saveMood: (body: { score?: number; note?: string; day?: string }) =>
|
||||||
call<{ id: string; day: string; score?: number; note?: string }>('/api/v1/moods', {
|
call<MoodEntry>('/api/v1/moods', { method: 'POST', body }),
|
||||||
method: 'POST',
|
getMoodToday: () => call<{ mood: MoodEntry | null }>('/api/v1/moods/today'),
|
||||||
body,
|
getMoodsRecent: () => call<{ items: MoodEntry[] }>('/api/v1/moods/recent'),
|
||||||
}),
|
getExploreCatalog: () => call<{ categories: ExploreCategory[] }>('/api/v1/explore/catalog'),
|
||||||
getMoodToday: () =>
|
getExploreCategory: (key: string) => call<ExploreCategory>(`/api/v1/explore/catalog/${key}`),
|
||||||
call<{ mood: { id: string; day: string; score?: number; note?: string } | null }>(
|
listGrowthPlans: () => call<{ items: GrowthPlan[] }>('/api/v1/growth/plans'),
|
||||||
'/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',
|
|
||||||
),
|
|
||||||
createGrowthPlan: (body: { title: string; focus?: string }) =>
|
createGrowthPlan: (body: { title: string; focus?: string }) =>
|
||||||
call<{ id: string; title: string; focus: string }>('/api/v1/growth/plans', {
|
call<GrowthPlan>('/api/v1/growth/plans', { method: 'POST', body }),
|
||||||
method: 'POST',
|
|
||||||
body,
|
|
||||||
}),
|
|
||||||
growthCheckin: (planId: string, body?: { note?: string }) =>
|
growthCheckin: (planId: string, body?: { note?: string }) =>
|
||||||
call<{ id: string; day: string }>(`/api/v1/growth/plans/${planId}/checkin`, {
|
call<{ id: string; day: string }>(`/api/v1/growth/plans/${planId}/checkin`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: body || {},
|
body: body || {},
|
||||||
}),
|
}),
|
||||||
listGrowthCheckins: (planId: string) =>
|
listGrowthCheckins: (planId: string) =>
|
||||||
call<{ items: { id: string; day: string; note?: string }[] }>(
|
call<{ items: GrowthCheckin[] }>(`/api/v1/growth/plans/${planId}/checkins`),
|
||||||
`/api/v1/growth/plans/${planId}/checkins`,
|
|
||||||
),
|
|
||||||
listReports: () => call<{ items: GrowthReport[] }>('/api/v1/reports'),
|
listReports: () => call<{ items: GrowthReport[] }>('/api/v1/reports'),
|
||||||
getReport: (id: string) => call<GrowthReport>(`/api/v1/reports/${id}`),
|
getReport: (id: string) => call<GrowthReport>(`/api/v1/reports/${id}`),
|
||||||
getMembership: () => call<MembershipMe>('/api/v1/membership/me'),
|
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 }),
|
call<{ order_id: string }>('/api/v1/orders', { method: 'POST', body }),
|
||||||
payMock: (orderId: string) =>
|
payMock: (orderId: string) =>
|
||||||
call<{ paid: boolean }>(`/api/v1/orders/${orderId}/pay-mock`, { method: 'POST' }),
|
call<{ paid: boolean }>(`/api/v1/orders/${orderId}/pay-mock`, { method: 'POST' }),
|
||||||
createRelationInsight: (profile_a_id: string, profile_b_id: string) =>
|
createRelationInsight: (profile_a_id: string, profile_b_id: string) =>
|
||||||
call<{
|
call<RelationInsightResult>('/api/v1/relation/insight', {
|
||||||
insight: { id: string; report_id?: string }
|
|
||||||
report: GrowthReport
|
|
||||||
}>('/api/v1/relation/insight', {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { profile_a_id, profile_b_id },
|
body: { profile_a_id, profile_b_id },
|
||||||
}),
|
}),
|
||||||
listScales: () =>
|
listScales: () => call<{ items: ScaleSummary[] }>('/api/v1/scales'),
|
||||||
call<{ items: { slug: string; title: string; description: string }[] }>('/api/v1/scales'),
|
getScale: (slug: string) => call<ScaleDetail>(`/api/v1/scales/${slug}`),
|
||||||
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}`),
|
|
||||||
submitScale: (slug: string, profile_id: string, answers: Record<string, string>) =>
|
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',
|
method: 'POST',
|
||||||
body: { profile_id, answers },
|
body: { profile_id, answers },
|
||||||
}),
|
}),
|
||||||
|
|||||||
+145
-5
@@ -21,6 +21,26 @@ export interface Profile {
|
|||||||
created_at: string
|
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 可能因权益被剥离) */
|
/** 成长报告(detail 可能因权益被剥离) */
|
||||||
export interface GrowthReport {
|
export interface GrowthReport {
|
||||||
id: string
|
id: string
|
||||||
@@ -33,13 +53,34 @@ export interface GrowthReport {
|
|||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 探索测试列表项 */
|
/** 探索测试列表项 — 对齐 Go ScaleListItem / listScales 响应 */
|
||||||
export interface ScaleSummary {
|
export interface ScaleSummary {
|
||||||
slug: string
|
slug: string
|
||||||
name: string
|
title: string
|
||||||
category: string
|
description: string
|
||||||
questionCount: number
|
}
|
||||||
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
|
ask_quota_left?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CreateOrderBody {
|
||||||
|
kind: 'membership' | 'deep_access'
|
||||||
|
plan?: string
|
||||||
|
report_id?: string
|
||||||
|
}
|
||||||
|
|
||||||
/** 问答线程 */
|
/** 问答线程 */
|
||||||
export interface AskThread {
|
export interface AskThread {
|
||||||
id: string
|
id: string
|
||||||
@@ -76,3 +123,96 @@ export interface AskQuota {
|
|||||||
free_limit: number
|
free_limit: number
|
||||||
source: 'membership' | 'free' | string
|
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