duqing2026's picture
init
9f76c25
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>增长飞轮模拟器 (Growth Loop Simulator)</title>
<script src="{{ url_for('static', filename='js/vue.global.js') }}"></script>
<script src="{{ url_for('static', filename='js/tailwindcss.js') }}"></script>
<script src="{{ url_for('static', filename='js/chart.js') }}"></script>
<script src="{{ url_for('static', filename='js/html2canvas.min.js') }}"></script>
<link href="{{ url_for('static', filename='css/all.min.css') }}" rel="stylesheet">
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
primary: '#6366f1',
secondary: '#ec4899',
dark: '#0f172a',
darker: '#020617',
}
}
}
}
</script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
body { font-family: 'Inter', sans-serif; }
.slider-thumb::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
background: #6366f1;
cursor: pointer;
border-radius: 50%;
}
</style>
</head>
<body class="bg-gray-50 text-gray-900 dark:bg-darker dark:text-gray-100 transition-colors duration-300">
{% raw %}
<div id="app" class="min-h-screen flex flex-col">
<!-- Header -->
<header class="bg-white dark:bg-dark border-b border-gray-200 dark:border-gray-800 sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 bg-gradient-to-br from-primary to-secondary rounded-lg flex items-center justify-center text-white font-bold text-xl shadow-lg">
<i class="fa-solid fa-rocket"></i>
</div>
<h1 class="text-xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-primary to-secondary">
增长飞轮模拟器
</h1>
</div>
<div class="flex items-center space-x-4">
<button @click="toggleTheme" class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors">
<i :class="isDark ? 'fa-solid fa-sun text-yellow-400' : 'fa-solid fa-moon text-gray-600'"></i>
</button>
<button @click="exportReport" class="px-4 py-2 bg-primary hover:bg-indigo-600 text-white rounded-lg text-sm font-medium transition-colors shadow-md flex items-center gap-2">
<i class="fa-solid fa-download"></i> 导出报告
</button>
</div>
</div>
</header>
<!-- Main Content -->
<main class="flex-1 max-w-7xl mx-auto w-full p-4 sm:p-6 lg:p-8 flex flex-col lg:flex-row gap-6">
<!-- Left Sidebar: Controls -->
<div class="w-full lg:w-1/3 space-y-6 overflow-y-auto max-h-[calc(100vh-8rem)] pr-2 custom-scrollbar">
<!-- Presets -->
<div class="bg-white dark:bg-dark p-5 rounded-xl shadow-sm border border-gray-200 dark:border-gray-800">
<h2 class="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-4">商业模式预设</h2>
<div class="grid grid-cols-3 gap-2">
<button @click="applyPreset('plg')" :class="preset === 'plg' ? 'bg-primary text-white border-primary' : 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 border-transparent hover:border-primary'" class="p-2 rounded-lg text-xs font-medium border transition-all">
PLG (产品驱动)
</button>
<button @click="applyPreset('slg')" :class="preset === 'slg' ? 'bg-secondary text-white border-secondary' : 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 border-transparent hover:border-secondary'" class="p-2 rounded-lg text-xs font-medium border transition-all">
SLG (销售驱动)
</button>
<button @click="applyPreset('viral')" :class="preset === 'viral' ? 'bg-green-500 text-white border-green-500' : 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 border-transparent hover:border-green-500'" class="p-2 rounded-lg text-xs font-medium border transition-all">
病毒式传播
</button>
</div>
</div>
<!-- Input Groups -->
<div class="space-y-4">
<!-- Acquisition -->
<div class="bg-white dark:bg-dark p-5 rounded-xl shadow-sm border border-gray-200 dark:border-gray-800 transition-all hover:shadow-md">
<div class="flex items-center gap-2 mb-4 text-primary">
<i class="fa-solid fa-magnet"></i>
<h3 class="font-bold">获客 (Acquisition)</h3>
</div>
<control-slider label="月度广告预算 ($)" v-model="params.adBudget" :min="0" :max="50000" :step="500"></control-slider>
<control-slider label="CPC (单次点击成本 $)" v-model="params.cpc" :min="0.1" :max="50" :step="0.1"></control-slider>
<control-slider label="自然流量 (月访客)" v-model="params.organicTraffic" :min="0" :max="10000" :step="100"></control-slider>
</div>
<!-- Activation -->
<div class="bg-white dark:bg-dark p-5 rounded-xl shadow-sm border border-gray-200 dark:border-gray-800 transition-all hover:shadow-md">
<div class="flex items-center gap-2 mb-4 text-blue-500">
<i class="fa-solid fa-bolt"></i>
<h3 class="font-bold">激活 (Activation)</h3>
</div>
<control-slider label="访客注册率 (%)" v-model="params.visitorToSignup" :min="0.1" :max="50" :step="0.1"></control-slider>
<control-slider label="注册激活率 (%)" v-model="params.signupToActive" :min="1" :max="100" :step="1"></control-slider>
</div>
<!-- Retention -->
<div class="bg-white dark:bg-dark p-5 rounded-xl shadow-sm border border-gray-200 dark:border-gray-800 transition-all hover:shadow-md">
<div class="flex items-center gap-2 mb-4 text-red-500">
<i class="fa-solid fa-heart-crack"></i>
<h3 class="font-bold">留存 (Retention)</h3>
</div>
<control-slider label="月流失率 (%)" v-model="params.churnRate" :min="0.1" :max="30" :step="0.1" tooltip="越低越好"></control-slider>
</div>
<!-- Referral -->
<div class="bg-white dark:bg-dark p-5 rounded-xl shadow-sm border border-gray-200 dark:border-gray-800 transition-all hover:shadow-md">
<div class="flex items-center gap-2 mb-4 text-green-500">
<i class="fa-solid fa-users-viewfinder"></i>
<h3 class="font-bold">推荐 (Referral)</h3>
</div>
<control-slider label="病毒系数 (K-Factor)" v-model="params.viralCoefficient" :min="0" :max="2" :step="0.05" tooltip=">1 为自增长"></control-slider>
</div>
<!-- Revenue -->
<div class="bg-white dark:bg-dark p-5 rounded-xl shadow-sm border border-gray-200 dark:border-gray-800 transition-all hover:shadow-md">
<div class="flex items-center gap-2 mb-4 text-yellow-500">
<i class="fa-solid fa-coins"></i>
<h3 class="font-bold">营收 (Revenue)</h3>
</div>
<control-slider label="ARPU (平均用户月收入 $)" v-model="params.arpu" :min="0" :max="500" :step="1"></control-slider>
</div>
</div>
</div>
<!-- Right Content: Charts & Metrics -->
<div class="w-full lg:w-2/3 flex flex-col gap-6" id="report-area">
<!-- Key Metrics Cards -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<metric-card title="第12月 MRR" :value="formatCurrency(month12Data.mrr)" icon="fa-chart-line" color="text-primary"></metric-card>
<metric-card title="第12月 活跃用户" :value="formatNumber(month12Data.activeUsers)" icon="fa-users" color="text-blue-500"></metric-card>
<metric-card title="LTV (生命周期价值)" :value="formatCurrency(ltv)" icon="fa-gem" color="text-purple-500"></metric-card>
<metric-card title="CAC (获客成本)" :value="formatCurrency(cac)" icon="fa-hand-holding-dollar" :color="ltvToCac > 3 ? 'text-green-500' : 'text-red-500'"></metric-card>
</div>
<!-- North Star Metric -->
<div class="bg-gradient-to-r from-indigo-500 to-purple-600 rounded-xl p-6 text-white shadow-lg flex justify-between items-center">
<div>
<div class="text-indigo-100 text-sm font-medium mb-1">健康度指标 (LTV / CAC)</div>
<div class="text-3xl font-bold flex items-baseline gap-2">
{{ ltvToCac.toFixed(2) }}x
<span class="text-sm font-normal bg-white/20 px-2 py-0.5 rounded" v-if="ltvToCac > 3">健康 🚀</span>
<span class="text-sm font-normal bg-red-500/50 px-2 py-0.5 rounded" v-else>危险 ⚠️</span>
</div>
</div>
<div class="text-right">
<div class="text-indigo-100 text-sm font-medium mb-1">达到 $10k MRR</div>
<div class="text-2xl font-bold">{{ monthTo10k > 0 ? `第 ${monthTo10k} 个月` : '未达成' }}</div>
</div>
</div>
<!-- Main Chart -->
<div class="bg-white dark:bg-dark p-6 rounded-xl shadow-sm border border-gray-200 dark:border-gray-800 flex-1 min-h-[400px]">
<h3 class="text-lg font-bold mb-4">24个月增长预测</h3>
<div class="relative h-[350px] w-full">
<canvas id="growthChart"></canvas>
</div>
</div>
<!-- Breakdown Chart -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="bg-white dark:bg-dark p-6 rounded-xl shadow-sm border border-gray-200 dark:border-gray-800">
<h3 class="text-lg font-bold mb-4">用户来源构成 (第12月)</h3>
<div class="relative h-[250px] w-full flex justify-center">
<canvas id="sourceChart"></canvas>
</div>
</div>
<div class="bg-white dark:bg-dark p-6 rounded-xl shadow-sm border border-gray-200 dark:border-gray-800">
<h3 class="text-lg font-bold mb-4">关键洞察</h3>
<ul class="space-y-3 text-sm">
<li class="flex items-start gap-2">
<i class="fa-solid fa-check-circle text-green-500 mt-1"></i>
<span>如果流失率降低 1%,第24个月收入将增加 <span class="font-bold text-green-600">{{ formatCurrency(churnImpact) }}</span></span>
</li>
<li class="flex items-start gap-2">
<i class="fa-solid fa-check-circle text-blue-500 mt-1"></i>
<span>当前付费回本周期 (Payback Period): <span class="font-bold">{{ paybackPeriod.toFixed(1) }} 个月</span></span>
</li>
<li class="flex items-start gap-2" v-if="params.viralCoefficient > 0.5">
<i class="fa-solid fa-fire text-orange-500 mt-1"></i>
<span>病毒系数较高,建议加大顶部流量漏斗。</span>
</li>
<li class="flex items-start gap-2" v-if="params.churnRate > 5">
<i class="fa-solid fa-triangle-exclamation text-red-500 mt-1"></i>
<span>流失率过高 (>5%),这应该是首要解决的问题。</span>
</li>
</ul>
</div>
</div>
</div>
</main>
</div>
<!-- Components -->
<script type="text/x-template" id="control-slider-template">
<div class="mb-4">
<div class="flex justify-between items-center mb-1">
<label class="text-xs font-medium text-gray-500 dark:text-gray-400 flex items-center gap-1">
{{ label }}
<i v-if="tooltip" class="fa-regular fa-circle-question cursor-help" :title="tooltip"></i>
</label>
<span class="text-sm font-bold text-primary">{{ modelValue }}</span>
</div>
<input type="range"
:value="modelValue"
@input="$emit('update:modelValue', Number($event.target.value))"
:min="min" :max="max" :step="step"
class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700 slider-thumb accent-primary">
</div>
</script>
<script type="text/x-template" id="metric-card-template">
<div class="bg-white dark:bg-dark p-4 rounded-xl shadow-sm border border-gray-200 dark:border-gray-800 flex flex-col justify-between h-full">
<div class="text-xs text-gray-500 dark:text-gray-400 font-medium mb-1">{{ title }}</div>
<div class="flex items-end justify-between">
<div class="text-lg font-bold truncate" :class="color">{{ value }}</div>
<i class="fa-solid text-gray-200 dark:text-gray-700 text-xl" :class="icon"></i>
</div>
</div>
</script>
<script>
const { createApp, ref, reactive, computed, watch, onMounted, nextTick } = Vue;
const ControlSlider = {
props: ['label', 'modelValue', 'min', 'max', 'step', 'tooltip'],
emits: ['update:modelValue'],
template: '#control-slider-template'
};
const MetricCard = {
props: ['title', 'value', 'icon', 'color'],
template: '#metric-card-template'
};
createApp({
components: {
'control-slider': ControlSlider,
'metric-card': MetricCard
},
setup() {
const isDark = ref(false);
const preset = ref('plg');
// Initial Params
const params = reactive({
adBudget: 2000,
cpc: 2.5,
organicTraffic: 500,
visitorToSignup: 5.0,
signupToActive: 40,
churnRate: 3.5,
viralCoefficient: 0.1,
arpu: 29
});
// Simulation Logic
const simulation = computed(() => {
let months = [];
let activeUsers = 0;
// Monthly data
for (let i = 0; i < 24; i++) {
// 1. New Users Calculation
const paidTraffic = params.adBudget / Math.max(0.01, params.cpc);
const totalTraffic = paidTraffic + params.organicTraffic;
const signups = totalTraffic * (params.visitorToSignup / 100);
const newActiveFromFunnel = signups * (params.signupToActive / 100);
// 2. Viral Growth
const viralNewUsers = activeUsers * params.viralCoefficient;
const totalNewUsers = newActiveFromFunnel + viralNewUsers;
// 3. Churn
const churnedUsers = activeUsers * (params.churnRate / 100);
// 4. Update Active Users
activeUsers = Math.max(0, activeUsers + totalNewUsers - churnedUsers);
// 5. Revenue
const mrr = activeUsers * params.arpu;
months.push({
month: i + 1,
activeUsers: Math.round(activeUsers),
mrr: Math.round(mrr),
newUsers: Math.round(totalNewUsers),
paidUsers: Math.round(newActiveFromFunnel), // Approximation for chart
viralUsers: Math.round(viralNewUsers)
});
}
return months;
});
// Computed Metrics
const month12Data = computed(() => simulation.value[11] || { mrr: 0, activeUsers: 0 });
const month24Data = computed(() => simulation.value[23] || { mrr: 0, activeUsers: 0 });
const cac = computed(() => {
const paidTraffic = params.adBudget / Math.max(0.01, params.cpc);
const signups = paidTraffic * (params.visitorToSignup / 100);
const paidCustomers = signups * (params.signupToActive / 100);
if (paidCustomers <= 0) return 0;
return params.adBudget / paidCustomers;
});
const ltv = computed(() => {
if (params.churnRate <= 0) return 0;
return params.arpu / (params.churnRate / 100);
});
const ltvToCac = computed(() => {
if (cac.value <= 0) return 0;
return ltv.value / cac.value;
});
const monthTo10k = computed(() => {
const found = simulation.value.find(m => m.mrr >= 10000);
return found ? found.month : 0;
});
const paybackPeriod = computed(() => {
if (params.arpu <= 0) return 0;
return cac.value / params.arpu;
});
const churnImpact = computed(() => {
// Simulate with 1% less churn
let active = 0;
for (let i = 0; i < 24; i++) {
const paidTraffic = params.adBudget / Math.max(0.01, params.cpc);
const totalTraffic = paidTraffic + params.organicTraffic;
const signups = totalTraffic * (params.visitorToSignup / 100);
const newActive = signups * (params.signupToActive / 100);
const viral = active * params.viralCoefficient;
const totalNew = newActive + viral;
const churn = active * (Math.max(0, params.churnRate - 1) / 100);
active = active + totalNew - churn;
}
const newMRR = active * params.arpu;
return newMRR - month24Data.value.mrr;
});
// Chart Instances
let growthChartInstance = null;
let sourceChartInstance = null;
const updateCharts = () => {
if (!growthChartInstance || !sourceChartInstance) return;
const labels = simulation.value.map(d => `M${d.month}`);
const mrrData = simulation.value.map(d => d.mrr);
const userData = simulation.value.map(d => d.activeUsers);
// Update Growth Chart
growthChartInstance.data.labels = labels;
growthChartInstance.data.datasets[0].data = mrrData;
growthChartInstance.data.datasets[1].data = userData;
growthChartInstance.update();
// Update Source Chart (Month 12 snapshot)
const m12 = simulation.value[11];
if (m12) {
sourceChartInstance.data.datasets[0].data = [m12.paidUsers, m12.viralUsers];
sourceChartInstance.update();
}
};
const initCharts = () => {
const ctxGrowth = document.getElementById('growthChart').getContext('2d');
const ctxSource = document.getElementById('sourceChart').getContext('2d');
Chart.defaults.color = isDark.value ? '#94a3b8' : '#64748b';
Chart.defaults.borderColor = isDark.value ? '#334155' : '#e2e8f0';
growthChartInstance = new Chart(ctxGrowth, {
type: 'line',
data: {
labels: [],
datasets: [
{
label: 'MRR ($)',
data: [],
borderColor: '#6366f1',
backgroundColor: 'rgba(99, 102, 241, 0.1)',
yAxisID: 'y',
fill: true,
tension: 0.4
},
{
label: '活跃用户',
data: [],
borderColor: '#ec4899',
backgroundColor: 'rgba(236, 72, 153, 0.1)',
yAxisID: 'y1',
fill: true,
tension: 0.4
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false,
},
scales: {
y: {
type: 'linear',
display: true,
position: 'left',
title: { display: true, text: 'MRR ($)' }
},
y1: {
type: 'linear',
display: true,
position: 'right',
grid: { drawOnChartArea: false },
title: { display: true, text: 'Users' }
}
}
}
});
sourceChartInstance = new Chart(ctxSource, {
type: 'doughnut',
data: {
labels: ['渠道/付费', '病毒/推荐'],
datasets: [{
data: [50, 50],
backgroundColor: ['#6366f1', '#10b981'],
borderWidth: 0
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'bottom' }
}
}
});
};
// Presets Logic
const presets = {
plg: { adBudget: 1000, cpc: 2, organicTraffic: 2000, visitorToSignup: 8, signupToActive: 60, churnRate: 4, viralCoefficient: 0.15, arpu: 15 },
slg: { adBudget: 5000, cpc: 10, organicTraffic: 200, visitorToSignup: 2, signupToActive: 20, churnRate: 1.5, viralCoefficient: 0.05, arpu: 200 },
viral: { adBudget: 500, cpc: 0.5, organicTraffic: 5000, visitorToSignup: 15, signupToActive: 50, churnRate: 8, viralCoefficient: 0.8, arpu: 5 }
};
const applyPreset = (key) => {
preset.value = key;
Object.assign(params, presets[key]);
};
// Watchers & Lifecycle
watch(params, () => updateCharts(), { deep: true });
watch(isDark, () => {
if(growthChartInstance) {
Chart.defaults.color = isDark.value ? '#94a3b8' : '#64748b';
Chart.defaults.borderColor = isDark.value ? '#334155' : '#e2e8f0';
growthChartInstance.update();
sourceChartInstance.update();
}
if (isDark.value) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
});
onMounted(() => {
// Check system preference
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
isDark.value = true;
}
nextTick(() => {
initCharts();
updateCharts();
});
});
const toggleTheme = () => isDark.value = !isDark.value;
const formatCurrency = (val) => {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(val);
};
const formatNumber = (val) => {
return new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }).format(val);
};
const exportReport = async () => {
const element = document.getElementById('report-area');
try {
const canvas = await html2canvas(element, {
backgroundColor: isDark.value ? '#0f172a' : '#ffffff',
scale: 2
});
const link = document.createElement('a');
link.download = `growth-simulation-${new Date().toISOString().slice(0,10)}.png`;
link.href = canvas.toDataURL();
link.click();
} catch (err) {
console.error("Export failed", err);
alert("导出失败,请重试");
}
};
return {
isDark, toggleTheme, params, preset, applyPreset,
simulation, month12Data, month24Data,
cac, ltv, ltvToCac, monthTo10k, paybackPeriod, churnImpact,
formatCurrency, formatNumber, exportReport
};
}
}).mount('#app');
</script>
{% endraw %}
</body>
</html>