Trae Assistant
feat: upgrade UI, fix delimiters, add file upload, localization
bb3c41b
<!DOCTYPE html>
<html lang="zh-CN" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>归因逻辑引擎 | Attribution Logic Engine</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
gray: {
800: '#1f2937',
900: '#111827',
}
}
}
}
}
</script>
<style>
body { background-color: #0f172a; color: #e2e8f0; }
.glass-panel {
background: rgba(30, 41, 59, 0.7);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.chart-container {
height: 400px;
width: 100%;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #1e293b;
}
::-webkit-scrollbar-thumb {
background: #475569;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #64748b;
}
</style>
</head>
<body class="min-h-screen p-6 font-sans">
<div id="app" class="max-w-7xl mx-auto space-y-6">
<!-- Header -->
<header class="flex justify-between items-center mb-8">
<div>
<h1 class="text-3xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-blue-400 to-purple-500">
归因逻辑引擎
</h1>
<p class="text-slate-400 mt-2">商业级多渠道营销归因分析与模型对比系统</p>
</div>
<div class="flex items-center space-x-4">
<span class="px-3 py-1 rounded-full bg-blue-500/20 text-blue-300 text-sm border border-blue-500/30">
v1.1.0
</span>
</div>
</header>
<!-- Controls -->
<div class="glass-panel p-6 rounded-xl grid grid-cols-1 md:grid-cols-3 gap-6 items-end">
<div>
<label class="block text-sm font-medium text-slate-300 mb-2">
模拟样本量 (Sample Size)
</label>
<input type="range" v-model.number="sampleSize" min="100" max="5000" step="100"
class="w-full h-2 bg-slate-700 rounded-lg appearance-none cursor-pointer">
<div class="text-right text-xs text-slate-400 mt-1">${ sampleSize } 条数据</div>
</div>
<div class="flex justify-end md:col-span-2 space-x-4">
<!-- File Upload -->
<input type="file" ref="fileInput" @change="handleFileUpload" class="hidden" accept=".csv,.json">
<button @click="triggerUpload" :disabled="loading"
class="px-6 py-2.5 bg-slate-700 hover:bg-slate-600 text-white rounded-lg font-medium transition-all border border-slate-600 flex items-center space-x-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
<span>上传数据</span>
</button>
<button @click="analyze" :disabled="loading"
class="px-6 py-2.5 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium transition-all shadow-lg shadow-blue-900/50 flex items-center space-x-2">
<span v-if="loading" class="animate-spin"></span>
<span>${ loading ? '计算中...' : '生成模拟分析' }</span>
</button>
</div>
</div>
<!-- Error Message -->
<div v-if="error" class="p-4 rounded-lg bg-red-500/20 border border-red-500/50 text-red-200">
<strong>错误:</strong> ${ error }
</div>
<!-- Metrics Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6" v-if="results">
<div class="glass-panel p-6 rounded-xl border-l-4 border-emerald-500">
<h3 class="text-slate-400 text-sm uppercase">总转化数 (Conversions)</h3>
<p class="text-3xl font-bold text-emerald-400 mt-2">${ results.attribution_results.last_click.total_conversions }</p>
</div>
<div class="glass-panel p-6 rounded-xl border-l-4 border-indigo-500">
<h3 class="text-slate-400 text-sm uppercase">总营收 (Revenue)</h3>
<p class="text-3xl font-bold text-indigo-400 mt-2">
¥${ formatCurrency(results.attribution_results.last_click.total_revenue) }
</p>
</div>
<div class="glass-panel p-6 rounded-xl border-l-4 border-purple-500">
<h3 class="text-slate-400 text-sm uppercase">平均转化率 (CVR)</h3>
<p class="text-3xl font-bold text-purple-400 mt-2">
${ ((results.attribution_results.last_click.total_conversions / results.journey_count) * 100).toFixed(1) }%
</p>
</div>
</div>
<!-- Charts Grid -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6" v-show="results">
<!-- Model Comparison -->
<div class="glass-panel p-6 rounded-xl">
<h3 class="text-lg font-semibold text-white mb-4 flex items-center">
<span class="w-2 h-6 bg-blue-500 rounded mr-3"></span>
归因模型对比 (Model Comparison)
</h3>
<div id="barChart" class="chart-container"></div>
</div>
<!-- Sankey Flow -->
<div class="glass-panel p-6 rounded-xl">
<h3 class="text-lg font-semibold text-white mb-4 flex items-center">
<span class="w-2 h-6 bg-pink-500 rounded mr-3"></span>
用户路径流向 (Journey Flow)
</h3>
<div id="sankeyChart" class="chart-container"></div>
</div>
</div>
<!-- Insights Table -->
<div class="glass-panel p-6 rounded-xl" v-if="results">
<h3 class="text-lg font-semibold text-white mb-4">渠道价值详情 (Channel Breakdown)</h3>
<div class="overflow-x-auto">
<table class="w-full text-left border-collapse">
<thead>
<tr class="text-slate-400 border-b border-slate-700">
<th class="p-3">渠道 (Channel)</th>
<th class="p-3">Last Click</th>
<th class="p-3">First Click</th>
<th class="p-3">Linear</th>
<th class="p-3">Time Decay</th>
<th class="p-3">Position Based</th>
</tr>
</thead>
<tbody class="text-slate-300">
<tr v-for="channel in displayedChannels" :key="channel" class="border-b border-slate-700/50 hover:bg-slate-800/50">
<td class="p-3 font-medium text-white">${ channel }</td>
<td class="p-3">¥${ formatCurrency(results.attribution_results.last_click.breakdown[channel] || 0) }</td>
<td class="p-3">¥${ formatCurrency(results.attribution_results.first_click.breakdown[channel] || 0) }</td>
<td class="p-3">¥${ formatCurrency(results.attribution_results.linear.breakdown[channel] || 0) }</td>
<td class="p-3">¥${ formatCurrency(results.attribution_results.time_decay.breakdown[channel] || 0) }</td>
<td class="p-3 text-yellow-400 font-bold">¥${ formatCurrency(results.attribution_results.position_based.breakdown[channel] || 0) }</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<script>
const { createApp, ref, onMounted, nextTick, computed } = Vue;
createApp({
delimiters: ['${', '}'],
setup() {
const sampleSize = ref(1000);
const loading = ref(false);
const results = ref(null);
const error = ref(null);
const fileInput = ref(null);
let barChart = null;
let sankeyChart = null;
const displayedChannels = computed(() => {
if (!results.value) return [];
// Extract all unique channels from the results
const channels = new Set();
const breakdown = results.value.attribution_results.last_click.breakdown;
for (const ch in breakdown) {
channels.add(ch);
}
return Array.from(channels).sort();
});
const formatCurrency = (val) => {
return Math.round(val).toLocaleString();
};
const analyze = async () => {
loading.value = true;
error.value = null;
try {
const res = await fetch('/api/analyze', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ sample_size: sampleSize.value })
});
if (!res.ok) throw new Error(await res.text());
results.value = await res.json();
await nextTick();
renderCharts();
} catch (e) {
console.error(e);
error.value = '分析失败: ' + e.message;
} finally {
loading.value = false;
}
};
const triggerUpload = () => {
fileInput.value.click();
};
const handleFileUpload = async (event) => {
const file = event.target.files[0];
if (!file) return;
loading.value = true;
error.value = null;
const formData = new FormData();
formData.append('file', file);
try {
const res = await fetch('/api/upload', {
method: 'POST',
body: formData
});
if (!res.ok) {
const errText = await res.text();
throw new Error(errText || '上传失败');
}
results.value = await res.json();
await nextTick();
renderCharts();
// Reset input
event.target.value = '';
} catch (e) {
console.error(e);
error.value = '文件处理失败: ' + e.message;
} finally {
loading.value = false;
}
};
const renderCharts = () => {
if (!results.value) return;
// Bar Chart
if (barChart) barChart.dispose();
barChart = echarts.init(document.getElementById('barChart'), 'dark');
const channels = displayedChannels.value;
const models = ['last_click', 'first_click', 'linear', 'time_decay', 'position_based'];
const modelNames = ['Last Click', 'First Click', 'Linear', 'Time Decay', 'Position'];
const series = channels.map(channel => {
return {
name: channel,
type: 'bar',
stack: 'total',
emphasis: { focus: 'series' },
data: models.map(m => Math.round(results.value.attribution_results[m].breakdown[channel] || 0))
};
});
barChart.setOption({
backgroundColor: 'transparent',
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
legend: { data: channels, bottom: 0, textStyle: { color: '#94a3b8' } },
grid: { left: '3%', right: '4%', bottom: '15%', containLabel: true },
xAxis: {
type: 'category',
data: modelNames,
axisLine: { lineStyle: { color: '#475569' } }
},
yAxis: {
type: 'value',
axisLine: { lineStyle: { color: '#475569' } },
splitLine: { lineStyle: { color: '#334155', type: 'dashed' } }
},
series: series
});
// Sankey Chart
if (sankeyChart) sankeyChart.dispose();
sankeyChart = echarts.init(document.getElementById('sankeyChart'), 'dark');
sankeyChart.setOption({
backgroundColor: 'transparent',
tooltip: { trigger: 'item', triggerOn: 'mousemove' },
series: [{
type: 'sankey',
data: results.value.sankey_data.nodes,
links: results.value.sankey_data.links,
emphasis: { focus: 'adjacency' },
lineStyle: { color: 'gradient', curveness: 0.5 },
label: { color: '#e2e8f0' },
layoutIterations: 32 // Improve layout
}]
});
window.addEventListener('resize', () => {
barChart && barChart.resize();
sankeyChart && sankeyChart.resize();
});
};
onMounted(() => {
analyze();
});
return {
sampleSize,
loading,
results,
error,
fileInput,
analyze,
triggerUpload,
handleFileUpload,
displayedChannels,
formatCurrency
};
}
}).mount('#app');
</script>
</body>
</html>