feat: 按测测功能重构 H5 首页与各子页,并修正顶栏「+」添加档案菜单

对齐测测交互:首页「+」支持邀请填档案/添加档案/邀请合盘;各 Tab 与工具页按同一视觉与功能标准落地;本地对照截图目录显式忽略。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-05 14:32:35 +08:00
co-authored by Cursor
parent bd22d9dddd
commit 53eb4577b3
35 changed files with 8900 additions and 1356 deletions
+224 -43
View File
@@ -1,47 +1,66 @@
<template>
<main class="yxg-page">
<div class="yxg-hero">
<BackButton />
<PageTitle feature="reports" title="我的成长报告" sub="按类型筛选 · 愈心解码 / 星座 / 节律 / 匹配 / 意象" />
<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="yxg-sheet">
<div class="yxg-chips">
<button
v-for="f in filters"
:key="f.key"
type="button"
class="yxg-chip yxg-chip-solid"
: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="yxg-hint pad">加载中</p>
<p v-else-if="error" class="yxg-err pad">
<p v-if="loading" class="hint">加载中</p>
<p v-else-if="error" class="err">
{{ error }}
<button type="button" class="yxg-link" @click="load">重试</button>
<button type="button" class="retry" @click="load">重试</button>
</p>
<template v-else>
<div v-if="!filtered.length" class="yxg-card empty">
<div v-if="!filtered.length" class="empty">
<HomeToolIcon name="reports" :size="56" />
<p>还没有{{ filter === 'all' ? '' : '该类' }}成长报告</p>
<router-link class="yxg-btn" to="/explore">去探索</router-link>
<router-link class="cta" to="/explore">去探索</router-link>
</div>
<ul v-else class="yxg-list">
<li v-for="r in filtered" :key="r.id">
<router-link :to="`/reports/${r.id}`">
<span class="yxg-body">
<strong>{{ typeLabel(r.type) }}</strong>
<span class="desc">{{ headlineOf(r) }}</span>
<span class="meta">{{ formatDate(r.created_at) }}</span>
<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="chev" aria-hidden="true"></span>
</router-link>
</li>
</ul>
<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>
</main>
</PageShell>
</template>
<script setup lang="ts">
@@ -49,7 +68,9 @@ import { computed, onMounted, ref } from 'vue'
import type { GrowthReport } from '@yuxingu/types'
import { api } from '../api/client'
import BackButton from '../components/BackButton.vue'
import PageTitle from '../components/PageTitle.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)
@@ -73,14 +94,14 @@ const filtered = computed(() => {
function typeLabel(t: string) {
const map: Record<string, string> = {
portrait: '愈心解码',
relation: '人格匹配',
synastry: '合盘',
star: '星座',
rhythm: '身心节律',
image_card: '意象卡片',
portrait: '愈心解码',
relation: '人格匹配',
synastry: '合盘',
star: '星座',
rhythm: '身心节律',
image_card: '意象卡片',
}
return map[t] || '成长报告'
return map[t] || '成长报告'
}
function headlineOf(r: GrowthReport) {
@@ -90,12 +111,24 @@ function headlineOf(r: GrowthReport) {
function formatDate(v: string) {
try {
return new Date(v).toLocaleString('zh-CN')
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 = ''
@@ -113,6 +146,154 @@ onMounted(load)
</script>
<style scoped>
.pad{padding:0 16px}
.empty .yxg-btn{margin-top:12px}
.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>