feat: P1 合盘/星座/问答与测测完整设计包及模拟器取证工具
落地 synastry/star/ask API 与 H5 页面,补齐 cece-frontend-re complete-design 证据文档,并加入 Android 模拟器截图抓取脚本。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,17 +1,213 @@
|
||||
<template>
|
||||
<main class="page">
|
||||
<h1>问答</h1>
|
||||
<p class="sub">AI 成长助手 · 结合你的档案回答(占位)</p>
|
||||
<div class="card">
|
||||
预置场景:认识自己 · 理解关系 · 职业探索 · 情绪整理 · 生活建议。
|
||||
可切换我的档案 / TA 的档案;深度分析为会员权益。
|
||||
<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>
|
||||
.page{padding:24px 16px}
|
||||
h1{font-size:22px}
|
||||
.sub{color:var(--yxg-sub);font-size:13px;margin:6px 0 16px}
|
||||
.card{background:#fff;border-radius:16px;padding:18px;font-size:14px;color:#666;line-height:1.6}
|
||||
.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>
|
||||
|
||||
Reference in New Issue
Block a user