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.

408 lines
16 KiB

This file contains ambiguous Unicode 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.

"""
共享 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)