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:
@@ -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')
|
||||
|
||||
@@ -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 |
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = ''
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
/** 未上传头像时的愈心小人正面(从 public/logo/愈心小人1.png 抠图) */
|
||||
export const DEFAULT_AVATAR_SRC = `${import.meta.env.BASE_URL}default-avatar.png`
|
||||
@@ -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 成长助手']
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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' },
|
||||
]
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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('去愈心解码')
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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('情绪整理')
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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'
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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('开始')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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,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',
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user