feat: 新增产品管理模块 + AI基于产品详情生成范式对话 + 一键远程部署脚本
- schema.sql: 新增 product 表(positioning/selling_points) + deal 表加 product_id + 序列重置 + ALTER TABLE 迁移 - server.py: 产品 CRUD API + deal 关联 product_id + 仪表盘产品线统计 + 范式对话动态读取产品详情 - index.html: 产品管理页面 + 录入成交产品下拉 + 仪表盘增强 + 范式对话产品选择 + 导航通用化 - run_demo.sh: python 改 python3 + 预置成交关联 product_id - deploy.sh: 一键远程部署脚本(rsync+ssh+重启) - deploy_remote.sh: 远程数据库迁移脚本
This commit is contained in:
@@ -80,12 +80,31 @@ CREATE TABLE IF NOT EXISTS customer (
|
||||
CREATE INDEX IF NOT EXISTS idx_customer_salesperson ON customer(salesperson_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_customer_intent ON customer(intent_level);
|
||||
|
||||
-- 产品
|
||||
CREATE TABLE IF NOT EXISTS product (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
spec TEXT,
|
||||
price NUMERIC(12,2),
|
||||
description TEXT,
|
||||
positioning TEXT,
|
||||
selling_points TEXT[],
|
||||
target_audience TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_product_category ON product(category);
|
||||
CREATE INDEX IF NOT EXISTS idx_product_status ON product(status);
|
||||
|
||||
-- 成交记录
|
||||
CREATE TABLE IF NOT EXISTS deal (
|
||||
id SERIAL PRIMARY KEY,
|
||||
salesperson_id INT NOT NULL REFERENCES salesperson(id) ON DELETE CASCADE,
|
||||
customer_id INT REFERENCES customer(id) ON DELETE SET NULL,
|
||||
contact_id INT REFERENCES contact(id) ON DELETE SET NULL,
|
||||
product_id INT REFERENCES product(id) ON DELETE SET NULL,
|
||||
product_name TEXT NOT NULL,
|
||||
amount NUMERIC(12,2) NOT NULL,
|
||||
deal_date DATE NOT NULL,
|
||||
@@ -97,6 +116,53 @@ CREATE TABLE IF NOT EXISTS deal (
|
||||
CREATE INDEX IF NOT EXISTS idx_deal_salesperson ON deal(salesperson_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_deal_customer ON deal(customer_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_deal_date ON deal(deal_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_deal_product ON deal(product_id);
|
||||
|
||||
-- 迁移:为已存在的 deal 表补充 product_id 列(首次执行时若列已存在则跳过)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'deal' AND column_name = 'product_id') THEN
|
||||
ALTER TABLE deal ADD COLUMN product_id INT REFERENCES product(id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'product' AND column_name = 'positioning') THEN
|
||||
ALTER TABLE product ADD COLUMN positioning TEXT;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'product' AND column_name = 'selling_points') THEN
|
||||
ALTER TABLE product ADD COLUMN selling_points TEXT[];
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- 预置产品数据(幂等)
|
||||
INSERT INTO product (id, name, category, spec, price, description, positioning, selling_points, target_audience) VALUES
|
||||
(1, '益童宝益生菌', '儿童益生菌', '3盒套餐', 798.00,
|
||||
'丹麦进口益生菌粉,专为3-12岁儿童设计。采用科汉森DDS-1菌株,每袋含100亿活菌。主打调节肠道菌群、增强免疫力,对小儿湿疹、过敏性鼻炎、食物过敏等有显著改善效果。3盒为一个完整调理周期,建议持续服用3个月。',
|
||||
'进口菌株儿童抗过敏专家,宝妈信赖之选',
|
||||
ARRAY['丹麦进口科汉森DDS-1菌株,品质有保障', '每袋100亿活菌,活性稳定易吸收', '临床验证对湿疹、鼻炎、食物过敏改善率超85%', '3盒完整周期,从根源调节免疫', '无添加无副作用,儿童长期服用安全'],
|
||||
'3-12岁儿童,尤其适合过敏体质儿童'),
|
||||
(2, '益童宝益生菌', '儿童益生菌', '单盒', 298.00,
|
||||
'丹麦进口益生菌粉,专为3-12岁儿童设计。采用科汉森DDS-1菌株,每袋含100亿活菌。主打调节肠道菌群、增强免疫力,对小儿湿疹、过敏性鼻炎、食物过敏等有显著改善效果。单盒15袋,半月用量,适合初次体验。',
|
||||
'进口菌株儿童抗过敏专家,宝妈信赖之选',
|
||||
ARRAY['丹麦进口科汉森DDS-1菌株,品质有保障', '每袋100亿活菌,活性稳定易吸收', '临床验证对湿疹、鼻炎、食物过敏改善率超85%', '单盒体验装,低门槛试用'],
|
||||
'3-12岁儿童,尤其适合过敏体质儿童'),
|
||||
(3, '益童宝益生菌', '儿童益生菌', '6盒套餐', 1499.00,
|
||||
'丹麦进口益生菌粉,专为3-12岁儿童设计。采用科汉森DDS-1菌株,每袋含100亿活菌。6盒为两个完整调理周期,适合顽固性过敏体质儿童长期调理,性价比最高。',
|
||||
'进口菌株儿童抗过敏专家,宝妈信赖之选',
|
||||
ARRAY['丹麦进口科汉森DDS-1菌株,品质有保障', '每袋100亿活菌,活性稳定易吸收', '临床验证对湿疹、鼻炎、食物过敏改善率超85%', '6盒双周期,适合顽固性过敏长期调理', '均价最低,性价比之王'],
|
||||
'3-12岁儿童,尤其适合顽固性过敏体质'),
|
||||
(4, '纤益益生菌', '女性减肥益生菌', '2盒周期装', 596.00,
|
||||
'专为18-35岁年轻女性设计的体重管理益生菌。含专利菌株B420,经临床验证可减少体脂率、腰围。配合膳食纤维和绿茶提取物,促进代谢、抑制脂肪吸收。2盒为一个周期(60天),建议配合轻度运动效果更佳。',
|
||||
'科学减脂不反弹,年轻女性的身材管理专家',
|
||||
ARRAY['专利菌株B420,临床验证减脂效果', '抑制脂肪吸收,促进肠道代谢', '配合膳食纤维+绿茶提取物,三效合一', '不节食不腹泻,健康减脂', '2盒完整周期,见效明显'],
|
||||
'18-35岁有体重管理需求的年轻女性'),
|
||||
(5, '纤益益生菌', '女性减肥益生菌', '单盒', 328.00,
|
||||
'专为18-35岁年轻女性设计的体重管理益生菌。含专利菌株B420,经临床验证可减少体脂率、腰围。单盒30天用量,适合初次体验。',
|
||||
'科学减脂不反弹,年轻女性的身材管理专家',
|
||||
ARRAY['专利菌株B420,临床验证减脂效果', '抑制脂肪吸收,促进肠道代谢', '单盒体验装,低门槛试用', '不节食不腹泻,健康减脂'],
|
||||
'18-35岁有体重管理需求的年轻女性')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- 重置 product 序列,避免显式 ID 插入后 SERIAL 不同步
|
||||
SELECT setval('product_id_seq', COALESCE((SELECT max(id) FROM product), 1), true);
|
||||
|
||||
-- 预置 3 名销售人员(幂等)
|
||||
INSERT INTO salesperson (id, name, team, wx_account, device_id) VALUES
|
||||
@@ -104,3 +170,6 @@ INSERT INTO salesperson (id, name, team, wx_account, device_id) VALUES
|
||||
(2, '李娜', '华东团队', 'wxid_lina', 'macbook-ln-002'),
|
||||
(3, '王强', '华南团队', 'wxid_wangqiang', 'macbook-wq-003')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- 重置 salesperson 序列
|
||||
SELECT setval('salesperson_id_seq', COALESCE((SELECT max(id) FROM salesperson), 1), true);
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
#!/bin/bash
|
||||
set -eu
|
||||
|
||||
# ============================================
|
||||
# 远程服务器端部署脚本(由 deploy.sh 调用)
|
||||
# 在远程服务器上执行数据库迁移和数据预置
|
||||
# ============================================
|
||||
|
||||
DEMO_DIR="/opt/wxsales/demo"
|
||||
|
||||
# 查找 psql 路径
|
||||
PSQL=""
|
||||
for p in /usr/bin/psql /usr/local/bin/psql /usr/pgsql-17/bin/psql /usr/local/pg17/bin/psql; do
|
||||
if [ -x "$p" ]; then PSQL="$p"; break; fi
|
||||
done
|
||||
if [ -z "$PSQL" ]; then
|
||||
echo "[ERROR] 未找到 psql"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 查找 PG socket 目录
|
||||
SOCKET_DIR=""
|
||||
for d in /var/lib/pgsql/data /opt/wxsales/demo/db/socket /tmp; do
|
||||
if [ -S "$d/.s.PGSQL.5434" ]; then SOCKET_DIR="$d"; break; fi
|
||||
done
|
||||
|
||||
# 构建连接参数
|
||||
if [ -n "$SOCKET_DIR" ]; then
|
||||
PSQL_CMD="$PSQL -h $SOCKET_DIR -p 5434 -d wxchat_sales"
|
||||
else
|
||||
# 尝试默认连接
|
||||
PSQL_CMD="$PSQL -p 5434 -d wxchat_sales"
|
||||
fi
|
||||
|
||||
echo "[INFO] 使用 psql: $PSQL"
|
||||
echo "[INFO] 执行 schema.sql..."
|
||||
|
||||
# 执行 schema.sql(幂等,包含迁移语句)
|
||||
$PSQL_CMD -f "$DEMO_DIR/db/schema.sql" 2>&1 || {
|
||||
echo "[WARN] schema.sql 执行有警告,尝试继续..."
|
||||
}
|
||||
|
||||
# 重置序列
|
||||
$PSQL_CMD -c "SELECT setval('product_id_seq', COALESCE((SELECT max(id) FROM product), 1), true);" 2>/dev/null || true
|
||||
$PSQL_CMD -c "SELECT setval('salesperson_id_seq', COALESCE((SELECT max(id) FROM salesperson), 1), true);" 2>/dev/null || true
|
||||
|
||||
# 验证
|
||||
PRODUCT_COUNT=$($PSQL_CMD -t -c "SELECT count(*) FROM product;" 2>/dev/null | xargs || echo "0")
|
||||
echo "[OK] 产品数量: $PRODUCT_COUNT"
|
||||
echo "[OK] 数据库迁移完成"
|
||||
+24
-22
@@ -30,7 +30,7 @@ bash "$ROOT/db/init_db.sh"
|
||||
echo ""
|
||||
echo "[2/5] 运行模拟采集代理..."
|
||||
for sp_id in 1 2 3; do
|
||||
python "$ROOT/agent/mock_sync.py" --salesperson-id $sp_id
|
||||
python3 "$ROOT/agent/mock_sync.py" --salesperson-id $sp_id
|
||||
done
|
||||
|
||||
# 4. AI 分析
|
||||
@@ -38,10 +38,10 @@ echo ""
|
||||
echo "[3/5] AI 分析..."
|
||||
if [ -n "$DASHSCOPE_API_KEY" ]; then
|
||||
echo " 使用千问 LLM 模式"
|
||||
python "$ROOT/ai/analyze.py" --mode llm
|
||||
python3 "$ROOT/ai/analyze.py" --mode llm
|
||||
else
|
||||
echo " 未设置 DASHSCOPE_API_KEY,使用规则模式"
|
||||
python "$ROOT/ai/analyze.py" --mode rule
|
||||
python3 "$ROOT/ai/analyze.py" --mode rule
|
||||
fi
|
||||
|
||||
# 5. 预置成交数据
|
||||
@@ -56,43 +56,45 @@ WHERE d1.id > d2.id
|
||||
AND d1.customer_id = d2.customer_id
|
||||
AND d1.product_name = d2.product_name
|
||||
AND d1.deal_date = d2.deal_date;
|
||||
-- 清理旧格式产品名数据(不带产品线前缀的旧名称)
|
||||
DELETE FROM deal WHERE product_name IN ('益童宝3盒套餐', '益童宝6盒套餐', '益童宝单盒');
|
||||
|
||||
-- 预置成交(用 NOT EXISTS 防重复)
|
||||
INSERT INTO deal (salesperson_id, customer_id, product_name, amount, deal_date, status)
|
||||
SELECT s.id, cu.id, '益童宝3盒套餐', 798, '2026-07-05', 'closed'
|
||||
INSERT INTO deal (salesperson_id, customer_id, product_id, product_name, amount, deal_date, status)
|
||||
SELECT s.id, cu.id, 1, '益童宝益生菌 3盒套餐', 798, '2026-07-05', 'closed'
|
||||
FROM salesperson s, customer cu
|
||||
WHERE s.id = 1 AND cu.salesperson_id = 1 AND cu.customer_name LIKE '辰辰%'
|
||||
AND NOT EXISTS (SELECT 1 FROM deal WHERE customer_id = cu.id AND product_name = '益童宝3盒套餐' AND deal_date = '2026-07-05');
|
||||
AND NOT EXISTS (SELECT 1 FROM deal WHERE customer_id = cu.id AND product_name = '益童宝益生菌 3盒套餐' AND deal_date = '2026-07-05');
|
||||
|
||||
INSERT INTO deal (salesperson_id, customer_id, product_name, amount, deal_date, status)
|
||||
SELECT s.id, cu.id, '益童宝3盒套餐', 798, '2026-07-08', 'closed'
|
||||
INSERT INTO deal (salesperson_id, customer_id, product_id, product_name, amount, deal_date, status)
|
||||
SELECT s.id, cu.id, 1, '益童宝益生菌 3盒套餐', 798, '2026-07-08', 'closed'
|
||||
FROM salesperson s, customer cu
|
||||
WHERE s.id = 1 AND cu.salesperson_id = 1 AND cu.customer_name LIKE '依依%'
|
||||
AND NOT EXISTS (SELECT 1 FROM deal WHERE customer_id = cu.id AND product_name = '益童宝3盒套餐' AND deal_date = '2026-07-08');
|
||||
AND NOT EXISTS (SELECT 1 FROM deal WHERE customer_id = cu.id AND product_name = '益童宝益生菌 3盒套餐' AND deal_date = '2026-07-08');
|
||||
|
||||
INSERT INTO deal (salesperson_id, customer_id, product_name, amount, deal_date, status)
|
||||
SELECT s.id, cu.id, '益童宝6盒套餐', 1499, '2026-07-12', 'closed'
|
||||
INSERT INTO deal (salesperson_id, customer_id, product_id, product_name, amount, deal_date, status)
|
||||
SELECT s.id, cu.id, 3, '益童宝益生菌 6盒套餐', 1499, '2026-07-12', 'closed'
|
||||
FROM salesperson s, customer cu
|
||||
WHERE s.id = 1 AND cu.salesperson_id = 1 AND cu.customer_name LIKE '糖糖%'
|
||||
AND NOT EXISTS (SELECT 1 FROM deal WHERE customer_id = cu.id AND product_name = '益童宝6盒套餐' AND deal_date = '2026-07-12');
|
||||
AND NOT EXISTS (SELECT 1 FROM deal WHERE customer_id = cu.id AND product_name = '益童宝益生菌 6盒套餐' AND deal_date = '2026-07-12');
|
||||
|
||||
INSERT INTO deal (salesperson_id, customer_id, product_name, amount, deal_date, status)
|
||||
SELECT s.id, cu.id, '益童宝单盒', 298, '2026-07-10', 'closed'
|
||||
INSERT INTO deal (salesperson_id, customer_id, product_id, product_name, amount, deal_date, status)
|
||||
SELECT s.id, cu.id, 2, '益童宝益生菌 单盒', 298, '2026-07-10', 'closed'
|
||||
FROM salesperson s, customer cu
|
||||
WHERE s.id = 2 AND cu.salesperson_id = 2 AND cu.customer_name LIKE '甜甜%'
|
||||
AND NOT EXISTS (SELECT 1 FROM deal WHERE customer_id = cu.id AND product_name = '益童宝单盒' AND deal_date = '2026-07-10');
|
||||
AND NOT EXISTS (SELECT 1 FROM deal WHERE customer_id = cu.id AND product_name = '益童宝益生菌 单盒' AND deal_date = '2026-07-10');
|
||||
|
||||
INSERT INTO deal (salesperson_id, customer_id, product_name, amount, deal_date, status)
|
||||
SELECT s.id, cu.id, '益童宝3盒套餐', 798, '2026-07-06', 'closed'
|
||||
INSERT INTO deal (salesperson_id, customer_id, product_id, product_name, amount, deal_date, status)
|
||||
SELECT s.id, cu.id, 1, '益童宝益生菌 3盒套餐', 798, '2026-07-06', 'closed'
|
||||
FROM salesperson s, customer cu
|
||||
WHERE s.id = 3 AND cu.salesperson_id = 3 AND cu.customer_name LIKE '阳阳%'
|
||||
AND NOT EXISTS (SELECT 1 FROM deal WHERE customer_id = cu.id AND product_name = '益童宝3盒套餐' AND deal_date = '2026-07-06');
|
||||
AND NOT EXISTS (SELECT 1 FROM deal WHERE customer_id = cu.id AND product_name = '益童宝益生菌 3盒套餐' AND deal_date = '2026-07-06');
|
||||
|
||||
INSERT INTO deal (salesperson_id, customer_id, product_name, amount, deal_date, status)
|
||||
SELECT s.id, cu.id, '益童宝3盒套餐', 798, '2026-06-20', 'closed'
|
||||
INSERT INTO deal (salesperson_id, customer_id, product_id, product_name, amount, deal_date, status)
|
||||
SELECT s.id, cu.id, 1, '益童宝益生菌 3盒套餐', 798, '2026-06-20', 'closed'
|
||||
FROM salesperson s, customer cu
|
||||
WHERE s.id = 3 AND cu.salesperson_id = 3 AND cu.customer_name LIKE '糖糖%'
|
||||
AND NOT EXISTS (SELECT 1 FROM deal WHERE customer_id = cu.id AND product_name = '益童宝3盒套餐' AND deal_date = '2026-06-20');
|
||||
AND NOT EXISTS (SELECT 1 FROM deal WHERE customer_id = cu.id AND product_name = '益童宝益生菌 3盒套餐' AND deal_date = '2026-06-20');
|
||||
" 2>/dev/null || true
|
||||
|
||||
DEAL_COUNT=$($PSQL -t -c "SELECT count(*) FROM deal")
|
||||
@@ -108,7 +110,7 @@ if lsof -ti:8770 >/dev/null 2>&1; then
|
||||
sleep 1
|
||||
fi
|
||||
|
||||
nohup python -m uvicorn web.server:app --port 8770 --host 127.0.0.1 \
|
||||
nohup python3 -m uvicorn web.server:app --port 8770 --host 127.0.0.1 \
|
||||
> "$ROOT/web.log" 2>&1 &
|
||||
echo $! > "$PID_FILE"
|
||||
sleep 2
|
||||
|
||||
+277
-14
@@ -75,6 +75,7 @@ class DealCreate(BaseModel):
|
||||
salesperson_id: int
|
||||
customer_id: int | None = None
|
||||
contact_id: int | None = None
|
||||
product_id: int | None = None
|
||||
product_name: str
|
||||
amount: float
|
||||
deal_date: str
|
||||
@@ -82,6 +83,32 @@ class DealCreate(BaseModel):
|
||||
status: str = "closed"
|
||||
|
||||
|
||||
class ProductCreate(BaseModel):
|
||||
"""新增产品请求模型。"""
|
||||
name: str
|
||||
category: str
|
||||
spec: str | None = None
|
||||
price: float | None = None
|
||||
description: str | None = None
|
||||
positioning: str | None = None
|
||||
selling_points: list[str] | None = None
|
||||
target_audience: str | None = None
|
||||
status: str = "active"
|
||||
|
||||
|
||||
class ProductUpdate(BaseModel):
|
||||
"""编辑产品请求模型。"""
|
||||
name: str | None = None
|
||||
category: str | None = None
|
||||
spec: str | None = None
|
||||
price: float | None = None
|
||||
description: str | None = None
|
||||
positioning: str | None = None
|
||||
selling_points: list[str] | None = None
|
||||
target_audience: str | None = None
|
||||
status: str | None = None
|
||||
|
||||
|
||||
class AnalyzeRequest(BaseModel):
|
||||
mode: str = "rule"
|
||||
force: bool = False
|
||||
@@ -134,11 +161,33 @@ def dashboard():
|
||||
for r in cur.fetchall()
|
||||
]
|
||||
|
||||
# 按产品线统计本月销售额
|
||||
cur.execute("""
|
||||
SELECT p.category,
|
||||
COALESCE(sum(d.amount), 0) AS amount,
|
||||
count(*) AS deal_count
|
||||
FROM deal d
|
||||
LEFT JOIN product p ON p.id = d.product_id
|
||||
WHERE d.status = 'closed'
|
||||
AND d.deal_date >= date_trunc('month', now())
|
||||
GROUP BY p.category
|
||||
ORDER BY amount DESC
|
||||
""")
|
||||
product_stats = [
|
||||
{
|
||||
"category": r[0] or "未分类",
|
||||
"amount": float(r[1]),
|
||||
"deal_count": r[2],
|
||||
}
|
||||
for r in cur.fetchall()
|
||||
]
|
||||
|
||||
return {
|
||||
"total_messages": total_messages,
|
||||
"active_customers": active_customers,
|
||||
"monthly_deal_amount": monthly_deal_amount,
|
||||
"salespersons": salespersons,
|
||||
"product_stats": product_stats,
|
||||
}
|
||||
finally:
|
||||
put_conn(conn)
|
||||
@@ -371,14 +420,23 @@ def create_deal(deal: DealCreate):
|
||||
if row:
|
||||
contact_id = row[0]
|
||||
|
||||
# 如果有 product_id 但没有 product_name,从 product 表获取
|
||||
product_name = deal.product_name
|
||||
if deal.product_id and not product_name:
|
||||
cur.execute("SELECT name, spec FROM product WHERE id = %s", (deal.product_id,))
|
||||
prow = cur.fetchone()
|
||||
if prow:
|
||||
product_name = f"{prow[0]} {prow[1]}" if prow[1] else prow[0]
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO deal (salesperson_id, customer_id, contact_id, product_name,
|
||||
INSERT INTO deal (salesperson_id, customer_id, contact_id, product_id, product_name,
|
||||
amount, deal_date, status, notes)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||
VALUES (%s, %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.product_id, product_name,
|
||||
deal.amount, deal.deal_date,
|
||||
deal.status, deal.notes,
|
||||
))
|
||||
deal_id = cur.fetchone()[0]
|
||||
@@ -394,6 +452,7 @@ def create_deal(deal: DealCreate):
|
||||
@app.get("/api/deals")
|
||||
def list_deals(
|
||||
salesperson_id: int | None = Query(None),
|
||||
product_id: int | None = Query(None),
|
||||
start_date: str | None = Query(None),
|
||||
end_date: str | None = Query(None),
|
||||
):
|
||||
@@ -403,7 +462,7 @@ def list_deals(
|
||||
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
|
||||
d.product_id, 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
|
||||
@@ -413,6 +472,9 @@ def list_deals(
|
||||
if salesperson_id is not None:
|
||||
conditions.append("d.salesperson_id = %s")
|
||||
params.append(salesperson_id)
|
||||
if product_id is not None:
|
||||
conditions.append("d.product_id = %s")
|
||||
params.append(product_id)
|
||||
if start_date:
|
||||
conditions.append("d.deal_date >= %s")
|
||||
params.append(start_date)
|
||||
@@ -428,10 +490,11 @@ def list_deals(
|
||||
"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],
|
||||
"product_id": r[5],
|
||||
"product_name": r[6],
|
||||
"amount": float(r[7]),
|
||||
"deal_date": r[8].isoformat() if r[8] else None,
|
||||
"status": r[9], "notes": r[10],
|
||||
}
|
||||
for r in cur.fetchall()
|
||||
]
|
||||
@@ -439,6 +502,155 @@ def list_deals(
|
||||
put_conn(conn)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 产品管理 API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/api/products")
|
||||
def list_products(
|
||||
category: str | None = Query(None),
|
||||
status: str | None = Query(None),
|
||||
):
|
||||
"""获取产品列表,支持按产品线和状态筛选。"""
|
||||
conn = get_conn()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
sql = """
|
||||
SELECT p.id, p.name, p.category, p.spec, p.price,
|
||||
p.description, p.positioning, p.selling_points,
|
||||
p.target_audience, p.status, p.created_at,
|
||||
COALESCE(ds.deal_count, 0) AS deal_count,
|
||||
COALESCE(ds.total_amount, 0) AS total_amount
|
||||
FROM product p
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT count(*) AS deal_count, COALESCE(sum(d.amount), 0) AS total_amount
|
||||
FROM deal d WHERE d.product_id = p.id AND d.status = 'closed'
|
||||
) ds ON true
|
||||
"""
|
||||
conditions = []
|
||||
params = []
|
||||
if category:
|
||||
conditions.append("p.category = %s")
|
||||
params.append(category)
|
||||
if status:
|
||||
conditions.append("p.status = %s")
|
||||
params.append(status)
|
||||
if conditions:
|
||||
sql += " WHERE " + " AND ".join(conditions)
|
||||
sql += " ORDER BY p.category, p.id"
|
||||
cur.execute(sql, params)
|
||||
return [
|
||||
{
|
||||
"id": r[0], "name": r[1], "category": r[2],
|
||||
"spec": r[3], "price": float(r[4]) if r[4] else None,
|
||||
"description": r[5], "positioning": r[6],
|
||||
"selling_points": parse_pg_array(r[7]),
|
||||
"target_audience": r[8],
|
||||
"status": r[9],
|
||||
"created_at": r[10].isoformat() if r[10] else None,
|
||||
"deal_count": r[11],
|
||||
"total_amount": float(r[12]),
|
||||
}
|
||||
for r in cur.fetchall()
|
||||
]
|
||||
finally:
|
||||
put_conn(conn)
|
||||
|
||||
|
||||
@app.post("/api/products")
|
||||
def create_product(product: ProductCreate):
|
||||
"""新增产品。"""
|
||||
conn = get_conn()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
INSERT INTO product (name, category, spec, price, description, positioning, selling_points, target_audience, status)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""", (
|
||||
product.name, product.category, product.spec,
|
||||
product.price, product.description, product.positioning,
|
||||
product.selling_points, product.target_audience,
|
||||
product.status,
|
||||
))
|
||||
product_id = cur.fetchone()[0]
|
||||
conn.commit()
|
||||
return {"id": product_id, "message": "产品已创建"}
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
put_conn(conn)
|
||||
|
||||
|
||||
@app.put("/api/products/{product_id}")
|
||||
def update_product(product_id: int, product: ProductUpdate):
|
||||
"""编辑产品信息。"""
|
||||
conn = get_conn()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
fields = []
|
||||
params = []
|
||||
for field in ["name", "category", "spec", "price", "description", "positioning", "selling_points", "target_audience", "status"]:
|
||||
val = getattr(product, field)
|
||||
if val is not None:
|
||||
fields.append(f"{field} = %s")
|
||||
params.append(val)
|
||||
if not fields:
|
||||
raise HTTPException(status_code=400, detail="没有需要更新的字段")
|
||||
params.append(product_id)
|
||||
cur.execute(
|
||||
f"UPDATE product SET {', '.join(fields)} WHERE id = %s RETURNING id",
|
||||
params,
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="产品不存在")
|
||||
conn.commit()
|
||||
return {"id": product_id, "message": "产品已更新"}
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
put_conn(conn)
|
||||
|
||||
|
||||
@app.delete("/api/products/{product_id}")
|
||||
def delete_product(product_id: int):
|
||||
"""删除产品(软删除,设为 inactive)。"""
|
||||
conn = get_conn()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE product SET status = 'inactive' WHERE id = %s RETURNING id",
|
||||
(product_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="产品不存在")
|
||||
conn.commit()
|
||||
return {"id": product_id, "message": "产品已下架"}
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
put_conn(conn)
|
||||
|
||||
|
||||
@app.get("/api/products/categories")
|
||||
def list_product_categories():
|
||||
"""获取所有产品线(去重)。"""
|
||||
conn = get_conn()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT DISTINCT category FROM product WHERE status = 'active' ORDER BY category"
|
||||
)
|
||||
return [r[0] for r in cur.fetchall()]
|
||||
finally:
|
||||
put_conn(conn)
|
||||
|
||||
|
||||
@app.post("/api/analyze")
|
||||
def trigger_analyze(req: AnalyzeRequest):
|
||||
"""触发 AI 分析(调用 analyze.py 脚本)。"""
|
||||
@@ -510,8 +722,11 @@ def list_conversations(
|
||||
|
||||
|
||||
@app.post("/api/conversations/{customer_id}/paradigm")
|
||||
def generate_paradigm(customer_id: int):
|
||||
"""调用 LLM 生成标准范式对话。"""
|
||||
def generate_paradigm(
|
||||
customer_id: int,
|
||||
product_id: int | None = Query(None),
|
||||
):
|
||||
"""调用 LLM 生成标准范式对话,基于产品详情动态构建 prompt。"""
|
||||
import os as _os
|
||||
import urllib.request as _urllib
|
||||
import urllib.error as _urllib_err
|
||||
@@ -535,6 +750,53 @@ def generate_paradigm(customer_id: int):
|
||||
if not customer:
|
||||
raise HTTPException(status_code=404, detail="客户不存在")
|
||||
|
||||
# 如果未指定 product_id,尝试从客户成交记录中推断
|
||||
if product_id is None:
|
||||
cur.execute("""
|
||||
SELECT d.product_id FROM deal d
|
||||
WHERE d.customer_id = %s AND d.product_id IS NOT NULL
|
||||
ORDER BY d.deal_date DESC LIMIT 1
|
||||
""", (customer_id,))
|
||||
deal_row = cur.fetchone()
|
||||
if deal_row:
|
||||
product_id = deal_row[0]
|
||||
|
||||
# 从产品表获取产品详情;如果仍无 product_id,取所有 active 产品汇总
|
||||
product_info_text = ""
|
||||
if product_id is not None:
|
||||
cur.execute("""
|
||||
SELECT name, spec, price, description, positioning,
|
||||
selling_points, target_audience, category
|
||||
FROM product WHERE id = %s
|
||||
""", (product_id,))
|
||||
prow = cur.fetchone()
|
||||
if prow:
|
||||
sp_list = parse_pg_array(prow[4])
|
||||
product_info_text = (
|
||||
f"### 主推产品:{prow[0]}({prow[1]})\n"
|
||||
f"- 产品线:{prow[7]}\n"
|
||||
f"- 价格:¥{float(prow[2]):.2f}\n"
|
||||
f"- 定位:{prow[4] or '未设定'}\n"
|
||||
f"- 目标人群:{prow[6] or '未设定'}\n"
|
||||
f"- 产品描述:{prow[3] or '未设定'}\n"
|
||||
f"- 核心卖点:\n" + "\n".join(f" {i+1}. {sp}" for i, sp in enumerate(sp_list))
|
||||
)
|
||||
else:
|
||||
cur.execute("""
|
||||
SELECT name, spec, price, positioning, target_audience, category
|
||||
FROM product WHERE status = 'active' ORDER BY category, id
|
||||
""")
|
||||
products = cur.fetchall()
|
||||
if products:
|
||||
lines = []
|
||||
for p in products:
|
||||
lines.append(
|
||||
f"- {p[0]}({p[1]})¥{float(p[2]):.2f} | {p[5]} | 定位:{p[3] or '未设定'} | 目标:{p[4] or '未设定'}"
|
||||
)
|
||||
product_info_text = "### 全部在售产品\n" + "\n".join(lines)
|
||||
else:
|
||||
product_info_text = "暂无产品数据"
|
||||
|
||||
# 获取对话记录
|
||||
cur.execute("SELECT contact_id FROM customer WHERE id = %s", (customer_id,))
|
||||
contact_id = cur.fetchone()[0]
|
||||
@@ -570,10 +832,10 @@ def generate_paradigm(customer_id: int):
|
||||
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"""你是一个微信销售对话分析专家。请基于以下真实销售对话,生成一套标准范式对话模板。
|
||||
prompt = f"""你是一个微信销售对话分析专家。请基于以下产品详情和真实销售对话,生成一套标准范式对话模板。
|
||||
|
||||
## 产品背景
|
||||
益童宝儿童益生菌粉:丹麦进口菌株,主打小儿抗过敏(湿疹、鼻炎、食物过敏),298元/盒,3盒套餐798元,6盒套餐1499元。目标客户是宝妈。
|
||||
{product_info_text}
|
||||
|
||||
## 客户信息
|
||||
- 客户: {customer_name}
|
||||
@@ -588,12 +850,13 @@ def generate_paradigm(customer_id: int):
|
||||
{dialog_text}
|
||||
|
||||
## 输出要求
|
||||
请基于上述真实对话,提炼并生成一套**标准范式对话模板**,即针对此类客户的最优销售话术流程。输出严格 JSON:
|
||||
请基于上述产品详情(定位、卖点、目标人群)和真实对话,提炼并生成一套**标准范式对话模板**,即针对此类客户的最优销售话术流程。话术要自然融入产品的核心卖点和定位,不要生硬推销。输出严格 JSON:
|
||||
- customer_type: 客户类型描述(如"湿疹宝妈-价格敏感型")
|
||||
- product_positioning: 对该客户的产品推荐理由(结合产品定位和客户需求,1-2句话)
|
||||
- stages: 按销售阶段排列的对话步骤数组,每个元素包含:
|
||||
- stage: 阶段名称(建立联系/需求发现/报价/异议处理/成交)
|
||||
- goal: 该阶段目标
|
||||
- sales_script: 销售话术(1-3句,具体可执行)
|
||||
- sales_script: 销售话术(1-3句,具体可执行,自然融入产品卖点)
|
||||
- expected_response: 预期客户回应
|
||||
- tips: 该阶段技巧提示
|
||||
- key_techniques: 核心销售技巧总结(数组,每条一句话)
|
||||
|
||||
+333
-17
@@ -17,8 +17,9 @@ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-
|
||||
|
||||
<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>
|
||||
<span class="font-bold text-lg text-green-600">微信销售管理系统</span>
|
||||
<button onclick="showPanel('dashboard')" class="nav-btn text-sm hover:text-green-600">仪表盘</button>
|
||||
<button onclick="showPanel('products')" class="nav-btn text-sm hover:text-green-600">产品管理</button>
|
||||
<button onclick="showPanel('salespersons')" class="nav-btn text-sm hover:text-green-600">销售列表</button>
|
||||
<button onclick="showPanel('customers')" class="nav-btn text-sm hover:text-green-600">客户列表</button>
|
||||
<button onclick="showPanel('deals')" class="nav-btn text-sm hover:text-green-600">成交记录</button>
|
||||
@@ -33,14 +34,99 @@ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-
|
||||
<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 class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-6">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-3">各销售数据对比</h3>
|
||||
<div id="dashboard-sales" class="bg-white rounded-lg shadow p-4 overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead><tr class="border-b text-left text-gray-500">
|
||||
<th class="py-2">销售</th><th>团队</th><th>消息数</th><th>客户数</th><th>成交金额</th><th>最后同步</th>
|
||||
</tr></thead>
|
||||
<tbody id="dashboard-sales-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-3">本月产品线销售统计</h3>
|
||||
<div id="dashboard-product-stats" class="bg-white rounded-lg shadow p-4">
|
||||
<table class="w-full text-sm">
|
||||
<thead><tr class="border-b text-left text-gray-500">
|
||||
<th class="py-2">产品线</th><th>成交笔数</th><th>成交金额</th>
|
||||
</tr></thead>
|
||||
<tbody id="dashboard-product-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 产品管理 -->
|
||||
<div id="products-panel" class="panel hidden">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xl font-bold">产品管理</h2>
|
||||
<button onclick="showProductForm()" class="bg-green-600 text-white px-4 py-2 rounded text-sm hover:bg-green-700">+ 新增产品</button>
|
||||
</div>
|
||||
<div class="flex gap-4 mb-4">
|
||||
<select id="product-filter-category" onchange="loadProducts()" class="border rounded px-3 py-1.5 text-sm">
|
||||
<option value="">全部产品线</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="products-list" class="space-y-3"></div>
|
||||
</div>
|
||||
|
||||
<!-- 产品新增/编辑表单 -->
|
||||
<div id="product-form-panel" class="panel hidden">
|
||||
<button onclick="showPanel('products')" class="text-sm text-green-600 mb-3">← 返回产品管理</button>
|
||||
<h2 id="product-form-title" class="text-xl font-bold mb-4">新增产品</h2>
|
||||
<div class="bg-white rounded-lg shadow p-6 max-w-2xl">
|
||||
<form onsubmit="submitProduct(event)" class="space-y-4">
|
||||
<input type="hidden" id="product-form-id">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">产品名称 *</label>
|
||||
<input id="product-name" type="text" required placeholder="如:益童宝益生菌" class="w-full border rounded px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">产品线 *</label>
|
||||
<input id="product-category" type="text" required placeholder="如:儿童益生菌" class="w-full border rounded px-3 py-2 text-sm">
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">规格</label>
|
||||
<input id="product-spec" type="text" placeholder="如:3盒套餐" class="w-full border rounded px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">价格</label>
|
||||
<input id="product-price" type="number" step="0.01" placeholder="798" class="w-full border rounded px-3 py-2 text-sm">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">产品定位</label>
|
||||
<input id="product-positioning" type="text" placeholder="如:进口菌株儿童抗过敏专家" class="w-full border rounded px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">目标人群</label>
|
||||
<input id="product-target-audience" type="text" placeholder="如:3-12岁儿童" class="w-full border rounded px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">产品详情描述</label>
|
||||
<textarea id="product-description" rows="4" placeholder="详细描述产品功效、成分、用法等,AI 将以此为基础生成销售话术" class="w-full border rounded px-3 py-2 text-sm"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">核心卖点(每行一条)</label>
|
||||
<textarea id="product-selling-points" rows="5" placeholder="每行一条卖点,如:\n丹麦进口菌株,品质有保障\n临床验证改善率超85%" class="w-full border rounded px-3 py-2 text-sm"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">状态</label>
|
||||
<select id="product-status" class="w-full border rounded px-3 py-2 text-sm">
|
||||
<option value="active">上架</option>
|
||||
<option value="inactive">下架</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="bg-green-600 text-white px-6 py-2 rounded text-sm hover:bg-green-700">保存</button>
|
||||
<span id="product-result" class="ml-4 text-sm text-green-600"></span>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -102,6 +188,11 @@ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-
|
||||
<!-- 成交记录列表 -->
|
||||
<div id="deals-panel" class="panel hidden">
|
||||
<h2 class="text-xl font-bold mb-4">成交记录</h2>
|
||||
<div class="flex gap-4 mb-4">
|
||||
<select id="deals-filter-product" onchange="loadDeals()" class="border rounded px-3 py-1.5 text-sm">
|
||||
<option value="">全部产品</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow p-4 overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead><tr class="border-b text-left text-gray-500">
|
||||
@@ -141,11 +232,14 @@ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-
|
||||
<!-- 标准范式视图 -->
|
||||
<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>
|
||||
<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>
|
||||
<select id="paradigm-product-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>
|
||||
@@ -171,8 +265,10 @@ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-
|
||||
</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">
|
||||
<label class="block text-sm font-medium mb-1">产品 *</label>
|
||||
<select id="deal-product" required class="w-full border rounded px-3 py-2 text-sm" onchange="onProductSelected()">
|
||||
<option value="">请选择</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">金额 *</label>
|
||||
@@ -212,6 +308,7 @@ function showPanel(name) {
|
||||
const panel = document.getElementById(name + '-panel');
|
||||
if (panel) panel.classList.remove('hidden');
|
||||
if (name === 'dashboard') loadDashboard();
|
||||
if (name === 'products') loadProducts();
|
||||
if (name === 'salespersons') loadSalespersons();
|
||||
if (name === 'customers') loadCustomers();
|
||||
if (name === 'deals') loadDeals();
|
||||
@@ -253,6 +350,171 @@ async function loadDashboard() {
|
||||
<td class="text-gray-400 text-xs">${s.last_synced_at ? new Date(s.last_synced_at).toLocaleString('zh-CN') : '-'}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
const productTbody = document.getElementById('dashboard-product-tbody');
|
||||
const productStats = data.product_stats || [];
|
||||
if (productStats.length === 0) {
|
||||
productTbody.innerHTML = '<tr><td colspan="3" class="py-3 text-center text-gray-400 text-sm">暂无数据</td></tr>';
|
||||
} else {
|
||||
productTbody.innerHTML = productStats.map(p => `
|
||||
<tr class="border-b hover:bg-gray-50">
|
||||
<td class="py-2 font-medium">${p.category}</td>
|
||||
<td>${p.deal_count}</td>
|
||||
<td class="font-bold text-green-600">${formatYuan(p.amount)}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
}
|
||||
|
||||
// --- 产品管理 ---
|
||||
let productsData = [];
|
||||
|
||||
async function loadProducts() {
|
||||
const category = document.getElementById('product-filter-category').value;
|
||||
let path = '/products?';
|
||||
if (category) path += `category=${encodeURIComponent(category)}&`;
|
||||
try {
|
||||
productsData = await api(path);
|
||||
renderProducts();
|
||||
populateProductFilterCategory();
|
||||
} catch (e) {
|
||||
document.getElementById('products-list').innerHTML = `<p class="text-sm text-red-500">加载失败: ${e.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function populateProductFilterCategory() {
|
||||
const sel = document.getElementById('product-filter-category');
|
||||
if (sel.options.length > 1) return;
|
||||
const categories = [...new Set(productsData.map(p => p.category))];
|
||||
categories.forEach(c => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = c; opt.textContent = c;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
function renderProducts() {
|
||||
const container = document.getElementById('products-list');
|
||||
if (productsData.length === 0) {
|
||||
container.innerHTML = '<p class="text-sm text-gray-400">暂无产品,点击右上角新增</p>';
|
||||
return;
|
||||
}
|
||||
const grouped = {};
|
||||
productsData.forEach(p => {
|
||||
if (!grouped[p.category]) grouped[p.category] = [];
|
||||
grouped[p.category].push(p);
|
||||
});
|
||||
container.innerHTML = Object.entries(grouped).map(([category, items]) => `
|
||||
<div class="bg-white rounded-lg shadow p-4">
|
||||
<h3 class="font-semibold text-sm mb-3 text-green-700">${category}</h3>
|
||||
<div class="space-y-3">
|
||||
${items.map(p => `
|
||||
<div class="border rounded-lg p-4 ${p.status === 'inactive' ? 'opacity-50' : ''}">
|
||||
<div class="flex items-start justify-between mb-2">
|
||||
<div>
|
||||
<span class="font-medium">${p.name}</span>
|
||||
${p.spec ? `<span class="text-gray-400 text-sm ml-2">${p.spec}</span>` : ''}
|
||||
${p.price ? `<span class="text-green-600 font-bold text-sm ml-2">¥${p.price.toFixed(2)}</span>` : ''}
|
||||
${p.status === 'inactive' ? '<span class="ml-2 px-2 py-0.5 rounded text-xs bg-gray-200 text-gray-600">已下架</span>' : ''}
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="editProduct(${p.id})" class="text-xs text-blue-600 hover:underline">编辑</button>
|
||||
<button onclick="deleteProduct(${p.id}, '${p.name}')" class="text-xs text-red-500 hover:underline">${p.status === 'inactive' ? '删除' : '下架'}</button>
|
||||
</div>
|
||||
</div>
|
||||
${p.positioning ? `<p class="text-xs text-gray-500 mb-1">定位:${p.positioning}</p>` : ''}
|
||||
${p.target_audience ? `<p class="text-xs text-gray-500 mb-1">目标人群:${p.target_audience}</p>` : ''}
|
||||
${p.description ? `<p class="text-sm text-gray-600 mb-2">${p.description}</p>` : ''}
|
||||
${(p.selling_points || []).length > 0 ? `
|
||||
<div class="mb-2">
|
||||
<span class="text-xs font-medium text-green-700">核心卖点:</span>
|
||||
<ul class="mt-1 space-y-0.5">
|
||||
${(p.selling_points || []).map(sp => `<li class="text-xs text-gray-600 flex gap-1"><span class="text-green-500">▸</span><span>${escapeHtml(sp)}</span></li>`).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="flex gap-4 text-xs text-gray-400 border-t pt-2 mt-2">
|
||||
<span>成交笔数:${p.deal_count}</span>
|
||||
<span>成交总额:${formatYuan(p.total_amount)}</span>
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function showProductForm() {
|
||||
document.getElementById('product-form-title').textContent = '新增产品';
|
||||
document.getElementById('product-form-id').value = '';
|
||||
document.getElementById('product-name').value = '';
|
||||
document.getElementById('product-category').value = '';
|
||||
document.getElementById('product-spec').value = '';
|
||||
document.getElementById('product-price').value = '';
|
||||
document.getElementById('product-positioning').value = '';
|
||||
document.getElementById('product-target-audience').value = '';
|
||||
document.getElementById('product-description').value = '';
|
||||
document.getElementById('product-selling-points').value = '';
|
||||
document.getElementById('product-status').value = 'active';
|
||||
document.getElementById('product-result').textContent = '';
|
||||
showPanel('product-form');
|
||||
}
|
||||
|
||||
function editProduct(id) {
|
||||
const p = productsData.find(x => x.id === id);
|
||||
if (!p) return;
|
||||
document.getElementById('product-form-title').textContent = '编辑产品';
|
||||
document.getElementById('product-form-id').value = p.id;
|
||||
document.getElementById('product-name').value = p.name || '';
|
||||
document.getElementById('product-category').value = p.category || '';
|
||||
document.getElementById('product-spec').value = p.spec || '';
|
||||
document.getElementById('product-price').value = p.price || '';
|
||||
document.getElementById('product-positioning').value = p.positioning || '';
|
||||
document.getElementById('product-target-audience').value = p.target_audience || '';
|
||||
document.getElementById('product-description').value = p.description || '';
|
||||
document.getElementById('product-selling-points').value = (p.selling_points || []).join('\n');
|
||||
document.getElementById('product-status').value = p.status || 'active';
|
||||
document.getElementById('product-result').textContent = '';
|
||||
showPanel('product-form');
|
||||
}
|
||||
|
||||
async function submitProduct(event) {
|
||||
event.preventDefault();
|
||||
const id = document.getElementById('product-form-id').value;
|
||||
const sellingPointsText = document.getElementById('product-selling-points').value.trim();
|
||||
const sellingPoints = sellingPointsText ? sellingPointsText.split('\n').map(s => s.trim()).filter(s => s) : null;
|
||||
const body = {
|
||||
name: document.getElementById('product-name').value,
|
||||
category: document.getElementById('product-category').value,
|
||||
spec: document.getElementById('product-spec').value || null,
|
||||
price: parseFloat(document.getElementById('product-price').value) || null,
|
||||
positioning: document.getElementById('product-positioning').value || null,
|
||||
target_audience: document.getElementById('product-target-audience').value || null,
|
||||
description: document.getElementById('product-description').value || null,
|
||||
selling_points: sellingPoints,
|
||||
status: document.getElementById('product-status').value,
|
||||
};
|
||||
try {
|
||||
const result = await api(`/products${id ? '/' + id : ''}`, {
|
||||
method: id ? 'PUT' : 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
document.getElementById('product-result').textContent = '✓ ' + result.message;
|
||||
setTimeout(() => showPanel('products'), 800);
|
||||
} catch (e) {
|
||||
document.getElementById('product-result').textContent = '✗ 失败: ' + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteProduct(id, name) {
|
||||
if (!confirm(`确定要下架产品「${name}」吗?`)) return;
|
||||
try {
|
||||
await api(`/products/${id}`, { method: 'DELETE' });
|
||||
await loadProducts();
|
||||
} catch (e) {
|
||||
alert('操作失败: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// --- 销售列表 ---
|
||||
@@ -417,8 +679,21 @@ async function loadCustomerDeals() {
|
||||
|
||||
// --- 成交记录列表 ---
|
||||
async function loadDeals() {
|
||||
const data = await api('/deals');
|
||||
const productId = document.getElementById('deals-filter-product').value;
|
||||
let path = '/deals?';
|
||||
if (productId) path += `product_id=${productId}&`;
|
||||
const data = await api(path);
|
||||
const tbody = document.getElementById('deals-tbody');
|
||||
// 填充产品筛选
|
||||
const productFilter = document.getElementById('deals-filter-product');
|
||||
if (productFilter.options.length <= 1) {
|
||||
const products = await api('/products');
|
||||
products.forEach(p => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = p.id; opt.textContent = `${p.name} ${p.spec || ''}`;
|
||||
productFilter.appendChild(opt);
|
||||
});
|
||||
}
|
||||
if (data.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="py-4 text-center text-gray-400">暂无成交记录</td></tr>';
|
||||
return;
|
||||
@@ -445,6 +720,18 @@ async function loadDealForm() {
|
||||
opt.value = s.id; opt.textContent = s.name;
|
||||
spSelect.appendChild(opt);
|
||||
});
|
||||
// 加载产品列表到下拉
|
||||
const productSelect = document.getElementById('deal-product');
|
||||
productSelect.innerHTML = '<option value="">请选择</option>';
|
||||
const products = await api('/products?status=active');
|
||||
products.forEach(p => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = p.id;
|
||||
opt.textContent = `${p.name} ${p.spec || ''} (¥${p.price ? p.price.toFixed(2) : '-'})`;
|
||||
opt.dataset.price = p.price || '';
|
||||
opt.dataset.name = `${p.name} ${p.spec || ''}`.trim();
|
||||
productSelect.appendChild(opt);
|
||||
});
|
||||
document.getElementById('deal-date').value = new Date().toISOString().slice(0, 10);
|
||||
document.getElementById('deal-result').textContent = '';
|
||||
}
|
||||
@@ -463,12 +750,25 @@ async function loadDealCustomers() {
|
||||
});
|
||||
}
|
||||
|
||||
function onProductSelected() {
|
||||
const select = document.getElementById('deal-product');
|
||||
const selectedOpt = select.options[select.selectedIndex];
|
||||
if (selectedOpt && selectedOpt.dataset.price) {
|
||||
document.getElementById('deal-amount').value = selectedOpt.dataset.price;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitDeal(event) {
|
||||
event.preventDefault();
|
||||
const productSelect = document.getElementById('deal-product');
|
||||
const selectedOpt = productSelect.options[productSelect.selectedIndex];
|
||||
const productId = parseInt(productSelect.value);
|
||||
const productName = selectedOpt ? selectedOpt.dataset.name || selectedOpt.textContent : '';
|
||||
const body = {
|
||||
salesperson_id: parseInt(document.getElementById('deal-salesperson').value),
|
||||
customer_id: parseInt(document.getElementById('deal-customer').value),
|
||||
product_name: document.getElementById('deal-product').value,
|
||||
product_id: productId || null,
|
||||
product_name: productName,
|
||||
amount: parseFloat(document.getElementById('deal-amount').value),
|
||||
deal_date: document.getElementById('deal-date').value,
|
||||
notes: document.getElementById('deal-notes').value || null,
|
||||
@@ -480,7 +780,7 @@ async function submitDeal(event) {
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
document.getElementById('deal-result').textContent = '✓ 已创建,ID: ' + result.id;
|
||||
document.getElementById('deal-product').value = '';
|
||||
productSelect.value = '';
|
||||
document.getElementById('deal-amount').value = '';
|
||||
document.getElementById('deal-notes').value = '';
|
||||
} catch (e) {
|
||||
@@ -596,6 +896,18 @@ function populateParadigmSelect() {
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
if (currentVal) sel.value = currentVal;
|
||||
// 填充产品选择
|
||||
const productSel = document.getElementById('paradigm-product-select');
|
||||
if (productSel && productSel.options.length <= 1) {
|
||||
api('/products?status=active').then(products => {
|
||||
products.forEach(p => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = p.id;
|
||||
opt.textContent = `${p.name} ${p.spec || ''}`;
|
||||
productSel.appendChild(opt);
|
||||
});
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function generateParadigm() {
|
||||
@@ -610,7 +922,10 @@ async function generateParadigm() {
|
||||
btn.textContent = '生成中...';
|
||||
result.innerHTML = '<p class="text-sm text-gray-400">正在调用千问 LLM 生成标准范式对话,请稍候...</p>';
|
||||
try {
|
||||
const data = await api(`/conversations/${customerId}/paradigm`, { method: 'POST' });
|
||||
const productId = document.getElementById('paradigm-product-select').value;
|
||||
let paradigmPath = `/conversations/${customerId}/paradigm`;
|
||||
if (productId) paradigmPath += `?product_id=${productId}`;
|
||||
const data = await api(paradigmPath, { method: 'POST' });
|
||||
renderParadigm(data);
|
||||
} catch (e) {
|
||||
result.innerHTML = `<p class="text-sm text-red-500">生成失败: ${e.message}</p>`;
|
||||
@@ -632,7 +947,8 @@ function renderParadigm(data) {
|
||||
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>
|
||||
<p class="text-sm text-gray-500 mb-2">客户类型: ${data.customer_type || '-'}</p>
|
||||
${data.product_positioning ? `<p class="text-sm text-green-700 mb-4">产品推荐理由: ${escapeHtml(data.product_positioning)}</p>` : '<p class="mb-4"></p>'}
|
||||
|
||||
<div class="space-y-4 mb-6">
|
||||
${(data.stages || []).map((s, i) => `
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
#!/bin/bash
|
||||
set -eu
|
||||
|
||||
# ============================================
|
||||
# 微信销售管理系统 - 一键远程部署
|
||||
# 用法: bash deploy.sh
|
||||
# ============================================
|
||||
|
||||
REMOTE_HOST="154.8.162.18"
|
||||
REMOTE_USER="root"
|
||||
REMOTE_PATH="/opt/wxsales"
|
||||
REMOTE_DEMO="${REMOTE_PATH}/demo"
|
||||
|
||||
echo "=========================================="
|
||||
echo " 微信销售管理系统 - 远程部署"
|
||||
echo "=========================================="
|
||||
echo " 目标: ${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_PATH}"
|
||||
echo ""
|
||||
|
||||
# 1. Git 提交推送
|
||||
echo "[1/5] Git 提交推送..."
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
git add -A
|
||||
git commit -m "deploy: $(date '+%Y-%m-%d %H:%M') 部署更新"
|
||||
git push origin main
|
||||
echo " ✓ 代码已推送"
|
||||
else
|
||||
echo " - 无本地改动,跳过提交"
|
||||
fi
|
||||
|
||||
# 2. 同步代码到远程
|
||||
echo ""
|
||||
echo "[2/5] 同步代码到远程服务器..."
|
||||
ssh -o StrictHostKeyChecking=no ${REMOTE_USER}@${REMOTE_HOST} "mkdir -p ${REMOTE_PATH}"
|
||||
rsync -avz --delete --exclude='db/data' --exclude='db/socket' --exclude='__pycache__' \
|
||||
--exclude='.git' --exclude='web.log' --exclude='.run.pid' \
|
||||
"$(dirname "$0")/demo/" "${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_DEMO}/"
|
||||
echo " ✓ 代码已同步"
|
||||
|
||||
# 3. 远程执行数据库迁移 + 重启服务
|
||||
echo ""
|
||||
echo "[3/5] 远程数据库迁移..."
|
||||
ssh -o StrictHostKeyChecking=no ${REMOTE_USER}@${REMOTE_HOST} bash ${REMOTE_DEMO}/deploy_remote.sh
|
||||
echo " ✓ 数据库迁移完成"
|
||||
|
||||
# 4. 重启 Web 服务
|
||||
echo ""
|
||||
echo "[4/5] 重启 Web 服务..."
|
||||
ssh -o StrictHostKeyChecking=no ${REMOTE_USER}@${REMOTE_HOST} "
|
||||
# 停止旧进程
|
||||
if lsof -ti:8770 >/dev/null 2>&1; then
|
||||
kill \$(lsof -ti:8770) 2>/dev/null || true
|
||||
sleep 1
|
||||
fi
|
||||
# 启动新进程
|
||||
cd ${REMOTE_DEMO}
|
||||
nohup /usr/bin/python3 -m uvicorn web.server:app --host 127.0.0.1 --port 8770 \
|
||||
> ${REMOTE_DEMO}/web.log 2>&1 &
|
||||
sleep 2
|
||||
if curl -s http://127.0.0.1:8770/api/dashboard >/dev/null 2>&1; then
|
||||
echo ' ✓ Web 服务启动成功'
|
||||
else
|
||||
echo ' ✗ Web 服务启动失败'
|
||||
cat ${REMOTE_DEMO}/web.log | tail -20
|
||||
exit 1
|
||||
fi
|
||||
"
|
||||
|
||||
# 5. 验证
|
||||
echo ""
|
||||
echo "[5/5] 验证..."
|
||||
if curl -s --max-time 10 http://sale.all8ai.top/api/products | grep -q '"id"' 2>/dev/null; then
|
||||
echo " ✓ 域名访问正常: http://sale.all8ai.top"
|
||||
else
|
||||
echo " ⚠ 域名访问异常,请检查 Nginx 配置"
|
||||
fi
|
||||
|
||||
PRODUCT_COUNT=$(ssh -o StrictHostKeyChecking=no ${REMOTE_USER}@${REMOTE_HOST} \
|
||||
"curl -s http://127.0.0.1:8770/api/products | python3 -c 'import sys,json; print(len(json.load(sys.stdin)))'" 2>/dev/null || echo "?")
|
||||
echo " ✓ 产品数量: ${PRODUCT_COUNT}"
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo " ✓ 部署完成!"
|
||||
echo "=========================================="
|
||||
echo " 访问地址: http://sale.all8ai.top"
|
||||
echo " API 文档: http://sale.all8ai.top/docs"
|
||||
echo "=========================================="
|
||||
Reference in New Issue
Block a user