Files
digital-psychology/apps/user-h5/src/pages/ReportsPage.vue
T
jackyu66gitandCursor 89756f65b4 feat(ECR-012–016): 合规、题库、时辰刷新、头像、MBTI OEJTS 与埋点
落地输入合规、探索题库、报告日/时辰刷新、账号头像、OEJTS 量表,并补齐 H5 埋点与 Admin 漏斗;同步 ESS 工件、切至自建 Git、清理 GitHub Actions。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 01:27:58 +08:00

319 lines
8.1 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>
<div class="head">
<HomeToolIcon name="reports" :size="48" />
<div>
<h1 class="title">我的成长报告</h1>
<p class="sub">探索记录 · 持续回看</p>
</div>
</div>
</template>
<div class="chips">
<button
v-for="f in filters"
:key="f.key"
type="button"
class="chip"
:class="{ on: filter === f.key }"
@click="filter = f.key"
>
{{ f.label }}
</button>
</div>
<div class="body">
<router-link class="vip-strip" to="/membership">
<span class="vip-ico" aria-hidden="true"></span>
<span class="vip-copy">
<strong>成长会员</strong>
<em>解锁全部深度报告与节气陪伴</em>
</span>
<span class="vip-go">开通 </span>
</router-link>
<p v-if="loading" class="hint">加载中</p>
<p v-else-if="error" class="err">
{{ error }}
<button type="button" class="retry" @click="load">重试</button>
</p>
<template v-else>
<div v-if="!filtered.length" class="empty">
<HomeToolIcon name="reports" :size="56" />
<p>还没有{{ filter === 'all' ? '' : '该类' }}成长报告</p>
<router-link class="cta" to="/explore">去探索</router-link>
</div>
<div v-else class="list">
<router-link v-for="r in filtered" :key="r.id" :to="`/reports/${r.id}`" class="report-card">
<span class="thumb" aria-hidden="true">
<HomeToolIcon :name="toolIconFor(r.type)" :size="44" />
</span>
<span class="card-body">
<span class="card-title">
{{ typeLabel(r.type) }}
<span v-if="badgeFor(r)" class="badge" :class="'b-' + badgeFor(r)">{{ badgeLabel(badgeFor(r)!) }}</span>
</span>
<span class="card-desc">{{ headlineOf(r) }}</span>
<span class="card-date">{{ formatDate(r.created_at) }}</span>
</span>
<span class="card-cta">查看报告</span>
</router-link>
</div>
</template>
</div>
</PageShell>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import type { GrowthReport } from '@yuxingu/types'
import { api } from '../api/client'
import HomeToolIcon from '../components/HomeToolIcon.vue'
import PageShell from '../components/PageShell.vue'
import { ensureAccount } from '../lib/authSession'
import { toolIconFor } from '../lib/toolIconMap'
const router = useRouter()
const route = useRoute()
const items = ref<GrowthReport[]>([])
const loading = ref(true)
const error = ref('')
const filter = ref('all')
const filters = [
{ key: 'all', label: '全部' },
{ key: 'portrait', label: '解码' },
{ key: 'star', label: '星座' },
{ key: 'synastry', label: '合盘' },
{ key: 'rhythm', label: '节律' },
{ key: 'relation', label: '匹配' },
{ key: 'image_card', label: '意象' },
]
const filtered = computed(() => {
if (filter.value === 'all') return items.value
return items.value.filter((r) => r.type === filter.value)
})
function typeLabel(t: string) {
const map: Record<string, string> = {
portrait: '愈心解码',
relation: '人格匹配',
synastry: '合盘',
star: '星座',
rhythm: '身心节律',
image_card: '意象卡片',
}
return map[t] || '成长报告'
}
function headlineOf(r: GrowthReport) {
const s = (r.summary || {}) as Record<string, unknown>
return String(s.headline || s.diff_one_liner || s.one_liner || '查看报告')
}
function formatDate(v: string) {
try {
return new Date(v).toLocaleDateString('zh-CN')
} catch {
return v
}
}
function badgeFor(r: GrowthReport): 'new' | 'hot' | null {
const created = new Date(r.created_at).getTime()
const weekAgo = Date.now() - 7 * 86400000
if (created > weekAgo) return 'new'
if (r.type === 'portrait' || r.type === 'star') return 'hot'
return null
}
function badgeLabel(b: 'new' | 'hot') {
return b === 'new' ? 'NEW' : 'HOT'
}
async function load() {
loading.value = true
error.value = ''
if (!(await ensureAccount(router, route.fullPath))) {
loading.value = false
return
}
try {
const res = await api.listReports()
items.value = res.items || []
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
onMounted(load)
</script>
<style scoped>
.head {
display: flex;
align-items: center;
gap: 12px;
margin-top: 8px;
padding-bottom: 8px;
}
.title {
font-family: var(--font-display);
font-size: 22px;
font-weight: 700;
letter-spacing: 0.06em;
}
.sub {
font-size: 12px;
color: rgba(0, 0, 0, 0.38);
margin-top: 2px;
}
.chips {
display: flex;
gap: 8px;
padding: 8px 16px 0;
overflow-x: auto;
scrollbar-width: none;
}
.chips::-webkit-scrollbar { display: none; }
.chip {
flex-shrink: 0;
padding: 7px 14px;
border-radius: var(--radius-pill);
border: 1px solid #f0ebe8;
background: #fafafa;
font-size: 12px;
color: #666;
}
.chip.on {
background: #fff;
color: var(--color-primary);
font-weight: 600;
border-color: #f5c4bc;
box-shadow: 0 2px 8px rgba(229, 77, 66, 0.1);
}
.body { padding: 12px 16px 24px; }
.vip-strip {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 14px;
padding: 12px 14px;
border-radius: var(--radius-lg);
background: linear-gradient(145deg, #fff4e0, #ffe8b8);
box-shadow: 0 4px 14px rgba(200, 146, 58, 0.12);
color: inherit;
}
.vip-ico {
width: 32px;
height: 32px;
border-radius: 10px;
background: linear-gradient(135deg, #f0c86a, #e8a830);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
flex-shrink: 0;
}
.vip-copy { flex: 1; min-width: 0; }
.vip-copy strong { display: block; font-size: 14px; color: #222; }
.vip-copy em { display: block; font-style: normal; font-size: 11px; color: #8a7040; margin-top: 2px; }
.vip-go { font-size: 13px; font-weight: 600; color: #b07a28; flex-shrink: 0; }
.list { display: flex; flex-direction: column; gap: 10px; }
.report-card {
display: flex;
align-items: center;
gap: 12px;
padding: 14px;
border-radius: var(--radius-xl);
background: #fff;
box-shadow: var(--shadow-card);
border: 1px solid rgba(0, 0, 0, 0.03);
color: inherit;
transition: transform var(--duration-fast) ease;
}
.report-card:active { transform: scale(0.985); }
.thumb {
flex-shrink: 0;
width: 52px;
height: 52px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 14px;
background: linear-gradient(155deg, #ffe9e2, #ffd4c8);
}
.card-body { flex: 1; min-width: 0; }
.card-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 15px;
font-weight: 700;
color: #222;
}
.badge {
font-size: 9px;
font-weight: 700;
color: #fff;
padding: 2px 6px;
border-radius: 6px;
}
.badge.b-hot { background: linear-gradient(135deg, #ff7a6e, var(--color-primary)); }
.badge.b-new { background: linear-gradient(135deg, #6eb6ff, #4a90e2); }
.card-desc {
display: block;
font-size: 12px;
color: #888;
margin-top: 4px;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-date { display: block; font-size: 11px; color: #c4c4c4; margin-top: 4px; }
.card-cta {
flex-shrink: 0;
font-size: 12px;
font-weight: 600;
color: var(--color-primary);
padding: 8px 12px;
border-radius: var(--radius-pill);
background: #fff5f4;
border: 1px solid #ffe0dc;
}
.empty {
text-align: center;
padding: 32px 16px;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
color: #888;
}
.cta {
display: inline-flex;
padding: 11px 20px;
border-radius: 22px;
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
color: #fff;
font-weight: 600;
font-size: 14px;
}
.hint, .err { font-size: 13px; color: #999; }
.err { color: var(--color-primary); }
.retry {
border: none;
background: transparent;
color: var(--color-accent-blue);
text-decoration: underline;
margin-left: 6px;
}
</style>