Files
wxsales/demo/web/static/index.html
T
2026-07-15 15:33:06 +08:00

1417 lines
61 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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">&larr; 返回产品管理</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">&larr; 返回客户列表</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');
}
let liContent = inlineMd(trimmed.slice(2));
let rawText = trimmed.slice(2).replace(/^\*\*(.+?)\*\*[:]?\s*/, '');
// 销售话术:绿色高亮卡片 + 复制按钮
if (/^\*\*销售话术\*\*/.test(trimmed.slice(2).trim())) {
liContent = liContent.replace(/^<strong class="font-semibold text-gray-900">销售话术<\/strong>[:]?\s*/, '');
const scriptId = 'script-' + Math.random().toString(36).slice(2, 9);
html += `<li class="text-gray-700 list-none ml-0">
<div class="script-card border-l-4 border-green-500 bg-green-50 rounded-r-lg px-4 py-3 my-2 shadow-sm" id="${scriptId}">
<div class="flex items-center justify-between mb-1.5">
<span class="text-xs font-semibold text-green-800 flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/></svg>
销售话术
</span>
<button onclick="copyScript('${scriptId}')" class="text-xs text-green-700 hover:text-green-900 hover:bg-green-100 px-2 py-1 rounded transition-colors flex items-center gap-1">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>
复制话术
</button>
</div>
<div class="script-content text-sm text-gray-800 leading-relaxed whitespace-pre-wrap">${liContent}</div>
</div>
</li>`;
} else if (/^\*\*预期回应\*\*/.test(trimmed.slice(2).trim())) {
// 预期回应:灰色信息块
liContent = liContent.replace(/^<strong class="font-semibold text-gray-900">预期回应<\/strong>[:]?\s*/, '');
html += `<li class="text-gray-700 list-none ml-0">
<div class="border-l-4 border-gray-300 bg-gray-50 rounded-r-lg px-4 py-3 my-2">
<div class="text-xs font-semibold text-gray-500 mb-1.5 flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h18M7 15h12M7 20h12"/></svg>
预期回应
</div>
<div class="text-sm text-gray-600 leading-relaxed">${liContent}</div>
</div>
</li>`;
} else if (/^\*\*技巧提示\*\*/.test(trimmed.slice(2).trim())) {
// 技巧提示:黄色提示块
liContent = liContent.replace(/^<strong class="font-semibold text-gray-900">技巧提示<\/strong>[:]?\s*/, '');
html += `<li class="text-gray-700 list-none ml-0">
<div class="border-l-4 border-amber-400 bg-amber-50 rounded-r-lg px-4 py-3 my-2">
<div class="text-xs font-semibold text-amber-700 mb-1.5 flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/></svg>
技巧提示
</div>
<div class="text-sm text-gray-700 leading-relaxed">${liContent}</div>
</div>
</li>`;
} else {
html += `<li class="text-gray-700">${liContent}</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;
}
/** 复制销售话术到剪贴板,并显示 toast 提示 */
function copyScript(scriptId) {
const el = document.getElementById(scriptId);
if (!el) return;
const contentEl = el.querySelector('.script-content');
if (!contentEl) return;
const text = contentEl.innerText.trim();
navigator.clipboard.writeText(text).then(() => {
showToast('话术已复制', 'success');
}).catch(() => {
// 降级方案:使用临时 textarea
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
try {
document.execCommand('copy');
showToast('话术已复制', 'success');
} catch (e) {
showToast('复制失败,请手动选择文本', 'error');
}
document.body.removeChild(textarea);
});
}
/** 轻量 toast 提示 */
function showToast(message, type) {
const colors = { success: 'bg-green-600', error: 'bg-red-500', info: 'bg-gray-700' };
const toast = document.createElement('div');
toast.className = `fixed top-6 left-1/2 -translate-x-1/2 ${colors[type] || colors.info} text-white text-sm px-4 py-2 rounded-lg shadow-lg z-50 transition-all duration-300`;
toast.style.opacity = '0';
toast.style.transform = 'translate(-50%, -10px)';
toast.textContent = message;
document.body.appendChild(toast);
requestAnimationFrame(() => {
toast.style.opacity = '1';
toast.style.transform = 'translate(-50%, 0)';
});
setTimeout(() => {
toast.style.opacity = '0';
toast.style.transform = 'translate(-50%, -10px)';
setTimeout(() => toast.remove(), 300);
}, 2000);
}
// --- 工具 ---
function escapeHtml(s) {
const div = document.createElement('div');
div.textContent = s || '';
return div.innerHTML;
}
// --- 初始化 ---
handleRoute();
</script>
</body>
</html>