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
@@ -0,0 +1,354 @@
<template>
<div class="ai-pane">
<p v-if="bootLoading" class="hint">加载中</p>
<p v-else-if="bootError" class="err">
{{ bootError }}
<button type="button" class="retry" @click="$emit('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="$emit('pick-scene', s)"
>
{{ s.label }}
</button>
</div>
<button type="button" class="advisor-link" @click="$emit('switch-advisor')">
也可以看看成长顾问
</button>
</div>
<div class="card controls">
<label class="lbl">档案</label>
<select class="sel" :value="profileId" @change="$emit('update:profileId', ($event.target as HTMLSelectElement).value); $emit('profile-change')">
<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="$emit('navigate', q.to)"
>
{{ q.label }}
</button>
</div>
</div>
<div class="thread" ref="threadRef">
<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="$emit('send')">
<textarea
class="ta"
:value="draft"
rows="2"
placeholder="让我来解答你的问题吧"
:disabled="sending || (quota !== null && quota.remaining <= 0)"
@input="$emit('update:draft', ($event.target as HTMLTextAreaElement).value)"
/>
<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>
</template>
<script setup lang="ts">
import { nextTick, ref, watch } from 'vue'
import type { AskMessage, AskQuota, Profile } from '@yuxingu/types'
import HomeToolIcon from '../HomeToolIcon.vue'
import { askQuickTools, askScenes } from '../../composables/useAskPage'
const props = defineProps<{
bootLoading: boolean
bootError: string
profiles: Profile[]
profileId: string
messages: AskMessage[]
draft: string
sending: boolean
sendError: string
quotaExhausted: boolean
quota: AskQuota | null
}>()
const threadRef = ref<HTMLElement | null>(null)
watch(
() => props.messages.length,
async () => {
await nextTick()
if (threadRef.value) threadRef.value.scrollTop = threadRef.value.scrollHeight
},
)
defineEmits<{
boot: []
'pick-scene': [s: { key: string; prompt: string }]
'switch-advisor': []
'update:profileId': [v: string]
'profile-change': []
navigate: [to: string]
'update:draft': [v: string]
send: []
}>()
const guidePrompts = askScenes
const quickTools = askQuickTools
</script>
<style scoped>
.ai-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;
}
.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);
}
.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>