You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1558 lines
66 KiB

This file contains invisible Unicode characters!

This file contains invisible Unicode characters that may be processed differently from what appears below. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to reveal hidden characters.

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

"""
主窗口 —— 包含 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)
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)
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)
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 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):
if is_group:
key = f"group_{key_name}"
else:
key = f"private_{key_name}"
if key in self.chat_panels:
self.chat_panels[key].load_history(messages)
def show_search_results(self, results, keyword, chat_type, target_id):
"""显示搜索结果的响应处理"""
# 如果搜索窗口打开,更新结果
pass
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)
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)