Files
digital-psychology/apps/user-h5/src/pages/ImageCardPage.vue
T
jackyu66gitandCursor 53eb4577b3 feat: 按测测功能重构 H5 首页与各子页,并修正顶栏「+」添加档案菜单
对齐测测交互:首页「+」支持邀请填档案/添加档案/邀请合盘;各 Tab 与工具页按同一视觉与功能标准落地;本地对照截图目录显式忽略。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 14:32:35 +08:00

467 lines
12 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>
<PageShell>
<template #hero>
<BackButton />
</template>
<div class="body" :class="{ 'has-results': cards.length }">
<!-- Greeting -->
<div class="greet">
<div class="greet-icons" aria-hidden="true">
<HomeToolIcon name="cards" :size="44" />
<HomeToolIcon name="ask" :size="36" class="icon-offset" />
</div>
<h1 class="greet-title">Hi我是愈心 AI</h1>
<p class="greet-sub">来用意念卡片整理问题吧</p>
<p v-if="bootError" class="yxg-err">{{ bootError }}仍可填写后重试抽取</p>
<p class="quota">今日剩余 {{ quotaLabel }} · 场景 {{ scene }}</p>
</div>
<!-- Prompt rows (landing) -->
<div v-if="!cards.length" class="prompt-list">
<button
v-for="(row, i) in promptRows"
:key="i"
type="button"
class="prompt-row"
@click="onPromptClick(row)"
>
<span class="prompt-ico" aria-hidden="true">{{ row.icon }}</span>
<span class="prompt-text">{{ row.label }}</span>
<span class="prompt-go" aria-hidden="true"></span>
</button>
</div>
<!-- Mode chips -->
<div v-if="!cards.length" class="mode-row" role="group" aria-label="抽卡模式">
<button
type="button"
class="mode-chip"
:class="{ on: !wantDepth }"
@click="wantDepth = false"
>
单卡
</button>
<button
type="button"
class="mode-chip"
:class="{ on: wantDepth }"
@click="wantDepth = true"
>
三卡组合
</button>
</div>
<!-- Collapsible birth -->
<div v-if="!cards.length" class="birth-fold">
<button type="button" class="birth-toggle" @click="showBirth = !showBirth">
<span>关联生日</span>
<span class="caret" :class="{ open: showBirth }"></span>
</button>
<div v-show="showBirth" class="birth-panel yxg-card soft">
<BirthDateInputs v-model:year="year" v-model:month="month" v-model:day="day" compact />
<button class="yxg-btn yxg-btn-block" type="button" :disabled="drawing" @click="draw">
{{ drawing ? '抽取中…' : '抽取卡片' }}
</button>
</div>
</div>
<!-- Results -->
<template v-if="cards.length">
<article v-for="c in cards" :key="c.id" class="yxg-card card-result">
<h2 class="card-title">{{ c.title }}</h2>
<p class="imagery">{{ c.imagery }}</p>
<p><strong>反思</strong> {{ c.prompt }}</p>
<p class="tip">{{ c.tip }}</p>
</article>
<ReportRich v-if="report" :summary="summary" :detail="detail" :deep="!!report.has_deep_access" />
<div v-if="report && !report.has_deep_access && wantDepth" class="yxg-card lock">
<p>三卡组合解读需深度版或成长会员</p>
<button class="yxg-btn" type="button" :disabled="paying" @click="buyDeep">解锁组合模拟支付</button>
</div>
<router-link v-if="report" class="yxg-link-plain report-link" :to="`/reports/${report.id}`">
在报告页打开
</router-link>
</template>
<p class="yxg-disc ai-note">意象卡片用于投射与自我反思不判定好坏也不预测未来部分内容由 AI 生成仅供参考</p>
<p v-if="error" class="yxg-err err-float">{{ error }}</p>
</div>
<!-- Bottom input bar -->
<div v-if="!cards.length" class="input-bar">
<input
v-model="questionInput"
type="text"
class="question-in"
placeholder="让我来解答你的问题吧"
@keydown.enter="sendDraw"
/>
<button type="button" class="send-btn" :disabled="drawing" aria-label="发送并抽取" @click="sendDraw">
<span v-if="drawing" class="send-spin"></span>
<span v-else class="send-ico" aria-hidden="true"></span>
</button>
</div>
</PageShell>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import type { GrowthReport } from '@yuxingu/types'
import { validateBirth } from '@yuxingu/utils'
import { api } from '../api/client'
import BackButton from '../components/BackButton.vue'
import BirthDateInputs from '../components/BirthDateInputs.vue'
import HomeToolIcon from '../components/HomeToolIcon.vue'
import PageShell from '../components/PageShell.vue'
import ReportRich from '../components/ReportRich.vue'
import { AnalyticsEvent, track } from '../lib/analytics'
const scenes = ref<{ key: string; label: string }[]>([])
const scene = ref('情绪整理')
const quota = ref<{ remaining: number; daily_free: number; unlimited: boolean } | null>(null)
const year = ref('')
const month = ref('')
const day = ref('')
const wantDepth = ref(false)
const drawing = ref(false)
const paying = ref(false)
const error = ref('')
const bootError = ref('')
const cards = ref<{ id: string; title: string; imagery: string; prompt: string; tip: string }[]>([])
const report = ref<GrowthReport | null>(null)
const questionInput = ref('')
const showBirth = ref(false)
const promptRows = [
{ icon: '🌊', label: '最近情绪有点乱,帮我理一理', scene: '情绪整理' },
{ icon: '💬', label: '和 TA 相处时,我总在重复什么模式?', scene: '关系反思' },
{ icon: '🪞', label: '我想更了解自己此刻的状态', scene: '自我觉察' },
{ icon: '🍃', label: '生活节奏太快,我需要一点方向感', scene: '生活节奏' },
]
const summary = computed(() => (report.value?.summary || {}) as Record<string, unknown>)
const detail = computed(() => (report.value?.detail || null) as Record<string, unknown> | null)
const quotaLabel = computed(() => {
if (!quota.value) return '…'
if (quota.value.unlimited) return '不限(会员)'
return `${quota.value.remaining} / ${quota.value.daily_free}`
})
function birthFilled() {
const y = Number(year.value)
const m = Number(month.value)
const d = Number(day.value)
return !validateBirth(y, m, d)
}
function selectScene(key: string) {
scene.value = key
track('cards_scene_selected', { scene: key })
}
function onPromptClick(row: { label: string; scene: string }) {
selectScene(row.scene)
questionInput.value = row.label
if (birthFilled()) {
draw()
} else {
showBirth.value = true
}
}
function sendDraw() {
if (questionInput.value.trim()) {
scene.value = questionInput.value.trim()
}
draw()
}
async function refreshQuota() {
quota.value = await api.getImageCardQuota()
}
async function boot() {
try {
const [sc] = await Promise.all([api.listImageCardScenes(), refreshQuota()])
scenes.value = sc.items || []
if (scenes.value.length && !scenes.value.find((s) => s.key === scene.value)) {
scene.value = scenes.value[0].key
}
} catch (e) {
bootError.value = e instanceof Error ? e.message : '加载失败'
}
}
async function draw() {
error.value = ''
const y = Number(year.value)
const m = Number(month.value)
const d = Number(day.value)
const msg = validateBirth(y, m, d)
if (msg) {
error.value = msg
showBirth.value = true
return
}
drawing.value = true
try {
const birth = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
const profile = await api.createProfile({ relation: 'self', birth_date: birth, display_name: '我' })
const out = await api.drawImageCard({
profile_id: profile.id,
scene: scene.value,
depth: wantDepth.value,
})
cards.value = out.cards || []
report.value = out.report
quota.value = {
remaining: out.quota_left,
daily_free: quota.value?.daily_free || 3,
unlimited: !!quota.value?.unlimited,
}
track('cards_drawn', { scene: scene.value, depth: wantDepth.value ? 1 : 0 })
} catch (e) {
const m = e instanceof Error ? e.message : '抽取失败'
error.value = m
if (m.includes('次数')) track('cards_quota_exhausted')
} finally {
drawing.value = false
}
}
async function buyDeep() {
if (!report.value) return
paying.value = true
error.value = ''
track(AnalyticsEvent.DeepAccessClicked, { surface: 'cards' })
try {
const { order_id } = await api.createOrder({ kind: 'deep_access', report_id: report.value.id })
await api.payMock(order_id)
report.value = await api.getReport(report.value.id)
track(AnalyticsEvent.PurchaseCompleted, { kind: 'deep_access' })
} catch (e) {
error.value = e instanceof Error ? e.message : '支付失败'
} finally {
paying.value = false
}
}
onMounted(boot)
</script>
<style scoped>
.body {
padding: 0 16px 88px;
}
.body.has-results {
padding-bottom: 24px;
}
.greet {
text-align: center;
padding: 8px 0 20px;
}
.greet-icons {
display: flex;
align-items: flex-end;
justify-content: center;
gap: 4px;
margin-bottom: 12px;
}
.icon-offset {
margin-bottom: -4px;
opacity: 0.85;
}
.greet-title {
font-family: var(--font-display);
font-size: 22px;
font-weight: 700;
color: var(--color-text-primary);
letter-spacing: 0.04em;
margin: 0;
}
.greet-sub {
font-size: 14px;
color: var(--color-text-secondary);
margin: 6px 0 10px;
}
.quota {
font-size: 11px;
color: var(--color-text-tertiary);
margin: 0;
}
.prompt-list {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 18px;
}
.prompt-row {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 16px 14px;
border: none;
border-radius: var(--radius-xl);
background: var(--color-surface);
box-shadow: var(--shadow-card);
text-align: left;
cursor: pointer;
transition: transform var(--duration-fast) ease;
}
.prompt-row:active {
transform: scale(0.985);
}
.prompt-ico {
font-size: 22px;
flex-shrink: 0;
width: 36px;
text-align: center;
}
.prompt-text {
flex: 1;
font-size: 14px;
font-weight: 500;
color: var(--color-text-primary);
line-height: 1.45;
}
.prompt-go {
font-size: 14px;
color: var(--color-primary);
opacity: 0.6;
}
.mode-row {
display: flex;
gap: 8px;
justify-content: center;
margin-bottom: 16px;
}
.mode-chip {
border: 1.5px solid #eee;
background: #fff;
border-radius: 999px;
padding: 8px 20px;
font-size: 13px;
color: #666;
cursor: pointer;
}
.mode-chip.on {
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
color: #fff;
border-color: transparent;
box-shadow: 0 4px 12px rgba(229, 77, 66, 0.22);
}
.birth-fold {
margin-bottom: 12px;
}
.birth-toggle {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 12px 14px;
border: none;
border-radius: var(--radius-lg);
background: rgba(255, 255, 255, 0.7);
font-size: 13px;
font-weight: 600;
color: var(--color-text-secondary);
cursor: pointer;
}
.caret {
transition: transform 0.2s ease;
}
.caret.open {
transform: rotate(180deg);
}
.birth-panel {
margin-top: 8px;
}
.birth-panel :deep(.bd) {
margin: 8px 0 12px;
}
.card-result {
margin-bottom: 12px;
}
.imagery {
color: #666;
margin-bottom: 8px;
}
.tip {
color: #3d6b45;
margin-top: 6px;
}
.lock .yxg-btn {
margin-top: 12px;
width: 100%;
}
.report-link {
display: block;
margin-top: 12px;
}
.ai-note {
margin-top: 16px;
}
.err-float {
margin-top: 8px;
}
.input-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 20;
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px calc(10px + env(safe-area-inset-bottom, 0));
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-top: 1px solid rgba(0, 0, 0, 0.06);
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.06);
}
.question-in {
flex: 1;
border: 1.5px solid #eee;
border-radius: 22px;
padding: 11px 16px;
font-size: 14px;
background: #fdfaf8;
outline: none;
}
.question-in:focus {
border-color: rgba(229, 77, 66, 0.35);
}
.send-btn {
width: 40px;
height: 40px;
flex-shrink: 0;
border: none;
border-radius: 50%;
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
color: #fff;
font-size: 18px;
font-weight: 700;
cursor: pointer;
box-shadow: 0 4px 12px rgba(229, 77, 66, 0.28);
display: flex;
align-items: center;
justify-content: center;
}
.send-btn:disabled {
opacity: 0.6;
}
.send-ico {
line-height: 1;
margin-top: -2px;
}
</style>