refactor(ECR-001): Phase F types/sdk 对齐并拆分超标 H5 页
抽出 OpenAPI DTO;将 Ask/Report/Profile/Star 等 8 页拆到 ≤400 行,并修 ScaleSummary、fortuneKey、Scale 选答与单测。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,588 +1,59 @@
|
||||
<template>
|
||||
<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>
|
||||
<AskRails v-model="rail" />
|
||||
</template>
|
||||
|
||||
<!-- 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="retry" @click="boot">重试</button>
|
||||
</p>
|
||||
<template v-else-if="!profiles.length">
|
||||
<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 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>
|
||||
<AskAiPane
|
||||
v-show="rail === 'ai'"
|
||||
:boot-loading="bootLoading"
|
||||
:boot-error="bootError"
|
||||
:profiles="profiles"
|
||||
:profile-id="profileId"
|
||||
:messages="messages"
|
||||
:draft="draft"
|
||||
:sending="sending"
|
||||
:send-error="sendError"
|
||||
:quota-exhausted="quotaExhausted"
|
||||
:quota="quota"
|
||||
@boot="boot"
|
||||
@pick-scene="pickScene"
|
||||
@switch-advisor="rail = 'advisor'"
|
||||
@update:profile-id="profileId = $event"
|
||||
@profile-change="onProfileChange"
|
||||
@navigate="router.push($event)"
|
||||
@update:draft="draft = $event"
|
||||
@send="send"
|
||||
/>
|
||||
|
||||
<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>
|
||||
<div class="quick">
|
||||
<button
|
||||
v-for="q in quickTools"
|
||||
:key="q.to"
|
||||
type="button"
|
||||
class="q-btn"
|
||||
@click="router.push(q.to)"
|
||||
>
|
||||
{{ q.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="thread" ref="threadEl">
|
||||
<div v-for="m in messages" :key="m.id" :class="['bubble', m.role]">
|
||||
<p>{{ m.content }}</p>
|
||||
</div>
|
||||
<p v-if="sending" class="hint pad">助手正在回复…</p>
|
||||
</div>
|
||||
|
||||
<p v-if="sendError" class="err pad">
|
||||
{{ sendError }}
|
||||
<router-link v-if="quotaExhausted" class="retry" to="/membership">开通成长会员</router-link>
|
||||
</p>
|
||||
|
||||
<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>
|
||||
|
||||
<!-- 顾问轨(对标真人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>
|
||||
<AskAdvisorPane v-show="rail === 'advisor'" @ask="askAdvisor" />
|
||||
</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 HomeToolIcon from '../components/HomeToolIcon.vue'
|
||||
import AskAdvisorPane from '../components/ask/AskAdvisorPane.vue'
|
||||
import AskAiPane from '../components/ask/AskAiPane.vue'
|
||||
import AskRails from '../components/ask/AskRails.vue'
|
||||
import PageShell from '../components/PageShell.vue'
|
||||
import { useAskPage } from '../composables/useAskPage'
|
||||
|
||||
const router = useRouter()
|
||||
const rail = ref<'ai' | 'advisor'>('ai')
|
||||
|
||||
const scenes = [
|
||||
{ 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[]>([])
|
||||
const profileId = ref('')
|
||||
const scene = ref('self')
|
||||
const threadId = ref('')
|
||||
const messages = ref<AskMessage[]>([])
|
||||
const draft = ref('')
|
||||
const sending = ref(false)
|
||||
const sendError = ref('')
|
||||
const quotaExhausted = ref(false)
|
||||
const quota = ref<AskQuota | null>(null)
|
||||
const threadEl = ref<HTMLElement | null>(null)
|
||||
|
||||
async function boot() {
|
||||
bootLoading.value = true
|
||||
bootError.value = ''
|
||||
try {
|
||||
const [list, q] = await Promise.all([api.listProfiles(), api.getAskQuota()])
|
||||
profiles.value = list.items || []
|
||||
quota.value = q
|
||||
const self = profiles.value.find((p) => p.relation === 'self')
|
||||
profileId.value = self?.id || profiles.value[0]?.id || ''
|
||||
threadId.value = ''
|
||||
messages.value = []
|
||||
} catch (e) {
|
||||
bootError.value = e instanceof Error ? e.message : '加载失败'
|
||||
} finally {
|
||||
bootLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onProfileChange() {
|
||||
threadId.value = ''
|
||||
messages.value = []
|
||||
sendError.value = ''
|
||||
}
|
||||
|
||||
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('请先选择档案')
|
||||
const th = await api.createAskThread({ profile_id: profileId.value, scene: scene.value })
|
||||
threadId.value = th.id
|
||||
return th.id
|
||||
}
|
||||
|
||||
async function scrollBottom() {
|
||||
await nextTick()
|
||||
const el = threadEl.value
|
||||
if (el) el.scrollTop = el.scrollHeight
|
||||
}
|
||||
|
||||
async function send() {
|
||||
const content = draft.value.trim()
|
||||
if (!content || sending.value) return
|
||||
sending.value = true
|
||||
sendError.value = ''
|
||||
quotaExhausted.value = false
|
||||
try {
|
||||
const tid = await ensureThread()
|
||||
const out = await api.sendAskMessage(tid, content)
|
||||
messages.value = [...messages.value, out.user_message, out.assistant_message]
|
||||
quota.value = out.quota
|
||||
draft.value = ''
|
||||
await scrollBottom()
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '发送失败'
|
||||
sendError.value = msg
|
||||
quotaExhausted.value = msg.includes('次数已用完') || msg.includes('成长会员')
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(boot)
|
||||
const {
|
||||
router,
|
||||
rail,
|
||||
bootLoading,
|
||||
bootError,
|
||||
profiles,
|
||||
profileId,
|
||||
messages,
|
||||
draft,
|
||||
sending,
|
||||
sendError,
|
||||
quotaExhausted,
|
||||
quota,
|
||||
onProfileChange,
|
||||
pickScene,
|
||||
askAdvisor,
|
||||
send,
|
||||
boot,
|
||||
} = useAskPage()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.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);
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.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); }
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user