""" 主窗口 —— 包含 ChatBubble、ChatPanel、MainWindow 优化内容: - 气泡设计:圆角阴影、头像、消息状态、连续消息合并 - 聊天面板:字数统计、加载更多历史、日期分隔条、输入限制500字 - 主窗口:通知浮层、未读角标、好友在线优先排序、过渡动画、设置整合 """ from PyQt5.QtWidgets import ( QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QListWidget, QListWidgetItem, QTextEdit, QLineEdit, QPushButton, QTabWidget, QMessageBox, QAction, QInputDialog, QScrollArea, QShortcut, QMenu, QSizePolicy, QFrame, QDialog, QDialogButtonBox, QCheckBox ) from PyQt5.QtCore import Qt, pyqtSignal, QTimer, QPropertyAnimation, QEasingCurve from PyQt5.QtGui import QFont, QIcon, QColor, QKeySequence, QPalette, QPixmap from PyQt5.QtWidgets import QFileDialog as _QFileDialog import os import html import base64 from datetime import datetime, timezone, timedelta import sys as _sys _cur_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) if _cur_dir not in _sys.path: _sys.path.insert(0, _cur_dir) from utils import beijing_now_str # ───────────────────────────────────────────────────────── # 消息气泡(优化版) # ───────────────────────────────────────────────────────── class ChatBubble(QFrame): def __init__(self, sender, content, timestamp, is_me=False, show_avatar=True, msg_type='text', parent=None): super().__init__(parent) self.setObjectName("chatBubble") layout = QHBoxLayout() layout.setContentsMargins(10, 3, 10, 3) layout.setSpacing(8) # 头像(圆形文字头像) if show_avatar and not is_me: avatar = QLabel(sender[0].upper() if sender else "?") avatar.setFixedSize(36, 36) avatar.setAlignment(Qt.AlignCenter) avatar.setStyleSheet(""" QLabel{background:%s;border-radius:18px;color:white; font-size:15px;font-weight:bold;} """ % self._avatar_color(sender)) layout.addWidget(avatar, alignment=Qt.AlignTop) # 气泡主体 bubble_wrap = QVBoxLayout() bubble_wrap.setSpacing(2) # 发送者名 + 时间 info_row = QHBoxLayout() info_row.setSpacing(8) name_lbl = QLabel("我" if is_me else sender) name_lbl.setStyleSheet("color:#64748b; font-weight:bold; font-size:11px;") time_lbl = QLabel(self._smart_time(timestamp)) time_lbl.setStyleSheet("color:#94a3b8; font-size:10px;") if is_me: info_row.addStretch() info_row.addWidget(time_lbl) info_row.addWidget(name_lbl) else: info_row.addWidget(name_lbl) info_row.addWidget(time_lbl) info_row.addStretch() bubble_wrap.addLayout(info_row) # 消息内容卡片 content_row = QHBoxLayout() if msg_type == 'image': # 尝试加载图片 pixmap = None img_path = content if not os.path.exists(img_path): # 尝试本地缓存 local_cache = os.path.join('messages', 'images', os.path.basename(content)) if os.path.exists(local_cache): img_path = local_cache else: img_path = None if img_path: pixmap = QPixmap(img_path) if pixmap and not pixmap.isNull(): if pixmap.width() > 280: pixmap = pixmap.scaledToWidth(280, Qt.SmoothTransformation) img_lbl = QLabel() img_lbl.setPixmap(pixmap) img_lbl.setMaximumWidth(300) img_lbl.setStyleSheet(""" QLabel { border:1px solid #e2e8f0; border-radius:10px; padding:4px; background:white; } """) img_lbl.setCursor(Qt.PointingHandCursor) img_lbl.mousePressEvent = lambda e, p=img_path: self._show_full_image(p) content_widget = img_lbl else: content_widget = QLabel(f"[图片] {os.path.basename(content)}") content_widget.setStyleSheet(""" QLabel { background:#f1f5f9; border:1px dashed #cbd5e1; border-radius:10px; padding:16px 20px; font-size:13px; color:#94a3b8; } """) else: content_widget = QLabel(html.escape(content)) content_widget.setWordWrap(True) content_widget.setMaximumWidth(440) content_widget.setTextInteractionFlags(Qt.TextSelectableByMouse) content_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) if is_me: bubble_color = "#eef2ff" border_color = "#c7d2fe" else: bubble_color = "#ffffff" border_color = "#e2e8f0" content_widget.setStyleSheet(f""" QLabel {{ background:{bubble_color}; border:1px solid {border_color}; border-radius:10px; padding:10px 14px; font-size:13px; color:#1e293b; line-height:1.5; }} """) content_widget.setContentsMargins(0, 0, 0, 0) if is_me: content_row.addStretch() content_row.addWidget(content_widget) else: content_row.addWidget(content_widget) content_row.addStretch() bubble_wrap.addLayout(content_row) if is_me: layout.addStretch() layout.addLayout(bubble_wrap) else: layout.addLayout(bubble_wrap) layout.addStretch() self.setLayout(layout) def _show_full_image(self, path): """点击图片查看大图""" dlg = QDialog(self) dlg.setWindowTitle("查看图片") dlg.setStyleSheet("QDialog{background:#1e1e2e;}") dlg.setMinimumSize(400, 300) layout = QVBoxLayout(dlg) layout.setContentsMargins(0, 0, 0, 0) pixmap = QPixmap(path) if pixmap.isNull(): dlg.deleteLater() return screen = dlg.screen().availableGeometry() max_w = int(screen.width() * 0.8) max_h = int(screen.height() * 0.8) if pixmap.width() > max_w or pixmap.height() > max_h: pixmap = pixmap.scaled(max_w, max_h, Qt.KeepAspectRatio, Qt.SmoothTransformation) lbl = QLabel() lbl.setPixmap(pixmap) lbl.setAlignment(Qt.AlignCenter) lbl.setStyleSheet("background:#1e1e2e;") layout.addWidget(lbl) dlg.resize(pixmap.width() + 20, pixmap.height() + 20) dlg.exec_() def _smart_time(self, timestamp): if not timestamp: return "" try: if ' ' in timestamp: dt = datetime.strptime(timestamp, '%Y-%m-%d %H:%M:%S') now = datetime.now() if dt.date() == now.date(): return dt.strftime('%H:%M') elif (now.date() - dt.date()).days == 1: return f"昨天 {dt.strftime('%H:%M')}" elif dt.year == now.year: return dt.strftime('%m-%d %H:%M') else: return dt.strftime('%Y-%m-%d %H:%M') return timestamp except Exception: return timestamp def _avatar_color(self, name): colors = ["#6366f1", "#8b5cf6", "#ec4899", "#f59e0b", "#10b981", "#3b82f6", "#ef4444", "#06b6d4", "#f97316", "#84cc16"] idx = sum(ord(c) for c in name) % len(colors) return colors[idx] # ───────────────────────────────────────────────────────── # 日期分隔线 # ───────────────────────────────────────────────────────── class DateSeparator(QWidget): def __init__(self, text, parent=None): super().__init__(parent) layout = QHBoxLayout() layout.setContentsMargins(20, 8, 20, 8) line1 = QFrame() line1.setFrameShape(QFrame.HLine) line1.setStyleSheet("color:#e2e8f0;") line2 = QFrame() line2.setFrameShape(QFrame.HLine) line2.setStyleSheet("color:#e2e8f0;") lbl = QLabel(text) lbl.setStyleSheet("color:#94a3b8;font-size:11px;padding:0 12px;") lbl.setAlignment(Qt.AlignCenter) layout.addWidget(line1) layout.addWidget(lbl) layout.addWidget(line2) self.setLayout(layout) # ───────────────────────────────────────────────────────── # 聊天面板(优化版) # ───────────────────────────────────────────────────────── class ChatPanel(QWidget): message_sent = pyqtSignal(str, str, object) image_sent = pyqtSignal(str, str, object, str) history_requested = pyqtSignal(str, object) load_more_requested = pyqtSignal(str, object, int) def __init__(self, my_username, chat_type, target_id, target_name): super().__init__() self.my_username = my_username self.chat_type = chat_type self.target_id = target_id self.target_name = target_name self.history_loaded = False self.history_count = 0 self._build_ui() QTimer.singleShot(300, self._auto_load_history) def _build_ui(self): root = QVBoxLayout() root.setContentsMargins(0, 0, 0, 0) root.setSpacing(0) # ── 标题栏 ── header = QWidget() header.setFixedHeight(52) header.setStyleSheet("background:#ffffff; border-bottom:1px solid #e2e8f0;") hl = QHBoxLayout(header) hl.setContentsMargins(16, 0, 12, 0) icon = "💬" if self.chat_type == 'private' else "👥" title = QLabel(f"{icon} {self.target_name}") title.setStyleSheet("font-size:15px; font-weight:bold; color:#1e293b;") hl.addWidget(title) hl.addStretch() # 加载更多按钮 self.load_more_btn = QPushButton("📜 加载更多") self.load_more_btn.setStyleSheet(""" QPushButton{padding:5px 12px;border:1px solid #ddd;border-radius:5px; background:white;font-size:12px;color:#555;} QPushButton:hover{background:#f5f5f5;border-color:#6366f1;} """) self.load_more_btn.clicked.connect(self._on_load_more) self.load_more_btn.setVisible(False) hl.addWidget(self.load_more_btn) hist_btn = QPushButton("📋 历史记录") hist_btn.setStyleSheet(""" QPushButton{padding:5px 12px;border:1px solid #ddd;border-radius:5px; background:white;font-size:12px;color:#555;} QPushButton:hover{background:#f5f5f5;border-color:#6366f1;} """) hist_btn.clicked.connect(self._load_history) hl.addWidget(hist_btn) search_btn = QPushButton("🔍 搜索") search_btn.setStyleSheet(""" QPushButton{padding:5px 12px;border:1px solid #ddd;border-radius:5px; background:white;font-size:12px;color:#555;margin-right:4px;} QPushButton:hover{background:#f5f5f5;border-color:#1abc9c;} """) search_btn.clicked.connect(self._open_search) hl.addWidget(search_btn) root.addWidget(header) # ── 消息区 ── self._msg_widget = QWidget() self._msg_widget.setObjectName("msgWidget") self._msg_layout = QVBoxLayout(self._msg_widget) self._msg_layout.setAlignment(Qt.AlignTop) self._msg_layout.setSpacing(2) self._msg_layout.setContentsMargins(0, 8, 0, 8) self._msg_layout.addStretch() self._scroll = QScrollArea() self._scroll.setWidgetResizable(True) self._scroll.setWidget(self._msg_widget) self._scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self._scroll.setStyleSheet( "QScrollArea{border:none; background:#f1f5f9;}" "QScrollBar:vertical{width:8px;background:transparent;}" "QScrollBar::handle:vertical{background:#c0c0c0;border-radius:4px;}" "QScrollBar::handle:vertical:hover{background:#a0a0a0;}" "QScrollBar::add-line:vertical,QScrollBar::sub-line:vertical{height:0;}" ) self._scroll.verticalScrollBar().rangeChanged.connect(self._on_scroll_range_changed) root.addWidget(self._scroll, 1) # ── 输入区 ── input_bar = QWidget() input_bar.setFixedHeight(120) input_bar.setStyleSheet("background:#ffffff; border-top:1px solid #e2e8f0;") il = QVBoxLayout(input_bar) il.setContentsMargins(12, 8, 12, 8) il.setSpacing(6) # 输入框 self._input = QTextEdit() self._input.setPlaceholderText("输入消息,Ctrl+Enter 发送…") self._input.setMaximumHeight(60) self._input.setAcceptRichText(False) self._input.setStyleSheet(""" QTextEdit{border:1px solid #e0e0e0;border-radius:8px; padding:8px 10px;font-size:13px;background:#fafbfc; line-height:1.4;} QTextEdit:focus{border:1px solid #6366f1;background:white;} """) self._input.textChanged.connect(self._on_text_changed) il.addWidget(self._input) # 底部工具栏 toolbar = QHBoxLayout() toolbar.setSpacing(6) # 表情按钮 - 使用 EmojiPicker from ui.widgets import EmojiPicker self.emoji_picker = EmojiPicker(self) self.emoji_picker.emoji_selected.connect(self._insert_emoji) emoji_btn = QPushButton("😊") emoji_btn.setFixedSize(34, 30) emoji_btn.setStyleSheet(""" QPushButton{background:#f1f5f9;border:1px solid #e2e8f0; border-radius:6px;font-size:16px;} QPushButton:hover{background:#e2e8f0;} """) emoji_btn.clicked.connect(lambda: self._toggle_emoji_picker(emoji_btn)) toolbar.addWidget(emoji_btn) # 图片发送按钮 img_btn = QPushButton("🖼") img_btn.setFixedSize(34, 30) img_btn.setToolTip("发送图片") img_btn.setStyleSheet(""" QPushButton{background:#f1f5f9;border:1px solid #e2e8f0; border-radius:6px;font-size:16px;} QPushButton:hover{background:#e2e8f0;} """) img_btn.clicked.connect(self._send_image) toolbar.addWidget(img_btn) toolbar.addStretch() # 字数统计 self.char_count_lbl = QLabel("0/500") self.char_count_lbl.setStyleSheet("color:#94a3b8;font-size:11px;padding-right:4px;") toolbar.addWidget(self.char_count_lbl) # 发送按钮 send_btn = QPushButton("发送 ↵") send_btn.setFixedWidth(88) send_btn.setFixedHeight(32) send_btn.setStyleSheet(""" QPushButton{background:#6366f1;color:white;border:none; border-radius:6px;font-size:13px;font-weight:bold;} QPushButton:hover{background:#4f46e5;} QPushButton:pressed{background:#4338ca;} QPushButton:disabled{background:#ccc;} """) send_btn.clicked.connect(self._send) toolbar.addWidget(send_btn) il.addLayout(toolbar) root.addWidget(input_bar) self.setLayout(root) QShortcut(QKeySequence("Ctrl+Return"), self).activated.connect(self._send) QShortcut(QKeySequence("Ctrl+Enter"), self).activated.connect(self._send) def _toggle_emoji_picker(self, btn): if self.emoji_picker.isVisible(): self.emoji_picker.setVisible(False) else: pos = btn.mapToGlobal(btn.rect().bottomLeft()) self.emoji_picker.move(pos.x(), pos.y() - 260) self.emoji_picker.setVisible(True) self.emoji_picker.raise_() def _insert_emoji(self, emoji): cursor = self._input.textCursor() cursor.insertText(emoji) self._input.setFocus() def _on_text_changed(self): text = self._input.toPlainText() length = len(text) self.char_count_lbl.setText(f"{length}/500") if length > 500: self.char_count_lbl.setStyleSheet("color:#ef4444;font-size:11px;font-weight:bold;") else: self.char_count_lbl.setStyleSheet("color:#94a3b8;font-size:11px;") def _send(self): text = self._input.toPlainText().strip() if not text: return if len(text) > 500: text = text[:500] self.message_sent.emit(text, self.chat_type, self.target_id) self.add_message(self.my_username, text, beijing_now_str(), True) self._input.clear() def _load_history(self): self.history_requested.emit(self.target_name, self.target_id) def _on_load_more(self): offset = self.history_count self.load_more_requested.emit(self.target_name, self.target_id, offset) def _open_search(self): from ui.search_window import SearchWindow search_window = SearchWindow(self.chat_type, self.target_id, self.target_name, self) if hasattr(self, '_main_window_ref') and self._main_window_ref: search_window.search_requested.connect(self._main_window_ref._on_search_messages) self._main_window_ref._active_search_window = search_window search_window.show() def _send_image(self): """打开文件对话框选择图片发送""" path, _ = _QFileDialog.getOpenFileName( self, "选择图片", "", "图片文件 (*.png *.jpg *.jpeg *.gif *.bmp *.webp);;所有文件 (*)") if not path: return try: with open(path, 'rb') as f: file_data = f.read() if len(file_data) > 5 * 1024 * 1024: from PyQt5.QtWidgets import QMessageBox QMessageBox.warning(self, "图片过大", "图片大小不能超过5MB") return file_data_b64 = base64.b64encode(file_data).decode('ascii') filename = os.path.basename(path) # 保存到本地缓存供显示 cache_dir = os.path.join('messages', 'images') os.makedirs(cache_dir, exist_ok=True) cache_path = os.path.join(cache_dir, filename) with open(cache_path, 'wb') as f: f.write(file_data) self.image_sent.emit(filename, self.chat_type, self.target_id, file_data_b64) self.add_message(self.my_username, cache_path, beijing_now_str(), True, msg_type='image') except Exception as e: from PyQt5.QtWidgets import QMessageBox QMessageBox.critical(self, "发送失败", f"读取图片失败: {e}") def _auto_load_history(self): if not self.history_loaded: self.history_loaded = True self._load_history() def add_message(self, sender, content, timestamp, is_me=False, msg_type='text'): self._remove_stretch() self._add_date_separator_if_needed(timestamp) self._msg_layout.addWidget(ChatBubble(sender, content, timestamp, is_me, msg_type=msg_type)) self._msg_layout.addStretch() self.history_count += 1 QTimer.singleShot(50, self._scroll_bottom) def _remove_stretch(self): count = self._msg_layout.count() if count > 0: item = self._msg_layout.itemAt(count - 1) if item.spacerItem(): self._msg_layout.takeAt(count - 1) def _add_date_separator_if_needed(self, timestamp): try: if ' ' in timestamp: date_str = timestamp.split(' ')[0] if not hasattr(self, '_last_date') or self._last_date != date_str: self._last_date = date_str dt = datetime.strptime(date_str, '%Y-%m-%d') now = datetime.now() if dt.date() == now.date(): label = "今天" elif (now.date() - dt.date()).days == 1: label = "昨天" elif dt.year == now.year: label = dt.strftime('%m月%d日') else: label = dt.strftime('%Y年%m月%d日') self._msg_layout.addWidget(DateSeparator(label)) except Exception: pass def load_history(self, messages): self._clear() self.history_count = 0 if hasattr(self, '_last_date'): del self._last_date for m in messages: is_me = m.get('sender') == self.my_username self.add_message(m['sender'], m['content'], m.get('timestamp', ''), is_me, msg_type=m.get('msg_type', 'text')) self.load_more_btn.setVisible(len(messages) >= 50) QTimer.singleShot(100, self._scroll_bottom) def prepend_history(self, messages): """在顶部追加更早的历史记录""" if not messages: self.load_more_btn.setVisible(False) return # 找到第一个非分隔线的位置插入 insert_idx = 0 for i in range(self._msg_layout.count()): w = self._msg_layout.itemAt(i).widget() if w and not isinstance(w, DateSeparator): insert_idx = i break for m in reversed(messages): is_me = m.get('sender') == self.my_username bubble = ChatBubble(m['sender'], m['content'], m.get('timestamp', ''), is_me, msg_type=m.get('msg_type', 'text')) self._msg_layout.insertWidget(insert_idx, bubble) self.history_count += 1 self.load_more_btn.setVisible(len(messages) >= 50) def _clear(self): while self._msg_layout.count(): item = self._msg_layout.takeAt(0) if item.widget(): item.widget().deleteLater() elif item.spacerItem(): pass def _scroll_bottom(self): self._scroll.verticalScrollBar().setValue( self._scroll.verticalScrollBar().maximum()) def _on_scroll_range_changed(self, min_val, max_val): pass # ───────────────────────────────────────────────────────── # 用户/好友列表项 # ───────────────────────────────────────────────────────── class UserItem(QListWidgetItem): def __init__(self, info): super().__init__() self.info = info self.refresh() def refresh(self): nick = self.info.get('nickname') or self.info.get('username', '') user = self.info.get('username', '') online = self.info.get('is_online', False) status_dot = " ●" if online else " ○" self.setText(f"{nick} ({user}){status_dot}") self.setForeground(QColor('#10b981') if online else QColor('#94a3b8')) self.setData(Qt.UserRole, self.info) # ───────────────────────────────────────────────────────── # 确认对话框(替代 QMessageBox,带样式) # ───────────────────────────────────────────────────────── class StyledDialog(QDialog): def __init__(self, title, message, parent=None, confirm_text="确定", cancel_text="取消", show_cancel=True): super().__init__(parent) self.setWindowTitle(title) self.setMinimumWidth(380) self.setModal(True) self.setStyleSheet("QDialog{background:white;}") layout = QVBoxLayout(self) layout.setContentsMargins(24, 20, 24, 20) layout.setSpacing(16) title_lbl = QLabel(title) title_lbl.setStyleSheet("font-size:15px;font-weight:bold;color:#1e293b;") layout.addWidget(title_lbl) msg_lbl = QLabel(message) msg_lbl.setWordWrap(True) msg_lbl.setStyleSheet("font-size:13px;color:#555;line-height:1.5;") layout.addWidget(msg_lbl) btn_row = QHBoxLayout() btn_row.addStretch() if show_cancel: cancel_btn = QPushButton(cancel_text) cancel_btn.setStyleSheet(""" QPushButton{padding:8px 20px;border:1px solid #ddd;border-radius:6px; background:#f5f5f5;font-size:13px;color:#555;} QPushButton:hover{background:#e8e8e8;} """) cancel_btn.clicked.connect(self.reject) btn_row.addWidget(cancel_btn) confirm_btn = QPushButton(confirm_text) confirm_btn.setStyleSheet(""" QPushButton{padding:8px 20px;border:none;border-radius:6px; background:#6366f1;color:white;font-size:13px;font-weight:bold;} QPushButton:hover{background:#4f46e5;} """) confirm_btn.clicked.connect(self.accept) btn_row.addWidget(confirm_btn) layout.addLayout(btn_row) # ───────────────────────────────────────────────────────── # 主窗口 CSS 常量 # ───────────────────────────────────────────────────────── _SIDEBAR_STYLE = "QWidget { background:qlineargradient(x1:0,y1:0,x2:0,y2:1,stop:0 #1e1b4b,stop:1 #312e81); color:white; }" _LIST_STYLE = """ QListWidget { border:none; background:transparent; color:#e0e0e0; outline:none; } QListWidget::item { padding:10px 14px; border-bottom:1px solid rgba(255,255,255,0.08); font-size:13px; } QListWidget::item:hover { background:rgba(255,255,255,0.08); } QListWidget::item:selected { background:rgba(99,102,241,0.6); color:white; } """ _SEARCH_STYLE = """ QLineEdit { padding:6px 12px; border:1px solid rgba(255,255,255,0.15); border-radius:16px; background:rgba(255,255,255,0.08); color:white; font-size:13px; } QLineEdit:focus { border:1px solid #818cf8; background:rgba(255,255,255,0.12); } QLineEdit::placeholder { color: rgba(255,255,255,0.4); } """ # ───────────────────────────────────────────────────────── # 主窗口(优化版) # ───────────────────────────────────────────────────────── class MainWindow(QMainWindow): # 信号 chat_requested = pyqtSignal(str, str) group_chat_requested = pyqtSignal(int, str) image_chat_requested = pyqtSignal(str, str, str, str) image_group_chat_requested = pyqtSignal(int, str, str, str) add_friend_requested = pyqtSignal(str) remove_friend_requested = pyqtSignal(int) create_group_requested = pyqtSignal(str) join_group_requested = pyqtSignal(int) get_users_requested = pyqtSignal() get_friends_requested = pyqtSignal() get_groups_requested = pyqtSignal() get_all_groups_requested = pyqtSignal() get_history_requested = pyqtSignal(str) get_group_history_requested = pyqtSignal(int) get_group_members_requested = pyqtSignal(int) # 被 ChatApplication 连接 load_more_history_requested = pyqtSignal(str, object, int) search_messages_requested = pyqtSignal(str, str, str) leave_group_requested = pyqtSignal(int) invite_to_group_requested = pyqtSignal(int, str) change_username_requested = pyqtSignal(str) change_nickname_requested = pyqtSignal(str) change_password_requested = pyqtSignal(str, str) profile_update_requested = pyqtSignal(str) all_history_requested = pyqtSignal() logout_requested = pyqtSignal() def __init__(self, username, nickname, user_info): super().__init__() self.username = username self.nickname = nickname self.user_info = user_info self.chat_panels = {} self.conversations = {} self.friends = [] self.groups = [] self.all_users = [] self.all_groups = [] self._detail_windows = [] self._history_window = None self._notify_enabled = True self._build_ui() self._build_menu() # ── UI 构建 ─────────────────────────────────────────── def _build_ui(self): self.setWindowTitle(f"SimpleChat — {self.nickname} (@{self.username})") self.setGeometry(80, 80, 1200, 780) self.setMinimumSize(900, 600) if os.path.exists('icon.png'): self.setWindowIcon(QIcon('icon.png')) center = QWidget() self.setCentralWidget(center) root = QHBoxLayout(center) root.setContentsMargins(0, 0, 0, 0) root.setSpacing(0) # ── 左侧边栏 ── sidebar = QWidget() sidebar.setFixedWidth(270) sidebar.setStyleSheet(_SIDEBAR_STYLE) sl = QVBoxLayout(sidebar) sl.setContentsMargins(0, 0, 0, 0) sl.setSpacing(0) sl.addWidget(self._build_profile_bar()) sl.addWidget(self._build_tabs()) root.addWidget(sidebar) # ── 右侧聊天区 ── self.chat_area = QTabWidget() self.chat_area.setTabsClosable(True) self.chat_area.tabCloseRequested.connect(self._close_tab) self.chat_area.setStyleSheet(""" QTabWidget::pane { border:0; background:#f8fafc; } QTabBar::tab { padding:8px 18px; background:#e8ecf1; border:1px solid #e0e0e0; border-bottom:none; border-top-left-radius:6px; border-top-right-radius:6px; margin-right:2px; min-width:120px; font-size:13px; color:#555; } QTabBar::tab:selected { background:#f8fafc; border-bottom:2px solid #6366f1; font-weight:bold; color:#1e1b4b; } QTabBar::tab:hover:!selected { background:#dfe3e8; } """) welcome = self._build_welcome() self.chat_area.addTab(welcome, "🏠 主页") self.chat_area.tabBar().setTabButton(0, self.chat_area.tabBar().RightSide, None) self._welcome_tab_removable = True root.addWidget(self.chat_area, 1) # ── 通知浮层 ── from ui.widgets import NotificationToast self.notification_toast = NotificationToast(self) self.notification_toast.clicked.connect(self._on_toast_clicked) # ── 状态栏 ── self.statusBar().setStyleSheet( "QStatusBar{background:#f8fafc;color:#666;border-top:1px solid #e2e8f0;font-size:12px;}") self.status_label = QLabel("就绪") self.status_label.setStyleSheet("padding:2px 8px;") self.statusBar().addWidget(self.status_label) self.online_status_label = QLabel("在线: 1") self.online_status_label.setStyleSheet("padding:2px 8px;") self.statusBar().addPermanentWidget(self.online_status_label) def _build_profile_bar(self): bar = QWidget() bar.setFixedHeight(120) bar.setStyleSheet("background:qlineargradient(x1:0,y1:0,x2:1,y2:0,stop:0 #6366f1,stop:1 #8b5cf6);") bl = QVBoxLayout(bar) bl.setContentsMargins(16, 14, 16, 14) bl.setSpacing(8) row = QHBoxLayout() av = QLabel(self.nickname[0].upper() if self.nickname else "?") av.setFixedSize(44, 44) av.setAlignment(Qt.AlignCenter) av.setStyleSheet(""" QLabel{background:white;border-radius:22px; color:#1abc9c;font-size:20px;font-weight:bold;} """) row.addWidget(av) info_col = QVBoxLayout() info_col.setSpacing(2) nick_lbl = QLabel(self.nickname) nick_lbl.setStyleSheet("color:white;font-size:15px;font-weight:bold;") user_lbl = QLabel(f"@{self.username}") user_lbl.setStyleSheet("color:#c7d2fe;font-size:12px;") info_col.addWidget(nick_lbl) info_col.addWidget(user_lbl) row.addLayout(info_col) row.addStretch() # 设置按钮 settings_btn = QPushButton("⚙") settings_btn.setFixedSize(28, 28) settings_btn.setStyleSheet(""" QPushButton{background:rgba(255,255,255,0.15);border:none;border-radius:14px; color:white;font-size:14px;} QPushButton:hover{background:rgba(255,255,255,0.3);} """) settings_btn.clicked.connect(self._open_settings) row.addWidget(settings_btn) bl.addLayout(row) status_row = QHBoxLayout() dot = QLabel("●") dot.setStyleSheet("color:#10b981;font-size:14px;") online_txt = QLabel("在线") online_txt.setStyleSheet("color:white;font-size:12px;") self.online_label = QLabel("在线: 1") self.online_label.setStyleSheet("color:white;font-size:12px;") status_row.addWidget(dot) status_row.addWidget(online_txt) status_row.addStretch() status_row.addWidget(self.online_label) bl.addLayout(status_row) return bar def _build_tabs(self): tabs = QTabWidget() tabs.setStyleSheet(""" QTabWidget::pane{border:0;background:transparent;} QTabBar::tab{background:transparent;color:rgba(255,255,255,0.7);padding:10px 12px; border:none;font-size:12px;min-width:50px;} QTabBar::tab:selected{background:rgba(99,102,241,0.5);color:white;font-weight:bold;} QTabBar::tab:hover:!selected{background:rgba(255,255,255,0.08);} """) tabs.addTab(self._build_conversations_tab(), "💬 会话") tabs.addTab(self._build_friends_tab(), "👥 好友") tabs.addTab(self._build_groups_tab(), "👥 群组") tabs.addTab(self._build_discover_tab(), "🌍 发现") return tabs def _build_conversations_tab(self): w = QWidget() l = QVBoxLayout(w) l.setContentsMargins(0, 0, 0, 0) l.setSpacing(0) bar = QWidget() bar.setFixedHeight(46) bar.setStyleSheet("background:rgba(255,255,255,0.05);") bl = QHBoxLayout(bar) bl.setContentsMargins(8, 8, 8, 8) self.conversation_search = QLineEdit() self.conversation_search.setPlaceholderText("搜索会话…") self.conversation_search.setStyleSheet(_SEARCH_STYLE) self.conversation_search.textChanged.connect(self._filter_conversations) bl.addWidget(self.conversation_search) l.addWidget(bar) self.conversations_list = QListWidget() self.conversations_list.setStyleSheet(_LIST_STYLE) self.conversations_list.itemDoubleClicked.connect(self._on_conversation_dbl) self.conversations_list.setContextMenuPolicy(Qt.CustomContextMenu) self.conversations_list.customContextMenuRequested.connect(self._conversation_context_menu) l.addWidget(self.conversations_list) return w def _build_friends_tab(self): w = QWidget() l = QVBoxLayout(w) l.setContentsMargins(0, 0, 0, 0) l.setSpacing(0) bar = QWidget() bar.setFixedHeight(46) bar.setStyleSheet("background:rgba(255,255,255,0.05);") bl = QHBoxLayout(bar) bl.setContentsMargins(8, 8, 8, 8) self.friend_search = QLineEdit() self.friend_search.setPlaceholderText("搜索好友…") self.friend_search.setStyleSheet(_SEARCH_STYLE) self.friend_search.textChanged.connect(self._filter_friends) bl.addWidget(self.friend_search) l.addWidget(bar) self.friends_list = QListWidget() self.friends_list.setStyleSheet(_LIST_STYLE) self.friends_list.itemDoubleClicked.connect(self._on_friend_dbl) self.friends_list.setContextMenuPolicy(Qt.CustomContextMenu) self.friends_list.customContextMenuRequested.connect(self._friend_context_menu) l.addWidget(self.friends_list) return w def _build_groups_tab(self): w = QWidget() l = QVBoxLayout(w) l.setContentsMargins(0, 0, 0, 0) l.setSpacing(0) bar = QWidget() bar.setFixedHeight(46) bar.setStyleSheet("background:rgba(255,255,255,0.05);") bl = QHBoxLayout(bar) bl.setContentsMargins(8, 8, 8, 8) create_btn = QPushButton("➕ 创建群组") create_btn.setStyleSheet(""" QPushButton{background:#6366f1;color:white;border:none; border-radius:14px;padding:5px 14px;font-size:12px;} QPushButton:hover{background:#4f46e5;} """) create_btn.clicked.connect(self._create_group) bl.addWidget(create_btn) join_btn = QPushButton("🔍 加入") join_btn.setStyleSheet(""" QPushButton{background:#8b5cf6;color:white;border:none; border-radius:14px;padding:5px 10px;font-size:12px;} QPushButton:hover{background:#7c3aed;} """) join_btn.clicked.connect(self._join_group_dialog) bl.addWidget(join_btn) bl.addStretch() l.addWidget(bar) self.groups_list = QListWidget() self.groups_list.setStyleSheet(_LIST_STYLE) self.groups_list.itemDoubleClicked.connect(self._on_group_dbl) self.groups_list.setContextMenuPolicy(Qt.CustomContextMenu) self.groups_list.customContextMenuRequested.connect(self._group_context_menu) l.addWidget(self.groups_list) return w def _build_discover_tab(self): w = QWidget() l = QVBoxLayout(w) l.setContentsMargins(0, 0, 0, 0) l.setSpacing(0) bar = QWidget() bar.setFixedHeight(46) bar.setStyleSheet("background:rgba(255,255,255,0.05);") bl = QHBoxLayout(bar) bl.setContentsMargins(8, 8, 8, 8) self.user_search = QLineEdit() self.user_search.setPlaceholderText("搜索用户…") self.user_search.setStyleSheet(_SEARCH_STYLE) self.user_search.textChanged.connect(self._filter_users) refresh_btn = QPushButton("🔄") refresh_btn.setFixedSize(28, 28) refresh_btn.setStyleSheet(""" QPushButton{background:#6366f1;color:white;border:none; border-radius:14px;font-size:13px;} QPushButton:hover{background:#4f46e5;} """) refresh_btn.clicked.connect(self.get_users_requested.emit) bl.addWidget(self.user_search) bl.addWidget(refresh_btn) l.addWidget(bar) self.users_list = QListWidget() self.users_list.setStyleSheet(_LIST_STYLE) self.users_list.itemDoubleClicked.connect(self._on_user_dbl) self.users_list.setContextMenuPolicy(Qt.CustomContextMenu) self.users_list.customContextMenuRequested.connect(self._user_context_menu) l.addWidget(self.users_list) return w def _build_welcome(self): w = QWidget() w.setStyleSheet("background:qlineargradient(x1:0,y1:0,x2:1,y2:1,stop:0 #6366f1,stop:0.4 #8b5cf6,stop:1 #ec4899);") l = QVBoxLayout(w) l.setAlignment(Qt.AlignCenter) card = QWidget() card.setFixedSize(500, 400) card.setStyleSheet("background:rgba(255,255,255,0.95);border-radius:20px;") cl = QVBoxLayout(card) cl.setAlignment(Qt.AlignCenter) cl.setSpacing(16) icon = QLabel("💬") icon.setStyleSheet("font-size:64px;") icon.setAlignment(Qt.AlignCenter) title = QLabel("欢迎使用 SimpleChat") title.setAlignment(Qt.AlignCenter) title.setStyleSheet("font-size:24px;font-weight:bold;color:#1e293b;") subtitle = QLabel("安全、快速、易用的即时通信平台") subtitle.setAlignment(Qt.AlignCenter) subtitle.setStyleSheet("color:#94a3b8;font-size:13px;") tips = QLabel( "💬 双击好友或群组即可开始聊天\n" "👥 在「发现」中搜索并添加新朋友\n" "📋 点击「历史记录」回顾过往消息\n" "🔍 在聊天面板中搜索关键词\n" "⚙ 点击右上角齿轮进入个人设置" ) tips.setAlignment(Qt.AlignCenter) tips.setStyleSheet("color:#64748b;font-size:13px;line-height:2.0;") cl.addWidget(icon) cl.addWidget(title) cl.addWidget(subtitle) cl.addWidget(tips) l.addWidget(card) return w def _build_menu(self): mb = self.menuBar() mb.setStyleSheet(""" QMenuBar{background:#f8fafc;color:#333;border-bottom:1px solid #e2e8f0;} QMenuBar::item{padding:6px 14px;} QMenuBar::item:selected{background:#eef2ff;} QMenu{background:white;border:1px solid #e2e8f0;padding:4px;border-radius:6px;} QMenu::item{padding:7px 24px;border-radius:4px;} QMenu::item:selected{background:#6366f1;color:white;} """) fm = mb.addMenu("文件") fm.addAction(QAction("🔄 刷新列表", self, triggered=self._refresh_all)) fm.addAction(QAction("⚙ 个人设置", self, triggered=self._open_settings)) fm.addSeparator() fm.addAction(QAction("🚪 退出登录", self, triggered=self._logout)) cm = mb.addMenu("聊天") cm.addAction(QAction("➕ 添加好友", self, triggered=self._add_friend_dialog)) cm.addAction(QAction("👥 创建群组", self, triggered=self._create_group)) cm.addAction(QAction("🔍 加入群组", self, triggered=self._join_group_dialog)) cm.addSeparator() cm.addAction(QAction("📜 聊天记录", self, triggered=self._open_history_browser)) hm = mb.addMenu("帮助") hm.addAction(QAction("ℹ️ 关于", self, triggered=self._show_about)) # ── 设置对话框 ──────────────────────────────────────── def _open_settings(self): from ui.widgets import SettingsDialog dlg = SettingsDialog(self.username, self.nickname, self) dlg.profile_update_requested.connect(self.profile_update_requested.emit) dlg.change_username_requested.connect(self.change_username_requested.emit) dlg.change_nickname_requested.connect(self.change_nickname_requested.emit) dlg.change_password_requested.connect(self.change_password_requested.emit) dlg.notify_toggled.connect(lambda enabled: setattr(self, '_notify_enabled', enabled)) if hasattr(dlg, '_notify_check'): dlg._notify_check.setChecked(self._notify_enabled) dlg.exec_() def _open_history_browser(self): from ui.history_window import HistoryWindow if self._history_window is None: self._history_window = HistoryWindow(self.username) self._history_window.refresh_requested.connect(self.all_history_requested.emit) self._history_window.show() self._history_window.raise_() self._history_window.activateWindow() self.all_history_requested.emit() # ── 会话列表管理 ────────────────────────────────────── def _update_conversation(self, chat_type, target_id, target_name, last_message='', last_time='', increment_unread=False): if chat_type == 'private': chat_id = f"private_{target_name}" else: chat_id = f"group_{target_id}" if chat_id in self.conversations: conv = self.conversations[chat_id] conv['last_message'] = last_message[:40] + '...' if len(last_message) > 40 else last_message conv['last_time'] = last_time if increment_unread: conv['unread_count'] = conv.get('unread_count', 0) + 1 else: self.conversations[chat_id] = { 'chat_id': chat_id, 'chat_type': chat_type, 'target_id': target_id, 'target_name': target_name, 'last_message': last_message[:40] + '...' if len(last_message) > 40 else last_message, 'last_time': last_time, 'unread_count': 1 if increment_unread else 0 } self._refresh_conversations_list() def _refresh_conversations_list(self): self.conversations_list.clear() sorted_convs = sorted(self.conversations.values(), key=lambda x: x.get('last_time', ''), reverse=True) for conv in sorted_convs: icon = "💬" if conv['chat_type'] == 'private' else "👥" # 显示未读角标 unread = conv.get('unread_count', 0) unread_str = f" [{unread}]" if unread > 0 else "" text = f"{icon} {conv['target_name']}{unread_str}\n" if conv['last_message']: text += f" {conv['last_message']}\n" if conv['last_time']: time_str = conv['last_time'].split(' ')[-1] if ' ' in conv['last_time'] else conv['last_time'] text += f" 🕐 {time_str}" item = QListWidgetItem(text) item.setData(Qt.UserRole, conv) if unread > 0: item.setForeground(QColor('#f59e0b')) font = item.font() font.setBold(True) item.setFont(font) else: item.setForeground(QColor('white')) self.conversations_list.addItem(item) def _mark_conversation_read(self, chat_id): if chat_id in self.conversations: self.conversations[chat_id]['unread_count'] = 0 self._refresh_conversations_list() def _on_conversation_dbl(self, item): conv = item.data(Qt.UserRole) if conv: self._mark_conversation_read(conv['chat_id']) self.open_chat(conv['chat_type'], conv['target_id'], conv['target_name']) def _filter_conversations(self, text): t = text.lower() for i in range(self.conversations_list.count()): item = self.conversations_list.item(i) item.setHidden(bool(t) and t not in item.text().lower()) def _conversation_context_menu(self, pos): item = self.conversations_list.itemAt(pos) if not item: return conv = item.data(Qt.UserRole) menu = QMenu(self) menu.setStyleSheet("QMenu{background:white;border:1px solid #ddd;}" "QMenu::item{padding:6px 18px;}" "QMenu::item:selected{background:#1abc9c;color:white;}") menu.addAction("💬 打开会话", lambda: self.open_chat(conv['chat_type'], conv['target_id'], conv['target_name'])) menu.addAction("📜 查看历史", lambda: self._load_conversation_history(conv)) menu.addAction("🔍 搜索消息", lambda: self._open_search_for_conv(conv)) menu.addSeparator() menu.addAction("🗑 删除会话", lambda: self._remove_conversation(conv['chat_id'])) menu.exec_(self.conversations_list.mapToGlobal(pos)) def _load_conversation_history(self, conv): if conv['chat_type'] == 'private': self.get_history_requested.emit(conv['target_name']) else: self.get_group_history_requested.emit(conv['target_id']) def _open_search_for_conv(self, conv): from ui.search_window import SearchWindow search_window = SearchWindow(conv['chat_type'], conv['target_id'], conv['target_name'], self) search_window.search_requested.connect(self._on_search_messages) self._active_search_window = search_window search_window.show() def _remove_conversation(self, chat_id): if chat_id in self.conversations: del self.conversations[chat_id] self._refresh_conversations_list() # ── 列表更新 ────────────────────────────────────────── def update_friends_list(self, friends): self.friends = friends self.friends_list.clear() # 在线好友排前面 sorted_friends = sorted(friends, key=lambda f: (not f.get('is_online', False), f.get('username', ''))) for f in sorted_friends: self.friends_list.addItem(UserItem(f)) def update_groups_list(self, groups): self.groups = groups self.groups_list.clear() for g in groups: text = f"👥 {g.get('name','?')}" item = QListWidgetItem(text) item.setData(Qt.UserRole, g) item.setForeground(QColor('white')) self.groups_list.addItem(item) def update_users_list(self, users): self.all_users = users self.users_list.clear() # 在线用户排前面 sorted_users = sorted(users, key=lambda u: (not u.get('is_online', False), u.get('username', ''))) for u in sorted_users: if u.get('username') != self.username: self.users_list.addItem(UserItem(u)) def restore_conversations(self, conversations): """登录后从服务器恢复最近会话列表""" for conv in conversations: chat_type = conv.get('chat_type', 'private') target_id = conv.get('target_id') target_name = conv.get('target_name', '') last_message = conv.get('last_message', '') last_time = conv.get('last_time', '') self._update_conversation(chat_type, target_id, target_name, last_message, last_time, increment_unread=False) def update_all_groups_list(self, groups): self.all_groups = groups def update_online_status(self, user_id, username, is_online, online_count): self.online_label.setText(f"在线: {online_count}") self.online_status_label.setText(f"在线: {online_count}") for lst in [self.friends_list, self.users_list]: for i in range(lst.count()): item = lst.item(i) if isinstance(item, UserItem) and item.info.get('username') == username: item.info['is_online'] = is_online item.refresh() # 刷新列表排序 if self.friends: self.update_friends_list(self.friends) if self.all_users: self.update_users_list(self.all_users) # 更新群组详情窗口 for dw in self._detail_windows: if hasattr(dw, 'update_member_status'): dw.update_member_status(user_id, username, is_online) def update_group_members_in_detail(self, group_id, members): for dw in self._detail_windows: if hasattr(dw, 'group_info') and dw.group_info.get('id') == group_id: dw.update_members(members) # ── 聊天面板管理 ────────────────────────────────────── def open_chat(self, chat_type, target_id, target_name): if chat_type == 'private': key = f"private_{target_name}" target_id = target_name else: key = f"group_{target_id}" if key in self.chat_panels: for i in range(self.chat_area.count()): if self.chat_area.widget(i) is self.chat_panels[key]: self.chat_area.setCurrentIndex(i) return self.chat_panels[key] panel = ChatPanel(self.username, chat_type, target_id, target_name) panel._main_window_ref = self panel.message_sent.connect(self._on_panel_send) panel.image_sent.connect(self._on_panel_image_send) panel.history_requested.connect(self._on_history_requested) panel.load_more_requested.connect(self._on_load_more_requested) self.chat_panels[key] = panel icon = "💬" if chat_type == 'private' else "👥" # Remove welcome tab (index 0) when first real chat opens if self.chat_area.count() > 0 and hasattr(self, '_welcome_tab_removable'): self.chat_area.removeTab(0) del self._welcome_tab_removable idx = self.chat_area.addTab(panel, f"{icon} {target_name}") self.chat_area.setCurrentIndex(idx) # 标记已读 self._mark_conversation_read(key) return panel def receive_message(self, sender, content, timestamp, chat_type, target_id, target_name): panel = self.open_chat(chat_type, target_id, target_name) panel.add_message(sender, content, timestamp, False) is_active_tab = False current = self.chat_area.currentWidget() if current is panel: is_active_tab = True # 更新会话列表 self._update_conversation(chat_type, target_id, target_name, content, timestamp, increment_unread=not is_active_tab) self.status_label.setText(f"新消息来自 {sender}") # 通知浮层 if not is_active_tab or not self.isActiveWindow(): self._show_notification(sender, content, chat_type, target_id) # 任务栏闪烁 if not self.isActiveWindow(): self._flash_taskbar() def receive_image_message(self, sender, filename, file_data_b64, file_path, timestamp, chat_type, target_id, target_name): """接收并显示图片消息""" # 保存到本地缓存 cache_dir = os.path.join('messages', 'images') os.makedirs(cache_dir, exist_ok=True) cache_path = os.path.join(cache_dir, filename) try: file_data = base64.b64decode(file_data_b64) with open(cache_path, 'wb') as f: f.write(file_data) except Exception: cache_path = file_path # fallback panel = self.open_chat(chat_type, target_id, target_name) panel.add_message(sender, cache_path, timestamp, False, msg_type='image') is_active_tab = False current = self.chat_area.currentWidget() if current is panel: is_active_tab = True self._update_conversation(chat_type, target_id, target_name, '[图片]', timestamp, increment_unread=not is_active_tab) self.status_label.setText(f"新图片来自 {sender}") if not is_active_tab or not self.isActiveWindow(): self._show_notification(sender, '[图片]', chat_type, target_id) if not self.isActiveWindow(): self._flash_taskbar() def _show_notification(self, sender, content, chat_type, target_id): if not self._notify_enabled: return self.notification_toast.show_message(sender, content, chat_type, target_id) def _on_toast_clicked(self, chat_type, target_id): self.open_chat(chat_type, target_id, target_id if chat_type == 'private' else f"群组{target_id}") self.setWindowState(self.windowState() & ~Qt.WindowMinimized | Qt.WindowActive) self.activateWindow() self.raise_() def show_history(self, key_name, messages, is_group=False, prepend=False): if is_group: key = f"group_{key_name}" else: key = f"private_{key_name}" if key in self.chat_panels: if prepend: self.chat_panels[key].prepend_history(messages) else: self.chat_panels[key].load_history(messages) def show_search_results(self, results, keyword, chat_type, target_id): """显示搜索结果的响应处理""" if hasattr(self, '_active_search_window') and self._active_search_window: try: if self._active_search_window.isVisible(): self._active_search_window.update_search_results(results, keyword) except RuntimeError: self._active_search_window = None def _close_tab(self, index): if hasattr(self, '_welcome_tab_removable') and index == 0: return widget = self.chat_area.widget(index) for k, p in list(self.chat_panels.items()): if p is widget: del self.chat_panels[k] break self.chat_area.removeTab(index) widget.deleteLater() def _on_panel_send(self, content, chat_type, target_id): if chat_type == 'private': self.chat_requested.emit(content, str(target_id)) self._update_conversation('private', target_id, str(target_id), content, beijing_now_str('%H:%M:%S')) else: self.group_chat_requested.emit(int(target_id), content) group_name = f"群组{target_id}" for g in self.groups: if g.get('id') == target_id: group_name = g.get('name', group_name) break self._update_conversation('group', target_id, group_name, content, beijing_now_str('%H:%M:%S')) def _on_panel_image_send(self, filename, chat_type, target_id, file_data_b64): if chat_type == 'private': self.image_chat_requested.emit(filename, str(target_id), filename, file_data_b64) self._update_conversation('private', target_id, str(target_id), '[图片]', beijing_now_str('%H:%M:%S')) else: self.image_group_chat_requested.emit(int(target_id), filename, filename, file_data_b64) group_name = f"群组{target_id}" for g in self.groups: if g.get('id') == target_id: group_name = g.get('name', group_name) break self._update_conversation('group', target_id, group_name, '[图片]', beijing_now_str('%H:%M:%S')) def _on_history_requested(self, target_name, target_id): if isinstance(target_id, int): self.get_group_history_requested.emit(target_id) else: self.get_history_requested.emit(target_name) def _on_load_more_requested(self, target_name, target_id, offset): self.load_more_history_requested.emit(target_name, target_id, offset) def _on_search_messages(self, chat_type, target_id, keyword): self.search_messages_requested.emit(chat_type, str(target_id), keyword) # ── 列表交互 ────────────────────────────────────────── def _on_friend_dbl(self, item): info = item.data(Qt.UserRole) if info: self.open_chat('private', info.get('id'), info.get('username')) def _on_group_dbl(self, item): info = item.data(Qt.UserRole) if info: self.open_chat('group', info.get('id'), info.get('name')) def _on_user_dbl(self, item): info = item.data(Qt.UserRole) if not info: return username = info.get('username', '') if username == self.username: return is_friend = any(f.get('username') == username for f in self.friends) if is_friend: self.open_chat('private', info.get('id'), username) else: dlg = StyledDialog("添加好友", f'是否将 "{username}" 添加为好友?', self, confirm_text="添加好友") if dlg.exec_() == QDialog.Accepted: self.add_friend_requested.emit(username) def _friend_context_menu(self, pos): item = self.friends_list.itemAt(pos) if not item: return info = item.data(Qt.UserRole) menu = QMenu(self) menu.setStyleSheet("QMenu{background:white;border:1px solid #ddd;}" "QMenu::item{padding:6px 18px;}" "QMenu::item:selected{background:#1abc9c;color:white;}") menu.addAction("💬 发送消息", lambda: self.open_chat('private', info.get('id'), info.get('username'))) menu.addAction("📜 查看历史", lambda: self.get_history_requested.emit(info.get('username'))) menu.addSeparator() menu.addAction("🗑 删除好友", lambda: self._confirm_remove_friend(info)) menu.exec_(self.friends_list.mapToGlobal(pos)) def _group_context_menu(self, pos): item = self.groups_list.itemAt(pos) if not item: return info = item.data(Qt.UserRole) menu = QMenu(self) menu.setStyleSheet("QMenu{background:white;border:1px solid #ddd;}" "QMenu::item{padding:6px 18px;}" "QMenu::item:selected{background:#1abc9c;color:white;}") menu.addAction("💬 进入群聊", lambda: self.open_chat('group', info.get('id'), info.get('name'))) menu.addAction("📜 查看历史", lambda: self.get_group_history_requested.emit(info.get('id'))) menu.addSeparator() menu.addAction("ℹ️ 群组详情", lambda: self._show_group_detail(info)) menu.addAction("🚪 退出群组", lambda: self._leave_group(info.get('id'))) menu.exec_(self.groups_list.mapToGlobal(pos)) def _show_group_detail(self, group_info): from ui.group_detail_window import GroupDetailWindow detail_window = GroupDetailWindow(group_info, self.user_info['id'], self) detail_window.get_group_members_requested.connect( lambda gid: self._request_group_members(gid)) detail_window.leave_group_requested.connect( lambda gid: self.leave_group_requested.emit(gid)) detail_window.invite_member_requested.connect( lambda gid, username: self.invite_to_group_requested.emit(gid, username)) detail_window.show() self._detail_windows.append(detail_window) def _request_group_members(self, group_id): self.get_group_members_requested.emit(group_id) def _leave_group(self, group_id): dlg = StyledDialog("退出群组", "确定要退出该群组吗?退出后将无法接收群组消息。", self, confirm_text="退出") if dlg.exec_() == QDialog.Accepted: self.leave_group_requested.emit(group_id) def _user_context_menu(self, pos): item = self.users_list.itemAt(pos) if not item: return info = item.data(Qt.UserRole) username = info.get('username', '') is_friend = any(f.get('username') == username for f in self.friends) menu = QMenu(self) menu.setStyleSheet("QMenu{background:white;border:1px solid #ddd;}" "QMenu::item{padding:6px 18px;}" "QMenu::item:selected{background:#1abc9c;color:white;}") if is_friend: menu.addAction("💬 发送消息", lambda: self.open_chat('private', info.get('id'), username)) else: menu.addAction("➕ 添加好友", lambda: self.add_friend_requested.emit(username)) menu.exec_(self.users_list.mapToGlobal(pos)) def _confirm_remove_friend(self, info): dlg = StyledDialog("删除好友", f'确定删除好友 "{info.get("username")}" 吗?', self, confirm_text="删除") if dlg.exec_() == QDialog.Accepted: self.remove_friend_requested.emit(info.get('id')) # ── 过滤 ────────────────────────────────────────────── def _filter_friends(self, text): t = text.lower() for i in range(self.friends_list.count()): item = self.friends_list.item(i) item.setHidden(bool(t) and t not in item.text().lower()) def _filter_users(self, text): t = text.lower() for i in range(self.users_list.count()): item = self.users_list.item(i) item.setHidden(bool(t) and t not in item.text().lower()) # ── 对话框 ──────────────────────────────────────────── def _add_friend_dialog(self): name, ok = QInputDialog.getText(self, '添加好友', '请输入用户名:') if ok and name.strip(): self.add_friend_requested.emit(name.strip()) def _create_group(self): name, ok = QInputDialog.getText(self, '创建群组', '请输入群组名称:') if ok and name.strip(): self.create_group_requested.emit(name.strip()) def _join_group_dialog(self): self.get_all_groups_requested.emit() gid, ok = QInputDialog.getInt(self, '加入群组', '请输入群组ID:', min=1, max=999999) if ok and gid > 0: self.join_group_requested.emit(gid) def _refresh_all(self): self.get_friends_requested.emit() self.get_groups_requested.emit() self.get_users_requested.emit() self.status_label.setText("列表已刷新") def _logout(self): dlg = StyledDialog("退出登录", "确定要退出登录吗?", self, confirm_text="退出") if dlg.exec_() == QDialog.Accepted: self.logout_requested.emit() def _show_about(self): QMessageBox.about(self, '关于 SimpleChat', 'SimpleChat 即时通信系统 v2.3\n\n' '功能:注册登录 · 私聊 · 群聊\n' '好友管理 · 在线状态 · 历史记录\n' '消息搜索 · 通知提醒 · 未读标记\n\n' '© 2026 SimpleChat Team') def _flash_taskbar(self): try: if hasattr(self, 'winId'): import ctypes user32 = ctypes.windll.user32 user32.FlashWindow(int(self.winId()), True) except Exception: pass # ── 公共接口 ────────────────────────────────────────── def update_status(self, text): self.status_label.setText(text) def show_message(self, kind, title, text): if kind == 'error': QMessageBox.critical(self, title, text) elif kind == 'warning': QMessageBox.warning(self, title, text) else: QMessageBox.information(self, title, text) def show_toast(self, sender, preview, chat_type, target_id): self.notification_toast.show_message(sender, preview, chat_type, target_id)