对齐测测交互:首页「+」支持邀请填档案/添加档案/邀请合盘;各 Tab 与工具页按同一视觉与功能标准落地;本地对照截图目录显式忽略。 Co-authored-by: Cursor <cursoragent@cursor.com>
300 lines
7.8 KiB
Vue
300 lines
7.8 KiB
Vue
<template>
|
||
<PageShell>
|
||
<template #hero>
|
||
<div class="top-row">
|
||
<BackButton />
|
||
<span class="who-static">我的成长报告</span>
|
||
<span class="spacer" />
|
||
</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 type { GrowthReport } from '@yuxingu/types'
|
||
import { api } from '../api/client'
|
||
import BackButton from '../components/BackButton.vue'
|
||
import HomeToolIcon from '../components/HomeToolIcon.vue'
|
||
import PageShell from '../components/PageShell.vue'
|
||
import { toolIconFor } from '../lib/toolIconMap'
|
||
|
||
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 = ''
|
||
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>
|
||
.top-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding-bottom: 4px;
|
||
}
|
||
.who-static { flex: 1; font-size: 16px; font-weight: 700; text-align: center; }
|
||
.spacer { width: 36px; }
|
||
.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>
|