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:
jackyu66git
2026-08-03 11:37:53 +08:00
co-authored by Cursor
parent 15a9db374a
commit bd22d9dddd
248 changed files with 26309 additions and 842 deletions
+120 -76
View File
@@ -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>