diff --git a/Utils/QianNiu/QianNiuUtils.py b/Utils/QianNiu/QianNiuUtils.py index 610796e..292ac95 100644 --- a/Utils/QianNiu/QianNiuUtils.py +++ b/Utils/QianNiu/QianNiuUtils.py @@ -22,10 +22,6 @@ import threading from Utils.message_models import PlatformMessage import config -# 设置标准输出编码为UTF-8 -sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8') -sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8') - # 千牛WebSocket管理器类 class QianNiuWebsocketManager: @@ -100,12 +96,6 @@ QIANNIU_CONFIG = { "sign_key": b'111111', "sha256": True, - # 测试配置 - "test_store_id": "test_store_001", - "test_store_name": "测试店铺", - "test_user_nick": "tb420723827:redboat", - "default_store_id": "4c4025e3-8702-42fc-bdc2-671e335c0ff7", - # 连接配置 "connect_timeout": 60, "reconnect_attempts": 3, @@ -120,29 +110,18 @@ QIANNIU_CONFIG = { "service_startup_delay": 2 } -# 配置日志(修复Unicode编码问题) +# 简化的日志配置 logging.basicConfig( - level=logging.DEBUG, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', - handlers=[ - logging.FileHandler("sainiu_test.log", encoding='utf-8'), - logging.StreamHandler() - ] + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s', + encoding='utf-8' ) -logger = logging.getLogger("SaiNiuTest") +logger = logging.getLogger("QianNiu") # 赛牛插件配置 QN_WS_URL = "ws://127.0.0.1:3030" # 赛牛插件WebSocket地址 QN_HTTP_API_URL = "http://127.0.0.1:3030/QianNiu/Function" QN_HTTP_API_URL_AVATAR = 'http://127.0.0.1:3030/QianNiu/Api' # 赛牛插件HTTP API地址 -STORE_ID = "test_store_001" # 测试店铺ID -STORE_NAME = "测试店铺" # 测试店铺名称 -USER_NICK = "tb420723827:redboat" # 测试账号 - -import ctypes -import hashlib -import time -from ctypes import c_int, c_bool, c_char_p class SaiNiuService: @@ -155,11 +134,43 @@ class SaiNiuService: self.sign_key = b'' # 默认签名密钥 self.sha256 = True # 使用SHA256签名 - # 获取项目根目录(相对于当前文件) - current_dir = os.path.dirname(os.path.abspath(__file__)) # Utils/QianNiu/ - project_root = os.path.dirname(os.path.dirname(current_dir)) # shuidrop_gui/ - self.python32_path = os.path.join(project_root, "Utils", "PythonNew32", "python32.exe") - self.dll_dir = os.path.join(project_root, "Utils", "PythonNew32") + # 获取项目根目录(PyInstaller兼容版本) + if getattr(sys, 'frozen', False): + # PyInstaller打包后的环境 + exe_dir = os.path.dirname(sys.executable) + + # 检查新版PyInstaller的_internal目录结构 + internal_dir = os.path.join(exe_dir, "_internal") + if os.path.exists(internal_dir): + # 新版PyInstaller: exe在外面,资源在_internal里 + base_path = internal_dir + print(f"🔧 检测到新版PyInstaller _internal 结构") + print(f"📁 exe目录: {exe_dir}") + print(f"📦 资源目录: {internal_dir}") + else: + # 老版PyInstaller或onefile模式 + if hasattr(sys, '_MEIPASS'): + # 一次性临时解压目录 + base_path = sys._MEIPASS + print(f"🔧 使用临时解压目录: {base_path}") + else: + # 直接exe目录 + base_path = exe_dir + print(f"🔧 使用exe目录: {base_path}") + + # 在exe环境下,Utils/PythonNew32在base_path下 + self.dll_dir = os.path.join(base_path, "Utils", "PythonNew32") + self.python32_path = os.path.join(self.dll_dir, "python32.exe") + + print(f"🎯 最终DLL路径: {self.dll_dir}") + print(f"🐍 最终Python32路径: {self.python32_path}") + else: + # 开发环境 + current_dir = os.path.dirname(os.path.abspath(__file__)) # Utils/QianNiu/ + project_root = os.path.dirname(os.path.dirname(current_dir)) # GUI_master/ + self.python32_path = os.path.join(project_root, "Utils", "PythonNew32", "python32.exe") + self.dll_dir = os.path.join(project_root, "Utils", "PythonNew32") + self.dll_process = None # 验证关键路径 @@ -168,11 +179,31 @@ class SaiNiuService: def _validate_paths(self): """验证关键路径是否存在""" print(f"🔍 验证路径配置...") + + # 添加环境信息 + if getattr(sys, 'frozen', False): + print(f"🎯 运行环境: PyInstaller打包exe") + print(f"📁 exe路径: {sys.executable}") + if hasattr(sys, '_MEIPASS'): + print(f"📦 临时解压目录: {sys._MEIPASS}") + else: + print(f"🎯 运行环境: 开发环境") + print(f"📁 当前文件: {__file__}") + print(f"📁 DLL目录: {self.dll_dir}") print(f"🐍 Python32路径: {self.python32_path}") if not os.path.exists(self.dll_dir): print(f"⚠️ DLL目录不存在: {self.dll_dir}") + # 列出父目录内容帮助调试 + parent_dir = os.path.dirname(self.dll_dir) + if os.path.exists(parent_dir): + print(f"📂 父目录 {parent_dir} 包含:") + try: + for item in os.listdir(parent_dir): + print(f" - {item}") + except: + print(" 无法列出目录内容") else: print(f"✅ DLL目录存在") @@ -185,6 +216,14 @@ class SaiNiuService: dll_path = os.path.join(self.dll_dir, "SaiNiuApi.dll") if not os.path.exists(dll_path): print(f"⚠️ SaiNiuApi.dll文件不存在: {dll_path}") + # 列出DLL目录内容 + if os.path.exists(self.dll_dir): + print(f"📂 DLL目录 {self.dll_dir} 包含:") + try: + for item in os.listdir(self.dll_dir): + print(f" - {item}") + except: + print(" 无法列出目录内容") else: print(f"✅ SaiNiuApi.dll文件存在") @@ -259,7 +298,7 @@ try: log_print("正在加载 SaiNiuApi.dll...") # 加载DLL文件 sainiu_api = ctypes.CDLL('SaiNiuApi.dll') - log_print("✅ SaiNiuApi.dll 加载成功") + log_print("[OK] SaiNiuApi.dll 加载成功") # 定义函数参数类型和返回值类型 sainiu_api.Access_ServerStart.argtypes = [ @@ -274,7 +313,7 @@ try: ctypes.c_bool ] sainiu_api.Access_ServerStart.restype = ctypes.c_char_p - log_print("✅ DLL函数类型定义完成") + log_print("[OK] DLL函数类型定义完成") # 调用函数时传入的参数 port = 3030 @@ -304,9 +343,9 @@ try: # 解码并打印结果 try: result_str = result.decode('gbk') - log_print(f"✅ Access_ServerStart 服务器启动结果: {result_str}") + log_print(f"[OK] Access_ServerStart 服务器启动结果: {result_str}") except UnicodeDecodeError: - log_print(f"✅ Access_ServerStart 服务器启动结果: {result}") + log_print(f"[OK] Access_ServerStart 服务器启动结果: {result}") log_print("=== DLL服务启动完成,进入监控模式 ===") @@ -316,12 +355,12 @@ try: while True: time.sleep(5) heartbeat_count += 1 - log_print(f"❤️ DLL服务心跳 #{heartbeat_count} - 服务正常运行中...") + log_print(f"[HEARTBEAT] DLL服务心跳 #{heartbeat_count} - 服务正常运行中...") except KeyboardInterrupt: log_print("收到中断信号,正在停止服务...") except Exception as e: - log_print(f"❌ DLL启动失败: {e}") + log_print(f"[ERROR] DLL启动失败: {e}") import traceback error_trace = traceback.format_exc() log_print(f"错误详情: {error_trace}") @@ -346,16 +385,31 @@ finally: print(f"🚀 启动Python32进程: {self.python32_path}") print(f"📄 脚本路径: {script_path}") - self.dll_process = subprocess.Popen( - [self.python32_path, "-u", script_path], # -u 参数强制不缓冲输出 - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - creationflags=subprocess.CREATE_NEW_PROCESS_GROUP, - cwd=dll_dir, - encoding='gbk', - text=True, - bufsize=0 # 不缓冲 - ) + # PyInstaller环境下需要特殊处理 + creation_flags = subprocess.CREATE_NEW_PROCESS_GROUP + if getattr(sys, 'frozen', False): + # exe环境下添加额外标志 + creation_flags |= subprocess.CREATE_NO_WINDOW # 隐藏控制台窗口 + + try: + self.dll_process = subprocess.Popen( + [self.python32_path, "-u", script_path], # -u 参数强制不缓冲输出 + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + creationflags=creation_flags, + cwd=dll_dir, + text=True, + bufsize=0, # 不缓冲 + # exe环境下使用更稳定的编码配置 + encoding='utf-8' if getattr(sys, 'frozen', False) else 'gbk', + errors='ignore' # 忽略编码错误 + ) + print(f"✅ Python32进程启动成功,PID: {self.dll_process.pid}") + except Exception as e: + print(f"❌ Python32进程启动失败: {e}") + # 尝试恢复目录 + os.chdir(original_dir) + return False # 等待进程启动(改进版) print("⏰ 等待Python32进程启动DLL服务...") @@ -578,16 +632,6 @@ class SimpleCache: cache = SimpleCache() -class MockStore: - """模拟店铺对象""" - - def __init__(self, pk, name, account, password): - self.pk = pk - self.store_name = name - self.store_account = account - self.store_password = password - - class AIServiceConnector: """AI服务连接器 - 负责与AI后端服务通信(单连接多店铺架构)""" @@ -715,7 +759,44 @@ class AIServiceConnector: self._log(f"📥 收到消息类型: {message_type}", "DEBUG") - # 分发消息给对应的处理器 + # 🔥 优先使用platform_name进行平台路由(只对message类型有效) + if message_type == "message": + platform_name = data.get("platform_name", "") + qianniu_platforms = ['淘宝', '千牛', 'QIANNIU', 'qianniu', 'taobao'] + + # 如果明确指定了非千牛平台,直接转发 + if platform_name and platform_name not in qianniu_platforms: + self._log(f"🔥 [总路由] 明确平台: {platform_name} -> 转发给主GUI", "INFO") + try: + from WebSocket.backend_singleton import get_websocket_manager + manager = get_websocket_manager() + if manager and manager.backend_client: + manager.backend_client.on_message_received(data) + self._log(f"🔥 [总路由] {platform_name}平台消息转发成功", "INFO") + continue # 转发后跳过本地处理 + else: + self._log(f"🔥 [总路由] 未找到主GUI的backend_client", "ERROR") + except Exception as e: + self._log(f"🔥 [总路由] 转发{platform_name}平台消息失败: {e}", "ERROR") + + # 如果没有platform_name,使用智能识别(向后兼容) + elif not platform_name: + target_platform = self._identify_message_platform(data, message_type) + if target_platform != "千牛": + self._log(f"🔥 [智能路由] 识别为{target_platform}平台消息,转发给主GUI", "INFO") + try: + from WebSocket.backend_singleton import get_websocket_manager + manager = get_websocket_manager() + if manager and manager.backend_client: + manager.backend_client.on_message_received(data) + self._log(f"🔥 [智能路由] {target_platform}消息转发成功", "INFO") + continue # 转发后跳过本地处理 + else: + self._log(f"🔥 [智能路由] 未找到主GUI的backend_client", "ERROR") + except Exception as e: + self._log(f"🔥 [智能路由] 转发失败: {e}", "ERROR") + + # 千牛消息或其他类型消息,本地处理 await self._dispatch_message(data, message_type) except asyncio.TimeoutError: @@ -853,6 +934,56 @@ class AIServiceConnector: # 处理message类型(统一消息处理) if message_type == "message": + # 🔥 优先使用platform_name字段进行平台判断 + platform_name = data.get("platform_name", "") + store_id = data.get("store_id", "") + + self._log(f"🔥 [平台路由] 收到消息 - platform_name: {platform_name}, store_id: {store_id}", "DEBUG") + + # 定义千牛平台的标识符 + qianniu_platforms = ['淘宝', '千牛', 'QIANNIU', 'qianniu', 'taobao'] + + # 如果明确指定了非千牛平台,直接转发给主GUI + if platform_name and platform_name not in qianniu_platforms: + self._log(f"🔥 [平台路由] 明确指定平台: {platform_name} -> 转发给主GUI", "INFO") + try: + from WebSocket.backend_singleton import get_websocket_manager + manager = get_websocket_manager() + if manager and manager.backend_client: + manager.backend_client.on_message_received(data) + self._log(f"🔥 [平台路由] {platform_name}平台消息转发成功", "INFO") + return # 转发后直接返回,不继续处理 + else: + self._log(f"🔥 [平台路由] 未找到主GUI的backend_client", "ERROR") + except Exception as e: + self._log(f"🔥 [平台路由] 转发{platform_name}平台消息失败: {e}", "ERROR") + + # 如果明确指定了千牛平台,直接本地处理 + elif platform_name and platform_name in qianniu_platforms: + self._log(f"🔥 [平台路由] 明确指定千牛平台: {platform_name} -> 本地处理", "INFO") + # 继续执行后续的千牛消息处理逻辑 + + # 如果没有platform_name字段,使用原有的智能识别逻辑(向后兼容) + elif not platform_name: + self._log(f"🔥 [平台路由] 无platform_name字段,使用智能识别逻辑", "DEBUG") + # 🔧 智能平台识别和消息路由(保留原有逻辑) + target_platform = self._identify_message_platform(data, message_type) + + if target_platform != "千牛": + self._log(f"🔥 [智能路由] 识别为{target_platform}平台消息,转发给主GUI", "INFO") + try: + from WebSocket.backend_singleton import get_websocket_manager + manager = get_websocket_manager() + if manager and manager.backend_client: + manager.backend_client.on_message_received(data) + self._log(f"🔥 [智能路由] {target_platform}消息转发成功", "INFO") + return # 转发后跳过本地处理 + else: + self._log(f"🔥 [智能路由] 未找到主GUI的backend_client", "ERROR") + except Exception as e: + self._log(f"🔥 [智能路由] 转发失败: {e}", "ERROR") + + # 千牛平台或无明确平台标识的消息,本地处理 # 使用receiver.id作为匹配键 receiver_info = data.get("receiver", {}) receiver_id = receiver_info.get("id") if receiver_info else None @@ -887,14 +1018,46 @@ class AIServiceConnector: # 处理transfer类型消息 elif message_type == "transfer": - receiver_info = data.get("receiver", {}) - receiver_id = receiver_info.get("id") if receiver_info else None + # 转接消息需要立即处理,不只是作为回复 + handler = self.message_handlers.get("transfer") + if handler and callable(handler): + task = asyncio.create_task(handler(data)) + task.add_done_callback(self._handle_task_completion) + else: + self._log("❌ 未找到转接消息处理器", "WARNING") - if receiver_id and receiver_id in self.pending_ai_replies: - reply_info = self.pending_ai_replies.get(receiver_id) - if reply_info and not reply_info.get('received'): - reply_info['reply'] = data - reply_info['received'] = True + # 处理login类型消息(智能路由到对应平台) + elif message_type == "login": + platform_name = data.get('platform_name', '') + store_id = data.get('store_id', '') + self._log(f"🔥 [消息路由] 收到login消息 - 平台: {platform_name}, store: {store_id}", "INFO") + + # 如果是千牛平台的消息,本地处理 + if platform_name in ['淘宝', '千牛', 'QIANNIU']: + self._log(f"🔥 [消息路由] 千牛平台消息,本地处理", "INFO") + handler = self.message_handlers.get("login") + if handler and callable(handler): + task = asyncio.create_task(handler(data)) + task.add_done_callback(self._handle_task_completion) + else: + self._log(f"🔥 [消息路由] 千牛平台未设置login处理器", "WARNING") + else: + # 其他平台的消息,转发给主GUI + self._log(f"🔥 [消息路由] 非千牛平台消息,转发给主GUI处理", "INFO") + try: + from WebSocket.backend_singleton import get_websocket_manager + manager = get_websocket_manager() + if manager and manager.backend_client: + self._log(f"🔥 [消息路由] 找到主GUI的backend_client,开始转发", "INFO") + # 直接调用backend_client的消息处理方法 + manager.backend_client.on_message_received(data) + self._log(f"🔥 [消息路由] login消息转发完成", "INFO") + else: + self._log(f"🔥 [消息路由] 未找到主GUI的backend_client", "ERROR") + except Exception as e: + self._log(f"🔥 [消息路由] 转发login消息失败: {e}", "ERROR") + import traceback + self._log(f"🔥 [消息路由] 详细错误: {traceback.format_exc()}", "ERROR") # 处理其他协议消息类型 elif message_type in ["connect_success", "error", "staff_list"]: @@ -907,6 +1070,61 @@ class AIServiceConnector: else: self._log(f"📨 未处理的消息类型: {message_type}", "DEBUG") + def _identify_message_platform(self, data, message_type): + """智能识别消息的目标平台""" + try: + # 1. 优先检查明确的平台标识 + platform_name = data.get("platform_name", "") + if platform_name: + if platform_name in ['淘宝', '千牛', 'QIANNIU']: + return "千牛" + elif platform_name in ['京东', 'JD']: + return "京东" + elif platform_name in ['抖音', 'DY', 'DOUYIN']: + return "抖音" + elif platform_name in ['拼多多', 'PDD', 'PINDUODUO']: + return "拼多多" + + # 2. 通过receiver.id特征识别 + receiver_info = data.get("receiver", {}) + receiver_id = receiver_info.get("id", "") if receiver_info else "" + + if receiver_id: + # 京东特征:以"jd_"开头 + if receiver_id.startswith("jd_"): + self._log(f"🔍 [平台识别] receiver_id: {receiver_id} -> 京东", "DEBUG") + return "京东" + + # 抖音特征:以"dy_"开头(如果有的话) + if receiver_id.startswith("dy_"): + self._log(f"🔍 [平台识别] receiver_id: {receiver_id} -> 抖音", "DEBUG") + return "抖音" + + # 拼多多特征:以"pdd_"开头(如果有的话) + if receiver_id.startswith("pdd_"): + self._log(f"🔍 [平台识别] receiver_id: {receiver_id} -> 拼多多", "DEBUG") + return "拼多多" + + # 千牛特征:纯数字ID(长度通常10-15位) + if receiver_id.isdigit() and 10 <= len(receiver_id) <= 15: + self._log(f"🔍 [平台识别] receiver_id: {receiver_id} (纯数字) -> 千牛", "DEBUG") + return "千牛" + + # 3. login消息的特殊识别 + if message_type == "login": + cookies = data.get("cookies", "") + if "jd.com" in cookies or "TrackID" in cookies: + self._log(f"🔍 [平台识别] login消息cookies包含京东特征 -> 京东", "DEBUG") + return "京东" + + # 4. 默认策略:无明确特征时默认为千牛 + self._log(f"🔍 [平台识别] 无明确特征,默认 -> 千牛", "DEBUG") + return "千牛" + + except Exception as e: + self._log(f"🔍 [平台识别] 识别异常: {e}, 默认 -> 千牛", "ERROR") + return "千牛" # 异常时默认为千牛 + def _handle_task_completion(self, task): """处理异步任务完成(避免任务泄露)""" try: @@ -1031,7 +1249,7 @@ class AIServiceIntegration: """AI服务集成类 - 负责处理AI回复相关功能(单连接多店铺架构)""" def __init__(self, store_id=None, exe_token=None): - self.store_id = store_id or QIANNIU_CONFIG["default_store_id"] + self.store_id = store_id self.exe_token = exe_token # 新增:用户执行令牌 self.ai_service = AIServiceConnector() self.loop = None @@ -1081,7 +1299,13 @@ class AIServiceIntegration: self.ai_service.register_message_handler(message_type, handler) async def get_ai_reply(self, message_content, sender_nick, avatar_url=""): - """获取AI回复""" + """获取AI回复 + + Args: + message_content: 消息内容 + sender_nick: 发送者ID(现在实际是senderUid) + avatar_url: 头像URL + """ try: # 确保连接正常 if not await self.ensure_connection(): @@ -1104,13 +1328,13 @@ class AIServiceIntegration: elif control_type.__contains__("订单号"): msg_type = "order_card" - # 创建消息模板对象(避免重复创建) + # 创建消息模板对象(修正:sender.id现在使用senderUid) message_template = PlatformMessage( type="message", content=message_content, msg_type=msg_type, sender={ - "id": sender_nick, + "id": sender_nick, # 现在这里是senderUid而不是senderNick }, pin_image=avatar_url, store_id=self.store_id, @@ -1138,23 +1362,6 @@ class AIServiceIntegration: logger.error(f"获取AI回复失败: {e}") return "您好,感谢您的咨询,我们会尽快回复您!" - # async def start_backend_listening(self, message_callback): - # """启动后端消息监听""" - # if not await self.ensure_connection(): - # self._log("❌ AI服务连接失败,无法启动后端监听", "ERROR") - # return False - # - # try: - # # 直接调用AIServiceConnector的监听方法 - # asyncio.create_task( - # self.ai_service.listen_for_backend_messages(message_callback) - # ) - # self._log("✅ 后端消息监听已启动", "SUCCESS") - # return True - # except Exception as e: - # self._log(f"❌ 启动后端监听失败: {e}", "ERROR") - # return False - def _log(self, message, level="INFO"): """日志记录""" timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") @@ -1244,6 +1451,262 @@ class QianNiuClient: if self._http_session: await self._http_session.close() + def _log(self, message, level="INFO"): + """日志记录""" + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + # 根据日志级别添加颜色 + if level == "ERROR": + print(f"\033[91m[{timestamp}] [{level}] {message}\033[0m") # 红色 + elif level == "WARNING": + print(f"\033[93m[{timestamp}] [{level}] {message}\033[0m") # 黄色 + elif level == "SUCCESS": + print(f"\033[92m[{timestamp}] [{level}] {message}\033[0m") # 绿色 + elif level == "DEBUG": + print(f"\033[96m[{timestamp}] [{level}] {message}\033[0m") # 蓝色 + else: + print(f"[{timestamp}] [{level}] {message}") + + async def get_customer_list(self) -> List[Dict]: + """获取千牛客服列表""" + try: + self._log(" 开始获取千牛客服列表...", "INFO") + + # 调用GetOnlineSubAccount API获取在线接待子账号 + sub_account_list = await self.get_online_sub_account() + + if not sub_account_list: + self._log("❌ 获取在线子账号列表失败", "ERROR") + return [] + + # 转换为客服列表格式,只包含在线且未暂停的客服 + online_staff_list = [] + for account in sub_account_list: + # 只包含在线且未暂停的客服 + if account['online'] and not account['suspended']: + staff_info = { + "staff_id": account['account_id'], + "name": account['sub_name'], + "status": 1, + "department": account['group_name'], + "online": True + } + online_staff_list.append(staff_info) + + self._log(f"✅ 成功获取在线客服列表,共 {len(online_staff_list)} 个在线客服", "SUCCESS") + return online_staff_list + + except Exception as e: + self._log(f"❌ 获取在线客服列表异常: {e}", "ERROR") + import traceback + self._log(f"异常详情: {traceback.format_exc()}", "DEBUG") + return [] + + async def get_online_sub_account(self) -> List[Dict]: + """获取在线接待子账号列表""" + try: + self._log("🔵 开始获取在线接待子账号列表...", "INFO") + + # 调用GetOnlineSubAccount API + response = await self._http_api_call("GetOnlineSubAccount", { + "userNick": self.user_nick + }) + + if not response or response.get("code") != 200: + self._log("❌ 获取在线子账号API调用失败", "ERROR") + return [] + + # 解析返回数据 + data = response.get("data", {}) + result = data.get("result", {}) + groups = result.get("groups", []) + + sub_account_list = [] + + for group in groups: + group_name = group.get("groupName", "") + accounts = group.get("accounts", []) + + for account in accounts: + account_info = { + "account_id": str(account.get("accountId", "")), + "nick": account.get("nick", ""), + "sub_name": account.get("subName", ""), + "pc_online": account.get("pcOnline", False), + "mobile_online": account.get("mobileOnline", False), + "suspended": account.get("suspended", False), + "group_name": group_name, + "group_id": str(group.get("groupId", "")), + "online": account.get("pcOnline", False) or account.get("mobileOnline", False) + } + sub_account_list.append(account_info) + + self._log(f"✅ 成功获取在线子账号列表,共 {len(sub_account_list)} 个账号", "SUCCESS") + + # 统计在线账号数量 + online_count = sum(1 for account in sub_account_list if account["online"]) + self._log( + f"📊 统计信息 - 总账号: {len(sub_account_list)}, 在线: {online_count}, 离线: {len(sub_account_list) - online_count}", + "INFO") + + return sub_account_list + + except Exception as e: + self._log(f"❌ 获取在线子账号列表异常: {e}", "ERROR") + import traceback + self._log(f"异常详情: {traceback.format_exc()}", "DEBUG") + return [] + + async def send_staff_list_to_backend(self, store_id: str): + """发送在线客服列表到后端(通过统一后端连接)""" + try: + # 获取在线客服列表 + online_staff_list = await self.get_customer_list() + + if not online_staff_list: + self._log("⚠️ 在线客服列表为空,无法发送", "WARNING") + return False + + # 创建消息模板(按照WebSocket协议v2格式) + message_template = PlatformMessage( + type="staff_list", + content="客服列表更新", + data={ + "staff_list": online_staff_list, + "total_count": len(online_staff_list) + }, + store_id=store_id + ) + + # 🔧 关键修改:通过统一后端连接发送,而不是千牛插件WebSocket + try: + from WebSocket.backend_singleton import get_backend_client + backend_client = get_backend_client() + if not backend_client or not backend_client.is_connected: + self._log("❌ 统一后端连接不可用", "ERROR") + return False + + # 通过后端连接发送在线客服列表 + backend_client.send_message(message_template.to_dict()) + self._log(f"📤 通过统一后端连接发送在线客服列表: {len(online_staff_list)} 个客服", "INFO") + self._log(f"✅ 成功发送在线客服列表到后端,共 {len(online_staff_list)} 个客服", "SUCCESS") + return True + + except Exception as e: + self._log(f"❌ 通过统一后端连接发送失败: {e}, 回退到千牛WebSocket", "WARNING") + + # 回退:通过千牛插件WebSocket发送(兼容性) + if hasattr(self, 'qn_ws') and self.qn_ws: + await self.qn_ws.send(json.dumps(message_template.to_dict())) + self._log(f"📤 回退:通过千牛WebSocket发送在线客服列表", "WARNING") + return True + else: + self._log(f"❌ 千牛WebSocket也不可用", "ERROR") + return False + + except Exception as e: + self._log(f"❌ 发送在线客服列表到后端异常: {e}", "ERROR") + return False + + async def _get_first_user(self, max_retries=3, retry_delay=2): + """获取第一个可用的账号(初始化完成后调用,减少重试)""" + for retry in range(max_retries): + try: + self._log(f"🔵 获取所有连接的账号... (尝试 {retry + 1}/{max_retries})", "INFO") + connected_users = await self.get_all_connected_users() + + if not connected_users: + if retry < max_retries - 1: + self._log(f"❌ 未找到可用的千牛账号,{retry_delay}秒后重试...", "WARNING") + await asyncio.sleep(retry_delay) + continue + else: + self._log("❌ 未找到可用的千牛账号(所有重试已耗尽)", "ERROR") + self._log("💡 请确认:1) 千牛客户端已启动 2) 已登录账号 3) DLL初始化已完成", "INFO") + return None + + # 获取第一个账号 + first_user = next(iter(connected_users.values())) + user_nick = first_user.get("userNick") + + if not user_nick: + self._log("❌ 账号信息中缺少userNick字段", "ERROR") + if retry < max_retries - 1: + await asyncio.sleep(retry_delay) + continue + return None + + self._log(f"✅ 获取到账号: {user_nick}", "SUCCESS") + self._log(f"账号详情: UID={first_user.get('userUid')}, 版本={first_user.get('version')}", "DEBUG") + + return user_nick + + except Exception as e: + if retry < max_retries - 1: + self._log(f"❌ 获取账号失败: {e},{retry_delay}秒后重试...", "WARNING") + await asyncio.sleep(retry_delay) + else: + self._log(f"❌ 获取账号失败(所有重试已耗尽): {e}", "ERROR") + return None + + async def get_all_connected_users(self) -> Dict[str, Dict]: + """获取所有已连接的千牛账号 + 返回格式: {"user_nick": {"userNick": "xxx", "userUid": "xxx", ...}} + """ + logger.info("开始获取所有已连接的千牛账号") + + try: + # 通过HTTP API调用 + response = await self._http_api_call_kefu("GetAllUser", {}) + + # 区分API调用失败(None)和返回空结果({}) + if response is None: + logger.error("获取连接账号失败:API调用失败") + return {} + + logger.debug(f"获取连接账号响应: {response}") + + # 检查返回数据格式 + if isinstance(response, dict): + # 直接返回账号信息字典(可能为空) + logger.info(f"成功获取到 {len(response)} 个已连接账号") + if len(response) == 0: + logger.warning("API调用成功但未返回任何账号信息,可能原因:") + logger.warning("1. 千牛客户端未启动") + logger.warning("2. 千牛客户端已启动但未登录任何账号") + logger.warning("3. DLL服务与千牛客户端连接异常") + return response + else: + logger.error(f"获取连接账号失败:返回数据格式错误 - {type(response)}: {response}") + return {} + + except Exception as e: + logger.error(f"获取连接账号异常: {e}") + return {} + + async def _http_api_call_kefu(self, post_name: str, data_obj: Dict[str, Any]) -> Dict[str, Any]: + """按官方文档以 form-data 方式调用 QianNiu/Api 接口""" + form = aiohttp.FormData() + form.add_field("post", post_name) + form.add_field("data", json.dumps(data_obj)) + logger.debug(f"[TB-DIAG] HTTP CALL -> {post_name} url={QN_HTTP_API_URL} data={data_obj}") + try: + async with aiohttp.ClientSession() as session: + async with session.post(QN_HTTP_API_URL, data=form, timeout=10) as resp: + text = await resp.text() + logger.debug(f"[TB-DIAG] HTTP RESP <- {post_name} status={resp.status} body={text[:300]}") + if resp.status != 200: + logger.error(f"[QianNiuClient] HTTP接口 {post_name} 调用失败: status={resp.status}") + return {} + try: + return json.loads(text) + except Exception: + logger.error(f"[TB-DIAG] HTTP RESP JSON decode error on {post_name}") + return {} + except Exception as e: + logger.error(f"[QianNiuClient] HTTP接口 {post_name} 调用异常: {e}") + return {} + # 在 QianNiuClient 类中添加HTTP API调用方法 (新增) async def _http_api_call(self, post_name: str, data_obj: Dict[str, Any]) -> Dict[str, Any]: """按官方文档以 form-data 方式调用 QianNiu/Api 接口""" @@ -1408,46 +1871,6 @@ class QianNiuClient: logger.error(f"系统初始化异常: {e}") return False - # async def connect(self): - # """连接到赛牛插件 - 修改为不发送初始化消息""" - # logger.info(f"尝试连接到赛牛插件: {QN_WS_URL}") - # print("=== 断点1: 开始连接赛牛插件 ===") - # - # try: - # # 连接WebSocket - # self.qn_ws = await websockets.connect(QN_WS_URL, ping_interval=20, ping_timeout=10) - # self.is_connected = True - # logger.info("成功连接到赛牛插件") - # print("=== 断点2: 成功连接到赛牛插件 ===") - # - # # 正式账号需要执行授权流程 - # print("=== 开始正式账号授权流程 ===") - # - # # 1. 请求授权 - # auth_success = await self._request_authorization() - # if not auth_success: - # logger.error("授权请求失败") - # return False - # - # # 2. 系统初始化 - # init_success = await self._system_initialization() - # if not init_success: - # logger.error("系统初始化失败") - # return False - # - # - # # 测试账号不需要初始化,连接成功即认证成功 - # self.is_authenticated = True - # logger.info("✅ 正式账号认证和初始化成功") - # - # return True - # - # except Exception as e: - # logger.error(f"连接赛牛插件失败: {e}") - # print(f"=== 断点3: 连接失败 - {e} ===") - # self.is_connected = False - # return False - async def connect(self): """连接到赛牛插件 - 完整流程""" logger.info("开始完整的赛牛连接流程") @@ -1695,21 +2118,6 @@ class QianNiuClient: print("=== 断点9: 消息监听循环开始 ===") try: - # # 确保正确启动后端监听 - # if (self.message_handler and - # hasattr(self.message_handler, 'ai_service') and - # self.message_handler.ai_service and - # hasattr(self.message_handler.ai_service.ai_service, 'listen_for_backend_messages')): - # - # print("🚀 启动后端消息监听任务", "DEBUG") - # # 创建监听任务 - # asyncio.create_task( - # self.message_handler.ai_service.ai_service.listen_for_backend_messages( - # self.message_handler.handle_customer_message - # ) - # ) - # else: - # print("⚠️ 无法启动后端监听:缺少必要的属性或方法", "WARNING") async for message in self.qn_ws: print("=== 断点10: 收到原始消息 ===") @@ -1788,6 +2196,7 @@ class QianNiuClient: except Exception as e: message_content = f"有需求要询问订单 优先回复" sender_nick = msg_data.get("senderNick", "") + sender_uid = msg_data.get("senderUid", "") # 新增:获取senderUid #### 核心处理逻辑!!!! logger.info(f"收到买家消息: {sender_nick} -> {message_content}") print(f"=== 断点14: 收到买家消息 - {sender_nick}: {message_content} ===") @@ -1798,11 +2207,11 @@ class QianNiuClient: "content": message_content, "msg_type": "text", "sender": { - "id": sender_nick, + "id": sender_uid if sender_uid else sender_nick, # 修改:优先使用senderUid "name": f"淘宝用户_{sender_nick}", "is_customer": True }, - "store_id": STORE_ID, + "store_id": "test_store_001", "message_id": str(uuid.uuid4()), "platform": "淘宝", # 新增:保存原始数据 @@ -1833,6 +2242,49 @@ class QianNiuClient: return await self.connect() return True + async def transfer_buyer_to_staff(self, buyer_uid: str, to_staff_uid: str = "", reason: str = "人工接待") -> bool: + """转接买家到指定客服 + + Args: + buyer_uid: 买家旺旺Uid + to_staff_uid: 接收子账号Uid,留空转自己 + reason: 转接原因 + + Returns: + bool: 转接是否成功 + """ + if not self.user_nick: + self._log("❌ 用户昵称未设置,无法转接", "ERROR") + return False + + try: + # 构建转接请求数据 + transfer_data = { + "userNick": self.user_nick, + "buyerUid": buyer_uid, + "toUid": to_staff_uid, + "reason": reason + } + + self._log(f"🔄 开始转接买家 {buyer_uid} 到客服 {to_staff_uid or '自己'}", "INFO") + self._log(f"转接原因: {reason}", "DEBUG") + + # 使用HTTP API调用转接接口 + result = await self._http_api_call_kefu("TransferBuyerUid", transfer_data) + + if result is not None: + self._log(f"✅ 转接请求发送成功: {result}", "SUCCESS") + return True + else: + self._log("❌ 转接请求发送失败", "ERROR") + return False + + except Exception as e: + self._log(f"❌ 转接买家异常: {e}", "ERROR") + import traceback + self._log(f"异常详情: {traceback.format_exc()}", "DEBUG") + return False + async def send_message(self, to_nick: str, message: str) -> bool: """发送消息给买家""" logger.info(f"准备发送消息 -> {to_nick}: {message}") @@ -1865,18 +2317,30 @@ class QianNiuClient: } # 构建发送消息请求 + # 根据SaiNiu新接口要求,同时支持buyerNick和buyerUid + send_data = { + "userNick": self.user_nick, + "text": message, + "siteid": "cntaobao", + "waitingTime": 5000 + } + + # 判断to_nick是UID还是Nick格式 + if to_nick.isdigit() and len(to_nick) > 10: + # 如果是纯数字且长度较长,认为是UID + send_data["buyerUid"] = to_nick + send_data["buyerNick"] = "" # 保留字段但置空,让API自动获取 + else: + # 否则认为是Nick + send_data["buyerNick"] = to_nick + send_data["buyerUid"] = "" # 保留字段但置空 + send_msg = { "type": "Invoke_QianNiu", "traceId": trace_id, "codeType": "Function", "post": "SendMessages", - "data": { - "userNick": self.user_nick, - "buyerNick": to_nick, - "text": message, - "siteid": "cntaobao", - "waitingTime": 5000 - } + "data": send_data } # 如果有认证token,添加到消息中 @@ -1970,6 +2434,7 @@ class TestMessageHandler: # 注册消息处理器(更新为新协议) self.ai_service.register_message_handlers({ "message": self.handle_customer_message, # 处理后端主动推送的消息 + "transfer": self.handle_transfer_message, # 处理转接消息 "connect_success": self.handle_connect_success, "error": self.handle_error_message }) @@ -2000,6 +2465,48 @@ class TestMessageHandler: self._log(f"❌ 启动后端监听失败: {e}", "ERROR") return False + async def send_customer_list_after_init(self): + """在初始化完成后发送客服列表""" + try: + if not self.qn_client: + self._log("❌ 千牛客户端未初始化", "ERROR") + return False + + # 等待一下确保连接完全稳定 + await asyncio.sleep(2) + + # 🔧 在发送前检查并确保后端连接可用 + max_retries = 3 + for retry in range(max_retries): + try: + from WebSocket.backend_singleton import get_backend_client + backend_client = get_backend_client() + + if backend_client and backend_client.is_connected: + self._log(f"✅ 后端连接验证成功 (尝试 {retry + 1})", "SUCCESS") + break + else: + self._log(f"⚠️ 后端连接不可用,等待重连... (尝试 {retry + 1}/{max_retries})", "WARNING") + await asyncio.sleep(2) + + except Exception as e: + self._log(f"⚠️ 后端连接检查异常: {e}", "WARNING") + await asyncio.sleep(2) + + # 发送客服列表 + success = await self.qn_client.send_staff_list_to_backend(self.store_id) + + if success: + self._log("✅ 客服列表发送成功", "SUCCESS") + else: + self._log("❌ 客服列表发送失败", "ERROR") + + return success + + except Exception as e: + self._log(f"❌ 发送客服列表异常: {e}", "ERROR") + return False + async def handle_ai_message(self, message_data): """处理AI回复消息(用于监控、日志记录等目的)""" try: @@ -2095,21 +2602,22 @@ class TestMessageHandler: # 使用转换后的数据 content = message.get("content", "") - sender_nick = message.get("sender", {}).get("id", "") + sender_uid = message.get("sender", {}).get("id", "") # 这是正确的senderUid - # 获取头像URL(如果存在) 先拿到对应的sendernick + # 获取头像URL(如果存在) 需要使用senderNick raw_data = message.get("raw_data", {}) + sender_nick = "" # 用于头像获取和日志 if raw_data: sender_nick = raw_data.get("senderNick", "") avatar_url = await self.qn_client.fetch_buyer_avatar_by_http(sender_nick) else: pass - if not content or not sender_nick: - self._log("消息内容或发送者昵称为空", "WARNING") + if not content or not sender_uid: + self._log("消息内容或发送者UID为空", "WARNING") return - self._log(f"处理买家消息: {sender_nick} -> {content}", "INFO") + self._log(f"处理买家消息: {sender_nick}({sender_uid}) -> {content}", "INFO") # 获取AI回复(带重试机制) max_retries = 2 # 减少重试次数,避免长时间等待 @@ -2117,7 +2625,7 @@ class TestMessageHandler: try: ai_reply = await self.ai_service.get_ai_reply( message_content=content, - sender_nick=sender_nick, + sender_nick=sender_uid, # 修正:传递sender_uid而不是sender_nick avatar_url=avatar_url # 新增: 传递 头像url ) @@ -2210,6 +2718,50 @@ class TestMessageHandler: self._log(f"❌ 处理错误消息异常: {e}", "ERROR") return False + async def handle_transfer_message(self, message_data): + """处理转接消息""" + try: + # 解析转接消息 + # content 是要转接到的客服UID(如:"2219807109857") + target_staff_uid = message_data.get("content", "") + receiver_info = message_data.get("receiver", {}) + buyer_uid = receiver_info.get("id", "") if receiver_info else "" + store_id = message_data.get("store_id", "") + platform_name = message_data.get("platform_name", "") + + self._log(f"🔄 收到转接消息: 将买家UID {buyer_uid} 转接到客服UID {target_staff_uid}", "INFO") + + if not buyer_uid: + self._log("❌ 转接消息中缺少买家UID", "ERROR") + return False + + if not target_staff_uid: + self._log("❌ 转接消息中缺少目标客服UID", "ERROR") + return False + + self._log(f"📋 开始执行转接操作: buyerUid={buyer_uid}, toStaffUid={target_staff_uid}", "INFO") + + # 执行转接操作 + # buyer_uid和target_staff_uid都是后端传来的正确UID格式 + success = await self.qn_client.transfer_buyer_to_staff( + buyer_uid=buyer_uid, + to_staff_uid=target_staff_uid, + reason="后端AI指派转接" + ) + + if success: + self._log(f"✅ 转接操作成功: 买家UID {buyer_uid} -> 客服UID {target_staff_uid}", "SUCCESS") + else: + self._log(f"❌ 转接操作失败: 买家UID {buyer_uid} -> 客服UID {target_staff_uid}", "ERROR") + + return success + + except Exception as e: + self._log(f"❌ 处理转接消息异常: {e}", "ERROR") + import traceback + self._log(f"异常详情: {traceback.format_exc()}", "DEBUG") + return False + async def close(self): """关闭消息处理器""" if self.ai_service: @@ -2276,47 +2828,6 @@ class QianNiuListenerForGUI: color = color_map.get(log_type, "") print(f"{color}[{timestamp}] [{log_type}] {message}\033[0m") - async def _get_first_user(self, max_retries=2, retry_delay=2): - """获取第一个可用的账号(初始化完成后调用,减少重试)""" - for retry in range(max_retries): - try: - self._log(f"🔵 获取所有连接的账号... (尝试 {retry + 1}/{max_retries})", "INFO") - connected_users = await self.get_all_connected_users() - - if not connected_users: - if retry < max_retries - 1: - self._log(f"❌ 未找到可用的千牛账号,{retry_delay}秒后重试...", "WARNING") - await asyncio.sleep(retry_delay) - continue - else: - self._log("❌ 未找到可用的千牛账号(所有重试已耗尽)", "ERROR") - self._log("💡 请确认:1) 千牛客户端已启动 2) 已登录账号 3) DLL初始化已完成", "INFO") - return None - - # 获取第一个账号 - first_user = next(iter(connected_users.values())) - user_nick = first_user.get("userNick") - - if not user_nick: - self._log("❌ 账号信息中缺少userNick字段", "ERROR") - if retry < max_retries - 1: - await asyncio.sleep(retry_delay) - continue - return None - - self._log(f"✅ 获取到账号: {user_nick}", "SUCCESS") - self._log(f"账号详情: UID={first_user.get('userUid')}, 版本={first_user.get('version')}", "DEBUG") - - return user_nick - - except Exception as e: - if retry < max_retries - 1: - self._log(f"❌ 获取账号失败: {e},{retry_delay}秒后重试...", "WARNING") - await asyncio.sleep(retry_delay) - else: - self._log(f"❌ 获取账号失败(所有重试已耗尽): {e}", "ERROR") - return None - async def _wait_for_dll_ready(self, max_attempts=15, delay=2): """等待DLL服务完全就绪(改进版)""" import socket @@ -2351,78 +2862,6 @@ class QianNiuListenerForGUI: self._log("❌ DLL服务等待超时", "ERROR") return False - async def get_all_connected_users(self) -> Dict[str, Dict]: - """获取所有已连接的千牛账号 - 返回格式: {"user_nick": {"userNick": "xxx", "userUid": "xxx", ...}} - """ - logger.info("开始获取所有已连接的千牛账号") - - try: - # 通过HTTP API调用 - response = await self._http_api_call("GetAllUser", {}) - - # 区分API调用失败(None)和返回空结果({}) - if response is None: - logger.error("获取连接账号失败:API调用失败") - return {} - - logger.debug(f"获取连接账号响应: {response}") - - # 检查返回数据格式 - if isinstance(response, dict): - # 直接返回账号信息字典(可能为空) - logger.info(f"成功获取到 {len(response)} 个已连接账号") - if len(response) == 0: - logger.warning("API调用成功但未返回任何账号信息,可能原因:") - logger.warning("1. 千牛客户端未启动") - logger.warning("2. 千牛客户端已启动但未登录任何账号") - logger.warning("3. DLL服务与千牛客户端连接异常") - return response - else: - logger.error(f"获取连接账号失败:返回数据格式错误 - {type(response)}: {response}") - return {} - - except Exception as e: - logger.error(f"获取连接账号异常: {e}") - return {} - - async def _http_api_call(self, post_name: str, data_obj: Dict[str, Any]) -> Dict[str, Any]: - """按官方文档以 form-data 方式调用 QianNiu/Api 接口(改进版)""" - form = aiohttp.FormData() - form.add_field("post", post_name) - form.add_field("data", json.dumps(data_obj)) - logger.debug(f"[TB-DIAG] HTTP CALL -> {post_name} url={QN_HTTP_API_URL} data={data_obj}") - - try: - async with aiohttp.ClientSession() as session: - async with session.post(QN_HTTP_API_URL, data=form, timeout=26) as resp: - text = await resp.text() - logger.debug(f"[TB-DIAG] HTTP RESP <- {post_name} status={resp.status} body={text[:300]}") - - if resp.status != 200: - logger.error(f"[QianNiuClient] HTTP接口 {post_name} 调用失败: status={resp.status}") - # 返回None而不是空字典,便于区分真正的空结果和错误 - return None - - try: - result = json.loads(text) - # 即使结果为空也是成功的API调用 - logger.debug(f"[TB-DIAG] HTTP API {post_name} 调用成功,返回数据类型: {type(result)}") - return result if result is not None else {} - except json.JSONDecodeError as e: - logger.error(f"[TB-DIAG] HTTP RESP JSON decode error on {post_name}: {e}") - return None - - except asyncio.TimeoutError: - logger.error(f"[QianNiuClient] HTTP接口 {post_name} 调用超时") - return None - except aiohttp.ClientError as e: - logger.error(f"[QianNiuClient] HTTP接口 {post_name} 网络错误: {e}") - return None - except Exception as e: - logger.error(f"[QianNiuClient] HTTP接口 {post_name} 调用异常: {e}") - return None - # 新增供给架构调用 ---- 测试中 async def start_listening_with_store(self, store_id, exe_token=None): """带店铺ID的监听启动方法(单连接多店铺架构)""" @@ -2431,6 +2870,10 @@ class QianNiuListenerForGUI: self.store_id = store_id # 保存店铺ID self.exe_token = exe_token # 保存用户令牌 + # 如果是exe环境,先输出调试信息 + if getattr(sys, 'frozen', False): + self.debug_exe_environment() + # 1. 先创建临时千牛客户端用于启动DLL服务 temp_client = QianNiuClient() self._log("🔵 步骤1: 启动DLL服务...", "INFO") @@ -2467,7 +2910,7 @@ class QianNiuListenerForGUI: # 7. 现在可以安全地获取已连接的账号(初始化完成后) self._log("🔵 步骤4: 获取可用账号...", "INFO") - self.user_nick = await self._get_first_user() + self.user_nick = await self.qn_client._get_first_user() if not self.user_nick: self._log("❌ 无法获取可用账号", "ERROR") return False @@ -2489,6 +2932,10 @@ class QianNiuListenerForGUI: return False self._log("✅ AI服务初始化成功", "SUCCESS") + self._log("🔵 步骤7: 发送客服列表到后端...", "INFO") + await self.message_handler.send_customer_list_after_init() + self._log("✅ 客服列表发送完成", "SUCCESS") + # 10. 注册到全局管理器 qn_manager = QianNiuWebsocketManager() shop_key = f"千牛:{store_id}" @@ -2526,71 +2973,6 @@ class QianNiuListenerForGUI: self._log(f"错误详情: {traceback.format_exc()}", "DEBUG") return False - async def start_listening(self, user_nick): - """启动监听的主方法""" - try: - self._log("🔵 开始千牛平台连接流程", "INFO") - - # 1. 创建千牛客户端 - self.qn_client = QianNiuClient() - self._log("✅ 千牛客户端创建成功", "DEBUG") - - # 2. 测试DLL加载和启动(与main方法一致) - self._log("🔵 步骤1: 测试DLL加载和启动...", "INFO") - if not await self.qn_client._start_sainiu_service(): - self._log("❌ DLL服务启动失败", "ERROR") - return False - self._log("✅ DLL服务启动成功", "SUCCESS") - - # 3. 测试WebSocket连接(与main方法一致) - self._log("🔵 步骤2: 测试WebSocket连接...", "INFO") - if not await self.qn_client._connect_websocket(): - self._log("❌ WebSocket连接失败", "ERROR") - return False - self._log("✅ WebSocket连接成功", "SUCCESS") - - # 4. 创建消息处理器 - self.message_handler = TestMessageHandler(self.qn_client) - self._log("✅ 消息处理器创建成功", "DEBUG") - - # 5. 初始化AI服务 - success = await self.message_handler.initialize() - if not success: - self._log("❌ AI服务初始化失败", "ERROR") - return False - self._log("✅ AI服务初始化成功", "SUCCESS") - - # 6. 开始监听消息 - self._log("🔵 步骤3: 开始监听消息...", "INFO") - self.stop_event = asyncio.Event() - self.running = True - - # 7. 启动监听任务 - listen_task, heartbeat_task = await self.qn_client.start_listening( - self.message_handler.handle_message - ) - self.tasks.extend([listen_task, heartbeat_task]) - self._log("✅ 监听任务启动成功", "SUCCESS") - - # 8. 等待任务完成或停止信号 - try: - await asyncio.gather(*self.tasks, return_exceptions=True) - except asyncio.CancelledError: - self._log("🔵 监听任务被取消", "WARNING") - except Exception as e: - self._log(f"❌ 监听过程中出现错误: {e}", "ERROR") - import traceback - self._log(f"错误详情: {traceback.format_exc()}", "DEBUG") - - self._log("🎉 千牛平台消息监听已启动", "SUCCESS") - return True - - except Exception as e: - self._log(f"❌ 启动监听失败: {e}", "ERROR") - import traceback - self._log(f"错误详情: {traceback.format_exc()}", "DEBUG") - return False - def stop_listening(self): """停止监听""" try: @@ -2632,6 +3014,71 @@ class QianNiuListenerForGUI: """设置日志信号""" self.log_signal = signal + def debug_exe_environment(self): + """调试exe环境下的配置问题""" + print("\n" + "=" * 60) + print("🔧 EXE环境调试信息") + print("=" * 60) + + # 基本环境信息 + print(f"🎯 Python版本: {sys.version}") + print(f"🎯 当前工作目录: {os.getcwd()}") + print(f"🎯 是否为打包环境: {getattr(sys, 'frozen', False)}") + + if getattr(sys, 'frozen', False): + print(f"📁 exe文件路径: {sys.executable}") + print(f"📁 exe目录: {os.path.dirname(sys.executable)}") + if hasattr(sys, '_MEIPASS'): + print(f"📦 临时解压目录: {sys._MEIPASS}") + + # 检查关键目录结构 + exe_dir = os.path.dirname(sys.executable) if getattr(sys, 'frozen', False) else os.getcwd() + + print(f"\n📂 exe目录结构:") + try: + for root, dirs, files in os.walk(exe_dir): + level = root.replace(exe_dir, '').count(os.sep) + indent = ' ' * 2 * level + print(f"{indent}{os.path.basename(root)}/") + subindent = ' ' * 2 * (level + 1) + for file in files: + if level < 3: # 限制深度避免输出过多 + print(f"{subindent}{file}") + if level >= 2: # 限制遍历深度 + break + except Exception as e: + print(f"❌ 无法遍历目录: {e}") + + # 检查Utils/PythonNew32 + utils_path = os.path.join(exe_dir, "Utils", "PythonNew32") + print(f"\n📂 Utils/PythonNew32 目录检查:") + print(f" 路径: {utils_path}") + print(f" 存在: {os.path.exists(utils_path)}") + + if os.path.exists(utils_path): + print(" 内容:") + try: + for item in os.listdir(utils_path): + item_path = os.path.join(utils_path, item) + item_type = "📁" if os.path.isdir(item_path) else "📄" + print(f" {item_type} {item}") + except Exception as e: + print(f" ❌ 无法列出内容: {e}") + + # 检查关键文件 + key_files = [ + os.path.join(utils_path, "python32.exe"), + os.path.join(utils_path, "SaiNiuApi.dll"), + ] + + print(f"\n📄 关键文件检查:") + for file_path in key_files: + exists = os.path.exists(file_path) + status = "✅" if exists else "❌" + print(f" {status} {file_path}") + + print("=" * 60) + async def main(store_id=None): """主测试函数""" @@ -2640,7 +3087,7 @@ async def main(store_id=None): # 如果没有提供store_id,使用默认值 if store_id is None: - store_id = STORE_ID + store_id = "4c4025e3-8702-42fc-bdc2-671e335c0ff7" listener = QianNiuListenerForGUI() @@ -2691,75 +3138,9 @@ async def main(store_id=None): logger.info("=== 赛牛接口本地测试结束 ===") print("=== 断点F: 测试结束 ===") - # # 创建千牛客户端 - # qn_client = QianNiuClient() - # message_handler = None # 提前定义变量 - # - # try: - # # 1. 测试DLL加载和启动 - # print("\n📋 步骤1: 测试DLL加载和启动...") - # if not await qn_client._start_sainiu_service(): - # print("❌ DLL服务启动失败") - # return False - # print("✅ DLL服务启动成功") - # - # # 2. 测试WebSocket连接(包含授权和初始化) - # print("\n📋 步骤2: 测试WebSocket连接...") - # if not await qn_client._connect_websocket(): - # print("❌ WebSocket连接失败") - # return False - # print("✅ WebSocket连接成功") - # - # # 3. 创建消息处理器 - # message_handler = TestMessageHandler(qn_client) - # - # # 4. 初始化消息处理器和AI服务 - # print("\n📋 步骤3: 初始化AI服务...") - # if not await message_handler.initialize(): - # logger.error("AI服务初始化失败") - # print("❌ AI服务初始化失败") - # return - # print("✅ AI服务初始化成功") - # - # # 5. 开始监听消息 - # print("\n📋 步骤4: 开始监听消息...") - # listen_task, heartbeat_task = await qn_client.start_listening(message_handler.handle_message) - # - # # 6. 测试发送消息功能 - # print("\n📋 步骤5: 测试发送消息功能...") - # test_nick = "test_buyer" - # test_message = "这是一条测试消息" - # - # print(f"=== 准备发送测试消息给 {test_nick} ===") - # success = await qn_client.send_message(test_nick, test_message) - # - # if success: - # print("✅ 测试消息发送成功") - # else: - # print("❌ 测试消息发送失败") - # - # # 保持运行,直到用户中断 - # logger.info("测试程序已启动,按Ctrl+C停止...") - # print("=== 测试程序运行中,等待消息... ===") - # - # # 等待任务完成或用户中断 - # await asyncio.gather(listen_task, heartbeat_task, return_exceptions=True) - # - # except KeyboardInterrupt: - # logger.info("用户中断测试") - # print("=== 断点D: 用户中断测试 ===") - # except Exception as e: - # logger.error(f"测试异常: {e}") - # print(f"=== 断点E: 测试异常 - {e} ===") - # finally: - # if message_handler: # 检查变量是否已定义 - # await message_handler.close() # 关闭消息处理器和AI服务 - # await qn_client.close() - # logger.info("=== 赛牛接口本地测试结束 ===") - # print("=== 断点F: 测试结束 ===") - if __name__ == "__main__": # 运行测试 - asyncio.run(main(store_id="4c4025e3-8702-42fc-bdc2-671e335c0ff7")) - # asyncio.run(test_auth()) + # asyncio.run(test_online_sub_account()) # 新在线子账号测试 + # 后续如其它平台转发逻辑可能需要进行重新集成 + pass \ No newline at end of file diff --git a/WebSocket/backend_singleton.py b/WebSocket/backend_singleton.py index 9f0779f..7a78bd0 100644 --- a/WebSocket/backend_singleton.py +++ b/WebSocket/backend_singleton.py @@ -29,13 +29,16 @@ class WebSocketManager: def __init__(self): self.backend_client = None self.platform_listeners = {} # 存储各平台的监听器 + self.connected_platforms = [] # 存储已连接的平台列表 # <- 新增 self.callbacks = { 'log': None, 'success': None, - 'error': None + 'error': None, + 'platform_connected': None, } - def set_callbacks(self, log: Callable = None, success: Callable = None, error: Callable = None): + def set_callbacks(self, log: Callable = None, success: Callable = None, error: Callable = None, + platform_connected: Callable = None): # ← 新增参数 """设置回调函数""" if log: self.callbacks['log'] = log @@ -43,6 +46,8 @@ class WebSocketManager: self.callbacks['success'] = success if error: self.callbacks['error'] = error + if platform_connected: # ← 新增 + self.callbacks['platform_connected'] = platform_connected def _log(self, message: str, level: str = "INFO"): """内部日志方法""" @@ -51,6 +56,18 @@ class WebSocketManager: else: print(f"[{level}] {message}") + def _notify_platform_connected(self, platform_name: str): + """通知GUI平台连接成功""" + try: + if platform_name not in self.connected_platforms: + self.connected_platforms.append(platform_name) + + if self.callbacks['platform_connected']: + self.callbacks['platform_connected'](platform_name, self.connected_platforms.copy()) + self._log(f"已通知GUI平台连接: {platform_name}", "INFO") + except Exception as e: + self._log(f"通知平台连接失败: {e}", "ERROR") + def connect_backend(self, token: str) -> bool: """连接后端WebSocket""" try: @@ -202,6 +219,7 @@ class WebSocketManager: self._log(f"上报连接状态失败: {e}", "WARNING") self._log("已启动京东平台监听", "SUCCESS") + self._notify_platform_connected("京东") # ← 新增 except Exception as e: self._log(f"启动京东平台监听失败: {e}", "ERROR") @@ -265,6 +283,7 @@ class WebSocketManager: self._log(f"上报抖音平台连接状态失败: {e}", "WARNING") self._log("已启动抖音平台监听", "SUCCESS") + self._notify_platform_connected("抖音") # ← 新增 except Exception as e: self._log(f"启动抖音平台监听失败: {e}", "ERROR") @@ -324,6 +343,7 @@ class WebSocketManager: self._log(f"上报连接状态失败: {e}", "WARNING") self._log("已启动千牛平台监听(单连接多店铺架构)", "SUCCESS") + self._notify_platform_connected("千牛") # ← 新增 except Exception as e: self._log(f"启动千牛平台监听失败: {e}", "ERROR") @@ -363,6 +383,7 @@ class WebSocketManager: self._log("⚠️ [PDD] 验证码错误,已发送错误通知给后端", "WARNING") elif result: self._log("✅ [PDD] 登录成功,平台连接已建立", "SUCCESS") + self._notify_platform_connected("拼多多") else: self._log("❌ [PDD] 登录失败", "ERROR") else: diff --git a/build_production.bat b/build_production.bat new file mode 100644 index 0000000..1dffed5 --- /dev/null +++ b/build_production.bat @@ -0,0 +1,21 @@ +@echo off +chcp 65001 >nul +echo. +echo ===================================================== +echo 🔇 生产环境打包工具(无日志版本) +echo ===================================================== +echo. + +echo 🔧 正在生成无日志版本... +python build_production.py + +echo. +echo ===================================================== +echo 🎉 生产环境打包完成! +echo. +echo 📁 输出目录: dist/MultiPlatformGUI/ +echo 🔇 客户端运行时不会产生任何日志文件 +echo 🚀 可直接交付给客户使用 +echo. +echo ===================================================== +pause >nul \ No newline at end of file diff --git a/build_production.py b/build_production.py new file mode 100644 index 0000000..b6ee6a6 --- /dev/null +++ b/build_production.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +生产环境打包脚本 - 自动禁用日志功能 +""" + +import os +import shutil +import subprocess + +def disable_logging(): + """自动禁用所有日志功能""" + print("🔧 正在禁用日志功能...") + + # 备份原文件 + files_to_backup = ['main.py', 'exe_file_logger.py'] + for file_name in files_to_backup: + if os.path.exists(file_name): + shutil.copy(file_name, f'{file_name}.backup') + print(f"✅ 已备份 {file_name}") + + # 1. 修改 main.py - 注释掉日志初始化 + if os.path.exists('main.py'): + with open('main.py', 'r', encoding='utf-8') as f: + content = f.read() + + # 注释掉日志相关的导入和初始化 + content = content.replace( + 'from exe_file_logger import setup_file_logging, log_to_file', + '# from exe_file_logger import setup_file_logging, log_to_file # 生产环境禁用' + ).replace( + 'setup_file_logging()', + '# setup_file_logging() # 生产环境禁用' + ).replace( + 'print("文件日志系统已在main.py中初始化")', + '# print("文件日志系统已在main.py中初始化") # 生产环境禁用' + ) + + with open('main.py', 'w', encoding='utf-8') as f: + f.write(content) + print("✅ 已禁用 main.py 中的日志初始化") + + # 2. 修改 exe_file_logger.py - 让所有函数变成空操作 + if os.path.exists('exe_file_logger.py'): + with open('exe_file_logger.py', 'r', encoding='utf-8') as f: + content = f.read() + + # 将 setup_file_logging 函数替换为空函数 + content = content.replace( + 'def setup_file_logging():', + 'def setup_file_logging():\n """生产环境 - 禁用文件日志"""\n return None\n\ndef setup_file_logging_original():' + ).replace( + 'def log_to_file(message):', + 'def log_to_file(message):\n """生产环境 - 禁用文件日志"""\n pass\n\ndef log_to_file_original(message):' + ) + + with open('exe_file_logger.py', 'w', encoding='utf-8') as f: + f.write(content) + print("✅ 已禁用 exe_file_logger.py 中的日志功能") + + print("✅ 所有日志功能已禁用") + +def restore_logging(): + """恢复日志功能""" + files_to_restore = ['main.py', 'exe_file_logger.py'] + for file_name in files_to_restore: + backup_file = f'{file_name}.backup' + if os.path.exists(backup_file): + shutil.copy(backup_file, file_name) + os.remove(backup_file) + print(f"✅ 已恢复 {file_name}") + print("✅ 所有文件已恢复到开发环境配置") + +def main(): + """主函数""" + print("🔥 生产环境打包工具(无日志版本)") + print("=" * 60) + + try: + # 1. 禁用日志功能 + disable_logging() + + # 2. 执行打包 + print("\n🚀 开始打包...") + result = subprocess.run(['python', 'quick_build.py'], capture_output=False) + + if result.returncode == 0: + print("\n🎉 生产环境打包成功!") + print("📁 输出目录: dist/MultiPlatformGUI/") + print("🔇 已禁用所有日志功能,客户端不会产生日志文件") + else: + print("\n❌ 打包失败") + + except Exception as e: + print(f"❌ 打包过程出错: {e}") + + finally: + # 3. 恢复日志功能(用于开发) + print("\n🔄 恢复开发环境配置...") + restore_logging() + + print("\n" + "=" * 60) + input("按Enter键退出...") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/exe_file_logger.py b/exe_file_logger.py new file mode 100644 index 0000000..c522365 --- /dev/null +++ b/exe_file_logger.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +EXE文件日志器 - 将所有输出重定向到文件 +适用于 pyinstaller -w 打包的exe +""" + +import sys +import os +from datetime import datetime +import threading + +class FileLogger: + """文件日志器类""" + + def __init__(self): + # 确定日志文件路径 + if getattr(sys, 'frozen', False): + # 打包环境 + self.log_dir = os.path.dirname(sys.executable) + else: + # 开发环境 + self.log_dir = os.getcwd() + + # 创建日志文件 + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + self.log_file = os.path.join(self.log_dir, f"qianniu_exe_{timestamp}.log") + + # 线程锁,确保多线程写入安全 + self.lock = threading.Lock() + + # 初始化日志文件 + self.write_log("=" * 80) + self.write_log("千牛EXE日志开始") + self.write_log(f"Python版本: {sys.version}") + self.write_log(f"是否打包环境: {getattr(sys, 'frozen', False)}") + self.write_log(f"日志文件: {self.log_file}") + self.write_log("=" * 80) + + def write_log(self, message): + """写入日志到文件""" + try: + with self.lock: + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3] + log_entry = f"[{timestamp}] {message}\n" + + with open(self.log_file, 'a', encoding='utf-8') as f: + f.write(log_entry) + f.flush() + os.fsync(f.fileno()) # 强制写入磁盘 + except Exception as e: + # 如果写入失败,至少尝试输出到stderr + try: + sys.stderr.write(f"LOG_ERROR: {message} | Error: {e}\n") + sys.stderr.flush() + except: + pass + + def write(self, text): + """实现write方法以支持作为sys.stdout使用""" + if text.strip(): # 只记录非空内容 + self.write_log(text.strip()) + + def flush(self): + """实现flush方法""" + pass + +class TeeOutput: + """同时输出到原始输出和文件的类""" + + def __init__(self, original, file_logger): + self.original = original + self.file_logger = file_logger + + def write(self, text): + # 写入原始输出(如果存在) + if self.original: + try: + self.original.write(text) + self.original.flush() + except: + pass + + # 写入文件日志 + if text.strip(): + self.file_logger.write_log(text.strip()) + + def flush(self): + if self.original: + try: + self.original.flush() + except: + pass + +# 全局文件日志器实例 +_file_logger = None + +def setup_file_logging(): + """设置文件日志记录""" + global _file_logger + + if _file_logger is None: + _file_logger = FileLogger() + + # 保存原始的stdout和stderr + original_stdout = sys.stdout + original_stderr = sys.stderr + + # 创建Tee输出,同时输出到原始输出和文件 + sys.stdout = TeeOutput(original_stdout, _file_logger) + sys.stderr = TeeOutput(original_stderr, _file_logger) + + _file_logger.write_log("文件日志系统已设置完成") + + return _file_logger + +def log_to_file(message): + """直接写入文件日志的函数""" + global _file_logger + if _file_logger: + _file_logger.write_log(message) + else: + # 如果还没有初始化,先初始化 + setup_file_logging() + _file_logger.write_log(message) + +# 注释掉自动初始化,改为手动调用 +# if getattr(sys, 'frozen', False): +# setup_file_logging() \ No newline at end of file diff --git a/main.py b/main.py index a6d904a..985cfbe 100644 --- a/main.py +++ b/main.py @@ -3,10 +3,20 @@ from PyQt5.QtCore import Qt from PyQt5.QtGui import QFont, QPalette, QColor from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QLineEdit, - QTextEdit, QFrame, QDialog, QDialogButtonBox, QComboBox) - + QTextEdit, QFrame, QDialog, QDialogButtonBox, QComboBox, + QSystemTrayIcon, QMenu, QAction, QMessageBox, QGraphicsDropShadowEffect) +from PyQt5.QtCore import QPropertyAnimation, QEasingCurve, QRect, QTimer, pyqtProperty +from PyQt5.QtGui import QColor import config from WebSocket.backend_singleton import get_websocket_manager +from windows_taskbar_fix import setup_windows_taskbar_icon +import os +# ===================== 文件日志系统 - 同时支持开发和exe环境 ===================== +# 重定向所有输出到文件,确保有日志记录 +from exe_file_logger import setup_file_logging, log_to_file + +setup_file_logging() +print("文件日志系统已在main.py中初始化") # 新增: 用户名密码输入对话框类 @@ -21,22 +31,27 @@ class LoginWindow(QMainWindow): self.pdd_worker = None # 重复执行防护 - self.platform_combo_connected = False self.last_login_time = 0 self.login_cooldown = 2 # 登录冷却时间(秒) + # 平台连接状态管理 + self.connected_platforms = [] + self.status_timer = None + self.last_platform_connect_time = 0 # 记录最后一次平台连接时间 + # 日志管理相关变量已删除 self.initUI() - # 设置当前平台为ComboBox的当前值 - self.current_platform = self.platform_combo.currentText() - def initUI(self): # 设置窗口基本属性 - self.setWindowTitle('AI回复连接入口-V1.0') - self.setGeometry(300, 300, 800, 600) # 增大窗口尺寸 - self.setMinimumSize(700, 500) # 设置最小尺寸保证显示完整 + self.setWindowTitle('AI客服智能助手') + self.setGeometry(300, 300, 450, 300) # 进一步减小窗口尺寸 + self.setMinimumSize(420, 280) # 设置更小的最小尺寸 + self.setMaximumSize(500, 350) # 设置最大尺寸,保持紧凑 + + # 窗口图标将由统一的任务栏修复模块设置,这里只做备用检查 + print("[INFO] 窗口图标将由统一模块设置") # 创建中央widget central_widget = QWidget() @@ -44,60 +59,44 @@ class LoginWindow(QMainWindow): # 创建主布局 main_layout = QVBoxLayout() - main_layout.setSpacing(25) # 增加间距 - main_layout.setContentsMargins(40, 40, 40, 40) # 增加边距 + main_layout.setSpacing(15) # 减小间距 + main_layout.setContentsMargins(30, 20, 30, 20) # 减小边距 central_widget.setLayout(main_layout) - # 添加标题 - title_label = QLabel('AI回复连接入口') + # 添加标题和副标题 + title_label = QLabel('AI客服智能助手') + title_label.setObjectName("title") title_label.setAlignment(Qt.AlignCenter) - title_label.setFont(QFont('Microsoft YaHei', 24, QFont.Bold)) # 使用系统自带字体 - title_label.setStyleSheet("color: #2c3e50;") + title_label.setFont(QFont('Microsoft YaHei', 18, QFont.Bold)) # 减小字体 + title_label.setStyleSheet("color: #2c3e50; margin-bottom: 3px;") main_layout.addWidget(title_label) - # 添加分隔线 - line = QFrame() - line.setFrameShape(QFrame.HLine) - line.setFrameShadow(QFrame.Sunken) - line.setStyleSheet("color: #bdc3c7;") - main_layout.addWidget(line) + # 添加副标题说明 + subtitle_label = QLabel('智能连接多平台客服,提供AI自动回复服务') + subtitle_label.setObjectName("subtitle") + subtitle_label.setAlignment(Qt.AlignCenter) + subtitle_label.setFont(QFont('Microsoft YaHei', 9)) # 减小字体 + subtitle_label.setStyleSheet("color: #7f8c8d; margin-bottom: 10px;") + main_layout.addWidget(subtitle_label) - # 在token_input之前添加平台选择区域 - platform_layout = QHBoxLayout() - platform_label = QLabel("选择平台:") - self.platform_combo = QComboBox() - self.platform_combo.addItems(["JD", "抖音", "千牛(淘宝)", "拼多多"]) - - # 防止重复连接信号 - 更强的保护 - if not hasattr(self, 'platform_combo_connected') or not self.platform_combo_connected: - try: - self.platform_combo.currentIndexChanged.disconnect() - except TypeError: - pass # 如果没有连接则忽略 - - self.platform_combo.currentIndexChanged.connect(self.on_platform_changed) - self.platform_combo_connected = True - platform_layout.addWidget(platform_label) - platform_layout.addWidget(self.platform_combo) - - # 将platform_layout添加到主布局中,在token_layout之前 - main_layout.addLayout(platform_layout) + # 添加少量垂直空间 + main_layout.addStretch(1) # 创建令牌输入区域 - token_layout = QHBoxLayout() - token_layout.setSpacing(15) + token_layout = QVBoxLayout() + token_layout.setSpacing(8) # 减小间距 - token_label = QLabel('令牌:') - token_label.setFont(QFont('Microsoft YaHei', 12)) - token_label.setFixedWidth(80) + token_label = QLabel('访问令牌') + token_label.setFont(QFont('Microsoft YaHei', 11, QFont.Bold)) # 减小字体 + token_label.setStyleSheet("color: #34495e;") self.token_input = QLineEdit() - self.token_input.setPlaceholderText('请输入您的访问令牌') + self.token_input.setPlaceholderText('请输入您的访问令牌以连接服务') self.token_input.setEchoMode(QLineEdit.Password) - self.token_input.setFont(QFont('Microsoft YaHei', 11)) + self.token_input.setFont(QFont('Microsoft YaHei', 10)) # 减小字体 # noinspection PyUnresolvedReferences self.token_input.returnPressed.connect(self.login) # 表示回车提交 - self.token_input.setMinimumHeight(40) # 增加输入框高度 + self.token_input.setMinimumHeight(38) # 减小输入框高度 # 预填已保存的令牌(如果存在) try: from config import get_saved_token @@ -112,18 +111,22 @@ class LoginWindow(QMainWindow): main_layout.addLayout(token_layout) # 创建连接按钮 - self.login_btn = QPushButton('是否连接') - self.login_btn.setFont(QFont('Microsoft YaHei', 12, QFont.Bold)) - self.login_btn.setMinimumHeight(45) # 增大按钮高度 + self.login_btn = QPushButton('连接服务') + self.login_btn.setFont(QFont('Microsoft YaHei', 11, QFont.Bold)) # 减小字体 + self.login_btn.setMinimumHeight(40) # 减小按钮高度 self.login_btn.clicked.connect(self.login) # 表示点击提交 main_layout.addWidget(self.login_btn) - # 添加分隔线 - line = QFrame() - line.setFrameShape(QFrame.HLine) - line.setFrameShadow(QFrame.Sunken) - line.setStyleSheet("color: #bdc3c7;") - main_layout.addWidget(line) + # 添加连接状态提示 + self.status_label = QLabel('等待连接...') + self.status_label.setObjectName("status") + self.status_label.setAlignment(Qt.AlignCenter) + self.status_label.setFont(QFont('Microsoft YaHei', 9)) # 减小字体 + self.status_label.setStyleSheet("color: #95a5a6; margin-top: 5px;") + main_layout.addWidget(self.status_label) + + # 添加少量底部空间 + main_layout.addStretch(1) # 日志框已永久删除,只使用终端输出 self.log_display = None @@ -131,102 +134,142 @@ class LoginWindow(QMainWindow): # 应用现代化样式 self.apply_modern_styles() + # 添加视觉效果 + self.add_visual_effects() + + # 初始化系统托盘 + self.init_system_tray() + # 系统初始化日志输出到终端 print("[INFO] 系统初始化完成") - # # 在平台选择改变时添加调试日志 - # self.platform_combo.currentIndexChanged.connect(self.on_platform_changed) - - # 设置默认平台 - self.platform_combo.setCurrentText("JD") - print(f"🔥 设置默认平台为: {self.platform_combo.currentText()}") - def apply_modern_styles(self): - """应用现代化样式,兼容各Windows版本""" + """应用现代化简约样式 - 精致美化版""" self.setStyleSheet(""" QMainWindow { - background-color: #f9f9f9; + background: qlineargradient( + x1:0, y1:0, x2:0, y2:1, + stop:0 #f8fafb, + stop:0.5 #f1f4f6, + stop:1 #e8ecef + ); + border: 1px solid rgba(255, 255, 255, 0.3); } QLabel { - color: #34495e; + color: #2c3e50; + background: transparent; + } + + QLabel[objectName="title"] { + color: qlineargradient( + x1:0, y1:0, x2:1, y2:0, + stop:0 #2c3e50, + stop:1 #34495e + ); + font-weight: bold; + } + + QLabel[objectName="subtitle"] { + color: #7f8c8d; + background: transparent; + } + + QLabel[objectName="status"] { + padding: 5px 10px; + border-radius: 12px; + background: rgba(149, 165, 166, 0.1); + font-size: 9px; } QLineEdit { - padding: 10px; - border: 2px solid #dfe6e9; - border-radius: 6px; - font-size: 14px; - background-color: white; - selection-background-color: #3498db; + padding: 12px 16px; + border: 2px solid transparent; + border-radius: 12px; + font-size: 13px; + background: qlineargradient( + x1:0, y1:0, x2:0, y2:1, + stop:0 #ffffff, + stop:1 #f8fafb + ); + selection-background-color: #007bff; selection-color: white; + color: #2c3e50; } QLineEdit:focus { - border-color: #3498db; + border: 2px solid #007bff; + background: white; + outline: none; + } + + QLineEdit:hover { + border: 2px solid #6c757d; + background: white; } QPushButton { - background-color: #3498db; + background: qlineargradient( + x1:0, y1:0, x2:0, y2:1, + stop:0 #4285f4, + stop:0.5 #1976d2, + stop:1 #1565c0 + ); border: none; color: white; padding: 12px 24px; - border-radius: 6px; - font-size: 14px; - min-width: 120px; + border-radius: 12px; + font-size: 13px; + font-weight: 600; + min-width: 180px; + letter-spacing: 0.5px; } QPushButton:hover { - background-color: #2980b9; + background: qlineargradient( + x1:0, y1:0, x2:0, y2:1, + stop:0 #5294f5, + stop:0.5 #2986d3, + stop:1 #1e74c1 + ); } QPushButton:pressed { - background-color: #1a6a9c; + background: qlineargradient( + x1:0, y1:0, x2:0, y2:1, + stop:0 #3274d4, + stop:0.5 #1666c2, + stop:1 #1455b0 + ); } QPushButton:disabled { - background-color: #bdc3c7; - color: #7f8c8d; + background: qlineargradient( + x1:0, y1:0, x2:0, y2:1, + stop:0 #f8f9fa, + stop:1 #e9ecef + ); + color: #6c757d; + border: 1px solid #dee2e6; } - QTextEdit { - border: 2px solid #dfe6e9; - border-radius: 6px; - background-color: white; - padding: 8px; - font-family: 'Consolas', 'Microsoft YaHei', monospace; + /* 连接成功状态的按钮 */ + QPushButton[objectName="connected"] { + background: qlineargradient( + x1:0, y1:0, x2:0, y2:1, + stop:0 #28a745, + stop:0.5 #20963b, + stop:1 #1e7e34 + ); } - QFrame[frameShape="4"] { - color: #dfe6e9; - } - - QGroupBox { - font-weight: bold; - border: 2px solid #dfe6e9; - border-radius: 6px; - margin-top: 10px; - padding-top: 10px; - } - - QComboBox { - padding: 8px; - border: 2px solid #dfe6e9; - border-radius: 6px; - font-size: 14px; - background-color: white; - min-width: 100px; - } - - QComboBox::drop-down { - border: none; - width: 20px; - } - - QComboBox QAbstractItemView { - border: 2px solid #dfe6e9; - selection-background-color: #3498db; - selection-color: white; + QPushButton[objectName="connected"]:hover { + background: qlineargradient( + x1:0, y1:0, x2:0, y2:1, + stop:0 #34ce57, + stop:0.5 #28a745, + stop:1 #20963b + ); } """) @@ -236,104 +279,256 @@ class LoginWindow(QMainWindow): # 设置调色板确保颜色一致性 palette = QPalette() - palette.setColor(QPalette.Window, QColor(249, 249, 249)) - palette.setColor(QPalette.WindowText, QColor(52, 73, 94)) + palette.setColor(QPalette.Window, QColor(248, 250, 251)) + palette.setColor(QPalette.WindowText, QColor(44, 62, 80)) palette.setColor(QPalette.Base, QColor(255, 255, 255)) - palette.setColor(QPalette.AlternateBase, QColor(233, 235, 239)) + palette.setColor(QPalette.AlternateBase, QColor(241, 244, 246)) palette.setColor(QPalette.ToolTipBase, QColor(255, 255, 255)) - palette.setColor(QPalette.ToolTipText, QColor(52, 73, 94)) - palette.setColor(QPalette.Text, QColor(52, 73, 94)) - palette.setColor(QPalette.Button, QColor(52, 152, 219)) + palette.setColor(QPalette.ToolTipText, QColor(44, 62, 80)) + palette.setColor(QPalette.Text, QColor(44, 62, 80)) + palette.setColor(QPalette.Button, QColor(66, 133, 244)) palette.setColor(QPalette.ButtonText, QColor(255, 255, 255)) palette.setColor(QPalette.BrightText, QColor(255, 255, 255)) - palette.setColor(QPalette.Highlight, QColor(52, 152, 219)) + palette.setColor(QPalette.Highlight, QColor(66, 133, 244)) palette.setColor(QPalette.HighlightedText, QColor(255, 255, 255)) QApplication.setPalette(palette) - def on_platform_changed(self, index): - """平台选择改变时的处理 - 新增方法""" - platform = self.platform_combo.currentText() - self.current_platform = platform - self.add_log(f"已选择平台: {platform}", "INFO") - print(f"🔥 平台已更改为: {platform}") - - def add_log(self, message, log_type="INFO"): - """添加日志信息 - 只输出到终端""" - from datetime import datetime - timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - - # 根据日志类型设置颜色(ANSI颜色码) - colors = { - "ERROR": "\033[91m", # 红色 - "SUCCESS": "\033[92m", # 绿色 - "WARNING": "\033[93m", # 黄色 - "INFO": "\033[94m", # 蓝色 - "DEBUG": "\033[95m" # 紫色 - } - reset = "\033[0m" # 重置颜色 - - color = colors.get(log_type, colors["INFO"]) - log_entry = f"{color}[{timestamp}] [{log_type}] {message}{reset}" - print(log_entry) - - - def login(self): - """处理连接逻辑""" - # 防止重复快速点击 - import time - current_time = time.time() - if current_time - self.last_login_time < self.login_cooldown: - self.add_log(f"请等待 {self.login_cooldown} 秒后再试", "WARNING") - return - - self.last_login_time = current_time - - token = self.token_input.text().strip() - if not token: - self.add_log("请输入有效的连接令牌", "ERROR") - return - - # 禁用连接按钮,防止重复点击 - self.login_btn.setEnabled(False) - self.login_btn.setText("连接中...") - + def add_visual_effects(self): + """添加视觉效果""" try: - # 使用 WebSocket 管理器处理连接 - ws_manager = get_websocket_manager() - - # 设置回调函数 - ws_manager.set_callbacks( - log=self.add_log, - success=lambda: self.add_log("WebSocket连接管理器连接成功", "SUCCESS"), - error=lambda error: self.add_log(f"WebSocket连接管理器错误: {error}", "ERROR") - ) - - # 连接后端 - success = ws_manager.connect_backend(token) - - if success: - self.add_log("已启动WebSocket连接管理器", "SUCCESS") - else: - self.add_log("WebSocket连接管理器启动失败", "ERROR") - + # 为主窗口添加阴影效果 + shadow = QGraphicsDropShadowEffect() + shadow.setBlurRadius(20) + shadow.setXOffset(0) + shadow.setYOffset(5) + shadow.setColor(QColor(0, 0, 0, 40)) + + # 为输入框添加阴影 + input_shadow = QGraphicsDropShadowEffect() + input_shadow.setBlurRadius(8) + input_shadow.setXOffset(0) + input_shadow.setYOffset(2) + input_shadow.setColor(QColor(0, 0, 0, 20)) + self.token_input.setGraphicsEffect(input_shadow) + + # 为按钮添加阴影 + button_shadow = QGraphicsDropShadowEffect() + button_shadow.setBlurRadius(12) + button_shadow.setXOffset(0) + button_shadow.setYOffset(4) + button_shadow.setColor(QColor(66, 133, 244, 80)) + self.login_btn.setGraphicsEffect(button_shadow) + + # 设置对象名称以应用特定样式 + self.token_input.setObjectName("tokenInput") + self.login_btn.setObjectName("loginButton") + + print("[INFO] 视觉效果已添加") + except Exception as e: - self.add_log(f"连接失败: {e}", "ERROR") - finally: - self.login_btn.setEnabled(True) - self.login_btn.setText("开始连接") + print(f"[WARNING] 添加视觉效果失败: {e}") - def verify_token(self, token): - """简化的令牌校验:非空即通过(实际校验由后端承担)""" - return bool(token) + def animate_success(self): + """连接成功的动画效果""" + try: + # 按钮轻微放大动画 + self.button_animation = QPropertyAnimation(self.login_btn, b"geometry") + original_rect = self.login_btn.geometry() - def closeEvent(self, event): - """窗口关闭事件处理""" + # 计算放大后的位置(居中放大) + expanded_rect = QRect( + original_rect.x() - 5, + original_rect.y() - 2, + original_rect.width() + 10, + original_rect.height() + 4 + ) + + self.button_animation.setDuration(200) + self.button_animation.setStartValue(original_rect) + self.button_animation.setEndValue(expanded_rect) + self.button_animation.setEasingCurve(QEasingCurve.OutBack) + + # 动画完成后恢复原始大小 + def restore_size(): + restore_animation = QPropertyAnimation(self.login_btn, b"geometry") + restore_animation.setDuration(150) + restore_animation.setStartValue(expanded_rect) + restore_animation.setEndValue(original_rect) + restore_animation.setEasingCurve(QEasingCurve.InBack) + restore_animation.start() + + self.button_animation.finished.connect(restore_size) + self.button_animation.start() + + print("[INFO] 成功动画已启动") + + except Exception as e: + print(f"[WARNING] 动画效果失败: {e}") + + def on_platform_connected(self, platform_name: str, all_platforms: list): + """处理平台连接成功事件""" + try: + import time + self.connected_platforms = all_platforms.copy() + self.last_platform_connect_time = time.time() + + # 短暂显示平台连接成功提示 + self.status_label.setText(f"🟢 {platform_name}平台连接成功!") + self.status_label.setStyleSheet( + "color: #28a745; background: rgba(40, 167, 69, 0.15); border-radius: 12px; padding: 5px 10px; font-weight: bold;") + + # 停止之前的定时器 + if self.status_timer: + self.status_timer.stop() + self.status_timer = None + + self.add_log(f"GUI收到平台连接通知: {platform_name}, 当前已连接: {', '.join(all_platforms)}", "INFO") + + # 使用线程定时器,更可靠(资源消耗极小) + import threading + + def delayed_update(): + import time + time.sleep(3) # 等待3秒 + # 使用QTimer.singleShot确保在主线程中执行GUI更新 + QTimer.singleShot(0, self.delayed_platform_summary) + + # 启动轻量级守护线程(3秒后自动销毁) + timer_thread = threading.Thread(target=delayed_update, daemon=True) + timer_thread.start() + + self.add_log("3秒线程定时器已启动,将自动切换到汇总显示", "INFO") + + except Exception as e: + self.add_log(f"处理平台连接事件失败: {e}", "ERROR") + + def delayed_platform_summary(self): + """定时器触发的汇总显示更新""" + try: + self.add_log("🎯 定时器触发!开始执行汇总显示更新", "INFO") + self.update_platform_summary() + self.add_log("🎯 定时器执行完成", "INFO") + except Exception as e: + self.add_log(f"定时器执行失败: {e}", "ERROR") + + def check_and_update_summary(self): + """检查是否应该更新汇总显示""" + try: + import time + elapsed = time.time() - self.last_platform_connect_time + self.add_log(f"检查汇总更新: 距离最后连接 {elapsed:.1f} 秒", "INFO") + + # 检查是否在最近1秒内还有新的平台连接 + if elapsed >= 2.8: # 距离最后连接超过2.8秒 + self.add_log("满足条件,开始更新汇总显示", "INFO") + self.update_platform_summary() + else: + # 如果还有新连接,再延迟一会儿 + self.add_log(f"还需等待 {2.8 - elapsed:.1f} 秒,延迟更新", "INFO") + QTimer.singleShot(1000, self.check_and_update_summary) + except Exception as e: + self.add_log(f"检查汇总更新失败: {e}", "ERROR") + + def update_platform_summary(self): + """更新平台连接汇总显示""" + try: + self.add_log(f"开始更新汇总显示,当前连接平台: {self.connected_platforms}", "INFO") + + if self.connected_platforms: + platforms_text = "、".join(self.connected_platforms) + summary_text = f"🟢 已连接: {platforms_text}" + self.status_label.setText(summary_text) + self.status_label.setStyleSheet( + "color: #28a745; background: rgba(40, 167, 69, 0.1); border-radius: 12px; padding: 5px 10px;") + self.add_log(f"汇总显示已更新: {summary_text}", "INFO") + else: + self.status_label.setText("🟢 连接成功!等待平台指令...") + self.status_label.setStyleSheet( + "color: #28a745; background: rgba(40, 167, 69, 0.1); border-radius: 12px; padding: 5px 10px;") + self.add_log("已更新为等待平台指令状态", "INFO") + except Exception as e: + self.add_log(f"更新平台汇总显示失败: {e}", "ERROR") + + def init_system_tray(self): + """初始化系统托盘""" + # 检查系统是否支持托盘 + if not QSystemTrayIcon.isSystemTrayAvailable(): + QMessageBox.critical(None, "系统托盘", + "系统托盘不可用") + return + + # 创建托盘图标 + self.tray_icon = QSystemTrayIcon(self) + + # 使用自定义的美观图标 + try: + tray_icon_path = os.path.join(os.path.dirname(__file__), "static", "ai_assistant_icon_16.png") + if os.path.exists(tray_icon_path): + from PyQt5.QtGui import QIcon + self.tray_icon.setIcon(QIcon(tray_icon_path)) + print(f"[INFO] 已加载托盘图标: {tray_icon_path}") + else: + print(f"[WARNING] 托盘图标文件不存在: {tray_icon_path}") + # 备用方案:使用系统图标 + self.tray_icon.setIcon(self.style().standardIcon(self.style().SP_ComputerIcon)) + except Exception as e: + print(f"[WARNING] 加载托盘图标失败: {e}, 使用默认图标") + # 备用方案:使用系统图标 + self.tray_icon.setIcon(self.style().standardIcon(self.style().SP_ComputerIcon)) + + # 创建托盘菜单 + tray_menu = QMenu() + + # 显示窗口动作 + show_action = QAction("显示主窗口", self) + show_action.triggered.connect(self.show_window) + tray_menu.addAction(show_action) + + # 分隔线 + tray_menu.addSeparator() + + # 退出动作 + quit_action = QAction("退出程序", self) + quit_action.triggered.connect(self.quit_application) + tray_menu.addAction(quit_action) + + # 设置托盘菜单 + self.tray_icon.setContextMenu(tray_menu) + + # 设置托盘提示 + self.tray_icon.setToolTip("AI客服智能助手") + + # 双击托盘图标显示窗口 + self.tray_icon.activated.connect(self.tray_icon_activated) + + # 显示托盘图标 + self.tray_icon.show() + + print("[INFO] 系统托盘已初始化") + + def tray_icon_activated(self, reason): + """托盘图标被激活时的处理""" + if reason == QSystemTrayIcon.DoubleClick: + self.show_window() + + def show_window(self): + """显示主窗口""" + self.show() + self.raise_() + self.activateWindow() + print("[INFO] 主窗口已显示") + + def quit_application(self): + """真正退出应用程序""" + print("[INFO] 正在退出应用程序...") + + # 执行原来的关闭逻辑 try: # 使用 WebSocket 管理器断开所有连接 from WebSocket.backend_singleton import get_websocket_manager ws_manager = get_websocket_manager() ws_manager.disconnect_all() - + # 停止所有工作线程(向后兼容) workers = [] if hasattr(self, 'jd_worker') and self.jd_worker: @@ -358,8 +553,124 @@ class LoginWindow(QMainWindow): worker.loop.close() except Exception as e: - print(f"关闭窗口时发生错误: {str(e)}") + print(f"关闭时发生错误: {str(e)}") finally: + # 隐藏托盘图标 + if hasattr(self, 'tray_icon'): + self.tray_icon.hide() + + # 退出应用程序 + QApplication.quit() + + def add_log(self, message, log_type="INFO"): + """添加日志信息 - 只输出到终端""" + from datetime import datetime + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + # 根据日志类型设置颜色(ANSI颜色码) + colors = { + "ERROR": "\033[91m", # 红色 + "SUCCESS": "\033[92m", # 绿色 + "WARNING": "\033[93m", # 黄色 + "INFO": "\033[94m", # 蓝色 + "DEBUG": "\033[95m" # 紫色 + } + reset = "\033[0m" # 重置颜色 + + color = colors.get(log_type, colors["INFO"]) + log_entry = f"{color}[{timestamp}] [{log_type}] {message}{reset}" + print(log_entry) + + def login(self): + """处理连接逻辑""" + # 防止重复快速点击 + import time + current_time = time.time() + if current_time - self.last_login_time < self.login_cooldown: + self.add_log(f"请等待 {self.login_cooldown} 秒后再试", "WARNING") + return + + self.last_login_time = current_time + + token = self.token_input.text().strip() + if not token: + self.add_log("请输入有效的连接令牌", "ERROR") + return + + # 禁用连接按钮,防止重复点击 + self.login_btn.setEnabled(False) + self.login_btn.setText("连接中...") + self.status_label.setText("正在连接服务...") + self.status_label.setStyleSheet("color: #ffc107;") + + success = False + try: + # 使用 WebSocket 管理器处理连接 + ws_manager = get_websocket_manager() + + # 设置回调函数 + ws_manager.set_callbacks( + log=self.add_log, + success=lambda: self.add_log("WebSocket连接管理器连接成功", "SUCCESS"), + error=lambda error: self.add_log(f"WebSocket连接管理器错误: {error}", "ERROR"), + platform_connected=self.on_platform_connected # 新增:平台连接回调 + ) + + # 连接后端 + success = ws_manager.connect_backend(token) + + if success: + self.add_log("已启动WebSocket连接管理器", "SUCCESS") + self.status_label.setText("🟢 连接成功!等待平台指令...") + self.status_label.setStyleSheet( + "color: #28a745; background: rgba(40, 167, 69, 0.1); border-radius: 12px; padding: 5px 10px;") + self.login_btn.setText("✓ 已连接") + self.login_btn.setObjectName("connected") + self.login_btn.setStyleSheet(self.login_btn.styleSheet()) # 刷新样式 + + # 添加成功动画效果 + self.animate_success() + else: + self.add_log("WebSocket连接管理器启动失败", "ERROR") + self.status_label.setText("🔴 连接失败,请检查令牌") + self.status_label.setStyleSheet( + "color: #dc3545; background: rgba(220, 53, 69, 0.1); border-radius: 12px; padding: 5px 10px;") + + except Exception as e: + self.add_log(f"连接失败: {e}", "ERROR") + self.status_label.setText(f"🔴 连接失败: {str(e)}") + self.status_label.setStyleSheet( + "color: #dc3545; background: rgba(220, 53, 69, 0.1); border-radius: 12px; padding: 5px 10px;") + finally: + self.login_btn.setEnabled(True) + if not success: + self.login_btn.setText("重新连接") + self.login_btn.setObjectName("loginButton") # 恢复原始样式 + + def verify_token(self, token): + """简化的令牌校验:非空即通过(实际校验由后端承担)""" + return bool(token) + + def closeEvent(self, event): + """窗口关闭事件处理 - 最小化到托盘""" + if hasattr(self, 'tray_icon') and self.tray_icon.isVisible(): + # 首次最小化时显示提示消息 + if not hasattr(self, '_tray_message_shown'): + self.tray_icon.showMessage( + "AI客服智能助手", + "程序已最小化到系统托盘。双击托盘图标可重新显示窗口。", + QSystemTrayIcon.Information, + 3000 + ) + self._tray_message_shown = True + + # 隐藏窗口而不是关闭 + self.hide() + event.ignore() + print("[INFO] 窗口已最小化到系统托盘") + else: + # 如果托盘不可用,则正常退出 + self.quit_application() event.accept() @@ -371,8 +682,36 @@ def main(): app.setApplicationName(config.WINDOW_TITLE) app.setApplicationVersion(config.VERSION) + # 设置应用程序唯一ID(重要:避免和Python默认图标混淆) + try: + import ctypes + # 设置应用程序用户模型ID,让Windows识别为独立应用 + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("ShuidropAI.CustomerService.1.0") + print("[INFO] 已设置应用程序唯一ID") + except Exception as e: + print(f"[WARNING] 设置应用程序ID失败: {e}") + # 创建主窗口 window = LoginWindow() + + # 统一设置所有图标(使用任务栏修复模块) + try: + app_icon = setup_windows_taskbar_icon(app, window) + print("[INFO] 所有图标设置完成") + except Exception as e: + print(f"[WARNING] 图标设置失败: {e}") + # 备用方案:使用简单的图标设置 + try: + icon_path = os.path.join(os.path.dirname(__file__), "static", "ai_assistant_icon_32.png") + if os.path.exists(icon_path): + from PyQt5.QtGui import QIcon + backup_icon = QIcon(icon_path) + app.setWindowIcon(backup_icon) + window.setWindowIcon(backup_icon) + print(f"[INFO] 已使用备用图标: {icon_path}") + except Exception as e2: + print(f"[ERROR] 备用图标也设置失败: {e2}") + window.show() # 程序启动断点 # 运行应用程序 @@ -380,7 +719,7 @@ def main(): if __name__ == '__main__': - main() # sd_aAoHIO9fDRIkePZEhW6oaFgK6IzAPxuB 测试令牌(token) + main() # sd_acF0TisgfFOtsBm4ytqb17MQbcxuX9Vp 测试令牌(token) # username = "KLD测试" # password = "kld168168" # taobao nickname = "tb420723827:redboat" diff --git a/quick_build.py b/quick_build.py new file mode 100644 index 0000000..f4ed9a2 --- /dev/null +++ b/quick_build.py @@ -0,0 +1,300 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +快速打包脚本 - 避免spec文件缩进问题 +""" + +import os +import subprocess +import shutil + + +def clean_build(): + """清理构建目录""" + print("🧹 清理旧的构建文件...") + + # 跳过进程结束步骤,避免影响当前脚本运行 + print("🔄 跳过进程结束步骤(避免脚本自终止)...") + + # 等待少量时间确保文件句柄释放 + import time + print("⏳ 等待文件句柄释放...") + time.sleep(0.5) + + dirs_to_clean = ['dist', 'build'] + for dir_name in dirs_to_clean: + print(f"🔍 检查目录: {dir_name}") + if os.path.exists(dir_name): + try: + print(f"🗑️ 正在删除: {dir_name}") + shutil.rmtree(dir_name) + print(f"✅ 已删除: {dir_name}") + except PermissionError as e: + print(f"⚠️ {dir_name} 被占用,尝试强制删除... 错误: {e}") + try: + subprocess.run(['rd', '/s', '/q', dir_name], + shell=True, check=True) + print(f"✅ 强制删除成功: {dir_name}") + except Exception as e2: + print(f"❌ 无法删除 {dir_name}: {e2}") + print("💡 请手动删除或运行 force_clean_dist.bat") + except Exception as e: + print(f"❌ 删除 {dir_name} 时出错: {e}") + else: + print(f"ℹ️ {dir_name} 不存在,跳过") + + print("✅ 清理阶段完成") + + +def build_with_command(): + """使用命令行参数直接打包""" + print("🚀 开始打包...") + + cmd = [ + 'pyinstaller', + '--name=main', + '--onedir', # 相当于 --exclude-binaries + '--windowed', # 相当于 -w + '--add-data=config.py;.', + '--add-data=exe_file_logger.py;.', + '--add-data=Utils/PythonNew32;Utils/PythonNew32', + '--add-data=Utils/JD;Utils/JD', + '--add-data=Utils/Dy;Utils/Dy', + '--add-data=Utils/Pdd;Utils/Pdd', + '--add-data=Utils/QianNiu;Utils/QianNiu', + '--add-data=Utils/message_models.py;Utils', + '--add-data=Utils/__init__.py;Utils', + '--add-data=WebSocket;WebSocket', + '--add-data=static;static', + '--add-binary=Utils/PythonNew32/SaiNiuApi.dll;Utils/PythonNew32', + '--add-binary=Utils/PythonNew32/SaiNiuSys.dll;Utils/PythonNew32', + '--add-binary=Utils/PythonNew32/SaiNiuServer.dll;Utils/PythonNew32', + '--add-binary=Utils/PythonNew32/python32.exe;Utils/PythonNew32', + '--add-binary=Utils/PythonNew32/python313.dll;Utils/PythonNew32', + '--add-binary=Utils/PythonNew32/vcruntime140.dll;Utils/PythonNew32', + '--hidden-import=PyQt5.QtCore', + '--hidden-import=PyQt5.QtGui', + '--hidden-import=PyQt5.QtWidgets', + '--hidden-import=websockets', + '--hidden-import=asyncio', + '--hidden-import=Utils.QianNiu.QianNiuUtils', + '--hidden-import=Utils.JD.JdUtils', + '--hidden-import=Utils.Dy.DyUtils', + '--hidden-import=Utils.Pdd.PddUtils', + '--hidden-import=WebSocket.backend_singleton', + '--hidden-import=WebSocket.BackendClient', + 'main.py' + ] + + try: + print(f"执行命令: {' '.join(cmd[:5])}... (共{len(cmd)}个参数)") + result = subprocess.run(cmd, capture_output=True, text=True, encoding='utf-8') + + if result.returncode == 0: + print("✅ 打包成功!") + print("📁 打包结果: dist/main/") + + # 重命名目录 + if os.path.exists('dist/main'): + try: + if os.path.exists('dist/MultiPlatformGUI'): + shutil.rmtree('dist/MultiPlatformGUI') + os.rename('dist/main', 'dist/MultiPlatformGUI') + print("✅ 已重命名为: dist/MultiPlatformGUI/") + except Exception as e: + print(f"⚠️ 重命名失败: {e}") + print("💡 可以手动重命名: dist/main -> dist/MultiPlatformGUI") + print("📁 当前可用路径: dist/main/") + + # 创建使用说明 + try: + create_usage_guide() + except: + create_usage_guide_fallback() + + return True + else: + print("❌ 打包失败") + if result.stderr: + print("错误信息:") + print(result.stderr) + return False + + except Exception as e: + print(f"❌ 打包过程出错: {e}") + return False + + +def create_usage_guide(): + """创建使用说明""" + guide_content = r"""# 多平台客服GUI使用说明 + +## 🚀 运行方式 +``` +cd dist/MultiPlatformGUI +.\main.exe +``` + +## 📝 支持平台 +- 千牛 (淘宝) +- 京东 (JD) +- 抖音 (DY) +- 拼多多 (PDD) + +## 🎯 特点 +- 支持多平台同时监听 +- 无平台间冲突 +- 自动生成日志文件 + +## 📁 文件结构 +``` +MultiPlatformGUI/ +├── main.exe # 主程序 +├── qianniu_exe_*.log # 运行日志 +└── _internal/ # 程序依赖文件 + ├── Utils/ + │ ├── PythonNew32/ # 千牛DLL文件 + │ ├── JD/ # 京东模块 + │ ├── Dy/ # 抖音模块 + │ └── Pdd/ # 拼多多模块 + └── ... +``` +""" + + try: + with open('dist/MultiPlatformGUI/使用说明.txt', 'w', encoding='utf-8') as f: + f.write(guide_content) + print("✅ 已生成使用说明文件") + except: + print("⚠️ 使用说明生成失败") + + +def create_usage_guide_fallback(): + """备用使用说明生成""" + guide_content = r"""# 多平台客服GUI使用说明 + +## 🚀 运行方式 +``` +cd dist/main +.\main.exe +``` + +## 📝 支持平台 +- 千牛 (淘宝) +- 京东 (JD) +- 抖音 (DY) +- 拼多多 (PDD) + +## 🎯 特点 +- 支持多平台同时监听 +- 无平台间冲突 +- 自动生成日志文件 +""" + + try: + with open('dist/main/使用说明.txt', 'w', encoding='utf-8') as f: + f.write(guide_content) + print("✅ 已生成使用说明文件 (备用路径)") + except: + print("⚠️ 使用说明生成失败") + + +def verify_result(): + """验证打包结果""" + print("\n🔍 验证打包结果...") + + # 先检查MultiPlatformGUI,再检查main目录 + base_dirs = ["dist/MultiPlatformGUI", "dist/main"] + + for base_dir in base_dirs: + if os.path.exists(base_dir): + exe_path = f"{base_dir}/main.exe" + dll_path = f"{base_dir}/_internal/Utils/PythonNew32/SaiNiuApi.dll" + + if os.path.exists(exe_path): + size = os.path.getsize(exe_path) + print(f"✅ 主程序: {exe_path} ({size:,} bytes)") + + if os.path.exists(dll_path): + dll_size = os.path.getsize(dll_path) + print(f"✅ 千牛DLL: SaiNiuApi.dll ({dll_size:,} bytes)") + print(f"📁 有效路径: {base_dir}/") + print("✅ 验证通过") + return True + else: + print("❌ 千牛DLL不存在") + else: + print(f"❌ {exe_path} 不存在") + + print("❌ 未找到有效的打包结果") + return False + + +def main(): + """主函数""" + print("🔥 多平台客服GUI快速打包工具") + print("=" * 60) + + try: + # 检查依赖 + print("🔍 检查打包依赖...") + try: + import subprocess + result = subprocess.run(['pyinstaller', '--version'], + capture_output=True, text=True, check=False) + if result.returncode == 0: + print(f"✅ PyInstaller 版本: {result.stdout.strip()}") + else: + print("❌ PyInstaller 未安装或不可用") + print("💡 请运行: pip install pyinstaller") + return False + except Exception as e: + print(f"❌ 检查PyInstaller时出错: {e}") + return False + + # 清理 + print("\n📍 开始清理阶段...") + clean_build() + print("📍 清理阶段完成") + + # 打包 + print("\n📍 开始打包阶段...") + if not build_with_command(): + print("❌ 打包阶段失败") + return False + print("📍 打包阶段完成") + + # 验证 + print("\n📍 开始验证阶段...") + if not verify_result(): + print("❌ 验证阶段失败") + return False + print("📍 验证阶段完成") + + print("\n" + "=" * 60) + print("🎉 打包完成!") + + # 智能显示可用路径 + if os.path.exists("dist/MultiPlatformGUI"): + print("📁 打包结果: dist/MultiPlatformGUI/") + print("🚀 运行方式: cd dist/MultiPlatformGUI && .\\main.exe") + elif os.path.exists("dist/main"): + print("📁 打包结果: dist/main/") + print("🚀 运行方式: cd dist/main && .\\main.exe") + print("💡 提示: 可手动重命名为 MultiPlatformGUI") + + print("=" * 60) + + return True + + except Exception as e: + print(f"❌ 打包失败: {e}") + return False + + +if __name__ == "__main__": + success = main() + if success: + print("\n✅ 可以开始测试了!") + else: + print("\n❌ 请检查错误信息并重试") \ No newline at end of file diff --git a/static/ai_assistant_icon_16.png b/static/ai_assistant_icon_16.png new file mode 100644 index 0000000..52cd14f Binary files /dev/null and b/static/ai_assistant_icon_16.png differ diff --git a/static/ai_assistant_icon_32.png b/static/ai_assistant_icon_32.png new file mode 100644 index 0000000..fea88d4 Binary files /dev/null and b/static/ai_assistant_icon_32.png differ diff --git a/static/ai_assistant_icon_64.png b/static/ai_assistant_icon_64.png new file mode 100644 index 0000000..970f4c6 Binary files /dev/null and b/static/ai_assistant_icon_64.png differ diff --git a/windows_taskbar_fix.py b/windows_taskbar_fix.py new file mode 100644 index 0000000..8200bb4 --- /dev/null +++ b/windows_taskbar_fix.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Windows任务栏图标修复模块 +解决PyQt5应用在任务栏显示Python图标的问题 +""" + +import os +import sys +from PyQt5.QtGui import QIcon, QPixmap +from PyQt5.QtCore import QSize + +def set_windows_app_id(app_id="ShuidropAI.CustomerService.1.0"): + """设置Windows应用程序用户模型ID""" + try: + import ctypes + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(app_id) + print(f"[INFO] Windows应用ID已设置: {app_id}") + return True + except Exception as e: + print(f"[WARNING] 设置Windows应用ID失败: {e}") + return False + +def create_multi_size_icon(base_path="static"): + """创建包含多种尺寸的QIcon对象""" + try: + icon = QIcon() + + # 我们有的图标文件 + available_files = [ + ("ai_assistant_icon_16.png", 16), + ("ai_assistant_icon_32.png", 32), + ("ai_assistant_icon_64.png", 64), + ("ai_assistant_icon_128.png", 128) + ] + + icon_loaded = False + for filename, size in available_files: + icon_path = os.path.join(base_path, filename) + if os.path.exists(icon_path): + icon.addFile(icon_path, QSize(size, size)) + print(f"[INFO] 添加图标 {size}x{size}: {filename}") + icon_loaded = True + + # 如果没有加载任何图标,尝试加载单个文件 + if not icon_loaded: + fallback_path = os.path.join(base_path, "ai_assistant_icon_32.png") + if os.path.exists(fallback_path): + icon = QIcon(fallback_path) + print(f"[INFO] 使用备用图标: {fallback_path}") + else: + print("[WARNING] 没有找到任何图标文件") + + return icon + + except Exception as e: + print(f"[ERROR] 创建多尺寸图标失败: {e}") + return QIcon() + +def setup_windows_taskbar_icon(app, window=None): + """完整设置Windows任务栏图标""" + print("[INFO] 开始设置Windows任务栏图标...") + + # 1. 设置应用程序唯一ID + set_windows_app_id() + + # 2. 创建多尺寸图标 + icon = create_multi_size_icon() + + # 3. 设置应用程序级别图标 + app.setWindowIcon(icon) + print("[INFO] 应用程序图标已设置") + + # 4. 如果提供了窗口,也设置窗口图标 + if window: + window.setWindowIcon(icon) + print("[INFO] 窗口图标已设置") + + # 5. 强制刷新应用程序图标缓存 + try: + if window and hasattr(window, 'winId'): + # 等待窗口完全初始化 + from PyQt5.QtCore import QTimer + def delayed_icon_refresh(): + try: + window.setWindowIcon(icon) + print("[INFO] 已延迟刷新窗口图标") + except Exception as e: + print(f"[WARNING] 延迟图标刷新失败: {e}") + + # 延迟100ms再次设置图标 + QTimer.singleShot(100, delayed_icon_refresh) + + except Exception as e: + print(f"[WARNING] 图标刷新失败: {e}") + + print("[INFO] Windows任务栏图标设置完成") + return icon + +if __name__ == "__main__": + # 测试模块 + from PyQt5.QtWidgets import QApplication, QMainWindow, QLabel + + app = QApplication(sys.argv) + window = QMainWindow() + window.setWindowTitle("任务栏图标测试") + window.setCentralWidget(QLabel("测试Windows任务栏图标")) + + # 设置任务栏图标 + setup_windows_taskbar_icon(app, window) + + window.show() + sys.exit(app.exec_()) \ No newline at end of file