落地 synastry/star/ask API 与 H5 页面,补齐 cece-frontend-re complete-design 证据文档,并加入 Android 模拟器截图抓取脚本。 Co-authored-by: Cursor <cursoragent@cursor.com>
214 lines
7.5 KiB
Vue
214 lines
7.5 KiB
Vue
<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>
|
||
|
||
<div class="yxg-sheet ask-sheet">
|
||
<p v-if="bootLoading" class="yxg-hint pad">加载中…</p>
|
||
<p v-else-if="bootError" class="yxg-err pad">
|
||
{{ bootError }}
|
||
<button type="button" class="yxg-link" @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>
|
||
</template>
|
||
<template v-else>
|
||
<div class="yxg-card controls">
|
||
<label class="yxg-label">选择档案</label>
|
||
<select class="yxg-select" 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">
|
||
<button
|
||
v-for="s in scenes"
|
||
:key="s.key"
|
||
type="button"
|
||
class="yxg-chip yxg-chip-solid"
|
||
:class="{ on: scene === s.key }"
|
||
@click="pickScene(s)"
|
||
>
|
||
<span aria-hidden="true">{{ s.icon }} </span>{{ s.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>
|
||
</div>
|
||
|
||
<p v-if="sendError" class="yxg-err pad">
|
||
{{ sendError }}
|
||
<router-link v-if="quotaExhausted" class="yxg-link" 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>
|
||
</template>
|
||
</div>
|
||
</main>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { nextTick, onMounted, ref } from 'vue'
|
||
import type { AskMessage, AskQuota, Profile } from '@yuxingu/types'
|
||
import { api } from '../api/client'
|
||
import PageTitle from '../components/PageTitle.vue'
|
||
|
||
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: '我想弄清现阶段什么对我真正重要。' },
|
||
] 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: (typeof scenes)[number]) {
|
||
scene.value = s.key
|
||
draft.value = s.prompt
|
||
threadId.value = ''
|
||
messages.value = []
|
||
}
|
||
|
||
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)
|
||
</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;
|
||
}
|
||
.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);
|
||
}
|
||
.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%);
|
||
}
|
||
.composer .yxg-textarea{flex:1;resize:none;min-height:44px}
|
||
.composer .yxg-btn{flex-shrink:0;padding:11px 16px}
|
||
</style>
|