抽出 OpenAPI DTO;将 Ask/Report/Profile/Star 等 8 页拆到 ≤400 行,并修 ScaleSummary、fortuneKey、Scale 选答与单测。 Co-authored-by: Cursor <cursoragent@cursor.com>
355 lines
8.1 KiB
Vue
355 lines
8.1 KiB
Vue
<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>
|