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:
jackyu66git
2026-08-03 11:37:53 +08:00
co-authored by Cursor
parent 15a9db374a
commit bd22d9dddd
248 changed files with 26309 additions and 842 deletions
+206 -10
View File
@@ -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>