diff --git a/.gitignore b/.gitignore index 528ee26..6faea12 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,8 @@ __pycache__/ .kiro/* -client_gui.log \ No newline at end of file +client_gui.log + +data/ + +downloads/ \ No newline at end of file diff --git a/client/app.py b/client/app.py index abf781b..610c5de 100644 --- a/client/app.py +++ b/client/app.py @@ -8,6 +8,7 @@ import asyncio import logging +import os import sys import time from typing import Optional, Callable, Dict, Any, List @@ -68,6 +69,10 @@ class P2PClientApp: # 设置文件传输的消息发送函数 self._file_transfer.set_send_message_func(self._send_message_async) + # 文件接收回调 + self._file_received_callbacks: List[Callable[[str, str, str], None]] = [] + self._file_transfer.add_file_received_callback(self._on_file_received) + # 回调函数 self._message_callbacks: List[Callable[[Message], None]] = [] self._state_callbacks: List[Callable[[ConnectionState, Optional[str]], None]] = [] @@ -536,6 +541,51 @@ class P2PClientApp: error = message.payload.decode('utf-8') logger.error(f"Error from server: {error}") + def _on_file_received(self, sender_id: str, file_name: str, file_path: str) -> None: + """ + 处理文件接收完成 + + Args: + sender_id: 发送者ID + file_name: 文件名 + file_path: 保存路径 + """ + logger.info(f"File received from {sender_id}: {file_name} -> {file_path}") + + # 判断是否是图片 + image_extensions = {'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'} + ext = os.path.splitext(file_name)[1].lower() + content_type = MessageType.IMAGE if ext in image_extensions else MessageType.FILE_REQUEST + + # 保存到聊天历史 + chat_msg = ChatMessage( + message_id=f"file_{time.time()}", + sender_id=sender_id, + receiver_id=self._user_info.user_id if self._user_info else "", + content_type=content_type, + content=file_path, # 保存文件路径 + timestamp=datetime.now(), + is_read=False, + is_sent=True + ) + self._add_to_history(sender_id, chat_msg) + + # 通知回调 + for callback in self._file_received_callbacks: + try: + callback(sender_id, file_name, file_path) + except Exception as e: + logger.error(f"File received callback error: {e}") + + def add_file_received_callback(self, callback: Callable[[str, str, str], None]) -> None: + """添加文件接收回调""" + self._file_received_callbacks.append(callback) + + def remove_file_received_callback(self, callback: Callable[[str, str, str], None]) -> None: + """移除文件接收回调""" + if callback in self._file_received_callbacks: + self._file_received_callbacks.remove(callback) + def _on_connection_state_changed(self, state: ConnectionState, reason: Optional[str]) -> None: """ 处理连接状态变化 diff --git a/client/file_transfer.py b/client/file_transfer.py index 3786320..ef8b56c 100644 --- a/client/file_transfer.py +++ b/client/file_transfer.py @@ -174,6 +174,9 @@ class FileTransferModule: # 进度回调 self._progress_callbacks: Dict[str, ProgressCallback] = {} + # 文件接收完成回调 + self._file_received_callbacks: List[Callable[[str, str, str], None]] = [] # (sender_id, file_name, file_path) + # 消息处理器 self._message_handler = MessageHandler() @@ -181,6 +184,10 @@ class FileTransferModule: self._state_dir = Path(self.config.data_dir) / self.STATE_DIR self._state_dir.mkdir(parents=True, exist_ok=True) + # 确保下载目录存在 + self._downloads_dir = Path(self.config.downloads_dir) + self._downloads_dir.mkdir(parents=True, exist_ok=True) + # 加载未完成的传输状态 self._load_transfer_states() @@ -195,6 +202,22 @@ class FileTransferModule: """ self._send_message = func + def add_file_received_callback(self, callback: Callable[[str, str, str], None]) -> None: + """ + 添加文件接收完成回调 + + Args: + callback: 回调函数 (sender_id, file_name, file_path) + """ + self._file_received_callbacks.append(callback) + + def remove_file_received_callback(self, callback: Callable[[str, str, str], None]) -> None: + """ + 移除文件接收完成回调 + """ + if callback in self._file_received_callbacks: + self._file_received_callbacks.remove(callback) + # ==================== 文件哈希计算 (需求 4.4) ==================== def calculate_file_hash(self, file_path: str, algorithm: str = "sha256") -> str: @@ -731,25 +754,39 @@ class FileTransferModule: state.status = TransferStatus.FAILED return False + # 如果没有设置保存路径,自动保存到 downloads 目录 + if not state.save_path: + state.save_path = str(self._downloads_dir / state.file_name) + # 如果文件已存在,添加序号 + base_path = state.save_path + counter = 1 + while os.path.exists(state.save_path): + name, ext = os.path.splitext(base_path) + state.save_path = f"{name}_{counter}{ext}" + counter += 1 + # 组装文件 - if state.save_path: - success = self._assemble_file(file_id, state.save_path) - - if success: - # 验证文件完整性 - if self.verify_file_integrity(state.save_path, file_hash): - state.status = TransferStatus.COMPLETED - logger.info(f"File received and verified: {state.file_name}") - else: - state.status = TransferStatus.FAILED - logger.error(f"File integrity check failed: {state.file_name}") - return False + success = self._assemble_file(file_id, state.save_path) + + if success: + # 验证文件完整性 + if self.verify_file_integrity(state.save_path, file_hash): + state.status = TransferStatus.COMPLETED + logger.info(f"File received and verified: {state.file_name} -> {state.save_path}") + + # 通知回调 + for callback in self._file_received_callbacks: + try: + callback(state.sender_id, state.file_name, state.save_path) + except Exception as e: + logger.error(f"File received callback error: {e}") else: state.status = TransferStatus.FAILED + logger.error(f"File integrity check failed: {state.file_name}") return False else: - # 保存路径未设置,保持数据在缓冲区 - state.status = TransferStatus.COMPLETED + state.status = TransferStatus.FAILED + return False # 清理缓冲区 if file_id in self._receive_buffers: diff --git a/client/ui/chat_widget.py b/client/ui/chat_widget.py index 4ddc96e..7c00a1a 100644 --- a/client/ui/chat_widget.py +++ b/client/ui/chat_widget.py @@ -37,10 +37,18 @@ class MessageBubble(QFrame): layout.setContentsMargins(10, 5, 10, 5) layout.setSpacing(2) - # 消息内容 - content_label = QLabel(self.message.content) - content_label.setWordWrap(True) - content_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + # 根据消息类型显示不同内容 + if self.message.content_type == MessageType.IMAGE: + # 图片消息 - 显示缩略图 + content_widget = self._create_image_widget() + elif self.message.content_type == MessageType.FILE_REQUEST: + # 文件消息 - 显示文件信息 + content_widget = self._create_file_widget() + else: + # 文本消息 + content_widget = QLabel(self.message.content) + content_widget.setWordWrap(True) + content_widget.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) # 时间戳 time_str = self.message.timestamp.strftime("%H:%M") @@ -68,7 +76,7 @@ class MessageBubble(QFrame): bottom_layout.addWidget(time_label) bottom_layout.addWidget(status_label) - layout.addWidget(content_label) + layout.addWidget(content_widget) layout.addLayout(bottom_layout) else: # 对方发送的消息,左对齐 @@ -82,8 +90,110 @@ class MessageBubble(QFrame): """) layout.setAlignment(Qt.AlignmentFlag.AlignLeft) - layout.addWidget(content_label) + layout.addWidget(content_widget) layout.addWidget(time_label) + + def _create_image_widget(self) -> QWidget: + """创建图片显示组件""" + import os + from PyQt6.QtGui import QPixmap + + container = QWidget() + layout = QVBoxLayout(container) + layout.setContentsMargins(0, 0, 0, 0) + + file_path = self.message.content + file_name = os.path.basename(file_path) + + # 尝试加载图片 + if os.path.exists(file_path): + pixmap = QPixmap(file_path) + if not pixmap.isNull(): + # 缩放图片 + scaled = pixmap.scaled(200, 200, Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation) + image_label = QLabel() + image_label.setPixmap(scaled) + image_label.setCursor(Qt.CursorShape.PointingHandCursor) + image_label.setToolTip(f"点击打开: {file_path}") + image_label.mousePressEvent = lambda e: self._open_file(file_path) + layout.addWidget(image_label) + else: + layout.addWidget(QLabel(f"[图片] {file_name}")) + else: + layout.addWidget(QLabel(f"[图片] {file_name}\n(文件不存在)")) + + return container + + def _create_file_widget(self) -> QWidget: + """创建文件显示组件""" + import os + + container = QWidget() + layout = QHBoxLayout(container) + layout.setContentsMargins(0, 0, 0, 0) + + file_path = self.message.content + file_name = os.path.basename(file_path) + + # 文件图标 + icon_label = QLabel("📄") + icon_label.setStyleSheet("font-size: 24px;") + layout.addWidget(icon_label) + + # 文件信息 + info_layout = QVBoxLayout() + name_label = QLabel(file_name) + name_label.setStyleSheet("font-weight: bold;") + info_layout.addWidget(name_label) + + if os.path.exists(file_path): + size = os.path.getsize(file_path) + size_str = self._format_size(size) + size_label = QLabel(size_str) + size_label.setStyleSheet("color: #666; font-size: 11px;") + info_layout.addWidget(size_label) + else: + info_layout.addWidget(QLabel("(文件不存在)")) + + layout.addLayout(info_layout) + + # 点击打开 + container.setCursor(Qt.CursorShape.PointingHandCursor) + container.setToolTip(f"点击打开: {file_path}") + container.mousePressEvent = lambda e: self._open_file(file_path) + + return container + + def _format_size(self, size: int) -> str: + """格式化文件大小""" + if size < 1024: + return f"{size} B" + elif size < 1024 * 1024: + return f"{size / 1024:.1f} KB" + elif size < 1024 * 1024 * 1024: + return f"{size / (1024 * 1024):.1f} MB" + else: + return f"{size / (1024 * 1024 * 1024):.1f} GB" + + def _open_file(self, file_path: str): + """打开文件""" + import os + import subprocess + import sys + + if not os.path.exists(file_path): + return + + try: + if sys.platform == 'win32': + os.startfile(file_path) + elif sys.platform == 'darwin': + subprocess.run(['open', file_path]) + else: + subprocess.run(['xdg-open', file_path]) + except Exception as e: + logger.error(f"Failed to open file: {e}") class ChatWidget(QWidget): diff --git a/config.py b/config.py index 8fc9472..6d0ba9e 100644 --- a/config.py +++ b/config.py @@ -60,6 +60,7 @@ class ClientConfig: # Local storage data_dir: str = "data" cache_dir: str = "cache" + downloads_dir: str = "downloads" # 接收文件保存目录 @dataclass diff --git a/run_client_gui.py b/run_client_gui.py index 11fc33e..a49672f 100644 --- a/run_client_gui.py +++ b/run_client_gui.py @@ -306,6 +306,9 @@ class P2PChatGUI(QObject): # 创建客户端 self.client = P2PClientApp(self.config) + # 添加文件接收回调 + self.client.add_file_received_callback(self._on_file_received) + # 创建工作线程 self.worker = AsyncWorker(self.client, self._current_user) self.worker.connected.connect(self._on_connected) @@ -528,6 +531,49 @@ class P2PChatGUI(QObject): self.client.reject_voice_call(caller_id) self.main_window._statusbar.showMessage("已拒绝来电", 3000) + def _on_file_received(self, sender_id: str, file_name: str, file_path: str): + """文件接收完成回调 - 使用信号在主线程中更新GUI""" + # 使用 QTimer 在主线程中执行 GUI 更新 + QTimer.singleShot(0, lambda: self._handle_file_received_ui(sender_id, file_name, file_path)) + + def _handle_file_received_ui(self, sender_id: str, file_name: str, file_path: str): + """在主线程中处理文件接收的UI更新""" + import os + + # 判断是否是图片 + image_extensions = {'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'} + ext = os.path.splitext(file_name)[1].lower() + is_image = ext in image_extensions + + # 显示通知 + if is_image: + self.main_window.show_notification("收到图片", f"来自 {sender_id}: {file_name}") + else: + self.main_window.show_notification("收到文件", f"来自 {sender_id}: {file_name}") + + self.main_window._statusbar.showMessage(f"收到文件: {file_name},已保存到 {file_path}", 5000) + + # 如果当前正在和发送者聊天,在聊天窗口显示 + if self._current_chat_peer == sender_id: + from datetime import datetime + from shared.models import ChatMessage + + content_type = MessageType.IMAGE if is_image else MessageType.FILE_REQUEST + + msg = ChatMessage( + message_id=f"file_{datetime.now().timestamp()}", + sender_id=sender_id, + receiver_id=self._current_user.user_id if self._current_user else "", + content_type=content_type, + content=file_path, + timestamp=datetime.now(), + is_sent=True, + is_read=False + ) + + if hasattr(self, '_chat_widget') and self._chat_widget: + self._chat_widget.add_message(msg, is_self=False) + def _on_contact_selected(self, user_id: str): """联系人选中回调 - 打开聊天窗口""" self._current_chat_peer = user_id