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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-05 14:32:35 +08:00
co-authored by Cursor
parent bd22d9dddd
commit 53eb4577b3
35 changed files with 8900 additions and 1356 deletions
+460 -85
View File
@@ -1,101 +1,183 @@
<template>
<main class="yxg-page ask">
<div class="yxg-hero">
<PageTitle feature="ask" title="问答" sub="AI 成长助手 · 结合档案回答" />
<p v-if="quota" class="yxg-meta quota">
剩余 {{ quota.remaining }}
<span v-if="!quota.active_membership">免费 {{ quota.free_limit }} </span>
</p>
</div>
<PageShell :sheet="false">
<template #hero>
<!-- 双轨顶栏 · 对标测测测测AI真人1v1 -->
<div class="rails">
<button
type="button"
class="rail"
:class="{ on: rail === 'ai' }"
@click="rail = 'ai'"
>
愈心 AI
</button>
<button
type="button"
class="rail"
:class="{ on: rail === 'advisor' }"
@click="rail = 'advisor'"
>
顾问
<span class="rail-tag">推荐</span>
</button>
</div>
</template>
<div class="yxg-sheet ask-sheet">
<p v-if="bootLoading" class="yxg-hint pad">加载中</p>
<p v-else-if="bootError" class="yxg-err pad">
<!-- AI -->
<div v-show="rail === 'ai'" class="ai-pane">
<p v-if="bootLoading" class="hint">加载中</p>
<p v-else-if="bootError" class="err">
{{ bootError }}
<button type="button" class="yxg-link" @click="boot">重试</button>
<button type="button" class="retry" @click="boot">重试</button>
</p>
<template v-else-if="!profiles.length">
<div class="yxg-card empty">
<p>还没有个人档案请先完成性格探索再来提问</p>
<router-link class="yxg-btn" to="/">去首页探索</router-link>
<div class="card empty">
<HomeToolIcon name="ask" :size="56" />
<p>还没有个人档案完成愈心解码后再来提问回答会更贴合你</p>
<router-link class="cta" to="/portrait">去愈心解码</router-link>
</div>
</template>
<template v-else>
<div class="yxg-card controls">
<label class="yxg-label">选择档案</label>
<select class="yxg-select" v-model="profileId" @change="onProfileChange">
<!-- 冷启动引导 -->
<div v-if="!messages.length" class="card greet reveal">
<div class="greet-row">
<HomeToolIcon name="ask" :size="48" />
<div>
<p class="hi">Hi我是愈心 AI</p>
<p class="hi-sub">最近有什么事都可以和我聊聊</p>
</div>
</div>
<div class="prompts">
<button
v-for="s in guidePrompts"
:key="s.key"
type="button"
class="prompt"
@click="pickScene(s)"
>
{{ s.label }}
</button>
</div>
<button type="button" class="advisor-link" @click="rail = 'advisor'">
也可以看看成长顾问
</button>
</div>
<div class="card controls">
<label class="lbl">档案</label>
<select class="sel" v-model="profileId" @change="onProfileChange">
<option v-for="p in profiles" :key="p.id" :value="p.id">
{{ p.relation === 'self' ? '我的档案' : `TA · ${p.display_name || '未命名'}` }}
</option>
</select>
<label class="yxg-label">场景</label>
<div class="scenes">
<div class="quick">
<button
v-for="s in scenes"
:key="s.key"
v-for="q in quickTools"
:key="q.to"
type="button"
class="yxg-chip yxg-chip-solid"
:class="{ on: scene === s.key }"
@click="pickScene(s)"
class="q-btn"
@click="router.push(q.to)"
>
<span aria-hidden="true">{{ s.icon }} </span>{{ s.label }}
{{ q.label }}
</button>
</div>
</div>
<div class="thread" ref="threadEl">
<p v-if="!messages.length && !sending" class="yxg-hint empty-msg">
选一个场景或直接输入你的问题
</p>
<div v-for="m in messages" :key="m.id" :class="['bubble', m.role]">
<p>{{ m.content }}</p>
</div>
<p v-if="sending" class="yxg-hint pad">助手正在回复</p>
<p v-if="sending" class="hint pad">助手正在回复</p>
</div>
<p v-if="sendError" class="yxg-err pad">
<p v-if="sendError" class="err pad">
{{ sendError }}
<router-link v-if="quotaExhausted" class="yxg-link" to="/membership">开通成长会员</router-link>
<router-link v-if="quotaExhausted" class="retry" to="/membership">开通成长会员</router-link>
</p>
<form class="composer" @submit.prevent="send">
<textarea
class="yxg-textarea"
v-model="draft"
rows="2"
placeholder="例如:我和 TA 沟通时容易卡住,该怎么调整?"
:disabled="sending || (quota !== null && quota.remaining <= 0)"
/>
<button
class="yxg-btn"
type="submit"
:disabled="sending || !draft.trim() || (quota !== null && quota.remaining <= 0)"
>
发送
</button>
</form>
<div class="composer-wrap">
<p v-if="quota" class="quota">
剩余 {{ quota.remaining }}
<span v-if="!quota.active_membership">免费 {{ quota.free_limit }}</span>
</p>
<form class="composer" @submit.prevent="send">
<textarea
class="ta"
v-model="draft"
rows="2"
placeholder="让我来解答你的问题吧"
:disabled="sending || (quota !== null && quota.remaining <= 0)"
/>
<button
class="send"
type="submit"
:disabled="sending || !draft.trim() || (quota !== null && quota.remaining <= 0)"
>
发送
</button>
</form>
<p class="ai-note">部分内容由 AI 生成仅供参考</p>
</div>
</template>
</div>
</main>
<!-- 顾问轨对标真人1v1无连麦则引导提问/会员 -->
<div v-show="rail === 'advisor'" class="adv-pane">
<div class="card">
<p class="adv-title">成长顾问</p>
<p class="adv-sub">结合档案聊聊卡住的事 · 可先用 AI或开通会员解锁更多</p>
</div>
<div
v-for="a in advisors"
:key="a.name"
class="adv-card"
>
<div class="av" :style="{ background: a.bg }">{{ a.mark }}</div>
<div class="adv-body">
<strong>{{ a.name }}</strong>
<span class="meta">{{ a.meta }}</span>
<span class="tags">{{ a.tags }}</span>
</div>
<button type="button" class="ask-btn" @click="askAdvisor(a)">去提问</button>
</div>
<router-link class="cta soft" to="/membership">查看成长会员权益</router-link>
</div>
</PageShell>
</template>
<script setup lang="ts">
import { nextTick, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import type { AskMessage, AskQuota, Profile } from '@yuxingu/types'
import { api } from '../api/client'
import PageTitle from '../components/PageTitle.vue'
import HomeToolIcon from '../components/HomeToolIcon.vue'
import PageShell from '../components/PageShell.vue'
const router = useRouter()
const rail = ref<'ai' | 'advisor'>('ai')
const scenes = [
{ key: 'self', icon: '△', label: '认识自己', prompt: '我想更了解自己的性格与互动风格。' },
{ key: 'relation', icon: '♡', label: '理解关系', prompt: '和重要的人相处时,我该如何更好地沟通?' },
{ key: 'career', icon: '▽', label: '职业探索', prompt: '从我的特点看,职业选择上可以注意什么?' },
{ key: 'emotion', icon: '○', label: '情绪整理', prompt: '最近情绪有点乱,我想梳理一下。' },
{ key: 'life', icon: '☯', label: '生活建议', prompt: '想改善作息和日常节奏,有什么建议?' },
{ key: 'dream', icon: '✦', label: '梦境整理', prompt: '昨晚的梦让我有些在意,想从感受层面整理一下,不是解梦预测。' },
{ key: 'family', icon: '◎', label: '原生家庭', prompt: '想梳理原生家庭模式对我沟通与边界的影响。' },
{ key: 'values', icon: '◈', label: '价值澄清', prompt: '我想弄清现阶段什么对我真正重要。' },
{ key: 'self', label: '我想更了解自己的性格与互动风格', prompt: '我想更了解自己的性格与互动风格。' },
{ key: 'relation', label: '和重要的人相处时该如何沟通?', prompt: '和重要的人相处时,我该如何更好地沟通?' },
{ key: 'emotion', label: '最近情绪有点乱,想梳理一下', prompt: '最近情绪有点乱,我想梳理一下。' },
{ key: 'career', label: '职业选择上可以注意什么?', prompt: '从我的特点看,职业选择上可以注意什么?' },
] as const
const guidePrompts = scenes
const quickTools = [
{ to: '/portrait', label: '工具' },
{ to: '/synastry', label: '合盘' },
{ to: '/cards', label: '意象卡' },
{ to: '/relation', label: '匹配' },
]
const advisors = [
{ name: '心语', mark: '心', bg: 'linear-gradient(145deg,#ffd0c8,#ffb0a4)', meta: '好评率 98% · 情绪整理', tags: '文字沟通', scene: 'emotion' as const },
{ name: '明朗', mark: '明', bg: 'linear-gradient(145deg,#d6e8ff,#b0d0f5)', meta: '好评率 97% · 关系理解', tags: '文字沟通', scene: 'relation' as const },
{ name: '志远', mark: '志', bg: 'linear-gradient(145deg,#ffe6c8,#f5c98a)', meta: '好评率 96% · 职业探索', tags: '文字沟通', scene: 'career' as const },
]
const bootLoading = ref(true)
const bootError = ref('')
const profiles = ref<Profile[]>([])
@@ -134,13 +216,19 @@ function onProfileChange() {
sendError.value = ''
}
function pickScene(s: (typeof scenes)[number]) {
function pickScene(s: { key: string; prompt: string }) {
scene.value = s.key
draft.value = s.prompt
threadId.value = ''
messages.value = []
}
function askAdvisor(a: (typeof advisors)[number]) {
rail.value = 'ai'
const s = scenes.find((x) => x.key === a.scene) || scenes[0]
pickScene(s)
}
async function ensureThread(): Promise<string> {
if (threadId.value) return threadId.value
if (!profileId.value) throw new Error('请先选择档案')
@@ -181,33 +269,320 @@ onMounted(boot)
</script>
<style scoped>
.ask{padding-bottom:24px}
.quota{margin-top:4px}
.ask-sheet{padding-bottom:100px;min-height:60vh}
.pad{padding:0 16px}
.controls{margin-top:8px}
.controls .yxg-label:first-child{margin-top:0}
.scenes{display:flex;flex-wrap:wrap;gap:8px;margin-top:4px}
.scenes .yxg-chip.on{
background:linear-gradient(135deg,#ff7a6e,var(--yxg-pri));
color:#fff;border-color:transparent;
.rails {
display: flex;
gap: 0;
background: rgba(255, 255, 255, 0.72);
border-radius: var(--radius-pill);
padding: 4px;
border: 1px solid rgba(255, 255, 255, 0.95);
box-shadow: 0 4px 16px rgba(180, 90, 70, 0.08);
backdrop-filter: blur(10px);
}
.thread{overflow-y:auto;max-height:42vh;padding:4px 16px 12px}
.bubble{
margin:8px 0;padding:12px 14px;border-radius:16px;
font-size:14px;line-height:1.65;white-space:pre-wrap;
box-shadow:var(--shadow-card);
.rail {
flex: 1;
border: none;
background: transparent;
padding: 10px 12px;
border-radius: var(--radius-pill);
font-size: 14px;
font-weight: 600;
color: #888;
position: relative;
}
.bubble.user{background:#fff;margin-left:28px;color:#333}
.bubble.assistant{background:#fff5f4;margin-right:28px;color:#444;box-shadow:none}
.empty-msg{text-align:center;padding:28px 16px}
.empty .yxg-btn{margin-top:12px}
.composer{
display:flex;gap:8px;align-items:flex-end;
position:sticky;bottom:72px;
margin:0 16px;padding:10px 0 4px;
background:linear-gradient(180deg,transparent, #fff 28%);
.rail.on {
background: #fff;
color: var(--color-primary);
box-shadow: 0 2px 8px rgba(229, 77, 66, 0.12);
}
.rail-tag {
position: absolute;
top: -2px;
right: 8px;
font-size: 9px;
font-weight: 700;
color: #fff;
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
padding: 1px 5px;
border-radius: 6px;
}
.ai-pane,
.adv-pane {
padding: 12px 16px 100px;
position: relative;
z-index: 2;
}
.card {
background: var(--color-surface);
border-radius: var(--radius-xl);
padding: 16px;
box-shadow: var(--shadow-hero);
margin-bottom: 12px;
}
.card.empty {
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
color: #666;
}
.greet-row {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 14px;
}
.hi {
font-size: 16px;
font-weight: 700;
color: var(--color-text-primary);
}
.hi-sub {
font-size: 12px;
color: #888;
margin-top: 2px;
}
.prompts {
display: flex;
flex-direction: column;
gap: 8px;
}
.prompt {
text-align: left;
border: 1px solid #f0ebe8;
background: #fffaf8;
border-radius: 14px;
padding: 12px 14px;
font-size: 13px;
color: #444;
line-height: 1.45;
}
.prompt:active {
background: #fff0ec;
border-color: #f5c4bc;
}
.advisor-link {
margin-top: 12px;
border: none;
background: transparent;
font-size: 12px;
color: var(--color-primary);
font-weight: 600;
}
.controls .lbl {
display: block;
font-size: 12px;
color: #999;
margin-bottom: 6px;
}
.sel {
width: 100%;
padding: 10px 12px;
border-radius: 12px;
border: 1.5px solid #eee;
background: var(--color-input-bg);
font-size: 14px;
outline: none;
}
.quick {
display: flex;
gap: 8px;
margin-top: 12px;
overflow-x: auto;
scrollbar-width: none;
}
.q-btn {
flex-shrink: 0;
padding: 7px 12px;
border-radius: var(--radius-pill);
border: 1px solid #f0ebe8;
background: #fff;
font-size: 12px;
color: #666;
}
.thread {
overflow-y: auto;
max-height: 36vh;
padding: 4px 0 12px;
}
.bubble {
margin: 8px 0;
padding: 12px 14px;
border-radius: 16px;
font-size: 14px;
line-height: 1.65;
white-space: pre-wrap;
box-shadow: var(--shadow-card);
background: #fff;
}
.bubble.user {
margin-left: 28px;
color: #333;
}
.bubble.assistant {
margin-right: 28px;
background: #fff5f4;
color: #444;
box-shadow: none;
}
.composer-wrap {
position: sticky;
bottom: 72px;
padding-top: 8px;
background: linear-gradient(180deg, transparent, var(--color-bg-sheet) 24%);
}
.quota {
font-size: 11px;
color: #aaa;
margin-bottom: 6px;
}
.composer {
display: flex;
gap: 8px;
align-items: flex-end;
}
.ta {
flex: 1;
resize: none;
min-height: 44px;
padding: 11px 14px;
border-radius: 16px;
border: 1.5px solid #eee;
background: #fff;
font-size: 14px;
font-family: inherit;
outline: none;
}
.ta:focus {
border-color: #f0b8b0;
}
.send {
flex-shrink: 0;
padding: 11px 16px;
border: none;
border-radius: 22px;
color: #fff;
font-size: 14px;
font-weight: 600;
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
box-shadow: 0 6px 16px rgba(229, 77, 66, 0.28);
}
.send:disabled {
opacity: 0.45;
}
.ai-note {
margin-top: 8px;
font-size: 10px;
color: #ccc;
text-align: center;
}
.adv-title {
font-size: 16px;
font-weight: 700;
color: #222;
}
.adv-sub {
font-size: 12px;
color: #888;
margin-top: 4px;
line-height: 1.45;
}
.adv-card {
display: flex;
align-items: center;
gap: 12px;
padding: 14px;
background: #fff;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
margin-bottom: 10px;
}
.av {
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
color: #c23b32;
flex-shrink: 0;
}
.adv-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.adv-body strong {
font-size: 14px;
color: #222;
}
.meta {
font-size: 11px;
color: #999;
}
.tags {
font-size: 11px;
color: var(--color-primary);
}
.ask-btn {
flex-shrink: 0;
padding: 8px 12px;
border: none;
border-radius: 18px;
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
color: #fff;
font-size: 12px;
font-weight: 600;
}
.cta {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 12px 20px;
border-radius: 22px;
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
color: #fff;
font-weight: 600;
font-size: 14px;
box-shadow: 0 6px 16px rgba(229, 77, 66, 0.28);
}
.cta.soft {
display: flex;
margin-top: 8px;
background: #fff;
color: var(--color-primary);
border: 1.5px solid #f5c4bc;
box-shadow: none;
}
.hint {
font-size: 13px;
color: #999;
text-align: center;
padding: 24px;
}
.err {
font-size: 13px;
color: var(--color-primary);
padding: 8px 0;
}
.pad { padding: 0 4px; }
.retry {
border: none;
background: transparent;
color: var(--color-accent-blue);
text-decoration: underline;
font-size: 13px;
margin-left: 6px;
}
.reveal {
animation: rise 0.45s cubic-bezier(0.22, 1, 0.36, 1) both;
}
@keyframes rise {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.composer .yxg-textarea{flex:1;resize:none;min-height:44px}
.composer .yxg-btn{flex-shrink:0;padding:11px 16px}
</style>