1323 lines
56 KiB
HTML
1323 lines
56 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>微信营销管理系统</title>
|
||
<link rel="stylesheet" href="/tailwind.min.css">
|
||
<style>
|
||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
|
||
.chat-bubble { max-width: 70%; padding: 8px 14px; border-radius: 12px; margin: 4px 0; }
|
||
.chat-sales { background: #95ec69; align-self: flex-start; }
|
||
.chat-customer { background: #fff; border: 1px solid #e5e7eb; align-self: flex-end; }
|
||
.chat-other { background: #f3f4f6; align-self: flex-end; }
|
||
</style>
|
||
</head>
|
||
<body class="bg-gray-50 text-gray-900">
|
||
|
||
<nav class="bg-white border-b shadow-sm sticky top-0 z-50">
|
||
<div class="max-w-7xl mx-auto px-4 flex items-center h-14 gap-6">
|
||
<span class="font-bold text-lg text-green-600">微信销售管理系统</span>
|
||
<button onclick="showPanel('dashboard')" class="nav-btn text-sm hover:text-green-600">仪表盘</button>
|
||
<button onclick="showPanel('products')" class="nav-btn text-sm hover:text-green-600">产品管理</button>
|
||
<button onclick="showPanel('salespersons')" class="nav-btn text-sm hover:text-green-600">销售列表</button>
|
||
<button onclick="showPanel('customers')" class="nav-btn text-sm hover:text-green-600">客户列表</button>
|
||
<button onclick="showPanel('deals')" class="nav-btn text-sm hover:text-green-600">成交记录</button>
|
||
<button onclick="showPanel('analysis')" class="nav-btn text-sm hover:text-green-600">交流分析</button>
|
||
<button onclick="showPanel('deal-form')" class="nav-btn text-sm hover:text-green-600">录入成交</button>
|
||
</div>
|
||
</nav>
|
||
|
||
<main class="max-w-7xl mx-auto px-4 py-6">
|
||
|
||
<!-- 仪表盘 -->
|
||
<div id="dashboard-panel" class="panel">
|
||
<h2 class="text-xl font-bold mb-4">仪表盘</h2>
|
||
<div id="dashboard-cards" class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6"></div>
|
||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-6">
|
||
<div>
|
||
<h3 class="text-lg font-semibold mb-3">各销售数据对比</h3>
|
||
<div id="dashboard-sales" 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>
|
||
</tr></thead>
|
||
<tbody id="dashboard-sales-tbody"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<h3 class="text-lg font-semibold mb-3">本月产品线销售统计</h3>
|
||
<div id="dashboard-product-stats" class="bg-white rounded-lg shadow p-4">
|
||
<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>
|
||
</tr></thead>
|
||
<tbody id="dashboard-product-tbody"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 产品管理 -->
|
||
<div id="products-panel" class="panel hidden">
|
||
<div class="flex items-center justify-between mb-4">
|
||
<h2 class="text-xl font-bold">产品管理</h2>
|
||
<button onclick="showProductForm()" class="bg-green-600 text-white px-4 py-2 rounded text-sm hover:bg-green-700">+ 新增产品</button>
|
||
</div>
|
||
<div class="flex gap-4 mb-4">
|
||
<select id="product-filter-category" onchange="loadProducts()" class="border rounded px-3 py-1.5 text-sm">
|
||
<option value="">全部产品线</option>
|
||
</select>
|
||
</div>
|
||
<div id="products-list" class="space-y-3"></div>
|
||
</div>
|
||
|
||
<!-- 产品新增/编辑表单 -->
|
||
<div id="product-form-panel" class="panel hidden">
|
||
<button onclick="showPanel('products')" class="text-sm text-green-600 mb-3">← 返回产品管理</button>
|
||
<h2 id="product-form-title" class="text-xl font-bold mb-4">新增产品</h2>
|
||
<div class="bg-white rounded-lg shadow p-6 max-w-2xl">
|
||
<form onsubmit="submitProduct(event)" class="space-y-4">
|
||
<input type="hidden" id="product-form-id">
|
||
<div class="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label class="block text-sm font-medium mb-1">产品名称 *</label>
|
||
<input id="product-name" type="text" required placeholder="如:益童宝益生菌" class="w-full border rounded px-3 py-2 text-sm">
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium mb-1">产品线 *</label>
|
||
<input id="product-category" type="text" required placeholder="如:儿童益生菌" class="w-full border rounded px-3 py-2 text-sm">
|
||
</div>
|
||
</div>
|
||
<div class="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label class="block text-sm font-medium mb-1">规格</label>
|
||
<input id="product-spec" type="text" placeholder="如:3盒套餐" class="w-full border rounded px-3 py-2 text-sm">
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium mb-1">价格</label>
|
||
<input id="product-price" type="number" step="0.01" placeholder="798" class="w-full border rounded px-3 py-2 text-sm">
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium mb-1">产品定位</label>
|
||
<input id="product-positioning" type="text" placeholder="如:进口菌株儿童抗过敏专家" class="w-full border rounded px-3 py-2 text-sm">
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium mb-1">目标人群</label>
|
||
<input id="product-target-audience" type="text" placeholder="如:3-12岁儿童" class="w-full border rounded px-3 py-2 text-sm">
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium mb-1">产品详情描述</label>
|
||
<textarea id="product-description" rows="4" placeholder="详细描述产品功效、成分、用法等,AI 将以此为基础生成销售话术" class="w-full border rounded px-3 py-2 text-sm"></textarea>
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium mb-1">核心卖点(每行一条)</label>
|
||
<textarea id="product-selling-points" rows="5" placeholder="每行一条卖点,如:\n丹麦进口菌株,品质有保障\n临床验证改善率超85%" class="w-full border rounded px-3 py-2 text-sm"></textarea>
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium mb-1">状态</label>
|
||
<select id="product-status" class="w-full border rounded px-3 py-2 text-sm">
|
||
<option value="active">上架</option>
|
||
<option value="inactive">下架</option>
|
||
</select>
|
||
</div>
|
||
<button type="submit" class="bg-green-600 text-white px-6 py-2 rounded text-sm hover:bg-green-700">保存</button>
|
||
<span id="product-result" class="ml-4 text-sm text-green-600"></span>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 销售列表 -->
|
||
<div id="salespersons-panel" class="panel hidden">
|
||
<h2 class="text-xl font-bold mb-4">销售列表</h2>
|
||
<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><th>最后同步</th>
|
||
</tr></thead>
|
||
<tbody id="salespersons-tbody"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 客户列表 -->
|
||
<div id="customers-panel" class="panel hidden">
|
||
<h2 class="text-xl font-bold mb-4">客户列表</h2>
|
||
<div class="flex gap-4 mb-4">
|
||
<select id="filter-salesperson" onchange="loadCustomers()" class="border rounded px-3 py-1.5 text-sm">
|
||
<option value="">全部销售</option>
|
||
</select>
|
||
<select id="filter-intent" onchange="loadCustomers()" class="border rounded px-3 py-1.5 text-sm">
|
||
<option value="">全部意向</option>
|
||
<option value="high">高意向</option>
|
||
<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 id="customers-grouped" class="space-y-6"></div>
|
||
</div>
|
||
|
||
<!-- 客户详情 -->
|
||
<div id="customer-detail-panel" class="panel hidden">
|
||
<button onclick="showPanel('customers')" class="text-sm text-green-600 mb-3">← 返回客户列表</button>
|
||
<div id="detail-info" class="bg-white rounded-lg shadow p-6 mb-4"></div>
|
||
<div id="detail-summary" class="bg-white rounded-lg shadow p-6 mb-4"></div>
|
||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||
<div class="bg-white rounded-lg shadow p-6">
|
||
<h3 class="font-semibold mb-3">聊天记录</h3>
|
||
<div id="detail-messages" class="flex flex-col gap-1 max-h-96 overflow-y-auto"></div>
|
||
<div id="detail-pagination" class="mt-3 flex items-center gap-3"></div>
|
||
</div>
|
||
<div class="bg-white rounded-lg shadow p-6">
|
||
<h3 class="font-semibold mb-3">成交记录</h3>
|
||
<div id="detail-deals"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 成交记录列表 -->
|
||
<div id="deals-panel" class="panel hidden">
|
||
<h2 class="text-xl font-bold mb-4">成交记录</h2>
|
||
<div class="flex gap-4 mb-4">
|
||
<select id="deals-filter-product" onchange="loadDeals()" 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>
|
||
</tr></thead>
|
||
<tbody id="deals-tbody"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 交流分析 -->
|
||
<div id="analysis-panel" class="panel hidden">
|
||
<h2 class="text-xl font-bold mb-4">交流分析</h2>
|
||
<div class="flex gap-1 mb-4">
|
||
<button id="analysis-tab-list" onclick="analysisView='list';renderAnalysisView()" class="px-4 py-1.5 rounded text-sm bg-green-600 text-white">对话列表</button>
|
||
<button id="analysis-tab-paradigm" onclick="analysisView='paradigm';renderAnalysisView()" class="px-4 py-1.5 rounded text-sm bg-white border hover:bg-gray-50">标准范式</button>
|
||
</div>
|
||
|
||
<!-- 对话列表视图 -->
|
||
<div id="analysis-list-view">
|
||
<div class="flex gap-3 mb-4">
|
||
<select id="analysis-filter-sp" onchange="loadConversations()" class="border rounded px-3 py-1.5 text-sm">
|
||
<option value="">全部销售</option>
|
||
</select>
|
||
<select id="analysis-filter-stage" onchange="loadConversations()" class="border rounded px-3 py-1.5 text-sm">
|
||
<option value="">全部阶段</option>
|
||
<option value="建立联系">建立联系</option>
|
||
<option value="需求发现">需求发现</option>
|
||
<option value="报价">报价</option>
|
||
<option value="异议处理">异议处理</option>
|
||
<option value="成交">成交</option>
|
||
</select>
|
||
</div>
|
||
<div id="analysis-list" class="space-y-3"></div>
|
||
</div>
|
||
|
||
<!-- 标准范式视图 -->
|
||
<div id="analysis-paradigm-view" class="hidden">
|
||
<div class="bg-white rounded-lg shadow p-4 mb-4">
|
||
<p class="text-sm text-gray-500 mb-2">选择客户和产品,基于产品详情和真实对话生成标准范式对话模板</p>
|
||
<div class="flex gap-3 items-center">
|
||
<select id="paradigm-customer-select" onchange="onParadigmCustomerChange()" class="border rounded px-3 py-2 text-sm flex-1">
|
||
<option value="">请选择客户</option>
|
||
</select>
|
||
<select id="paradigm-product-select" onchange="onParadigmProductChange()" class="border rounded px-3 py-2 text-sm flex-1">
|
||
<option value="">自动推断产品</option>
|
||
</select>
|
||
<button onclick="generateParadigm()" id="paradigm-btn" class="bg-green-600 text-white px-6 py-2 rounded text-sm hover:bg-green-700">生成范式对话</button>
|
||
</div>
|
||
<p id="paradigm-match-hint" class="text-xs text-gray-400 mt-2"></p>
|
||
</div>
|
||
<div id="paradigm-result"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 录入成交 -->
|
||
<div id="deal-form-panel" class="panel hidden">
|
||
<h2 class="text-xl font-bold mb-4">录入成交</h2>
|
||
<div class="bg-white rounded-lg shadow p-6 max-w-lg">
|
||
<form onsubmit="submitDeal(event)" class="space-y-4">
|
||
<div>
|
||
<label class="block text-sm font-medium mb-1">销售 *</label>
|
||
<select id="deal-salesperson" required class="w-full border rounded px-3 py-2 text-sm" onchange="loadDealCustomers()">
|
||
<option value="">请选择</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium mb-1">客户 *</label>
|
||
<select id="deal-customer" required class="w-full border rounded px-3 py-2 text-sm">
|
||
<option value="">请选择</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium mb-1">产品 *</label>
|
||
<select id="deal-product" required class="w-full border rounded px-3 py-2 text-sm" onchange="onProductSelected()">
|
||
<option value="">请选择</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium mb-1">金额 *</label>
|
||
<input id="deal-amount" type="number" step="0.01" required placeholder="798" class="w-full border rounded px-3 py-2 text-sm">
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium mb-1">成交日期 *</label>
|
||
<input id="deal-date" type="date" required class="w-full border rounded px-3 py-2 text-sm">
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium mb-1">备注</label>
|
||
<textarea id="deal-notes" rows="2" class="w-full border rounded px-3 py-2 text-sm"></textarea>
|
||
</div>
|
||
<button type="submit" class="bg-green-600 text-white px-6 py-2 rounded text-sm hover:bg-green-700">提交</button>
|
||
<span id="deal-result" class="ml-4 text-sm text-green-600"></span>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
</main>
|
||
|
||
<script>
|
||
const API = '/api';
|
||
let currentCustomerId = null;
|
||
let currentMsgPage = 1;
|
||
|
||
function formatYuan(val) {
|
||
return '¥' + (val || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||
}
|
||
|
||
const intentLabels = {high: '高意向', medium: '中意向', low: '低意向'};
|
||
const dealStatusLabels = {closed: '已成交', pending: '待确认', cancelled: '已取消'};
|
||
|
||
// --- 路由 ---
|
||
const ROUTES = {
|
||
'dashboard': () => loadDashboard(),
|
||
'products': () => loadProducts(),
|
||
'product-form': () => {},
|
||
'salespersons': () => loadSalespersons(),
|
||
'customers': () => loadCustomers(),
|
||
'customer-detail': () => {},
|
||
'deals': () => loadDeals(),
|
||
'analysis': () => loadAnalysis(),
|
||
'deal-form': () => loadDealForm(),
|
||
};
|
||
|
||
/** 导航到指定页面,更新 URL hash */
|
||
function showPanel(name) {
|
||
location.hash = name;
|
||
}
|
||
|
||
/** 根据 hash 路由渲染对应面板 */
|
||
function handleRoute() {
|
||
const hash = location.hash.slice(1) || 'dashboard';
|
||
const parts = hash.split('/');
|
||
const name = parts[0];
|
||
document.querySelectorAll('.panel').forEach(p => p.classList.add('hidden'));
|
||
const panel = document.getElementById(name + '-panel');
|
||
if (panel) panel.classList.remove('hidden');
|
||
// 执行路由对应的加载函数
|
||
if (ROUTES[name]) ROUTES[name]();
|
||
// 客户详情带 ID:#customer-detail/15
|
||
if (name === 'customer-detail' && parts[1]) {
|
||
showCustomerDetail(parseInt(parts[1]));
|
||
}
|
||
}
|
||
|
||
window.addEventListener('hashchange', handleRoute);
|
||
|
||
// --- API 封装 ---
|
||
async function api(path, options) {
|
||
const res = await fetch(API + path, options);
|
||
if (!res.ok) throw new Error(`API ${path}: ${res.status}`);
|
||
return res.json();
|
||
}
|
||
|
||
// --- 仪表盘 ---
|
||
async function loadDashboard() {
|
||
const data = await api('/dashboard');
|
||
const cards = document.getElementById('dashboard-cards');
|
||
cards.innerHTML = [
|
||
{label: '总消息数', value: data.total_messages, color: 'blue'},
|
||
{label: '活跃客户', value: data.active_customers, color: 'green'},
|
||
{label: '本月成交', value: formatYuan(data.monthly_deal_amount), color: 'orange'},
|
||
{label: '销售人数', value: data.salespersons.length, color: 'purple'},
|
||
].map(c => `
|
||
<div class="bg-white rounded-lg shadow p-4">
|
||
<div class="text-2xl font-bold text-${c.color}-600">${c.value}</div>
|
||
<div class="text-sm text-gray-500 mt-1">${c.label}</div>
|
||
</div>
|
||
`).join('');
|
||
|
||
const tbody = document.getElementById('dashboard-sales-tbody');
|
||
tbody.innerHTML = data.salespersons.map(s => `
|
||
<tr class="border-b hover:bg-gray-50">
|
||
<td class="py-2 font-medium">${s.name}</td>
|
||
<td>${s.team}</td>
|
||
<td>${s.message_count}</td>
|
||
<td>${s.customer_count}</td>
|
||
<td>${formatYuan(s.deal_amount)}</td>
|
||
<td class="text-gray-400 text-xs">${s.last_synced_at ? new Date(s.last_synced_at).toLocaleString('zh-CN') : '-'}</td>
|
||
</tr>
|
||
`).join('');
|
||
|
||
const productTbody = document.getElementById('dashboard-product-tbody');
|
||
const productStats = data.product_stats || [];
|
||
if (productStats.length === 0) {
|
||
productTbody.innerHTML = '<tr><td colspan="3" class="py-3 text-center text-gray-400 text-sm">暂无数据</td></tr>';
|
||
} else {
|
||
productTbody.innerHTML = productStats.map(p => `
|
||
<tr class="border-b hover:bg-gray-50">
|
||
<td class="py-2 font-medium">${p.category}</td>
|
||
<td>${p.deal_count}</td>
|
||
<td class="font-bold text-green-600">${formatYuan(p.amount)}</td>
|
||
</tr>
|
||
`).join('');
|
||
}
|
||
}
|
||
|
||
// --- 产品管理 ---
|
||
let productsData = [];
|
||
|
||
async function loadProducts() {
|
||
const category = document.getElementById('product-filter-category').value;
|
||
let path = '/products?';
|
||
if (category) path += `category=${encodeURIComponent(category)}&`;
|
||
try {
|
||
productsData = await api(path);
|
||
renderProducts();
|
||
populateProductFilterCategory();
|
||
} catch (e) {
|
||
document.getElementById('products-list').innerHTML = `<p class="text-sm text-red-500">加载失败: ${e.message}</p>`;
|
||
}
|
||
}
|
||
|
||
function populateProductFilterCategory() {
|
||
const sel = document.getElementById('product-filter-category');
|
||
if (sel.options.length > 1) return;
|
||
const categories = [...new Set(productsData.map(p => p.category))];
|
||
categories.forEach(c => {
|
||
const opt = document.createElement('option');
|
||
opt.value = c; opt.textContent = c;
|
||
sel.appendChild(opt);
|
||
});
|
||
}
|
||
|
||
function renderProducts() {
|
||
const container = document.getElementById('products-list');
|
||
if (productsData.length === 0) {
|
||
container.innerHTML = '<p class="text-sm text-gray-400">暂无产品,点击右上角新增</p>';
|
||
return;
|
||
}
|
||
const grouped = {};
|
||
productsData.forEach(p => {
|
||
if (!grouped[p.category]) grouped[p.category] = [];
|
||
grouped[p.category].push(p);
|
||
});
|
||
container.innerHTML = Object.entries(grouped).map(([category, items]) => `
|
||
<div class="bg-white rounded-lg shadow p-4">
|
||
<h3 class="font-semibold text-sm mb-3 text-green-700">${category}</h3>
|
||
<div class="space-y-3">
|
||
${items.map(p => `
|
||
<div class="border rounded-lg p-4 ${p.status === 'inactive' ? 'opacity-50' : ''}">
|
||
<div class="flex items-start justify-between mb-2">
|
||
<div>
|
||
<span class="font-medium">${p.name}</span>
|
||
${p.spec ? `<span class="text-gray-400 text-sm ml-2">${p.spec}</span>` : ''}
|
||
${p.price ? `<span class="text-green-600 font-bold text-sm ml-2">¥${p.price.toFixed(2)}</span>` : ''}
|
||
${p.status === 'inactive' ? '<span class="ml-2 px-2 py-0.5 rounded text-xs bg-gray-200 text-gray-600">已下架</span>' : ''}
|
||
</div>
|
||
<div class="flex gap-2">
|
||
<button onclick="editProduct(${p.id})" class="text-xs text-blue-600 hover:underline">编辑</button>
|
||
<button onclick="deleteProduct(${p.id}, '${p.name}')" class="text-xs text-red-500 hover:underline">${p.status === 'inactive' ? '删除' : '下架'}</button>
|
||
</div>
|
||
</div>
|
||
${p.positioning ? `<p class="text-xs text-gray-500 mb-1">定位:${p.positioning}</p>` : ''}
|
||
${p.target_audience ? `<p class="text-xs text-gray-500 mb-1">目标人群:${p.target_audience}</p>` : ''}
|
||
${p.description ? `<p class="text-sm text-gray-600 mb-2">${p.description}</p>` : ''}
|
||
${(p.selling_points || []).length > 0 ? `
|
||
<div class="mb-2">
|
||
<span class="text-xs font-medium text-green-700">核心卖点:</span>
|
||
<ul class="mt-1 space-y-0.5">
|
||
${(p.selling_points || []).map(sp => `<li class="text-xs text-gray-600 flex gap-1"><span class="text-green-500">▸</span><span>${escapeHtml(sp)}</span></li>`).join('')}
|
||
</ul>
|
||
</div>
|
||
` : ''}
|
||
<div class="flex gap-4 text-xs text-gray-400 border-t pt-2 mt-2">
|
||
<span>成交笔数:${p.deal_count}</span>
|
||
<span>成交总额:${formatYuan(p.total_amount)}</span>
|
||
</div>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
function showProductForm() {
|
||
document.getElementById('product-form-title').textContent = '新增产品';
|
||
document.getElementById('product-form-id').value = '';
|
||
document.getElementById('product-name').value = '';
|
||
document.getElementById('product-category').value = '';
|
||
document.getElementById('product-spec').value = '';
|
||
document.getElementById('product-price').value = '';
|
||
document.getElementById('product-positioning').value = '';
|
||
document.getElementById('product-target-audience').value = '';
|
||
document.getElementById('product-description').value = '';
|
||
document.getElementById('product-selling-points').value = '';
|
||
document.getElementById('product-status').value = 'active';
|
||
document.getElementById('product-result').textContent = '';
|
||
showPanel('product-form');
|
||
}
|
||
|
||
function editProduct(id) {
|
||
const p = productsData.find(x => x.id === id);
|
||
if (!p) return;
|
||
document.getElementById('product-form-title').textContent = '编辑产品';
|
||
document.getElementById('product-form-id').value = p.id;
|
||
document.getElementById('product-name').value = p.name || '';
|
||
document.getElementById('product-category').value = p.category || '';
|
||
document.getElementById('product-spec').value = p.spec || '';
|
||
document.getElementById('product-price').value = p.price || '';
|
||
document.getElementById('product-positioning').value = p.positioning || '';
|
||
document.getElementById('product-target-audience').value = p.target_audience || '';
|
||
document.getElementById('product-description').value = p.description || '';
|
||
document.getElementById('product-selling-points').value = (p.selling_points || []).join('\n');
|
||
document.getElementById('product-status').value = p.status || 'active';
|
||
document.getElementById('product-result').textContent = '';
|
||
showPanel('product-form');
|
||
}
|
||
|
||
async function submitProduct(event) {
|
||
event.preventDefault();
|
||
const id = document.getElementById('product-form-id').value;
|
||
const sellingPointsText = document.getElementById('product-selling-points').value.trim();
|
||
const sellingPoints = sellingPointsText ? sellingPointsText.split('\n').map(s => s.trim()).filter(s => s) : null;
|
||
const body = {
|
||
name: document.getElementById('product-name').value,
|
||
category: document.getElementById('product-category').value,
|
||
spec: document.getElementById('product-spec').value || null,
|
||
price: parseFloat(document.getElementById('product-price').value) || null,
|
||
positioning: document.getElementById('product-positioning').value || null,
|
||
target_audience: document.getElementById('product-target-audience').value || null,
|
||
description: document.getElementById('product-description').value || null,
|
||
selling_points: sellingPoints,
|
||
status: document.getElementById('product-status').value,
|
||
};
|
||
try {
|
||
const result = await api(`/products${id ? '/' + id : ''}`, {
|
||
method: id ? 'PUT' : 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify(body),
|
||
});
|
||
document.getElementById('product-result').textContent = '✓ ' + result.message;
|
||
setTimeout(() => showPanel('products'), 800);
|
||
} catch (e) {
|
||
document.getElementById('product-result').textContent = '✗ 失败: ' + e.message;
|
||
}
|
||
}
|
||
|
||
async function deleteProduct(id, name) {
|
||
if (!confirm(`确定要下架产品「${name}」吗?`)) return;
|
||
try {
|
||
await api(`/products/${id}`, { method: 'DELETE' });
|
||
await loadProducts();
|
||
} catch (e) {
|
||
alert('操作失败: ' + e.message);
|
||
}
|
||
}
|
||
|
||
// --- 销售列表 ---
|
||
async function loadSalespersons() {
|
||
const data = await api('/salespersons');
|
||
const tbody = document.getElementById('salespersons-tbody');
|
||
tbody.innerHTML = data.map(s => `
|
||
<tr class="border-b hover:bg-gray-50">
|
||
<td class="py-2 font-medium">${s.name}</td>
|
||
<td>${s.team}</td>
|
||
<td class="text-gray-500">${s.wx_account}</td>
|
||
<td>${s.contact_count}</td>
|
||
<td>${s.customer_count}</td>
|
||
<td>${s.message_count}</td>
|
||
<td>${formatYuan(s.deal_amount)}</td>
|
||
<td class="text-gray-400 text-xs">${s.last_synced_at ? new Date(s.last_synced_at).toLocaleString('zh-CN') : '-'}</td>
|
||
</tr>
|
||
`).join('');
|
||
}
|
||
|
||
// --- 客户列表 ---
|
||
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}&`;
|
||
|
||
const data = await api(path);
|
||
|
||
// 填充销售筛选
|
||
const spFilter = document.getElementById('filter-salesperson');
|
||
if (spFilter.options.length <= 1) {
|
||
const spData = await api('/salespersons');
|
||
spData.forEach(s => {
|
||
const opt = document.createElement('option');
|
||
opt.value = s.id; opt.textContent = s.name;
|
||
spFilter.appendChild(opt);
|
||
});
|
||
}
|
||
|
||
// 填充产品线筛选
|
||
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 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('');
|
||
}
|
||
|
||
// --- 客户详情 ---
|
||
|
||
/** 切换 AI 沟通摘要的折叠/展开状态 */
|
||
function toggleSummary() {
|
||
const body = document.getElementById('summary-body');
|
||
const arrow = document.getElementById('summary-arrow');
|
||
if (!body || !arrow) return;
|
||
const collapsed = body.style.display === 'none';
|
||
body.style.display = collapsed ? '' : 'none';
|
||
arrow.classList.toggle('rotate-90', collapsed);
|
||
arrow.classList.toggle('rotate-0', !collapsed);
|
||
}
|
||
|
||
async function showCustomerDetail(id) {
|
||
currentCustomerId = id;
|
||
currentMsgPage = 1;
|
||
// 如果 hash 已经是 customer-detail/id 则直接渲染,否则更新 hash 触发路由
|
||
const expectedHash = `customer-detail/${id}`;
|
||
if (location.hash.slice(1) !== expectedHash) {
|
||
location.hash = expectedHash;
|
||
return;
|
||
}
|
||
|
||
const customer = await api(`/customers/${id}`);
|
||
const info = document.getElementById('detail-info');
|
||
info.innerHTML = `
|
||
<h3 class="text-lg font-bold mb-3">${customer.customer_name || customer.contact_display_name}</h3>
|
||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||
<div><span class="text-gray-500">销售:</span> ${customer.salesperson_name}</div>
|
||
<div><span class="text-gray-500">行业:</span> ${customer.industry || '-'}</div>
|
||
<div><span class="text-gray-500">意向:</span> <span class="font-medium">${intentLabels[customer.intent_level] || customer.intent_level || '-'}</span></div>
|
||
<div><span class="text-gray-500">阶段:</span> <span class="font-medium">${customer.stage || '-'}</span></div>
|
||
</div>
|
||
<div class="mt-3 text-sm">
|
||
<span class="text-gray-500">关键需求:</span> ${(customer.key_needs || []).join('、') || '-'}
|
||
</div>
|
||
<div class="mt-2 text-sm text-gray-400">识别原因: ${customer.reason || '-'}</div>
|
||
`;
|
||
|
||
const summary = document.getElementById('detail-summary');
|
||
summary.innerHTML = `
|
||
<div class="flex items-center justify-between mb-3 cursor-pointer select-none" onclick="toggleSummary()">
|
||
<div class="flex items-center gap-2">
|
||
<svg id="summary-arrow" class="w-4 h-4 text-gray-400 transition-transform rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
|
||
<h3 class="font-semibold">AI 沟通摘要</h3>
|
||
</div>
|
||
<span class="text-xs text-gray-400">分析时间: ${customer.last_analysis ? new Date(customer.last_analysis).toLocaleString('zh-CN') : '-'}</span>
|
||
</div>
|
||
<div id="summary-body" class="transition-all duration-200 overflow-hidden">
|
||
<div class="bg-gray-50 border border-gray-100 rounded-lg p-4 mb-4">
|
||
<p class="text-sm text-gray-700 leading-relaxed">${customer.summary || '暂无摘要'}</p>
|
||
</div>
|
||
${(customer.key_points || []).length > 0 ? `
|
||
<div class="mb-4">
|
||
<h4 class="text-sm font-semibold mb-2 ${customer.stage === '成交' ? 'text-green-700' : 'text-orange-700'}">${customer.stage === '成交' ? '关键成交点' : '需要突破的问题点'}</h4>
|
||
<ul class="space-y-1.5">
|
||
${(customer.key_points || []).map(p => `
|
||
<li class="flex gap-2 items-start text-sm">
|
||
<span class="${customer.stage === '成交' ? 'text-green-500' : 'text-orange-500'} shrink-0">▸</span>
|
||
<span class="text-gray-700">${p}</span>
|
||
</li>
|
||
`).join('')}
|
||
</ul>
|
||
</div>
|
||
` : ''}
|
||
<div class="space-y-3 text-sm border-t pt-3">
|
||
<div class="flex gap-2 items-start">
|
||
<span class="text-gray-500 shrink-0 w-14">异议:</span>
|
||
<span class="text-gray-700">${(customer.objections || []).join('、') || '无'}</span>
|
||
</div>
|
||
<div class="flex gap-2 items-start">
|
||
<span class="text-gray-500 shrink-0 w-14">下一步:</span>
|
||
<span class="text-gray-700">${customer.next_action || '-'}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
await loadCustomerMessages();
|
||
await loadCustomerDeals();
|
||
}
|
||
|
||
async function loadCustomerMessages() {
|
||
const data = await api(`/customers/${currentCustomerId}/messages?page=${currentMsgPage}&page_size=50`);
|
||
const container = document.getElementById('detail-messages');
|
||
container.innerHTML = data.messages.map(m => {
|
||
const isSales = m.sender_display_name && ['张伟','李娜','王强'].includes(m.sender_display_name);
|
||
const cls = isSales ? 'chat-sales' : 'chat-customer';
|
||
const content = m.message_type === 'text' ? m.normalized_content : m.normalized_content || `[${m.message_type}]`;
|
||
return `
|
||
<div class="flex flex-col">
|
||
<span class="text-xs text-gray-400 mb-0.5">${m.sender_display_name || '未知'} · ${m.created_at ? new Date(m.created_at).toLocaleString('zh-CN') : ''}</span>
|
||
<div class="chat-bubble ${cls} text-sm">${escapeHtml(content)}</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
const pagination = document.getElementById('detail-pagination');
|
||
const totalPages = Math.ceil(data.total / 50);
|
||
pagination.innerHTML = `
|
||
<span class="text-sm text-gray-500">${data.total} 条消息,第 ${currentMsgPage}/${totalPages} 页</span>
|
||
${currentMsgPage > 1 ? `<button onclick="prevPage()" class="text-sm text-green-600">上一页</button>` : ''}
|
||
${currentMsgPage < totalPages ? `<button onclick="nextPage()" class="text-sm text-green-600">下一页</button>` : ''}
|
||
`;
|
||
}
|
||
|
||
function prevPage() { currentMsgPage--; loadCustomerMessages(); }
|
||
function nextPage() { currentMsgPage++; loadCustomerMessages(); }
|
||
|
||
async function loadCustomerDeals() {
|
||
const data = await api(`/customers/${currentCustomerId}/deals`);
|
||
const container = document.getElementById('detail-deals');
|
||
if (data.length === 0) {
|
||
container.innerHTML = '<p class="text-sm text-gray-400">暂无成交记录</p>';
|
||
return;
|
||
}
|
||
container.innerHTML = data.map(d => `
|
||
<div class="border-b py-2 text-sm">
|
||
<div class="flex justify-between">
|
||
<span class="font-medium">${d.product_name}</span>
|
||
<span class="font-bold text-green-600">${formatYuan(d.amount)}</span>
|
||
</div>
|
||
<div class="text-xs text-gray-400 mt-1">${d.deal_date} · ${dealStatusLabels[d.status] || d.status} · ${d.salesperson_name}</div>
|
||
${d.notes ? `<div class="text-xs text-gray-500 mt-1">${d.notes}</div>` : ''}
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
// --- 成交记录列表 ---
|
||
async function loadDeals() {
|
||
const productId = document.getElementById('deals-filter-product').value;
|
||
let path = '/deals?';
|
||
if (productId) path += `product_id=${productId}&`;
|
||
const data = await api(path);
|
||
const tbody = document.getElementById('deals-tbody');
|
||
// 填充产品筛选
|
||
const productFilter = document.getElementById('deals-filter-product');
|
||
if (productFilter.options.length <= 1) {
|
||
const products = await api('/products');
|
||
products.forEach(p => {
|
||
const opt = document.createElement('option');
|
||
opt.value = p.id; opt.textContent = `${p.name} ${p.spec || ''}`;
|
||
productFilter.appendChild(opt);
|
||
});
|
||
}
|
||
if (data.length === 0) {
|
||
tbody.innerHTML = '<tr><td colspan="6" class="py-4 text-center text-gray-400">暂无成交记录</td></tr>';
|
||
return;
|
||
}
|
||
tbody.innerHTML = data.map(d => `
|
||
<tr class="border-b hover:bg-gray-50">
|
||
<td class="py-2">${d.salesperson_name}</td>
|
||
<td>${d.customer_name || '-'}</td>
|
||
<td>${d.product_name}</td>
|
||
<td class="font-bold text-green-600">${formatYuan(d.amount)}</td>
|
||
<td>${d.deal_date}</td>
|
||
<td><span class="px-2 py-0.5 rounded text-xs ${d.status==='closed'?'bg-green-100 text-green-700':'bg-yellow-100 text-yellow-700'}">${dealStatusLabels[d.status] || d.status}</span></td>
|
||
</tr>
|
||
`).join('');
|
||
}
|
||
|
||
// --- 录入成交 ---
|
||
async function loadDealForm() {
|
||
const spSelect = document.getElementById('deal-salesperson');
|
||
spSelect.innerHTML = '<option value="">请选择</option>';
|
||
const spData = await api('/salespersons');
|
||
spData.forEach(s => {
|
||
const opt = document.createElement('option');
|
||
opt.value = s.id; opt.textContent = s.name;
|
||
spSelect.appendChild(opt);
|
||
});
|
||
// 加载产品列表到下拉
|
||
const productSelect = document.getElementById('deal-product');
|
||
productSelect.innerHTML = '<option value="">请选择</option>';
|
||
const products = await api('/products?status=active');
|
||
products.forEach(p => {
|
||
const opt = document.createElement('option');
|
||
opt.value = p.id;
|
||
opt.textContent = `${p.name} ${p.spec || ''} (¥${p.price ? p.price.toFixed(2) : '-'})`;
|
||
opt.dataset.price = p.price || '';
|
||
opt.dataset.name = `${p.name} ${p.spec || ''}`.trim();
|
||
productSelect.appendChild(opt);
|
||
});
|
||
document.getElementById('deal-date').value = new Date().toISOString().slice(0, 10);
|
||
document.getElementById('deal-result').textContent = '';
|
||
}
|
||
|
||
async function loadDealCustomers() {
|
||
const spId = document.getElementById('deal-salesperson').value;
|
||
const custSelect = document.getElementById('deal-customer');
|
||
custSelect.innerHTML = '<option value="">请选择</option>';
|
||
if (!spId) return;
|
||
const data = await api(`/customers?salesperson_id=${spId}`);
|
||
data.forEach(c => {
|
||
const opt = document.createElement('option');
|
||
opt.value = c.id;
|
||
opt.textContent = c.customer_name || c.contact_display_name;
|
||
custSelect.appendChild(opt);
|
||
});
|
||
}
|
||
|
||
function onProductSelected() {
|
||
const select = document.getElementById('deal-product');
|
||
const selectedOpt = select.options[select.selectedIndex];
|
||
if (selectedOpt && selectedOpt.dataset.price) {
|
||
document.getElementById('deal-amount').value = selectedOpt.dataset.price;
|
||
}
|
||
}
|
||
|
||
async function submitDeal(event) {
|
||
event.preventDefault();
|
||
const productSelect = document.getElementById('deal-product');
|
||
const selectedOpt = productSelect.options[productSelect.selectedIndex];
|
||
const productId = parseInt(productSelect.value);
|
||
const productName = selectedOpt ? selectedOpt.dataset.name || selectedOpt.textContent : '';
|
||
const body = {
|
||
salesperson_id: parseInt(document.getElementById('deal-salesperson').value),
|
||
customer_id: parseInt(document.getElementById('deal-customer').value),
|
||
product_id: productId || null,
|
||
product_name: productName,
|
||
amount: parseFloat(document.getElementById('deal-amount').value),
|
||
deal_date: document.getElementById('deal-date').value,
|
||
notes: document.getElementById('deal-notes').value || null,
|
||
};
|
||
try {
|
||
const result = await api('/deals', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify(body),
|
||
});
|
||
document.getElementById('deal-result').textContent = '✓ 已创建,ID: ' + result.id;
|
||
productSelect.value = '';
|
||
document.getElementById('deal-amount').value = '';
|
||
document.getElementById('deal-notes').value = '';
|
||
} catch (e) {
|
||
document.getElementById('deal-result').textContent = '✗ 失败: ' + e.message;
|
||
}
|
||
}
|
||
|
||
// --- 交流分析 ---
|
||
let analysisView = 'list';
|
||
let conversationsData = [];
|
||
|
||
async function loadAnalysis() {
|
||
analysisView = 'list';
|
||
renderAnalysisView();
|
||
await loadConversations();
|
||
}
|
||
|
||
function renderAnalysisView() {
|
||
const listTab = document.getElementById('analysis-tab-list');
|
||
const paradigmTab = document.getElementById('analysis-tab-paradigm');
|
||
const listView = document.getElementById('analysis-list-view');
|
||
const paradigmView = document.getElementById('analysis-paradigm-view');
|
||
if (analysisView === 'list') {
|
||
listTab.className = 'px-4 py-1.5 rounded text-sm bg-green-600 text-white';
|
||
paradigmTab.className = 'px-4 py-1.5 rounded text-sm bg-white border hover:bg-gray-50';
|
||
listView.classList.remove('hidden');
|
||
paradigmView.classList.add('hidden');
|
||
} else {
|
||
listTab.className = 'px-4 py-1.5 rounded text-sm bg-white border hover:bg-gray-50';
|
||
paradigmTab.className = 'px-4 py-1.5 rounded text-sm bg-green-600 text-white';
|
||
listView.classList.add('hidden');
|
||
paradigmView.classList.remove('hidden');
|
||
populateParadigmSelect();
|
||
}
|
||
}
|
||
|
||
async function loadConversations() {
|
||
const sp = document.getElementById('analysis-filter-sp').value;
|
||
const stage = document.getElementById('analysis-filter-stage').value;
|
||
let path = '/conversations?';
|
||
if (sp) path += `salesperson_id=${sp}&`;
|
||
if (stage) path += `stage=${encodeURIComponent(stage)}&`;
|
||
try {
|
||
conversationsData = await api(path);
|
||
renderAnalysisList();
|
||
populateParadigmSelect();
|
||
} catch (e) {
|
||
document.getElementById('analysis-list').innerHTML = `<p class="text-sm text-red-500">加载失败: ${e.message}</p>`;
|
||
}
|
||
// 填充销售筛选
|
||
const spSelect = document.getElementById('analysis-filter-sp');
|
||
if (spSelect.options.length <= 1) {
|
||
const sps = await api('/salespersons');
|
||
sps.forEach(s => {
|
||
const opt = document.createElement('option');
|
||
opt.value = s.id;
|
||
opt.textContent = s.name;
|
||
spSelect.appendChild(opt);
|
||
});
|
||
}
|
||
}
|
||
|
||
const STAGE_COLORS = {
|
||
'成交': 'bg-green-100 text-green-700',
|
||
'异议处理': 'bg-orange-100 text-orange-700',
|
||
'报价': 'bg-blue-100 text-blue-700',
|
||
'需求发现': 'bg-purple-100 text-purple-700',
|
||
'建立联系': 'bg-gray-100 text-gray-700',
|
||
};
|
||
|
||
function renderAnalysisList() {
|
||
const container = document.getElementById('analysis-list');
|
||
if (conversationsData.length === 0) {
|
||
container.innerHTML = '<p class="text-sm text-gray-400">暂无交流分析数据</p>';
|
||
return;
|
||
}
|
||
container.innerHTML = conversationsData.map(c => `
|
||
<div class="bg-white rounded-lg shadow p-4">
|
||
<div class="flex items-center justify-between mb-2">
|
||
<div class="flex items-center gap-3">
|
||
<span class="font-medium text-sm">${c.customer_name}</span>
|
||
<span class="text-xs text-gray-400">${c.salesperson_name}</span>
|
||
<span class="text-xs text-gray-400">${c.msg_count} 条消息</span>
|
||
</div>
|
||
<span class="px-2 py-0.5 rounded text-xs ${STAGE_COLORS[c.stage] || STAGE_COLORS['建立联系']}">${c.stage || '-'}</span>
|
||
</div>
|
||
<p class="text-sm text-gray-600 mb-2">${c.summary || '暂无摘要'}</p>
|
||
${(c.key_points || []).length > 0 ? `
|
||
<div class="mb-2">
|
||
<span class="text-xs font-semibold ${c.stage === '成交' ? 'text-green-700' : 'text-orange-700'}">${c.stage === '成交' ? '关键成交点' : '需要突破的问题点'}</span>
|
||
<ul class="mt-1 space-y-0.5">
|
||
${(c.key_points || []).map(p => `<li class="text-xs text-gray-600 flex gap-1"><span class="${c.stage === '成交' ? 'text-green-500' : 'text-orange-500'}">▸</span><span>${escapeHtml(p)}</span></li>`).join('')}
|
||
</ul>
|
||
</div>
|
||
` : ''}
|
||
<div class="flex gap-4 text-xs text-gray-500 border-t pt-2 mt-2">
|
||
<span>异议: ${(c.objections || []).join('、') || '无'}</span>
|
||
<span>下一步: ${c.next_action || '-'}</span>
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
let paradigmCustomers = [];
|
||
let paradigmProducts = [];
|
||
|
||
function populateParadigmSelect() {
|
||
const sel = document.getElementById('paradigm-customer-select');
|
||
if (!sel) return;
|
||
paradigmCustomers = conversationsData;
|
||
// 填充客户下拉
|
||
sel.innerHTML = '<option value="">请选择客户</option>';
|
||
paradigmCustomers.forEach(c => {
|
||
const opt = document.createElement('option');
|
||
opt.value = c.id;
|
||
opt.textContent = `${c.customer_name} (${c.salesperson_name}, ${c.stage})`;
|
||
sel.appendChild(opt);
|
||
});
|
||
// 填充产品下拉
|
||
const productSel = document.getElementById('paradigm-product-select');
|
||
productSel.innerHTML = '<option value="">自动推断产品</option>';
|
||
api('/products?status=active').then(products => {
|
||
paradigmProducts = products;
|
||
products.forEach(p => {
|
||
const opt = document.createElement('option');
|
||
opt.value = p.id;
|
||
opt.textContent = `${p.name} ${p.spec || ''}`;
|
||
productSel.appendChild(opt);
|
||
});
|
||
}).catch(() => {});
|
||
}
|
||
|
||
/** 选择客户时,自动筛选匹配的产品(保留已选产品) */
|
||
function onParadigmCustomerChange() {
|
||
const customerId = document.getElementById('paradigm-customer-select').value;
|
||
const productSel = document.getElementById('paradigm-product-select');
|
||
const hint = document.getElementById('paradigm-match-hint');
|
||
const currentProductId = productSel.value;
|
||
if (!customerId) {
|
||
// 恢复全部产品,保留当前选择
|
||
const prevVal = currentProductId;
|
||
productSel.innerHTML = '<option value="">自动推断产品</option>';
|
||
paradigmProducts.forEach(p => {
|
||
const opt = document.createElement('option');
|
||
opt.value = p.id;
|
||
opt.textContent = `${p.name} ${p.spec || ''}`;
|
||
productSel.appendChild(opt);
|
||
});
|
||
if (prevVal) productSel.value = prevVal;
|
||
hint.textContent = '';
|
||
return;
|
||
}
|
||
const customer = paradigmCustomers.find(c => String(c.id) === customerId);
|
||
if (!customer) return;
|
||
const cats = (customer.product_categories || '').split(', ').filter(x => x);
|
||
if (cats.length > 0) {
|
||
// 如果已选产品匹配当前客户产品线,保留选择不重新筛选
|
||
if (currentProductId) {
|
||
const currentProduct = paradigmProducts.find(p => String(p.id) === currentProductId);
|
||
if (currentProduct && cats.includes(currentProduct.category)) {
|
||
hint.textContent = `客户关联产品线: ${cats.join('、')},当前产品已匹配`;
|
||
return;
|
||
}
|
||
}
|
||
// 筛选匹配产品线的产品
|
||
const matched = paradigmProducts.filter(p => cats.includes(p.category));
|
||
productSel.innerHTML = '<option value="">自动推断产品</option>';
|
||
matched.forEach(p => {
|
||
const opt = document.createElement('option');
|
||
opt.value = p.id;
|
||
opt.textContent = `${p.name} ${p.spec || ''}`;
|
||
productSel.appendChild(opt);
|
||
});
|
||
hint.textContent = `客户关联产品线: ${cats.join('、')},已筛选 ${matched.length} 个匹配产品`;
|
||
} else {
|
||
// 无关联产品线,显示全部,保留当前选择
|
||
const prevVal = currentProductId;
|
||
productSel.innerHTML = '<option value="">自动推断产品</option>';
|
||
paradigmProducts.forEach(p => {
|
||
const opt = document.createElement('option');
|
||
opt.value = p.id;
|
||
opt.textContent = `${p.name} ${p.spec || ''}`;
|
||
productSel.appendChild(opt);
|
||
});
|
||
if (prevVal) productSel.value = prevVal;
|
||
hint.textContent = '该客户未关联产品线,显示全部产品';
|
||
}
|
||
}
|
||
|
||
/** 选择产品时,筛选匹配产品线的客户(保留已选客户) */
|
||
function onParadigmProductChange() {
|
||
const productId = document.getElementById('paradigm-product-select').value;
|
||
const customerSel = document.getElementById('paradigm-customer-select');
|
||
const hint = document.getElementById('paradigm-match-hint');
|
||
const currentCustomerId = customerSel.value;
|
||
if (!productId) {
|
||
// 恢复全部客户,保留当前选择
|
||
const prevVal = currentCustomerId;
|
||
customerSel.innerHTML = '<option value="">请选择客户</option>';
|
||
paradigmCustomers.forEach(c => {
|
||
const opt = document.createElement('option');
|
||
opt.value = c.id;
|
||
opt.textContent = `${c.customer_name} (${c.salesperson_name}, ${c.stage})`;
|
||
customerSel.appendChild(opt);
|
||
});
|
||
if (prevVal) customerSel.value = prevVal;
|
||
hint.textContent = '';
|
||
return;
|
||
}
|
||
const product = paradigmProducts.find(p => String(p.id) === productId);
|
||
if (!product) return;
|
||
// 如果已选客户匹配当前产品线,保留选择不重新筛选
|
||
if (currentCustomerId) {
|
||
const currentCustomer = paradigmCustomers.find(c => String(c.id) === currentCustomerId);
|
||
if (currentCustomer) {
|
||
const cats = (currentCustomer.product_categories || '').split(', ').filter(x => x);
|
||
if (cats.includes(product.category)) {
|
||
hint.textContent = `产品线: ${product.category},当前客户已匹配`;
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
// 筛选产品线匹配的客户
|
||
const matched = paradigmCustomers.filter(c => {
|
||
const cats = (c.product_categories || '').split(', ').filter(x => x);
|
||
return cats.includes(product.category);
|
||
});
|
||
if (matched.length > 0) {
|
||
customerSel.innerHTML = '<option value="">请选择客户</option>';
|
||
matched.forEach(c => {
|
||
const opt = document.createElement('option');
|
||
opt.value = c.id;
|
||
opt.textContent = `${c.customer_name} (${c.salesperson_name}, ${c.stage})`;
|
||
customerSel.appendChild(opt);
|
||
});
|
||
hint.textContent = `产品线: ${product.category},已筛选 ${matched.length} 个匹配客户`;
|
||
} else {
|
||
customerSel.innerHTML = '<option value="">请选择客户</option>';
|
||
paradigmCustomers.forEach(c => {
|
||
const opt = document.createElement('option');
|
||
opt.value = c.id;
|
||
opt.textContent = `${c.customer_name} (${c.salesperson_name}, ${c.stage})`;
|
||
customerSel.appendChild(opt);
|
||
});
|
||
hint.textContent = `产品线: ${product.category},无匹配客户,显示全部`;
|
||
}
|
||
}
|
||
|
||
async function generateParadigm() {
|
||
const customerId = document.getElementById('paradigm-customer-select').value;
|
||
if (!customerId) {
|
||
document.getElementById('paradigm-result').innerHTML = '<p class="text-sm text-orange-500">请先选择客户</p>';
|
||
return;
|
||
}
|
||
const btn = document.getElementById('paradigm-btn');
|
||
const result = document.getElementById('paradigm-result');
|
||
btn.disabled = true;
|
||
btn.textContent = '生成中...';
|
||
result.innerHTML = `
|
||
<div class="bg-white rounded-lg shadow p-6">
|
||
<div class="flex items-center gap-2 mb-4">
|
||
<svg class="animate-spin w-5 h-5 text-green-600" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
|
||
<span class="text-sm text-gray-500">正在调用千问 LLM 生成标准范式对话...</span>
|
||
</div>
|
||
<div id="paradigm-md" class="text-sm leading-relaxed"></div>
|
||
</div>
|
||
`;
|
||
const mdContainer = document.getElementById('paradigm-md');
|
||
let fullContent = '';
|
||
try {
|
||
const productId = document.getElementById('paradigm-product-select').value;
|
||
let paradigmUrl = `${API}/conversations/${customerId}/paradigm`;
|
||
if (productId) paradigmUrl += `?product_id=${productId}`;
|
||
const resp = await fetch(paradigmUrl, { method: 'POST' });
|
||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||
const reader = resp.body.getReader();
|
||
const decoder = new TextDecoder();
|
||
let buffer = '';
|
||
let renderRaf = null;
|
||
function scheduleRender() {
|
||
if (renderRaf) return;
|
||
renderRaf = requestAnimationFrame(() => {
|
||
renderRaf = null;
|
||
mdContainer.innerHTML = renderMarkdown(fullContent);
|
||
mdContainer.scrollTop = mdContainer.scrollHeight;
|
||
});
|
||
}
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
buffer += decoder.decode(value, { stream: true });
|
||
const lines = buffer.split('\n');
|
||
buffer = lines.pop();
|
||
for (const line of lines) {
|
||
if (!line.startsWith('data: ')) continue;
|
||
const dataStr = line.slice(6).trim();
|
||
if (dataStr === '[DONE]') continue;
|
||
try {
|
||
const obj = JSON.parse(dataStr);
|
||
if (obj.error) throw new Error(obj.error);
|
||
if (obj.content) {
|
||
fullContent += obj.content;
|
||
scheduleRender();
|
||
}
|
||
} catch (e) {
|
||
// 忽略解析错误的行
|
||
}
|
||
}
|
||
}
|
||
// 最终渲染
|
||
if (renderRaf) cancelAnimationFrame(renderRaf);
|
||
mdContainer.innerHTML = renderMarkdown(fullContent);
|
||
// 移除加载动画
|
||
const loader = result.querySelector('.animate-spin');
|
||
if (loader) loader.parentElement.remove();
|
||
} catch (e) {
|
||
result.innerHTML = `<p class="text-sm text-red-500">生成失败: ${e.message}</p>`;
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.textContent = '生成范式对话';
|
||
}
|
||
}
|
||
|
||
/** 轻量 Markdown 转 HTML 渲染器(支持标题、列表、子列表、引用、粗体、代码) */
|
||
function renderMarkdown(md) {
|
||
if (!md) return '';
|
||
const lines = md.split('\n');
|
||
let html = '';
|
||
let i = 0;
|
||
/** 关闭所有打开的列表 */
|
||
function closeLists(stack) {
|
||
while (stack.length) {
|
||
html += stack.pop() === 'ul' ? '</ul>' : '</ol>';
|
||
}
|
||
}
|
||
const listStack = [];
|
||
while (i < lines.length) {
|
||
let line = lines[i];
|
||
const trimmed = line.trimStart();
|
||
const indent = line.length - trimmed.length;
|
||
// 空行
|
||
if (trimmed === '') {
|
||
closeLists(listStack);
|
||
i++;
|
||
continue;
|
||
}
|
||
// 标题
|
||
if (/^### /.test(trimmed)) {
|
||
closeLists(listStack);
|
||
html += `<h4 class="font-semibold text-base mt-4 mb-2 text-gray-800">${inlineMd(trimmed.slice(4))}</h4>`;
|
||
i++; continue;
|
||
} else if (/^## /.test(trimmed)) {
|
||
closeLists(listStack);
|
||
html += `<h3 class="font-bold text-lg mt-5 mb-2 text-gray-900 border-b pb-1">${inlineMd(trimmed.slice(3))}</h3>`;
|
||
i++; continue;
|
||
} else if (/^# /.test(trimmed)) {
|
||
closeLists(listStack);
|
||
html += `<h2 class="font-bold text-xl mt-5 mb-3 text-gray-900">${inlineMd(trimmed.slice(2))}</h2>`;
|
||
i++; continue;
|
||
}
|
||
// 引用块 >
|
||
if (/^> /.test(trimmed)) {
|
||
closeLists(listStack);
|
||
const quoteLines = [];
|
||
while (i < lines.length && /^>\s?/.test(lines[i].trimStart())) {
|
||
quoteLines.push(lines[i].trimStart().replace(/^>\s?/, ''));
|
||
i++;
|
||
}
|
||
html += `<blockquote class="border-l-4 border-gray-300 pl-3 py-1 my-2 text-gray-600 italic">${inlineMd(quoteLines.join(' '))}</blockquote>`;
|
||
continue;
|
||
}
|
||
// 有序列表
|
||
if (/^\d+\. /.test(trimmed)) {
|
||
const level = Math.floor(indent / 2);
|
||
while (listStack.length > level) {
|
||
html += listStack.pop() === 'ul' ? '</ul>' : '</ol>';
|
||
}
|
||
if (listStack.length === level) {
|
||
if (listStack[level - 1] !== 'ol') {
|
||
if (listStack.length) html += listStack.pop() === 'ul' ? '</ul>' : '</ol>';
|
||
html += '<ol class="space-y-1 ml-5 list-decimal">';
|
||
listStack.push('ol');
|
||
}
|
||
} else {
|
||
html += '<ol class="space-y-1 ml-5 list-decimal">';
|
||
listStack.push('ol');
|
||
}
|
||
html += `<li class="text-gray-700">${inlineMd(trimmed.replace(/^\d+\. /, ''))}</li>`;
|
||
i++; continue;
|
||
}
|
||
// 无序列表
|
||
if (/^[-*] /.test(trimmed)) {
|
||
const level = Math.floor(indent / 2);
|
||
while (listStack.length > level) {
|
||
html += listStack.pop() === 'ul' ? '</ul>' : '</ol>';
|
||
}
|
||
if (listStack.length === level) {
|
||
if (listStack[level - 1] !== 'ul') {
|
||
if (listStack.length) html += listStack.pop() === 'ul' ? '</ul>' : '</ol>';
|
||
html += '<ul class="space-y-1 ml-4 list-disc">';
|
||
listStack.push('ul');
|
||
}
|
||
} else {
|
||
html += '<ul class="space-y-1 ml-4 list-disc">';
|
||
listStack.push('ul');
|
||
}
|
||
html += `<li class="text-gray-700">${inlineMd(trimmed.slice(2))}</li>`;
|
||
i++; continue;
|
||
}
|
||
// 普通段落(收集连续非空非格式行)
|
||
closeLists(listStack);
|
||
const paraLines = [trimmed];
|
||
i++;
|
||
while (i < lines.length) {
|
||
const nextTrimmed = lines[i].trimStart();
|
||
if (nextTrimmed === '' || /^#{1,3} /.test(nextTrimmed) || /^[-*] /.test(nextTrimmed) ||
|
||
/^\d+\. /.test(nextTrimmed) || /^> /.test(nextTrimmed)) break;
|
||
paraLines.push(nextTrimmed);
|
||
i++;
|
||
}
|
||
html += `<p class="mb-3 text-gray-700 leading-relaxed">${inlineMd(paraLines.join(' '))}</p>`;
|
||
}
|
||
closeLists(listStack);
|
||
return html;
|
||
}
|
||
|
||
/** 行内 Markdown:粗体、行内代码、链接 */
|
||
function inlineMd(text) {
|
||
text = escapeHtml(text);
|
||
text = text.replace(/\*\*(.+?)\*\*/g, '<strong class="font-semibold text-gray-900">$1</strong>');
|
||
text = text.replace(/`(.+?)`/g, '<code class="bg-gray-100 px-1 rounded text-xs">$1</code>');
|
||
return text;
|
||
}
|
||
|
||
// --- 工具 ---
|
||
function escapeHtml(s) {
|
||
const div = document.createElement('div');
|
||
div.textContent = s || '';
|
||
return div.innerHTML;
|
||
}
|
||
|
||
// --- 初始化 ---
|
||
handleRoute();
|
||
</script>
|
||
</body>
|
||
</html>
|