|
|
"""
|
|
|
主窗口 —— 包含 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)
|