feat(ECR-012–016): 合规、题库、时辰刷新、头像、MBTI OEJTS 与埋点

落地输入合规、探索题库、报告日/时辰刷新、账号头像、OEJTS 量表,并补齐 H5 埋点与 Admin 漏斗;同步 ESS 工件、切至自建 Git、清理 GitHub Actions。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-13 01:27:58 +08:00
co-authored by Cursor
parent 13860bf1ef
commit 89756f65b4
227 changed files with 7405 additions and 1407 deletions
+2 -1
View File
@@ -100,7 +100,8 @@ test('star / rhythm / cards pages render with mocked API', async ({ page }) => {
await expect(page.getByText('E2E节律')).toBeVisible()
await page.goto('/psy/cards')
await expect(page.getByRole('heading', { name: 'Hi,我是愈心 AI' })).toBeVisible()
await expect(page.getByRole('heading', { name: '意象卡片' })).toBeVisible()
await expect(page.getByText('投射整理 · 自我反思')).toBeVisible()
await page.getByRole('button', { name: '关联生日' }).click()
await page.locator('input[type="number"]').nth(0).fill('1990')
await page.locator('input[type="number"]').nth(1).fill('5')
+1
View File
@@ -24,6 +24,7 @@
},
"devDependencies": {
"@playwright/test": "^1.50.1",
"@types/node": "^26.2.0",
"@vitejs/plugin-vue": "^5.2.1",
"@vue/test-utils": "^2.4.6",
"jsdom": "^26.0.0",
Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 824 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

+2 -24
View File
@@ -1,34 +1,12 @@
<template>
<div class="app-shell" :class="{ 'is-immersive': immersive }">
<AppHeader v-if="!immersive" />
<div class="app-shell">
<AppHeader />
<router-view />
<TabBar />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import AppHeader from './components/AppHeader.vue'
import TabBar from './components/TabBar.vue'
const route = useRoute()
/** 五 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-immersive {
background: transparent;
}
</style>
+58 -13
View File
@@ -1,29 +1,74 @@
<template>
<header class="app-header">
<div class="side left">
<BackButton v-if="showBack" />
</div>
<router-link to="/" class="logo-link" aria-label="愈心谷首页">
<BrandLogo size="header" />
<BrandLogo size="header" class="logo" />
</router-link>
<div class="side right" aria-hidden="true" />
</header>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import BackButton from './BackButton.vue'
import BrandLogo from './BrandLogo.vue'
const route = useRoute()
/** 首页 / 我的 为 Tab 根页不显示返回;探索可从首页进入,显示返回 */
const showBack = computed(() => {
const p = route.path
return !(p === '/' || p === '/mine')
})
</script>
<style scoped>
.app-header{
position:sticky;top:0;z-index:50;
display:flex;justify-content:center;align-items:center;
padding:10px 16px 6px;
background:linear-gradient(180deg, rgba(255,209,199,.92) 0%, rgba(255,209,199,.55) 70%, transparent 100%);
backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);
.app-header {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 50;
display: grid;
grid-template-columns: 44px 1fr 44px;
align-items: center;
gap: 4px;
padding: 10px 12px 4px;
background: transparent;
pointer-events: none;
}
.side {
display: flex;
align-items: center;
min-height: 36px;
pointer-events: auto;
}
.side.left {
justify-content: flex-start;
}
.side.right {
justify-content: flex-end;
pointer-events: none;
}
.logo-link {
display: flex;
justify-content: center;
justify-self: center;
line-height: 0;
background: transparent;
pointer-events: auto;
}
.logo {
background: transparent !important;
filter: drop-shadow(0 2px 10px rgba(180, 90, 70, 0.12));
}
.logo-link{display:flex;justify-content:center;line-height:0;background:transparent}
.logo-link :deep(.header),
.logo-link :deep(.brand-logo){
width:min(120px,42vw);
background:transparent !important;
mix-blend-mode:normal;
.logo-link :deep(.brand-logo) {
width: min(148px, 48vw);
max-height: 36px;
background: transparent !important;
}
</style>
+37 -17
View File
@@ -1,11 +1,14 @@
<template>
<button class="back" type="button" @click="go">
<span class="icon" aria-hidden="true">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M10 3L5 8l5 5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</span>
<span class="label">返回</span>
<button class="back" type="button" aria-label="返回" @click="go">
<svg class="chev" width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<path
d="M11.25 3.75 6 9l5.25 5.25"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
</template>
@@ -21,15 +24,32 @@ function go() {
</script>
<style scoped>
.back{
display:inline-flex;align-items:center;gap:4px;
border:none;background:rgba(255,255,255,.78);color:#333;
border-radius:var(--radius-sm);
padding:8px 12px 8px 10px;margin:0 0 12px;font-size:14px;font-weight:500;
box-shadow:0 1px 4px rgba(0,0,0,.05);cursor:pointer;
backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);
.back {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
margin: 0;
padding: 0;
border: 1px solid rgba(255, 255, 255, 0.95);
border-radius: 12px;
background: rgba(255, 255, 255, 0.78);
color: #3a3330;
box-shadow: 0 4px 14px rgba(180, 90, 70, 0.08);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
cursor: pointer;
transition:
transform var(--duration-fast, 0.15s) ease,
background var(--duration-fast, 0.15s) ease;
}
.back:active {
transform: scale(0.94);
background: rgba(255, 255, 255, 0.92);
}
.chev {
display: block;
margin-right: 1px;
}
.back:active{opacity:.75}
.icon{display:inline-flex;line-height:0;color:#333}
.label{color:#333}
</style>
+31 -13
View File
@@ -205,6 +205,35 @@
<circle cx="38" cy="26" r="3" fill="#5BA8F5" />
</g>
<!-- 九型 · 九宫圆 -->
<g v-else-if="name === 'nine'" :filter="f">
<circle cx="32" cy="32" r="23" :fill="g('purp')" />
<circle cx="32" cy="32" r="14" fill="none" stroke="#fff" stroke-width="2" opacity=".75" />
<text x="32" y="38" text-anchor="middle" font-size="20" font-weight="800" fill="#fff" font-family="system-ui,sans-serif">9</text>
</g>
<!-- 情绪智力 · 心波 -->
<g v-else-if="name === 'eq'" :filter="f">
<circle cx="32" cy="32" r="23" :fill="g('teal')" />
<path d="M18 34 Q24 20 32 32 Q40 44 46 30" fill="none" stroke="#fff" stroke-width="3.5" stroke-linecap="round" />
<circle cx="32" cy="32" r="4" fill="#FFE9A0" />
</g>
<!-- 压力 · 橙浪 -->
<g v-else-if="name === 'stress'" :filter="f">
<rect x="8" y="8" width="48" height="48" rx="14" :fill="g('coral')" />
<path d="M16 38 Q24 22 32 36 Q40 50 48 28" fill="none" stroke="#fff" stroke-width="3.5" stroke-linecap="round" />
<circle cx="48" cy="22" r="3" fill="#FFE9A0" />
</g>
<!-- 睡眠 · 月牙 -->
<g v-else-if="name === 'sleep'" :filter="f">
<circle cx="32" cy="32" r="23" :fill="g('blu')" />
<path d="M38 16 A14 14 0 1 0 38 48 A10 10 0 1 1 38 16 Z" fill="#fff" opacity=".92" />
<circle cx="44" cy="20" r="2" fill="#FFE9A0" />
<circle cx="48" cy="28" r="1.4" fill="#FFE9A0" opacity=".8" />
</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>
@@ -214,20 +243,9 @@
<script setup lang="ts">
import { computed, useId } from 'vue'
import type { HomeToolIconName } from '../lib/homeToolIconName'
export type HomeToolIconName =
| 'mbti'
| 'star'
| 'portrait'
| 'rhythm'
| 'synastry'
| 'astro'
| 'companion'
| 'ask'
| 'cards'
| 'reports'
| 'growth'
| 'relation'
export type { HomeToolIconName }
const props = withDefaults(
defineProps<{
+23 -17
View File
@@ -42,23 +42,29 @@
</template>
<script setup lang="ts">
defineProps<{
meStyle: string
otherStyle: string
fitLabel?: string
harmony?: number | null
love?: number | null
friend?: number | null
marriage?: number | null
starCompare?: { key: string; title: string; me: string; other: string; note?: string }[]
dimensions?: {
key: string
title: string
me_score?: number
other_score?: number
note?: string
}[]
}>()
withDefaults(
defineProps<{
meStyle: string
otherStyle: string
fitLabel?: string
harmony?: number | null
love?: number | null
friend?: number | null
marriage?: number | null
starCompare?: { key: string; title: string; me: string; other: string; note?: string }[]
dimensions?: {
key: string
title: string
me_score?: number
other_score?: number
note?: string
}[]
}>(),
{
starCompare: () => [],
dimensions: () => [],
},
)
</script>
<style scoped>
@@ -35,6 +35,7 @@ withDefaults(
.ps {
position: relative;
overflow-x: hidden;
padding-top: 48px;
padding-bottom: var(--spacing-xs);
min-height: 100%;
background:
@@ -46,6 +47,7 @@ withDefaults(
.ps-flat {
background: transparent;
min-height: 0;
padding-top: 48px;
}
.atm {
position: absolute;
+1 -1
View File
@@ -11,7 +11,7 @@
<div v-if="dimensions.length" class="card">
<h2><span class="hm" aria-hidden="true"></span>多维速览</h2>
<div v-for="d in dimensions" :key="d.key || d.title" class="dim">
<div v-for="d in dimensions" :key="String(d.key ?? d.title ?? '')" class="dim">
<div class="dim-head">
<strong>{{ d.title }}</strong>
<span v-if="d.score != null" class="score">{{ d.score }}</span>
+116 -41
View File
@@ -7,7 +7,19 @@
class="item"
:class="{ ask: item.ask, on: isActive(item.key) }"
>
<span class="ni" aria-hidden="true">{{ item.icon }}</span>
<span class="ico-wrap" aria-hidden="true">
<svg class="ico" viewBox="0 0 24 24" fill="none">
<path
v-for="(d, i) in item.paths"
:key="i"
:d="d"
stroke="currentColor"
:stroke-width="isActive(item.key) ? 2.15 : 1.85"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</span>
<span class="lb">{{ item.label }}</span>
</router-link>
</nav>
@@ -17,13 +29,55 @@
import { useRoute } from 'vue-router'
const route = useRoute()
const items = [
{ key: 'home', to: '/', 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: '我的' },
] as const
type TabItem = {
key: string
to: string
label: string
paths: readonly string[]
ask?: boolean
}
const items: TabItem[] = [
{
key: 'home',
to: '/',
label: '首页',
paths: ['M4 10.5 12 4l8 6.5V20a1 1 0 0 1-1 1h-5.2v-5.2h-3.6V21H5a1 1 0 0 1-1-1v-9.5Z'],
},
{
key: 'explore',
to: '/explore',
label: '探索',
paths: ['M12 3.5 14.2 9.8 20.5 12 14.2 14.2 12 20.5 9.8 14.2 3.5 12 9.8 9.8 12 3.5Z'],
},
{
key: 'ask',
to: '/ask',
label: '问答',
ask: true,
paths: [
'M8.2 18.2 5.5 20.5V8.2A2.7 2.7 0 0 1 8.2 5.5h7.6A2.7 2.7 0 0 1 18.5 8.2v5.4a2.7 2.7 0 0 1-2.7 2.7H9.6l-1.4 1.9Z',
],
},
{
key: 'companion',
to: '/companion',
label: '陪伴',
paths: [
'M12 20.2S4.8 15.4 4.8 10.2A3.7 3.7 0 0 1 12 8.1a3.7 3.7 0 0 1 7.2 2.1c0 5.2-7.2 10-7.2 10Z',
],
},
{
key: 'mine',
to: '/mine',
label: '我的',
paths: [
'M12 12.2a3.4 3.4 0 1 0 0-6.8 3.4 3.4 0 0 0 0 6.8Z',
'M5.2 19.2a6.8 6.8 0 0 1 13.6 0',
],
},
]
function activeKey(): string {
const p = route.path
@@ -33,7 +87,8 @@ function activeKey(): string {
p.startsWith('/mine') ||
p.startsWith('/profile') ||
p.startsWith('/membership') ||
p.startsWith('/reports')
p.startsWith('/reports') ||
p.startsWith('/login')
) {
return 'mine'
}
@@ -69,56 +124,76 @@ function isActive(key: string): boolean {
width: 100%;
max-width: var(--yxg-max-w);
background: rgba(255, 255, 255, 0.96);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
display: flex;
justify-content: space-around;
padding: 6px 0 calc(10px + env(safe-area-inset-bottom, 0));
border-top: 1px solid var(--color-border);
align-items: flex-end;
padding: 8px 4px calc(10px + env(safe-area-inset-bottom, 0));
border-top: 1px solid rgba(240, 230, 224, 0.9);
z-index: 100;
box-shadow: var(--shadow-nav);
box-shadow: 0 -6px 24px rgba(160, 90, 70, 0.06);
}
.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;
gap: 4px;
color: #9a8f8b;
padding: 2px 0 0;
letter-spacing: 0.04em;
transition:
color var(--duration-fast) ease,
transform var(--duration-fast) ease;
}
.item.on {
color: var(--color-primary);
font-weight: 600;
}
.ni {
font-size: 17px;
line-height: 1.2;
.item:active {
transform: scale(0.96);
}
.ico-wrap {
width: 30px;
height: 30px;
display: grid;
place-items: center;
}
.ico {
width: 28px;
height: 28px;
display: block;
}
.lb {
line-height: 1.2;
line-height: 1.15;
font-size: 11px;
font-weight: 550;
}
.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;
.item.on .lb {
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 .ico-wrap {
width: 54px;
height: 54px;
margin-top: -22px;
border-radius: 50%;
background: linear-gradient(145deg, #ff8f80, var(--color-primary));
color: #fff;
box-shadow:
0 8px 20px rgba(229, 77, 66, 0.36),
inset 0 1px 0 rgba(255, 255, 255, 0.35);
border: 3px solid #fff;
}
.item.ask.on .ni {
box-shadow: 0 8px 20px rgba(229, 77, 66, 0.45);
.item.ask .ico {
width: 27px;
height: 27px;
}
.item.ask.on .ico-wrap {
box-shadow:
0 10px 24px rgba(229, 77, 66, 0.48),
inset 0 1px 0 rgba(255, 255, 255, 0.42);
}
.item.ask .lb {
margin-top: 2px;
}
</style>
@@ -0,0 +1,172 @@
<template>
<div class="adv-chat">
<div class="adv-head card">
<div class="av" :style="{ background: advisor.bg }">{{ advisor.mark }}</div>
<div class="copy">
<strong>{{ advisor.name }}</strong>
<span class="meta">{{ advisor.meta }}</span>
<span class="tags">{{ advisor.tags }} · 专属对话</span>
</div>
</div>
<p v-if="bootLoading" class="hint">加载中</p>
<p v-else-if="bootError" class="err">
{{ bootError }}
<button type="button" class="retry" @click="$emit('boot')">重试</button>
</p>
<AskEmpty v-else-if="!profiles.length" />
<template v-else>
<AskProfileBar
:profiles="profiles"
:profile-id="profileId"
:self-label="selfLabel"
@update:profile-id="$emit('update:profileId', $event)"
@profile-change="$emit('profile-change')"
@navigate="$emit('navigate', $event)"
/>
<div v-if="!messages.length" class="intro card">
<p class="hi">你好我是{{ advisor.name }}</p>
<p class="hi-sub">可以先从下方草稿开始或直接说出你想聊的事</p>
</div>
<AskMessageList :messages="messages" :sending="sending" />
<p v-if="sendError" class="err pad">{{ sendError }}</p>
<AskQuotaBuy
v-if="showBuy"
:lead="quotaExhausted ? '问答次数已用完,购买后可继续聊' : '想聊更多?可先加购额度'"
@purchased="$emit('quota-refreshed')"
/>
<AskComposer
:draft="draft"
:sending="sending"
:quota="quota"
@update:draft="$emit('update:draft', $event)"
@send="$emit('send')"
/>
</template>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { AskMessage, AskQuota, Profile } from '@yuxingu/types'
import { askAdvisors } from '../../composables/useAskPage'
import AskComposer from './AskComposer.vue'
import AskEmpty from './AskEmpty.vue'
import AskMessageList from './AskMessageList.vue'
import AskProfileBar from './AskProfileBar.vue'
import AskQuotaBuy from './AskQuotaBuy.vue'
const props = defineProps<{
advisor: (typeof askAdvisors)[number]
bootLoading: boolean
bootError: string
profiles: Profile[]
profileId: string
selfLabel?: string
messages: AskMessage[]
draft: string
sending: boolean
sendError: string
quotaExhausted: boolean
quota: AskQuota | null
}>()
defineEmits<{
boot: []
'update:profileId': [v: string]
'profile-change': []
navigate: [to: string]
'update:draft': [v: string]
send: []
'quota-refreshed': []
}>()
const showBuy = computed(
() => props.quotaExhausted || (props.quota !== null && props.quota.remaining <= 0),
)
</script>
<style scoped>
.adv-chat {
padding: 8px 16px 108px;
position: relative;
z-index: 2;
}
.card {
background: rgba(255, 255, 255, 0.92);
border-radius: var(--radius-xl);
padding: 14px;
box-shadow: var(--shadow-card);
border: 1px solid #f6ece8;
margin-bottom: 12px;
}
.adv-head {
display: flex;
align-items: center;
gap: 12px;
}
.av {
width: 52px;
height: 52px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
color: #c23b32;
flex-shrink: 0;
}
.copy {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.copy strong {
font-size: 16px;
color: #222;
}
.meta {
font-size: 12px;
color: #999;
}
.tags {
font-size: 11px;
color: var(--color-primary);
}
.intro .hi {
font-size: 15px;
font-weight: 650;
color: #222;
}
.intro .hi-sub {
margin-top: 4px;
font-size: 13px;
color: #888;
line-height: 1.5;
}
.hint {
font-size: 13px;
color: #9a8f8b;
text-align: center;
padding: 28px 16px;
}
.err {
font-size: 13px;
color: var(--color-primary);
padding: 10px 12px;
margin: 4px 0 8px;
border-radius: var(--radius-sm);
background: var(--color-primary-soft);
}
.err.pad {
margin: 0 0 8px;
}
.retry {
margin-left: 8px;
border: none;
background: transparent;
color: var(--color-primary);
font-weight: 600;
}
</style>
@@ -15,6 +15,7 @@
<AskProfileBar
:profiles="profiles"
:profile-id="profileId"
:self-label="selfLabel"
@update:profile-id="$emit('update:profileId', $event)"
@profile-change="$emit('profile-change')"
@navigate="$emit('navigate', $event)"
@@ -54,6 +55,7 @@ const props = defineProps<{
bootError: string
profiles: Profile[]
profileId: string
selfLabel?: string
messages: AskMessage[]
draft: string
sending: boolean
@@ -15,8 +15,10 @@
:aria-selected="p.id === profileId"
@click="pick(p.id)"
>
<span class="chip-mark">{{ p.relation === 'self' ? '我' : 'TA' }}</span>
<span class="chip-name">{{ p.relation === 'self' ? '我的档案' : p.display_name || '未命名' }}</span>
<span class="chip-mark">{{ p.relation === 'self' ? selfMark : 'TA' }}</span>
<span class="chip-name">{{
p.relation === 'self' ? selfLabel : p.display_name || '未命名'
}}</span>
</button>
</div>
<div class="quick">
@@ -34,14 +36,19 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { Profile } from '@yuxingu/types'
import { askQuickTools } from '../../composables/useAskPage'
const props = defineProps<{
profiles: Profile[]
profileId: string
selfLabel?: string
}>()
const selfLabel = computed(() => (props.selfLabel || '').trim() || '我')
const selfMark = computed(() => selfLabel.value.slice(0, 1) || '我')
const emit = defineEmits<{
'update:profileId': [v: string]
'profile-change': []
@@ -1,10 +1,10 @@
<template>
<div class="archive reveal" style="--d: 40ms">
<router-link to="/portrait" class="av-self" aria-label="自己的档案">
<router-link to="/portrait" class="av-self" :aria-label="`${selfLabel}的档案`">
<span class="av" aria-hidden="true">
<BrandLogo size="card" class="av-logo" />
<img class="av-img" :src="avatarSrc" alt="" />
</span>
<span class="av-name">自己</span>
<span class="av-name">{{ selfLabel }}</span>
</router-link>
<button type="button" class="av-add" aria-label="添加档案" @click="$emit('add')">
<span class="plus-dot" aria-hidden="true">+</span>
@@ -18,9 +18,27 @@
</template>
<script setup lang="ts">
import BrandLogo from '../BrandLogo.vue'
import { computed } from 'vue'
import { apiAssetURL } from '../../lib/apiAsset'
import { DEFAULT_AVATAR_SRC } from '../../lib/defaultAvatar'
const props = withDefaults(
defineProps<{
nickname?: string
avatarUrl?: string
}>(),
{ nickname: '', avatarUrl: '' },
)
defineEmits<{ add: [] }>()
const selfLabel = computed(() => {
const n = (props.nickname || '').trim()
if (!n) return '访客'
return n.length > 4 ? `${n.slice(0, 4)}` : n
})
const avatarSrc = computed(() => apiAssetURL(props.avatarUrl) || DEFAULT_AVATAR_SRC)
</script>
<style scoped>
@@ -67,16 +85,23 @@ defineEmits<{ add: [] }>()
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7);
border: 2px solid rgba(229, 77, 66, 0.35);
}
.av-logo {
width: 22px !important;
height: auto !important;
max-height: none !important;
.av-img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center 42%;
display: block;
}
.av-name {
font-size: 11px;
font-weight: 600;
color: #8a4a3a;
line-height: 1;
max-width: 3.2em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: center;
}
.plus-dot {
width: 40px;
@@ -2,9 +2,10 @@
<section class="self-card reveal" style="--d: 80ms" data-track="home_self_card">
<div class="self-head">
<button type="button" class="who-btn" @click="$emit('open-profile')">
{{ profileLabel }}
<span class="who-name">{{ profileLabel }}</span>
<span class="caret" aria-hidden="true"></span>
</button>
<span class="who-day">{{ !loading && tips.asOf ? `今日 ${tips.asOf.slice(5)}` : '' }}</span>
<router-link class="more" to="/profile">更多</router-link>
</div>
@@ -126,10 +127,11 @@ const ringOffset = computed(() => {
box-shadow: var(--shadow-hero);
}
.self-head {
display: flex;
justify-content: space-between;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
margin-bottom: 12px;
gap: 8px;
}
.who-btn {
border: none;
@@ -142,12 +144,29 @@ const ringOffset = computed(() => {
gap: 4px;
padding: 0;
font-family: var(--font-sans);
min-width: 0;
justify-self: start;
}
.who-name {
max-width: 9em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.who-day {
justify-self: center;
font-size: 11px;
font-weight: 500;
color: var(--color-text-tertiary);
letter-spacing: 0.02em;
white-space: nowrap;
}
.caret {
font-size: 10px;
color: var(--color-text-tertiary);
}
.self-head .more {
justify-self: end;
font-size: 12px;
color: var(--color-text-tertiary);
}
@@ -1,251 +0,0 @@
<template>
<header class="top reveal" :class="{ 'top--menu-open': plusOpen }" style="--d: 0ms">
<router-link to="/growth-plan" class="ico-btn" aria-label="每日打卡">
<span class="ico-mark"></span>
</router-link>
<button type="button" class="search" @click="$emit('search')">
<span class="search-ico" aria-hidden="true" />
<span class="search-ph">{{ searchHint }}</span>
</button>
<div class="plus-wrap">
<button
type="button"
class="ico-btn"
aria-label="添加档案"
aria-haspopup="menu"
:aria-expanded="plusOpen"
@click="$emit('update:plusOpen', !plusOpen)"
>
<span class="ico-mark plus">+</span>
</button>
<div v-if="plusOpen" class="plus-mask" @click="$emit('update:plusOpen', false)" />
<div v-if="plusOpen" class="plus-menu" role="menu">
<button type="button" class="plus-item" role="menuitem" @click="$emit('plus', 'inviteFill')">
<span class="plus-ico pi-invite" aria-hidden="true" />
<span>邀请好友填档案</span>
</button>
<button type="button" class="plus-item" role="menuitem" @click="$emit('plus', 'add')">
<span class="plus-ico pi-add" aria-hidden="true" />
<span>添加档案</span>
</button>
<button type="button" class="plus-item" role="menuitem" @click="$emit('plus', 'synastry')">
<span class="plus-ico pi-heart" aria-hidden="true" />
<span>邀请好友合盘</span>
</button>
</div>
</div>
</header>
</template>
<script setup lang="ts">
defineProps<{
searchHint: string
plusOpen: boolean
}>()
defineEmits<{
search: []
'update:plusOpen': [value: boolean]
plus: [kind: 'inviteFill' | 'add' | 'synastry']
}>()
</script>
<style scoped>
.top {
display: grid;
grid-template-columns: 40px 1fr 40px;
gap: 10px;
align-items: center;
padding: 12px 14px 0;
position: relative;
/* Must sit above .sheet (z-index: 3) or the + menu is covered */
z-index: 10;
}
.top--menu-open {
z-index: 50;
}
.ico-btn {
width: 38px;
height: 38px;
border-radius: 13px;
border: 1px solid rgba(255, 255, 255, 0.95);
background: rgba(255, 255, 255, 0.78);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 14px rgba(180, 90, 70, 0.08);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
transition: transform var(--duration-fast) ease;
}
.ico-btn:active {
transform: scale(0.94);
}
.ico-mark {
font-size: 14px;
font-weight: 700;
color: var(--color-text-primary);
letter-spacing: 0.02em;
}
.ico-mark.plus {
font-size: 20px;
font-weight: 500;
line-height: 1;
color: #666;
}
.plus-wrap {
position: relative;
z-index: 20;
}
.plus-mask {
position: fixed;
inset: 0;
z-index: 40;
background: transparent;
}
.plus-menu {
position: absolute;
top: calc(100% + 8px);
right: 0;
z-index: 50;
min-width: 176px;
padding: 8px 0;
border-radius: 14px;
background: rgba(255, 255, 255, 0.98);
border: 1px solid rgba(255, 255, 255, 0.95);
box-shadow: 0 12px 32px rgba(120, 60, 50, 0.16);
animation: plusPop 0.2s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.plus-menu::before {
content: '';
position: absolute;
top: -6px;
right: 14px;
width: 12px;
height: 12px;
background: inherit;
border-left: 1px solid rgba(255, 255, 255, 0.95);
border-top: 1px solid rgba(255, 255, 255, 0.95);
transform: rotate(45deg);
}
@keyframes plusPop {
from {
opacity: 0;
transform: translateY(-6px) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.plus-item {
width: 100%;
display: flex;
align-items: center;
gap: 10px;
padding: 11px 14px;
border: none;
background: transparent;
font-size: 14px;
font-weight: 600;
color: var(--color-text-primary);
text-align: left;
}
.plus-item:active {
background: rgba(229, 77, 66, 0.06);
}
.plus-ico {
width: 28px;
height: 28px;
border-radius: 9px;
flex-shrink: 0;
display: grid;
place-items: center;
font-size: 13px;
font-weight: 700;
color: #fff;
}
.pi-invite {
background: linear-gradient(145deg, #5ecf8a, #3bb56e);
}
.pi-invite::after {
content: '邀';
}
.pi-add {
background: linear-gradient(145deg, #ffb45c, #f08a3a);
border-radius: 8px;
}
.pi-add::after {
content: '档';
}
.pi-heart {
background: linear-gradient(145deg, #ff8aa8, #e54d7a);
}
.pi-heart::after {
content: '合';
}
.search {
height: 38px;
border-radius: var(--radius-pill);
border: 1px solid rgba(255, 255, 255, 0.95);
background: rgba(255, 255, 255, 0.82);
padding: 0 14px 0 12px;
display: flex;
align-items: center;
gap: 8px;
text-align: left;
box-shadow: 0 4px 16px rgba(180, 90, 70, 0.07);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
transition: background var(--duration-fast) ease;
}
.search:active {
background: rgba(255, 255, 255, 0.95);
}
.search-ico {
width: 14px;
height: 14px;
flex-shrink: 0;
border: 1.5px solid rgba(0, 0, 0, 0.28);
border-radius: 50%;
position: relative;
}
.search-ico::after {
content: '';
position: absolute;
width: 6px;
height: 1.5px;
background: rgba(0, 0, 0, 0.28);
border-radius: 1px;
right: -3px;
bottom: -1px;
transform: rotate(45deg);
transform-origin: left center;
}
.search-ph {
font-size: 13px;
color: rgba(0, 0, 0, 0.36);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.reveal {
animation: rise 0.45s cubic-bezier(0.22, 1, 0.36, 1) both;
animation-delay: var(--d, 0ms);
}
@keyframes rise {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.reveal {
animation: none !important;
}
}
</style>
@@ -1,19 +1,11 @@
<template>
<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>
</template>
<script setup lang="ts">
import HomeToolIcon from '../HomeToolIcon.vue'
defineProps<{
bootError: string
quotaLabel: string
@@ -24,31 +16,7 @@ defineProps<{
<style scoped>
.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;
padding: 0 0 12px;
}
.quota {
font-size: 11px;
@@ -0,0 +1,60 @@
<template>
<section class="assets">
<router-link to="/membership" class="asset">
<strong>{{ membershipActive ? 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>{{ membershipActive ? '续费' : '开通' }}</strong>
<span>会员中心</span>
</router-link>
</section>
</template>
<script setup lang="ts">
defineProps<{
membershipActive: boolean
planLabel: string
profileCount: number
}>()
</script>
<style scoped>
.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);
}
</style>
@@ -0,0 +1,121 @@
<template>
<input
ref="albumEl"
class="file-hidden"
type="file"
accept="image/jpeg,image/png,image/webp,image/*"
@change="onPick('album', $event)"
/>
<input
ref="cameraEl"
class="file-hidden"
type="file"
accept="image/*"
capture="environment"
@change="onPick('camera', $event)"
/>
<div v-if="open" class="sheet-mask" @click="$emit('close')">
<div class="sheet" role="dialog" aria-label="更换头像" @click.stop>
<p class="sheet-title">更换头像</p>
<button type="button" class="sheet-btn" :disabled="busy" @click="clickAlbum">
从相册选择
</button>
<button type="button" class="sheet-btn" :disabled="busy" @click="clickCamera">
拍照
</button>
<button type="button" class="sheet-btn cancel" @click="$emit('close')">取消</button>
<p v-if="error" class="sheet-err">{{ error }}</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
defineProps<{
open: boolean
busy: boolean
error: string
}>()
const emit = defineEmits<{
close: []
pick: [ev: Event]
source: [source: 'album' | 'camera']
}>()
const albumEl = ref<HTMLInputElement | null>(null)
const cameraEl = ref<HTMLInputElement | null>(null)
function clickAlbum() {
emit('source', 'album')
albumEl.value?.click()
}
function clickCamera() {
emit('source', 'camera')
cameraEl.value?.click()
}
function onPick(source: 'album' | 'camera', ev: Event) {
emit('source', source)
emit('pick', ev)
}
</script>
<style scoped>
.file-hidden {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.sheet-mask {
position: fixed;
inset: 0;
z-index: 200;
background: rgba(30, 20, 16, 0.42);
display: flex;
align-items: flex-start;
justify-content: center;
}
.sheet {
width: 100%;
max-width: var(--yxg-max-w);
padding: calc(12px + env(safe-area-inset-top, 0)) 14px 14px;
background: #f7f3f1;
border-radius: 0 0 18px 18px;
display: grid;
gap: 8px;
}
.sheet-title {
text-align: center;
font-size: 13px;
color: #9a8f8b;
padding: 4px 0 6px;
}
.sheet-btn {
border: none;
border-radius: 14px;
background: #fff;
padding: 14px;
font-size: 16px;
font-weight: 600;
color: #222;
}
.sheet-btn.cancel {
color: #888;
font-weight: 500;
margin-top: 2px;
}
.sheet-btn:disabled {
opacity: 0.5;
}
.sheet-err {
text-align: center;
font-size: 12px;
color: var(--color-primary);
}
</style>
@@ -0,0 +1,111 @@
<template>
<div class="profile-head">
<button
type="button"
class="av"
:aria-label="loggedIn ? '更换头像' : '头像'"
:disabled="!loggedIn || avatarBusy"
@click="$emit('avatar')"
>
<img class="av-img" :src="avatarSrc" alt="" />
<span v-if="loggedIn" class="av-edit" aria-hidden="true"></span>
</button>
<div class="who">
<p class="name">{{ accountLabel }}</p>
<p class="meta">档案 {{ profileCount }} </p>
</div>
<router-link v-if="!loggedIn" class="edit" to="/login">登录 </router-link>
<button v-else type="button" class="edit linkish" @click="$emit('logout')">退出</button>
</div>
</template>
<script setup lang="ts">
defineProps<{
loggedIn: boolean
avatarBusy: boolean
avatarSrc: string
accountLabel: string
profileCount: number
}>()
defineEmits<{
avatar: []
logout: []
}>()
</script>
<style scoped>
.profile-head {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 0 12px;
}
.av {
position: relative;
width: 56px;
height: 56px;
border-radius: 50%;
border: none;
padding: 0;
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;
cursor: pointer;
}
.av:disabled {
cursor: default;
}
.av-img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center 42%;
display: block;
}
.av-edit {
position: absolute;
right: 0;
bottom: 0;
min-width: 18px;
height: 18px;
padding: 0 4px;
border-radius: 9px;
background: rgba(50, 40, 36, 0.72);
color: #fff;
font-size: 9px;
font-weight: 700;
display: grid;
place-items: center;
line-height: 1;
}
.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;
}
.linkish {
border: 0;
background: none;
padding: 0;
cursor: pointer;
}
</style>
@@ -0,0 +1,49 @@
<template>
<section class="sec">
<p class="sec-title">{{ title }}</p>
<div class="group">
<ListRow
v-for="item in items"
:key="item.to"
:to="item.to"
:title="item.label"
:icon="item.icon"
/>
</div>
</section>
</template>
<script setup lang="ts">
import ListRow from '../ListRow.vue'
import type { MineNavItem } from '../../lib/mineCatalog'
defineProps<{
title: string
items: MineNavItem[]
}>()
</script>
<style scoped>
.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;
}
</style>
@@ -4,7 +4,7 @@
<div>
<p class="lbl">账号昵称</p>
<p class="val">{{ nickname || '未设置' }}</p>
<p class="hint">首页自己展示默认如心语岛微光谷</p>
<p class="hint">首页展示此昵称默认如心语岛微光谷</p>
</div>
<button v-if="!editing" type="button" class="op-btn" @click="startEdit">修改</button>
</div>
@@ -29,7 +29,9 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { validateUserText } from '@yuxingu/utils'
import { api } from '../../api/client'
import { AnalyticsEvent, track } from '../../lib/analytics'
const props = defineProps<{
nickname: string
@@ -65,17 +67,18 @@ function cancel() {
}
async function save() {
const next = draft.value.trim()
if (!next) {
err.value = '请填写昵称'
const checked = validateUserText('nickname', draft.value)
if (!checked.ok) {
err.value = checked.message
return
}
busy.value = true
err.value = ''
try {
const me = await api.authUpdateMe({ nickname: next })
const me = await api.authUpdateMe({ nickname: checked.value })
emit('updated', me.nickname)
editing.value = false
track(AnalyticsEvent.NicknameUpdated, { surface: 'profile' })
} catch (e) {
err.value = e instanceof Error ? e.message : '保存失败'
} finally {
@@ -1,11 +1,11 @@
<template>
<div class="self-card">
<div class="av-ring">
<span class="av-inner">{{ avatarInitial(profile) }}</span>
<span class="av-inner">{{ label.slice(0, 1) }}</span>
</div>
<div class="self-body">
<strong>{{ profile.display_name || '我' }}</strong>
<span class="meta"> · {{ formatProfileDate(profile.birth_date) }}</span>
<strong>{{ label }}</strong>
<span class="meta">{{ label }} · {{ formatProfileDate(profile.birth_date) }}</span>
<span v-if="profile.birth_place" class="meta sub">{{ profile.birth_place }}</span>
</div>
<div class="self-ops">
@@ -15,16 +15,20 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { Profile } from '@yuxingu/types'
import { avatarInitial, formatProfileDate } from '../../composables/useProfilePage'
import { formatProfileDate } from '../../composables/useProfilePage'
defineProps<{
const props = defineProps<{
profile: Profile
nickname?: string
}>()
defineEmits<{
edit: [p: Profile]
}>()
const label = computed(() => (props.nickname || '').trim() || props.profile.display_name || '我')
</script>
<style scoped>
@@ -32,6 +32,11 @@
<HomeToolIcon name="rhythm" :size="48" />
<p>暂无五行分布数据可从概览与建议了解节律倾向</p>
</div>
<div v-if="wuxingCare" class="spotlight care">
<strong>养护方向</strong>
<p>{{ wuxingCare }}</p>
</div>
<p class="disc-mini">五行分布为自我探索隐喻非医疗诊断</p>
<ReportRich :summary="summary" :detail="null" :deep="false" />
</template>
@@ -74,13 +79,14 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { GrowthReport } from '@yuxingu/types'
import HomeToolIcon from '../HomeToolIcon.vue'
import ReportRich from '../ReportRich.vue'
import WuxingBars, { type WuxingBar } from '../WuxingBars.vue'
import { resultTabs } from '../../composables/useLifeRhythmPage'
defineProps<{
const props = defineProps<{
tab: 'overview' | 'wuxing' | 'tips' | 'term'
headline: string
oneLiner: string
@@ -100,6 +106,13 @@ defineEmits<{
'update:tab': [value: 'overview' | 'wuxing' | 'tips' | 'term']
buyDeep: []
}>()
const wuxingCare = computed(() => {
const wx = props.summary.wuxing
if (!wx || typeof wx !== 'object') return ''
const o = wx as Record<string, unknown>
return String(o.care_dir || '')
})
</script>
<style scoped>
@@ -153,6 +166,8 @@ defineEmits<{
}
.spotlight strong { font-size: 14px; color: #222; }
.spotlight p { margin-top: 6px; font-size: 13px; color: #666; line-height: 1.5; }
.spotlight.care { margin-top: 12px; }
.disc-mini { margin-top: 10px; font-size: 11px; color: #aaa; line-height: 1.4; }
.overview { margin-top: 12px; font-size: 13px; color: #555; line-height: 1.65; }
.empty-soft {
text-align: center;
@@ -10,21 +10,45 @@
<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
type="radio"
:name="`q-${currentQuestion.id}`"
:value="opt.key"
:checked="answers[currentQuestion.id] === opt.key"
@change="onPick(opt.key)"
/>
<span class="opt-text">{{ opt.text }}</span>
</label>
<template v-if="isLikert">
<div class="poles">
<span class="pole left">{{ currentQuestion.body.left }}</span>
<span class="pole right">{{ currentQuestion.body.right }}</span>
</div>
<div class="likert">
<button
v-for="opt in currentQuestion.body.options"
:key="opt.key"
type="button"
class="likert-btn"
:class="{ picked: answers[currentQuestion.id] === opt.key }"
:aria-label="opt.text"
@click="onPick(opt.key)"
>
{{ opt.key }}
</button>
</div>
<p class="likert-hint">1 更接近左侧 · 5 更接近右侧</p>
</template>
<template v-else>
<label
v-for="opt in currentQuestion.body.options"
:key="opt.key"
class="opt"
:class="{ picked: answers[currentQuestion.id] === opt.key }"
>
<input
type="radio"
:name="`q-${currentQuestion.id}`"
:value="opt.key"
:checked="answers[currentQuestion.id] === opt.key"
@change="onPick(opt.key)"
/>
<span class="opt-text">{{ opt.text }}</span>
</label>
</template>
</div>
<p v-if="submitError" class="yxg-err">{{ submitError }}</p>
@@ -36,12 +60,23 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
draftRestored: boolean
currentIdx: number
progressPct: number
totalQuestions: number
currentQuestion: { id: string; body: { prompt: string; options: { key: string; text: string }[] } } | null
currentQuestion: {
id: string
body: {
prompt: string
format?: string
left?: string
right?: string
options: { key: string; text: string }[]
}
} | null
answers: Record<string, string>
submitError: string
needProfile: boolean
@@ -51,6 +86,8 @@ const emit = defineEmits<{
answer: [questionId: string, key: string]
}>()
const isLikert = computed(() => props.currentQuestion?.body.format === 'likert5')
function onPick(key: string) {
if (!props.currentQuestion) return
emit('answer', props.currentQuestion.id, key)
@@ -97,6 +134,49 @@ function onPick(key: string) {
margin-bottom: 16px;
line-height: 1.5;
}
.poles {
display: flex;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.pole {
flex: 1;
font-size: 13px;
line-height: 1.4;
color: #5a4a44;
font-weight: 600;
}
.pole.right {
text-align: right;
}
.likert {
display: flex;
justify-content: space-between;
gap: 8px;
}
.likert-btn {
flex: 1;
height: 44px;
border-radius: 12px;
border: 1.5px solid #eee;
background: #fdfaf8;
font-size: 16px;
font-weight: 700;
color: #333;
cursor: pointer;
}
.likert-btn.picked {
border-color: var(--color-primary);
background: #fff5f3;
color: var(--color-primary);
}
.likert-hint {
margin: 10px 0 0;
font-size: 11px;
color: var(--color-text-tertiary);
text-align: center;
}
.opt {
display: flex;
align-items: flex-start;
@@ -4,9 +4,31 @@
<HomeToolIcon name="mbti" :size="64" />
<h2 class="intro-title">{{ title || '探索测试' }}</h2>
<p v-if="description" class="intro-desc">{{ description }}</p>
<p v-if="questionCount" class="intro-count"> {{ questionCount }} · {{ estMinutes }} 分钟</p>
<p v-if="questionCount && !showMbtiVersions" class="intro-count">
{{ questionCount }} · {{ estMinutes }} 分钟
</p>
<p v-if="draftRestored" class="draft-hint">检测到上次未完成的作答开始后将继续</p>
<button class="yxg-btn yxg-btn-block intro-start" type="button" :disabled="!questionCount" @click="$emit('start')">
<template v-if="showMbtiVersions">
<button class="yxg-btn yxg-btn-block intro-start" type="button" :disabled="!questionCount" @click="$emit('start')">
标准版 · 32 题免费
</button>
<button class="yxg-btn yxg-btn-ghost yxg-btn-block intro-full" type="button" @click="$emit('start-full')">
完整版 · 60 成长会员
</button>
<p class="credit">题库基于开源 OEJTS · 非官方 MBTI 版权量表</p>
</template>
<template v-else-if="locked">
<p class="lock-hint">完整版需开通成长会员后作答</p>
<router-link class="yxg-btn yxg-btn-block intro-start" to="/membership">去开通会员</router-link>
</template>
<button
v-else
class="yxg-btn yxg-btn-block intro-start"
type="button"
:disabled="!questionCount"
@click="$emit('start')"
>
开始
</button>
</div>
@@ -22,9 +44,11 @@ defineProps<{
questionCount: number
estMinutes: number
draftRestored: boolean
showMbtiVersions?: boolean
locked?: boolean
}>()
defineEmits<{ start: [] }>()
defineEmits<{ start: []; 'start-full': [] }>()
</script>
<style scoped>
@@ -61,7 +85,8 @@ defineEmits<{ start: [] }>()
color: var(--color-text-tertiary);
margin: 0 0 20px;
}
.draft-hint {
.draft-hint,
.lock-hint {
font-size: 12px;
color: var(--color-accent-gold);
margin: 0 0 12px;
@@ -73,4 +98,14 @@ defineEmits<{ start: [] }>()
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
box-shadow: 0 6px 18px rgba(229, 77, 66, 0.28);
}
.intro-full {
margin-top: 10px;
font-weight: 650;
}
.credit {
margin: 14px 0 0;
font-size: 11px;
color: var(--color-text-tertiary);
line-height: 1.4;
}
</style>
@@ -5,23 +5,38 @@
<div class="yxg-card">
<p v-if="shareLine" class="share">{{ shareLine }}</p>
<button type="button" class="yxg-btn yxg-btn-ghost" @click="$emit('share')">生成分享卡</button>
<router-link class="yxg-link-plain link" to="/relation">用结果去做关系理解 </router-link>
<router-link class="yxg-link-plain link" to="/ask">去问答聊聊 </router-link>
<button type="button" class="yxg-btn retake" @click="$emit('retake')">重新测试</button>
<router-link class="yxg-link-plain link" to="/relation" @click="onCtaRelation">
用结果去做关系理解
</router-link>
<router-link class="yxg-link-plain link" to="/ask" @click="onCtaAsk">
去问答聊聊
</router-link>
</div>
</div>
</template>
<script setup lang="ts">
import ReportRich from '../ReportRich.vue'
import { AnalyticsEvent, track } from '../../lib/analytics'
defineProps<{
const props = defineProps<{
slug: string
resultLabel: string
shareLine: string
resultSummaryObj: Record<string, unknown>
resultDetailObj: Record<string, unknown>
}>()
defineEmits<{ share: [] }>()
defineEmits<{ share: []; retake: [] }>()
function onCtaRelation() {
track(AnalyticsEvent.ScaleCtaRelation, { slug: props.slug })
}
function onCtaAsk() {
track(AnalyticsEvent.ScaleCtaAsk, { slug: props.slug })
}
</script>
<style scoped>
@@ -37,6 +52,11 @@ defineEmits<{ share: [] }>()
margin: 0 0 10px;
color: var(--yxg-gold);
}
.retake {
display: block;
width: 100%;
margin-top: 10px;
}
.link {
display: block;
margin-top: 12px;
@@ -1,10 +1,10 @@
<template>
<div class="yxg-card soft classic-panel">
<label class="yxg-label">档案 A</label>
<label class="yxg-label">{{ selfLabel }}档案 A</label>
<select :value="profileA" class="sel" @change="$emit('update:profileA', ($event.target as HTMLSelectElement).value)">
<option disabled value="">请选择</option>
<option v-for="p in selfProfiles" :key="p.id" :value="p.id">
{{ p.display_name || '' }} · {{ birthLabel(p.birth_date) }}
{{ selfLabel }} · {{ birthLabel(p.birth_date) }}
</option>
</select>
<label class="yxg-label">TA档案 B</label>
@@ -32,6 +32,7 @@ defineProps<{
otherProfiles: Profile[]
nearby: { profile: Profile; distance_km: number }[]
asOf: string
selfLabel: string
birthLabel: (d?: string) => string
}>()
@@ -4,8 +4,8 @@
<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>
<span class="pick-label">{{ selfLabel }}</span>
<span v-if="profileAName && profileAName !== selfLabel" class="pick-name">{{ profileAName }}</span>
</div>
<span class="pick-link" aria-hidden="true">×</span>
<div class="pick-side ta-side">
@@ -53,6 +53,7 @@ import type { Profile } from '@yuxingu/types'
defineProps<{
selfInitial: string
selfLabel: string
profileAName: string
pickableProfiles: Profile[]
profileB: string
@@ -9,6 +9,7 @@
<SynastryDualPick
:self-initial="selfInitial"
:self-label="selfLabel"
:profile-a-name="profileAName"
:pickable-profiles="pickableProfiles"
:profile-b="profileB"
@@ -53,6 +54,7 @@
:other-profiles="pickableProfiles"
:nearby="nearby"
:as-of="asOf"
:self-label="selfLabel"
:birth-label="birthLabel"
@update:profile-a="$emit('update:profileA', $event)"
@update:profile-b="$emit('update:profileB', $event)"
@@ -90,6 +92,7 @@ import SynastryRelationChips from './SynastryRelationChips.vue'
defineProps<{
relationType: string
selfInitial: string
selfLabel: string
profileAName: string
pickableProfiles: Profile[]
profileB: string
@@ -1,6 +1,7 @@
import type { Ref } from 'vue'
import type { Router } from 'vue-router'
import type { Profile } from '@yuxingu/types'
import { validateUserText } from '@yuxingu/utils'
import { api } from '../api/client'
import { copyText } from '../lib/shareLink'
@@ -180,6 +181,8 @@ export function createProfilePageActions(r: ProfilePageActionRefs) {
try {
const birth = joinBirth(r.formY.value, r.formM.value, r.formD.value)
if (!birth) throw new Error('请填写生日')
const nameCheck = validateUserText('display_name', r.formName.value)
if (!nameCheck.ok) throw new Error(nameCheck.message)
const body: {
display_name: string
birth_date: string
@@ -187,7 +190,7 @@ export function createProfilePageActions(r: ProfilePageActionRefs) {
birth_place?: string
geo_visible?: boolean
} = {
display_name: r.formName.value.trim(),
display_name: nameCheck.value,
birth_date: birth,
birth_place: r.formPlace.value || '',
}
@@ -224,10 +227,13 @@ export function createProfilePageActions(r: ProfilePageActionRefs) {
try {
const birth = joinBirth(r.newY.value, r.newM.value, r.newD.value)
if (!birth) throw new Error('请填写生日')
const nameRaw = r.newName.value.trim() || 'TA'
const nameCheck = validateUserText('display_name', nameRaw)
if (!nameCheck.ok) throw new Error(nameCheck.message)
await api.createProfile({
relation: 'other',
birth_date: birth,
display_name: r.newName.value.trim() || 'TA',
display_name: nameCheck.value,
relation_type: r.newRelationType.value,
birth_place: r.newPlace.value || undefined,
})
@@ -94,7 +94,7 @@ export function createSynastryPageActions(r: SynastryPageActionRefs) {
try {
const res = await api.createSynastryInvite(r.profileA.value)
r.invitePath.value = res.path
track(AnalyticsEvent.SynastryInviteCreated, { token: res.token })
track(AnalyticsEvent.SynastryInviteCreated)
} catch (e) {
r.error.value = e instanceof Error ? e.message : '邀请失败'
} finally {
+107 -12
View File
@@ -1,8 +1,10 @@
import { onMounted, ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import type { AskMessage, AskQuota, Profile } from '@yuxingu/types'
import { validateUserText } from '@yuxingu/utils'
import { api } from '../api/client'
import { ensureAccount, pickableArchiveList } from '../lib/authSession'
import { useAccountNickname } from '../lib/accountNickname'
export const askScenes = [
{ key: 'self', label: '我想更了解自己的性格与互动风格', prompt: '我想更了解自己的性格与互动风格。' },
@@ -19,15 +21,45 @@ export const askQuickTools = [
]
export const askAdvisors = [
{ 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 },
{
key: 'xinyu',
name: '心语',
mark: '心',
bg: 'linear-gradient(145deg,#ffd0c8,#ffb0a4)',
meta: '好评率 98% · 情绪整理',
tags: '文字沟通',
scene: 'emotion' as const,
},
{
key: 'minglang',
name: '明朗',
mark: '明',
bg: 'linear-gradient(145deg,#d6e8ff,#b0d0f5)',
meta: '好评率 97% · 关系理解',
tags: '文字沟通',
scene: 'relation' as const,
},
{
key: 'zhiyuan',
name: '志远',
mark: '志',
bg: 'linear-gradient(145deg,#ffe6c8,#f5c98a)',
meta: '好评率 96% · 职业探索',
tags: '文字沟通',
scene: 'career' as const,
},
]
export function findAskAdvisor(key: string | undefined | null) {
if (!key) return null
return askAdvisors.find((a) => a.key === key) || null
}
export function useAskPage() {
const router = useRouter()
const route = useRoute()
const rail = ref<'ai' | 'advisor'>('ai')
const { selfLabel } = useAccountNickname()
const bootLoading = ref(true)
const bootError = ref('')
@@ -42,6 +74,10 @@ export function useAskPage() {
const quotaExhausted = ref(false)
const quota = ref<AskQuota | null>(null)
const activeAdvisor = computed(() =>
findAskAdvisor(typeof route.query.advisor === 'string' ? route.query.advisor : ''),
)
async function boot() {
bootLoading.value = true
bootError.value = ''
@@ -55,8 +91,11 @@ export function useAskPage() {
quota.value = q
const self = profiles.value.find((p) => p.relation === 'self')
profileId.value = self?.id || profiles.value[0]?.id || ''
threadId.value = ''
messages.value = []
if (!activeAdvisor.value) {
threadId.value = ''
messages.value = []
}
syncAdvisorFromRoute()
} catch (e) {
bootError.value = e instanceof Error ? e.message : '加载失败'
} finally {
@@ -64,6 +103,20 @@ export function useAskPage() {
}
}
function syncAdvisorFromRoute() {
const a = activeAdvisor.value
if (!a) return
rail.value = 'advisor'
const nextScene = `advisor:${a.key}`
if (scene.value !== nextScene) {
scene.value = nextScene
threadId.value = ''
messages.value = []
const s = askScenes.find((x) => x.key === a.scene)
draft.value = s?.prompt || ''
}
}
function onProfileChange() {
threadId.value = ''
messages.value = []
@@ -77,10 +130,36 @@ export function useAskPage() {
messages.value = []
}
function askAdvisor(a: (typeof askAdvisors)[number]) {
rail.value = 'ai'
const s = askScenes.find((x) => x.key === a.scene) || askScenes[0]
pickScene(s)
function openAdvisor(a: (typeof askAdvisors)[number]) {
rail.value = 'advisor'
scene.value = `advisor:${a.key}`
const s = askScenes.find((x) => x.key === a.scene)
draft.value = s?.prompt || ''
threadId.value = ''
messages.value = []
sendError.value = ''
void router.push({ path: '/ask', query: { advisor: a.key } })
}
function closeAdvisor() {
threadId.value = ''
messages.value = []
draft.value = ''
sendError.value = ''
scene.value = 'self'
void router.replace({ path: '/ask', query: {} })
rail.value = 'advisor'
}
function setRail(v: 'ai' | 'advisor') {
rail.value = v
if (v === 'ai' && route.query.advisor) {
threadId.value = ''
messages.value = []
draft.value = ''
scene.value = 'self'
void router.replace({ path: '/ask', query: {} })
}
}
async function ensureThread(): Promise<string> {
@@ -92,7 +171,12 @@ export function useAskPage() {
}
async function send() {
const content = draft.value.trim()
const checked = validateUserText('ask_content', draft.value)
if (!checked.ok) {
sendError.value = checked.message
return
}
const content = checked.value
if (!content || sending.value) return
sending.value = true
sendError.value = ''
@@ -181,13 +265,23 @@ export function useAskPage() {
onMounted(boot)
watch(
() => route.query.advisor,
() => {
syncAdvisorFromRoute()
},
)
return {
router,
rail,
setRail,
activeAdvisor,
bootLoading,
bootError,
profiles,
profileId,
selfLabel,
messages,
draft,
sending,
@@ -196,7 +290,8 @@ export function useAskPage() {
quota,
onProfileChange,
pickScene,
askAdvisor,
openAdvisor,
closeAdvisor,
send,
boot,
refreshQuota,
+112 -11
View File
@@ -1,4 +1,4 @@
import { onMounted, ref } from 'vue'
import { onMounted, onUnmounted, ref } from 'vue'
import type { HomeDailyTips as ApiTips } from '@yuxingu/types'
import { api } from '../api/client'
@@ -13,11 +13,26 @@ export type HomeDailyTips = {
needBirth?: boolean
guaName?: string
source?: string
asOf?: string
shichen?: number
shichenName?: string
validUntil?: string
}
function localDayKey(d = new Date()) {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
/** Local 十二时辰 index (子时 23:00 起). */
function localShichenIndex(d = new Date()) {
return ((d.getHours() + 1) % 24) >> 1
}
function localFallback(): HomeDailyTips {
const n = new Date()
const seed = n.getFullYear() * 10000 + (n.getMonth() + 1) * 100 + n.getDate() + n.getHours()
const day = Math.floor(Date.UTC(n.getFullYear(), n.getMonth(), n.getDate()) / 86400000)
const shi = localShichenIndex(n)
const seed = day * 13 + shi * 19
const palettes: ColorChip[][] = [
[
{ name: '米白', hex: '#F5F0E8' },
@@ -31,19 +46,50 @@ function localFallback(): HomeDailyTips {
{ name: '浅灰', hex: '#D8D6D4' },
{ name: '雾粉', hex: '#E8B8C4' },
],
[
{ name: '天青', hex: '#9BB8D4' },
{ name: '陶土', hex: '#C4A484' },
],
]
const clothing = [
'轻薄透气更舒服,外套可备一件薄衫应付温差。',
'今日宜层搭:薄内搭+透气外衫,方便随时增减。',
'选柔软面料贴身,活动一整天也不易紧绷。',
'宽松剪裁更自在,适合慢节奏出门与散步。',
]
const notes = ['清爽干净,不抢戏。', '今日偏清透,层次更轻。', '温柔耐看,适合日常。', '柔和提气色,不显沉。']
const well = [
'午后泡一杯温茶,给身心一点缓冲。',
'今日做三次深呼吸,拉长呼气更易放松。',
'今晚早点放下屏幕,让眼睛歇一会儿。',
'走路时把肩膀放松,呼吸会顺很多。',
]
const i = seed % palettes.length
const end = nextShichenEnd(n)
return {
clothingIndex: 62 + (seed % 31),
clothing: '轻薄透气更舒服,外套可备一件薄衫应付温差。',
colorNote: '清爽干净,不抢戏。',
clothingIndex: 55 + (seed % 41),
clothing: clothing[i],
colorNote: notes[i],
palette: palettes[i],
wellness: '午后泡一杯温茶,给身心一点缓冲。',
wellness: well[i],
source: 'fallback',
needBirth: true,
asOf: localDayKey(n),
shichen: shi,
validUntil: end.toISOString(),
}
}
function nextShichenEnd(d = new Date()) {
const shi = localShichenIndex(d)
const startHour = (shi * 2 + 23) % 24
const start = new Date(d)
start.setHours(startHour, 0, 0, 0)
if (start.getTime() > d.getTime()) start.setDate(start.getDate() - 1)
const end = new Date(start.getTime() + 2 * 3600 * 1000)
return end
}
function fromApi(t: ApiTips): HomeDailyTips {
return {
clothingIndex: t.clothing_index,
@@ -54,23 +100,78 @@ function fromApi(t: ApiTips): HomeDailyTips {
needBirth: !!t.need_birth,
guaName: t.gua_name,
source: t.source,
asOf: t.as_of || localDayKey(),
shichen: t.shichen,
shichenName: t.shichen_name,
validUntil: t.valid_until,
}
}
/** Load daily tips from API (LLM + 易经种子); local fallback on failure. */
/** Load tips from API; silent refresh at each 时辰 boundary. */
export function useHomeDailyTips() {
const tips = ref<HomeDailyTips>(localFallback())
const loading = ref(true)
let timer: ReturnType<typeof setTimeout> | null = null
let upgradeTimer: ReturnType<typeof setTimeout> | null = null
let loadedShichen = tips.value.shichen ?? localShichenIndex()
onMounted(async () => {
loading.value = true
function clearTimers() {
if (timer) clearTimeout(timer)
if (upgradeTimer) clearTimeout(upgradeTimer)
timer = null
upgradeTimer = null
}
function schedule(validUntil?: string) {
if (timer) clearTimeout(timer)
const end = validUntil ? new Date(validUntil).getTime() : nextShichenEnd().getTime()
const ms = Math.max(1000, end - Date.now() + 400)
timer = setTimeout(() => {
void load({ silent: true })
}, ms)
}
async function load(opts?: { silent?: boolean }) {
const silent = !!opts?.silent
if (!silent) loading.value = true
try {
tips.value = fromApi(await api.getHomeDailyTips())
const next = fromApi(await api.getHomeDailyTips())
tips.value = next
loadedShichen = next.shichen ?? localShichenIndex()
schedule(next.validUntil)
if (!next.needBirth && next.source === 'fallback') {
if (upgradeTimer) clearTimeout(upgradeTimer)
upgradeTimer = setTimeout(() => {
void load({ silent: true })
}, 2800)
}
} catch {
/* keep local fallback */
if (!silent) {
tips.value = localFallback()
loadedShichen = localShichenIndex()
schedule(tips.value.validUntil)
}
} finally {
loading.value = false
}
}
function onVisibility() {
if (document.visibilityState !== 'visible') return
if (localShichenIndex() !== loadedShichen) {
void load({ silent: true })
return
}
schedule(tips.value.validUntil)
}
onMounted(() => {
void load({ silent: true })
document.addEventListener('visibilitychange', onVisibility)
})
onUnmounted(() => {
clearTimers()
document.removeEventListener('visibilitychange', onVisibility)
})
return { tips, loading }
+10 -12
View File
@@ -1,4 +1,4 @@
import { computed, onMounted, reactive, ref, toRefs } from 'vue'
import { onMounted, reactive, ref, toRefs } from 'vue'
import { useRouter } from 'vue-router'
import { api } from '../api/client'
import { AnalyticsEvent, track } from '../lib/analytics'
@@ -6,11 +6,11 @@ import {
homeFeeds,
homeGridRow1,
homeGridRow2,
homeSearchHints,
type HomeTool,
} from '../lib/homeCatalog'
import type { HomeToolIconName } from '../components/HomeToolIcon.vue'
import { useHomeDailyTips } from './useHomeDailyTips'
import { setAccountNickname } from '../lib/accountNickname'
const ICONS = new Set([
'mbti', 'star', 'portrait', 'rhythm', 'synastry', 'astro',
@@ -56,7 +56,7 @@ function mapTools(
export function useHomePage() {
const router = useRouter()
const profileLabel = ref('访客')
const plusOpen = ref(false)
const avatarUrl = ref('')
const { tips, loading: tipsLoading } = useHomeDailyTips()
const grid = reactive({
gridRow1: [...homeGridRow1] as HomeTool[],
@@ -67,10 +67,15 @@ export function useHomePage() {
void api
.authMe()
.then((me) => {
if (me.nickname) profileLabel.value = me.nickname
if (me.nickname) {
profileLabel.value = me.nickname
setAccountNickname(me.nickname)
}
avatarUrl.value = me.avatar_url || ''
})
.catch(() => {
profileLabel.value = '访客'
avatarUrl.value = ''
})
void api
.getHomeTools()
@@ -86,13 +91,7 @@ export function useHomePage() {
})
})
const searchHint = computed(() => {
const i = new Date().getHours() % homeSearchHints.length
return homeSearchHints[i]
})
function goPlus(kind: 'inviteFill' | 'add' | 'synastry') {
plusOpen.value = false
track(AnalyticsEvent.HomeCtaPortrait, { source: 'home_plus', kind })
if (kind === 'inviteFill') {
router.push({ path: '/profile', query: { inviteFill: '1' } })
@@ -112,10 +111,9 @@ export function useHomePage() {
return {
router,
profileLabel,
plusOpen,
avatarUrl,
tips,
tipsLoading,
searchHint,
...toRefs(grid),
feeds: homeFeeds,
goPlus,
@@ -1,4 +1,4 @@
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import type { GrowthReport } from '@yuxingu/types'
import { validateBirth } from '@yuxingu/utils'
@@ -6,6 +6,7 @@ import { api } from '../api/client'
import type { WuxingBar } from '../components/WuxingBars.vue'
import { AnalyticsEvent, track } from '../lib/analytics'
import { ensureAccount, loadSelfLatest, ensureSelfProfile } from '../lib/authSession'
import { localDayKey, scheduleAtValidUntil } from '../lib/scheduleRefresh'
export const resultTabs = [
{ key: 'overview' as const, label: '概览' },
@@ -26,6 +27,8 @@ export function useLifeRhythmPage() {
const year = ref(String(route.query.y || ''))
const month = ref(String(route.query.m || ''))
const day = ref(String(route.query.d || ''))
let dayTimer: ReturnType<typeof setTimeout> | null = null
let loadedDay = localDayKey()
const summary = computed(() => (report.value?.summary || {}) as Record<string, unknown>)
const detail = computed(() => (report.value?.detail || null) as Record<string, unknown> | null)
@@ -63,6 +66,47 @@ export function useLifeRhythmPage() {
.filter((b) => b.el)
})
function clearDayTimer() {
if (dayTimer) clearTimeout(dayTimer)
dayTimer = null
}
function scheduleDayRefresh() {
clearDayTimer()
if (!report.value) return
const s = (report.value.summary || {}) as Record<string, unknown>
loadedDay = String(s.as_of || localDayKey())
const until = s.valid_until ? String(s.valid_until) : undefined
dayTimer = scheduleAtValidUntil(until, () => {
void silentRefresh()
})
}
async function silentRefresh() {
if (!report.value) return
try {
const reportId = String(route.query.report_id || '')
if (reportId) {
report.value = await api.getReport(reportId)
} else {
const cached = await loadSelfLatest('rhythm')
if (cached.report) report.value = cached.report
}
scheduleDayRefresh()
} catch {
/* keep current */
}
}
function onVisibility() {
if (document.visibilityState !== 'visible' || !report.value) return
if (localDayKey() !== loadedDay) {
void silentRefresh()
return
}
scheduleDayRefresh()
}
async function generate(y: number, m: number, d: number) {
loading.value = true
error.value = ''
@@ -72,7 +116,8 @@ export function useLifeRhythmPage() {
const profile = await ensureSelfProfile({ birth_date: birth, display_name: '我' })
report.value = await api.createRhythm(profile.id)
tab.value = 'overview'
track(AnalyticsEvent.PortraitCompleted, { source: 'rhythm' })
scheduleDayRefresh()
track(AnalyticsEvent.RhythmCompleted)
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
needBirth.value = true
@@ -102,6 +147,7 @@ export function useLifeRhythmPage() {
try {
report.value = await api.getReport(reportId)
tab.value = 'overview'
scheduleDayRefresh()
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
needBirth.value = true
@@ -117,12 +163,14 @@ export function useLifeRhythmPage() {
report.value = cached.report
needBirth.value = false
tab.value = 'overview'
scheduleDayRefresh()
return
}
if (cached.profile) {
report.value = await api.createRhythm(cached.profile.id)
needBirth.value = false
tab.value = 'overview'
scheduleDayRefresh()
return
}
const y = Number(route.query.y)
@@ -151,6 +199,7 @@ export function useLifeRhythmPage() {
const { order_id } = await api.createOrder({ kind: 'deep_access', report_id: report.value.id })
await api.payMock(order_id)
report.value = await api.getReport(report.value.id)
scheduleDayRefresh()
track(AnalyticsEvent.PurchaseCompleted, { kind: 'deep_access' })
} catch (e) {
error.value = e instanceof Error ? e.message : '支付失败'
@@ -159,7 +208,14 @@ export function useLifeRhythmPage() {
}
}
onMounted(load)
onMounted(() => {
void load()
document.addEventListener('visibilitychange', onVisibility)
})
onUnmounted(() => {
clearDayTimer()
document.removeEventListener('visibilitychange', onVisibility)
})
return {
loading,
+143
View File
@@ -0,0 +1,143 @@
import { computed, onMounted, ref } from 'vue'
import type { AuthUser, MembershipMe } from '@yuxingu/types'
import { api } from '../api/client'
import { apiAssetURL } from '../lib/apiAsset'
import { AnalyticsEvent, track } from '../lib/analytics'
import { clearAuthToken } from '../lib/authSession'
import { DEFAULT_AVATAR_SRC } from '../lib/defaultAvatar'
import { mineGroupArchive, mineGroupGrowth } from '../lib/mineCatalog'
function avatarFailReason(msg: string): string {
const m = msg.toLowerCase()
if (m.includes('2mb') || m.includes('过大') || m.includes('too large') || m.includes('size')) {
return 'too_large'
}
if (m.includes('type') || m.includes('格式') || m.includes('jpeg') || m.includes('png') || m.includes('webp')) {
return 'bad_type'
}
if (m.includes('network') || m.includes('fetch') || m.includes('网络')) return 'network'
if (m.includes('401') || m.includes('403') || m.includes('500') || m.includes('服务')) return 'server'
return 'unknown'
}
export function useMinePage() {
const loading = ref(true)
const error = ref('')
const membership = ref<MembershipMe | null>(null)
const profileCount = ref(0)
const me = ref<AuthUser | null>(null)
const sheetOpen = ref(false)
const avatarBusy = ref(false)
const avatarErr = ref('')
const avatarSource = ref<'album' | 'camera'>('album')
const loggedIn = computed(() => !!me.value)
const accountLabel = computed(() => me.value?.nickname || me.value?.phone || '未登录')
const avatarSrc = computed(() => apiAssetURL(me.value?.avatar_url) || DEFAULT_AVATAR_SRC)
const planLabel = computed(() => {
const p = membership.value?.plan
if (p === 'quarter') return '季卡'
if (p === 'year') return '年卡'
if (p === 'month') return '月卡'
return '会员中'
})
function openAvatarSheet() {
if (!loggedIn.value) return
avatarErr.value = ''
sheetOpen.value = true
track(AnalyticsEvent.AvatarSheetOpened, { surface: 'mine' })
}
function onAvatarPickSource(source: 'album' | 'camera') {
avatarSource.value = source
}
async function onFilePicked(ev: Event) {
const input = ev.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
const source = avatarSource.value
sheetOpen.value = false
avatarBusy.value = true
avatarErr.value = ''
try {
me.value = await api.authUploadAvatar(file, file.name || 'avatar.jpg')
track(AnalyticsEvent.AvatarUploadSucceeded, { source })
} catch (e) {
const msg = e instanceof Error ? e.message : '上传失败'
avatarErr.value = msg
sheetOpen.value = true
track(AnalyticsEvent.AvatarUploadFailed, {
source,
reason: avatarFailReason(msg),
})
} finally {
avatarBusy.value = false
}
}
async function load() {
loading.value = true
error.value = ''
try {
me.value = await api.authMe()
} catch {
me.value = null
membership.value = null
profileCount.value = 0
loading.value = false
return
}
try {
const [m, profiles] = await Promise.all([api.getMembership(), api.listProfiles()])
membership.value = m
profileCount.value = (profiles.items || []).length
} catch (e) {
membership.value = null
profileCount.value = 0
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
async function logout() {
try {
await api.authLogout()
} catch {
/* ignore */
}
clearAuthToken()
me.value = null
membership.value = null
profileCount.value = 0
track(AnalyticsEvent.AuthLogout, { surface: 'mine' })
await load()
}
onMounted(load)
return {
groupArchive: mineGroupArchive,
groupGrowth: mineGroupGrowth,
loading,
error,
membership,
profileCount,
sheetOpen,
avatarBusy,
avatarErr,
loggedIn,
accountLabel,
avatarSrc,
planLabel,
openAvatarSheet,
onAvatarPickSource,
onFilePicked,
load,
logout,
}
}
@@ -3,6 +3,7 @@ import { useRoute, useRouter } from 'vue-router'
import type { Profile } from '@yuxingu/types'
import { api } from '../api/client'
import { ensureAccount } from '../lib/authSession'
import { setAccountNickname } from '../lib/accountNickname'
import {
avatarInitial,
createProfilePageActions,
@@ -106,6 +107,7 @@ export function useProfilePage() {
function onNicknameUpdated(n: string) {
accountNickname.value = n
setAccountNickname(n)
}
onMounted(async () => {
@@ -113,6 +115,7 @@ export function useProfilePage() {
try {
const me = await api.authMe()
accountNickname.value = me.nickname || ''
setAccountNickname(accountNickname.value)
} catch {
accountNickname.value = ''
}
+54 -2
View File
@@ -1,8 +1,9 @@
import { computed, onMounted, ref, watch } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import type { GrowthReport } from '@yuxingu/types'
import { api } from '../api/client'
import { ensureAccount } from '../lib/authSession'
import { localDayKey, scheduleAtValidUntil } from '../lib/scheduleRefresh'
import type { WheelAspect, WheelPlanet } from '../components/NatalWheel.vue'
import { AnalyticsEvent, track } from '../lib/analytics'
import {
@@ -43,6 +44,8 @@ export function useReportPage() {
const panelKey = ref('chart')
const signTab = ref('sun')
const fortuneKey = ref<(typeof reportFortuneKeys)[number]>('daily')
let dayTimer: ReturnType<typeof setTimeout> | null = null
let loadedDay = localDayKey()
const summary = computed(() => (report.value?.summary || {}) as Record<string, unknown>)
const detail = computed(() => (report.value?.detail || null) as Record<string, unknown> | null)
@@ -89,10 +92,50 @@ export function useReportPage() {
buildReportSharePayload(report.value, summary.value, headline.value, oneLiner.value, keywords.value),
)
function clearDayTimer() {
if (dayTimer) clearTimeout(dayTimer)
dayTimer = null
}
function scheduleDayRefresh() {
clearDayTimer()
const typ = report.value?.type
if (typ !== 'star' && typ !== 'rhythm') return
const s = (report.value?.summary || {}) as Record<string, unknown>
loadedDay = String(s.as_of || localDayKey())
const until = s.valid_until ? String(s.valid_until) : undefined
dayTimer = scheduleAtValidUntil(until, () => {
void silentRefresh()
})
}
async function silentRefresh() {
const id = String(route.params.id || '')
if (!id) return
try {
report.value = await api.getReport(id)
scheduleDayRefresh()
} catch {
/* keep */
}
}
function onVisibility() {
if (document.visibilityState !== 'visible' || !report.value) return
const typ = report.value.type
if (typ !== 'star' && typ !== 'rhythm') return
if (localDayKey() !== loadedDay) {
void silentRefresh()
return
}
scheduleDayRefresh()
}
async function load() {
loading.value = true
error.value = ''
report.value = null
clearDayTimer()
if (!(await ensureAccount(router, route.fullPath))) {
loading.value = false
return
@@ -104,6 +147,7 @@ export function useReportPage() {
panelKey.value = report.value.type === 'synastry' ? 'compare' : 'chart'
signTab.value = 'sun'
fortuneKey.value = 'daily'
scheduleDayRefresh()
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
@@ -119,6 +163,7 @@ export function useReportPage() {
const { order_id } = await api.createOrder({ kind: 'deep_access', report_id: report.value.id })
await api.payMock(order_id)
report.value = await api.getReport(report.value.id)
scheduleDayRefresh()
track(AnalyticsEvent.PurchaseCompleted, { kind: 'deep_access' })
} catch (e) {
error.value = e instanceof Error ? e.message : '支付失败'
@@ -128,7 +173,14 @@ export function useReportPage() {
}
watch(() => route.params.id, load)
onMounted(load)
onMounted(() => {
void load()
document.addEventListener('visibilitychange', onVisibility)
})
onUnmounted(() => {
clearDayTimer()
document.removeEventListener('visibilitychange', onVisibility)
})
return {
loading,
+118 -12
View File
@@ -4,20 +4,39 @@ import { api } from '../api/client'
import { ensureAccount, findSelfProfile } from '../lib/authSession'
import { clearScaleDraft, loadScaleDraft, saveScaleDraft } from '../lib/scaleDraft'
import type { PortraitSharePayload } from '../lib/shareLink'
import { AnalyticsEvent, track } from '../lib/analytics'
function scaleAccessLabel(raw: string): string {
if (raw === 'membership' || raw === 'member') return 'member'
return 'free'
}
export function useScalePage() {
const route = useRoute()
const router = useRouter()
const slug = String(route.params.slug || '')
const slug = computed(() => String(route.params.slug || ''))
const title = ref('')
const description = ref('')
const questions = ref<{ id: string; body: { prompt: string; options: { key: string; text: string }[] } }[]>([])
const questions = ref<
{
id: string
body: {
prompt: string
format?: string
left?: string
right?: string
options: { key: string; text: string }[]
}
}[]
>([])
const answers = reactive<Record<string, string>>({})
const loadingScale = ref(true)
const submitting = ref(false)
const loadError = ref('')
const submitError = ref('')
const needProfile = ref(false)
const locked = ref(false)
const access = ref('free')
const phase = ref<'intro' | 'answering' | 'result'>('intro')
const resultLabel = ref('')
const shareLine = ref('')
@@ -39,7 +58,9 @@ export function useScalePage() {
answers,
() => {
if (!persistReady.value || phase.value === 'result') return
saveScaleDraft(slug, { ...answers })
const s = slug.value
if (!s) return
saveScaleDraft(s, { ...answers })
},
{ deep: true },
)
@@ -76,14 +97,60 @@ export function useScalePage() {
}
})
function applyResult(out: { result: Record<string, unknown> }) {
resultRaw.value = out.result
resultLabel.value = String(out.result.label || '探索结果')
shareLine.value = String(out.result.share_line || '')
phase.value = 'result'
}
function clearAnswers() {
for (const k of Object.keys(answers)) delete answers[k]
}
function resetViewState() {
persistReady.value = false
draftRestored.value = false
resultRaw.value = null
resultLabel.value = ''
shareLine.value = ''
submitError.value = ''
needProfile.value = false
locked.value = false
currentIdx.value = 0
clearAnswers()
phase.value = 'intro'
}
function firstUnansweredIdx(): number {
const idx = questions.value.findIndex((q) => !answers[q.id])
return idx >= 0 ? idx : 0
}
function startTest() {
if (locked.value) return
phase.value = 'answering'
currentIdx.value = draftRestored.value ? firstUnansweredIdx() : 0
track(AnalyticsEvent.ScaleStarted, { slug: slug.value, access: scaleAccessLabel(access.value) })
}
function startFull() {
router.push('/scales/mbti-full')
}
function retake() {
const s = slug.value
clearScaleDraft(s)
clearAnswers()
draftRestored.value = false
resultRaw.value = null
resultLabel.value = ''
shareLine.value = ''
submitError.value = ''
needProfile.value = false
currentIdx.value = 0
phase.value = 'intro'
track(AnalyticsEvent.ScaleRetakeClicked, { slug: s })
}
function prevQ() {
@@ -99,22 +166,52 @@ export function useScalePage() {
}
async function load() {
const s = slug.value
loadingScale.value = true
loadError.value = ''
needProfile.value = false
resetViewState()
if (!s) {
loadError.value = '无效量表'
loadingScale.value = false
return
}
if (!(await ensureAccount(router, route.fullPath))) {
loadingScale.value = false
return
}
try {
const d = await api.getScale(slug)
const d = await api.getScale(s)
title.value = d.title
description.value = d.description
questions.value = d.questions.map((q) => ({
access.value = d.access || 'free'
locked.value = !!d.locked
questions.value = (d.questions || []).map((q) => ({
id: q.id,
body: typeof q.body === 'string' ? JSON.parse(q.body as unknown as string) : q.body,
}))
const draft = loadScaleDraft(slug)
if (locked.value) {
phase.value = 'intro'
persistReady.value = true
track(AnalyticsEvent.ScaleLockedViewed, { slug: s })
return
}
try {
const latest = await api.getScaleResult(s)
clearScaleDraft(s)
clearAnswers()
draftRestored.value = false
applyResult(latest)
persistReady.value = true
track(AnalyticsEvent.ScaleResultReopened, { slug: s })
return
} catch {
/* no prior result · continue intro / draft */
}
const draft = loadScaleDraft(s)
if (draft) {
const ids = new Set(questions.value.map((q) => q.id))
let n = 0
@@ -136,9 +233,11 @@ export function useScalePage() {
function openShare() {
shareOpen.value = true
track(AnalyticsEvent.ScaleShareClicked, { slug: slug.value })
}
async function submit() {
const s = slug.value
submitting.value = true
submitError.value = ''
needProfile.value = false
@@ -151,13 +250,12 @@ export function useScalePage() {
needProfile.value = true
throw new Error('请先在首页完成性格探索,创建个人档案')
}
const out = await api.submitScale(slug, self.id, { ...answers })
clearScaleDraft(slug)
const out = await api.submitScale(s, self.id, { ...answers })
clearScaleDraft(s)
draftRestored.value = false
phase.value = 'result'
resultRaw.value = out.result as Record<string, unknown>
resultLabel.value = String(out.result.label || '探索结果')
shareLine.value = String(out.result.share_line || '')
clearAnswers()
applyResult(out)
track(AnalyticsEvent.ScaleCompleted, { slug: s, access: scaleAccessLabel(access.value) })
} catch (e) {
submitError.value = e instanceof Error ? e.message : '提交失败'
} finally {
@@ -170,8 +268,12 @@ export function useScalePage() {
}
onMounted(load)
watch(slug, (next, prev) => {
if (next && next !== prev) void load()
})
return {
slug,
title,
description,
questions,
@@ -181,6 +283,8 @@ export function useScalePage() {
loadError,
submitError,
needProfile,
locked,
access,
phase,
resultLabel,
shareLine,
@@ -196,6 +300,8 @@ export function useScalePage() {
sharePayload,
load,
startTest,
startFull,
retake,
prevQ,
onNext,
openShare,
@@ -1,4 +1,4 @@
import { computed, onMounted, ref, watch } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import type { GrowthReport } from '@yuxingu/types'
import { validateBirth } from '@yuxingu/utils'
@@ -6,6 +6,7 @@ import { api } from '../api/client'
import type { WheelAspect, WheelPlanet } from '../components/NatalWheel.vue'
import { AnalyticsEvent, track } from '../lib/analytics'
import { ensureAccount, loadSelfLatest, ensureSelfProfile } from '../lib/authSession'
import { localDayKey, scheduleAtValidUntil } from '../lib/scheduleRefresh'
import {
ascLonFromSummary,
fortuneBundleFrom,
@@ -49,6 +50,8 @@ export function useStarProfilePage() {
const settings = loadWheelSettings()
const showOuter = ref(settings.showOuter)
const tightOrb = ref(settings.tightOrb)
let dayTimer: ReturnType<typeof setTimeout> | null = null
let loadedDay = localDayKey()
watch([showOuter, tightOrb], () => {
saveWheelSettings(showOuter.value, tightOrb.value)
@@ -85,6 +88,47 @@ export function useStarProfilePage() {
return { type: 'portrait', title: headline.value || '我的星座', line: oneLiner.value, keywords: keywords.value }
})
function clearDayTimer() {
if (dayTimer) clearTimeout(dayTimer)
dayTimer = null
}
function scheduleDayRefresh() {
clearDayTimer()
if (!report.value) return
const s = (report.value.summary || {}) as Record<string, unknown>
loadedDay = String(s.as_of || localDayKey())
const until = s.valid_until ? String(s.valid_until) : undefined
dayTimer = scheduleAtValidUntil(until, () => {
void silentRefresh()
})
}
async function silentRefresh() {
if (!report.value) return
try {
const reportId = String(route.query.report_id || '')
if (reportId) {
report.value = await api.getReport(reportId)
} else {
const cached = await loadSelfLatest('star')
if (cached.report) report.value = cached.report
}
scheduleDayRefresh()
} catch {
/* keep current */
}
}
function onVisibility() {
if (document.visibilityState !== 'visible' || !report.value) return
if (localDayKey() !== loadedDay) {
void silentRefresh()
return
}
scheduleDayRefresh()
}
function onWheelSelect(key: string) {
if (['sun', 'moon', 'rise'].includes(key)) signTab.value = key
track(AnalyticsEvent.StarWheelViewed, { planet: key })
@@ -102,6 +146,7 @@ export function useStarProfilePage() {
function resetForm() {
needBirth.value = true
report.value = null
clearDayTimer()
}
async function generate(y: number, m: number, d: number) {
@@ -119,7 +164,8 @@ export function useStarProfilePage() {
})
report.value = await api.createStar(profile.id)
view.value = 'overview'
track(AnalyticsEvent.PortraitCompleted, { source: 'star' })
scheduleDayRefresh()
track(AnalyticsEvent.StarCompleted)
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
needBirth.value = true
@@ -149,6 +195,7 @@ export function useStarProfilePage() {
try {
report.value = await api.getReport(reportId)
view.value = 'overview'
scheduleDayRefresh()
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
needBirth.value = true
@@ -164,12 +211,14 @@ export function useStarProfilePage() {
report.value = cached.report
needBirth.value = false
view.value = 'overview'
scheduleDayRefresh()
return
}
if (cached.profile) {
report.value = await api.createStar(cached.profile.id)
needBirth.value = false
view.value = 'overview'
scheduleDayRefresh()
return
}
const y = Number(route.query.y)
@@ -198,6 +247,7 @@ export function useStarProfilePage() {
const { order_id } = await api.createOrder({ kind: 'deep_access', report_id: report.value.id })
await api.payMock(order_id)
report.value = await api.getReport(report.value.id)
scheduleDayRefresh()
track(AnalyticsEvent.PurchaseCompleted, { kind: 'deep_access' })
} catch (e) {
error.value = e instanceof Error ? e.message : '支付失败'
@@ -206,7 +256,14 @@ export function useStarProfilePage() {
}
}
onMounted(load)
onMounted(() => {
void load()
document.addEventListener('visibilitychange', onVisibility)
})
onUnmounted(() => {
clearDayTimer()
document.removeEventListener('visibilitychange', onVisibility)
})
return {
loading,
@@ -19,6 +19,7 @@ import {
type SynastryMainTab,
} from '../lib/synastryChart'
import { ensureAccount } from '../lib/authSession'
import { useAccountNickname } from '../lib/accountNickname'
import { createSynastryPageActions } from './synastryPageActions'
export type MainTab = SynastryMainTab
@@ -60,11 +61,12 @@ export function useSynastryPage() {
profiles.value.filter((p) => p.relation === 'other' && p.id !== profileA.value),
)
const selfProfiles = computed(() => profiles.value.filter((p) => p.relation === 'self'))
const { selfLabel, selfInitial } = useAccountNickname()
const profileAName = computed(() => {
const p = profiles.value.find((x) => x.id === profileA.value)
if (p?.relation === 'self') return selfLabel.value
return p?.display_name || ''
})
const selfInitial = computed(() => (profileAName.value || '我').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)
@@ -183,6 +185,7 @@ export function useSynastryPage() {
pickableProfiles,
selfProfiles,
profileAName,
selfLabel,
selfInitial,
profileInitial,
headline,
+45
View File
@@ -0,0 +1,45 @@
import { computed, ref } from 'vue'
import { api } from '../api/client'
const nickname = ref('')
let inflight: Promise<string> | null = null
/** Load/cache account nickname for「自己」UI labels. */
export async function loadAccountNickname(force = false): Promise<string> {
if (nickname.value && !force) return nickname.value
if (!inflight || force) {
inflight = api
.authMe()
.then((me) => {
nickname.value = (me.nickname || '').trim()
return nickname.value
})
.catch(() => nickname.value)
.finally(() => {
inflight = null
})
}
return inflight
}
export function setAccountNickname(v: string) {
nickname.value = (v || '').trim()
}
/** Prefer nickname; fall back for guests / not loaded. */
export function selfDisplayName(fallback = '我'): string {
return nickname.value || fallback
}
export function useAccountNickname() {
void loadAccountNickname()
const selfLabel = computed(() => selfDisplayName())
const selfInitial = computed(() => selfLabel.value.slice(0, 1) || '我')
return {
nickname,
selfLabel,
selfInitial,
refresh: () => loadAccountNickname(true),
setNickname: setAccountNickname,
}
}
+7 -3
View File
@@ -33,9 +33,13 @@ describe('analytics.track', () => {
expect(window.gtag).toHaveBeenCalledWith('event', 'home_cta_portrait', {})
})
it('omits undefined params', () => {
track(AnalyticsEvent.PortraitCompleted, { source: 'query', report_id: undefined })
expect(window.gtag).toHaveBeenCalledWith('event', 'portrait_completed', { source: 'query' })
it('scrubs disallowed props before gtag and queue', () => {
track(AnalyticsEvent.SynastryInviteCreated, { token: 'secret', source: 'synastry' })
expect(window.gtag).toHaveBeenCalledWith('event', 'synastry_invite_created', {
source: 'synastry',
})
const q = _peekQueueForTests()
expect(q[0].props).toEqual({ source: 'synastry' })
})
it('does not throw when gtag missing', () => {
+65 -15
View File
@@ -7,7 +7,7 @@ import { api } from '@/api/client'
export type AnalyticsParams = Record<string, string | number | boolean | undefined>
/** Frozen P1 core funnel events (+ page_view via router). */
/** Frozen P1 core funnel events (+ page_view via router) + Ops feature events. */
export const AnalyticsEvent = {
HomeCtaPortrait: 'home_cta_portrait',
PortraitCompleted: 'portrait_completed',
@@ -19,15 +19,76 @@ export const AnalyticsEvent = {
SynastryInviteAccepted: 'synastry_invite_accepted',
SynastryNearbyOpened: 'synastry_nearby_opened',
StarWheelViewed: 'star_wheel_viewed',
StarCompleted: 'star_completed',
RhythmCompleted: 'rhythm_completed',
PageView: 'page_view',
SessionStart: 'session_start',
SessionEnd: 'session_end',
PageLeave: 'page_leave',
UiClick: 'ui_click',
AuthRegister: 'auth_register',
AuthLogin: 'auth_login',
AuthLogout: 'auth_logout',
AvatarSheetOpened: 'avatar_sheet_opened',
AvatarUploadSucceeded: 'avatar_upload_succeeded',
AvatarUploadFailed: 'avatar_upload_failed',
NicknameUpdated: 'nickname_updated',
ScaleListViewed: 'scale_list_viewed',
ScaleBankCategoryViewed: 'scale_bank_category_viewed',
ScaleStarted: 'scale_started',
ScaleCompleted: 'scale_completed',
ScaleResultReopened: 'scale_result_reopened',
ScaleRetakeClicked: 'scale_retake_clicked',
ScaleLockedViewed: 'scale_locked_viewed',
ScaleShareClicked: 'scale_share_clicked',
ScaleCtaRelation: 'scale_cta_relation',
ScaleCtaAsk: 'scale_cta_ask',
GrowthPlanViewed: 'growth_plan_viewed',
GrowthPlanCreated: 'growth_plan_created',
GrowthPlanCheckin: 'growth_plan_checkin',
} as const
export type CoreAnalyticsEvent = (typeof AnalyticsEvent)[keyof typeof AnalyticsEvent]
/** Align with apps/api analytics allowedPropKeys — drop PII / capability tokens before GA + queue. */
const ALLOWED_PROP_KEYS = new Set([
'page_path',
'page_title',
'referrer_path',
'dwell_ms',
'element_id',
'exit_page',
'duration_ms',
'cold',
'app_ver',
'source',
'kind',
'surface',
'label',
'plan',
'count',
'depth',
'scene',
'score',
'planet',
'report_id',
'slug',
'category',
'reason',
'access',
])
function scrubParams(params?: AnalyticsParams): Record<string, string | number | boolean> {
const clean: Record<string, string | number | boolean> = {}
if (!params) return clean
for (const [k, v] of Object.entries(params)) {
if (v === undefined) continue
if (!ALLOWED_PROP_KEYS.has(k)) continue
clean[k] = v
}
return clean
}
declare global {
interface Window {
dataLayer?: unknown[]
@@ -122,15 +183,9 @@ export function ensureSession(forceNew = false): string {
function enqueue(name: string, params?: AnalyticsParams): void {
if (!ownEnabled) return
const sid = ensureSession()
const clean: Record<string, string | number | boolean> = {}
const clean = scrubParams(params)
let pagePath = currentPath
if (params) {
for (const [k, v] of Object.entries(params)) {
if (v === undefined) continue
clean[k] = v
if (k === 'page_path' && typeof v === 'string') pagePath = v
}
}
if (typeof clean.page_path === 'string') pagePath = clean.page_path
queue.push({
name,
session_id: sid,
@@ -218,12 +273,7 @@ function bindLifecycle(): void {
/** Fire a custom event. Dual-write GA + own queue; never throws. */
export function track(event: string, params?: AnalyticsParams): void {
try {
const clean: Record<string, string | number | boolean> = {}
if (params) {
for (const [k, v] of Object.entries(params)) {
if (v !== undefined) clean[k] = v
}
}
const clean = scrubParams(params)
if (debugEnabled()) console.debug('[analytics]', event, clean)
if (typeof window !== 'undefined' && typeof window.gtag === 'function') {
window.gtag('event', event, clean)
+7
View File
@@ -0,0 +1,7 @@
/** Map API-relative asset path to H5 URL (base /psy). */
export function apiAssetURL(path?: string | null): string {
const p = (path || '').trim()
if (!p) return ''
if (/^https?:\/\//i.test(p)) return p
return '/psy' + (p.startsWith('/') ? p : `/${p}`)
}
+13 -4
View File
@@ -1,6 +1,7 @@
import type { AuthUser, GrowthReport, Profile } from '@yuxingu/types'
import type { Router } from 'vue-router'
import { api } from '../api/client'
import { loadAccountNickname, selfDisplayName } from './accountNickname'
const TOKEN_KEY = 'yxg_token'
@@ -39,11 +40,18 @@ export async function ensureSelfProfile(input: {
birth_time?: string
birth_place?: string
}): Promise<Profile> {
await loadAccountNickname()
const nick = selfDisplayName('我')
const nameIn = (input.display_name || '').trim()
const preferred = !nameIn || nameIn === '我' ? nick : nameIn
const existing = await findSelfProfile()
if (existing) {
const keep = existing.display_name?.trim()
const display =
keep && keep !== '我' ? keep : preferred
return api.updateProfile(existing.id, {
// Keep user-edited archive name; only set name on create.
display_name: existing.display_name || input.display_name || '我',
display_name: display,
birth_date: input.birth_date,
birth_time: input.birth_time,
birth_place: input.birth_place,
@@ -53,7 +61,7 @@ export async function ensureSelfProfile(input: {
return await api.createProfile({
relation: 'self',
birth_date: input.birth_date,
display_name: input.display_name || '我',
display_name: preferred,
birth_time: input.birth_time,
birth_place: input.birth_place,
})
@@ -65,8 +73,9 @@ export async function ensureSelfProfile(input: {
}
const again = await findSelfProfile()
if (!again) throw e
const keep = again.display_name?.trim()
return api.updateProfile(again.id, {
display_name: again.display_name || input.display_name || '我',
display_name: keep && keep !== '我' ? keep : preferred,
birth_date: input.birth_date,
birth_time: input.birth_time,
birth_place: input.birth_place,
+2
View File
@@ -0,0 +1,2 @@
/** 未上传头像时的愈心小人正面(从 public/logo/愈心小人1.png 抠图) */
export const DEFAULT_AVATAR_SRC = `${import.meta.env.BASE_URL}default-avatar.png`
+3 -5
View File
@@ -20,7 +20,7 @@ export type HomeFeed = {
/** 对标测测 12 宫格 · 软立体图标 */
export const homeGridRow1: HomeTool[] = [
{ to: '/scales/mbti-lite', icon: 'mbti', label: '人格测试' },
{ to: '/scales/mbti-lite', icon: 'mbti', label: 'MBTI测试' },
{ to: '/star', icon: 'star', label: '星座' },
{ to: '/portrait', icon: 'portrait', label: '愈心解码', badge: '热', badgeTone: 'hot' },
{ to: '/rhythm', icon: 'rhythm', label: '身心节律' },
@@ -76,8 +76,8 @@ export const homeFeeds: HomeFeed[] = [
{
to: '/scales/mbti-lite',
icon: 'mbti',
title: '人格测试',
meta: '16 型人格,看见自己的相处模式',
title: 'MBTI测试',
meta: '四维偏好探索,看见自己的能量与决策节奏',
stat: '热门测评',
tone: 'fc-a',
},
@@ -90,5 +90,3 @@ export const homeFeeds: HomeFeed[] = [
tone: 'fc-e',
},
]
export const homeSearchHints = ['今日穿衣指数', '愈心解码', '合盘了解彼此', 'AI 成长助手']
+41
View File
@@ -0,0 +1,41 @@
export type HomeToolIconName =
| 'mbti'
| 'star'
| 'portrait'
| 'rhythm'
| 'synastry'
| 'astro'
| 'companion'
| 'ask'
| 'cards'
| 'reports'
| 'growth'
| 'relation'
| 'nine'
| 'eq'
| 'stress'
| 'sleep'
const ALLOW: readonly HomeToolIconName[] = [
'mbti',
'star',
'portrait',
'rhythm',
'synastry',
'ask',
'cards',
'reports',
'growth',
'relation',
'companion',
'astro',
'nine',
'eq',
'stress',
'sleep',
] as const
/** Map API/catalog icon tokens to known HomeToolIcon names. */
export function resolveHomeToolIconName(raw: string, fallback: HomeToolIconName = 'mbti'): HomeToolIconName {
return ALLOW.includes(raw as HomeToolIconName) ? (raw as HomeToolIconName) : fallback
}
+20
View File
@@ -0,0 +1,20 @@
import type { HomeToolIconName } from '../components/HomeToolIcon.vue'
export type MineNavItem = { to: string; label: string; icon: HomeToolIconName }
export const mineGroupArchive: MineNavItem[] = [
{ 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' },
]
export const mineGroupGrowth: MineNavItem[] = [
{ 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' },
]
+19
View File
@@ -0,0 +1,19 @@
/** Schedule a one-shot callback at validUntil (or next local midnight). */
export function nextLocalMidnight(d = new Date()) {
const end = new Date(d)
end.setHours(24, 0, 0, 0)
return end
}
export function localDayKey(d = new Date()) {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
export function scheduleAtValidUntil(
validUntil: string | undefined,
onFire: () => void,
): ReturnType<typeof setTimeout> {
const end = validUntil ? new Date(validUntil).getTime() : nextLocalMidnight().getTime()
const ms = Math.max(1000, end - Date.now() + 400)
return setTimeout(onFire, ms)
}
+1 -1
View File
@@ -15,6 +15,6 @@ export function toolIconFor(pathOrKey: string): HomeToolIconName {
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'
if (s.includes('explore') || s.includes('探索') || s.includes('分类')) return 'mbti'
return 'portrait'
}
+2
View File
@@ -39,6 +39,8 @@ describe('AskPage', () => {
api.getAskQuota.mockResolvedValue({ remaining: 3, free_limit: 3, active_membership: false, source: 'free' })
const w = mount(AskPage, { global: { stubs: { RouterLink: RouterLinkStub } } })
await flushPromises()
expect(w.text()).toContain('问答')
expect(w.text()).toContain('AI 成长助手')
expect(w.text()).toContain('先建立你的个人档案')
expect(w.text()).toContain('去愈心解码')
})
+69 -14
View File
@@ -2,8 +2,12 @@
<PageShell :sheet="false">
<template #hero>
<div class="ask-hero">
<div class="ask-top">
<BackButton />
<div class="head">
<HomeToolIcon name="ask" :size="48" />
<div class="head-text">
<h1 class="title">问答</h1>
<p class="sub">AI 成长助手 · 结合档案聊聊</p>
</div>
<button
v-if="messages.length"
type="button"
@@ -11,19 +15,21 @@
:disabled="sending"
@click="clearChat"
>
清空聊天
清空
</button>
</div>
<AskRails v-model="rail" />
<AskRails v-if="!activeAdvisor" :model-value="rail" @update:model-value="setRail" />
<p v-else class="adv-title">{{ activeAdvisor.name }}的专属对话</p>
</div>
</template>
<AskAiPane
v-show="rail === 'ai'"
v-show="rail === 'ai' && !activeAdvisor"
:boot-loading="bootLoading"
:boot-error="bootError"
:profiles="profiles"
:profile-id="profileId"
:self-label="selfLabel"
:messages="messages"
:draft="draft"
:sending="sending"
@@ -32,7 +38,7 @@
:quota="quota"
@boot="boot"
@pick-scene="pickScene"
@switch-advisor="rail = 'advisor'"
@switch-advisor="setRail('advisor')"
@update:profile-id="profileId = $event"
@profile-change="onProfileChange"
@navigate="router.push($event)"
@@ -41,25 +47,55 @@
@quota-refreshed="refreshQuota"
/>
<AskAdvisorPane v-show="rail === 'advisor'" @ask="askAdvisor" />
<AskAdvisorPane
v-show="rail === 'advisor' && !activeAdvisor"
@ask="openAdvisor"
/>
<AskAdvisorChat
v-if="activeAdvisor"
:advisor="activeAdvisor"
:boot-loading="bootLoading"
:boot-error="bootError"
:profiles="profiles"
:profile-id="profileId"
:self-label="selfLabel"
:messages="messages"
:draft="draft"
:sending="sending"
:send-error="sendError"
:quota-exhausted="quotaExhausted"
:quota="quota"
@boot="boot"
@update:profile-id="profileId = $event"
@profile-change="onProfileChange"
@navigate="router.push($event)"
@update:draft="draft = $event"
@send="send"
@quota-refreshed="refreshQuota"
/>
</PageShell>
</template>
<script setup lang="ts">
import AskAdvisorChat from '../components/ask/AskAdvisorChat.vue'
import AskAdvisorPane from '../components/ask/AskAdvisorPane.vue'
import AskAiPane from '../components/ask/AskAiPane.vue'
import AskRails from '../components/ask/AskRails.vue'
import BackButton from '../components/BackButton.vue'
import HomeToolIcon from '../components/HomeToolIcon.vue'
import PageShell from '../components/PageShell.vue'
import { useAskPage } from '../composables/useAskPage'
const {
router,
rail,
setRail,
activeAdvisor,
bootLoading,
bootError,
profiles,
profileId,
selfLabel,
messages,
draft,
sending,
@@ -68,7 +104,7 @@ const {
quota,
onProfileChange,
pickScene,
askAdvisor,
openAdvisor,
send,
boot,
refreshQuota,
@@ -83,15 +119,28 @@ const {
gap: 10px;
padding-bottom: 4px;
}
.ask-top {
.head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 8px;
}
.ask-hero :deep(.back) {
margin-bottom: 0;
align-self: flex-start;
.head-text {
flex: 1;
min-width: 0;
}
.title {
font-family: var(--font-display);
font-size: 22px;
font-weight: 700;
letter-spacing: 0.06em;
color: var(--color-text-primary);
}
.sub {
margin-top: 2px;
font-size: 12px;
color: rgba(0, 0, 0, 0.38);
letter-spacing: 0.04em;
}
.clear-btn {
border: none;
@@ -104,4 +153,10 @@ const {
.clear-btn:disabled {
opacity: 0.4;
}
.adv-title {
margin: 0;
font-size: 14px;
font-weight: 600;
color: #666;
}
</style>
+24 -8
View File
@@ -2,8 +2,11 @@
<PageShell>
<template #hero>
<div class="head">
<h1 class="title">陪伴</h1>
<p class="sub">节气生活 · 心情记录</p>
<HomeToolIcon name="companion" :size="48" />
<div>
<h1 class="title">陪伴</h1>
<p class="sub">节气生活 · 心情记录</p>
</div>
</div>
</template>
@@ -92,6 +95,7 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { validateUserText } from '@yuxingu/utils'
import { api } from '../api/client'
import HomeToolIcon from '../components/HomeToolIcon.vue'
import PageShell from '../components/PageShell.vue'
@@ -145,7 +149,13 @@ async function save() {
moodErr.value = ''
saved.value = false
try {
await api.saveMood({ score: score.value, note: note.value || undefined })
let moodNote: string | undefined
if (note.value.trim()) {
const noteCheck = validateUserText('note', note.value)
if (!noteCheck.ok) throw new Error(noteCheck.message)
moodNote = noteCheck.value || undefined
}
await api.saveMood({ score: score.value, note: moodNote })
saved.value = true
track('mood_saved', { score: score.value })
const trail = await api.getMoodsRecent()
@@ -161,19 +171,25 @@ onMounted(load)
</script>
<style scoped>
.head { padding: 4px 0 8px; }
.head {
display: flex;
align-items: center;
gap: 12px;
margin-top: 8px;
padding-bottom: 8px;
}
.title {
font-family: var(--font-display);
font-size: 26px;
font-size: 22px;
font-weight: 700;
letter-spacing: 0.08em;
letter-spacing: 0.06em;
color: var(--color-text-primary);
}
.sub {
margin-top: 4px;
margin-top: 2px;
font-size: 12px;
color: rgba(0, 0, 0, 0.38);
letter-spacing: 0.06em;
letter-spacing: 0.04em;
}
.sec { padding: 0 16px; margin-top: 14px; }
.hero-card {
@@ -0,0 +1,160 @@
<template>
<PageShell>
<template #hero>
<div class="head">
<HomeToolIcon :name="heroIcon" :size="48" />
<div>
<h1 class="title">{{ cat?.title || '题库分类' }}</h1>
<p v-if="cat" class="sub">{{ cat.description }}</p>
</div>
</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>
<div v-else class="list">
<ListRow
v-for="it in items"
:key="it.slug"
:to="it.path"
:title="it.title"
:desc="`${it.description} · ${it.question_count} 题`"
:icon="iconName(it.icon || cat?.icon || 'mbti')"
/>
</div>
<p class="disc">题库内容仅供自我探索参考不构成心理或医学诊断</p>
</PageShell>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api } from '../api/client'
import HomeToolIcon from '../components/HomeToolIcon.vue'
import ListRow from '../components/ListRow.vue'
import PageShell from '../components/PageShell.vue'
import { ensureAccount } from '../lib/authSession'
import { resolveHomeToolIconName, type HomeToolIconName } from '../lib/homeToolIconName'
import { AnalyticsEvent, track } from '../lib/analytics'
type Cat = {
key: string
title: string
description: string
icon: string
count: number
path: string
}
type Item = {
slug: string
title: string
description: string
icon?: string
question_count: number
path: string
}
const route = useRoute()
const router = useRouter()
const cat = ref<Cat | null>(null)
const items = ref<Item[]>([])
const loading = ref(true)
const error = ref('')
/** Avoid double-count on retry for the same category key. */
let lastTrackedCategory = ''
const heroIcon = computed((): HomeToolIconName => iconName(cat.value?.icon || 'mbti'))
function iconName(raw: string): HomeToolIconName {
return resolveHomeToolIconName(raw)
}
async function load() {
loading.value = true
error.value = ''
cat.value = null
items.value = []
if (!(await ensureAccount(router, route.fullPath))) {
loading.value = false
return
}
try {
const key = String(route.params.category || '')
const res = await api.getScaleBankCategory(key)
cat.value = res.category
items.value = res.items || []
if (key && key !== lastTrackedCategory) {
lastTrackedCategory = key
track(AnalyticsEvent.ScaleBankCategoryViewed, { category: key })
}
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
watch(
() => route.params.category,
() => {
lastTrackedCategory = ''
void load()
},
)
onMounted(load)
</script>
<style scoped>
.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;
color: var(--color-text-primary);
}
.sub {
margin-top: 2px;
font-size: 12px;
color: rgba(0, 0, 0, 0.38);
}
.list {
margin: 0 16px;
background: #fafafa;
border-radius: var(--radius-lg);
padding: 4px 10px;
}
.list :deep(.lr) {
border-bottom: 1px solid #f0ebe8;
}
.list :deep(.lr:last-child) {
border-bottom: none;
}
.hint, .err {
padding: 16px;
font-size: 13px;
color: var(--color-text-secondary);
}
.err { color: var(--color-primary); }
.retry {
margin-left: 8px;
border: none;
background: transparent;
color: var(--color-accent-blue);
text-decoration: underline;
}
.disc {
margin: 16px;
font-size: 11px;
color: #aaa;
line-height: 1.45;
}
</style>
+18 -8
View File
@@ -2,9 +2,11 @@
<PageShell>
<template #hero>
<div class="head">
<BackButton />
<h1 class="title">{{ cat?.title || '分类' }}</h1>
<p v-if="cat" class="sub">{{ cat.description }}</p>
<HomeToolIcon :name="heroIcon" :size="48" />
<div>
<h1 class="title">{{ cat?.title || '分类' }}</h1>
<p v-if="cat" class="sub">{{ cat.description }}</p>
</div>
</div>
</template>
@@ -32,10 +34,9 @@
</template>
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api } from '../api/client'
import BackButton from '../components/BackButton.vue'
import HomeToolIcon from '../components/HomeToolIcon.vue'
import PageShell from '../components/PageShell.vue'
import { ensureAccount } from '../lib/authSession'
@@ -65,6 +66,10 @@ const cat = ref<Cat | null>(null)
const loading = ref(true)
const error = ref('')
const heroIcon = computed(() =>
toolIconFor(cat.value?.key || String(route.params.category || 'explore')),
)
async function load() {
loading.value = true
error.value = ''
@@ -87,9 +92,14 @@ watch(() => route.params.category, load)
</script>
<style scoped>
.head { padding: 0 0 8px; }
.head {
display: flex;
align-items: center;
gap: 12px;
margin-top: 8px;
padding-bottom: 8px;
}
.title {
margin-top: 4px;
font-family: var(--font-display);
font-size: 22px;
font-weight: 700;
@@ -97,7 +107,7 @@ watch(() => route.params.category, load)
letter-spacing: 0.06em;
}
.sub {
margin-top: 4px;
margin-top: 2px;
font-size: 12px;
color: rgba(0, 0, 0, 0.38);
}
+96 -78
View File
@@ -2,52 +2,68 @@
<PageShell>
<template #hero>
<div class="head">
<BackButton />
<h1 class="title">探索</h1>
<p class="sub">测评 · 工具 · 自我理解</p>
<HomeToolIcon name="mbti" :size="48" />
<div>
<h1 class="title">探索</h1>
<p class="sub">题库测评 · 工具 · 自我理解</p>
</div>
</div>
</template>
<!-- 推荐宫格对齐首页 12 + 截图更多 -->
<section class="sec">
<div class="sec-head">
<span class="sec-title">推荐工具</span>
<span class="sec-title">题库精选</span>
</div>
<div class="grid">
<p v-if="bankLoading" class="hint">加载中</p>
<p v-else-if="bankError" class="err">
{{ bankError }}
<button type="button" class="retry" @click="loadBank">重试</button>
</p>
<div v-else class="grid">
<router-link
v-for="t in featured"
:key="t.to + t.label"
:to="t.to"
:key="t.slug"
:to="t.path"
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>
<HomeToolIcon :name="iconName(t.icon)" :size="52" />
<span class="g-label">{{ t.title }}</span>
</router-link>
</div>
</section>
<section class="sec">
<div class="sec-head">
<span class="sec-title">全部分类</span>
<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="retry" @click="load">重试</button>
</p>
<div v-else class="cat-list">
<div v-if="!bankLoading && !bankError" class="cat-list">
<ListRow
v-for="c in categories"
:key="c.key"
:to="`/explore/${c.key}`"
:to="c.path"
:title="c.title"
:desc="`${c.description} · ${c.items?.length || 0} `"
:icon="toolIconFor(c.key)"
:badge="hotBadge(c)"
:desc="`${c.description} · ${c.count} `"
:icon="iconName(c.icon)"
/>
</div>
</section>
<section class="sec">
<div class="sec-head">
<span class="sec-title">其它工具</span>
</div>
<div class="grid">
<router-link
v-for="t in tools"
:key="t.to + t.label"
:to="t.to"
class="g-item"
>
<HomeToolIcon :name="t.icon" :size="52" />
<span class="g-label">{{ t.label }}</span>
</router-link>
</div>
</section>
</PageShell>
</template>
@@ -55,87 +71,106 @@
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api } from '../api/client'
import BackButton from '../components/BackButton.vue'
import HomeToolIcon, { type HomeToolIconName } from '../components/HomeToolIcon.vue'
import HomeToolIcon from '../components/HomeToolIcon.vue'
import ListRow from '../components/ListRow.vue'
import PageShell from '../components/PageShell.vue'
import { ensureAccount } from '../lib/authSession'
import { toolIconFor } from '../lib/toolIconMap'
import { resolveHomeToolIconName, type HomeToolIconName } from '../lib/homeToolIconName'
import { AnalyticsEvent, track } from '../lib/analytics'
type Featured = {
slug: string
title: string
description: string
category_key: string
icon: string
path: string
}
type Cat = {
key: string
title: string
description: string
icon: string
tone: string
items?: { badge?: string }[]
count: number
path: string
}
const router = useRouter()
const route = useRoute()
const featured: { to: string; label: string; icon: HomeToolIconName; badge?: string; badgeTone?: 'hot' | 'new' }[] = [
{ to: '/scales/mbti-lite', label: '人格测试', icon: 'mbti' },
const tools: { to: string; label: string; icon: HomeToolIconName }[] = [
{ to: '/star', label: '星座', icon: 'star' },
{ to: '/portrait', label: '愈心解码', icon: 'portrait', badge: '热', badgeTone: 'hot' },
{ to: '/portrait', label: '愈心解码', icon: 'portrait' },
{ to: '/rhythm', label: '身心节律', icon: 'rhythm' },
{ to: '/synastry', label: '合盘', icon: 'synastry', badge: '新', badgeTone: 'new' },
{ to: '/synastry', label: '合盘', icon: 'synastry' },
{ 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' },
{ to: '/companion', label: '节气陪伴', icon: 'companion' },
]
const featured = ref<Featured[]>([])
const categories = ref<Cat[]>([])
const loading = ref(true)
const error = ref('')
const bankLoading = ref(true)
const bankError = ref('')
function hotBadge(c: Cat) {
const b = c.items?.find((i) => i.badge)?.badge
return b || undefined
function iconName(raw: string): HomeToolIconName {
return resolveHomeToolIconName(raw)
}
async function load() {
loading.value = true
error.value = ''
if (!(await ensureAccount(router, route.fullPath))) {
loading.value = false
return
}
async function loadBank() {
bankLoading.value = true
bankError.value = ''
try {
const res = await api.getExploreCatalog()
const res = await api.getScaleBankCatalog()
featured.value = res.featured || []
categories.value = res.categories || []
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
bankError.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
bankLoading.value = false
}
}
onMounted(load)
onMounted(async () => {
if (!(await ensureAccount(router, route.fullPath))) {
bankLoading.value = false
return
}
await loadBank()
if (!bankError.value) track(AnalyticsEvent.ScaleListViewed)
})
</script>
<style scoped>
.head { padding: 4px 0 8px; }
.head {
display: flex;
align-items: center;
gap: 12px;
margin-top: 8px;
padding-bottom: 8px;
}
.title {
font-family: var(--font-display);
font-size: 26px;
font-size: 22px;
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;
}
.sub {
margin-top: 2px;
font-size: 12px;
color: rgba(0, 0, 0, 0.38);
letter-spacing: 0.04em;
}
.sec { padding: 0 16px; margin-top: 18px; }
.sec:first-child { margin-top: 12px; }
.sec-head { margin-bottom: 12px; }
.sec-head {
margin-bottom: 12px;
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.sec-title {
font-size: 17px;
font-weight: 700;
@@ -163,23 +198,6 @@ onMounted(load)
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);
+18 -6
View File
@@ -1,7 +1,6 @@
<template>
<PageShell>
<template #hero>
<BackButton />
<div class="head">
<HomeToolIcon name="growth" :size="48" />
<div>
@@ -16,7 +15,7 @@
<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="例如:保护睡眠与情绪" />
<input class="inp" v-model="focus" maxlength="40" placeholder="例如:保护睡眠与情绪" />
<button class="cta" type="button" :disabled="saving || !title.trim()" @click="create">创建计划</button>
<p v-if="err" class="err">{{ err }}</p>
</div>
@@ -42,11 +41,12 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { validateUserText } from '@yuxingu/utils'
import { api } from '../api/client'
import BackButton from '../components/BackButton.vue'
import HomeToolIcon from '../components/HomeToolIcon.vue'
import PageShell from '../components/PageShell.vue'
import { ensureAccount } from '../lib/authSession'
import { AnalyticsEvent, track } from '../lib/analytics'
const router = useRouter()
const route = useRoute()
@@ -59,7 +59,7 @@ const saving = ref(false)
const checking = ref('')
const err = ref('')
async function load() {
async function load(opts?: { trackView?: boolean }) {
loading.value = true
if (!(await ensureAccount(router, route.fullPath))) {
loading.value = false
@@ -76,6 +76,7 @@ async function load() {
err.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
if (opts?.trackView) track(AnalyticsEvent.GrowthPlanViewed)
}
}
@@ -83,9 +84,17 @@ async function create() {
saving.value = true
err.value = ''
try {
await api.createGrowthPlan({ title: title.value.trim(), focus: focus.value.trim() || undefined })
const titleCheck = validateUserText('title', title.value)
if (!titleCheck.ok) throw new Error(titleCheck.message)
const focusCheck = validateUserText('focus', focus.value)
if (!focusCheck.ok) throw new Error(focusCheck.message)
await api.createGrowthPlan({
title: titleCheck.value,
focus: focusCheck.value || undefined,
})
title.value = ''
focus.value = ''
track(AnalyticsEvent.GrowthPlanCreated)
await load()
} catch (e) {
err.value = e instanceof Error ? e.message : '创建失败'
@@ -101,6 +110,7 @@ async function checkin(id: string) {
await api.createGrowthCheckin(id, {})
const ck = await api.listGrowthCheckins(id)
checkins.value[id] = ck.items || []
track(AnalyticsEvent.GrowthPlanCheckin)
} catch (e) {
err.value = e instanceof Error ? e.message : '打卡失败'
} finally {
@@ -108,7 +118,9 @@ async function checkin(id: string) {
}
}
onMounted(load)
onMounted(() => {
void load({ trackView: true })
})
</script>
<style scoped>
+3 -10
View File
@@ -5,13 +5,7 @@
<span class="atm a2" aria-hidden="true" />
<span class="atm a3" aria-hidden="true" />
<HomeTopBar
v-model:plus-open="plusOpen"
:search-hint="searchHint"
@search="router.push('/explore')"
@plus="goPlus"
/>
<HomeArchiveStrip @add="goPlus('add')" />
<HomeArchiveStrip :nickname="profileLabel" :avatar-url="avatarUrl" @add="goPlus('add')" />
<HomeSelfCard
:profile-label="profileLabel"
:tips="tips"
@@ -32,17 +26,15 @@ import HomeArchiveStrip from '../components/home/HomeArchiveStrip.vue'
import HomeFeedSection from '../components/home/HomeFeedSection.vue'
import HomePromoSection from '../components/home/HomePromoSection.vue'
import HomeSelfCard from '../components/home/HomeSelfCard.vue'
import HomeTopBar from '../components/home/HomeTopBar.vue'
import HomeToolGrid from '../components/home/HomeToolGrid.vue'
import { useHomePage } from '../composables/useHomePage'
const {
router,
profileLabel,
plusOpen,
avatarUrl,
tips,
tipsLoading,
searchHint,
gridRow1,
gridRow2,
feeds,
@@ -55,6 +47,7 @@ const {
.home {
position: relative;
overflow-x: hidden;
padding-top: 48px;
padding-bottom: var(--spacing-xs);
min-height: 100%;
background:
@@ -61,6 +61,8 @@ describe('ImageCardPage', () => {
const w = mount(ImageCardPage)
await flushPromises()
expect(w.text()).toContain('意象卡片')
expect(w.text()).toContain('投射整理')
expect(w.text()).toContain('今日剩余')
expect(w.text()).toContain('情绪整理')
+28 -2
View File
@@ -1,7 +1,13 @@
<template>
<PageShell>
<template #hero>
<BackButton />
<div class="head">
<HomeToolIcon name="cards" :size="48" />
<div>
<h1 class="title">意象卡片</h1>
<p class="sub">投射整理 · 自我反思</p>
</div>
</div>
</template>
<div class="body" :class="{ 'has-results': cards.length }">
@@ -48,8 +54,8 @@
</template>
<script setup lang="ts">
import BackButton from '../components/BackButton.vue'
import PageShell from '../components/PageShell.vue'
import HomeToolIcon from '../components/HomeToolIcon.vue'
import ImageCardGreeting from '../components/imagecard/ImageCardGreeting.vue'
import ImageCardInputBar from '../components/imagecard/ImageCardInputBar.vue'
import ImageCardLanding from '../components/imagecard/ImageCardLanding.vue'
@@ -81,6 +87,26 @@ const {
</script>
<style scoped>
.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;
color: var(--color-text-primary);
}
.sub {
margin-top: 2px;
font-size: 12px;
color: rgba(0, 0, 0, 0.38);
letter-spacing: 0.04em;
}
.body {
padding: 0 16px 88px;
}
+20 -14
View File
@@ -1,10 +1,12 @@
<template>
<PageShell>
<template #hero>
<div class="top-row">
<BackButton />
<span class="who-static">身心节律</span>
<span class="spacer" />
<div class="head">
<HomeToolIcon name="rhythm" :size="48" />
<div>
<h1 class="title">身心节律</h1>
<p class="sub">体质倾向 · 节气与作息参考</p>
</div>
</div>
</template>
@@ -49,8 +51,8 @@
</template>
<script setup lang="ts">
import BackButton from '../components/BackButton.vue'
import PageShell from '../components/PageShell.vue'
import HomeToolIcon from '../components/HomeToolIcon.vue'
import RhythmIntro from '../components/rhythm/RhythmIntro.vue'
import RhythmResult from '../components/rhythm/RhythmResult.vue'
import { useLifeRhythmPage } from '../composables/useLifeRhythmPage'
@@ -82,19 +84,23 @@ const {
</script>
<style scoped>
.top-row {
.head {
display: flex;
align-items: center;
gap: 10px;
padding-bottom: 4px;
gap: 12px;
margin-top: 8px;
padding-bottom: 8px;
}
.who-static {
flex: 1;
font-size: 16px;
.title {
font-family: var(--font-display);
font-size: 22px;
font-weight: 700;
text-align: center;
color: var(--color-text-primary);
letter-spacing: 0.06em;
}
.sub {
font-size: 12px;
color: rgba(0, 0, 0, 0.38);
margin-top: 2px;
}
.spacer { width: 36px; }
.body { padding: 8px 16px 88px; }
</style>
+15 -26
View File
@@ -2,11 +2,6 @@
<PageShell>
<template #hero>
<div class="hero">
<BackButton />
<div class="brand">
<BrandLogo size="card" class="logo" />
<p class="brand-name">愈心谷</p>
</div>
<h1 class="title">{{ mode === 'login' ? '欢迎回来' : '开启你的成长档案' }}</h1>
<p class="sub">登录后生日探索与对话会保存在你的账号里</p>
</div>
@@ -85,10 +80,10 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { validateUserText } from '@yuxingu/utils'
import { api } from '../api/client'
import BackButton from '../components/BackButton.vue'
import BrandLogo from '../components/BrandLogo.vue'
import PageShell from '../components/PageShell.vue'
import { AnalyticsEvent, track } from '../lib/analytics'
import { setAuthToken } from '../lib/authSession'
const router = useRouter()
@@ -110,14 +105,26 @@ function setMode(next: 'login' | 'register') {
async function submit() {
if (!canSubmit.value || busy.value) return
error.value = ''
let nick: string | undefined
if (mode.value === 'register' && nickname.value.trim()) {
const checked = validateUserText('nickname', nickname.value)
if (!checked.ok) {
error.value = checked.message
return
}
nick = checked.value
}
busy.value = true
try {
const body = { phone: phone.value.trim(), password: password.value }
const res =
mode.value === 'login'
? await api.authLogin(body)
: await api.authRegister({ ...body, nickname: nickname.value.trim() || undefined })
: await api.authRegister({ ...body, nickname: nick })
setAuthToken(res.token)
track(res.is_new ? AnalyticsEvent.AuthRegister : AnalyticsEvent.AuthLogin, {
source: 'login_page',
})
const redirect = String(route.query.redirect || '/mine')
await router.replace(redirect)
} catch (e) {
@@ -141,24 +148,6 @@ onMounted(async () => {
.hero {
padding: var(--spacing-2xs) 0 var(--spacing-sm);
}
.brand {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-2xs);
margin: var(--spacing-xs) 0 var(--spacing-md);
}
.logo {
width: 72px !important;
filter: drop-shadow(0 8px 18px rgba(229, 77, 66, 0.18));
}
.brand-name {
font-family: var(--font-display);
font-size: 15px;
font-weight: 700;
letter-spacing: 0.28em;
color: var(--color-text-primary);
}
.title {
margin: 0;
text-align: center;
@@ -1,7 +1,6 @@
<template>
<PageShell>
<template #hero>
<BackButton />
<div class="head">
<HomeToolIcon name="growth" :size="48" />
<div>
@@ -59,7 +58,6 @@ import { useRoute, useRouter } from 'vue-router'
import type { MembershipMe } from '@yuxingu/types'
import { api } from '../api/client'
import AskQuotaBuy from '../components/ask/AskQuotaBuy.vue'
import BackButton from '../components/BackButton.vue'
import HomeToolIcon from '../components/HomeToolIcon.vue'
import PageShell from '../components/PageShell.vue'
import { AnalyticsEvent, track } from '../lib/analytics'
+55 -235
View File
@@ -1,17 +1,15 @@
<template>
<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">{{ accountLabel }}</p>
<p class="meta">档案 {{ profileCount }} </p>
</div>
<router-link v-if="!loggedIn" class="edit" to="/login">登录 </router-link>
<button v-else type="button" class="edit linkish" @click="logout">退出</button>
</div>
<MineHero
:logged-in="loggedIn"
:avatar-busy="avatarBusy"
:avatar-src="avatarSrc"
:account-label="accountLabel"
:profile-count="profileCount"
@avatar="openAvatarSheet"
@logout="logout"
/>
</template>
<p v-if="loading" class="hint">加载中</p>
@@ -20,244 +18,66 @@
<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>
<MineAssets
:membership-active="!!membership?.active"
:plan-label="planLabel"
:profile-count="profileCount"
/>
<MineNavSection title="档案与内容" :items="groupArchive" />
<MineNavSection title="成长与会员" :items="groupGrowth" />
</template>
<MineAvatarSheet
:open="sheetOpen"
:busy="avatarBusy"
:error="avatarErr"
@close="sheetOpen = false"
@source="onAvatarPickSource"
@pick="onFilePicked"
/>
</PageShell>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import type { AuthUser, MembershipMe } from '@yuxingu/types'
import { api } from '../api/client'
import BrandLogo from '../components/BrandLogo.vue'
import ListRow from '../components/ListRow.vue'
import MineAssets from '../components/mine/MineAssets.vue'
import MineAvatarSheet from '../components/mine/MineAvatarSheet.vue'
import MineHero from '../components/mine/MineHero.vue'
import MineNavSection from '../components/mine/MineNavSection.vue'
import PageShell from '../components/PageShell.vue'
import type { HomeToolIconName } from '../components/HomeToolIcon.vue'
import { clearAuthToken } from '../lib/authSession'
import { useMinePage } from '../composables/useMinePage'
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)
const error = ref('')
const membership = ref<MembershipMe | null>(null)
const profileCount = ref(0)
const me = ref<AuthUser | null>(null)
const loggedIn = computed(() => !!me.value)
const accountLabel = computed(() => me.value?.nickname || me.value?.phone || '未登录')
const planLabel = computed(() => {
const p = membership.value?.plan
if (p === 'quarter') return '季卡'
if (p === 'year') return '年卡'
if (p === 'month') return '月卡'
return '会员中'
})
async function load() {
loading.value = true
error.value = ''
try {
me.value = await api.authMe()
} catch {
// + 401
me.value = null
membership.value = null
profileCount.value = 0
loading.value = false
return
}
try {
const [m, profiles] = await Promise.all([api.getMembership(), api.listProfiles()])
membership.value = m
profileCount.value = (profiles.items || []).length
} catch (e) {
membership.value = null
profileCount.value = 0
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
async function logout() {
try {
await api.authLogout()
} catch {
/* ignore */
}
clearAuthToken()
me.value = null
membership.value = null
profileCount.value = 0
await load()
}
onMounted(load)
const {
groupArchive,
groupGrowth,
loading,
error,
membership,
profileCount,
sheetOpen,
avatarBusy,
avatarErr,
loggedIn,
accountLabel,
avatarSrc,
planLabel,
openAvatarSheet,
onAvatarPickSource,
onFilePicked,
load,
logout,
} = useMinePage()
</script>
<style scoped>
.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;
}
.linkish {
border: 0;
background: none;
padding: 0;
cursor: pointer;
}
.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 {
.hint,
.err {
padding: 16px;
font-size: 13px;
color: #999;
}
.err { color: var(--color-primary); }
.err {
color: var(--color-primary);
}
.retry {
border: none;
background: transparent;
+4 -3
View File
@@ -1,7 +1,6 @@
<template>
<PageShell>
<template #hero>
<BackButton />
<div v-if="!report" class="head">
<HomeToolIcon name="portrait" :size="48" />
<div>
@@ -11,7 +10,10 @@
</div>
<div v-else class="head compact">
<HomeToolIcon name="portrait" :size="40" />
<h1 class="title sm">愈心解码</h1>
<div>
<h1 class="title sm">愈心解码</h1>
<p class="sub">一个生日读懂身与心</p>
</div>
</div>
</template>
@@ -67,7 +69,6 @@
</template>
<script setup lang="ts">
import BackButton from '../components/BackButton.vue'
import BirthDateInputs from '../components/BirthDateInputs.vue'
import DecodePanel from '../components/DecodePanel.vue'
import HomeToolIcon from '../components/HomeToolIcon.vue'
+23 -10
View File
@@ -1,10 +1,12 @@
<template>
<PageShell>
<template #hero>
<div class="top-row">
<BackButton />
<span class="who-static">个人档案</span>
<span class="spacer" />
<div class="head">
<HomeToolIcon name="portrait" :size="48" />
<div>
<h1 class="title">个人档案</h1>
<p class="sub">自己与重要他人 · 探索共用</p>
</div>
</div>
</template>
@@ -27,6 +29,7 @@
<ProfileSelfCard
v-if="selfProfile"
:profile="selfProfile"
:nickname="accountNickname"
@edit="startEdit"
/>
<ProfileOthersList
@@ -88,8 +91,8 @@
</template>
<script setup lang="ts">
import BackButton from '../components/BackButton.vue'
import PageShell from '../components/PageShell.vue'
import HomeToolIcon from '../components/HomeToolIcon.vue'
import AccountNicknameCard from '../components/profile/AccountNicknameCard.vue'
import ProfileAddForm from '../components/profile/ProfileAddForm.vue'
import ProfileBottomCta from '../components/profile/ProfileBottomCta.vue'
@@ -148,13 +151,23 @@ const {
</script>
<style scoped>
.top-row {
.head {
display: flex;
align-items: center;
gap: 10px;
padding-bottom: 4px;
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;
}
.who-static { flex: 1; font-size: 16px; font-weight: 700; text-align: center; }
.spacer { width: 36px; }
.body { padding: 8px 16px 100px; }
</style>
+25 -13
View File
@@ -1,11 +1,13 @@
<template>
<PageShell>
<template #hero>
<div class="top-row">
<BackButton />
<span class="who-static">人格匹配</span>
<div class="head">
<HomeToolIcon name="relation" :size="48" />
<div class="head-text">
<h1 class="title">人格匹配</h1>
<p class="sub">双人风格对照 · 相处说明书</p>
</div>
<button v-if="report" type="button" class="share-ico" aria-label="分享" @click="shareOpen = true"></button>
<span v-else class="spacer" />
</div>
</template>
@@ -51,8 +53,8 @@
</template>
<script setup lang="ts">
import BackButton from '../components/BackButton.vue'
import PageShell from '../components/PageShell.vue'
import HomeToolIcon from '../components/HomeToolIcon.vue'
import RelationLanding from '../components/relation/RelationLanding.vue'
import RelationResult from '../components/relation/RelationResult.vue'
import ShareSheet from '../components/ShareSheet.vue'
@@ -92,19 +94,28 @@ const {
</script>
<style scoped>
.top-row {
.head {
display: flex;
align-items: center;
gap: 10px;
padding-bottom: 4px;
gap: 12px;
margin-top: 8px;
padding-bottom: 8px;
}
.who-static {
.head-text {
flex: 1;
font-size: 16px;
font-weight: 700;
text-align: center;
min-width: 0;
}
.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;
}
.spacer { width: 36px; }
.share-ico {
width: 36px;
height: 36px;
@@ -112,6 +123,7 @@ const {
border-radius: 12px;
background: rgba(255, 255, 255, 0.8);
font-size: 16px;
flex-shrink: 0;
}
.body { padding: 8px 16px 88px; }
</style>
-2
View File
@@ -2,7 +2,6 @@
<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" />
@@ -88,7 +87,6 @@
</template>
<script setup lang="ts">
import BackButton from '../components/BackButton.vue'
import PageShell from '../components/PageShell.vue'
import ShareSheet from '../components/ShareSheet.vue'
import ReportBodyContent from '../components/report/ReportBodyContent.vue'
+21 -10
View File
@@ -1,10 +1,12 @@
<template>
<PageShell>
<template #hero>
<div class="top-row">
<BackButton />
<span class="who-static">我的成长报告</span>
<span class="spacer" />
<div class="head">
<HomeToolIcon name="reports" :size="48" />
<div>
<h1 class="title">我的成长报告</h1>
<p class="sub">探索记录 · 持续回看</p>
</div>
</div>
</template>
@@ -68,7 +70,6 @@ import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import type { GrowthReport } from '@yuxingu/types'
import { api } from '../api/client'
import BackButton from '../components/BackButton.vue'
import HomeToolIcon from '../components/HomeToolIcon.vue'
import PageShell from '../components/PageShell.vue'
import { ensureAccount } from '../lib/authSession'
@@ -154,14 +155,24 @@ onMounted(load)
</script>
<style scoped>
.top-row {
.head {
display: flex;
align-items: center;
gap: 10px;
padding-bottom: 4px;
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;
}
.who-static { flex: 1; font-size: 16px; font-weight: 700; text-align: center; }
.spacer { width: 36px; }
.chips {
display: flex;
gap: 8px;
+82 -29
View File
@@ -2,13 +2,16 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import ScalePage from './ScalePage.vue'
const { api } = vi.hoisted(() => ({
const { api, replace, routeState } = vi.hoisted(() => ({
api: {
authMe: vi.fn(),
getScale: vi.fn(),
getScaleResult: vi.fn(),
listProfiles: vi.fn(),
submitScale: vi.fn(),
},
replace: vi.fn(),
routeState: { slug: 'mbti-lite' },
}))
vi.mock('../api/client', () => ({ api }))
@@ -18,24 +21,41 @@ vi.mock('../lib/scaleDraft', () => ({
clearScaleDraft: vi.fn(),
}))
vi.mock('vue-router', () => ({
useRoute: () => ({ params: { slug: 'mbti-lite' }, fullPath: '/scales/mbti-lite' }),
useRouter: () => ({ replace: vi.fn() }),
useRoute: () => ({
get params() {
return { slug: routeState.slug }
},
get fullPath() {
return `/scales/${routeState.slug}`
},
}),
useRouter: () => ({ replace, push: vi.fn() }),
}))
const likertOpts = [
{ key: '1', text: '非常接近左侧' },
{ key: '2', text: '比较接近左侧' },
{ key: '3', text: '居中' },
{ key: '4', text: '比较接近右侧' },
{ key: '5', text: '非常接近右侧' },
]
const scaleDetail = {
slug: 'mbti-lite',
title: 'MBTI测',
description: '探索向',
title: 'MBTI测',
description: 'OEJTS',
access: 'free',
questions: [
{
id: 'q1',
sort: 1,
body: {
prompt: '第一题?',
options: [
{ key: 'a', text: '选项A' },
{ key: 'b', text: '选项B' },
],
format: 'likert5',
dimension: 'EI',
left: '外向',
right: '内向',
options: likertOpts,
},
},
{
@@ -43,10 +63,11 @@ const scaleDetail = {
sort: 2,
body: {
prompt: '第二题?',
options: [
{ key: 'a', text: '选项A' },
{ key: 'b', text: '选项B' },
],
format: 'likert5',
dimension: 'SN',
left: '感觉',
right: '直觉',
options: likertOpts,
},
},
],
@@ -55,55 +76,87 @@ const scaleDetail = {
describe('ScalePage', () => {
beforeEach(() => {
vi.clearAllMocks()
routeState.slug = 'mbti-lite'
api.authMe.mockResolvedValue({ id: 'u1' })
api.getScale.mockResolvedValue(scaleDetail)
api.getScaleResult.mockRejectedValue(new Error('result not found'))
api.listProfiles.mockResolvedValue({
items: [{ id: 'p1', relation: 'self', display_name: '我', birth_date: '1990-01-01' }],
})
api.submitScale.mockResolvedValue({
id: 'res1',
result: { label: '探索结果', share_line: '分享一句', summary: '摘要' },
result: { label: 'ENFP · 热情启发者', type_code: 'ENFP', share_line: '分享一句', summary: '摘要' },
})
})
it('selects answers via emit then submits on last next', async () => {
it('answers likert then submits', async () => {
const w = mount(ScalePage, {
global: { stubs: { RouterLink: true, ShareSheet: true, HomeToolIcon: true, BackButton: true } },
})
await flushPromises()
expect(w.text()).toContain('MBTI测')
expect(w.text()).toContain('MBTI测')
expect(w.text()).toContain('标准版')
await w.find('button.intro-start').trigger('click')
await flushPromises()
expect(w.text()).toContain('第一题?')
const radios = w.findAll('input[type="radio"]')
expect(radios.length).toBeGreaterThan(0)
await radios[0].setValue()
await radios[0].trigger('change')
await w.findAll('button.likert-btn')[0].trigger('click')
await flushPromises()
const next = w.findAll('button').find((b) => b.text().includes('下一题'))
expect(next).toBeTruthy()
await next!.trigger('click')
await flushPromises()
expect(w.text()).toContain('第二题?')
const radios2 = w.findAll('input[type="radio"]')
await radios2[0].setValue()
await radios2[0].trigger('change')
await w.findAll('button.likert-btn')[4].trigger('click')
await flushPromises()
const submitBtn = w.findAll('button').find((b) => b.text().includes('查看探索结果'))
expect(submitBtn).toBeTruthy()
await submitBtn!.trigger('click')
await flushPromises()
expect(api.submitScale).toHaveBeenCalledWith(
'mbti-lite',
'p1',
expect.objectContaining({ q1: 'a', q2: 'a' }),
expect.objectContaining({ q1: '1', q2: '5' }),
)
expect(w.text()).toContain('探索结果')
expect(w.text()).toContain('ENFP')
expect(w.text()).toContain('重新测试')
})
it('opens latest result without answering again', async () => {
api.getScaleResult.mockResolvedValue({
id: 'res0',
result: { label: 'ISTJ · 稳健执行者', type_code: 'ISTJ', share_line: '已测', summary: '上次结果' },
})
const w = mount(ScalePage, {
global: { stubs: { RouterLink: true, ShareSheet: true, HomeToolIcon: true, BackButton: true } },
})
await flushPromises()
expect(w.text()).toContain('ISTJ')
expect(w.text()).toContain('重新测试')
})
it('bank slug opens latest result without answering again', async () => {
routeState.slug = 'sm1'
api.getScale.mockResolvedValue({
slug: 'sm1',
title: '睡眠质量探索',
description: '题库',
access: 'free',
questions: [{ id: 'q1', sort: 1, body: { prompt: '', options: [{ key: '1', text: 'a' }] } }],
})
api.getScaleResult.mockResolvedValue({
id: 'res-bank',
result: { label: '节奏适中型', share_line: '题库已测', summary: '上次睡眠探索' },
})
const w = mount(ScalePage, {
global: { stubs: { RouterLink: true, ShareSheet: true, HomeToolIcon: true, BackButton: true } },
})
await flushPromises()
expect(api.getScale).toHaveBeenCalledWith('sm1')
expect(api.getScaleResult).toHaveBeenCalledWith('sm1')
expect(w.text()).toContain('节奏适中型')
expect(w.text()).toContain('重新测试')
expect(w.text()).not.toContain('开始')
})
})
+9 -2
View File
@@ -1,7 +1,6 @@
<template>
<PageShell>
<template #hero>
<BackButton />
<div class="head">
<HomeToolIcon name="mbti" :size="48" />
<div>
@@ -25,7 +24,10 @@
:question-count="questions.length"
:est-minutes="estMinutes"
:draft-restored="draftRestored"
:show-mbti-versions="slug === 'mbti-lite'"
:locked="locked"
@start="startTest"
@start-full="startFull"
/>
<ScaleAnswering
@@ -43,11 +45,13 @@
<ScaleResult
v-else-if="phase === 'result'"
:slug="slug"
:result-label="resultLabel"
:share-line="shareLine"
:result-summary-obj="resultSummaryObj"
:result-detail-obj="resultDetailObj"
@share="openShare"
@retake="retake"
/>
</div>
@@ -66,7 +70,6 @@
</template>
<script setup lang="ts">
import BackButton from '../components/BackButton.vue'
import HomeToolIcon from '../components/HomeToolIcon.vue'
import PageShell from '../components/PageShell.vue'
import ScaleAnswering from '../components/scale/ScaleAnswering.vue'
@@ -77,6 +80,7 @@ import ShareSheet from '../components/ShareSheet.vue'
import { useScalePage } from '../composables/useScalePage'
const {
slug,
title,
description,
questions,
@@ -86,6 +90,7 @@ const {
loadError,
submitError,
needProfile,
locked,
phase,
resultLabel,
shareLine,
@@ -101,6 +106,8 @@ const {
sharePayload,
load,
startTest,
startFull,
retake,
prevQ,
onNext,
openShare,
-3
View File
@@ -2,9 +2,7 @@
<PageShell>
<template #hero>
<div class="top-row">
<BackButton />
<span class="who-static">分享</span>
<span class="spacer" />
</div>
</template>
@@ -40,7 +38,6 @@
<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'
+39 -16
View File
@@ -1,12 +1,17 @@
<template>
<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>
<div class="head">
<HomeToolIcon name="star" :size="48" />
<div class="head-text">
<h1 class="title">星座</h1>
<p class="sub">
<button v-if="report" type="button" class="who-link" @click="resetForm">
{{ selfLabel }} · 重新选择
</button>
<template v-else>星象性格 · 本命盘与运势</template>
</p>
</div>
<button v-if="report" type="button" class="share-ico" aria-label="分享" @click="shareOpen = true"></button>
</div>
</template>
@@ -87,8 +92,8 @@
</template>
<script setup lang="ts">
import BackButton from '../components/BackButton.vue'
import PageShell from '../components/PageShell.vue'
import HomeToolIcon from '../components/HomeToolIcon.vue'
import ShareSheet from '../components/ShareSheet.vue'
import StarBirthForm from '../components/star/StarBirthForm.vue'
import StarChartPanel from '../components/star/StarChartPanel.vue'
@@ -98,6 +103,9 @@ import StarOverviewPanel from '../components/star/StarOverviewPanel.vue'
import StarPlanetsPanel from '../components/star/StarPlanetsPanel.vue'
import StarReportFooter from '../components/star/StarReportFooter.vue'
import { starFortuneKeys, starViewTabs, useStarProfilePage } from '../composables/useStarProfilePage'
import { useAccountNickname } from '../lib/accountNickname'
const { selfLabel } = useAccountNickname()
const {
loading,
@@ -147,22 +155,36 @@ function onPlanetSelect(key: string) {
</script>
<style scoped>
.top-row {
.head {
display: flex;
align-items: center;
gap: 10px;
padding-bottom: 4px;
gap: 12px;
margin-top: 8px;
padding-bottom: 8px;
}
.who, .who-static {
.head-text {
flex: 1;
min-width: 0;
}
.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;
}
.who-link {
border: none;
background: transparent;
font-size: 16px;
font-weight: 700;
color: var(--color-text-primary);
text-align: center;
padding: 0;
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
font-weight: 600;
}
.who-static { pointer-events: none; }
.share-ico {
width: 36px;
height: 36px;
@@ -170,6 +192,7 @@ function onPlanetSelect(key: string) {
border-radius: 12px;
background: rgba(255, 255, 255, 0.8);
font-size: 16px;
flex-shrink: 0;
}
.body { padding: 8px 16px 24px; }
</style>
@@ -2,9 +2,7 @@
<PageShell>
<template #hero>
<div class="top-row">
<BackButton />
<span class="who-static">合盘邀请</span>
<span class="spacer" />
</div>
</template>
@@ -51,9 +49,8 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { validateBirth } from '@yuxingu/utils'
import { validateBirth, validateUserText } 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 PageShell from '../components/PageShell.vue'
@@ -100,8 +97,10 @@ async function accept() {
error.value = ''
try {
const birth = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
const nameCheck = validateUserText('display_name', name.value || '我')
if (!nameCheck.ok) throw new Error(nameCheck.message)
const rep = await api.acceptSynastryInvite(String(route.params.token), {
display_name: name.value || '我',
display_name: nameCheck.value,
birth_date: birth,
})
track(AnalyticsEvent.SynastryInviteAccepted, { report_id: rep.id })
+2 -2
View File
@@ -1,7 +1,6 @@
<template>
<PageShell>
<template #hero>
<BackButton />
<div class="head">
<HomeToolIcon name="synastry" :size="48" />
<div>
@@ -25,6 +24,7 @@
v-model:profile-a="profileA"
v-model:as-of="asOf"
:self-initial="selfInitial"
:self-label="selfLabel"
:profile-a-name="profileAName"
:pickable-profiles="pickableProfiles"
:nearby="nearby"
@@ -89,7 +89,6 @@
</template>
<script setup lang="ts">
import BackButton from '../components/BackButton.vue'
import HomeToolIcon from '../components/HomeToolIcon.vue'
import PageShell from '../components/PageShell.vue'
import ShareSheet from '../components/ShareSheet.vue'
@@ -127,6 +126,7 @@ const {
pickableProfiles,
selfProfiles,
profileAName,
selfLabel,
selfInitial,
profileInitial,
headline,
+5
View File
@@ -5,6 +5,11 @@ const router = createRouter({
routes: [
{ path: '/', name: 'home', component: () => import('../pages/HomePage.vue') },
{ path: '/explore', name: 'explore', component: () => import('../pages/ExplorePage.vue') },
{
path: '/explore/bank/:category',
name: 'explore-bank-category',
component: () => import('../pages/ExploreBankCategoryPage.vue'),
},
{
path: '/explore/:category',
name: 'explore-category',
+1 -1
View File
@@ -19,7 +19,7 @@
"@/*": ["./src/*"]
},
"baseUrl": ".",
"types": ["vitest/globals"]
"types": ["vitest/globals", "node"]
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "e2e/**/*.ts", "vitest.config.ts", "playwright.config.ts"]
}
+1
View File
@@ -11,6 +11,7 @@ export default defineConfig({
},
},
server: {
host: true, // 0.0.0.0 · 允许局域网 IP 访问
port: 5173,
proxy: {
// H5 history base is /psy/; client baseURL is /psy/api → rewrite to Go /api