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:
jackyu66git
2026-08-05 18:19:34 +08:00
co-authored by Cursor
parent 19d3cd5945
commit e735aa43e3
64 changed files with 6198 additions and 4157 deletions
+46 -575
View File
@@ -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>