Files
digital-psychology/apps/user-h5/src/components/synastry/SynastryLanding.vue
T
jackyu66gitandCursor 19d3cd5945 refactor(ECR-001): 接入 ESS 并完成结构对齐 Phase A–E
绑定 ESS 双轨治理,拆分超大 H5 页与 Go 引擎,抽出 membership 服务,
并将 star/fortune 重命名为 outlook(JSON 双写兼容);同时修复 /psy API 代理与首页 + 菜单层级。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 17:51:40 +08:00

397 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="synastry-landing">
<h2 class="hero-title">看看你和 TA 的默契指数</h2>
<div class="rel-chips" role="group" aria-label="关系类型">
<button
v-for="r in relationTypes"
:key="r"
type="button"
class="rel-chip"
:class="{ on: relationType === r }"
@click="$emit('update:relationType', r)"
>
{{ r }}
</button>
</div>
<div class="dual-pick">
<div class="pick-side self-side">
<div class="av-ring on">
<span class="av-inner">{{ selfInitial }}</span>
</div>
<span class="pick-label">自己</span>
<span v-if="profileAName" class="pick-name">{{ profileAName }}</span>
</div>
<span class="pick-link" aria-hidden="true">×</span>
<div class="pick-side ta-side">
<div class="scroll-profiles" role="list">
<button
v-for="p in pickableProfiles"
:key="p.id"
type="button"
class="profile-chip"
:class="{ on: profileB === p.id }"
role="listitem"
@click="$emit('update:profileB', p.id)"
>
<span class="av-ring sm" :class="{ on: profileB === p.id }">
<span class="av-inner sm">{{ profileInitial(p) }}</span>
</span>
<span class="chip-name">{{ p.display_name || '未命名' }}</span>
</button>
<button
v-for="n in nearby"
:key="'n' + n.profile.id"
type="button"
class="profile-chip"
:class="{ on: profileB === n.profile.id }"
@click="$emit('update:profileB', n.profile.id)"
>
<span class="av-ring sm nearby" :class="{ on: profileB === n.profile.id }">
<span class="av-inner sm"></span>
</span>
<span class="chip-name">{{ n.profile.display_name || '匿名' }}</span>
</button>
<button type="button" class="profile-chip add-chip" @click="$emit('update:showQuickAdd', !showQuickAdd)">
<span class="av-ring sm add">
<span class="av-inner sm">+</span>
</span>
<span class="chip-name">添加档案</span>
</button>
</div>
</div>
</div>
<div v-if="showQuickAdd" class="yxg-card soft add-form">
<p class="card-title">快速添加 TA</p>
<BirthDateInputs :year="ty" :month="tm" :day="td" @update:year="$emit('update:ty', $event)" @update:month="$emit('update:tm', $event)" @update:day="$emit('update:td', $event)" />
<input :value="tName" class="name-in" placeholder="称呼(如:TA" @input="$emit('update:tName', ($event.target as HTMLInputElement).value)" />
<button type="button" class="yxg-btn yxg-btn-ghost" :disabled="adding" @click="$emit('add-temp')">
{{ adding ? '添加中…' : '添加档案' }}
</button>
</div>
<button
class="yxg-btn yxg-btn-block cta-main"
type="button"
:disabled="loading || !profileA || !profileB"
@click="$emit('generate')"
>
{{ loading ? '合盘中…' : '立即合盘' }}
</button>
<button type="button" class="toggle-classic" @click="$emit('update:showClassicSelects', !showClassicSelects)">
{{ showClassicSelects ? '收起档案选择' : '直接选择档案' }}
</button>
<div v-if="showClassicSelects" class="yxg-card soft classic-panel">
<label class="yxg-label">档案 A</label>
<select :value="profileA" class="sel" @change="$emit('update:profileA', ($event.target as HTMLSelectElement).value)">
<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 :value="profileB" class="sel" @change="$emit('update:profileB', ($event.target as HTMLSelectElement).value)">
<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 :value="asOf" type="date" class="sel" @input="$emit('update:asOf', ($event.target as HTMLInputElement).value)" />
</div>
<div class="social-row" :class="{ pulse: inviteHighlight }">
<button type="button" class="yxg-btn yxg-btn-ghost" :disabled="!profileA || inviting" @click="$emit('create-invite')">
{{ inviting ? '生成中…' : '邀请好友合盘' }}
</button>
<button type="button" class="yxg-btn yxg-btn-ghost" :disabled="nearbyLoading" @click="$emit('load-nearby')">
{{ nearbyLoading ? '定位中…' : '附近的人' }}
</button>
</div>
<p v-if="invitePath" class="yxg-meta invite-path">邀请链接{{ 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>
<SynastryFeatureCards />
<p v-if="error" class="yxg-err">{{ error }}</p>
</div>
</template>
<script setup lang="ts">
import type { Profile } from '@yuxingu/types'
import BirthDateInputs from '../BirthDateInputs.vue'
import SynastryFeatureCards from './SynastryFeatureCards.vue'
import { relationTypes } from '../../composables/useSynastryPage'
defineProps<{
relationType: string
selfInitial: string
profileAName: string
pickableProfiles: Profile[]
profileB: string
nearby: { profile: Profile; distance_km: number }[]
showQuickAdd: boolean
ty: string
tm: string
td: string
tName: string
adding: boolean
loading: boolean
profileA: string
showClassicSelects: boolean
profiles: Profile[]
asOf: string
inviteHighlight: boolean
inviting: boolean
nearbyLoading: boolean
invitePath: string
nearbyHint: string
error: string
profileInitial: (p: Profile) => string
birthLabel: (d?: string) => string
}>()
defineEmits<{
'update:relationType': [value: string]
'update:profileB': [value: string]
'update:showQuickAdd': [value: boolean]
'update:ty': [value: string]
'update:tm': [value: string]
'update:td': [value: string]
'update:tName': [value: string]
'update:showClassicSelects': [value: boolean]
'update:profileA': [value: string]
'update:asOf': [value: string]
'add-temp': []
generate: []
'create-invite': []
'load-nearby': []
}>()
</script>
<style scoped>
.hero-title {
font-family: var(--font-display);
font-size: 20px;
font-weight: 700;
color: var(--color-text-primary);
text-align: center;
margin: 4px 0 16px;
letter-spacing: 0.04em;
}
.rel-chips {
display: flex;
gap: 8px;
justify-content: center;
flex-wrap: wrap;
margin-bottom: 20px;
}
.rel-chip {
border: 1.5px solid #eee;
background: #fff;
border-radius: 999px;
padding: 7px 16px;
font-size: 13px;
color: #666;
cursor: pointer;
transition: all var(--duration-fast) ease;
}
.rel-chip.on {
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
color: #fff;
border-color: transparent;
box-shadow: 0 4px 12px rgba(229, 77, 66, 0.22);
}
.dual-pick {
display: flex;
align-items: flex-start;
gap: 10px;
margin-bottom: 16px;
}
.pick-side {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.self-side {
flex-shrink: 0;
width: 72px;
}
.ta-side {
flex: 1;
min-width: 0;
align-items: stretch;
}
.pick-link {
flex-shrink: 0;
margin-top: 18px;
font-size: 18px;
color: var(--color-primary);
opacity: 0.5;
}
.pick-label {
font-size: 12px;
font-weight: 600;
color: var(--color-text-primary);
}
.pick-name {
font-size: 10px;
color: var(--color-text-tertiary);
max-width: 68px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.av-ring {
width: 56px;
height: 56px;
border-radius: 50%;
background: linear-gradient(145deg, #ffe8e0, #ffd0c4);
display: flex;
align-items: center;
justify-content: center;
border: 2px solid transparent;
box-shadow: 0 4px 14px rgba(229, 77, 66, 0.1);
}
.av-ring.sm {
width: 44px;
height: 44px;
}
.av-ring.on {
border-color: var(--color-primary);
box-shadow: 0 4px 14px rgba(229, 77, 66, 0.25);
}
.av-ring.add {
background: #fff;
border: 2px dashed rgba(229, 77, 66, 0.35);
}
.av-ring.nearby {
background: linear-gradient(145deg, #e8f4ff, #b4d2f5);
}
.av-inner {
font-size: 18px;
font-weight: 700;
color: #8a4a3a;
}
.av-inner.sm {
font-size: 14px;
}
.scroll-profiles {
display: flex;
gap: 10px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
padding: 2px 0 4px;
}
.scroll-profiles::-webkit-scrollbar {
display: none;
}
.profile-chip {
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
border: none;
background: transparent;
cursor: pointer;
padding: 0;
min-width: 52px;
}
.chip-name {
font-size: 10px;
color: #666;
max-width: 52px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.profile-chip.on .chip-name {
color: var(--color-primary);
font-weight: 600;
}
.add-form {
margin-bottom: 12px;
}
.name-in {
width: 100%;
padding: 10px;
border: 1.5px solid #eee;
border-radius: 10px;
margin: 8px 0;
font-size: 14px;
}
.cta-main {
margin-top: 4px;
font-size: 16px;
font-weight: 700;
padding: 14px;
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
box-shadow: 0 6px 18px rgba(229, 77, 66, 0.28);
}
.toggle-classic {
display: block;
width: 100%;
margin: 10px 0;
border: none;
background: transparent;
font-size: 12px;
color: rgba(0, 0, 0, 0.42);
text-decoration: underline;
text-underline-offset: 3px;
cursor: pointer;
}
.classic-panel {
margin-bottom: 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;
}
.social-row {
display: flex;
gap: 8px;
margin-top: 8px;
flex-wrap: wrap;
}
.social-row.pulse {
animation: invitePulse 1.2s ease 2;
border-radius: 14px;
padding: 6px;
background: rgba(229, 77, 66, 0.06);
}
@keyframes invitePulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(229, 77, 66, 0); }
50% { box-shadow: 0 0 0 4px rgba(229, 77, 66, 0.12); }
}
.social-row .yxg-btn {
flex: 1;
min-width: 120px;
}
.invite-path { word-break: break-all; }
</style>