feat: P1 合盘/星座/问答与测测完整设计包及模拟器取证工具
落地 synastry/star/ask API 与 H5 页面,补齐 cece-frontend-re complete-design 证据文档,并加入 Android 模拟器截图抓取脚本。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import AskPage from './AskPage.vue'
|
||||
|
||||
const { api } = vi.hoisted(() => ({
|
||||
api: {
|
||||
listProfiles: vi.fn(),
|
||||
getAskQuota: vi.fn(),
|
||||
createAskThread: vi.fn(),
|
||||
sendAskMessage: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../api/client', () => ({ api }))
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => ({ query: {} }),
|
||||
useRouter: () => ({ back: vi.fn(), push: vi.fn() }),
|
||||
}))
|
||||
|
||||
const RouterLinkStub = defineComponent({
|
||||
props: ['to'],
|
||||
setup(props, { slots }) {
|
||||
return () => h('a', { href: String(props.to) }, slots.default?.())
|
||||
},
|
||||
})
|
||||
|
||||
describe('AskPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('shows empty state when no profiles', async () => {
|
||||
api.listProfiles.mockResolvedValue({ items: [] })
|
||||
api.getAskQuota.mockResolvedValue({ remaining: 3, free_limit: 3, active_membership: false, source: 'free' })
|
||||
const w = mount(AskPage, { global: { stubs: { RouterLink: RouterLinkStub } } })
|
||||
await flushPromises()
|
||||
expect(w.text()).toContain('还没有个人档案')
|
||||
expect(w.text()).toContain('去首页探索')
|
||||
})
|
||||
|
||||
it('loads profiles and sends a message', async () => {
|
||||
api.listProfiles.mockResolvedValue({
|
||||
items: [{ id: 'p1', relation: 'self', display_name: '我', birth_date: '1990-01-01' }],
|
||||
})
|
||||
api.getAskQuota.mockResolvedValue({ remaining: 3, free_limit: 3, active_membership: false, source: 'free' })
|
||||
api.createAskThread.mockResolvedValue({ id: 't1', profile_id: 'p1' })
|
||||
api.sendAskMessage.mockResolvedValue({
|
||||
user_message: { id: 'm1', role: 'user', content: '你好', thread_id: 't1', created_at: '' },
|
||||
assistant_message: {
|
||||
id: 'm2',
|
||||
role: 'assistant',
|
||||
content: '结合档案的回复',
|
||||
thread_id: 't1',
|
||||
created_at: '',
|
||||
},
|
||||
quota: { remaining: 2, free_limit: 3, active_membership: false, source: 'free' },
|
||||
})
|
||||
|
||||
const w = mount(AskPage, { global: { stubs: { RouterLink: RouterLinkStub } } })
|
||||
await flushPromises()
|
||||
expect(w.text()).toContain('剩余 3 次')
|
||||
await w.find('textarea').setValue('你好')
|
||||
await w.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
expect(api.createAskThread).toHaveBeenCalled()
|
||||
expect(w.text()).toContain('结合档案的回复')
|
||||
expect(w.text()).toContain('剩余 2 次')
|
||||
})
|
||||
|
||||
it('shows boot error with retry', async () => {
|
||||
api.listProfiles.mockRejectedValueOnce(new Error('网络错误'))
|
||||
api.getAskQuota.mockRejectedValueOnce(new Error('网络错误'))
|
||||
const w = mount(AskPage, { global: { stubs: { RouterLink: RouterLinkStub } } })
|
||||
await flushPromises()
|
||||
expect(w.text()).toContain('网络错误')
|
||||
api.listProfiles.mockResolvedValue({ items: [] })
|
||||
api.getAskQuota.mockResolvedValue({ remaining: 3, free_limit: 3, active_membership: false, source: 'free' })
|
||||
await w.find('button.link').trigger('click')
|
||||
await flushPromises()
|
||||
expect(w.text()).toContain('还没有个人档案')
|
||||
})
|
||||
})
|
||||
@@ -1,17 +1,213 @@
|
||||
<template>
|
||||
<main class="page">
|
||||
<h1>问答</h1>
|
||||
<p class="sub">AI 成长助手 · 结合你的档案回答(占位)</p>
|
||||
<div class="card">
|
||||
预置场景:认识自己 · 理解关系 · 职业探索 · 情绪整理 · 生活建议。
|
||||
可切换我的档案 / TA 的档案;深度分析为会员权益。
|
||||
<main class="yxg-page ask">
|
||||
<div class="yxg-hero">
|
||||
<PageTitle feature="ask" title="问答" sub="AI 成长助手 · 结合档案回答" />
|
||||
<p v-if="quota" class="yxg-meta quota">
|
||||
剩余 {{ quota.remaining }} 次
|
||||
<span v-if="!quota.active_membership">(免费 {{ quota.free_limit }} 次)</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="yxg-sheet ask-sheet">
|
||||
<p v-if="bootLoading" class="yxg-hint pad">加载中…</p>
|
||||
<p v-else-if="bootError" class="yxg-err pad">
|
||||
{{ bootError }}
|
||||
<button type="button" class="yxg-link" @click="boot">重试</button>
|
||||
</p>
|
||||
<template v-else-if="!profiles.length">
|
||||
<div class="yxg-card empty">
|
||||
<p>还没有个人档案。请先完成性格探索,再来提问。</p>
|
||||
<router-link class="yxg-btn" to="/">去首页探索</router-link>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="yxg-card controls">
|
||||
<label class="yxg-label">选择档案</label>
|
||||
<select class="yxg-select" 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>
|
||||
<label class="yxg-label">场景</label>
|
||||
<div class="scenes">
|
||||
<button
|
||||
v-for="s in scenes"
|
||||
:key="s.key"
|
||||
type="button"
|
||||
class="yxg-chip yxg-chip-solid"
|
||||
:class="{ on: scene === s.key }"
|
||||
@click="pickScene(s)"
|
||||
>
|
||||
<span aria-hidden="true">{{ s.icon }} </span>{{ s.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="thread" ref="threadEl">
|
||||
<p v-if="!messages.length && !sending" class="yxg-hint empty-msg">
|
||||
选一个场景,或直接输入你的问题。
|
||||
</p>
|
||||
<div v-for="m in messages" :key="m.id" :class="['bubble', m.role]">
|
||||
<p>{{ m.content }}</p>
|
||||
</div>
|
||||
<p v-if="sending" class="yxg-hint pad">助手正在回复…</p>
|
||||
</div>
|
||||
|
||||
<p v-if="sendError" class="yxg-err pad">
|
||||
{{ sendError }}
|
||||
<router-link v-if="quotaExhausted" class="yxg-link" to="/membership">开通成长会员</router-link>
|
||||
</p>
|
||||
|
||||
<form class="composer" @submit.prevent="send">
|
||||
<textarea
|
||||
class="yxg-textarea"
|
||||
v-model="draft"
|
||||
rows="2"
|
||||
placeholder="例如:我和 TA 沟通时容易卡住,该怎么调整?"
|
||||
:disabled="sending || (quota !== null && quota.remaining <= 0)"
|
||||
/>
|
||||
<button
|
||||
class="yxg-btn"
|
||||
type="submit"
|
||||
:disabled="sending || !draft.trim() || (quota !== null && quota.remaining <= 0)"
|
||||
>
|
||||
发送
|
||||
</button>
|
||||
</form>
|
||||
</template>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onMounted, ref } from 'vue'
|
||||
import type { AskMessage, AskQuota, Profile } from '@yuxingu/types'
|
||||
import { api } from '../api/client'
|
||||
import PageTitle from '../components/PageTitle.vue'
|
||||
|
||||
const scenes = [
|
||||
{ key: 'self', icon: '△', label: '认识自己', prompt: '我想更了解自己的性格与互动风格。' },
|
||||
{ key: 'relation', icon: '♡', label: '理解关系', prompt: '和重要的人相处时,我该如何更好地沟通?' },
|
||||
{ key: 'career', icon: '▽', label: '职业探索', prompt: '从我的特点看,职业选择上可以注意什么?' },
|
||||
{ key: 'emotion', icon: '○', label: '情绪整理', prompt: '最近情绪有点乱,我想梳理一下。' },
|
||||
{ key: 'life', icon: '☯', label: '生活建议', prompt: '想改善作息和日常节奏,有什么建议?' },
|
||||
{ key: 'dream', icon: '✦', label: '梦境整理', prompt: '昨晚的梦让我有些在意,想从感受层面整理一下,不是解梦预测。' },
|
||||
{ key: 'family', icon: '◎', label: '原生家庭', prompt: '想梳理原生家庭模式对我沟通与边界的影响。' },
|
||||
{ key: 'values', icon: '◈', label: '价值澄清', prompt: '我想弄清现阶段什么对我真正重要。' },
|
||||
] 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: (typeof scenes)[number]) {
|
||||
scene.value = s.key
|
||||
draft.value = s.prompt
|
||||
threadId.value = ''
|
||||
messages.value = []
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
.page{padding:24px 16px}
|
||||
h1{font-size:22px}
|
||||
.sub{color:var(--yxg-sub);font-size:13px;margin:6px 0 16px}
|
||||
.card{background:#fff;border-radius:16px;padding:18px;font-size:14px;color:#666;line-height:1.6}
|
||||
.ask{padding-bottom:24px}
|
||||
.quota{margin-top:4px}
|
||||
.ask-sheet{padding-bottom:100px;min-height:60vh}
|
||||
.pad{padding:0 16px}
|
||||
.controls{margin-top:8px}
|
||||
.controls .yxg-label:first-child{margin-top:0}
|
||||
.scenes{display:flex;flex-wrap:wrap;gap:8px;margin-top:4px}
|
||||
.scenes .yxg-chip.on{
|
||||
background:linear-gradient(135deg,#ff7a6e,var(--yxg-pri));
|
||||
color:#fff;border-color:transparent;
|
||||
}
|
||||
.thread{overflow-y:auto;max-height:42vh;padding:4px 16px 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);
|
||||
}
|
||||
.bubble.user{background:#fff;margin-left:28px;color:#333}
|
||||
.bubble.assistant{background:#fff5f4;margin-right:28px;color:#444;box-shadow:none}
|
||||
.empty-msg{text-align:center;padding:28px 16px}
|
||||
.empty .yxg-btn{margin-top:12px}
|
||||
.composer{
|
||||
display:flex;gap:8px;align-items:flex-end;
|
||||
position:sticky;bottom:72px;
|
||||
margin:0 16px;padding:10px 0 4px;
|
||||
background:linear-gradient(180deg,transparent, #fff 28%);
|
||||
}
|
||||
.composer .yxg-textarea{flex:1;resize:none;min-height:44px}
|
||||
.composer .yxg-btn{flex-shrink:0;padding:11px 16px}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import CompanionPage from './CompanionPage.vue'
|
||||
|
||||
const { api } = vi.hoisted(() => ({
|
||||
api: {
|
||||
getSolarTermsToday: vi.fn(),
|
||||
getMoodToday: vi.fn(),
|
||||
saveMood: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../api/client', () => ({ api }))
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => ({ path: '/companion' }),
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
}))
|
||||
|
||||
describe('CompanionPage', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('shows solar term and saves mood', async () => {
|
||||
api.getSolarTermsToday.mockResolvedValue({ name: '立秋', tip: '收敛节奏', date: '2026-08-02' })
|
||||
api.getMoodToday.mockResolvedValue({ mood: null })
|
||||
api.saveMood.mockResolvedValue({ id: 'm1', day: '2026-08-02', score: 4 })
|
||||
|
||||
const w = mount(CompanionPage)
|
||||
await flushPromises()
|
||||
expect(w.text()).toContain('立秋')
|
||||
expect(w.text()).toContain('收敛节奏')
|
||||
|
||||
await w.findAll('button.score')[3].trigger('click') // score 4
|
||||
await w.find('button.save').trigger('click')
|
||||
await flushPromises()
|
||||
expect(api.saveMood).toHaveBeenCalledWith({ score: 4, note: undefined })
|
||||
expect(w.text()).toContain('已保存')
|
||||
})
|
||||
})
|
||||
@@ -1,14 +1,160 @@
|
||||
<template>
|
||||
<main class="page">
|
||||
<h1>陪伴</h1>
|
||||
<p class="sub">节气生活 · 心情记录(占位)</p>
|
||||
<div class="card">今日节气与生活建议将由 API 下发。第二阶段完善心情与成长记录。</div>
|
||||
<main class="yxg-page">
|
||||
<div class="yxg-hero">
|
||||
<PageTitle feature="companion" title="陪伴" sub="节气生活 · 心情记录" />
|
||||
</div>
|
||||
|
||||
<div class="yxg-sheet">
|
||||
<div class="yxg-card term-card">
|
||||
<p class="term"><span class="tm icon-green" aria-hidden="true">☯</span>今日 · {{ term.name }}</p>
|
||||
<p class="yxg-meta date">{{ todayLabel }}</p>
|
||||
<p class="tip">{{ term.tip }}</p>
|
||||
</div>
|
||||
|
||||
<div class="yxg-card">
|
||||
<p class="sec-title"><span aria-hidden="true">○ </span>今日心情</p>
|
||||
<div class="scores">
|
||||
<button
|
||||
v-for="n in 5"
|
||||
:key="n"
|
||||
type="button"
|
||||
class="score"
|
||||
:class="{ on: score === n }"
|
||||
@click="score = n"
|
||||
>{{ moodMarks[n - 1] }}</button>
|
||||
</div>
|
||||
<p class="yxg-meta score-hint">{{ score ? `已选 ${score} 分` : '点选此刻感受(1–5)' }}</p>
|
||||
<textarea
|
||||
class="yxg-textarea"
|
||||
v-model="note"
|
||||
rows="3"
|
||||
maxlength="200"
|
||||
placeholder="可选:写一两句此刻的感受"
|
||||
/>
|
||||
<button type="button" class="yxg-btn yxg-btn-soft save" :disabled="saving || !score" @click="save">
|
||||
{{ saving ? '保存中…' : saved ? '已保存' : '保存心情' }}
|
||||
</button>
|
||||
<p v-if="moodErr" class="yxg-err">{{ moodErr }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="recent.length" class="yxg-card">
|
||||
<p class="sec-title"><span aria-hidden="true">▣ </span>近七日心情</p>
|
||||
<ul class="trail">
|
||||
<li v-for="m in recent" :key="m.id">
|
||||
<span class="d">{{ m.day }}</span>
|
||||
<span class="s">{{ m.score ?? '—' }}</span>
|
||||
<span class="n">{{ m.note || '' }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="yxg-card soft links">
|
||||
<p>想继续整理感受,可以去问答聊几句。</p>
|
||||
<router-link class="yxg-link-plain" to="/ask"><span aria-hidden="true">◎ </span>去 AI 成长助手 →</router-link>
|
||||
<router-link class="yxg-link-plain" to="/rhythm"><span aria-hidden="true">☯ </span>身心节律探索 →</router-link>
|
||||
<router-link class="yxg-link-plain" to="/growth-plan"><span aria-hidden="true">▽ </span>成长计划 →</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { api } from '../api/client'
|
||||
import PageTitle from '../components/PageTitle.vue'
|
||||
import { track } from '../lib/analytics'
|
||||
|
||||
const moodMarks = ['◦', '○', '◎', '●', '◉']
|
||||
const term = ref({ name: '…', tip: '加载中…', date: '' })
|
||||
const score = ref<number | null>(null)
|
||||
const note = ref('')
|
||||
const saving = ref(false)
|
||||
const saved = ref(false)
|
||||
const moodErr = ref('')
|
||||
const recent = ref<{ id: string; day: string; score?: number; note?: string }[]>([])
|
||||
|
||||
const today = new Date()
|
||||
const todayLabel = computed(() =>
|
||||
today.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long' }),
|
||||
)
|
||||
|
||||
async function load() {
|
||||
track('companion_viewed')
|
||||
try {
|
||||
term.value = await api.getSolarTermsToday()
|
||||
} catch {
|
||||
term.value = { name: '今日', tip: '给身心留一点缓冲,慢一点也很好。', date: '' }
|
||||
}
|
||||
try {
|
||||
const res = await api.getMoodToday()
|
||||
if (res.mood?.score) score.value = res.mood.score
|
||||
if (res.mood?.note) note.value = res.mood.note
|
||||
if (res.mood) saved.value = true
|
||||
} catch {
|
||||
/* optional */
|
||||
}
|
||||
try {
|
||||
const trail = await api.getMoodsRecent()
|
||||
recent.value = trail.items || []
|
||||
} catch {
|
||||
recent.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!score.value) return
|
||||
saving.value = true
|
||||
moodErr.value = ''
|
||||
saved.value = false
|
||||
try {
|
||||
await api.saveMood({ score: score.value, note: note.value || undefined })
|
||||
saved.value = true
|
||||
track('mood_saved', { score: score.value })
|
||||
const trail = await api.getMoodsRecent()
|
||||
recent.value = trail.items || []
|
||||
} catch (e) {
|
||||
moodErr.value = e instanceof Error ? e.message : '保存失败'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page{padding:24px 16px}
|
||||
h1{font-size:22px}
|
||||
.sub{color:var(--yxg-sub);font-size:13px;margin:6px 0 16px}
|
||||
.card{background:#fff;border-radius:16px;padding:18px;font-size:14px;color:#666;line-height:1.6}
|
||||
.term-card{margin-top:8px}
|
||||
.term{
|
||||
font-family:var(--font-display);
|
||||
font-size:18px;font-weight:700;color:#3d6b45;
|
||||
display:flex;align-items:center;gap:8px;letter-spacing:.04em;
|
||||
}
|
||||
.tm{
|
||||
width:28px;height:28px;border-radius:10px;display:inline-flex;align-items:center;justify-content:center;
|
||||
font-size:15px;font-family:var(--font-sans);box-shadow:inset 0 -2px 0 rgba(0,0,0,.03);
|
||||
}
|
||||
.date{margin:6px 0 10px}
|
||||
.tip{color:#444;line-height:1.7}
|
||||
.sec-title{font-size:15px;font-weight:650;color:#222;margin-bottom:12px}
|
||||
.scores{display:flex;gap:8px;margin-bottom:6px}
|
||||
.score{
|
||||
width:44px;height:44px;border-radius:14px;border:1px solid #eee;background:#fafafa;
|
||||
font-weight:600;color:#666;font-size:16px;cursor:pointer;
|
||||
}
|
||||
.score.on{
|
||||
border-color:var(--color-accent-green);
|
||||
background:var(--color-accent-green-soft);
|
||||
color:#3d6b45;
|
||||
}
|
||||
.score-hint{margin-bottom:10px}
|
||||
.save{margin-top:12px}
|
||||
.links{display:flex;flex-direction:column;gap:8px;color:#666}
|
||||
.trail{list-style:none;margin:0;padding:0;font-size:13px}
|
||||
.trail li{
|
||||
display:grid;grid-template-columns:96px 28px 1fr;gap:8px;
|
||||
padding:10px 0;border-bottom:1px solid #f3f3f3;color:#666;
|
||||
}
|
||||
.trail li:last-child{border-bottom:none}
|
||||
.trail .d{color:#999}
|
||||
.trail .s{font-weight:700;color:#3d6b45}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<main class="yxg-page">
|
||||
<div class="yxg-hero">
|
||||
<BackButton />
|
||||
<PageTitle
|
||||
v-if="cat"
|
||||
:icon="cat.icon"
|
||||
:tone="(cat.tone as any)"
|
||||
:title="cat.title"
|
||||
:sub="cat.description"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="yxg-sheet">
|
||||
<p v-if="loading" class="yxg-hint pad">加载中…</p>
|
||||
<p v-else-if="error" class="yxg-err pad">{{ error }}</p>
|
||||
<ul v-else-if="cat" class="yxg-list">
|
||||
<li v-for="it in cat.items" :key="it.key">
|
||||
<router-link :to="it.path">
|
||||
<span class="yxg-mark" :class="'icon-' + it.tone" aria-hidden="true">{{ it.icon }}</span>
|
||||
<span class="yxg-body">
|
||||
<strong>
|
||||
{{ it.title }}
|
||||
<span v-if="it.badge" class="badge">{{ it.badge }}</span>
|
||||
</strong>
|
||||
<span class="desc">{{ it.description }}</span>
|
||||
</span>
|
||||
<span class="chev" aria-hidden="true">›</span>
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { api } from '../api/client'
|
||||
import BackButton from '../components/BackButton.vue'
|
||||
import PageTitle from '../components/PageTitle.vue'
|
||||
|
||||
type Item = {
|
||||
key: string
|
||||
title: string
|
||||
description: string
|
||||
path: string
|
||||
icon: string
|
||||
tone: string
|
||||
badge?: string
|
||||
}
|
||||
type Cat = {
|
||||
key: string
|
||||
title: string
|
||||
description: string
|
||||
icon: string
|
||||
tone: string
|
||||
items: Item[]
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const cat = ref<Cat | null>(null)
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
cat.value = null
|
||||
try {
|
||||
cat.value = await api.getExploreCategory(String(route.params.category))
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
watch(() => route.params.category, load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pad{padding:8px 16px}
|
||||
.yxg-list{margin-top:8px}
|
||||
.badge{
|
||||
font-size:10px;background:linear-gradient(135deg,#FF7A6E,var(--yxg-pri));
|
||||
color:#fff;padding:1px 6px;border-radius:6px;margin-left:4px;font-weight:600;
|
||||
vertical-align:middle;
|
||||
}
|
||||
</style>
|
||||
@@ -1,42 +1,66 @@
|
||||
<template>
|
||||
<main class="page">
|
||||
<h1>探索</h1>
|
||||
<p class="sub">性格探索 · 探索测试 · 关系理解 · 个人画像</p>
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
<ul class="list">
|
||||
<li><router-link to="/portrait">性格探索 / 个人画像</router-link></li>
|
||||
<li><router-link to="/relation">关系理解</router-link></li>
|
||||
<li v-for="s in scales" :key="s.slug">
|
||||
<router-link :to="`/scales/${s.slug}`">{{ s.title }}</router-link>
|
||||
<span class="desc">{{ s.description }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<main class="yxg-page">
|
||||
<div class="yxg-hero">
|
||||
<PageTitle feature="explore" title="探索" sub="六大分类 · 找到适合你的自我理解方式" />
|
||||
</div>
|
||||
|
||||
<div class="yxg-sheet">
|
||||
<p v-if="loading" class="yxg-hint pad">加载中…</p>
|
||||
<p v-else-if="error" class="yxg-err pad">
|
||||
{{ error }}
|
||||
<button type="button" class="yxg-link" @click="load">重试</button>
|
||||
</p>
|
||||
<ul v-else class="yxg-list">
|
||||
<li v-for="c in categories" :key="c.key">
|
||||
<router-link :to="`/explore/${c.key}`">
|
||||
<span class="yxg-mark" :class="'icon-' + c.tone" aria-hidden="true">{{ c.icon }}</span>
|
||||
<span class="yxg-body">
|
||||
<strong>{{ c.title }}</strong>
|
||||
<span class="desc">{{ c.description }} · {{ c.items?.length || 0 }} 项</span>
|
||||
</span>
|
||||
<span class="chev" aria-hidden="true">›</span>
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { api } from '../api/client'
|
||||
import PageTitle from '../components/PageTitle.vue'
|
||||
|
||||
const scales = ref<{ slug: string; title: string; description: string }[]>([])
|
||||
type Cat = {
|
||||
key: string
|
||||
title: string
|
||||
description: string
|
||||
icon: string
|
||||
tone: string
|
||||
items?: unknown[]
|
||||
}
|
||||
|
||||
const categories = ref<Cat[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const res = await api.listScales()
|
||||
scales.value = res.items || []
|
||||
const res = await api.getExploreCatalog()
|
||||
categories.value = res.categories || []
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page{padding:24px 16px}
|
||||
h1{font-size:22px}
|
||||
.sub{color:var(--yxg-sub);font-size:13px;margin:6px 0 16px}
|
||||
.err{color:var(--yxg-pri);font-size:13px}
|
||||
.list{background:#fff;border-radius:16px;padding:8px 16px;line-height:1.8;font-size:14px;color:#555}
|
||||
.list a{color:inherit;text-decoration:none;font-weight:600}
|
||||
.desc{display:block;font-size:12px;color:#aaa;font-weight:400;margin-bottom:8px}
|
||||
.pad{padding:8px 16px}
|
||||
.yxg-list{margin-top:8px}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
<template>
|
||||
<main class="yxg-page">
|
||||
<div class="yxg-hero">
|
||||
<BackButton />
|
||||
<PageTitle feature="growth" title="成长计划" sub="小目标 · 每日打卡(生活成长,非运势)" />
|
||||
</div>
|
||||
|
||||
<div class="yxg-sheet">
|
||||
<div class="yxg-card">
|
||||
<label class="yxg-label">新计划标题</label>
|
||||
<input class="yxg-input" v-model="title" maxlength="40" placeholder="例如:每晚 11 点前放下手机" />
|
||||
<label class="yxg-label">焦点(可选)</label>
|
||||
<input class="yxg-input" v-model="focus" maxlength="80" placeholder="例如:保护睡眠与情绪" />
|
||||
<button class="yxg-btn" type="button" :disabled="saving || !title.trim()" @click="create">创建计划</button>
|
||||
<p v-if="err" class="yxg-err">{{ err }}</p>
|
||||
</div>
|
||||
|
||||
<p v-if="loading" class="yxg-hint pad">加载中…</p>
|
||||
<ul v-else class="yxg-list plans">
|
||||
<li v-for="p in plans" :key="p.id" class="plan">
|
||||
<strong>▽ {{ p.title }}</strong>
|
||||
<p v-if="p.focus" class="focus">{{ p.focus }}</p>
|
||||
<button type="button" class="yxg-btn yxg-btn-ghost" :disabled="checking === p.id" @click="checkin(p.id)">
|
||||
{{ checking === p.id ? '记录中…' : '今日打卡' }}
|
||||
</button>
|
||||
<ul v-if="checkins[p.id]?.length" class="cks">
|
||||
<li v-for="c in checkins[p.id]" :key="c.id">{{ c.day }}{{ c.note ? ' · ' + c.note : '' }}</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li v-if="!plans.length" class="empty">还没有计划,先创建一个小目标吧。</li>
|
||||
</ul>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { api } from '../api/client'
|
||||
import BackButton from '../components/BackButton.vue'
|
||||
import PageTitle from '../components/PageTitle.vue'
|
||||
|
||||
const plans = ref<{ id: string; title: string; focus: string }[]>([])
|
||||
const checkins = ref<Record<string, { id: string; day: string; note?: string }[]>>({})
|
||||
const title = ref('')
|
||||
const focus = ref('')
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const checking = ref('')
|
||||
const err = ref('')
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await api.listGrowthPlans()
|
||||
plans.value = res.items || []
|
||||
for (const p of plans.value) {
|
||||
const ck = await api.listGrowthCheckins(p.id)
|
||||
checkins.value[p.id] = ck.items || []
|
||||
}
|
||||
} catch (e) {
|
||||
err.value = e instanceof Error ? e.message : '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function create() {
|
||||
saving.value = true
|
||||
err.value = ''
|
||||
try {
|
||||
await api.createGrowthPlan({ title: title.value.trim(), focus: focus.value.trim() || undefined })
|
||||
title.value = ''
|
||||
focus.value = ''
|
||||
await load()
|
||||
} catch (e) {
|
||||
err.value = e instanceof Error ? e.message : '创建失败'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function checkin(id: string) {
|
||||
checking.value = id
|
||||
err.value = ''
|
||||
try {
|
||||
await api.growthCheckin(id, {})
|
||||
const ck = await api.listGrowthCheckins(id)
|
||||
checkins.value[id] = ck.items || []
|
||||
} catch (e) {
|
||||
err.value = e instanceof Error ? e.message : '打卡失败'
|
||||
} finally {
|
||||
checking.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.yxg-card{margin-top:8px}
|
||||
.yxg-card .yxg-label:first-child{margin-top:0}
|
||||
.yxg-btn{margin-top:12px}
|
||||
.pad{padding:0 16px}
|
||||
.plans{list-style:none}
|
||||
.plan{display:block}
|
||||
.plan strong{font-size:15px;color:#222}
|
||||
.focus{font-size:13px;color:#888;margin:6px 0}
|
||||
.cks{margin-top:8px;padding-left:16px;font-size:12px;color:#999}
|
||||
.empty{color:#999;font-size:13px;padding:8px 4px}
|
||||
</style>
|
||||
@@ -1,65 +1,174 @@
|
||||
<template>
|
||||
<!-- 测测式首页:暖色顶区 → 建档卡 → chips → 白 sheet(金刚区 / feed / 横滑) -->
|
||||
<main class="home">
|
||||
<header class="brand">
|
||||
<div class="word">愈<span>心</span>谷</div>
|
||||
<p class="tag">认识自己 · 理解他人</p>
|
||||
</header>
|
||||
<span class="glow g1" aria-hidden="true" />
|
||||
<span class="glow g2" aria-hidden="true" />
|
||||
|
||||
<section class="portrait-card">
|
||||
<h2>性格探索</h2>
|
||||
<p class="sub">填写生日,生成你的成长画像</p>
|
||||
<div class="row">
|
||||
<input v-model="year" type="number" placeholder="1990" />
|
||||
<span>年</span>
|
||||
<input v-model="month" type="number" placeholder="1" />
|
||||
<span>月</span>
|
||||
<input v-model="day" type="number" placeholder="1" />
|
||||
<span>日</span>
|
||||
<button type="button" @click="goPortrait">开始</button>
|
||||
<header class="top">
|
||||
<router-link to="/profile" class="avatar" aria-label="个人档案">◍</router-link>
|
||||
<div class="brand">
|
||||
<BrandLogo size="header" class="home-logo" />
|
||||
</div>
|
||||
<router-link to="/reports" class="side-link" aria-label="我的报告">报告</router-link>
|
||||
</header>
|
||||
<p class="tagline">愈见自己 · 遇见更好</p>
|
||||
|
||||
<section class="decode">
|
||||
<div class="di-title">愈心解码</div>
|
||||
<div class="di-sub">一个生日,读懂性格与节奏</div>
|
||||
<div class="di-inputs">
|
||||
<BirthDateInputs v-model:year="year" v-model:month="month" v-model:day="day" />
|
||||
<button type="button" @click="goPortrait">生成</button>
|
||||
</div>
|
||||
<p v-if="err" class="err">{{ err }}</p>
|
||||
</section>
|
||||
|
||||
<section class="block">
|
||||
<div class="head"><h3>快捷入口</h3><router-link to="/explore">全部</router-link></div>
|
||||
<div class="grid">
|
||||
<router-link v-for="t in entries" :key="t.to" :to="t.to" class="cell">
|
||||
<div class="icon" :style="{ background: t.bg, color: t.fg }">{{ t.icon }}</div>
|
||||
<span>{{ t.label }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</section>
|
||||
<nav class="chips" aria-label="快捷分类">
|
||||
<a
|
||||
v-for="c in chips"
|
||||
:key="c.key"
|
||||
href="#"
|
||||
class="chip"
|
||||
:class="{ on: chipOn === c.key }"
|
||||
@click.prevent="onChip(c)"
|
||||
>{{ c.label }}</a>
|
||||
</nav>
|
||||
|
||||
<section class="block">
|
||||
<div class="head"><h3>服务状态</h3></div>
|
||||
<p class="api">API:{{ apiStatus }}</p>
|
||||
</section>
|
||||
<div class="sheet">
|
||||
<section class="section" id="grid">
|
||||
<div class="sec-head">
|
||||
<span class="title">认识自己</span>
|
||||
<router-link class="more" to="/explore">全部</router-link>
|
||||
</div>
|
||||
<div class="test-grid">
|
||||
<router-link v-for="t in entries" :key="t.to" :to="t.to" class="test-item">
|
||||
<div class="icon" :class="'icon-' + t.tone" aria-hidden="true">{{ t.icon }}</div>
|
||||
<div class="label">{{ t.label }}</div>
|
||||
<span v-if="t.badge" class="badge">{{ t.badge }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="sec-head">
|
||||
<span class="title">热门推荐</span>
|
||||
<router-link class="more" to="/explore">更多</router-link>
|
||||
</div>
|
||||
<div class="feed">
|
||||
<router-link v-for="f in feeds" :key="f.to" :to="f.to" class="feed-card" :class="f.tone">
|
||||
<span v-if="f.tag" class="feed-tag">{{ f.tag }}</span>
|
||||
<div class="feed-cover" aria-hidden="true">{{ f.icon }}</div>
|
||||
<div class="feed-body">
|
||||
<div class="name">{{ f.title }}</div>
|
||||
<div class="meta">{{ f.meta }}</div>
|
||||
<div class="stat">{{ f.stat }}</div>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="sec-head"><span class="title">精选工具</span></div>
|
||||
<div class="scroll-row">
|
||||
<router-link v-for="m in minis" :key="m.to" :to="m.to" class="mini-card" :class="m.tone">
|
||||
<div class="mi" aria-hidden="true">{{ m.icon }}</div>
|
||||
<div class="mt">{{ m.title }}</div>
|
||||
<div class="md">{{ m.desc }}</div>
|
||||
</router-link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { validateBirth } from '@yuxingu/utils'
|
||||
import { api } from '../api/client'
|
||||
import BirthDateInputs from '../components/BirthDateInputs.vue'
|
||||
import BrandLogo from '../components/BrandLogo.vue'
|
||||
import { AnalyticsEvent, track } from '../lib/analytics'
|
||||
import type { IconTone } from '../lib/featureIcons'
|
||||
|
||||
const router = useRouter()
|
||||
const year = ref('')
|
||||
const month = ref('')
|
||||
const day = ref('')
|
||||
const err = ref('')
|
||||
const apiStatus = ref('检测中…')
|
||||
const chipOn = ref('decode')
|
||||
|
||||
const entries = [
|
||||
{ to: '/portrait', icon: '△', label: '性格探索', bg: '#FFF3D6', fg: '#C8923A' },
|
||||
{ to: '/explore', icon: '◆', label: '人格测评', bg: '#E3EEFF', fg: '#4A90E2' },
|
||||
{ to: '/relation', icon: '◇', label: '关系理解', bg: '#FFE8D6', fg: '#E8985A' },
|
||||
{ to: '/ask', icon: '问', label: 'AI问答', bg: '#FFE4E4', fg: '#E54D42' },
|
||||
{ to: '/companion', icon: '☯', label: '节气陪伴', bg: '#E0F6E4', fg: '#5CB85C' },
|
||||
{ to: '/profile', icon: '◍', label: '个人档案', bg: '#F0F4F8', fg: '#5A6A7A' },
|
||||
{ to: '/membership', icon: '◇', label: '成长会员', bg: '#F5F0FF', fg: '#8B5CF6' },
|
||||
const chips = [
|
||||
{ key: 'decode', label: '愈心解码', to: '/portrait' },
|
||||
{ key: 'star', label: '星座', to: '/star' },
|
||||
{ key: 'synastry', label: '合盘', to: '/synastry' },
|
||||
{ key: 'match', label: '人格匹配', to: '/relation' },
|
||||
{ key: 'ask', label: 'AI 问答', to: '/ask' },
|
||||
{ key: 'more', label: '更多测评', to: '/explore/tests' },
|
||||
]
|
||||
|
||||
/** 金刚区:四核心优先;星座为单入口(太阳/月亮/上升在结果页内分维) */
|
||||
const entries: { to: string; icon: string; label: string; tone: IconTone; badge?: string }[] = [
|
||||
{ to: '/portrait', icon: '△', label: '愈心解码', tone: 'gold', badge: '热' },
|
||||
{ to: '/star', icon: '✦', label: '星座', tone: 'night', badge: 'AI' },
|
||||
{ to: '/synastry', icon: '✧', label: '合盘', tone: 'night', badge: '新' },
|
||||
{ to: '/relation', icon: '♡', label: '人格匹配', tone: 'rose', badge: '热' },
|
||||
{ to: '/ask', icon: '◎', label: 'AI问答', tone: 'gold' },
|
||||
{ to: '/rhythm', icon: '☯', label: '身心节律', tone: 'green' },
|
||||
{ to: '/cards', icon: '◈', label: '意象卡片', tone: 'teal' },
|
||||
{ to: '/companion', icon: '♡', label: '节气陪伴', tone: 'pink' },
|
||||
{ to: '/growth-plan', icon: '▽', label: '成长计划', tone: 'teal' },
|
||||
{ to: '/membership', icon: '⑨', label: '成长会员', tone: 'purple' },
|
||||
{ to: '/reports', icon: '☰', label: '我的报告', tone: 'mute' },
|
||||
{ to: '/profile', icon: '◍', label: '个人档案', tone: 'orange' },
|
||||
{ to: '/explore/tests', icon: '⋯', label: '更多测评', tone: 'mute' },
|
||||
]
|
||||
|
||||
const feeds = [
|
||||
{
|
||||
to: '/portrait',
|
||||
icon: '△',
|
||||
title: '愈心解码',
|
||||
meta: '一个生日,读懂性格与身心节奏',
|
||||
stat: '核心入口',
|
||||
tone: 'fc-e',
|
||||
tag: '热',
|
||||
},
|
||||
{
|
||||
to: '/star',
|
||||
icon: '✦',
|
||||
title: '星座排盘',
|
||||
meta: '圆形本命盘 · 相位 · 日周月年与一生运势',
|
||||
stat: '本周热门',
|
||||
tone: 'fc-b',
|
||||
tag: '新',
|
||||
},
|
||||
{
|
||||
to: '/synastry',
|
||||
icon: '✧',
|
||||
title: '合盘',
|
||||
meta: '比较盘 · 恋爱 / 友情 / 婚姻指数',
|
||||
stat: '了解彼此',
|
||||
tone: 'fc-c',
|
||||
tag: '新',
|
||||
},
|
||||
]
|
||||
|
||||
const minis = [
|
||||
{ to: '/portrait', icon: '△', title: '愈心解码', desc: '生日生成性格解码', tone: 'mc-sand' },
|
||||
{ to: '/star', icon: '✦', title: '星座', desc: '排盘与运势', tone: 'mc-sky' },
|
||||
{ to: '/synastry', icon: '✧', title: '合盘', desc: '五主盘 · 推运 · 三指数', tone: 'mc-coral' },
|
||||
{ to: '/ask', icon: '◎', title: 'AI 问答', desc: '结合档案聊聊卡住的事', tone: 'mc-mint' },
|
||||
]
|
||||
|
||||
function onChip(c: (typeof chips)[number]) {
|
||||
chipOn.value = c.key
|
||||
if (c.to.startsWith('#')) {
|
||||
document.querySelector(c.to)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
return
|
||||
}
|
||||
router.push(c.to)
|
||||
}
|
||||
|
||||
function goPortrait() {
|
||||
const y = Number(year.value)
|
||||
const m = Number(month.value)
|
||||
@@ -70,49 +179,186 @@ function goPortrait() {
|
||||
return
|
||||
}
|
||||
err.value = ''
|
||||
track(AnalyticsEvent.HomeCtaPortrait)
|
||||
router.push({ path: '/portrait', query: { y: String(y), m: String(m), d: String(d) } })
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const data = await api.healthz()
|
||||
apiStatus.value = data.status === 'ok' ? '已连接' : '异常'
|
||||
} catch {
|
||||
apiStatus.value = '未连接(请启动 apps/api)'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.home{padding:18px 16px 8px}
|
||||
.brand{text-align:center;margin-bottom:14px}
|
||||
.word{font-size:28px;font-weight:700;letter-spacing:.18em}
|
||||
.word span{color:var(--yxg-pri)}
|
||||
.tag{font-size:12px;color:rgba(0,0,0,.38);margin-top:6px;letter-spacing:.12em}
|
||||
.portrait-card{
|
||||
background:#fff;border-radius:20px;padding:18px 14px;text-align:center;
|
||||
box-shadow:0 8px 28px rgba(229,77,66,.12);
|
||||
.home{
|
||||
position:relative;overflow-x:hidden;padding-bottom:8px;
|
||||
background:
|
||||
radial-gradient(120% 80% at 80% -10%, rgba(255,180,160,.55) 0%, transparent 55%),
|
||||
radial-gradient(90% 60% at 10% 8%, rgba(255,210,190,.7) 0%, transparent 50%),
|
||||
linear-gradient(180deg, #ffd6cc 0%, #ffe8e0 28%, #fff6f2 55%, #fff9f7 100%);
|
||||
margin-top:-4px;
|
||||
}
|
||||
.portrait-card h2{font-size:17px;color:var(--yxg-gold);letter-spacing:.1em}
|
||||
.sub{font-size:12px;color:#aaa;margin:4px 0 14px}
|
||||
.row{display:flex;gap:6px;align-items:center;justify-content:center;flex-wrap:wrap}
|
||||
.row input{
|
||||
width:56px;padding:10px 4px;border:1.5px solid #eee;border-radius:12px;
|
||||
text-align:center;font-size:15px;outline:none;
|
||||
.glow{
|
||||
position:absolute;pointer-events:none;border-radius:50%;filter:blur(2px);z-index:0;
|
||||
}
|
||||
.row button{
|
||||
padding:10px 16px;border:none;border-radius:22px;color:#fff;font-weight:600;
|
||||
.glow.g1{width:140px;height:140px;top:40px;right:-30px;background:rgba(255,255,255,.45)}
|
||||
.glow.g2{width:90px;height:90px;top:100px;left:-20px;background:rgba(255,200,180,.4)}
|
||||
|
||||
/* 顶栏:左档案 · 中品牌 · 右报告(测测式工具条,非营销大标题) */
|
||||
.top{
|
||||
display:grid;grid-template-columns:48px 1fr 48px;align-items:center;
|
||||
padding:10px 14px 0;position:relative;z-index:2;
|
||||
}
|
||||
.avatar,.side-link{
|
||||
width:36px;height:36px;border-radius:12px;
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
background:rgba(255,255,255,.72);border:1px solid rgba(255,255,255,.9);
|
||||
font-size:12px;color:#666;backdrop-filter:blur(6px);
|
||||
}
|
||||
.avatar{font-size:16px}
|
||||
.side-link{font-size:11px;font-weight:600;letter-spacing:.02em}
|
||||
.brand{
|
||||
display:flex;align-items:center;justify-content:center;min-height:36px;
|
||||
}
|
||||
.brand :deep(img.brand-logo){
|
||||
width:min(160px,54vw);height:auto;max-height:36px;
|
||||
object-fit:contain;
|
||||
}
|
||||
.tagline{
|
||||
text-align:center;margin:6px 0 0;font-size:12px;
|
||||
color:rgba(0,0,0,.38);letter-spacing:.12em;position:relative;z-index:2;
|
||||
}
|
||||
|
||||
/* 建档主卡 */
|
||||
.decode{
|
||||
margin:14px 16px 0;position:relative;z-index:2;
|
||||
padding:18px 16px 16px;background:#fff;border-radius:20px;
|
||||
box-shadow:0 8px 28px rgba(229,77,66,.12);text-align:center;
|
||||
animation:bdayIn .5s ease both;
|
||||
}
|
||||
.di-title{
|
||||
font-family:var(--font-display);
|
||||
font-size:17px;font-weight:700;color:#c8923a;letter-spacing:.1em;
|
||||
}
|
||||
.di-sub{font-size:12px;color:#aaa;margin:4px 0 14px}
|
||||
.di-inputs{display:flex;gap:8px;align-items:center;justify-content:center;flex-wrap:wrap}
|
||||
.di-inputs button{
|
||||
padding:11px 18px;border:none;border-radius:22px;color:#fff;font-size:14px;font-weight:600;
|
||||
background:linear-gradient(135deg,#ff7a6e,var(--yxg-pri));
|
||||
box-shadow:0 6px 16px rgba(229,77,66,.28);
|
||||
}
|
||||
.err{color:var(--yxg-pri);font-size:12px;margin-top:8px}
|
||||
.block{margin-top:22px;background:#fff;border-radius:18px;padding:16px;box-shadow:0 2px 10px rgba(0,0,0,.04)}
|
||||
.head{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}
|
||||
.head h3{font-size:16px}
|
||||
.head a{font-size:12px;color:#bbb}
|
||||
.grid{display:grid;grid-template-columns:repeat(3,1fr);gap:12px 8px}
|
||||
.cell{display:flex;flex-direction:column;align-items:center;gap:8px;font-size:11px;color:#555}
|
||||
.icon{
|
||||
width:52px;height:52px;border-radius:18px;display:flex;align-items:center;justify-content:center;font-size:20px;
|
||||
@keyframes bdayIn{
|
||||
from{opacity:0;transform:translateY(8px)}
|
||||
to{opacity:1;transform:translateY(0)}
|
||||
}
|
||||
|
||||
/* chips:sheet 外、暖色底上 */
|
||||
.chips{
|
||||
display:flex;gap:8px;padding:14px 16px 4px;
|
||||
overflow-x:auto;-webkit-overflow-scrolling:touch;scrollbar-width:none;
|
||||
position:relative;z-index:2;
|
||||
}
|
||||
.chips::-webkit-scrollbar{display:none}
|
||||
.chip{
|
||||
flex-shrink:0;padding:7px 14px;border-radius:999px;
|
||||
background:rgba(255,255,255,.72);border:1px solid rgba(255,255,255,.9);
|
||||
font-size:12px;color:#666;text-decoration:none;
|
||||
backdrop-filter:blur(6px);
|
||||
}
|
||||
.chip.on{background:#fff;color:var(--yxg-pri);font-weight:600;box-shadow:0 2px 8px rgba(0,0,0,.04)}
|
||||
|
||||
/* 白 sheet */
|
||||
.sheet{
|
||||
margin-top:14px;background:#fff;
|
||||
border-radius:22px 22px 0 0;
|
||||
padding:8px 0 28px;
|
||||
box-shadow:0 -4px 24px rgba(180,100,80,.06);
|
||||
position:relative;z-index:3;
|
||||
animation:sheetUp .55s cubic-bezier(.22,.8,.28,1) both;
|
||||
}
|
||||
@keyframes sheetUp{
|
||||
from{opacity:0;transform:translateY(24px)}
|
||||
to{opacity:1;transform:translateY(0)}
|
||||
}
|
||||
.section{padding:0 16px;margin-top:20px}
|
||||
.sec-head{
|
||||
display:flex;justify-content:space-between;align-items:baseline;margin-bottom:12px;
|
||||
}
|
||||
.sec-head .title{font-size:17px;font-weight:700;color:#222;letter-spacing:.02em}
|
||||
.sec-head .more{font-size:12px;color:#bbb;text-decoration:none}
|
||||
.sec-head .more::after{content:" ›"}
|
||||
|
||||
.test-grid{
|
||||
display:grid;grid-template-columns:repeat(4,1fr);gap:14px 8px;padding:4px 2px 2px;
|
||||
}
|
||||
.test-item{
|
||||
display:flex;flex-direction:column;align-items:center;gap:8px;
|
||||
color:#333;position:relative;
|
||||
}
|
||||
.test-item:active{transform:scale(.92)}
|
||||
.test-item .icon{
|
||||
width:54px;height:54px;border-radius:18px;
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
font-size:22px;box-shadow:inset 0 -2px 0 rgba(0,0,0,.03);
|
||||
}
|
||||
.test-item .label{
|
||||
font-size:11px;color:#555;text-align:center;line-height:1.25;
|
||||
max-width:68px;word-break:keep-all;
|
||||
}
|
||||
.badge{
|
||||
position:absolute;top:-4px;right:6px;
|
||||
font-size:9px;background:linear-gradient(135deg,#ff7a6e,var(--yxg-pri));
|
||||
color:#fff;padding:1px 5px;border-radius:6px;line-height:1.4;
|
||||
box-shadow:0 2px 6px rgba(229,77,66,.3);
|
||||
}
|
||||
|
||||
.feed{display:flex;flex-direction:column;gap:12px}
|
||||
.feed-card{
|
||||
display:flex;gap:14px;align-items:stretch;
|
||||
padding:12px;background:#fafafa;border-radius:16px;
|
||||
color:#333;position:relative;overflow:hidden;
|
||||
}
|
||||
.feed-card:active{transform:scale(.985);background:#f5f5f5}
|
||||
.feed-cover{
|
||||
width:72px;height:72px;border-radius:14px;flex-shrink:0;
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
font-size:28px;position:relative;overflow:hidden;
|
||||
}
|
||||
.feed-cover::after{
|
||||
content:"";position:absolute;inset:0;
|
||||
background:linear-gradient(160deg,rgba(255,255,255,.35),transparent 50%);
|
||||
}
|
||||
.feed-body{flex:1;min-width:0;display:flex;flex-direction:column;justify-content:center;padding:2px 0}
|
||||
.feed-body .name{font-size:15px;font-weight:650;color:#222;line-height:1.35}
|
||||
.feed-body .meta{font-size:12px;color:#999;margin-top:4px;line-height:1.4}
|
||||
.feed-body .stat{font-size:11px;color:#c4c4c4;margin-top:6px}
|
||||
.feed-tag{
|
||||
position:absolute;top:10px;right:10px;
|
||||
font-size:10px;color:#fff;background:var(--yxg-pri);
|
||||
padding:2px 7px;border-radius:8px;
|
||||
}
|
||||
.fc-a .feed-cover{background:linear-gradient(145deg,#ffd0c8,#ffb0a4);color:#c23b32}
|
||||
.fc-b .feed-cover{background:linear-gradient(145deg,#e4d8f8,#c9b6f0);color:#5b4a8a}
|
||||
.fc-c .feed-cover{background:linear-gradient(145deg,#d6e8ff,#b0d0f5);color:#3a6fb0}
|
||||
.fc-e .feed-cover{background:linear-gradient(145deg,#ffe6c8,#f5c98a);color:#b07a28}
|
||||
|
||||
.scroll-row{
|
||||
display:flex;gap:10px;overflow-x:auto;padding:2px 0 4px;
|
||||
-webkit-overflow-scrolling:touch;scrollbar-width:none;
|
||||
}
|
||||
.scroll-row::-webkit-scrollbar{display:none}
|
||||
.mini-card{
|
||||
flex:0 0 148px;padding:14px 14px 16px;border-radius:16px;color:#333;
|
||||
}
|
||||
.mini-card:active{transform:scale(.97)}
|
||||
.mini-card .mi{font-size:22px;margin-bottom:8px}
|
||||
.mini-card .mt{font-size:14px;font-weight:650}
|
||||
.mini-card .md{font-size:11px;color:rgba(0,0,0,.4);margin-top:3px;line-height:1.35}
|
||||
.mc-coral{background:linear-gradient(160deg,#ffe8e2,#ffd4ca)}
|
||||
.mc-mint{background:linear-gradient(160deg,#e4f6ea,#d0edd8)}
|
||||
.mc-sky{background:linear-gradient(160deg,#e6f0ff,#d4e4ff)}
|
||||
.mc-sand{background:linear-gradient(160deg,#fff3e0,#ffe4c2)}
|
||||
|
||||
@media (max-width:380px){
|
||||
.word{font-size:18px}
|
||||
.test-item .icon{width:48px;height:48px;font-size:20px;border-radius:16px}
|
||||
.test-item .label{font-size:10px}
|
||||
.feed-cover{width:64px;height:64px}
|
||||
}
|
||||
.api{font-size:13px;color:var(--yxg-sub)}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import ImageCardPage from './ImageCardPage.vue'
|
||||
|
||||
const { api } = vi.hoisted(() => ({
|
||||
api: {
|
||||
listImageCardScenes: vi.fn(),
|
||||
getImageCardQuota: vi.fn(),
|
||||
createProfile: vi.fn(),
|
||||
drawImageCard: vi.fn(),
|
||||
createOrder: vi.fn(),
|
||||
payMock: vi.fn(),
|
||||
getReport: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../api/client', () => ({ api }))
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => ({ query: {} }),
|
||||
useRouter: () => ({ back: vi.fn() }),
|
||||
}))
|
||||
|
||||
describe('ImageCardPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
api.listImageCardScenes.mockResolvedValue({
|
||||
items: [
|
||||
{ key: '情绪整理', label: '情绪整理' },
|
||||
{ key: '关系', label: '关系' },
|
||||
],
|
||||
})
|
||||
api.getImageCardQuota.mockResolvedValue({ remaining: 3, daily_free: 3, unlimited: false })
|
||||
})
|
||||
|
||||
it('boots scenes and draws a card', async () => {
|
||||
api.createProfile.mockResolvedValue({ id: 'p1' })
|
||||
api.drawImageCard.mockResolvedValue({
|
||||
scene: '情绪整理',
|
||||
quota_left: 2,
|
||||
cards: [
|
||||
{
|
||||
id: 'c01',
|
||||
title: '微光小路',
|
||||
imagery: '一条小路',
|
||||
prompt: '你想靠近什么?',
|
||||
tip: '走一小步',
|
||||
},
|
||||
],
|
||||
report: {
|
||||
id: 'r-card',
|
||||
has_deep_access: false,
|
||||
summary: { headline: '意象·探索' },
|
||||
detail: null,
|
||||
},
|
||||
})
|
||||
|
||||
const w = mount(ImageCardPage)
|
||||
await flushPromises()
|
||||
expect(w.text()).toContain('今日剩余')
|
||||
expect(w.text()).toContain('情绪整理')
|
||||
|
||||
const inputs = w.findAll('input[type="number"]')
|
||||
await inputs[0].setValue('1990')
|
||||
await inputs[1].setValue('5')
|
||||
await inputs[2].setValue('12')
|
||||
await w.findAll('button').find((b) => b.text().includes('抽取卡片'))!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(api.drawImageCard).toHaveBeenCalled()
|
||||
expect(w.text()).toContain('微光小路')
|
||||
expect(w.text()).toContain('走一小步')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,178 @@
|
||||
<template>
|
||||
<main class="yxg-page">
|
||||
<div class="yxg-hero">
|
||||
<BackButton />
|
||||
<PageTitle feature="cards" title="意象卡片" sub="选一个场景,抽取一张(或组合)卡片做反思练习" />
|
||||
</div>
|
||||
<div class="yxg-sheet sheet-pad">
|
||||
<p v-if="bootError" class="yxg-err">{{ bootError }}</p>
|
||||
<template v-else>
|
||||
<p class="yxg-meta quota">今日剩余 {{ quotaLabel }}</p>
|
||||
<div class="scenes">
|
||||
<button
|
||||
v-for="s in scenes"
|
||||
:key="s.key"
|
||||
type="button"
|
||||
class="yxg-chip yxg-chip-solid scene"
|
||||
:class="{ on: scene === s.key }"
|
||||
@click="selectScene(s.key)"
|
||||
><span class="si icon-teal" aria-hidden="true">◈</span>{{ s.label }}</button>
|
||||
</div>
|
||||
|
||||
<div class="yxg-card">
|
||||
<label class="yxg-label">生日(用于关联档案)</label>
|
||||
<BirthDateInputs v-model:year="year" v-model:month="month" v-model:day="day" compact />
|
||||
<label class="chk"><input v-model="wantDepth" type="checkbox" /> 请求三卡组合(会员直接解锁;否则可模拟支付)</label>
|
||||
<button class="yxg-btn yxg-btn-block" type="button" :disabled="drawing" @click="draw">
|
||||
{{ drawing ? '抽取中…' : '抽取卡片' }}
|
||||
</button>
|
||||
<p v-if="error" class="yxg-err">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<template v-if="cards.length">
|
||||
<article v-for="c in cards" :key="c.id" class="yxg-card">
|
||||
<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>
|
||||
</template>
|
||||
<p class="yxg-disc">意象卡片用于投射与自我反思,不判定好坏,也不预测未来。</p>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import type { GrowthReport } from '@yuxingu/types'
|
||||
import { validateBirth } from '@yuxingu/utils'
|
||||
import { api } from '../api/client'
|
||||
import BackButton from '../components/BackButton.vue'
|
||||
import BirthDateInputs from '../components/BirthDateInputs.vue'
|
||||
import PageTitle from '../components/PageTitle.vue'
|
||||
import ReportRich from '../components/ReportRich.vue'
|
||||
import { AnalyticsEvent, track } from '../lib/analytics'
|
||||
|
||||
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 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 selectScene(key: string) {
|
||||
scene.value = key
|
||||
track('cards_scene_selected', { scene: key })
|
||||
}
|
||||
|
||||
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
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
.quota{margin:0 0 10px}
|
||||
.scenes{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:12px}
|
||||
.scene{display:inline-flex;align-items:center;gap:6px}
|
||||
.si{
|
||||
width:22px;height:22px;border-radius:8px;display:inline-flex;align-items:center;justify-content:center;
|
||||
font-size:12px;box-shadow:inset 0 -1px 0 rgba(0,0,0,.03);
|
||||
}
|
||||
.yxg-card{margin:0 0 12px}
|
||||
.yxg-card .yxg-label:first-child{margin-top:0}
|
||||
:deep(.bd){margin:8px 0 12px}
|
||||
.chk{display:flex;align-items:center;gap:8px;font-size:13px;color:#666;margin:4px 0 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,40 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import LifeRhythmPage from './LifeRhythmPage.vue'
|
||||
|
||||
const { api } = vi.hoisted(() => ({
|
||||
api: {
|
||||
createProfile: vi.fn(),
|
||||
createRhythm: vi.fn(),
|
||||
getReport: vi.fn(),
|
||||
createOrder: vi.fn(),
|
||||
payMock: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../api/client', () => ({ api }))
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => ({ query: { y: '1992', m: '6', d: '8' } }),
|
||||
useRouter: () => ({ back: vi.fn() }),
|
||||
}))
|
||||
|
||||
describe('LifeRhythmPage', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('creates rhythm report from birth query', async () => {
|
||||
api.createProfile.mockResolvedValue({ id: 'p1' })
|
||||
api.createRhythm.mockResolvedValue({
|
||||
id: 'r-rhythm',
|
||||
type: 'rhythm',
|
||||
has_deep_access: false,
|
||||
summary: { headline: '节律标题', one_liner: '节律一句' },
|
||||
detail: null,
|
||||
})
|
||||
|
||||
const w = mount(LifeRhythmPage)
|
||||
await flushPromises()
|
||||
expect(api.createRhythm).toHaveBeenCalledWith('p1')
|
||||
expect(w.text()).toContain('节律标题')
|
||||
expect(w.text()).toContain('节气陪伴')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,140 @@
|
||||
<template>
|
||||
<main class="yxg-page">
|
||||
<div class="yxg-hero">
|
||||
<BackButton />
|
||||
<PageTitle feature="rhythm" title="身心节律" :sub="needBirth && !report ? '填写生日,生成身心节律生活建议' : undefined" />
|
||||
</div>
|
||||
<div class="yxg-sheet sheet-pad">
|
||||
<template v-if="needBirth && !report">
|
||||
<div class="yxg-card">
|
||||
<BirthDateInputs v-model:year="year" v-model:month="month" v-model:day="day" />
|
||||
<button class="yxg-btn yxg-btn-block mt" type="button" :disabled="loading" @click="start">开始</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="error" class="yxg-err">
|
||||
{{ error }}
|
||||
<button type="button" class="yxg-link" @click="load">重试</button>
|
||||
</p>
|
||||
<template v-else-if="report">
|
||||
<p class="yxg-meta head">{{ headline }}</p>
|
||||
<p v-if="oneLiner" class="yxg-lead">{{ oneLiner }}</p>
|
||||
<ReportRich :summary="summary" :detail="detail" :deep="!!report.has_deep_access" />
|
||||
<div v-if="!report.has_deep_access" class="yxg-card lock">
|
||||
<p>完整节律建议与练习可在深度版或成长会员中查看。</p>
|
||||
<button class="yxg-btn" type="button" :disabled="paying" @click="buyDeep">查看深度版(模拟支付)</button>
|
||||
</div>
|
||||
<router-link class="yxg-link-plain report-link" to="/companion">查看今日节气陪伴 →</router-link>
|
||||
<router-link v-if="report.id" class="yxg-link-plain report-link" :to="`/reports/${report.id}`">在报告页打开 →</router-link>
|
||||
</template>
|
||||
<p class="yxg-disc">本内容为自我探索与生活方式参考,不构成医疗建议,亦非占卜预测。</p>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import type { GrowthReport } from '@yuxingu/types'
|
||||
import { validateBirth } from '@yuxingu/utils'
|
||||
import { api } from '../api/client'
|
||||
import BackButton from '../components/BackButton.vue'
|
||||
import BirthDateInputs from '../components/BirthDateInputs.vue'
|
||||
import PageTitle from '../components/PageTitle.vue'
|
||||
import ReportRich from '../components/ReportRich.vue'
|
||||
import { AnalyticsEvent, track } from '../lib/analytics'
|
||||
|
||||
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 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 || ''))
|
||||
|
||||
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)
|
||||
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)
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
} 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)) {
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
.yxg-card{margin:8px 0 12px}
|
||||
.mt{margin-top:14px}
|
||||
.head{margin:4px 0 8px;font-size:13px}
|
||||
.lock .yxg-btn{margin-top:12px;width:100%}
|
||||
.report-link{display:block;margin-top:12px}
|
||||
</style>
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import MembershipPage from './MembershipPage.vue'
|
||||
|
||||
const { api } = vi.hoisted(() => ({
|
||||
api: {
|
||||
getMembership: vi.fn(),
|
||||
createOrder: vi.fn(),
|
||||
payMock: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../api/client', () => ({ api }))
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => ({ query: {} }),
|
||||
useRouter: () => ({ back: vi.fn() }),
|
||||
}))
|
||||
|
||||
describe('MembershipPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('shows subscribe CTA when inactive', async () => {
|
||||
api.getMembership.mockResolvedValue({ active: false, status: 'none' })
|
||||
const w = mount(MembershipPage)
|
||||
await flushPromises()
|
||||
expect(w.text()).toContain('尚未开通')
|
||||
expect(w.text()).toContain('开通月卡')
|
||||
})
|
||||
|
||||
it('activates membership after mock pay', async () => {
|
||||
api.getMembership
|
||||
.mockResolvedValueOnce({ active: false, status: 'none' })
|
||||
.mockResolvedValueOnce({
|
||||
active: true,
|
||||
status: 'active',
|
||||
plan: 'month',
|
||||
expires_at: '2099-01-01T00:00:00Z',
|
||||
ask_quota_left: 100,
|
||||
})
|
||||
api.createOrder.mockResolvedValue({ order_id: 'om1' })
|
||||
api.payMock.mockResolvedValue({ paid: true })
|
||||
|
||||
const w = mount(MembershipPage)
|
||||
await flushPromises()
|
||||
await w.findAll('button').find((b) => b.text().includes('开通月卡'))!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(api.createOrder).toHaveBeenCalledWith({ kind: 'membership', plan: 'month' })
|
||||
expect(w.text()).toContain('会员有效')
|
||||
expect(w.text()).toContain('月卡')
|
||||
})
|
||||
|
||||
it('shows error with retry', async () => {
|
||||
api.getMembership.mockRejectedValueOnce(new Error('网络错误'))
|
||||
const w = mount(MembershipPage)
|
||||
await flushPromises()
|
||||
expect(w.text()).toContain('网络错误')
|
||||
api.getMembership.mockResolvedValue({ active: false, status: 'none' })
|
||||
await w.find('button.link').trigger('click')
|
||||
await flushPromises()
|
||||
expect(w.text()).toContain('尚未开通')
|
||||
})
|
||||
})
|
||||
@@ -1,18 +1,94 @@
|
||||
<template>
|
||||
<main class="page">
|
||||
<button class="back" type="button" @click="$router.back()">← 返回</button>
|
||||
<h1>成长会员</h1>
|
||||
<p class="sub">完整分析 · AI成长助手更多次数 · 专属内容</p>
|
||||
<div class="card">
|
||||
月 / 季 / 年套餐与模拟支付将接入 /api/v1/orders。亦可在成长报告内选择「深度版」单次完整分析。
|
||||
<main class="yxg-page">
|
||||
<div class="yxg-hero">
|
||||
<BackButton />
|
||||
<PageTitle feature="membership" title="成长会员" sub="完整分析 · AI成长助手更多次数 · 专属内容" />
|
||||
</div>
|
||||
<div class="yxg-sheet sheet-pad">
|
||||
<p v-if="loading" class="yxg-hint">加载中…</p>
|
||||
<p v-else-if="error" class="yxg-err">
|
||||
{{ error }}
|
||||
<button type="button" class="yxg-link" @click="load">重试</button>
|
||||
</p>
|
||||
<template v-else>
|
||||
<div v-if="me?.active" class="yxg-card ok">
|
||||
<p class="ok-title">会员有效 · {{ planLabel }}</p>
|
||||
<p v-if="me.expires_at" class="yxg-meta">到期:{{ formatDate(me.expires_at) }}</p>
|
||||
<p class="yxg-meta">问答额度剩余:{{ me.ask_quota_left ?? 0 }}</p>
|
||||
</div>
|
||||
<div v-else class="yxg-card">
|
||||
<p>尚未开通成长会员。开通后可查看画像与关系理解的完整分析。</p>
|
||||
<button class="yxg-btn" type="button" :disabled="paying" @click="subscribe('month')">开通月卡(模拟支付)</button>
|
||||
<button class="yxg-btn yxg-btn-ghost" type="button" :disabled="paying" @click="subscribe('quarter')">季卡</button>
|
||||
<button class="yxg-btn yxg-btn-ghost" type="button" :disabled="paying" @click="subscribe('year')">年卡</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import type { MembershipMe } from '@yuxingu/types'
|
||||
import { api } from '../api/client'
|
||||
import BackButton from '../components/BackButton.vue'
|
||||
import PageTitle from '../components/PageTitle.vue'
|
||||
import { AnalyticsEvent, track } from '../lib/analytics'
|
||||
|
||||
const loading = ref(true)
|
||||
const paying = ref(false)
|
||||
const error = ref('')
|
||||
const me = ref<MembershipMe | null>(null)
|
||||
|
||||
const planLabel = computed(() => {
|
||||
const p = me.value?.plan
|
||||
if (p === 'quarter') return '季卡'
|
||||
if (p === 'year') return '年卡'
|
||||
if (p === 'month') return '月卡'
|
||||
return p || '成长会员'
|
||||
})
|
||||
|
||||
function formatDate(iso: string) {
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString('zh-CN')
|
||||
} catch {
|
||||
return iso
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
me.value = await api.getMembership()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function subscribe(plan: string) {
|
||||
paying.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const { order_id } = await api.createOrder({ kind: 'membership', plan })
|
||||
await api.payMock(order_id)
|
||||
me.value = await api.getMembership()
|
||||
track(AnalyticsEvent.PurchaseCompleted, { kind: 'membership' })
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '支付失败'
|
||||
} finally {
|
||||
paying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page{padding:16px}
|
||||
.back{border:none;background:#fff;border-radius:10px;padding:8px 12px;margin-bottom:12px}
|
||||
h1{font-size:22px}
|
||||
.sub{color:var(--yxg-sub);margin:8px 0 14px;font-size:13px}
|
||||
.card{background:#fff;border-radius:16px;padding:18px;font-size:14px;line-height:1.7;color:#555}
|
||||
.yxg-card{margin-top:8px}
|
||||
.ok{border:1px solid #c8e6c9;background:#f4fff4}
|
||||
.ok-title{font-size:15px;font-weight:650;color:#222;margin-bottom:6px}
|
||||
.yxg-btn{margin-top:12px;margin-right:8px}
|
||||
</style>
|
||||
|
||||
@@ -1,20 +1,98 @@
|
||||
<template>
|
||||
<main class="page">
|
||||
<h1>我的</h1>
|
||||
<p class="sub">个人档案 · 成长报告 · 成长会员</p>
|
||||
<ul class="list">
|
||||
<li><router-link to="/profile">个人档案</router-link></li>
|
||||
<li><router-link to="/portrait">个人画像</router-link></li>
|
||||
<li><router-link to="/relation">关系理解</router-link></li>
|
||||
<li><router-link to="/membership">成长会员</router-link></li>
|
||||
</ul>
|
||||
<main class="yxg-page">
|
||||
<div class="yxg-hero">
|
||||
<PageTitle feature="profile" title="我的" sub="个人档案 · 成长报告 · 成长会员" />
|
||||
</div>
|
||||
|
||||
<div class="yxg-sheet">
|
||||
<p v-if="loading" class="yxg-hint pad">加载中…</p>
|
||||
<p v-else-if="error" class="yxg-err pad">
|
||||
{{ error }}
|
||||
<button type="button" class="yxg-link" @click="load">重试</button>
|
||||
</p>
|
||||
<template v-else>
|
||||
<div class="yxg-card member">
|
||||
<p class="yxg-chip-ico">
|
||||
<span class="yxg-mark icon-purple tiny" aria-hidden="true">⑨</span>
|
||||
<span class="member-text">
|
||||
<strong v-if="membership?.active">成长会员有效 · {{ planLabel }}</strong>
|
||||
<strong v-else>尚未开通成长会员</strong>
|
||||
<span class="yxg-meta">档案 {{ profileCount }} 份</span>
|
||||
</span>
|
||||
</p>
|
||||
<router-link class="yxg-btn" to="/membership">
|
||||
{{ membership?.active ? '查看会员' : '开通成长会员' }}
|
||||
</router-link>
|
||||
</div>
|
||||
<ul class="yxg-list">
|
||||
<li v-for="item in links" :key="item.to">
|
||||
<router-link :to="item.to">
|
||||
<span class="yxg-mark" :class="'icon-' + item.tone" aria-hidden="true">{{ item.icon }}</span>
|
||||
<span class="yxg-body"><strong>{{ item.label }}</strong></span>
|
||||
<span class="chev" aria-hidden="true">›</span>
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import type { MembershipMe } from '@yuxingu/types'
|
||||
import { api } from '../api/client'
|
||||
import PageTitle from '../components/PageTitle.vue'
|
||||
import type { IconTone } from '../lib/featureIcons'
|
||||
|
||||
const links: { to: string; icon: string; label: string; tone: IconTone }[] = [
|
||||
{ to: '/profile', icon: '◍', label: '个人档案', tone: 'mute' },
|
||||
{ to: '/reports', icon: '▣', label: '我的成长报告', tone: 'orange' },
|
||||
{ to: '/', icon: '△', label: '个人画像', tone: 'gold' },
|
||||
{ to: '/star', icon: '✦', label: '星象性格', tone: 'night' },
|
||||
{ to: '/rhythm', icon: '☯', label: '身心节律', tone: 'green' },
|
||||
{ to: '/cards', icon: '◈', label: '意象卡片', tone: 'teal' },
|
||||
{ to: '/relation', icon: '♡', label: '关系理解', tone: 'rose' },
|
||||
{ to: '/ask', icon: '◎', label: 'AI 成长助手', tone: 'gold' },
|
||||
{ to: '/explore', icon: '◆', label: '探索测试', tone: 'blue' },
|
||||
{ to: '/membership', icon: '⑨', label: '成长会员', tone: 'purple' },
|
||||
]
|
||||
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const membership = ref<MembershipMe | null>(null)
|
||||
const profileCount = ref(0)
|
||||
|
||||
const planLabel = computed(() => {
|
||||
const p = membership.value?.plan
|
||||
if (p === 'quarter') return '季卡'
|
||||
if (p === 'year') return '年卡'
|
||||
if (p === 'month') return '月卡'
|
||||
return '成长会员'
|
||||
})
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const [m, profiles] = await Promise.all([api.getMembership(), api.listProfiles()])
|
||||
membership.value = m
|
||||
profileCount.value = (profiles.items || []).length
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page{padding:24px 16px}
|
||||
h1{font-size:22px}
|
||||
.sub{color:var(--yxg-sub);font-size:13px;margin:6px 0 16px}
|
||||
.list{background:#fff;border-radius:16px;padding:8px 16px;line-height:2.2;font-size:14px;color:#555}
|
||||
.list a{color:inherit;text-decoration:none}
|
||||
.pad{padding:8px 16px}
|
||||
.member{display:flex;flex-direction:column;gap:12px;align-items:flex-start}
|
||||
.member-text{display:flex;flex-direction:column;gap:2px}
|
||||
.member-text strong{font-size:15px;color:#222;font-weight:650}
|
||||
.tiny{width:36px!important;height:36px!important;font-size:16px!important;border-radius:12px!important}
|
||||
.yxg-btn{margin-top:2px}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import PortraitPage from './PortraitPage.vue'
|
||||
|
||||
const { api, routeQuery } = vi.hoisted(() => ({
|
||||
api: {
|
||||
createProfile: vi.fn(),
|
||||
createPortrait: vi.fn(),
|
||||
getReport: vi.fn(),
|
||||
createOrder: vi.fn(),
|
||||
payMock: vi.fn(),
|
||||
},
|
||||
routeQuery: { y: '', m: '', d: '' } as Record<string, string>,
|
||||
}))
|
||||
|
||||
vi.mock('../api/client', () => ({ api }))
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => ({ query: routeQuery }),
|
||||
useRouter: () => ({ back: vi.fn() }),
|
||||
RouterLink: {
|
||||
name: 'RouterLink',
|
||||
props: ['to'],
|
||||
template: '<a><slot /></a>',
|
||||
},
|
||||
}))
|
||||
|
||||
const freeSummary = {
|
||||
headline: '小愈是9号人 · 博爱大器·成功者',
|
||||
main_number: 9,
|
||||
person_title: '博爱大器·成功者',
|
||||
one_liner: '你身上有一种让人想靠近的魔力',
|
||||
pyramid: { I: 3, J: 5, K: 1, L: 9, M: 8, N: 1, O: 9 },
|
||||
wuxing: {
|
||||
primary: '湿热质',
|
||||
care_dir: '养心 · 需呵护肺',
|
||||
strong: '心',
|
||||
weak: '肺',
|
||||
emotion: ['热情', '感染力'],
|
||||
bars: [
|
||||
{ el: '木', pct: 25, color: '#5CB85C' },
|
||||
{ el: '火', pct: 50, color: '#E54D42' },
|
||||
{ el: '土', pct: 13, color: '#E8985A' },
|
||||
{ el: '金', pct: 13, color: '#C8923A' },
|
||||
{ el: '水', pct: 0, color: '#4A90E2' },
|
||||
],
|
||||
},
|
||||
today_tip: { solar_term: '立秋', season_el: '火', yi: '午休', ji: '暴晒' },
|
||||
keywords: ['9号人'],
|
||||
lock_teaser_number: '天赋优势占位',
|
||||
lock_teaser_wuxing: '体质占位',
|
||||
}
|
||||
|
||||
describe('PortraitPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
routeQuery.y = ''
|
||||
routeQuery.m = ''
|
||||
routeQuery.d = ''
|
||||
})
|
||||
|
||||
it('shows birth form when opened without query', async () => {
|
||||
const w = mount(PortraitPage, { global: { stubs: { RouterLink: true } } })
|
||||
await flushPromises()
|
||||
expect(w.text()).toContain('开始解码')
|
||||
expect(w.text()).not.toContain('请从首页')
|
||||
expect(w.text()).not.toContain('去首页')
|
||||
})
|
||||
|
||||
it('shows free pyramid and main number; hides deep copy without entitlement', async () => {
|
||||
routeQuery.y = '1990'
|
||||
routeQuery.m = '5'
|
||||
routeQuery.d = '12'
|
||||
api.createProfile.mockResolvedValue({ id: 'p1' })
|
||||
api.createPortrait.mockResolvedValue({
|
||||
id: 'r1',
|
||||
has_deep_access: false,
|
||||
summary: freeSummary,
|
||||
detail: null,
|
||||
})
|
||||
const w = mount(PortraitPage, { global: { stubs: { RouterLink: true } } })
|
||||
await flushPromises()
|
||||
expect(w.text()).toContain('9号人')
|
||||
expect(w.text()).toContain('博爱大器·成功者')
|
||||
expect(w.text()).toContain('湿热质')
|
||||
expect(w.text()).toContain('今日养生锦囊')
|
||||
expect(w.text()).toContain('解锁完整分析')
|
||||
expect(w.text()).not.toContain('自信果敢、创造力爆棚')
|
||||
expect(w.text()).not.toContain('体型偏瘦或适中,面色偏青黄')
|
||||
})
|
||||
|
||||
it('generates from form submit', async () => {
|
||||
api.createProfile.mockResolvedValue({ id: 'p1' })
|
||||
api.createPortrait.mockResolvedValue({
|
||||
id: 'r1',
|
||||
has_deep_access: false,
|
||||
summary: freeSummary,
|
||||
detail: null,
|
||||
})
|
||||
|
||||
const w = mount(PortraitPage, { global: { stubs: { RouterLink: true } } })
|
||||
await flushPromises()
|
||||
const inputs = w.findAll('input')
|
||||
await inputs[0].setValue('1990')
|
||||
await inputs[1].setValue('5')
|
||||
await inputs[2].setValue('12')
|
||||
await w.findAll('button').find((b) => b.text().includes('开始解码'))!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(api.createPortrait).toHaveBeenCalledWith('p1')
|
||||
expect(w.text()).toContain('9号人')
|
||||
expect(w.text()).toContain('解锁完整分析')
|
||||
})
|
||||
|
||||
it('unlocks talent and constitution deep after mock deep_access pay', async () => {
|
||||
routeQuery.y = '1990'
|
||||
routeQuery.m = '5'
|
||||
routeQuery.d = '12'
|
||||
api.createProfile.mockResolvedValue({ id: 'p1' })
|
||||
api.createPortrait.mockResolvedValue({
|
||||
id: 'r1',
|
||||
has_deep_access: false,
|
||||
summary: freeSummary,
|
||||
detail: null,
|
||||
})
|
||||
api.createOrder.mockResolvedValue({ order_id: 'o1' })
|
||||
api.payMock.mockResolvedValue({ paid: true })
|
||||
api.getReport.mockResolvedValue({
|
||||
id: 'r1',
|
||||
has_deep_access: true,
|
||||
summary: freeSummary,
|
||||
detail: {
|
||||
number_deep: {
|
||||
talent: '慈悲大爱、机会磁铁',
|
||||
topic: '贪多嚼不烂',
|
||||
direction: '慈善家、艺人',
|
||||
desc: '你身上有一种让人想靠近的魔力。完整能量长文。',
|
||||
joints: [],
|
||||
missing: [],
|
||||
},
|
||||
constitution_deep: {
|
||||
body: '面色偏红,手心脚心热乎乎的',
|
||||
tips: '清心降火、静养心神',
|
||||
risk: '失眠、口腔溃疡反复',
|
||||
season_risk: '夏天最敏感',
|
||||
food: '苦味清心',
|
||||
rhythm: '午时小憩',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const w = mount(PortraitPage, { global: { stubs: { RouterLink: true } } })
|
||||
await flushPromises()
|
||||
await w.findAll('button').find((b) => b.text().includes('解锁完整分析'))!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(api.createOrder).toHaveBeenCalledWith({ kind: 'deep_access', report_id: 'r1' })
|
||||
expect(w.text()).toContain('慈悲大爱、机会磁铁')
|
||||
expect(w.text()).toContain('面色偏红,手心脚心热乎乎的')
|
||||
expect(w.text()).toContain('清心降火、静养心神')
|
||||
})
|
||||
})
|
||||
@@ -1,31 +1,46 @@
|
||||
<template>
|
||||
<main class="page">
|
||||
<button class="back" type="button" @click="$router.back()">← 返回</button>
|
||||
<h1>个人画像</h1>
|
||||
<p v-if="loading" class="sub">正在生成…</p>
|
||||
<p v-else-if="error" class="err">{{ error }}</p>
|
||||
<template v-else-if="report">
|
||||
<p class="sub">{{ headline }}</p>
|
||||
<div class="card">
|
||||
<p class="line">{{ oneLiner }}</p>
|
||||
<p class="tip">生活建议:{{ lifeTip }}</p>
|
||||
<div v-if="keywords.length" class="tags">
|
||||
<span v-for="k in keywords" :key="k">{{ k }}</span>
|
||||
<main class="yxg-page decode-page">
|
||||
<div class="yxg-hero">
|
||||
<BackButton />
|
||||
<PageTitle
|
||||
feature="portrait"
|
||||
title="愈心解码"
|
||||
:sub="needBirth && !report ? '填写生日,生成身心解码报告' : '一个生日,读懂身与心'"
|
||||
/>
|
||||
</div>
|
||||
<div class="yxg-sheet sheet-pad">
|
||||
<template v-if="needBirth && !report">
|
||||
<div class="yxg-card">
|
||||
<BirthDateInputs v-model:year="year" v-model:month="month" v-model:day="day" />
|
||||
<button class="yxg-btn yxg-btn-block mt" type="button" :disabled="loading" @click="start">
|
||||
{{ loading ? '解码中…' : '开始解码' }}
|
||||
</button>
|
||||
<p v-if="formError" class="yxg-err">{{ formError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="report.has_deep_access && detail" class="card deep">
|
||||
<h2>完整分析</h2>
|
||||
<p><strong>行为模式</strong> {{ detail.behavior_pattern }}</p>
|
||||
<p><strong>关系特点</strong> {{ detail.relation_style }}</p>
|
||||
<p><strong>成长方向</strong> {{ detail.growth_direction }}</p>
|
||||
</div>
|
||||
<div v-else class="card lock">
|
||||
<p>完整分析、行为模式与成长方向可在深度版或成长会员中查看。</p>
|
||||
<button type="button" :disabled="paying" @click="buyDeep">查看深度版(模拟支付)</button>
|
||||
</div>
|
||||
</template>
|
||||
<p class="disc">本内容为自我探索与生活方式参考,不构成医疗建议,亦非占卜预测。</p>
|
||||
</template>
|
||||
<p v-else-if="loading" class="yxg-hint">正在解码…</p>
|
||||
<p v-else-if="error" class="yxg-err">
|
||||
{{ error }}
|
||||
<button type="button" class="yxg-link" @click="retry">重试</button>
|
||||
</p>
|
||||
<template v-else-if="report">
|
||||
<DecodePanel
|
||||
:summary="summary"
|
||||
:detail="detail"
|
||||
:deep="!!report.has_deep_access"
|
||||
:paying="paying"
|
||||
@unlock="buyDeep"
|
||||
/>
|
||||
<p v-if="error" class="yxg-err">{{ error }}</p>
|
||||
<div class="links">
|
||||
<router-link class="yxg-link-plain" to="/relation">去做人格匹配 →</router-link>
|
||||
<router-link v-if="report.id" class="yxg-link-plain" :to="`/reports/${report.id}`">在报告页打开 →</router-link>
|
||||
</div>
|
||||
<button type="button" class="yxg-btn yxg-btn-ghost share-btn" @click="shareOpen = true">生成分享卡</button>
|
||||
</template>
|
||||
<p class="yxg-disc">自我探索与生活方式参考,不是占卜或算命。体质内容非医疗建议。</p>
|
||||
</div>
|
||||
<ShareSheet :open="shareOpen" :payload="sharePayload" @close="shareOpen = false" />
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -33,55 +48,123 @@
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import type { GrowthReport } from '@yuxingu/types'
|
||||
import { validateBirth } from '@yuxingu/utils'
|
||||
import { api } from '../api/client'
|
||||
import BackButton from '../components/BackButton.vue'
|
||||
import BirthDateInputs from '../components/BirthDateInputs.vue'
|
||||
import DecodePanel from '../components/DecodePanel.vue'
|
||||
import PageTitle from '../components/PageTitle.vue'
|
||||
import ShareSheet from '../components/ShareSheet.vue'
|
||||
import { AnalyticsEvent, track } from '../lib/analytics'
|
||||
import type { PortraitSharePayload } from '../lib/shareLink'
|
||||
|
||||
const route = useRoute()
|
||||
const loading = ref(true)
|
||||
const loading = ref(false)
|
||||
const needBirth = ref(true)
|
||||
const error = ref('')
|
||||
const formError = ref('')
|
||||
const report = ref<GrowthReport | null>(null)
|
||||
const paying = ref(false)
|
||||
const shareOpen = ref(false)
|
||||
const year = ref(String(route.query.y || ''))
|
||||
const month = ref(String(route.query.m || ''))
|
||||
const day = ref(String(route.query.d || ''))
|
||||
|
||||
const summary = computed(() => (report.value?.summary || {}) as Record<string, unknown>)
|
||||
const detail = computed(() => (report.value?.detail || null) as Record<string, unknown> | null)
|
||||
const headline = computed(() => String(summary.value.headline || ''))
|
||||
const oneLiner = computed(() => String(summary.value.one_liner || ''))
|
||||
const lifeTip = computed(() => String(summary.value.life_tip || ''))
|
||||
const keywords = computed(() => (Array.isArray(summary.value.keywords) ? summary.value.keywords as string[] : []))
|
||||
const keywords = computed(() => (Array.isArray(summary.value.keywords) ? (summary.value.keywords as string[]) : []))
|
||||
const sharePayload = computed<PortraitSharePayload | null>(() => {
|
||||
if (!report.value) return null
|
||||
return {
|
||||
type: 'portrait',
|
||||
title: headline.value || String(summary.value.person_title || '愈心解码'),
|
||||
line: oneLiner.value,
|
||||
keywords: keywords.value,
|
||||
}
|
||||
})
|
||||
|
||||
async function load() {
|
||||
async function generate(y: number, m: number, d: number) {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
formError.value = ''
|
||||
needBirth.value = false
|
||||
try {
|
||||
const reportId = String(route.query.report_id || '')
|
||||
if (reportId) {
|
||||
report.value = await api.getReport(reportId)
|
||||
return
|
||||
}
|
||||
const y = Number(route.query.y)
|
||||
const m = Number(route.query.m)
|
||||
const d = Number(route.query.d)
|
||||
if (!y || !m || !d) {
|
||||
error.value = '请从首页填写生日后进入'
|
||||
return
|
||||
}
|
||||
const birth = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
|
||||
const profile = await api.createProfile({ relation: 'self', birth_date: birth, display_name: '我' })
|
||||
report.value = await api.createPortrait(profile.id)
|
||||
track(AnalyticsEvent.PortraitCompleted, { source: 'form' })
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
needBirth.value = true
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function start() {
|
||||
const y = Number(year.value)
|
||||
const m = Number(month.value)
|
||||
const d = Number(day.value)
|
||||
const msg = validateBirth(y, m, d)
|
||||
if (msg) {
|
||||
formError.value = msg
|
||||
return
|
||||
}
|
||||
await generate(y, m, d)
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const reportId = String(route.query.report_id || '')
|
||||
if (reportId) {
|
||||
loading.value = true
|
||||
needBirth.value = false
|
||||
error.value = ''
|
||||
try {
|
||||
report.value = await api.getReport(reportId)
|
||||
track(AnalyticsEvent.PortraitCompleted, { source: 'report_id' })
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
needBirth.value = true
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
return
|
||||
}
|
||||
const y = Number(route.query.y)
|
||||
const m = Number(route.query.m)
|
||||
const d = Number(route.query.d)
|
||||
if (y && m && d && !validateBirth(y, m, d)) {
|
||||
year.value = String(y)
|
||||
month.value = String(m)
|
||||
day.value = String(d)
|
||||
await generate(y, m, d)
|
||||
return
|
||||
}
|
||||
needBirth.value = true
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
function retry() {
|
||||
if (report.value) {
|
||||
load()
|
||||
return
|
||||
}
|
||||
needBirth.value = true
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
async function buyDeep() {
|
||||
if (!report.value) return
|
||||
paying.value = true
|
||||
error.value = ''
|
||||
track(AnalyticsEvent.DeepAccessClicked, { surface: 'portrait' })
|
||||
try {
|
||||
const { order_id } = await api.createOrder({ kind: 'deep_access', report_id: report.value.id })
|
||||
await api.payMock(order_id)
|
||||
report.value = await api.getReport(report.value.id)
|
||||
track(AnalyticsEvent.PurchaseCompleted, { kind: 'deep_access' })
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '支付失败'
|
||||
} finally {
|
||||
@@ -93,22 +176,17 @@ onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page{padding:16px}
|
||||
.back{border:none;background:#fff;border-radius:10px;padding:8px 12px;margin-bottom:12px}
|
||||
h1{font-size:22px}
|
||||
h2{font-size:16px;margin-bottom:8px}
|
||||
.sub{color:var(--yxg-sub);margin:8px 0 14px;font-size:13px}
|
||||
.err{color:var(--yxg-pri);font-size:13px;margin:8px 0}
|
||||
.card{background:#fff;border-radius:16px;padding:18px;font-size:14px;line-height:1.7;color:#555;margin-bottom:12px}
|
||||
.line{font-size:15px;color:#333}
|
||||
.tip{margin-top:8px;color:#666}
|
||||
.tags{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px}
|
||||
.tags span{background:var(--yxg-bg-start,#ffe4e4);color:var(--yxg-pri);padding:4px 10px;border-radius:999px;font-size:12px}
|
||||
.lock button{
|
||||
margin-top:12px;padding:10px 16px;border:none;border-radius:22px;color:#fff;font-weight:600;
|
||||
background:linear-gradient(135deg,#ff7a6e,var(--yxg-pri));
|
||||
.mt {
|
||||
margin-top: 14px;
|
||||
}
|
||||
.share-btn {
|
||||
margin-top: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
.links {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin: 4px 0 12px;
|
||||
}
|
||||
.lock button:disabled{opacity:.6}
|
||||
.disc{font-size:11px;color:#bbb;margin-top:16px;line-height:1.5}
|
||||
.deep p{margin-top:8px}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import ProfilePage from './ProfilePage.vue'
|
||||
|
||||
const { api } = vi.hoisted(() => ({
|
||||
api: {
|
||||
listProfiles: vi.fn(),
|
||||
updateProfile: vi.fn(),
|
||||
deleteProfile: vi.fn(),
|
||||
createProfile: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../api/client', () => ({ api }))
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => ({ query: {} }),
|
||||
useRouter: () => ({ back: vi.fn() }),
|
||||
}))
|
||||
|
||||
const RouterLinkStub = defineComponent({
|
||||
props: ['to'],
|
||||
setup(props, { slots }) {
|
||||
return () => h('a', { href: String(props.to) }, slots.default?.())
|
||||
},
|
||||
})
|
||||
|
||||
describe('ProfilePage', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('shows empty state', async () => {
|
||||
api.listProfiles.mockResolvedValue({ items: [] })
|
||||
const w = mount(ProfilePage, { global: { stubs: { RouterLink: RouterLinkStub } } })
|
||||
await flushPromises()
|
||||
expect(w.text()).toContain('暂无档案')
|
||||
})
|
||||
|
||||
it('lists profiles and opens edit', async () => {
|
||||
api.listProfiles.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: 'p1',
|
||||
relation: 'self',
|
||||
display_name: '我',
|
||||
birth_date: '1990-05-12',
|
||||
user_id: 'u',
|
||||
created_at: '',
|
||||
},
|
||||
],
|
||||
})
|
||||
const w = mount(ProfilePage, { global: { stubs: { RouterLink: RouterLinkStub } } })
|
||||
await flushPromises()
|
||||
expect(w.text()).toContain('1990-05-12')
|
||||
await w.findAll('button').find((b) => b.text() === '编辑')!.trigger('click')
|
||||
expect(w.text()).toContain('编辑档案')
|
||||
})
|
||||
|
||||
it('shows error with retry', async () => {
|
||||
api.listProfiles.mockRejectedValueOnce(new Error('网络错误'))
|
||||
const w = mount(ProfilePage, { global: { stubs: { RouterLink: RouterLinkStub } } })
|
||||
await flushPromises()
|
||||
expect(w.text()).toContain('网络错误')
|
||||
api.listProfiles.mockResolvedValue({ items: [] })
|
||||
await w.find('button.linkbtn').trigger('click')
|
||||
await flushPromises()
|
||||
expect(w.text()).toContain('暂无档案')
|
||||
})
|
||||
})
|
||||
@@ -1,19 +1,87 @@
|
||||
<template>
|
||||
<main class="page">
|
||||
<button class="back" type="button" @click="$router.back()">← 返回</button>
|
||||
<h1>个人档案</h1>
|
||||
<p class="sub">管理我的信息与重要的人</p>
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
<ul v-if="items.length" class="list">
|
||||
<li v-for="p in items" :key="p.id">
|
||||
<strong>{{ p.display_name || (p.relation === 'self' ? '我' : 'TA') }}</strong>
|
||||
· {{ p.relation === 'self' ? '我' : '关系对象' }}
|
||||
· {{ formatDate(p.birth_date) }}
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else class="card">暂无档案。请先去首页完成性格探索。</div>
|
||||
<router-link class="link" to="/">去性格探索 →</router-link>
|
||||
<router-link class="link" to="/relation">去关系理解 →</router-link>
|
||||
<main class="yxg-page">
|
||||
<div class="yxg-hero">
|
||||
<BackButton />
|
||||
<PageTitle feature="profile" title="个人档案" sub="管理我的信息与重要的人" />
|
||||
</div>
|
||||
<div class="yxg-sheet sheet-pad">
|
||||
<p v-if="loading" class="yxg-hint">加载中…</p>
|
||||
<p v-else-if="error" class="yxg-err">
|
||||
{{ error }}
|
||||
<button type="button" class="yxg-link" @click="load">重试</button>
|
||||
</p>
|
||||
<template v-else>
|
||||
<div v-if="!items.length" class="yxg-card empty">
|
||||
<p>暂无档案。请先去首页完成性格探索,或添加重要的人。</p>
|
||||
<router-link class="yxg-btn" to="/">去性格探索</router-link>
|
||||
</div>
|
||||
|
||||
<ul v-else class="yxg-list">
|
||||
<li v-for="p in items" :key="p.id">
|
||||
<div class="row">
|
||||
<div>
|
||||
<strong>{{ p.display_name || (p.relation === 'self' ? '我' : 'TA') }}</strong>
|
||||
<span class="meta">
|
||||
{{ p.relation === 'self' ? '我' : relationLabel(p.relation_type) }}
|
||||
· {{ formatDate(p.birth_date) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="ops">
|
||||
<button type="button" class="yxg-btn" @click="startEdit(p)">编辑</button>
|
||||
<button type="button" class="yxg-btn yxg-btn-ghost danger" :disabled="busyId === p.id" @click="remove(p)">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div v-if="editing" class="yxg-card form">
|
||||
<h2 class="card-title">编辑档案</h2>
|
||||
<label class="yxg-label">称呼</label>
|
||||
<input class="yxg-input" v-model="formName" />
|
||||
<label class="yxg-label">生日</label>
|
||||
<BirthDateInputs v-model:year="formY" v-model:month="formM" v-model:day="formD" compact />
|
||||
<label class="yxg-label">出生地(可选,省 / 市 / 区县)</label>
|
||||
<BirthPlaceSelect v-model="formPlace" />
|
||||
<label v-if="editing.relation === 'other'" class="yxg-label">关系类型</label>
|
||||
<select v-if="editing.relation === 'other'" class="yxg-select" v-model="formRelationType">
|
||||
<option value="partner">伴侣</option>
|
||||
<option value="friend">朋友</option>
|
||||
<option value="family">家人</option>
|
||||
<option value="colleague">同事</option>
|
||||
<option value="other">其他</option>
|
||||
</select>
|
||||
<label v-if="editing.relation === 'self'" class="check">
|
||||
<input v-model="formGeoVisible" type="checkbox" />
|
||||
位置可见(开启后可出现在他人「附近的人」合盘列表;默认关闭)
|
||||
</label>
|
||||
<p v-if="formError" class="yxg-err">{{ formError }}</p>
|
||||
<div class="form-actions">
|
||||
<button class="yxg-btn" type="button" :disabled="saving" @click="saveEdit">保存</button>
|
||||
<button class="yxg-btn yxg-btn-ghost" type="button" @click="editing = null">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="yxg-card form">
|
||||
<h2 class="card-title">添加重要的人</h2>
|
||||
<label class="yxg-label">称呼</label>
|
||||
<input class="yxg-input" v-model="newName" placeholder="例如:伴侣" />
|
||||
<label class="yxg-label">生日</label>
|
||||
<BirthDateInputs v-model:year="newY" v-model:month="newM" v-model:day="newD" compact />
|
||||
<label class="yxg-label">关系类型</label>
|
||||
<select class="yxg-select" v-model="newRelationType">
|
||||
<option value="partner">伴侣</option>
|
||||
<option value="friend">朋友</option>
|
||||
<option value="family">家人</option>
|
||||
<option value="colleague">同事</option>
|
||||
<option value="other">其他</option>
|
||||
</select>
|
||||
<p v-if="addError" class="yxg-err">{{ addError }}</p>
|
||||
<button class="yxg-btn" type="button" :disabled="adding" @click="addOther">添加</button>
|
||||
</div>
|
||||
|
||||
<router-link class="yxg-link-plain link" to="/relation">去做关系理解 →</router-link>
|
||||
</template>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -21,31 +89,173 @@
|
||||
import { onMounted, ref } from 'vue'
|
||||
import type { Profile } from '@yuxingu/types'
|
||||
import { api } from '../api/client'
|
||||
import BackButton from '../components/BackButton.vue'
|
||||
import BirthDateInputs from '../components/BirthDateInputs.vue'
|
||||
import BirthPlaceSelect from '../components/BirthPlaceSelect.vue'
|
||||
import PageTitle from '../components/PageTitle.vue'
|
||||
|
||||
const items = ref<Profile[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const busyId = ref('')
|
||||
const editing = ref<Profile | null>(null)
|
||||
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 newRelationType = ref('partner')
|
||||
const addError = ref('')
|
||||
const adding = ref(false)
|
||||
|
||||
function formatDate(v: string) {
|
||||
return (v || '').slice(0, 10)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
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 || ''] || '关系对象'
|
||||
}
|
||||
|
||||
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
|
||||
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,
|
||||
})
|
||||
newName.value = ''
|
||||
newY.value = ''
|
||||
newM.value = ''
|
||||
newD.value = ''
|
||||
await load()
|
||||
} catch (e) {
|
||||
addError.value = e instanceof Error ? e.message : '添加失败'
|
||||
} finally {
|
||||
adding.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page{padding:16px}
|
||||
.back{border:none;background:#fff;border-radius:10px;padding:8px 12px;margin-bottom:12px}
|
||||
h1{font-size:22px}
|
||||
.sub{color:var(--yxg-sub);margin:8px 0 14px;font-size:13px}
|
||||
.err{color:var(--yxg-pri);font-size:13px}
|
||||
.card{background:#fff;border-radius:16px;padding:18px;font-size:14px;color:#555}
|
||||
.list{background:#fff;border-radius:16px;padding:8px 16px;line-height:2.1;font-size:14px;color:#555;margin-bottom:12px}
|
||||
.link{display:block;margin-top:12px;font-size:14px;color:var(--yxg-pri)}
|
||||
.yxg-card{margin-bottom:12px}
|
||||
.empty .yxg-btn{margin-top:10px}
|
||||
.yxg-list{margin-bottom:12px}
|
||||
.row{display:flex;justify-content:space-between;gap:10px;align-items:flex-start;width:100%}
|
||||
.meta{display:block;font-size:12px;color:#999;margin-top:2px}
|
||||
.ops{display:flex;gap:6px;flex-shrink:0}
|
||||
.ops .yxg-btn{padding:6px 12px;font-size:12px;box-shadow:none}
|
||||
.form .yxg-label:first-of-type{margin-top:0}
|
||||
.form :deep(.bd){margin:4px 0 10px}
|
||||
.form-actions{display:flex;gap:8px;margin-top:12px}
|
||||
.form > .yxg-btn{margin-top:12px}
|
||||
.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}
|
||||
.link{display:block;margin-top:8px}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import RelationPage from './RelationPage.vue'
|
||||
|
||||
const { api } = vi.hoisted(() => ({
|
||||
api: {
|
||||
listProfiles: vi.fn(),
|
||||
createProfile: vi.fn(),
|
||||
createRelationInsight: vi.fn(),
|
||||
createOrder: vi.fn(),
|
||||
payMock: vi.fn(),
|
||||
getReport: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../api/client', () => ({ api }))
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => ({ query: {} }),
|
||||
useRouter: () => ({ back: vi.fn() }),
|
||||
}))
|
||||
|
||||
describe('RelationPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('generates relation insight summary and gates tips', async () => {
|
||||
api.listProfiles.mockResolvedValue({
|
||||
items: [{ id: 'self1', relation: 'self', display_name: '我', birth_date: '1990-01-01' }],
|
||||
})
|
||||
api.createProfile.mockResolvedValue({ id: 'other1', relation: 'other' })
|
||||
api.createRelationInsight.mockResolvedValue({
|
||||
insight: { id: 'i1', report_id: 'rr1' },
|
||||
report: {
|
||||
id: 'rr1',
|
||||
has_deep_access: false,
|
||||
summary: {
|
||||
me_style: '我·稳',
|
||||
other_style: 'TA·活',
|
||||
diff_one_liner: '节奏不同',
|
||||
me_keywords: ['稳'],
|
||||
other_keywords: ['活'],
|
||||
},
|
||||
detail: null,
|
||||
},
|
||||
})
|
||||
|
||||
const w = mount(RelationPage)
|
||||
await w.findAll('button').find((b) => b.text().includes('开始匹配'))!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(w.text()).toContain('我·稳')
|
||||
expect(w.text()).toContain('节奏不同')
|
||||
expect(w.text()).toContain('解锁完整分析')
|
||||
})
|
||||
|
||||
it('shows tips after deep_access pay', async () => {
|
||||
api.listProfiles.mockResolvedValue({
|
||||
items: [{ id: 'self1', relation: 'self', display_name: '我', birth_date: '1990-01-01' }],
|
||||
})
|
||||
api.createProfile.mockResolvedValue({ id: 'other1' })
|
||||
api.createRelationInsight.mockResolvedValue({
|
||||
insight: { id: 'i1' },
|
||||
report: {
|
||||
id: 'rr1',
|
||||
has_deep_access: false,
|
||||
summary: { me_style: 'A', other_style: 'B', diff_one_liner: 'D', me_keywords: [], other_keywords: [] },
|
||||
detail: null,
|
||||
},
|
||||
})
|
||||
api.createOrder.mockResolvedValue({ order_id: 'o1' })
|
||||
api.payMock.mockResolvedValue({ paid: true })
|
||||
api.getReport.mockResolvedValue({
|
||||
id: 'rr1',
|
||||
has_deep_access: true,
|
||||
summary: { me_style: 'A', other_style: 'B', diff_one_liner: 'D', me_keywords: [], other_keywords: [] },
|
||||
detail: {
|
||||
communication: ['先倾听'],
|
||||
interaction: ['留白'],
|
||||
maintenance: ['定期沟通'],
|
||||
},
|
||||
})
|
||||
|
||||
const w = mount(RelationPage)
|
||||
await w.findAll('button').find((b) => b.text().includes('开始匹配'))!.trigger('click')
|
||||
await flushPromises()
|
||||
await w.findAll('button').find((b) => b.text().includes('解锁完整分析'))!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(w.text()).toContain('可执行建议')
|
||||
expect(w.text()).toContain('先倾听')
|
||||
})
|
||||
})
|
||||
@@ -1,44 +1,59 @@
|
||||
<template>
|
||||
<main class="page">
|
||||
<button class="back" type="button" @click="$router.back()">← 返回</button>
|
||||
<h1>关系理解</h1>
|
||||
<p class="sub">添加重要的人,了解双方差异与相处建议</p>
|
||||
|
||||
<div class="card">
|
||||
<label>TA 的称呼</label>
|
||||
<input v-model="otherName" placeholder="例如:伴侣" />
|
||||
<label>TA 的生日</label>
|
||||
<div class="row">
|
||||
<input v-model.number="oy" type="number" placeholder="年" />
|
||||
<input v-model.number="om" type="number" placeholder="月" />
|
||||
<input v-model.number="od" type="number" placeholder="日" />
|
||||
</div>
|
||||
<p class="hint">将使用你最近一份「我」的档案进行对比;若没有会先引导去首页创建个人画像。</p>
|
||||
<button type="button" :disabled="loading" @click="run">生成关系理解</button>
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
<main class="yxg-page">
|
||||
<div class="yxg-hero">
|
||||
<BackButton />
|
||||
<PageTitle feature="relation" title="人格匹配" sub="合盘指数 · 恋爱/友情/婚姻 · 相处建议" />
|
||||
</div>
|
||||
<div class="yxg-sheet sheet-pad">
|
||||
<div class="yxg-card">
|
||||
<label class="yxg-label">TA 的称呼</label>
|
||||
<input class="yxg-input" v-model="otherName" placeholder="例如:伴侣" />
|
||||
<label class="yxg-label">关系类型</label>
|
||||
<select class="yxg-select" v-model="relationType">
|
||||
<option value="partner">伴侣</option>
|
||||
<option value="friend">朋友</option>
|
||||
<option value="family">家人</option>
|
||||
<option value="other">其他</option>
|
||||
</select>
|
||||
<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>
|
||||
<p v-if="loading" class="yxg-hint">生成中…</p>
|
||||
<button class="yxg-btn yxg-btn-block" 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 v-if="report">
|
||||
<div class="card">
|
||||
<p><strong>我</strong>:{{ meStyle }}</p>
|
||||
<p><strong>TA</strong>:{{ otherStyle }}</p>
|
||||
<p class="line">{{ diff }}</p>
|
||||
<div class="tags">
|
||||
<span v-for="k in meKeys" :key="'a'+k">我·{{ k }}</span>
|
||||
<span v-for="k in otherKeys" :key="'b'+k">TA·{{ k }}</span>
|
||||
<template v-if="report">
|
||||
<p class="yxg-lead">{{ diff }}</p>
|
||||
<MatchCompare
|
||||
:me-style="meStyle"
|
||||
:other-style="otherStyle"
|
||||
:fit-label="fitLabel"
|
||||
:harmony="harmony"
|
||||
:love="loveIndex"
|
||||
:friend="friendIndex"
|
||||
:marriage="marriageIndex"
|
||||
:star-compare="starCompare"
|
||||
:dimensions="dimCompare"
|
||||
/>
|
||||
<ReportRich :summary="summary" :detail="detail" :deep="!!report.has_deep_access" />
|
||||
<div v-if="!report.has_deep_access" class="yxg-card lock">
|
||||
<p>完整相处建议、冲突修复、对话示例与本周练习可在深度版或成长会员中查看。</p>
|
||||
<button class="yxg-btn" type="button" :disabled="paying" @click="buyDeep">解锁完整分析(模拟支付)</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="report.has_deep_access && detail" class="card">
|
||||
<h2>相处建议</h2>
|
||||
<ul>
|
||||
<li v-for="(t, i) in tips" :key="i">{{ t }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-else class="card lock">
|
||||
<p>完整相处建议可在深度版或成长会员中查看。</p>
|
||||
<button type="button" :disabled="paying" @click="buyDeep">查看深度版(模拟支付)</button>
|
||||
</div>
|
||||
</template>
|
||||
<div class="links">
|
||||
<router-link class="yxg-link-plain" to="/star">看看星座 →</router-link>
|
||||
<router-link class="yxg-link-plain" to="/portrait">回看愈心解码 →</router-link>
|
||||
</div>
|
||||
<button type="button" class="yxg-btn yxg-btn-ghost share-btn" @click="shareOpen = true">生成分享卡</button>
|
||||
</template>
|
||||
</div>
|
||||
<ShareSheet :open="shareOpen" :payload="sharePayload" @close="shareOpen = false" />
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -46,59 +61,103 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import type { GrowthReport, Profile } from '@yuxingu/types'
|
||||
import { api } from '../api/client'
|
||||
import BackButton from '../components/BackButton.vue'
|
||||
import BirthDateInputs from '../components/BirthDateInputs.vue'
|
||||
import MatchCompare from '../components/MatchCompare.vue'
|
||||
import PageTitle from '../components/PageTitle.vue'
|
||||
import ReportRich from '../components/ReportRich.vue'
|
||||
import ShareSheet from '../components/ShareSheet.vue'
|
||||
import { AnalyticsEvent, track } from '../lib/analytics'
|
||||
import type { RelationSharePayload } from '../lib/shareLink'
|
||||
|
||||
const otherName = ref('TA')
|
||||
const oy = ref<number | null>(1992)
|
||||
const om = ref<number | null>(6)
|
||||
const od = ref<number | null>(8)
|
||||
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 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 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 tips = computed(() => {
|
||||
const d = detail.value
|
||||
if (!d) return [] as string[]
|
||||
const a = Array.isArray(d.communication) ? d.communication as string[] : []
|
||||
const b = Array.isArray(d.interaction) ? d.interaction as string[] : []
|
||||
const c = Array.isArray(d.maintenance) ? d.maintenance as string[] : []
|
||||
return [...a, ...b, ...c]
|
||||
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('请先在首页完成性格探索,创建「我」的个人档案')
|
||||
throw new Error('请先在首页完成愈心解码,创建「我」的个人档案')
|
||||
}
|
||||
|
||||
async function run() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
needSelf.value = false
|
||||
report.value = null
|
||||
try {
|
||||
if (!oy.value || !om.value || !od.value) {
|
||||
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 = `${oy.value}-${String(om.value).padStart(2, '0')}-${String(od.value).padStart(2, '0')}`
|
||||
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: 'partner',
|
||||
relation_type: relationType.value,
|
||||
})
|
||||
const out = await api.createRelationInsight(self.id, other.id)
|
||||
report.value = out.report
|
||||
track(AnalyticsEvent.RelationCompleted)
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '生成失败'
|
||||
const msg = e instanceof Error ? e.message : '生成失败'
|
||||
error.value = msg
|
||||
needSelf.value = msg.includes('个人档案') || msg.includes('愈心解码') || msg.includes('性格探索')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -107,10 +166,12 @@ async function run() {
|
||||
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 {
|
||||
@@ -120,26 +181,9 @@ async function buyDeep() {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page{padding:16px}
|
||||
.back{border:none;background:#fff;border-radius:10px;padding:8px 12px;margin-bottom:12px}
|
||||
h1{font-size:22px}
|
||||
h2{font-size:16px;margin-bottom:8px}
|
||||
.sub{color:var(--yxg-sub);margin:8px 0 14px;font-size:13px}
|
||||
.card{background:#fff;border-radius:16px;padding:18px;font-size:14px;line-height:1.7;color:#555;margin-bottom:12px}
|
||||
label{display:block;font-size:12px;color:#999;margin:8px 0 4px}
|
||||
input{width:100%;box-sizing:border-box;padding:10px;border:1.5px solid #eee;border-radius:12px;margin-bottom:4px}
|
||||
.row{display:flex;gap:8px}
|
||||
.row input{flex:1}
|
||||
.hint{font-size:12px;color:#aaa;margin:8px 0}
|
||||
button{
|
||||
margin-top:10px;padding:10px 16px;border:none;border-radius:22px;color:#fff;font-weight:600;
|
||||
background:linear-gradient(135deg,#ff7a6e,var(--yxg-pri));
|
||||
}
|
||||
button:disabled{opacity:.6}
|
||||
.err{color:var(--yxg-pri);font-size:13px;margin-top:8px}
|
||||
.line{margin-top:8px;color:#333}
|
||||
.tags{display:flex;flex-wrap:wrap;gap:8px;margin-top:10px}
|
||||
.tags span{background:#fff3d6;color:#c8923a;padding:4px 10px;border-radius:999px;font-size:12px}
|
||||
ul{padding-left:18px;margin:0}
|
||||
li{margin:6px 0}
|
||||
.yxg-card{margin:8px 0 12px}
|
||||
.yxg-card .yxg-label:first-child{margin-top:0}
|
||||
.yxg-btn{margin-top:12px}
|
||||
.lock .yxg-btn,.share-btn{width:100%}
|
||||
.links{display:flex;flex-direction:column;gap:8px;margin:12px 0}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,443 @@
|
||||
<template>
|
||||
<main class="yxg-page">
|
||||
<div class="yxg-hero">
|
||||
<BackButton />
|
||||
<PageTitle feature="reports" title="成长报告" :sub="typeLabel || undefined" />
|
||||
</div>
|
||||
<div class="yxg-sheet sheet-pad">
|
||||
<p v-if="loading" class="yxg-hint">加载中…</p>
|
||||
<p v-else-if="error" class="yxg-err">
|
||||
{{ error }}
|
||||
<button type="button" class="yxg-link" @click="load">重试</button>
|
||||
</p>
|
||||
<template v-else-if="report">
|
||||
<p class="yxg-meta">{{ typeLabel }} · {{ createdLabel }}</p>
|
||||
<p class="yxg-lead">{{ headline || oneLiner }}</p>
|
||||
|
||||
<!-- 星座:报告页也展示星盘 / 运势 / 行星,避免「打开报告却缺内容」 -->
|
||||
<template v-if="report.type === 'star'">
|
||||
<div class="panel-tabs">
|
||||
<button
|
||||
v-for="p in starPanels"
|
||||
:key="p.key"
|
||||
type="button"
|
||||
class="panel-tab"
|
||||
:class="{ on: starPanel === p.key }"
|
||||
@click="starPanel = p.key"
|
||||
>
|
||||
{{ p.label }}
|
||||
</button>
|
||||
</div>
|
||||
<template v-if="starPanel === 'chart'">
|
||||
<NatalWheel
|
||||
v-if="wheelPlanets.length"
|
||||
:planets="wheelPlanets"
|
||||
:aspects="aspectPreview"
|
||||
:asc-lon="ascLon"
|
||||
/>
|
||||
<div v-if="transits.length" class="yxg-card soft">
|
||||
<h2 class="card-title">今日行运</h2>
|
||||
<p v-for="t in transits" :key="t.key" class="yxg-meta">{{ t.title }} · {{ t.aspect }}:{{ t.tip }}</p>
|
||||
</div>
|
||||
<SignCards v-if="signCards.length" v-model="signTab" :cards="signCards" />
|
||||
<div v-if="activeCard" class="yxg-card soft">
|
||||
<h2 class="card-title">{{ activeCard.title }} · {{ activeCard.label }}</h2>
|
||||
<p>{{ activeCard.teaser }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="starPanel === '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="yxg-card soft">
|
||||
<p class="ft-title">
|
||||
{{ activeFortune.title }} · {{ activeFortune.label }} · {{ activeFortune.score }}分
|
||||
</p>
|
||||
<p>{{ activeFortune.tip }}</p>
|
||||
<p v-if="activeFortune.dims" class="yxg-meta">
|
||||
感情 {{ activeFortune.dims.love }} · 事业 {{ activeFortune.dims.career }} · 财运
|
||||
{{ activeFortune.dims.money }} · 心情 {{ activeFortune.dims.mood }}
|
||||
</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="yxg-card soft planet">
|
||||
<strong>{{ p.title }}</strong>
|
||||
<span>{{ p.sign }} {{ p.degree }} · 第{{ p.house }}宫</span>
|
||||
</li>
|
||||
<li v-if="!planets.length" class="yxg-hint">暂无行星数据(请重新生成星座报告)</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<!-- 人格匹配 / 合盘指数 -->
|
||||
<div v-if="(report.type === 'relation' || report.type === 'synastry') && hasMatchIndex" class="indices">
|
||||
<div class="idx"><em>恋爱</em><strong>{{ loveIndex }}</strong></div>
|
||||
<div class="idx"><em>友情</em><strong>{{ friendIndex }}</strong></div>
|
||||
<div class="idx"><em>婚姻</em><strong>{{ marriageIndex }}</strong></div>
|
||||
</div>
|
||||
<template v-if="report.type === 'synastry'">
|
||||
<div class="panel-tabs">
|
||||
<button
|
||||
v-for="t in synTabs"
|
||||
:key="t.key"
|
||||
type="button"
|
||||
class="panel-tab"
|
||||
:class="{ on: synTab === t.key }"
|
||||
@click="synTab = t.key"
|
||||
>
|
||||
{{ t.label }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="synActiveTip" class="yxg-meta">{{ synActiveTip }}</p>
|
||||
<template v-if="synTab === '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="synTab === 'overlay'">
|
||||
<p v-for="(e, i) in synOverlayEntries" :key="i" class="yxg-meta">
|
||||
{{ e.planet }} → {{ e.house }}宫 · {{ e.house_tip }}
|
||||
</p>
|
||||
</template>
|
||||
<NatalWheel
|
||||
v-else-if="synActivePlanets.length"
|
||||
:planets="synActivePlanets"
|
||||
:aspects="[]"
|
||||
:asc-lon="synActiveAsc"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 愈心解码:三角 + 五行 + 付费墙 -->
|
||||
<template v-if="report.type === 'portrait'">
|
||||
<DecodePanel
|
||||
:summary="summary"
|
||||
:detail="detail"
|
||||
:deep="!!report.has_deep_access"
|
||||
:paying="paying"
|
||||
@unlock="buyDeep"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<ReportRich :summary="summary" :detail="detail" :deep="!!report.has_deep_access" />
|
||||
<div v-if="!report.has_deep_access" class="yxg-card lock">
|
||||
<p>完整分析可在深度版或成长会员中查看。</p>
|
||||
<button class="yxg-btn" type="button" :disabled="paying" @click="buyDeep">查看深度版(模拟支付)</button>
|
||||
</div>
|
||||
</template>
|
||||
<div class="links">
|
||||
<router-link v-if="report.type === 'star'" class="yxg-link-plain" to="/star">回星座页 →</router-link>
|
||||
<router-link v-if="report.type === 'synastry'" class="yxg-link-plain" to="/synastry">回合盘页 →</router-link>
|
||||
<router-link v-if="report.type === 'portrait'" class="yxg-link-plain" to="/portrait">回愈心解码 →</router-link>
|
||||
<router-link v-if="report.type === 'relation'" class="yxg-link-plain" to="/relation">回人格匹配 →</router-link>
|
||||
<router-link class="yxg-link-plain" to="/reports">全部报告 →</router-link>
|
||||
</div>
|
||||
<button type="button" class="yxg-btn yxg-btn-ghost share-btn" @click="shareOpen = true">生成分享卡</button>
|
||||
</template>
|
||||
</div>
|
||||
<ShareSheet :open="shareOpen" :payload="sharePayload" @close="shareOpen = false" />
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import type { GrowthReport } from '@yuxingu/types'
|
||||
import { api } from '../api/client'
|
||||
import BackButton from '../components/BackButton.vue'
|
||||
import PageTitle from '../components/PageTitle.vue'
|
||||
import DecodePanel from '../components/DecodePanel.vue'
|
||||
import NatalWheel, { type WheelAspect, type WheelPlanet } from '../components/NatalWheel.vue'
|
||||
import ReportRich from '../components/ReportRich.vue'
|
||||
import ShareSheet from '../components/ShareSheet.vue'
|
||||
import SignCards, { type SignCard } from '../components/SignCards.vue'
|
||||
import { AnalyticsEvent, track } from '../lib/analytics'
|
||||
import type { PortraitSharePayload, RelationSharePayload, SharePayload } from '../lib/shareLink'
|
||||
|
||||
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 starPanel = ref<'chart' | 'fortune' | 'planets'>('chart')
|
||||
const signTab = ref('sun')
|
||||
const fortuneKey = ref<'daily' | 'weekly' | 'monthly' | 'yearly'>('daily')
|
||||
const synTab = ref('compare')
|
||||
const synTabs = [
|
||||
{ key: 'compare', label: '比较' },
|
||||
{ key: 'composite', label: '组合' },
|
||||
{ key: 'davison', label: '时空' },
|
||||
{ key: 'marks_me', label: '马克斯' },
|
||||
{ key: 'overlay', label: '配对' },
|
||||
{ key: 'composite_progressed', label: '组合次限' },
|
||||
]
|
||||
const starPanels = [
|
||||
{ key: 'chart' as const, label: '星盘' },
|
||||
{ key: 'fortune' as const, label: '运势' },
|
||||
{ key: 'planets' as const, label: '行星' },
|
||||
]
|
||||
const fortuneKeys = ['daily', 'weekly', 'monthly', 'yearly', 'lifetime'] as const
|
||||
|
||||
type FortunePeriod = {
|
||||
title?: string
|
||||
label?: string
|
||||
score?: number
|
||||
tip?: string
|
||||
dims?: { love?: number; career?: number; money?: number; mood?: number }
|
||||
}
|
||||
type PlanetRow = { key: string; title: string; sign: string; degree: string; house: number }
|
||||
|
||||
const summary = computed(() => (report.value?.summary || {}) as Record<string, unknown>)
|
||||
const detail = computed(() => (report.value?.detail || null) as Record<string, unknown> | null)
|
||||
const headline = computed(() => String(summary.value.headline || summary.value.diff_one_liner || ''))
|
||||
const oneLiner = computed(() => String(summary.value.one_liner || summary.value.diff_one_liner || ''))
|
||||
const keywords = computed(() => {
|
||||
if (Array.isArray(summary.value.keywords)) return summary.value.keywords as string[]
|
||||
const a = Array.isArray(summary.value.me_keywords) ? (summary.value.me_keywords as string[]) : []
|
||||
const b = Array.isArray(summary.value.other_keywords) ? (summary.value.other_keywords as string[]) : []
|
||||
return [...a, ...b]
|
||||
})
|
||||
const typeLabel = computed(() => {
|
||||
const map: Record<string, string> = {
|
||||
portrait: '愈心解码',
|
||||
relation: '人格匹配',
|
||||
synastry: '合盘',
|
||||
star: '星座',
|
||||
rhythm: '身心节律',
|
||||
image_card: '意象卡片',
|
||||
}
|
||||
return map[report.value?.type || ''] || '成长报告'
|
||||
})
|
||||
const 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[synTab.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).toLocaleString('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(
|
||||
() => loveIndex.value != null || friendIndex.value != null || marriageIndex.value != null,
|
||||
)
|
||||
|
||||
const sharePayload = computed<SharePayload | null>(() => {
|
||||
if (!report.value) return null
|
||||
if (report.value.type === 'relation') {
|
||||
const p: RelationSharePayload = {
|
||||
type: 'relation',
|
||||
me: String(summary.value.me_style || ''),
|
||||
other: String(summary.value.other_style || ''),
|
||||
diff: String(summary.value.diff_one_liner || ''),
|
||||
keywords: keywords.value,
|
||||
}
|
||||
return p
|
||||
}
|
||||
const p: PortraitSharePayload = {
|
||||
type: 'portrait',
|
||||
title: headline.value,
|
||||
line: oneLiner.value,
|
||||
keywords: keywords.value,
|
||||
}
|
||||
return p
|
||||
})
|
||||
|
||||
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)
|
||||
starPanel.value = '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>
|
||||
|
||||
<style scoped>
|
||||
.yxg-card{margin:12px 0}
|
||||
.lock .yxg-btn,.share-btn{margin-top:12px;width:100%}
|
||||
.panel-tabs,.fortune-tabs{display:flex;gap:8px;margin:10px 0 12px;flex-wrap:wrap}
|
||||
.panel-tab,.ftab{
|
||||
flex:1;min-width:64px;border:1px solid #eee;background:#fafafa;border-radius:12px;
|
||||
padding:10px 8px;font-size:13px;color:#666;cursor:pointer;
|
||||
}
|
||||
.panel-tab.on,.ftab.on{border-color:var(--yxg-pri);background:#fff5f4;color:#222;font-weight:600}
|
||||
.ft-title{font-weight:700;color:#222;margin-bottom:6px}
|
||||
.card-title{font-size:16px;margin:0 0 8px}
|
||||
.planet-list{list-style:none;padding:0;margin:0}
|
||||
.planet{display:flex;flex-direction:column;gap:4px;padding:12px}
|
||||
.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(--yxg-pri);font-family:var(--font-display)}
|
||||
.links{display:flex;flex-direction:column;gap:8px;margin:12px 0}
|
||||
</style>
|
||||
@@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<main class="yxg-page">
|
||||
<div class="yxg-hero">
|
||||
<BackButton />
|
||||
<PageTitle feature="reports" title="我的成长报告" sub="按类型筛选 · 愈心解码 / 星座 / 节律 / 匹配 / 意象" />
|
||||
</div>
|
||||
|
||||
<div class="yxg-sheet">
|
||||
<div class="yxg-chips">
|
||||
<button
|
||||
v-for="f in filters"
|
||||
:key="f.key"
|
||||
type="button"
|
||||
class="yxg-chip yxg-chip-solid"
|
||||
:class="{ on: filter === f.key }"
|
||||
@click="filter = f.key"
|
||||
>{{ f.label }}</button>
|
||||
</div>
|
||||
|
||||
<p v-if="loading" class="yxg-hint pad">加载中…</p>
|
||||
<p v-else-if="error" class="yxg-err pad">
|
||||
{{ error }}
|
||||
<button type="button" class="yxg-link" @click="load">重试</button>
|
||||
</p>
|
||||
<template v-else>
|
||||
<div v-if="!filtered.length" class="yxg-card empty">
|
||||
<p>还没有{{ filter === 'all' ? '' : '该类' }}成长报告。</p>
|
||||
<router-link class="yxg-btn" to="/explore">去探索</router-link>
|
||||
</div>
|
||||
<ul v-else class="yxg-list">
|
||||
<li v-for="r in filtered" :key="r.id">
|
||||
<router-link :to="`/reports/${r.id}`">
|
||||
<span class="yxg-body">
|
||||
<strong>{{ typeLabel(r.type) }}</strong>
|
||||
<span class="desc">{{ headlineOf(r) }}</span>
|
||||
<span class="meta">{{ formatDate(r.created_at) }}</span>
|
||||
</span>
|
||||
<span class="chev" aria-hidden="true">›</span>
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import type { GrowthReport } from '@yuxingu/types'
|
||||
import { api } from '../api/client'
|
||||
import BackButton from '../components/BackButton.vue'
|
||||
import PageTitle from '../components/PageTitle.vue'
|
||||
|
||||
const items = ref<GrowthReport[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const filter = ref('all')
|
||||
|
||||
const filters = [
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'portrait', label: '解码' },
|
||||
{ key: 'star', label: '星座' },
|
||||
{ key: 'synastry', label: '合盘' },
|
||||
{ key: 'rhythm', label: '节律' },
|
||||
{ key: 'relation', label: '匹配' },
|
||||
{ key: 'image_card', label: '意象' },
|
||||
]
|
||||
|
||||
const filtered = computed(() => {
|
||||
if (filter.value === 'all') return items.value
|
||||
return items.value.filter((r) => r.type === filter.value)
|
||||
})
|
||||
|
||||
function typeLabel(t: string) {
|
||||
const map: Record<string, string> = {
|
||||
portrait: '△ 愈心解码',
|
||||
relation: '♡ 人格匹配',
|
||||
synastry: '✧ 合盘',
|
||||
star: '✦ 星座',
|
||||
rhythm: '☯ 身心节律',
|
||||
image_card: '◈ 意象卡片',
|
||||
}
|
||||
return map[t] || '▣ 成长报告'
|
||||
}
|
||||
|
||||
function headlineOf(r: GrowthReport) {
|
||||
const s = (r.summary || {}) as Record<string, unknown>
|
||||
return String(s.headline || s.diff_one_liner || s.one_liner || '查看报告')
|
||||
}
|
||||
|
||||
function formatDate(v: string) {
|
||||
try {
|
||||
return new Date(v).toLocaleString('zh-CN')
|
||||
} catch {
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const res = await api.listReports()
|
||||
items.value = res.items || []
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pad{padding:0 16px}
|
||||
.empty .yxg-btn{margin-top:12px}
|
||||
</style>
|
||||
@@ -1,34 +1,64 @@
|
||||
<template>
|
||||
<main class="page">
|
||||
<button class="back" type="button" @click="$router.back()">← 返回</button>
|
||||
<h1>{{ title || '探索测试' }}</h1>
|
||||
<p class="sub">{{ description }}</p>
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
|
||||
<div v-if="!done" class="card">
|
||||
<div v-for="q in questions" :key="q.id" class="q">
|
||||
<p class="prompt">{{ q.body.prompt }}</p>
|
||||
<label v-for="opt in q.body.options" :key="opt.key" class="opt">
|
||||
<input v-model="answers[q.id]" type="radio" :value="opt.key" />
|
||||
{{ opt.text }}
|
||||
</label>
|
||||
</div>
|
||||
<button type="button" :disabled="loading" @click="submit">查看探索结果</button>
|
||||
<main class="yxg-page">
|
||||
<div class="yxg-hero">
|
||||
<BackButton />
|
||||
<PageTitle feature="scale" :title="title || '探索测试'" :sub="description" />
|
||||
</div>
|
||||
<div class="yxg-sheet sheet-pad">
|
||||
<p v-if="loadingScale" class="yxg-hint">加载题目…</p>
|
||||
<p v-else-if="loadError" class="yxg-err">
|
||||
{{ loadError }}
|
||||
<button type="button" class="yxg-link" @click="load">重试</button>
|
||||
</p>
|
||||
<template v-else>
|
||||
<div v-if="!done" class="yxg-card">
|
||||
<p v-if="draftRestored" class="yxg-hint draft">已恢复上次未提交的作答</p>
|
||||
<p v-if="questions.length" class="progress">
|
||||
已答 {{ answeredCount }} / {{ questions.length }}
|
||||
</p>
|
||||
<div v-for="(q, idx) in questions" :key="q.id" class="q">
|
||||
<p class="prompt">{{ idx + 1 }}. {{ q.body.prompt }}</p>
|
||||
<label v-for="opt in q.body.options" :key="opt.key" class="opt">
|
||||
<input v-model="answers[q.id]" type="radio" :value="opt.key" />
|
||||
{{ opt.text }}
|
||||
</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>
|
||||
<button v-else class="yxg-btn yxg-btn-block" type="button" :disabled="submitting" @click="submit">
|
||||
查看探索结果
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="card">
|
||||
<h2>{{ resultLabel }}</h2>
|
||||
<p>{{ resultSummary }}</p>
|
||||
<p class="share">{{ shareLine }}</p>
|
||||
<router-link class="link" to="/relation">用结果去做关系理解 →</router-link>
|
||||
<div v-else>
|
||||
<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>
|
||||
</template>
|
||||
</div>
|
||||
<ShareSheet :open="shareOpen" :payload="sharePayload" @close="shareOpen = false" />
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
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 PageTitle from '../components/PageTitle.vue'
|
||||
import ReportRich from '../components/ReportRich.vue'
|
||||
import ShareSheet from '../components/ShareSheet.vue'
|
||||
import { clearScaleDraft, loadScaleDraft, saveScaleDraft } from '../lib/scaleDraft'
|
||||
import type { PortraitSharePayload } from '../lib/shareLink'
|
||||
|
||||
const route = useRoute()
|
||||
const slug = String(route.params.slug || '')
|
||||
@@ -36,67 +66,142 @@ 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 loading = ref(false)
|
||||
const error = ref('')
|
||||
const loadingScale = ref(true)
|
||||
const submitting = ref(false)
|
||||
const loadError = ref('')
|
||||
const submitError = ref('')
|
||||
const needProfile = ref(false)
|
||||
const done = ref(false)
|
||||
const resultLabel = ref('')
|
||||
const resultSummary = ref('')
|
||||
const shareLine = ref('')
|
||||
const shareOpen = ref(false)
|
||||
const resultRaw = ref<Record<string, unknown> | null>(null)
|
||||
const draftRestored = ref(false)
|
||||
const persistReady = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
const answeredCount = computed(() => questions.value.filter((q) => answers[q.id]).length)
|
||||
|
||||
watch(
|
||||
answers,
|
||||
() => {
|
||||
if (!persistReady.value || done.value) 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 (!done.value) return null
|
||||
return {
|
||||
type: 'portrait',
|
||||
title: resultLabel.value,
|
||||
line: shareLine.value || String(resultRaw.value?.summary || ''),
|
||||
keywords: resultLabel.value ? [resultLabel.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) : q.body,
|
||||
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) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
loadError.value = e instanceof Error ? e.message : '加载失败'
|
||||
} finally {
|
||||
loadingScale.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function openShare() {
|
||||
shareOpen.value = true
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
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()
|
||||
let self = (items || []).find((p) => p.relation === 'self')
|
||||
const self = (items || []).find((p) => p.relation === 'self')
|
||||
if (!self) {
|
||||
self = await api.createProfile({ relation: 'self', birth_date: '1990-01-01', display_name: '我' })
|
||||
needProfile.value = true
|
||||
throw new Error('请先在首页完成性格探索,创建个人档案')
|
||||
}
|
||||
const out = await api.submitScale(slug, self.id, { ...answers })
|
||||
clearScaleDraft(slug)
|
||||
draftRestored.value = false
|
||||
done.value = true
|
||||
resultRaw.value = out.result as Record<string, unknown>
|
||||
resultLabel.value = String(out.result.label || '探索结果')
|
||||
resultSummary.value = String(out.result.summary || '')
|
||||
shareLine.value = String(out.result.share_line || '')
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '提交失败'
|
||||
submitError.value = e instanceof Error ? e.message : '提交失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page{padding:16px}
|
||||
.back{border:none;background:#fff;border-radius:10px;padding:8px 12px;margin-bottom:12px}
|
||||
h1{font-size:22px}
|
||||
h2{font-size:18px;margin-bottom:8px}
|
||||
.sub{color:var(--yxg-sub);font-size:13px;margin:8px 0 14px}
|
||||
.err{color:var(--yxg-pri);font-size:13px}
|
||||
.card{background:#fff;border-radius:16px;padding:18px;font-size:14px;line-height:1.7;color:#555}
|
||||
.yxg-card{margin:8px 0 12px}
|
||||
.progress{font-size:12px;color:#999;margin-bottom:12px}
|
||||
.q{margin-bottom:18px}
|
||||
.prompt{font-weight:600;color:#333;margin-bottom:8px}
|
||||
.opt{display:block;margin:6px 0;cursor:pointer}
|
||||
button{
|
||||
margin-top:8px;padding:10px 16px;border:none;border-radius:22px;color:#fff;font-weight:600;
|
||||
background:linear-gradient(135deg,#ff7a6e,var(--yxg-pri));
|
||||
.prompt{font-weight:650;color:#222;margin-bottom:8px;line-height:1.45}
|
||||
.opt{display:flex;align-items:flex-start;gap:8px;margin:8px 0;cursor:pointer;line-height:1.45}
|
||||
.result-lead{
|
||||
font-family:var(--font-display);
|
||||
font-size:20px;font-weight:700;color:#222;margin:4px 0 12px;letter-spacing:.04em;
|
||||
}
|
||||
.share{margin-top:12px;color:var(--yxg-gold,#c8923a)}
|
||||
.link{display:inline-block;margin-top:14px;color:var(--yxg-pri)}
|
||||
.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}
|
||||
.yxg-btn{margin-top:8px}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<main class="yxg-page">
|
||||
<div class="yxg-hero">
|
||||
<BackButton />
|
||||
</div>
|
||||
<div class="yxg-sheet sheet-pad">
|
||||
<p v-if="!payload" class="yxg-err">
|
||||
分享内容无效或已过期。
|
||||
<router-link class="yxg-link" to="/">回首页</router-link>
|
||||
</p>
|
||||
<template v-else>
|
||||
<p class="yxg-meta lead">朋友分享了{{ payload.type === 'relation' ? '一段关系理解' : '一份个人画像' }}</p>
|
||||
<ShareCard v-bind="cardProps" />
|
||||
<div class="actions">
|
||||
<router-link class="yxg-btn yxg-btn-block" :to="ctaTo">{{ ctaText }}</router-link>
|
||||
<router-link class="yxg-link-plain ghost" to="/">先逛逛首页</router-link>
|
||||
</div>
|
||||
<p class="yxg-disc">本内容为自我探索与生活方式参考,不构成医疗建议,亦非占卜预测。</p>
|
||||
</template>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import BackButton from '../components/BackButton.vue'
|
||||
import ShareCard from '../components/ShareCard.vue'
|
||||
import { parseShareQuery } from '../lib/shareLink'
|
||||
|
||||
const route = useRoute()
|
||||
const payload = computed(() => parseShareQuery(route.query as Record<string, unknown>))
|
||||
|
||||
const cardProps = computed(() => {
|
||||
const p = payload.value
|
||||
if (!p) return {}
|
||||
if (p.type === 'portrait') {
|
||||
return {
|
||||
type: 'portrait' as const,
|
||||
title: p.title,
|
||||
line: p.line,
|
||||
keywords: p.keywords,
|
||||
ctaLabel: '查看完整成长报告',
|
||||
}
|
||||
}
|
||||
return {
|
||||
type: 'relation' as const,
|
||||
title: '我的方式 vs TA 的方式',
|
||||
rows: [
|
||||
p.me ? `我:${p.me}` : '',
|
||||
p.other ? `TA:${p.other}` : '',
|
||||
p.diff || '',
|
||||
].filter(Boolean),
|
||||
keywords: p.keywords,
|
||||
ctaLabel: '了解彼此 → 查看关系理解',
|
||||
}
|
||||
})
|
||||
|
||||
const ctaTo = computed(() => (payload.value?.type === 'relation' ? '/relation' : '/'))
|
||||
const ctaText = computed(() =>
|
||||
payload.value?.type === 'relation' ? '开始关系理解' : '生成我的个人画像',
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.lead{margin:4px 0 16px}
|
||||
.actions{display:flex;flex-direction:column;gap:10px;margin-top:18px;align-items:center}
|
||||
.ghost{text-align:center;font-size:13px}
|
||||
</style>
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import StarProfilePage from './StarProfilePage.vue'
|
||||
|
||||
const { api, routeQuery } = vi.hoisted(() => ({
|
||||
api: {
|
||||
createProfile: vi.fn(),
|
||||
createStar: vi.fn(),
|
||||
getReport: vi.fn(),
|
||||
createOrder: vi.fn(),
|
||||
payMock: vi.fn(),
|
||||
},
|
||||
routeQuery: { y: '1990', m: '5', d: '12' } as Record<string, string>,
|
||||
}))
|
||||
|
||||
vi.mock('../api/client', () => ({ api }))
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => ({ query: routeQuery }),
|
||||
useRouter: () => ({ back: vi.fn() }),
|
||||
}))
|
||||
|
||||
const summary = {
|
||||
headline: '小愈的星座更偏「稳健沉淀者」',
|
||||
one_liner: '一句',
|
||||
sign_cards: [{ key: 'sun', title: '太阳', label: '金牛', teaser: 't', element: '土', modality: '固定' }],
|
||||
chart: { note: '热带黄道', asc_lon: 120, houses: [{ num: 1, sign: '狮子' }] },
|
||||
planets: [
|
||||
{ key: 'sun', title: '太阳', sign: '金牛', degree: '10.0°', house: 1, lon: 40, element: '土', modality: '固定' },
|
||||
{ key: 'moon', title: '月亮', sign: '巨蟹', degree: '5.0°', house: 2, lon: 95, element: '水', modality: '开创' },
|
||||
{ key: 'rise', title: '上升', sign: '狮子', degree: '0.0°', house: 1, lon: 120, element: '火', modality: '固定' },
|
||||
],
|
||||
aspects_preview: [{ a: 'sun', b: 'moon', type: 'square', orb: 2, label: '太阳刑相月亮(容许2.0°)', a_title: '太阳', b_title: '月亮' }],
|
||||
fortune: {
|
||||
daily: { title: '今日运势', label: '吉', score: 80, tip: 'tip', focus: '行动', lucky: '红', caution: '慢', dims: { love: 1, career: 2, money: 3, mood: 4 } },
|
||||
lifetime: { title: '一生运势摘要', label: '平稳', score: 70, tip: '人生阶段', focus: '人生阶段', lucky: 'x', caution: 'y', dims: {} },
|
||||
transits: [{ key: 't1', title: '行运太阳×本命太阳', aspect: '合相', tip: '推进' }],
|
||||
},
|
||||
transits: [{ key: 't1', title: '行运太阳×本命太阳', aspect: '合相', tip: '推进' }],
|
||||
keywords: ['金牛'],
|
||||
}
|
||||
|
||||
describe('StarProfilePage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
api.createProfile.mockResolvedValue({ id: 'p1' })
|
||||
api.createStar.mockResolvedValue({
|
||||
id: 'r1',
|
||||
has_deep_access: false,
|
||||
summary,
|
||||
detail: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('shows natal wheel and free chart content', async () => {
|
||||
const w = mount(StarProfilePage, { global: { stubs: { RouterLink: true } } })
|
||||
await flushPromises()
|
||||
expect(api.createStar).toHaveBeenCalled()
|
||||
expect(w.find('svg.wheel').exists() || w.text().includes('本命')).toBe(true)
|
||||
expect(w.text()).toContain('相位速览')
|
||||
expect(w.text()).toContain('今日行运')
|
||||
expect(w.find('svg.wheel').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('shows lifetime fortune tab', async () => {
|
||||
const w = mount(StarProfilePage, { global: { stubs: { RouterLink: true } } })
|
||||
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('一生运势摘要')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,404 @@
|
||||
<template>
|
||||
<main class="yxg-page">
|
||||
<div class="yxg-hero">
|
||||
<BackButton />
|
||||
<PageTitle feature="star" title="星座" :sub="needBirth && !report ? '填写生日(可选出生时/地),生成星盘与运势' : undefined" />
|
||||
</div>
|
||||
<div class="yxg-sheet sheet-pad">
|
||||
<template v-if="needBirth && !report">
|
||||
<div class="yxg-card">
|
||||
<label class="yxg-label">生日 / 出生时(可选)</label>
|
||||
<div class="birth-row">
|
||||
<BirthDateInputs v-model:year="year" v-model:month="month" v-model:day="day" compact />
|
||||
<input class="bd-time" v-model="birthTime" type="time" aria-label="出生时" />
|
||||
<span class="bd-sep">时</span>
|
||||
</div>
|
||||
<label class="yxg-label">出生地(可选,省 / 市 / 区县)</label>
|
||||
<BirthPlaceSelect v-model="birthPlace" />
|
||||
<button class="yxg-btn yxg-btn-block" type="button" :disabled="loading" @click="start">生成星座</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="error" class="yxg-err">
|
||||
{{ error }}
|
||||
<button type="button" class="yxg-link" @click="load">重试</button>
|
||||
</p>
|
||||
<template v-else-if="report">
|
||||
<p class="yxg-meta head">{{ headline }}</p>
|
||||
<p v-if="oneLiner" class="yxg-lead">{{ oneLiner }}</p>
|
||||
<p v-if="chartNote" class="yxg-meta">{{ chartNote }}</p>
|
||||
|
||||
<div class="panel-tabs" role="tablist">
|
||||
<button
|
||||
v-for="p in panels"
|
||||
:key="p.key"
|
||||
type="button"
|
||||
class="panel-tab"
|
||||
:class="{ on: panel === p.key }"
|
||||
@click="panel = p.key"
|
||||
>
|
||||
{{ p.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template v-if="panel === 'chart'">
|
||||
<div class="wheel-settings">
|
||||
<label><input v-model="showOuter" type="checkbox" /> 显示外行星</label>
|
||||
<label><input v-model="tightOrb" type="checkbox" /> 紧容许度</label>
|
||||
</div>
|
||||
<NatalWheel
|
||||
v-if="wheelPlanets.length"
|
||||
:planets="wheelPlanets"
|
||||
:aspects="aspectLines"
|
||||
:asc-lon="ascLon"
|
||||
:show-outer="showOuter"
|
||||
:tight-orb="tightOrb"
|
||||
@select="onWheelSelect"
|
||||
/>
|
||||
<SignCards v-model="signTab" :cards="signCards" />
|
||||
<div v-if="activeCard" class="yxg-card soft tab-body">
|
||||
<h2 class="card-title">{{ activeCard.title }} · {{ activeCard.label }}</h2>
|
||||
<p>{{ activeCard.teaser }}</p>
|
||||
<p v-if="activeCard.element" class="yxg-meta">{{ activeCard.element }}象 · {{ activeCard.modality }}</p>
|
||||
</div>
|
||||
<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>
|
||||
<p v-if="!report.has_deep_access" class="yxg-meta">完整相位表见深度版</p>
|
||||
<ul v-else class="aspect-full">
|
||||
<li v-for="(a, i) in fullAspects" :key="'f' + i">{{ a.label }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-if="transits.length" class="yxg-card soft">
|
||||
<h2 class="card-title">今日行运</h2>
|
||||
<div v-for="t in transits" :key="t.key" class="transit">
|
||||
<strong>{{ t.title }}</strong>
|
||||
<span class="yxg-meta">{{ t.aspect }}</span>
|
||||
<p>{{ t.tip }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="panel === '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="yxg-card daily">
|
||||
<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="yxg-card soft planet">
|
||||
<strong>{{ p.title }}</strong>
|
||||
<span>{{ p.sign }} {{ p.degree }} · 第{{ p.house }}宫</span>
|
||||
<span class="yxg-meta">{{ p.element }}象 · {{ p.modality }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<ReportRich :summary="summary" :detail="detail" :deep="!!report.has_deep_access" />
|
||||
<div v-if="!report.has_deep_access" class="yxg-card lock">
|
||||
<p>完整相位与年运详解可在深度版或成长会员中查看。</p>
|
||||
<button class="yxg-btn" type="button" :disabled="paying" @click="buyDeep">解锁完整分析(模拟支付)</button>
|
||||
</div>
|
||||
<div class="links">
|
||||
<router-link class="yxg-link-plain" to="/synastry">去做合盘(比较盘)→</router-link>
|
||||
<router-link class="yxg-link-plain" to="/relation">去做人格匹配 →</router-link>
|
||||
<router-link class="yxg-link-plain" to="/ask">去问答聊聊星座 →</router-link>
|
||||
<router-link v-if="report.id" class="yxg-link-plain" :to="`/reports/${report.id}`">在报告页打开 →</router-link>
|
||||
</div>
|
||||
<button type="button" class="yxg-btn yxg-btn-ghost share-btn" @click="shareOpen = true">生成分享卡</button>
|
||||
</template>
|
||||
<p class="yxg-disc">运势与星盘为自我探索参考,请结合现实判断。</p>
|
||||
</div>
|
||||
<ShareSheet :open="shareOpen" :payload="sharePayload" @close="shareOpen = false" />
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import type { GrowthReport } from '@yuxingu/types'
|
||||
import { validateBirth } from '@yuxingu/utils'
|
||||
import { api } from '../api/client'
|
||||
import BackButton from '../components/BackButton.vue'
|
||||
import BirthDateInputs from '../components/BirthDateInputs.vue'
|
||||
import BirthPlaceSelect from '../components/BirthPlaceSelect.vue'
|
||||
import NatalWheel, { type WheelAspect, type WheelPlanet } from '../components/NatalWheel.vue'
|
||||
import PageTitle from '../components/PageTitle.vue'
|
||||
import ReportRich from '../components/ReportRich.vue'
|
||||
import ShareSheet from '../components/ShareSheet.vue'
|
||||
import SignCards, { type SignCard } from '../components/SignCards.vue'
|
||||
import { AnalyticsEvent, track } from '../lib/analytics'
|
||||
import type { PortraitSharePayload } from '../lib/shareLink'
|
||||
|
||||
const SETTINGS_KEY = 'yuxingu_star_wheel_settings'
|
||||
|
||||
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 panel = ref<'chart' | 'fortune' | 'planets'>('chart')
|
||||
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 panels = [
|
||||
{ key: 'chart' as const, label: '星盘' },
|
||||
{ key: 'fortune' as const, label: '运势' },
|
||||
{ key: 'planets' as const, label: '行星' },
|
||||
]
|
||||
const fortuneKeys = ['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 TransitRow = { key: string; title: string; aspect: string; tip: 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 transits = computed(() => {
|
||||
const fromSummary = summary.value.transits
|
||||
if (Array.isArray(fromSummary)) return fromSummary as TransitRow[]
|
||||
const f = fortuneBundle.value as Record<string, unknown>
|
||||
if (Array.isArray(f.transits)) return f.transits as TransitRow[]
|
||||
return []
|
||||
})
|
||||
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 })
|
||||
}
|
||||
|
||||
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)
|
||||
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)
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
} 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)) {
|
||||
await generate(y, m, d)
|
||||
}
|
||||
}
|
||||
|
||||
async function buyDeep() {
|
||||
if (!report.value) return
|
||||
paying.value = true
|
||||
error.value = ''
|
||||
track(AnalyticsEvent.DeepAccessClicked, { surface: 'star' })
|
||||
try {
|
||||
const { order_id } = await api.createOrder({ kind: 'deep_access', report_id: report.value.id })
|
||||
await api.payMock(order_id)
|
||||
report.value = await api.getReport(report.value.id)
|
||||
track(AnalyticsEvent.PurchaseCompleted, { kind: 'deep_access' })
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '支付失败'
|
||||
} finally {
|
||||
paying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.yxg-card{margin:8px 0 12px}
|
||||
.yxg-label{margin-top:12px}
|
||||
.yxg-label:first-child{margin-top:0}
|
||||
.birth-row{
|
||||
display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-bottom:4px;
|
||||
}
|
||||
.birth-row :deep(.bd){margin:0;flex-wrap:nowrap}
|
||||
.bd-time{
|
||||
width:108px;padding:10px 6px;border:1.5px solid #eee;border-radius:12px;
|
||||
font-size:14px;text-align:center;outline:none;background:#fdfaf8;color:#333;
|
||||
}
|
||||
.bd-time:focus{border-color:#f0b8b0;background:#fff}
|
||||
.bd-sep{font-size:12px;color:#bbb;flex-shrink:0}
|
||||
:deep(.bp){margin-bottom:10px}
|
||||
.head{margin:4px 0 8px;font-size:13px}
|
||||
.tab-body p{margin:0 0 6px;line-height:1.65}
|
||||
.panel-tabs,.fortune-tabs{display:flex;gap:8px;margin:10px 0 12px;flex-wrap:wrap}
|
||||
.panel-tab,.ftab{
|
||||
flex:1;min-width:56px;border:1px solid #eee;background:#fafafa;border-radius:12px;
|
||||
padding:10px 6px;font-size:13px;color:#666;cursor:pointer;
|
||||
}
|
||||
.panel-tab.on,.ftab.on{border-color:var(--yxg-pri);background:#fff5f4;color:#222;font-weight:600}
|
||||
.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;padding:0;margin:0}
|
||||
.planet{display:flex;flex-direction:column;gap:4px;padding:12px}
|
||||
.lock .yxg-btn,.share-btn{margin-top:12px;width:100%}
|
||||
.links{display:flex;flex-direction:column;gap:8px;margin:12px 0}
|
||||
.wheel-settings{
|
||||
display:flex;gap:16px;font-size:12px;color:#666;margin:4px 0 8px;justify-content:center;
|
||||
}
|
||||
.wheel-settings label{display:flex;align-items:center;gap:4px;cursor:pointer}
|
||||
.house-row{font-size:13px;color:#555;margin:4px 0}
|
||||
.aspect-line{font-size:13px;color:#555;margin:4px 0;line-height:1.5}
|
||||
.aspect-full{margin:8px 0 0;padding-left:18px;font-size:12px;color:#666}
|
||||
.transit{margin:8px 0;font-size:13px}
|
||||
.transit p{margin:2px 0 0;color:#555}
|
||||
</style>
|
||||
@@ -0,0 +1,98 @@
|
||||
<template>
|
||||
<main class="yxg-page">
|
||||
<div class="yxg-hero">
|
||||
<BackButton />
|
||||
<PageTitle feature="star" title="合盘邀请" sub="填写生日,与好友生成合盘" />
|
||||
</div>
|
||||
<div class="yxg-sheet sheet-pad">
|
||||
<p v-if="loading" class="yxg-hint">加载邀请…</p>
|
||||
<template v-else-if="meta">
|
||||
<p class="yxg-lead">{{ meta.host_name || '好友' }} 邀请你合盘</p>
|
||||
<p v-if="meta.already_accepted" class="yxg-meta">该邀请已使用,可直接去合盘页再测。</p>
|
||||
<div v-else class="yxg-card">
|
||||
<label class="yxg-label">你的称呼</label>
|
||||
<input v-model="name" class="name-in" placeholder="我" />
|
||||
<label class="yxg-label">生日</label>
|
||||
<BirthDateInputs v-model:year="ty" v-model:month="tm" v-model:day="td" />
|
||||
<button class="yxg-btn yxg-btn-block mt" type="button" :disabled="submitting" @click="accept">
|
||||
{{ submitting ? '生成中…' : '接受并合盘' }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="error" class="yxg-err">{{ error }}</p>
|
||||
<router-link class="yxg-link-plain" to="/synastry">回合盘页 →</router-link>
|
||||
</template>
|
||||
<p v-else class="yxg-err">{{ error || '邀请无效或已过期' }}</p>
|
||||
<p class="yxg-disc">合盘为自我探索参考,不是占卜或算命。</p>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { validateBirth } from '@yuxingu/utils'
|
||||
import { api } from '../api/client'
|
||||
import BackButton from '../components/BackButton.vue'
|
||||
import BirthDateInputs from '../components/BirthDateInputs.vue'
|
||||
import PageTitle from '../components/PageTitle.vue'
|
||||
import { AnalyticsEvent, track } from '../lib/analytics'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const loading = ref(true)
|
||||
const submitting = ref(false)
|
||||
const error = ref('')
|
||||
const name = ref('我')
|
||||
const ty = ref('')
|
||||
const tm = ref('')
|
||||
const td = ref('')
|
||||
const meta = ref<{
|
||||
host_name: string
|
||||
already_accepted: boolean
|
||||
} | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
const token = String(route.params.token || '')
|
||||
try {
|
||||
meta.value = await api.getSynastryInvite(token)
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '邀请无效'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
async function accept() {
|
||||
const y = Number(ty.value)
|
||||
const m = Number(tm.value)
|
||||
const d = Number(td.value)
|
||||
const msg = validateBirth(y, m, d)
|
||||
if (msg) {
|
||||
error.value = msg
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const birth = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
|
||||
const rep = await api.acceptSynastryInvite(String(route.params.token), {
|
||||
display_name: name.value || '我',
|
||||
birth_date: birth,
|
||||
})
|
||||
track(AnalyticsEvent.SynastryInviteAccepted, { report_id: rep.id })
|
||||
await router.push(`/reports/${rep.id}`)
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '接受失败'
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.yxg-label{display:block;margin:12px 0 6px;font-size:13px;color:#666}
|
||||
.name-in{
|
||||
width:100%;padding:10px;border:1.5px solid #eee;border-radius:10px;font-size:14px;
|
||||
}
|
||||
.mt{margin-top:14px}
|
||||
</style>
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import SynastryPage from './SynastryPage.vue'
|
||||
|
||||
const { api } = vi.hoisted(() => ({
|
||||
api: {
|
||||
listProfiles: vi.fn(),
|
||||
createProfile: vi.fn(),
|
||||
createSynastry: vi.fn(),
|
||||
createSynastryInvite: vi.fn(),
|
||||
listSynastryNearby: vi.fn(),
|
||||
updateProfile: vi.fn(),
|
||||
getReport: vi.fn(),
|
||||
createOrder: vi.fn(),
|
||||
payMock: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../api/client', () => ({ api }))
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => ({ query: {} }),
|
||||
useRouter: () => ({ back: vi.fn() }),
|
||||
}))
|
||||
|
||||
describe('SynastryPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
api.listProfiles.mockResolvedValue({
|
||||
items: [
|
||||
{ id: 'a1', relation: 'self', display_name: '我', birth_date: '1990-05-12' },
|
||||
{ id: 'b1', relation: 'other', display_name: 'TA', birth_date: '1992-08-20' },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('generates synastry and shows paywall without deep access', async () => {
|
||||
api.createSynastry.mockResolvedValue({
|
||||
id: 'r1',
|
||||
has_deep_access: false,
|
||||
summary: {
|
||||
headline: '我 × TA:恋爱 80 · 友情 70 · 婚姻 75',
|
||||
one_liner: '五盘合观',
|
||||
as_of: '2026-08-02',
|
||||
love_index: 80,
|
||||
friend_index: 70,
|
||||
marriage_index: 75,
|
||||
love_note: '恋爱说明',
|
||||
me_name: '我',
|
||||
other_name: 'TA',
|
||||
chart_a: {
|
||||
asc_lon: 10,
|
||||
planets: [{ key: 'sun', title: '太阳', sign: '金牛', degree: '1°', house: 1, lon: 40 }],
|
||||
},
|
||||
chart_b: {
|
||||
asc_lon: 20,
|
||||
planets: [{ key: 'sun', title: '太阳', sign: '狮子', degree: '2°', house: 1, lon: 122 }],
|
||||
},
|
||||
aspects_preview: [{ label: '我方太阳合相对方月亮(容许1.0°)' }],
|
||||
charts: {
|
||||
compare: { tip: '比较盘提示' },
|
||||
composite: {
|
||||
tip: '组合盘:关系整体气质。',
|
||||
asc_lon: 30,
|
||||
planets: [{ key: 'sun', title: '太阳', sign: '双子', degree: '3°', house: 1, lon: 63 }],
|
||||
aspects_preview: [{ label: '太阳六合月亮(容许2.0°)' }],
|
||||
},
|
||||
composite_progressed: {
|
||||
tip: '组合次限',
|
||||
planets: [{ key: 'sun', title: '太阳', sign: '巨蟹', degree: '4°', house: 1, lon: 94 }],
|
||||
aspects_preview: [],
|
||||
},
|
||||
davison: { tip: '时空', planets: [], aspects_preview: [] },
|
||||
marks_me: { tip: '马克斯我', planets: [], aspects_preview: [] },
|
||||
marks_other: { tip: '马克斯TA', planets: [], aspects_preview: [] },
|
||||
overlay: {
|
||||
tip: '配对盘提示',
|
||||
entries: [{ planet: '太阳', sign: '狮子', house: 5, house_tip: '恋爱表达与创造' }],
|
||||
},
|
||||
davison_progressed: { tip: '时空次限', planets: [], aspects_preview: [] },
|
||||
marks_progressed: { tip: '马盘推运', planets: [], aspects_preview: [] },
|
||||
},
|
||||
},
|
||||
detail: null,
|
||||
})
|
||||
|
||||
const w = mount(SynastryPage, { global: { stubs: { RouterLink: true } } })
|
||||
await flushPromises()
|
||||
await w.find('button.yxg-btn-block').trigger('click')
|
||||
await flushPromises()
|
||||
expect(api.createSynastry).toHaveBeenCalled()
|
||||
const args = api.createSynastry.mock.calls[0]
|
||||
expect(args[0]).toBe('a1')
|
||||
expect(args[1]).toBe('b1')
|
||||
expect(w.text()).toContain('80')
|
||||
expect(w.text()).toContain('解锁完整合盘')
|
||||
expect(w.text()).not.toContain('相处深析')
|
||||
|
||||
// switch to composite tab
|
||||
const tabs = w.findAll('button.tab')
|
||||
const comp = tabs.find((t) => t.text() === '组合')
|
||||
expect(comp).toBeTruthy()
|
||||
await comp!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(w.text()).toContain('组合盘:关系整体气质')
|
||||
|
||||
const prog = w.findAll('button.stab').find((t) => t.text() === '次限')
|
||||
await prog!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(w.text()).toContain('组合次限')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,498 @@
|
||||
<template>
|
||||
<main class="yxg-page">
|
||||
<div class="yxg-hero">
|
||||
<BackButton />
|
||||
<PageTitle feature="star" title="合盘" sub="比较 · 组合 · 时空 · 马克斯 · 配对" />
|
||||
</div>
|
||||
<div class="yxg-sheet sheet-pad">
|
||||
<template v-if="!report">
|
||||
<div class="yxg-card">
|
||||
<p class="yxg-lead">选择双方档案,一次生成五主盘与次限推运。</p>
|
||||
<label class="yxg-label">我(档案 A)</label>
|
||||
<select v-model="profileA" class="sel">
|
||||
<option disabled value="">请选择</option>
|
||||
<option v-for="p in profiles" :key="p.id" :value="p.id">
|
||||
{{ p.display_name || '未命名' }} · {{ birthLabel(p.birth_date) }}
|
||||
</option>
|
||||
</select>
|
||||
<label class="yxg-label">TA(档案 B)</label>
|
||||
<select v-model="profileB" class="sel">
|
||||
<option disabled value="">请选择</option>
|
||||
<option v-for="p in profiles" :key="p.id" :value="p.id" :disabled="p.id === profileA">
|
||||
{{ p.display_name || '未命名' }} · {{ birthLabel(p.birth_date) }}
|
||||
</option>
|
||||
<option v-for="n in nearby" :key="'n' + n.profile.id" :value="n.profile.id">
|
||||
附近 · {{ n.profile.display_name || '匿名' }} · {{ n.distance_km }}km
|
||||
</option>
|
||||
</select>
|
||||
<label class="yxg-label">推运日期</label>
|
||||
<input v-model="asOf" type="date" class="sel" />
|
||||
|
||||
<div class="social-row">
|
||||
<button type="button" class="yxg-btn yxg-btn-ghost" :disabled="!profileA || inviting" @click="createInvite">
|
||||
{{ inviting ? '生成中…' : '邀请好友合盘' }}
|
||||
</button>
|
||||
<button type="button" class="yxg-btn yxg-btn-ghost" :disabled="nearbyLoading" @click="loadNearby">
|
||||
{{ nearbyLoading ? '定位中…' : '附近的人' }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="invitePath" class="yxg-meta">邀请链接:{{ invitePath }}(可复制分享)</p>
|
||||
<p v-if="nearbyHint" class="yxg-meta">{{ nearbyHint }}</p>
|
||||
|
||||
<p v-if="profiles.length < 2 && nearby.length === 0" class="yxg-meta">
|
||||
需要至少两个档案。可先去
|
||||
<router-link class="yxg-link" to="/profile">档案页</router-link>
|
||||
添加 TA,或在下方快速创建临时档案。
|
||||
</p>
|
||||
<div class="yxg-card soft mini">
|
||||
<p class="card-title">快速添加 TA</p>
|
||||
<BirthDateInputs v-model:year="ty" v-model:month="tm" v-model:day="td" />
|
||||
<input v-model="tName" class="name-in" placeholder="称呼(如:TA)" />
|
||||
<button type="button" class="yxg-btn yxg-btn-ghost" :disabled="adding" @click="addTemp">
|
||||
{{ adding ? '添加中…' : '添加档案' }}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="yxg-btn yxg-btn-block mt"
|
||||
type="button"
|
||||
:disabled="loading || !profileA || !profileB"
|
||||
@click="generate"
|
||||
>
|
||||
{{ loading ? '合盘中…' : '开始合盘' }}
|
||||
</button>
|
||||
<p v-if="error" class="yxg-err">{{ error }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<p v-else-if="loading" class="yxg-hint">合盘计算中…</p>
|
||||
<template v-else-if="report">
|
||||
<p class="yxg-lead">{{ headline }}</p>
|
||||
<p class="yxg-meta">{{ oneLiner }}</p>
|
||||
<div class="indices">
|
||||
<div class="idx"><em>恋爱</em><strong>{{ love }}</strong></div>
|
||||
<div class="idx"><em>友情</em><strong>{{ friend }}</strong></div>
|
||||
<div class="idx"><em>婚姻</em><strong>{{ marriage }}</strong></div>
|
||||
</div>
|
||||
<p class="yxg-meta">{{ loveNote }} · 推运 {{ asOfLabel }}</p>
|
||||
|
||||
<div class="tabs" role="tablist">
|
||||
<button
|
||||
v-for="t in mainTabs"
|
||||
:key="t.key"
|
||||
type="button"
|
||||
class="tab"
|
||||
:class="{ on: mainTab === t.key }"
|
||||
@click="mainTab = t.key"
|
||||
>
|
||||
{{ t.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="needsSubTab" class="subtabs">
|
||||
<button type="button" class="stab" :class="{ on: subTab === 'natal' }" @click="subTab = 'natal'">本盘</button>
|
||||
<button type="button" class="stab" :class="{ on: subTab === 'prog' }" @click="subTab = 'prog'">次限</button>
|
||||
<template v-if="mainTab === 'marks'">
|
||||
<button type="button" class="stab" :class="{ on: marksWho === 'me' }" @click="marksWho = 'me'">我</button>
|
||||
<button type="button" class="stab" :class="{ on: marksWho === 'other' }" @click="marksWho = 'other'">TA</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 比较盘 -->
|
||||
<template v-if="mainTab === 'compare'">
|
||||
<p class="tip">{{ chartTip('compare') }}</p>
|
||||
<h3 class="sec">比较盘 · 我</h3>
|
||||
<NatalWheel v-if="planetsA.length" :planets="planetsA" :aspects="[]" :asc-lon="ascA" />
|
||||
<h3 class="sec">比较盘 · TA</h3>
|
||||
<NatalWheel v-if="planetsB.length" :planets="planetsB" :aspects="[]" :asc-lon="ascB" />
|
||||
<div v-if="aspectPreview.length" class="yxg-card soft">
|
||||
<h2 class="card-title">跨盘相位速览</h2>
|
||||
<p v-for="(a, i) in aspectPreview" :key="i" class="aline">{{ a.label }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 配对盘 -->
|
||||
<template v-else-if="mainTab === 'overlay'">
|
||||
<p class="tip">{{ overlayTip }}</p>
|
||||
<div class="yxg-card soft">
|
||||
<p v-for="(e, i) in overlayEntries" :key="i" class="aline">
|
||||
{{ e.planet }}({{ e.sign }})→ 我方 {{ e.house }} 宫 · {{ e.house_tip }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 单盘型 -->
|
||||
<template v-else>
|
||||
<p class="tip">{{ activeChartTip }}</p>
|
||||
<NatalWheel
|
||||
v-if="activePlanets.length"
|
||||
:planets="activePlanets"
|
||||
:aspects="[]"
|
||||
:asc-lon="activeAsc"
|
||||
/>
|
||||
<div v-if="activeAspectPreview.length" class="yxg-card soft">
|
||||
<h2 class="card-title">相位速览</h2>
|
||||
<p v-for="(a, i) in activeAspectPreview" :key="i" class="aline">{{ a.label }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="report.has_deep_access && detail" class="yxg-card">
|
||||
<h2 class="card-title">相处深析</h2>
|
||||
<div v-for="(s, i) in sections" :key="i" class="sec-block">
|
||||
<strong>{{ s.title }}</strong>
|
||||
<p>{{ s.body }}</p>
|
||||
</div>
|
||||
<ul>
|
||||
<li v-for="(a, i) in fullAspects" :key="'fa' + i">{{ a.label }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-else class="yxg-card lock">
|
||||
<p>完整相位、推运深文案与落宫解读可在深度版解锁。</p>
|
||||
<button class="yxg-btn" type="button" :disabled="paying" @click="buyDeep">解锁完整合盘(模拟支付)</button>
|
||||
</div>
|
||||
|
||||
<div class="links">
|
||||
<router-link class="yxg-link-plain" to="/star">回星座排盘 →</router-link>
|
||||
<router-link class="yxg-link-plain" to="/relation">人格匹配 →</router-link>
|
||||
<router-link v-if="report.id" class="yxg-link-plain" :to="`/reports/${report.id}`">在报告页打开 →</router-link>
|
||||
<button type="button" class="yxg-link-plain reset" @click="reset">再测一对</button>
|
||||
</div>
|
||||
<button type="button" class="yxg-btn yxg-btn-ghost share-btn" @click="shareOpen = true">生成分享卡</button>
|
||||
</template>
|
||||
<p class="yxg-disc">合盘指数为自我探索参考,不是占卜或算命。</p>
|
||||
</div>
|
||||
<ShareSheet :open="shareOpen" :payload="sharePayload" @close="shareOpen = false" />
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import type { GrowthReport, Profile } from '@yuxingu/types'
|
||||
import { validateBirth } from '@yuxingu/utils'
|
||||
import { api } from '../api/client'
|
||||
import BackButton from '../components/BackButton.vue'
|
||||
import BirthDateInputs from '../components/BirthDateInputs.vue'
|
||||
import NatalWheel, { type WheelPlanet } from '../components/NatalWheel.vue'
|
||||
import PageTitle from '../components/PageTitle.vue'
|
||||
import ShareSheet from '../components/ShareSheet.vue'
|
||||
import { AnalyticsEvent, track } from '../lib/analytics'
|
||||
import type { RelationSharePayload } from '../lib/shareLink'
|
||||
|
||||
type MainTab = 'compare' | 'composite' | 'davison' | 'marks' | 'overlay'
|
||||
|
||||
const loading = ref(false)
|
||||
const adding = ref(false)
|
||||
const paying = ref(false)
|
||||
const inviting = ref(false)
|
||||
const nearbyLoading = ref(false)
|
||||
const error = ref('')
|
||||
const nearbyHint = ref('')
|
||||
const invitePath = ref('')
|
||||
const profiles = ref<Profile[]>([])
|
||||
const nearby = ref<{ profile: Profile; distance_km: number }[]>([])
|
||||
const profileA = ref('')
|
||||
const profileB = ref('')
|
||||
const asOf = ref(new Date().toISOString().slice(0, 10))
|
||||
const report = ref<GrowthReport | null>(null)
|
||||
const shareOpen = ref(false)
|
||||
const ty = ref('')
|
||||
const tm = ref('')
|
||||
const td = ref('')
|
||||
const tName = ref('TA')
|
||||
const mainTab = ref<MainTab>('compare')
|
||||
const subTab = ref<'natal' | 'prog'>('natal')
|
||||
const marksWho = ref<'me' | 'other'>('me')
|
||||
|
||||
const mainTabs: { key: MainTab; label: string }[] = [
|
||||
{ key: 'compare', label: '比较' },
|
||||
{ key: 'composite', label: '组合' },
|
||||
{ key: 'davison', label: '时空' },
|
||||
{ key: 'marks', label: '马克斯' },
|
||||
{ key: 'overlay', label: '配对' },
|
||||
]
|
||||
|
||||
const needsSubTab = computed(() => ['composite', 'davison', 'marks'].includes(mainTab.value))
|
||||
|
||||
const summary = computed(() => (report.value?.summary || {}) as Record<string, unknown>)
|
||||
const detail = computed(() => (report.value?.detail || null) as Record<string, unknown> | null)
|
||||
const charts = computed(() => (summary.value.charts || {}) as Record<string, unknown>)
|
||||
const headline = computed(() => String(summary.value.headline || ''))
|
||||
const oneLiner = computed(() => String(summary.value.one_liner || ''))
|
||||
const love = computed(() => Number(summary.value.love_index || 0))
|
||||
const friend = computed(() => Number(summary.value.friend_index || 0))
|
||||
const marriage = computed(() => Number(summary.value.marriage_index || 0))
|
||||
const loveNote = computed(() => String(summary.value.love_note || ''))
|
||||
const asOfLabel = computed(() => String(summary.value.as_of || asOf.value))
|
||||
|
||||
function asPlanets(raw: unknown): WheelPlanet[] {
|
||||
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),
|
||||
element: o.element != null ? String(o.element) : undefined,
|
||||
modality: o.modality != null ? String(o.modality) : undefined,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function chartPlanets(key: 'chart_a' | 'chart_b'): WheelPlanet[] {
|
||||
const c = summary.value[key]
|
||||
if (!c || typeof c !== 'object') return []
|
||||
return asPlanets((c as { planets?: unknown }).planets)
|
||||
}
|
||||
|
||||
const planetsA = computed(() => chartPlanets('chart_a'))
|
||||
const planetsB = computed(() => chartPlanets('chart_b'))
|
||||
const ascA = computed(() => {
|
||||
const c = summary.value.chart_a as { asc_lon?: number } | undefined
|
||||
return typeof c?.asc_lon === 'number' ? c.asc_lon : null
|
||||
})
|
||||
const ascB = computed(() => {
|
||||
const c = summary.value.chart_b as { asc_lon?: number } | undefined
|
||||
return typeof c?.asc_lon === 'number' ? c.asc_lon : null
|
||||
})
|
||||
const aspectPreview = computed(() => {
|
||||
const raw = summary.value.aspects_preview
|
||||
return Array.isArray(raw) ? (raw as { label: string }[]) : []
|
||||
})
|
||||
|
||||
function chartBlock(key: string): Record<string, unknown> | null {
|
||||
const c = charts.value[key]
|
||||
if (!c || typeof c !== 'object') return null
|
||||
return c as Record<string, unknown>
|
||||
}
|
||||
|
||||
function chartTip(key: string): string {
|
||||
const c = chartBlock(key)
|
||||
return String(c?.tip || '')
|
||||
}
|
||||
|
||||
const activeChartKey = computed(() => {
|
||||
if (mainTab.value === 'composite') {
|
||||
return subTab.value === 'prog' ? 'composite_progressed' : 'composite'
|
||||
}
|
||||
if (mainTab.value === 'davison') {
|
||||
return subTab.value === 'prog' ? 'davison_progressed' : 'davison'
|
||||
}
|
||||
if (mainTab.value === 'marks') {
|
||||
if (subTab.value === 'prog') return 'marks_progressed'
|
||||
return marksWho.value === 'other' ? 'marks_other' : 'marks_me'
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const activePlanets = computed(() => {
|
||||
const c = chartBlock(activeChartKey.value)
|
||||
return asPlanets(c?.planets)
|
||||
})
|
||||
const activeAsc = computed(() => {
|
||||
const c = chartBlock(activeChartKey.value)
|
||||
return typeof c?.asc_lon === 'number' ? (c.asc_lon as number) : null
|
||||
})
|
||||
const activeAspectPreview = computed(() => {
|
||||
const c = chartBlock(activeChartKey.value)
|
||||
const raw = c?.aspects_preview
|
||||
return Array.isArray(raw) ? (raw as { label: string }[]) : []
|
||||
})
|
||||
const activeChartTip = computed(() => chartTip(activeChartKey.value))
|
||||
|
||||
const overlayTip = computed(() => String(chartBlock('overlay')?.tip || ''))
|
||||
const overlayEntries = computed(() => {
|
||||
const raw = chartBlock('overlay')?.entries
|
||||
return Array.isArray(raw)
|
||||
? (raw as { planet: string; sign: string; house: number; house_tip: string }[])
|
||||
: []
|
||||
})
|
||||
|
||||
const fullAspects = computed(() => {
|
||||
const raw = detail.value?.aspects
|
||||
return Array.isArray(raw) ? (raw as { label: string }[]) : []
|
||||
})
|
||||
const sections = computed(() => {
|
||||
const raw = detail.value?.sections
|
||||
return Array.isArray(raw) ? (raw as { title: string; body: string }[]) : []
|
||||
})
|
||||
const sharePayload = computed<RelationSharePayload | null>(() => {
|
||||
if (!report.value) return null
|
||||
return {
|
||||
type: 'relation',
|
||||
me: String(summary.value.me_name || '我'),
|
||||
other: String(summary.value.other_name || 'TA'),
|
||||
diff: headline.value,
|
||||
keywords: [`恋爱${love.value}`, `友情${friend.value}`, `婚姻${marriage.value}`],
|
||||
}
|
||||
})
|
||||
|
||||
function birthLabel(d?: string) {
|
||||
if (!d) return ''
|
||||
return String(d).slice(0, 10)
|
||||
}
|
||||
|
||||
async function loadProfiles() {
|
||||
try {
|
||||
const res = await api.listProfiles()
|
||||
profiles.value = res.items || []
|
||||
const self = profiles.value.find((p) => p.relation === 'self')
|
||||
if (self && !profileA.value) profileA.value = self.id
|
||||
const other = profiles.value.find((p) => p.id !== profileA.value)
|
||||
if (other && !profileB.value) profileB.value = other.id
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载档案失败'
|
||||
}
|
||||
}
|
||||
|
||||
async function addTemp() {
|
||||
const y = Number(ty.value)
|
||||
const m = Number(tm.value)
|
||||
const d = Number(td.value)
|
||||
const msg = validateBirth(y, m, d)
|
||||
if (msg) {
|
||||
error.value = msg
|
||||
return
|
||||
}
|
||||
adding.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const birth = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
|
||||
const p = await api.createProfile({
|
||||
relation: 'other',
|
||||
birth_date: birth,
|
||||
display_name: tName.value || 'TA',
|
||||
})
|
||||
await loadProfiles()
|
||||
profileB.value = p.id
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '添加失败'
|
||||
} finally {
|
||||
adding.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function generate() {
|
||||
if (!profileA.value || !profileB.value) return
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
report.value = await api.createSynastry(profileA.value, profileB.value, asOf.value)
|
||||
mainTab.value = 'compare'
|
||||
track(AnalyticsEvent.SynastryCompleted, { source: 'synastry' })
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '合盘失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createInvite() {
|
||||
if (!profileA.value) return
|
||||
inviting.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const res = await api.createSynastryInvite(profileA.value)
|
||||
invitePath.value = res.path
|
||||
track(AnalyticsEvent.SynastryInviteCreated, { token: res.token })
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '邀请失败'
|
||||
} finally {
|
||||
inviting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadNearby() {
|
||||
nearbyLoading.value = true
|
||||
nearbyHint.value = ''
|
||||
error.value = ''
|
||||
try {
|
||||
const pos = await new Promise<GeolocationPosition>((resolve, reject) => {
|
||||
if (!navigator.geolocation) {
|
||||
reject(new Error('当前环境不支持定位'))
|
||||
return
|
||||
}
|
||||
navigator.geolocation.getCurrentPosition(resolve, reject, { timeout: 8000 })
|
||||
})
|
||||
const lat = pos.coords.latitude
|
||||
const lng = pos.coords.longitude
|
||||
// Only save coordinates; visibility stays off unless user explicitly enables it.
|
||||
const self = profiles.value.find((p) => p.relation === 'self')
|
||||
if (self) {
|
||||
await api.updateProfile(self.id, { geo_lat: lat, geo_lng: lng })
|
||||
}
|
||||
const res = await api.listSynastryNearby(lat, lng, 50)
|
||||
nearby.value = res.items || []
|
||||
nearbyHint.value = nearby.value.length
|
||||
? `找到 ${nearby.value.length} 位附近可合盘对象。若希望别人看到你,请在档案中开启「位置可见」。`
|
||||
: '附近暂无已开启位置可见的档案;可邀请好友或手动添加。需要被看到时请在档案开启位置可见。'
|
||||
track(AnalyticsEvent.SynastryNearbyOpened, { count: nearby.value.length })
|
||||
} catch (e) {
|
||||
nearbyHint.value = e instanceof Error ? e.message : '定位失败,可手动选城后在档案页开启位置'
|
||||
} finally {
|
||||
nearbyLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function buyDeep() {
|
||||
if (!report.value) return
|
||||
paying.value = true
|
||||
track(AnalyticsEvent.DeepAccessClicked, { surface: 'synastry' })
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
report.value = null
|
||||
}
|
||||
|
||||
onMounted(loadProfiles)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.yxg-card{margin:8px 0 12px}
|
||||
.yxg-label{display:block;margin:12px 0 6px;font-size:13px;color:#666}
|
||||
.sel{
|
||||
width:100%;padding:12px;border:1.5px solid #eee;border-radius:12px;background:#fdfaf8;font-size:14px;
|
||||
}
|
||||
.mini{margin-top:12px}
|
||||
.name-in{
|
||||
width:100%;padding:10px;border:1.5px solid #eee;border-radius:10px;margin:8px 0;font-size:14px;
|
||||
}
|
||||
.mt{margin-top:14px}
|
||||
.social-row{display:flex;gap:8px;margin-top:12px;flex-wrap:wrap}
|
||||
.social-row .yxg-btn{flex:1;min-width:120px}
|
||||
.indices{display:flex;gap:10px;margin:14px 0}
|
||||
.idx{
|
||||
flex:1;text-align:center;background:#fff;border-radius:14px;padding:12px 8px;
|
||||
box-shadow:0 4px 16px rgba(0,0,0,.05);
|
||||
}
|
||||
.idx em{display:block;font-style:normal;font-size:11px;color:#999;margin-bottom:4px}
|
||||
.idx strong{font-size:22px;color:var(--yxg-pri)}
|
||||
.tabs{display:flex;gap:6px;flex-wrap:wrap;margin:12px 0 8px}
|
||||
.tab{
|
||||
border:1px solid #eee;background:#fff;border-radius:999px;padding:6px 12px;font-size:13px;cursor:pointer;color:#666;
|
||||
}
|
||||
.tab.on{background:var(--yxg-pri);color:#fff;border-color:transparent}
|
||||
.subtabs{display:flex;gap:6px;margin-bottom:8px;flex-wrap:wrap}
|
||||
.stab{
|
||||
border:none;background:#f3eeea;border-radius:8px;padding:5px 10px;font-size:12px;cursor:pointer;color:#555;
|
||||
}
|
||||
.stab.on{background:#2c2c2c;color:#fff}
|
||||
.tip{font-size:13px;color:#666;margin:4px 0 10px}
|
||||
.sec{font-size:14px;margin:16px 0 6px;color:#444}
|
||||
.aline{font-size:13px;color:#555;margin:4px 0}
|
||||
.sec-block{margin:10px 0}
|
||||
.sec-block p{margin:4px 0;color:#555;font-size:13px}
|
||||
.lock .yxg-btn,.share-btn{width:100%;margin-top:10px}
|
||||
.links{display:flex;flex-direction:column;gap:8px;margin:12px 0}
|
||||
.reset{background:none;border:none;text-align:left;cursor:pointer;padding:0;font:inherit;color:inherit}
|
||||
</style>
|
||||
Reference in New Issue
Block a user