# P2P Network Communication - Chat Widget """ 聊天窗口组件 实现消息输入、发送、显示和历史加载 需求: 3.1, 3.2, 3.3, 3.4, 3.5, 9.2 """ import logging from datetime import datetime from typing import Optional, List from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QTextEdit, QLineEdit, QPushButton, QLabel, QScrollArea, QFrame, QSizePolicy ) from PyQt6.QtCore import Qt, pyqtSignal, QTimer from PyQt6.QtGui import QTextCursor, QKeyEvent from shared.models import ChatMessage, MessageType, UserInfo logger = logging.getLogger(__name__) class MessageBubble(QFrame): """消息气泡组件""" def __init__(self, message: ChatMessage, is_self: bool = False, parent=None): super().__init__(parent) self.message = message self.is_self = is_self self._setup_ui() def _setup_ui(self) -> None: """设置UI""" layout = QVBoxLayout(self) layout.setContentsMargins(10, 5, 10, 5) layout.setSpacing(2) # 根据消息类型显示不同内容 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") time_label = QLabel(time_str) time_label.setStyleSheet("color: #999; font-size: 10px;") # 状态指示 status_text = "✓✓" if self.message.is_read else ("✓" if self.message.is_sent else "⏳") status_label = QLabel(status_text) status_label.setStyleSheet("color: #999; font-size: 10px;") if self.is_self: # 自己发送的消息,右对齐 self.setStyleSheet(""" QFrame { background-color: #dcf8c6; border-radius: 10px; margin-left: 50px; } """) layout.setAlignment(Qt.AlignmentFlag.AlignRight) bottom_layout = QHBoxLayout() bottom_layout.addStretch() bottom_layout.addWidget(time_label) bottom_layout.addWidget(status_label) layout.addWidget(content_widget) layout.addLayout(bottom_layout) else: # 对方发送的消息,左对齐 self.setStyleSheet(""" QFrame { background-color: white; border-radius: 10px; margin-right: 50px; border: 1px solid #eee; } """) layout.setAlignment(Qt.AlignmentFlag.AlignLeft) 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): """ 聊天窗口组件 实现消息输入和发送 (需求 3.1) 实现消息显示和历史加载 (需求 3.2, 3.5, 9.2) 实现消息状态显示 (需求 3.3, 3.4) """ # 信号定义 message_sent = pyqtSignal(str, str) # peer_id, content file_send_requested = pyqtSignal(str) # peer_id image_send_requested = pyqtSignal(str) # peer_id voice_call_requested = pyqtSignal(str) # peer_id voice_call_end_requested = pyqtSignal() # 挂断通话 def __init__(self, parent=None): super().__init__(parent) self._current_peer_id: Optional[str] = None self._current_peer_info: Optional[UserInfo] = None self._messages: List[ChatMessage] = [] self._in_call: bool = False # 是否在通话中 self._setup_ui() self._connect_signals() logger.info("ChatWidget initialized") def _setup_ui(self) -> None: """设置UI""" layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) # 聊天对象信息栏 self._header = self._create_header() layout.addWidget(self._header) # 消息显示区域 self._message_area = self._create_message_area() layout.addWidget(self._message_area, 1) # 输入区域 self._input_area = self._create_input_area() layout.addWidget(self._input_area) def _create_header(self) -> QWidget: """创建头部信息栏""" header = QFrame() header.setFixedHeight(50) header.setStyleSheet(""" QFrame { background-color: #f5f5f5; border-bottom: 1px solid #ddd; } """) layout = QHBoxLayout(header) layout.setContentsMargins(15, 0, 15, 0) self._peer_name_label = QLabel("选择联系人开始聊天") self._peer_name_label.setStyleSheet("font-size: 16px; font-weight: bold;") layout.addWidget(self._peer_name_label) layout.addStretch() self._peer_status_label = QLabel("") self._peer_status_label.setStyleSheet("color: #666;") layout.addWidget(self._peer_status_label) return header def _create_message_area(self) -> QWidget: """创建消息显示区域""" scroll_area = QScrollArea() scroll_area.setWidgetResizable(True) scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) scroll_area.setStyleSheet(""" QScrollArea { border: none; background-color: #e5ddd5; } """) self._message_container = QWidget() self._message_layout = QVBoxLayout(self._message_container) self._message_layout.setContentsMargins(10, 10, 10, 10) self._message_layout.setSpacing(10) self._message_layout.addStretch() scroll_area.setWidget(self._message_container) self._scroll_area = scroll_area return scroll_area def _create_input_area(self) -> QWidget: """创建输入区域""" input_frame = QFrame() input_frame.setStyleSheet(""" QFrame { background-color: #f0f0f0; border-top: 1px solid #ddd; } """) layout = QVBoxLayout(input_frame) layout.setContentsMargins(10, 10, 10, 10) layout.setSpacing(10) # 功能按钮行 button_layout = QHBoxLayout() button_layout.setSpacing(5) self._file_btn = QPushButton("📎") self._file_btn.setFixedSize(30, 30) self._file_btn.setToolTip("发送文件") self._file_btn.clicked.connect(self._on_file_btn_clicked) button_layout.addWidget(self._file_btn) self._image_btn = QPushButton("🖼️") self._image_btn.setFixedSize(30, 30) self._image_btn.setToolTip("发送图片") self._image_btn.clicked.connect(self._on_image_btn_clicked) button_layout.addWidget(self._image_btn) self._voice_btn = QPushButton("📞") self._voice_btn.setFixedSize(30, 30) self._voice_btn.setToolTip("语音通话") self._voice_btn.clicked.connect(self._on_voice_btn_clicked) button_layout.addWidget(self._voice_btn) # 挂断按钮(默认隐藏) self._hangup_btn = QPushButton("📵") self._hangup_btn.setFixedSize(30, 30) self._hangup_btn.setToolTip("挂断通话") self._hangup_btn.setStyleSheet(""" QPushButton { background-color: #ff4444; border-radius: 15px; color: white; } QPushButton:hover { background-color: #ff0000; } """) self._hangup_btn.clicked.connect(self._on_hangup_btn_clicked) self._hangup_btn.hide() # 默认隐藏 button_layout.addWidget(self._hangup_btn) # 通话状态标签(默认隐藏) self._call_status_label = QLabel("") self._call_status_label.setStyleSheet("color: #4a90d9; font-weight: bold;") self._call_status_label.hide() button_layout.addWidget(self._call_status_label) button_layout.addStretch() layout.addLayout(button_layout) # 输入行 input_layout = QHBoxLayout() input_layout.setSpacing(10) self._input_edit = QLineEdit() self._input_edit.setPlaceholderText("输入消息...") self._input_edit.setMinimumHeight(40) self._input_edit.setStyleSheet(""" QLineEdit { border: 1px solid #ddd; border-radius: 20px; padding: 0 15px; background-color: white; } """) self._input_edit.returnPressed.connect(self._on_send_clicked) input_layout.addWidget(self._input_edit, 1) self._send_btn = QPushButton("发送") self._send_btn.setMinimumHeight(40) self._send_btn.setMinimumWidth(80) self._send_btn.setStyleSheet(""" QPushButton { background-color: #4a90d9; color: white; border: none; border-radius: 20px; font-size: 14px; } QPushButton:hover { background-color: #3a80c9; } QPushButton:pressed { background-color: #2a70b9; } QPushButton:disabled { background-color: #ccc; } """) self._send_btn.clicked.connect(self._on_send_clicked) input_layout.addWidget(self._send_btn) layout.addLayout(input_layout) return input_frame def _connect_signals(self) -> None: """连接信号""" pass def _on_send_clicked(self) -> None: """处理发送按钮点击""" if not self._current_peer_id: return content = self._input_edit.text().strip() if not content: return self.message_sent.emit(self._current_peer_id, content) self._input_edit.clear() def _on_file_btn_clicked(self) -> None: """处理文件按钮点击""" if self._current_peer_id: self.file_send_requested.emit(self._current_peer_id) def _on_image_btn_clicked(self) -> None: """处理图片按钮点击""" if self._current_peer_id: self.image_send_requested.emit(self._current_peer_id) def _on_voice_btn_clicked(self) -> None: """处理语音按钮点击""" if self._current_peer_id: self.voice_call_requested.emit(self._current_peer_id) def _on_hangup_btn_clicked(self) -> None: """处理挂断按钮点击""" self.voice_call_end_requested.emit() self.set_call_state(False) def _scroll_to_bottom(self) -> None: """滚动到底部""" QTimer.singleShot(100, lambda: self._scroll_area.verticalScrollBar().setValue( self._scroll_area.verticalScrollBar().maximum() )) def _clear_messages(self) -> None: """清空消息显示""" while self._message_layout.count() > 1: item = self._message_layout.takeAt(0) if item.widget(): item.widget().deleteLater() # ==================== 公共方法 ==================== def set_peer(self, peer_id: str, peer_info: Optional[UserInfo] = None) -> None: """ 设置当前聊天对象 实现用户切换聊天对象 (需求 9.2) WHEN 用户切换聊天对象 THEN P2P_Client SHALL 加载并显示与该用户的聊天历史 Args: peer_id: 对等端ID peer_info: 对等端用户信息 """ self._current_peer_id = peer_id self._current_peer_info = peer_info if peer_info: self._peer_name_label.setText(peer_info.display_name or peer_info.username) self._peer_status_label.setText(peer_info.status.value) else: self._peer_name_label.setText(peer_id) self._peer_status_label.setText("") # 清空并加载历史消息 self._clear_messages() self._messages.clear() # 启用输入 self._input_edit.setEnabled(True) self._send_btn.setEnabled(True) self._input_edit.setFocus() logger.info(f"Chat peer set: {peer_id}") def add_message(self, message: ChatMessage, is_self: bool = False) -> None: """ 添加消息到显示区域 实现消息显示 (需求 3.2) WHEN P2P_Client 收到文本消息 THEN P2P_Client SHALL 立即显示消息内容和发送者信息 Args: message: 聊天消息 is_self: 是否是自己发送的消息 """ self._messages.append(message) bubble = MessageBubble(message, is_self) # 在stretch之前插入 self._message_layout.insertWidget(self._message_layout.count() - 1, bubble) self._scroll_to_bottom() def load_history(self, messages: List[ChatMessage], current_user_id: str) -> None: """ 加载聊天历史 实现消息历史加载 (需求 3.5) WHEN 显示消息历史 THEN P2P_Client SHALL 按时间顺序展示所有消息记录 Args: messages: 消息列表(应按时间排序) current_user_id: 当前用户ID """ self._clear_messages() self._messages = messages.copy() for msg in messages: is_self = msg.sender_id == current_user_id bubble = MessageBubble(msg, is_self) self._message_layout.insertWidget(self._message_layout.count() - 1, bubble) self._scroll_to_bottom() logger.info(f"Loaded {len(messages)} messages") def update_message_status(self, message_id: str, is_sent: bool = False, is_read: bool = False) -> None: """ 更新消息状态 实现消息状态显示 (需求 3.3, 3.4) WHEN 消息发送成功 THEN P2P_Client SHALL 显示发送成功的状态标识 IF 消息发送失败 THEN P2P_Client SHALL 显示错误提示并提供重试选项 Args: message_id: 消息ID is_sent: 是否已发送 is_read: 是否已读 """ for msg in self._messages: if msg.message_id == message_id: msg.is_sent = is_sent msg.is_read = is_read break # 刷新显示(简化实现,实际应只更新对应的bubble) if self._current_peer_id and self._messages: current_user_id = self._messages[0].sender_id if self._messages else "" self.load_history(self._messages, current_user_id) def clear(self) -> None: """清空聊天窗口""" self._current_peer_id = None self._current_peer_info = None self._clear_messages() self._messages.clear() self._peer_name_label.setText("选择联系人开始聊天") self._peer_status_label.setText("") self._input_edit.setEnabled(False) self._send_btn.setEnabled(False) @property def current_peer_id(self) -> Optional[str]: """获取当前聊天对象ID""" return self._current_peer_id def set_call_state(self, in_call: bool, status_text: str = "") -> None: """ 设置通话状态 Args: in_call: 是否在通话中 status_text: 状态文本(如"通话中..."、"正在呼叫...") """ self._in_call = in_call if in_call: self._voice_btn.hide() self._hangup_btn.show() self._call_status_label.setText(status_text or "通话中...") self._call_status_label.show() else: self._voice_btn.show() self._hangup_btn.hide() self._call_status_label.hide() self._call_status_label.setText("") @property def is_in_call(self) -> bool: """是否在通话中""" return self._in_call