Spaces:
Sleeping
Sleeping
| <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> | |