Trae Assistant commited on
Commit
7c534cc
·
1 Parent(s): f49e315

Enhance features, fix UI/UX, add file upload, fix Vue delimiters

Browse files
Files changed (4) hide show
  1. Dockerfile +15 -0
  2. README.md +53 -6
  3. app.py +574 -0
  4. requirements.txt +5 -0
Dockerfile ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY requirements.txt .
6
+ RUN pip install --no-cache-dir -r requirements.txt
7
+
8
+ COPY . .
9
+
10
+ # Create a non-root user for security (Hugging Face Spaces requirement)
11
+ RUN useradd -m -u 1000 user
12
+ USER user
13
+ ENV PATH="/home/user/.local/bin:$PATH"
14
+
15
+ CMD ["python", "app.py"]
README.md CHANGED
@@ -1,10 +1,57 @@
1
  ---
2
- title: Lead Scoring Engine
3
- emoji: 😻
4
- colorFrom: green
5
- colorTo: blue
6
  sdk: docker
7
- pinned: false
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: 销售线索智能评分引擎 (Lead Scoring Engine)
3
+ emoji: 🎯
4
+ colorFrom: indigo
5
+ colorTo: green
6
  sdk: docker
7
+ app_port: 7860
8
+ short_description: B2B销售线索自动化评分与分级工具,提升销售转化率。
9
  ---
10
 
