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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-13 01:27:58 +08:00
co-authored by Cursor
parent 13860bf1ef
commit 89756f65b4
227 changed files with 7405 additions and 1407 deletions
+2
View File
@@ -39,6 +39,8 @@ describe('AskPage', () => {
api.getAskQuota.mockResolvedValue({ remaining: 3, free_limit: 3, active_membership: false, source: 'free' })
const w = mount(AskPage, { global: { stubs: { RouterLink: RouterLinkStub } } })
await flushPromises()
expect(w.text()).toContain('问答')
expect(w.text()).toContain('AI 成长助手')
expect(w.text()).toContain('先建立你的个人档案')
expect(w.text()).toContain('去愈心解码')
})
+69 -14
View File
@@ -2,8 +2,12 @@
<PageShell :sheet="false">
<template #hero>
<div class="ask-hero">
<div class="ask-top">
<BackButton />
<div class="head">
<HomeToolIcon name="ask" :size="48" />
<div class="head-text">
<h1 class="title">问答</h1>
<p class="sub">AI 成长助手 · 结合档案聊聊</p>
</div>
<button
v-if="messages.length"
type="button"
@@ -11,19 +15,21 @@
:disabled="sending"
@click="clearChat"
>
清空聊天
清空
</button>
</div>
<AskRails v-model="rail" />
<AskRails v-if="!activeAdvisor" :model-value="rail" @update:model-value="setRail" />
<p v-else class="adv-title">{{ activeAdvisor.name }}的专属对话</p>
</div>
</template>
<AskAiPane
v-show="rail === 'ai'"
v-show="rail === 'ai' && !activeAdvisor"
:boot-loading="bootLoading"
:boot-error="bootError"
:profiles="profiles"
:profile-id="profileId"
:self-label="selfLabel"
:messages="messages"
:draft="draft"
:sending="sending"
@@ -32,7 +38,7 @@
:quota="quota"
@boot="boot"
@pick-scene="pickScene"
@switch-advisor="rail = 'advisor'"
@switch-advisor="setRail('advisor')"
@update:profile-id="profileId = $event"
@profile-change="onProfileChange"
@navigate="router.push($event)"
@@ -41,25 +47,55 @@
@quota-refreshed="refreshQuota"
/>
<AskAdvisorPane v-show="rail === 'advisor'" @ask="askAdvisor" />
<AskAdvisorPane
v-show="rail === 'advisor' && !activeAdvisor"
@ask="openAdvisor"
/>
<AskAdvisorChat
v-if="activeAdvisor"
:advisor="activeAdvisor"
:boot-loading="bootLoading"
:boot-error="bootError"
:profiles="profiles"
:profile-id="profileId"
:self-label="selfLabel"
:messages="messages"
:draft="draft"
:sending="sending"
:send-error="sendError"
:quota-exhausted="quotaExhausted"
:quota="quota"
@boot="boot"
@update:profile-id="profileId = $event"
@profile-change="onProfileChange"
@navigate="router.push($event)"
@update:draft="draft = $event"
@send="send"
@quota-refreshed="refreshQuota"
/>
</PageShell>
</template>
<script setup lang="ts">
import AskAdvisorChat from '../components/ask/AskAdvisorChat.vue'
import AskAdvisorPane from '../components/ask/AskAdvisorPane.vue'
import AskAiPane from '../components/ask/AskAiPane.vue'
import AskRails from '../components/ask/AskRails.vue'
import BackButton from '../components/BackButton.vue'
import HomeToolIcon from '../components/HomeToolIcon.vue'
import PageShell from '../components/PageShell.vue'
import { useAskPage } from '../composables/useAskPage'
const {
router,
rail,
setRail,
activeAdvisor,
bootLoading,
bootError,
profiles,
profileId,
selfLabel,
messages,
draft,
sending,
@@ -68,7 +104,7 @@ const {
quota,
onProfileChange,
pickScene,
askAdvisor,
openAdvisor,
send,
boot,
refreshQuota,
@@ -83,15 +119,28 @@ const {
gap: 10px;
padding-bottom: 4px;
}
.ask-top {
.head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 8px;
}
.ask-hero :deep(.back) {
margin-bottom: 0;
align-self: flex-start;
.head-text {
flex: 1;
min-width: 0;
}
.title {
font-family: var(--font-display);
font-size: 22px;
font-weight: 700;
letter-spacing: 0.06em;
color: var(--color-text-primary);
}
.sub {
margin-top: 2px;
font-size: 12px;
color: rgba(0, 0, 0, 0.38);
letter-spacing: 0.04em;
}
.clear-btn {
border: none;
@@ -104,4 +153,10 @@ const {
.clear-btn:disabled {
opacity: 0.4;
}
.adv-title {
margin: 0;
font-size: 14px;
font-weight: 600;
color: #666;
}
</style>
+24 -8
View File
@@ -2,8 +2,11 @@
<PageShell>
<template #hero>
<div class="head">
<h1 class="title">陪伴</h1>
<p class="sub">节气生活 · 心情记录</p>
<HomeToolIcon name="companion" :size="48" />
<div>
<h1 class="title">陪伴</h1>
<p class="sub">节气生活 · 心情记录</p>
</div>
</div>
</template>
@@ -92,6 +95,7 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { validateUserText } from '@yuxingu/utils'
import { api } from '../api/client'
import HomeToolIcon from '../components/HomeToolIcon.vue'
import PageShell from '../components/PageShell.vue'
@@ -145,7 +149,13 @@ async function save() {
moodErr.value = ''
saved.value = false
try {
await api.saveMood({ score: score.value, note: note.value || undefined })
let moodNote: string | undefined
if (note.value.trim()) {
const noteCheck = validateUserText('note', note.value)
if (!noteCheck.ok) throw new Error(noteCheck.message)
moodNote = noteCheck.value || undefined
}
await api.saveMood({ score: score.value, note: moodNote })
saved.value = true
track('mood_saved', { score: score.value })
const trail = await api.getMoodsRecent()
@@ -161,19 +171,25 @@ onMounted(load)
</script>
<style scoped>
.head { padding: 4px 0 8px; }
.head {
display: flex;
align-items: center;
gap: 12px;
margin-top: 8px;
padding-bottom: 8px;
}
.title {
font-family: var(--font-display);
font-size: 26px;
font-size: 22px;
font-weight: 700;
letter-spacing: 0.08em;
letter-spacing: 0.06em;
color: var(--color-text-primary);
}
.sub {
margin-top: 4px;
margin-top: 2px;
font-size: 12px;
color: rgba(0, 0, 0, 0.38);
letter-spacing: 0.06em;
letter-spacing: 0.04em;
}
.sec { padding: 0 16px; margin-top: 14px; }
.hero-card {
@@ -0,0 +1,160 @@
<template>
<PageShell>
<template #hero>
<div class="head">
<HomeToolIcon :name="heroIcon" :size="48" />
<div>
<h1 class="title">{{ cat?.title || '题库分类' }}</h1>
<p v-if="cat" class="sub">{{ cat.description }}</p>
</div>
</div>
</template>
<p v-if="loading" class="hint">加载中</p>
<p v-else-if="error" class="err">
{{ error }}
<button type="button" class="retry" @click="load">重试</button>
</p>
<div v-else class="list">
<ListRow
v-for="it in items"
:key="it.slug"
:to="it.path"
:title="it.title"
:desc="`${it.description} · ${it.question_count} 题`"
:icon="iconName(it.icon || cat?.icon || 'mbti')"
/>
</div>
<p class="disc">题库内容仅供自我探索参考不构成心理或医学诊断</p>
</PageShell>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api } from '../api/client'
import HomeToolIcon from '../components/HomeToolIcon.vue'
import ListRow from '../components/ListRow.vue'
import PageShell from '../components/PageShell.vue'
import { ensureAccount } from '../lib/authSession'
import { resolveHomeToolIconName, type HomeToolIconName } from '../lib/homeToolIconName'
import { AnalyticsEvent, track } from '../lib/analytics'
type Cat = {
key: string
title: string
description: string
icon: string
count: number
path: string
}
type Item = {
slug: string
title: string
description: string
icon?: string
question_count: number
path: string
}
const route = useRoute()
const router = useRouter()
const cat = ref<Cat | null>(null)
const items = ref<Item[]>([])
const loading = ref(true)
const error = ref('')
/** Avoid double-count on retry for the same category key. */
let lastTrackedCategory = ''
const heroIcon = computed((): HomeToolIconName => iconName(cat.value?.icon || 'mbti'))
function iconName(raw: string): HomeToolIconName {
return resolveHomeToolIconName(raw)
}
async function load() {
loading.value = true
error.value = ''
cat.value = null
items.value = []
if (!(await ensureAccount(router, route.fullPath))) {
loading.value = false
return
}
try {
const key = String(route.params.category || '')
const res = await api.getScaleBankCategory(key)
cat.value = res.category
items.value = res.items || []
if (key && key !== lastTrackedCategory) {
lastTrackedCategory = key
track(AnalyticsEvent.ScaleBankCategoryViewed, { category: key })
}
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
watch(
() => route.params.category,
() => {
lastTrackedCategory = ''
void load()
},
)
onMounted(load)
</script>
<style scoped>
.head {
display: flex;
align-items: center;
gap: 12px;
margin-top: 8px;
padding-bottom: 8px;
}
.title {
font-family: var(--font-display);
font-size: 22px;
font-weight: 700;
color: var(--color-text-primary);
}
.sub {
margin-top: 2px;
font-size: 12px;
color: rgba(0, 0, 0, 0.38);
}
.list {
margin: 0 16px;
background: #fafafa;
border-radius: var(--radius-lg);
padding: 4px 10px;
}
.list :deep(.lr) {
border-bottom: 1px solid #f0ebe8;
}
.list :deep(.lr:last-child) {
border-bottom: none;
}
.hint, .err {
padding: 16px;
font-size: 13px;
color: var(--color-text-secondary);
}
.err { color: var(--color-primary); }
.retry {
margin-left: 8px;
border: none;
background: transparent;
color: var(--color-accent-blue);
text-decoration: underline;
}
.disc {
margin: 16px;
font-size: 11px;
color: #aaa;
line-height: 1.45;
}
</style>
+18 -8
View File
@@ -2,9 +2,11 @@
<PageShell>
<template #hero>
<div class="head">
<BackButton />
<h1 class="title">{{ cat?.title || '分类' }}</h1>
<p v-if="cat" class="sub">{{ cat.description }}</p>
<HomeToolIcon :name="heroIcon" :size="48" />
<div>
<h1 class="title">{{ cat?.title || '分类' }}</h1>
<p v-if="cat" class="sub">{{ cat.description }}</p>
</div>
</div>
</template>
@@ -32,10 +34,9 @@
</template>
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api } from '../api/client'
import BackButton from '../components/BackButton.vue'
import HomeToolIcon from '../components/HomeToolIcon.vue'
import PageShell from '../components/PageShell.vue'
import { ensureAccount } from '../lib/authSession'
@@ -65,6 +66,10 @@ const cat = ref<Cat | null>(null)
const loading = ref(true)
const error = ref('')
const heroIcon = computed(() =>
toolIconFor(cat.value?.key || String(route.params.category || 'explore')),
)
async function load() {
loading.value = true
error.value = ''
@@ -87,9 +92,14 @@ watch(() => route.params.category, load)
</script>
<style scoped>
.head { padding: 0 0 8px; }
.head {
display: flex;
align-items: center;
gap: 12px;
margin-top: 8px;
padding-bottom: 8px;
}
.title {
margin-top: 4px;
font-family: var(--font-display);
font-size: 22px;
font-weight: 700;
@@ -97,7 +107,7 @@ watch(() => route.params.category, load)
letter-spacing: 0.06em;
}
.sub {
margin-top: 4px;
margin-top: 2px;
font-size: 12px;
color: rgba(0, 0, 0, 0.38);
}
+96 -78
View File
@@ -2,52 +2,68 @@
<PageShell>
<template #hero>
<div class="head">
<BackButton />
<h1 class="title">探索</h1>
<p class="sub">测评 · 工具 · 自我理解</p>
<HomeToolIcon name="mbti" :size="48" />
<div>
<h1 class="title">探索</h1>
<p class="sub">题库测评 · 工具 · 自我理解</p>
</div>
</div>
</template>
<!-- 推荐宫格对齐首页 12 + 截图更多 -->
<section class="sec">
<div class="sec-head">
<span class="sec-title">推荐工具</span>
<span class="sec-title">题库精选</span>
</div>
<div class="grid">
<p v-if="bankLoading" class="hint">加载中</p>
<p v-else-if="bankError" class="err">
{{ bankError }}
<button type="button" class="retry" @click="loadBank">重试</button>
</p>
<div v-else class="grid">
<router-link
v-for="t in featured"
:key="t.to + t.label"
:to="t.to"
:key="t.slug"
:to="t.path"
class="g-item"
>
<HomeToolIcon :name="t.icon" :size="52" />
<span class="g-label">{{ t.label }}</span>
<span v-if="t.badge" class="g-badge" :class="'b-' + (t.badgeTone || 'hot')">{{ t.badge }}</span>
<HomeToolIcon :name="iconName(t.icon)" :size="52" />
<span class="g-label">{{ t.title }}</span>
</router-link>
</div>
</section>
<section class="sec">
<div class="sec-head">
<span class="sec-title">全部分类</span>
<span class="sec-title">题库分类</span>
</div>
<p v-if="loading" class="hint">加载中</p>
<p v-else-if="error" class="err">
{{ error }}
<button type="button" class="retry" @click="load">重试</button>
</p>
<div v-else class="cat-list">
<div v-if="!bankLoading && !bankError" class="cat-list">
<ListRow
v-for="c in categories"
:key="c.key"
:to="`/explore/${c.key}`"
:to="c.path"
:title="c.title"
:desc="`${c.description} · ${c.items?.length || 0} `"
:icon="toolIconFor(c.key)"
:badge="hotBadge(c)"
:desc="`${c.description} · ${c.count} `"
:icon="iconName(c.icon)"
/>
</div>
</section>
<section class="sec">
<div class="sec-head">
<span class="sec-title">其它工具</span>
</div>
<div class="grid">
<router-link
v-for="t in tools"
:key="t.to + t.label"
:to="t.to"
class="g-item"
>
<HomeToolIcon :name="t.icon" :size="52" />
<span class="g-label">{{ t.label }}</span>
</router-link>
</div>
</section>
</PageShell>
</template>
@@ -55,87 +71,106 @@
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api } from '../api/client'
import BackButton from '../components/BackButton.vue'
import HomeToolIcon, { type HomeToolIconName } from '../components/HomeToolIcon.vue'
import HomeToolIcon from '../components/HomeToolIcon.vue'
import ListRow from '../components/ListRow.vue'
import PageShell from '../components/PageShell.vue'
import { ensureAccount } from '../lib/authSession'
import { toolIconFor } from '../lib/toolIconMap'
import { resolveHomeToolIconName, type HomeToolIconName } from '../lib/homeToolIconName'
import { AnalyticsEvent, track } from '../lib/analytics'
type Featured = {
slug: string
title: string
description: string
category_key: string
icon: string
path: string
}
type Cat = {
key: string
title: string
description: string
icon: string
tone: string
items?: { badge?: string }[]
count: number
path: string
}
const router = useRouter()
const route = useRoute()
const featured: { to: string; label: string; icon: HomeToolIconName; badge?: string; badgeTone?: 'hot' | 'new' }[] = [
{ to: '/scales/mbti-lite', label: '人格测试', icon: 'mbti' },
const tools: { to: string; label: string; icon: HomeToolIconName }[] = [
{ to: '/star', label: '星座', icon: 'star' },
{ to: '/portrait', label: '愈心解码', icon: 'portrait', badge: '热', badgeTone: 'hot' },
{ to: '/portrait', label: '愈心解码', icon: 'portrait' },
{ to: '/rhythm', label: '身心节律', icon: 'rhythm' },
{ to: '/synastry', label: '合盘', icon: 'synastry', badge: '新', badgeTone: 'new' },
{ to: '/synastry', label: '合盘', icon: 'synastry' },
{ to: '/ask', label: 'AI问答', icon: 'ask' },
{ to: '/companion', label: '节气陪伴', icon: 'companion' },
{ to: '/cards', label: '意象卡片', icon: 'cards' },
{ to: '/reports', label: '成长报告', icon: 'reports', badge: '新', badgeTone: 'new' },
{ to: '/growth-plan', label: '成长计划', icon: 'growth' },
{ to: '/relation', label: '人格匹配', icon: 'relation' },
{ to: '/membership', label: '成长会员', icon: 'growth' },
{ to: '/companion', label: '节气陪伴', icon: 'companion' },
]
const featured = ref<Featured[]>([])
const categories = ref<Cat[]>([])
const loading = ref(true)
const error = ref('')
const bankLoading = ref(true)
const bankError = ref('')
function hotBadge(c: Cat) {
const b = c.items?.find((i) => i.badge)?.badge
return b || undefined
function iconName(raw: string): HomeToolIconName {
return resolveHomeToolIconName(raw)
}
async function load() {
loading.value = true
error.value = ''
if (!(await ensureAccount(router, route.fullPath))) {
loading.value = false
return
}
async function loadBank() {
bankLoading.value = true
bankError.value = ''
try {
const res = await api.getExploreCatalog()
const res = await api.getScaleBankCatalog()
featured.value = res.featured || []
categories.value = res.categories || []
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
bankError.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
bankLoading.value = false
}
}
onMounted(load)
onMounted(async () => {
if (!(await ensureAccount(router, route.fullPath))) {
bankLoading.value = false
return
}
await loadBank()
if (!bankError.value) track(AnalyticsEvent.ScaleListViewed)
})
</script>
<style scoped>
.head { padding: 4px 0 8px; }
.head {
display: flex;
align-items: center;
gap: 12px;
margin-top: 8px;
padding-bottom: 8px;
}
.title {
font-family: var(--font-display);
font-size: 26px;
font-size: 22px;
font-weight: 700;
color: var(--color-text-primary);
letter-spacing: 0.08em;
}
.sub {
margin-top: 4px;
font-size: 12px;
color: rgba(0, 0, 0, 0.38);
letter-spacing: 0.06em;
}
.sub {
margin-top: 2px;
font-size: 12px;
color: rgba(0, 0, 0, 0.38);
letter-spacing: 0.04em;
}
.sec { padding: 0 16px; margin-top: 18px; }
.sec:first-child { margin-top: 12px; }
.sec-head { margin-bottom: 12px; }
.sec-head {
margin-bottom: 12px;
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.sec-title {
font-size: 17px;
font-weight: 700;
@@ -163,23 +198,6 @@ onMounted(load)
text-align: center;
line-height: 1.2;
}
.g-badge {
position: absolute;
top: -4px;
right: 4px;
font-size: 9px;
font-weight: 650;
color: #fff;
padding: 2px 5px;
border-radius: 7px;
line-height: 1.3;
}
.g-badge.b-hot {
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
}
.g-badge.b-new {
background: linear-gradient(135deg, #6eb6ff, #4a90e2);
}
.cat-list {
background: #fafafa;
border-radius: var(--radius-lg);
+18 -6
View File
@@ -1,7 +1,6 @@
<template>
<PageShell>
<template #hero>
<BackButton />
<div class="head">
<HomeToolIcon name="growth" :size="48" />
<div>
@@ -16,7 +15,7 @@
<label class="lbl">新计划标题</label>
<input class="inp" v-model="title" maxlength="40" placeholder="例如:每晚 11 点前放下手机" />
<label class="lbl">焦点可选</label>
<input class="inp" v-model="focus" maxlength="80" placeholder="例如:保护睡眠与情绪" />
<input class="inp" v-model="focus" maxlength="40" placeholder="例如:保护睡眠与情绪" />
<button class="cta" type="button" :disabled="saving || !title.trim()" @click="create">创建计划</button>
<p v-if="err" class="err">{{ err }}</p>
</div>
@@ -42,11 +41,12 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { validateUserText } from '@yuxingu/utils'
import { api } from '../api/client'
import BackButton from '../components/BackButton.vue'
import HomeToolIcon from '../components/HomeToolIcon.vue'
import PageShell from '../components/PageShell.vue'
import { ensureAccount } from '../lib/authSession'
import { AnalyticsEvent, track } from '../lib/analytics'
const router = useRouter()
const route = useRoute()
@@ -59,7 +59,7 @@ const saving = ref(false)
const checking = ref('')
const err = ref('')
async function load() {
async function load(opts?: { trackView?: boolean }) {
loading.value = true
if (!(await ensureAccount(router, route.fullPath))) {
loading.value = false
@@ -76,6 +76,7 @@ async function load() {
err.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
if (opts?.trackView) track(AnalyticsEvent.GrowthPlanViewed)
}
}
@@ -83,9 +84,17 @@ async function create() {
saving.value = true
err.value = ''
try {
await api.createGrowthPlan({ title: title.value.trim(), focus: focus.value.trim() || undefined })
const titleCheck = validateUserText('title', title.value)
if (!titleCheck.ok) throw new Error(titleCheck.message)
const focusCheck = validateUserText('focus', focus.value)
if (!focusCheck.ok) throw new Error(focusCheck.message)
await api.createGrowthPlan({
title: titleCheck.value,
focus: focusCheck.value || undefined,
})
title.value = ''
focus.value = ''
track(AnalyticsEvent.GrowthPlanCreated)
await load()
} catch (e) {
err.value = e instanceof Error ? e.message : '创建失败'
@@ -101,6 +110,7 @@ async function checkin(id: string) {
await api.createGrowthCheckin(id, {})
const ck = await api.listGrowthCheckins(id)
checkins.value[id] = ck.items || []
track(AnalyticsEvent.GrowthPlanCheckin)
} catch (e) {
err.value = e instanceof Error ? e.message : '打卡失败'
} finally {
@@ -108,7 +118,9 @@ async function checkin(id: string) {
}
}
onMounted(load)
onMounted(() => {
void load({ trackView: true })
})
</script>
<style scoped>
+3 -10
View File
@@ -5,13 +5,7 @@
<span class="atm a2" aria-hidden="true" />
<span class="atm a3" aria-hidden="true" />
<HomeTopBar
v-model:plus-open="plusOpen"
:search-hint="searchHint"
@search="router.push('/explore')"
@plus="goPlus"
/>
<HomeArchiveStrip @add="goPlus('add')" />
<HomeArchiveStrip :nickname="profileLabel" :avatar-url="avatarUrl" @add="goPlus('add')" />
<HomeSelfCard
:profile-label="profileLabel"
:tips="tips"
@@ -32,17 +26,15 @@ import HomeArchiveStrip from '../components/home/HomeArchiveStrip.vue'
import HomeFeedSection from '../components/home/HomeFeedSection.vue'
import HomePromoSection from '../components/home/HomePromoSection.vue'
import HomeSelfCard from '../components/home/HomeSelfCard.vue'
import HomeTopBar from '../components/home/HomeTopBar.vue'
import HomeToolGrid from '../components/home/HomeToolGrid.vue'
import { useHomePage } from '../composables/useHomePage'
const {
router,
profileLabel,
plusOpen,
avatarUrl,
tips,
tipsLoading,
searchHint,
gridRow1,
gridRow2,
feeds,
@@ -55,6 +47,7 @@ const {
.home {
position: relative;
overflow-x: hidden;
padding-top: 48px;
padding-bottom: var(--spacing-xs);
min-height: 100%;
background:
@@ -61,6 +61,8 @@ describe('ImageCardPage', () => {
const w = mount(ImageCardPage)
await flushPromises()
expect(w.text()).toContain('意象卡片')
expect(w.text()).toContain('投射整理')
expect(w.text()).toContain('今日剩余')
expect(w.text()).toContain('情绪整理')
+28 -2
View File
@@ -1,7 +1,13 @@
<template>
<PageShell>
<template #hero>
<BackButton />
<div class="head">
<HomeToolIcon name="cards" :size="48" />
<div>
<h1 class="title">意象卡片</h1>
<p class="sub">投射整理 · 自我反思</p>
</div>
</div>
</template>
<div class="body" :class="{ 'has-results': cards.length }">
@@ -48,8 +54,8 @@
</template>
<script setup lang="ts">
import BackButton from '../components/BackButton.vue'
import PageShell from '../components/PageShell.vue'
import HomeToolIcon from '../components/HomeToolIcon.vue'
import ImageCardGreeting from '../components/imagecard/ImageCardGreeting.vue'
import ImageCardInputBar from '../components/imagecard/ImageCardInputBar.vue'
import ImageCardLanding from '../components/imagecard/ImageCardLanding.vue'
@@ -81,6 +87,26 @@ const {
</script>
<style scoped>
.head {
display: flex;
align-items: center;
gap: 12px;
margin-top: 8px;
padding-bottom: 8px;
}
.title {
font-family: var(--font-display);
font-size: 22px;
font-weight: 700;
letter-spacing: 0.06em;
color: var(--color-text-primary);
}
.sub {
margin-top: 2px;
font-size: 12px;
color: rgba(0, 0, 0, 0.38);
letter-spacing: 0.04em;
}
.body {
padding: 0 16px 88px;
}
+20 -14
View File
@@ -1,10 +1,12 @@
<template>
<PageShell>
<template #hero>
<div class="top-row">
<BackButton />
<span class="who-static">身心节律</span>
<span class="spacer" />
<div class="head">
<HomeToolIcon name="rhythm" :size="48" />
<div>
<h1 class="title">身心节律</h1>
<p class="sub">体质倾向 · 节气与作息参考</p>
</div>
</div>
</template>
@@ -49,8 +51,8 @@
</template>
<script setup lang="ts">
import BackButton from '../components/BackButton.vue'
import PageShell from '../components/PageShell.vue'
import HomeToolIcon from '../components/HomeToolIcon.vue'
import RhythmIntro from '../components/rhythm/RhythmIntro.vue'
import RhythmResult from '../components/rhythm/RhythmResult.vue'
import { useLifeRhythmPage } from '../composables/useLifeRhythmPage'
@@ -82,19 +84,23 @@ const {
</script>
<style scoped>
.top-row {
.head {
display: flex;
align-items: center;
gap: 10px;
padding-bottom: 4px;
gap: 12px;
margin-top: 8px;
padding-bottom: 8px;
}
.who-static {
flex: 1;
font-size: 16px;
.title {
font-family: var(--font-display);
font-size: 22px;
font-weight: 700;
text-align: center;
color: var(--color-text-primary);
letter-spacing: 0.06em;
}
.sub {
font-size: 12px;
color: rgba(0, 0, 0, 0.38);
margin-top: 2px;
}
.spacer { width: 36px; }
.body { padding: 8px 16px 88px; }
</style>
+15 -26
View File
@@ -2,11 +2,6 @@
<PageShell>
<template #hero>
<div class="hero">
<BackButton />
<div class="brand">
<BrandLogo size="card" class="logo" />
<p class="brand-name">愈心谷</p>
</div>
<h1 class="title">{{ mode === 'login' ? '欢迎回来' : '开启你的成长档案' }}</h1>
<p class="sub">登录后生日探索与对话会保存在你的账号里</p>
</div>
@@ -85,10 +80,10 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { validateUserText } from '@yuxingu/utils'
import { api } from '../api/client'
import BackButton from '../components/BackButton.vue'
import BrandLogo from '../components/BrandLogo.vue'
import PageShell from '../components/PageShell.vue'
import { AnalyticsEvent, track } from '../lib/analytics'
import { setAuthToken } from '../lib/authSession'
const router = useRouter()
@@ -110,14 +105,26 @@ function setMode(next: 'login' | 'register') {
async function submit() {
if (!canSubmit.value || busy.value) return
error.value = ''
let nick: string | undefined
if (mode.value === 'register' && nickname.value.trim()) {
const checked = validateUserText('nickname', nickname.value)
if (!checked.ok) {
error.value = checked.message
return
}
nick = checked.value
}
busy.value = true
try {
const body = { phone: phone.value.trim(), password: password.value }
const res =
mode.value === 'login'
? await api.authLogin(body)
: await api.authRegister({ ...body, nickname: nickname.value.trim() || undefined })
: await api.authRegister({ ...body, nickname: nick })
setAuthToken(res.token)
track(res.is_new ? AnalyticsEvent.AuthRegister : AnalyticsEvent.AuthLogin, {
source: 'login_page',
})
const redirect = String(route.query.redirect || '/mine')
await router.replace(redirect)
} catch (e) {
@@ -141,24 +148,6 @@ onMounted(async () => {
.hero {
padding: var(--spacing-2xs) 0 var(--spacing-sm);
}
.brand {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-2xs);
margin: var(--spacing-xs) 0 var(--spacing-md);
}
.logo {
width: 72px !important;
filter: drop-shadow(0 8px 18px rgba(229, 77, 66, 0.18));
}
.brand-name {
font-family: var(--font-display);
font-size: 15px;
font-weight: 700;
letter-spacing: 0.28em;
color: var(--color-text-primary);
}
.title {
margin: 0;
text-align: center;
@@ -1,7 +1,6 @@
<template>
<PageShell>
<template #hero>
<BackButton />
<div class="head">
<HomeToolIcon name="growth" :size="48" />
<div>
@@ -59,7 +58,6 @@ import { useRoute, useRouter } from 'vue-router'
import type { MembershipMe } from '@yuxingu/types'
import { api } from '../api/client'
import AskQuotaBuy from '../components/ask/AskQuotaBuy.vue'
import BackButton from '../components/BackButton.vue'
import HomeToolIcon from '../components/HomeToolIcon.vue'
import PageShell from '../components/PageShell.vue'
import { AnalyticsEvent, track } from '../lib/analytics'
+55 -235
View File
@@ -1,17 +1,15 @@
<template>
<PageShell>
<template #hero>
<div class="profile-head">
<span class="av" aria-hidden="true">
<BrandLogo size="card" class="av-logo" />
</span>
<div class="who">
<p class="name">{{ accountLabel }}</p>
<p class="meta">档案 {{ profileCount }} </p>
</div>
<router-link v-if="!loggedIn" class="edit" to="/login">登录 </router-link>
<button v-else type="button" class="edit linkish" @click="logout">退出</button>
</div>
<MineHero
:logged-in="loggedIn"
:avatar-busy="avatarBusy"
:avatar-src="avatarSrc"
:account-label="accountLabel"
:profile-count="profileCount"
@avatar="openAvatarSheet"
@logout="logout"
/>
</template>
<p v-if="loading" class="hint">加载中</p>
@@ -20,244 +18,66 @@
<button type="button" class="retry" @click="load">重试</button>
</p>
<template v-else>
<!-- 资产条 -->
<section class="assets">
<router-link to="/membership" class="asset">
<strong>{{ membership?.active ? planLabel : '未开通' }}</strong>
<span>成长会员</span>
</router-link>
<router-link to="/ask" class="asset">
<strong>{{ profileCount }}</strong>
<span>档案</span>
</router-link>
<router-link to="/reports" class="asset">
<strong>报告</strong>
<span>成长记录</span>
</router-link>
<router-link to="/membership" class="asset accent">
<strong>{{ membership?.active ? '续费' : '开通' }}</strong>
<span>会员中心</span>
</router-link>
</section>
<section class="sec">
<p class="sec-title">档案与内容</p>
<div class="group">
<ListRow
v-for="item in groupArchive"
:key="item.to"
:to="item.to"
:title="item.label"
:icon="item.icon"
/>
</div>
</section>
<section class="sec">
<p class="sec-title">成长与会员</p>
<div class="group">
<ListRow
v-for="item in groupGrowth"
:key="item.to"
:to="item.to"
:title="item.label"
:icon="item.icon"
/>
</div>
</section>
<MineAssets
:membership-active="!!membership?.active"
:plan-label="planLabel"
:profile-count="profileCount"
/>
<MineNavSection title="档案与内容" :items="groupArchive" />
<MineNavSection title="成长与会员" :items="groupGrowth" />
</template>
<MineAvatarSheet
:open="sheetOpen"
:busy="avatarBusy"
:error="avatarErr"
@close="sheetOpen = false"
@source="onAvatarPickSource"
@pick="onFilePicked"
/>
</PageShell>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import type { AuthUser, MembershipMe } from '@yuxingu/types'
import { api } from '../api/client'
import BrandLogo from '../components/BrandLogo.vue'
import ListRow from '../components/ListRow.vue'
import MineAssets from '../components/mine/MineAssets.vue'
import MineAvatarSheet from '../components/mine/MineAvatarSheet.vue'
import MineHero from '../components/mine/MineHero.vue'
import MineNavSection from '../components/mine/MineNavSection.vue'
import PageShell from '../components/PageShell.vue'
import type { HomeToolIconName } from '../components/HomeToolIcon.vue'
import { clearAuthToken } from '../lib/authSession'
import { useMinePage } from '../composables/useMinePage'
const groupArchive: { to: string; label: string; icon: HomeToolIconName }[] = [
{ to: '/profile', label: '个人档案', icon: 'portrait' },
{ to: '/reports', label: '我的成长报告', icon: 'reports' },
{ to: '/portrait', label: '愈心解码', icon: 'portrait' },
{ to: '/star', label: '星象性格', icon: 'star' },
{ to: '/rhythm', label: '身心节律', icon: 'rhythm' },
{ to: '/cards', label: '意象卡片', icon: 'cards' },
]
const groupGrowth: { to: string; label: string; icon: HomeToolIconName }[] = [
{ to: '/relation', label: '人格匹配', icon: 'relation' },
{ to: '/ask', label: 'AI 成长助手', icon: 'ask' },
{ to: '/explore', label: '探索测试', icon: 'mbti' },
{ to: '/growth-plan', label: '成长计划', icon: 'growth' },
{ to: '/membership', label: '成长会员', icon: 'growth' },
]
const loading = ref(true)
const error = ref('')
const membership = ref<MembershipMe | null>(null)
const profileCount = ref(0)
const me = ref<AuthUser | null>(null)
const loggedIn = computed(() => !!me.value)
const accountLabel = computed(() => me.value?.nickname || me.value?.phone || '未登录')
const planLabel = computed(() => {
const p = membership.value?.plan
if (p === 'quarter') return '季卡'
if (p === 'year') return '年卡'
if (p === 'month') return '月卡'
return '会员中'
})
async function load() {
loading.value = true
error.value = ''
try {
me.value = await api.authMe()
} catch {
// 未登录:展示游客态(未登录 + 登录入口),不把 401 当成整页错误
me.value = null
membership.value = null
profileCount.value = 0
loading.value = false
return
}
try {
const [m, profiles] = await Promise.all([api.getMembership(), api.listProfiles()])
membership.value = m
profileCount.value = (profiles.items || []).length
} catch (e) {
membership.value = null
profileCount.value = 0
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
async function logout() {
try {
await api.authLogout()
} catch {
/* ignore */
}
clearAuthToken()
me.value = null
membership.value = null
profileCount.value = 0
await load()
}
onMounted(load)
const {
groupArchive,
groupGrowth,
loading,
error,
membership,
profileCount,
sheetOpen,
avatarBusy,
avatarErr,
loggedIn,
accountLabel,
avatarSrc,
planLabel,
openAvatarSheet,
onAvatarPickSource,
onFilePicked,
load,
logout,
} = useMinePage()
</script>
<style scoped>
.profile-head {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 0 12px;
}
.av {
width: 56px;
height: 56px;
border-radius: 50%;
background: linear-gradient(145deg, #ffe8e0, #ffd0c4);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7);
flex-shrink: 0;
}
.av-logo {
width: 34px !important;
height: auto !important;
max-height: none !important;
}
.who { flex: 1; min-width: 0; }
.name {
font-size: 18px;
font-weight: 700;
color: var(--color-text-primary);
}
.meta {
font-size: 12px;
color: #999;
margin-top: 2px;
}
.edit {
font-size: 12px;
color: #bbb;
flex-shrink: 0;
}
.linkish {
border: 0;
background: none;
padding: 0;
cursor: pointer;
}
.assets {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
padding: 0 16px;
margin-top: 4px;
}
.asset {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 12px 4px;
background: #fafafa;
border-radius: var(--radius-md);
text-align: center;
}
.asset strong {
font-size: 13px;
font-weight: 700;
color: #222;
}
.asset span {
font-size: 10px;
color: #999;
}
.asset.accent strong {
color: var(--color-primary);
}
.sec {
padding: 0 16px;
margin-top: 20px;
}
.sec-title {
font-size: 13px;
font-weight: 650;
color: #999;
margin-bottom: 8px;
letter-spacing: 0.04em;
}
.group {
background: #fafafa;
border-radius: var(--radius-lg);
padding: 2px 10px;
}
.group :deep(.lr) {
border-bottom: 1px solid #f0ebe8;
}
.group :deep(.lr:last-child) {
border-bottom: none;
}
.hint, .err {
.hint,
.err {
padding: 16px;
font-size: 13px;
color: #999;
}
.err { color: var(--color-primary); }
.err {
color: var(--color-primary);
}
.retry {
border: none;
background: transparent;
+4 -3
View File
@@ -1,7 +1,6 @@
<template>
<PageShell>
<template #hero>
<BackButton />
<div v-if="!report" class="head">
<HomeToolIcon name="portrait" :size="48" />
<div>
@@ -11,7 +10,10 @@
</div>
<div v-else class="head compact">
<HomeToolIcon name="portrait" :size="40" />
<h1 class="title sm">愈心解码</h1>
<div>
<h1 class="title sm">愈心解码</h1>
<p class="sub">一个生日读懂身与心</p>
</div>
</div>
</template>
@@ -67,7 +69,6 @@
</template>
<script setup lang="ts">
import BackButton from '../components/BackButton.vue'
import BirthDateInputs from '../components/BirthDateInputs.vue'
import DecodePanel from '../components/DecodePanel.vue'
import HomeToolIcon from '../components/HomeToolIcon.vue'
+23 -10
View File
@@ -1,10 +1,12 @@
<template>
<PageShell>
<template #hero>
<div class="top-row">
<BackButton />
<span class="who-static">个人档案</span>
<span class="spacer" />
<div class="head">
<HomeToolIcon name="portrait" :size="48" />
<div>
<h1 class="title">个人档案</h1>
<p class="sub">自己与重要他人 · 探索共用</p>
</div>
</div>
</template>
@@ -27,6 +29,7 @@
<ProfileSelfCard
v-if="selfProfile"
:profile="selfProfile"
:nickname="accountNickname"
@edit="startEdit"
/>
<ProfileOthersList
@@ -88,8 +91,8 @@
</template>
<script setup lang="ts">
import BackButton from '../components/BackButton.vue'
import PageShell from '../components/PageShell.vue'
import HomeToolIcon from '../components/HomeToolIcon.vue'
import AccountNicknameCard from '../components/profile/AccountNicknameCard.vue'
import ProfileAddForm from '../components/profile/ProfileAddForm.vue'
import ProfileBottomCta from '../components/profile/ProfileBottomCta.vue'
@@ -148,13 +151,23 @@ const {
</script>
<style scoped>
.top-row {
.head {
display: flex;
align-items: center;
gap: 10px;
padding-bottom: 4px;
gap: 12px;
margin-top: 8px;
padding-bottom: 8px;
}
.title {
font-family: var(--font-display);
font-size: 22px;
font-weight: 700;
letter-spacing: 0.06em;
}
.sub {
font-size: 12px;
color: rgba(0, 0, 0, 0.38);
margin-top: 2px;
}
.who-static { flex: 1; font-size: 16px; font-weight: 700; text-align: center; }
.spacer { width: 36px; }
.body { padding: 8px 16px 100px; }
</style>
+25 -13
View File
@@ -1,11 +1,13 @@
<template>
<PageShell>
<template #hero>
<div class="top-row">
<BackButton />
<span class="who-static">人格匹配</span>
<div class="head">
<HomeToolIcon name="relation" :size="48" />
<div class="head-text">
<h1 class="title">人格匹配</h1>
<p class="sub">双人风格对照 · 相处说明书</p>
</div>
<button v-if="report" type="button" class="share-ico" aria-label="分享" @click="shareOpen = true"></button>
<span v-else class="spacer" />
</div>
</template>
@@ -51,8 +53,8 @@
</template>
<script setup lang="ts">
import BackButton from '../components/BackButton.vue'
import PageShell from '../components/PageShell.vue'
import HomeToolIcon from '../components/HomeToolIcon.vue'
import RelationLanding from '../components/relation/RelationLanding.vue'
import RelationResult from '../components/relation/RelationResult.vue'
import ShareSheet from '../components/ShareSheet.vue'
@@ -92,19 +94,28 @@ const {
</script>
<style scoped>
.top-row {
.head {
display: flex;
align-items: center;
gap: 10px;
padding-bottom: 4px;
gap: 12px;
margin-top: 8px;
padding-bottom: 8px;
}
.who-static {
.head-text {
flex: 1;
font-size: 16px;
font-weight: 700;
text-align: center;
min-width: 0;
}
.title {
font-family: var(--font-display);
font-size: 22px;
font-weight: 700;
letter-spacing: 0.06em;
}
.sub {
font-size: 12px;
color: rgba(0, 0, 0, 0.38);
margin-top: 2px;
}
.spacer { width: 36px; }
.share-ico {
width: 36px;
height: 36px;
@@ -112,6 +123,7 @@ const {
border-radius: 12px;
background: rgba(255, 255, 255, 0.8);
font-size: 16px;
flex-shrink: 0;
}
.body { padding: 8px 16px 88px; }
</style>
-2
View File
@@ -2,7 +2,6 @@
<PageShell>
<template #hero>
<div class="top-row">
<BackButton />
<span class="who-static">成长报告</span>
<button v-if="report" type="button" class="share-ico" aria-label="分享" @click="shareOpen = true"></button>
<span v-else class="spacer" />
@@ -88,7 +87,6 @@
</template>
<script setup lang="ts">
import BackButton from '../components/BackButton.vue'
import PageShell from '../components/PageShell.vue'
import ShareSheet from '../components/ShareSheet.vue'
import ReportBodyContent from '../components/report/ReportBodyContent.vue'
+21 -10
View File
@@ -1,10 +1,12 @@
<template>
<PageShell>
<template #hero>
<div class="top-row">
<BackButton />
<span class="who-static">我的成长报告</span>
<span class="spacer" />
<div class="head">
<HomeToolIcon name="reports" :size="48" />
<div>
<h1 class="title">我的成长报告</h1>
<p class="sub">探索记录 · 持续回看</p>
</div>
</div>
</template>
@@ -68,7 +70,6 @@ import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import type { GrowthReport } from '@yuxingu/types'
import { api } from '../api/client'
import BackButton from '../components/BackButton.vue'
import HomeToolIcon from '../components/HomeToolIcon.vue'
import PageShell from '../components/PageShell.vue'
import { ensureAccount } from '../lib/authSession'
@@ -154,14 +155,24 @@ onMounted(load)
</script>
<style scoped>
.top-row {
.head {
display: flex;
align-items: center;
gap: 10px;
padding-bottom: 4px;
gap: 12px;
margin-top: 8px;
padding-bottom: 8px;
}
.title {
font-family: var(--font-display);
font-size: 22px;
font-weight: 700;
letter-spacing: 0.06em;
}
.sub {
font-size: 12px;
color: rgba(0, 0, 0, 0.38);
margin-top: 2px;
}
.who-static { flex: 1; font-size: 16px; font-weight: 700; text-align: center; }
.spacer { width: 36px; }
.chips {
display: flex;
gap: 8px;
+82 -29
View File
@@ -2,13 +2,16 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import ScalePage from './ScalePage.vue'
const { api } = vi.hoisted(() => ({
const { api, replace, routeState } = vi.hoisted(() => ({
api: {
authMe: vi.fn(),
getScale: vi.fn(),
getScaleResult: vi.fn(),
listProfiles: vi.fn(),
submitScale: vi.fn(),
},
replace: vi.fn(),
routeState: { slug: 'mbti-lite' },
}))
vi.mock('../api/client', () => ({ api }))
@@ -18,24 +21,41 @@ vi.mock('../lib/scaleDraft', () => ({
clearScaleDraft: vi.fn(),
}))
vi.mock('vue-router', () => ({
useRoute: () => ({ params: { slug: 'mbti-lite' }, fullPath: '/scales/mbti-lite' }),
useRouter: () => ({ replace: vi.fn() }),
useRoute: () => ({
get params() {
return { slug: routeState.slug }
},
get fullPath() {
return `/scales/${routeState.slug}`
},
}),
useRouter: () => ({ replace, push: vi.fn() }),
}))
const likertOpts = [
{ key: '1', text: '非常接近左侧' },
{ key: '2', text: '比较接近左侧' },
{ key: '3', text: '居中' },
{ key: '4', text: '比较接近右侧' },
{ key: '5', text: '非常接近右侧' },
]
const scaleDetail = {
slug: 'mbti-lite',
title: 'MBTI测',
description: '探索向',
title: 'MBTI测',
description: 'OEJTS',
access: 'free',
questions: [
{
id: 'q1',
sort: 1,
body: {
prompt: '第一题?',
options: [
{ key: 'a', text: '选项A' },
{ key: 'b', text: '选项B' },
],
format: 'likert5',
dimension: 'EI',
left: '外向',
right: '内向',
options: likertOpts,
},
},
{
@@ -43,10 +63,11 @@ const scaleDetail = {
sort: 2,
body: {
prompt: '第二题?',
options: [
{ key: 'a', text: '选项A' },
{ key: 'b', text: '选项B' },
],
format: 'likert5',
dimension: 'SN',
left: '感觉',
right: '直觉',
options: likertOpts,
},
},
],
@@ -55,55 +76,87 @@ const scaleDetail = {
describe('ScalePage', () => {
beforeEach(() => {
vi.clearAllMocks()
routeState.slug = 'mbti-lite'
api.authMe.mockResolvedValue({ id: 'u1' })
api.getScale.mockResolvedValue(scaleDetail)
api.getScaleResult.mockRejectedValue(new Error('result not found'))
api.listProfiles.mockResolvedValue({
items: [{ id: 'p1', relation: 'self', display_name: '我', birth_date: '1990-01-01' }],
})
api.submitScale.mockResolvedValue({
id: 'res1',
result: { label: '探索结果', share_line: '分享一句', summary: '摘要' },
result: { label: 'ENFP · 热情启发者', type_code: 'ENFP', share_line: '分享一句', summary: '摘要' },
})
})
it('selects answers via emit then submits on last next', async () => {
it('answers likert then submits', async () => {
const w = mount(ScalePage, {
global: { stubs: { RouterLink: true, ShareSheet: true, HomeToolIcon: true, BackButton: true } },
})
await flushPromises()
expect(w.text()).toContain('MBTI测')
expect(w.text()).toContain('MBTI测')
expect(w.text()).toContain('标准版')
await w.find('button.intro-start').trigger('click')
await flushPromises()
expect(w.text()).toContain('第一题?')
const radios = w.findAll('input[type="radio"]')
expect(radios.length).toBeGreaterThan(0)
await radios[0].setValue()
await radios[0].trigger('change')
await w.findAll('button.likert-btn')[0].trigger('click')
await flushPromises()
const next = w.findAll('button').find((b) => b.text().includes('下一题'))
expect(next).toBeTruthy()
await next!.trigger('click')
await flushPromises()
expect(w.text()).toContain('第二题?')
const radios2 = w.findAll('input[type="radio"]')
await radios2[0].setValue()
await radios2[0].trigger('change')
await w.findAll('button.likert-btn')[4].trigger('click')
await flushPromises()
const submitBtn = w.findAll('button').find((b) => b.text().includes('查看探索结果'))
expect(submitBtn).toBeTruthy()
await submitBtn!.trigger('click')
await flushPromises()
expect(api.submitScale).toHaveBeenCalledWith(
'mbti-lite',
'p1',
expect.objectContaining({ q1: 'a', q2: 'a' }),
expect.objectContaining({ q1: '1', q2: '5' }),
)
expect(w.text()).toContain('探索结果')
expect(w.text()).toContain('ENFP')
expect(w.text()).toContain('重新测试')
})
it('opens latest result without answering again', async () => {
api.getScaleResult.mockResolvedValue({
id: 'res0',
result: { label: 'ISTJ · 稳健执行者', type_code: 'ISTJ', share_line: '已测', summary: '上次结果' },
})
const w = mount(ScalePage, {
global: { stubs: { RouterLink: true, ShareSheet: true, HomeToolIcon: true, BackButton: true } },
})
await flushPromises()
expect(w.text()).toContain('ISTJ')
expect(w.text()).toContain('重新测试')
})
it('bank slug opens latest result without answering again', async () => {
routeState.slug = 'sm1'
api.getScale.mockResolvedValue({
slug: 'sm1',
title: '睡眠质量探索',
description: '题库',
access: 'free',
questions: [{ id: 'q1', sort: 1, body: { prompt: '', options: [{ key: '1', text: 'a' }] } }],
})
api.getScaleResult.mockResolvedValue({
id: 'res-bank',
result: { label: '节奏适中型', share_line: '题库已测', summary: '上次睡眠探索' },
})
const w = mount(ScalePage, {
global: { stubs: { RouterLink: true, ShareSheet: true, HomeToolIcon: true, BackButton: true } },
})
await flushPromises()
expect(api.getScale).toHaveBeenCalledWith('sm1')
expect(api.getScaleResult).toHaveBeenCalledWith('sm1')
expect(w.text()).toContain('节奏适中型')
expect(w.text()).toContain('重新测试')
expect(w.text()).not.toContain('开始')
})
})
+9 -2
View File
@@ -1,7 +1,6 @@
<template>
<PageShell>
<template #hero>
<BackButton />
<div class="head">
<HomeToolIcon name="mbti" :size="48" />
<div>
@@ -25,7 +24,10 @@
:question-count="questions.length"
:est-minutes="estMinutes"
:draft-restored="draftRestored"
:show-mbti-versions="slug === 'mbti-lite'"
:locked="locked"
@start="startTest"
@start-full="startFull"
/>
<ScaleAnswering
@@ -43,11 +45,13 @@
<ScaleResult
v-else-if="phase === 'result'"
:slug="slug"
:result-label="resultLabel"
:share-line="shareLine"
:result-summary-obj="resultSummaryObj"
:result-detail-obj="resultDetailObj"
@share="openShare"
@retake="retake"
/>
</div>
@@ -66,7 +70,6 @@
</template>
<script setup lang="ts">
import BackButton from '../components/BackButton.vue'
import HomeToolIcon from '../components/HomeToolIcon.vue'
import PageShell from '../components/PageShell.vue'
import ScaleAnswering from '../components/scale/ScaleAnswering.vue'
@@ -77,6 +80,7 @@ import ShareSheet from '../components/ShareSheet.vue'
import { useScalePage } from '../composables/useScalePage'
const {
slug,
title,
description,
questions,
@@ -86,6 +90,7 @@ const {
loadError,
submitError,
needProfile,
locked,
phase,
resultLabel,
shareLine,
@@ -101,6 +106,8 @@ const {
sharePayload,
load,
startTest,
startFull,
retake,
prevQ,
onNext,
openShare,
-3
View File
@@ -2,9 +2,7 @@
<PageShell>
<template #hero>
<div class="top-row">
<BackButton />
<span class="who-static">分享</span>
<span class="spacer" />
</div>
</template>
@@ -40,7 +38,6 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import BackButton from '../components/BackButton.vue'
import HomeToolIcon from '../components/HomeToolIcon.vue'
import PageShell from '../components/PageShell.vue'
import ShareCard from '../components/ShareCard.vue'
+39 -16
View File
@@ -1,12 +1,17 @@
<template>
<PageShell>
<template #hero>
<div class="top-row">
<BackButton />
<button v-if="report" type="button" class="who" @click="resetForm">
自己 <span aria-hidden="true"></span>
</button>
<span v-else class="who-static">星座</span>
<div class="head">
<HomeToolIcon name="star" :size="48" />
<div class="head-text">
<h1 class="title">星座</h1>
<p class="sub">
<button v-if="report" type="button" class="who-link" @click="resetForm">
{{ selfLabel }} · 重新选择
</button>
<template v-else>星象性格 · 本命盘与运势</template>
</p>
</div>
<button v-if="report" type="button" class="share-ico" aria-label="分享" @click="shareOpen = true"></button>
</div>
</template>
@@ -87,8 +92,8 @@
</template>
<script setup lang="ts">
import BackButton from '../components/BackButton.vue'
import PageShell from '../components/PageShell.vue'
import HomeToolIcon from '../components/HomeToolIcon.vue'
import ShareSheet from '../components/ShareSheet.vue'
import StarBirthForm from '../components/star/StarBirthForm.vue'
import StarChartPanel from '../components/star/StarChartPanel.vue'
@@ -98,6 +103,9 @@ import StarOverviewPanel from '../components/star/StarOverviewPanel.vue'
import StarPlanetsPanel from '../components/star/StarPlanetsPanel.vue'
import StarReportFooter from '../components/star/StarReportFooter.vue'
import { starFortuneKeys, starViewTabs, useStarProfilePage } from '../composables/useStarProfilePage'
import { useAccountNickname } from '../lib/accountNickname'
const { selfLabel } = useAccountNickname()
const {
loading,
@@ -147,22 +155,36 @@ function onPlanetSelect(key: string) {
</script>
<style scoped>
.top-row {
.head {
display: flex;
align-items: center;
gap: 10px;
padding-bottom: 4px;
gap: 12px;
margin-top: 8px;
padding-bottom: 8px;
}
.who, .who-static {
.head-text {
flex: 1;
min-width: 0;
}
.title {
font-family: var(--font-display);
font-size: 22px;
font-weight: 700;
letter-spacing: 0.06em;
}
.sub {
font-size: 12px;
color: rgba(0, 0, 0, 0.38);
margin-top: 2px;
}
.who-link {
border: none;
background: transparent;
font-size: 16px;
font-weight: 700;
color: var(--color-text-primary);
text-align: center;
padding: 0;
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
font-weight: 600;
}
.who-static { pointer-events: none; }
.share-ico {
width: 36px;
height: 36px;
@@ -170,6 +192,7 @@ function onPlanetSelect(key: string) {
border-radius: 12px;
background: rgba(255, 255, 255, 0.8);
font-size: 16px;
flex-shrink: 0;
}
.body { padding: 8px 16px 24px; }
</style>
@@ -2,9 +2,7 @@
<PageShell>
<template #hero>
<div class="top-row">
<BackButton />
<span class="who-static">合盘邀请</span>
<span class="spacer" />
</div>
</template>
@@ -51,9 +49,8 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { validateBirth } from '@yuxingu/utils'
import { validateBirth, validateUserText } from '@yuxingu/utils'
import { api } from '../api/client'
import BackButton from '../components/BackButton.vue'
import BirthDateInputs from '../components/BirthDateInputs.vue'
import HomeToolIcon from '../components/HomeToolIcon.vue'
import PageShell from '../components/PageShell.vue'
@@ -100,8 +97,10 @@ async function accept() {
error.value = ''
try {
const birth = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
const nameCheck = validateUserText('display_name', name.value || '我')
if (!nameCheck.ok) throw new Error(nameCheck.message)
const rep = await api.acceptSynastryInvite(String(route.params.token), {
display_name: name.value || '我',
display_name: nameCheck.value,
birth_date: birth,
})
track(AnalyticsEvent.SynastryInviteAccepted, { report_id: rep.id })
+2 -2
View File
@@ -1,7 +1,6 @@
<template>
<PageShell>
<template #hero>
<BackButton />
<div class="head">
<HomeToolIcon name="synastry" :size="48" />
<div>
@@ -25,6 +24,7 @@
v-model:profile-a="profileA"
v-model:as-of="asOf"
:self-initial="selfInitial"
:self-label="selfLabel"
:profile-a-name="profileAName"
:pickable-profiles="pickableProfiles"
:nearby="nearby"
@@ -89,7 +89,6 @@
</template>
<script setup lang="ts">
import BackButton from '../components/BackButton.vue'
import HomeToolIcon from '../components/HomeToolIcon.vue'
import PageShell from '../components/PageShell.vue'
import ShareSheet from '../components/ShareSheet.vue'
@@ -127,6 +126,7 @@ const {
pickableProfiles,
selfProfiles,
profileAName,
selfLabel,
selfInitial,
profileInitial,
headline,