feat: 优化部署脚本
This commit is contained in:
11
backend/data/ai_reply_config.json
Normal file
11
backend/data/ai_reply_config.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"key": "HBpEnbtj9BJZ",
|
||||||
|
"super_admin_wxids": [
|
||||||
|
"wxid_f2q8xscgg31322"
|
||||||
|
],
|
||||||
|
"whitelist_wxids": [
|
||||||
|
"zhang499142409"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
11
backend/data/models.json
Normal file
11
backend/data/models.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "dee0443f-36f3-4d7c-9321-618d80c18a89",
|
||||||
|
"name": "千问",
|
||||||
|
"provider": "openai",
|
||||||
|
"api_key": "sk-85880595fc714d63bfd0b025e917bd26",
|
||||||
|
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||||
|
"model_name": "qwen3.5-plus",
|
||||||
|
"is_current": true
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -1174,5 +1174,314 @@
|
|||||||
"new_msg_id": 4819003726112313030
|
"new_msg_id": 4819003726112313030
|
||||||
},
|
},
|
||||||
"type": "message"
|
"type": "message"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "out",
|
||||||
|
"ToUserName": "zhang499142409",
|
||||||
|
"Content": "你好吗?",
|
||||||
|
"CreateTime": 1773162081,
|
||||||
|
"key": "HBpEnbtj9BJZ"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "HBpEnbtj9BJZ",
|
||||||
|
"message": {
|
||||||
|
"msg_id": 1610150761,
|
||||||
|
"from_user_name": {
|
||||||
|
"str": "zhang499142409"
|
||||||
|
},
|
||||||
|
"to_user_name": {
|
||||||
|
"str": "wxid_f2q8xscgg31322"
|
||||||
|
},
|
||||||
|
"msg_type": 1,
|
||||||
|
"content": {
|
||||||
|
"str": "你是谁"
|
||||||
|
},
|
||||||
|
"status": 3,
|
||||||
|
"img_status": 1,
|
||||||
|
"img_buf": {
|
||||||
|
"len": 0
|
||||||
|
},
|
||||||
|
"create_time": 1773163138,
|
||||||
|
"msg_source": "<msgsource>\n\t<bizflag>0</bizflag>\n\t<pua>1</pua>\n\t<eggIncluded>1</eggIncluded>\n\t<signature>N0_V1_tvPQ/7y0|v1_SpiyYTgw</signature>\n\t<tmp_node>\n\t\t<publisher-id></publisher-id>\n\t</tmp_node>\n</msgsource>\n",
|
||||||
|
"push_content": "Daniel : 你是谁",
|
||||||
|
"new_msg_id": 3512349988965098431
|
||||||
|
},
|
||||||
|
"type": "message"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "HBpEnbtj9BJZ",
|
||||||
|
"message": {
|
||||||
|
"msg_id": 1826119229,
|
||||||
|
"from_user_name": {
|
||||||
|
"str": "wxid_f2q8xscgg31322"
|
||||||
|
},
|
||||||
|
"to_user_name": {
|
||||||
|
"str": "wxid_f2q8xscgg31322"
|
||||||
|
},
|
||||||
|
"msg_type": 51,
|
||||||
|
"content": {
|
||||||
|
"str": "<msg>\n<op id='5'>\n<username>wxid_f2q8xscgg31322</username>\n<name>lastMessage</name>\n<arg>{\"messageSvrId\":\"9191983264673337867\",\"MsgCreateTime\":\"1773161823\"}</arg>\n</op>\n</msg>"
|
||||||
|
},
|
||||||
|
"status": 3,
|
||||||
|
"img_status": 1,
|
||||||
|
"img_buf": {
|
||||||
|
"len": 0
|
||||||
|
},
|
||||||
|
"create_time": 1773163203,
|
||||||
|
"msg_source": "<msgsource>\n\t<signature>v1_eSSKf/rE</signature>\n\t<tmp_node>\n\t\t<publisher-id></publisher-id>\n\t</tmp_node>\n</msgsource>\n",
|
||||||
|
"new_msg_id": 3552106780167326835
|
||||||
|
},
|
||||||
|
"type": "message"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "HBpEnbtj9BJZ",
|
||||||
|
"message": {
|
||||||
|
"msg_id": 216882921,
|
||||||
|
"from_user_name": {
|
||||||
|
"str": "wxid_f2q8xscgg31322"
|
||||||
|
},
|
||||||
|
"to_user_name": {
|
||||||
|
"str": "zhang499142409"
|
||||||
|
},
|
||||||
|
"msg_type": 51,
|
||||||
|
"content": {
|
||||||
|
"str": "<msg>\n<op id='2'>\n<username>zhang499142409</username>\n<name>lastMessage</name>\n<arg>{\"messageSvrId\":\"3512349988965098431\",\"MsgCreateTime\":\"1773163138\"}</arg>\n</op>\n</msg>"
|
||||||
|
},
|
||||||
|
"status": 3,
|
||||||
|
"img_status": 1,
|
||||||
|
"img_buf": {
|
||||||
|
"len": 0
|
||||||
|
},
|
||||||
|
"create_time": 1773163205,
|
||||||
|
"msg_source": "<msgsource>\n\t<signature>v1_L2fvIOvF</signature>\n\t<tmp_node>\n\t\t<publisher-id></publisher-id>\n\t</tmp_node>\n</msgsource>\n",
|
||||||
|
"new_msg_id": 8434304876441850640
|
||||||
|
},
|
||||||
|
"type": "message"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "HBpEnbtj9BJZ",
|
||||||
|
"message": {
|
||||||
|
"msg_id": 2132745747,
|
||||||
|
"from_user_name": {
|
||||||
|
"str": "wxid_f2q8xscgg31322"
|
||||||
|
},
|
||||||
|
"to_user_name": {
|
||||||
|
"str": "zhang499142409"
|
||||||
|
},
|
||||||
|
"msg_type": 51,
|
||||||
|
"content": {
|
||||||
|
"str": "<msg>\n<op id='5'>\n<username>zhang499142409</username>\n<name>lastMessage</name>\n<arg>{\"messageSvrId\":\"3512349988965098431\",\"MsgCreateTime\":\"1773163138\"}</arg>\n</op>\n</msg>"
|
||||||
|
},
|
||||||
|
"status": 3,
|
||||||
|
"img_status": 1,
|
||||||
|
"img_buf": {
|
||||||
|
"len": 0
|
||||||
|
},
|
||||||
|
"create_time": 1773163205,
|
||||||
|
"msg_source": "<msgsource>\n\t<signature>v1_DG+ZFs7h</signature>\n\t<tmp_node>\n\t\t<publisher-id></publisher-id>\n\t</tmp_node>\n</msgsource>\n",
|
||||||
|
"new_msg_id": 2869033315579360891
|
||||||
|
},
|
||||||
|
"type": "message"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "HBpEnbtj9BJZ",
|
||||||
|
"message": {
|
||||||
|
"msg_id": 2110142296,
|
||||||
|
"from_user_name": {
|
||||||
|
"str": "wxid_f2q8xscgg31322"
|
||||||
|
},
|
||||||
|
"to_user_name": {
|
||||||
|
"str": "zhang499142409"
|
||||||
|
},
|
||||||
|
"msg_type": 51,
|
||||||
|
"content": {
|
||||||
|
"str": "<msg>\n<op id='2'>\n<username>zhang499142409</username>\n<name>lastMessage</name>\n<arg>{\"messageSvrId\":\"3512349988965098431\",\"MsgCreateTime\":\"1773163138\"}</arg>\n</op>\n</msg>"
|
||||||
|
},
|
||||||
|
"status": 3,
|
||||||
|
"img_status": 1,
|
||||||
|
"img_buf": {
|
||||||
|
"len": 0
|
||||||
|
},
|
||||||
|
"create_time": 1773163291,
|
||||||
|
"msg_source": "<msgsource>\n\t<signature>v1_XmjXzlCu</signature>\n\t<tmp_node>\n\t\t<publisher-id></publisher-id>\n\t</tmp_node>\n</msgsource>\n",
|
||||||
|
"new_msg_id": 6554530052967632446
|
||||||
|
},
|
||||||
|
"type": "message"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "HBpEnbtj9BJZ",
|
||||||
|
"message": {
|
||||||
|
"msg_id": 1891079631,
|
||||||
|
"from_user_name": {
|
||||||
|
"str": "wxid_f2q8xscgg31322"
|
||||||
|
},
|
||||||
|
"to_user_name": {
|
||||||
|
"str": "zhang499142409"
|
||||||
|
},
|
||||||
|
"msg_type": 51,
|
||||||
|
"content": {
|
||||||
|
"str": "<msg>\n<op id='5'>\n<username>zhang499142409</username>\n<name>lastMessage</name>\n<arg>{\"messageSvrId\":\"3512349988965098431\",\"MsgCreateTime\":\"1773163138\"}</arg>\n</op>\n</msg>"
|
||||||
|
},
|
||||||
|
"status": 3,
|
||||||
|
"img_status": 1,
|
||||||
|
"img_buf": {
|
||||||
|
"len": 0
|
||||||
|
},
|
||||||
|
"create_time": 1773163294,
|
||||||
|
"msg_source": "<msgsource>\n\t<signature>v1_iRwWbu7A</signature>\n\t<tmp_node>\n\t\t<publisher-id></publisher-id>\n\t</tmp_node>\n</msgsource>\n",
|
||||||
|
"new_msg_id": 6757624217414248141
|
||||||
|
},
|
||||||
|
"type": "message"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "HBpEnbtj9BJZ",
|
||||||
|
"message": {
|
||||||
|
"msg_id": 58087331,
|
||||||
|
"from_user_name": {
|
||||||
|
"str": "wxid_f2q8xscgg31322"
|
||||||
|
},
|
||||||
|
"to_user_name": {
|
||||||
|
"str": "wxid_f2q8xscgg31322"
|
||||||
|
},
|
||||||
|
"msg_type": 51,
|
||||||
|
"content": {
|
||||||
|
"str": "<msg>\n<op id='2'>\n<username>wxid_f2q8xscgg31322</username>\n<name>lastMessage</name>\n<arg>{\"messageSvrId\":\"9191983264673337867\",\"MsgCreateTime\":\"1773161823\"}</arg>\n</op>\n</msg>"
|
||||||
|
},
|
||||||
|
"status": 3,
|
||||||
|
"img_status": 1,
|
||||||
|
"img_buf": {
|
||||||
|
"len": 0
|
||||||
|
},
|
||||||
|
"create_time": 1773163296,
|
||||||
|
"msg_source": "<msgsource>\n\t<signature>v1_IFm3SM7Y</signature>\n\t<tmp_node>\n\t\t<publisher-id></publisher-id>\n\t</tmp_node>\n</msgsource>\n",
|
||||||
|
"new_msg_id": 1302874624611387202
|
||||||
|
},
|
||||||
|
"type": "message"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "HBpEnbtj9BJZ",
|
||||||
|
"message": {
|
||||||
|
"msg_id": 647268517,
|
||||||
|
"from_user_name": {
|
||||||
|
"str": "wxid_f2q8xscgg31322"
|
||||||
|
},
|
||||||
|
"to_user_name": {
|
||||||
|
"str": "wxid_f2q8xscgg31322"
|
||||||
|
},
|
||||||
|
"msg_type": 1,
|
||||||
|
"content": {
|
||||||
|
"str": "你用的什么模型"
|
||||||
|
},
|
||||||
|
"status": 3,
|
||||||
|
"img_status": 1,
|
||||||
|
"img_buf": {
|
||||||
|
"len": 0
|
||||||
|
},
|
||||||
|
"create_time": 1773163308,
|
||||||
|
"msg_source": "<msgsource>\n\t<bizflag>0</bizflag>\n\t<pua>1</pua>\n\t<eggIncluded>1</eggIncluded>\n\t<signature>N0_V1_/m2bkvRf|v1_1FCs6fvq</signature>\n\t<tmp_node>\n\t\t<publisher-id></publisher-id>\n\t</tmp_node>\n</msgsource>\n",
|
||||||
|
"new_msg_id": 8354732942085133458
|
||||||
|
},
|
||||||
|
"type": "message"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "HBpEnbtj9BJZ",
|
||||||
|
"message": {
|
||||||
|
"msg_id": 771150200,
|
||||||
|
"from_user_name": {
|
||||||
|
"str": "wxid_f2q8xscgg31322"
|
||||||
|
},
|
||||||
|
"to_user_name": {
|
||||||
|
"str": "wxid_f2q8xscgg31322"
|
||||||
|
},
|
||||||
|
"msg_type": 51,
|
||||||
|
"content": {
|
||||||
|
"str": "<msg>\n<op id='5'>\n<username>wxid_f2q8xscgg31322</username>\n<name>lastMessage</name>\n<arg>{\"messageSvrId\":\"8354732942085133458\",\"MsgCreateTime\":\"1773163308\"}</arg>\n</op>\n</msg>"
|
||||||
|
},
|
||||||
|
"status": 3,
|
||||||
|
"img_status": 1,
|
||||||
|
"img_buf": {
|
||||||
|
"len": 0
|
||||||
|
},
|
||||||
|
"create_time": 1773163310,
|
||||||
|
"msg_source": "<msgsource>\n\t<signature>v1_2hC615We</signature>\n\t<tmp_node>\n\t\t<publisher-id></publisher-id>\n\t</tmp_node>\n</msgsource>\n",
|
||||||
|
"new_msg_id": 7243733440829071694
|
||||||
|
},
|
||||||
|
"type": "message"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "HBpEnbtj9BJZ",
|
||||||
|
"message": {
|
||||||
|
"msg_id": 317539696,
|
||||||
|
"from_user_name": {
|
||||||
|
"str": "wxid_f2q8xscgg31322"
|
||||||
|
},
|
||||||
|
"to_user_name": {
|
||||||
|
"str": "wxid_f2q8xscgg31322"
|
||||||
|
},
|
||||||
|
"msg_type": 51,
|
||||||
|
"content": {
|
||||||
|
"str": "<msg>\n<op id='2'>\n<username>wxid_f2q8xscgg31322</username>\n<name>lastMessage</name>\n<arg>{\"messageSvrId\":\"8354732942085133458\",\"MsgCreateTime\":\"1773163308\"}</arg>\n</op>\n</msg>"
|
||||||
|
},
|
||||||
|
"status": 3,
|
||||||
|
"img_status": 1,
|
||||||
|
"img_buf": {
|
||||||
|
"len": 0
|
||||||
|
},
|
||||||
|
"create_time": 1773163312,
|
||||||
|
"msg_source": "<msgsource>\n\t<signature>v1_1PuNB2Q3</signature>\n\t<tmp_node>\n\t\t<publisher-id></publisher-id>\n\t</tmp_node>\n</msgsource>\n",
|
||||||
|
"new_msg_id": 7265708667374985818
|
||||||
|
},
|
||||||
|
"type": "message"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "HBpEnbtj9BJZ",
|
||||||
|
"message": {
|
||||||
|
"msg_id": 106384113,
|
||||||
|
"from_user_name": {
|
||||||
|
"str": "wxid_f2q8xscgg31322"
|
||||||
|
},
|
||||||
|
"to_user_name": {
|
||||||
|
"str": "wxid_f2q8xscgg31322"
|
||||||
|
},
|
||||||
|
"msg_type": 51,
|
||||||
|
"content": {
|
||||||
|
"str": "<msg>\n<op id='5'>\n<username>wxid_f2q8xscgg31322</username>\n<name>lastMessage</name>\n<arg>{\"messageSvrId\":\"8354732942085133458\",\"MsgCreateTime\":\"1773163308\"}</arg>\n</op>\n</msg>"
|
||||||
|
},
|
||||||
|
"status": 3,
|
||||||
|
"img_status": 1,
|
||||||
|
"img_buf": {
|
||||||
|
"len": 0
|
||||||
|
},
|
||||||
|
"create_time": 1773163320,
|
||||||
|
"msg_source": "<msgsource>\n\t<signature>v1_rWs/fWf2</signature>\n\t<tmp_node>\n\t\t<publisher-id></publisher-id>\n\t</tmp_node>\n</msgsource>\n",
|
||||||
|
"new_msg_id": 1510294059264702492
|
||||||
|
},
|
||||||
|
"type": "message"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "HBpEnbtj9BJZ",
|
||||||
|
"message": {
|
||||||
|
"msg_id": 576932746,
|
||||||
|
"from_user_name": {
|
||||||
|
"str": "zhang499142409"
|
||||||
|
},
|
||||||
|
"to_user_name": {
|
||||||
|
"str": "wxid_f2q8xscgg31322"
|
||||||
|
},
|
||||||
|
"msg_type": 1,
|
||||||
|
"content": {
|
||||||
|
"str": "告诉我模型内容"
|
||||||
|
},
|
||||||
|
"status": 3,
|
||||||
|
"img_status": 1,
|
||||||
|
"img_buf": {
|
||||||
|
"len": 0
|
||||||
|
},
|
||||||
|
"create_time": 1773163339,
|
||||||
|
"msg_source": "<msgsource>\n\t<bizflag>0</bizflag>\n\t<pua>1</pua>\n\t<eggIncluded>1</eggIncluded>\n\t<signature>N0_V1_8uaj8gCr|v1_Flh4iaN8</signature>\n\t<tmp_node>\n\t\t<publisher-id></publisher-id>\n\t</tmp_node>\n</msgsource>\n",
|
||||||
|
"push_content": "Daniel : 告诉我模型内容",
|
||||||
|
"new_msg_id": 6612157681502055018
|
||||||
|
},
|
||||||
|
"type": "message"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
BIN
backend/data/wechat.db
Normal file
BIN
backend/data/wechat.db
Normal file
Binary file not shown.
179
backend/db.py
Normal file
179
backend/db.py
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""SQLite 数据库:表结构初始化与连接,数据目录由 DATA_DIR 决定(可挂载到宿主机)。"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
_DATA_DIR = os.getenv("DATA_DIR") or os.path.join(os.path.dirname(__file__), "data")
|
||||||
|
_DB_PATH = os.path.join(_DATA_DIR, "wechat.db")
|
||||||
|
|
||||||
|
def get_db_path() -> str:
|
||||||
|
return _DB_PATH
|
||||||
|
|
||||||
|
def get_conn() -> sqlite3.Connection:
|
||||||
|
os.makedirs(_DATA_DIR, exist_ok=True)
|
||||||
|
conn = sqlite3.connect(_DB_PATH, check_same_thread=False)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
def init_schema(conn: sqlite3.Connection) -> None:
|
||||||
|
cur = conn.cursor()
|
||||||
|
# 客户档案
|
||||||
|
cur.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS customers (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
wxid TEXT NOT NULL,
|
||||||
|
remark_name TEXT,
|
||||||
|
region TEXT,
|
||||||
|
age TEXT,
|
||||||
|
gender TEXT,
|
||||||
|
level TEXT,
|
||||||
|
tags TEXT
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
cur.execute("CREATE INDEX IF NOT EXISTS idx_customers_key ON customers(key)")
|
||||||
|
# 定时问候任务
|
||||||
|
cur.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS greeting_tasks (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
name TEXT,
|
||||||
|
send_time TEXT,
|
||||||
|
customer_tags TEXT,
|
||||||
|
template TEXT,
|
||||||
|
use_qwen INTEGER DEFAULT 0,
|
||||||
|
enabled INTEGER DEFAULT 1,
|
||||||
|
executed_at TEXT
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
cur.execute("CREATE INDEX IF NOT EXISTS idx_greeting_tasks_key ON greeting_tasks(key)")
|
||||||
|
# 商品标签
|
||||||
|
cur.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS product_tags (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
name TEXT
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
# 推送群组
|
||||||
|
cur.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS push_groups (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
name TEXT,
|
||||||
|
customer_ids TEXT,
|
||||||
|
tag_ids TEXT
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
# 推送任务
|
||||||
|
cur.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS push_tasks (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
product_tag_id TEXT,
|
||||||
|
group_id TEXT,
|
||||||
|
content TEXT,
|
||||||
|
send_at TEXT,
|
||||||
|
status TEXT,
|
||||||
|
created_at TEXT
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
# 同步消息(WS 拉取 + 发出记录)
|
||||||
|
cur.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS sync_messages (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
create_time INTEGER DEFAULT 0,
|
||||||
|
payload TEXT
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
cur.execute("CREATE INDEX IF NOT EXISTS idx_sync_messages_key ON sync_messages(key)")
|
||||||
|
# 模型配置
|
||||||
|
cur.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS models (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT,
|
||||||
|
provider TEXT,
|
||||||
|
api_key TEXT,
|
||||||
|
base_url TEXT,
|
||||||
|
model_name TEXT,
|
||||||
|
is_current INTEGER DEFAULT 0
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
# AI 回复配置(白名单 / 超级管理员)
|
||||||
|
cur.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS ai_reply_config (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
super_admin_wxids TEXT,
|
||||||
|
whitelist_wxids TEXT
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.commit()
|
||||||
|
_migrate_json_if_needed(conn)
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_json_if_needed(conn: sqlite3.Connection) -> None:
|
||||||
|
"""若表为空且存在同名 JSON 文件,则从 JSON 迁移一次。"""
|
||||||
|
cur = conn.cursor()
|
||||||
|
# (table, filename, columns, json_columns)
|
||||||
|
tables_files = [
|
||||||
|
("customers", "customers.json", ["id", "key", "wxid", "remark_name", "region", "age", "gender", "level", "tags"], ["tags"]),
|
||||||
|
("greeting_tasks", "greeting_tasks.json", ["id", "key", "name", "send_time", "customer_tags", "template", "use_qwen", "enabled", "executed_at"], ["customer_tags"]),
|
||||||
|
("product_tags", "product_tags.json", ["id", "key", "name"], []),
|
||||||
|
("push_groups", "push_groups.json", ["id", "key", "name", "customer_ids", "tag_ids"], ["customer_ids", "tag_ids"]),
|
||||||
|
("push_tasks", "push_tasks.json", ["id", "key", "product_tag_id", "group_id", "content", "send_at", "status", "created_at"], []),
|
||||||
|
("models", "models.json", ["id", "name", "provider", "api_key", "base_url", "model_name", "is_current"], []),
|
||||||
|
("ai_reply_config", "ai_reply_config.json", ["key", "super_admin_wxids", "whitelist_wxids"], ["super_admin_wxids", "whitelist_wxids"]),
|
||||||
|
]
|
||||||
|
for table, filename, columns, json_cols in tables_files:
|
||||||
|
json_cols_set = set(json_cols)
|
||||||
|
cur.execute(f"SELECT COUNT(*) FROM {table}")
|
||||||
|
if cur.fetchone()[0] > 0:
|
||||||
|
continue
|
||||||
|
path = os.path.join(_DATA_DIR, filename)
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
rows = json.load(f)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if not rows:
|
||||||
|
continue
|
||||||
|
for r in rows:
|
||||||
|
if not isinstance(r, dict):
|
||||||
|
continue
|
||||||
|
vals = []
|
||||||
|
for c in columns:
|
||||||
|
v = r.get(c)
|
||||||
|
if c in json_cols_set and isinstance(v, (list, dict)):
|
||||||
|
v = json.dumps(v, ensure_ascii=False)
|
||||||
|
elif isinstance(v, bool):
|
||||||
|
v = 1 if v else 0
|
||||||
|
vals.append(v)
|
||||||
|
placeholders = ",".join("?" * len(columns))
|
||||||
|
cur.execute(f"INSERT OR IGNORE INTO {table} ({','.join(columns)}) VALUES ({placeholders})", vals)
|
||||||
|
# sync_messages: 按 key + payload 迁移
|
||||||
|
cur.execute("SELECT COUNT(*) FROM sync_messages")
|
||||||
|
if cur.fetchone()[0] == 0:
|
||||||
|
path = os.path.join(_DATA_DIR, "sync_messages.json")
|
||||||
|
if os.path.isfile(path):
|
||||||
|
try:
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
rows = json.load(f)
|
||||||
|
for r in rows:
|
||||||
|
if not isinstance(r, dict):
|
||||||
|
continue
|
||||||
|
key = r.get("key", "")
|
||||||
|
ct = int(r.get("CreateTime") or 0) if isinstance(r.get("CreateTime"), (int, float)) else 0
|
||||||
|
cur.execute("INSERT INTO sync_messages (key, create_time, payload) VALUES (?,?,?)", (key, ct, json.dumps(r, ensure_ascii=False)))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _conn():
|
||||||
|
c = get_conn()
|
||||||
|
init_schema(c)
|
||||||
|
return c
|
||||||
329
backend/main.py
329
backend/main.py
@@ -28,6 +28,13 @@ SLIDER_VERIFY_BASE_URL = os.getenv("SLIDER_VERIFY_BASE_URL", "http://113.44.162.
|
|||||||
SLIDER_VERIFY_KEY = os.getenv("SLIDER_VERIFY_KEY", os.getenv("KEY", "408449830"))
|
SLIDER_VERIFY_KEY = os.getenv("SLIDER_VERIFY_KEY", os.getenv("KEY", "408449830"))
|
||||||
# 发送文本消息:swagger 中为 POST /message/SendTextMessage,body 为 SendMessageModel(MsgItem 数组)
|
# 发送文本消息:swagger 中为 POST /message/SendTextMessage,body 为 SendMessageModel(MsgItem 数组)
|
||||||
SEND_MSG_PATH = (os.getenv("SEND_MSG_PATH") or "/message/SendTextMessage").strip()
|
SEND_MSG_PATH = (os.getenv("SEND_MSG_PATH") or "/message/SendTextMessage").strip()
|
||||||
|
# 发送图片消息:部分上游为独立接口,或与文本同 path 仅 MsgType 不同(如 3=图片)
|
||||||
|
SEND_IMAGE_PATH = (os.getenv("SEND_IMAGE_PATH") or "").strip() or SEND_MSG_PATH
|
||||||
|
# 联系人列表:7006 为 POST /friend/GetContactList,body 传 CurrentChatRoomContactSeq/CurrentWxcontactSeq=0
|
||||||
|
CONTACT_LIST_PATH = (os.getenv("CONTACT_LIST_PATH") or os.getenv("FRIEND_LIST_PATH") or "/friend/GetContactList").strip()
|
||||||
|
FRIEND_LIST_PATH = (os.getenv("FRIEND_LIST_PATH") or CONTACT_LIST_PATH).strip()
|
||||||
|
# 图片消息 MsgType:部分上游为 0,常见为 3
|
||||||
|
IMAGE_MSG_TYPE = int(os.getenv("IMAGE_MSG_TYPE", "3"))
|
||||||
|
|
||||||
# 按 key 缓存取码结果与 Data62,供后续步骤使用
|
# 按 key 缓存取码结果与 Data62,供后续步骤使用
|
||||||
qrcode_store: dict = {}
|
qrcode_store: dict = {}
|
||||||
@@ -39,15 +46,103 @@ logging.basicConfig(
|
|||||||
logger = logging.getLogger("wechat-backend")
|
logger = logging.getLogger("wechat-backend")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_self_sent(msg: dict) -> bool:
|
||||||
|
"""判断是否为当前账号自己发出的消息(则不由 AI 回复)。"""
|
||||||
|
if msg.get("direction") == "out":
|
||||||
|
return True
|
||||||
|
if msg.get("IsSelf") in (1, True, "1"):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _allowed_ai_reply(key: str, from_user: str) -> bool:
|
||||||
|
"""分级处理:仅超级管理员或白名单内的联系人可获得 AI 回复,其他一律不回复。"""
|
||||||
|
if not from_user or not from_user.strip():
|
||||||
|
return False
|
||||||
|
cfg = store.get_ai_reply_config(key)
|
||||||
|
if not cfg:
|
||||||
|
return False
|
||||||
|
super_admins = set(cfg.get("super_admin_wxids") or [])
|
||||||
|
whitelist = set(cfg.get("whitelist_wxids") or [])
|
||||||
|
return from_user.strip() in super_admins or from_user.strip() in whitelist
|
||||||
|
|
||||||
|
|
||||||
|
async def _ai_takeover_reply(key: str, from_user: str, content: str) -> None:
|
||||||
|
"""收到他人消息时由 AI 接管:生成回复并发送。"""
|
||||||
|
if not from_user or not content or not content.strip():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
recent = store.list_sync_messages(key, limit=10)
|
||||||
|
# 仅取与该用户的最近几条作为上下文(简化:只取最后几条)
|
||||||
|
context = []
|
||||||
|
for m in reversed(recent):
|
||||||
|
c = (m.get("Content") or m.get("content") or "").strip()
|
||||||
|
if not c:
|
||||||
|
continue
|
||||||
|
if m.get("direction") == "out" and (m.get("ToUserName") or "").strip() == from_user:
|
||||||
|
context.append({"role": "assistant", "content": c})
|
||||||
|
elif (m.get("FromUserName") or m.get("from") or "").strip() == from_user and not _is_self_sent(m):
|
||||||
|
context.append({"role": "user", "content": c})
|
||||||
|
if len(context) >= 6:
|
||||||
|
break
|
||||||
|
if not context or context[-1].get("role") != "user":
|
||||||
|
context.append({"role": "user", "content": content})
|
||||||
|
text = await llm_chat(context)
|
||||||
|
if text and text.strip():
|
||||||
|
await _send_message_upstream(key, from_user, text.strip())
|
||||||
|
logger.info("AI takeover replied to %s: %s", from_user[:20], text.strip()[:50])
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("AI takeover reply error (from=%s): %s", from_user, e)
|
||||||
|
|
||||||
|
|
||||||
def _on_ws_message(key: str, data: dict) -> None:
|
def _on_ws_message(key: str, data: dict) -> None:
|
||||||
"""GetSyncMsg 收到数据时:写入 store,便于前端拉取与自动回复逻辑使用。"""
|
"""GetSyncMsg 收到数据时:写入 store;若为他人消息则 AI 接管对话。"""
|
||||||
msg_list = data.get("MsgList") or data.get("List") or data.get("msgList")
|
msg_list = data.get("MsgList") or data.get("List") or data.get("msgList")
|
||||||
if isinstance(msg_list, list) and msg_list:
|
if isinstance(msg_list, list) and msg_list:
|
||||||
store.append_sync_messages(key, msg_list)
|
store.append_sync_messages(key, msg_list)
|
||||||
|
for m in msg_list:
|
||||||
|
if _is_self_sent(m):
|
||||||
|
continue
|
||||||
|
from_user = (m.get("FromUserName") or m.get("from") or "").strip()
|
||||||
|
content = (m.get("Content") or m.get("content") or "").strip()
|
||||||
|
msg_type = m.get("MsgType") or m.get("msgType")
|
||||||
|
if from_user and content and (msg_type in (1, None) or str(msg_type) == "1"): # 仅文本触发 AI
|
||||||
|
if not _allowed_ai_reply(key, from_user):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
asyncio.get_running_loop().create_task(_ai_takeover_reply(key, from_user, content))
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
elif isinstance(data, list):
|
elif isinstance(data, list):
|
||||||
store.append_sync_messages(key, data)
|
store.append_sync_messages(key, data)
|
||||||
|
for m in data:
|
||||||
|
if not isinstance(m, dict) or _is_self_sent(m):
|
||||||
|
continue
|
||||||
|
from_user = (m.get("FromUserName") or m.get("from") or "").strip()
|
||||||
|
content = (m.get("Content") or m.get("content") or "").strip()
|
||||||
|
msg_type = m.get("MsgType") or m.get("msgType")
|
||||||
|
if from_user and content and (msg_type in (1, None) or str(msg_type) == "1"):
|
||||||
|
if not _allowed_ai_reply(key, from_user):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
asyncio.get_running_loop().create_task(_ai_takeover_reply(key, from_user, content))
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
else:
|
else:
|
||||||
store.append_sync_messages(key, [data])
|
store.append_sync_messages(key, [data])
|
||||||
|
m = data if isinstance(data, dict) else {}
|
||||||
|
if not _is_self_sent(m):
|
||||||
|
from_user = (m.get("FromUserName") or m.get("from") or "").strip()
|
||||||
|
content = (m.get("Content") or m.get("content") or "").strip()
|
||||||
|
msg_type = m.get("MsgType") or m.get("msgType")
|
||||||
|
if from_user and content and (msg_type in (1, None) or str(msg_type) == "1"):
|
||||||
|
if not _allowed_ai_reply(key, from_user):
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
asyncio.get_running_loop().create_task(_ai_takeover_reply(key, from_user, content))
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
async def _run_greeting_scheduler() -> None:
|
async def _run_greeting_scheduler() -> None:
|
||||||
@@ -409,6 +504,24 @@ class SendMessageBody(BaseModel):
|
|||||||
content: str
|
content: str
|
||||||
|
|
||||||
|
|
||||||
|
class BatchSendItem(BaseModel):
|
||||||
|
to_user_name: str
|
||||||
|
content: str
|
||||||
|
|
||||||
|
|
||||||
|
class BatchSendBody(BaseModel):
|
||||||
|
key: str
|
||||||
|
items: List[BatchSendItem]
|
||||||
|
|
||||||
|
|
||||||
|
class SendImageBody(BaseModel):
|
||||||
|
key: str
|
||||||
|
to_user_name: str
|
||||||
|
image_content: str # 图片 base64 或 URL,依上游约定
|
||||||
|
text_content: Optional[str] = ""
|
||||||
|
at_wxid_list: Optional[List[str]] = None
|
||||||
|
|
||||||
|
|
||||||
class QwenGenerateBody(BaseModel):
|
class QwenGenerateBody(BaseModel):
|
||||||
prompt: str
|
prompt: str
|
||||||
system: Optional[str] = None
|
system: Optional[str] = None
|
||||||
@@ -588,6 +701,65 @@ async def _send_message_upstream(key: str, to_user_name: str, content: str) -> d
|
|||||||
return {"ok": True, "raw": resp.text[:500]}
|
return {"ok": True, "raw": resp.text[:500]}
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_batch_upstream(key: str, items: List[dict]) -> dict:
|
||||||
|
"""批量发送:一次请求多个 MsgItem,快速分发。"""
|
||||||
|
url = f"{WECHAT_UPSTREAM_BASE_URL.rstrip('/')}{SEND_MSG_PATH}"
|
||||||
|
msg_items = []
|
||||||
|
for it in items:
|
||||||
|
to_user = (it.get("to_user_name") or it.get("ToUserName") or "").strip()
|
||||||
|
content = (it.get("content") or it.get("TextContent") or "").strip()
|
||||||
|
if not to_user:
|
||||||
|
continue
|
||||||
|
msg_items.append({"ToUserName": to_user, "MsgType": 1, "TextContent": content})
|
||||||
|
if not msg_items:
|
||||||
|
raise HTTPException(status_code=400, detail="items 中至少需要一条有效 to_user_name 与 content")
|
||||||
|
payload = {"MsgItem": msg_items}
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
resp = await client.post(url, params={"key": key}, json=payload)
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
body_preview = resp.text[:400] if resp.text else ""
|
||||||
|
logger.warning("Batch send upstream %s: %s", resp.status_code, body_preview)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=502,
|
||||||
|
detail=f"upstream_returned_{resp.status_code}: {body_preview}",
|
||||||
|
)
|
||||||
|
for it in msg_items:
|
||||||
|
store.append_sent_message(key, it["ToUserName"], it.get("TextContent", ""))
|
||||||
|
try:
|
||||||
|
return resp.json()
|
||||||
|
except Exception:
|
||||||
|
return {"ok": True, "sent": len(msg_items), "raw": resp.text[:500]}
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_image_upstream(key: str, to_user_name: str, image_content: str,
|
||||||
|
text_content: Optional[str] = "",
|
||||||
|
at_wxid_list: Optional[List[str]] = None) -> dict:
|
||||||
|
"""发送图片消息:MsgItem 含 ImageContent、MsgType=3(或 0,依上游),可选 TextContent、AtWxIDList。"""
|
||||||
|
url = f"{WECHAT_UPSTREAM_BASE_URL.rstrip('/')}{SEND_IMAGE_PATH}"
|
||||||
|
item = {
|
||||||
|
"ToUserName": to_user_name,
|
||||||
|
"MsgType": IMAGE_MSG_TYPE,
|
||||||
|
"ImageContent": image_content or "",
|
||||||
|
"TextContent": text_content or "",
|
||||||
|
"AtWxIDList": at_wxid_list or [],
|
||||||
|
}
|
||||||
|
payload = {"MsgItem": [item]}
|
||||||
|
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||||
|
resp = await client.post(url, params={"key": key}, json=payload)
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
body_preview = resp.text[:400] if resp.text else ""
|
||||||
|
logger.warning("Send image upstream %s: %s", resp.status_code, body_preview)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=502,
|
||||||
|
detail=f"upstream_returned_{resp.status_code}: {body_preview}",
|
||||||
|
)
|
||||||
|
store.append_sent_message(key, to_user_name, "[图片]" + ((" " + text_content) if text_content else ""))
|
||||||
|
try:
|
||||||
|
return resp.json()
|
||||||
|
except Exception:
|
||||||
|
return {"ok": True, "raw": resp.text[:500]}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/send-message")
|
@app.post("/api/send-message")
|
||||||
async def api_send_message(body: SendMessageBody):
|
async def api_send_message(body: SendMessageBody):
|
||||||
try:
|
try:
|
||||||
@@ -599,6 +771,161 @@ async def api_send_message(body: SendMessageBody):
|
|||||||
raise HTTPException(status_code=502, detail=f"upstream_error: {exc}") from exc
|
raise HTTPException(status_code=502, detail=f"upstream_error: {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/send-batch")
|
||||||
|
async def api_send_batch(body: BatchSendBody):
|
||||||
|
"""快速群发:一次请求批量发送给多人,支持从好友/客户列表选择后调用。"""
|
||||||
|
items = [{"to_user_name": it.to_user_name, "content": it.content} for it in body.items]
|
||||||
|
try:
|
||||||
|
return await _send_batch_upstream(body.key, items)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Batch send error: %s", exc)
|
||||||
|
raise HTTPException(status_code=502, detail=f"upstream_error: {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/send-image")
|
||||||
|
async def api_send_image(body: SendImageBody):
|
||||||
|
"""发送图片消息快捷方式,参数对应 MsgItem:ImageContent、TextContent、ToUserName、AtWxIDList。"""
|
||||||
|
try:
|
||||||
|
return await _send_image_upstream(
|
||||||
|
body.key,
|
||||||
|
body.to_user_name,
|
||||||
|
body.image_content,
|
||||||
|
text_content=body.text_content or "",
|
||||||
|
at_wxid_list=body.at_wxid_list,
|
||||||
|
)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Send image error: %s", exc)
|
||||||
|
raise HTTPException(status_code=502, detail=f"upstream_error: {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_contact_list(raw: Any) -> List[dict]:
|
||||||
|
"""将上游 GetContactList 多种返回格式统一为 [ { wxid, remark_name, ... } ]。"""
|
||||||
|
items = []
|
||||||
|
if isinstance(raw, list):
|
||||||
|
items = raw
|
||||||
|
elif isinstance(raw, dict):
|
||||||
|
data = raw.get("Data") or raw.get("data") or raw
|
||||||
|
if isinstance(data, list):
|
||||||
|
items = data
|
||||||
|
elif isinstance(data, dict):
|
||||||
|
items = (
|
||||||
|
data.get("ContactList")
|
||||||
|
or data.get("contactList")
|
||||||
|
or data.get("WxcontactList")
|
||||||
|
or data.get("wxcontactList")
|
||||||
|
or data.get("CachedContactList")
|
||||||
|
or data.get("List")
|
||||||
|
or data.get("list")
|
||||||
|
or data.get("items")
|
||||||
|
or []
|
||||||
|
)
|
||||||
|
items = items or raw.get("items") or raw.get("list") or raw.get("List") or []
|
||||||
|
result = []
|
||||||
|
for x in items:
|
||||||
|
if not isinstance(x, dict):
|
||||||
|
continue
|
||||||
|
wxid = (
|
||||||
|
x.get("wxid")
|
||||||
|
or x.get("Wxid")
|
||||||
|
or x.get("UserName")
|
||||||
|
or x.get("userName")
|
||||||
|
or x.get("Alias")
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
remark = (
|
||||||
|
x.get("remark_name")
|
||||||
|
or x.get("RemarkName")
|
||||||
|
or x.get("NickName")
|
||||||
|
or x.get("nickName")
|
||||||
|
or x.get("DisplayName")
|
||||||
|
or wxid
|
||||||
|
)
|
||||||
|
result.append({"wxid": wxid, "remark_name": remark, **{k: v for k, v in x.items() if k not in ("wxid", "Wxid", "remark_name", "RemarkName")}})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# 上游 GetContactList 请求体:CurrentChatRoomContactSeq、CurrentWxcontactSeq 传 0 表示拉取全量
|
||||||
|
GET_CONTACT_LIST_BODY = {"CurrentChatRoomContactSeq": 0, "CurrentWxcontactSeq": 0}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/contact-list")
|
||||||
|
async def api_contact_list(key: str = Query(..., description="账号 key")):
|
||||||
|
"""获取全部联系人:POST 上游,body 为 CurrentChatRoomContactSeq/CurrentWxcontactSeq=0,key 走 query。"""
|
||||||
|
base = WECHAT_UPSTREAM_BASE_URL.rstrip("/")
|
||||||
|
path = CONTACT_LIST_PATH if CONTACT_LIST_PATH.startswith("/") else f"/{CONTACT_LIST_PATH}"
|
||||||
|
url = f"{base}{path}"
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
url,
|
||||||
|
params={"key": key},
|
||||||
|
json=GET_CONTACT_LIST_BODY,
|
||||||
|
)
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
logger.warning("GetContactList %s: %s", resp.status_code, resp.text[:200])
|
||||||
|
return {"items": [], "error": resp.text[:200]}
|
||||||
|
raw = resp.json()
|
||||||
|
# 日志便于确认 7006 返回结构(不打印完整列表)
|
||||||
|
if isinstance(raw, dict):
|
||||||
|
data = raw.get("Data") or raw.get("data")
|
||||||
|
data_keys = list(data.keys()) if isinstance(data, dict) else getattr(data, "__name__", type(data).__name__)
|
||||||
|
logger.info("GetContactList response keys: raw=%s, Data=%s", list(raw.keys()), data_keys)
|
||||||
|
items = _normalize_contact_list(raw)
|
||||||
|
if not items and isinstance(raw, dict):
|
||||||
|
items = _normalize_contact_list(raw.get("Data") or raw.get("data") or raw)
|
||||||
|
logger.info("GetContactList normalized items count: %s", len(items))
|
||||||
|
return {"items": items}
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("GetContactList error: %s", e)
|
||||||
|
return {"items": [], "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/friends")
|
||||||
|
async def api_list_friends(key: str = Query(..., description="账号 key")):
|
||||||
|
"""好友列表:代理上游联系人接口,与 /api/contact-list 同源;否则返回客户档案。"""
|
||||||
|
return await api_contact_list(key)
|
||||||
|
|
||||||
|
|
||||||
|
def _friends_fallback(key: str) -> List[dict]:
|
||||||
|
"""用客户档案作为可选联系人,便于在管理页选择群发对象。"""
|
||||||
|
customers = store.list_customers(key)
|
||||||
|
return [
|
||||||
|
{"wxid": c.get("wxid"), "remark_name": c.get("remark_name") or c.get("wxid"), "id": c.get("id")}
|
||||||
|
for c in customers
|
||||||
|
if c.get("wxid")
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- AI 接管回复配置(白名单 + 超级管理员) ----------
|
||||||
|
class AIReplyConfigUpdate(BaseModel):
|
||||||
|
key: str
|
||||||
|
super_admin_wxids: Optional[List[str]] = None
|
||||||
|
whitelist_wxids: Optional[List[str]] = None
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/ai-reply-config")
|
||||||
|
async def api_get_ai_reply_config(key: str = Query(..., description="账号 key")):
|
||||||
|
"""获取当前账号的 AI 回复配置:超级管理员与白名单 wxid 列表。"""
|
||||||
|
cfg = store.get_ai_reply_config(key)
|
||||||
|
if not cfg:
|
||||||
|
return {"key": key, "super_admin_wxids": [], "whitelist_wxids": []}
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
@app.patch("/api/ai-reply-config")
|
||||||
|
async def api_update_ai_reply_config(body: AIReplyConfigUpdate):
|
||||||
|
"""设置 AI 回复白名单与超级管理员:仅列表内联系人会收到 AI 自动回复。"""
|
||||||
|
return store.update_ai_reply_config(
|
||||||
|
body.key,
|
||||||
|
super_admin_wxids=body.super_admin_wxids,
|
||||||
|
whitelist_wxids=body.whitelist_wxids,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------- 模型管理(多模型切换,API Key 按模型配置) ----------
|
# ---------- 模型管理(多模型切换,API Key 按模型配置) ----------
|
||||||
class ModelCreate(BaseModel):
|
class ModelCreate(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
|
|||||||
711
backend/store.py
711
backend/store.py
@@ -1,115 +1,143 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""JSON 文件存储:客户档案、定时问候任务、商品标签、推送群组、推送任务、同步消息。"""
|
"""数据库存储:客户档案、定时问候、商品标签、推送群组/任务、同步消息、模型、AI 回复配置。使用 SQLite,便于增删改查。"""
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
_DATA_DIR = os.path.join(os.path.dirname(__file__), "data")
|
try:
|
||||||
|
from backend import db
|
||||||
|
except ImportError:
|
||||||
|
import db
|
||||||
|
|
||||||
_LOCK = threading.Lock()
|
_LOCK = threading.Lock()
|
||||||
|
|
||||||
def _path(name: str) -> str:
|
def _conn():
|
||||||
os.makedirs(_DATA_DIR, exist_ok=True)
|
conn = db.get_conn()
|
||||||
return os.path.join(_DATA_DIR, f"{name}.json")
|
db.init_schema(conn)
|
||||||
|
return conn
|
||||||
|
|
||||||
|
def _row_to_dict(row) -> dict:
|
||||||
def _load(name: str) -> list:
|
if row is None:
|
||||||
with _LOCK:
|
return {}
|
||||||
p = _path(name)
|
d = dict(row)
|
||||||
if not os.path.exists(p):
|
out = {}
|
||||||
return []
|
for k, v in d.items():
|
||||||
|
if k in ("tags", "customer_tags", "customer_ids", "tag_ids", "super_admin_wxids", "whitelist_wxids") and isinstance(v, str):
|
||||||
try:
|
try:
|
||||||
with open(p, "r", encoding="utf-8") as f:
|
out[k] = json.loads(v) if v else []
|
||||||
return json.load(f)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
return []
|
out[k] = []
|
||||||
|
elif k in ("use_qwen", "enabled", "is_current") and v is not None:
|
||||||
|
out[k] = bool(v)
|
||||||
|
else:
|
||||||
|
out[k] = v
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _save(name: str, data: list) -> None:
|
# ---------- 客户档案 ----------
|
||||||
with _LOCK:
|
|
||||||
p = _path(name)
|
|
||||||
with open(p, "w", encoding="utf-8") as f:
|
|
||||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------- 客户档案 R1-2 ----------
|
|
||||||
def list_customers(key: Optional[str] = None) -> List[Dict]:
|
def list_customers(key: Optional[str] = None) -> List[Dict]:
|
||||||
"""key: 微信 key,若传则只返回该 key 下的客户。"""
|
with _LOCK:
|
||||||
rows = _load("customers")
|
conn = _conn()
|
||||||
|
try:
|
||||||
if key:
|
if key:
|
||||||
rows = [r for r in rows if r.get("key") == key]
|
cur = conn.execute("SELECT * FROM customers WHERE key = ? ORDER BY remark_name, wxid", (key,))
|
||||||
return sorted(rows, key=lambda x: (x.get("remark_name") or x.get("wxid") or ""))
|
else:
|
||||||
|
cur = conn.execute("SELECT * FROM customers ORDER BY remark_name, wxid")
|
||||||
|
return [_row_to_dict(r) for r in cur.fetchall()]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
def get_customer(customer_id: str) -> Optional[Dict]:
|
def get_customer(customer_id: str) -> Optional[Dict]:
|
||||||
rows = _load("customers")
|
with _LOCK:
|
||||||
for r in rows:
|
conn = _conn()
|
||||||
if r.get("id") == customer_id:
|
try:
|
||||||
return r
|
cur = conn.execute("SELECT * FROM customers WHERE id = ?", (customer_id,))
|
||||||
return None
|
row = cur.fetchone()
|
||||||
|
return _row_to_dict(row) if row else None
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
def upsert_customer(key: str, wxid: str, remark_name: str = "", region: str = "",
|
def upsert_customer(key: str, wxid: str, remark_name: str = "", region: str = "",
|
||||||
age: str = "", gender: str = "", level: str = "", tags: Optional[List[str]] = None,
|
age: str = "", gender: str = "", level: str = "", tags: Optional[List[str]] = None,
|
||||||
customer_id: Optional[str] = None) -> Dict:
|
customer_id: Optional[str] = None) -> Dict:
|
||||||
"""拿货等级 level;tags 为标签列表,用于分群与问候。"""
|
tags = tags or []
|
||||||
rows = _load("customers")
|
|
||||||
if tags is None:
|
|
||||||
tags = []
|
|
||||||
rid = customer_id or str(uuid.uuid4())
|
rid = customer_id or str(uuid.uuid4())
|
||||||
for r in rows:
|
tags_json = json.dumps(tags, ensure_ascii=False)
|
||||||
if r.get("id") == rid or (r.get("key") == key and r.get("wxid") == wxid and not customer_id):
|
with _LOCK:
|
||||||
r.update({
|
conn = _conn()
|
||||||
"key": key, "wxid": wxid, "remark_name": remark_name, "region": region,
|
try:
|
||||||
"age": age, "gender": gender, "level": level, "tags": tags,
|
if customer_id:
|
||||||
})
|
conn.execute(
|
||||||
_save("customers", rows)
|
"UPDATE customers SET key=?, wxid=?, remark_name=?, region=?, age=?, gender=?, level=?, tags=? WHERE id=?",
|
||||||
return r
|
(key, wxid, remark_name, region, age, gender, level, tags_json, customer_id)
|
||||||
new_row = {
|
)
|
||||||
"id": rid, "key": key, "wxid": wxid, "remark_name": remark_name,
|
conn.commit()
|
||||||
"region": region, "age": age, "gender": gender, "level": level, "tags": tags,
|
cur = conn.execute("SELECT * FROM customers WHERE id = ?", (customer_id,))
|
||||||
}
|
return _row_to_dict(cur.fetchone())
|
||||||
rows.append(new_row)
|
cur = conn.execute("SELECT id FROM customers WHERE key = ? AND wxid = ?", (key, wxid))
|
||||||
_save("customers", rows)
|
row = cur.fetchone()
|
||||||
return new_row
|
if row:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE customers SET remark_name=?, region=?, age=?, gender=?, level=?, tags=? WHERE id=?",
|
||||||
|
(remark_name, region, age, gender, level, tags_json, row["id"])
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
cur = conn.execute("SELECT * FROM customers WHERE id = ?", (row["id"],))
|
||||||
|
return _row_to_dict(cur.fetchone())
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO customers (id, key, wxid, remark_name, region, age, gender, level, tags) VALUES (?,?,?,?,?,?,?,?,?)",
|
||||||
|
(rid, key, wxid, remark_name, region, age, gender, level, tags_json)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
cur = conn.execute("SELECT * FROM customers WHERE id = ?", (rid,))
|
||||||
|
return _row_to_dict(cur.fetchone())
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
def delete_customer(customer_id: str) -> bool:
|
def delete_customer(customer_id: str) -> bool:
|
||||||
rows = _load("customers")
|
with _LOCK:
|
||||||
for i, r in enumerate(rows):
|
conn = _conn()
|
||||||
if r.get("id") == customer_id:
|
try:
|
||||||
rows.pop(i)
|
cur = conn.execute("DELETE FROM customers WHERE id = ?", (customer_id,))
|
||||||
_save("customers", rows)
|
conn.commit()
|
||||||
return True
|
return cur.rowcount > 0
|
||||||
return False
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
def list_customer_tags(key: str) -> List[str]:
|
def list_customer_tags(key: str) -> List[str]:
|
||||||
"""返回该 key 下客户档案中出现过的所有标签(去重、排序)。"""
|
rows = [r for r in list_customers(key=key) if r]
|
||||||
rows = [r for r in _load("customers") if r.get("key") == key]
|
|
||||||
tags_set = set()
|
tags_set = set()
|
||||||
for r in rows:
|
for r in rows:
|
||||||
for t in r.get("tags") or []:
|
for t in (r.get("tags") or []):
|
||||||
if t and str(t).strip():
|
if t and str(t).strip():
|
||||||
tags_set.add(str(t).strip())
|
tags_set.add(str(t).strip())
|
||||||
return sorted(tags_set)
|
return sorted(tags_set)
|
||||||
|
|
||||||
|
|
||||||
# ---------- 定时问候任务 R1-3 ----------
|
# ---------- 定时问候任务 ----------
|
||||||
def list_greeting_tasks(key: Optional[str] = None) -> List[Dict]:
|
def list_greeting_tasks(key: Optional[str] = None) -> List[Dict]:
|
||||||
rows = _load("greeting_tasks")
|
with _LOCK:
|
||||||
|
conn = _conn()
|
||||||
|
try:
|
||||||
if key:
|
if key:
|
||||||
rows = [r for r in rows if r.get("key") == key]
|
cur = conn.execute("SELECT * FROM greeting_tasks WHERE key = ? ORDER BY send_time", (key,))
|
||||||
return sorted(rows, key=lambda x: x.get("send_time", "") or x.get("cron", ""))
|
else:
|
||||||
|
cur = conn.execute("SELECT * FROM greeting_tasks ORDER BY send_time")
|
||||||
|
return [_row_to_dict(r) for r in cur.fetchall()]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
def get_greeting_task(task_id: str) -> Optional[Dict]:
|
def get_greeting_task(task_id: str) -> Optional[Dict]:
|
||||||
for r in _load("greeting_tasks"):
|
with _LOCK:
|
||||||
if r.get("id") == task_id:
|
conn = _conn()
|
||||||
return r
|
try:
|
||||||
return None
|
cur = conn.execute("SELECT * FROM greeting_tasks WHERE id = ?", (task_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
return _row_to_dict(row) if row else None
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
def create_greeting_task(key: str, name: str, send_time: str, customer_tags: List[str],
|
def create_greeting_task(key: str, name: str, send_time: str, customer_tags: List[str],
|
||||||
template: str, use_qwen: bool = False) -> Dict:
|
template: str, use_qwen: bool = False) -> Dict:
|
||||||
@@ -119,187 +147,291 @@ def create_greeting_task(key: str, name: str, send_time: str, customer_tags: Lis
|
|||||||
"customer_tags": customer_tags or [], "template": template, "use_qwen": use_qwen,
|
"customer_tags": customer_tags or [], "template": template, "use_qwen": use_qwen,
|
||||||
"enabled": True, "executed_at": None,
|
"enabled": True, "executed_at": None,
|
||||||
}
|
}
|
||||||
rows = _load("greeting_tasks")
|
with _LOCK:
|
||||||
rows.append(row)
|
conn = _conn()
|
||||||
_save("greeting_tasks", rows)
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO greeting_tasks (id, key, name, send_time, customer_tags, template, use_qwen, enabled, executed_at) VALUES (?,?,?,?,?,?,?,?,?)",
|
||||||
|
(rid, key, name, send_time, json.dumps(customer_tags or [], ensure_ascii=False), template, 1 if use_qwen else 0, 1, None)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
return row
|
return row
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
def update_greeting_task(task_id: str, **kwargs) -> Optional[Dict]:
|
def update_greeting_task(task_id: str, **kwargs) -> Optional[Dict]:
|
||||||
rows = _load("greeting_tasks")
|
allowed = ("name", "send_time", "cron", "customer_tags", "template", "use_qwen", "enabled", "executed_at")
|
||||||
for r in rows:
|
with _LOCK:
|
||||||
if r.get("id") == task_id:
|
conn = _conn()
|
||||||
for k, v in kwargs.items():
|
try:
|
||||||
if k in ("name", "send_time", "cron", "customer_tags", "template", "use_qwen", "enabled", "executed_at"):
|
cur = conn.execute("SELECT * FROM greeting_tasks WHERE id = ?", (task_id,))
|
||||||
r[k] = v
|
row = cur.fetchone()
|
||||||
_save("greeting_tasks", rows)
|
if not row:
|
||||||
return r
|
|
||||||
return None
|
return None
|
||||||
|
updates = []
|
||||||
|
params = []
|
||||||
|
if "send_time" in kwargs:
|
||||||
|
updates.append("send_time = ?")
|
||||||
|
params.append(kwargs["send_time"])
|
||||||
|
if "cron" in kwargs:
|
||||||
|
updates.append("send_time = ?")
|
||||||
|
params.append(kwargs["cron"])
|
||||||
|
for k in ("name", "customer_tags", "template", "use_qwen", "enabled", "executed_at"):
|
||||||
|
if k in kwargs:
|
||||||
|
v = kwargs[k]
|
||||||
|
if k == "customer_tags":
|
||||||
|
updates.append("customer_tags = ?")
|
||||||
|
params.append(json.dumps(v, ensure_ascii=False))
|
||||||
|
elif k == "use_qwen":
|
||||||
|
updates.append("use_qwen = ?")
|
||||||
|
params.append(1 if v else 0)
|
||||||
|
elif k == "enabled":
|
||||||
|
updates.append("enabled = ?")
|
||||||
|
params.append(1 if v else 0)
|
||||||
|
else:
|
||||||
|
updates.append(f"{k} = ?")
|
||||||
|
params.append(v)
|
||||||
|
if not updates:
|
||||||
|
return _row_to_dict(row)
|
||||||
|
params.append(task_id)
|
||||||
|
conn.execute(f"UPDATE greeting_tasks SET {', '.join(updates)} WHERE id = ?", params)
|
||||||
|
conn.commit()
|
||||||
|
cur = conn.execute("SELECT * FROM greeting_tasks WHERE id = ?", (task_id,))
|
||||||
|
return _row_to_dict(cur.fetchone())
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
def delete_greeting_task(task_id: str) -> bool:
|
def delete_greeting_task(task_id: str) -> bool:
|
||||||
rows = _load("greeting_tasks")
|
with _LOCK:
|
||||||
for i, r in enumerate(rows):
|
conn = _conn()
|
||||||
if r.get("id") == task_id:
|
try:
|
||||||
rows.pop(i)
|
cur = conn.execute("DELETE FROM greeting_tasks WHERE id = ?", (task_id,))
|
||||||
_save("greeting_tasks", rows)
|
conn.commit()
|
||||||
return True
|
return cur.rowcount > 0
|
||||||
return False
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
# ---------- 商品标签 R1-4 ----------
|
# ---------- 商品标签 ----------
|
||||||
def list_product_tags(key: Optional[str] = None) -> List[Dict]:
|
def list_product_tags(key: Optional[str] = None) -> List[Dict]:
|
||||||
rows = _load("product_tags")
|
with _LOCK:
|
||||||
|
conn = _conn()
|
||||||
|
try:
|
||||||
if key:
|
if key:
|
||||||
rows = [r for r in rows if r.get("key") == key]
|
cur = conn.execute("SELECT * FROM product_tags WHERE key = ?", (key,))
|
||||||
return rows
|
else:
|
||||||
|
cur = conn.execute("SELECT * FROM product_tags")
|
||||||
|
return [_row_to_dict(r) for r in cur.fetchall()]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
def create_product_tag(key: str, name: str) -> Dict:
|
def create_product_tag(key: str, name: str) -> Dict:
|
||||||
rid = str(uuid.uuid4())
|
rid = str(uuid.uuid4())
|
||||||
row = {"id": rid, "key": key, "name": name}
|
with _LOCK:
|
||||||
rows = _load("product_tags")
|
conn = _conn()
|
||||||
rows.append(row)
|
try:
|
||||||
_save("product_tags", rows)
|
conn.execute("INSERT INTO product_tags (id, key, name) VALUES (?,?,?)", (rid, key, name))
|
||||||
return row
|
conn.commit()
|
||||||
|
cur = conn.execute("SELECT * FROM product_tags WHERE id = ?", (rid,))
|
||||||
|
return _row_to_dict(cur.fetchone())
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
def delete_product_tag(tag_id: str) -> bool:
|
def delete_product_tag(tag_id: str) -> bool:
|
||||||
rows = _load("product_tags")
|
with _LOCK:
|
||||||
for i, r in enumerate(rows):
|
conn = _conn()
|
||||||
if r.get("id") == tag_id:
|
try:
|
||||||
rows.pop(i)
|
cur = conn.execute("DELETE FROM product_tags WHERE id = ?", (tag_id,))
|
||||||
_save("product_tags", rows)
|
conn.commit()
|
||||||
return True
|
return cur.rowcount > 0
|
||||||
return False
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
# ---------- 推送群组(客户群组) ----------
|
# ---------- 推送群组 ----------
|
||||||
def list_push_groups(key: Optional[str] = None) -> List[Dict]:
|
def list_push_groups(key: Optional[str] = None) -> List[Dict]:
|
||||||
rows = _load("push_groups")
|
with _LOCK:
|
||||||
|
conn = _conn()
|
||||||
|
try:
|
||||||
if key:
|
if key:
|
||||||
rows = [r for r in rows if r.get("key") == key]
|
cur = conn.execute("SELECT * FROM push_groups WHERE key = ?", (key,))
|
||||||
return rows
|
else:
|
||||||
|
cur = conn.execute("SELECT * FROM push_groups")
|
||||||
|
return [_row_to_dict(r) for r in cur.fetchall()]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
def create_push_group(key: str, name: str, customer_ids: List[str], tag_ids: List[str]) -> Dict:
|
def create_push_group(key: str, name: str, customer_ids: List[str], tag_ids: List[str]) -> Dict:
|
||||||
rid = str(uuid.uuid4())
|
rid = str(uuid.uuid4())
|
||||||
row = {"id": rid, "key": key, "name": name, "customer_ids": customer_ids or [], "tag_ids": tag_ids or []}
|
with _LOCK:
|
||||||
rows = _load("push_groups")
|
conn = _conn()
|
||||||
rows.append(row)
|
try:
|
||||||
_save("push_groups", rows)
|
conn.execute(
|
||||||
return row
|
"INSERT INTO push_groups (id, key, name, customer_ids, tag_ids) VALUES (?,?,?,?,?)",
|
||||||
|
(rid, key, name, json.dumps(customer_ids or [], ensure_ascii=False), json.dumps(tag_ids or [], ensure_ascii=False))
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
cur = conn.execute("SELECT * FROM push_groups WHERE id = ?", (rid,))
|
||||||
|
return _row_to_dict(cur.fetchone())
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
def update_push_group(group_id: str, name: Optional[str] = None, customer_ids: Optional[List[str]] = None,
|
def update_push_group(group_id: str, name: Optional[str] = None, customer_ids: Optional[List[str]] = None,
|
||||||
tag_ids: Optional[List[str]] = None) -> Optional[Dict]:
|
tag_ids: Optional[List[str]] = None) -> Optional[Dict]:
|
||||||
rows = _load("push_groups")
|
with _LOCK:
|
||||||
for r in rows:
|
conn = _conn()
|
||||||
if r.get("id") == group_id:
|
try:
|
||||||
if name is not None:
|
cur = conn.execute("SELECT * FROM push_groups WHERE id = ?", (group_id,))
|
||||||
r["name"] = name
|
if cur.fetchone() is None:
|
||||||
if customer_ids is not None:
|
|
||||||
r["customer_ids"] = customer_ids
|
|
||||||
if tag_ids is not None:
|
|
||||||
r["tag_ids"] = tag_ids
|
|
||||||
_save("push_groups", rows)
|
|
||||||
return r
|
|
||||||
return None
|
return None
|
||||||
|
updates, params = [], []
|
||||||
|
if name is not None:
|
||||||
|
updates.append("name = ?")
|
||||||
|
params.append(name)
|
||||||
|
if customer_ids is not None:
|
||||||
|
updates.append("customer_ids = ?")
|
||||||
|
params.append(json.dumps(customer_ids, ensure_ascii=False))
|
||||||
|
if tag_ids is not None:
|
||||||
|
updates.append("tag_ids = ?")
|
||||||
|
params.append(json.dumps(tag_ids, ensure_ascii=False))
|
||||||
|
if not updates:
|
||||||
|
cur = conn.execute("SELECT * FROM push_groups WHERE id = ?", (group_id,))
|
||||||
|
return _row_to_dict(cur.fetchone())
|
||||||
|
params.append(group_id)
|
||||||
|
conn.execute(f"UPDATE push_groups SET {', '.join(updates)} WHERE id = ?", params)
|
||||||
|
conn.commit()
|
||||||
|
cur = conn.execute("SELECT * FROM push_groups WHERE id = ?", (group_id,))
|
||||||
|
return _row_to_dict(cur.fetchone())
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
def delete_push_group(group_id: str) -> bool:
|
def delete_push_group(group_id: str) -> bool:
|
||||||
rows = _load("push_groups")
|
with _LOCK:
|
||||||
for i, r in enumerate(rows):
|
conn = _conn()
|
||||||
if r.get("id") == group_id:
|
try:
|
||||||
rows.pop(i)
|
cur = conn.execute("DELETE FROM push_groups WHERE id = ?", (group_id,))
|
||||||
_save("push_groups", rows)
|
conn.commit()
|
||||||
return True
|
return cur.rowcount > 0
|
||||||
return False
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
# ---------- 推送任务(一键/定时发送) ----------
|
# ---------- 推送任务 ----------
|
||||||
def list_push_tasks(key: Optional[str] = None, limit: int = 200) -> List[Dict]:
|
def list_push_tasks(key: Optional[str] = None, limit: int = 200) -> List[Dict]:
|
||||||
rows = _load("push_tasks")
|
with _LOCK:
|
||||||
|
conn = _conn()
|
||||||
|
try:
|
||||||
if key:
|
if key:
|
||||||
rows = [r for r in rows if r.get("key") == key]
|
cur = conn.execute("SELECT * FROM push_tasks WHERE key = ? ORDER BY created_at DESC LIMIT ?", (key, limit))
|
||||||
rows = sorted(rows, key=lambda x: x.get("created_at", ""), reverse=True)
|
else:
|
||||||
return rows[:limit]
|
cur = conn.execute("SELECT * FROM push_tasks ORDER BY created_at DESC LIMIT ?", (limit,))
|
||||||
|
return [_row_to_dict(r) for r in cur.fetchall()]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
def create_push_task(key: str, product_tag_id: str, group_id: str, content: str,
|
def create_push_task(key: str, product_tag_id: str, group_id: str, content: str,
|
||||||
send_at: Optional[str] = None) -> Dict:
|
send_at: Optional[str] = None) -> Dict:
|
||||||
rid = str(uuid.uuid4())
|
rid = str(uuid.uuid4())
|
||||||
import time
|
created = time.strftime("%Y-%m-%dT%H:%M:%S")
|
||||||
row = {
|
with _LOCK:
|
||||||
"id": rid, "key": key, "product_tag_id": product_tag_id, "group_id": group_id,
|
conn = _conn()
|
||||||
"content": content, "send_at": send_at, "status": "pending",
|
try:
|
||||||
"created_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
conn.execute(
|
||||||
}
|
"INSERT INTO push_tasks (id, key, product_tag_id, group_id, content, send_at, status, created_at) VALUES (?,?,?,?,?,?,?,?)",
|
||||||
rows = _load("push_tasks")
|
(rid, key, product_tag_id, group_id, content, send_at, "pending", created)
|
||||||
rows.append(row)
|
)
|
||||||
_save("push_tasks", rows)
|
conn.commit()
|
||||||
return row
|
cur = conn.execute("SELECT * FROM push_tasks WHERE id = ?", (rid,))
|
||||||
|
return _row_to_dict(cur.fetchone())
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
def update_push_task_status(task_id: str, status: str) -> Optional[Dict]:
|
def update_push_task_status(task_id: str, status: str) -> Optional[Dict]:
|
||||||
rows = _load("push_tasks")
|
with _LOCK:
|
||||||
for r in rows:
|
conn = _conn()
|
||||||
if r.get("id") == task_id:
|
try:
|
||||||
r["status"] = status
|
conn.execute("UPDATE push_tasks SET status = ? WHERE id = ?", (status, task_id))
|
||||||
_save("push_tasks", rows)
|
conn.commit()
|
||||||
return r
|
cur = conn.execute("SELECT * FROM push_tasks WHERE id = ?", (task_id,))
|
||||||
return None
|
row = cur.fetchone()
|
||||||
|
return _row_to_dict(row) if row else None
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
# ---------- WS 同步消息(GetSyncMsg 结果) ----------
|
# ---------- 同步消息 ----------
|
||||||
def append_sync_messages(key: str, messages: List[Dict], max_per_key: int = 500) -> None:
|
def append_sync_messages(key: str, messages: List[Dict], max_per_key: int = 500) -> None:
|
||||||
rows = _load("sync_messages")
|
with _LOCK:
|
||||||
|
conn = _conn()
|
||||||
|
try:
|
||||||
for m in messages:
|
for m in messages:
|
||||||
m["key"] = key
|
create_time = int(m.get("CreateTime") or 0) if isinstance(m.get("CreateTime"), (int, float)) else 0
|
||||||
rows.append(m)
|
conn.execute("INSERT INTO sync_messages (key, create_time, payload) VALUES (?,?,?)",
|
||||||
by_key: Dict[str, List[Dict]] = {}
|
(key, create_time, json.dumps(m, ensure_ascii=False)))
|
||||||
for m in rows:
|
conn.commit()
|
||||||
k = m.get("key", "")
|
# 每个 key 只保留最近 max_per_key 条
|
||||||
by_key.setdefault(k, []).append(m)
|
cur = conn.execute("SELECT id FROM sync_messages WHERE key = ? ORDER BY create_time DESC", (key,))
|
||||||
new_rows = []
|
rows = cur.fetchall()
|
||||||
for lst in by_key.values():
|
if len(rows) > max_per_key:
|
||||||
new_rows.extend(lst[-max_per_key:])
|
to_del = [r["id"] for r in rows[max_per_key:]]
|
||||||
_save("sync_messages", new_rows)
|
placeholders = ",".join("?" * len(to_del))
|
||||||
|
conn.execute(f"DELETE FROM sync_messages WHERE id IN ({placeholders})", to_del)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
def list_sync_messages(key: str, limit: int = 100) -> List[Dict]:
|
def list_sync_messages(key: str, limit: int = 100) -> List[Dict]:
|
||||||
rows = _load("sync_messages")
|
with _LOCK:
|
||||||
rows = [r for r in rows if r.get("key") == key]
|
conn = _conn()
|
||||||
# 统一按 CreateTime 排序(支持 int 时间戳与其它格式),新消息在前
|
try:
|
||||||
rows = sorted(rows, key=lambda x: int(x.get("CreateTime") or 0) if isinstance(x.get("CreateTime"), (int, float)) else 0, reverse=True)
|
cur = conn.execute(
|
||||||
return rows[:limit]
|
"SELECT payload FROM sync_messages WHERE key = ? ORDER BY create_time DESC LIMIT ?",
|
||||||
|
(key, limit)
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
out = []
|
||||||
|
for r in rows:
|
||||||
|
try:
|
||||||
|
out.append(json.loads(r["payload"]))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return out
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
def append_sent_message(key: str, to_user_name: str, content: str) -> None:
|
def append_sent_message(key: str, to_user_name: str, content: str) -> None:
|
||||||
"""发送消息成功后写入一条「发出」记录,便于在实时消息页展示完整对话。"""
|
|
||||||
import time
|
|
||||||
append_sync_messages(key, [{"direction": "out", "ToUserName": to_user_name, "Content": content, "CreateTime": int(time.time())}])
|
append_sync_messages(key, [{"direction": "out", "ToUserName": to_user_name, "Content": content, "CreateTime": int(time.time())}])
|
||||||
|
|
||||||
|
|
||||||
# ---------- 模型管理(多模型切换,API Key 按模型配置) ----------
|
# ---------- 模型 ----------
|
||||||
def list_models() -> List[Dict]:
|
def list_models() -> List[Dict]:
|
||||||
rows = _load("models")
|
with _LOCK:
|
||||||
return sorted(rows, key=lambda x: (not x.get("is_current"), x.get("name") or ""))
|
conn = _conn()
|
||||||
|
try:
|
||||||
|
cur = conn.execute("SELECT * FROM models ORDER BY is_current DESC, name")
|
||||||
|
return [_row_to_dict(r) for r in cur.fetchall()]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
def get_model(model_id: str) -> Optional[Dict]:
|
def get_model(model_id: str) -> Optional[Dict]:
|
||||||
for r in _load("models"):
|
with _LOCK:
|
||||||
if r.get("id") == model_id:
|
conn = _conn()
|
||||||
return r
|
try:
|
||||||
return None
|
cur = conn.execute("SELECT * FROM models WHERE id = ?", (model_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
return _row_to_dict(row) if row else None
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
def get_current_model() -> Optional[Dict]:
|
def get_current_model() -> Optional[Dict]:
|
||||||
for r in _load("models"):
|
with _LOCK:
|
||||||
if r.get("is_current"):
|
conn = _conn()
|
||||||
return r
|
try:
|
||||||
return None
|
cur = conn.execute("SELECT * FROM models WHERE is_current = 1 LIMIT 1")
|
||||||
|
row = cur.fetchone()
|
||||||
|
return _row_to_dict(row) if row else None
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
def create_model(
|
def create_model(
|
||||||
name: str,
|
name: str,
|
||||||
@@ -309,36 +441,35 @@ def create_model(
|
|||||||
model_name: str = "",
|
model_name: str = "",
|
||||||
is_current: bool = False,
|
is_current: bool = False,
|
||||||
) -> Dict:
|
) -> Dict:
|
||||||
rows = _load("models")
|
with _LOCK:
|
||||||
|
conn = _conn()
|
||||||
|
try:
|
||||||
if is_current:
|
if is_current:
|
||||||
for r in rows:
|
conn.execute("UPDATE models SET is_current = 0")
|
||||||
r["is_current"] = False
|
|
||||||
rid = str(uuid.uuid4())
|
rid = str(uuid.uuid4())
|
||||||
|
default_base = "https://api.openai.com/v1"
|
||||||
|
default_model = "gpt-3.5-turbo"
|
||||||
if provider == "qwen":
|
if provider == "qwen":
|
||||||
default_base = "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
default_base = "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
||||||
default_model = "qwen-turbo"
|
default_model = "qwen-turbo"
|
||||||
elif provider == "doubao":
|
elif provider == "doubao":
|
||||||
default_base = "https://ark.cn-beijing.volces.com/api/v3"
|
default_base = "https://ark.cn-beijing.volces.com/api/v3"
|
||||||
default_model = "doubao-seed-2-0-pro-260215"
|
default_model = "doubao-seed-2-0-pro-260215"
|
||||||
else:
|
base_url = (base_url or default_base).strip()
|
||||||
default_base = "https://api.openai.com/v1"
|
model_name = (model_name or default_model).strip()
|
||||||
default_model = "gpt-3.5-turbo"
|
cur = conn.execute("SELECT COUNT(*) as c FROM models")
|
||||||
row = {
|
is_current = is_current or cur.fetchone()["c"] == 0
|
||||||
"id": rid,
|
if is_current:
|
||||||
"name": name,
|
conn.execute("UPDATE models SET is_current = 0")
|
||||||
"provider": provider,
|
conn.execute(
|
||||||
"api_key": api_key,
|
"INSERT INTO models (id, name, provider, api_key, base_url, model_name, is_current) VALUES (?,?,?,?,?,?,?)",
|
||||||
"base_url": (base_url or default_base).strip(),
|
(rid, name, provider, api_key, base_url, model_name, 1 if is_current else 0)
|
||||||
"model_name": (model_name or default_model).strip(),
|
)
|
||||||
"is_current": is_current or len(rows) == 0,
|
conn.commit()
|
||||||
}
|
cur = conn.execute("SELECT * FROM models WHERE id = ?", (rid,))
|
||||||
if row["is_current"]:
|
return _row_to_dict(cur.fetchone())
|
||||||
for r in rows:
|
finally:
|
||||||
r["is_current"] = False
|
conn.close()
|
||||||
rows.append(row)
|
|
||||||
_save("models", rows)
|
|
||||||
return row
|
|
||||||
|
|
||||||
|
|
||||||
def update_model(
|
def update_model(
|
||||||
model_id: str,
|
model_id: str,
|
||||||
@@ -347,44 +478,104 @@ def update_model(
|
|||||||
base_url: Optional[str] = None,
|
base_url: Optional[str] = None,
|
||||||
model_name: Optional[str] = None,
|
model_name: Optional[str] = None,
|
||||||
) -> Optional[Dict]:
|
) -> Optional[Dict]:
|
||||||
rows = _load("models")
|
with _LOCK:
|
||||||
for r in rows:
|
conn = _conn()
|
||||||
if r.get("id") == model_id:
|
try:
|
||||||
if name is not None:
|
updates, params = [], []
|
||||||
r["name"] = name
|
for k, v in (("name", name), ("api_key", api_key), ("base_url", base_url), ("model_name", model_name)):
|
||||||
if api_key is not None:
|
if v is not None:
|
||||||
r["api_key"] = api_key
|
updates.append(f"{k} = ?")
|
||||||
if base_url is not None:
|
params.append(v)
|
||||||
r["base_url"] = base_url
|
if not updates:
|
||||||
if model_name is not None:
|
cur = conn.execute("SELECT * FROM models WHERE id = ?", (model_id,))
|
||||||
r["model_name"] = model_name
|
row = cur.fetchone()
|
||||||
_save("models", rows)
|
return _row_to_dict(row) if row else None
|
||||||
return r
|
params.append(model_id)
|
||||||
return None
|
conn.execute(f"UPDATE models SET {', '.join(updates)} WHERE id = ?", params)
|
||||||
|
conn.commit()
|
||||||
|
cur = conn.execute("SELECT * FROM models WHERE id = ?", (model_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
return _row_to_dict(row) if row else None
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
def set_current_model(model_id: str) -> Optional[Dict]:
|
def set_current_model(model_id: str) -> Optional[Dict]:
|
||||||
rows = _load("models")
|
with _LOCK:
|
||||||
found = None
|
conn = _conn()
|
||||||
for r in rows:
|
try:
|
||||||
if r.get("id") == model_id:
|
conn.execute("UPDATE models SET is_current = 0")
|
||||||
r["is_current"] = True
|
conn.execute("UPDATE models SET is_current = 1 WHERE id = ?", (model_id,))
|
||||||
found = r
|
conn.commit()
|
||||||
else:
|
cur = conn.execute("SELECT * FROM models WHERE id = ?", (model_id,))
|
||||||
r["is_current"] = False
|
row = cur.fetchone()
|
||||||
if found:
|
return _row_to_dict(row) if row else None
|
||||||
_save("models", rows)
|
finally:
|
||||||
return found
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
def delete_model(model_id: str) -> bool:
|
def delete_model(model_id: str) -> bool:
|
||||||
rows = _load("models")
|
with _LOCK:
|
||||||
for i, r in enumerate(rows):
|
conn = _conn()
|
||||||
if r.get("id") == model_id:
|
try:
|
||||||
was_current = r.get("is_current")
|
cur = conn.execute("SELECT is_current FROM models WHERE id = ?", (model_id,))
|
||||||
rows.pop(i)
|
row = cur.fetchone()
|
||||||
if was_current and rows:
|
if not row:
|
||||||
rows[0]["is_current"] = True
|
|
||||||
_save("models", rows)
|
|
||||||
return True
|
|
||||||
return False
|
return False
|
||||||
|
was_current = row["is_current"]
|
||||||
|
conn.execute("DELETE FROM models WHERE id = ?", (model_id,))
|
||||||
|
if was_current:
|
||||||
|
cur = conn.execute("SELECT id FROM models LIMIT 1")
|
||||||
|
first = cur.fetchone()
|
||||||
|
if first:
|
||||||
|
conn.execute("UPDATE models SET is_current = 1 WHERE id = ?", (first["id"],))
|
||||||
|
conn.commit()
|
||||||
|
return True
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- AI 回复配置 ----------
|
||||||
|
def get_ai_reply_config(key: str) -> Optional[Dict]:
|
||||||
|
with _LOCK:
|
||||||
|
conn = _conn()
|
||||||
|
try:
|
||||||
|
cur = conn.execute("SELECT * FROM ai_reply_config WHERE key = ?", (key,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
return _row_to_dict(row) if row else None
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def update_ai_reply_config(
|
||||||
|
key: str,
|
||||||
|
super_admin_wxids: Optional[List[str]] = None,
|
||||||
|
whitelist_wxids: Optional[List[str]] = None,
|
||||||
|
) -> Dict:
|
||||||
|
with _LOCK:
|
||||||
|
conn = _conn()
|
||||||
|
try:
|
||||||
|
cur = conn.execute("SELECT * FROM ai_reply_config WHERE key = ?", (key,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if row:
|
||||||
|
updates, params = [], []
|
||||||
|
if super_admin_wxids is not None:
|
||||||
|
updates.append("super_admin_wxids = ?")
|
||||||
|
params.append(json.dumps([str(x).strip() for x in super_admin_wxids if str(x).strip()], ensure_ascii=False))
|
||||||
|
if whitelist_wxids is not None:
|
||||||
|
updates.append("whitelist_wxids = ?")
|
||||||
|
params.append(json.dumps([str(x).strip() for x in whitelist_wxids if str(x).strip()], ensure_ascii=False))
|
||||||
|
if updates:
|
||||||
|
params.append(key)
|
||||||
|
conn.execute(f"UPDATE ai_reply_config SET {', '.join(updates)} WHERE key = ?", params)
|
||||||
|
conn.commit()
|
||||||
|
cur = conn.execute("SELECT * FROM ai_reply_config WHERE key = ?", (key,))
|
||||||
|
return _row_to_dict(cur.fetchone())
|
||||||
|
super_admin_wxids = [str(x).strip() for x in (super_admin_wxids or []) if str(x).strip()]
|
||||||
|
whitelist_wxids = [str(x).strip() for x in (whitelist_wxids or []) if str(x).strip()]
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO ai_reply_config (key, super_admin_wxids, whitelist_wxids) VALUES (?,?,?)",
|
||||||
|
(key, json.dumps(super_admin_wxids, ensure_ascii=False), json.dumps(whitelist_wxids, ensure_ascii=False))
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
cur = conn.execute("SELECT * FROM ai_reply_config WHERE key = ?", (key,))
|
||||||
|
return _row_to_dict(cur.fetchone())
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|||||||
140
public/chat.html
140
public/chat.html
@@ -27,11 +27,14 @@
|
|||||||
.nav {
|
.nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 16px;
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
padding: 12px 24px;
|
padding: 12px 24px;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
background: rgba(2, 6, 23, 0.95);
|
background: rgba(2, 6, 23, 0.95);
|
||||||
}
|
}
|
||||||
|
.nav-links { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
|
||||||
.nav a {
|
.nav a {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -39,6 +42,8 @@
|
|||||||
}
|
}
|
||||||
.nav a:hover { color: var(--accent); }
|
.nav a:hover { color: var(--accent); }
|
||||||
.nav a.current { color: var(--accent); font-weight: 500; }
|
.nav a.current { color: var(--accent); font-weight: 500; }
|
||||||
|
.nav-banner { font-size: 18px; font-weight: 700; letter-spacing: 0.04em; color: var(--text); text-shadow: 0 0 20px rgba(34, 197, 94, 0.2); }
|
||||||
|
.nav-banner span { color: var(--accent); }
|
||||||
.container {
|
.container {
|
||||||
max-width: 720px;
|
max-width: 720px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
@@ -64,12 +69,17 @@
|
|||||||
padding: 8px;
|
padding: 8px;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
.msg-item { font-size: 12px; padding: 8px 10px; border-bottom: 1px solid var(--border); }
|
.msg-item { font-size: 12px; padding: 8px 10px; border-bottom: 1px solid var(--border); display:flex; flex-direction:column; gap:4px; }
|
||||||
.msg-item:last-child { border-bottom: none; }
|
.msg-item:last-child { border-bottom: none; }
|
||||||
.msg-item .from { color: var(--accent); margin-right: 8px; }
|
.msg-item .from { color: var(--accent); margin-right: 8px; }
|
||||||
.msg-item.out { opacity: 0.9; }
|
.msg-item.out { opacity: 0.9; }
|
||||||
.msg-item.out .from { color: #94a3b8; }
|
.msg-item.out .from { color: #94a3b8; }
|
||||||
.msg-item .time { font-size: 11px; color: var(--muted); margin-left: 8px; }
|
.msg-item .time { font-size: 11px; color: var(--muted); margin-left: 8px; }
|
||||||
|
.msg-item .meta { display:flex; align-items:center; flex-wrap:wrap; }
|
||||||
|
.msg-item .content { margin-left: 0; font-size: 12px; word-break: break-all; }
|
||||||
|
.msg-item .content img { max-width: 100%; border-radius: 6px; margin-top: 4px; }
|
||||||
|
.msg-item .content audio,
|
||||||
|
.msg-item .content video { max-width: 100%; margin-top: 4px; }
|
||||||
.form-row { display: flex; gap: 12px; align-items: flex-end; flex-wrap: wrap; margin-bottom: 10px; }
|
.form-row { display: flex; gap: 12px; align-items: flex-end; flex-wrap: wrap; margin-bottom: 10px; }
|
||||||
.form-row label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 4px; }
|
.form-row label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 4px; }
|
||||||
.form-row input { padding: 8px 12px; border: 1px solid var(--border); border-radius: 8px; background: rgba(15,23,42,0.9); color: var(--text); font-size: 13px; }
|
.form-row input { padding: 8px 12px; border: 1px solid var(--border); border-radius: 8px; background: rgba(15,23,42,0.9); color: var(--text); font-size: 13px; }
|
||||||
@@ -81,10 +91,13 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav class="nav">
|
<nav class="nav">
|
||||||
<a href="index.html">登录</a>
|
<div class="nav-banner">Wechat <span>智能托管服务</span></div>
|
||||||
|
<div class="nav-links">
|
||||||
<a href="manage.html">客户与消息管理</a>
|
<a href="manage.html">客户与消息管理</a>
|
||||||
<a href="chat.html" class="current">实时消息</a>
|
<a href="chat.html" class="current">实时消息</a>
|
||||||
<a href="models.html">模型管理</a>
|
<a href="models.html">模型管理</a>
|
||||||
|
<a href="swagger.html" target="_blank">API 文档 (Swagger)</a>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -117,42 +130,119 @@
|
|||||||
const API_BASE = 'http://localhost:8000';
|
const API_BASE = 'http://localhost:8000';
|
||||||
const KEY_STORAGE = 'wechat_key';
|
const KEY_STORAGE = 'wechat_key';
|
||||||
|
|
||||||
function getKey() {
|
function getToken() {
|
||||||
const k = $('key').value.trim();
|
try {
|
||||||
if (!k) { alert('请先填写账号 key'); return null; }
|
return localStorage.getItem('auth_token') || '';
|
||||||
return k;
|
} catch (_) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function redirectToLogin(msg) {
|
||||||
|
if (msg) alert(msg);
|
||||||
|
window.location.href = 'index.html';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function callApi(path, options = {}) {
|
async function callApi(path, options = {}) {
|
||||||
const url = API_BASE + path;
|
const url = API_BASE + path;
|
||||||
const res = await fetch(url, { ...options, headers: { 'Content-Type': 'application/json', ...(options.headers || {}) } });
|
const token = getToken();
|
||||||
|
const headers = { 'Content-Type': 'application/json', ...(options.headers || {}) };
|
||||||
|
if (token) headers['Authorization'] = 'Bearer ' + token;
|
||||||
|
|
||||||
|
const res = await fetch(url, { ...options, headers });
|
||||||
let body = null;
|
let body = null;
|
||||||
try { body = await res.json(); } catch (_) {}
|
try { body = await res.json(); } catch (_) {}
|
||||||
|
if (res.status === 401) {
|
||||||
|
redirectToLogin('登录已失效,请重新扫码登录');
|
||||||
|
throw new Error('unauthorized');
|
||||||
|
}
|
||||||
if (!res.ok) throw new Error((body && (body.detail || body.message)) || res.statusText || '请求失败');
|
if (!res.ok) throw new Error((body && (body.detail || body.message)) || res.statusText || '请求失败');
|
||||||
return body;
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderMessageContent(m) {
|
||||||
|
const msgType = m.MsgType ?? m.msgType;
|
||||||
|
const rawContent = m.Content || m.content || '';
|
||||||
|
const imageContent = m.ImageContent || m.imageContent || '';
|
||||||
|
const from = m.FromUserName || m.from || m.MsgId || '-';
|
||||||
|
|
||||||
|
// 链接检测
|
||||||
|
const isUrl = (s) => typeof s === 'string' && /^https?:\/\//i.test(s.trim());
|
||||||
|
const isBase64Like = (s) => typeof s === 'string' && /^[A-Za-z0-9+/=\s]+$/.test(s) && s.replace(/\s+/g, '').length > 60;
|
||||||
|
|
||||||
|
// 图片:上游通常提供 ImageContent(base64)或 Content 为图片链接
|
||||||
|
if (imageContent || (msgType === 3) || (msgType === 0 && imageContent)) {
|
||||||
|
const src = isUrl(imageContent) ? imageContent :
|
||||||
|
(imageContent ? ('data:image/png;base64,' + imageContent.replace(/\s+/g, '')) :
|
||||||
|
(isUrl(rawContent) ? rawContent : ''));
|
||||||
|
if (src) {
|
||||||
|
return `<div class="content"><div>${rawContent ? String(rawContent) : ''}</div><img src="${src}" alt="图片消息" /></div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 视频 / 音频:简单通过扩展名或 MsgType 约定判断
|
||||||
|
if (msgType === 43 || msgType === 'video') {
|
||||||
|
const src = isUrl(rawContent) ? rawContent : '';
|
||||||
|
if (src) return `<div class="content"><video src="${src}" controls></video></div>`;
|
||||||
|
}
|
||||||
|
if (msgType === 34 || msgType === 'audio') {
|
||||||
|
const src = isUrl(rawContent) ? rawContent : '';
|
||||||
|
if (src) return `<div class="content"><audio src="${src}" controls></audio></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 若内容是 URL,则渲染为可点击链接
|
||||||
|
if (isUrl(rawContent)) {
|
||||||
|
const safe = String(rawContent);
|
||||||
|
return `<div class="content"><a href="${safe}" target="_blank" rel="noopener noreferrer">${safe}</a></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 若内容看起来是图片 base64,则按图片渲染
|
||||||
|
if (isBase64Like(rawContent) && (msgType === 3 || msgType === 0)) {
|
||||||
|
const src = 'data:image/png;base64,' + String(rawContent).replace(/\s+/g, '');
|
||||||
|
return `<div class="content"><img src="${src}" alt="图片消息" /></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兜底为纯文本(含 MsgType 提示)
|
||||||
|
const text = rawContent ? String(rawContent) : (msgType != null ? `MsgType=${msgType}` : '');
|
||||||
|
return `<div class="content">${text}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadMessages() {
|
async function loadMessages() {
|
||||||
const key = $('key').value.trim();
|
const key = (function() {
|
||||||
if (!key) { $('message-list').innerHTML = '<p class="small-label">请先填写账号 key。</p>'; return; }
|
|
||||||
try {
|
try {
|
||||||
|
return localStorage.getItem(KEY_STORAGE) || '';
|
||||||
|
} catch (_) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
if (!key) {
|
||||||
|
$('message-list').innerHTML = '<p class="small-label">请先在登录页扫码登录。</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// 后端已按时间倒序(最新在前)返回,这里保持顺序即可
|
||||||
const data = await callApi('/api/messages?key=' + encodeURIComponent(key) + '&limit=80');
|
const data = await callApi('/api/messages?key=' + encodeURIComponent(key) + '&limit=80');
|
||||||
const list = data.items || [];
|
const list = data.items || [];
|
||||||
// 按时间正序排列,最新在底部,便于看完整对话
|
$('message-list').innerHTML = list.length ? list.map(m => {
|
||||||
const sorted = [...list].sort((a, b) => (a.CreateTime || 0) - (b.CreateTime || 0));
|
|
||||||
$('message-list').innerHTML = sorted.length ? sorted.map(m => {
|
|
||||||
const isOut = m.direction === 'out';
|
const isOut = m.direction === 'out';
|
||||||
const from = isOut ? ('我 → ' + (m.ToUserName || '')) : (m.FromUserName || m.from || m.MsgId || '-').toString().slice(0, 32);
|
const fromLabel = isOut ? ('我 → ' + (m.ToUserName || '')) : (m.FromUserName || m.from || m.MsgId || '-').toString().slice(0, 32);
|
||||||
const content = (m.Content || m.content || m.MsgType || '').toString().slice(0, 200);
|
const time = m.CreateTime ? (typeof m.CreateTime === 'number'
|
||||||
const time = m.CreateTime ? (typeof m.CreateTime === 'number' ? new Date(m.CreateTime * 1000).toLocaleTimeString('zh-CN', { hour12: false }) : m.CreateTime) : '';
|
? new Date(m.CreateTime * 1000).toLocaleTimeString('zh-CN', { hour12: false })
|
||||||
return '<div class="msg-item' + (isOut ? ' out' : '') + '"><span class="from">' + from + '</span>' + content + (time ? ' <span class="time">' + time + '</span>' : '') + '</div>';
|
: m.CreateTime) : '';
|
||||||
}).join('') : '<p class="small-label">暂无对话。请确保已登录且后端 WS 已连接 GetSyncMsg;发送的消息也会在此展示。</p>';
|
const meta = '<div class="meta"><span class="from">' + fromLabel + '</span>' + (time ? '<span class="time">' + time + '</span>' : '') + '</div>';
|
||||||
} catch (e) { $('message-list').innerHTML = '<p class="small-label">加载失败: ' + e.message + '</p>'; }
|
const body = renderMessageContent(m);
|
||||||
|
return '<div class="msg-item' + (isOut ? ' out' : '') + '">' + meta + body + '</div>';
|
||||||
|
}).join('') : '<p class="small-label">暂无对话。请确保已登录且后端 WS 已连接 GetSyncMsg;发送的消息(含图片、音视频等)也会在此展示。</p>';
|
||||||
|
} catch (e) {
|
||||||
|
$('message-list').innerHTML = '<p class="small-label">加载失败: ' + e.message + '</p>';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendMessage() {
|
async function sendMessage() {
|
||||||
const key = getKey();
|
const key = (function() {
|
||||||
if (!key) return;
|
try { return localStorage.getItem(KEY_STORAGE) || ''; } catch (_) { return ''; }
|
||||||
|
})();
|
||||||
|
if (!key) { alert('请先在登录页扫码登录'); return; }
|
||||||
const to = $('send-to').value.trim();
|
const to = $('send-to').value.trim();
|
||||||
const content = $('send-content').value.trim();
|
const content = $('send-content').value.trim();
|
||||||
if (!to || !content) { alert('请填写对方用户名和内容'); return; }
|
if (!to || !content) { alert('请填写对方用户名和内容'); return; }
|
||||||
@@ -163,10 +253,14 @@
|
|||||||
} catch (e) { alert('发送失败: ' + e.message); }
|
} catch (e) { alert('发送失败: ' + e.message); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 隐藏 key 行,仅内部使用 localStorage 中的 key
|
||||||
|
(function hideKeyRow() {
|
||||||
|
const row = document.querySelector('.key-row');
|
||||||
|
if (row) row.style.display = 'none';
|
||||||
|
})();
|
||||||
|
|
||||||
$('btn-refresh-msg').addEventListener('click', loadMessages);
|
$('btn-refresh-msg').addEventListener('click', loadMessages);
|
||||||
$('btn-send-msg').addEventListener('click', sendMessage);
|
$('btn-send-msg').addEventListener('click', sendMessage);
|
||||||
$('key').addEventListener('change', function() { try { localStorage.setItem(KEY_STORAGE, this.value.trim()); } catch (_) {} });
|
|
||||||
if (typeof localStorage !== 'undefined' && localStorage.getItem(KEY_STORAGE)) $('key').value = localStorage.getItem(KEY_STORAGE);
|
|
||||||
loadMessages();
|
loadMessages();
|
||||||
|
|
||||||
(function wsStatusCheck() {
|
(function wsStatusCheck() {
|
||||||
|
|||||||
@@ -510,6 +510,7 @@
|
|||||||
<div class="page-wrap">
|
<div class="page-wrap">
|
||||||
<div class="home-banner">
|
<div class="home-banner">
|
||||||
<h1 class="banner-text">Wechat <span>智能托管服务</span></h1>
|
<h1 class="banner-text">Wechat <span>智能托管服务</span></h1>
|
||||||
|
<p style="margin:8px 0 0;font-size:14px"><a href="swagger.html" target="_blank" style="color:var(--accent)">API 文档 (Swagger)</a></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="shell">
|
<div class="shell">
|
||||||
<div class="shell-inner">
|
<div class="shell-inner">
|
||||||
|
|||||||
@@ -27,11 +27,14 @@
|
|||||||
.nav {
|
.nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 16px;
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
padding: 12px 24px;
|
padding: 12px 24px;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
background: rgba(2, 6, 23, 0.95);
|
background: rgba(2, 6, 23, 0.95);
|
||||||
}
|
}
|
||||||
|
.nav-links { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
|
||||||
.nav a {
|
.nav a {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -39,6 +42,14 @@
|
|||||||
}
|
}
|
||||||
.nav a:hover { color: var(--accent); }
|
.nav a:hover { color: var(--accent); }
|
||||||
.nav a.current { color: var(--accent); font-weight: 500; }
|
.nav a.current { color: var(--accent); font-weight: 500; }
|
||||||
|
.nav-banner {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--text);
|
||||||
|
text-shadow: 0 0 20px rgba(34, 197, 94, 0.2);
|
||||||
|
}
|
||||||
|
.nav-banner span { color: var(--accent); }
|
||||||
.container {
|
.container {
|
||||||
max-width: 960px;
|
max-width: 960px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
@@ -77,8 +88,6 @@
|
|||||||
.tag { display: inline-block; padding: 4px 10px; border-radius: 999px; background: rgba(30,41,59,0.9); border: 1px solid var(--border); font-size: 12px; margin-right: 6px; margin-bottom: 6px; }
|
.tag { display: inline-block; padding: 4px 10px; border-radius: 999px; background: rgba(30,41,59,0.9); border: 1px solid var(--border); font-size: 12px; margin-right: 6px; margin-bottom: 6px; }
|
||||||
.primary { padding: 10px 20px; border-radius: 8px; background: var(--accent); color: #000; border: none; cursor: pointer; font-weight: 500; }
|
.primary { padding: 10px 20px; border-radius: 8px; background: var(--accent); color: #000; border: none; cursor: pointer; font-weight: 500; }
|
||||||
.secondary { padding: 8px 16px; border-radius: 8px; background: rgba(30,41,59,0.9); border: 1px solid var(--border); color: var(--text); cursor: pointer; font-size: 13px; }
|
.secondary { padding: 8px 16px; border-radius: 8px; background: rgba(30,41,59,0.9); border: 1px solid var(--border); color: var(--text); cursor: pointer; font-size: 13px; }
|
||||||
.key-row { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
|
|
||||||
.key-row input { max-width: 280px; padding: 8px 12px; border: 1px solid var(--border); border-radius: 8px; background: rgba(15,23,42,0.9); color: var(--text); }
|
|
||||||
.error-msg { color: #f87171; font-size: 12px; }
|
.error-msg { color: #f87171; font-size: 12px; }
|
||||||
.tags-chips { display: inline-flex; flex-wrap: wrap; gap: 6px; }
|
.tags-chips { display: inline-flex; flex-wrap: wrap; gap: 6px; }
|
||||||
.tags-chips .chip { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 999px; background: var(--accent-soft); border: 1px solid var(--accent); font-size: 12px; color: var(--accent); }
|
.tags-chips .chip { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 999px; background: var(--accent-soft); border: 1px solid var(--accent); font-size: 12px; color: var(--accent); }
|
||||||
@@ -88,26 +97,35 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav class="nav">
|
<nav class="nav">
|
||||||
<a href="index.html">登录</a>
|
<div class="nav-banner">Wechat <span>智能托管服务</span></div>
|
||||||
|
<div class="nav-links">
|
||||||
<a href="manage.html" class="current">客户与消息管理</a>
|
<a href="manage.html" class="current">客户与消息管理</a>
|
||||||
<a href="chat.html">实时消息</a>
|
<a href="chat.html">实时消息</a>
|
||||||
<a href="models.html">模型管理</a>
|
<a href="models.html">模型管理</a>
|
||||||
|
<a href="swagger.html" target="_blank">API 文档 (Swagger)</a>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-title">客户与消息管理(R1-2 / R1-3 / R1-4)</div>
|
<div class="card-title">客户与消息管理</div>
|
||||||
<div class="key-row">
|
<input type="hidden" id="key" autocomplete="off" />
|
||||||
<label for="key">账号 key</label>
|
|
||||||
<input type="text" id="key" placeholder="与登录页一致,如 HBpEnbtj9BJZ" autocomplete="off" />
|
|
||||||
</div>
|
|
||||||
<div class="mgmt-tabs">
|
<div class="mgmt-tabs">
|
||||||
<button type="button" class="mgmt-tab active" data-panel="panel-customers">客户档案</button>
|
<button type="button" class="mgmt-tab active" data-panel="panel-customers">客户档案</button>
|
||||||
<button type="button" class="mgmt-tab" data-panel="panel-greeting">定时问候</button>
|
<button type="button" class="mgmt-tab" data-panel="panel-greeting">定时问候</button>
|
||||||
<button type="button" class="mgmt-tab" data-panel="panel-push">分群推送</button>
|
<button type="button" class="mgmt-tab" data-panel="panel-push">分群推送</button>
|
||||||
|
<button type="button" class="mgmt-tab" data-panel="panel-ai-reply">AI 回复设置</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="panel-customers" class="mgmt-panel show">
|
<div id="panel-customers" class="mgmt-panel show">
|
||||||
<div class="mgmt-form-grid">
|
<div class="mgmt-form-grid">
|
||||||
<div class="field full"><label>微信ID (wxid)</label><input id="c-wxid" placeholder="wxid_xxx" /></div>
|
<div class="field full">
|
||||||
|
<label>微信ID (wxid)</label>
|
||||||
|
<input id="c-wxid" placeholder="wxid_xxx 或从下方多选填入" />
|
||||||
|
<div class="small-label" style="margin-top:6px">从联系人选择(多选可批量填入)</div>
|
||||||
|
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:4px">
|
||||||
|
<button type="button" class="secondary" id="btn-load-contact-list" style="padding:4px 10px;font-size:12px">加载联系人</button>
|
||||||
|
<select id="c-wxid-select" multiple style="min-width:200px;min-height:80px;max-height:120px"></select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="field"><label>备注名</label><input id="c-remark" placeholder="客户备注" /></div>
|
<div class="field"><label>备注名</label><input id="c-remark" placeholder="客户备注" /></div>
|
||||||
<div class="field"><label>地区</label><input id="c-region" placeholder="如:广东" /></div>
|
<div class="field"><label>地区</label><input id="c-region" placeholder="如:广东" /></div>
|
||||||
<div class="field"><label>年龄</label><input id="c-age" placeholder="25" /></div>
|
<div class="field"><label>年龄</label><input id="c-age" placeholder="25" /></div>
|
||||||
@@ -127,7 +145,7 @@
|
|||||||
<div class="field full">
|
<div class="field full">
|
||||||
<label>客户标签(从客户档案中选)</label>
|
<label>客户标签(从客户档案中选)</label>
|
||||||
<div style="display:flex;flex-wrap:wrap;gap:8px;align-items:center;">
|
<div style="display:flex;flex-wrap:wrap;gap:8px;align-items:center;">
|
||||||
<select id="g-tag-select" style="max-width:160px"><option value="">请先选账号 key 并加载</option></select>
|
<select id="g-tag-select" style="max-width:160px"><option value="">请先登录后加载</option></select>
|
||||||
<button type="button" class="secondary" id="g-tag-add" style="padding:4px 10px;font-size:12px">添加标签</button>
|
<button type="button" class="secondary" id="g-tag-add" style="padding:4px 10px;font-size:12px">添加标签</button>
|
||||||
<span id="g-tags-chips" class="tags-chips"></span>
|
<span id="g-tags-chips" class="tags-chips"></span>
|
||||||
</div>
|
</div>
|
||||||
@@ -140,6 +158,27 @@
|
|||||||
<div id="greeting-list"></div>
|
<div id="greeting-list"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="panel-push" class="mgmt-panel">
|
<div id="panel-push" class="mgmt-panel">
|
||||||
|
<div class="small-label" style="margin-bottom:8px">快速群发(选好友/客户后一键分发)</div>
|
||||||
|
<div class="mgmt-form-grid">
|
||||||
|
<div class="field full">
|
||||||
|
<label>选择接收人(从好友/客户列表)</label>
|
||||||
|
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
|
||||||
|
<button type="button" class="secondary" id="btn-load-friends" style="padding:4px 10px;font-size:12px">加载联系人</button>
|
||||||
|
<span id="mass-selected-count" class="small-label">已选 0 人</span>
|
||||||
|
</div>
|
||||||
|
<div id="mass-friend-list" style="max-height:140px;overflow-y:auto;border:1px solid var(--border);border-radius:8px;padding:8px;margin-top:6px;background:rgba(15,23,42,0.6)"></div>
|
||||||
|
</div>
|
||||||
|
<div class="field full"><label>群发文案</label><textarea id="mass-content" rows="2" placeholder="输入要群发的文字…"></textarea></div>
|
||||||
|
<button type="button" class="primary" id="btn-mass-send">一键群发</button>
|
||||||
|
</div>
|
||||||
|
<div class="small-label" style="margin-top:16px;margin-bottom:8px">发送图片消息(快捷方式)</div>
|
||||||
|
<div class="mgmt-form-grid">
|
||||||
|
<div class="field"><label>接收人 wxid</label><input id="img-to-user" placeholder="zhang499142409" /></div>
|
||||||
|
<div class="field full"><label>图片内容(base64 或 URL)</label><input id="img-content" placeholder="图片 base64 或图片 URL" /></div>
|
||||||
|
<div class="field full"><label>附带文字(可选)</label><input id="img-text" placeholder="可选" /></div>
|
||||||
|
<button type="button" class="primary" id="btn-send-image">发送图片</button>
|
||||||
|
</div>
|
||||||
|
<hr style="border:0;border-top:1px solid var(--border);margin:20px 0" />
|
||||||
<div class="small-label">商品标签</div>
|
<div class="small-label">商品标签</div>
|
||||||
<div class="mgmt-form-grid">
|
<div class="mgmt-form-grid">
|
||||||
<div class="field"><input id="pt-name" placeholder="如:爆款、清仓、新中式" /></div>
|
<div class="field"><input id="pt-name" placeholder="如:爆款、清仓、新中式" /></div>
|
||||||
@@ -157,6 +196,21 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="push-task-list"></div>
|
<div id="push-task-list"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="panel-ai-reply" class="mgmt-panel">
|
||||||
|
<p class="small-label" style="margin-bottom:12px">分级处理:仅<strong>超级管理员</strong>与<strong>白名单</strong>中的联系人会收到 AI 自动回复,其他消息一律不回复。</p>
|
||||||
|
<div class="mgmt-form-grid">
|
||||||
|
<div class="field full">
|
||||||
|
<label>超级管理员 wxid(每行一个或逗号分隔)</label>
|
||||||
|
<textarea id="ai-super-admin" rows="3" placeholder="zhang499142409 wxid_xxx"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="field full">
|
||||||
|
<label>白名单 wxid(可收到 AI 回复的联系人,每行一个或逗号分隔)</label>
|
||||||
|
<textarea id="ai-whitelist" rows="4" placeholder="wxid_abc wxid_def"></textarea>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="primary" id="btn-ai-reply-save">保存配置</button>
|
||||||
|
</div>
|
||||||
|
<p class="small-label" style="margin-top:12px">保存后,仅上述列表中的发信人会被 AI 接管回复;未在列表中的联系人发来的消息将不会触发自动回复。</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
@@ -165,8 +219,8 @@
|
|||||||
const KEY_STORAGE = 'wechat_key';
|
const KEY_STORAGE = 'wechat_key';
|
||||||
|
|
||||||
function getKey() {
|
function getKey() {
|
||||||
const k = $('key').value.trim();
|
const k = ($('key') && $('key').value ? $('key').value.trim() : '') || (typeof localStorage !== 'undefined' ? (localStorage.getItem(KEY_STORAGE) || '').trim() : '');
|
||||||
if (!k) { alert('请先填写账号 key'); return null; }
|
if (!k) { alert('请先登录'); return null; }
|
||||||
return k;
|
return k;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,6 +243,45 @@
|
|||||||
const tab = document.querySelector('.mgmt-tab[data-panel="' + panelId + '"]');
|
const tab = document.querySelector('.mgmt-tab[data-panel="' + panelId + '"]');
|
||||||
if (tab) tab.classList.add('active');
|
if (tab) tab.classList.add('active');
|
||||||
if (panelId === 'panel-greeting') loadCustomerTagsForGreeting();
|
if (panelId === 'panel-greeting') loadCustomerTagsForGreeting();
|
||||||
|
if (panelId === 'panel-ai-reply') loadAiReplyConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _parseWxidLines(ta) {
|
||||||
|
const raw = (ta && ta.value) ? ta.value.trim() : '';
|
||||||
|
return raw.split(/[\n,,]/).map(s => s.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAiReplyConfig() {
|
||||||
|
const key = $('key').value.trim();
|
||||||
|
const superEl = $('ai-super-admin');
|
||||||
|
const whiteEl = $('ai-whitelist');
|
||||||
|
if (!key) {
|
||||||
|
if (superEl) superEl.value = '';
|
||||||
|
if (whiteEl) whiteEl.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = await callApi('/api/ai-reply-config?key=' + encodeURIComponent(key));
|
||||||
|
if (superEl) superEl.value = (data.super_admin_wxids || []).join('\n');
|
||||||
|
if (whiteEl) whiteEl.value = (data.whitelist_wxids || []).join('\n');
|
||||||
|
} catch (e) {
|
||||||
|
if (superEl) superEl.value = '';
|
||||||
|
if (whiteEl) whiteEl.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAiReplyConfig() {
|
||||||
|
const key = getKey();
|
||||||
|
if (!key) return;
|
||||||
|
const superList = _parseWxidLines($('ai-super-admin'));
|
||||||
|
const whiteList = _parseWxidLines($('ai-whitelist'));
|
||||||
|
try {
|
||||||
|
await callApi('/api/ai-reply-config', {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ key, super_admin_wxids: superList, whitelist_wxids: whiteList })
|
||||||
|
});
|
||||||
|
alert('已保存:仅超级管理员与白名单中的联系人将收到 AI 回复。');
|
||||||
|
} catch (e) { alert('保存失败: ' + e.message); }
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderGreetingTagChips() {
|
function renderGreetingTagChips() {
|
||||||
@@ -213,7 +306,7 @@
|
|||||||
const sel = $('g-tag-select');
|
const sel = $('g-tag-select');
|
||||||
if (!sel) return;
|
if (!sel) return;
|
||||||
if (!key) {
|
if (!key) {
|
||||||
sel.innerHTML = '<option value="">请先填写账号 key</option>';
|
sel.innerHTML = '<option value="">请先登录后加载</option>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -225,9 +318,37 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadContactListForWxidSelect() {
|
||||||
|
const key = $('key').value.trim();
|
||||||
|
const sel = $('c-wxid-select');
|
||||||
|
if (!sel) return;
|
||||||
|
if (!key) { alert('请先登录'); return; }
|
||||||
|
sel.innerHTML = '<option value="">加载中…</option>';
|
||||||
|
try {
|
||||||
|
const data = await callApi('/api/contact-list?key=' + encodeURIComponent(key));
|
||||||
|
const list = data.items || [];
|
||||||
|
if (data.error) {
|
||||||
|
sel.innerHTML = '<option value="">获取失败</option>';
|
||||||
|
alert('获取联系人失败:' + (data.error || '请检查网络或 key'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sel.innerHTML = list.length
|
||||||
|
? list.map(c => {
|
||||||
|
const w = (c.wxid || c.Wxid || '').toString();
|
||||||
|
const r = (c.remark_name || c.RemarkName || c.NickName || w).toString();
|
||||||
|
return '<option value="' + escapeHtml(w) + '">' + escapeHtml(r) + ' (' + escapeHtml(w.slice(0, 20)) + (w.length > 20 ? '…' : '') + ')</option>';
|
||||||
|
}).join('')
|
||||||
|
: '<option value="">无联系人数据</option>';
|
||||||
|
sel.multiple = true;
|
||||||
|
} catch (e) {
|
||||||
|
sel.innerHTML = '<option value="">加载失败</option>';
|
||||||
|
alert('加载联系人失败: ' + (e.message || e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadCustomers() {
|
async function loadCustomers() {
|
||||||
const key = $('key').value.trim();
|
const key = $('key').value.trim();
|
||||||
if (!key) { $('customer-list').innerHTML = '<p class="small-label">请先填写账号 key。</p>'; return; }
|
if (!key) { $('customer-list').innerHTML = '<p class="small-label">请先登录。</p>'; return; }
|
||||||
try {
|
try {
|
||||||
const data = await callApi('/api/customers?key=' + encodeURIComponent(key));
|
const data = await callApi('/api/customers?key=' + encodeURIComponent(key));
|
||||||
const list = data.items || [];
|
const list = data.items || [];
|
||||||
@@ -249,13 +370,18 @@
|
|||||||
async function saveCustomer() {
|
async function saveCustomer() {
|
||||||
const key = getKey();
|
const key = getKey();
|
||||||
if (!key) return;
|
if (!key) return;
|
||||||
const wxid = $('c-wxid').value.trim();
|
const wxidRaw = $('c-wxid').value.trim();
|
||||||
if (!wxid) { alert('请填写微信ID'); return; }
|
if (!wxidRaw) { alert('请填写微信ID或从联系人多选'); return; }
|
||||||
|
const wxids = wxidRaw.split(',').map(s => s.trim()).filter(Boolean);
|
||||||
const tagsStr = $('c-tags').value.trim();
|
const tagsStr = $('c-tags').value.trim();
|
||||||
const tags = tagsStr ? tagsStr.split(',').map(s => s.trim()).filter(Boolean) : [];
|
const tags = tagsStr ? tagsStr.split(',').map(s => s.trim()).filter(Boolean) : [];
|
||||||
|
const payload = { key, remark_name: $('c-remark').value.trim(), region: $('c-region').value.trim(), age: $('c-age').value.trim(), gender: $('c-gender').value, level: $('c-level').value.trim(), tags };
|
||||||
try {
|
try {
|
||||||
await callApi('/api/customers', { method: 'POST', body: JSON.stringify({ key, wxid, remark_name: $('c-remark').value.trim(), region: $('c-region').value.trim(), age: $('c-age').value.trim(), gender: $('c-gender').value, level: $('c-level').value.trim(), tags }) });
|
for (const wxid of wxids) {
|
||||||
|
await callApi('/api/customers', { method: 'POST', body: JSON.stringify({ ...payload, wxid }) });
|
||||||
|
}
|
||||||
loadCustomers();
|
loadCustomers();
|
||||||
|
if (wxids.length > 1) alert('已保存 ' + wxids.length + ' 个客户'); else alert('已保存');
|
||||||
} catch (e) { alert('保存失败: ' + e.message); }
|
} catch (e) { alert('保存失败: ' + e.message); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,7 +419,7 @@
|
|||||||
|
|
||||||
async function loadGreetingTasks() {
|
async function loadGreetingTasks() {
|
||||||
const key = $('key').value.trim();
|
const key = $('key').value.trim();
|
||||||
if (!key) { $('greeting-list').innerHTML = '<p class="small-label">请先填写账号 key。</p>'; return; }
|
if (!key) { $('greeting-list').innerHTML = '<p class="small-label">请先登录。</p>'; return; }
|
||||||
try {
|
try {
|
||||||
const data = await callApi('/api/greeting-tasks?key=' + encodeURIComponent(key));
|
const data = await callApi('/api/greeting-tasks?key=' + encodeURIComponent(key));
|
||||||
const list = data.items || [];
|
const list = data.items || [];
|
||||||
@@ -337,7 +463,7 @@
|
|||||||
|
|
||||||
async function loadProductTags() {
|
async function loadProductTags() {
|
||||||
const key = $('key').value.trim();
|
const key = $('key').value.trim();
|
||||||
if (!key) { $('product-tag-list').innerHTML = '<span class="small-label">请先填写账号 key。</span>'; return; }
|
if (!key) { $('product-tag-list').innerHTML = '<span class="small-label">请先登录。</span>'; return; }
|
||||||
try {
|
try {
|
||||||
const data = await callApi('/api/product-tags?key=' + encodeURIComponent(key));
|
const data = await callApi('/api/product-tags?key=' + encodeURIComponent(key));
|
||||||
const list = data.items || [];
|
const list = data.items || [];
|
||||||
@@ -357,7 +483,7 @@
|
|||||||
|
|
||||||
async function loadPushGroups() {
|
async function loadPushGroups() {
|
||||||
const key = $('key').value.trim();
|
const key = $('key').value.trim();
|
||||||
if (!key) { $('push-group-list').innerHTML = '<p class="small-label">请先填写账号 key。</p>'; return; }
|
if (!key) { $('push-group-list').innerHTML = '<p class="small-label">请先登录。</p>'; return; }
|
||||||
try {
|
try {
|
||||||
const data = await callApi('/api/push-groups?key=' + encodeURIComponent(key));
|
const data = await callApi('/api/push-groups?key=' + encodeURIComponent(key));
|
||||||
const list = data.items || [];
|
const list = data.items || [];
|
||||||
@@ -411,21 +537,125 @@
|
|||||||
} catch (e) { alert(e.message); }
|
} catch (e) { alert(e.message); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let massSelectedWxids = [];
|
||||||
|
|
||||||
|
async function loadFriendsForMass() {
|
||||||
|
const key = $('key').value.trim();
|
||||||
|
if (!key) { alert('请先登录'); return; }
|
||||||
|
const el = $('mass-friend-list');
|
||||||
|
el.innerHTML = '<span class="small-label">加载中…</span>';
|
||||||
|
try {
|
||||||
|
const data = await callApi('/api/friends?key=' + encodeURIComponent(key));
|
||||||
|
const list = data.items || [];
|
||||||
|
if (!list.length) {
|
||||||
|
el.innerHTML = '<span class="small-label">暂无联系人,请先在「客户档案」添加客户。</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.innerHTML = list.map(f => {
|
||||||
|
const wxid = (f.wxid || f.Wxid || f.UserName || '').toString();
|
||||||
|
const name = (f.remark_name || f.RemarkName || f.NickName || wxid).toString();
|
||||||
|
return '<label style="display:block;margin:4px 0"><input type="checkbox" class="mass-friend-cb" data-wxid="' + escapeHtml(wxid) + '" /> ' + escapeHtml(name) + ' <span class="small-label">(' + escapeHtml(wxid.slice(0, 16)) + '…)</span></label>';
|
||||||
|
}).join('');
|
||||||
|
el.querySelectorAll('.mass-friend-cb').forEach(cb => {
|
||||||
|
cb.addEventListener('change', updateMassSelected);
|
||||||
|
});
|
||||||
|
massSelectedWxids = [];
|
||||||
|
updateMassSelected();
|
||||||
|
} catch (e) {
|
||||||
|
el.innerHTML = '<span class="small-label">加载失败: ' + escapeHtml(e.message) + '</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMassSelected() {
|
||||||
|
const list = $('mass-friend-list');
|
||||||
|
if (!list) return;
|
||||||
|
const checked = list.querySelectorAll('.mass-friend-cb:checked');
|
||||||
|
massSelectedWxids = Array.from(checked).map(c => c.dataset.wxid).filter(Boolean);
|
||||||
|
const countEl = $('mass-selected-count');
|
||||||
|
if (countEl) countEl.textContent = '已选 ' + massSelectedWxids.length + ' 人';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doMassSend() {
|
||||||
|
const key = getKey();
|
||||||
|
if (!key) return;
|
||||||
|
const content = $('mass-content').value.trim();
|
||||||
|
if (!content) { alert('请填写群发文案'); return; }
|
||||||
|
if (!massSelectedWxids.length) { alert('请先点击「加载联系人」并勾选要群发的对象'); return; }
|
||||||
|
try {
|
||||||
|
const items = massSelectedWxids.map(wxid => ({ to_user_name: wxid, content }));
|
||||||
|
await callApi('/api/send-batch', { method: 'POST', body: JSON.stringify({ key, items }) });
|
||||||
|
alert('已提交群发,共 ' + items.length + ' 人');
|
||||||
|
$('mass-content').value = '';
|
||||||
|
} catch (e) { alert('群发失败: ' + e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doSendImage() {
|
||||||
|
const key = getKey();
|
||||||
|
if (!key) return;
|
||||||
|
const toUser = $('img-to-user').value.trim();
|
||||||
|
const imageContent = $('img-content').value.trim();
|
||||||
|
if (!toUser || !imageContent) { alert('请填写接收人 wxid 和图片内容'); return; }
|
||||||
|
try {
|
||||||
|
await callApi('/api/send-image', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
key,
|
||||||
|
to_user_name: toUser,
|
||||||
|
image_content: imageContent,
|
||||||
|
text_content: ($('img-text').value || '').trim()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
alert('图片已发送');
|
||||||
|
$('img-content').value = '';
|
||||||
|
$('img-text').value = '';
|
||||||
|
} catch (e) { alert('发送图片失败: ' + e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
async function doPushSend() {
|
async function doPushSend() {
|
||||||
const key = getKey();
|
const key = getKey();
|
||||||
if (!key) return;
|
if (!key) return;
|
||||||
const tagId = $('push-tag').value;
|
const tagId = $('push-tag').value;
|
||||||
const groupId = $('push-group').value;
|
const groupId = $('push-group').value;
|
||||||
const content = $('push-content').value.trim();
|
const content = $('push-content').value.trim();
|
||||||
if (!tagId || !groupId || !content) { alert('请选择标签、群组并填写内容'); return; }
|
if (!groupId || !content) { alert('请选择目标群组并填写推送内容'); return; }
|
||||||
try {
|
try {
|
||||||
await callApi('/api/push-tasks', { method: 'POST', body: JSON.stringify({ key, product_tag_id: tagId, group_id: groupId, content }) });
|
const groupsRes = await callApi('/api/push-groups?key=' + encodeURIComponent(key));
|
||||||
|
const group = (groupsRes.items || []).find(g => g.id === groupId);
|
||||||
|
if (!group) { alert('未找到该群组'); return; }
|
||||||
|
const customerIds = group.customer_ids || [];
|
||||||
|
const tagIds = group.tag_ids || [];
|
||||||
|
const customersRes = await callApi('/api/customers?key=' + encodeURIComponent(key));
|
||||||
|
const allCustomers = customersRes.items || [];
|
||||||
|
let wxids = [];
|
||||||
|
customerIds.forEach(cid => {
|
||||||
|
const c = allCustomers.find(x => x.id === cid);
|
||||||
|
if (c && c.wxid) wxids.push(c.wxid);
|
||||||
|
});
|
||||||
|
if (tagIds.length) {
|
||||||
|
const byTag = new Set(tagIds);
|
||||||
|
allCustomers.forEach(c => {
|
||||||
|
if (c.wxid && (c.tags || []).some(t => byTag.has(t)) && !wxids.includes(c.wxid)) wxids.push(c.wxid);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
wxids = [...new Set(wxids)];
|
||||||
|
if (!wxids.length) { alert('该群组下没有可推送的客户,请先编辑群组添加客户或标签'); return; }
|
||||||
|
const items = wxids.map(wxid => ({ to_user_name: wxid, content }));
|
||||||
|
await callApi('/api/send-batch', { method: 'POST', body: JSON.stringify({ key, items }) });
|
||||||
|
await callApi('/api/push-tasks', { method: 'POST', body: JSON.stringify({ key, product_tag_id: tagId || '', group_id: groupId, content }) });
|
||||||
|
alert('已推送至 ' + items.length + ' 人');
|
||||||
$('push-content').value = '';
|
$('push-content').value = '';
|
||||||
} catch (e) { alert(e.message); }
|
} catch (e) { alert(e.message); }
|
||||||
}
|
}
|
||||||
|
|
||||||
document.querySelectorAll('.mgmt-tab').forEach(tab => tab.addEventListener('click', () => switchPanel(tab.dataset.panel)));
|
document.querySelectorAll('.mgmt-tab').forEach(tab => tab.addEventListener('click', () => switchPanel(tab.dataset.panel)));
|
||||||
$('btn-customer-save').addEventListener('click', saveCustomer);
|
$('btn-customer-save').addEventListener('click', saveCustomer);
|
||||||
|
$('btn-load-contact-list').addEventListener('click', loadContactListForWxidSelect);
|
||||||
|
$('c-wxid-select').addEventListener('change', function() {
|
||||||
|
const sel = $('c-wxid-select');
|
||||||
|
const opts = sel ? sel.querySelectorAll('option:checked') : [];
|
||||||
|
const vals = Array.from(opts).map(o => o.value).filter(Boolean);
|
||||||
|
if ($('c-wxid')) $('c-wxid').value = vals.join(',');
|
||||||
|
});
|
||||||
$('btn-greeting-add').addEventListener('click', addGreetingTask);
|
$('btn-greeting-add').addEventListener('click', addGreetingTask);
|
||||||
$('g-tag-add').addEventListener('click', () => {
|
$('g-tag-add').addEventListener('click', () => {
|
||||||
const sel = $('g-tag-select');
|
const sel = $('g-tag-select');
|
||||||
@@ -435,7 +665,11 @@
|
|||||||
greetingSelectedTags.push(v);
|
greetingSelectedTags.push(v);
|
||||||
renderGreetingTagChips();
|
renderGreetingTagChips();
|
||||||
});
|
});
|
||||||
$('key').addEventListener('input', () => { if ($('panel-greeting') && $('panel-greeting').classList.contains('show')) loadCustomerTagsForGreeting(); });
|
(function initKeyFromStorage() {
|
||||||
|
try {
|
||||||
|
if (typeof localStorage !== 'undefined' && $('key')) $('key').value = localStorage.getItem(KEY_STORAGE) || '';
|
||||||
|
} catch (_) {}
|
||||||
|
})();
|
||||||
(function initGreetingTimeMin() {
|
(function initGreetingTimeMin() {
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
const dateEl = $('g-date');
|
const dateEl = $('g-date');
|
||||||
@@ -458,11 +692,13 @@
|
|||||||
if ($('g-date')) { $('g-date').addEventListener('change', onGreetingTimeChange); $('g-date').addEventListener('input', onGreetingTimeChange); }
|
if ($('g-date')) { $('g-date').addEventListener('change', onGreetingTimeChange); $('g-date').addEventListener('input', onGreetingTimeChange); }
|
||||||
if ($('g-time')) { $('g-time').addEventListener('change', onGreetingTimeChange); $('g-time').addEventListener('input', onGreetingTimeChange); }
|
if ($('g-time')) { $('g-time').addEventListener('change', onGreetingTimeChange); $('g-time').addEventListener('input', onGreetingTimeChange); }
|
||||||
})();
|
})();
|
||||||
|
$('btn-load-friends').addEventListener('click', loadFriendsForMass);
|
||||||
|
$('btn-mass-send').addEventListener('click', doMassSend);
|
||||||
|
$('btn-send-image').addEventListener('click', doSendImage);
|
||||||
|
$('btn-ai-reply-save').addEventListener('click', saveAiReplyConfig);
|
||||||
$('btn-pt-add').addEventListener('click', addProductTag);
|
$('btn-pt-add').addEventListener('click', addProductTag);
|
||||||
$('btn-push-group-add').addEventListener('click', createPushGroup);
|
$('btn-push-group-add').addEventListener('click', createPushGroup);
|
||||||
$('btn-push-send').addEventListener('click', doPushSend);
|
$('btn-push-send').addEventListener('click', doPushSend);
|
||||||
$('key').addEventListener('change', function() { try { localStorage.setItem(KEY_STORAGE, this.value.trim()); } catch (_) {} });
|
|
||||||
if (typeof localStorage !== 'undefined' && localStorage.getItem(KEY_STORAGE)) $('key').value = localStorage.getItem(KEY_STORAGE);
|
|
||||||
loadCustomers();
|
loadCustomers();
|
||||||
loadGreetingTasks();
|
loadGreetingTasks();
|
||||||
loadProductTags();
|
loadProductTags();
|
||||||
|
|||||||
@@ -27,14 +27,19 @@
|
|||||||
.nav {
|
.nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 16px;
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
padding: 12px 24px;
|
padding: 12px 24px;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
background: rgba(2, 6, 23, 0.95);
|
background: rgba(2, 6, 23, 0.95);
|
||||||
}
|
}
|
||||||
|
.nav-links { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
|
||||||
.nav a { color: var(--muted); text-decoration: none; font-size: 14px; }
|
.nav a { color: var(--muted); text-decoration: none; font-size: 14px; }
|
||||||
.nav a:hover { color: var(--accent); }
|
.nav a:hover { color: var(--accent); }
|
||||||
.nav a.current { color: var(--accent); font-weight: 500; }
|
.nav a.current { color: var(--accent); font-weight: 500; }
|
||||||
|
.nav-banner { font-size: 18px; font-weight: 700; letter-spacing: 0.04em; color: var(--text); text-shadow: 0 0 20px rgba(34, 197, 94, 0.2); }
|
||||||
|
.nav-banner span { color: var(--accent); }
|
||||||
.container { max-width: 720px; margin: 0 auto; padding: 24px; }
|
.container { max-width: 720px; margin: 0 auto; padding: 24px; }
|
||||||
.card {
|
.card {
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
@@ -62,10 +67,13 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav class="nav">
|
<nav class="nav">
|
||||||
<a href="index.html">登录</a>
|
<div class="nav-banner">Wechat <span>智能托管服务</span></div>
|
||||||
|
<div class="nav-links">
|
||||||
<a href="manage.html">客户与消息管理</a>
|
<a href="manage.html">客户与消息管理</a>
|
||||||
<a href="chat.html">实时消息</a>
|
<a href="chat.html">实时消息</a>
|
||||||
<a href="models.html" class="current">模型管理</a>
|
<a href="models.html" class="current">模型管理</a>
|
||||||
|
<a href="swagger.html" target="_blank">API 文档 (Swagger)</a>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
|||||||
243
public/swagger.html
Normal file
243
public/swagger.html
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>API 文档 (Swagger) - Wechat 智能托管</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #f8f9fa;
|
||||||
|
--card: #ffffff;
|
||||||
|
--border: #e5e7eb;
|
||||||
|
--accent: #22c55e;
|
||||||
|
--text: #111827;
|
||||||
|
--muted: #6b7280;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--bg);
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.swagger-config {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: rgba(30, 41, 59, 0.6);
|
||||||
|
}
|
||||||
|
.config-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px 24px;
|
||||||
|
}
|
||||||
|
.config-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.config-item label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.config-item input[type="text"] {
|
||||||
|
width: 280px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(15, 23, 42, 0.9);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.config-item input[type="radio"] {
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
.config-item .radio-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.config-item .radio-group label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.copy-base-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.copy-base-wrap input[type="checkbox"] {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.base-url-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
#swagger-ui {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px 24px 40px;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 10px 25px rgba(15, 23, 42, 0.12);
|
||||||
|
}
|
||||||
|
.btn-copy {
|
||||||
|
padding: 4px 10px;
|
||||||
|
margin-left: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
background: rgba(34, 197, 94, 0.15);
|
||||||
|
color: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-copy:hover { background: rgba(34, 197, 94, 0.25); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="swagger-ui"></div>
|
||||||
|
|
||||||
|
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var baseUrlInput = document.getElementById('base-url');
|
||||||
|
var keyInput = document.getElementById('key-param');
|
||||||
|
var openapiUrlEl = document.getElementById('openapi-url');
|
||||||
|
var copyBaseCheck = document.getElementById('copy-base-url');
|
||||||
|
|
||||||
|
function getOpenApiUrl() {
|
||||||
|
var base = (baseUrlInput && baseUrlInput.value.trim()) || 'http://localhost:8000';
|
||||||
|
return base.replace(/\/$/, '') + '/openapi.json';
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyBaseUrl() {
|
||||||
|
var url = getOpenApiUrl();
|
||||||
|
if (openapiUrlEl) openapiUrlEl.textContent = url;
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
baseUrlInput && baseUrlInput.addEventListener('change', applyBaseUrl);
|
||||||
|
baseUrlInput && baseUrlInput.addEventListener('input', applyBaseUrl);
|
||||||
|
|
||||||
|
var apiUrl = applyBaseUrl();
|
||||||
|
|
||||||
|
window.xxcaibi = {
|
||||||
|
BASE_URL: (baseUrlInput && baseUrlInput.value.trim()) || 'http://localhost:8000',
|
||||||
|
copy: true,
|
||||||
|
KEY: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
copyBaseCheck && copyBaseCheck.addEventListener('change', function () {
|
||||||
|
window.xxcaibi.copy = copyBaseCheck.checked;
|
||||||
|
});
|
||||||
|
keyInput && keyInput.addEventListener('input', function () {
|
||||||
|
window.xxcaibi.KEY = keyInput.value.trim();
|
||||||
|
});
|
||||||
|
|
||||||
|
function copyText(text) {
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
navigator.clipboard.writeText(text).catch(function () {
|
||||||
|
fallbackCopy(text);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
fallbackCopy(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function fallbackCopy(text) {
|
||||||
|
var ta = document.createElement('textarea');
|
||||||
|
ta.value = text;
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.select();
|
||||||
|
try { document.execCommand('copy'); } catch (e) {}
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.ui = SwaggerUIBundle({
|
||||||
|
url: apiUrl,
|
||||||
|
dom_id: '#swagger-ui',
|
||||||
|
deepLinking: true,
|
||||||
|
presets: [
|
||||||
|
SwaggerUIBundle.presets.apis,
|
||||||
|
SwaggerUIBundle.SwaggerUIStandalonePreset
|
||||||
|
],
|
||||||
|
layout: 'BaseLayout',
|
||||||
|
docExpansion: 'none',
|
||||||
|
onComplete: function () {
|
||||||
|
applyBaseUrl();
|
||||||
|
var config = window.ui.specSelectors.specJson();
|
||||||
|
if (config && config.toJS) {
|
||||||
|
var spec = config.toJS();
|
||||||
|
window.xxcaibi.BASE_URL = (baseUrlInput && baseUrlInput.value.trim()) || (spec.servers && spec.servers[0] && spec.servers[0].url) || 'http://localhost:8000';
|
||||||
|
}
|
||||||
|
function addCopyButtons() {
|
||||||
|
document.querySelectorAll('.opblock-summary-path').forEach(function (pathEl) {
|
||||||
|
if (pathEl.getAttribute('data-copy-done')) return;
|
||||||
|
var pathSpan = pathEl.querySelector('[data-path]');
|
||||||
|
var path = pathSpan ? pathSpan.getAttribute('data-path') : (pathEl.textContent || '').trim().replace(/\s+/g, '');
|
||||||
|
if (!path) return;
|
||||||
|
var parent = pathEl.closest('.opblock-summary');
|
||||||
|
var methodEl = parent ? parent.querySelector('.opblock-summary-method') : null;
|
||||||
|
var method = methodEl ? methodEl.textContent.trim() : '';
|
||||||
|
var btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.className = 'btn-copy';
|
||||||
|
btn.textContent = '复制';
|
||||||
|
btn.addEventListener('click', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
var base = (window.xxcaibi && window.xxcaibi.copy && window.xxcaibi.BASE_URL) ? window.xxcaibi.BASE_URL.replace(/\/$/, '') : '';
|
||||||
|
var text = base ? base + path : method + ' ' + path;
|
||||||
|
copyText(text);
|
||||||
|
btn.textContent = '已复制';
|
||||||
|
setTimeout(function () { btn.textContent = '复制'; }, 600);
|
||||||
|
});
|
||||||
|
pathEl.appendChild(btn);
|
||||||
|
pathEl.setAttribute('data-copy-done', '1');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
addCopyButtons();
|
||||||
|
var observer = new MutationObserver(function () { addCopyButtons(); });
|
||||||
|
var swaggerEl = document.getElementById('swagger-ui');
|
||||||
|
if (swaggerEl) observer.observe(swaggerEl, { childList: true, subtree: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('input[name="doc-expansion"]').forEach(function (radio) {
|
||||||
|
radio.addEventListener('change', function () {
|
||||||
|
if (window.ui && window.ui.getConfigs) {
|
||||||
|
var c = window.ui.getConfigs();
|
||||||
|
c.docExpansion = this.value;
|
||||||
|
window.ui.configsActions && window.ui.configsActions.toggle && window.ui.configsActions.toggle();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
keyInput && keyInput.addEventListener('change', function () {
|
||||||
|
var key = keyInput.value.trim();
|
||||||
|
if (!window.ui || !window.ui.specActions) return;
|
||||||
|
try {
|
||||||
|
var spec = window.ui.specSelectors.specJson().toJS();
|
||||||
|
var paths = spec.paths || {};
|
||||||
|
Object.keys(paths).forEach(function (pathKey) {
|
||||||
|
Object.keys(paths[pathKey]).forEach(function (method) {
|
||||||
|
var op = paths[pathKey][method];
|
||||||
|
if (op.parameters && Array.isArray(op.parameters)) {
|
||||||
|
op.parameters.forEach(function (p) {
|
||||||
|
if (p.name === 'key' && p.in === 'query') p.default = key;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
window.ui.specActions.updateSpec(JSON.stringify(spec));
|
||||||
|
} catch (e) {}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -5,6 +5,8 @@ set -e
|
|||||||
IMAGE_NAME="wechat-admin-backend"
|
IMAGE_NAME="wechat-admin-backend"
|
||||||
CONTAINER_NAME="wechat-admin-backend"
|
CONTAINER_NAME="wechat-admin-backend"
|
||||||
PORT="${PORT:-3000}"
|
PORT="${PORT:-3000}"
|
||||||
|
# 数据目录挂载到宿主机,防止容器删除后丢失(SQLite 库 wechat.db 及表数据)
|
||||||
|
HOST_DATA_DIR="${HOST_DATA_DIR:-$(pwd)/data}"
|
||||||
|
|
||||||
echo "Building Docker image: ${IMAGE_NAME}..."
|
echo "Building Docker image: ${IMAGE_NAME}..."
|
||||||
docker build -t "${IMAGE_NAME}" .
|
docker build -t "${IMAGE_NAME}" .
|
||||||
@@ -14,6 +16,9 @@ if [ "$(docker ps -aq -f name=${CONTAINER_NAME})" ]; then
|
|||||||
docker rm -f "${CONTAINER_NAME}" >/dev/null 2>&1 || true
|
docker rm -f "${CONTAINER_NAME}" >/dev/null 2>&1 || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
mkdir -p "${HOST_DATA_DIR}"
|
||||||
|
echo "Data dir (host): ${HOST_DATA_DIR} -> container /app/backend/data"
|
||||||
|
|
||||||
ENV_FILE=".env"
|
ENV_FILE=".env"
|
||||||
if [ ! -f "${ENV_FILE}" ]; then
|
if [ ! -f "${ENV_FILE}" ]; then
|
||||||
echo "Env file ${ENV_FILE} not found, copying from .env.example ..."
|
echo "Env file ${ENV_FILE} not found, copying from .env.example ..."
|
||||||
@@ -26,7 +31,9 @@ docker run -d \
|
|||||||
--env-file "${ENV_FILE}" \
|
--env-file "${ENV_FILE}" \
|
||||||
-p "${PORT}:3000" \
|
-p "${PORT}:3000" \
|
||||||
-p "8000:8000" \
|
-p "8000:8000" \
|
||||||
|
-v "${HOST_DATA_DIR}:/app/backend/data" \
|
||||||
"${IMAGE_NAME}"
|
"${IMAGE_NAME}"
|
||||||
|
|
||||||
echo "Container started. Health check: curl http://localhost:${PORT}/health"
|
echo "Container started. Data persisted on host: ${HOST_DATA_DIR}"
|
||||||
|
echo "Health check: curl http://localhost:${PORT}/health"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user