feat: 客户列表按产品线分组展示 + 产品线筛选
- server.py: /api/customers 增加 product_id 筛选参数 + 返回 product_categories 字段 - index.html: 客户列表按产品线分组渲染(儿童益生菌/女性减肥益生菌/未关联) + 增加产品线筛选下拉
This commit is contained in:
+10
-2
@@ -243,21 +243,25 @@ def list_salespersons():
|
||||
def list_customers(
|
||||
salesperson_id: int | None = Query(None),
|
||||
intent_level: str | None = Query(None),
|
||||
product_id: int | None = Query(None),
|
||||
):
|
||||
conn = get_conn()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
sql = """
|
||||
SELECT cu.id, cu.salesperson_id, s.name AS salesperson_name,
|
||||
SELECT DISTINCT cu.id, cu.salesperson_id, s.name AS salesperson_name,
|
||||
cu.customer_name, cu.industry, cu.intent_level,
|
||||
cu.key_needs, cu.stage, cu.last_analysis,
|
||||
c.display_name AS contact_display_name,
|
||||
max(m.created_at) AS last_message_at
|
||||
max(m.created_at) AS last_message_at,
|
||||
string_agg(DISTINCT p.category, ', ') AS product_categories
|
||||
FROM customer cu
|
||||
JOIN salesperson s ON s.id = cu.salesperson_id
|
||||
JOIN contact c ON c.id = cu.contact_id
|
||||
LEFT JOIN conversation conv ON conv.contact_id = c.id
|
||||
LEFT JOIN message m ON m.conversation_id = conv.id
|
||||
LEFT JOIN deal d ON d.customer_id = cu.id
|
||||
LEFT JOIN product p ON p.id = d.product_id
|
||||
"""
|
||||
conditions = []
|
||||
params = []
|
||||
@@ -267,6 +271,9 @@ def list_customers(
|
||||
if intent_level is not None:
|
||||
conditions.append("cu.intent_level = %s")
|
||||
params.append(intent_level)
|
||||
if product_id is not None:
|
||||
conditions.append("d.product_id = %s")
|
||||
params.append(product_id)
|
||||
if conditions:
|
||||
sql += " WHERE " + " AND ".join(conditions)
|
||||
sql += """
|
||||
@@ -286,6 +293,7 @@ def list_customers(
|
||||
"last_analysis": r[8].isoformat() if r[8] else None,
|
||||
"contact_display_name": r[9],
|
||||
"last_message_at": r[10].isoformat() if r[10] else None,
|
||||
"product_categories": r[11],
|
||||
}
|
||||
for r in cur.fetchall()
|
||||
]
|
||||
|
||||
+68
-20
@@ -156,15 +156,11 @@ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-
|
||||
<option value="medium">中意向</option>
|
||||
<option value="low">低意向</option>
|
||||
</select>
|
||||
<select id="filter-product-category" onchange="loadCustomers()" class="border rounded px-3 py-1.5 text-sm">
|
||||
<option value="">全部产品线</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow p-4 overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead><tr class="border-b text-left text-gray-500">
|
||||
<th class="py-2">客户</th><th>销售</th><th>行业</th><th>意向</th><th>阶段</th><th>关键需求</th><th>最后沟通</th>
|
||||
</tr></thead>
|
||||
<tbody id="customers-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="customers-grouped" class="space-y-6"></div>
|
||||
</div>
|
||||
|
||||
<!-- 客户详情 -->
|
||||
@@ -539,6 +535,7 @@ async function loadSalespersons() {
|
||||
async function loadCustomers() {
|
||||
const sp = document.getElementById('filter-salesperson').value;
|
||||
const intent = document.getElementById('filter-intent').value;
|
||||
const cat = document.getElementById('filter-product-category').value;
|
||||
let path = '/customers?';
|
||||
if (sp) path += `salesperson_id=${sp}&`;
|
||||
if (intent) path += `intent_level=${intent}&`;
|
||||
@@ -556,19 +553,70 @@ async function loadCustomers() {
|
||||
});
|
||||
}
|
||||
|
||||
// 填充产品线筛选
|
||||
const catFilter = document.getElementById('filter-product-category');
|
||||
if (catFilter.options.length <= 1) {
|
||||
const prodData = await api('/products/categories');
|
||||
prodData.forEach(c => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = c; opt.textContent = c;
|
||||
catFilter.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
// 按产品线筛选
|
||||
const filtered = cat ? data.filter(c => (c.product_categories || '').includes(cat)) : data;
|
||||
|
||||
// 按产品线分组:有 product_categories 的按类别分,没有的归入"未关联产品"
|
||||
const groups = {};
|
||||
filtered.forEach(c => {
|
||||
const cats = (c.product_categories || '').split(', ').filter(x => x);
|
||||
if (cats.length === 0) cats.push('未关联产品');
|
||||
cats.forEach(g => {
|
||||
if (!groups[g]) groups[g] = [];
|
||||
if (!groups[g].find(x => x.id === c.id)) groups[g].push(c);
|
||||
});
|
||||
});
|
||||
|
||||
const intentColors = {high: 'red', medium: 'yellow', low: 'gray'};
|
||||
const tbody = document.getElementById('customers-tbody');
|
||||
tbody.innerHTML = data.map(c => `
|
||||
<tr class="border-b hover:bg-gray-50 cursor-pointer" onclick="showCustomerDetail(${c.id})">
|
||||
<td class="py-2 font-medium text-green-700">${c.customer_name || c.contact_display_name}</td>
|
||||
<td>${c.salesperson_name}</td>
|
||||
<td>${c.industry || '-'}</td>
|
||||
<td><span class="px-2 py-0.5 rounded text-xs bg-${intentColors[c.intent_level] || 'gray'}-100 text-${intentColors[c.intent_level] || 'gray'}-700">${intentLabels[c.intent_level] || c.intent_level || '-'}</span></td>
|
||||
<td>${c.stage || '-'}</td>
|
||||
<td class="text-xs text-gray-500">${(c.key_needs || []).join('、')}</td>
|
||||
<td class="text-gray-400 text-xs">${c.last_message_at ? new Date(c.last_message_at).toLocaleDateString('zh-CN') : '-'}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
const container = document.getElementById('customers-grouped');
|
||||
const groupNames = Object.keys(groups).sort((a, b) => a === '未关联产品' ? 1 : -1);
|
||||
|
||||
if (groupNames.length === 0) {
|
||||
container.innerHTML = '<div class="text-gray-400 text-center py-8">暂无客户数据</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = groupNames.map(gname => {
|
||||
const customers = groups[gname];
|
||||
const headerColor = gname === '未关联产品' ? 'gray' : 'indigo';
|
||||
return `
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div class="bg-${headerColor}-50 px-4 py-2 border-b flex items-center justify-between">
|
||||
<span class="font-semibold text-${headerColor}-700">${gname}</span>
|
||||
<span class="text-xs text-${headerColor}-500">${customers.length} 位客户</span>
|
||||
</div>
|
||||
<table class="w-full text-sm">
|
||||
<thead><tr class="border-b text-left text-gray-400">
|
||||
<th class="py-2 px-4">客户</th><th>销售</th><th>行业</th><th>意向</th><th>阶段</th><th>关键需求</th><th>最后沟通</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
${customers.map(c => `
|
||||
<tr class="border-b hover:bg-gray-50 cursor-pointer" onclick="showCustomerDetail(${c.id})">
|
||||
<td class="py-2 px-4 font-medium text-green-700">${c.customer_name || c.contact_display_name}</td>
|
||||
<td>${c.salesperson_name}</td>
|
||||
<td>${c.industry || '-'}</td>
|
||||
<td><span class="px-2 py-0.5 rounded text-xs bg-${intentColors[c.intent_level] || 'gray'}-100 text-${intentColors[c.intent_level] || 'gray'}-700">${intentLabels[c.intent_level] || c.intent_level || '-'}</span></td>
|
||||
<td>${c.stage || '-'}</td>
|
||||
<td class="text-xs text-gray-500">${(c.key_needs || []).join('、')}</td>
|
||||
<td class="text-gray-400 text-xs">${c.last_message_at ? new Date(c.last_message_at).toLocaleDateString('zh-CN') : '-'}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// --- 客户详情 ---
|
||||
|
||||
Reference in New Issue
Block a user