mirror of
https://github.com/zhinianboke/xianyu-auto-reply.git
synced 2025-08-01 12:07:36 +08:00
补充延时发货,完善自动回复逻辑
This commit is contained in:
parent
10ab84bf19
commit
20e2dcdc96
40
.gitignore
vendored
40
.gitignore
vendored
@ -59,6 +59,42 @@ Thumbs.db
|
||||
*~
|
||||
|
||||
# Local environment files
|
||||
|
||||
.env.local
|
||||
.env.*.local
|
||||
.env.*.local
|
||||
|
||||
# ==================== 项目特定文件 ====================
|
||||
# 日志文件
|
||||
logs/
|
||||
realtime.log
|
||||
|
||||
# 数据目录
|
||||
data/
|
||||
backups/
|
||||
|
||||
# Excel测试文件
|
||||
keywords_*.xlsx
|
||||
*.xls
|
||||
|
||||
# 图片缓存
|
||||
*.png.cache
|
||||
*.jpg.cache
|
||||
|
||||
# 配置文件(包含敏感信息)
|
||||
config.local.yml
|
||||
global_config.local.yml
|
||||
|
||||
# 测试文件
|
||||
test_*.py
|
||||
*_test.py
|
||||
keywords_sample.xlsx
|
||||
|
||||
# 备份文件
|
||||
*.bak
|
||||
*.backup
|
||||
*.old
|
||||
|
||||
# 压缩文件
|
||||
*.zip
|
||||
*.tar.gz
|
||||
*.rar
|
||||
*.7z
|
@ -76,6 +76,10 @@ RUN apt-get update && \
|
||||
# 设置时区
|
||||
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
||||
|
||||
# 验证Node.js安装并设置环境变量
|
||||
RUN node --version && npm --version
|
||||
ENV NODE_PATH=/usr/lib/node_modules
|
||||
|
||||
# 复制requirements.txt并安装Python依赖
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir --upgrade pip && \
|
||||
|
80
README.md
80
README.md
@ -21,15 +21,20 @@
|
||||
|
||||
### 🤖 智能回复系统
|
||||
- **关键词匹配** - 支持精确关键词匹配回复
|
||||
- **商品专用回复** - 支持为特定商品设置专用关键词回复
|
||||
- **通用关键词** - 支持全局通用关键词,适用于所有商品
|
||||
- **批量导入导出** - 支持Excel格式的关键词批量导入导出
|
||||
- **AI智能回复** - 集成OpenAI API,支持上下文理解
|
||||
- **变量替换** - 回复内容支持动态变量(用户名、商品信息等)
|
||||
- **优先级策略** - 关键词回复优先,AI回复兜底
|
||||
- **优先级策略** - 商品专用关键词 > 通用关键词 > AI回复
|
||||
|
||||
### 🚚 自动发货功能
|
||||
- **智能匹配** - 基于商品信息自动匹配发货规则
|
||||
- **延时发货** - 支持设置发货延时时间(0-3600秒)
|
||||
- **多种触发** - 支持付款消息、小刀消息等多种触发条件
|
||||
- **防重复发货** - 智能防重复机制,避免重复发货
|
||||
- **卡密发货** - 支持文本内容和卡密文件发货
|
||||
- **多种发货方式** - 支持文本内容、卡密文件、API调用等发货方式
|
||||
- **发货统计** - 完整的发货记录和统计功能
|
||||
|
||||
### 🛍️ 商品管理
|
||||
- **自动收集** - 消息触发时自动收集商品信息
|
||||
@ -44,12 +49,63 @@
|
||||
- **前端分页** - 灵活的前端分页显示
|
||||
- **商品详情** - 支持查看完整商品详情信息
|
||||
|
||||
### <EFBFBD>📊 系统监控
|
||||
### 📊 系统监控
|
||||
- **实时日志** - 完整的操作日志记录和查看
|
||||
- **性能监控** - 系统资源使用情况监控
|
||||
- **健康检查** - 服务状态健康检查
|
||||
|
||||
### 📁 数据管理
|
||||
- **Excel导入导出** - 支持关键词数据的Excel格式导入导出
|
||||
- **模板生成** - 自动生成包含示例数据的导入模板
|
||||
- **批量操作** - 支持批量添加、更新关键词数据
|
||||
- **数据验证** - 导入时自动验证数据格式和重复性
|
||||
- **数据备份** - 自动数据备份和恢复
|
||||
|
||||
## 📁 项目结构
|
||||
|
||||
```
|
||||
xianyu-auto-reply/
|
||||
├── 📄 核心文件
|
||||
│ ├── Start.py # 项目启动入口
|
||||
│ ├── XianyuAutoAsync.py # 闲鱼WebSocket连接和消息处理
|
||||
│ ├── reply_server.py # FastAPI Web服务器和API接口
|
||||
│ ├── db_manager.py # SQLite数据库管理
|
||||
│ ├── cookie_manager.py # Cookie和账号管理
|
||||
│ ├── ai_reply_engine.py # AI智能回复引擎
|
||||
│ ├── file_log_collector.py # 日志收集和管理
|
||||
│ └── config.py # 配置文件管理
|
||||
├── 🛠️ 工具模块
|
||||
│ └── utils/
|
||||
│ ├── xianyu_utils.py # 闲鱼API工具函数
|
||||
│ ├── message_utils.py # 消息格式化工具
|
||||
│ ├── ws_utils.py # WebSocket客户端工具
|
||||
│ └── item_search.py # 商品搜索功能
|
||||
├── 🌐 前端界面
|
||||
│ └── static/
|
||||
│ ├── index.html # 主管理界面
|
||||
│ ├── login.html # 用户登录页面
|
||||
│ ├── register.html # 用户注册页面
|
||||
│ ├── user_management.html # 用户管理页面
|
||||
│ ├── data_management.html # 数据管理页面
|
||||
│ ├── log_management.html # 日志管理页面
|
||||
│ ├── item_search.html # 商品搜索页面
|
||||
│ └── lib/ # 前端依赖库
|
||||
├── 🐳 Docker部署
|
||||
│ ├── Dockerfile # Docker镜像构建文件
|
||||
│ ├── docker-compose.yml # Docker Compose配置
|
||||
│ ├── docker-deploy.sh # Docker部署脚本
|
||||
│ ├── .env # 环境变量配置文件
|
||||
│ └── nginx/ # Nginx反向代理配置
|
||||
├── 📋 配置文件
|
||||
│ ├── global_config.yml # 全局配置文件
|
||||
│ ├── requirements.txt # Python依赖包
|
||||
│ └── README.md # 项目说明文档
|
||||
└── 📊 数据目录
|
||||
├── data/ # 数据库和数据文件
|
||||
├── logs/ # 日志文件
|
||||
└── backups/ # 备份文件
|
||||
```
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 方式一:Docker 一键部署(最简单)
|
||||
@ -351,6 +407,24 @@ exit
|
||||
docker restart xianyu-auto-reply
|
||||
```
|
||||
|
||||
**2. JavaScript运行时错误**
|
||||
```bash
|
||||
# 错误信息:Could not find an available JavaScript runtime
|
||||
|
||||
# 解决方案1:重新构建镜像(推荐)
|
||||
docker-compose down
|
||||
docker-compose build --no-cache
|
||||
docker-compose up -d
|
||||
|
||||
# 解决方案2:手动修复
|
||||
docker exec -it xianyu-auto-reply bash
|
||||
apt-get update
|
||||
apt-get install -y nodejs npm
|
||||
python fix_js_runtime.py
|
||||
exit
|
||||
docker restart xianyu-auto-reply
|
||||
```
|
||||
|
||||
**2. 其他问题排查**
|
||||
1. 查看日志:`docker-compose logs -f`
|
||||
2. 检查状态:`./docker-deploy.sh status`
|
||||
|
@ -752,21 +752,39 @@ class XianyuLive:
|
||||
logger.error(f"获取默认回复失败: {self._safe_str(e)}")
|
||||
return None
|
||||
|
||||
async def get_keyword_reply(self, send_user_name: str, send_user_id: str, send_message: str) -> str:
|
||||
"""获取关键词匹配回复"""
|
||||
async def get_keyword_reply(self, send_user_name: str, send_user_id: str, send_message: str, item_id: str = None) -> str:
|
||||
"""获取关键词匹配回复(支持商品ID优先匹配)"""
|
||||
try:
|
||||
from db_manager import db_manager
|
||||
|
||||
# 获取当前账号的关键词列表
|
||||
keywords = db_manager.get_keywords(self.cookie_id)
|
||||
# 获取当前账号的关键词列表(包含商品ID)
|
||||
keywords = db_manager.get_keywords_with_item_id(self.cookie_id)
|
||||
|
||||
if not keywords:
|
||||
logger.debug(f"账号 {self.cookie_id} 没有配置关键词")
|
||||
return None
|
||||
|
||||
# 遍历关键词,查找匹配
|
||||
for keyword, reply in keywords:
|
||||
if keyword.lower() in send_message.lower():
|
||||
# 1. 如果有商品ID,优先匹配该商品ID对应的关键词
|
||||
if item_id:
|
||||
for keyword, reply, keyword_item_id in keywords:
|
||||
if keyword_item_id == item_id and keyword.lower() in send_message.lower():
|
||||
# 进行变量替换
|
||||
try:
|
||||
formatted_reply = reply.format(
|
||||
send_user_name=send_user_name,
|
||||
send_user_id=send_user_id,
|
||||
send_message=send_message
|
||||
)
|
||||
logger.info(f"商品ID关键词匹配成功: 商品{item_id} '{keyword}' -> {formatted_reply}")
|
||||
return formatted_reply
|
||||
except Exception as format_error:
|
||||
logger.error(f"关键词回复变量替换失败: {self._safe_str(format_error)}")
|
||||
# 如果变量替换失败,返回原始内容
|
||||
return reply
|
||||
|
||||
# 2. 如果商品ID匹配失败或没有商品ID,匹配没有商品ID的通用关键词
|
||||
for keyword, reply, keyword_item_id in keywords:
|
||||
if not keyword_item_id and keyword.lower() in send_message.lower():
|
||||
# 进行变量替换
|
||||
try:
|
||||
formatted_reply = reply.format(
|
||||
@ -774,7 +792,7 @@ class XianyuLive:
|
||||
send_user_id=send_user_id,
|
||||
send_message=send_message
|
||||
)
|
||||
logger.info(f"关键词匹配成功: '{keyword}' -> {formatted_reply}")
|
||||
logger.info(f"通用关键词匹配成功: '{keyword}' -> {formatted_reply}")
|
||||
return formatted_reply
|
||||
except Exception as format_error:
|
||||
logger.error(f"关键词回复变量替换失败: {self._safe_str(format_error)}")
|
||||
@ -1164,6 +1182,13 @@ class XianyuLive:
|
||||
rule = delivery_rules[0]
|
||||
logger.info(f"找到匹配的发货规则: {rule['keyword']} -> {rule['card_name']} ({rule['card_type']})")
|
||||
|
||||
# 检查是否需要延时发货
|
||||
delay_seconds = rule.get('card_delay_seconds', 0)
|
||||
if delay_seconds and delay_seconds > 0:
|
||||
logger.info(f"检测到延时发货设置: {delay_seconds}秒,开始延时...")
|
||||
await asyncio.sleep(delay_seconds)
|
||||
logger.info(f"延时发货完成,开始发送内容")
|
||||
|
||||
delivery_content = None
|
||||
|
||||
# 根据卡券类型处理发货内容
|
||||
@ -2099,8 +2124,8 @@ class XianyuLive:
|
||||
|
||||
# 如果API回复失败或未启用API,按新的优先级顺序处理
|
||||
if not reply:
|
||||
# 1. 首先尝试关键词匹配
|
||||
reply = await self.get_keyword_reply(send_user_name, send_user_id, send_message)
|
||||
# 1. 首先尝试关键词匹配(传入商品ID)
|
||||
reply = await self.get_keyword_reply(send_user_name, send_user_id, send_message, item_id)
|
||||
if reply:
|
||||
reply_source = '关键词' # 标记为关键词回复
|
||||
else:
|
||||
|
108
db_manager.py
108
db_manager.py
@ -123,6 +123,7 @@ class DBManager:
|
||||
cookie_id TEXT,
|
||||
keyword TEXT,
|
||||
reply TEXT,
|
||||
item_id TEXT,
|
||||
PRIMARY KEY (cookie_id, keyword),
|
||||
FOREIGN KEY (cookie_id) REFERENCES cookies(id) ON DELETE CASCADE
|
||||
)
|
||||
@ -195,6 +196,7 @@ class DBManager:
|
||||
data_content TEXT,
|
||||
description TEXT,
|
||||
enabled BOOLEAN DEFAULT TRUE,
|
||||
delay_seconds INTEGER DEFAULT 0,
|
||||
user_id INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
@ -212,6 +214,24 @@ class DBManager:
|
||||
self._execute_sql(cursor, "CREATE INDEX IF NOT EXISTS idx_cards_user_id ON cards(user_id)")
|
||||
logger.info("cards 表 user_id 列添加完成")
|
||||
|
||||
# 检查并添加 delay_seconds 列(用于自动发货延时功能)
|
||||
try:
|
||||
self._execute_sql(cursor, "SELECT delay_seconds FROM cards LIMIT 1")
|
||||
except sqlite3.OperationalError:
|
||||
# delay_seconds 列不存在,需要添加
|
||||
logger.info("正在为 cards 表添加 delay_seconds 列...")
|
||||
self._execute_sql(cursor, "ALTER TABLE cards ADD COLUMN delay_seconds INTEGER DEFAULT 0")
|
||||
logger.info("cards 表 delay_seconds 列添加完成")
|
||||
|
||||
# 检查并添加 item_id 列(用于自动回复商品ID功能)
|
||||
try:
|
||||
self._execute_sql(cursor, "SELECT item_id FROM keywords LIMIT 1")
|
||||
except sqlite3.OperationalError:
|
||||
# item_id 列不存在,需要添加
|
||||
logger.info("正在为 keywords 表添加 item_id 列...")
|
||||
self._execute_sql(cursor, "ALTER TABLE keywords ADD COLUMN item_id TEXT")
|
||||
logger.info("keywords 表 item_id 列添加完成")
|
||||
|
||||
# 创建商品信息表
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS item_info (
|
||||
@ -576,7 +596,13 @@ class DBManager:
|
||||
|
||||
# -------------------- 关键字操作 --------------------
|
||||
def save_keywords(self, cookie_id: str, keywords: List[Tuple[str, str]]) -> bool:
|
||||
"""保存关键字列表,先删除旧数据再插入新数据"""
|
||||
"""保存关键字列表,先删除旧数据再插入新数据(向后兼容方法)"""
|
||||
# 转换为新格式(不包含item_id)
|
||||
keywords_with_item_id = [(keyword, reply, None) for keyword, reply in keywords]
|
||||
return self.save_keywords_with_item_id(cookie_id, keywords_with_item_id)
|
||||
|
||||
def save_keywords_with_item_id(self, cookie_id: str, keywords: List[Tuple[str, str, str]]) -> bool:
|
||||
"""保存关键字列表(包含商品ID),先删除旧数据再插入新数据"""
|
||||
with self.lock:
|
||||
try:
|
||||
cursor = self.conn.cursor()
|
||||
@ -585,8 +611,10 @@ class DBManager:
|
||||
self._execute_sql(cursor, "DELETE FROM keywords WHERE cookie_id = ?", (cookie_id,))
|
||||
|
||||
# 插入新关键字
|
||||
for keyword, reply in keywords:
|
||||
self._execute_sql(cursor, "INSERT INTO keywords (cookie_id, keyword, reply) VALUES (?, ?, ?)", (cookie_id, keyword, reply))
|
||||
for keyword, reply, item_id in keywords:
|
||||
self._execute_sql(cursor,
|
||||
"INSERT INTO keywords (cookie_id, keyword, reply, item_id) VALUES (?, ?, ?, ?)",
|
||||
(cookie_id, keyword, reply, item_id))
|
||||
|
||||
self.conn.commit()
|
||||
logger.info(f"关键字保存成功: {cookie_id}, {len(keywords)}条")
|
||||
@ -597,7 +625,7 @@ class DBManager:
|
||||
return False
|
||||
|
||||
def get_keywords(self, cookie_id: str) -> List[Tuple[str, str]]:
|
||||
"""获取指定Cookie的关键字列表"""
|
||||
"""获取指定Cookie的关键字列表(向后兼容方法)"""
|
||||
with self.lock:
|
||||
try:
|
||||
cursor = self.conn.cursor()
|
||||
@ -606,6 +634,41 @@ class DBManager:
|
||||
except Exception as e:
|
||||
logger.error(f"获取关键字失败: {e}")
|
||||
return []
|
||||
|
||||
def get_keywords_with_item_id(self, cookie_id: str) -> List[Tuple[str, str, str]]:
|
||||
"""获取指定Cookie的关键字列表(包含商品ID)"""
|
||||
with self.lock:
|
||||
try:
|
||||
cursor = self.conn.cursor()
|
||||
self._execute_sql(cursor, "SELECT keyword, reply, item_id FROM keywords WHERE cookie_id = ?", (cookie_id,))
|
||||
return [(row[0], row[1], row[2]) for row in cursor.fetchall()]
|
||||
except Exception as e:
|
||||
logger.error(f"获取关键字失败: {e}")
|
||||
return []
|
||||
|
||||
def check_keyword_duplicate(self, cookie_id: str, keyword: str, item_id: str = None) -> bool:
|
||||
"""检查关键词是否重复"""
|
||||
with self.lock:
|
||||
try:
|
||||
cursor = self.conn.cursor()
|
||||
if item_id:
|
||||
# 如果有商品ID,检查相同cookie_id、keyword、item_id的组合
|
||||
self._execute_sql(cursor,
|
||||
"SELECT COUNT(*) FROM keywords WHERE cookie_id = ? AND keyword = ? AND item_id = ?",
|
||||
(cookie_id, keyword, item_id))
|
||||
else:
|
||||
# 如果没有商品ID,检查相同cookie_id、keyword且item_id为空的组合
|
||||
self._execute_sql(cursor,
|
||||
"SELECT COUNT(*) FROM keywords WHERE cookie_id = ? AND keyword = ? AND (item_id IS NULL OR item_id = '')",
|
||||
(cookie_id, keyword))
|
||||
|
||||
count = cursor.fetchone()[0]
|
||||
return count > 0
|
||||
except Exception as e:
|
||||
logger.error(f"检查关键词重复失败: {e}")
|
||||
return False
|
||||
|
||||
|
||||
|
||||
def get_all_keywords(self, user_id: int = None) -> Dict[str, List[Tuple[str, str]]]:
|
||||
"""获取所有Cookie的关键字(支持用户隔离)"""
|
||||
@ -1637,7 +1700,7 @@ class DBManager:
|
||||
|
||||
def create_card(self, name: str, card_type: str, api_config=None,
|
||||
text_content: str = None, data_content: str = None,
|
||||
description: str = None, enabled: bool = True, user_id: int = None):
|
||||
description: str = None, enabled: bool = True, delay_seconds: int = 0, user_id: int = None):
|
||||
"""创建新卡券"""
|
||||
with self.lock:
|
||||
try:
|
||||
@ -1653,10 +1716,10 @@ class DBManager:
|
||||
cursor = self.conn.cursor()
|
||||
cursor.execute('''
|
||||
INSERT INTO cards (name, type, api_config, text_content, data_content,
|
||||
description, enabled, user_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
description, enabled, delay_seconds, user_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
''', (name, card_type, api_config_str, text_content, data_content,
|
||||
description, enabled, user_id))
|
||||
description, enabled, delay_seconds, user_id))
|
||||
self.conn.commit()
|
||||
card_id = cursor.lastrowid
|
||||
logger.info(f"创建卡券成功: {name} (ID: {card_id})")
|
||||
@ -1673,7 +1736,7 @@ class DBManager:
|
||||
if user_id is not None:
|
||||
cursor.execute('''
|
||||
SELECT id, name, type, api_config, text_content, data_content,
|
||||
description, enabled, created_at, updated_at
|
||||
description, enabled, delay_seconds, created_at, updated_at
|
||||
FROM cards
|
||||
WHERE user_id = ?
|
||||
ORDER BY created_at DESC
|
||||
@ -1681,7 +1744,7 @@ class DBManager:
|
||||
else:
|
||||
cursor.execute('''
|
||||
SELECT id, name, type, api_config, text_content, data_content,
|
||||
description, enabled, created_at, updated_at
|
||||
description, enabled, delay_seconds, created_at, updated_at
|
||||
FROM cards
|
||||
ORDER BY created_at DESC
|
||||
''')
|
||||
@ -1707,8 +1770,9 @@ class DBManager:
|
||||
'data_content': row[5],
|
||||
'description': row[6],
|
||||
'enabled': bool(row[7]),
|
||||
'created_at': row[8],
|
||||
'updated_at': row[9]
|
||||
'delay_seconds': row[8] or 0,
|
||||
'created_at': row[9],
|
||||
'updated_at': row[10]
|
||||
})
|
||||
|
||||
return cards
|
||||
@ -1724,13 +1788,13 @@ class DBManager:
|
||||
if user_id is not None:
|
||||
cursor.execute('''
|
||||
SELECT id, name, type, api_config, text_content, data_content,
|
||||
description, enabled, created_at, updated_at
|
||||
description, enabled, delay_seconds, created_at, updated_at
|
||||
FROM cards WHERE id = ? AND user_id = ?
|
||||
''', (card_id, user_id))
|
||||
else:
|
||||
cursor.execute('''
|
||||
SELECT id, name, type, api_config, text_content, data_content,
|
||||
description, enabled, created_at, updated_at
|
||||
description, enabled, delay_seconds, created_at, updated_at
|
||||
FROM cards WHERE id = ?
|
||||
''', (card_id,))
|
||||
|
||||
@ -1755,8 +1819,9 @@ class DBManager:
|
||||
'data_content': row[5],
|
||||
'description': row[6],
|
||||
'enabled': bool(row[7]),
|
||||
'created_at': row[8],
|
||||
'updated_at': row[9]
|
||||
'delay_seconds': row[8] or 0,
|
||||
'created_at': row[9],
|
||||
'updated_at': row[10]
|
||||
}
|
||||
return None
|
||||
except Exception as e:
|
||||
@ -1765,7 +1830,7 @@ class DBManager:
|
||||
|
||||
def update_card(self, card_id: int, name: str = None, card_type: str = None,
|
||||
api_config=None, text_content: str = None, data_content: str = None,
|
||||
description: str = None, enabled: bool = None):
|
||||
description: str = None, enabled: bool = None, delay_seconds: int = None):
|
||||
"""更新卡券"""
|
||||
with self.lock:
|
||||
try:
|
||||
@ -1805,6 +1870,9 @@ class DBManager:
|
||||
if enabled is not None:
|
||||
update_fields.append("enabled = ?")
|
||||
params.append(enabled)
|
||||
if delay_seconds is not None:
|
||||
update_fields.append("delay_seconds = ?")
|
||||
params.append(delay_seconds)
|
||||
|
||||
if not update_fields:
|
||||
return True # 没有需要更新的字段
|
||||
@ -1903,7 +1971,8 @@ class DBManager:
|
||||
SELECT dr.id, dr.keyword, dr.card_id, dr.delivery_count, dr.enabled,
|
||||
dr.description, dr.delivery_times,
|
||||
c.name as card_name, c.type as card_type, c.api_config,
|
||||
c.text_content, c.data_content, c.enabled as card_enabled, c.description as card_description
|
||||
c.text_content, c.data_content, c.enabled as card_enabled, c.description as card_description,
|
||||
c.delay_seconds as card_delay_seconds
|
||||
FROM delivery_rules dr
|
||||
LEFT JOIN cards c ON dr.card_id = c.id
|
||||
WHERE dr.enabled = 1 AND c.enabled = 1
|
||||
@ -1942,7 +2011,8 @@ class DBManager:
|
||||
'card_text_content': row[10],
|
||||
'card_data_content': row[11],
|
||||
'card_enabled': bool(row[12]),
|
||||
'card_description': row[13] # 卡券备注信息
|
||||
'card_description': row[13], # 卡券备注信息
|
||||
'card_delay_seconds': row[14] or 0 # 延时秒数
|
||||
})
|
||||
|
||||
return rules
|
||||
|
297
reply_server.py
297
reply_server.py
@ -1,6 +1,6 @@
|
||||
from fastapi import FastAPI, HTTPException, Depends, status, UploadFile, File
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse, StreamingResponse
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Tuple, Optional, Dict, Any
|
||||
@ -12,6 +12,8 @@ import time
|
||||
import json
|
||||
import os
|
||||
import uvicorn
|
||||
import pandas as pd
|
||||
import io
|
||||
|
||||
import cookie_manager
|
||||
from db_manager import db_manager
|
||||
@ -1318,6 +1320,9 @@ def remove_cookie(cid: str, current_user: Dict[str, Any] = Depends(get_current_u
|
||||
class KeywordIn(BaseModel):
|
||||
keywords: Dict[str, str] # key -> reply
|
||||
|
||||
class KeywordWithItemIdIn(BaseModel):
|
||||
keywords: List[Dict[str, Any]] # [{"keyword": str, "reply": str, "item_id": str}]
|
||||
|
||||
|
||||
@app.get("/keywords/{cid}")
|
||||
def get_keywords(cid: str, current_user: Dict[str, Any] = Depends(get_current_user)):
|
||||
@ -1335,6 +1340,35 @@ def get_keywords(cid: str, current_user: Dict[str, Any] = Depends(get_current_us
|
||||
return cookie_manager.manager.get_keywords(cid)
|
||||
|
||||
|
||||
@app.get("/keywords-with-item-id/{cid}")
|
||||
def get_keywords_with_item_id(cid: str, current_user: Dict[str, Any] = Depends(get_current_user)):
|
||||
"""获取包含商品ID的关键词列表"""
|
||||
if cookie_manager.manager is None:
|
||||
raise HTTPException(status_code=500, detail="CookieManager 未就绪")
|
||||
|
||||
# 检查cookie是否属于当前用户
|
||||
user_id = current_user['user_id']
|
||||
from db_manager import db_manager
|
||||
user_cookies = db_manager.get_all_cookies(user_id)
|
||||
|
||||
if cid not in user_cookies:
|
||||
raise HTTPException(status_code=403, detail="无权限访问该Cookie")
|
||||
|
||||
# 获取包含商品ID的关键词
|
||||
keywords = db_manager.get_keywords_with_item_id(cid)
|
||||
|
||||
# 转换为前端需要的格式
|
||||
result = []
|
||||
for keyword, reply, item_id in keywords:
|
||||
result.append({
|
||||
"keyword": keyword,
|
||||
"reply": reply,
|
||||
"item_id": item_id or ""
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@app.post("/keywords/{cid}")
|
||||
def update_keywords(cid: str, body: KeywordIn, current_user: Dict[str, Any] = Depends(get_current_user)):
|
||||
if cookie_manager.manager is None:
|
||||
@ -1357,6 +1391,263 @@ def update_keywords(cid: str, body: KeywordIn, current_user: Dict[str, Any] = De
|
||||
return {"msg": "updated", "count": len(kw_list)}
|
||||
|
||||
|
||||
@app.post("/keywords-with-item-id/{cid}")
|
||||
def update_keywords_with_item_id(cid: str, body: KeywordWithItemIdIn, current_user: Dict[str, Any] = Depends(get_current_user)):
|
||||
"""更新包含商品ID的关键词列表"""
|
||||
if cookie_manager.manager is None:
|
||||
raise HTTPException(status_code=500, detail="CookieManager 未就绪")
|
||||
|
||||
# 检查cookie是否属于当前用户
|
||||
user_id = current_user['user_id']
|
||||
from db_manager import db_manager
|
||||
user_cookies = db_manager.get_all_cookies(user_id)
|
||||
|
||||
if cid not in user_cookies:
|
||||
log_with_user('warning', f"尝试操作其他用户的Cookie关键字: {cid}", current_user)
|
||||
raise HTTPException(status_code=403, detail="无权限操作该Cookie")
|
||||
|
||||
# 验证数据格式
|
||||
keywords_to_save = []
|
||||
keyword_set = set() # 用于检查当前提交的关键词中是否有重复
|
||||
|
||||
for kw_data in body.keywords:
|
||||
keyword = kw_data.get('keyword', '').strip()
|
||||
reply = kw_data.get('reply', '').strip()
|
||||
item_id = kw_data.get('item_id', '').strip() or None
|
||||
|
||||
if not keyword or not reply:
|
||||
raise HTTPException(status_code=400, detail="关键词和回复内容不能为空")
|
||||
|
||||
# 检查当前提交的关键词中是否有重复
|
||||
keyword_key = f"{keyword}|{item_id or ''}"
|
||||
if keyword_key in keyword_set:
|
||||
item_id_text = f"(商品ID: {item_id})" if item_id else "(通用关键词)"
|
||||
raise HTTPException(status_code=400, detail=f"关键词 '{keyword}' {item_id_text} 在当前提交中重复")
|
||||
keyword_set.add(keyword_key)
|
||||
|
||||
keywords_to_save.append((keyword, reply, item_id))
|
||||
|
||||
# 保存关键词
|
||||
success = db_manager.save_keywords_with_item_id(cid, keywords_to_save)
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail="保存关键词失败")
|
||||
|
||||
log_with_user('info', f"更新Cookie关键字(含商品ID): {cid}, 数量: {len(keywords_to_save)}", current_user)
|
||||
return {"msg": "updated", "count": len(keywords_to_save)}
|
||||
|
||||
|
||||
@app.get("/items/{cid}")
|
||||
def get_items_list(cid: str, current_user: Dict[str, Any] = Depends(get_current_user)):
|
||||
"""获取指定账号的商品列表"""
|
||||
if cookie_manager.manager is None:
|
||||
raise HTTPException(status_code=500, detail="CookieManager 未就绪")
|
||||
|
||||
# 检查cookie是否属于当前用户
|
||||
user_id = current_user['user_id']
|
||||
from db_manager import db_manager
|
||||
user_cookies = db_manager.get_all_cookies(user_id)
|
||||
|
||||
if cid not in user_cookies:
|
||||
raise HTTPException(status_code=403, detail="无权限访问该Cookie")
|
||||
|
||||
try:
|
||||
# 获取该账号的所有商品
|
||||
with db_manager.lock:
|
||||
cursor = db_manager.conn.cursor()
|
||||
cursor.execute('''
|
||||
SELECT item_id, item_title, item_price, created_at
|
||||
FROM item_info
|
||||
WHERE cookie_id = ?
|
||||
ORDER BY created_at DESC
|
||||
''', (cid,))
|
||||
|
||||
items = []
|
||||
for row in cursor.fetchall():
|
||||
items.append({
|
||||
'item_id': row[0],
|
||||
'item_title': row[1] or '未知商品',
|
||||
'item_price': row[2] or '价格未知',
|
||||
'created_at': row[3]
|
||||
})
|
||||
|
||||
return {"items": items, "count": len(items)}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取商品列表失败: {e}")
|
||||
raise HTTPException(status_code=500, detail="获取商品列表失败")
|
||||
|
||||
|
||||
@app.get("/keywords-export/{cid}")
|
||||
def export_keywords(cid: str, current_user: Dict[str, Any] = Depends(get_current_user)):
|
||||
"""导出指定账号的关键词为Excel文件"""
|
||||
if cookie_manager.manager is None:
|
||||
raise HTTPException(status_code=500, detail="CookieManager 未就绪")
|
||||
|
||||
# 检查cookie是否属于当前用户
|
||||
user_id = current_user['user_id']
|
||||
from db_manager import db_manager
|
||||
user_cookies = db_manager.get_all_cookies(user_id)
|
||||
|
||||
if cid not in user_cookies:
|
||||
raise HTTPException(status_code=403, detail="无权限访问该Cookie")
|
||||
|
||||
try:
|
||||
# 获取关键词数据
|
||||
keywords = db_manager.get_keywords_with_item_id(cid)
|
||||
|
||||
# 创建DataFrame
|
||||
data = []
|
||||
for keyword, reply, item_id in keywords:
|
||||
data.append({
|
||||
'关键词': keyword,
|
||||
'商品ID': item_id or '',
|
||||
'关键词内容': reply
|
||||
})
|
||||
|
||||
# 如果没有数据,创建空的DataFrame但保留列名(作为模板)
|
||||
if not data:
|
||||
df = pd.DataFrame(columns=['关键词', '商品ID', '关键词内容'])
|
||||
else:
|
||||
df = pd.DataFrame(data)
|
||||
|
||||
# 创建Excel文件
|
||||
output = io.BytesIO()
|
||||
with pd.ExcelWriter(output, engine='openpyxl') as writer:
|
||||
df.to_excel(writer, sheet_name='关键词数据', index=False)
|
||||
|
||||
# 如果是空模板,添加一些示例说明
|
||||
if data == []:
|
||||
worksheet = writer.sheets['关键词数据']
|
||||
# 添加示例数据作为注释(从第2行开始)
|
||||
worksheet['A2'] = '你好'
|
||||
worksheet['B2'] = ''
|
||||
worksheet['C2'] = '您好!欢迎咨询,有什么可以帮助您的吗?'
|
||||
|
||||
worksheet['A3'] = '价格'
|
||||
worksheet['B3'] = '123456'
|
||||
worksheet['C3'] = '这个商品的价格是99元,现在有优惠活动哦!'
|
||||
|
||||
worksheet['A4'] = '发货'
|
||||
worksheet['B4'] = ''
|
||||
worksheet['C4'] = '我们会在24小时内发货,请耐心等待。'
|
||||
|
||||
# 设置示例行的样式(浅灰色背景)
|
||||
from openpyxl.styles import PatternFill
|
||||
gray_fill = PatternFill(start_color='F0F0F0', end_color='F0F0F0', fill_type='solid')
|
||||
for row in range(2, 5):
|
||||
for col in range(1, 4):
|
||||
worksheet.cell(row=row, column=col).fill = gray_fill
|
||||
|
||||
output.seek(0)
|
||||
|
||||
# 生成文件名(使用URL编码处理中文)
|
||||
from urllib.parse import quote
|
||||
if not data:
|
||||
filename = f"keywords_template_{cid}_{int(time.time())}.xlsx"
|
||||
else:
|
||||
filename = f"keywords_{cid}_{int(time.time())}.xlsx"
|
||||
encoded_filename = quote(filename.encode('utf-8'))
|
||||
|
||||
# 返回文件
|
||||
return StreamingResponse(
|
||||
io.BytesIO(output.read()),
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"导出关键词失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"导出关键词失败: {str(e)}")
|
||||
|
||||
|
||||
@app.post("/keywords-import/{cid}")
|
||||
async def import_keywords(cid: str, file: UploadFile = File(...), current_user: Dict[str, Any] = Depends(get_current_user)):
|
||||
"""导入Excel文件中的关键词到指定账号"""
|
||||
if cookie_manager.manager is None:
|
||||
raise HTTPException(status_code=500, detail="CookieManager 未就绪")
|
||||
|
||||
# 检查cookie是否属于当前用户
|
||||
user_id = current_user['user_id']
|
||||
from db_manager import db_manager
|
||||
user_cookies = db_manager.get_all_cookies(user_id)
|
||||
|
||||
if cid not in user_cookies:
|
||||
raise HTTPException(status_code=403, detail="无权限访问该Cookie")
|
||||
|
||||
# 检查文件类型
|
||||
if not file.filename.endswith(('.xlsx', '.xls')):
|
||||
raise HTTPException(status_code=400, detail="请上传Excel文件(.xlsx或.xls)")
|
||||
|
||||
try:
|
||||
# 读取Excel文件
|
||||
contents = await file.read()
|
||||
df = pd.read_excel(io.BytesIO(contents))
|
||||
|
||||
# 检查必要的列
|
||||
required_columns = ['关键词', '商品ID', '关键词内容']
|
||||
missing_columns = [col for col in required_columns if col not in df.columns]
|
||||
if missing_columns:
|
||||
raise HTTPException(status_code=400, detail=f"Excel文件缺少必要的列: {', '.join(missing_columns)}")
|
||||
|
||||
# 获取现有关键词
|
||||
existing_keywords = db_manager.get_keywords_with_item_id(cid)
|
||||
existing_dict = {}
|
||||
for keyword, reply, item_id in existing_keywords:
|
||||
key = f"{keyword}|{item_id or ''}"
|
||||
existing_dict[key] = (keyword, reply, item_id)
|
||||
|
||||
# 处理导入数据
|
||||
import_data = []
|
||||
update_count = 0
|
||||
add_count = 0
|
||||
|
||||
for index, row in df.iterrows():
|
||||
keyword = str(row['关键词']).strip()
|
||||
item_id = str(row['商品ID']).strip() if pd.notna(row['商品ID']) and str(row['商品ID']).strip() else None
|
||||
reply = str(row['关键词内容']).strip()
|
||||
|
||||
if not keyword or not reply:
|
||||
continue # 跳过空行
|
||||
|
||||
# 检查是否重复
|
||||
key = f"{keyword}|{item_id or ''}"
|
||||
if key in existing_dict:
|
||||
# 更新现有关键词
|
||||
update_count += 1
|
||||
else:
|
||||
# 新增关键词
|
||||
add_count += 1
|
||||
|
||||
import_data.append((keyword, reply, item_id))
|
||||
|
||||
if not import_data:
|
||||
raise HTTPException(status_code=400, detail="Excel文件中没有有效的关键词数据")
|
||||
|
||||
# 保存到数据库
|
||||
success = db_manager.save_keywords_with_item_id(cid, import_data)
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail="保存关键词到数据库失败")
|
||||
|
||||
log_with_user('info', f"导入关键词成功: {cid}, 新增: {add_count}, 更新: {update_count}", current_user)
|
||||
|
||||
return {
|
||||
"msg": "导入成功",
|
||||
"total": len(import_data),
|
||||
"added": add_count,
|
||||
"updated": update_count
|
||||
}
|
||||
|
||||
except pd.errors.EmptyDataError:
|
||||
raise HTTPException(status_code=400, detail="Excel文件为空")
|
||||
except pd.errors.ParserError:
|
||||
raise HTTPException(status_code=400, detail="Excel文件格式错误")
|
||||
except Exception as e:
|
||||
logger.error(f"导入关键词失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"导入关键词失败: {str(e)}")
|
||||
|
||||
|
||||
# 卡券管理API
|
||||
@app.get("/cards")
|
||||
def get_cards(current_user: Dict[str, Any] = Depends(get_current_user)):
|
||||
@ -1388,6 +1679,7 @@ def create_card(card_data: dict, current_user: Dict[str, Any] = Depends(get_curr
|
||||
data_content=card_data.get('data_content'),
|
||||
description=card_data.get('description'),
|
||||
enabled=card_data.get('enabled', True),
|
||||
delay_seconds=card_data.get('delay_seconds', 0),
|
||||
user_id=user_id
|
||||
)
|
||||
|
||||
@ -1426,7 +1718,8 @@ def update_card(card_id: int, card_data: dict, _: None = Depends(require_auth)):
|
||||
text_content=card_data.get('text_content'),
|
||||
data_content=card_data.get('data_content'),
|
||||
description=card_data.get('description'),
|
||||
enabled=card_data.get('enabled', True)
|
||||
enabled=card_data.get('enabled', True),
|
||||
delay_seconds=card_data.get('delay_seconds')
|
||||
)
|
||||
if success:
|
||||
return {"message": "卡券更新成功"}
|
||||
|
@ -47,4 +47,8 @@ bcrypt>=4.0.1
|
||||
python-dateutil>=2.8.2
|
||||
|
||||
# 正则表达式增强
|
||||
regex>=2023.10.3
|
||||
regex>=2023.10.3
|
||||
|
||||
# Excel文件处理(导入导出功能)
|
||||
pandas>=2.0.0
|
||||
openpyxl>=3.1.0
|
@ -378,7 +378,7 @@
|
||||
|
||||
.keyword-input-group {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2fr auto;
|
||||
grid-template-columns: 1fr 2fr 1fr auto;
|
||||
gap: 1rem;
|
||||
align-items: end;
|
||||
}
|
||||
@ -399,7 +399,7 @@
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.input-field input {
|
||||
.input-field input, .input-field select {
|
||||
width: 100%;
|
||||
padding: 1rem 0.75rem 0.75rem;
|
||||
border: 2px solid #e5e7eb;
|
||||
@ -1483,8 +1483,18 @@
|
||||
<i class="bi bi-chat-dots me-2"></i>
|
||||
关键词管理
|
||||
</h3>
|
||||
<div class="account-badge" id="currentAccountBadge">
|
||||
<!-- 动态显示当前账号 -->
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="account-badge" id="currentAccountBadge">
|
||||
<!-- 动态显示当前账号 -->
|
||||
</div>
|
||||
<div class="btn-group" role="group">
|
||||
<button class="btn btn-outline-success btn-sm" onclick="exportKeywords()" title="导出关键词">
|
||||
<i class="bi bi-download"></i> 导出
|
||||
</button>
|
||||
<button class="btn btn-outline-primary btn-sm" onclick="showImportModal()" title="导入关键词">
|
||||
<i class="bi bi-upload"></i> 导入
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1493,12 +1503,18 @@
|
||||
<div class="keyword-input-group">
|
||||
<div class="input-field">
|
||||
<label>关键词</label>
|
||||
<input type="text" id="newKeyword" placeholder="例如:你好、价格、发货...">
|
||||
<input type="text" id="newKeyword" placeholder="例如:你好">
|
||||
</div>
|
||||
<div class="input-field">
|
||||
<label>自动回复内容</label>
|
||||
<input type="text" id="newReply" placeholder="例如:您好,欢迎咨询!有什么可以帮助您的吗?">
|
||||
</div>
|
||||
<div class="input-field">
|
||||
<label>商品ID(可选)</label>
|
||||
<select id="newItemIdSelect" class="form-select">
|
||||
<option value="">选择商品或留空表示通用关键词</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="add-btn" onclick="addKeyword()">
|
||||
<i class="bi bi-plus-lg"></i>
|
||||
添加
|
||||
@ -1551,6 +1567,7 @@
|
||||
<th>卡券名称</th>
|
||||
<th>类型</th>
|
||||
<th>数据量</th>
|
||||
<th>延时时间</th>
|
||||
<th>状态</th>
|
||||
<th>创建时间</th>
|
||||
<th>操作</th>
|
||||
@ -1558,7 +1575,7 @@
|
||||
</thead>
|
||||
<tbody id="cardsTableBody">
|
||||
<tr>
|
||||
<td colspan="6" class="text-center py-4 text-muted">
|
||||
<td colspan="7" class="text-center py-4 text-muted">
|
||||
<i class="bi bi-credit-card fs-1 d-block mb-3"></i>
|
||||
<h5>暂无卡券数据</h5>
|
||||
<p class="mb-0">点击"添加卡券"开始创建您的第一个卡券</p>
|
||||
@ -2215,6 +2232,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">延时发货时间</label>
|
||||
<div class="input-group">
|
||||
<input type="number" class="form-control" id="cardDelaySeconds" value="0" min="0" max="3600" placeholder="0">
|
||||
<span class="input-group-text">秒</span>
|
||||
</div>
|
||||
<small class="form-text text-muted">
|
||||
<i class="bi bi-clock me-1"></i>
|
||||
设置自动发货的延时时间,0表示立即发货,最大3600秒(1小时)
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">备注信息</label>
|
||||
<textarea class="form-control" id="cardDescription" rows="3" placeholder="可选的备注信息,支持变量替换: {DELIVERY_CONTENT} - 发货内容 例如:您的卡券信息:{DELIVERY_CONTENT},请妥善保管。"></textarea>
|
||||
@ -2331,8 +2360,24 @@
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">备注</label>
|
||||
<textarea class="form-control" id="editCardDescription" rows="2"></textarea>
|
||||
<label class="form-label">延时发货时间</label>
|
||||
<div class="input-group">
|
||||
<input type="number" class="form-control" id="editCardDelaySeconds" value="0" min="0" max="3600" placeholder="0">
|
||||
<span class="input-group-text">秒</span>
|
||||
</div>
|
||||
<small class="form-text text-muted">
|
||||
<i class="bi bi-clock me-1"></i>
|
||||
设置自动发货的延时时间,0表示立即发货,最大3600秒(1小时)
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">备注信息</label>
|
||||
<textarea class="form-control" id="editCardDescription" rows="3" placeholder="可选的备注信息,支持变量替换: {DELIVERY_CONTENT} - 发货内容 例如:您的卡券信息:{DELIVERY_CONTENT},请妥善保管。"></textarea>
|
||||
<small class="form-text text-muted">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
备注内容会与发货内容一起发送。使用 <code>{DELIVERY_CONTENT}</code> 变量可以在备注中插入实际的发货内容。
|
||||
</small>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@ -2897,7 +2942,7 @@
|
||||
console.log(`加载关键词时账号 ${accountId} 状态: enabled=${currentAccount?.enabled}, accountStatus=${accountStatus}`); // 调试信息
|
||||
}
|
||||
|
||||
const response = await fetch(`${apiBase}/keywords/${accountId}`, {
|
||||
const response = await fetch(`${apiBase}/keywords-with-item-id/${accountId}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${authToken}`
|
||||
}
|
||||
@ -2907,16 +2952,16 @@
|
||||
const data = await response.json();
|
||||
console.log('从服务器获取的关键词数据:', data); // 调试信息
|
||||
|
||||
// 后端返回的是 [[keyword, reply], ...] 格式,转换为前端需要的格式
|
||||
const formattedData = data.map(([keyword, reply]) => ({
|
||||
keyword: keyword,
|
||||
reply: reply
|
||||
}));
|
||||
// 后端返回的是 [{keyword, reply, item_id}, ...] 格式,直接使用
|
||||
const formattedData = data;
|
||||
|
||||
console.log('格式化后的关键词数据:', formattedData); // 调试信息
|
||||
keywordsData[accountId] = formattedData;
|
||||
renderKeywordsList(formattedData);
|
||||
|
||||
// 加载商品列表
|
||||
await loadItemsList(accountId);
|
||||
|
||||
// 更新账号徽章显示
|
||||
updateAccountBadge(accountId, accountStatus);
|
||||
|
||||
@ -2962,10 +3007,50 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 加载商品列表
|
||||
async function loadItemsList(accountId) {
|
||||
try {
|
||||
const response = await fetch(`${apiBase}/items/${accountId}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${authToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const items = data.items || [];
|
||||
|
||||
// 更新商品选择下拉框
|
||||
const selectElement = document.getElementById('newItemIdSelect');
|
||||
if (selectElement) {
|
||||
// 清空现有选项(保留第一个默认选项)
|
||||
selectElement.innerHTML = '<option value="">选择商品或留空表示通用关键词</option>';
|
||||
|
||||
// 添加商品选项
|
||||
items.forEach(item => {
|
||||
const option = document.createElement('option');
|
||||
option.value = item.item_id;
|
||||
option.textContent = `${item.item_id} - ${item.item_title}`;
|
||||
selectElement.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`加载了 ${items.length} 个商品到选择列表`);
|
||||
} else {
|
||||
console.warn('加载商品列表失败:', response.status);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载商品列表时发生错误:', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 添加或更新关键词
|
||||
async function addKeyword() {
|
||||
const keyword = document.getElementById('newKeyword').value.trim();
|
||||
const reply = document.getElementById('newReply').value.trim();
|
||||
const itemId = document.getElementById('newItemIdSelect').value.trim();
|
||||
|
||||
if (!keyword || !reply) {
|
||||
showToast('请填写关键词和回复内容', 'warning');
|
||||
@ -2992,31 +3077,42 @@
|
||||
currentKeywords.splice(window.editingIndex, 1);
|
||||
}
|
||||
|
||||
// 检查关键词是否已存在(排除编辑的原关键词)
|
||||
const existingKeyword = currentKeywords.find(item => item.keyword === keyword);
|
||||
if (existingKeyword && (!isEditMode || keyword !== window.originalKeyword)) {
|
||||
showToast(`关键词 "${keyword}" 已存在,请使用其他关键词`, 'warning');
|
||||
// 准备要保存的关键词列表
|
||||
let keywordsToSave = [...currentKeywords];
|
||||
|
||||
// 如果是编辑模式,先移除原关键词
|
||||
if (isEditMode && typeof window.editingIndex !== 'undefined') {
|
||||
keywordsToSave.splice(window.editingIndex, 1);
|
||||
}
|
||||
|
||||
// 检查关键词是否已存在(考虑商品ID)
|
||||
const existingKeyword = keywordsToSave.find(item =>
|
||||
item.keyword === keyword &&
|
||||
(item.item_id || '') === (itemId || '')
|
||||
);
|
||||
if (existingKeyword) {
|
||||
const itemIdText = itemId ? `(商品ID: ${itemId})` : '(通用关键词)';
|
||||
showToast(`关键词 "${keyword}" ${itemIdText} 已存在,请使用其他关键词或商品ID`, 'warning');
|
||||
toggleLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 转换为后端期望的格式
|
||||
const keywordsObj = {};
|
||||
currentKeywords.forEach(item => {
|
||||
keywordsObj[item.keyword] = item.reply;
|
||||
});
|
||||
|
||||
// 添加新关键词或更新的关键词
|
||||
keywordsObj[keyword] = reply;
|
||||
const newKeyword = {
|
||||
keyword: keyword,
|
||||
reply: reply,
|
||||
item_id: itemId || ''
|
||||
};
|
||||
keywordsToSave.push(newKeyword);
|
||||
|
||||
const response = await fetch(`${apiBase}/keywords/${currentCookieId}`, {
|
||||
const response = await fetch(`${apiBase}/keywords-with-item-id/${currentCookieId}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${authToken}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
keywords: keywordsObj
|
||||
keywords: keywordsToSave
|
||||
})
|
||||
});
|
||||
|
||||
@ -3026,10 +3122,14 @@
|
||||
// 清空输入框并重置样式
|
||||
const keywordInput = document.getElementById('newKeyword');
|
||||
const replyInput = document.getElementById('newReply');
|
||||
const selectElement = document.getElementById('newItemIdSelect');
|
||||
const addBtn = document.querySelector('.add-btn');
|
||||
|
||||
keywordInput.value = '';
|
||||
replyInput.value = '';
|
||||
if (selectElement) {
|
||||
selectElement.value = '';
|
||||
}
|
||||
keywordInput.style.borderColor = '#e5e7eb';
|
||||
replyInput.style.borderColor = '#e5e7eb';
|
||||
addBtn.style.opacity = '0.7';
|
||||
@ -3105,11 +3205,17 @@
|
||||
|
||||
const keywordItem = document.createElement('div');
|
||||
keywordItem.className = 'keyword-item';
|
||||
// 商品ID显示
|
||||
const itemIdDisplay = item.item_id ?
|
||||
`<small class="text-muted d-block"><i class="bi bi-box"></i> 商品ID: ${item.item_id}</small>` :
|
||||
'<small class="text-muted d-block"><i class="bi bi-globe"></i> 通用关键词</small>';
|
||||
|
||||
keywordItem.innerHTML = `
|
||||
<div class="keyword-item-header">
|
||||
<div class="keyword-tag">
|
||||
<i class="bi bi-tag-fill"></i>
|
||||
${item.keyword}
|
||||
${itemIdDisplay}
|
||||
</div>
|
||||
<div class="keyword-actions">
|
||||
<button class="action-btn edit-btn" onclick="editKeyword(${index})" title="编辑">
|
||||
@ -3149,9 +3255,16 @@
|
||||
document.getElementById('newKeyword').value = keyword.keyword;
|
||||
document.getElementById('newReply').value = keyword.reply;
|
||||
|
||||
// 设置商品ID选择框
|
||||
const selectElement = document.getElementById('newItemIdSelect');
|
||||
if (selectElement) {
|
||||
selectElement.value = keyword.item_id || '';
|
||||
}
|
||||
|
||||
// 设置编辑模式标识
|
||||
window.editingIndex = index;
|
||||
window.originalKeyword = keyword.keyword;
|
||||
window.originalItemId = keyword.item_id || '';
|
||||
|
||||
// 更新按钮文本和样式
|
||||
const addBtn = document.querySelector('.add-btn');
|
||||
@ -3195,9 +3308,16 @@
|
||||
document.getElementById('newKeyword').value = '';
|
||||
document.getElementById('newReply').value = '';
|
||||
|
||||
// 清空商品ID选择框
|
||||
const selectElement = document.getElementById('newItemIdSelect');
|
||||
if (selectElement) {
|
||||
selectElement.value = '';
|
||||
}
|
||||
|
||||
// 重置编辑状态
|
||||
delete window.editingIndex;
|
||||
delete window.originalKeyword;
|
||||
delete window.originalItemId;
|
||||
|
||||
// 恢复添加按钮
|
||||
const addBtn = document.querySelector('.add-btn');
|
||||
@ -3227,21 +3347,15 @@
|
||||
// 移除指定索引的关键词
|
||||
currentKeywords.splice(index, 1);
|
||||
|
||||
// 转换为后端期望的格式
|
||||
const keywordsObj = {};
|
||||
currentKeywords.forEach(item => {
|
||||
keywordsObj[item.keyword] = item.reply;
|
||||
});
|
||||
|
||||
// 更新服务器
|
||||
const response = await fetch(`${apiBase}/keywords/${cookieId}`, {
|
||||
const response = await fetch(`${apiBase}/keywords-with-item-id/${cookieId}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${authToken}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
keywords: keywordsObj
|
||||
keywords: currentKeywords
|
||||
})
|
||||
});
|
||||
|
||||
@ -4825,7 +4939,7 @@
|
||||
if (cards.length === 0) {
|
||||
tbody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="6" class="text-center py-4 text-muted">
|
||||
<td colspan="7" class="text-center py-4 text-muted">
|
||||
<i class="bi bi-credit-card fs-1 d-block mb-3"></i>
|
||||
<h5>暂无卡券数据</h5>
|
||||
<p class="mb-0">点击"添加卡券"开始创建您的第一个卡券</p>
|
||||
@ -4870,6 +4984,11 @@
|
||||
dataCount = '1';
|
||||
}
|
||||
|
||||
// 延时时间显示
|
||||
const delayDisplay = card.delay_seconds > 0 ?
|
||||
`${card.delay_seconds}秒` :
|
||||
'<span class="text-muted">立即</span>';
|
||||
|
||||
tr.innerHTML = `
|
||||
<td>
|
||||
<div class="fw-bold">${card.name}</div>
|
||||
@ -4877,6 +4996,7 @@
|
||||
</td>
|
||||
<td>${typeBadge}</td>
|
||||
<td>${dataCount}</td>
|
||||
<td>${delayDisplay}</td>
|
||||
<td>${statusBadge}</td>
|
||||
<td>
|
||||
<small class="text-muted">${new Date(card.created_at).toLocaleString('zh-CN')}</small>
|
||||
@ -4945,6 +5065,7 @@
|
||||
name: cardName,
|
||||
type: cardType,
|
||||
description: document.getElementById('cardDescription').value,
|
||||
delay_seconds: parseInt(document.getElementById('cardDelaySeconds').value) || 0,
|
||||
enabled: true
|
||||
};
|
||||
|
||||
@ -5234,6 +5355,7 @@
|
||||
document.getElementById('editCardName').value = card.name;
|
||||
document.getElementById('editCardType').value = card.type;
|
||||
document.getElementById('editCardDescription').value = card.description || '';
|
||||
document.getElementById('editCardDelaySeconds').value = card.delay_seconds || 0;
|
||||
document.getElementById('editCardEnabled').checked = card.enabled;
|
||||
|
||||
// 根据类型填充特定字段
|
||||
@ -5289,6 +5411,7 @@
|
||||
name: cardName,
|
||||
type: cardType,
|
||||
description: document.getElementById('editCardDescription').value,
|
||||
delay_seconds: parseInt(document.getElementById('editCardDelaySeconds').value) || 0,
|
||||
enabled: document.getElementById('editCardEnabled').checked
|
||||
};
|
||||
|
||||
@ -6941,7 +7064,134 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 导入导出功能 ====================
|
||||
|
||||
// 导出关键词
|
||||
async function exportKeywords() {
|
||||
if (!currentCookieId) {
|
||||
showToast('请先选择账号', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiBase}/keywords-export/${currentCookieId}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${authToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// 创建下载链接
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
|
||||
// 根据当前账号是否有数据来设置文件名和提示
|
||||
const currentKeywords = keywordsData[currentCookieId] || [];
|
||||
const hasData = currentKeywords.length > 0;
|
||||
|
||||
if (hasData) {
|
||||
a.download = `keywords_${currentCookieId}_${new Date().getTime()}.xlsx`;
|
||||
showToast('关键词导出成功!', 'success');
|
||||
} else {
|
||||
a.download = `keywords_template_${currentCookieId}_${new Date().getTime()}.xlsx`;
|
||||
showToast('导入模板导出成功!模板中包含示例数据供参考', 'success');
|
||||
}
|
||||
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showToast(`导出失败: ${error.detail}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('导出关键词失败:', error);
|
||||
showToast('导出关键词失败', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 显示导入模态框
|
||||
function showImportModal() {
|
||||
if (!currentCookieId) {
|
||||
showToast('请先选择账号', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const modal = new bootstrap.Modal(document.getElementById('importKeywordsModal'));
|
||||
modal.show();
|
||||
}
|
||||
|
||||
// 导入关键词
|
||||
async function importKeywords() {
|
||||
if (!currentCookieId) {
|
||||
showToast('请先选择账号', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const fileInput = document.getElementById('importFileInput');
|
||||
const file = fileInput.files[0];
|
||||
|
||||
if (!file) {
|
||||
showToast('请选择要导入的Excel文件', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 显示进度条
|
||||
const progressDiv = document.getElementById('importProgress');
|
||||
const progressBar = progressDiv.querySelector('.progress-bar');
|
||||
progressDiv.style.display = 'block';
|
||||
progressBar.style.width = '30%';
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await fetch(`${apiBase}/keywords-import/${currentCookieId}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${authToken}`
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
|
||||
progressBar.style.width = '70%';
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
progressBar.style.width = '100%';
|
||||
|
||||
setTimeout(() => {
|
||||
progressDiv.style.display = 'none';
|
||||
progressBar.style.width = '0%';
|
||||
|
||||
// 关闭模态框
|
||||
const modal = bootstrap.Modal.getInstance(document.getElementById('importKeywordsModal'));
|
||||
modal.hide();
|
||||
|
||||
// 清空文件输入
|
||||
fileInput.value = '';
|
||||
|
||||
// 重新加载关键词列表
|
||||
loadAccountKeywords(currentCookieId);
|
||||
|
||||
showToast(`导入成功!新增: ${result.added}, 更新: ${result.updated}`, 'success');
|
||||
}, 500);
|
||||
} else {
|
||||
const error = await response.json();
|
||||
progressDiv.style.display = 'none';
|
||||
progressBar.style.width = '0%';
|
||||
showToast(`导入失败: ${error.detail}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('导入关键词失败:', error);
|
||||
document.getElementById('importProgress').style.display = 'none';
|
||||
document.querySelector('#importProgress .progress-bar').style.width = '0%';
|
||||
showToast('导入关键词失败', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
@ -7103,5 +7353,50 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 导入关键词模态框 -->
|
||||
<div class="modal fade" id="importKeywordsModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">
|
||||
<i class="bi bi-upload me-2"></i>导入关键词
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">选择Excel文件</label>
|
||||
<input type="file" class="form-control" id="importFileInput" accept=".xlsx,.xls">
|
||||
<div class="form-text">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
请上传包含"关键词"、"商品ID"、"关键词内容"三列的Excel文件
|
||||
</div>
|
||||
</div>
|
||||
<div class="alert alert-warning">
|
||||
<h6><i class="bi bi-exclamation-triangle me-1"></i>导入说明:</h6>
|
||||
<ul class="mb-0">
|
||||
<li>Excel文件必须包含三列:关键词、商品ID、关键词内容</li>
|
||||
<li>商品ID可以为空,表示通用关键词</li>
|
||||
<li>如果关键词+商品ID组合已存在,将更新关键词内容</li>
|
||||
<li>导入将覆盖当前账号的所有关键词数据</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div id="importProgress" style="display: none;">
|
||||
<div class="progress mb-2">
|
||||
<div class="progress-bar" role="progressbar" style="width: 0%"></div>
|
||||
</div>
|
||||
<small class="text-muted">正在导入...</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
|
||||
<button type="button" class="btn btn-primary" onclick="importKeywords()">
|
||||
<i class="bi bi-upload me-1"></i>开始导入
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
@ -21,9 +21,38 @@ def get_js_path():
|
||||
return js_path
|
||||
|
||||
try:
|
||||
# 检查JavaScript运行时是否可用
|
||||
available_runtimes = execjs.runtime_names
|
||||
logger.info(f"可用的JavaScript运行时: {available_runtimes}")
|
||||
|
||||
# 尝试获取默认运行时
|
||||
current_runtime = execjs.get()
|
||||
logger.info(f"当前JavaScript运行时: {current_runtime.name}")
|
||||
|
||||
xianyu_js = execjs.compile(open(get_js_path(), 'r', encoding='utf-8').read())
|
||||
logger.info("JavaScript文件加载成功")
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"无法加载JavaScript文件: {e}")
|
||||
error_msg = str(e)
|
||||
logger.error(f"JavaScript运行时错误: {error_msg}")
|
||||
|
||||
if "Could not find an available JavaScript runtime" in error_msg:
|
||||
logger.error("解决方案:")
|
||||
logger.error("1. 确保已安装Node.js: apt-get install nodejs")
|
||||
logger.error("2. 或安装其他JS运行时: apt-get install nodejs npm")
|
||||
logger.error("3. 检查PATH环境变量是否包含Node.js路径")
|
||||
|
||||
# 尝试检测系统中的JavaScript运行时
|
||||
import subprocess
|
||||
try:
|
||||
result = subprocess.run(['node', '--version'], capture_output=True, text=True)
|
||||
if result.returncode == 0:
|
||||
logger.info(f"检测到Node.js版本: {result.stdout.strip()}")
|
||||
else:
|
||||
logger.error("Node.js未正确安装或不在PATH中")
|
||||
except FileNotFoundError:
|
||||
logger.error("未找到Node.js可执行文件")
|
||||
|
||||
raise RuntimeError(f"无法加载JavaScript文件: {error_msg}")
|
||||
|
||||
def trans_cookies(cookies_str: str) -> dict:
|
||||
"""将cookies字符串转换为字典"""
|
||||
|
Loading…
x
Reference in New Issue
Block a user