11
+ # 🎯 销售线索智能评分引擎 (Lead Scoring Engine)
12
+
13
+ 这是一个专为 B2B 销售团队设计的智能化线索评分工具。它能够根据预设的**人口统计学属性**(Demographic)和**行为数据**(Behavioral)模型,自动对潜在客户进行打分和分级(A/B/C/D),帮助销售团队优先跟进高价值线索,提升转化效率。
14
+
15
+ ## ✨ 核心功能
16
+
17
+ 1. **自定义评分模型**:
18
+ * 支持配置基于职位、行业、公司规模等属性的规则。
19
+ * 支持配置基于官网访问、白皮书下载、邮件打开等行为的规则。
20
+ 2. **自动化模拟数据**:
21
+ * 内置模拟数据生成器,一键生成逼真的 B2B 潜在客户数据用于测试。
22
+ 3. **实时评分计算**:
23
+ * 根据当前模型配置,实时计算列表中所有线索的得分。
24
+ * 自动划分为 A (High), B (Medium), C (Low), D (Poor) 四个等级。
25
+ 4. **可视化仪表盘**:
26
+ * 使用 ECharts 展示线索质量分布饼图。
27
+ * 清晰的列表展示,包含得分详情 breakdown。
28
+
29
+ ## 🛠️ 技术栈
30
+
31
+ * **Backend**: Python Flask (轻量级 Web 服务)
32
+ * **Frontend**: Vue.js 3 (交互逻辑) + Tailwind CSS (UI 样式)
33
+ * **Visualization**: Apache ECharts (数据可视化)
34
+ * **Data Processing**: Pandas (未来扩展批处理能力)
35
+ * **Demo Data**: Faker (生成逼真测试数据)
36
+
37
+ ## 🚀 快速开始 (Docker)
38
+
39
+ ```bash
40
+ # 构建镜像
41
+ docker build -t lead-scoring-engine .
42
+
43
+ # 运行容器
44
+ docker run -p 7860:7860 lead-scoring-engine
45
+ ```
46
+
47
+ 访问浏览器: `http://localhost:7860`
48
+
49
+ ## 💼 商业应用场景
50
+
51
+ * **SaaS 销售**: 自动筛选注册用户中的高意向客户(如 CEO、下载过白皮书)。
52
+ * **展会线索清洗**: 快速处理收集到的名片数据,根据公司规模和行业打分。
53
+ * **营销自动化 (MA)**: 作为 MA 系统的一个轻量级评分组件。
54
+
55
+ ## 📝 许可证
56
+
57
+ MIT License
app.py ADDED
@@ -0,0 +1,574 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ import random
4
+ import pandas as pd
5
+ from flask import Flask, render_template_string, request, jsonify
6
+ from faker import Faker
7
+ import logging
8
+
9
+ # Configure logging
10
+ logging.basicConfig(level=logging.INFO)
11
+ logger = logging.getLogger(__name__)
12
+
13
+ app = Flask(__name__)
14
+ fake = Faker(['zh_CN'])
15
+
16
+ # Default Scoring Model
17
+ DEFAULT_MODEL = {
18
+ "demographic": [
19
+ {"field": "role", "operator": "contains", "value": "CEO", "score": 20, "desc": "职位包含 CEO"},
20
+ {"field": "role", "operator": "contains", "value": "总监", "score": 15, "desc": "职位包含 总监"},
21
+ {"field": "role", "operator": "contains", "value": "经理", "score": 10, "desc": "职位包含 经理"},
22
+ {"field": "industry", "operator": "equals", "value": "互联网", "score": 10, "desc": "行业为 互联网"},
23
+ {"field": "company_size", "operator": "gt", "value": 100, "score": 15, "desc": "公司规模 > 100人"}
24
+ ],
25
+ "behavioral": [
26
+ {"field": "website_visits", "operator": "gt", "value": 5, "score": 10, "desc": "访问官网 > 5次"},
27
+ {"field": "email_opens", "operator": "gt", "value": 0, "score": 5, "desc": "打开过邮件"},
28
+ {"field": "downloaded_whitepaper", "operator": "equals", "value": True, "score": 20, "desc": "下载过白皮书"},
29
+ {"field": "webinar_attended", "operator": "equals", "value": True, "score": 15, "desc": "参加过研讨会"}
30
+ ]
31
+ }
32
+
33
+ def evaluate_rule(lead, rule):
34
+ field = rule.get('field')
35
+ operator = rule.get('operator')
36
+ target = rule.get('value')
37
+ score = rule.get('score', 0)
38
+
39
+ val = lead.get(field)
40
+
41
+ if val is None:
42
+ return 0
43
+
44
+ matched = False
45
+ try:
46
+ if operator == 'equals':
47
+ # Handle boolean/string comparison carefully
48
+ if isinstance(target, bool):
49
+ matched = bool(val) == target
50
+ elif isinstance(val, str) and isinstance(target, str):
51
+ matched = val.lower() == target.lower()
52
+ else:
53
+ matched = val == target
54
+ elif operator == 'contains':
55
+ matched = str(target).lower() in str(val).lower()
56
+ elif operator == 'gt':
57
+ matched = float(val) > float(target)
58
+ elif operator == 'lt':
59
+ matched = float(val) < float(target)
60
+ elif operator == 'gte':
61
+ matched = float(val) >= float(target)
62
+ elif operator == 'lte':
63
+ matched = float(val) <= float(target)
64
+ except Exception as e:
65
+ logger.warning(f"Error evaluating rule {rule} for value {val}: {e}")
66
+ matched = False
67
+
68
+ return score if matched else 0
69
+
70
+ @app.route('/')
71
+ def index():
72
+ return render_template_string(HTML_TEMPLATE)
73
+
74
+ @app.route('/api/generate-leads', methods=['POST'])
75
+ def generate_leads():
76
+ try:
77
+ count = request.json.get('count', 10)
78
+ leads = []
79
+ industries = ['互联网', '金融', '制造业', '教育', '医疗', '零售']
80
+ roles = ['CEO', 'CTO', '市场总监', '销售经理', '研发工程师', '运营专员', '采购经理']
81
+
82
+ for _ in range(count):
83
+ leads.append({
84
+ "id": fake.uuid4(),
85
+ "name": fake.name(),
86
+ "company": fake.company(),
87
+ "role": random.choice(roles),
88
+ "industry": random.choice(industries),
89
+ "company_size": random.randint(10, 5000),
90
+ "email": fake.email(),
91
+ "website_visits": random.randint(0, 50),
92
+ "email_opens": random.randint(0, 20),
93
+ "downloaded_whitepaper": random.choice([True, False]),
94
+ "webinar_attended": random.choice([True, False]),
95
+ "last_contact_days": random.randint(1, 100)
96
+ })
97
+ return jsonify(leads)
98
+ except Exception as e:
99
+ logger.error(f"Error generating leads: {e}")
100
+ return jsonify({"error": str(e)}), 500
101
+
102
+ @app.route('/api/score', methods=['POST'])
103
+ def score_leads():
104
+ try:
105
+ data = request.json
106
+ leads = data.get('leads', [])
107
+ model = data.get('model', DEFAULT_MODEL)
108
+
109
+ results = []
110
+ for lead in leads:
111
+ total_score = 0
112
+ breakdown = []
113
+
114
+ # Demographic
115
+ for rule in model.get('demographic', []):
116
+ points = evaluate_rule(lead, rule)
117
+ if points > 0:
118
+ total_score += points
119
+ breakdown.append({"desc": rule['desc'], "score": points, "type": "基本属性"})
120
+
121
+ # Behavioral
122
+ for rule in model.get('behavioral', []):
123
+ points = evaluate_rule(lead, rule)
124
+ if points > 0:
125
+ total_score += points
126
+ breakdown.append({"desc": rule['desc'], "score": points, "type": "行为数据"})
127
+
128
+ # Determine Grade
129
+ grade = 'D'
130
+ if total_score >= 80: grade = 'A'
131
+ elif total_score >= 60: grade = 'B'
132
+ elif total_score >= 40: grade = 'C'
133
+
134
+ results.append({
135
+ **lead,
136
+ "score": total_score,
137
+ "grade": grade,
138
+ "breakdown": breakdown
139
+ })
140
+
141
+ # Sort by score desc
142
+ results.sort(key=lambda x: x['score'], reverse=True)
143
+
144
+ return jsonify(results)
145
+ except Exception as e:
146
+ logger.error(f"Error scoring leads: {e}")
147
+ return jsonify({"error": str(e)}), 500
148
+
149
+ @app.route('/api/upload', methods=['POST'])
150
+ def upload_file():
151
+ if 'file' not in request.files:
152
+ return jsonify({"error": "No file part"}), 400
153
+ file = request.files['file']
154
+ if file.filename == '':
155
+ return jsonify({"error": "No selected file"}), 400
156
+
157
+ try:
158
+ if file.filename.endswith('.csv'):
159
+ df = pd.read_csv(file)
160
+ elif file.filename.endswith(('.xls', '.xlsx')):
161
+ df = pd.read_excel(file)
162
+ else:
163
+ return jsonify({"error": "Unsupported file format. Please use CSV or Excel."}), 400
164
+
165
+ # Ensure required columns exist or fill with defaults
166
+ required_fields = ['name', 'company', 'role', 'industry', 'company_size', 'website_visits', 'email_opens']
167
+ for field in required_fields:
168
+ if field not in df.columns:
169
+ if field == 'name': df['name'] = 'Unknown'
170
+ elif field == 'company': df['company'] = 'Unknown'
171
+ else: df[field] = 0 # Default numeric
172
+
173
+ leads = df.to_dict('records')
174
+
175
+ # Add ID if missing
176
+ for lead in leads:
177
+ if 'id' not in lead:
178
+ lead['id'] = fake.uuid4()
179
+ # Normalize boolean fields
180
+ if 'downloaded_whitepaper' in lead:
181
+ lead['downloaded_whitepaper'] = bool(lead['downloaded_whitepaper'])
182
+ else:
183
+ lead['downloaded_whitepaper'] = False
184
+ if 'webinar_attended' in lead:
185
+ lead['webinar_attended'] = bool(lead['webinar_attended'])
186
+ else:
187
+ lead['webinar_attended'] = False
188
+
189
+ return jsonify(leads)
190
+ except Exception as e:
191
+ logger.error(f"Error processing file: {e}")
192
+ return jsonify({"error": f"File processing failed: {str(e)}"}), 500
193
+
194
+ HTML_TEMPLATE = """
195
+ <!DOCTYPE html>
196
+ <html lang="zh-CN">
197
+ <head>
198
+ <meta charset="UTF-8">
199
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
200
+ <title>销售线索智能评分引擎 | Lead Scoring Engine</title>
201
+ <script src="https://cdn.tailwindcss.com"></script>
202
+ <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
203
+ <script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
204
+ <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
205
+ <style>
206
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap');
207
+ body { font-family: 'Inter', sans-serif; background-color: #f3f4f6; }
208
+ .card { background: white; border-radius: 12px; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); }
209
+ .grade-A { color: #16a34a; font-weight: bold; }
210
+ .grade-B { color: #2563eb; font-weight: bold; }
211
+ .grade-C { color: #d97706; font-weight: bold; }
212
+ .grade-D { color: #dc2626; font-weight: bold; }
213
+
214
+ /* Loading Overlay */
215
+ .loading-overlay {
216
+ position: fixed; top: 0; left: 0; width: 100%; height: 100%;
217
+ background: rgba(255, 255, 255, 0.8);
218
+ display: flex; justify-content: center; align-items: center;
219
+ z-index: 9999;
220
+ }
221
+
222
+ /* Toast */
223
+ .toast {
224
+ position: fixed; top: 20px; right: 20px;
225
+ padding: 1rem; border-radius: 8px; color: white;
226
+ z-index: 10000; transition: all 0.3s;
227
+ box-shadow: 0 4px 12px rgba(0,0,0,0.15);
228
+ }
229
+ .toast-error { background-color: #ef4444; }
230
+ .toast-success { background-color: #10b981; }
231
+
232
+ /* Fade transition */
233
+ .fade-enter-active, .fade-leave-active { transition: opacity 0.5s; }
234
+ .fade-enter-from, .fade-leave-to { opacity: 0; }
235
+ </style>
236
+ </head>
237
+ <body>
238
+ <div id="app" class="min-h-screen p-6">
239
+ <!-- Toast Notification -->
240
+ <transition name="fade">
241
+ <div v-if="toast.show" :class="['toast', 'toast-' + toast.type]">
242
+ <i :class="['fas', toast.type === 'success' ? 'fa-check-circle' : 'fa-exclamation-circle', 'mr-2']"></i>
243
+ ${ toast.message }
244
+ </div>
245
+ </transition>
246
+
247
+ <!-- Loading -->
248
+ <div v-if="loading" class="loading-overlay">
249
+ <div class="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
250
+ </div>
251
+
252
+ <!-- Header -->
253
+ <header class="mb-8 flex flex-col md:flex-row justify-between items-center gap-4">
254
+ <div>
255
+ <h1 class="text-3xl font-bold text-gray-800"><i class="fas fa-bullseye text-indigo-600 mr-2"></i>销售线索智能评分引擎</h1>
256
+ <p class="text-gray-500 mt-1">基于多维数据的智能化潜客分级系统</p>
257
+ </div>
258
+ <div class="flex gap-3">
259
+ <input type="file" ref="fileInput" @change="handleFileUpload" style="display:none" accept=".csv,.xlsx,.xls">
260
+ <button @click="triggerUpload" class="bg-white text-gray-700 px-4 py-2 rounded-lg border border-gray-300 hover:bg-gray-50 transition">
261
+ <i class="fas fa-file-upload mr-2"></i>导入数据
262
+ </button>
263
+ <button @click="generateDemoData" class="bg-white text-indigo-600 px-4 py-2 rounded-lg border border-indigo-200 hover:bg-indigo-50 transition">
264
+ <i class="fas fa-random mr-2"></i>生成模拟数据
265
+ </button>
266
+ <button @click="runScoring" class="bg-indigo-600 text-white px-6 py-2 rounded-lg hover:bg-indigo-700 transition shadow-lg">
267
+ <i class="fas fa-play mr-2"></i>执行评分
268
+ </button>
269
+ </div>
270
+ </header>
271
+
272
+ <div class="grid grid-cols-1 lg:grid-cols-12 gap-6">
273
+
274
+ <!-- Sidebar: Scoring Model -->
275
+ <div class="lg:col-span-4 space-y-6">
276
+ <div class="card p-6">
277
+ <div class="flex justify-between items-center mb-4">
278
+ <h2 class="text-xl font-semibold text-gray-800">评分模型配置</h2>
279
+ <span class="text-xs bg-indigo-100 text-indigo-800 px-2 py-1 rounded">当前版本: v1.0</span>
280
+ </div>
281
+
282
+ <!-- Demographic Rules -->
283
+ <div class="mb-6">
284
+ <h3 class="text-sm font-bold text-gray-500 uppercase tracking-wider mb-3">基本属性规则 (Demographic)</h3>
285
+ <div class="space-y-3">
286
+ <div v-for="(rule, index) in model.demographic" :key="'demo-'+index" class="flex items-center justify-between bg-gray-50 p-3 rounded border border-gray-100">
287
+ <div>
288
+ <div class="text-sm font-medium text-gray-700">${ rule.desc }</div>
289
+ <div class="text-xs text-gray-400">${ rule.field } ${ rule.operator } ${ rule.value }</div>
290
+ </div>
291
+ <div class="font-bold text-indigo-600">+${ rule.score }</div>
292
+ </div>
293
+ </div>
294
+ </div>
295
+
296
+ <!-- Behavioral Rules -->
297
+ <div>
298
+ <h3 class="text-sm font-bold text-gray-500 uppercase tracking-wider mb-3">行为数据规则 (Behavioral)</h3>
299
+ <div class="space-y-3">
300
+ <div v-for="(rule, index) in model.behavioral" :key="'beh-'+index" class="flex items-center justify-between bg-gray-50 p-3 rounded border border-gray-100">
301
+ <div>
302
+ <div class="text-sm font-medium text-gray-700">${ rule.desc }</div>
303
+ <div class="text-xs text-gray-400">${ rule.field } ${ rule.operator } ${ rule.value }</div>
304
+ </div>
305
+ <div class="font-bold text-emerald-600">+${ rule.score }</div>
306
+ </div>
307
+ </div>
308
+ </div>
309
+ </div>
310
+
311
+ <!-- Stats Chart -->
312
+ <div class="card p-6 h-80">
313
+ <h3 class="text-lg font-semibold mb-4">线索质量分布</h3>
314
+ <div id="chart-container" class="w-full h-full"></div>
315
+ </div>
316
+ </div>
317
+
318
+ <!-- Main: Lead List -->
319
+ <div class="lg:col-span-8">
320
+ <div class="card p-6">
321
+ <div class="flex justify-between items-center mb-6">
322
+ <h2 class="text-xl font-semibold text-gray-800">线索列表 (${ leads.length })</h2>
323
+ <div class="flex gap-2">
324
+ <div class="flex items-center gap-2 text-sm text-gray-500">
325
+ <span class="w-3 h-3 rounded-full bg-green-500"></span> A级(High)
326
+ <span class="w-3 h-3 rounded-full bg-blue-500"></span> B级(Med)
327
+ <span class="w-3 h-3 rounded-full bg-yellow-500"></span> C级(Low)
328
+ </div>
329
+ </div>
330
+ </div>
331
+
332
+ <div class="overflow-x-auto">
333
+ <table class="w-full text-left border-collapse">
334
+ <thead>
335
+ <tr class="text-gray-400 text-sm border-b border-gray-100">
336
+ <th class="py-3 px-2">姓名/公司</th>
337
+ <th class="py-3 px-2">职位</th>
338
+ <th class="py-3 px-2">行为指标</th>
339
+ <th class="py-3 px-2 text-center">总分</th>
340
+ <th class="py-3 px-2 text-center">等级</th>
341
+ <th class="py-3 px-2">得分详情</th>
342
+ </tr>
343
+ </thead>
344
+ <tbody>
345
+ <tr v-for="lead in leads" :key="lead.id" class="border-b border-gray-50 hover:bg-gray-50 transition">
346
+ <td class="py-4 px-2">
347
+ <div class="font-semibold text-gray-800">${ lead.name }</div>
348
+ <div class="text-xs text-gray-500">${ lead.company } (${ lead.industry })</div>
349
+ </td>
350
+ <td class="py-4 px-2 text-sm text-gray-600">${ lead.role }<br><span class="text-xs text-gray-400">${ lead.company_size }人</span></td>
351
+ <td class="py-4 px-2">
352
+ <div class="flex gap-2 text-xs">
353
+ <span v-if="lead.website_visits > 0" class="px-2 py-1 bg-blue-50 text-blue-600 rounded">访客:${lead.website_visits}</span>
354
+ <span v-if="lead.downloaded_whitepaper" class="px-2 py-1 bg-purple-50 text-purple-600 rounded">白皮书</span>
355
+ </div>
356
+ </td>
357
+ <td class="py-4 px-2 text-center font-bold text-lg text-gray-800">${ lead.score || '-' }</td>
358
+ <td class="py-4 px-2 text-center">
359
+ <span v-if="lead.grade" :class="'px-3 py-1 rounded-full text-sm bg-opacity-10 grade-' + lead.grade"
360
+ :style="{ backgroundColor: getGradeColor(lead.grade) }">
361
+ ${ lead.grade }
362
+ </span>
363
+ <span v-else class="text-gray-300">-</span>
364
+ </td>
365
+ <td class="py-4 px-2">
366
+ <div v-if="lead.breakdown && lead.breakdown.length" class="text-xs space-y-1">
367
+ <div v-for="item in lead.breakdown.slice(0, 2)" class="flex justify-between w-32">
368
+ <span class="text-gray-500 truncate w-24">${ item.desc }</span>
369
+ <span class="text-green-600">+${ item.score }</span>
370
+ </div>
371
+ <div v-if="lead.breakdown.length > 2" class="text-gray-400 italic">+${ lead.breakdown.length - 2 } 更多...</div>
372
+ </div>
373
+ </td>
374
+ </tr>
375
+ </tbody>
376
+ </table>
377
+ </div>
378
+
379
+ <div v-if="leads.length === 0" class="text-center py-12 text-gray-400">
380
+ <i class="fas fa-inbox text-4xl mb-3"></i>
381
+ <p>暂无数据,请点击右上角生成数据或导入文件</p>
382
+ </div>
383
+ </div>
384
+ </div>
385
+ </div>
386
+ </div>
387
+
388
+ <script>
389
+ const { createApp, ref, reactive, onMounted, nextTick } = Vue;
390
+
391
+ createApp({
392
+ setup() {
393
+ const leads = ref([]);
394
+ const loading = ref(false);
395
+ const fileInput = ref(null);
396
+ const toast = reactive({ show: false, message: '', type: 'success' });
397
+
398
+ const model = ref({
399
+ demographic: [
400
+ {field: "role", operator: "contains", value: "CEO", score: 20, desc: "职位包含 CEO"},
401
+ {field: "role", operator: "contains", value: "总监", score: 15, desc: "职位包含 总监"},
402
+ {field: "industry", operator: "equals", value: "互联网", score: 10, desc: "行业为 互联网"},
403
+ {field: "company_size", operator: "gt", value: 100, score: 15, desc: "公司规模 > 100人"}
404
+ ],
405
+ behavioral: [
406
+ {field: "website_visits", operator: "gt", value: 5, score: 10, desc: "访问官网 > 5次"},
407
+ {field: "downloaded_whitepaper", operator: "equals", value: true, score: 20, desc: "下载过白皮书"},
408
+ {field: "webinar_attended", operator: "equals", value: true, score: 15, desc: "参加过研讨会"}
409
+ ]
410
+ });
411
+
412
+ let chartInstance = null;
413
+
414
+ const showToast = (msg, type='success') => {
415
+ toast.message = msg;
416
+ toast.type = type;
417
+ toast.show = true;
418
+ setTimeout(() => toast.show = false, 3000);
419
+ };
420
+
421
+ const getGradeColor = (grade) => {
422
+ const map = { 'A': '#dcfce7', 'B': '#dbeafe', 'C': '#fef3c7', 'D': '#fee2e2' };
423
+ return map[grade] || '#f3f4f6';
424
+ };
425
+
426
+ const generateDemoData = async () => {
427
+ loading.value = true;
428
+ try {
429
+ const res = await fetch('/api/generate-leads', {
430
+ method: 'POST',
431
+ headers: {'Content-Type': 'application/json'},
432
+ body: JSON.stringify({ count: 15 })
433
+ });
434
+ if (!res.ok) throw new Error('Failed to generate data');
435
+ const data = await res.json();
436
+ leads.value = data;
437
+ showToast('模拟数据生成成功');
438
+ // Auto score after generation
439
+ await runScoring();
440
+ } catch (e) {
441
+ console.error(e);
442
+ showToast(e.message, 'error');
443
+ } finally {
444
+ loading.value = false;
445
+ }
446
+ };
447
+
448
+ const runScoring = async () => {
449
+ if (leads.value.length === 0) return;
450
+ loading.value = true;
451
+ try {
452
+ const res = await fetch('/api/score', {
453
+ method: 'POST',
454
+ headers: {'Content-Type': 'application/json'},
455
+ body: JSON.stringify({
456
+ leads: leads.value,
457
+ model: model.value
458
+ })
459
+ });
460
+ if (!res.ok) throw new Error('Scoring failed');
461
+ const data = await res.json();
462
+ leads.value = data;
463
+ updateChart();
464
+ showToast('评分完成');
465
+ } catch (e) {
466
+ console.error(e);
467
+ showToast(e.message, 'error');
468
+ } finally {
469
+ loading.value = false;
470
+ }
471
+ };
472
+
473
+ const triggerUpload = () => {
474
+ if(fileInput.value) fileInput.value.click();
475
+ };
476
+
477
+ const handleFileUpload = async (event) => {
478
+ const file = event.target.files[0];
479
+ if (!file) return;
480
+
481
+ const formData = new FormData();
482
+ formData.append('file', file);
483
+
484
+ loading.value = true;
485
+ try {
486
+ const res = await fetch('/api/upload', {
487
+ method: 'POST',
488
+ body: formData
489
+ });
490
+ if (!res.ok) {
491
+ const err = await res.json();
492
+ throw new Error(err.error || 'Upload failed');
493
+ }
494
+ const data = await res.json();
495
+ leads.value = data;
496
+ showToast(`成功导入 ${data.length} 条线索`);
497
+ // Auto score
498
+ await runScoring();
499
+ } catch (e) {
500
+ console.error(e);
501
+ showToast(e.message, 'error');
502
+ } finally {
503
+ loading.value = false;
504
+ event.target.value = ''; // Reset input
505
+ }
506
+ };
507
+
508
+ const updateChart = () => {
509
+ nextTick(() => {
510
+ if (!chartInstance) {
511
+ const el = document.getElementById('chart-container');
512
+ if (el) chartInstance = echarts.init(el);
513
+ }
514
+
515
+ if (!chartInstance) return;
516
+
517
+ const grades = { 'A': 0, 'B': 0, 'C': 0, 'D': 0 };
518
+ leads.value.forEach(l => {
519
+ if (l.grade) grades[l.grade]++;
520
+ });
521
+
522
+ const option = {
523
+ tooltip: { trigger: 'item' },
524
+ legend: { bottom: '0%' },
525
+ color: ['#16a34a', '#2563eb', '#d97706', '#dc2626'],
526
+ series: [
527
+ {
528
+ name: '线索等级',
529
+ type: 'pie',
530
+ radius: ['40%', '70%'],
531
+ avoidLabelOverlap: false,
532
+ itemStyle: { borderRadius: 10, borderColor: '#fff', borderWidth: 2 },
533
+ label: { show: false, position: 'center' },
534
+ emphasis: { label: { show: true, fontSize: 20, fontWeight: 'bold' } },
535
+ data: [
536
+ { value: grades.A, name: 'A级 (高价值)' },
537
+ { value: grades.B, name: 'B级 (潜力)' },
538
+ { value: grades.C, name: 'C级 (一般)' },
539
+ { value: grades.D, name: 'D级 (低质)' }
540
+ ]
541
+ }
542
+ ]
543
+ };
544
+ chartInstance.setOption(option);
545
+ });
546
+ };
547
+
548
+ onMounted(() => {
549
+ generateDemoData();
550
+ window.addEventListener('resize', () => chartInstance && chartInstance.resize());
551
+ });
552
+
553
+ return {
554
+ leads,
555
+ model,
556
+ loading,
557
+ toast,
558
+ fileInput,
559
+ generateDemoData,
560
+ runScoring,
561
+ getGradeColor,
562
+ triggerUpload,
563
+ handleFileUpload
564
+ };
565
+ },
566
+ delimiters: ['${', '}']
567
+ }).mount('#app');
568
+ </script>
569
+ </body>
570
+ </html>
571
+ """
572
+
573
+ if __name__ == '__main__':
574
+ app.run(host='0.0.0.0', port=7860, debug=True)
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ flask
2
+ pandas
3
+ faker
4
+ gunicorn
5
+ openpyxl