微信营销管理系统 - 益童宝销售管理平台

- 数据库: PostgreSQL schema + 3名销售/21客户/341消息/6成交
- 采集代理: mock_sync.py 模拟微信聊天同步
- AI分析: analyze.py 规则模式 + 千问LLM模式
- 后端: FastAPI 11个API接口
- 前端: 仪表盘/销售列表/客户列表/成交记录/交流分析/录入成交
- 交流分析: 全部客户对话概览 + LLM标准范式对话生成
- 部署: systemd + nginx, 已部署至 sale.all8ai.top
This commit is contained in:
freedakgmail
2026-07-14 22:31:29 +08:00
commit 5926c73ff1
20 changed files with 6343 additions and 0 deletions
+667
View File
@@ -0,0 +1,667 @@
#!/usr/bin/env python3
"""微信营销管理系统 MVP — FastAPI 后端。
提供 11 个 API 接口 + 静态前端托管。
"""
from __future__ import annotations
import datetime as dt
import json
import pathlib
import re
import subprocess
import sys
import psycopg2
import psycopg2.pool
from fastapi import FastAPI, HTTPException, Query
from fastapi.staticfiles import StaticFiles
from fastapi.responses import RedirectResponse
from pydantic import BaseModel
# ---------------------------------------------------------------------------
# 配置
# ---------------------------------------------------------------------------
ROOT = pathlib.Path(__file__).resolve().parent
STATIC_DIR = ROOT / "static"
SOCKET_DIR = ROOT.parent / "db" / "socket"
DSN = f"host={SOCKET_DIR} port=5434 dbname=wxchat_sales"
app = FastAPI(title="微信营销管理系统 MVP")
def parse_pg_array(val):
"""将 PostgreSQL TEXT[] 的字符串表示解析为 Python list。"""
if val is None:
return []
if isinstance(val, list):
return val
if isinstance(val, str):
s = val.strip()
if s == "{}" or s == "":
return []
# 去掉首尾 { }
s = s[1:-1] if s.startswith("{") and s.endswith("}") else s
# 按逗号分割(简单处理,不考虑逗号在引号内的情况)
return [item.strip().strip('"') for item in s.split(",") if item.strip()]
return []
# 连接池
_pool: psycopg2.pool.SimpleConnectionPool | None = None
def get_pool():
global _pool
if _pool is None:
_pool = psycopg2.pool.SimpleConnectionPool(1, 10, DSN)
return _pool
def get_conn():
return get_pool().getconn()
def put_conn(conn):
get_pool().putconn(conn)
# ---------------------------------------------------------------------------
# Pydantic 模型
# ---------------------------------------------------------------------------
class DealCreate(BaseModel):
salesperson_id: int
customer_id: int | None = None
contact_id: int | None = None
product_name: str
amount: float
deal_date: str
notes: str | None = None
status: str = "closed"
class AnalyzeRequest(BaseModel):
mode: str = "rule"
force: bool = False
# ---------------------------------------------------------------------------
# API 路由
# ---------------------------------------------------------------------------
@app.get("/api/dashboard")
def dashboard():
conn = get_conn()
try:
with conn.cursor() as cur:
cur.execute("SELECT count(*) FROM message")
total_messages = cur.fetchone()[0]
cur.execute("SELECT count(*) FROM customer")
active_customers = cur.fetchone()[0]
cur.execute("""
SELECT COALESCE(sum(amount), 0) FROM deal
WHERE status = 'closed'
AND deal_date >= date_trunc('month', now())
""")
monthly_deal_amount = float(cur.fetchone()[0])
cur.execute("""
SELECT s.id, s.name, s.team,
count(DISTINCT m.id) AS msg_count,
count(DISTINCT cu.id) AS customer_count,
COALESCE(sd.deal_amount, 0) AS deal_amount,
max(conv.last_synced_at) AS last_synced
FROM salesperson s
LEFT JOIN message m ON m.salesperson_id = s.id
LEFT JOIN customer cu ON cu.salesperson_id = s.id
LEFT JOIN conversation conv ON conv.salesperson_id = s.id
LEFT JOIN LATERAL (
SELECT COALESCE(sum(d.amount), 0) AS deal_amount
FROM deal d WHERE d.salesperson_id = s.id AND d.status = 'closed'
) sd ON true
GROUP BY s.id, s.name, s.team, sd.deal_amount
ORDER BY s.id
""")
salespersons = [
{
"id": r[0], "name": r[1], "team": r[2],
"message_count": r[3], "customer_count": r[4],
"deal_amount": float(r[5]),
"last_synced_at": r[6].isoformat() if r[6] else None,
}
for r in cur.fetchall()
]
return {
"total_messages": total_messages,
"active_customers": active_customers,
"monthly_deal_amount": monthly_deal_amount,
"salespersons": salespersons,
}
finally:
put_conn(conn)
@app.get("/api/salespersons")
def list_salespersons():
conn = get_conn()
try:
with conn.cursor() as cur:
cur.execute("""
SELECT s.id, s.name, s.team, s.wx_account, s.device_id,
count(DISTINCT c.id) AS contacts,
count(DISTINCT cu.id) AS customers,
count(DISTINCT m.id) AS messages,
COALESCE(sd.deal_amount, 0) AS deal_amount,
max(conv.last_synced_at) AS last_synced
FROM salesperson s
LEFT JOIN contact c ON c.salesperson_id = s.id
LEFT JOIN customer cu ON cu.salesperson_id = s.id
LEFT JOIN message m ON m.salesperson_id = s.id
LEFT JOIN conversation conv ON conv.salesperson_id = s.id
LEFT JOIN LATERAL (
SELECT COALESCE(sum(d.amount), 0) AS deal_amount
FROM deal d WHERE d.salesperson_id = s.id AND d.status = 'closed'
) sd ON true
GROUP BY s.id, s.name, s.team, s.wx_account, s.device_id, sd.deal_amount
ORDER BY s.id
""")
return [
{
"id": r[0], "name": r[1], "team": r[2],
"wx_account": r[3], "device_id": r[4],
"contact_count": r[5], "customer_count": r[6],
"message_count": r[7], "deal_amount": float(r[8]),
"last_synced_at": r[9].isoformat() if r[9] else None,
}
for r in cur.fetchall()
]
finally:
put_conn(conn)
@app.get("/api/customers")
def list_customers(
salesperson_id: int | None = Query(None),
intent_level: str | None = Query(None),
):
conn = get_conn()
try:
with conn.cursor() as cur:
sql = """
SELECT cu.id, cu.salesperson_id, s.name AS salesperson_name,
cu.customer_name, cu.industry, cu.intent_level,
cu.key_needs, cu.stage, cu.last_analysis,
c.display_name AS contact_display_name,
max(m.created_at) AS last_message_at
FROM customer cu
JOIN salesperson s ON s.id = cu.salesperson_id
JOIN contact c ON c.id = cu.contact_id
LEFT JOIN conversation conv ON conv.contact_id = c.id
LEFT JOIN message m ON m.conversation_id = conv.id
"""
conditions = []
params = []
if salesperson_id is not None:
conditions.append("cu.salesperson_id = %s")
params.append(salesperson_id)
if intent_level is not None:
conditions.append("cu.intent_level = %s")
params.append(intent_level)
if conditions:
sql += " WHERE " + " AND ".join(conditions)
sql += """
GROUP BY cu.id, cu.salesperson_id, s.name, cu.customer_name,
cu.industry, cu.intent_level, cu.key_needs, cu.stage,
cu.last_analysis, c.display_name
ORDER BY cu.salesperson_id, cu.id
"""
cur.execute(sql, params)
return [
{
"id": r[0], "salesperson_id": r[1],
"salesperson_name": r[2],
"customer_name": r[3], "industry": r[4],
"intent_level": r[5], "key_needs": parse_pg_array(r[6]),
"stage": r[7],
"last_analysis": r[8].isoformat() if r[8] else None,
"contact_display_name": r[9],
"last_message_at": r[10].isoformat() if r[10] else None,
}
for r in cur.fetchall()
]
finally:
put_conn(conn)
@app.get("/api/customers/{customer_id}")
def get_customer(customer_id: int):
conn = get_conn()
try:
with conn.cursor() as cur:
cur.execute("""
SELECT cu.id, cu.salesperson_id, s.name AS salesperson_name,
cu.customer_name, cu.industry, cu.intent_level,
cu.key_needs, cu.reason, cu.summary, cu.stage,
cu.key_points, cu.objections, cu.next_action, cu.last_analysis,
c.display_name AS contact_display_name,
c.remark, c.nickname
FROM customer cu
JOIN salesperson s ON s.id = cu.salesperson_id
JOIN contact c ON c.id = cu.contact_id
WHERE cu.id = %s
""", (customer_id,))
r = cur.fetchone()
if not r:
raise HTTPException(status_code=404, detail="客户不存在")
return {
"id": r[0], "salesperson_id": r[1],
"salesperson_name": r[2],
"customer_name": r[3], "industry": r[4],
"intent_level": r[5], "key_needs": parse_pg_array(r[6]),
"reason": r[7], "summary": r[8], "stage": r[9],
"key_points": parse_pg_array(r[10]),
"objections": parse_pg_array(r[11]), "next_action": r[12],
"last_analysis": r[13].isoformat() if r[13] else None,
"contact_display_name": r[14],
"contact_remark": r[15], "contact_nickname": r[16],
}
finally:
put_conn(conn)
@app.get("/api/customers/{customer_id}/messages")
def get_customer_messages(
customer_id: int,
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
):
conn = get_conn()
try:
with conn.cursor() as cur:
# 获取 contact_id
cur.execute("SELECT contact_id FROM customer WHERE id = %s", (customer_id,))
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="客户不存在")
contact_id = row[0]
# 获取会话
cur.execute("SELECT id FROM conversation WHERE contact_id = %s", (contact_id,))
conv_ids = [r[0] for r in cur.fetchall()]
if not conv_ids:
return {"messages": [], "total": 0, "page": page, "page_size": page_size}
# 总数
cur.execute(
"SELECT count(*) FROM message WHERE conversation_id = ANY(%s)",
(conv_ids,),
)
total = cur.fetchone()[0]
# 分页
offset = (page - 1) * page_size
cur.execute("""
SELECT id, sender_display_name, message_type, normalized_content, created_at
FROM message
WHERE conversation_id = ANY(%s)
ORDER BY created_at, id
LIMIT %s OFFSET %s
""", (conv_ids, page_size, offset))
messages = [
{
"id": r[0],
"sender_display_name": r[1],
"message_type": r[2],
"normalized_content": r[3],
"created_at": r[4].isoformat() if r[4] else None,
}
for r in cur.fetchall()
]
return {
"messages": messages,
"total": total,
"page": page,
"page_size": page_size,
}
finally:
put_conn(conn)
@app.get("/api/customers/{customer_id}/deals")
def get_customer_deals(customer_id: int):
conn = get_conn()
try:
with conn.cursor() as cur:
cur.execute("""
SELECT d.id, d.product_name, d.amount, d.deal_date, d.status, d.notes,
d.created_at, s.name AS salesperson_name
FROM deal d
JOIN salesperson s ON s.id = d.salesperson_id
WHERE d.customer_id = %s
ORDER BY d.deal_date DESC
""", (customer_id,))
return [
{
"id": r[0], "product_name": r[1],
"amount": float(r[2]),
"deal_date": r[3].isoformat() if r[3] else None,
"status": r[4], "notes": r[5],
"created_at": r[6].isoformat() if r[6] else None,
"salesperson_name": r[7],
}
for r in cur.fetchall()
]
finally:
put_conn(conn)
@app.post("/api/deals")
def create_deal(deal: DealCreate):
conn = get_conn()
try:
with conn.cursor() as cur:
# 如果没有 contact_id,从 customer 获取
contact_id = deal.contact_id
if contact_id is None and deal.customer_id:
cur.execute("SELECT contact_id FROM customer WHERE id = %s", (deal.customer_id,))
row = cur.fetchone()
if row:
contact_id = row[0]
cur.execute("""
INSERT INTO deal (salesperson_id, customer_id, contact_id, product_name,
amount, deal_date, status, notes)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
""", (
deal.salesperson_id, deal.customer_id, contact_id,
deal.product_name, deal.amount, deal.deal_date,
deal.status, deal.notes,
))
deal_id = cur.fetchone()[0]
conn.commit()
return {"id": deal_id, "message": "成交记录已创建"}
except Exception:
conn.rollback()
raise
finally:
put_conn(conn)
@app.get("/api/deals")
def list_deals(
salesperson_id: int | None = Query(None),
start_date: str | None = Query(None),
end_date: str | None = Query(None),
):
conn = get_conn()
try:
with conn.cursor() as cur:
sql = """
SELECT d.id, d.salesperson_id, s.name AS salesperson_name,
d.customer_id, cu.customer_name,
d.product_name, d.amount, d.deal_date, d.status, d.notes
FROM deal d
JOIN salesperson s ON s.id = d.salesperson_id
LEFT JOIN customer cu ON cu.id = d.customer_id
"""
conditions = []
params = []
if salesperson_id is not None:
conditions.append("d.salesperson_id = %s")
params.append(salesperson_id)
if start_date:
conditions.append("d.deal_date >= %s")
params.append(start_date)
if end_date:
conditions.append("d.deal_date <= %s")
params.append(end_date)
if conditions:
sql += " WHERE " + " AND ".join(conditions)
sql += " ORDER BY d.deal_date DESC, d.id DESC"
cur.execute(sql, params)
return [
{
"id": r[0], "salesperson_id": r[1],
"salesperson_name": r[2],
"customer_id": r[3], "customer_name": r[4],
"product_name": r[5],
"amount": float(r[6]),
"deal_date": r[7].isoformat() if r[7] else None,
"status": r[8], "notes": r[9],
}
for r in cur.fetchall()
]
finally:
put_conn(conn)
@app.post("/api/analyze")
def trigger_analyze(req: AnalyzeRequest):
"""触发 AI 分析(调用 analyze.py 脚本)。"""
analyze_script = ROOT.parent / "ai" / "analyze.py"
cmd = [sys.executable, str(analyze_script), "--mode", req.mode]
if req.force:
cmd.append("--force")
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=120,
)
if result.returncode != 0:
raise HTTPException(status_code=500, detail=result.stderr)
return json.loads(result.stdout)
except subprocess.TimeoutExpired:
raise HTTPException(status_code=504, detail="分析超时")
@app.get("/api/conversations")
def list_conversations(
salesperson_id: int | None = Query(None),
stage: str | None = Query(None),
):
"""全部客户的交流对话分析概览。"""
conn = get_conn()
try:
with conn.cursor() as cur:
sql = """
SELECT cu.id, cu.customer_name, s.name AS salesperson_name,
cu.industry, cu.intent_level, cu.stage,
cu.summary, cu.key_points, cu.objections, cu.next_action,
cu.key_needs, cu.last_analysis,
(SELECT count(*) FROM message m
JOIN conversation conv ON conv.id = m.conversation_id
WHERE conv.contact_id = cu.contact_id) AS msg_count
FROM customer cu
JOIN salesperson s ON s.id = cu.salesperson_id
"""
conditions = []
params = []
if salesperson_id is not None:
conditions.append("cu.salesperson_id = %s")
params.append(salesperson_id)
if stage:
conditions.append("cu.stage = %s")
params.append(stage)
if conditions:
sql += " WHERE " + " AND ".join(conditions)
sql += " ORDER BY cu.salesperson_id, cu.id"
cur.execute(sql, params)
return [
{
"id": r[0], "customer_name": r[1],
"salesperson_name": r[2], "industry": r[3],
"intent_level": r[4], "stage": r[5],
"summary": r[6],
"key_points": parse_pg_array(r[7]),
"objections": parse_pg_array(r[8]),
"next_action": r[9],
"key_needs": parse_pg_array(r[10]),
"last_analysis": r[11].isoformat() if r[11] else None,
"msg_count": r[12],
}
for r in cur.fetchall()
]
finally:
put_conn(conn)
@app.post("/api/conversations/{customer_id}/paradigm")
def generate_paradigm(customer_id: int):
"""调用 LLM 生成标准范式对话。"""
import os as _os
import urllib.request as _urllib
import urllib.error as _urllib_err
api_key = _os.environ.get("DASHSCOPE_API_KEY")
if not api_key:
raise HTTPException(status_code=500, detail="未设置 DASHSCOPE_API_KEY 环境变量")
conn = get_conn()
try:
with conn.cursor() as cur:
# 获取客户信息
cur.execute("""
SELECT cu.customer_name, cu.industry, cu.stage, cu.summary,
cu.objections, cu.key_needs, s.name
FROM customer cu
JOIN salesperson s ON s.id = cu.salesperson_id
WHERE cu.id = %s
""", (customer_id,))
customer = cur.fetchone()
if not customer:
raise HTTPException(status_code=404, detail="客户不存在")
# 获取对话记录
cur.execute("SELECT contact_id FROM customer WHERE id = %s", (customer_id,))
contact_id = cur.fetchone()[0]
cur.execute("SELECT id FROM conversation WHERE contact_id = %s", (contact_id,))
conv_ids = [r[0] for r in cur.fetchall()]
if not conv_ids:
raise HTTPException(status_code=400, detail="该客户无对话记录")
cur.execute("""
SELECT sender_display_name, message_type, normalized_content, created_at
FROM message
WHERE conversation_id = ANY(%s)
ORDER BY created_at, id
LIMIT 100
""", (conv_ids,))
messages = cur.fetchall()
finally:
put_conn(conn)
# 构建对话文本
dialog_lines = []
for m in messages:
sender = m[0] or "未知"
content = m[2] or f"[{m[1]}]"
dialog_lines.append(f"{sender}: {content}")
dialog_text = "\n".join(dialog_lines)
if len(dialog_text) > 6000:
dialog_text = dialog_text[:3000] + "\n...(中间部分省略)...\n" + dialog_text[-3000:]
customer_name, industry, stage, summary, objections, key_needs, sp_name = customer
objections_str = "".join(parse_pg_array(objections)) if objections else ""
key_needs_str = "".join(parse_pg_array(key_needs)) if key_needs else "未明确"
prompt = f"""你是一个微信销售对话分析专家。请基于以下真实销售对话,生成一套标准范式对话模板。
## 产品背景
益童宝儿童益生菌粉:丹麦进口菌株,主打小儿抗过敏(湿疹、鼻炎、食物过敏),298元/盒,3盒套餐798元,6盒套餐1499元。目标客户是宝妈。
## 客户信息
- 客户: {customer_name}
- 行业: {industry}
- 销售人员: {sp_name}
- 当前阶段: {stage}
- 关键需求: {key_needs_str}
- 异议: {objections_str}
- 摘要: {summary or ''}
## 真实对话记录
{dialog_text}
## 输出要求
请基于上述真实对话,提炼并生成一套**标准范式对话模板**,即针对此类客户的最优销售话术流程。输出严格 JSON:
- customer_type: 客户类型描述(如"湿疹宝妈-价格敏感型"
- stages: 按销售阶段排列的对话步骤数组,每个元素包含:
- stage: 阶段名称(建立联系/需求发现/报价/异议处理/成交)
- goal: 该阶段目标
- sales_script: 销售话术(1-3句,具体可执行)
- expected_response: 预期客户回应
- tips: 该阶段技巧提示
- key_techniques: 核心销售技巧总结(数组,每条一句话)
- improvement_points: 真实对话中可改进的点(数组,每条一句话)
只输出 JSON,不要其他文字。"""
body = json.dumps({
"model": "qwen-plus",
"messages": [
{"role": "system", "content": "你是微信销售对话分析专家,擅长从真实对话中提炼标准范式。"},
{"role": "user", "content": prompt},
],
"response_format": {"type": "json_object"},
"temperature": 0.4,
}).encode("utf-8")
req = _urllib.Request(
"https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions",
data=body,
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
method="POST",
)
try:
with _urllib.urlopen(req, timeout=60) as resp:
result = json.loads(resp.read().decode("utf-8"))
content = result["choices"][0]["message"]["content"]
parsed = json.loads(content)
return parsed
except Exception as exc:
raise HTTPException(status_code=502, detail=f"LLM 调用失败: {exc}")
@app.get("/api/sync/status")
def sync_status():
conn = get_conn()
try:
with conn.cursor() as cur:
cur.execute("""
SELECT s.id, s.name,
max(conv.last_synced_at) AS last_synced,
count(DISTINCT m.id) AS message_count
FROM salesperson s
LEFT JOIN conversation conv ON conv.salesperson_id = s.id
LEFT JOIN message m ON m.salesperson_id = s.id
GROUP BY s.id, s.name
ORDER BY s.id
""")
return [
{
"salesperson_id": r[0],
"name": r[1],
"last_synced_at": r[2].isoformat() if r[2] else None,
"message_count": r[3],
}
for r in cur.fetchall()
]
finally:
put_conn(conn)
# ---------------------------------------------------------------------------
# 静态文件托管(前端)
# ---------------------------------------------------------------------------
if STATIC_DIR.exists():
app.mount("/", StaticFiles(directory=str(STATIC_DIR), html=True), name="static")
+693
View File
@@ -0,0 +1,693 @@
<!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('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>
<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 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>
</div>
<div class="bg-white rounded-lg shadow p-4 overflow-x-auto">
<table class="w-full text-sm">
<thead><tr class="border-b text-left text-gray-500">
<th class="py-2">客户</th><th>销售</th><th>行业</th><th>意向</th><th>阶段</th><th>关键需求</th><th>最后沟通</th>
</tr></thead>
<tbody id="customers-tbody"></tbody>
</table>
</div>
</div>
<!-- 客户详情 -->
<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="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" 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>
</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>
<input id="deal-product" type="text" required 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="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: '已取消'};
// --- 路由 ---
function showPanel(name) {
document.querySelectorAll('.panel').forEach(p => p.classList.add('hidden'));
const panel = document.getElementById(name + '-panel');
if (panel) panel.classList.remove('hidden');
if (name === 'dashboard') loadDashboard();
if (name === 'salespersons') loadSalespersons();
if (name === 'customers') loadCustomers();
if (name === 'deals') loadDeals();
if (name === 'analysis') loadAnalysis();
if (name === 'deal-form') loadDealForm();
}
// --- 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('');
}
// --- 销售列表 ---
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;
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 intentColors = {high: 'red', medium: 'yellow', low: 'gray'};
const tbody = document.getElementById('customers-tbody');
tbody.innerHTML = data.map(c => `
<tr class="border-b hover:bg-gray-50 cursor-pointer" onclick="showCustomerDetail(${c.id})">
<td class="py-2 font-medium text-green-700">${c.customer_name || c.contact_display_name}</td>
<td>${c.salesperson_name}</td>
<td>${c.industry || '-'}</td>
<td><span class="px-2 py-0.5 rounded text-xs bg-${intentColors[c.intent_level] || 'gray'}-100 text-${intentColors[c.intent_level] || 'gray'}-700">${intentLabels[c.intent_level] || c.intent_level || '-'}</span></td>
<td>${c.stage || '-'}</td>
<td class="text-xs text-gray-500">${(c.key_needs || []).join('、')}</td>
<td class="text-gray-400 text-xs">${c.last_message_at ? new Date(c.last_message_at).toLocaleDateString('zh-CN') : '-'}</td>
</tr>
`).join('');
}
// --- 客户详情 ---
async function showCustomerDetail(id) {
currentCustomerId = id;
currentMsgPage = 1;
showPanel('customer-detail');
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">
<h3 class="font-semibold">AI 沟通摘要</h3>
<span class="text-xs text-gray-400">分析时间: ${customer.last_analysis ? new Date(customer.last_analysis).toLocaleString('zh-CN') : '-'}</span>
</div>
<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>
`;
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 data = await api('/deals');
const tbody = document.getElementById('deals-tbody');
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);
});
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);
});
}
async function submitDeal(event) {
event.preventDefault();
const body = {
salesperson_id: parseInt(document.getElementById('deal-salesperson').value),
customer_id: parseInt(document.getElementById('deal-customer').value),
product_name: document.getElementById('deal-product').value,
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;
document.getElementById('deal-product').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('');
}
function populateParadigmSelect() {
const sel = document.getElementById('paradigm-customer-select');
if (!sel) return;
const currentVal = sel.value;
sel.innerHTML = '<option value="">请选择客户</option>';
conversationsData.forEach(c => {
const opt = document.createElement('option');
opt.value = c.id;
opt.textContent = `${c.customer_name} (${c.salesperson_name}, ${c.stage})`;
sel.appendChild(opt);
});
if (currentVal) sel.value = currentVal;
}
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 = '<p class="text-sm text-gray-400">正在调用千问 LLM 生成标准范式对话,请稍候...</p>';
try {
const data = await api(`/conversations/${customerId}/paradigm`, { method: 'POST' });
renderParadigm(data);
} catch (e) {
result.innerHTML = `<p class="text-sm text-red-500">生成失败: ${e.message}</p>`;
} finally {
btn.disabled = false;
btn.textContent = '生成范式对话';
}
}
function renderParadigm(data) {
const result = document.getElementById('paradigm-result');
const stageColors = {
'成交': 'border-green-400 bg-green-50',
'异议处理': 'border-orange-400 bg-orange-50',
'报价': 'border-blue-400 bg-blue-50',
'需求发现': 'border-purple-400 bg-purple-50',
'建立联系': 'border-gray-300 bg-gray-50',
};
result.innerHTML = `
<div class="bg-white rounded-lg shadow p-6">
<h3 class="text-lg font-bold mb-1">标准范式对话模板</h3>
<p class="text-sm text-gray-500 mb-4">客户类型: ${data.customer_type || '-'}</p>
<div class="space-y-4 mb-6">
${(data.stages || []).map((s, i) => `
<div class="border-l-4 ${stageColors[s.stage] || stageColors['建立联系']} pl-4 py-3">
<div class="flex items-center gap-2 mb-2">
<span class="bg-gray-700 text-white rounded-full w-6 h-6 flex items-center justify-center text-xs font-bold">${i + 1}</span>
<span class="font-semibold text-sm">${s.stage}</span>
</div>
<p class="text-xs text-gray-500 mb-2">目标: ${s.goal || '-'}</p>
<div class="mb-2">
<span class="text-xs font-medium text-green-700">销售话术:</span>
<p class="text-sm text-gray-700 mt-1 bg-green-50 rounded p-2">${escapeHtml(s.sales_script || '-')}</p>
</div>
<div class="mb-2">
<span class="text-xs font-medium text-gray-600">预期回应:</span>
<p class="text-sm text-gray-600 mt-1">${escapeHtml(s.expected_response || '-')}</p>
</div>
<div>
<span class="text-xs font-medium text-blue-600">技巧提示:</span>
<p class="text-sm text-gray-600 mt-1">${escapeHtml(s.tips || '-')}</p>
</div>
</div>
`).join('')}
</div>
${(data.key_techniques || []).length > 0 ? `
<div class="mb-4">
<h4 class="text-sm font-semibold mb-2 text-green-700">核心销售技巧</h4>
<ul class="space-y-1">
${(data.key_techniques || []).map(t => `<li class="text-sm text-gray-700 flex gap-2"><span class="text-green-500">✓</span><span>${escapeHtml(t)}</span></li>`).join('')}
</ul>
</div>
` : ''}
${(data.improvement_points || []).length > 0 ? `
<div class="mb-4">
<h4 class="text-sm font-semibold mb-2 text-orange-700">可改进的点</h4>
<ul class="space-y-1">
${(data.improvement_points || []).map(t => `<li class="text-sm text-gray-700 flex gap-2"><span class="text-orange-500">!</span><span>${escapeHtml(t)}</span></li>`).join('')}
</ul>
</div>
` : ''}
</div>
`;
}
// --- 工具 ---
function escapeHtml(s) {
const div = document.createElement('div');
div.textContent = s || '';
return div.innerHTML;
}
// --- 初始化 ---
showPanel('dashboard');
</script>
</body>
</html>
File diff suppressed because one or more lines are too long