Trae Assistant commited on
Commit
bb3c41b
·
0 Parent(s):

feat: upgrade UI, fix delimiters, add file upload, localization

Browse files
Files changed (6) hide show
  1. .gitignore +5 -0
  2. Dockerfile +23 -0
  3. README.md +70 -0
  4. app.py +274 -0
  5. requirements.txt +2 -0
  6. templates/index.html +353 -0
.gitignore ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ .DS_Store
4
+ .env
5
+ venv/
Dockerfile ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ # Install system dependencies
6
+ RUN apt-get update && apt-get install -y \
7
+ gcc \
8
+ && rm -rf /var/lib/apt/lists/*
9
+
10
+ # Install Python dependencies
11
+ COPY requirements.txt .
12
+ RUN pip install --no-cache-dir -r requirements.txt
13
+
14
+ COPY . .
15
+
16
+ # Create a non-root user
17
+ RUN useradd -m -u 1000 user
18
+ USER user
19
+ ENV PATH="/home/user/.local/bin:$PATH"
20
+
21
+ EXPOSE 7860
22
+
23
+ CMD ["python", "app.py"]
README.md ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Attribution Logic Engine
3
+ emoji: 📊
4
+ colorFrom: blue
5
+ colorTo: purple
6
+ sdk: docker
7
+ pinned: false
8
+ short_description: 商业级多渠道营销归因分析与模型对比系统
9
+ ---
10
+
11
+ # 归因逻辑引擎 (Attribution Logic Engine)
12
+
13
+ ## 项目简介
14
+ 这是一个商业级的多渠道营销归因分析系统,旨在帮助市场营销人员理解不同营销渠道(如搜索广告、社交媒体、邮件等)对最终转化的贡献价值。
15
+
16
+ 传统的“最后点击” (Last Click) 归因模型往往掩盖了用户决策路径中早期接触点的重要性。本系统通过模拟真实用户路径,并应用多种归因算法(线性、时间衰减、位置优先等),直观展示不同模型下的 ROI 差异。
17
+
18
+ ## 核心功能
19
+ 1. **多模型对比 (Model Comparison)**:
20
+ - **Last Click**: 100% 归因于最后一次交互。
21
+ - **First Click**: 100% 归因于第一次交互。
22
+ - **Linear**: 所有交互点平分功劳。
23
+ - **Time Decay**: 距离转化越近的交互点权重越高(指数衰减)。
24
+ - **Position Based (U-Shaped)**: 首尾各 40%,中间平分 20%。
25
+
26
+ 2. **用户路径可视化 (Journey Visualization)**:
27
+ - 使用 Sankey 图(桑基图)展示用户从首次接触到最终转化(或流失)的常见流动路径。
28
+
29
+ 3. **数据导入与模拟**:
30
+ - **模拟引擎**: 内置蒙特卡洛模拟器,可生成数千条复杂的用户行为路径。
31
+ - **自定义数据上传**: 支持上传 `.csv` 或 `.json` 格式的归因数据进行分析。
32
+
33
+ ## 数据上传格式说明
34
+
35
+ ### CSV 格式
36
+ 需要包含 `path` (或 `touchpoints`), `converted`, `value` 字段。
37
+ - `path`: 渠道路径,可用 `>` 或 `,` 分隔。例如 `Email > Social > Direct`。
38
+ - `converted`: 是否转化 (1/0, true/false)。
39
+ - `value`: 转化价值 (数字)。
40
+
41
+ ### JSON 格式
42
+ 一个包含对象列表的文件:
43
+ ```json
44
+ [
45
+ {
46
+ "path": ["Email", "Social Ads", "Direct"],
47
+ "converted": true,
48
+ "value": 150.0
49
+ },
50
+ ...
51
+ ]
52
+ ```
53
+
54
+ ## 技术栈
55
+ - **Backend**: Python 3.11, Flask, Pandas
56
+ - **Frontend**: Vue 3, Tailwind CSS, ECharts 5
57
+ - **Deployment**: Docker (Hugging Face Spaces Compatible)
58
+
59
+ ## 商业价值
60
+ 对于广告投放预算超过 $10k/月的企业,错误的归因模型可能导致 20-30% 的预算浪费。本工具帮助识别那些“助攻”型渠道(如社交媒体种草),避免因 ROI 计算错误而过早关闭有效渠道。
61
+
62
+ ## 运行方式
63
+ ```bash
64
+ # 构建镜像
65
+ docker build -t attribution-engine .
66
+
67
+ # 运行容器
68
+ docker run -p 7860:7860 attribution-engine
69
+ ```
70
+ 访问 http://localhost:7860 即可使用。
app.py ADDED
@@ -0,0 +1,274 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import random
3
+ import json
4
+ import csv
5
+ import io
6
+ from flask import Flask, render_template, jsonify, request
7
+ from collections import defaultdict
8
+
9
+ app = Flask(__name__)
10
+ app.secret_key = os.urandom(24)
11
+ app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB max upload
12
+
13
+ # Configuration
14
+ CHANNELS = ['Paid Search', 'Social Ads', 'Email', 'Direct', 'Referral', 'Display']
15
+ MAX_JOURNEY_LENGTH = 5
16
+
17
+ def generate_mock_data(count=1000):
18
+ """Generate synthetic user journeys."""
19
+ journeys = []
20
+ for _ in range(count):
21
+ # Random journey length 1-5
22
+ length = random.randint(1, MAX_JOURNEY_LENGTH)
23
+ # Random path
24
+ path = [random.choice(CHANNELS) for _ in range(length)]
25
+ # Random conversion (20% chance)
26
+ converted = random.random() < 0.2
27
+ value = 100 if converted else 0
28
+
29
+ journeys.append({
30
+ 'path': path,
31
+ 'converted': converted,
32
+ 'value': value
33
+ })
34
+ return journeys
35
+
36
+ def calculate_attribution(journeys, model):
37
+ """
38
+ Calculate attribution value for each channel based on the selected model.
39
+ Models: 'last_click', 'first_click', 'linear', 'time_decay', 'position_based'
40
+ """
41
+ channel_values = defaultdict(float)
42
+ total_conversions = 0
43
+ total_revenue = 0
44
+
45
+ for journey in journeys:
46
+ # Ensure robust data types
47
+ converted = bool(journey.get('converted', False))
48
+ if not converted:
49
+ continue
50
+
51
+ path = journey.get('path', [])
52
+ if not path:
53
+ continue
54
+
55
+ value = float(journey.get('value', 0))
56
+
57
+ total_conversions += 1
58
+ total_revenue += value
59
+
60
+ if model == 'last_click':
61
+ if path:
62
+ channel_values[path[-1]] += value
63
+
64
+ elif model == 'first_click':
65
+ if path:
66
+ channel_values[path[0]] += value
67
+
68
+ elif model == 'linear':
69
+ weight = value / len(path)
70
+ for touch in path:
71
+ channel_values[touch] += weight
72
+
73
+ elif model == 'time_decay':
74
+ # Exponential decay: 2^(-x) where x is distance from conversion
75
+ weights = [2 ** -(len(path) - 1 - i) for i in range(len(path))]
76
+ total_weight = sum(weights)
77
+ if total_weight > 0:
78
+ normalized_weights = [w / total_weight * value for w in weights]
79
+ for i, touch in enumerate(path):
80
+ channel_values[touch] += normalized_weights[i]
81
+
82
+ elif model == 'position_based':
83
+ # 40% first, 40% last, 20% middle distributed
84
+ if len(path) == 1:
85
+ channel_values[path[0]] += value
86
+ elif len(path) == 2:
87
+ channel_values[path[0]] += value * 0.5
88
+ channel_values[path[1]] += value * 0.5
89
+ else:
90
+ channel_values[path[0]] += value * 0.4
91
+ channel_values[path[-1]] += value * 0.4
92
+ middle_weight = (value * 0.2) / (len(path) - 2)
93
+ for touch in path[1:-1]:
94
+ channel_values[touch] += middle_weight
95
+
96
+ return {
97
+ 'breakdown': dict(channel_values),
98
+ 'total_conversions': total_conversions,
99
+ 'total_revenue': total_revenue
100
+ }
101
+
102
+ def get_top_paths(journeys, limit=10):
103
+ """Aggregate common paths for Sankey diagram."""
104
+ path_counts = defaultdict(int)
105
+ for journey in journeys:
106
+ path = journey.get('path', [])
107
+ converted = journey.get('converted', False)
108
+ if not path:
109
+ continue
110
+
111
+ # Convert list to tuple for hashing
112
+ path_tuple = tuple(path + ['Conversion' if converted else 'Dropoff'])
113
+ path_counts[path_tuple] += 1
114
+
115
+ sorted_paths = sorted(path_counts.items(), key=lambda x: x[1], reverse=True)[:limit]
116
+
117
+ # Format for ECharts Sankey
118
+ nodes = set()
119
+ links = []
120
+
121
+ for path, count in sorted_paths:
122
+ for i in range(len(path) - 1):
123
+ src_node = f"{path[i]} (Step {i+1})"
124
+ tgt_node = f"{path[i+1]} (Step {i+2})"
125
+
126
+ if path[i+1] in ['Conversion', 'Dropoff']:
127
+ tgt_node = path[i+1]
128
+
129
+ nodes.add(src_node)
130
+ nodes.add(tgt_node)
131
+
132
+ # Check if link exists
133
+ found = False
134
+ for link in links:
135
+ if link['source'] == src_node and link['target'] == tgt_node:
136
+ link['value'] += count
137
+ found = True
138
+ break
139
+ if not found:
140
+ links.append({'source': src_node, 'target': tgt_node, 'value': count})
141
+
142
+ return {
143
+ 'nodes': [{'name': n} for n in list(nodes)],
144
+ 'links': links
145
+ }
146
+
147
+ def parse_uploaded_file(file):
148
+ """Parse CSV or JSON file into standard journey format."""
149
+ filename = file.filename.lower()
150
+ journeys = []
151
+
152
+ try:
153
+ if filename.endswith('.json'):
154
+ content = json.load(file)
155
+ # Expect list of dicts
156
+ if isinstance(content, list):
157
+ journeys = content
158
+ else:
159
+ raise ValueError("JSON must be a list of journey objects")
160
+
161
+ elif filename.endswith('.csv'):
162
+ # Read CSV
163
+ stream = io.StringIO(file.stream.read().decode("UTF8"), newline=None)
164
+ reader = csv.DictReader(stream)
165
+
166
+ for row in reader:
167
+ # Heuristic to find path column
168
+ path_str = row.get('path') or row.get('touchpoints') or row.get('channels')
169
+ if not path_str:
170
+ continue
171
+
172
+ # Try to parse path string (e.g. "A > B > C" or "A,B,C")
173
+ if '>' in path_str:
174
+ path = [p.strip() for p in path_str.split('>')]
175
+ else:
176
+ path = [p.strip() for p in path_str.split(',')]
177
+
178
+ # Conversion
179
+ conv_str = str(row.get('converted', '0')).lower()
180
+ converted = conv_str in ['true', '1', 'yes', 'on']
181
+
182
+ # Value
183
+ try:
184
+ value = float(row.get('value', 0))
185
+ except:
186
+ value = 0
187
+
188
+ journeys.append({
189
+ 'path': path,
190
+ 'converted': converted,
191
+ 'value': value
192
+ })
193
+ else:
194
+ raise ValueError("Unsupported file type. Please upload .csv or .json")
195
+
196
+ except Exception as e:
197
+ raise ValueError(f"Error parsing file: {str(e)}")
198
+
199
+ if not journeys:
200
+ raise ValueError("No valid journey data found in file")
201
+
202
+ return journeys
203
+
204
+ @app.route('/')
205
+ def index():
206
+ return render_template('index.html')
207
+
208
+ @app.route('/api/analyze', methods=['POST'])
209
+ def analyze():
210
+ try:
211
+ data = request.json
212
+ sample_size = int(data.get('sample_size', 1000))
213
+
214
+ # Generate data
215
+ journeys = generate_mock_data(sample_size)
216
+
217
+ # Calculate for all models
218
+ results = {}
219
+ models = ['last_click', 'first_click', 'linear', 'time_decay', 'position_based']
220
+
221
+ for m in models:
222
+ results[m] = calculate_attribution(journeys, m)
223
+
224
+ # Get Sankey data
225
+ sankey_data = get_top_paths(journeys, limit=20)
226
+
227
+ return jsonify({
228
+ 'attribution_results': results,
229
+ 'sankey_data': sankey_data,
230
+ 'journey_count': len(journeys)
231
+ })
232
+
233
+ except Exception as e:
234
+ return jsonify({'error': str(e)}), 500
235
+
236
+ @app.route('/api/upload', methods=['POST'])
237
+ def upload_file():
238
+ try:
239
+ if 'file' not in request.files:
240
+ return jsonify({'error': 'No file part'}), 400
241
+
242
+ file = request.files['file']
243
+ if file.filename == '':
244
+ return jsonify({'error': 'No selected file'}), 400
245
+
246
+ journeys = parse_uploaded_file(file)
247
+
248
+ # Limit processing for performance if too large
249
+ if len(journeys) > 50000:
250
+ journeys = journeys[:50000]
251
+
252
+ # Calculate for all models
253
+ results = {}
254
+ models = ['last_click', 'first_click', 'linear', 'time_decay', 'position_based']
255
+
256
+ for m in models:
257
+ results[m] = calculate_attribution(journeys, m)
258
+
259
+ # Get Sankey data
260
+ sankey_data = get_top_paths(journeys, limit=30)
261
+
262
+ return jsonify({
263
+ 'attribution_results': results,
264
+ 'sankey_data': sankey_data,
265
+ 'journey_count': len(journeys)
266
+ })
267
+
268
+ except ValueError as e:
269
+ return jsonify({'error': str(e)}), 400
270
+ except Exception as e:
271
+ return jsonify({'error': f"Internal error: {str(e)}"}), 500
272
+
273
+ if __name__ == '__main__':
274
+ app.run(host='0.0.0.0', port=7860)
requirements.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ flask
2
+ pandas
templates/index.html ADDED
@@ -0,0 +1,353 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN" class="dark">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>归因逻辑引擎 | Attribution Logic Engine</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
9
+ <script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
10
+ <script>
11
+ tailwind.config = {
12
+ darkMode: 'class',
13
+ theme: {
14
+ extend: {
15
+ colors: {
16
+ gray: {
17
+ 800: '#1f2937',
18
+ 900: '#111827',
19
+ }
20
+ }
21
+ }
22
+ }
23
+ }
24
+ </script>
25
+ <style>
26
+ body { background-color: #0f172a; color: #e2e8f0; }
27
+ .glass-panel {
28
+ background: rgba(30, 41, 59, 0.7);
29
+ backdrop-filter: blur(10px);
30
+ border: 1px solid rgba(255, 255, 255, 0.1);
31
+ }
32
+ .chart-container {
33
+ height: 400px;
34
+ width: 100%;
35
+ }
36
+ /* Custom scrollbar */
37
+ ::-webkit-scrollbar {
38
+ width: 8px;
39
+ height: 8px;
40
+ }
41
+ ::-webkit-scrollbar-track {
42
+ background: #1e293b;
43
+ }
44
+ ::-webkit-scrollbar-thumb {
45
+ background: #475569;
46
+ border-radius: 4px;
47
+ }
48
+ ::-webkit-scrollbar-thumb:hover {
49
+ background: #64748b;
50
+ }
51
+ </style>
52
+ </head>
53
+ <body class="min-h-screen p-6 font-sans">
54
+ <div id="app" class="max-w-7xl mx-auto space-y-6">
55
+ <!-- Header -->
56
+ <header class="flex justify-between items-center mb-8">
57
+ <div>
58
+ <h1 class="text-3xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-blue-400 to-purple-500">
59
+ 归因逻辑引擎
60
+ </h1>
61
+ <p class="text-slate-400 mt-2">商业级多渠道营销归因分析与模型对比系统</p>
62
+ </div>
63
+ <div class="flex items-center space-x-4">
64
+ <span class="px-3 py-1 rounded-full bg-blue-500/20 text-blue-300 text-sm border border-blue-500/30">
65
+ v1.1.0
66
+ </span>
67
+ </div>
68
+ </header>
69
+
70
+ <!-- Controls -->
71
+ <div class="glass-panel p-6 rounded-xl grid grid-cols-1 md:grid-cols-3 gap-6 items-end">
72
+ <div>
73
+ <label class="block text-sm font-medium text-slate-300 mb-2">
74
+ 模拟样本量 (Sample Size)
75
+ </label>
76
+ <input type="range" v-model.number="sampleSize" min="100" max="5000" step="100"
77
+ class="w-full h-2 bg-slate-700 rounded-lg appearance-none cursor-pointer">
78
+ <div class="text-right text-xs text-slate-400 mt-1">${ sampleSize } 条数据</div>
79
+ </div>
80
+
81
+ <div class="flex justify-end md:col-span-2 space-x-4">
82
+ <!-- File Upload -->
83
+ <input type="file" ref="fileInput" @change="handleFileUpload" class="hidden" accept=".csv,.json">
84
+ <button @click="triggerUpload" :disabled="loading"
85
+ 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">
86
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
87
+ <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" />
88
+ </svg>
89
+ <span>上传数据</span>
90
+ </button>
91
+
92
+ <button @click="analyze" :disabled="loading"
93
+ 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">
94
+ <span v-if="loading" class="animate-spin">⟳</span>
95
+ <span>${ loading ? '计算中...' : '生成模拟分析' }</span>
96
+ </button>
97
+ </div>
98
+ </div>
99
+
100
+ <!-- Error Message -->
101
+ <div v-if="error" class="p-4 rounded-lg bg-red-500/20 border border-red-500/50 text-red-200">
102
+ <strong>错误:</strong> ${ error }
103
+ </div>
104
+
105
+ <!-- Metrics Cards -->
106
+ <div class="grid grid-cols-1 md:grid-cols-3 gap-6" v-if="results">
107
+ <div class="glass-panel p-6 rounded-xl border-l-4 border-emerald-500">
108
+ <h3 class="text-slate-400 text-sm uppercase">总转化数 (Conversions)</h3>
109
+ <p class="text-3xl font-bold text-emerald-400 mt-2">${ results.attribution_results.last_click.total_conversions }</p>
110
+ </div>
111
+ <div class="glass-panel p-6 rounded-xl border-l-4 border-indigo-500">
112
+ <h3 class="text-slate-400 text-sm uppercase">总营收 (Revenue)</h3>
113
+ <p class="text-3xl font-bold text-indigo-400 mt-2">
114
+ ¥${ formatCurrency(results.attribution_results.last_click.total_revenue) }
115
+ </p>
116
+ </div>
117
+ <div class="glass-panel p-6 rounded-xl border-l-4 border-purple-500">
118
+ <h3 class="text-slate-400 text-sm uppercase">平均转化率 (CVR)</h3>
119
+ <p class="text-3xl font-bold text-purple-400 mt-2">
120
+ ${ ((results.attribution_results.last_click.total_conversions / results.journey_count) * 100).toFixed(1) }%
121
+ </p>
122
+ </div>
123
+ </div>
124
+
125
+ <!-- Charts Grid -->
126
+ <div class="grid grid-cols-1 lg:grid-cols-2 gap-6" v-show="results">
127
+ <!-- Model Comparison -->
128
+ <div class="glass-panel p-6 rounded-xl">
129
+ <h3 class="text-lg font-semibold text-white mb-4 flex items-center">
130
+ <span class="w-2 h-6 bg-blue-500 rounded mr-3"></span>
131
+ 归因模型对比 (Model Comparison)
132
+ </h3>
133
+ <div id="barChart" class="chart-container"></div>
134
+ </div>
135
+
136
+ <!-- Sankey Flow -->
137
+ <div class="glass-panel p-6 rounded-xl">
138
+ <h3 class="text-lg font-semibold text-white mb-4 flex items-center">
139
+ <span class="w-2 h-6 bg-pink-500 rounded mr-3"></span>
140
+ 用户路径流向 (Journey Flow)
141
+ </h3>
142
+ <div id="sankeyChart" class="chart-container"></div>
143
+ </div>
144
+ </div>
145
+
146
+ <!-- Insights Table -->
147
+ <div class="glass-panel p-6 rounded-xl" v-if="results">
148
+ <h3 class="text-lg font-semibold text-white mb-4">渠道价值详情 (Channel Breakdown)</h3>
149
+ <div class="overflow-x-auto">
150
+ <table class="w-full text-left border-collapse">
151
+ <thead>
152
+ <tr class="text-slate-400 border-b border-slate-700">
153
+ <th class="p-3">渠道 (Channel)</th>
154
+ <th class="p-3">Last Click</th>
155
+ <th class="p-3">First Click</th>
156
+ <th class="p-3">Linear</th>
157
+ <th class="p-3">Time Decay</th>
158
+ <th class="p-3">Position Based</th>
159
+ </tr>
160
+ </thead>
161
+ <tbody class="text-slate-300">
162
+ <tr v-for="channel in displayedChannels" :key="channel" class="border-b border-slate-700/50 hover:bg-slate-800/50">
163
+ <td class="p-3 font-medium text-white">${ channel }</td>
164
+ <td class="p-3">¥${ formatCurrency(results.attribution_results.last_click.breakdown[channel] || 0) }</td>
165
+ <td class="p-3">¥${ formatCurrency(results.attribution_results.first_click.breakdown[channel] || 0) }</td>
166
+ <td class="p-3">¥${ formatCurrency(results.attribution_results.linear.breakdown[channel] || 0) }</td>
167
+ <td class="p-3">¥${ formatCurrency(results.attribution_results.time_decay.breakdown[channel] || 0) }</td>
168
+ <td class="p-3 text-yellow-400 font-bold">¥${ formatCurrency(results.attribution_results.position_based.breakdown[channel] || 0) }</td>
169
+ </tr>
170
+ </tbody>
171
+ </table>
172
+ </div>
173
+ </div>
174
+
175
+ </div>
176
+
177
+ <script>
178
+ const { createApp, ref, onMounted, nextTick, computed } = Vue;
179
+
180
+ createApp({
181
+ delimiters: ['${', '}'],
182
+ setup() {
183
+ const sampleSize = ref(1000);
184
+ const loading = ref(false);
185
+ const results = ref(null);
186
+ const error = ref(null);
187
+ const fileInput = ref(null);
188
+
189
+ let barChart = null;
190
+ let sankeyChart = null;
191
+
192
+ const displayedChannels = computed(() => {
193
+ if (!results.value) return [];
194
+ // Extract all unique channels from the results
195
+ const channels = new Set();
196
+ const breakdown = results.value.attribution_results.last_click.breakdown;
197
+ for (const ch in breakdown) {
198
+ channels.add(ch);
199
+ }
200
+ return Array.from(channels).sort();
201
+ });
202
+
203
+ const formatCurrency = (val) => {
204
+ return Math.round(val).toLocaleString();
205
+ };
206
+
207
+ const analyze = async () => {
208
+ loading.value = true;
209
+ error.value = null;
210
+ try {
211
+ const res = await fetch('/api/analyze', {
212
+ method: 'POST',
213
+ headers: {'Content-Type': 'application/json'},
214
+ body: JSON.stringify({ sample_size: sampleSize.value })
215
+ });
216
+ if (!res.ok) throw new Error(await res.text());
217
+
218
+ results.value = await res.json();
219
+
220
+ await nextTick();
221
+ renderCharts();
222
+ } catch (e) {
223
+ console.error(e);
224
+ error.value = '分析失败: ' + e.message;
225
+ } finally {
226
+ loading.value = false;
227
+ }
228
+ };
229
+
230
+ const triggerUpload = () => {
231
+ fileInput.value.click();
232
+ };
233
+
234
+ const handleFileUpload = async (event) => {
235
+ const file = event.target.files[0];
236
+ if (!file) return;
237
+
238
+ loading.value = true;
239
+ error.value = null;
240
+
241
+ const formData = new FormData();
242
+ formData.append('file', file);
243
+
244
+ try {
245
+ const res = await fetch('/api/upload', {
246
+ method: 'POST',
247
+ body: formData
248
+ });
249
+
250
+ if (!res.ok) {
251
+ const errText = await res.text();
252
+ throw new Error(errText || '上传失败');
253
+ }
254
+
255
+ results.value = await res.json();
256
+ await nextTick();
257
+ renderCharts();
258
+
259
+ // Reset input
260
+ event.target.value = '';
261
+
262
+ } catch (e) {
263
+ console.error(e);
264
+ error.value = '文件处理失败: ' + e.message;
265
+ } finally {
266
+ loading.value = false;
267
+ }
268
+ };
269
+
270
+ const renderCharts = () => {
271
+ if (!results.value) return;
272
+
273
+ // Bar Chart
274
+ if (barChart) barChart.dispose();
275
+ barChart = echarts.init(document.getElementById('barChart'), 'dark');
276
+
277
+ const channels = displayedChannels.value;
278
+ const models = ['last_click', 'first_click', 'linear', 'time_decay', 'position_based'];
279
+ const modelNames = ['Last Click', 'First Click', 'Linear', 'Time Decay', 'Position'];
280
+
281
+ const series = channels.map(channel => {
282
+ return {
283
+ name: channel,
284
+ type: 'bar',
285
+ stack: 'total',
286
+ emphasis: { focus: 'series' },
287
+ data: models.map(m => Math.round(results.value.attribution_results[m].breakdown[channel] || 0))
288
+ };
289
+ });
290
+
291
+ barChart.setOption({
292
+ backgroundColor: 'transparent',
293
+ tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
294
+ legend: { data: channels, bottom: 0, textStyle: { color: '#94a3b8' } },
295
+ grid: { left: '3%', right: '4%', bottom: '15%', containLabel: true },
296
+ xAxis: {
297
+ type: 'category',
298
+ data: modelNames,
299
+ axisLine: { lineStyle: { color: '#475569' } }
300
+ },
301
+ yAxis: {
302
+ type: 'value',
303
+ axisLine: { lineStyle: { color: '#475569' } },
304
+ splitLine: { lineStyle: { color: '#334155', type: 'dashed' } }
305
+ },
306
+ series: series
307
+ });
308
+
309
+ // Sankey Chart
310
+ if (sankeyChart) sankeyChart.dispose();
311
+ sankeyChart = echarts.init(document.getElementById('sankeyChart'), 'dark');
312
+
313
+ sankeyChart.setOption({
314
+ backgroundColor: 'transparent',
315
+ tooltip: { trigger: 'item', triggerOn: 'mousemove' },
316
+ series: [{
317
+ type: 'sankey',
318
+ data: results.value.sankey_data.nodes,
319
+ links: results.value.sankey_data.links,
320
+ emphasis: { focus: 'adjacency' },
321
+ lineStyle: { color: 'gradient', curveness: 0.5 },
322
+ label: { color: '#e2e8f0' },
323
+ layoutIterations: 32 // Improve layout
324
+ }]
325
+ });
326
+
327
+ window.addEventListener('resize', () => {
328
+ barChart && barChart.resize();
329
+ sankeyChart && sankeyChart.resize();
330
+ });
331
+ };
332
+
333
+ onMounted(() => {
334
+ analyze();
335
+ });
336
+
337
+ return {
338
+ sampleSize,
339
+ loading,
340
+ results,
341
+ error,
342
+ fileInput,
343
+ analyze,
344
+ triggerUpload,
345
+ handleFileUpload,
346
+ displayedChannels,
347
+ formatCurrency
348
+ };
349
+ }
350
+ }).mount('#app');
351
+ </script>
352
+ </body>
353
+ </html>