feat: 按测测功能重构 H5 首页与各子页,并修正顶栏「+」添加档案菜单

对齐测测交互:首页「+」支持邀请填档案/添加档案/邀请合盘;各 Tab 与工具页按同一视觉与功能标准落地;本地对照截图目录显式忽略。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-05 14:32:35 +08:00
co-authored by Cursor
parent bd22d9dddd
commit 53eb4577b3
35 changed files with 8900 additions and 1356 deletions
+2
View File
@@ -24,7 +24,9 @@ apps/api/tmp/
.scale_results.db
# Local emulator validation + android-adb skill symlinks
# 测测对照截图本地保存,不入库:.tmp/cece-validation/
.tmp/
.tmp/cece-validation/
tools/android
tools/android-adb-docs
+16 -7
View File
@@ -1,6 +1,6 @@
<template>
<div class="app-shell" :class="{ 'is-home': isHome }">
<AppHeader v-if="!isHome" />
<div class="app-shell" :class="{ 'is-immersive': immersive }">
<AppHeader v-if="!immersive" />
<router-view />
<TabBar />
</div>
@@ -13,13 +13,22 @@ import AppHeader from './components/AppHeader.vue'
import TabBar from './components/TabBar.vue'
const route = useRoute()
/** 首页自带测测式顶栏,避免 sticky Logo 叠两层品牌 */
const isHome = computed(() => route.path === '/')
/** 五 Tab 页自带 PageShell / 首页顶栏,避免 sticky Logo */
const immersive = computed(() => {
const p = route.path
return (
p === '/' ||
p === '/explore' ||
p.startsWith('/explore/') ||
p === '/ask' ||
p === '/companion' ||
p === '/mine'
)
})
</script>
<style scoped>
.app-shell.is-home{
/* 首页自带完整暖色底,避免再叠一层全局洗色 */
background:transparent;
.app-shell.is-immersive {
background: transparent;
}
</style>
@@ -0,0 +1,255 @@
<template>
<!-- 测测风格软立体图标 · 愈心谷主题 -->
<svg
class="hti"
viewBox="0 0 64 64"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<defs>
<filter :id="uid + 's'" x="-20%" y="-15%" width="140%" height="150%">
<feDropShadow dx="0" dy="2.5" stdDeviation="2" flood-color="#000" flood-opacity=".14" />
</filter>
<linearGradient :id="uid + 'yel'" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#FFE9A0" />
<stop offset="55%" stop-color="#FFD24A" />
<stop offset="100%" stop-color="#F0B020" />
</linearGradient>
<linearGradient :id="uid + 'blu'" x1="0" y1="0" x2=".3" y2="1">
<stop offset="0%" stop-color="#B8DEFF" />
<stop offset="45%" stop-color="#5BA8F5" />
<stop offset="100%" stop-color="#2F7FE0" />
</linearGradient>
<linearGradient :id="uid + 'gold'" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#FFE8B0" />
<stop offset="50%" stop-color="#F0C45A" />
<stop offset="100%" stop-color="#D4922A" />
</linearGradient>
<linearGradient :id="uid + 'green'" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#C8F5D4" />
<stop offset="50%" stop-color="#5CCF8A" />
<stop offset="100%" stop-color="#2FA866" />
</linearGradient>
<linearGradient :id="uid + 'purp'" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#E4D4FF" />
<stop offset="50%" stop-color="#A78BFA" />
<stop offset="100%" stop-color="#7C5CE0" />
</linearGradient>
<linearGradient :id="uid + 'pink'" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#FFD0E0" />
<stop offset="50%" stop-color="#FF7AA8" />
<stop offset="100%" stop-color="#E84880" />
</linearGradient>
<linearGradient :id="uid + 'coral'" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#FFC8B8" />
<stop offset="45%" stop-color="#FF8A72" />
<stop offset="100%" stop-color="#E54D42" />
</linearGradient>
<linearGradient :id="uid + 'oran'" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#FFE0B8" />
<stop offset="50%" stop-color="#FFA85A" />
<stop offset="100%" stop-color="#E87830" />
</linearGradient>
<linearGradient :id="uid + 'teal'" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#C8F5F0" />
<stop offset="50%" stop-color="#4ECFC0" />
<stop offset="100%" stop-color="#2A9D8F" />
</linearGradient>
<linearGradient :id="uid + 'sky'" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#E8F4FF" />
<stop offset="100%" stop-color="#7EBEFA" />
</linearGradient>
<linearGradient :id="uid + 'shine'" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#fff" stop-opacity=".55" />
<stop offset="100%" stop-color="#fff" stop-opacity="0" />
</linearGradient>
</defs>
<!-- 人格测试 · 黄底 I/E -->
<g v-if="name === 'mbti'" :filter="f">
<rect x="8" y="8" width="48" height="48" rx="14" :fill="g('yel')" />
<rect x="10" y="10" width="44" height="20" rx="12" :fill="g('shine')" opacity=".7" />
<text
x="32" y="30" text-anchor="middle" font-size="13" font-weight="800"
fill="#fff" font-family="system-ui,sans-serif" letter-spacing="0.5"
>I / E</text>
<text
x="32" y="46" text-anchor="middle" font-size="11" font-weight="700"
fill="rgba(255,255,255,.88)" font-family="system-ui,sans-serif" letter-spacing="1"
>P · J</text>
</g>
<!-- 星座 · 立体蓝星 -->
<g v-else-if="name === 'star'" :filter="f">
<circle cx="32" cy="34" r="20" fill="#D6ECFF" opacity=".55" />
<path
d="M32 10 L37.2 24.5 L52 25.2 L40.4 34.8 L44.4 49 L32 40.8 L19.6 49 L23.6 34.8 L12 25.2 L26.8 24.5 Z"
:fill="g('blu')"
/>
<path d="M32 14 L35.2 24.2 L32 23 L28.8 24.2 Z" fill="#fff" opacity=".45" />
</g>
<!-- 愈心解码 · 金三角徽章 -->
<g v-else-if="name === 'portrait'" :filter="f">
<circle cx="32" cy="32" r="24" :fill="g('gold')" />
<circle cx="32" cy="32" r="18" fill="#FFF8E8" opacity=".35" />
<path d="M32 14 L48 46 H16 Z" fill="#fff" opacity=".92" />
<path d="M32 20 L42 42 H22 Z" :fill="g('coral')" />
<circle cx="32" cy="36" r="3.2" fill="#fff" />
</g>
<!-- 身心节律 · 太极 -->
<g v-else-if="name === 'rhythm'" :filter="f">
<circle cx="32" cy="32" r="23" :fill="g('green')" />
<path d="M32 9 A23 23 0 0 1 32 55 A11.5 11.5 0 0 1 32 32 A11.5 11.5 0 0 0 32 9 Z" fill="#fff" opacity=".92" />
<circle cx="32" cy="20.5" r="4" fill="#2FA866" />
<circle cx="32" cy="43.5" r="4" fill="#fff" />
</g>
<!-- 合盘 · 双心 -->
<g v-else-if="name === 'synastry'" :filter="f">
<circle cx="32" cy="32" r="23" fill="#FFE4EC" />
<path
d="M22 28 C22 22 28 20 30 26 C32 20 38 22 38 28 C38 36 30 42 30 42 C30 42 22 36 22 28 Z"
:fill="g('pink')"
transform="translate(-4,0)"
/>
<path
d="M22 28 C22 22 28 20 30 26 C32 20 38 22 38 28 C38 36 30 42 30 42 C30 42 22 36 22 28 Z"
:fill="g('blu')"
opacity=".9"
transform="translate(8,2) scale(.88)"
/>
</g>
<!-- 星象性格 · 紫星盘 -->
<g v-else-if="name === 'astro'" :filter="f">
<circle cx="32" cy="32" r="23" :fill="g('purp')" />
<circle cx="32" cy="32" r="16" fill="none" stroke="#fff" stroke-width="2" opacity=".85" />
<circle cx="32" cy="32" r="9" fill="none" stroke="#fff" stroke-width="1.5" opacity=".55" />
<circle cx="32" cy="18" r="2.5" fill="#FFE9A0" />
<circle cx="44" cy="36" r="2" fill="#fff" />
<circle cx="22" cy="38" r="2.2" fill="#FFD0E0" />
<line x1="32" y1="32" x2="40" y2="22" stroke="#fff" stroke-width="1.5" stroke-linecap="round" opacity=".8" />
</g>
<!-- 节气陪伴 · 笑脸星 -->
<g v-else-if="name === 'companion'" :filter="f">
<path
d="M32 8 L36.5 22 L51 22.5 L39.5 31.5 L43.5 45.5 L32 37.5 L20.5 45.5 L24.5 31.5 L13 22.5 L27.5 22 Z"
:fill="g('yel')"
/>
<circle cx="27" cy="28" r="2.2" fill="#5A3A10" />
<circle cx="37" cy="28" r="2.2" fill="#5A3A10" />
<path d="M27 34 Q32 38 37 34" fill="none" stroke="#5A3A10" stroke-width="2" stroke-linecap="round" />
<ellipse cx="24" cy="31" rx="3" ry="1.6" fill="#FF8A9A" opacity=".55" />
<ellipse cx="40" cy="31" rx="3" ry="1.6" fill="#FF8A9A" opacity=".55" />
</g>
<!-- AI问答 · 对话泡 -->
<g v-else-if="name === 'ask'" :filter="f">
<path
d="M12 16 H44 A10 10 0 0 1 54 26 V38 A10 10 0 0 1 44 48 H28 L18 56 V48 H12 A10 10 0 0 1 2 38 V26 A10 10 0 0 1 12 16 Z"
:fill="g('oran')"
transform="translate(4,-2) scale(.92)"
/>
<circle cx="24" cy="32" r="3.2" fill="#fff" />
<circle cx="32" cy="32" r="3.2" fill="#fff" />
<circle cx="40" cy="32" r="3.2" fill="#fff" />
</g>
<!-- 意象卡片 · 叠卡 -->
<g v-else-if="name === 'cards'" :filter="f">
<rect x="18" y="10" width="30" height="40" rx="6" fill="#C8E8FF" transform="rotate(-12 33 30)" />
<rect x="16" y="12" width="30" height="40" rx="6" :fill="g('teal')" />
<rect x="20" y="18" width="22" height="14" rx="3" fill="#fff" opacity=".55" />
<circle cx="31" cy="42" r="4" fill="#FFE9A0" />
<path d="M31 38 L32.5 41 H35 L33 43 L33.8 46 L31 44.2 L28.2 46 L29 43 L27 41 H29.5 Z" fill="#E87830" />
</g>
<!-- 成长报告 · 蓝文件夹 -->
<g v-else-if="name === 'reports'" :filter="f">
<path
d="M10 20 H26 L30 16 H50 A6 6 0 0 1 56 22 V46 A6 6 0 0 1 50 52 H14 A6 6 0 0 1 8 46 V26 A6 6 0 0 1 14 20 Z"
:fill="g('sky')"
transform="translate(0,2) scale(.95)"
/>
<rect x="18" y="28" width="28" height="22" rx="4" :fill="g('blu')" />
<rect x="22" y="32" width="16" height="2.5" rx="1" fill="#fff" opacity=".85" />
<rect x="22" y="37" width="12" height="2.5" rx="1" fill="#fff" opacity=".65" />
<rect x="22" y="42" width="14" height="2.5" rx="1" fill="#fff" opacity=".5" />
<circle cx="42" cy="24" r="5" fill="#FFE9A0" />
</g>
<!-- 成长计划 · 日轮徽章 -->
<g v-else-if="name === 'growth'" :filter="f">
<circle cx="32" cy="32" r="23" :fill="g('oran')" />
<circle cx="32" cy="32" r="14" fill="#FFF4E0" opacity=".4" />
<path
d="M32 14 L34.5 26 L46 26.5 L36.8 34 L40 46 L32 39 L24 46 L27.2 34 L18 26.5 L29.5 26 Z"
fill="#fff"
opacity=".95"
/>
<circle cx="32" cy="32" r="5" :fill="g('coral')" />
</g>
<!-- 人格匹配 · 心形 -->
<g v-else-if="name === 'relation'" :filter="f">
<circle cx="32" cy="32" r="23" :fill="g('pink')" />
<path
d="M32 44 C32 44 16 34 16 24 C16 18 21 15 26 18 C29 20 32 24 32 24 C32 24 35 20 38 18 C43 15 48 18 48 24 C48 34 32 44 32 44 Z"
fill="#fff"
opacity=".95"
/>
<circle cx="26" cy="26" r="3" fill="#FF7AA8" />
<circle cx="38" cy="26" r="3" fill="#5BA8F5" />
</g>
<g v-else :filter="f">
<circle cx="32" cy="32" r="22" :fill="g('coral')" />
<text x="32" y="38" text-anchor="middle" font-size="22" fill="#fff" font-weight="700">·</text>
</g>
</svg>
</template>
<script setup lang="ts">
import { computed, useId } from 'vue'
export type HomeToolIconName =
| 'mbti'
| 'star'
| 'portrait'
| 'rhythm'
| 'synastry'
| 'astro'
| 'companion'
| 'ask'
| 'cards'
| 'reports'
| 'growth'
| 'relation'
const props = withDefaults(
defineProps<{
name: HomeToolIconName
size?: number
}>(),
{ size: 54 },
)
const uid = useId().replace(/:/g, '')
const f = computed(() => `url(#${uid}s)`)
const sizePx = computed(() => `${props.size}px`)
function g(key: string) {
return `url(#${uid}${key})`
}
</script>
<style scoped>
.hti {
width: v-bind(sizePx);
height: v-bind(sizePx);
display: block;
overflow: visible;
}
</style>
+93
View File
@@ -0,0 +1,93 @@
<template>
<component
:is="to ? 'router-link' : 'div'"
:to="to"
class="lr"
:class="{ link: !!to }"
>
<span v-if="$slots.icon || icon" class="lr-ico" aria-hidden="true">
<slot name="icon">
<HomeToolIcon v-if="icon" :name="icon" :size="40" />
</slot>
</span>
<span class="lr-body">
<strong class="lr-title">
{{ title }}
<span v-if="badge" class="lr-badge">{{ badge }}</span>
</strong>
<span v-if="desc" class="lr-desc">{{ desc }}</span>
</span>
<span v-if="chevron" class="lr-chev" aria-hidden="true"></span>
</component>
</template>
<script setup lang="ts">
import HomeToolIcon, { type HomeToolIconName } from './HomeToolIcon.vue'
withDefaults(
defineProps<{
title: string
desc?: string
to?: string
icon?: HomeToolIconName
badge?: string
chevron?: boolean
}>(),
{ chevron: true },
)
</script>
<style scoped>
.lr {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 4px;
color: inherit;
text-decoration: none;
}
.lr.link:active {
opacity: 0.85;
}
.lr-ico {
flex-shrink: 0;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
}
.lr-body {
flex: 1;
min-width: 0;
}
.lr-title {
display: block;
font-size: 15px;
font-weight: 650;
color: var(--color-text-primary);
line-height: 1.35;
}
.lr-badge {
font-size: 10px;
font-weight: 650;
color: #fff;
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
padding: 1px 6px;
border-radius: 6px;
margin-left: 4px;
vertical-align: middle;
}
.lr-desc {
display: block;
font-size: 12px;
color: var(--color-text-tertiary);
margin-top: 4px;
line-height: 1.45;
}
.lr-chev {
flex-shrink: 0;
color: #d0d0d0;
font-size: 16px;
}
</style>
+119
View File
@@ -0,0 +1,119 @@
<template>
<main class="ps" :class="{ 'ps-flat': flat }">
<span v-if="!flat" class="atm a1" aria-hidden="true" />
<span v-if="!flat" class="atm a2" aria-hidden="true" />
<div v-if="$slots.hero" class="ps-hero reveal" style="--d: 0ms">
<slot name="hero" />
</div>
<div v-if="$slots.top" class="ps-top reveal" style="--d: 40ms">
<slot name="top" />
</div>
<div v-if="sheet" class="ps-sheet reveal" :class="{ inset: sheetInset }" style="--d: 80ms">
<slot />
</div>
<template v-else>
<slot />
</template>
</main>
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
/** 白底圆角 sheet(对齐首页) */
sheet?: boolean
/** sheet 左右留白(卡片式) */
sheetInset?: boolean
/** 无氛围光斑(嵌套页) */
flat?: boolean
}>(),
{ sheet: true, sheetInset: false, flat: false },
)
</script>
<style scoped>
.ps {
position: relative;
overflow-x: hidden;
padding-bottom: var(--spacing-xs);
min-height: 100%;
background:
radial-gradient(110% 70% at 85% -8%, rgba(255, 190, 170, 0.65) 0%, transparent 52%),
radial-gradient(90% 55% at 8% 6%, rgba(255, 220, 200, 0.75) 0%, transparent 48%),
radial-gradient(60% 40% at 50% 0%, rgba(255, 255, 255, 0.55) 0%, transparent 60%),
linear-gradient(180deg, var(--color-bg-start) 0%, #ffe4d8 28%, #fff4ef 52%, var(--color-bg-sheet) 100%);
}
.ps-flat {
background: transparent;
min-height: 0;
}
.atm {
position: absolute;
pointer-events: none;
border-radius: 50%;
z-index: 0;
filter: blur(1px);
}
.atm.a1 {
width: 160px;
height: 160px;
top: 28px;
right: -40px;
background: rgba(255, 255, 255, 0.5);
animation: drift 12s ease-in-out infinite alternate;
}
.atm.a2 {
width: 100px;
height: 100px;
top: 88px;
left: -28px;
background: rgba(255, 200, 180, 0.45);
animation: drift 14s ease-in-out infinite alternate-reverse;
}
@keyframes drift {
from { transform: translate(0, 0); }
to { transform: translate(-8px, 10px); }
}
.ps-hero,
.ps-top {
position: relative;
z-index: 2;
padding: 8px 16px 0;
}
.ps-sheet {
margin-top: 14px;
background: var(--color-surface);
border-radius: var(--radius-sheet) var(--radius-sheet) 0 0;
padding: 8px 0 28px;
box-shadow: var(--shadow-sheet);
position: relative;
z-index: 3;
min-height: 52vh;
}
.ps-sheet :deep(.yxg-card),
.ps-sheet :deep(.yxg-list) {
margin-left: 0;
margin-right: 0;
}
.ps-sheet.inset {
margin: 14px 16px 0;
border-radius: var(--radius-xl);
min-height: 0;
padding: 4px 0 20px;
box-shadow: var(--shadow-card);
}
.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,
.atm {
animation: none !important;
}
}
</style>
+59 -26
View File
@@ -7,7 +7,7 @@
class="item"
:class="{ ask: item.ask, on: isActive(item.key) }"
>
<span class="ni">{{ item.icon }}</span>
<span class="ni" aria-hidden="true">{{ item.icon }}</span>
<span class="lb">{{ item.label }}</span>
</router-link>
</nav>
@@ -19,7 +19,7 @@ import { useRoute } from 'vue-router'
const route = useRoute()
const items = [
{ key: 'home', to: '/', icon: '⌂', label: '首页' },
{ key: 'explore', to: '/explore', icon: '', label: '探索' },
{ key: 'explore', to: '/explore', icon: '', label: '探索' },
{ key: 'ask', to: '/ask', icon: '问', label: '问答', ask: true },
{ key: 'companion', to: '/companion', icon: '♡', label: '陪伴' },
{ key: 'mine', to: '/mine', icon: '◍', label: '我的' },
@@ -61,31 +61,64 @@ function isActive(key: string): boolean {
</script>
<style scoped>
.tabbar{
position:fixed;bottom:0;left:50%;transform:translateX(-50%);
width:100%;max-width:var(--yxg-max-w);
background:rgba(255,255,255,.96);
backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);
display:flex;justify-content:space-around;
padding:6px 0 calc(10px + env(safe-area-inset-bottom,0));
border-top:1px solid var(--color-border);z-index:100;
box-shadow:var(--shadow-nav);
.tabbar {
position: fixed;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 100%;
max-width: var(--yxg-max-w);
background: rgba(255, 255, 255, 0.96);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
display: flex;
justify-content: space-around;
padding: 6px 0 calc(10px + env(safe-area-inset-bottom, 0));
border-top: 1px solid var(--color-border);
z-index: 100;
box-shadow: var(--shadow-nav);
}
.item{
flex:1;display:flex;flex-direction:column;align-items:center;gap:2px;
font-size:10px;color:var(--color-text-secondary);padding:2px 0;
letter-spacing:.02em;
.item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
font-size: 10px;
color: var(--color-text-secondary);
padding: 2px 0;
letter-spacing: 0.02em;
transition: color var(--duration-fast) ease;
}
.item.on{color:var(--yxg-pri);font-weight:600}
.ni{font-size:17px;line-height:1.2}
.lb{line-height:1.2}
.item.ask .ni{
width:40px;height:40px;border-radius:50%;
background:linear-gradient(135deg,#ff7a6e,var(--yxg-pri));
color:#fff;
display:flex;align-items:center;justify-content:center;
font-size:14px;font-weight:700;margin-top:-12px;
box-shadow:0 4px 12px rgba(229,77,66,.35);
.item.on {
color: var(--color-primary);
font-weight: 600;
}
.ni {
font-size: 17px;
line-height: 1.2;
}
.lb {
line-height: 1.2;
}
.item.ask .ni {
width: 44px;
height: 44px;
border-radius: 50%;
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
font-weight: 700;
margin-top: -14px;
box-shadow: 0 6px 16px rgba(229, 77, 66, 0.38);
}
.item.ask.on {
color: var(--color-primary);
}
.item.ask.on .ni {
box-shadow: 0 8px 20px rgba(229, 77, 66, 0.45);
}
.item.ask.on{color:var(--yxg-pri)}
</style>
+20
View File
@@ -0,0 +1,20 @@
import type { HomeToolIconName } from '../components/HomeToolIcon.vue'
/** 路由 / catalog key → 软立体图标 */
export function toolIconFor(pathOrKey: string): HomeToolIconName {
const s = pathOrKey.toLowerCase()
if (s.includes('mbti') || s.includes('人格类型') || s.includes('tests')) return 'mbti'
if (s.includes('synastry') || s.includes('合盘')) return 'synastry'
if (s.includes('portrait') || s.includes('decode') || s.includes('解码')) return 'portrait'
if (s.includes('star') || s.includes('星座') || s.includes('星盘')) return 'star'
if (s.includes('rhythm') || s.includes('节律')) return 'rhythm'
if (s.includes('companion') || s.includes('节气') || s.includes('mood')) return 'companion'
if (s.includes('ask') || s.includes('问答')) return 'ask'
if (s.includes('card') || s.includes('意象')) return 'cards'
if (s.includes('report') || s.includes('报告')) return 'reports'
if (s.includes('growth') || s.includes('计划') || s.includes('会员') || s.includes('member')) return 'growth'
if (s.includes('relation') || s.includes('匹配') || s.includes('love')) return 'relation'
if (s.includes('astro') || s.includes('星象')) return 'astro'
if (s.includes('enneagram') || s.includes('bigfive') || s.includes('scale')) return 'mbti'
return 'portrait'
}
+460 -85
View File
@@ -1,101 +1,183 @@
<template>
<main class="yxg-page ask">
<div class="yxg-hero">
<PageTitle feature="ask" title="问答" sub="AI 成长助手 · 结合档案回答" />
<p v-if="quota" class="yxg-meta quota">
剩余 {{ quota.remaining }}
<span v-if="!quota.active_membership">免费 {{ quota.free_limit }} </span>
</p>
</div>
<PageShell :sheet="false">
<template #hero>
<!-- 双轨顶栏 · 对标测测测测AI真人1v1 -->
<div class="rails">
<button
type="button"
class="rail"
:class="{ on: rail === 'ai' }"
@click="rail = 'ai'"
>
愈心 AI
</button>
<button
type="button"
class="rail"
:class="{ on: rail === 'advisor' }"
@click="rail = 'advisor'"
>
顾问
<span class="rail-tag">推荐</span>
</button>
</div>
</template>
<div class="yxg-sheet ask-sheet">
<p v-if="bootLoading" class="yxg-hint pad">加载中</p>
<p v-else-if="bootError" class="yxg-err pad">
<!-- AI -->
<div v-show="rail === 'ai'" class="ai-pane">
<p v-if="bootLoading" class="hint">加载中</p>
<p v-else-if="bootError" class="err">
{{ bootError }}
<button type="button" class="yxg-link" @click="boot">重试</button>
<button type="button" class="retry" @click="boot">重试</button>
</p>
<template v-else-if="!profiles.length">
<div class="yxg-card empty">
<p>还没有个人档案请先完成性格探索再来提问</p>
<router-link class="yxg-btn" to="/">去首页探索</router-link>
<div class="card empty">
<HomeToolIcon name="ask" :size="56" />
<p>还没有个人档案完成愈心解码后再来提问回答会更贴合你</p>
<router-link class="cta" to="/portrait">去愈心解码</router-link>
</div>
</template>
<template v-else>
<div class="yxg-card controls">
<label class="yxg-label">选择档案</label>
<select class="yxg-select" v-model="profileId" @change="onProfileChange">
<!-- 冷启动引导 -->
<div v-if="!messages.length" class="card greet reveal">
<div class="greet-row">
<HomeToolIcon name="ask" :size="48" />
<div>
<p class="hi">Hi我是愈心 AI</p>
<p class="hi-sub">最近有什么事都可以和我聊聊</p>
</div>
</div>
<div class="prompts">
<button
v-for="s in guidePrompts"
:key="s.key"
type="button"
class="prompt"
@click="pickScene(s)"
>
{{ s.label }}
</button>
</div>
<button type="button" class="advisor-link" @click="rail = 'advisor'">
也可以看看成长顾问
</button>
</div>
<div class="card controls">
<label class="lbl">档案</label>
<select class="sel" v-model="profileId" @change="onProfileChange">
<option v-for="p in profiles" :key="p.id" :value="p.id">
{{ p.relation === 'self' ? '我的档案' : `TA · ${p.display_name || '未命名'}` }}
</option>
</select>
<label class="yxg-label">场景</label>
<div class="scenes">
<div class="quick">
<button
v-for="s in scenes"
:key="s.key"
v-for="q in quickTools"
:key="q.to"
type="button"
class="yxg-chip yxg-chip-solid"
:class="{ on: scene === s.key }"
@click="pickScene(s)"
class="q-btn"
@click="router.push(q.to)"
>
<span aria-hidden="true">{{ s.icon }} </span>{{ s.label }}
{{ q.label }}
</button>
</div>
</div>
<div class="thread" ref="threadEl">
<p v-if="!messages.length && !sending" class="yxg-hint empty-msg">
选一个场景或直接输入你的问题
</p>
<div v-for="m in messages" :key="m.id" :class="['bubble', m.role]">
<p>{{ m.content }}</p>
</div>
<p v-if="sending" class="yxg-hint pad">助手正在回复</p>
<p v-if="sending" class="hint pad">助手正在回复</p>
</div>
<p v-if="sendError" class="yxg-err pad">
<p v-if="sendError" class="err pad">
{{ sendError }}
<router-link v-if="quotaExhausted" class="yxg-link" to="/membership">开通成长会员</router-link>
<router-link v-if="quotaExhausted" class="retry" to="/membership">开通成长会员</router-link>
</p>
<form class="composer" @submit.prevent="send">
<textarea
class="yxg-textarea"
v-model="draft"
rows="2"
placeholder="例如:我和 TA 沟通时容易卡住,该怎么调整?"
:disabled="sending || (quota !== null && quota.remaining <= 0)"
/>
<button
class="yxg-btn"
type="submit"
:disabled="sending || !draft.trim() || (quota !== null && quota.remaining <= 0)"
>
发送
</button>
</form>
<div class="composer-wrap">
<p v-if="quota" class="quota">
剩余 {{ quota.remaining }}
<span v-if="!quota.active_membership">免费 {{ quota.free_limit }}</span>
</p>
<form class="composer" @submit.prevent="send">
<textarea
class="ta"
v-model="draft"
rows="2"
placeholder="让我来解答你的问题吧"
:disabled="sending || (quota !== null && quota.remaining <= 0)"
/>
<button
class="send"
type="submit"
:disabled="sending || !draft.trim() || (quota !== null && quota.remaining <= 0)"
>
发送
</button>
</form>
<p class="ai-note">部分内容由 AI 生成仅供参考</p>
</div>
</template>
</div>
</main>
<!-- 顾问轨对标真人1v1无连麦则引导提问/会员 -->
<div v-show="rail === 'advisor'" class="adv-pane">
<div class="card">
<p class="adv-title">成长顾问</p>
<p class="adv-sub">结合档案聊聊卡住的事 · 可先用 AI或开通会员解锁更多</p>
</div>
<div
v-for="a in advisors"
:key="a.name"
class="adv-card"
>
<div class="av" :style="{ background: a.bg }">{{ a.mark }}</div>
<div class="adv-body">
<strong>{{ a.name }}</strong>
<span class="meta">{{ a.meta }}</span>
<span class="tags">{{ a.tags }}</span>
</div>
<button type="button" class="ask-btn" @click="askAdvisor(a)">去提问</button>
</div>
<router-link class="cta soft" to="/membership">查看成长会员权益</router-link>
</div>
</PageShell>
</template>
<script setup lang="ts">
import { nextTick, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import type { AskMessage, AskQuota, Profile } from '@yuxingu/types'
import { api } from '../api/client'
import PageTitle from '../components/PageTitle.vue'
import HomeToolIcon from '../components/HomeToolIcon.vue'
import PageShell from '../components/PageShell.vue'
const router = useRouter()
const rail = ref<'ai' | 'advisor'>('ai')
const scenes = [
{ key: 'self', icon: '△', label: '认识自己', prompt: '我想更了解自己的性格与互动风格。' },
{ key: 'relation', icon: '♡', label: '理解关系', prompt: '和重要的人相处时,我该如何更好地沟通?' },
{ key: 'career', icon: '▽', label: '职业探索', prompt: '从我的特点看,职业选择上可以注意什么?' },
{ key: 'emotion', icon: '○', label: '情绪整理', prompt: '最近情绪有点乱,我想梳理一下。' },
{ key: 'life', icon: '☯', label: '生活建议', prompt: '想改善作息和日常节奏,有什么建议?' },
{ key: 'dream', icon: '✦', label: '梦境整理', prompt: '昨晚的梦让我有些在意,想从感受层面整理一下,不是解梦预测。' },
{ key: 'family', icon: '◎', label: '原生家庭', prompt: '想梳理原生家庭模式对我沟通与边界的影响。' },
{ key: 'values', icon: '◈', label: '价值澄清', prompt: '我想弄清现阶段什么对我真正重要。' },
{ key: 'self', label: '我想更了解自己的性格与互动风格', prompt: '我想更了解自己的性格与互动风格。' },
{ key: 'relation', label: '和重要的人相处时该如何沟通?', prompt: '和重要的人相处时,我该如何更好地沟通?' },
{ key: 'emotion', label: '最近情绪有点乱,想梳理一下', prompt: '最近情绪有点乱,我想梳理一下。' },
{ key: 'career', label: '职业选择上可以注意什么?', prompt: '从我的特点看,职业选择上可以注意什么?' },
] as const
const guidePrompts = scenes
const quickTools = [
{ to: '/portrait', label: '工具' },
{ to: '/synastry', label: '合盘' },
{ to: '/cards', label: '意象卡' },
{ to: '/relation', label: '匹配' },
]
const advisors = [
{ name: '心语', mark: '心', bg: 'linear-gradient(145deg,#ffd0c8,#ffb0a4)', meta: '好评率 98% · 情绪整理', tags: '文字沟通', scene: 'emotion' as const },
{ name: '明朗', mark: '明', bg: 'linear-gradient(145deg,#d6e8ff,#b0d0f5)', meta: '好评率 97% · 关系理解', tags: '文字沟通', scene: 'relation' as const },
{ name: '志远', mark: '志', bg: 'linear-gradient(145deg,#ffe6c8,#f5c98a)', meta: '好评率 96% · 职业探索', tags: '文字沟通', scene: 'career' as const },
]
const bootLoading = ref(true)
const bootError = ref('')
const profiles = ref<Profile[]>([])
@@ -134,13 +216,19 @@ function onProfileChange() {
sendError.value = ''
}
function pickScene(s: (typeof scenes)[number]) {
function pickScene(s: { key: string; prompt: string }) {
scene.value = s.key
draft.value = s.prompt
threadId.value = ''
messages.value = []
}
function askAdvisor(a: (typeof advisors)[number]) {
rail.value = 'ai'
const s = scenes.find((x) => x.key === a.scene) || scenes[0]
pickScene(s)
}
async function ensureThread(): Promise<string> {
if (threadId.value) return threadId.value
if (!profileId.value) throw new Error('请先选择档案')
@@ -181,33 +269,320 @@ onMounted(boot)
</script>
<style scoped>
.ask{padding-bottom:24px}
.quota{margin-top:4px}
.ask-sheet{padding-bottom:100px;min-height:60vh}
.pad{padding:0 16px}
.controls{margin-top:8px}
.controls .yxg-label:first-child{margin-top:0}
.scenes{display:flex;flex-wrap:wrap;gap:8px;margin-top:4px}
.scenes .yxg-chip.on{
background:linear-gradient(135deg,#ff7a6e,var(--yxg-pri));
color:#fff;border-color:transparent;
.rails {
display: flex;
gap: 0;
background: rgba(255, 255, 255, 0.72);
border-radius: var(--radius-pill);
padding: 4px;
border: 1px solid rgba(255, 255, 255, 0.95);
box-shadow: 0 4px 16px rgba(180, 90, 70, 0.08);
backdrop-filter: blur(10px);
}
.thread{overflow-y:auto;max-height:42vh;padding:4px 16px 12px}
.bubble{
margin:8px 0;padding:12px 14px;border-radius:16px;
font-size:14px;line-height:1.65;white-space:pre-wrap;
box-shadow:var(--shadow-card);
.rail {
flex: 1;
border: none;
background: transparent;
padding: 10px 12px;
border-radius: var(--radius-pill);
font-size: 14px;
font-weight: 600;
color: #888;
position: relative;
}
.bubble.user{background:#fff;margin-left:28px;color:#333}
.bubble.assistant{background:#fff5f4;margin-right:28px;color:#444;box-shadow:none}
.empty-msg{text-align:center;padding:28px 16px}
.empty .yxg-btn{margin-top:12px}
.composer{
display:flex;gap:8px;align-items:flex-end;
position:sticky;bottom:72px;
margin:0 16px;padding:10px 0 4px;
background:linear-gradient(180deg,transparent, #fff 28%);
.rail.on {
background: #fff;
color: var(--color-primary);
box-shadow: 0 2px 8px rgba(229, 77, 66, 0.12);
}
.rail-tag {
position: absolute;
top: -2px;
right: 8px;
font-size: 9px;
font-weight: 700;
color: #fff;
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
padding: 1px 5px;
border-radius: 6px;
}
.ai-pane,
.adv-pane {
padding: 12px 16px 100px;
position: relative;
z-index: 2;
}
.card {
background: var(--color-surface);
border-radius: var(--radius-xl);
padding: 16px;
box-shadow: var(--shadow-hero);
margin-bottom: 12px;
}
.card.empty {
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
color: #666;
}
.greet-row {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 14px;
}
.hi {
font-size: 16px;
font-weight: 700;
color: var(--color-text-primary);
}
.hi-sub {
font-size: 12px;
color: #888;
margin-top: 2px;
}
.prompts {
display: flex;
flex-direction: column;
gap: 8px;
}
.prompt {
text-align: left;
border: 1px solid #f0ebe8;
background: #fffaf8;
border-radius: 14px;
padding: 12px 14px;
font-size: 13px;
color: #444;
line-height: 1.45;
}
.prompt:active {
background: #fff0ec;
border-color: #f5c4bc;
}
.advisor-link {
margin-top: 12px;
border: none;
background: transparent;
font-size: 12px;
color: var(--color-primary);
font-weight: 600;
}
.controls .lbl {
display: block;
font-size: 12px;
color: #999;
margin-bottom: 6px;
}
.sel {
width: 100%;
padding: 10px 12px;
border-radius: 12px;
border: 1.5px solid #eee;
background: var(--color-input-bg);
font-size: 14px;
outline: none;
}
.quick {
display: flex;
gap: 8px;
margin-top: 12px;
overflow-x: auto;
scrollbar-width: none;
}
.q-btn {
flex-shrink: 0;
padding: 7px 12px;
border-radius: var(--radius-pill);
border: 1px solid #f0ebe8;
background: #fff;
font-size: 12px;
color: #666;
}
.thread {
overflow-y: auto;
max-height: 36vh;
padding: 4px 0 12px;
}
.bubble {
margin: 8px 0;
padding: 12px 14px;
border-radius: 16px;
font-size: 14px;
line-height: 1.65;
white-space: pre-wrap;
box-shadow: var(--shadow-card);
background: #fff;
}
.bubble.user {
margin-left: 28px;
color: #333;
}
.bubble.assistant {
margin-right: 28px;
background: #fff5f4;
color: #444;
box-shadow: none;
}
.composer-wrap {
position: sticky;
bottom: 72px;
padding-top: 8px;
background: linear-gradient(180deg, transparent, var(--color-bg-sheet) 24%);
}
.quota {
font-size: 11px;
color: #aaa;
margin-bottom: 6px;
}
.composer {
display: flex;
gap: 8px;
align-items: flex-end;
}
.ta {
flex: 1;
resize: none;
min-height: 44px;
padding: 11px 14px;
border-radius: 16px;
border: 1.5px solid #eee;
background: #fff;
font-size: 14px;
font-family: inherit;
outline: none;
}
.ta:focus {
border-color: #f0b8b0;
}
.send {
flex-shrink: 0;
padding: 11px 16px;
border: none;
border-radius: 22px;
color: #fff;
font-size: 14px;
font-weight: 600;
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
box-shadow: 0 6px 16px rgba(229, 77, 66, 0.28);
}
.send:disabled {
opacity: 0.45;
}
.ai-note {
margin-top: 8px;
font-size: 10px;
color: #ccc;
text-align: center;
}
.adv-title {
font-size: 16px;
font-weight: 700;
color: #222;
}
.adv-sub {
font-size: 12px;
color: #888;
margin-top: 4px;
line-height: 1.45;
}
.adv-card {
display: flex;
align-items: center;
gap: 12px;
padding: 14px;
background: #fff;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
margin-bottom: 10px;
}
.av {
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
color: #c23b32;
flex-shrink: 0;
}
.adv-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.adv-body strong {
font-size: 14px;
color: #222;
}
.meta {
font-size: 11px;
color: #999;
}
.tags {
font-size: 11px;
color: var(--color-primary);
}
.ask-btn {
flex-shrink: 0;
padding: 8px 12px;
border: none;
border-radius: 18px;
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
color: #fff;
font-size: 12px;
font-weight: 600;
}
.cta {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 12px 20px;
border-radius: 22px;
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
color: #fff;
font-weight: 600;
font-size: 14px;
box-shadow: 0 6px 16px rgba(229, 77, 66, 0.28);
}
.cta.soft {
display: flex;
margin-top: 8px;
background: #fff;
color: var(--color-primary);
border: 1.5px solid #f5c4bc;
box-shadow: none;
}
.hint {
font-size: 13px;
color: #999;
text-align: center;
padding: 24px;
}
.err {
font-size: 13px;
color: var(--color-primary);
padding: 8px 0;
}
.pad { padding: 0 4px; }
.retry {
border: none;
background: transparent;
color: var(--color-accent-blue);
text-decoration: underline;
font-size: 13px;
margin-left: 6px;
}
.reveal {
animation: rise 0.45s cubic-bezier(0.22, 1, 0.36, 1) both;
}
@keyframes rise {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.composer .yxg-textarea{flex:1;resize:none;min-height:44px}
.composer .yxg-btn{flex-shrink:0;padding:11px 16px}
</style>
+164 -55
View File
@@ -1,18 +1,37 @@
<template>
<main class="yxg-page">
<div class="yxg-hero">
<PageTitle feature="companion" title="陪伴" sub="节气生活 · 心情记录" />
</div>
<PageShell>
<template #hero>
<div class="head">
<h1 class="title">陪伴</h1>
<p class="sub">节气生活 · 心情记录</p>
</div>
</template>
<div class="yxg-sheet">
<div class="yxg-card term-card">
<p class="term"><span class="tm icon-green" aria-hidden="true"></span>今日 · {{ term.name }}</p>
<p class="yxg-meta date">{{ todayLabel }}</p>
<!-- 角色问候卡 · 对标陪伴小星 -->
<section class="sec">
<div class="hero-card">
<HomeToolIcon name="companion" :size="64" />
<div class="hero-copy">
<p class="hi">Hi我是节气小伴</p>
<p class="hi-sub">今天也可以慢慢来先感受一下身体与心情</p>
</div>
</div>
</section>
<section class="sec">
<div class="term-card">
<p class="term">
<HomeToolIcon name="rhythm" :size="28" />
<span>今日 · {{ term.name }}</span>
</p>
<p class="date">{{ todayLabel }}</p>
<p class="tip">{{ term.tip }}</p>
</div>
</section>
<div class="yxg-card">
<p class="sec-title"><span aria-hidden="true"> </span>今日心情</p>
<section class="sec">
<div class="block">
<p class="sec-title">今日心情</p>
<div class="scores">
<button
v-for="n in 5"
@@ -21,24 +40,28 @@
class="score"
:class="{ on: score === n }"
@click="score = n"
>{{ moodMarks[n - 1] }}</button>
>
{{ moodMarks[n - 1] }}
</button>
</div>
<p class="yxg-meta score-hint">{{ score ? `已选 ${score}` : '点选此刻感受(15' }}</p>
<p class="score-hint">{{ score ? `已选 ${score}` : '点选此刻感受(15' }}</p>
<textarea
class="yxg-textarea"
class="ta"
v-model="note"
rows="3"
maxlength="200"
placeholder="可选:写一两句此刻的感受"
/>
<button type="button" class="yxg-btn yxg-btn-soft save" :disabled="saving || !score" @click="save">
<button type="button" class="save" :disabled="saving || !score" @click="save">
{{ saving ? '保存中' : saved ? '已保存' : '保存心情' }}
</button>
<p v-if="moodErr" class="yxg-err">{{ moodErr }}</p>
<p v-if="moodErr" class="err">{{ moodErr }}</p>
</div>
</section>
<div v-if="recent.length" class="yxg-card">
<p class="sec-title"><span aria-hidden="true"> </span>近七日心情</p>
<section v-if="recent.length" class="sec">
<div class="block">
<p class="sec-title">近七日心情</p>
<ul class="trail">
<li v-for="m in recent" :key="m.id">
<span class="d">{{ m.day }}</span>
@@ -47,21 +70,30 @@
</li>
</ul>
</div>
</section>
<div class="yxg-card soft links">
<p>想继续整理感受可以去问答聊几句</p>
<router-link class="yxg-link-plain" to="/ask"><span aria-hidden="true"> </span> AI 成长助手 </router-link>
<router-link class="yxg-link-plain" to="/rhythm"><span aria-hidden="true"> </span>身心节律探索 </router-link>
<router-link class="yxg-link-plain" to="/growth-plan"><span aria-hidden="true"> </span>成长计划 </router-link>
</div>
</div>
</main>
<section class="sec links">
<router-link class="link" to="/ask">
<HomeToolIcon name="ask" :size="36" />
<span> AI 成长助手聊聊 </span>
</router-link>
<router-link class="link" to="/rhythm">
<HomeToolIcon name="rhythm" :size="36" />
<span>身心节律探索 </span>
</router-link>
<router-link class="link" to="/growth-plan">
<HomeToolIcon name="growth" :size="36" />
<span>成长计划 </span>
</router-link>
</section>
</PageShell>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { api } from '../api/client'
import PageTitle from '../components/PageTitle.vue'
import HomeToolIcon from '../components/HomeToolIcon.vue'
import PageShell from '../components/PageShell.vue'
import { track } from '../lib/analytics'
const moodMarks = ['◦', '○', '◎', '●', '◉']
@@ -123,38 +155,115 @@ onMounted(load)
</script>
<style scoped>
.term-card{margin-top:8px}
.term{
font-family:var(--font-display);
font-size:18px;font-weight:700;color:#3d6b45;
display:flex;align-items:center;gap:8px;letter-spacing:.04em;
.head { padding: 4px 0 8px; }
.title {
font-family: var(--font-display);
font-size: 26px;
font-weight: 700;
letter-spacing: 0.08em;
color: var(--color-text-primary);
}
.tm{
width:28px;height:28px;border-radius:10px;display:inline-flex;align-items:center;justify-content:center;
font-size:15px;font-family:var(--font-sans);box-shadow:inset 0 -2px 0 rgba(0,0,0,.03);
.sub {
margin-top: 4px;
font-size: 12px;
color: rgba(0, 0, 0, 0.38);
letter-spacing: 0.06em;
}
.date{margin:6px 0 10px}
.tip{color:#444;line-height:1.7}
.sec-title{font-size:15px;font-weight:650;color:#222;margin-bottom:12px}
.scores{display:flex;gap:8px;margin-bottom:6px}
.score{
width:44px;height:44px;border-radius:14px;border:1px solid #eee;background:#fafafa;
font-weight:600;color:#666;font-size:16px;cursor:pointer;
.sec { padding: 0 16px; margin-top: 14px; }
.hero-card {
display: flex;
gap: 14px;
align-items: center;
padding: 16px;
background: linear-gradient(145deg, #fff8e8, #ffeec8);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-card);
}
.score.on{
border-color:var(--color-accent-green);
background:var(--color-accent-green-soft);
color:#3d6b45;
.hi { font-size: 16px; font-weight: 700; color: #222; }
.hi-sub { font-size: 12px; color: #888; margin-top: 4px; line-height: 1.45; }
.term-card,
.block {
background: #fafafa;
border-radius: var(--radius-lg);
padding: 16px;
}
.score-hint{margin-bottom:10px}
.save{margin-top:12px}
.links{display:flex;flex-direction:column;gap:8px;color:#666}
.trail{list-style:none;margin:0;padding:0;font-size:13px}
.trail li{
display:grid;grid-template-columns:96px 28px 1fr;gap:8px;
padding:10px 0;border-bottom:1px solid #f3f3f3;color:#666;
.term {
display: flex;
align-items: center;
gap: 8px;
font-family: var(--font-display);
font-size: 17px;
font-weight: 700;
color: #3d6b45;
letter-spacing: 0.04em;
}
.date { margin: 6px 0 10px; font-size: 12px; color: #999; }
.tip { color: #444; line-height: 1.7; font-size: 14px; }
.sec-title { font-size: 15px; font-weight: 650; color: #222; margin-bottom: 12px; }
.scores { display: flex; gap: 8px; margin-bottom: 6px; }
.score {
width: 44px;
height: 44px;
border-radius: 14px;
border: 1px solid #eee;
background: #fff;
font-weight: 600;
color: #666;
font-size: 16px;
}
.score.on {
border-color: var(--color-accent-green);
background: var(--color-accent-green-soft);
color: #3d6b45;
}
.score-hint { margin-bottom: 10px; font-size: 12px; color: #999; }
.ta {
width: 100%;
padding: 11px 12px;
border-radius: 12px;
border: 1.5px solid #eee;
background: #fff;
font-size: 14px;
font-family: inherit;
resize: none;
outline: none;
}
.save {
margin-top: 12px;
width: 100%;
padding: 12px;
border: none;
border-radius: 22px;
color: #fff;
font-size: 14px;
font-weight: 600;
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
box-shadow: 0 6px 16px rgba(229, 77, 66, 0.28);
}
.save:disabled { opacity: 0.45; }
.err { color: var(--color-primary); font-size: 12px; margin-top: 8px; }
.trail { list-style: none; margin: 0; padding: 0; font-size: 13px; }
.trail li {
display: grid;
grid-template-columns: 96px 28px 1fr;
gap: 8px;
padding: 10px 0;
border-bottom: 1px solid #f0ebe8;
color: #666;
}
.trail li:last-child { border-bottom: none; }
.trail .d { color: #999; }
.trail .s { font-weight: 700; color: #3d6b45; }
.links { display: flex; flex-direction: column; gap: 8px; padding-bottom: 8px; }
.link {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
background: #fafafa;
border-radius: var(--radius-lg);
font-size: 13px;
font-weight: 600;
color: #555;
}
.trail li:last-child{border-bottom:none}
.trail .d{color:#999}
.trail .s{font-weight:700;color:#3d6b45}
</style>
+91 -37
View File
@@ -1,36 +1,34 @@
<template>
<main class="yxg-page">
<div class="yxg-hero">
<BackButton />
<PageTitle
v-if="cat"
:icon="cat.icon"
:tone="(cat.tone as any)"
:title="cat.title"
:sub="cat.description"
/>
</div>
<PageShell>
<template #hero>
<div class="head">
<BackButton />
<h1 class="title">{{ cat?.title || '分类' }}</h1>
<p v-if="cat" class="sub">{{ cat.description }}</p>
</div>
</template>
<div class="yxg-sheet">
<p v-if="loading" class="yxg-hint pad">加载中</p>
<p v-else-if="error" class="yxg-err pad">{{ error }}</p>
<ul v-else-if="cat" class="yxg-list">
<li v-for="it in cat.items" :key="it.key">
<router-link :to="it.path">
<span class="yxg-mark" :class="'icon-' + it.tone" aria-hidden="true">{{ it.icon }}</span>
<span class="yxg-body">
<strong>
{{ it.title }}
<span v-if="it.badge" class="badge">{{ it.badge }}</span>
</strong>
<span class="desc">{{ it.description }}</span>
</span>
<span class="chev" aria-hidden="true"></span>
</router-link>
</li>
</ul>
<p v-if="loading" class="hint">加载中</p>
<p v-else-if="error" class="err">{{ error }}</p>
<div v-else-if="cat" class="grid-wrap">
<router-link
v-for="it in cat.items"
:key="it.key"
:to="it.path"
class="tile"
>
<HomeToolIcon :name="toolIconFor(it.path || it.key)" :size="48" />
<div class="tile-body">
<strong>
{{ it.title }}
<span v-if="it.badge" class="badge">{{ it.badge }}</span>
</strong>
<span class="desc">{{ it.description }}</span>
</div>
<span class="chev" aria-hidden="true"></span>
</router-link>
</div>
</main>
</PageShell>
</template>
<script setup lang="ts">
@@ -38,7 +36,9 @@ import { onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { api } from '../api/client'
import BackButton from '../components/BackButton.vue'
import PageTitle from '../components/PageTitle.vue'
import HomeToolIcon from '../components/HomeToolIcon.vue'
import PageShell from '../components/PageShell.vue'
import { toolIconFor } from '../lib/toolIconMap'
type Item = {
key: string
@@ -81,11 +81,65 @@ watch(() => route.params.category, load)
</script>
<style scoped>
.pad{padding:8px 16px}
.yxg-list{margin-top:8px}
.badge{
font-size:10px;background:linear-gradient(135deg,#FF7A6E,var(--yxg-pri));
color:#fff;padding:1px 6px;border-radius:6px;margin-left:4px;font-weight:600;
vertical-align:middle;
.head { padding: 0 0 8px; }
.title {
margin-top: 4px;
font-family: var(--font-display);
font-size: 22px;
font-weight: 700;
color: var(--color-text-primary);
letter-spacing: 0.06em;
}
.sub {
margin-top: 4px;
font-size: 12px;
color: rgba(0, 0, 0, 0.38);
}
.grid-wrap {
padding: 8px 16px 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.tile {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
background: #fafafa;
border-radius: var(--radius-lg);
color: inherit;
transition: transform var(--duration-fast) ease;
}
.tile:active { transform: scale(0.985); }
.tile-body { flex: 1; min-width: 0; }
.tile-body strong {
font-size: 15px;
font-weight: 650;
color: var(--color-text-primary);
}
.desc {
display: block;
font-size: 12px;
color: var(--color-text-tertiary);
margin-top: 4px;
line-height: 1.4;
}
.badge {
font-size: 10px;
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
color: #fff;
padding: 1px 6px;
border-radius: 6px;
margin-left: 4px;
font-weight: 600;
vertical-align: middle;
}
.chev { color: #d0d0d0; font-size: 16px; }
.hint, .err {
padding: 16px;
font-size: 13px;
color: var(--color-text-secondary);
}
.err { color: var(--color-primary); }
</style>
+157 -26
View File
@@ -1,35 +1,62 @@
<template>
<main class="yxg-page">
<div class="yxg-hero">
<PageTitle feature="explore" title="探索" sub="六大分类 · 找到适合你的自我理解方式" />
</div>
<PageShell>
<template #hero>
<div class="head">
<h1 class="title">探索</h1>
<p class="sub">测评 · 工具 · 自我理解</p>
</div>
</template>
<div class="yxg-sheet">
<p v-if="loading" class="yxg-hint pad">加载中</p>
<p v-else-if="error" class="yxg-err pad">
<!-- 推荐宫格对齐首页 12 + 截图更多 -->
<section class="sec">
<div class="sec-head">
<span class="sec-title">推荐工具</span>
</div>
<div class="grid">
<router-link
v-for="t in featured"
:key="t.to + t.label"
:to="t.to"
class="g-item"
>
<HomeToolIcon :name="t.icon" :size="52" />
<span class="g-label">{{ t.label }}</span>
<span v-if="t.badge" class="g-badge" :class="'b-' + (t.badgeTone || 'hot')">{{ t.badge }}</span>
</router-link>
</div>
</section>
<section class="sec">
<div class="sec-head">
<span class="sec-title">全部分类</span>
</div>
<p v-if="loading" class="hint">加载中</p>
<p v-else-if="error" class="err">
{{ error }}
<button type="button" class="yxg-link" @click="load">重试</button>
<button type="button" class="retry" @click="load">重试</button>
</p>
<ul v-else class="yxg-list">
<li v-for="c in categories" :key="c.key">
<router-link :to="`/explore/${c.key}`">
<span class="yxg-mark" :class="'icon-' + c.tone" aria-hidden="true">{{ c.icon }}</span>
<span class="yxg-body">
<strong>{{ c.title }}</strong>
<span class="desc">{{ c.description }} · {{ c.items?.length || 0 }} </span>
</span>
<span class="chev" aria-hidden="true"></span>
</router-link>
</li>
</ul>
</div>
</main>
<div v-else class="cat-list">
<ListRow
v-for="c in categories"
:key="c.key"
:to="`/explore/${c.key}`"
:title="c.title"
:desc="`${c.description} · ${c.items?.length || 0} 项`"
:icon="toolIconFor(c.key)"
:badge="hotBadge(c)"
/>
</div>
</section>
</PageShell>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { api } from '../api/client'
import PageTitle from '../components/PageTitle.vue'
import HomeToolIcon, { type HomeToolIconName } from '../components/HomeToolIcon.vue'
import ListRow from '../components/ListRow.vue'
import PageShell from '../components/PageShell.vue'
import { toolIconFor } from '../lib/toolIconMap'
type Cat = {
key: string
@@ -37,13 +64,33 @@ type Cat = {
description: string
icon: string
tone: string
items?: unknown[]
items?: { badge?: string }[]
}
const featured: { to: string; label: string; icon: HomeToolIconName; badge?: string; badgeTone?: 'hot' | 'new' }[] = [
{ to: '/scales/mbti-lite', label: '人格测试', icon: 'mbti' },
{ to: '/star', label: '星座', icon: 'star' },
{ to: '/portrait', label: '愈心解码', icon: 'portrait', badge: '热', badgeTone: 'hot' },
{ to: '/rhythm', label: '身心节律', icon: 'rhythm' },
{ to: '/synastry', label: '合盘', icon: 'synastry', badge: '新', badgeTone: 'new' },
{ to: '/ask', label: 'AI问答', icon: 'ask' },
{ to: '/companion', label: '节气陪伴', icon: 'companion' },
{ to: '/cards', label: '意象卡片', icon: 'cards' },
{ to: '/reports', label: '成长报告', icon: 'reports', badge: '新', badgeTone: 'new' },
{ to: '/growth-plan', label: '成长计划', icon: 'growth' },
{ to: '/relation', label: '人格匹配', icon: 'relation' },
{ to: '/membership', label: '成长会员', icon: 'growth' },
]
const categories = ref<Cat[]>([])
const loading = ref(true)
const error = ref('')
function hotBadge(c: Cat) {
const b = c.items?.find((i) => i.badge)?.badge
return b || undefined
}
async function load() {
loading.value = true
error.value = ''
@@ -61,6 +108,90 @@ onMounted(load)
</script>
<style scoped>
.pad{padding:8px 16px}
.yxg-list{margin-top:8px}
.head { padding: 4px 0 8px; }
.title {
font-family: var(--font-display);
font-size: 26px;
font-weight: 700;
color: var(--color-text-primary);
letter-spacing: 0.08em;
}
.sub {
margin-top: 4px;
font-size: 12px;
color: rgba(0, 0, 0, 0.38);
letter-spacing: 0.06em;
}
.sec { padding: 0 16px; margin-top: 18px; }
.sec:first-child { margin-top: 12px; }
.sec-head { margin-bottom: 12px; }
.sec-title {
font-size: 17px;
font-weight: 700;
color: var(--color-text-primary);
}
.grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 14px 6px;
}
.g-item {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
color: inherit;
transition: transform var(--duration-fast) ease;
}
.g-item:active { transform: scale(0.92); }
.g-label {
font-size: 11px;
font-weight: 500;
color: #555;
text-align: center;
line-height: 1.2;
}
.g-badge {
position: absolute;
top: -4px;
right: 4px;
font-size: 9px;
font-weight: 650;
color: #fff;
padding: 2px 5px;
border-radius: 7px;
line-height: 1.3;
}
.g-badge.b-hot {
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
}
.g-badge.b-new {
background: linear-gradient(135deg, #6eb6ff, #4a90e2);
}
.cat-list {
background: #fafafa;
border-radius: var(--radius-lg);
padding: 4px 10px;
}
.cat-list :deep(.lr) {
border-bottom: 1px solid #f0ebe8;
}
.cat-list :deep(.lr:last-child) {
border-bottom: none;
}
.hint, .err {
font-size: 13px;
color: var(--color-text-secondary);
padding: 8px 0;
}
.err { color: var(--color-primary); }
.retry {
margin-left: 8px;
border: none;
background: transparent;
color: var(--color-accent-blue);
font-size: 13px;
text-decoration: underline;
}
</style>
+115 -33
View File
@@ -1,43 +1,50 @@
<template>
<main class="yxg-page">
<div class="yxg-hero">
<PageShell>
<template #hero>
<BackButton />
<PageTitle feature="growth" title="成长计划" sub="小目标 · 每日打卡(生活成长,非运势)" />
</div>
<div class="head">
<HomeToolIcon name="growth" :size="48" />
<div>
<h1 class="title">成长计划</h1>
<p class="sub">小目标 · 每日打卡</p>
</div>
</div>
</template>
<div class="yxg-sheet">
<div class="yxg-card">
<label class="yxg-label">新计划标题</label>
<input class="yxg-input" v-model="title" maxlength="40" placeholder="例如:每晚 11 点前放下手机" />
<label class="yxg-label">焦点可选</label>
<input class="yxg-input" v-model="focus" maxlength="80" placeholder="例如:保护睡眠与情绪" />
<button class="yxg-btn" type="button" :disabled="saving || !title.trim()" @click="create">创建计划</button>
<p v-if="err" class="yxg-err">{{ err }}</p>
<div class="body">
<div class="create">
<label class="lbl">新计划标题</label>
<input class="inp" v-model="title" maxlength="40" placeholder="例如:每晚 11 点前放下手机" />
<label class="lbl">焦点可选</label>
<input class="inp" v-model="focus" maxlength="80" placeholder="例如:保护睡眠与情绪" />
<button class="cta" type="button" :disabled="saving || !title.trim()" @click="create">创建计划</button>
<p v-if="err" class="err">{{ err }}</p>
</div>
<p v-if="loading" class="yxg-hint pad">加载中</p>
<ul v-else class="yxg-list plans">
<li v-for="p in plans" :key="p.id" class="plan">
<strong> {{ p.title }}</strong>
<p v-if="loading" class="hint">加载中</p>
<div v-else class="plans">
<div v-for="p in plans" :key="p.id" class="plan">
<strong>{{ p.title }}</strong>
<p v-if="p.focus" class="focus">{{ p.focus }}</p>
<button type="button" class="yxg-btn yxg-btn-ghost" :disabled="checking === p.id" @click="checkin(p.id)">
<button type="button" class="check" :disabled="checking === p.id" @click="checkin(p.id)">
{{ checking === p.id ? '记录中' : '今日打卡' }}
</button>
<ul v-if="checkins[p.id]?.length" class="cks">
<li v-for="c in checkins[p.id]" :key="c.id">{{ c.day }}{{ c.note ? ' · ' + c.note : '' }}</li>
</ul>
</li>
<li v-if="!plans.length" class="empty">还没有计划先创建一个小目标吧</li>
</ul>
</div>
<p v-if="!plans.length" class="hint">还没有计划先创建一个小目标吧</p>
</div>
</div>
</main>
</PageShell>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { api } from '../api/client'
import BackButton from '../components/BackButton.vue'
import PageTitle from '../components/PageTitle.vue'
import HomeToolIcon from '../components/HomeToolIcon.vue'
import PageShell from '../components/PageShell.vue'
const plans = ref<{ id: string; title: string; focus: string }[]>([])
const checkins = ref<Record<string, { id: string; day: string; note?: string }[]>>({})
@@ -83,7 +90,7 @@ async function checkin(id: string) {
checking.value = id
err.value = ''
try {
await api.growthCheckin(id, {})
await api.createGrowthCheckin(id, {})
const ck = await api.listGrowthCheckins(id)
checkins.value[id] = ck.items || []
} catch (e) {
@@ -97,14 +104,89 @@ onMounted(load)
</script>
<style scoped>
.yxg-card{margin-top:8px}
.yxg-card .yxg-label:first-child{margin-top:0}
.yxg-btn{margin-top:12px}
.pad{padding:0 16px}
.plans{list-style:none}
.plan{display:block}
.plan strong{font-size:15px;color:#222}
.focus{font-size:13px;color:#888;margin:6px 0}
.cks{margin-top:8px;padding-left:16px;font-size:12px;color:#999}
.empty{color:#999;font-size:13px;padding:8px 4px}
.head {
display: flex;
align-items: center;
gap: 12px;
margin-top: 8px;
padding-bottom: 8px;
}
.title {
font-family: var(--font-display);
font-size: 22px;
font-weight: 700;
letter-spacing: 0.06em;
}
.sub {
font-size: 12px;
color: rgba(0, 0, 0, 0.38);
margin-top: 2px;
}
.body { padding: 8px 16px 0; }
.create {
padding: 16px;
background: #fafafa;
border-radius: var(--radius-xl);
margin-bottom: 14px;
}
.lbl {
display: block;
font-size: 12px;
color: #999;
margin: 8px 0 6px;
}
.lbl:first-child { margin-top: 0; }
.inp {
width: 100%;
padding: 11px 12px;
border-radius: 12px;
border: 1.5px solid #eee;
background: #fff;
font-size: 14px;
font-family: inherit;
outline: none;
}
.cta {
margin-top: 14px;
width: 100%;
padding: 12px;
border: none;
border-radius: 22px;
color: #fff;
font-size: 14px;
font-weight: 600;
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
box-shadow: 0 6px 16px rgba(229, 77, 66, 0.28);
}
.cta:disabled { opacity: 0.45; }
.plans { display: flex; flex-direction: column; gap: 10px; }
.plan {
padding: 16px;
background: #fff;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
border: 1px solid #f5f0ed;
}
.plan strong { font-size: 15px; color: #222; }
.focus { font-size: 12px; color: #888; margin: 6px 0 10px; }
.check {
padding: 8px 14px;
border-radius: 18px;
border: 1.5px solid #f5c4bc;
background: #fff;
color: var(--color-primary);
font-size: 13px;
font-weight: 600;
}
.check:disabled { opacity: 0.5; }
.cks {
list-style: none;
margin: 10px 0 0;
padding: 0;
font-size: 12px;
color: #999;
}
.cks li { padding: 4px 0; }
.hint { font-size: 13px; color: #999; text-align: center; padding: 16px; }
.err { color: var(--color-primary); font-size: 12px; margin-top: 8px; }
</style>
File diff suppressed because it is too large Load Diff
+341 -53
View File
@@ -1,52 +1,108 @@
<template>
<main class="yxg-page">
<div class="yxg-hero">
<PageShell>
<template #hero>
<BackButton />
<PageTitle feature="cards" title="意象卡片" sub="选一个场景,抽取一张(或组合)卡片做反思练习" />
</div>
<div class="yxg-sheet sheet-pad">
<p v-if="bootError" class="yxg-err">{{ bootError }}</p>
<template v-else>
<p class="yxg-meta quota">今日剩余 {{ quotaLabel }}</p>
<div class="scenes">
<button
v-for="s in scenes"
:key="s.key"
type="button"
class="yxg-chip yxg-chip-solid scene"
:class="{ on: scene === s.key }"
@click="selectScene(s.key)"
><span class="si icon-teal" aria-hidden="true"></span>{{ s.label }}</button>
</div>
</template>
<div class="yxg-card">
<label class="yxg-label">生日用于关联档案</label>
<div class="body" :class="{ 'has-results': cards.length }">
<!-- Greeting -->
<div class="greet">
<div class="greet-icons" aria-hidden="true">
<HomeToolIcon name="cards" :size="44" />
<HomeToolIcon name="ask" :size="36" class="icon-offset" />
</div>
<h1 class="greet-title">Hi我是愈心 AI</h1>
<p class="greet-sub">来用意念卡片整理问题吧</p>
<p v-if="bootError" class="yxg-err">{{ bootError }}仍可填写后重试抽取</p>
<p class="quota">今日剩余 {{ quotaLabel }} · 场景 {{ scene }}</p>
</div>
<!-- Prompt rows (landing) -->
<div v-if="!cards.length" class="prompt-list">
<button
v-for="(row, i) in promptRows"
:key="i"
type="button"
class="prompt-row"
@click="onPromptClick(row)"
>
<span class="prompt-ico" aria-hidden="true">{{ row.icon }}</span>
<span class="prompt-text">{{ row.label }}</span>
<span class="prompt-go" aria-hidden="true"></span>
</button>
</div>
<!-- Mode chips -->
<div v-if="!cards.length" class="mode-row" role="group" aria-label="抽卡模式">
<button
type="button"
class="mode-chip"
:class="{ on: !wantDepth }"
@click="wantDepth = false"
>
单卡
</button>
<button
type="button"
class="mode-chip"
:class="{ on: wantDepth }"
@click="wantDepth = true"
>
三卡组合
</button>
</div>
<!-- Collapsible birth -->
<div v-if="!cards.length" class="birth-fold">
<button type="button" class="birth-toggle" @click="showBirth = !showBirth">
<span>关联生日</span>
<span class="caret" :class="{ open: showBirth }"></span>
</button>
<div v-show="showBirth" class="birth-panel yxg-card soft">
<BirthDateInputs v-model:year="year" v-model:month="month" v-model:day="day" compact />
<label class="chk"><input v-model="wantDepth" type="checkbox" /> 请求三卡组合会员直接解锁否则可模拟支付</label>
<button class="yxg-btn yxg-btn-block" type="button" :disabled="drawing" @click="draw">
{{ drawing ? '抽取中…' : '抽取卡片' }}
</button>
<p v-if="error" class="yxg-err">{{ error }}</p>
</div>
</div>
<template v-if="cards.length">
<article v-for="c in cards" :key="c.id" class="yxg-card">
<h2 class="card-title">{{ c.title }}</h2>
<p class="imagery">{{ c.imagery }}</p>
<p><strong>反思</strong> {{ c.prompt }}</p>
<p class="tip">{{ c.tip }}</p>
</article>
<ReportRich v-if="report" :summary="summary" :detail="detail" :deep="!!report.has_deep_access" />
<div v-if="report && !report.has_deep_access && wantDepth" class="yxg-card lock">
<p>三卡组合解读需深度版或成长会员</p>
<button class="yxg-btn" type="button" :disabled="paying" @click="buyDeep">解锁组合模拟支付</button>
</div>
<router-link v-if="report" class="yxg-link-plain report-link" :to="`/reports/${report.id}`">在报告页打开 </router-link>
</template>
<!-- Results -->
<template v-if="cards.length">
<article v-for="c in cards" :key="c.id" class="yxg-card card-result">
<h2 class="card-title">{{ c.title }}</h2>
<p class="imagery">{{ c.imagery }}</p>
<p><strong>反思</strong> {{ c.prompt }}</p>
<p class="tip">{{ c.tip }}</p>
</article>
<ReportRich v-if="report" :summary="summary" :detail="detail" :deep="!!report.has_deep_access" />
<div v-if="report && !report.has_deep_access && wantDepth" class="yxg-card lock">
<p>三卡组合解读需深度版或成长会员</p>
<button class="yxg-btn" type="button" :disabled="paying" @click="buyDeep">解锁组合模拟支付</button>
</div>
<router-link v-if="report" class="yxg-link-plain report-link" :to="`/reports/${report.id}`">
在报告页打开
</router-link>
</template>
<p class="yxg-disc">意象卡片用于投射与自我反思不判定好坏也不预测未来</p>
<p class="yxg-disc ai-note">意象卡片用于投射与自我反思不判定好坏也不预测未来部分内容由 AI 生成仅供参考</p>
<p v-if="error" class="yxg-err err-float">{{ error }}</p>
</div>
</main>
<!-- Bottom input bar -->
<div v-if="!cards.length" class="input-bar">
<input
v-model="questionInput"
type="text"
class="question-in"
placeholder="让我来解答你的问题吧"
@keydown.enter="sendDraw"
/>
<button type="button" class="send-btn" :disabled="drawing" aria-label="发送并抽取" @click="sendDraw">
<span v-if="drawing" class="send-spin"></span>
<span v-else class="send-ico" aria-hidden="true"></span>
</button>
</div>
</PageShell>
</template>
<script setup lang="ts">
@@ -56,7 +112,8 @@ import { validateBirth } from '@yuxingu/utils'
import { api } from '../api/client'
import BackButton from '../components/BackButton.vue'
import BirthDateInputs from '../components/BirthDateInputs.vue'
import PageTitle from '../components/PageTitle.vue'
import HomeToolIcon from '../components/HomeToolIcon.vue'
import PageShell from '../components/PageShell.vue'
import ReportRich from '../components/ReportRich.vue'
import { AnalyticsEvent, track } from '../lib/analytics'
@@ -73,6 +130,15 @@ const error = ref('')
const bootError = ref('')
const cards = ref<{ id: string; title: string; imagery: string; prompt: string; tip: string }[]>([])
const report = ref<GrowthReport | null>(null)
const questionInput = ref('')
const showBirth = ref(false)
const promptRows = [
{ icon: '🌊', label: '最近情绪有点乱,帮我理一理', scene: '情绪整理' },
{ icon: '💬', label: '和 TA 相处时,我总在重复什么模式?', scene: '关系反思' },
{ icon: '🪞', label: '我想更了解自己此刻的状态', scene: '自我觉察' },
{ icon: '🍃', label: '生活节奏太快,我需要一点方向感', scene: '生活节奏' },
]
const summary = computed(() => (report.value?.summary || {}) as Record<string, unknown>)
const detail = computed(() => (report.value?.detail || null) as Record<string, unknown> | null)
@@ -82,11 +148,35 @@ const quotaLabel = computed(() => {
return `${quota.value.remaining} / ${quota.value.daily_free}`
})
function birthFilled() {
const y = Number(year.value)
const m = Number(month.value)
const d = Number(day.value)
return !validateBirth(y, m, d)
}
function selectScene(key: string) {
scene.value = key
track('cards_scene_selected', { scene: key })
}
function onPromptClick(row: { label: string; scene: string }) {
selectScene(row.scene)
questionInput.value = row.label
if (birthFilled()) {
draw()
} else {
showBirth.value = true
}
}
function sendDraw() {
if (questionInput.value.trim()) {
scene.value = questionInput.value.trim()
}
draw()
}
async function refreshQuota() {
quota.value = await api.getImageCardQuota()
}
@@ -111,6 +201,7 @@ async function draw() {
const msg = validateBirth(y, m, d)
if (msg) {
error.value = msg
showBirth.value = true
return
}
drawing.value = true
@@ -160,19 +251,216 @@ onMounted(boot)
</script>
<style scoped>
.quota{margin:0 0 10px}
.scenes{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:12px}
.scene{display:inline-flex;align-items:center;gap:6px}
.si{
width:22px;height:22px;border-radius:8px;display:inline-flex;align-items:center;justify-content:center;
font-size:12px;box-shadow:inset 0 -1px 0 rgba(0,0,0,.03);
.body {
padding: 0 16px 88px;
}
.body.has-results {
padding-bottom: 24px;
}
.greet {
text-align: center;
padding: 8px 0 20px;
}
.greet-icons {
display: flex;
align-items: flex-end;
justify-content: center;
gap: 4px;
margin-bottom: 12px;
}
.icon-offset {
margin-bottom: -4px;
opacity: 0.85;
}
.greet-title {
font-family: var(--font-display);
font-size: 22px;
font-weight: 700;
color: var(--color-text-primary);
letter-spacing: 0.04em;
margin: 0;
}
.greet-sub {
font-size: 14px;
color: var(--color-text-secondary);
margin: 6px 0 10px;
}
.quota {
font-size: 11px;
color: var(--color-text-tertiary);
margin: 0;
}
.prompt-list {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 18px;
}
.prompt-row {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 16px 14px;
border: none;
border-radius: var(--radius-xl);
background: var(--color-surface);
box-shadow: var(--shadow-card);
text-align: left;
cursor: pointer;
transition: transform var(--duration-fast) ease;
}
.prompt-row:active {
transform: scale(0.985);
}
.prompt-ico {
font-size: 22px;
flex-shrink: 0;
width: 36px;
text-align: center;
}
.prompt-text {
flex: 1;
font-size: 14px;
font-weight: 500;
color: var(--color-text-primary);
line-height: 1.45;
}
.prompt-go {
font-size: 14px;
color: var(--color-primary);
opacity: 0.6;
}
.mode-row {
display: flex;
gap: 8px;
justify-content: center;
margin-bottom: 16px;
}
.mode-chip {
border: 1.5px solid #eee;
background: #fff;
border-radius: 999px;
padding: 8px 20px;
font-size: 13px;
color: #666;
cursor: pointer;
}
.mode-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);
}
.birth-fold {
margin-bottom: 12px;
}
.birth-toggle {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 12px 14px;
border: none;
border-radius: var(--radius-lg);
background: rgba(255, 255, 255, 0.7);
font-size: 13px;
font-weight: 600;
color: var(--color-text-secondary);
cursor: pointer;
}
.caret {
transition: transform 0.2s ease;
}
.caret.open {
transform: rotate(180deg);
}
.birth-panel {
margin-top: 8px;
}
.birth-panel :deep(.bd) {
margin: 8px 0 12px;
}
.card-result {
margin-bottom: 12px;
}
.imagery {
color: #666;
margin-bottom: 8px;
}
.tip {
color: #3d6b45;
margin-top: 6px;
}
.lock .yxg-btn {
margin-top: 12px;
width: 100%;
}
.report-link {
display: block;
margin-top: 12px;
}
.ai-note {
margin-top: 16px;
}
.err-float {
margin-top: 8px;
}
.input-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 20;
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px calc(10px + env(safe-area-inset-bottom, 0));
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-top: 1px solid rgba(0, 0, 0, 0.06);
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.06);
}
.question-in {
flex: 1;
border: 1.5px solid #eee;
border-radius: 22px;
padding: 11px 16px;
font-size: 14px;
background: #fdfaf8;
outline: none;
}
.question-in:focus {
border-color: rgba(229, 77, 66, 0.35);
}
.send-btn {
width: 40px;
height: 40px;
flex-shrink: 0;
border: none;
border-radius: 50%;
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
color: #fff;
font-size: 18px;
font-weight: 700;
cursor: pointer;
box-shadow: 0 4px 12px rgba(229, 77, 66, 0.28);
display: flex;
align-items: center;
justify-content: center;
}
.send-btn:disabled {
opacity: 0.6;
}
.send-ico {
line-height: 1;
margin-top: -2px;
}
.yxg-card{margin:0 0 12px}
.yxg-card .yxg-label:first-child{margin-top:0}
:deep(.bd){margin:8px 0 12px}
.chk{display:flex;align-items:center;gap:8px;font-size:13px;color:#666;margin:4px 0 12px}
.imagery{color:#666;margin-bottom:8px}
.tip{color:#3d6b45;margin-top:6px}
.lock .yxg-btn{margin-top:12px;width:100%}
.report-link{display:block;margin-top:12px}
</style>
+304 -25
View File
@@ -1,36 +1,112 @@
<template>
<main class="yxg-page">
<div class="yxg-hero">
<BackButton />
<PageTitle feature="rhythm" title="身心节律" :sub="needBirth && !report ? '填写生日,生成身心节律生活建议' : undefined" />
</div>
<div class="yxg-sheet sheet-pad">
<PageShell>
<template #hero>
<div class="top-row">
<BackButton />
<span class="who-static">身心节律</span>
<span class="spacer" />
</div>
</template>
<div class="body">
<template v-if="needBirth && !report">
<div class="yxg-card">
<div class="intro-card">
<HomeToolIcon name="rhythm" :size="64" />
<h1 class="intro-title">身心节律</h1>
<p class="intro-sub">填写生日看见作息 · 饮食 · 活动的节奏建议生活方式参考非算命</p>
</div>
<div class="yxg-card form-card">
<label class="yxg-label">生日</label>
<BirthDateInputs v-model:year="year" v-model:month="month" v-model:day="day" />
<button class="yxg-btn yxg-btn-block mt" type="button" :disabled="loading" @click="start">开始</button>
<button class="cta" type="button" :disabled="loading" @click="start">
{{ loading ? '生成中…' : '生成我的节律' }}
</button>
<p v-if="error" class="yxg-err">{{ error }}</p>
</div>
</template>
<p v-else-if="loading" class="yxg-hint">正在生成</p>
<p v-else-if="error" class="yxg-err">
<p v-else-if="loading" class="yxg-hint">正在生成节律</p>
<p v-else-if="error && !report" class="yxg-err">
{{ error }}
<button type="button" class="yxg-link" @click="load">重试</button>
</p>
<template v-else-if="report">
<p class="yxg-meta head">{{ headline }}</p>
<p v-if="oneLiner" class="yxg-lead">{{ oneLiner }}</p>
<ReportRich :summary="summary" :detail="detail" :deep="!!report.has_deep_access" />
<div v-if="!report.has_deep_access" class="yxg-card lock">
<p>完整节律建议与练习可在深度版或成长会员中查看</p>
<button class="yxg-btn" type="button" :disabled="paying" @click="buyDeep">查看深度版模拟支付</button>
<div class="mode-tabs" role="tablist">
<button
v-for="t in resultTabs"
:key="t.key"
type="button"
class="mode-tab"
:class="{ on: tab === t.key }"
@click="tab = t.key"
>
{{ t.label }}
</button>
</div>
<template v-if="tab === 'overview'">
<h2 class="hl">{{ headline || '我的身心节律' }}</h2>
<p v-if="oneLiner" class="lead">{{ oneLiner }}</p>
<div v-if="keywords.length" class="tags">
<span v-for="k in keywords" :key="k" class="tag">{{ k }}</span>
</div>
<div v-if="styleLabel" class="spotlight">
<strong>节律倾向</strong>
<p>{{ styleLabel }}</p>
</div>
<p v-if="overviewText" class="overview">{{ overviewText }}</p>
</template>
<template v-else-if="tab === 'wuxing'">
<WuxingBars v-if="wuxingBars.length" :bars="wuxingBars" />
<div v-else class="empty-soft">
<HomeToolIcon name="rhythm" :size="48" />
<p>暂无五行分布数据可从概览与建议了解节律倾向</p>
</div>
<ReportRich :summary="summary" :detail="null" :deep="false" />
</template>
<template v-else-if="tab === 'tips'">
<ReportRich :summary="summary" :detail="detail" :deep="!!report.has_deep_access" />
<div v-if="!report.has_deep_access" class="lock-card">
<p>完整习惯方案与本周练习可在深度版或成长会员中查看</p>
<button class="cta soft" type="button" :disabled="paying" @click="buyDeep">解锁深度版</button>
</div>
</template>
<template v-else>
<router-link class="term-card" to="/companion">
<div class="term-ico" aria-hidden="true">
<HomeToolIcon name="companion" :size="40" />
</div>
<div class="term-body">
<strong>节气生活陪伴</strong>
<p>{{ solarTip || '结合当季节气,微调作息与饮食节奏。' }}</p>
</div>
<span class="term-go" aria-hidden="true"></span>
</router-link>
<p v-if="weekFocus" class="focus-tip">本周关注{{ weekFocus }}</p>
</template>
<div class="sticky-bar">
<router-link class="sb-btn primary" to="/companion">去节气陪伴</router-link>
<button
v-if="!report.has_deep_access"
class="sb-btn"
type="button"
:disabled="paying"
@click="buyDeep"
>
解锁深度
</button>
<router-link class="sb-btn" to="/ask">去问答</router-link>
</div>
<router-link class="yxg-link-plain report-link" to="/companion">查看今日节气陪伴 </router-link>
<router-link v-if="report.id" class="yxg-link-plain report-link" :to="`/reports/${report.id}`">在报告页打开 </router-link>
</template>
<p class="yxg-disc">本内容为自我探索与生活方式参考不构成医疗建议亦非占卜预测</p>
</div>
</main>
</PageShell>
</template>
<script setup lang="ts">
@@ -41,8 +117,10 @@ import { validateBirth } from '@yuxingu/utils'
import { api } from '../api/client'
import BackButton from '../components/BackButton.vue'
import BirthDateInputs from '../components/BirthDateInputs.vue'
import PageTitle from '../components/PageTitle.vue'
import HomeToolIcon from '../components/HomeToolIcon.vue'
import PageShell from '../components/PageShell.vue'
import ReportRich from '../components/ReportRich.vue'
import WuxingBars, { type WuxingBar } from '../components/WuxingBars.vue'
import { AnalyticsEvent, track } from '../lib/analytics'
const route = useRoute()
@@ -51,14 +129,53 @@ const needBirth = ref(true)
const error = ref('')
const report = ref<GrowthReport | null>(null)
const paying = ref(false)
const tab = ref<'overview' | 'wuxing' | 'tips' | 'term'>('overview')
const year = ref(String(route.query.y || ''))
const month = ref(String(route.query.m || ''))
const day = ref(String(route.query.d || ''))
const resultTabs = [
{ key: 'overview' as const, label: '概览' },
{ key: 'wuxing' as const, label: '五行' },
{ key: 'tips' as const, label: '建议' },
{ key: 'term' as const, label: '节气' },
]
const summary = computed(() => (report.value?.summary || {}) as Record<string, unknown>)
const detail = computed(() => (report.value?.detail || null) as Record<string, unknown> | null)
const headline = computed(() => String(summary.value.headline || ''))
const oneLiner = computed(() => String(summary.value.one_liner || ''))
const styleLabel = computed(() => String(summary.value.style_label || ''))
const overviewText = computed(() => String(summary.value.overview || ''))
const weekFocus = computed(() => String(summary.value.week_focus || ''))
const keywords = computed(() =>
Array.isArray(summary.value.keywords) ? (summary.value.keywords as string[]) : [],
)
const solarTip = computed(() => {
const st = summary.value.solar_term
if (st && typeof st === 'object') {
const tip = (st as Record<string, unknown>).tip
if (tip) return String(tip)
}
return String(summary.value.today_tip || summary.value.life_tip || '')
})
const wuxingBars = computed((): WuxingBar[] => {
const wx = summary.value.wuxing
if (!wx || typeof wx !== 'object') return []
const bars = (wx as Record<string, unknown>).bars
if (!Array.isArray(bars)) return []
return bars
.map((b) => {
const o = b as Record<string, unknown>
return {
el: String(o.el || o.element || ''),
pct: Number(o.pct ?? o.score ?? 0),
color: String(o.color || '#E54D42'),
}
})
.filter((b) => b.el)
})
async function generate(y: number, m: number, d: number) {
loading.value = true
@@ -68,6 +185,7 @@ async function generate(y: number, m: number, d: number) {
const birth = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
const profile = await api.createProfile({ relation: 'self', birth_date: birth, display_name: '我' })
report.value = await api.createRhythm(profile.id)
tab.value = 'overview'
track(AnalyticsEvent.PortraitCompleted, { source: 'rhythm' })
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
@@ -96,8 +214,10 @@ async function load() {
needBirth.value = false
try {
report.value = await api.getReport(reportId)
tab.value = 'overview'
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
needBirth.value = true
} finally {
loading.value = false
}
@@ -107,6 +227,9 @@ async function load() {
const m = Number(route.query.m)
const d = Number(route.query.d)
if (y && m && d && !validateBirth(y, m, d)) {
year.value = String(y)
month.value = String(m)
day.value = String(d)
await generate(y, m, d)
}
}
@@ -132,9 +255,165 @@ onMounted(load)
</script>
<style scoped>
.yxg-card{margin:8px 0 12px}
.mt{margin-top:14px}
.head{margin:4px 0 8px;font-size:13px}
.lock .yxg-btn{margin-top:12px;width:100%}
.report-link{display:block;margin-top:12px}
.top-row {
display: flex;
align-items: center;
gap: 10px;
padding-bottom: 4px;
}
.who-static {
flex: 1;
font-size: 16px;
font-weight: 700;
text-align: center;
color: var(--color-text-primary);
}
.spacer { width: 36px; }
.body { padding: 8px 16px 88px; }
.intro-card { text-align: center; padding: 20px 12px 8px; }
.intro-title {
font-family: var(--font-display);
font-size: 24px;
font-weight: 700;
margin-top: 8px;
letter-spacing: 0.08em;
}
.intro-sub { font-size: 13px; color: #888; margin-top: 6px; line-height: 1.5; }
.form-card { margin-top: 12px; }
.cta {
margin-top: 14px;
width: 100%;
padding: 13px;
border: none;
border-radius: 22px;
color: #fff;
font-size: 15px;
font-weight: 600;
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
box-shadow: 0 6px 16px rgba(229, 77, 66, 0.28);
}
.cta.soft {
background: #fff;
color: var(--color-primary);
border: 1.5px solid #f5c4bc;
box-shadow: none;
}
.cta:disabled { opacity: 0.45; }
.mode-tabs {
display: flex;
gap: 4px;
margin-bottom: 14px;
background: #fafafa;
border-radius: var(--radius-pill);
padding: 4px;
}
.mode-tab {
flex: 1;
border: none;
background: transparent;
padding: 9px 4px;
border-radius: var(--radius-pill);
font-size: 13px;
color: #888;
font-weight: 600;
}
.mode-tab.on {
background: #fff;
color: var(--color-primary);
box-shadow: 0 2px 8px rgba(229, 77, 66, 0.1);
}
.hl {
font-family: var(--font-display);
font-size: 20px;
font-weight: 700;
line-height: 1.35;
color: #222;
}
.lead { margin-top: 8px; font-size: 13px; color: #777; line-height: 1.55; }
.tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
.tag {
font-size: 11px;
padding: 4px 10px;
border-radius: var(--radius-pill);
background: rgba(255, 255, 255, 0.8);
color: #8a4a3a;
font-weight: 600;
border: 1px solid #f5e8e4;
}
.spotlight {
margin-top: 14px;
padding: 14px;
border-radius: var(--radius-xl);
background: linear-gradient(145deg, #fff0ec, #ffe4dc);
box-shadow: var(--shadow-card);
}
.spotlight strong { font-size: 14px; color: #222; }
.spotlight p { margin-top: 6px; font-size: 13px; color: #666; line-height: 1.5; }
.overview { margin-top: 12px; font-size: 13px; color: #555; line-height: 1.65; }
.empty-soft {
text-align: center;
padding: 24px 16px;
border-radius: var(--radius-xl);
background: #fafafa;
color: #999;
font-size: 13px;
line-height: 1.5;
margin-bottom: 12px;
}
.empty-soft p { margin-top: 10px; }
.lock-card {
margin-top: 12px;
padding: 16px;
background: linear-gradient(145deg, #fff8e8, #ffeec8);
border-radius: var(--radius-xl);
}
.term-card {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border-radius: var(--radius-xl);
background: linear-gradient(145deg, #fff0ec, #ffe4dc);
box-shadow: var(--shadow-card);
color: inherit;
}
.term-body { flex: 1; min-width: 0; }
.term-body strong { font-size: 15px; color: #222; }
.term-body p { margin-top: 4px; font-size: 12px; color: #666; line-height: 1.45; }
.term-go { font-size: 20px; color: var(--color-primary); }
.focus-tip { margin-top: 12px; font-size: 13px; color: #888; }
.sticky-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 20;
display: flex;
gap: 8px;
padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(12px);
border-top: 1px solid #f0ebe8;
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.06);
}
.sb-btn {
flex: 1;
padding: 11px 8px;
border-radius: 22px;
border: 1.5px solid #f0ebe8;
background: #fff;
font-size: 13px;
font-weight: 600;
color: #555;
text-align: center;
text-decoration: none;
}
.sb-btn.primary {
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
color: #fff;
border: none;
box-shadow: 0 4px 12px rgba(229, 77, 66, 0.25);
}
.sb-btn:disabled { opacity: 0.45; }
.yxg-label { display: block; margin-bottom: 8px; font-size: 12px; color: #999; }
</style>
+121 -22
View File
@@ -1,30 +1,52 @@
<template>
<main class="yxg-page">
<div class="yxg-hero">
<PageShell>
<template #hero>
<BackButton />
<PageTitle feature="membership" title="成长会员" sub="完整分析 · AI成长助手更多次数 · 专属内容" />
</div>
<div class="yxg-sheet sheet-pad">
<p v-if="loading" class="yxg-hint">加载中</p>
<p v-else-if="error" class="yxg-err">
<div class="head">
<HomeToolIcon name="growth" :size="48" />
<div>
<h1 class="title">成长会员</h1>
<p class="sub">完整分析 · AI 更多次数 · 专属内容</p>
</div>
</div>
</template>
<div class="body">
<p v-if="loading" class="hint">加载中</p>
<p v-else-if="error" class="err">
{{ error }}
<button type="button" class="yxg-link" @click="load">重试</button>
<button type="button" class="retry" @click="load">重试</button>
</p>
<template v-else>
<div v-if="me?.active" class="yxg-card ok">
<div v-if="me?.active" class="status-card on">
<p class="ok-title">会员有效 · {{ planLabel }}</p>
<p v-if="me.expires_at" class="yxg-meta">到期{{ formatDate(me.expires_at) }}</p>
<p class="yxg-meta">问答额度剩余{{ me.ask_quota_left ?? 0 }}</p>
<p v-if="me.expires_at" class="meta">到期{{ formatDate(me.expires_at) }}</p>
<p class="meta">问答额度剩余{{ me.ask_quota_left ?? 0 }}</p>
</div>
<div v-else class="yxg-card">
<p>尚未开通成长会员开通后可查看画像与关系理解的完整分析</p>
<button class="yxg-btn" type="button" :disabled="paying" @click="subscribe('month')">开通月卡模拟支付</button>
<button class="yxg-btn yxg-btn-ghost" type="button" :disabled="paying" @click="subscribe('quarter')">季卡</button>
<button class="yxg-btn yxg-btn-ghost" type="button" :disabled="paying" @click="subscribe('year')">年卡</button>
<div v-else class="status-card">
<p class="lead">开通后可查看画像与关系理解的完整分析并获得更多 AI 问答次数</p>
</div>
<div class="plans">
<button
v-for="p in plans"
:key="p.key"
type="button"
class="plan"
:class="{ featured: p.featured }"
:disabled="paying"
@click="subscribe(p.key)"
>
<span v-if="p.tag" class="tag">{{ p.tag }}</span>
<strong>{{ p.label }}</strong>
<span class="price">{{ p.price }}</span>
<span class="desc">{{ p.desc }}</span>
</button>
</div>
<p class="note">当前为模拟支付便于本地验收</p>
</template>
</div>
</main>
</PageShell>
</template>
<script setup lang="ts">
@@ -32,9 +54,16 @@ import { computed, onMounted, ref } from 'vue'
import type { MembershipMe } from '@yuxingu/types'
import { api } from '../api/client'
import BackButton from '../components/BackButton.vue'
import PageTitle from '../components/PageTitle.vue'
import HomeToolIcon from '../components/HomeToolIcon.vue'
import PageShell from '../components/PageShell.vue'
import { AnalyticsEvent, track } from '../lib/analytics'
const plans = [
{ key: 'month', label: '月卡', price: '模拟开通', desc: '按月灵活体验', featured: false, tag: '' },
{ key: 'quarter', label: '季卡', price: '模拟开通', desc: '三个月持续成长', featured: true, tag: '推荐' },
{ key: 'year', label: '年卡', price: '模拟开通', desc: '全年陪伴与报告', featured: false, tag: '' },
]
const loading = ref(true)
const paying = ref(false)
const error = ref('')
@@ -87,8 +116,78 @@ onMounted(load)
</script>
<style scoped>
.yxg-card{margin-top:8px}
.ok{border:1px solid #c8e6c9;background:#f4fff4}
.ok-title{font-size:15px;font-weight:650;color:#222;margin-bottom:6px}
.yxg-btn{margin-top:12px;margin-right:8px}
.head {
display: flex;
align-items: center;
gap: 12px;
margin-top: 8px;
padding-bottom: 8px;
}
.title {
font-family: var(--font-display);
font-size: 22px;
font-weight: 700;
letter-spacing: 0.06em;
}
.sub {
font-size: 12px;
color: rgba(0, 0, 0, 0.38);
margin-top: 2px;
}
.body { padding: 8px 16px 0; }
.status-card {
padding: 16px;
border-radius: var(--radius-xl);
background: #fafafa;
margin-bottom: 14px;
}
.status-card.on {
background: linear-gradient(145deg, #fff8e8, #ffeec8);
box-shadow: var(--shadow-card);
}
.ok-title { font-size: 16px; font-weight: 700; color: #222; }
.meta { font-size: 12px; color: #888; margin-top: 4px; }
.lead { font-size: 14px; color: #555; line-height: 1.6; }
.plans { display: flex; flex-direction: column; gap: 10px; }
.plan {
position: relative;
text-align: left;
padding: 16px;
border-radius: var(--radius-lg);
border: 1.5px solid #f0ebe8;
background: #fff;
display: flex;
flex-direction: column;
gap: 4px;
}
.plan.featured {
border-color: #f5c4bc;
background: linear-gradient(145deg, #fff7f4, #ffe9e2);
box-shadow: var(--shadow-hero);
}
.plan strong { font-size: 16px; color: #222; }
.price { font-size: 13px; font-weight: 650; color: var(--color-primary); }
.desc { font-size: 12px; color: #999; }
.tag {
position: absolute;
top: 10px;
right: 10px;
font-size: 9px;
font-weight: 700;
color: #fff;
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
padding: 2px 6px;
border-radius: 6px;
}
.plan:disabled { opacity: 0.55; }
.note { margin-top: 12px; font-size: 11px; color: #ccc; text-align: center; }
.hint, .err { font-size: 13px; color: #999; padding: 8px 0; }
.err { color: var(--color-primary); }
.retry {
border: none;
background: transparent;
color: var(--color-accent-blue);
text-decoration: underline;
margin-left: 6px;
}
</style>
+189 -56
View File
@@ -1,61 +1,97 @@
<template>
<main class="yxg-page">
<div class="yxg-hero">
<PageTitle feature="profile" title="我的" sub="个人档案 · 成长报告 · 成长会员" />
</div>
<div class="yxg-sheet">
<p v-if="loading" class="yxg-hint pad">加载中</p>
<p v-else-if="error" class="yxg-err pad">
{{ error }}
<button type="button" class="yxg-link" @click="load">重试</button>
</p>
<template v-else>
<div class="yxg-card member">
<p class="yxg-chip-ico">
<span class="yxg-mark icon-purple tiny" aria-hidden="true"></span>
<span class="member-text">
<strong v-if="membership?.active">成长会员有效 · {{ planLabel }}</strong>
<strong v-else>尚未开通成长会员</strong>
<span class="yxg-meta">档案 {{ profileCount }} </span>
</span>
</p>
<router-link class="yxg-btn" to="/membership">
{{ membership?.active ? '查看会员' : '开通成长会员' }}
</router-link>
<PageShell>
<template #hero>
<div class="profile-head">
<span class="av" aria-hidden="true">
<BrandLogo size="card" class="av-logo" />
</span>
<div class="who">
<p class="name">愈心谷用户</p>
<p class="meta">档案 {{ profileCount }} </p>
</div>
<ul class="yxg-list">
<li v-for="item in links" :key="item.to">
<router-link :to="item.to">
<span class="yxg-mark" :class="'icon-' + item.tone" aria-hidden="true">{{ item.icon }}</span>
<span class="yxg-body"><strong>{{ item.label }}</strong></span>
<span class="chev" aria-hidden="true"></span>
</router-link>
</li>
</ul>
</template>
</div>
</main>
<router-link class="edit" to="/profile">编辑 </router-link>
</div>
</template>
<p v-if="loading" class="hint">加载中</p>
<p v-else-if="error" class="err">
{{ error }}
<button type="button" class="retry" @click="load">重试</button>
</p>
<template v-else>
<!-- 资产条 -->
<section class="assets">
<router-link to="/membership" class="asset">
<strong>{{ membership?.active ? planLabel : '未开通' }}</strong>
<span>成长会员</span>
</router-link>
<router-link to="/ask" class="asset">
<strong>{{ profileCount }}</strong>
<span>档案</span>
</router-link>
<router-link to="/reports" class="asset">
<strong>报告</strong>
<span>成长记录</span>
</router-link>
<router-link to="/membership" class="asset accent">
<strong>{{ membership?.active ? '续费' : '开通' }}</strong>
<span>会员中心</span>
</router-link>
</section>
<section class="sec">
<p class="sec-title">档案与内容</p>
<div class="group">
<ListRow
v-for="item in groupArchive"
:key="item.to"
:to="item.to"
:title="item.label"
:icon="item.icon"
/>
</div>
</section>
<section class="sec">
<p class="sec-title">成长与会员</p>
<div class="group">
<ListRow
v-for="item in groupGrowth"
:key="item.to"
:to="item.to"
:title="item.label"
:icon="item.icon"
/>
</div>
</section>
</template>
</PageShell>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import type { MembershipMe } from '@yuxingu/types'
import { api } from '../api/client'
import PageTitle from '../components/PageTitle.vue'
import type { IconTone } from '../lib/featureIcons'
import BrandLogo from '../components/BrandLogo.vue'
import ListRow from '../components/ListRow.vue'
import PageShell from '../components/PageShell.vue'
import type { HomeToolIconName } from '../components/HomeToolIcon.vue'
const links: { to: string; icon: string; label: string; tone: IconTone }[] = [
{ to: '/profile', icon: '◍', label: '个人档案', tone: 'mute' },
{ to: '/reports', icon: '▣', label: '我的成长报告', tone: 'orange' },
{ to: '/', icon: '△', label: '个人画像', tone: 'gold' },
{ to: '/star', icon: '✦', label: '星象性格', tone: 'night' },
{ to: '/rhythm', icon: '☯', label: '身心节律', tone: 'green' },
{ to: '/cards', icon: '◈', label: '意象卡片', tone: 'teal' },
{ to: '/relation', icon: '♡', label: '关系理解', tone: 'rose' },
{ to: '/ask', icon: '◎', label: 'AI 成长助手', tone: 'gold' },
{ to: '/explore', icon: '◆', label: '探索测试', tone: 'blue' },
{ to: '/membership', icon: '⑨', label: '成长会员', tone: 'purple' },
const groupArchive: { to: string; label: string; icon: HomeToolIconName }[] = [
{ to: '/profile', label: '个人档案', icon: 'portrait' },
{ to: '/reports', label: '我的成长报告', icon: 'reports' },
{ to: '/portrait', label: '愈心解码', icon: 'portrait' },
{ to: '/star', label: '星象性格', icon: 'star' },
{ to: '/rhythm', label: '身心节律', icon: 'rhythm' },
{ to: '/cards', label: '意象卡片', icon: 'cards' },
]
const groupGrowth: { to: string; label: string; icon: HomeToolIconName }[] = [
{ to: '/relation', label: '人格匹配', icon: 'relation' },
{ to: '/ask', label: 'AI 成长助手', icon: 'ask' },
{ to: '/explore', label: '探索测试', icon: 'mbti' },
{ to: '/growth-plan', label: '成长计划', icon: 'growth' },
{ to: '/membership', label: '成长会员', icon: 'growth' },
]
const loading = ref(true)
@@ -68,7 +104,7 @@ const planLabel = computed(() => {
if (p === 'quarter') return '季卡'
if (p === 'year') return '年卡'
if (p === 'month') return '月卡'
return '成长会员'
return '会员'
})
async function load() {
@@ -89,10 +125,107 @@ onMounted(load)
</script>
<style scoped>
.pad{padding:8px 16px}
.member{display:flex;flex-direction:column;gap:12px;align-items:flex-start}
.member-text{display:flex;flex-direction:column;gap:2px}
.member-text strong{font-size:15px;color:#222;font-weight:650}
.tiny{width:36px!important;height:36px!important;font-size:16px!important;border-radius:12px!important}
.yxg-btn{margin-top:2px}
.profile-head {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 0 12px;
}
.av {
width: 56px;
height: 56px;
border-radius: 50%;
background: linear-gradient(145deg, #ffe8e0, #ffd0c4);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7);
flex-shrink: 0;
}
.av-logo {
width: 34px !important;
height: auto !important;
max-height: none !important;
}
.who { flex: 1; min-width: 0; }
.name {
font-size: 18px;
font-weight: 700;
color: var(--color-text-primary);
}
.meta {
font-size: 12px;
color: #999;
margin-top: 2px;
}
.edit {
font-size: 12px;
color: #bbb;
flex-shrink: 0;
}
.assets {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
padding: 0 16px;
margin-top: 4px;
}
.asset {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 12px 4px;
background: #fafafa;
border-radius: var(--radius-md);
text-align: center;
}
.asset strong {
font-size: 13px;
font-weight: 700;
color: #222;
}
.asset span {
font-size: 10px;
color: #999;
}
.asset.accent strong {
color: var(--color-primary);
}
.sec {
padding: 0 16px;
margin-top: 20px;
}
.sec-title {
font-size: 13px;
font-weight: 650;
color: #999;
margin-bottom: 8px;
letter-spacing: 0.04em;
}
.group {
background: #fafafa;
border-radius: var(--radius-lg);
padding: 2px 10px;
}
.group :deep(.lr) {
border-bottom: 1px solid #f0ebe8;
}
.group :deep(.lr:last-child) {
border-bottom: none;
}
.hint, .err {
padding: 16px;
font-size: 13px;
color: #999;
}
.err { color: var(--color-primary); }
.retry {
border: none;
background: transparent;
color: var(--color-accent-blue);
text-decoration: underline;
margin-left: 6px;
}
</style>
+167 -30
View File
@@ -1,28 +1,44 @@
<template>
<main class="yxg-page decode-page">
<div class="yxg-hero">
<PageShell>
<template #hero>
<BackButton />
<PageTitle
feature="portrait"
title="愈心解码"
:sub="needBirth && !report ? '填写生日,生成身心解码报告' : '一个生日,读懂身与心'"
/>
</div>
<div class="yxg-sheet sheet-pad">
<div v-if="!report" class="head">
<HomeToolIcon name="portrait" :size="48" />
<div>
<h1 class="title">愈心解码</h1>
<p class="sub">一个生日读懂身与心</p>
</div>
</div>
<div v-else class="head compact">
<HomeToolIcon name="portrait" :size="40" />
<h1 class="title sm">愈心解码</h1>
</div>
</template>
<div class="body" :class="{ 'has-sticky': !!report }">
<template v-if="needBirth && !report">
<div class="yxg-card">
<BirthDateInputs v-model:year="year" v-model:month="month" v-model:day="day" />
<button class="yxg-btn yxg-btn-block mt" type="button" :disabled="loading" @click="start">
{{ loading ? '解码中…' : '开始解码' }}
</button>
<p v-if="formError" class="yxg-err">{{ formError }}</p>
<div class="decode-cta">
<div class="decode-inner">
<HomeToolIcon name="portrait" :size="56" />
<h2 class="decode-title">愈心解码</h2>
<p class="decode-sub">一个生日读懂性格与身心节奏</p>
<div class="decode-inputs">
<BirthDateInputs v-model:year="year" v-model:month="month" v-model:day="day" />
<button class="decode-btn" type="button" :disabled="loading" @click="start">
{{ loading ? '解码中…' : '开始解码' }}
</button>
</div>
<p v-if="formError" class="yxg-err">{{ formError }}</p>
</div>
</div>
</template>
<p v-else-if="loading" class="yxg-hint">正在解码</p>
<p v-else-if="error" class="yxg-err">
<p v-else-if="error && !report" class="yxg-err">
{{ error }}
<button type="button" class="yxg-link" @click="retry">重试</button>
</p>
<template v-else-if="report">
<DecodePanel
:summary="summary"
@@ -32,16 +48,23 @@
@unlock="buyDeep"
/>
<p v-if="error" class="yxg-err">{{ error }}</p>
<div class="links">
<router-link class="yxg-link-plain" to="/relation">去做人格匹配 </router-link>
<router-link v-if="report.id" class="yxg-link-plain" :to="`/reports/${report.id}`">在报告页打开 </router-link>
</div>
<button type="button" class="yxg-btn yxg-btn-ghost share-btn" @click="shareOpen = true">生成分享卡</button>
<router-link v-if="report.id" class="yxg-link-plain report-link" :to="`/reports/${report.id}`">
在报告页打开
</router-link>
</template>
<p class="yxg-disc">自我探索与生活方式参考不是占卜或算命体质内容非医疗建议</p>
</div>
<!-- Sticky bottom CTAs -->
<div v-if="report" class="sticky-bar">
<router-link class="sticky-btn" to="/ask">去问答</router-link>
<router-link class="sticky-btn primary" to="/relation">去做匹配</router-link>
<button type="button" class="sticky-btn ghost" @click="shareOpen = true">分享</button>
</div>
<ShareSheet :open="shareOpen" :payload="sharePayload" @close="shareOpen = false" />
</main>
</PageShell>
</template>
<script setup lang="ts">
@@ -53,7 +76,8 @@ import { api } from '../api/client'
import BackButton from '../components/BackButton.vue'
import BirthDateInputs from '../components/BirthDateInputs.vue'
import DecodePanel from '../components/DecodePanel.vue'
import PageTitle from '../components/PageTitle.vue'
import HomeToolIcon from '../components/HomeToolIcon.vue'
import PageShell from '../components/PageShell.vue'
import ShareSheet from '../components/ShareSheet.vue'
import { AnalyticsEvent, track } from '../lib/analytics'
import type { PortraitSharePayload } from '../lib/shareLink'
@@ -176,17 +200,130 @@ onMounted(load)
</script>
<style scoped>
.mt {
margin-top: 14px;
}
.share-btn {
.head {
display: flex;
align-items: center;
gap: 12px;
margin-top: 8px;
width: 100%;
padding-bottom: 8px;
}
.links {
.head.compact {
padding-bottom: 4px;
}
.title {
font-family: var(--font-display);
font-size: 22px;
font-weight: 700;
letter-spacing: 0.06em;
}
.title.sm {
font-size: 18px;
}
.sub {
font-size: 12px;
color: rgba(0, 0, 0, 0.38);
margin-top: 2px;
}
.body {
padding: 8px 16px 0;
}
.body.has-sticky {
padding-bottom: 72px;
}
.decode-cta {
margin: 8px 0 16px;
padding: 28px 20px 24px;
background: var(--color-surface);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-hero);
text-align: center;
}
.decode-inner {
display: flex;
flex-direction: column;
align-items: center;
}
.decode-title {
font-family: var(--font-display);
font-size: 20px;
font-weight: 700;
color: var(--color-accent-gold);
letter-spacing: 0.08em;
margin: 14px 0 4px;
}
.decode-sub {
font-size: 13px;
color: var(--color-text-tertiary);
margin: 0 0 18px;
}
.decode-inputs {
width: 100%;
display: flex;
flex-direction: column;
gap: 14px;
align-items: center;
}
.decode-inputs :deep(.bd) {
justify-content: center;
}
.decode-btn {
width: 100%;
max-width: 280px;
padding: 14px 24px;
border: none;
border-radius: 24px;
color: #fff;
font-size: 16px;
font-weight: 700;
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
box-shadow: 0 6px 18px rgba(229, 77, 66, 0.28);
cursor: pointer;
}
.decode-btn:disabled {
opacity: 0.65;
}
.report-link {
display: block;
margin: 12px 0;
}
.sticky-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 20;
display: flex;
gap: 8px;
margin: 4px 0 12px;
padding: 10px 14px calc(10px + env(safe-area-inset-bottom, 0));
background: rgba(255, 255, 255, 0.94);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-top: 1px solid rgba(0, 0, 0, 0.06);
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.06);
}
.sticky-btn {
flex: 1;
padding: 12px 8px;
border-radius: 20px;
font-size: 14px;
font-weight: 600;
text-align: center;
text-decoration: none;
border: 1.5px solid #eee;
background: #fff;
color: var(--color-text-primary);
cursor: pointer;
}
.sticky-btn.primary {
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
color: #fff;
border-color: transparent;
box-shadow: 0 4px 12px rgba(229, 77, 66, 0.22);
}
.sticky-btn.ghost {
flex: 0.8;
}
</style>
+1 -1
View File
@@ -15,7 +15,7 @@ const { api } = vi.hoisted(() => ({
vi.mock('../api/client', () => ({ api }))
vi.mock('vue-router', () => ({
useRoute: () => ({ query: {} }),
useRouter: () => ({ back: vi.fn() }),
useRouter: () => ({ back: vi.fn(), replace: vi.fn() }),
}))
const RouterLinkStub = defineComponent({
+416 -65
View File
@@ -1,46 +1,75 @@
<template>
<main class="yxg-page">
<div class="yxg-hero">
<BackButton />
<PageTitle feature="profile" title="个人档案" sub="管理我的信息与重要的人" />
</div>
<div class="yxg-sheet sheet-pad">
<PageShell>
<template #hero>
<div class="top-row">
<BackButton />
<span class="who-static">个人档案</span>
<span class="spacer" />
</div>
</template>
<div class="body">
<p v-if="loading" class="yxg-hint">加载中</p>
<p v-else-if="error" class="yxg-err">
{{ error }}
<button type="button" class="yxg-link" @click="load">重试</button>
<button type="button" class="yxg-link linkbtn" @click="load">重试</button>
</p>
<template v-else>
<div v-if="!items.length" class="yxg-card empty">
<div v-if="!items.length" class="empty-card">
<HomeToolIcon name="portrait" :size="56" />
<p>暂无档案请先去首页完成性格探索或添加重要的人</p>
<router-link class="yxg-btn" to="/">去性格探索</router-link>
<router-link class="cta" to="/">去性格探索</router-link>
</div>
<ul v-else class="yxg-list">
<li v-for="p in items" :key="p.id">
<div class="row">
<div>
<strong>{{ p.display_name || (p.relation === 'self' ? '我' : 'TA') }}</strong>
<span class="meta">
{{ p.relation === 'self' ? '我' : relationLabel(p.relation_type) }}
· {{ formatDate(p.birth_date) }}
</span>
</div>
<div class="ops">
<button type="button" class="yxg-btn" @click="startEdit(p)">编辑</button>
<button type="button" class="yxg-btn yxg-btn-ghost danger" :disabled="busyId === p.id" @click="remove(p)">删除</button>
<template v-else>
<div v-if="selfProfile" class="self-card">
<div class="av-ring">
<span class="av-inner">{{ avatarInitial(selfProfile) }}</span>
</div>
<div class="self-body">
<strong>{{ selfProfile.display_name || '我' }}</strong>
<span class="meta"> · {{ formatDate(selfProfile.birth_date) }}</span>
<span v-if="selfProfile.birth_place" class="meta sub">{{ selfProfile.birth_place }}</span>
</div>
<div class="self-ops">
<button type="button" class="op-btn" @click="startEdit(selfProfile)">编辑</button>
</div>
</div>
<section v-if="others.length" class="section">
<h2 class="sec-title">重要的人</h2>
<div class="others-list">
<div v-for="p in others" :key="p.id" class="other-row">
<ListRow
:title="p.display_name || 'TA'"
:desc="`${relationLabel(p.relation_type)} · ${formatDate(p.birth_date)}`"
:icon="'relation'"
:chevron="false"
/>
<div class="row-ops">
<button type="button" class="op-btn" @click="startEdit(p)">编辑</button>
<button
type="button"
class="op-btn danger"
:disabled="busyId === p.id"
@click="remove(p)"
>
删除
</button>
</div>
</div>
</div>
</li>
</ul>
</section>
</template>
<div v-if="editing" class="yxg-card form">
<h2 class="card-title">编辑档案</h2>
<div v-if="editing" class="yxg-card form-panel">
<h2 class="panel-title">编辑档案</h2>
<label class="yxg-label">称呼</label>
<input class="yxg-input" v-model="formName" />
<label class="yxg-label">生日</label>
<BirthDateInputs v-model:year="formY" v-model:month="formM" v-model:day="formD" compact />
<label class="yxg-label">出生地可选 / / 区县</label>
<label class="yxg-label">出生地可选</label>
<BirthPlaceSelect v-model="formPlace" />
<label v-if="editing.relation === 'other'" class="yxg-label">关系类型</label>
<select v-if="editing.relation === 'other'" class="yxg-select" v-model="formRelationType">
@@ -56,49 +85,98 @@
</label>
<p v-if="formError" class="yxg-err">{{ formError }}</p>
<div class="form-actions">
<button class="yxg-btn" type="button" :disabled="saving" @click="saveEdit">保存</button>
<button class="yxg-btn yxg-btn-ghost" type="button" @click="editing = null">取消</button>
<button class="cta" type="button" :disabled="saving" @click="saveEdit">保存</button>
<button class="cta soft" type="button" @click="editing = null">取消</button>
</div>
</div>
<div class="yxg-card form">
<h2 class="card-title">添加重要的人</h2>
<label class="yxg-label">称呼</label>
<input class="yxg-input" v-model="newName" placeholder="例如:伴侣" />
<label class="yxg-label">生日</label>
<BirthDateInputs v-model:year="newY" v-model:month="newM" v-model:day="newD" compact />
<label class="yxg-label">关系类型</label>
<select class="yxg-select" v-model="newRelationType">
<option value="partner">伴侣</option>
<option value="friend">朋友</option>
<option value="family">家人</option>
<option value="colleague">同事</option>
<option value="other">其他</option>
</select>
<p v-if="addError" class="yxg-err">{{ addError }}</p>
<button class="yxg-btn" type="button" :disabled="adding" @click="addOther">添加</button>
<div v-if="showInviteFill" class="yxg-card form-panel invite-panel">
<h2 class="panel-title">邀请好友填档案</h2>
<p class="invite-desc">
生成链接发给家人或朋友对方填写生日后可出现在你的合盘与关系理解中
</p>
<p v-if="inviteHint" class="yxg-meta">{{ inviteHint }}</p>
<p v-if="invitePath" class="invite-link">{{ invitePath }}</p>
<p v-if="inviteError" class="yxg-err">{{ inviteError }}</p>
<div class="form-actions">
<button class="cta" type="button" :disabled="inviting || !selfProfile" @click="createFillInvite">
{{ inviting ? '生成中…' : invitePath ? '重新生成' : '生成填写链接' }}
</button>
<button
v-if="invitePath"
class="cta soft"
type="button"
:disabled="copyBusy"
@click="copyInvite"
>
{{ copyBusy ? '复制中…' : '复制链接' }}
</button>
</div>
<button type="button" class="yxg-link linkbtn" @click="closeInviteFill">关闭</button>
</div>
<router-link class="yxg-link-plain link" to="/relation">去做关系理解 </router-link>
<div v-if="showAddForm" class="yxg-card form-panel">
<div class="panel-head">
<h2 class="panel-title">添加档案</h2>
<button type="button" class="yxg-link linkbtn" @click="openInviteFill">邀请好友</button>
</div>
<label class="yxg-label">昵称</label>
<input class="yxg-input" v-model="newName" placeholder="请输入昵称(16 个字)" maxlength="6" />
<label class="yxg-label">关系</label>
<div class="rel-chips" role="group" aria-label="关系类型">
<button
v-for="r in relationOptions"
:key="r.value"
type="button"
class="rel-chip"
:class="{ on: newRelationType === r.value }"
@click="newRelationType = r.value"
>
{{ r.label }}
</button>
</div>
<label class="yxg-label">生日</label>
<BirthDateInputs v-model:year="newY" v-model:month="newM" v-model:day="newD" compact />
<label class="yxg-label">出生地可选</label>
<BirthPlaceSelect v-model="newPlace" />
<p v-if="addError" class="yxg-err">{{ addError }}</p>
<button class="cta" type="button" :disabled="adding" @click="addOther">保存</button>
</div>
<div class="bottom-cta">
<button type="button" class="fab" @click="toggleAdd">
{{ showAddForm ? '收起' : '添加档案' }}
</button>
<router-link class="link-row" to="/synastry">去做合盘 </router-link>
</div>
</template>
</div>
</main>
</PageShell>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import type { Profile } from '@yuxingu/types'
import { api } from '../api/client'
import BackButton from '../components/BackButton.vue'
import BirthDateInputs from '../components/BirthDateInputs.vue'
import BirthPlaceSelect from '../components/BirthPlaceSelect.vue'
import PageTitle from '../components/PageTitle.vue'
import HomeToolIcon from '../components/HomeToolIcon.vue'
import ListRow from '../components/ListRow.vue'
import PageShell from '../components/PageShell.vue'
import { copyText } from '../lib/shareLink'
const route = useRoute()
const router = useRouter()
const items = ref<Profile[]>([])
const loading = ref(true)
const error = ref('')
const busyId = ref('')
const editing = ref<Profile | null>(null)
const showAddForm = ref(false)
const showInviteFill = ref(false)
const formName = ref('')
const formY = ref('')
const formM = ref('')
@@ -112,14 +190,36 @@ const newName = ref('')
const newY = ref('')
const newM = ref('')
const newD = ref('')
const newRelationType = ref('partner')
const newPlace = ref('')
const newRelationType = ref('family')
const addError = ref('')
const adding = ref(false)
const inviting = ref(false)
const invitePath = ref('')
const inviteError = ref('')
const inviteHint = ref('')
const copyBusy = ref(false)
const relationOptions = [
{ value: 'family', label: '家人' },
{ value: 'friend', label: '朋友' },
{ value: 'partner', label: '伴侣' },
{ value: 'colleague', label: '同事' },
{ value: 'other', label: '其他' },
]
const selfProfile = computed(() => items.value.find((p) => p.relation === 'self'))
const others = computed(() => items.value.filter((p) => p.relation !== 'self'))
function formatDate(v: string) {
return (v || '').slice(0, 10)
}
function avatarInitial(p: Profile) {
const n = p.display_name || (p.relation === 'self' ? '我' : 'T')
return n.slice(0, 1)
}
function splitBirth(v: string) {
const s = formatDate(v)
const [y, m, d] = s.split('-')
@@ -145,6 +245,69 @@ function relationLabel(t?: string | null) {
return map[t || ''] || '关系对象'
}
function toggleAdd() {
showAddForm.value = !showAddForm.value
if (showAddForm.value) {
editing.value = null
showInviteFill.value = false
}
}
function openAddForm() {
showAddForm.value = true
showInviteFill.value = false
editing.value = null
}
function openInviteFill() {
showInviteFill.value = true
showAddForm.value = false
editing.value = null
inviteError.value = ''
if (!selfProfile.value) {
inviteHint.value = '请先完成自己的档案,再邀请好友填写。'
} else {
inviteHint.value = ''
}
}
function closeInviteFill() {
showInviteFill.value = false
void router.replace({ path: '/profile', query: {} })
}
function applyQueryFlags() {
if (route.query.add === '1') openAddForm()
if (route.query.inviteFill === '1') openInviteFill()
}
async function createFillInvite() {
if (!selfProfile.value) {
inviteError.value = '请先完成自己的档案'
return
}
inviting.value = true
inviteError.value = ''
inviteHint.value = ''
try {
const res = await api.createSynastryInvite(selfProfile.value.id)
invitePath.value = `${location.origin}${res.path}`
inviteHint.value = '好友打开链接后填写生日即可;也可用于合盘。'
} catch (e) {
inviteError.value = e instanceof Error ? e.message : '生成失败'
} finally {
inviting.value = false
}
}
async function copyInvite() {
if (!invitePath.value) return
copyBusy.value = true
const ok = await copyText(invitePath.value)
inviteHint.value = ok ? '链接已复制,发给家人或朋友即可。' : '复制失败,请长按链接手动复制。'
copyBusy.value = false
}
async function load() {
loading.value = true
error.value = ''
@@ -160,6 +323,7 @@ async function load() {
function startEdit(p: Profile) {
editing.value = p
showAddForm.value = false
formName.value = p.display_name || ''
const b = splitBirth(p.birth_date)
formY.value = b.y
@@ -227,12 +391,16 @@ async function addOther() {
birth_date: birth,
display_name: newName.value.trim() || 'TA',
relation_type: newRelationType.value,
birth_place: newPlace.value || undefined,
})
newName.value = ''
newY.value = ''
newM.value = ''
newD.value = ''
newPlace.value = ''
showAddForm.value = false
await load()
if (route.query.add === '1') void router.replace({ path: '/profile', query: {} })
} catch (e) {
addError.value = e instanceof Error ? e.message : '添加失败'
} finally {
@@ -240,22 +408,205 @@ async function addOther() {
}
}
onMounted(load)
watch(
() => [route.query.add, route.query.inviteFill],
() => applyQueryFlags(),
)
onMounted(async () => {
await load()
applyQueryFlags()
})
</script>
<style scoped>
.yxg-card{margin-bottom:12px}
.empty .yxg-btn{margin-top:10px}
.yxg-list{margin-bottom:12px}
.row{display:flex;justify-content:space-between;gap:10px;align-items:flex-start;width:100%}
.meta{display:block;font-size:12px;color:#999;margin-top:2px}
.ops{display:flex;gap:6px;flex-shrink:0}
.ops .yxg-btn{padding:6px 12px;font-size:12px;box-shadow:none}
.form .yxg-label:first-of-type{margin-top:0}
.form :deep(.bd){margin:4px 0 10px}
.form-actions{display:flex;gap:8px;margin-top:12px}
.form > .yxg-btn{margin-top:12px}
.check{display:flex;gap:8px;align-items:flex-start;margin:12px 0;font-size:13px;color:#555;line-height:1.4}
.check input{margin-top:3px}
.link{display:block;margin-top:8px}
.top-row {
display: flex;
align-items: center;
gap: 10px;
padding-bottom: 4px;
}
.who-static { flex: 1; font-size: 16px; font-weight: 700; text-align: center; }
.spacer { width: 36px; }
.body { padding: 8px 16px 100px; }
.empty-card {
text-align: center;
padding: 32px 16px;
border-radius: var(--radius-xl);
background: #fafafa;
color: #888;
}
.empty-card p { margin: 12px 0; font-size: 14px; line-height: 1.5; }
.self-card {
display: flex;
align-items: center;
gap: 14px;
padding: 16px;
margin-bottom: 16px;
border-radius: var(--radius-xl);
background: linear-gradient(145deg, #fff0ec, #ffe4dc);
box-shadow: var(--shadow-hero);
}
.av-ring {
flex-shrink: 0;
width: 64px;
height: 64px;
border-radius: 50%;
padding: 3px;
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
box-shadow: 0 4px 14px rgba(229, 77, 66, 0.25);
}
.av-inner {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
border-radius: 50%;
background: #fff;
font-size: 22px;
font-weight: 700;
color: var(--color-primary);
}
.self-body { flex: 1; min-width: 0; }
.self-body strong { display: block; font-size: 17px; color: #222; }
.meta { display: block; font-size: 12px; color: #888; margin-top: 4px; }
.meta.sub { color: #aaa; }
.self-ops { flex-shrink: 0; }
.sec-title {
font-size: 15px;
font-weight: 700;
color: #222;
margin-bottom: 10px;
}
.other-row {
margin-bottom: 8px;
border-radius: var(--radius-lg);
background: #fff;
box-shadow: var(--shadow-card);
border: 1px solid rgba(0, 0, 0, 0.03);
overflow: hidden;
}
.other-row :deep(.lr) { padding: 12px 14px 8px; }
.row-ops {
display: flex;
gap: 8px;
padding: 0 14px 12px;
}
.op-btn {
padding: 6px 14px;
border-radius: var(--radius-pill);
border: 1px solid #f0ebe8;
background: #fafafa;
font-size: 12px;
font-weight: 600;
color: #555;
}
.op-btn.danger { color: var(--color-primary); border-color: #ffe0dc; background: #fff5f4; }
.form-panel { margin: 14px 0; }
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 4px;
}
.panel-title { font-size: 16px; font-weight: 700; margin: 0 0 12px; color: #222; }
.panel-head .panel-title { margin-bottom: 0; }
.invite-panel .invite-desc {
margin: 0 0 10px;
font-size: 13px;
line-height: 1.55;
color: #666;
}
.invite-link {
margin: 8px 0;
padding: 10px 12px;
border-radius: 12px;
background: #faf7f5;
font-size: 12px;
line-height: 1.4;
color: #444;
word-break: break-all;
}
.rel-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 4px;
}
.rel-chip {
padding: 7px 14px;
border-radius: 999px;
border: 1.5px solid #eee;
background: #fff;
font-size: 13px;
font-weight: 600;
color: #666;
}
.rel-chip.on {
border-color: rgba(229, 77, 66, 0.45);
background: #fff5f4;
color: var(--color-primary);
}
.cta {
margin-top: 14px;
width: 100%;
padding: 12px;
border: none;
border-radius: 22px;
color: #fff;
font-size: 14px;
font-weight: 600;
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
box-shadow: 0 6px 16px rgba(229, 77, 66, 0.28);
}
.cta.soft {
background: #fff;
color: #666;
border: 1.5px solid #eee;
box-shadow: none;
}
.form-actions { display: flex; gap: 8px; margin-top: 12px; }
.form-actions .cta { flex: 1; margin-top: 0; }
.check {
display: flex;
gap: 8px;
align-items: flex-start;
margin: 12px 0;
font-size: 13px;
color: #555;
line-height: 1.4;
}
.check input { margin-top: 3px; }
.yxg-label { display: block; margin: 12px 0 6px; font-size: 12px; color: #999; }
.yxg-label:first-of-type { margin-top: 0; }
.bottom-cta {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 20;
padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(12px);
border-top: 1px solid #f0ebe8;
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
}
.fab {
width: 100%;
max-width: 360px;
padding: 13px;
border: none;
border-radius: 22px;
color: #fff;
font-size: 15px;
font-weight: 600;
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
box-shadow: 0 6px 16px rgba(229, 77, 66, 0.28);
}
.link-row { font-size: 13px; color: var(--color-primary); font-weight: 600; }
</style>
+2 -2
View File
@@ -47,7 +47,7 @@ describe('RelationPage', () => {
})
const w = mount(RelationPage)
await w.findAll('button').find((b) => b.text().includes('开始匹配'))!.trigger('click')
await w.findAll('button').find((b) => b.text().includes('匹配'))!.trigger('click')
await flushPromises()
expect(w.text()).toContain('我·稳')
expect(w.text()).toContain('节奏不同')
@@ -82,7 +82,7 @@ describe('RelationPage', () => {
})
const w = mount(RelationPage)
await w.findAll('button').find((b) => b.text().includes('开始匹配'))!.trigger('click')
await w.findAll('button').find((b) => b.text().includes('匹配'))!.trigger('click')
await flushPromises()
await w.findAll('button').find((b) => b.text().includes('解锁完整分析'))!.trigger('click')
await flushPromises()
+394 -57
View File
@@ -1,60 +1,142 @@
<template>
<main class="yxg-page">
<div class="yxg-hero">
<BackButton />
<PageTitle feature="relation" title="人格匹配" sub="合盘指数 · 恋爱/友情/婚姻 · 相处建议" />
</div>
<div class="yxg-sheet sheet-pad">
<div class="yxg-card">
<label class="yxg-label">TA 的称呼</label>
<input class="yxg-input" v-model="otherName" placeholder="例如:伴侣" />
<label class="yxg-label">关系类型</label>
<select class="yxg-select" v-model="relationType">
<option value="partner">伴侣</option>
<option value="friend">朋友</option>
<option value="family">家人</option>
<option value="other">其他</option>
</select>
<label class="yxg-label">TA 的生日</label>
<BirthDateInputs v-model:year="oy" v-model:month="om" v-model:day="od" compact />
<p class="yxg-hint">将使用你最近一份的档案进行匹配</p>
<p v-if="loading" class="yxg-hint">生成中</p>
<button class="yxg-btn yxg-btn-block" type="button" :disabled="loading" @click="run">
{{ loading ? '生成中…' : '开始匹配' }}
</button>
<p v-if="error" class="yxg-err">
{{ error }}
<router-link v-if="needSelf" class="yxg-link" to="/">去首页做愈心解码</router-link>
</p>
<PageShell>
<template #hero>
<div class="top-row">
<BackButton />
<span class="who-static">人格匹配</span>
<button v-if="report" type="button" class="share-ico" aria-label="分享" @click="shareOpen = true"></button>
<span v-else class="spacer" />
</div>
</template>
<div class="body">
<template v-if="!report">
<div class="hero-block">
<h1 class="hero-title">看见彼此的相处模样</h1>
<p class="hero-sub">基于档案与生日理解相处模式 · 性格对照 · 默契指数</p>
</div>
<div class="focus-chips" role="tablist">
<button
v-for="f in focusTabs"
:key="f.key"
type="button"
class="fchip"
:class="{ on: focus === f.key }"
@click="focus = f.key"
>
{{ f.label }}
</button>
</div>
<div class="deco-card" aria-hidden="true">
<div class="deco-blur">
<span class="deco-av me" />
<span class="deco-heart"></span>
<span class="deco-av ta" />
</div>
<p class="deco-hint">{{ focusHint }}</p>
</div>
<div class="yxg-card form-card">
<label class="yxg-label">关系类型</label>
<div class="rel-chips">
<button
v-for="r in relationOptions"
:key="r.value"
type="button"
class="rchip"
:class="{ on: relationType === r.value }"
@click="relationType = r.value"
>
{{ r.label }}
</button>
</div>
<label class="yxg-label">TA 的称呼</label>
<input class="yxg-input" v-model="otherName" placeholder="例如:伴侣" />
<label class="yxg-label">TA 的生日</label>
<BirthDateInputs v-model:year="oy" v-model:month="om" v-model:day="od" compact />
<p class="yxg-hint">将使用你最近一份的档案进行匹配</p>
<button class="cta" type="button" :disabled="loading" @click="run">
{{ loading ? '生成中…' : '去匹配' }}
</button>
<p v-if="error" class="yxg-err">
{{ error }}
<router-link v-if="needSelf" class="yxg-link" to="/">去首页做愈心解码</router-link>
</p>
</div>
</template>
<template v-else>
<p class="diff-lead">{{ diff }}</p>
<div class="mode-tabs" role="tablist">
<button
v-for="t in resultTabs"
:key="t.key"
type="button"
class="mode-tab"
:class="{ on: tab === t.key }"
@click="tab = t.key"
>
{{ t.label }}
</button>
</div>
<template v-if="tab === 'index'">
<div class="pair-spot">
<div class="who"><span class="tag"></span><strong>{{ meStyle || '—' }}</strong></div>
<span class="vs">×</span>
<div class="who"><span class="tag">TA</span><strong>{{ otherStyle || '—' }}</strong></div>
</div>
<div class="spotlight-row">
<div v-if="harmony != null" class="spot big">
<em>默契指数</em>
<strong>{{ harmony }}</strong>
</div>
<div v-if="fitLabel" class="spot badge-spot">
<span class="fit-badge">{{ fitLabel }}</span>
</div>
</div>
<div class="indices">
<div class="idx"><em>恋爱</em><strong>{{ loveIndex ?? '—' }}</strong></div>
<div class="idx"><em>友情</em><strong>{{ friendIndex ?? '—' }}</strong></div>
<div class="idx"><em>婚姻</em><strong>{{ marriageIndex ?? '—' }}</strong></div>
</div>
</template>
<template v-else-if="tab === 'compare'">
<MatchCompare
:me-style="meStyle"
:other-style="otherStyle"
:fit-label="fitLabel"
:harmony="harmony"
:love="loveIndex"
:friend="friendIndex"
:marriage="marriageIndex"
:star-compare="starCompare"
:dimensions="dimCompare"
/>
</template>
<p v-else class="tips-intro">基于你们的档案以下是相处与沟通建议</p>
<template v-if="report">
<p class="yxg-lead">{{ diff }}</p>
<MatchCompare
:me-style="meStyle"
:other-style="otherStyle"
:fit-label="fitLabel"
:harmony="harmony"
:love="loveIndex"
:friend="friendIndex"
:marriage="marriageIndex"
:star-compare="starCompare"
:dimensions="dimCompare"
/>
<ReportRich :summary="summary" :detail="detail" :deep="!!report.has_deep_access" />
<div v-if="!report.has_deep_access" class="yxg-card lock">
<p>完整相处建议冲突修复对话示例与本周练习可在深度版或成长会员中查看</p>
<button class="yxg-btn" type="button" :disabled="paying" @click="buyDeep">解锁完整分析模拟支付</button>
<div v-if="!report.has_deep_access" class="lock-card">
<p>完整相处建议冲突修复与本周练习可在深度版或成长会员中查看</p>
<button class="cta soft" type="button" :disabled="paying" @click="buyDeep">解锁完整分析</button>
</div>
<div class="links">
<router-link class="yxg-link-plain" to="/star">看看星座 </router-link>
<router-link class="yxg-link-plain" to="/portrait">回看愈心解码 </router-link>
<div class="sticky-bar">
<button type="button" class="sb-btn" @click="shareOpen = true">分享</button>
<router-link class="sb-btn primary" to="/ask">去问答</router-link>
<router-link class="sb-btn" to="/synastry">合盘</router-link>
</div>
<button type="button" class="yxg-btn yxg-btn-ghost share-btn" @click="shareOpen = true">生成分享卡</button>
</template>
</div>
<ShareSheet :open="shareOpen" :payload="sharePayload" @close="shareOpen = false" />
</main>
</PageShell>
</template>
<script setup lang="ts">
@@ -64,7 +146,7 @@ import { api } from '../api/client'
import BackButton from '../components/BackButton.vue'
import BirthDateInputs from '../components/BirthDateInputs.vue'
import MatchCompare from '../components/MatchCompare.vue'
import PageTitle from '../components/PageTitle.vue'
import PageShell from '../components/PageShell.vue'
import ReportRich from '../components/ReportRich.vue'
import ShareSheet from '../components/ShareSheet.vue'
import { AnalyticsEvent, track } from '../lib/analytics'
@@ -81,6 +163,34 @@ const error = ref('')
const report = ref<GrowthReport | null>(null)
const shareOpen = ref(false)
const needSelf = ref(false)
const focus = ref('mode')
const tab = ref<'index' | 'compare' | 'tips'>('index')
const focusTabs = [
{ key: 'mode', label: '相处模式' },
{ key: 'personality', label: '性格对照' },
{ key: 'sync', label: '默契指数' },
]
const relationOptions = [
{ value: 'partner', label: '伴侣' },
{ value: 'friend', label: '朋友' },
{ value: 'family', label: '家人' },
{ value: 'other', label: '其他' },
]
const resultTabs = [
{ key: 'index' as const, label: '指数' },
{ key: 'compare' as const, label: '对照' },
{ key: 'tips' as const, label: '建议' },
]
const focusHint = computed(() => {
const map: Record<string, string> = {
mode: '看见日常互动里,谁更主动、谁更需要空间',
personality: '对照彼此的性格关键词与表达习惯',
sync: '恋爱 · 友情 · 婚姻维度的默契参考',
}
return map[focus.value] || map.mode
})
const summary = computed(() => (report.value?.summary || {}) as Record<string, unknown>)
const detail = computed(() => (report.value?.detail || null) as Record<string, unknown> | null)
@@ -140,9 +250,7 @@ async function run() {
const y = Number(oy.value)
const m = Number(om.value)
const d = Number(od.value)
if (!y || !m || !d) {
throw new Error('请填写 TA 的完整生日')
}
if (!y || !m || !d) throw new Error('请填写 TA 的完整生日')
const self = await ensureSelf()
const birth = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
const other = await api.createProfile({
@@ -153,6 +261,7 @@ async function run() {
})
const out = await api.createRelationInsight(self.id, other.id)
report.value = out.report
tab.value = 'index'
track(AnalyticsEvent.RelationCompleted)
} catch (e) {
const msg = e instanceof Error ? e.message : '生成失败'
@@ -181,9 +290,237 @@ async function buyDeep() {
</script>
<style scoped>
.yxg-card{margin:8px 0 12px}
.yxg-card .yxg-label:first-child{margin-top:0}
.yxg-btn{margin-top:12px}
.lock .yxg-btn,.share-btn{width:100%}
.links{display:flex;flex-direction:column;gap:8px;margin:12px 0}
.top-row {
display: flex;
align-items: center;
gap: 10px;
padding-bottom: 4px;
}
.who-static {
flex: 1;
font-size: 16px;
font-weight: 700;
text-align: center;
}
.spacer { width: 36px; }
.share-ico {
width: 36px;
height: 36px;
border: none;
border-radius: 12px;
background: rgba(255, 255, 255, 0.8);
font-size: 16px;
}
.body { padding: 8px 16px 88px; }
.hero-block { text-align: center; padding: 12px 8px 4px; }
.hero-title {
font-family: var(--font-display);
font-size: 22px;
font-weight: 700;
letter-spacing: 0.04em;
color: #222;
}
.hero-sub { font-size: 13px; color: #888; margin-top: 8px; line-height: 1.5; }
.focus-chips {
display: flex;
gap: 8px;
margin: 14px 0;
justify-content: center;
}
.fchip {
padding: 8px 14px;
border-radius: var(--radius-pill);
border: 1px solid #f0ebe8;
background: #fafafa;
font-size: 12px;
color: #666;
font-weight: 600;
}
.fchip.on {
background: #fff5f4;
border-color: #f5c4bc;
color: var(--color-primary);
}
.deco-card {
margin-bottom: 14px;
padding: 20px 16px;
border-radius: var(--radius-xl);
background: linear-gradient(145deg, #fff0ec, #ffe4dc);
text-align: center;
overflow: hidden;
}
.deco-blur {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
filter: blur(0.5px);
}
.deco-av {
width: 56px;
height: 56px;
border-radius: 50%;
background: linear-gradient(145deg, #ffd0c4, #ffb8a8);
opacity: 0.85;
}
.deco-av.ta { background: linear-gradient(145deg, #d8eaff, #b4d2f5); }
.deco-heart { font-size: 20px; color: var(--color-primary); opacity: 0.7; }
.deco-hint { margin-top: 12px; font-size: 12px; color: #8a6a5a; line-height: 1.45; }
.form-card { margin-top: 4px; }
.rel-chips { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 4px; }
.rchip {
padding: 8px 16px;
border-radius: var(--radius-pill);
border: 1px solid #eee;
background: #fafafa;
font-size: 13px;
color: #666;
}
.rchip.on {
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
color: #fff;
border-color: transparent;
}
.cta {
margin-top: 14px;
width: 100%;
padding: 13px;
border: none;
border-radius: 22px;
color: #fff;
font-size: 15px;
font-weight: 600;
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
box-shadow: 0 6px 16px rgba(229, 77, 66, 0.28);
}
.cta.soft {
background: #fff;
color: var(--color-primary);
border: 1.5px solid #f5c4bc;
box-shadow: none;
}
.cta:disabled { opacity: 0.45; }
.diff-lead {
font-size: 15px;
font-weight: 600;
color: #444;
line-height: 1.5;
margin-bottom: 12px;
}
.pair-spot {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 12px;
padding: 14px;
border-radius: var(--radius-xl);
background: #fafafa;
}
.pair-spot .who { text-align: center; flex: 1; }
.pair-spot .tag {
display: block;
font-size: 11px;
color: #999;
margin-bottom: 4px;
}
.pair-spot strong { font-size: 14px; color: #222; }
.pair-spot .vs { font-size: 18px; color: var(--color-primary); opacity: 0.6; }
.mode-tabs {
display: flex;
gap: 4px;
margin-bottom: 14px;
background: #fafafa;
border-radius: var(--radius-pill);
padding: 4px;
}
.mode-tab {
flex: 1;
border: none;
background: transparent;
padding: 9px 4px;
border-radius: var(--radius-pill);
font-size: 13px;
color: #888;
font-weight: 600;
}
.mode-tab.on {
background: #fff;
color: var(--color-primary);
box-shadow: 0 2px 8px rgba(229, 77, 66, 0.1);
}
.spotlight-row { display: flex; gap: 10px; margin-bottom: 12px; }
.spot {
flex: 1;
padding: 16px;
border-radius: var(--radius-xl);
background: linear-gradient(145deg, #fff0ec, #ffe4dc);
text-align: center;
box-shadow: var(--shadow-card);
}
.spot em { display: block; font-style: normal; font-size: 12px; color: #999; margin-bottom: 4px; }
.spot strong { font-size: 32px; font-family: var(--font-display); color: var(--color-primary); }
.badge-spot { display: flex; align-items: center; justify-content: center; }
.fit-badge {
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
color: #fff;
font-size: 13px;
font-weight: 700;
padding: 8px 14px;
border-radius: var(--radius-pill);
}
.indices {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin-bottom: 12px;
}
.idx {
background: #fff5f4;
border: 1px solid #ffe0dc;
border-radius: 14px;
padding: 12px 8px;
text-align: center;
}
.idx em { display: block; font-style: normal; font-size: 11px; color: #999; margin-bottom: 4px; }
.idx strong { font-size: 22px; color: var(--color-primary); font-family: var(--font-display); }
.lock-card {
margin-top: 12px;
padding: 16px;
background: linear-gradient(145deg, #fff8e8, #ffeec8);
border-radius: var(--radius-xl);
}
.tips-intro { font-size: 13px; color: #888; margin-bottom: 12px; line-height: 1.5; }
.sticky-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 20;
display: flex;
gap: 8px;
padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(12px);
border-top: 1px solid #f0ebe8;
}
.sb-btn {
flex: 1;
padding: 11px 8px;
border-radius: 22px;
border: 1.5px solid #f0ebe8;
background: #fff;
font-size: 13px;
font-weight: 600;
color: #555;
text-align: center;
text-decoration: none;
}
.sb-btn.primary {
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
color: #fff;
border: none;
}
.yxg-label { display: block; margin: 12px 0 6px; font-size: 12px; color: #999; }
.yxg-label:first-child { margin-top: 0; }
</style>
+302 -111
View File
@@ -1,34 +1,54 @@
<template>
<main class="yxg-page">
<div class="yxg-hero">
<BackButton />
<PageTitle feature="reports" title="成长报告" :sub="typeLabel || undefined" />
</div>
<div class="yxg-sheet sheet-pad">
<PageShell>
<template #hero>
<div class="top-row">
<BackButton />
<span class="who-static">成长报告</span>
<button v-if="report" type="button" class="share-ico" aria-label="分享" @click="shareOpen = true"></button>
<span v-else class="spacer" />
</div>
</template>
<div class="body">
<p v-if="loading" class="yxg-hint">加载中</p>
<p v-else-if="error" class="yxg-err">
{{ error }}
<button type="button" class="yxg-link" @click="load">重试</button>
</p>
<template v-else-if="report">
<p class="yxg-meta">{{ typeLabel }} · {{ createdLabel }}</p>
<p class="yxg-lead">{{ headline || oneLiner }}</p>
<!-- 星座报告页也展示星盘 / 运势 / 行星避免打开报告却缺内容 -->
<template v-else-if="report">
<div class="report-head">
<span class="type-badge">{{ typeLabel }}</span>
<span class="date-badge">{{ createdLabel }}</span>
</div>
<h2 class="hl">{{ headline || oneLiner || '成长报告' }}</h2>
<p v-if="oneLiner && headline" class="lead">{{ oneLiner }}</p>
<router-link class="vip-strip" to="/membership">
<span class="vip-ico" aria-hidden="true"></span>
<span class="vip-copy">
<strong>成长会员</strong>
<em>深度报告 · 全年节气陪伴 · 解锁完整分析</em>
</span>
<span class="vip-go">开通 </span>
</router-link>
<div v-if="showModeTabs" class="mode-tabs" role="tablist">
<button
v-for="p in activePanels"
:key="p.key"
type="button"
class="mode-tab"
:class="{ on: panelKey === p.key }"
@click="panelKey = p.key"
>
{{ p.label }}
</button>
</div>
<!-- 星座 -->
<template v-if="report.type === 'star'">
<div class="panel-tabs">
<button
v-for="p in starPanels"
:key="p.key"
type="button"
class="panel-tab"
:class="{ on: starPanel === p.key }"
@click="starPanel = p.key"
>
{{ p.label }}
</button>
</div>
<template v-if="starPanel === 'chart'">
<template v-if="panelKey === 'chart'">
<NatalWheel
v-if="wheelPlanets.length"
:planets="wheelPlanets"
@@ -40,12 +60,12 @@
<p v-for="t in transits" :key="t.key" class="yxg-meta">{{ t.title }} · {{ t.aspect }}{{ t.tip }}</p>
</div>
<SignCards v-if="signCards.length" v-model="signTab" :cards="signCards" />
<div v-if="activeCard" class="yxg-card soft">
<h2 class="card-title">{{ activeCard.title }} · {{ activeCard.label }}</h2>
<div v-if="activeCard" class="spotlight">
<h3>{{ activeCard.title }} · {{ activeCard.label }}</h3>
<p>{{ activeCard.teaser }}</p>
</div>
</template>
<template v-else-if="starPanel === 'fortune'">
<template v-else-if="panelKey === 'fortune'">
<div class="fortune-tabs">
<button
v-for="k in fortuneKeys"
@@ -58,52 +78,36 @@
{{ fortuneLabel(k) }}
</button>
</div>
<div v-if="activeFortune" class="yxg-card soft">
<div v-if="activeFortune" class="spotlight">
<p class="ft-title">
{{ activeFortune.title }} · {{ activeFortune.label }} · {{ activeFortune.score }}
</p>
<p>{{ activeFortune.tip }}</p>
<p v-if="activeFortune.dims" class="yxg-meta">
感情 {{ activeFortune.dims.love }} · 事业 {{ activeFortune.dims.career }} · 财运
{{ activeFortune.dims.money }} · 心情 {{ activeFortune.dims.mood }}
</p>
</div>
<p v-else class="yxg-hint">暂无运势数据请重新生成星座报告</p>
<p v-else class="yxg-hint">暂无运势数据</p>
</template>
<ul v-else class="planet-list">
<li v-for="p in planets" :key="p.key" class="yxg-card soft planet">
<li v-for="p in planets" :key="p.key" class="planet-row">
<strong>{{ p.title }}</strong>
<span>{{ p.sign }} {{ p.degree }} · {{ p.house }}</span>
</li>
<li v-if="!planets.length" class="yxg-hint">暂无行星数据请重新生成星座报告</li>
</ul>
</template>
<!-- 人格匹配 / 合盘指数 -->
<div v-if="(report.type === 'relation' || report.type === 'synastry') && hasMatchIndex" class="indices">
<!-- 合盘 / 匹配指数 -->
<div v-if="hasMatchIndex" class="indices">
<div class="idx"><em>恋爱</em><strong>{{ loveIndex }}</strong></div>
<div class="idx"><em>友情</em><strong>{{ friendIndex }}</strong></div>
<div class="idx"><em>婚姻</em><strong>{{ marriageIndex }}</strong></div>
</div>
<template v-if="report.type === 'synastry'">
<div class="panel-tabs">
<button
v-for="t in synTabs"
:key="t.key"
type="button"
class="panel-tab"
:class="{ on: synTab === t.key }"
@click="synTab = t.key"
>
{{ t.label }}
</button>
</div>
<p v-if="synActiveTip" class="yxg-meta">{{ synActiveTip }}</p>
<template v-if="synTab === 'compare'">
<template v-if="panelKey === 'compare'">
<NatalWheel v-if="synPlanetsA.length" :planets="synPlanetsA" :aspects="[]" :asc-lon="synAscA" />
<NatalWheel v-if="synPlanetsB.length" :planets="synPlanetsB" :aspects="[]" :asc-lon="synAscB" />
</template>
<template v-else-if="synTab === 'overlay'">
<template v-else-if="panelKey === 'overlay'">
<p v-for="(e, i) in synOverlayEntries" :key="i" class="yxg-meta">
{{ e.planet }} {{ e.house }} · {{ e.house_tip }}
</p>
@@ -116,7 +120,6 @@
/>
</template>
<!-- 愈心解码三角 + 五行 + 付费墙 -->
<template v-if="report.type === 'portrait'">
<DecodePanel
:summary="summary"
@@ -128,23 +131,32 @@
</template>
<template v-else>
<ReportRich :summary="summary" :detail="detail" :deep="!!report.has_deep_access" />
<div v-if="!report.has_deep_access" class="yxg-card lock">
<p>完整分析可在深度版或成长会员中查看</p>
<button class="yxg-btn" type="button" :disabled="paying" @click="buyDeep">查看深度版模拟支付</button>
</div>
</template>
<div class="links">
<router-link v-if="report.type === 'star'" class="yxg-link-plain" to="/star">回星座页 </router-link>
<router-link v-if="report.type === 'synastry'" class="yxg-link-plain" to="/synastry">回合盘页 </router-link>
<router-link v-if="report.type === 'portrait'" class="yxg-link-plain" to="/portrait">回愈心解码 </router-link>
<router-link v-if="report.type === 'relation'" class="yxg-link-plain" to="/relation">回人格匹配 </router-link>
<router-link class="yxg-link-plain" to="/reports">全部报告 </router-link>
<div v-if="!report.has_deep_access && report.type !== 'portrait'" class="lock-card">
<p>完整分析可在深度版或成长会员中查看</p>
<button class="cta soft" type="button" :disabled="paying" @click="buyDeep">解锁深度版</button>
</div>
<div class="sticky-bar">
<button type="button" class="sb-btn" @click="shareOpen = true">分享</button>
<button
v-if="!report.has_deep_access"
class="sb-btn primary"
type="button"
:disabled="paying"
@click="buyDeep"
>
解锁
</button>
<router-link v-if="relatedLink" class="sb-btn" :class="{ primary: report.has_deep_access }" :to="relatedLink.to">
{{ relatedLink.label }}
</router-link>
</div>
<button type="button" class="yxg-btn yxg-btn-ghost share-btn" @click="shareOpen = true">生成分享卡</button>
</template>
</div>
<ShareSheet :open="shareOpen" :payload="sharePayload" @close="shareOpen = false" />
</main>
</PageShell>
</template>
<script setup lang="ts">
@@ -153,7 +165,7 @@ import { useRoute } from 'vue-router'
import type { GrowthReport } from '@yuxingu/types'
import { api } from '../api/client'
import BackButton from '../components/BackButton.vue'
import PageTitle from '../components/PageTitle.vue'
import PageShell from '../components/PageShell.vue'
import DecodePanel from '../components/DecodePanel.vue'
import NatalWheel, { type WheelAspect, type WheelPlanet } from '../components/NatalWheel.vue'
import ReportRich from '../components/ReportRich.vue'
@@ -168,10 +180,15 @@ const error = ref('')
const report = ref<GrowthReport | null>(null)
const paying = ref(false)
const shareOpen = ref(false)
const starPanel = ref<'chart' | 'fortune' | 'planets'>('chart')
const panelKey = ref('chart')
const signTab = ref('sun')
const fortuneKey = ref<'daily' | 'weekly' | 'monthly' | 'yearly'>('daily')
const synTab = ref('compare')
const starPanels = [
{ key: 'chart', label: '星盘' },
{ key: 'fortune', label: '运势' },
{ key: 'planets', label: '行星' },
]
const synTabs = [
{ key: 'compare', label: '比较' },
{ key: 'composite', label: '组合' },
@@ -180,11 +197,6 @@ const synTabs = [
{ key: 'overlay', label: '配对' },
{ key: 'composite_progressed', label: '组合次限' },
]
const starPanels = [
{ key: 'chart' as const, label: '星盘' },
{ key: 'fortune' as const, label: '运势' },
{ key: 'planets' as const, label: '行星' },
]
const fortuneKeys = ['daily', 'weekly', 'monthly', 'yearly', 'lifetime'] as const
type FortunePeriod = {
@@ -217,6 +229,19 @@ const typeLabel = computed(() => {
}
return map[report.value?.type || ''] || '成长报告'
})
const showModeTabs = computed(() => report.value?.type === 'star' || report.value?.type === 'synastry')
const activePanels = computed(() => (report.value?.type === 'synastry' ? synTabs : starPanels))
const relatedLink = computed(() => {
const t = report.value?.type
if (t === 'star') return { to: '/star', label: '星座' }
if (t === 'synastry') return { to: '/synastry', label: '合盘' }
if (t === 'portrait') return { to: '/portrait', label: '解码' }
if (t === 'relation') return { to: '/relation', label: '匹配' }
if (t === 'rhythm') return { to: '/rhythm', label: '节律' }
return { to: '/reports', label: '全部' }
})
const chartObj = computed(() =>
summary.value.chart && typeof summary.value.chart === 'object'
? (summary.value.chart as Record<string, unknown>)
@@ -261,7 +286,7 @@ const synCharts = computed(() =>
: {},
)
const synActiveBlock = computed(() => {
const c = synCharts.value[synTab.value]
const c = synCharts.value[panelKey.value]
return c && typeof c === 'object' ? (c as Record<string, unknown>) : null
})
const synActiveTip = computed(() => String(synActiveBlock.value?.tip || ''))
@@ -287,9 +312,7 @@ const synOverlayEntries = computed(() => {
const o = synCharts.value.overlay
if (!o || typeof o !== 'object') return []
const raw = (o as { entries?: unknown }).entries
return Array.isArray(raw)
? (raw as { planet: string; house: number; house_tip: string }[])
: []
return Array.isArray(raw) ? (raw as { planet: string; house: number; house_tip: string }[]) : []
})
function synChart(key: 'chart_a' | 'chart_b'): { planets: WheelPlanet[]; asc: number | null } {
@@ -319,7 +342,7 @@ const createdLabel = computed(() => {
const t = report.value?.created_at
if (!t) return ''
try {
return new Date(t).toLocaleString('zh-CN')
return new Date(t).toLocaleDateString('zh-CN')
} catch {
return t
}
@@ -346,34 +369,32 @@ const marriageIndex = computed(() =>
typeof summary.value.marriage_index === 'number' ? summary.value.marriage_index : null,
)
const hasMatchIndex = computed(
() => loveIndex.value != null || friendIndex.value != null || marriageIndex.value != null,
() =>
(report.value?.type === 'relation' || report.value?.type === 'synastry') &&
(loveIndex.value != null || friendIndex.value != null || marriageIndex.value != null),
)
const sharePayload = computed<SharePayload | null>(() => {
if (!report.value) return null
if (report.value.type === 'relation') {
const p: RelationSharePayload = {
return {
type: 'relation',
me: String(summary.value.me_style || ''),
other: String(summary.value.other_style || ''),
diff: String(summary.value.diff_one_liner || ''),
keywords: keywords.value,
}
return p
} as RelationSharePayload
}
const p: PortraitSharePayload = {
return {
type: 'portrait',
title: headline.value,
line: oneLiner.value,
keywords: keywords.value,
}
return p
} as PortraitSharePayload
})
function fortuneLabel(k: string) {
return ({ daily: '今日', weekly: '本周', monthly: '本月', yearly: '今年', lifetime: '一生' } as Record<string, string>)[
k
] || k
return ({ daily: '今日', weekly: '本周', monthly: '本月', yearly: '今年', lifetime: '一生' } as Record<string, string>)[k] || k
}
async function load() {
@@ -384,7 +405,7 @@ async function load() {
const id = String(route.params.id || '')
if (!id) throw new Error('缺少报告 id')
report.value = await api.getReport(id)
starPanel.value = 'chart'
panelKey.value = report.value.type === 'synastry' ? 'compare' : 'chart'
signTab.value = 'sun'
fortuneKey.value = 'daily'
} catch (e) {
@@ -410,34 +431,204 @@ async function buyDeep() {
}
}
watch(
() => route.params.id,
() => {
load()
},
)
watch(() => route.params.id, load)
onMounted(load)
</script>
<style scoped>
.yxg-card{margin:12px 0}
.lock .yxg-btn,.share-btn{margin-top:12px;width:100%}
.panel-tabs,.fortune-tabs{display:flex;gap:8px;margin:10px 0 12px;flex-wrap:wrap}
.panel-tab,.ftab{
flex:1;min-width:64px;border:1px solid #eee;background:#fafafa;border-radius:12px;
padding:10px 8px;font-size:13px;color:#666;cursor:pointer;
.top-row {
display: flex;
align-items: center;
gap: 10px;
padding-bottom: 4px;
}
.panel-tab.on,.ftab.on{border-color:var(--yxg-pri);background:#fff5f4;color:#222;font-weight:600}
.ft-title{font-weight:700;color:#222;margin-bottom:6px}
.card-title{font-size:16px;margin:0 0 8px}
.planet-list{list-style:none;padding:0;margin:0}
.planet{display:flex;flex-direction:column;gap:4px;padding:12px}
.indices{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin:12px 0}
.idx{
background:#fff5f4;border:1px solid #ffe0dc;border-radius:14px;padding:12px 8px;text-align:center;
.who-static { flex: 1; font-size: 16px; font-weight: 700; text-align: center; }
.spacer { width: 36px; }
.share-ico {
width: 36px;
height: 36px;
border: none;
border-radius: 12px;
background: rgba(255, 255, 255, 0.8);
font-size: 16px;
}
.idx em{display:block;font-style:normal;font-size:11px;color:#999;margin-bottom:4px}
.idx strong{font-size:22px;color:var(--yxg-pri);font-family:var(--font-display)}
.links{display:flex;flex-direction:column;gap:8px;margin:12px 0}
.body { padding: 8px 16px 88px; }
.report-head { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; }
.type-badge {
font-size: 11px;
font-weight: 700;
color: #fff;
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
padding: 4px 10px;
border-radius: var(--radius-pill);
}
.date-badge {
font-size: 11px;
color: #999;
padding: 4px 10px;
border-radius: var(--radius-pill);
background: #fafafa;
border: 1px solid #f0ebe8;
}
.hl {
font-family: var(--font-display);
font-size: 20px;
font-weight: 700;
line-height: 1.35;
color: #222;
}
.lead { margin-top: 6px; font-size: 13px; color: #777; line-height: 1.55; }
.vip-strip {
display: flex;
align-items: center;
gap: 10px;
margin: 14px 0;
padding: 12px 14px;
border-radius: var(--radius-lg);
background: linear-gradient(145deg, #fff4e0, #ffe8b8);
box-shadow: 0 4px 14px rgba(200, 146, 58, 0.12);
color: inherit;
}
.vip-ico {
width: 32px;
height: 32px;
border-radius: 10px;
background: linear-gradient(135deg, #f0c86a, #e8a830);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
flex-shrink: 0;
}
.vip-copy { flex: 1; min-width: 0; }
.vip-copy strong { display: block; font-size: 14px; color: #222; }
.vip-copy em { display: block; font-style: normal; font-size: 11px; color: #8a7040; margin-top: 2px; }
.vip-go { font-size: 13px; font-weight: 600; color: #b07a28; flex-shrink: 0; }
.mode-tabs {
display: flex;
gap: 4px;
margin-bottom: 14px;
background: #fafafa;
border-radius: var(--radius-pill);
padding: 4px;
overflow-x: auto;
scrollbar-width: none;
}
.mode-tabs::-webkit-scrollbar { display: none; }
.mode-tab {
flex: 1;
min-width: 52px;
border: none;
background: transparent;
padding: 9px 6px;
border-radius: var(--radius-pill);
font-size: 12px;
color: #888;
font-weight: 600;
white-space: nowrap;
}
.mode-tab.on {
background: #fff;
color: var(--color-primary);
box-shadow: 0 2px 8px rgba(229, 77, 66, 0.1);
}
.spotlight {
margin: 12px 0;
padding: 14px;
border-radius: var(--radius-xl);
background: linear-gradient(145deg, #fff0ec, #ffe4dc);
box-shadow: var(--shadow-card);
}
.spotlight h3 { font-size: 15px; margin-bottom: 6px; color: #222; }
.spotlight p { font-size: 13px; color: #555; line-height: 1.6; }
.fortune-tabs { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; }
.ftab {
flex: 1;
min-width: 52px;
border: 1px solid #eee;
background: #fafafa;
border-radius: 12px;
padding: 10px 4px;
font-size: 12px;
color: #666;
}
.ftab.on { border-color: var(--color-primary); background: #fff5f4; color: #222; font-weight: 600; }
.ft-title { font-weight: 700; color: #222; margin-bottom: 6px; }
.planet-list { list-style: none; padding: 0; margin: 0; }
.planet-row {
display: flex;
flex-direction: column;
gap: 4px;
padding: 14px;
margin-bottom: 8px;
background: #fafafa;
border-radius: 14px;
}
.planet-row strong { font-size: 14px; color: #222; }
.planet-row span { font-size: 12px; color: #888; }
.indices {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin: 12px 0;
}
.idx {
background: #fff5f4;
border: 1px solid #ffe0dc;
border-radius: 14px;
padding: 12px 8px;
text-align: center;
}
.idx em { display: block; font-style: normal; font-size: 11px; color: #999; margin-bottom: 4px; }
.idx strong { font-size: 22px; color: var(--color-primary); font-family: var(--font-display); }
.lock-card {
margin-top: 12px;
padding: 16px;
background: linear-gradient(145deg, #fff8e8, #ffeec8);
border-radius: var(--radius-xl);
}
.cta.soft {
margin-top: 12px;
width: 100%;
padding: 12px;
border: 1.5px solid #f5c4bc;
border-radius: 22px;
background: #fff;
color: var(--color-primary);
font-size: 14px;
font-weight: 600;
}
.cta.soft:disabled { opacity: 0.45; }
.sticky-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 20;
display: flex;
gap: 8px;
padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(12px);
border-top: 1px solid #f0ebe8;
}
.sb-btn {
flex: 1;
padding: 11px 8px;
border-radius: 22px;
border: 1.5px solid #f0ebe8;
background: #fff;
font-size: 13px;
font-weight: 600;
color: #555;
text-align: center;
text-decoration: none;
}
.sb-btn.primary {
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
color: #fff;
border: none;
}
.card-title { font-size: 16px; margin: 0 0 8px; }
</style>
+224 -43
View File
@@ -1,47 +1,66 @@
<template>
<main class="yxg-page">
<div class="yxg-hero">
<BackButton />
<PageTitle feature="reports" title="我的成长报告" sub="按类型筛选 · 愈心解码 / 星座 / 节律 / 匹配 / 意象" />
<PageShell>
<template #hero>
<div class="top-row">
<BackButton />
<span class="who-static">我的成长报告</span>
<span class="spacer" />
</div>
</template>
<div class="chips">
<button
v-for="f in filters"
:key="f.key"
type="button"
class="chip"
:class="{ on: filter === f.key }"
@click="filter = f.key"
>
{{ f.label }}
</button>
</div>
<div class="yxg-sheet">
<div class="yxg-chips">
<button
v-for="f in filters"
:key="f.key"
type="button"
class="yxg-chip yxg-chip-solid"
:class="{ on: filter === f.key }"
@click="filter = f.key"
>{{ f.label }}</button>
</div>
<div class="body">
<router-link class="vip-strip" to="/membership">
<span class="vip-ico" aria-hidden="true"></span>
<span class="vip-copy">
<strong>成长会员</strong>
<em>解锁全部深度报告与节气陪伴</em>
</span>
<span class="vip-go">开通 </span>
</router-link>
<p v-if="loading" class="yxg-hint pad">加载中</p>
<p v-else-if="error" class="yxg-err pad">
<p v-if="loading" class="hint">加载中</p>
<p v-else-if="error" class="err">
{{ error }}
<button type="button" class="yxg-link" @click="load">重试</button>
<button type="button" class="retry" @click="load">重试</button>
</p>
<template v-else>
<div v-if="!filtered.length" class="yxg-card empty">
<div v-if="!filtered.length" class="empty">
<HomeToolIcon name="reports" :size="56" />
<p>还没有{{ filter === 'all' ? '' : '该类' }}成长报告</p>
<router-link class="yxg-btn" to="/explore">去探索</router-link>
<router-link class="cta" to="/explore">去探索</router-link>
</div>
<ul v-else class="yxg-list">
<li v-for="r in filtered" :key="r.id">
<router-link :to="`/reports/${r.id}`">
<span class="yxg-body">
<strong>{{ typeLabel(r.type) }}</strong>
<span class="desc">{{ headlineOf(r) }}</span>
<span class="meta">{{ formatDate(r.created_at) }}</span>
<div v-else class="list">
<router-link v-for="r in filtered" :key="r.id" :to="`/reports/${r.id}`" class="report-card">
<span class="thumb" aria-hidden="true">
<HomeToolIcon :name="toolIconFor(r.type)" :size="44" />
</span>
<span class="card-body">
<span class="card-title">
{{ typeLabel(r.type) }}
<span v-if="badgeFor(r)" class="badge" :class="'b-' + badgeFor(r)">{{ badgeLabel(badgeFor(r)!) }}</span>
</span>
<span class="chev" aria-hidden="true"></span>
</router-link>
</li>
</ul>
<span class="card-desc">{{ headlineOf(r) }}</span>
<span class="card-date">{{ formatDate(r.created_at) }}</span>
</span>
<span class="card-cta">查看报告</span>
</router-link>
</div>
</template>
</div>
</main>
</PageShell>
</template>
<script setup lang="ts">
@@ -49,7 +68,9 @@ import { computed, onMounted, ref } from 'vue'
import type { GrowthReport } from '@yuxingu/types'
import { api } from '../api/client'
import BackButton from '../components/BackButton.vue'
import PageTitle from '../components/PageTitle.vue'
import HomeToolIcon from '../components/HomeToolIcon.vue'
import PageShell from '../components/PageShell.vue'
import { toolIconFor } from '../lib/toolIconMap'
const items = ref<GrowthReport[]>([])
const loading = ref(true)
@@ -73,14 +94,14 @@ const filtered = computed(() => {
function typeLabel(t: string) {
const map: Record<string, string> = {
portrait: '愈心解码',
relation: '人格匹配',
synastry: '合盘',
star: '星座',
rhythm: '身心节律',
image_card: '意象卡片',
portrait: '愈心解码',
relation: '人格匹配',
synastry: '合盘',
star: '星座',
rhythm: '身心节律',
image_card: '意象卡片',
}
return map[t] || '成长报告'
return map[t] || '成长报告'
}
function headlineOf(r: GrowthReport) {
@@ -90,12 +111,24 @@ function headlineOf(r: GrowthReport) {
function formatDate(v: string) {
try {
return new Date(v).toLocaleString('zh-CN')
return new Date(v).toLocaleDateString('zh-CN')
} catch {
return v
}
}
function badgeFor(r: GrowthReport): 'new' | 'hot' | null {
const created = new Date(r.created_at).getTime()
const weekAgo = Date.now() - 7 * 86400000
if (created > weekAgo) return 'new'
if (r.type === 'portrait' || r.type === 'star') return 'hot'
return null
}
function badgeLabel(b: 'new' | 'hot') {
return b === 'new' ? 'NEW' : 'HOT'
}
async function load() {
loading.value = true
error.value = ''
@@ -113,6 +146,154 @@ onMounted(load)
</script>
<style scoped>
.pad{padding:0 16px}
.empty .yxg-btn{margin-top:12px}
.top-row {
display: flex;
align-items: center;
gap: 10px;
padding-bottom: 4px;
}
.who-static { flex: 1; font-size: 16px; font-weight: 700; text-align: center; }
.spacer { width: 36px; }
.chips {
display: flex;
gap: 8px;
padding: 8px 16px 0;
overflow-x: auto;
scrollbar-width: none;
}
.chips::-webkit-scrollbar { display: none; }
.chip {
flex-shrink: 0;
padding: 7px 14px;
border-radius: var(--radius-pill);
border: 1px solid #f0ebe8;
background: #fafafa;
font-size: 12px;
color: #666;
}
.chip.on {
background: #fff;
color: var(--color-primary);
font-weight: 600;
border-color: #f5c4bc;
box-shadow: 0 2px 8px rgba(229, 77, 66, 0.1);
}
.body { padding: 12px 16px 24px; }
.vip-strip {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 14px;
padding: 12px 14px;
border-radius: var(--radius-lg);
background: linear-gradient(145deg, #fff4e0, #ffe8b8);
box-shadow: 0 4px 14px rgba(200, 146, 58, 0.12);
color: inherit;
}
.vip-ico {
width: 32px;
height: 32px;
border-radius: 10px;
background: linear-gradient(135deg, #f0c86a, #e8a830);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
flex-shrink: 0;
}
.vip-copy { flex: 1; min-width: 0; }
.vip-copy strong { display: block; font-size: 14px; color: #222; }
.vip-copy em { display: block; font-style: normal; font-size: 11px; color: #8a7040; margin-top: 2px; }
.vip-go { font-size: 13px; font-weight: 600; color: #b07a28; flex-shrink: 0; }
.list { display: flex; flex-direction: column; gap: 10px; }
.report-card {
display: flex;
align-items: center;
gap: 12px;
padding: 14px;
border-radius: var(--radius-xl);
background: #fff;
box-shadow: var(--shadow-card);
border: 1px solid rgba(0, 0, 0, 0.03);
color: inherit;
transition: transform var(--duration-fast) ease;
}
.report-card:active { transform: scale(0.985); }
.thumb {
flex-shrink: 0;
width: 52px;
height: 52px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 14px;
background: linear-gradient(155deg, #ffe9e2, #ffd4c8);
}
.card-body { flex: 1; min-width: 0; }
.card-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 15px;
font-weight: 700;
color: #222;
}
.badge {
font-size: 9px;
font-weight: 700;
color: #fff;
padding: 2px 6px;
border-radius: 6px;
}
.badge.b-hot { background: linear-gradient(135deg, #ff7a6e, var(--color-primary)); }
.badge.b-new { background: linear-gradient(135deg, #6eb6ff, #4a90e2); }
.card-desc {
display: block;
font-size: 12px;
color: #888;
margin-top: 4px;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-date { display: block; font-size: 11px; color: #c4c4c4; margin-top: 4px; }
.card-cta {
flex-shrink: 0;
font-size: 12px;
font-weight: 600;
color: var(--color-primary);
padding: 8px 12px;
border-radius: var(--radius-pill);
background: #fff5f4;
border: 1px solid #ffe0dc;
}
.empty {
text-align: center;
padding: 32px 16px;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
color: #888;
}
.cta {
display: inline-flex;
padding: 11px 20px;
border-radius: 22px;
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
color: #fff;
font-weight: 600;
font-size: 14px;
}
.hint, .err { font-size: 13px; color: #999; }
.err { color: var(--color-primary); }
.retry {
border: none;
background: transparent;
color: var(--color-accent-blue);
text-decoration: underline;
margin-left: 6px;
}
</style>
+317 -55
View File
@@ -1,52 +1,90 @@
<template>
<main class="yxg-page">
<div class="yxg-hero">
<PageShell>
<template #hero>
<BackButton />
<PageTitle feature="scale" :title="title || '探索测试'" :sub="description" />
</div>
<div class="yxg-sheet sheet-pad">
<div class="head">
<HomeToolIcon name="mbti" :size="48" />
<div>
<h1 class="title">{{ title || '探索测试' }}</h1>
<p v-if="description && phase !== 'intro'" class="sub">{{ description }}</p>
</div>
</div>
</template>
<div class="body" :class="{ 'has-bar': phase === 'answering' }">
<p v-if="loadingScale" class="yxg-hint">加载题目</p>
<p v-else-if="loadError" class="yxg-err">
{{ loadError }}
<button type="button" class="yxg-link" @click="load">重试</button>
</p>
<template v-else>
<div v-if="!done" class="yxg-card">
<p v-if="draftRestored" class="yxg-hint draft">已恢复上次未提交的作答</p>
<p v-if="questions.length" class="progress">
已答 {{ answeredCount }} / {{ questions.length }}
</p>
<div v-for="(q, idx) in questions" :key="q.id" class="q">
<p class="prompt">{{ idx + 1 }}. {{ q.body.prompt }}</p>
<label v-for="opt in q.body.options" :key="opt.key" class="opt">
<input v-model="answers[q.id]" type="radio" :value="opt.key" />
{{ opt.text }}
</label>
</div>
<p v-if="submitError" class="yxg-err">{{ submitError }}</p>
<div v-if="needProfile" class="need">
<p>提交结果需要先有的个人档案</p>
<router-link class="yxg-btn" to="/">去首页创建档案</router-link>
</div>
<button v-else class="yxg-btn yxg-btn-block" type="button" :disabled="submitting" @click="submit">
查看探索结果
<!-- Intro -->
<div v-else-if="phase === 'intro'" class="intro-screen">
<div class="intro-card">
<HomeToolIcon name="mbti" :size="64" />
<h2 class="intro-title">{{ title || '探索测试' }}</h2>
<p v-if="description" class="intro-desc">{{ description }}</p>
<p v-if="questions.length" class="intro-count"> {{ questions.length }} · {{ estMinutes }} 分钟</p>
<p v-if="draftRestored" class="draft-hint">检测到上次未完成的作答开始后将继续</p>
<button class="yxg-btn yxg-btn-block intro-start" type="button" :disabled="!questions.length" @click="startTest">
开始
</button>
</div>
</div>
<div v-else>
<p class="result-lead">{{ resultLabel }}</p>
<ReportRich :summary="resultSummaryObj" :detail="resultDetailObj" :deep="true" />
<div class="yxg-card">
<p v-if="shareLine" class="share">{{ shareLine }}</p>
<button type="button" class="yxg-btn yxg-btn-ghost" @click="openShare">生成分享卡</button>
<router-link class="yxg-link-plain link" to="/relation">用结果去做关系理解 </router-link>
<router-link class="yxg-link-plain link" to="/ask">去问答聊聊 </router-link>
<!-- Answering -->
<div v-else-if="phase === 'answering'" class="answer-screen">
<p v-if="draftRestored && currentIdx === 0" class="yxg-hint draft">已恢复上次未提交的作答</p>
<div class="progress-wrap">
<div class="progress-bar">
<i class="progress-fill" :style="{ width: progressPct + '%' }" />
</div>
<span class="progress-label">{{ currentIdx + 1 }} / {{ questions.length }}</span>
</div>
</template>
<div v-if="currentQuestion" class="q-card yxg-card">
<p class="prompt">{{ currentQuestion.body.prompt }}</p>
<label v-for="opt in currentQuestion.body.options" :key="opt.key" class="opt" :class="{ picked: answers[currentQuestion.id] === opt.key }">
<input v-model="answers[currentQuestion.id]" type="radio" :value="opt.key" />
<span class="opt-text">{{ opt.text }}</span>
</label>
</div>
<p v-if="submitError" class="yxg-err">{{ submitError }}</p>
<div v-if="needProfile" class="need">
<p>提交结果需要先有的个人档案</p>
<router-link class="yxg-btn" to="/">去首页创建档案</router-link>
</div>
</div>
<!-- Result -->
<div v-else-if="phase === 'result'">
<p class="result-lead">{{ resultLabel }}</p>
<ReportRich :summary="resultSummaryObj" :detail="resultDetailObj" :deep="true" />
<div class="yxg-card">
<p v-if="shareLine" class="share">{{ shareLine }}</p>
<button type="button" class="yxg-btn yxg-btn-ghost" @click="openShare">生成分享卡</button>
<router-link class="yxg-link-plain link" to="/relation">用结果去做关系理解 </router-link>
<router-link class="yxg-link-plain link" to="/ask">去问答聊聊 </router-link>
</div>
</div>
</div>
<!-- Nav bar -->
<div v-if="phase === 'answering'" class="nav-bar">
<button type="button" class="nav-btn prev" :disabled="currentIdx === 0" @click="prevQ">上一题</button>
<button
type="button"
class="nav-btn next"
:disabled="!answers[currentQuestion?.id || ''] || submitting"
@click="onNext"
>
{{ isLast ? (submitting ? '提交中…' : '查看探索结果') : '下一题' }}
</button>
</div>
<ShareSheet :open="shareOpen" :payload="sharePayload" @close="shareOpen = false" />
</main>
</PageShell>
</template>
<script setup lang="ts">
@@ -54,7 +92,8 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { api } from '../api/client'
import BackButton from '../components/BackButton.vue'
import PageTitle from '../components/PageTitle.vue'
import HomeToolIcon from '../components/HomeToolIcon.vue'
import PageShell from '../components/PageShell.vue'
import ReportRich from '../components/ReportRich.vue'
import ShareSheet from '../components/ShareSheet.vue'
import { clearScaleDraft, loadScaleDraft, saveScaleDraft } from '../lib/scaleDraft'
@@ -71,24 +110,32 @@ const submitting = ref(false)
const loadError = ref('')
const submitError = ref('')
const needProfile = ref(false)
const done = ref(false)
const phase = ref<'intro' | 'answering' | 'result'>('intro')
const resultLabel = ref('')
const shareLine = ref('')
const shareOpen = ref(false)
const resultRaw = ref<Record<string, unknown> | null>(null)
const draftRestored = ref(false)
const persistReady = ref(false)
const currentIdx = ref(0)
const answeredCount = computed(() => questions.value.filter((q) => answers[q.id]).length)
const currentQuestion = computed(() => questions.value[currentIdx.value] || null)
const isLast = computed(() => currentIdx.value >= questions.value.length - 1)
const progressPct = computed(() => {
if (!questions.value.length) return 0
return Math.round(((currentIdx.value + 1) / questions.value.length) * 100)
})
const estMinutes = computed(() => Math.max(1, Math.ceil(questions.value.length / 3)))
watch(
answers,
() => {
if (!persistReady.value || done.value) return
if (!persistReady.value || phase.value === 'result') return
saveScaleDraft(slug, { ...answers })
},
{ deep: true },
)
const resultSummaryObj = computed(() => {
const r = resultRaw.value || {}
return {
@@ -112,7 +159,7 @@ const resultDetailObj = computed(() => {
})
const sharePayload = computed<PortraitSharePayload | null>(() => {
if (!done.value) return null
if (phase.value !== 'result') return null
return {
type: 'portrait',
title: resultLabel.value,
@@ -121,6 +168,28 @@ const sharePayload = computed<PortraitSharePayload | null>(() => {
}
})
function firstUnansweredIdx(): number {
const idx = questions.value.findIndex((q) => !answers[q.id])
return idx >= 0 ? idx : 0
}
function startTest() {
phase.value = 'answering'
currentIdx.value = draftRestored.value ? firstUnansweredIdx() : 0
}
function prevQ() {
if (currentIdx.value > 0) currentIdx.value--
}
function onNext() {
if (isLast.value) {
submit()
} else {
currentIdx.value++
}
}
async function load() {
loadingScale.value = true
loadError.value = ''
@@ -174,7 +243,7 @@ async function submit() {
const out = await api.submitScale(slug, self.id, { ...answers })
clearScaleDraft(slug)
draftRestored.value = false
done.value = true
phase.value = 'result'
resultRaw.value = out.result as Record<string, unknown>
resultLabel.value = String(out.result.label || '探索结果')
shareLine.value = String(out.result.share_line || '')
@@ -189,19 +258,212 @@ onMounted(load)
</script>
<style scoped>
.yxg-card{margin:8px 0 12px}
.progress{font-size:12px;color:#999;margin-bottom:12px}
.q{margin-bottom:18px}
.prompt{font-weight:650;color:#222;margin-bottom:8px;line-height:1.45}
.opt{display:flex;align-items:flex-start;gap:8px;margin:8px 0;cursor:pointer;line-height:1.45}
.result-lead{
font-family:var(--font-display);
font-size:20px;font-weight:700;color:#222;margin:4px 0 12px;letter-spacing:.04em;
.head {
display: flex;
align-items: center;
gap: 12px;
margin-top: 8px;
padding-bottom: 8px;
}
.title {
font-family: var(--font-display);
font-size: 22px;
font-weight: 700;
letter-spacing: 0.06em;
}
.sub {
font-size: 12px;
color: rgba(0, 0, 0, 0.38);
margin-top: 2px;
}
.body {
padding: 8px 16px 0;
}
.body.has-bar {
padding-bottom: 72px;
}
.intro-screen {
display: flex;
justify-content: center;
padding: 24px 0 32px;
}
.intro-card {
width: 100%;
max-width: 340px;
text-align: center;
padding: 32px 24px;
background: var(--color-surface);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-hero);
}
.intro-title {
font-family: var(--font-display);
font-size: 22px;
font-weight: 700;
color: var(--color-text-primary);
margin: 16px 0 8px;
letter-spacing: 0.04em;
}
.intro-desc {
font-size: 14px;
color: var(--color-text-secondary);
line-height: 1.55;
margin: 0 0 12px;
}
.intro-count {
font-size: 12px;
color: var(--color-text-tertiary);
margin: 0 0 20px;
}
.draft-hint {
font-size: 12px;
color: var(--color-accent-gold);
margin: 0 0 12px;
}
.intro-start {
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);
}
.answer-screen {
padding-bottom: 8px;
}
.progress-wrap {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
}
.progress-bar {
flex: 1;
height: 6px;
border-radius: 999px;
background: #f0ebe7;
overflow: hidden;
}
.progress-fill {
display: block;
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, #ff7a6e, var(--color-primary));
transition: width 0.3s ease;
}
.progress-label {
font-size: 12px;
font-weight: 600;
color: var(--color-text-tertiary);
flex-shrink: 0;
}
.q-card {
margin: 0 0 12px;
}
.prompt {
font-weight: 650;
font-size: 17px;
color: #222;
margin-bottom: 16px;
line-height: 1.5;
}
.opt {
display: flex;
align-items: flex-start;
gap: 10px;
margin: 10px 0;
padding: 14px 12px;
border-radius: 14px;
border: 1.5px solid #eee;
background: #fdfaf8;
cursor: pointer;
line-height: 1.45;
transition: border-color 0.15s ease, background 0.15s ease;
}
.opt.picked {
border-color: var(--color-primary);
background: #fff5f3;
}
.opt input {
margin-top: 3px;
accent-color: var(--color-primary);
}
.opt-text {
flex: 1;
font-size: 14px;
}
.nav-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 20;
display: flex;
gap: 10px;
padding: 10px 16px calc(10px + env(safe-area-inset-bottom, 0));
background: rgba(255, 255, 255, 0.94);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-top: 1px solid rgba(0, 0, 0, 0.06);
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.06);
}
.nav-btn {
flex: 1;
padding: 13px;
border-radius: 22px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
border: none;
}
.nav-btn.prev {
background: #f3eeea;
color: #555;
}
.nav-btn.prev:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.nav-btn.next {
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
color: #fff;
box-shadow: 0 4px 14px rgba(229, 77, 66, 0.25);
}
.nav-btn.next:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.result-lead {
font-family: var(--font-display);
font-size: 20px;
font-weight: 700;
color: #222;
margin: 4px 0 12px;
letter-spacing: 0.04em;
}
.share {
margin: 0 0 10px;
color: var(--yxg-gold);
}
.link {
display: block;
margin-top: 12px;
}
.need {
margin-top: 10px;
padding: 12px;
background: #fff8f7;
border-radius: 12px;
}
.need .yxg-btn {
margin-top: 10px;
}
.draft {
color: var(--yxg-gold);
margin-bottom: 8px;
}
.share{margin:0 0 10px;color:var(--yxg-gold)}
.link{display:block;margin-top:12px}
.need{margin-top:10px;padding:12px;background:#fff8f7;border-radius:12px}
.need .yxg-btn{margin-top:10px}
.draft{color:var(--yxg-gold);margin-bottom:8px}
.yxg-btn{margin-top:8px}
</style>
+92 -21
View File
@@ -1,36 +1,65 @@
<template>
<main class="yxg-page">
<div class="yxg-hero">
<BackButton />
</div>
<div class="yxg-sheet sheet-pad">
<p v-if="!payload" class="yxg-err">
分享内容无效或已过期
<router-link class="yxg-link" to="/">回首页</router-link>
</p>
<PageShell>
<template #hero>
<div class="top-row">
<BackButton />
<span class="who-static">分享</span>
<span class="spacer" />
</div>
</template>
<div class="body">
<template v-if="!payload">
<div class="empty-hero">
<HomeToolIcon name="ask" :size="64" />
<h1 class="hero-title">分享内容无效</h1>
<p class="hero-sub">链接可能已过期你可以从首页重新开始</p>
<router-link class="cta" to="/">回首页</router-link>
</div>
</template>
<template v-else>
<p class="yxg-meta lead">朋友分享了{{ payload.type === 'relation' ? '一段关系理解' : '一份个人画像' }}</p>
<div class="hero-block">
<HomeToolIcon :name="payload.type === 'relation' ? 'relation' : 'portrait'" :size="64" />
<h1 class="hero-title">{{ shareTitle }}</h1>
<p class="hero-sub">{{ shareSub }}</p>
</div>
<ShareCard v-bind="cardProps" />
<div class="actions">
<router-link class="yxg-btn yxg-btn-block" :to="ctaTo">{{ ctaText }}</router-link>
<router-link class="yxg-link-plain ghost" to="/">先逛逛首页</router-link>
<router-link class="cta" :to="ctaTo">{{ ctaText }}</router-link>
<router-link class="ghost-link" to="/">先逛逛首页</router-link>
</div>
<p class="yxg-disc">本内容为自我探索与生活方式参考不构成医疗建议亦非占卜预测</p>
</template>
</div>
</main>
</PageShell>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import BackButton from '../components/BackButton.vue'
import HomeToolIcon from '../components/HomeToolIcon.vue'
import PageShell from '../components/PageShell.vue'
import ShareCard from '../components/ShareCard.vue'
import { parseShareQuery } from '../lib/shareLink'
const route = useRoute()
const payload = computed(() => parseShareQuery(route.query as Record<string, unknown>))
const shareTitle = computed(() => {
const p = payload.value
if (!p) return '分享'
return p.type === 'relation' ? '关系理解分享' : '个人画像分享'
})
const shareSub = computed(() => {
const p = payload.value
if (!p) return ''
return p.type === 'relation' ? '朋友分享了一段关系理解' : '朋友分享了一份个人画像'
})
const cardProps = computed(() => {
const p = payload.value
if (!p) return {}
@@ -46,11 +75,7 @@ const cardProps = computed(() => {
return {
type: 'relation' as const,
title: '我的方式 vs TA 的方式',
rows: [
p.me ? `我:${p.me}` : '',
p.other ? `TA${p.other}` : '',
p.diff || '',
].filter(Boolean),
rows: [p.me ? `我:${p.me}` : '', p.other ? `TA${p.other}` : '', p.diff || ''].filter(Boolean),
keywords: p.keywords,
ctaLabel: '了解彼此 → 查看关系理解',
}
@@ -63,7 +88,53 @@ const ctaText = computed(() =>
</script>
<style scoped>
.lead{margin:4px 0 16px}
.actions{display:flex;flex-direction:column;gap:10px;margin-top:18px;align-items:center}
.ghost{text-align:center;font-size:13px}
.top-row {
display: flex;
align-items: center;
gap: 10px;
padding-bottom: 4px;
}
.who-static { flex: 1; font-size: 16px; font-weight: 700; text-align: center; }
.spacer { width: 36px; }
.body { padding: 8px 16px 24px; }
.hero-block, .empty-hero {
text-align: center;
padding: 20px 12px 16px;
}
.hero-title {
font-family: var(--font-display);
font-size: 22px;
font-weight: 700;
margin-top: 10px;
letter-spacing: 0.06em;
color: #222;
}
.hero-sub {
font-size: 13px;
color: #888;
margin-top: 8px;
line-height: 1.5;
}
.cta {
display: block;
width: 100%;
margin-top: 18px;
padding: 13px;
border-radius: 22px;
text-align: center;
color: #fff;
font-size: 15px;
font-weight: 600;
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
box-shadow: 0 6px 16px rgba(229, 77, 66, 0.28);
}
.actions { margin-top: 8px; }
.ghost-link {
display: block;
text-align: center;
margin-top: 12px;
font-size: 13px;
color: #999;
}
.empty-hero .cta { max-width: 280px; margin: 18px auto 0; }
</style>
+389 -107
View File
@@ -1,50 +1,107 @@
<template>
<main class="yxg-page">
<div class="yxg-hero">
<BackButton />
<PageTitle feature="star" title="星座" :sub="needBirth && !report ? '填写生日(可选出生时/地),生成星盘与运势' : undefined" />
</div>
<div class="yxg-sheet sheet-pad">
<PageShell>
<template #hero>
<div class="top-row">
<BackButton />
<button v-if="report" type="button" class="who" @click="resetForm">
自己 <span aria-hidden="true"></span>
</button>
<span v-else class="who-static">星座</span>
<button v-if="report" type="button" class="share-ico" aria-label="分享" @click="shareOpen = true"></button>
</div>
</template>
<div class="body">
<template v-if="needBirth && !report">
<div class="yxg-card">
<div class="intro-card">
<HomeToolIcon name="star" :size="64" />
<h1 class="intro-title">星座性格</h1>
<p class="intro-sub">填写生日看见太阳 · 月亮 · 上升与行星画像</p>
</div>
<div class="yxg-card form-card">
<label class="yxg-label">生日 / 出生时可选</label>
<div class="birth-row">
<BirthDateInputs v-model:year="year" v-model:month="month" v-model:day="day" compact />
<input class="bd-time" v-model="birthTime" type="time" aria-label="出生时" />
<span class="bd-sep"></span>
</div>
<label class="yxg-label">出生地可选 / / 区县</label>
<label class="yxg-label">出生地可选</label>
<BirthPlaceSelect v-model="birthPlace" />
<button class="yxg-btn yxg-btn-block" type="button" :disabled="loading" @click="start">生成星座</button>
<button class="cta" type="button" :disabled="loading" @click="start">
{{ loading ? '生成中…' : '生成我的星座' }}
</button>
<p v-if="error" class="yxg-err">{{ error }}</p>
</div>
</template>
<p v-else-if="loading" class="yxg-hint">正在生成</p>
<p v-else-if="error" class="yxg-err">
<p v-else-if="loading" class="yxg-hint">正在生成星盘</p>
<p v-else-if="error && !report" class="yxg-err">
{{ error }}
<button type="button" class="yxg-link" @click="load">重试</button>
</p>
<template v-else-if="report">
<p class="yxg-meta head">{{ headline }}</p>
<p v-if="oneLiner" class="yxg-lead">{{ oneLiner }}</p>
<p v-if="chartNote" class="yxg-meta">{{ chartNote }}</p>
<div class="panel-tabs" role="tablist">
<template v-else-if="report">
<div class="mode-tabs" role="tablist">
<button
v-for="p in panels"
v-for="p in viewTabs"
:key="p.key"
type="button"
class="panel-tab"
:class="{ on: panel === p.key }"
@click="panel = p.key"
class="mode-tab"
:class="{ on: view === p.key }"
@click="view = p.key"
>
{{ p.label }}
</button>
</div>
<template v-if="panel === 'chart'">
<template v-if="view === 'overview'">
<h2 class="hl">{{ headline || '我的星座画像' }}</h2>
<p v-if="oneLiner" class="lead">{{ oneLiner }}</p>
<div class="spotlight">
<div class="sp-head">
<strong>星象速览</strong>
<router-link class="sp-ai" to="/ask">AI 解读 </router-link>
</div>
<p class="sp-text">{{ chartNote || '行星落在不同星座,构成独特的你。' }}</p>
<div v-if="keywords.length" class="tags">
<span v-for="k in keywords.slice(0, 4)" :key="k" class="tag">{{ k }}</span>
</div>
</div>
<div class="planet-grid">
<button
v-for="(cell, i) in gridCells"
:key="cell.key + i"
type="button"
class="cell"
:class="{ center: cell.center, on: signTab === cell.key }"
@click="onGridTap(cell)"
>
<template v-if="cell.center">
<HomeToolIcon name="star" :size="40" />
</template>
<template v-else>
<strong class="nick">{{ cell.nick }}</strong>
<span class="role">{{ cell.role }}</span>
</template>
</button>
</div>
<div v-if="activeCard" class="detail-card">
<h3>{{ activeCard.title }} · {{ activeCard.label }}</h3>
<p>{{ activeCard.teaser }}</p>
<p v-if="activeCard.element" class="yxg-meta">{{ activeCard.element }} · {{ activeCard.modality }}</p>
</div>
<router-link class="ask-bar" to="/ask">
<span>AI本周我可以关注什么</span>
<span aria-hidden="true"></span>
</router-link>
</template>
<template v-else-if="view === 'chart'">
<div class="wheel-settings">
<label><input v-model="showOuter" type="checkbox" /> 显示外行星</label>
<label><input v-model="showOuter" type="checkbox" /> 外行星</label>
<label><input v-model="tightOrb" type="checkbox" /> 紧容许度</label>
</div>
<NatalWheel
@@ -56,35 +113,19 @@
:tight-orb="tightOrb"
@select="onWheelSelect"
/>
<SignCards v-model="signTab" :cards="signCards" />
<div v-if="activeCard" class="yxg-card soft tab-body">
<h2 class="card-title">{{ activeCard.title }} · {{ activeCard.label }}</h2>
<p>{{ activeCard.teaser }}</p>
<p v-if="activeCard.element" class="yxg-meta">{{ activeCard.element }} · {{ activeCard.modality }}</p>
</div>
<div v-if="houses.length" class="yxg-card soft">
<h2 class="card-title">宫位</h2>
<p class="house-row" v-for="h in houses" :key="h.num">{{ h.num }} · {{ h.sign }}</p>
</div>
<div v-if="aspectPreview.length" class="yxg-card soft">
<h2 class="card-title">相位速览</h2>
<p v-for="(a, i) in aspectPreview" :key="i" class="aspect-line">{{ a.label || `${a.a_title}${a.type}${a.b_title}` }}</p>
<p v-if="!report.has_deep_access" class="yxg-meta">完整相位表见深度版</p>
<ul v-else class="aspect-full">
<li v-for="(a, i) in fullAspects" :key="'f' + i">{{ a.label }}</li>
</ul>
</div>
<div v-if="transits.length" class="yxg-card soft">
<h2 class="card-title">今日行运</h2>
<div v-for="t in transits" :key="t.key" class="transit">
<strong>{{ t.title }}</strong>
<span class="yxg-meta">{{ t.aspect }}</span>
<p>{{ t.tip }}</p>
</div>
<p v-for="(a, i) in aspectPreview" :key="i" class="aspect-line">
{{ a.label || `${a.a_title}${a.type}${a.b_title}` }}
</p>
</div>
</template>
<template v-else-if="panel === 'fortune'">
<template v-else-if="view === 'fortune'">
<div class="fortune-tabs">
<button
v-for="k in fortuneKeys"
@@ -97,7 +138,7 @@
{{ fortuneLabel(k) }}
</button>
</div>
<div v-if="activeFortune" class="yxg-card daily">
<div v-if="activeFortune" class="fortune-card">
<p class="daily-title">
{{ activeFortune.title }} · {{ activeFortune.label }} · {{ activeFortune.score }}
</p>
@@ -116,31 +157,30 @@
<template v-else>
<ul class="planet-list">
<li v-for="p in planets" :key="p.key" class="yxg-card soft planet">
<li v-for="p in planets" :key="p.key" class="planet-row" @click="signTab = p.key; view = 'overview'">
<strong>{{ p.title }}</strong>
<span>{{ p.sign }} {{ p.degree }} · {{ p.house }}</span>
<span class="yxg-meta">{{ p.element }} · {{ p.modality }}</span>
</li>
</ul>
</template>
<ReportRich :summary="summary" :detail="detail" :deep="!!report.has_deep_access" />
<div v-if="!report.has_deep_access" class="yxg-card lock">
<div v-if="!report.has_deep_access" class="lock-card">
<p>完整相位与年运详解可在深度版或成长会员中查看</p>
<button class="yxg-btn" type="button" :disabled="paying" @click="buyDeep">解锁完整分析模拟支付</button>
<button class="cta soft" type="button" :disabled="paying" @click="buyDeep">解锁完整分析</button>
</div>
<div class="links">
<router-link class="yxg-link-plain" to="/synastry">去做合盘比较盘</router-link>
<router-link class="yxg-link-plain" to="/relation">去做人格匹配 </router-link>
<router-link class="yxg-link-plain" to="/ask">去问答聊聊星座 </router-link>
<router-link v-if="report.id" class="yxg-link-plain" :to="`/reports/${report.id}`">报告页打开 </router-link>
<div class="bottom-links">
<router-link to="/synastry">合盘 </router-link>
<router-link to="/relation">人格匹配 </router-link>
<router-link v-if="report.id" :to="`/reports/${report.id}`">报告 </router-link>
</div>
<button type="button" class="yxg-btn yxg-btn-ghost share-btn" @click="shareOpen = true">生成分享卡</button>
</template>
<p class="yxg-disc">运势与星盘为自我探索参考请结合现实判断</p>
</div>
<ShareSheet :open="shareOpen" :payload="sharePayload" @close="shareOpen = false" />
</main>
</PageShell>
</template>
<script setup lang="ts">
@@ -152,11 +192,12 @@ import { api } from '../api/client'
import BackButton from '../components/BackButton.vue'
import BirthDateInputs from '../components/BirthDateInputs.vue'
import BirthPlaceSelect from '../components/BirthPlaceSelect.vue'
import HomeToolIcon from '../components/HomeToolIcon.vue'
import NatalWheel, { type WheelAspect, type WheelPlanet } from '../components/NatalWheel.vue'
import PageTitle from '../components/PageTitle.vue'
import PageShell from '../components/PageShell.vue'
import ReportRich from '../components/ReportRich.vue'
import ShareSheet from '../components/ShareSheet.vue'
import SignCards, { type SignCard } from '../components/SignCards.vue'
import type { SignCard } from '../components/SignCards.vue'
import { AnalyticsEvent, track } from '../lib/analytics'
import type { PortraitSharePayload } from '../lib/shareLink'
@@ -174,12 +215,20 @@ const month = ref(String(route.query.m || ''))
const day = ref(String(route.query.d || ''))
const birthTime = ref('')
const birthPlace = ref('')
const panel = ref<'chart' | 'fortune' | 'planets'>('chart')
const view = ref<'overview' | 'chart' | 'fortune' | 'planets'>('overview')
const signTab = ref('sun')
const fortuneKey = ref<'daily' | 'weekly' | 'monthly' | 'yearly' | 'lifetime'>('daily')
const showOuter = ref(true)
const tightOrb = ref(false)
const viewTabs = [
{ key: 'overview' as const, label: '概览' },
{ key: 'chart' as const, label: '星盘' },
{ key: 'fortune' as const, label: '运势' },
{ key: 'planets' as const, label: '行星' },
]
const fortuneKeys = ['daily', 'weekly', 'monthly', 'yearly', 'lifetime'] as const
try {
const raw = localStorage.getItem(SETTINGS_KEY)
if (raw) {
@@ -194,13 +243,6 @@ watch([showOuter, tightOrb], () => {
localStorage.setItem(SETTINGS_KEY, JSON.stringify({ showOuter: showOuter.value, tightOrb: tightOrb.value }))
})
const panels = [
{ key: 'chart' as const, label: '星盘' },
{ key: 'fortune' as const, label: '运势' },
{ key: 'planets' as const, label: '行星' },
]
const fortuneKeys = ['daily', 'weekly', 'monthly', 'yearly', 'lifetime'] as const
type FortunePeriod = {
title?: string
label?: string
@@ -213,7 +255,6 @@ type FortunePeriod = {
}
type PlanetRow = WheelPlanet & { element?: string; modality?: string }
type AspectRow = WheelAspect & { a_title?: string; b_title?: string; label?: string }
type TransitRow = { key: string; title: string; aspect: string; tip: string }
type HouseRow = { num: number; sign: string }
const summary = computed(() => (report.value?.summary || {}) as Record<string, unknown>)
@@ -221,7 +262,9 @@ const detail = computed(() => (report.value?.detail || null) as Record<string, u
const headline = computed(() => String(summary.value.headline || ''))
const oneLiner = computed(() => String(summary.value.one_liner || ''))
const keywords = computed(() => (Array.isArray(summary.value.keywords) ? (summary.value.keywords as string[]) : []))
const chartObj = computed(() => (summary.value.chart && typeof summary.value.chart === 'object' ? (summary.value.chart as Record<string, unknown>) : {}))
const chartObj = computed(() =>
summary.value.chart && typeof summary.value.chart === 'object' ? (summary.value.chart as Record<string, unknown>) : {},
)
const chartNote = computed(() => String(chartObj.value.note || ''))
const ascLon = computed(() => (typeof chartObj.value.asc_lon === 'number' ? chartObj.value.asc_lon : null))
const houses = computed(() => (Array.isArray(chartObj.value.houses) ? (chartObj.value.houses as HouseRow[]) : []))
@@ -263,13 +306,39 @@ const aspectLines = computed<WheelAspect[]>(() => {
const src = report.value?.has_deep_access ? fullAspects.value : aspectPreview.value
return src.map((a) => ({ a: a.a, b: a.b, type: a.type, orb: a.orb, label: a.label }))
})
const transits = computed(() => {
const fromSummary = summary.value.transits
if (Array.isArray(fromSummary)) return fromSummary as TransitRow[]
const f = fortuneBundle.value as Record<string, unknown>
if (Array.isArray(f.transits)) return f.transits as TransitRow[]
return []
const gridCells = computed(() => {
const cards = signCards.value
const slots: { key: string; nick: string; role: string; center?: boolean }[] = []
const order = ['sun', 'moon', 'rise', 'venus', 'mars', 'mercury', 'north_node', 'juno', 'jupiter']
const byKey = new Map(cards.map((c) => [c.key, c]))
const planetByKey = new Map(planets.value.map((p) => [p.key, p]))
for (let i = 0; i < 9; i++) {
if (i === 4) {
slots.push({ key: '_center', nick: '', role: '', center: true })
continue
}
const key = order[i < 4 ? i : i - 1] || cards[i]?.key || `p${i}`
const c = byKey.get(key)
const p = planetByKey.get(key)
if (c) {
slots.push({
key: c.key,
nick: (c.teaser || c.label || c.title).slice(0, 6),
role: `${c.title}${c.label}`,
})
} else if (p) {
slots.push({ key: p.key, nick: p.sign, role: `${p.title}${p.sign}` })
} else if (cards[i < 4 ? i : i - 1]) {
const x = cards[i < 4 ? i : i - 1]
slots.push({ key: x.key, nick: (x.teaser || x.label).slice(0, 6), role: `${x.title}${x.label}` })
} else {
slots.push({ key: `empty${i}`, nick: '—', role: '待生成' })
}
}
return slots
})
const sharePayload = computed<PortraitSharePayload | null>(() => {
if (!report.value) return null
return { type: 'portrait', title: headline.value || '我的星座', line: oneLiner.value, keywords: keywords.value }
@@ -284,6 +353,20 @@ function onWheelSelect(key: string) {
track(AnalyticsEvent.StarWheelViewed, { planet: key })
}
function onGridTap(cell: { key: string; center?: boolean }) {
if (cell.center) {
view.value = 'chart'
return
}
if (cell.key.startsWith('empty')) return
signTab.value = cell.key
}
function resetForm() {
needBirth.value = true
report.value = null
}
async function generate(y: number, m: number, d: number) {
loading.value = true
error.value = ''
@@ -299,6 +382,7 @@ async function generate(y: number, m: number, d: number) {
birth_place: birthPlace.value || undefined,
})
report.value = await api.createStar(profile.id)
view.value = 'overview'
track(AnalyticsEvent.PortraitCompleted, { source: 'star' })
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
@@ -327,8 +411,10 @@ async function load() {
needBirth.value = false
try {
report.value = await api.getReport(reportId)
view.value = 'overview'
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
needBirth.value = true
} finally {
loading.value = false
}
@@ -338,6 +424,9 @@ async function load() {
const m = Number(route.query.m)
const d = Number(route.query.d)
if (y && m && d && !validateBirth(y, m, d)) {
year.value = String(y)
month.value = String(m)
day.value = String(d)
await generate(y, m, d)
}
}
@@ -363,42 +452,235 @@ onMounted(load)
</script>
<style scoped>
.yxg-card{margin:8px 0 12px}
.yxg-label{margin-top:12px}
.yxg-label:first-child{margin-top:0}
.birth-row{
display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-bottom:4px;
.top-row {
display: flex;
align-items: center;
gap: 10px;
padding-bottom: 4px;
}
.birth-row :deep(.bd){margin:0;flex-wrap:nowrap}
.bd-time{
width:108px;padding:10px 6px;border:1.5px solid #eee;border-radius:12px;
font-size:14px;text-align:center;outline:none;background:#fdfaf8;color:#333;
.who, .who-static {
flex: 1;
border: none;
background: transparent;
font-size: 16px;
font-weight: 700;
color: var(--color-text-primary);
text-align: center;
}
.bd-time:focus{border-color:#f0b8b0;background:#fff}
.bd-sep{font-size:12px;color:#bbb;flex-shrink:0}
:deep(.bp){margin-bottom:10px}
.head{margin:4px 0 8px;font-size:13px}
.tab-body p{margin:0 0 6px;line-height:1.65}
.panel-tabs,.fortune-tabs{display:flex;gap:8px;margin:10px 0 12px;flex-wrap:wrap}
.panel-tab,.ftab{
flex:1;min-width:56px;border:1px solid #eee;background:#fafafa;border-radius:12px;
padding:10px 6px;font-size:13px;color:#666;cursor:pointer;
.who-static { pointer-events: none; }
.share-ico {
width: 36px;
height: 36px;
border: none;
border-radius: 12px;
background: rgba(255, 255, 255, 0.8);
font-size: 16px;
}
.panel-tab.on,.ftab.on{border-color:var(--yxg-pri);background:#fff5f4;color:#222;font-weight:600}
.daily-title{font-weight:700;color:#222;margin-bottom:6px}
.focus{margin:0 0 8px;color:#666}
.dims{display:flex;flex-wrap:wrap;gap:10px;margin:10px 0;font-size:12px;color:#555}
.planet-list{list-style:none;padding:0;margin:0}
.planet{display:flex;flex-direction:column;gap:4px;padding:12px}
.lock .yxg-btn,.share-btn{margin-top:12px;width:100%}
.links{display:flex;flex-direction:column;gap:8px;margin:12px 0}
.wheel-settings{
display:flex;gap:16px;font-size:12px;color:#666;margin:4px 0 8px;justify-content:center;
.body { padding: 8px 16px 24px; }
.intro-card { text-align: center; padding: 20px 12px 8px; }
.intro-title {
font-family: var(--font-display);
font-size: 24px;
font-weight: 700;
margin-top: 8px;
letter-spacing: 0.08em;
}
.wheel-settings label{display:flex;align-items:center;gap:4px;cursor:pointer}
.house-row{font-size:13px;color:#555;margin:4px 0}
.aspect-line{font-size:13px;color:#555;margin:4px 0;line-height:1.5}
.aspect-full{margin:8px 0 0;padding-left:18px;font-size:12px;color:#666}
.transit{margin:8px 0;font-size:13px}
.transit p{margin:2px 0 0;color:#555}
.intro-sub { font-size: 13px; color: #888; margin-top: 6px; line-height: 1.5; }
.form-card { margin-top: 12px; }
.cta {
margin-top: 14px;
width: 100%;
padding: 13px;
border: none;
border-radius: 22px;
color: #fff;
font-size: 15px;
font-weight: 600;
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
box-shadow: 0 6px 16px rgba(229, 77, 66, 0.28);
}
.cta.soft {
background: #fff;
color: var(--color-primary);
border: 1.5px solid #f5c4bc;
box-shadow: none;
}
.cta:disabled { opacity: 0.45; }
.birth-row { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
.bd-time {
width: 108px;
padding: 10px 6px;
border: 1.5px solid #eee;
border-radius: 12px;
font-size: 14px;
text-align: center;
background: #fdfaf8;
}
.mode-tabs {
display: flex;
gap: 4px;
margin-bottom: 14px;
background: #fafafa;
border-radius: var(--radius-pill);
padding: 4px;
}
.mode-tab {
flex: 1;
border: none;
background: transparent;
padding: 9px 4px;
border-radius: var(--radius-pill);
font-size: 13px;
color: #888;
font-weight: 600;
}
.mode-tab.on {
background: #fff;
color: var(--color-primary);
box-shadow: 0 2px 8px rgba(229, 77, 66, 0.1);
}
.hl {
font-family: var(--font-display);
font-size: 20px;
font-weight: 700;
line-height: 1.35;
color: #222;
}
.lead { margin-top: 8px; font-size: 13px; color: #777; line-height: 1.55; }
.spotlight {
margin-top: 14px;
padding: 14px;
border-radius: var(--radius-xl);
background: linear-gradient(145deg, #fff0ec, #ffe4dc);
box-shadow: var(--shadow-card);
}
.sp-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.sp-head strong { font-size: 14px; color: #222; }
.sp-ai { font-size: 12px; color: var(--color-primary); font-weight: 600; }
.sp-text { font-size: 12px; color: #666; line-height: 1.5; }
.tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
.tag {
font-size: 11px;
padding: 4px 10px;
border-radius: var(--radius-pill);
background: rgba(255, 255, 255, 0.8);
color: #8a4a3a;
font-weight: 600;
}
.planet-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin-top: 14px;
}
.cell {
min-height: 88px;
border: 1px solid #f0ebe8;
background: #fafafa;
border-radius: 16px;
padding: 12px 8px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
text-align: center;
}
.cell.on {
border-color: #f5c4bc;
background: #fff7f4;
box-shadow: var(--shadow-card);
}
.cell.center {
background: linear-gradient(160deg, #eef4ff, #ffe8e0);
border-color: transparent;
}
.nick { font-size: 14px; font-weight: 700; color: #222; line-height: 1.2; }
.role { font-size: 11px; color: var(--color-primary); font-weight: 600; }
.detail-card {
margin-top: 12px;
padding: 14px;
background: #fff;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
}
.detail-card h3 { font-size: 15px; margin-bottom: 6px; color: #222; }
.detail-card p { font-size: 13px; color: #555; line-height: 1.6; }
.ask-bar {
margin-top: 14px;
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 16px;
border-radius: var(--radius-pill);
background: #fff;
border: 1.5px solid #f0ebe8;
font-size: 13px;
font-weight: 600;
color: #555;
box-shadow: var(--shadow-card);
}
.fortune-tabs { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; }
.ftab {
flex: 1;
min-width: 52px;
border: 1px solid #eee;
background: #fafafa;
border-radius: 12px;
padding: 10px 4px;
font-size: 12px;
color: #666;
}
.ftab.on {
border-color: var(--color-primary);
background: #fff5f4;
color: #222;
font-weight: 600;
}
.fortune-card { padding: 16px; border-radius: var(--radius-xl); background: #fafafa; }
.daily-title { font-weight: 700; color: #222; margin-bottom: 6px; }
.focus { margin: 0 0 8px; color: #666; }
.dims { display: flex; flex-wrap: wrap; gap: 10px; margin: 10px 0; font-size: 12px; color: #555; }
.planet-list { list-style: none; margin: 0; padding: 0; }
.planet-row {
display: flex;
flex-direction: column;
gap: 4px;
padding: 14px;
margin-bottom: 8px;
background: #fafafa;
border-radius: 14px;
}
.planet-row strong { font-size: 14px; color: #222; }
.planet-row span { font-size: 12px; color: #888; }
.lock-card {
margin-top: 12px;
padding: 16px;
background: linear-gradient(145deg, #fff8e8, #ffeec8);
border-radius: var(--radius-xl);
}
.bottom-links {
display: flex;
gap: 16px;
margin: 16px 0 8px;
font-size: 13px;
font-weight: 600;
color: var(--color-primary);
}
.wheel-settings {
display: flex;
gap: 16px;
font-size: 12px;
color: #666;
margin: 4px 0 8px;
justify-content: center;
}
.house-row, .aspect-line { font-size: 13px; color: #555; margin: 4px 0; }
.yxg-label { display: block; margin: 12px 0 6px; font-size: 12px; color: #999; }
.yxg-label:first-child { margin-top: 0; }
</style>
+91 -23
View File
@@ -1,30 +1,51 @@
<template>
<main class="yxg-page">
<div class="yxg-hero">
<BackButton />
<PageTitle feature="star" title="合盘邀请" sub="填写生日,与好友生成合盘" />
</div>
<div class="yxg-sheet sheet-pad">
<PageShell>
<template #hero>
<div class="top-row">
<BackButton />
<span class="who-static">合盘邀请</span>
<span class="spacer" />
</div>
</template>
<div class="body">
<p v-if="loading" class="yxg-hint">加载邀请</p>
<template v-else-if="meta">
<p class="yxg-lead">{{ meta.host_name || '好友' }} 邀请你合盘</p>
<p v-if="meta.already_accepted" class="yxg-meta">该邀请已使用可直接去合盘页再测</p>
<div v-else class="yxg-card">
<div class="hero-block">
<HomeToolIcon name="synastry" :size="64" />
<h1 class="hero-title">{{ meta.host_name || '好友' }} 邀请你合盘</h1>
<p class="hero-sub">填写生日一起看见彼此的星盘互动与相处参考</p>
</div>
<div v-if="meta.already_accepted" class="notice-card">
<p>该邀请已使用可直接去合盘页再测</p>
<router-link class="cta" to="/synastry">回合盘页</router-link>
</div>
<div v-else class="yxg-card form-card">
<label class="yxg-label">你的称呼</label>
<input v-model="name" class="name-in" placeholder="我" />
<input v-model="name" class="yxg-input" placeholder="我" />
<label class="yxg-label">生日</label>
<BirthDateInputs v-model:year="ty" v-model:month="tm" v-model:day="td" />
<button class="yxg-btn yxg-btn-block mt" type="button" :disabled="submitting" @click="accept">
<button class="cta" type="button" :disabled="submitting" @click="accept">
{{ submitting ? '生成中…' : '接受并合盘' }}
</button>
</div>
<p v-if="error" class="yxg-err">{{ error }}</p>
<router-link class="yxg-link-plain" to="/synastry">回合盘页 </router-link>
<router-link v-if="!meta.already_accepted" class="ghost-link" to="/synastry">先看看合盘介绍 </router-link>
</template>
<p v-else class="yxg-err">{{ error || '邀请无效或已过期' }}</p>
<div v-else class="empty-hero">
<HomeToolIcon name="synastry" :size="56" />
<p class="hero-sub">{{ error || '邀请无效或已过期' }}</p>
<router-link class="cta" to="/synastry">去合盘页</router-link>
</div>
<p class="yxg-disc">合盘为自我探索参考不是占卜或算命</p>
</div>
</main>
</PageShell>
</template>
<script setup lang="ts">
@@ -34,7 +55,8 @@ import { validateBirth } from '@yuxingu/utils'
import { api } from '../api/client'
import BackButton from '../components/BackButton.vue'
import BirthDateInputs from '../components/BirthDateInputs.vue'
import PageTitle from '../components/PageTitle.vue'
import HomeToolIcon from '../components/HomeToolIcon.vue'
import PageShell from '../components/PageShell.vue'
import { AnalyticsEvent, track } from '../lib/analytics'
const route = useRoute()
@@ -46,10 +68,7 @@ const name = ref('我')
const ty = ref('')
const tm = ref('')
const td = ref('')
const meta = ref<{
host_name: string
already_accepted: boolean
} | null>(null)
const meta = ref<{ host_name: string; already_accepted: boolean } | null>(null)
onMounted(async () => {
const token = String(route.params.token || '')
@@ -90,9 +109,58 @@ async function accept() {
</script>
<style scoped>
.yxg-label{display:block;margin:12px 0 6px;font-size:13px;color:#666}
.name-in{
width:100%;padding:10px;border:1.5px solid #eee;border-radius:10px;font-size:14px;
.top-row {
display: flex;
align-items: center;
gap: 10px;
padding-bottom: 4px;
}
.mt{margin-top:14px}
.who-static { flex: 1; font-size: 16px; font-weight: 700; text-align: center; }
.spacer { width: 36px; }
.body { padding: 8px 16px 24px; }
.hero-block, .empty-hero { text-align: center; padding: 16px 8px; }
.hero-title {
font-family: var(--font-display);
font-size: 22px;
font-weight: 700;
margin-top: 10px;
letter-spacing: 0.04em;
color: #222;
line-height: 1.35;
}
.hero-sub { font-size: 13px; color: #888; margin-top: 8px; line-height: 1.5; }
.form-card { margin-top: 8px; }
.notice-card {
margin-top: 12px;
padding: 20px 16px;
border-radius: var(--radius-xl);
background: #fafafa;
text-align: center;
}
.notice-card p { font-size: 14px; color: #666; margin-bottom: 14px; }
.cta {
display: block;
width: 100%;
padding: 13px;
border: none;
border-radius: 22px;
text-align: center;
color: #fff;
font-size: 15px;
font-weight: 600;
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
box-shadow: 0 6px 16px rgba(229, 77, 66, 0.28);
}
.cta:disabled { opacity: 0.45; }
.yxg-label { display: block; margin: 12px 0 6px; font-size: 12px; color: #999; }
.yxg-label:first-child { margin-top: 0; }
.ghost-link {
display: block;
margin-top: 14px;
text-align: center;
font-size: 13px;
color: var(--color-primary);
font-weight: 600;
}
.empty-hero .cta { max-width: 280px; margin: 16px auto 0; }
</style>
+1 -1
View File
@@ -19,7 +19,7 @@ const { api } = vi.hoisted(() => ({
vi.mock('../api/client', () => ({ api }))
vi.mock('vue-router', () => ({
useRoute: () => ({ query: {} }),
useRouter: () => ({ back: vi.fn() }),
useRouter: () => ({ back: vi.fn(), replace: vi.fn() }),
}))
describe('SynastryPage', () => {
+658 -141
View File
@@ -1,13 +1,105 @@
<template>
<main class="yxg-page">
<div class="yxg-hero">
<PageShell>
<template #hero>
<BackButton />
<PageTitle feature="star" title="合盘" sub="比较 · 组合 · 时空 · 马克斯 · 配对" />
</div>
<div class="yxg-sheet sheet-pad">
<div class="head">
<HomeToolIcon name="synastry" :size="48" />
<div>
<h1 class="title">{{ report ? '合盘结果' : '缘分合盘' }}</h1>
<p class="sub">{{ report ? headline : '比较 · 组合 · 时空 · 马克斯 · 配对' }}</p>
</div>
</div>
</template>
<div class="body">
<!-- Landing -->
<template v-if="!report">
<div class="yxg-card">
<p class="yxg-lead">选择双方档案一次生成五主盘与次限推运</p>
<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="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="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="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="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 v-model:year="ty" v-model:month="tm" v-model:day="td" />
<input v-model="tName" class="name-in" placeholder="称呼(如:TA" />
<button type="button" class="yxg-btn yxg-btn-ghost" :disabled="adding" @click="addTemp">
{{ adding ? '添加中…' : '添加档案' }}
</button>
</div>
<button
class="yxg-btn yxg-btn-block cta-main"
type="button"
:disabled="loading || !profileA || !profileB"
@click="generate"
>
{{ loading ? '合盘中…' : '立即合盘' }}
</button>
<button type="button" class="toggle-classic" @click="showClassicSelects = !showClassicSelects">
{{ showClassicSelects ? '收起档案选择' : '直接选择档案' }}
</button>
<div v-if="showClassicSelects" class="yxg-card soft classic-panel">
<label class="yxg-label">档案 A</label>
<select v-model="profileA" class="sel">
<option disabled value="">请选择</option>
@@ -27,113 +119,128 @@
</select>
<label class="yxg-label">推运日期</label>
<input v-model="asOf" type="date" class="sel" />
<div class="social-row">
<button type="button" class="yxg-btn yxg-btn-ghost" :disabled="!profileA || inviting" @click="createInvite">
{{ inviting ? '生成中…' : '邀请好友合盘' }}
</button>
<button type="button" class="yxg-btn yxg-btn-ghost" :disabled="nearbyLoading" @click="loadNearby">
{{ nearbyLoading ? '定位中…' : '附近的人' }}
</button>
</div>
<p v-if="invitePath" class="yxg-meta">邀请链接{{ invitePath }}可复制分享</p>
<p v-if="nearbyHint" class="yxg-meta">{{ nearbyHint }}</p>
<p v-if="profiles.length < 2 && nearby.length === 0" class="yxg-meta">
需要至少两个档案可先去
<router-link class="yxg-link" to="/profile">档案页</router-link>
添加 TA或在下方快速创建临时档案
</p>
<div class="yxg-card soft mini">
<p class="card-title">快速添加 TA</p>
<BirthDateInputs v-model:year="ty" v-model:month="tm" v-model:day="td" />
<input v-model="tName" class="name-in" placeholder="称呼(如:TA" />
<button type="button" class="yxg-btn yxg-btn-ghost" :disabled="adding" @click="addTemp">
{{ adding ? '添加中…' : '添加档案' }}
</button>
</div>
<button
class="yxg-btn yxg-btn-block mt"
type="button"
:disabled="loading || !profileA || !profileB"
@click="generate"
>
{{ loading ? '合盘中…' : '开始合盘' }}
</button>
<p v-if="error" class="yxg-err">{{ error }}</p>
</div>
<div class="social-row" :class="{ pulse: inviteHighlight }">
<button type="button" class="yxg-btn yxg-btn-ghost" :disabled="!profileA || inviting" @click="createInvite">
{{ inviting ? '生成中…' : '邀请好友合盘' }}
</button>
<button type="button" class="yxg-btn yxg-btn-ghost" :disabled="nearbyLoading" @click="loadNearby">
{{ nearbyLoading ? '定位中…' : '附近的人' }}
</button>
</div>
<p v-if="invitePath" class="yxg-meta 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>
<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>
<p v-if="error" class="yxg-err">{{ error }}</p>
</template>
<!-- Loading -->
<p v-else-if="loading" class="yxg-hint">合盘计算中</p>
<!-- Results -->
<template v-else-if="report">
<p class="yxg-lead">{{ headline }}</p>
<p class="yxg-meta">{{ oneLiner }}</p>
<div class="indices">
<div class="idx"><em>恋爱</em><strong>{{ love }}</strong></div>
<div class="idx"><em>友情</em><strong>{{ friend }}</strong></div>
<div class="idx"><em>婚姻</em><strong>{{ marriage }}</strong></div>
</div>
<p class="yxg-meta">{{ loveNote }} · 推运 {{ asOfLabel }}</p>
<div class="tabs" role="tablist">
<button
v-for="t in mainTabs"
:key="t.key"
type="button"
class="tab"
:class="{ on: mainTab === t.key }"
@click="mainTab = t.key"
>
{{ t.label }}
</button>
<div 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 v-if="needsSubTab" class="subtabs">
<button type="button" class="stab" :class="{ on: subTab === 'natal' }" @click="subTab = 'natal'">本盘</button>
<button type="button" class="stab" :class="{ on: subTab === 'prog' }" @click="subTab = 'prog'">次限</button>
<template v-if="mainTab === 'marks'">
<button type="button" class="stab" :class="{ on: marksWho === 'me' }" @click="marksWho = 'me'"></button>
<button type="button" class="stab" :class="{ on: marksWho === 'other' }" @click="marksWho = 'other'">TA</button>
<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="mainTab = t.key"
>
{{ t.label }}
</button>
</div>
<div v-if="needsSubTab" class="subtabs">
<button type="button" class="stab" :class="{ on: subTab === 'natal' }" @click="subTab = 'natal'">本盘</button>
<button type="button" class="stab" :class="{ on: subTab === 'prog' }" @click="subTab = 'prog'">次限</button>
<template v-if="mainTab === 'marks'">
<button type="button" class="stab" :class="{ on: marksWho === 'me' }" @click="marksWho = 'me'"></button>
<button type="button" class="stab" :class="{ on: marksWho === 'other' }" @click="marksWho = 'other'">TA</button>
</template>
</div>
<template v-if="mainTab === 'compare'">
<p class="tip">{{ chartTip('compare') }}</p>
<h3 class="sec">比较盘 · </h3>
<NatalWheel v-if="planetsA.length" :planets="planetsA" :aspects="[]" :asc-lon="ascA" />
<h3 class="sec">比较盘 · TA</h3>
<NatalWheel v-if="planetsB.length" :planets="planetsB" :aspects="[]" :asc-lon="ascB" />
<div v-if="aspectPreview.length" class="yxg-card soft 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>
<!-- 比较盘 -->
<template v-if="mainTab === 'compare'">
<p class="tip">{{ chartTip('compare') }}</p>
<h3 class="sec">比较盘 · </h3>
<NatalWheel v-if="planetsA.length" :planets="planetsA" :aspects="[]" :asc-lon="ascA" />
<h3 class="sec">比较盘 · TA</h3>
<NatalWheel v-if="planetsB.length" :planets="planetsB" :aspects="[]" :asc-lon="ascB" />
<div v-if="aspectPreview.length" class="yxg-card soft">
<h2 class="card-title">跨盘相位速览</h2>
<p v-for="(a, i) in aspectPreview" :key="i" class="aline">{{ a.label }}</p>
</div>
</template>
<!-- 配对盘 -->
<template v-else-if="mainTab === 'overlay'">
<p class="tip">{{ overlayTip }}</p>
<div class="yxg-card soft">
<p v-for="(e, i) in overlayEntries" :key="i" class="aline">
{{ e.planet }}{{ e.sign }} 我方 {{ e.house }} · {{ e.house_tip }}
</p>
</div>
</template>
<!-- 单盘型 -->
<template v-else>
<p class="tip">{{ activeChartTip }}</p>
<NatalWheel
v-if="activePlanets.length"
:planets="activePlanets"
:aspects="[]"
:asc-lon="activeAsc"
/>
<div v-if="activeAspectPreview.length" class="yxg-card soft">
<h2 class="card-title">相位速览</h2>
<p v-for="(a, i) in activeAspectPreview" :key="i" class="aline">{{ a.label }}</p>
</div>
</template>
<div v-if="report.has_deep_access && detail" class="yxg-card">
<h2 class="card-title">相处深析</h2>
<div v-for="(s, i) in sections" :key="i" class="sec-block">
@@ -157,27 +264,33 @@
</div>
<button type="button" class="yxg-btn yxg-btn-ghost share-btn" @click="shareOpen = true">生成分享卡</button>
</template>
<p class="yxg-disc">合盘指数为自我探索参考不是占卜或算命</p>
</div>
<ShareSheet :open="shareOpen" :payload="sharePayload" @close="shareOpen = false" />
</main>
</PageShell>
</template>
<script setup lang="ts">
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 BackButton from '../components/BackButton.vue'
import BirthDateInputs from '../components/BirthDateInputs.vue'
import HomeToolIcon from '../components/HomeToolIcon.vue'
import NatalWheel, { type WheelPlanet } from '../components/NatalWheel.vue'
import PageTitle from '../components/PageTitle.vue'
import PageShell from '../components/PageShell.vue'
import ShareSheet from '../components/ShareSheet.vue'
import { AnalyticsEvent, track } from '../lib/analytics'
import type { RelationSharePayload } from '../lib/shareLink'
type MainTab = 'compare' | 'composite' | 'davison' | 'marks' | 'overlay'
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const adding = ref(false)
const paying = ref(false)
@@ -186,6 +299,7 @@ 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('')
@@ -200,6 +314,11 @@ 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 relationTypes = ['伴侣', '朋友', '家人', '其他']
const mainTabs: { key: MainTab; label: string }[] = [
{ key: 'compare', label: '比较' },
@@ -211,6 +330,22 @@ const mainTabs: { key: MainTab; label: string }[] = [
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>)
@@ -365,6 +500,7 @@ async function addTemp() {
})
await loadProfiles()
profileB.value = p.id
showQuickAdd.value = false
} catch (e) {
error.value = e instanceof Error ? e.message : '添加失败'
} finally {
@@ -416,7 +552,6 @@ async function loadNearby() {
})
const lat = pos.coords.latitude
const lng = pos.coords.longitude
// Only save coordinates; visibility stays off unless user explicitly enables it.
const self = profiles.value.find((p) => p.relation === 'self')
if (self) {
await api.updateProfile(self.id, { geo_lat: lat, geo_lng: lng })
@@ -454,45 +589,427 @@ function reset() {
report.value = null
}
onMounted(loadProfiles)
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: {} })
}
})
</script>
<style scoped>
.yxg-card{margin:8px 0 12px}
.yxg-label{display:block;margin:12px 0 6px;font-size:13px;color:#666}
.sel{
width:100%;padding:12px;border:1.5px solid #eee;border-radius:12px;background:#fdfaf8;font-size:14px;
.head {
display: flex;
align-items: center;
gap: 12px;
margin-top: 8px;
padding-bottom: 8px;
}
.mini{margin-top:12px}
.name-in{
width:100%;padding:10px;border:1.5px solid #eee;border-radius:10px;margin:8px 0;font-size:14px;
.title {
font-family: var(--font-display);
font-size: 22px;
font-weight: 700;
letter-spacing: 0.06em;
}
.mt{margin-top:14px}
.social-row{display:flex;gap:8px;margin-top:12px;flex-wrap:wrap}
.social-row .yxg-btn{flex:1;min-width:120px}
.indices{display:flex;gap:10px;margin:14px 0}
.idx{
flex:1;text-align:center;background:#fff;border-radius:14px;padding:12px 8px;
box-shadow:0 4px 16px rgba(0,0,0,.05);
.sub {
font-size: 12px;
color: rgba(0, 0, 0, 0.38);
margin-top: 2px;
}
.idx em{display:block;font-style:normal;font-size:11px;color:#999;margin-bottom:4px}
.idx strong{font-size:22px;color:var(--yxg-pri)}
.tabs{display:flex;gap:6px;flex-wrap:wrap;margin:12px 0 8px}
.tab{
border:1px solid #eee;background:#fff;border-radius:999px;padding:6px 12px;font-size:13px;cursor:pointer;color:#666;
.body {
padding: 8px 16px 0;
}
.tab.on{background:var(--yxg-pri);color:#fff;border-color:transparent}
.subtabs{display:flex;gap:6px;margin-bottom:8px;flex-wrap:wrap}
.stab{
border:none;background:#f3eeea;border-radius:8px;padding:5px 10px;font-size:12px;cursor:pointer;color:#555;
.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; }
.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;
}
.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;
}
.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>
+1958
View File
File diff suppressed because it is too large Load Diff
+247
View File
@@ -0,0 +1,247 @@
#!/usr/bin/env python3
"""Capture CeCe homepage top-right 「+」→ add family/friend archive flow."""
from __future__ import annotations
import json
import os
import re
import subprocess
import time
from pathlib import Path
ROOT = Path("/Users/jack/Project/digital-psychology")
TOOLS = ROOT / "tools" / "android"
ANDROID_HOME = Path("/opt/homebrew/share/android-commandlinetools")
PKG = "com.xxwolo.cc5"
ACT = f"{PKG}/com.cece.app.MainActivity"
STAMP = time.strftime("%Y%m%d-%H%M%S")
OUT = ROOT / ".tmp" / "cece-validation" / time.strftime("%Y%m%d") / f"home-plus-{STAMP}"
NOTES = OUT / "NOTES.md"
os.environ["ANDROID_HOME"] = str(ANDROID_HOME)
os.environ["ANDROID_SDK_ROOT"] = str(ANDROID_HOME)
os.environ["PATH"] = (
f"{ANDROID_HOME}/emulator:{ANDROID_HOME}/platform-tools:"
f"/opt/homebrew/bin:/usr/bin:/bin:" + os.environ.get("PATH", "")
)
def sh(args, timeout=120):
return subprocess.run(args, capture_output=True, text=True, timeout=timeout)
def adb(*args, timeout=120):
return sh(["adb", *args], timeout=timeout)
def tool(*args, timeout=120):
r = sh([str(TOOLS), *args, "--json"], timeout=timeout)
try:
return json.loads(r.stdout or "{}")
except Exception:
return {"error": r.stderr, "raw": (r.stdout or "")[:500]}
def label(e):
return (e.get("text") or e.get("contentDesc") or e.get("contentDescription") or "").strip()
def elements(data):
return data.get("elements") or []
def dump():
return tool("ui", "dump")
def center(e):
c = e.get("center")
if isinstance(c, dict):
return int(c["x"]), int(c["y"])
b = e.get("bounds") or {}
if isinstance(b, dict) and "width" in b:
return int(b["x"] + b["width"] / 2), int(b["y"] + b["height"] / 2)
return None
def area(e):
b = e.get("bounds") or {}
return int(b.get("width") or 0) * int(b.get("height") or 0)
def tap_xy(x, y, wait=1.6):
adb("shell", "input", "tap", str(x), str(y))
time.sleep(wait)
def back(n=1):
for _ in range(n):
adb("shell", "input", "keyevent", "4")
time.sleep(1.0)
def shot(rel: str, note: str = ""):
path = OUT / rel
path.parent.mkdir(parents=True, exist_ok=True)
tool("screenshot", "--out", str(path))
labs = sorted({label(e).split("\n", 1)[0] for e in elements(dump()) if label(e)})
(OUT / rel.replace(".png", ".labels.txt")).write_text("\n".join(labs), encoding="utf-8")
with NOTES.open("a", encoding="utf-8") as f:
f.write(f"- `{rel}` {note}\n")
f.write(f" labels: {', '.join(labs[:40])}\n")
print("SHOT", rel, note, flush=True)
return path
def save_dump(rel):
path = OUT / rel
path.parent.mkdir(parents=True, exist_ok=True)
data = dump()
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
return data
def first_lines(data=None):
data = data or dump()
return [label(e).split("\n", 1)[0] for e in elements(data) if label(e)]
def go_home():
adb("shell", "am", "start", "-n", ACT)
time.sleep(2.0)
# tap 首页 tab
tap_xy(108, 2255, wait=1.2)
# scroll top
for _ in range(2):
adb("shell", "input", "swipe", "540", "500", "540", "1600", "280")
time.sleep(0.5)
def find_plus_candidates(data):
"""Top-right + / 添加 / contentDesc."""
hits = []
for e in elements(data):
lab = label(e)
first = lab.split("\n", 1)[0]
b = e.get("bounds") or {}
y = int(b.get("y") or 0)
x = int(b.get("x") or 0)
w = int(b.get("width") or 0)
h = int(b.get("height") or 0)
if y > 280:
continue
if x < 700:
continue
# plus-like
if first in ("+", "", "添加", "新建", "添加档案") or "添加" in lab or lab in ("+", ""):
hits.append(e)
continue
cd = (e.get("contentDesc") or e.get("contentDescription") or "")
if any(k in cd for k in ("添加", "新建", "plus", "Plus", "+")):
hits.append(e)
continue
# small square top-right icon without text
if e.get("clickable") and not lab and w < 120 and h < 120 and x > 850:
hits.append(e)
hits.sort(key=lambda e: (area(e), -(center(e) or (0, 0))[0]))
return hits
def tap_contains(needle, ymax=9999):
data = dump()
cands = []
for e in elements(data):
lab = label(e)
if needle not in lab:
continue
b = e.get("bounds") or {}
if int(b.get("y") or 0) > ymax:
continue
if not e.get("clickable") and area(e) > 200 * 200:
continue
cands.append(e)
cands.sort(key=area)
if not cands:
return False
xy = center(cands[0])
if not xy:
return False
print(f" TAP contains {needle!r} @ {xy}", flush=True)
tap_xy(*xy)
return True
def explore_add_flow():
OUT.mkdir(parents=True, exist_ok=True)
NOTES.write_text(f"# home + archive capture {STAMP}\n\n", encoding="utf-8")
if not re.search(r"emulator-\d+\s+device", adb("devices").stdout or ""):
raise SystemExit("no emulator")
go_home()
shot("00-home.png", "home before +")
data = save_dump("00-home.json")
plus = find_plus_candidates(data)
print("plus candidates:", len(plus), flush=True)
for e in plus[:8]:
print(" ", label(e) or "(empty)", center(e), e.get("bounds"), flush=True)
opened = False
if plus:
xy = center(plus[0])
if xy:
print(f"tap plus candidate {xy}", flush=True)
tap_xy(*xy, wait=2.0)
opened = True
if not opened:
# fallback top-right +
for xy in ((1008, 168), (990, 155), (1020, 190), (960, 170)):
print(f"fallback tap {xy}", flush=True)
tap_xy(*xy, wait=1.8)
fls = first_lines()
if any(
k in " ".join(fls)
for k in ("添加档案", "新建档案", "家人", "朋友", "伴侣", "关系", "昵称", "生日", "档案")
):
opened = True
break
back(1)
time.sleep(0.6)
shot("01-after-plus.png", "after tapping +")
data = save_dump("01-after-plus.json")
fls = first_lines(data)
print("after-plus labels:", fls[:30], flush=True)
# Dive relation type chips if present
for lab in ("家人", "朋友", "伴侣", "同事", "其他", "添加档案", "新建档案", "创建档案", "立即添加"):
if lab in fls or any(lab in x for x in fls):
if tap_contains(lab, ymax=1800):
time.sleep(1.2)
shot(f"02-tap-{lab}.png", f"tap {lab}")
save_dump(f"02-tap-{lab}.json")
# Try fill-ish fields visibility
shot("03-form.png", "add archive form state")
save_dump("03-form.json")
# Look for 关系 / 生日 / 保存
for lab in ("保存", "完成", "确定", "下一步", "生成"):
if tap_contains(lab, ymax=2200):
time.sleep(1.2)
shot(f"04-{lab}.png", f"tap {lab}")
break
# Summary of labels for NOTES
final = first_lines()
with NOTES.open("a", encoding="utf-8") as f:
f.write("\n## Summary labels after +\n")
f.write("\n".join(f"- {x}" for x in final[:80]))
f.write("\n")
print("DONE", OUT, flush=True)
if __name__ == "__main__":
explore_add_flow()
+43
View File
@@ -0,0 +1,43 @@
#!/bin/bash
# Watchdog: keep deep crawl alive; restart on death.
set -u
ROOT="/Users/jack/Project/digital-psychology"
LOG="/tmp/cece-capture-deep-full.log"
PIDFILE="/tmp/cece-capture-deep.pid"
MAX_RESTARTS=30
cd "$ROOT"
restart=0
while [ "$restart" -lt "$MAX_RESTARTS" ]; do
if [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then
sleep 20
continue
fi
restart=$((restart + 1))
echo "[$(date '+%F %T')] watchdog start #$restart" >> "$LOG"
adb shell am start -n com.xxwolo.cc5/com.cece.app.MainActivity >/dev/null 2>&1 || true
sleep 2
nohup python3 -u tools/cece_capture_deep.py >> "$LOG" 2>&1 &
echo $! > "$PIDFILE"
echo "[$(date '+%F %T')] pid=$(cat "$PIDFILE")" >> "$LOG"
# wait until death
while kill -0 "$(cat "$PIDFILE")" 2>/dev/null; do
sleep 15
# stall detection: no log growth 3 min
if [ -f "$LOG" ]; then
sz1=$(wc -c < "$LOG")
sleep 90
sz2=$(wc -c < "$LOG")
if [ "$sz1" -eq "$sz2" ] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then
echo "[$(date '+%F %T')] stall → kill $(cat "$PIDFILE")" >> "$LOG"
kill "$(cat "$PIDFILE")" 2>/dev/null || true
sleep 2
kill -9 "$(cat "$PIDFILE")" 2>/dev/null || true
break
fi
fi
done
echo "[$(date '+%F %T')] crawl exited, restart soon" >> "$LOG"
sleep 5
done
echo "[$(date '+%F %T')] watchdog gave up after $MAX_RESTARTS" >> "$LOG"
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env python3
"""Daemonize + run cece_capture_deep outside the parent process group.
Cursor/agent shells often SIGKILL the whole process group when a command ends;
nohup alone is not enough. Double-fork so the crawl survives.
"""
from __future__ import annotations
import atexit
import faulthandler
import os
import runpy
import sys
import time
from pathlib import Path
ROOT = Path("/Users/jack/Project/digital-psychology")
LOG = Path("/tmp/cece-capture-deep-full.log")
FAULT = Path("/tmp/cece-fault.log")
PIDFILE = Path("/tmp/cece-capture-deep.pid")
def log(msg: str) -> None:
line = f"[{time.strftime('%F %T')}] {msg}\n"
with LOG.open("a", encoding="utf-8") as f:
f.write(line)
def daemonize() -> None:
# first fork
if os.fork() > 0:
sys.exit(0)
os.setsid()
# second fork
if os.fork() > 0:
sys.exit(0)
# detach stdio
sys.stdin.close()
sys.stdout.flush()
sys.stderr.flush()
out = open(LOG, "a", encoding="utf-8", buffering=1)
sys.stdout = out # type: ignore
sys.stderr = out # type: ignore
def main() -> None:
os.chdir(ROOT)
daemonize()
PIDFILE.write_text(str(os.getpid()), encoding="utf-8")
ff = FAULT.open("a", encoding="utf-8")
faulthandler.enable(file=ff, all_threads=True)
log(f"daemon start pid={os.getpid()} argv={sys.argv[1:]}")
def _atexit() -> None:
log(f"daemon atexit pid={os.getpid()}")
atexit.register(_atexit)
# remaining args after this script name
args = sys.argv[1:]
sys.argv = ["cece_capture_deep.py", *args]
try:
runpy.run_path(str(ROOT / "tools" / "cece_capture_deep.py"), run_name="__main__")
log("daemon finished ok")
except BaseException as ex:
log(f"daemon error: {type(ex).__name__}: {ex}")
import traceback
traceback.print_exc()
raise SystemExit(1)
if __name__ == "__main__":
main()
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env python3
"""Stable launcher for cece_capture_deep — logs exit code, avoids silent death."""
from __future__ import annotations
import atexit
import faulthandler
import os
import runpy
import sys
import time
from pathlib import Path
LOG = Path("/tmp/cece-capture-deep-full.log")
FAULT = Path("/tmp/cece-fault.log")
ROOT = Path("/Users/jack/Project/digital-psychology")
def _log(msg: str) -> None:
line = f"[{time.strftime('%F %T')}] {msg}\n"
with LOG.open("a", encoding="utf-8") as f:
f.write(line)
sys.stdout.write(line)
sys.stdout.flush()
def main() -> int:
os.chdir(ROOT)
FAULT.parent.mkdir(parents=True, exist_ok=True)
ff = FAULT.open("a", encoding="utf-8")
faulthandler.enable(file=ff, all_threads=True)
_log(f"launcher start pid={os.getpid()} argv={sys.argv[1:]}")
def _atexit() -> None:
_log(f"atexit pid={os.getpid()}")
atexit.register(_atexit)
sys.argv = ["cece_capture_deep.py", *sys.argv[1:]]
try:
runpy.run_path(str(ROOT / "tools" / "cece_capture_deep.py"), run_name="__main__")
_log("launcher normal return")
return 0
except BaseException as ex:
_log(f"launcher exception: {type(ex).__name__}: {ex}")
import traceback
with LOG.open("a", encoding="utf-8") as f:
traceback.print_exc(file=f)
traceback.print_exc()
return 1
if __name__ == "__main__":
raise SystemExit(main())