233 lines
5.9 KiB
Vue
233 lines
5.9 KiB
Vue
<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>
|