Trae Assistant
feat: enhance robustness, add import functionality, and prepare for HF deployment
727abcc
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>转化漏斗架构师 (Conversion Funnel Architect)</title>
<!-- Vue 3 -->
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- ECharts -->
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
body { font-family: 'Inter', sans-serif; background-color: #f3f4f6; }
.chart-container { height: 400px; width: 100%; }
/* Custom scrollbar for cleaner look */
::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-track { background: #f1f1f1; }
::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
</style>
</head>
<body>
<div id="app" class="flex h-screen overflow-hidden">
<!-- Sidebar: Configuration -->
<div class="w-80 bg-white border-r border-gray-200 flex flex-col h-full shadow-lg z-10">
<div class="p-6 border-b border-gray-100">
<h1 class="text-xl font-bold text-gray-800 flex items-center gap-2">
<i class="fa-solid fa-filter-circle-dollar text-indigo-600"></i>
漏斗架构师
</h1>
<p class="text-xs text-gray-500 mt-1">Conversion Funnel Architect</p>
</div>
<div class="flex-1 overflow-y-auto p-6 space-y-6">
<!-- Global Params -->
<div class="space-y-4">
<h3 class="text-sm font-semibold text-gray-400 uppercase tracking-wider">全局参数</h3>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">流量 (Traffic)</label>
<div class="relative">
<span class="absolute inset-y-0 left-0 pl-3 flex items-center text-gray-400"><i class="fa-solid fa-users"></i></span>
<input v-model.number="config.traffic" type="number" class="pl-10 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2" placeholder="10000">
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">单次点击成本 (CPC)</label>
<div class="relative">
<span class="absolute inset-y-0 left-0 pl-3 flex items-center text-gray-400">¥</span>
<input v-model.number="config.cpc" type="number" step="0.1" class="pl-8 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2" placeholder="1.5">
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">产品单价 (Price)</label>
<div class="relative">
<span class="absolute inset-y-0 left-0 pl-3 flex items-center text-gray-400">¥</span>
<input v-model.number="config.product_price" type="number" class="pl-8 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2" placeholder="99">
</div>
</div>
</div>
<div class="border-t border-gray-100 pt-6"></div>
<!-- Steps Editor -->
<div class="space-y-4">
<div class="flex justify-between items-center">
<h3 class="text-sm font-semibold text-gray-400 uppercase tracking-wider">漏斗步骤</h3>
<button @click="addStep" class="text-xs bg-indigo-50 text-indigo-600 px-2 py-1 rounded hover:bg-indigo-100 transition">
<i class="fa-solid fa-plus"></i> 添加
</button>
</div>
<div v-for="(step, index) in config.steps" :key="index" class="bg-gray-50 p-3 rounded-lg border border-gray-200 group relative">
<button @click="removeStep(index)" class="absolute top-2 right-2 text-gray-400 hover:text-red-500 opacity-0 group-hover:opacity-100 transition">
<i class="fa-solid fa-trash"></i>
</button>
<div class="mb-2">
<label class="text-xs text-gray-500 block mb-1">步骤名称</label>
<input v-model="step.name" type="text" class="w-full text-sm border-gray-300 rounded border p-1 focus:ring-indigo-500 focus:border-indigo-500">
</div>
<div>
<div class="flex justify-between mb-1">
<label class="text-xs text-gray-500">转化率</label>
<span class="text-xs font-medium text-indigo-600">${ step.rate }%</span>
</div>
<input v-model.number="step.rate" type="range" min="1" max="100" class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-indigo-600">
</div>
</div>
</div>
</div>
<div class="p-6 border-t border-gray-200 bg-gray-50">
<button @click="simulate" :disabled="loading" class="w-full bg-indigo-600 text-white py-2 px-4 rounded-md hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition shadow-md font-medium flex justify-center items-center gap-2">
<i v-if="loading" class="fa-solid fa-circle-notch fa-spin"></i>
<span v-else><i class="fa-solid fa-play"></i> 开始模拟</span>
</button>
</div>
</div>
<!-- Main Content -->
<div class="flex-1 flex flex-col h-full overflow-hidden bg-gray-50">
<!-- Header -->
<header class="bg-white shadow-sm px-8 py-4 flex justify-between items-center z-10">
<h2 class="text-lg font-medium text-gray-800">模拟仪表盘</h2>
<div class="flex gap-3">
<input type="file" ref="fileInput" @change="handleFileUpload" class="hidden" accept=".json">
<button @click="triggerUpload" class="text-gray-600 hover:text-indigo-600 px-3 py-1.5 text-sm border border-gray-300 rounded-md hover:border-indigo-300 transition bg-white">
<i class="fa-solid fa-upload"></i> 导入配置
</button>
<button @click="exportConfig" class="text-gray-600 hover:text-indigo-600 px-3 py-1.5 text-sm border border-gray-300 rounded-md hover:border-indigo-300 transition bg-white">
<i class="fa-solid fa-download"></i> 导出配置
</button>
</div>
</header>
<!-- Dashboard Content -->
<main class="flex-1 overflow-y-auto p-8">
<!-- Metrics Cards -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<div class="bg-white p-6 rounded-xl shadow-sm border border-gray-100 hover:shadow-md transition">
<div class="flex items-center justify-between mb-2">
<h3 class="text-sm font-medium text-gray-500">总投入 (Spend)</h3>
<div class="p-2 bg-blue-50 rounded-full text-blue-600"><i class="fa-solid fa-wallet"></i></div>
</div>
<p class="text-2xl font-bold text-gray-800">¥${ formatNumber(metrics.total_spend) }</p>
</div>
<div class="bg-white p-6 rounded-xl shadow-sm border border-gray-100 hover:shadow-md transition">
<div class="flex items-center justify-between mb-2">
<h3 class="text-sm font-medium text-gray-500">总营收 (Revenue)</h3>
<div class="p-2 bg-green-50 rounded-full text-green-600"><i class="fa-solid fa-sack-dollar"></i></div>
</div>
<p class="text-2xl font-bold text-gray-800">¥${ formatNumber(metrics.total_revenue) }</p>
<p class="text-xs mt-1" :class="metrics.total_revenue > metrics.total_spend ? 'text-green-500' : 'text-red-500'">
${ metrics.total_revenue > metrics.total_spend ? '+' : '' }${ formatNumber(metrics.total_revenue - metrics.total_spend) } 净利
</p>
</div>
<div class="bg-white p-6 rounded-xl shadow-sm border border-gray-100 hover:shadow-md transition">
<div class="flex items-center justify-between mb-2">
<h3 class="text-sm font-medium text-gray-500">ROI (回报率)</h3>
<div class="p-2 bg-purple-50 rounded-full text-purple-600"><i class="fa-solid fa-chart-line"></i></div>
</div>
<p class="text-2xl font-bold" :class="metrics.roi >= 0 ? 'text-green-600' : 'text-red-600'">
${ metrics.roi }%
</p>
</div>
<div class="bg-white p-6 rounded-xl shadow-sm border border-gray-100 hover:shadow-md transition">
<div class="flex items-center justify-between mb-2">
<h3 class="text-sm font-medium text-gray-500">CAC (获客成本)</h3>
<div class="p-2 bg-orange-50 rounded-full text-orange-600"><i class="fa-solid fa-user-tag"></i></div>
</div>
<p class="text-2xl font-bold text-gray-800">¥${ metrics.cac }</p>
</div>
</div>
<!-- Charts Area -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
<!-- Funnel Chart -->
<div class="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
<h3 class="text-lg font-semibold text-gray-800 mb-4">漏斗转化视图</h3>
<div id="funnelChart" class="chart-container"></div>
</div>
<!-- Dropoff Chart -->
<div class="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
<h3 class="text-lg font-semibold text-gray-800 mb-4">流失分析</h3>
<div id="dropoffChart" class="chart-container"></div>
</div>
</div>
<!-- Detailed Table -->
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-100">
<h3 class="text-lg font-semibold text-gray-800">详细数据表</h3>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm text-left text-gray-500">
<thead class="text-xs text-gray-700 uppercase bg-gray-50">
<tr>
<th class="px-6 py-3">步骤名称</th>
<th class="px-6 py-3">用户数</th>
<th class="px-6 py-3">转化率</th>
<th class="px-6 py-3">流失用户</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, index) in funnelData" :key="index" class="bg-white border-b hover:bg-gray-50">
<td class="px-6 py-4 font-medium text-gray-900">${ item.step_name }</td>
<td class="px-6 py-4">${ formatNumber(item.users) }</td>
<td class="px-6 py-4">
<span class="px-2 py-1 bg-indigo-50 text-indigo-700 rounded-full text-xs">
${ item.conversion_rate }%
</span>
</td>
<td class="px-6 py-4 text-red-500">${ item.dropoff > 0 ? '-' + formatNumber(item.dropoff) : '-' }</td>
</tr>
</tbody>
</table>
</div>
</div>
</main>
</div>
</div>
<script>
const { createApp, ref, onMounted, nextTick } = Vue;
createApp({
delimiters: ['${', '}'],
setup() {
const loading = ref(false);
const config = ref({
traffic: 10000,
cpc: 2.5,
product_price: 199,
steps: [
{ name: '着陆页 (Landing Page)', rate: 40 },
{ name: '注册 (Sign Up)', rate: 25 },
{ name: '试用 (Trial)', rate: 15 },
{ name: '付费 (Purchase)', rate: 20 }
]
});
const metrics = ref({
total_spend: 0,
total_revenue: 0,
roi: 0,
cac: 0,
final_conversions: 0
});
const funnelData = ref([]);
let funnelChart = null;
let dropoffChart = null;
const formatNumber = (num) => {
return new Intl.NumberFormat().format(Math.round(num));
};
const addStep = () => {
config.value.steps.push({ name: '新步骤', rate: 50 });
};
const removeStep = (index) => {
config.value.steps.splice(index, 1);
};
const initCharts = () => {
const funnelChartDom = document.getElementById('funnelChart');
const dropoffChartDom = document.getElementById('dropoffChart');
if (funnelChartDom) funnelChart = echarts.init(funnelChartDom);
if (dropoffChartDom) dropoffChart = echarts.init(dropoffChartDom);
window.addEventListener('resize', () => {
funnelChart && funnelChart.resize();
dropoffChart && dropoffChart.resize();
});
};
const updateCharts = (data) => {
if (!funnelChart || !dropoffChart) return;
// Funnel Chart
const funnelSeriesData = data.map(item => ({
value: item.users,
name: item.step_name
}));
const optionFunnel = {
tooltip: {
trigger: 'item',
formatter: "{b} : {c} ({d}%)"
},
toolbox: {
feature: {
saveAsImage: {}
}
},
series: [
{
name: 'Funnel',
type: 'funnel',
left: '10%',
top: 60,
bottom: 60,
width: '80%',
min: 0,
max: data[0].users, // Base on first step
minSize: '0%',
maxSize: '100%',
sort: 'none',
gap: 2,
label: {
show: true,
position: 'inside'
},
labelLine: {
length: 10,
lineStyle: {
width: 1,
type: 'solid'
}
},
itemStyle: {
borderColor: '#fff',
borderWidth: 1
},
emphasis: {
label: {
fontSize: 20
}
},
data: funnelSeriesData
}
]
};
funnelChart.setOption(optionFunnel);
// Dropoff Chart (Bar)
const steps = data.map(item => item.step_name);
const dropoffs = data.map(item => item.dropoff);
const optionBar = {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' }
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'category',
data: steps,
axisLabel: { interval: 0, rotate: 30 }
},
yAxis: {
type: 'value',
name: '流失用户数'
},
series: [
{
data: dropoffs,
type: 'bar',
itemStyle: {
color: '#ef4444'
},
label: {
show: true,
position: 'top'
}
}
]
};
dropoffChart.setOption(optionBar);
};
const simulate = async () => {
loading.value = true;
try {
const response = await fetch('/api/simulate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config.value)
});
const result = await response.json();
if (result.metrics) {
metrics.value = result.metrics;
funnelData.value = result.funnel_data;
nextTick(() => {
updateCharts(result.funnel_data);
});
}
} catch (error) {
console.error('Simulation failed:', error);
alert('模拟失败,请检查控制台');
} finally {
loading.value = false;
}
};
const exportConfig = () => {
const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(config.value, null, 2));
const downloadAnchorNode = document.createElement('a');
downloadAnchorNode.setAttribute("href", dataStr);
downloadAnchorNode.setAttribute("download", "funnel_config.json");
document.body.appendChild(downloadAnchorNode);
downloadAnchorNode.click();
downloadAnchorNode.remove();
};
const fileInput = ref(null);
const triggerUpload = () => {
fileInput.value.click();
};
const handleFileUpload = (event) => {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
const content = JSON.parse(e.target.result);
// Basic validation
if (content.traffic !== undefined && Array.isArray(content.steps)) {
config.value = content;
simulate();
alert('配置导入成功!');
} else {
alert('无效的配置文件格式:缺少必要的字段 (traffic, steps)');
}
} catch (error) {
console.error('Error parsing JSON:', error);
alert('文件解析失败,请确保是有效的JSON文件');
}
// Reset input
event.target.value = '';
};
reader.readAsText(file);
};
onMounted(() => {
initCharts();
simulate(); // Initial run
});
return {
config,
metrics,
funnelData,
loading,
addStep,
removeStep,
simulate,
exportConfig,
triggerUpload,
handleFileUpload,
fileInput,
formatNumber
};
}
}).mount('#app');
</script>
</body>
</html>