From 96997c3afc419fa26160b7083a3d50d62c147fe1 Mon Sep 17 00:00:00 2001 From: selfrelease Date: Wed, 15 Jul 2026 12:03:10 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=A2=E6=88=B7=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E6=8C=89=E4=BA=A7=E5=93=81=E7=BA=BF=E5=88=86=E7=BB=84=E5=B1=95?= =?UTF-8?q?=E7=A4=BA=20+=20=E4=BA=A7=E5=93=81=E7=BA=BF=E7=AD=9B=E9=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - server.py: /api/customers 增加 product_id 筛选参数 + 返回 product_categories 字段 - index.html: 客户列表按产品线分组渲染(儿童益生菌/女性减肥益生菌/未关联) + 增加产品线筛选下拉 --- demo/web/server.py | 12 +++++- demo/web/static/index.html | 88 +++++++++++++++++++++++++++++--------- 2 files changed, 78 insertions(+), 22 deletions(-) diff --git a/demo/web/server.py b/demo/web/server.py index 0969b8c..fb360e9 100644 --- a/demo/web/server.py +++ b/demo/web/server.py @@ -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() ] diff --git a/demo/web/static/index.html b/demo/web/static/index.html index 5d8a9fd..7146930 100644 --- a/demo/web/static/index.html +++ b/demo/web/static/index.html @@ -156,15 +156,11 @@ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans- + -
- - - - - -
客户销售行业意向阶段关键需求最后沟通
-
+
@@ -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 => ` - - ${c.customer_name || c.contact_display_name} - ${c.salesperson_name} - ${c.industry || '-'} - ${intentLabels[c.intent_level] || c.intent_level || '-'} - ${c.stage || '-'} - ${(c.key_needs || []).join('、')} - ${c.last_message_at ? new Date(c.last_message_at).toLocaleDateString('zh-CN') : '-'} - - `).join(''); + const container = document.getElementById('customers-grouped'); + const groupNames = Object.keys(groups).sort((a, b) => a === '未关联产品' ? 1 : -1); + + if (groupNames.length === 0) { + container.innerHTML = '
暂无客户数据
'; + return; + } + + container.innerHTML = groupNames.map(gname => { + const customers = groups[gname]; + const headerColor = gname === '未关联产品' ? 'gray' : 'indigo'; + return ` +
+
+ ${gname} + ${customers.length} 位客户 +
+ + + + + + ${customers.map(c => ` + + + + + + + + + + `).join('')} + +
客户销售行业意向阶段关键需求最后沟通
${c.customer_name || c.contact_display_name}${c.salesperson_name}${c.industry || '-'}${intentLabels[c.intent_level] || c.intent_level || '-'}${c.stage || '-'}${(c.key_needs || []).join('、')}${c.last_message_at ? new Date(c.last_message_at).toLocaleDateString('zh-CN') : '-'}
+
+ `; + }).join(''); } // --- 客户详情 ---