微信营销管理系统 - 益童宝销售管理平台
- 数据库: 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:
@@ -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")
|
||||
@@ -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">← 返回客户列表</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>
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user