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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user