Files
digital-psychology/apps/user-h5/src/pages/AskPage.vue
T
jackyu66gitandCursor bd22d9dddd feat: P1 合盘/星座/问答与测测完整设计包及模拟器取证工具
落地 synastry/star/ask API 与 H5 页面,补齐 cece-frontend-re complete-design 证据文档,并加入 Android 模拟器截图抓取脚本。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 11:37:53 +08:00

214 lines
7.5 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>