Files

233 lines
5.9 KiB
Vue
Raw Permalink Normal View History

<script setup>
const retentionData = [
{ date: '2024-01-01', users: 1000, day1: 45, day7: 32, day30: 18 },
{ date: '2024-01-02', users: 1200, day1: 42, day7: 28, day30: 15 },
{ date: '2024-01-03', users: 950, day1: 40, day7: 25, day30: 12 },
{ date: '2024-01-04', users: 1100, day1: 38, day7: 30, day30: 14 },
{ date: '2024-01-05', users: 1050, day1: 41, day7: 33, day30: 16 },
{ date: '2024-01-06', users: 1300, day1: 43, day7: 29, day30: 13 },
{ date: '2024-01-07', users: 1150, day1: 40, day7: 31, day30: 15 }
]
const curves = [
{
label: '次日留存',
color: '#3b82f6',
data: retentionData.map((r) => r.day1)
},
{
label: '7日留存',
color: '#22c55e',
data: retentionData.map((r) => r.day7)
},
{
label: '30日留存',
color: '#f59e0b',
data: retentionData.map((r) => r.day30)
}
]
function points(data) {
return data.map((v, i) => `${60 + i * 50},${180 - v * 1.6}`).join(' ')
}
function rateClass(rate) {
if (rate >= 40) return 'high'
if (rate >= 25) return 'mid'
return 'low'
}
</script>
<template>
<div class="retention-demo">
<div class="demo-header">
<span class="icon">📈</span>
<span class="title">留存分析演示</span>
<span class="subtitle">产品的"硬核"体检</span>
</div>
<div class="intro-text">
拉新是给桶加水留存是看桶漏不漏留存曲线若
<span class="hl">趋于平稳</span>说明产品已获得 PMF
<span class="hl">持续跌落至零</span>说明核心价值未被验证
</div>
<!-- 留存数据表 -->
<div class="section">
<div class="section-label">留存数据</div>
<div class="table-wrap">
<table class="r-table">
<thead>
<tr>
<th>注册日期</th>
<th>注册人数</th>
<th>次日留存</th>
<th>7日留存</th>
<th>30日留存</th>
</tr>
</thead>
<tbody>
<tr v-for="r in retentionData" :key="r.date">
<td>{{ r.date }}</td>
<td>{{ r.users }}</td>
<td :class="rateClass(r.day1)">{{ r.day1 }}%</td>
<td :class="rateClass(r.day7)">{{ r.day7 }}%</td>
<td :class="rateClass(r.day30)">{{ r.day30 }}%</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 留存曲线 -->
<div class="section">
<div class="section-label">留存曲线</div>
<div class="chart-wrap">
<svg viewBox="0 0 400 210" class="curve-svg">
<!-- 坐标轴 -->
<line x1="40" y1="180" x2="380" y2="180" stroke="#666" stroke-width="1" />
<line x1="40" y1="20" x2="40" y2="180" stroke="#666" stroke-width="1" />
<!-- Y轴标签 -->
<text x="12" y="30" font-size="10" fill="#999">100%</text>
<text x="17" y="100" font-size="10" fill="#999">50%</text>
<text x="25" y="183" font-size="10" fill="#999">0</text>
<!-- 曲线 -->
<template v-for="c in curves" :key="c.label">
<polyline
:points="points(c.data)"
fill="none"
:stroke="c.color"
stroke-width="2"
/>
<circle
v-for="(v, i) in c.data"
:key="i"
:cx="60 + i * 50"
:cy="180 - v * 1.6"
r="3.5"
:fill="c.color"
/>
</template>
<!-- X轴标签 -->
<text
v-for="(d, i) in ['D1','D2','D3','D4','D5','D6','D7']"
:key="d"
:x="60 + i * 50"
y="196"
font-size="10"
fill="#999"
text-anchor="middle"
>{{ d }}</text>
</svg>
<div class="legend">
<div v-for="c in curves" :key="c.label" class="legend-item">
<span class="legend-dot" :style="{ background: c.color }"></span>
<span class="legend-text">{{ c.label }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.retention-demo {
border: 1px solid var(--vp-c-divider);
border-radius: 12px;
background: var(--vp-c-bg-soft);
margin: 24px 0;
overflow: hidden;
}
.demo-header {
padding: 14px 20px;
background: var(--vp-c-bg);
border-bottom: 1px solid var(--vp-c-divider);
display: flex;
align-items: center;
gap: 8px;
}
.icon { font-size: 18px; }
.title { font-weight: 600; font-size: 15px; }
.subtitle { font-size: 12px; color: var(--vp-c-text-3); margin-left: auto; }
.intro-text {
padding: 16px 20px;
font-size: 13px;
color: var(--vp-c-text-2);
line-height: 1.7;
border-bottom: 1px solid var(--vp-c-divider);
}
.hl { color: var(--vp-c-brand); font-weight: 600; }
.section { padding: 16px 20px; }
.section-label { font-weight: 600; font-size: 13px; margin-bottom: 10px; }
.table-wrap {
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
overflow: hidden;
}
.r-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.r-table th,
.r-table td {
padding: 10px 12px;
text-align: left;
border-bottom: 1px solid var(--vp-c-divider);
}
.r-table th {
background: var(--vp-c-bg-alt);
font-weight: 600;
}
.r-table tbody tr:hover { background: var(--vp-c-bg-soft); }
.high { color: #22c55e; font-weight: 600; }
.mid { color: #f59e0b; font-weight: 600; }
.low { color: #ef4444; font-weight: 600; }
.chart-wrap {
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
padding: 16px;
}
.curve-svg { width: 100%; height: auto; }
.legend {
display: flex;
gap: 16px;
justify-content: center;
margin-top: 10px;
}
.legend-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
}
.legend-dot {
width: 10px;
height: 10px;
border-radius: 2px;
}
.legend-text { color: var(--vp-c-text-2); }
</style>