Trae Assistant
Enhance features, fix UI/UX, add file upload, fix Vue delimiters
7c534cc
import os
import json
import random
import pandas as pd
from flask import Flask, render_template_string, request, jsonify
from faker import Faker
import logging
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
app = Flask(__name__)
fake = Faker(['zh_CN'])
# Default Scoring Model
DEFAULT_MODEL = {
"demographic": [
{"field": "role", "operator": "contains", "value": "CEO", "score": 20, "desc": "职位包含 CEO"},
{"field": "role", "operator": "contains", "value": "总监", "score": 15, "desc": "职位包含 总监"},
{"field": "role", "operator": "contains", "value": "经理", "score": 10, "desc": "职位包含 经理"},
{"field": "industry", "operator": "equals", "value": "互联网", "score": 10, "desc": "行业为 互联网"},
{"field": "company_size", "operator": "gt", "value": 100, "score": 15, "desc": "公司规模 > 100人"}
],
"behavioral": [
{"field": "website_visits", "operator": "gt", "value": 5, "score": 10, "desc": "访问官网 > 5次"},
{"field": "email_opens", "operator": "gt", "value": 0, "score": 5, "desc": "打开过邮件"},
{"field": "downloaded_whitepaper", "operator": "equals", "value": True, "score": 20, "desc": "下载过白皮书"},
{"field": "webinar_attended", "operator": "equals", "value": True, "score": 15, "desc": "参加过研讨会"}
]
}
def evaluate_rule(lead, rule):
field = rule.get('field')
operator = rule.get('operator')
target = rule.get('value')
score = rule.get('score', 0)
val = lead.get(field)
if val is None:
return 0
matched = False
try:
if operator == 'equals':
# Handle boolean/string comparison carefully
if isinstance(target, bool):
matched = bool(val) == target
elif isinstance(val, str) and isinstance(target, str):
matched = val.lower() == target.lower()
else:
matched = val == target
elif operator == 'contains':
matched = str(target).lower() in str(val).lower()
elif operator == 'gt':
matched = float(val) > float(target)
elif operator == 'lt':
matched = float(val) < float(target)
elif operator == 'gte':
matched = float(val) >= float(target)
elif operator == 'lte':
matched = float(val) <= float(target)
except Exception as e:
logger.warning(f"Error evaluating rule {rule} for value {val}: {e}")
matched = False
return score if matched else 0
@app.route('/')
def index():
return render_template_string(HTML_TEMPLATE)
@app.route('/api/generate-leads', methods=['POST'])
def generate_leads():
try:
count = request.json.get('count', 10)
leads = []
industries = ['互联网', '金融', '制造业', '教育', '医疗', '零售']
roles = ['CEO', 'CTO', '市场总监', '销售经理', '研发工程师', '运营专员', '采购经理']
for _ in range(count):
leads.append({
"id": fake.uuid4(),
"name": fake.name(),
"company": fake.company(),
"role": random.choice(roles),
"industry": random.choice(industries),
"company_size": random.randint(10, 5000),
"email": fake.email(),
"website_visits": random.randint(0, 50),
"email_opens": random.randint(0, 20),
"downloaded_whitepaper": random.choice([True, False]),
"webinar_attended": random.choice([True, False]),
"last_contact_days": random.randint(1, 100)
})
return jsonify(leads)
except Exception as e:
logger.error(f"Error generating leads: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/score', methods=['POST'])
def score_leads():
try:
data = request.json
leads = data.get('leads', [])
model = data.get('model', DEFAULT_MODEL)
results = []
for lead in leads:
total_score = 0
breakdown = []
# Demographic
for rule in model.get('demographic', []):
points = evaluate_rule(lead, rule)
if points > 0:
total_score += points
breakdown.append({"desc": rule['desc'], "score": points, "type": "基本属性"})
# Behavioral
for rule in model.get('behavioral', []):
points = evaluate_rule(lead, rule)
if points > 0:
total_score += points
breakdown.append({"desc": rule['desc'], "score": points, "type": "行为数据"})
# Determine Grade
grade = 'D'
if total_score >= 80: grade = 'A'
elif total_score >= 60: grade = 'B'
elif total_score >= 40: grade = 'C'
results.append({
**lead,
"score": total_score,
"grade": grade,
"breakdown": breakdown
})
# Sort by score desc
results.sort(key=lambda x: x['score'], reverse=True)
return jsonify(results)
except Exception as e:
logger.error(f"Error scoring leads: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/upload', methods=['POST'])
def upload_file():
if 'file' not in request.files:
return jsonify({"error": "No file part"}), 400
file = request.files['file']
if file.filename == '':
return jsonify({"error": "No selected file"}), 400
try:
if file.filename.endswith('.csv'):
df = pd.read_csv(file)
elif file.filename.endswith(('.xls', '.xlsx')):
df = pd.read_excel(file)
else:
return jsonify({"error": "Unsupported file format. Please use CSV or Excel."}), 400
# Ensure required columns exist or fill with defaults
required_fields = ['name', 'company', 'role', 'industry', 'company_size', 'website_visits', 'email_opens']
for field in required_fields:
if field not in df.columns:
if field == 'name': df['name'] = 'Unknown'
elif field == 'company': df['company'] = 'Unknown'
else: df[field] = 0 # Default numeric
leads = df.to_dict('records')
# Add ID if missing
for lead in leads:
if 'id' not in lead:
lead['id'] = fake.uuid4()
# Normalize boolean fields
if 'downloaded_whitepaper' in lead:
lead['downloaded_whitepaper'] = bool(lead['downloaded_whitepaper'])
else:
lead['downloaded_whitepaper'] = False
if 'webinar_attended' in lead:
lead['webinar_attended'] = bool(lead['webinar_attended'])
else:
lead['webinar_attended'] = False
return jsonify(leads)
except Exception as e:
logger.error(f"Error processing file: {e}")
return jsonify({"error": f"File processing failed: {str(e)}"}), 500
HTML_TEMPLATE = """
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>销售线索智能评分引擎 | Lead Scoring 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>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap');
body { font-family: 'Inter', sans-serif; background-color: #f3f4f6; }
.card { background: white; border-radius: 12px; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); }
.grade-A { color: #16a34a; font-weight: bold; }
.grade-B { color: #2563eb; font-weight: bold; }
.grade-C { color: #d97706; font-weight: bold; }
.grade-D { color: #dc2626; font-weight: bold; }
/* Loading Overlay */
.loading-overlay {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(255, 255, 255, 0.8);
display: flex; justify-content: center; align-items: center;
z-index: 9999;
}
/* Toast */
.toast {
position: fixed; top: 20px; right: 20px;
padding: 1rem; border-radius: 8px; color: white;
z-index: 10000; transition: all 0.3s;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
.toast-error { background-color: #ef4444; }
.toast-success { background-color: #10b981; }
/* Fade transition */
.fade-enter-active, .fade-leave-active { transition: opacity 0.5s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style>
</head>
<body>
<div id="app" class="min-h-screen p-6">
<!-- Toast Notification -->
<transition name="fade">
<div v-if="toast.show" :class="['toast', 'toast-' + toast.type]">
<i :class="['fas', toast.type === 'success' ? 'fa-check-circle' : 'fa-exclamation-circle', 'mr-2']"></i>
${ toast.message }
</div>
</transition>
<!-- Loading -->
<div v-if="loading" class="loading-overlay">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
</div>
<!-- Header -->
<header class="mb-8 flex flex-col md:flex-row justify-between items-center gap-4">
<div>
<h1 class="text-3xl font-bold text-gray-800"><i class="fas fa-bullseye text-indigo-600 mr-2"></i>销售线索智能评分引擎</h1>
<p class="text-gray-500 mt-1">基于多维数据的智能化潜客分级系统</p>
</div>
<div class="flex gap-3">
<input type="file" ref="fileInput" @change="handleFileUpload" style="display:none" accept=".csv,.xlsx,.xls">
<button @click="triggerUpload" class="bg-white text-gray-700 px-4 py-2 rounded-lg border border-gray-300 hover:bg-gray-50 transition">
<i class="fas fa-file-upload mr-2"></i>导入数据
</button>
<button @click="generateDemoData" class="bg-white text-indigo-600 px-4 py-2 rounded-lg border border-indigo-200 hover:bg-indigo-50 transition">
<i class="fas fa-random mr-2"></i>生成模拟数据
</button>
<button @click="runScoring" class="bg-indigo-600 text-white px-6 py-2 rounded-lg hover:bg-indigo-700 transition shadow-lg">
<i class="fas fa-play mr-2"></i>执行评分
</button>
</div>
</header>
<div class="grid grid-cols-1 lg:grid-cols-12 gap-6">
<!-- Sidebar: Scoring Model -->
<div class="lg:col-span-4 space-y-6">
<div class="card p-6">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-semibold text-gray-800">评分模型配置</h2>
<span class="text-xs bg-indigo-100 text-indigo-800 px-2 py-1 rounded">当前版本: v1.0</span>
</div>
<!-- Demographic Rules -->
<div class="mb-6">
<h3 class="text-sm font-bold text-gray-500 uppercase tracking-wider mb-3">基本属性规则 (Demographic)</h3>
<div class="space-y-3">
<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">
<div>
<div class="text-sm font-medium text-gray-700">${ rule.desc }</div>
<div class="text-xs text-gray-400">${ rule.field } ${ rule.operator } ${ rule.value }</div>
</div>
<div class="font-bold text-indigo-600">+${ rule.score }</div>
</div>
</div>
</div>
<!-- Behavioral Rules -->
<div>
<h3 class="text-sm font-bold text-gray-500 uppercase tracking-wider mb-3">行为数据规则 (Behavioral)</h3>
<div class="space-y-3">
<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">
<div>
<div class="text-sm font-medium text-gray-700">${ rule.desc }</div>
<div class="text-xs text-gray-400">${ rule.field } ${ rule.operator } ${ rule.value }</div>
</div>
<div class="font-bold text-emerald-600">+${ rule.score }</div>
</div>
</div>
</div>
</div>
<!-- Stats Chart -->
<div class="card p-6 h-80">
<h3 class="text-lg font-semibold mb-4">线索质量分布</h3>
<div id="chart-container" class="w-full h-full"></div>
</div>
</div>
<!-- Main: Lead List -->
<div class="lg:col-span-8">
<div class="card p-6">
<div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-semibold text-gray-800">线索列表 (${ leads.length })</h2>
<div class="flex gap-2">
<div class="flex items-center gap-2 text-sm text-gray-500">
<span class="w-3 h-3 rounded-full bg-green-500"></span> A级(High)
<span class="w-3 h-3 rounded-full bg-blue-500"></span> B级(Med)
<span class="w-3 h-3 rounded-full bg-yellow-500"></span> C级(Low)
</div>
</div>
</div>
<div class="overflow-x-auto">
<table class="w-full text-left border-collapse">
<thead>
<tr class="text-gray-400 text-sm border-b border-gray-100">
<th class="py-3 px-2">姓名/公司</th>
<th class="py-3 px-2">职位</th>
<th class="py-3 px-2">行为指标</th>
<th class="py-3 px-2 text-center">总分</th>
<th class="py-3 px-2 text-center">等级</th>
<th class="py-3 px-2">得分详情</th>
</tr>
</thead>
<tbody>
<tr v-for="lead in leads" :key="lead.id" class="border-b border-gray-50 hover:bg-gray-50 transition">
<td class="py-4 px-2">
<div class="font-semibold text-gray-800">${ lead.name }</div>
<div class="text-xs text-gray-500">${ lead.company } (${ lead.industry })</div>
</td>
<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>
<td class="py-4 px-2">
<div class="flex gap-2 text-xs">
<span v-if="lead.website_visits > 0" class="px-2 py-1 bg-blue-50 text-blue-600 rounded">访客:${lead.website_visits}</span>
<span v-if="lead.downloaded_whitepaper" class="px-2 py-1 bg-purple-50 text-purple-600 rounded">白皮书</span>
</div>
</td>
<td class="py-4 px-2 text-center font-bold text-lg text-gray-800">${ lead.score || '-' }</td>
<td class="py-4 px-2 text-center">
<span v-if="lead.grade" :class="'px-3 py-1 rounded-full text-sm bg-opacity-10 grade-' + lead.grade"
:style="{ backgroundColor: getGradeColor(lead.grade) }">
${ lead.grade }
</span>
<span v-else class="text-gray-300">-</span>
</td>
<td class="py-4 px-2">
<div v-if="lead.breakdown && lead.breakdown.length" class="text-xs space-y-1">
<div v-for="item in lead.breakdown.slice(0, 2)" class="flex justify-between w-32">
<span class="text-gray-500 truncate w-24">${ item.desc }</span>
<span class="text-green-600">+${ item.score }</span>
</div>
<div v-if="lead.breakdown.length > 2" class="text-gray-400 italic">+${ lead.breakdown.length - 2 } 更多...</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="leads.length === 0" class="text-center py-12 text-gray-400">
<i class="fas fa-inbox text-4xl mb-3"></i>
<p>暂无数据,请点击右上角生成数据或导入文件</p>
</div>
</div>
</div>
</div>
</div>
<script>
const { createApp, ref, reactive, onMounted, nextTick } = Vue;
createApp({
setup() {
const leads = ref([]);
const loading = ref(false);
const fileInput = ref(null);
const toast = reactive({ show: false, message: '', type: 'success' });
const model = ref({
demographic: [
{field: "role", operator: "contains", value: "CEO", score: 20, desc: "职位包含 CEO"},
{field: "role", operator: "contains", value: "总监", score: 15, desc: "职位包含 总监"},
{field: "industry", operator: "equals", value: "互联网", score: 10, desc: "行业为 互联网"},
{field: "company_size", operator: "gt", value: 100, score: 15, desc: "公司规模 > 100人"}
],
behavioral: [
{field: "website_visits", operator: "gt", value: 5, score: 10, desc: "访问官网 > 5次"},
{field: "downloaded_whitepaper", operator: "equals", value: true, score: 20, desc: "下载过白皮书"},
{field: "webinar_attended", operator: "equals", value: true, score: 15, desc: "参加过研讨会"}
]
});
let chartInstance = null;
const showToast = (msg, type='success') => {
toast.message = msg;
toast.type = type;
toast.show = true;
setTimeout(() => toast.show = false, 3000);
};
const getGradeColor = (grade) => {
const map = { 'A': '#dcfce7', 'B': '#dbeafe', 'C': '#fef3c7', 'D': '#fee2e2' };
return map[grade] || '#f3f4f6';
};
const generateDemoData = async () => {
loading.value = true;
try {
const res = await fetch('/api/generate-leads', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ count: 15 })
});
if (!res.ok) throw new Error('Failed to generate data');
const data = await res.json();
leads.value = data;
showToast('模拟数据生成成功');
// Auto score after generation
await runScoring();
} catch (e) {
console.error(e);
showToast(e.message, 'error');
} finally {
loading.value = false;
}
};
const runScoring = async () => {
if (leads.value.length === 0) return;
loading.value = true;
try {
const res = await fetch('/api/score', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
leads: leads.value,
model: model.value
})
});
if (!res.ok) throw new Error('Scoring failed');
const data = await res.json();
leads.value = data;
updateChart();
showToast('评分完成');
} catch (e) {
console.error(e);
showToast(e.message, 'error');
} finally {
loading.value = false;
}
};
const triggerUpload = () => {
if(fileInput.value) fileInput.value.click();
};
const handleFileUpload = async (event) => {
const file = event.target.files[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
loading.value = true;
try {
const res = await fetch('/api/upload', {
method: 'POST',
body: formData
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || 'Upload failed');
}
const data = await res.json();
leads.value = data;
showToast(`成功导入 ${data.length} 条线索`);
// Auto score
await runScoring();
} catch (e) {
console.error(e);
showToast(e.message, 'error');
} finally {
loading.value = false;
event.target.value = ''; // Reset input
}
};
const updateChart = () => {
nextTick(() => {
if (!chartInstance) {
const el = document.getElementById('chart-container');
if (el) chartInstance = echarts.init(el);
}
if (!chartInstance) return;
const grades = { 'A': 0, 'B': 0, 'C': 0, 'D': 0 };
leads.value.forEach(l => {
if (l.grade) grades[l.grade]++;
});
const option = {
tooltip: { trigger: 'item' },
legend: { bottom: '0%' },
color: ['#16a34a', '#2563eb', '#d97706', '#dc2626'],
series: [
{
name: '线索等级',
type: 'pie',
radius: ['40%', '70%'],
avoidLabelOverlap: false,
itemStyle: { borderRadius: 10, borderColor: '#fff', borderWidth: 2 },
label: { show: false, position: 'center' },
emphasis: { label: { show: true, fontSize: 20, fontWeight: 'bold' } },
data: [
{ value: grades.A, name: 'A级 (高价值)' },
{ value: grades.B, name: 'B级 (潜力)' },
{ value: grades.C, name: 'C级 (一般)' },
{ value: grades.D, name: 'D级 (低质)' }
]
}
]
};
chartInstance.setOption(option);
});
};
onMounted(() => {
generateDemoData();
window.addEventListener('resize', () => chartInstance && chartInstance.resize());
});
return {
leads,
model,
loading,
toast,
fileInput,
generateDemoData,
runScoring,
getGradeColor,
triggerUpload,
handleFileUpload
};
},
delimiters: ['${', '}']
}).mount('#app');
</script>
</body>
</html>
"""
if __name__ == '__main__':
app.run(host='0.0.0.0', port=7860, debug=True)