|
|
"""
|
|
|
共享 UI 组件库
|
|
|
"""
|
|
|
from PyQt5.QtWidgets import (
|
|
|
QWidget, QLabel, QFrame, QPushButton, QVBoxLayout, QHBoxLayout,
|
|
|
QGridLayout, QDialog, QFormLayout, QLineEdit
|
|
|
)
|
|
|
from PyQt5.QtCore import Qt, pyqtSignal, QTimer
|
|
|
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
# 1. InlineValidator
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class InlineValidator(QWidget):
|
|
|
"""在输入框下方实时显示验证结果的提示组件"""
|
|
|
|
|
|
def __init__(self, parent=None):
|
|
|
super().__init__(parent)
|
|
|
self._is_valid = None # None=未验证, True=有效, False=无效
|
|
|
|
|
|
layout = QVBoxLayout(self)
|
|
|
layout.setContentsMargins(0, 0, 0, 0)
|
|
|
layout.setSpacing(0)
|
|
|
|
|
|
self._label = QLabel()
|
|
|
self._label.setWordWrap(True)
|
|
|
self._label.setStyleSheet("font-size: 12px;")
|
|
|
layout.addWidget(self._label)
|
|
|
|
|
|
self.setVisible(False)
|
|
|
|
|
|
# ── 公开接口 ──────────────────────────────────────────
|
|
|
|
|
|
def set_valid(self, message: str = "✓") -> None:
|
|
|
"""显示绿色通过提示"""
|
|
|
self._is_valid = True
|
|
|
self._label.setText(message)
|
|
|
self._label.setStyleSheet("font-size: 12px; color: #27ae60;")
|
|
|
self.setVisible(True)
|
|
|
|
|
|
def set_invalid(self, message: str) -> None:
|
|
|
"""显示红色错误提示"""
|
|
|
self._is_valid = False
|
|
|
self._label.setText(message)
|
|
|
self._label.setStyleSheet("font-size: 12px; color: #e74c3c;")
|
|
|
self.setVisible(True)
|
|
|
|
|
|
def clear(self) -> None:
|
|
|
"""隐藏提示(初始/重置状态)"""
|
|
|
self._is_valid = None
|
|
|
self._label.setText("")
|
|
|
self.setVisible(False)
|
|
|
|
|
|
@property
|
|
|
def is_valid(self):
|
|
|
"""当前是否处于有效状态(None=未验证,True=有效,False=无效)"""
|
|
|
return self._is_valid
|
|
|
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
# 2. NotificationToast
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class NotificationToast(QFrame):
|
|
|
"""屏幕右下角弹出的非阻塞消息通知浮层"""
|
|
|
|
|
|
clicked = pyqtSignal(str, object) # chat_type, target_id
|
|
|
|
|
|
def __init__(self, parent: QWidget):
|
|
|
super().__init__(parent)
|
|
|
self._timer = QTimer(self)
|
|
|
self._timer.setSingleShot(True)
|
|
|
self._timer.timeout.connect(self.dismiss)
|
|
|
self._chat_type = None
|
|
|
self._target_id = None
|
|
|
|
|
|
self.setFixedWidth(280)
|
|
|
self.setFrameShape(QFrame.StyledPanel)
|
|
|
self.setCursor(Qt.PointingHandCursor)
|
|
|
self.setStyleSheet(
|
|
|
"NotificationToast {"
|
|
|
" background: white;"
|
|
|
" border-radius: 10px;"
|
|
|
" border: 1px solid #e0e0e0;"
|
|
|
"}"
|
|
|
)
|
|
|
|
|
|
layout = QVBoxLayout(self)
|
|
|
layout.setContentsMargins(14, 10, 14, 10)
|
|
|
layout.setSpacing(4)
|
|
|
|
|
|
self._sender_lbl = QLabel()
|
|
|
self._sender_lbl.setStyleSheet("font-weight: bold; font-size: 13px; color: #222;")
|
|
|
|
|
|
self._preview_lbl = QLabel()
|
|
|
self._preview_lbl.setStyleSheet("font-size: 12px; color: #666;")
|
|
|
self._preview_lbl.setWordWrap(True)
|
|
|
|
|
|
layout.addWidget(self._sender_lbl)
|
|
|
layout.addWidget(self._preview_lbl)
|
|
|
|
|
|
self.setVisible(False)
|
|
|
|
|
|
def show_message(
|
|
|
self,
|
|
|
sender: str,
|
|
|
preview: str,
|
|
|
chat_type: str,
|
|
|
target_id,
|
|
|
auto_dismiss_ms: int = 4000,
|
|
|
) -> None:
|
|
|
self._chat_type = chat_type
|
|
|
self._target_id = target_id
|
|
|
|
|
|
self._sender_lbl.setText(sender)
|
|
|
truncated = preview[:30] + ("…" if len(preview) > 30 else "")
|
|
|
self._preview_lbl.setText(truncated)
|
|
|
|
|
|
self._reposition()
|
|
|
self.setVisible(True)
|
|
|
self.raise_()
|
|
|
|
|
|
self._timer.stop()
|
|
|
self._timer.start(auto_dismiss_ms)
|
|
|
|
|
|
def dismiss(self) -> None:
|
|
|
self._timer.stop()
|
|
|
self.setVisible(False)
|
|
|
|
|
|
def _reposition(self) -> None:
|
|
|
"""绝对定位于父窗口右下角"""
|
|
|
parent = self.parentWidget()
|
|
|
if parent is None:
|
|
|
return
|
|
|
margin = 16
|
|
|
x = parent.width() - self.width() - margin
|
|
|
y = parent.height() - self.sizeHint().height() - margin
|
|
|
self.move(x, y)
|
|
|
|
|
|
def mousePressEvent(self, event):
|
|
|
if event.button() == Qt.LeftButton:
|
|
|
self.clicked.emit(self._chat_type, self._target_id)
|
|
|
self.dismiss()
|
|
|
super().mousePressEvent(event)
|
|
|
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
# 3. EmojiPicker
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
_EMOJIS = [
|
|
|
"😀", "😂", "😍", "😎", "😭", "😡", "😱", "🤔",
|
|
|
"👍", "👎", "👏", "🙏", "🤝", "💪", "🎉", "🔥",
|
|
|
"❤️", "💔", "💯", "✨", "🌟", "⭐", "🎵", "🎶",
|
|
|
"🍕", "🍔", "🍦", "☕", "🍺", "🎂", "🌈", "🌙",
|
|
|
"😊", "😇", "🥰", "😏", "😴", "🤣", "😅", "😬",
|
|
|
"🙄", "😤", "🤯", "🥳", "🤩", "😋", "🤗", "🤫",
|
|
|
]
|
|
|
|
|
|
|
|
|
class EmojiPicker(QFrame):
|
|
|
"""简单 emoji 选择面板"""
|
|
|
|
|
|
emoji_selected = pyqtSignal(str)
|
|
|
|
|
|
def __init__(self, parent=None):
|
|
|
super().__init__(parent)
|
|
|
self.setFrameShape(QFrame.StyledPanel)
|
|
|
self.setStyleSheet(
|
|
|
"EmojiPicker {"
|
|
|
" background: white;"
|
|
|
" border-radius: 8px;"
|
|
|
" border: 1px solid #d0d7de;"
|
|
|
"}"
|
|
|
)
|
|
|
|
|
|
grid = QGridLayout(self)
|
|
|
grid.setContentsMargins(8, 8, 8, 8)
|
|
|
grid.setSpacing(2)
|
|
|
|
|
|
cols = 8
|
|
|
for idx, emoji in enumerate(_EMOJIS):
|
|
|
btn = QPushButton(emoji)
|
|
|
btn.setFixedSize(32, 32)
|
|
|
btn.setFlat(True)
|
|
|
btn.setCursor(Qt.PointingHandCursor)
|
|
|
btn.setStyleSheet(
|
|
|
"QPushButton { font-size: 18px; border: none; background: transparent; }"
|
|
|
"QPushButton:hover { background: #f0f0f0; border-radius: 4px; }"
|
|
|
)
|
|
|
btn.clicked.connect(lambda checked, e=emoji: self._on_emoji_clicked(e))
|
|
|
grid.addWidget(btn, idx // cols, idx % cols)
|
|
|
|
|
|
self.setVisible(False)
|
|
|
|
|
|
def _on_emoji_clicked(self, emoji: str) -> None:
|
|
|
self.emoji_selected.emit(emoji)
|
|
|
self.setVisible(False)
|
|
|
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
# 5. SettingsDialog
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class SettingsDialog(QDialog):
|
|
|
"""用户设置对话框:修改昵称、用户名、密码、通知设置"""
|
|
|
|
|
|
profile_update_requested = pyqtSignal(str)
|
|
|
change_username_requested = pyqtSignal(str)
|
|
|
change_nickname_requested = pyqtSignal(str)
|
|
|
change_password_requested = pyqtSignal(str, str)
|
|
|
notify_toggled = pyqtSignal(bool)
|
|
|
|
|
|
def __init__(self, username: str, nickname: str, parent=None):
|
|
|
super().__init__(parent)
|
|
|
self.username = username
|
|
|
self.setWindowTitle("个人设置")
|
|
|
self.setMinimumWidth(440)
|
|
|
self.setMaximumHeight(680)
|
|
|
self.setModal(True)
|
|
|
self._build_ui(username, nickname)
|
|
|
self._apply_style()
|
|
|
|
|
|
def _build_ui(self, username: str, nickname: str) -> None:
|
|
|
root = QVBoxLayout(self)
|
|
|
root.setContentsMargins(20, 16, 20, 16)
|
|
|
root.setSpacing(12)
|
|
|
root.setAlignment(Qt.AlignTop)
|
|
|
|
|
|
from PyQt5.QtWidgets import QScrollArea
|
|
|
scroll = QScrollArea()
|
|
|
scroll.setWidgetResizable(True)
|
|
|
scroll.setStyleSheet("QScrollArea{border:none;background:transparent;}")
|
|
|
|
|
|
content = QWidget()
|
|
|
cl = QVBoxLayout(content)
|
|
|
cl.setSpacing(12)
|
|
|
|
|
|
title = QLabel("个人设置")
|
|
|
title.setStyleSheet("font-size: 18px; font-weight: bold; color: #1a1a2e;")
|
|
|
cl.addWidget(title)
|
|
|
|
|
|
section1 = QLabel("基本资料")
|
|
|
section1.setStyleSheet("font-size: 12px; font-weight: bold; color: #e94560; margin-top: 2px;")
|
|
|
cl.addWidget(section1)
|
|
|
|
|
|
form = QFormLayout()
|
|
|
form.setSpacing(10)
|
|
|
form.setLabelAlignment(Qt.AlignRight)
|
|
|
|
|
|
self._username_input = QLineEdit(username)
|
|
|
self._username_input.setPlaceholderText("修改用户名(4-20位字母/数字/下划线)")
|
|
|
form.addRow("用户名:", self._username_input)
|
|
|
|
|
|
self._nickname_input = QLineEdit(nickname)
|
|
|
self._nickname_input.setPlaceholderText("修改昵称")
|
|
|
form.addRow("昵称:", self._nickname_input)
|
|
|
|
|
|
cl.addLayout(form)
|
|
|
|
|
|
section2 = QLabel("修改密码(留空则不修改)")
|
|
|
section2.setStyleSheet("font-size: 12px; font-weight: bold; color: #e94560; margin-top: 2px;")
|
|
|
cl.addWidget(section2)
|
|
|
|
|
|
pw_form = QFormLayout()
|
|
|
pw_form.setSpacing(10)
|
|
|
pw_form.setLabelAlignment(Qt.AlignRight)
|
|
|
|
|
|
self._old_password_input = QLineEdit()
|
|
|
self._old_password_input.setEchoMode(QLineEdit.Password)
|
|
|
self._old_password_input.setPlaceholderText("输入原密码")
|
|
|
pw_form.addRow("原密码:", self._old_password_input)
|
|
|
|
|
|
self._new_password_input = QLineEdit()
|
|
|
self._new_password_input.setEchoMode(QLineEdit.Password)
|
|
|
self._new_password_input.setPlaceholderText("输入新密码(6-20位)")
|
|
|
pw_form.addRow("新密码:", self._new_password_input)
|
|
|
|
|
|
self._confirm_password_input = QLineEdit()
|
|
|
self._confirm_password_input.setEchoMode(QLineEdit.Password)
|
|
|
self._confirm_password_input.setPlaceholderText("确认新密码")
|
|
|
pw_form.addRow("确认密码:", self._confirm_password_input)
|
|
|
|
|
|
cl.addLayout(pw_form)
|
|
|
|
|
|
from PyQt5.QtWidgets import QCheckBox
|
|
|
section3 = QLabel("通知设置")
|
|
|
section3.setStyleSheet("font-size: 12px; font-weight: bold; color: #e94560; margin-top: 2px;")
|
|
|
cl.addWidget(section3)
|
|
|
|
|
|
self._notify_check = QCheckBox("启用桌面通知")
|
|
|
self._notify_check.setChecked(True)
|
|
|
self._notify_check.setStyleSheet("font-size: 13px; color: #555;")
|
|
|
self._notify_check.toggled.connect(self.notify_toggled.emit)
|
|
|
cl.addWidget(self._notify_check)
|
|
|
|
|
|
cl.addStretch()
|
|
|
scroll.setWidget(content)
|
|
|
root.addWidget(scroll)
|
|
|
|
|
|
self._status_label = QLabel("")
|
|
|
self._status_label.setWordWrap(True)
|
|
|
self._status_label.setVisible(False)
|
|
|
root.addWidget(self._status_label)
|
|
|
|
|
|
btn_row = QHBoxLayout()
|
|
|
btn_row.addStretch()
|
|
|
|
|
|
self._cancel_btn = QPushButton("取消")
|
|
|
self._cancel_btn.setCursor(Qt.PointingHandCursor)
|
|
|
self._cancel_btn.clicked.connect(self.reject)
|
|
|
|
|
|
self._save_btn = QPushButton("保存修改")
|
|
|
self._save_btn.setCursor(Qt.PointingHandCursor)
|
|
|
self._save_btn.setDefault(True)
|
|
|
self._save_btn.clicked.connect(self._on_save)
|
|
|
|
|
|
btn_row.addWidget(self._cancel_btn)
|
|
|
btn_row.addWidget(self._save_btn)
|
|
|
root.addLayout(btn_row)
|
|
|
|
|
|
def _apply_style(self) -> None:
|
|
|
self.setStyleSheet("""
|
|
|
QDialog { background: #fafbfc; }
|
|
|
QLineEdit {
|
|
|
padding: 8px 14px;
|
|
|
border: 1.5px solid #e0e0e0;
|
|
|
border-radius: 8px;
|
|
|
font-size: 13px;
|
|
|
color: #333;
|
|
|
background: white;
|
|
|
}
|
|
|
QLineEdit:focus { border: 1.5px solid #e94560; background: #fff5f5; }
|
|
|
QLabel { color: #444; }
|
|
|
QPushButton { padding: 8px 22px; border-radius: 8px; font-size: 13px; }
|
|
|
QPushButton#saveBtn {
|
|
|
background: qlineargradient(x1:0,y1:0,x2:1,y2:0,stop:0 #e94560,stop:1 #c23152);
|
|
|
color: white; border: none; font-weight: bold;
|
|
|
}
|
|
|
QPushButton#saveBtn:hover { background: #c23152; }
|
|
|
QPushButton#cancelBtn {
|
|
|
background: #f0f0f0; color: #555; border: 1px solid #ddd;
|
|
|
}
|
|
|
QPushButton#cancelBtn:hover { background: #e0e0e0; }
|
|
|
""")
|
|
|
self._save_btn.setObjectName("saveBtn")
|
|
|
self._cancel_btn.setObjectName("cancelBtn")
|
|
|
|
|
|
def _on_save(self) -> None:
|
|
|
new_username = self._username_input.text().strip()
|
|
|
new_nickname = self._nickname_input.text().strip()
|
|
|
old_password = self._old_password_input.text()
|
|
|
new_password = self._new_password_input.text()
|
|
|
confirm_password = self._confirm_password_input.text()
|
|
|
|
|
|
changed = False
|
|
|
|
|
|
if new_nickname and new_nickname != self.username:
|
|
|
self.change_nickname_requested.emit(new_nickname)
|
|
|
changed = True
|
|
|
|
|
|
if new_username and new_username != self.username:
|
|
|
if len(new_username) < 4 or len(new_username) > 20:
|
|
|
self._show_status("用户名需4-20位", "error")
|
|
|
return
|
|
|
if not all(c.isalnum() or c == '_' for c in new_username):
|
|
|
self._show_status("用户名只能包含字母、数字和下划线", "error")
|
|
|
return
|
|
|
self.change_username_requested.emit(new_username)
|
|
|
self.username = new_username
|
|
|
changed = True
|
|
|
|
|
|
if old_password or new_password or confirm_password:
|
|
|
if not old_password:
|
|
|
self._show_status("请输入原密码", "error")
|
|
|
return
|
|
|
if not new_password:
|
|
|
self._show_status("请输入新密码", "error")
|
|
|
return
|
|
|
if len(new_password) < 6 or len(new_password) > 20:
|
|
|
self._show_status("新密码需6-20位", "error")
|
|
|
return
|
|
|
if new_password != confirm_password:
|
|
|
self._show_status("两次密码不一致", "error")
|
|
|
return
|
|
|
self.change_password_requested.emit(old_password, new_password)
|
|
|
changed = True
|
|
|
|
|
|
if changed:
|
|
|
self._show_status("设置已保存", "success")
|
|
|
else:
|
|
|
self._show_status("没有需要修改的内容", "info")
|
|
|
|
|
|
def _show_status(self, message, kind):
|
|
|
self._status_label.setText(message)
|
|
|
if kind == "error":
|
|
|
self._status_label.setStyleSheet(
|
|
|
"font-size: 12px; padding: 6px 12px; border-radius: 6px; background: #ffeaea; color: #c0392b;")
|
|
|
elif kind == "success":
|
|
|
self._status_label.setStyleSheet(
|
|
|
"font-size: 12px; padding: 6px 12px; border-radius: 6px; background: #eafaf1; color: #27ae60;")
|
|
|
else:
|
|
|
self._status_label.setStyleSheet(
|
|
|
"font-size: 12px; padding: 6px 12px; border-radius: 6px; background: #eaf2f8; color: #2980b9;")
|
|
|
self._status_label.setVisible(True)
|
|
|
|