绑定 ESS 双轨治理,拆分超大 H5 页与 Go 引擎,抽出 membership 服务, 并将 star/fortune 重命名为 outlook(JSON 双写兼容);同时修复 /psy API 代理与首页 + 菜单层级。 Co-authored-by: Cursor <cursoragent@cursor.com>
397 lines
11 KiB
Vue
397 lines
11 KiB
Vue
<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>
|