refactor(ECR-001): 接入 ESS 并完成结构对齐 Phase A–E
绑定 ESS 双轨治理,拆分超大 H5 页与 Go 引擎,抽出 membership 服务, 并将 star/fortune 重命名为 outlook(JSON 双写兼容);同时修复 /psy API 代理与首页 + 菜单层级。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -2,6 +2,7 @@ import { createClient, createBrowserAdapters } from '@yuxingu/sdk'
|
||||
|
||||
/** Shared API client for user-h5 (proxied to Go in dev). */
|
||||
export const api = createClient({
|
||||
// SDK paths already include `/api/v1/...`; with SPA base `/psy/` → `/psy/api/v1/...`
|
||||
baseURL: '/psy',
|
||||
adapters: createBrowserAdapters(),
|
||||
})
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
<template>
|
||||
<div class="archive reveal" style="--d: 40ms">
|
||||
<router-link to="/portrait" class="av-self" aria-label="自己的档案">
|
||||
<span class="av" aria-hidden="true">
|
||||
<BrandLogo size="card" class="av-logo" />
|
||||
</span>
|
||||
<span class="av-name">自己</span>
|
||||
</router-link>
|
||||
<button type="button" class="av-add" aria-label="添加档案" @click="$emit('add')">
|
||||
<span class="plus-dot" aria-hidden="true">+</span>
|
||||
<span class="av-name">添加</span>
|
||||
</button>
|
||||
<router-link to="/profile" class="archive-tail" aria-label="档案列表">
|
||||
<span class="list-ico" aria-hidden="true" />
|
||||
<span class="chev" aria-hidden="true">›</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import BrandLogo from '../BrandLogo.vue'
|
||||
|
||||
defineEmits<{ add: [] }>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.archive {
|
||||
margin: 12px 16px 0;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-xl);
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
border: 1px solid rgba(255, 255, 255, 0.95);
|
||||
box-shadow: 0 6px 20px rgba(229, 77, 66, 0.08);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
.av-self,
|
||||
.av-add {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.av-self:active,
|
||||
.av-add:active {
|
||||
transform: scale(0.94);
|
||||
}
|
||||
.av {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(145deg, #ffe8e0, #ffd0c4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7);
|
||||
border: 2px solid rgba(229, 77, 66, 0.35);
|
||||
}
|
||||
.av-logo {
|
||||
width: 22px !important;
|
||||
height: auto !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
.av-name {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #8a4a3a;
|
||||
line-height: 1;
|
||||
}
|
||||
.plus-dot {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 236, 230, 0.95);
|
||||
border: 1.5px dashed rgba(229, 77, 66, 0.35);
|
||||
color: var(--color-primary);
|
||||
font-size: 22px;
|
||||
font-weight: 500;
|
||||
line-height: 37px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.archive-tail {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #c4a090;
|
||||
flex-shrink: 0;
|
||||
padding: 8px 4px;
|
||||
}
|
||||
.list-ico {
|
||||
width: 14px;
|
||||
height: 12px;
|
||||
border: 1.5px solid currentColor;
|
||||
border-radius: 2px;
|
||||
position: relative;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.list-ico::before,
|
||||
.list-ico::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 2px;
|
||||
right: 2px;
|
||||
height: 1.5px;
|
||||
background: currentColor;
|
||||
border-radius: 1px;
|
||||
}
|
||||
.list-ico::before {
|
||||
top: 3px;
|
||||
}
|
||||
.list-ico::after {
|
||||
top: 6.5px;
|
||||
}
|
||||
.chev {
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
.reveal {
|
||||
animation: rise 0.45s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
animation-delay: var(--d, 0ms);
|
||||
}
|
||||
@keyframes rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.reveal {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,172 @@
|
||||
<template>
|
||||
<section class="section">
|
||||
<div class="sec-head">
|
||||
<span class="title">今日推荐</span>
|
||||
<router-link class="more" to="/explore">更多</router-link>
|
||||
</div>
|
||||
<div class="feed-grid">
|
||||
<router-link
|
||||
v-for="f in feeds"
|
||||
:key="f.to + f.title"
|
||||
: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">
|
||||
<span class="cover-orb" />
|
||||
<HomeToolIcon :name="f.icon" :size="48" />
|
||||
</div>
|
||||
<div class="feed-body">
|
||||
<div class="name">{{ f.title }}</div>
|
||||
<div class="meta">{{ f.meta }}</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat">{{ f.stat }}</span>
|
||||
<span class="go" aria-hidden="true">→</span>
|
||||
</div>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import HomeToolIcon from '../HomeToolIcon.vue'
|
||||
import type { HomeFeed } from '../../lib/homeCatalog'
|
||||
|
||||
defineProps<{ feeds: HomeFeed[] }>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.section {
|
||||
padding: 0 var(--spacing-md);
|
||||
margin-top: 18px;
|
||||
}
|
||||
.sec-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.sec-head .title {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.sec-head .more {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
.sec-head .more::after {
|
||||
content: ' ›';
|
||||
}
|
||||
.feed-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
.feed-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
background: var(--color-surface);
|
||||
border-radius: 16px;
|
||||
color: var(--color-text-primary);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-height: 168px;
|
||||
box-shadow: var(--shadow-card);
|
||||
border: 1px solid rgba(0, 0, 0, 0.03);
|
||||
transition: transform var(--duration-fast) ease, box-shadow var(--duration-fast) ease;
|
||||
}
|
||||
.feed-card:active {
|
||||
transform: scale(0.985);
|
||||
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
.feed-cover {
|
||||
width: 100%;
|
||||
height: 86px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.cover-orb {
|
||||
position: absolute;
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
border-radius: 50%;
|
||||
right: -18px;
|
||||
bottom: -28px;
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
pointer-events: none;
|
||||
}
|
||||
.feed-body {
|
||||
padding: 10px 11px 12px;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.feed-body .name {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
line-height: 1.3;
|
||||
}
|
||||
.feed-body .meta {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-secondary);
|
||||
margin-top: 4px;
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
}
|
||||
.stat-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.stat-row .stat {
|
||||
font-size: 10px;
|
||||
color: #c4c4c4;
|
||||
}
|
||||
.stat-row .go {
|
||||
font-size: 12px;
|
||||
color: var(--color-primary);
|
||||
opacity: 0.7;
|
||||
}
|
||||
.feed-tag {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
z-index: 1;
|
||||
font-size: 10px;
|
||||
font-weight: 650;
|
||||
color: #fff;
|
||||
background: var(--color-primary);
|
||||
padding: 2px 7px;
|
||||
border-radius: 7px;
|
||||
box-shadow: 0 2px 6px rgba(229, 77, 66, 0.25);
|
||||
}
|
||||
.fc-a .feed-cover {
|
||||
background: linear-gradient(155deg, #ffd8d0, #ffb4a8);
|
||||
color: #c23b32;
|
||||
}
|
||||
.fc-b .feed-cover {
|
||||
background: linear-gradient(155deg, #e8dff8, #cbb8f0);
|
||||
color: #5b4a8a;
|
||||
}
|
||||
.fc-c .feed-cover {
|
||||
background: linear-gradient(155deg, #d8eaff, #b4d2f5);
|
||||
color: #3a6fb0;
|
||||
}
|
||||
.fc-e .feed-cover {
|
||||
background: linear-gradient(155deg, #ffe9cc, #f5cc8a);
|
||||
color: #b07a28;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<section class="section promo-sec">
|
||||
<div class="promo-pair">
|
||||
<router-link to="/explore" class="promo p-plaza">
|
||||
<div class="p-copy">
|
||||
<div class="p-title">探索广场</div>
|
||||
<div class="p-sub">测评 · 工具 · 自我理解</div>
|
||||
</div>
|
||||
<span class="p-deco" aria-hidden="true" />
|
||||
<span class="p-chip">热</span>
|
||||
</router-link>
|
||||
<router-link to="/membership" class="promo p-vip">
|
||||
<div class="p-copy">
|
||||
<div class="p-title">成长会员</div>
|
||||
<div class="p-sub">深度报告与全年陪伴</div>
|
||||
</div>
|
||||
<span class="p-deco" aria-hidden="true" />
|
||||
<span class="p-chip soft">新</span>
|
||||
</router-link>
|
||||
</div>
|
||||
<p class="ai-note">部分内容由 AI 生成,仅供参考</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.section {
|
||||
padding: 0 var(--spacing-md);
|
||||
margin-top: 18px;
|
||||
}
|
||||
.promo-pair {
|
||||
display: grid;
|
||||
grid-template-columns: 1.12fr 0.88fr;
|
||||
gap: 10px;
|
||||
}
|
||||
.promo {
|
||||
position: relative;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 16px 14px;
|
||||
min-height: 96px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
transition: transform var(--duration-fast) ease;
|
||||
}
|
||||
.promo:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
.p-copy {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.promo .p-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.promo .p-sub {
|
||||
font-size: 11px;
|
||||
color: rgba(0, 0, 0, 0.42);
|
||||
margin-top: 5px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.p-deco {
|
||||
position: absolute;
|
||||
right: -12px;
|
||||
bottom: -18px;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
opacity: 0.55;
|
||||
pointer-events: none;
|
||||
}
|
||||
.p-plaza {
|
||||
background: linear-gradient(145deg, #ffe9e2 0%, #ffd4c8 55%, #ffc8ba 100%);
|
||||
box-shadow: 0 4px 14px rgba(229, 77, 66, 0.1);
|
||||
}
|
||||
.p-plaza .p-deco {
|
||||
background: radial-gradient(circle at 35% 35%, #fff 0%, #ffb0a0 55%, transparent 70%);
|
||||
}
|
||||
.p-vip {
|
||||
background: linear-gradient(145deg, #fff4e0 0%, #ffe8b8 55%, #ffd98a 100%);
|
||||
box-shadow: 0 4px 14px rgba(200, 146, 58, 0.12);
|
||||
}
|
||||
.p-vip .p-deco {
|
||||
background: radial-gradient(circle at 35% 35%, #fff 0%, #f0c86a 55%, transparent 70%);
|
||||
}
|
||||
.p-chip {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 1;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
|
||||
padding: 2px 6px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.p-chip.soft {
|
||||
background: linear-gradient(135deg, #6eb6ff, #4a90e2);
|
||||
}
|
||||
.ai-note {
|
||||
margin-top: 10px;
|
||||
font-size: 10px;
|
||||
color: #ccc;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,215 @@
|
||||
<template>
|
||||
<section class="self-card reveal" style="--d: 80ms" @click="$emit('open-portrait')">
|
||||
<div class="self-head">
|
||||
<button type="button" class="who-btn" @click.stop="$emit('open-profile')">
|
||||
{{ profileLabel }}
|
||||
<span class="caret" aria-hidden="true">▾</span>
|
||||
</button>
|
||||
<router-link class="more" to="/profile" @click.stop>更多</router-link>
|
||||
</div>
|
||||
|
||||
<div class="self-body">
|
||||
<div class="mood-col">
|
||||
<div class="mood-label">
|
||||
今日心情 <em>{{ mood.score }}</em><span>分</span>
|
||||
</div>
|
||||
<p class="mood-text">{{ mood.text }}</p>
|
||||
</div>
|
||||
<div class="dims" role="list">
|
||||
<div
|
||||
v-for="d in mood.dims"
|
||||
:key="d.key"
|
||||
class="dim"
|
||||
role="listitem"
|
||||
:style="{ '--bar': d.color, '--h': d.score + '%' }"
|
||||
>
|
||||
<b class="dim-score">{{ d.score }}</b>
|
||||
<div class="dim-track">
|
||||
<i class="dim-fill" />
|
||||
</div>
|
||||
<span class="dim-label">{{ d.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
profileLabel: string
|
||||
mood: {
|
||||
score: number
|
||||
text: string
|
||||
dims: { key: string; label: string; score: number; color: string }[]
|
||||
}
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'open-portrait': []
|
||||
'open-profile': []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.self-card {
|
||||
margin: 12px 16px 0;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
padding: 14px 14px 16px;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-hero);
|
||||
transition: transform var(--duration-fast) ease;
|
||||
}
|
||||
.self-card:active {
|
||||
transform: scale(0.99);
|
||||
}
|
||||
.self-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.who-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
.caret {
|
||||
font-size: 10px;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
.self-head .more {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
.self-head .more::after {
|
||||
content: ' ›';
|
||||
}
|
||||
.self-body {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: stretch;
|
||||
}
|
||||
.mood-col {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding-right: 4px;
|
||||
}
|
||||
.mood-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
line-height: 1.2;
|
||||
}
|
||||
.mood-label em {
|
||||
font-style: normal;
|
||||
font-family: var(--font-display);
|
||||
font-size: 34px;
|
||||
font-weight: 700;
|
||||
color: var(--color-primary);
|
||||
margin: 0 1px 0 4px;
|
||||
letter-spacing: -0.02em;
|
||||
vertical-align: -4px;
|
||||
}
|
||||
.mood-label span {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-tertiary);
|
||||
font-weight: 500;
|
||||
}
|
||||
.mood-text {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #777;
|
||||
line-height: 1.55;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.dims {
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
align-items: flex-end;
|
||||
flex-shrink: 0;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
.dim {
|
||||
width: 28px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.dim-score {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
line-height: 1;
|
||||
}
|
||||
.dim-track {
|
||||
width: 12px;
|
||||
height: 64px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: #f3ece8;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
.dim-fill {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: var(--h);
|
||||
border-radius: var(--radius-pill);
|
||||
background: linear-gradient(180deg, color-mix(in srgb, var(--bar) 70%, #fff) 0%, var(--bar) 100%);
|
||||
box-shadow: 0 -1px 0 rgba(255, 255, 255, 0.35) inset;
|
||||
animation: fillUp 0.7s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
animation-delay: calc(var(--d, 80ms) + 120ms);
|
||||
}
|
||||
@keyframes fillUp {
|
||||
from {
|
||||
height: 0;
|
||||
}
|
||||
to {
|
||||
height: var(--h);
|
||||
}
|
||||
}
|
||||
.dim-label {
|
||||
font-size: 10px;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.reveal {
|
||||
animation: rise 0.45s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
animation-delay: var(--d, 0ms);
|
||||
}
|
||||
@keyframes rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.reveal,
|
||||
.dim-fill {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,133 @@
|
||||
<template>
|
||||
<section class="section grid-sec">
|
||||
<div class="tool-scroll" aria-label="功能入口">
|
||||
<div class="tool-rows">
|
||||
<div class="tool-row">
|
||||
<router-link
|
||||
v-for="t in row1"
|
||||
:key="t.to + t.label"
|
||||
:to="t.to"
|
||||
class="tool-item"
|
||||
@click="$emit('track', t.label)"
|
||||
>
|
||||
<div class="icon" aria-hidden="true">
|
||||
<HomeToolIcon :name="t.icon" />
|
||||
</div>
|
||||
<div class="label">{{ t.label }}</div>
|
||||
<span v-if="t.badge" class="badge" :class="'b-' + (t.badgeTone || 'hot')">{{ t.badge }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="tool-row">
|
||||
<router-link
|
||||
v-for="t in row2"
|
||||
:key="t.to + t.label"
|
||||
:to="t.to"
|
||||
class="tool-item"
|
||||
@click="$emit('track', t.label)"
|
||||
>
|
||||
<div class="icon" aria-hidden="true">
|
||||
<HomeToolIcon :name="t.icon" />
|
||||
</div>
|
||||
<div class="label">{{ t.label }}</div>
|
||||
<span v-if="t.badge" class="badge" :class="'b-' + (t.badgeTone || 'hot')">{{ t.badge }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import HomeToolIcon from '../HomeToolIcon.vue'
|
||||
import type { HomeTool } from '../../lib/homeCatalog'
|
||||
|
||||
defineProps<{
|
||||
row1: HomeTool[]
|
||||
row2: HomeTool[]
|
||||
}>()
|
||||
|
||||
defineEmits<{ track: [label: string] }>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.section {
|
||||
padding: 0 var(--spacing-md);
|
||||
margin-top: 18px;
|
||||
}
|
||||
.grid-sec {
|
||||
padding-right: 0;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.tool-scroll {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
padding-right: var(--spacing-md);
|
||||
}
|
||||
.tool-scroll::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.tool-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
width: max-content;
|
||||
padding: 4px 0 2px;
|
||||
}
|
||||
.tool-row {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
.tool-item {
|
||||
width: 74px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--color-text-primary);
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
transition: transform var(--duration-fast) ease;
|
||||
}
|
||||
.tool-item:active {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
.tool-item .icon {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
overflow: visible;
|
||||
}
|
||||
.tool-item .label {
|
||||
font-size: 11px;
|
||||
color: #555;
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
font-weight: 500;
|
||||
}
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
right: 6px;
|
||||
font-size: 9px;
|
||||
color: #fff;
|
||||
padding: 2px 5px;
|
||||
border-radius: 7px;
|
||||
line-height: 1.3;
|
||||
font-weight: 650;
|
||||
box-shadow: 0 2px 6px rgba(229, 77, 66, 0.25);
|
||||
z-index: 2;
|
||||
}
|
||||
.badge.b-hot {
|
||||
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
|
||||
}
|
||||
.badge.b-new {
|
||||
background: linear-gradient(135deg, #6eb6ff, #4a90e2);
|
||||
box-shadow: 0 2px 6px rgba(74, 144, 226, 0.28);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,251 @@
|
||||
<template>
|
||||
<header class="top reveal" :class="{ 'top--menu-open': plusOpen }" style="--d: 0ms">
|
||||
<router-link to="/growth-plan" class="ico-btn" aria-label="每日打卡">
|
||||
<span class="ico-mark">日</span>
|
||||
</router-link>
|
||||
<button type="button" class="search" @click="$emit('search')">
|
||||
<span class="search-ico" aria-hidden="true" />
|
||||
<span class="search-ph">{{ searchHint }}</span>
|
||||
</button>
|
||||
<div class="plus-wrap">
|
||||
<button
|
||||
type="button"
|
||||
class="ico-btn"
|
||||
aria-label="添加档案"
|
||||
aria-haspopup="menu"
|
||||
:aria-expanded="plusOpen"
|
||||
@click="$emit('update:plusOpen', !plusOpen)"
|
||||
>
|
||||
<span class="ico-mark plus">+</span>
|
||||
</button>
|
||||
<div v-if="plusOpen" class="plus-mask" @click="$emit('update:plusOpen', false)" />
|
||||
<div v-if="plusOpen" class="plus-menu" role="menu">
|
||||
<button type="button" class="plus-item" role="menuitem" @click="$emit('plus', 'inviteFill')">
|
||||
<span class="plus-ico pi-invite" aria-hidden="true" />
|
||||
<span>邀请好友填档案</span>
|
||||
</button>
|
||||
<button type="button" class="plus-item" role="menuitem" @click="$emit('plus', 'add')">
|
||||
<span class="plus-ico pi-add" aria-hidden="true" />
|
||||
<span>添加档案</span>
|
||||
</button>
|
||||
<button type="button" class="plus-item" role="menuitem" @click="$emit('plus', 'synastry')">
|
||||
<span class="plus-ico pi-heart" aria-hidden="true" />
|
||||
<span>邀请好友合盘</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
searchHint: string
|
||||
plusOpen: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
search: []
|
||||
'update:plusOpen': [value: boolean]
|
||||
plus: [kind: 'inviteFill' | 'add' | 'synastry']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.top {
|
||||
display: grid;
|
||||
grid-template-columns: 40px 1fr 40px;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 12px 14px 0;
|
||||
position: relative;
|
||||
/* Must sit above .sheet (z-index: 3) or the + menu is covered */
|
||||
z-index: 10;
|
||||
}
|
||||
.top--menu-open {
|
||||
z-index: 50;
|
||||
}
|
||||
.ico-btn {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 13px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.95);
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 14px rgba(180, 90, 70, 0.08);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
transition: transform var(--duration-fast) ease;
|
||||
}
|
||||
.ico-btn:active {
|
||||
transform: scale(0.94);
|
||||
}
|
||||
.ico-mark {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.ico-mark.plus {
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
color: #666;
|
||||
}
|
||||
.plus-wrap {
|
||||
position: relative;
|
||||
z-index: 20;
|
||||
}
|
||||
.plus-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
background: transparent;
|
||||
}
|
||||
.plus-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
z-index: 50;
|
||||
min-width: 176px;
|
||||
padding: 8px 0;
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
border: 1px solid rgba(255, 255, 255, 0.95);
|
||||
box-shadow: 0 12px 32px rgba(120, 60, 50, 0.16);
|
||||
animation: plusPop 0.2s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
.plus-menu::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: 14px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: inherit;
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.95);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.95);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
@keyframes plusPop {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px) scale(0.96);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
.plus-item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 11px 14px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
text-align: left;
|
||||
}
|
||||
.plus-item:active {
|
||||
background: rgba(229, 77, 66, 0.06);
|
||||
}
|
||||
.plus-ico {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 9px;
|
||||
flex-shrink: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
.pi-invite {
|
||||
background: linear-gradient(145deg, #5ecf8a, #3bb56e);
|
||||
}
|
||||
.pi-invite::after {
|
||||
content: '邀';
|
||||
}
|
||||
.pi-add {
|
||||
background: linear-gradient(145deg, #ffb45c, #f08a3a);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.pi-add::after {
|
||||
content: '档';
|
||||
}
|
||||
.pi-heart {
|
||||
background: linear-gradient(145deg, #ff8aa8, #e54d7a);
|
||||
}
|
||||
.pi-heart::after {
|
||||
content: '合';
|
||||
}
|
||||
.search {
|
||||
height: 38px;
|
||||
border-radius: var(--radius-pill);
|
||||
border: 1px solid rgba(255, 255, 255, 0.95);
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
padding: 0 14px 0 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-align: left;
|
||||
box-shadow: 0 4px 16px rgba(180, 90, 70, 0.07);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
transition: background var(--duration-fast) ease;
|
||||
}
|
||||
.search:active {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
.search-ico {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
border: 1.5px solid rgba(0, 0, 0, 0.28);
|
||||
border-radius: 50%;
|
||||
position: relative;
|
||||
}
|
||||
.search-ico::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 6px;
|
||||
height: 1.5px;
|
||||
background: rgba(0, 0, 0, 0.28);
|
||||
border-radius: 1px;
|
||||
right: -3px;
|
||||
bottom: -1px;
|
||||
transform: rotate(45deg);
|
||||
transform-origin: left center;
|
||||
}
|
||||
.search-ph {
|
||||
font-size: 13px;
|
||||
color: rgba(0, 0, 0, 0.36);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.reveal {
|
||||
animation: rise 0.45s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
animation-delay: var(--d, 0ms);
|
||||
}
|
||||
@keyframes rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.reveal {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<div class="feature-pair">
|
||||
<div class="feature-card fc-a">
|
||||
<HomeToolIcon name="synastry" :size="40" />
|
||||
<div class="fc-body">
|
||||
<strong>多类别关系分析</strong>
|
||||
<p>恋爱、友情、婚姻指数一次看清,五主盘与次限推运同屏对比。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="feature-card fc-b">
|
||||
<HomeToolIcon name="relation" :size="40" />
|
||||
<div class="fc-body">
|
||||
<strong>TA 的雷点与建议</strong>
|
||||
<p>相处深析与相位解读,帮你理解彼此差异与磨合方向。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import HomeToolIcon from '../HomeToolIcon.vue'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.feature-pair {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
margin: 20px 0 12px;
|
||||
}
|
||||
.feature-card {
|
||||
border-radius: 16px;
|
||||
padding: 14px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-height: 140px;
|
||||
box-shadow: var(--shadow-card);
|
||||
border: 1px solid rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
.fc-a {
|
||||
background: linear-gradient(155deg, #ffd8d0, #ffb4a8);
|
||||
}
|
||||
.fc-b {
|
||||
background: linear-gradient(155deg, #d8eaff, #b4d2f5);
|
||||
}
|
||||
.fc-body strong {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-primary);
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.fc-body p {
|
||||
font-size: 11px;
|
||||
color: rgba(0, 0, 0, 0.52);
|
||||
line-height: 1.45;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,396 @@
|
||||
<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>
|
||||
@@ -0,0 +1,284 @@
|
||||
<template>
|
||||
<div class="synastry-result">
|
||||
<div class="result-hero yxg-card">
|
||||
<p class="result-headline">{{ 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 note">{{ loveNote }} · 推运 {{ asOfLabel }}</p>
|
||||
</div>
|
||||
|
||||
<div class="yxg-card chart-card">
|
||||
<div class="tabs" role="tablist">
|
||||
<button
|
||||
v-for="t in mainTabs"
|
||||
:key="t.key"
|
||||
type="button"
|
||||
class="tab"
|
||||
:class="{ on: mainTab === t.key }"
|
||||
@click="$emit('update:mainTab', t.key)"
|
||||
>
|
||||
{{ t.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="needsSubTab" class="subtabs">
|
||||
<button type="button" class="stab" :class="{ on: subTab === 'natal' }" @click="$emit('update:subTab', 'natal')">本盘</button>
|
||||
<button type="button" class="stab" :class="{ on: subTab === 'prog' }" @click="$emit('update:subTab', 'prog')">次限</button>
|
||||
<template v-if="mainTab === 'marks'">
|
||||
<button type="button" class="stab" :class="{ on: marksWho === 'me' }" @click="$emit('update:marksWho', 'me')">我</button>
|
||||
<button type="button" class="stab" :class="{ on: marksWho === 'other' }" @click="$emit('update: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 inset">
|
||||
<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 inset">
|
||||
<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 inset">
|
||||
<h2 class="card-title">相位速览</h2>
|
||||
<p v-for="(a, i) in activeAspectPreview" :key="i" class="aline">{{ a.label }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<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="$emit('buy-deep')">解锁完整合盘(模拟支付)</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="$emit('reset')">再测一对</button>
|
||||
</div>
|
||||
<button type="button" class="yxg-btn yxg-btn-ghost share-btn" @click="$emit('update:shareOpen', true)">生成分享卡</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { GrowthReport } from '@yuxingu/types'
|
||||
import NatalWheel, { type WheelPlanet } from '../NatalWheel.vue'
|
||||
import { mainTabs, type MainTab } from '../../composables/useSynastryPage'
|
||||
|
||||
defineProps<{
|
||||
headline: string
|
||||
oneLiner: string
|
||||
love: number
|
||||
friend: number
|
||||
marriage: number
|
||||
loveNote: string
|
||||
asOfLabel: string
|
||||
mainTab: MainTab
|
||||
needsSubTab: boolean
|
||||
subTab: 'natal' | 'prog'
|
||||
marksWho: 'me' | 'other'
|
||||
chartTip: (key: string) => string
|
||||
planetsA: WheelPlanet[]
|
||||
planetsB: WheelPlanet[]
|
||||
ascA: number | null
|
||||
ascB: number | null
|
||||
aspectPreview: { label: string }[]
|
||||
overlayTip: string
|
||||
overlayEntries: { planet: string; sign: string; house: number; house_tip: string }[]
|
||||
activeChartTip: string
|
||||
activePlanets: WheelPlanet[]
|
||||
activeAsc: number | null
|
||||
activeAspectPreview: { label: string }[]
|
||||
report: GrowthReport
|
||||
detail: Record<string, unknown> | null
|
||||
sections: { title: string; body: string }[]
|
||||
fullAspects: { label: string }[]
|
||||
paying: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'update:mainTab': [value: MainTab]
|
||||
'update:subTab': [value: 'natal' | 'prog']
|
||||
'update:marksWho': [value: 'me' | 'other']
|
||||
'update:shareOpen': [value: boolean]
|
||||
'buy-deep': []
|
||||
reset: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.result-hero {
|
||||
text-align: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.result-headline {
|
||||
font-family: var(--font-display);
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
.note {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.yxg-card.inset {
|
||||
margin: 10px 0 0;
|
||||
}
|
||||
|
||||
.indices {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin: 14px 0 0;
|
||||
}
|
||||
.idx {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
background: #fff8f6;
|
||||
border-radius: 14px;
|
||||
padding: 12px 8px;
|
||||
box-shadow: inset 0 0 0 1px rgba(229, 77, 66, 0.08);
|
||||
}
|
||||
.idx em {
|
||||
display: block;
|
||||
font-style: normal;
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.idx strong {
|
||||
font-size: 26px;
|
||||
font-family: var(--font-display);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
margin: 0 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(--color-primary);
|
||||
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>
|
||||
@@ -0,0 +1,39 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
const moodTexts = [
|
||||
'今天心态平稳,遇到小波折也能慢慢化解,适合把一件小事做完。',
|
||||
'精力在回升,适合温和推进计划,不必一次做完所有事。',
|
||||
'情绪有起伏很正常,给自己一点空隙,会更清楚下一步。',
|
||||
'今天利于沟通与整理思路,可以从身边亲近的人开始。',
|
||||
'节奏偏慢也没关系,把注意力放在身体感受上会更踏实。',
|
||||
]
|
||||
|
||||
const dimColors = ['#ff7a9a', '#ff9a5c', '#5b9cff', '#3ecfcf', '#a78bfa']
|
||||
|
||||
function daySeed(): number {
|
||||
const n = new Date()
|
||||
return n.getFullYear() * 10000 + (n.getMonth() + 1) * 100 + n.getDate()
|
||||
}
|
||||
|
||||
function clamp(n: number) {
|
||||
return Math.max(40, Math.min(95, n))
|
||||
}
|
||||
|
||||
function moodFromSeed(seed: number) {
|
||||
const score = 58 + (seed % 37)
|
||||
const text = moodTexts[seed % moodTexts.length]
|
||||
const base = [70, 62, 68, 64, 66]
|
||||
const dims = [
|
||||
{ key: 'love', label: '爱情', score: clamp(base[0] + (seed % 17) - 8), color: dimColors[0] },
|
||||
{ key: 'wealth', label: '财富', score: clamp(base[1] + ((seed >> 2) % 19) - 9), color: dimColors[1] },
|
||||
{ key: 'career', label: '事业', score: clamp(base[2] + ((seed >> 3) % 15) - 7), color: dimColors[2] },
|
||||
{ key: 'learn', label: '学习', score: clamp(base[3] + ((seed >> 4) % 21) - 10), color: dimColors[3] },
|
||||
{ key: 'social', label: '人际', score: clamp(base[4] + ((seed >> 5) % 13) - 6), color: dimColors[4] },
|
||||
]
|
||||
return { score, text, dims }
|
||||
}
|
||||
|
||||
/** Deterministic daily mood card for home self-card. */
|
||||
export function useHomeMood() {
|
||||
return computed(() => moodFromSeed(daySeed()))
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { AnalyticsEvent, track } from '../lib/analytics'
|
||||
import { homeFeeds, homeGridRow1, homeGridRow2, homeSearchHints } from '../lib/homeCatalog'
|
||||
import { useHomeMood } from './useHomeMood'
|
||||
|
||||
export function useHomePage() {
|
||||
const router = useRouter()
|
||||
const profileLabel = ref('自己')
|
||||
const plusOpen = ref(false)
|
||||
const mood = useHomeMood()
|
||||
|
||||
const searchHint = computed(() => {
|
||||
const i = new Date().getHours() % homeSearchHints.length
|
||||
return homeSearchHints[i]
|
||||
})
|
||||
|
||||
function goPlus(kind: 'inviteFill' | 'add' | 'synastry') {
|
||||
plusOpen.value = false
|
||||
track(AnalyticsEvent.HomeCtaPortrait, { source: 'home_plus', kind })
|
||||
if (kind === 'inviteFill') {
|
||||
router.push({ path: '/profile', query: { inviteFill: '1' } })
|
||||
return
|
||||
}
|
||||
if (kind === 'add') {
|
||||
router.push({ path: '/profile', query: { add: '1' } })
|
||||
return
|
||||
}
|
||||
router.push({ path: '/synastry', query: { invite: '1' } })
|
||||
}
|
||||
|
||||
function trackGrid(label: string) {
|
||||
track(AnalyticsEvent.HomeCtaPortrait, { source: 'home_grid', label })
|
||||
}
|
||||
|
||||
return {
|
||||
router,
|
||||
profileLabel,
|
||||
plusOpen,
|
||||
mood,
|
||||
searchHint,
|
||||
gridRow1: homeGridRow1,
|
||||
gridRow2: homeGridRow2,
|
||||
feeds: homeFeeds,
|
||||
goPlus,
|
||||
trackGrid,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import type { GrowthReport, Profile } from '@yuxingu/types'
|
||||
import { validateBirth } from '@yuxingu/utils'
|
||||
import { api } from '../api/client'
|
||||
import type { WheelPlanet } from '../components/NatalWheel.vue'
|
||||
import { AnalyticsEvent, track } from '../lib/analytics'
|
||||
import type { RelationSharePayload } from '../lib/shareLink'
|
||||
|
||||
export type MainTab = 'compare' | 'composite' | 'davison' | 'marks' | 'overlay'
|
||||
|
||||
export const relationTypes = ['伴侣', '朋友', '家人', '其他']
|
||||
|
||||
export const mainTabs: { key: MainTab; label: string }[] = [
|
||||
{ key: 'compare', label: '比较' },
|
||||
{ key: 'composite', label: '组合' },
|
||||
{ key: 'davison', label: '时空' },
|
||||
{ key: 'marks', label: '马克斯' },
|
||||
{ key: 'overlay', label: '配对' },
|
||||
]
|
||||
|
||||
export function useSynastryPage() {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
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 inviteHighlight = ref(false)
|
||||
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 relationType = ref('伴侣')
|
||||
const showClassicSelects = ref(false)
|
||||
const showQuickAdd = ref(false)
|
||||
|
||||
const needsSubTab = computed(() => ['composite', 'davison', 'marks'].includes(mainTab.value))
|
||||
|
||||
const pickableProfiles = computed(() => profiles.value.filter((p) => p.id !== profileA.value))
|
||||
|
||||
const profileAName = computed(() => {
|
||||
const p = profiles.value.find((x) => x.id === profileA.value)
|
||||
return p?.display_name || ''
|
||||
})
|
||||
|
||||
const selfInitial = computed(() => {
|
||||
const name = profileAName.value || '我'
|
||||
return name.slice(0, 1)
|
||||
})
|
||||
|
||||
function profileInitial(p: Profile) {
|
||||
return (p.display_name || 'TA').slice(0, 1)
|
||||
}
|
||||
|
||||
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
|
||||
showQuickAdd.value = false
|
||||
} 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
|
||||
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(async () => {
|
||||
await loadProfiles()
|
||||
if (route.query.invite === '1') {
|
||||
inviteHighlight.value = true
|
||||
if (profileA.value) {
|
||||
await createInvite()
|
||||
} else {
|
||||
error.value = '请先完成自己的档案,再邀请好友合盘'
|
||||
}
|
||||
void router.replace({ path: '/synastry', query: {} })
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
loading,
|
||||
adding,
|
||||
paying,
|
||||
inviting,
|
||||
nearbyLoading,
|
||||
error,
|
||||
nearbyHint,
|
||||
invitePath,
|
||||
inviteHighlight,
|
||||
profiles,
|
||||
nearby,
|
||||
profileA,
|
||||
profileB,
|
||||
asOf,
|
||||
report,
|
||||
shareOpen,
|
||||
ty,
|
||||
tm,
|
||||
td,
|
||||
tName,
|
||||
mainTab,
|
||||
subTab,
|
||||
marksWho,
|
||||
relationType,
|
||||
showClassicSelects,
|
||||
showQuickAdd,
|
||||
needsSubTab,
|
||||
pickableProfiles,
|
||||
profileAName,
|
||||
selfInitial,
|
||||
profileInitial,
|
||||
headline,
|
||||
oneLiner,
|
||||
love,
|
||||
friend,
|
||||
marriage,
|
||||
loveNote,
|
||||
asOfLabel,
|
||||
planetsA,
|
||||
planetsB,
|
||||
ascA,
|
||||
ascB,
|
||||
aspectPreview,
|
||||
chartTip,
|
||||
activePlanets,
|
||||
activeAsc,
|
||||
activeAspectPreview,
|
||||
activeChartTip,
|
||||
overlayTip,
|
||||
overlayEntries,
|
||||
fullAspects,
|
||||
sections,
|
||||
sharePayload,
|
||||
detail,
|
||||
birthLabel,
|
||||
addTemp,
|
||||
generate,
|
||||
createInvite,
|
||||
loadNearby,
|
||||
buyDeep,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { homeFeeds, homeGridRow1, homeGridRow2 } from '../lib/homeCatalog'
|
||||
|
||||
describe('homeCatalog', () => {
|
||||
it('keeps 12-grid rows', () => {
|
||||
expect(homeGridRow1).toHaveLength(6)
|
||||
expect(homeGridRow2).toHaveLength(6)
|
||||
})
|
||||
|
||||
it('keeps feed entries with routes', () => {
|
||||
expect(homeFeeds.length).toBeGreaterThanOrEqual(4)
|
||||
for (const f of homeFeeds) {
|
||||
expect(f.to.startsWith('/')).toBe(true)
|
||||
expect(f.title.length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { HomeToolIconName } from '../components/HomeToolIcon.vue'
|
||||
|
||||
export type HomeTool = {
|
||||
to: string
|
||||
icon: HomeToolIconName
|
||||
label: string
|
||||
badge?: string
|
||||
badgeTone?: 'hot' | 'new'
|
||||
}
|
||||
|
||||
export type HomeFeed = {
|
||||
to: string
|
||||
icon: HomeToolIconName
|
||||
title: string
|
||||
meta: string
|
||||
stat: string
|
||||
tone: string
|
||||
tag?: string
|
||||
}
|
||||
|
||||
/** 对标测测 12 宫格 · 软立体图标 */
|
||||
export const homeGridRow1: HomeTool[] = [
|
||||
{ to: '/scales/mbti-lite', icon: 'mbti', label: '人格测试' },
|
||||
{ to: '/star', icon: 'star', label: '星座' },
|
||||
{ to: '/portrait', icon: 'portrait', label: '愈心解码', badge: '热', badgeTone: 'hot' },
|
||||
{ to: '/rhythm', icon: 'rhythm', label: '身心节律' },
|
||||
{ to: '/synastry', icon: 'synastry', label: '合盘', badge: '新', badgeTone: 'new' },
|
||||
{ to: '/star', icon: 'astro', label: '星象性格' },
|
||||
]
|
||||
|
||||
export const homeGridRow2: HomeTool[] = [
|
||||
{ to: '/companion', icon: 'companion', label: '节气陪伴' },
|
||||
{ to: '/ask', icon: 'ask', label: 'AI问答' },
|
||||
{ to: '/cards', icon: 'cards', label: '意象卡片' },
|
||||
{ to: '/reports', icon: 'reports', label: '成长报告', badge: '新', badgeTone: 'new' },
|
||||
{ to: '/growth-plan', icon: 'growth', label: '成长计划' },
|
||||
{ to: '/relation', icon: 'relation', label: '人格匹配' },
|
||||
]
|
||||
|
||||
export const homeFeeds: HomeFeed[] = [
|
||||
{
|
||||
to: '/portrait',
|
||||
icon: 'portrait',
|
||||
title: '愈心解码',
|
||||
meta: '一个生日,读懂性格与身心节奏',
|
||||
stat: '核心入口',
|
||||
tone: 'fc-e',
|
||||
tag: '热',
|
||||
},
|
||||
{
|
||||
to: '/ask',
|
||||
icon: 'ask',
|
||||
title: 'AI 成长助手',
|
||||
meta: '结合档案聊聊卡住的事',
|
||||
stat: '随时可问',
|
||||
tone: 'fc-a',
|
||||
tag: 'AI',
|
||||
},
|
||||
{
|
||||
to: '/star',
|
||||
icon: 'star',
|
||||
title: '星座排盘',
|
||||
meta: '本命盘 · 相位 · 日周月运势',
|
||||
stat: '本周热门',
|
||||
tone: 'fc-b',
|
||||
tag: '新',
|
||||
},
|
||||
{
|
||||
to: '/synastry',
|
||||
icon: 'synastry',
|
||||
title: '合盘',
|
||||
meta: '恋爱 / 友情 / 婚姻指数',
|
||||
stat: '了解彼此',
|
||||
tone: 'fc-c',
|
||||
},
|
||||
{
|
||||
to: '/scales/mbti-lite',
|
||||
icon: 'mbti',
|
||||
title: '人格测试',
|
||||
meta: '16 型人格,看见自己的相处模式',
|
||||
stat: '热门测评',
|
||||
tone: 'fc-a',
|
||||
},
|
||||
{
|
||||
to: '/membership',
|
||||
icon: 'growth',
|
||||
title: '成长会员',
|
||||
meta: '深度报告与全年节气陪伴',
|
||||
stat: '解锁更多',
|
||||
tone: 'fc-e',
|
||||
},
|
||||
]
|
||||
|
||||
export const homeSearchHints = ['探索今日心情', '愈心解码', '合盘了解彼此', 'AI 成长助手']
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
base: '/psy/',
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
@@ -12,6 +13,12 @@ export default defineConfig({
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
// H5 history base is /psy/; client baseURL is /psy/api → rewrite to Go /api
|
||||
'/psy/api': {
|
||||
target: 'http://127.0.0.1:8080',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/psy\/api/, '/api'),
|
||||
},
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:8080',
|
||||
changeOrigin: true,
|
||||
|
||||
Reference in New Issue
Block a user