wuzilong преди 11 месеца
родител
ревизия
8a6008ab6b

+ 7 - 3
backend/app/api/client/auth.py

@@ -152,9 +152,13 @@ async def login(
         if not verify_password(req.password, user.password):
             return {"code": 401, "msg": "用户名或密码错误"}
         
-        # 验证机器码
-        if user.machine_code != req.machine_code:
-            return {"code": 403, "msg": "机器码不匹配,请先解绑"}
+        # 验证/绑定机器码
+        if user.machine_code:
+            if user.machine_code != req.machine_code:
+                return {"code": 403, "msg": "机器码不匹配,请先解绑"}
+        else:
+            # 用户已解绑或尚未绑定机器码,第一次登录时绑定当前机器码
+            user.machine_code = req.machine_code
         
         # 检查是否过期
         if user.expire_time and user.expire_time < datetime.now():

+ 13 - 0
backend/app/core/security.py

@@ -1,6 +1,19 @@
 """
 安全相关功能
 """
+# 临时兼容:部分 bcrypt 版本缺少 __about__.__version__,导致 passlib 初始化失败
+# 在导入 passlib 前补充该属性,避免登录/解绑失败
+try:
+    import bcrypt  # type: ignore
+
+    if not hasattr(bcrypt, "__about__"):
+        class _BcryptAbout:
+            __version__ = getattr(bcrypt, "__version__", "unknown")
+
+        bcrypt.__about__ = _BcryptAbout()  # type: ignore[attr-defined]
+except Exception:
+    pass
+
 try:
     from passlib.context import CryptContext
     pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")

+ 1 - 1
backend/app/middleware/logging.py

@@ -22,7 +22,7 @@ class APILoggingMiddleware(BaseHTTPMiddleware):
         client_ip = request.client.host if request.client else None
         user_agent = request.headers.get("user-agent", "")
         
-        # 获取请求体(如果是POST/PUT)
+        # 获取请求体(如果是POST/PUT),其它方法不修改url
         request_body = ""
         if method in ["POST", "PUT", "PATCH"]:
             try:

+ 1 - 1
backend/app/models/api_log.py

@@ -10,7 +10,7 @@ class APILog(Base):
     
     id = Column(BigInteger, primary_key=True, index=True, comment="主键ID")
     user_id = Column(Integer, ForeignKey("users.id"), index=True, comment="用户ID")
-    endpoint = Column(String(200), nullable=False, index=True, comment="接口路径")
+    endpoint = Column(String(500), nullable=False, index=True, comment="接口路径")
     method = Column(String(10), nullable=False, index=True, comment="HTTP方法")
     request_data = Column(Text, comment="请求数据")
     response_data = Column(Text, comment="响应数据")

+ 1 - 1
client/config/user_data.enc

@@ -1 +1 @@
-gAAAAABpEY5Lk1L77MqEKnFfDGMtRrJdI489QDp_P8dpF802B-0CwPBfX6szVnouI6_L9FqYjSnuxuhuDIkprfo87rmoaRx-guLwXzR5ttJmcfPRFmvD3mVnk1EEAxyYtn18B5rG_v4j5zbXbpg0lS2ybT17JjnzB511jZ2h51QSEq7kz1kI4ejFiLEXMQrCNPCyIZOyCp-PIuILDkFqcvWwWNfGmwn0kUzU166zVs0Kq6HoF9-QHhM3fKCC9qS5bzAzZaY_12P0ADZmPTOVnxJKsP79_guUzs96bJVA2jXgpMy5zT8ZKitZ3dVYz7dWgQN-GpKwBgyKocSgjZbsLSbE4uNR8s-Gg9LNlpORWRHY4URu3N_9p62eTiX86fHMfNPldQnhyAXZ6sq8Pph09Qxa9o87PSHsCgbwLtxsTvdAr1Bw4PgpTKsInEimlNPm3FGC3TvkkuAnid2JBJunMK_Eq-QZSA9uX5LJUVcQjsD_bFSp2zrkOb7v01cD7AJ6W3YFV3ZVPFN3
+gAAAAABpHuc1tBkWtHr_m7ipIq4CciV6PugNLcNQ-JfVqvE4SgQxL6QxleemeWsKPS-IZwrJ59D2C_BdUMGwhqCGpSF5om1048NZGEI3dP7266jKKhyXqGIwTuHCf9IBYcf2W6PqavyM3cPSNWhvkAWcXVF4TfHyxNYCGYr6ZwwdWooLi2H9l7TEeuIDycx7xg46wyinycD4YgoZOKKvzNA9OFLivkTcmeilPD26BVBiz2JjlbvAGLILtDi9UFQbKmZuT2TJvPMNNaHuNIaKgJbBca7lCfNJlFA_Mpp8R1rF0SxtZ0nNY3Pne0bh1G9uboLlnxWjqEm-WmSbcK3ORkbIi28eAs91PQ4Z1rnGLXb6cRZzI9D0AgHIJ-adgYAFwptG7K0K6OK639saZO9rWjKxl5vMqUILyOyibP1ZVJaOoq6AMMg9irDqzqKmw-yaDPsWXwxw1elqTgBYDcuQUvdk8MzHgM67yzJP-hgdgVoGoK5DfoEw0R5Jpb8oxgXo8oa9fnVMpvfn

+ 31 - 1
client/src/api/client.py

@@ -4,7 +4,7 @@ API客户端
 import requests
 from typing import Dict, Any, Optional
 from src.utils.logger import get_logger
-from src.config.constants import API_BASE_URL, API_TIMEOUT, SOFTWARE_CODE
+from src.config.constants import API_BASE_URL, API_TIMEOUT, SOFTWARE_CODE, ENCRYPT_ENABLED
 from src.api.encrypt import encryptor
 
 logger = get_logger(__name__)
@@ -26,6 +26,9 @@ class APIClient:
         request_data = data or {}
         request_data['software_code'] = SOFTWARE_CODE
         
+        # 确保公钥已加载
+        self.ensure_public_key_loaded()
+
         # 加密数据
         encrypted_data = encryptor.encrypt(request_data)
         
@@ -148,6 +151,33 @@ class APIClient:
         response = self.session.get(url, timeout=API_TIMEOUT)
         response.raise_for_status()
         return response.json()
+
+    def refresh_public_key(self) -> bool:
+        """主动刷新公钥"""
+        if not ENCRYPT_ENABLED:
+            return True
+        try:
+            result = self.get_public_key()
+            if result.get("code") == 200:
+                data = result.get("data", {})
+                public_key = data.get("public_key")
+                if public_key:
+                    encryptor.load_public_key(public_key)
+                    logger.info("公钥刷新成功")
+                    return True
+                raise ValueError("公钥数据为空")
+            raise ValueError(result.get("msg", "获取公钥失败"))
+        except Exception as e:
+            logger.error(f"刷新公钥失败: {e}")
+            raise Exception("获取公钥失败,请稍后重试")
+
+    def ensure_public_key_loaded(self):
+        """确保公钥已加载(必要时自动刷新)"""
+        if not ENCRYPT_ENABLED:
+            return
+        if encryptor.public_key:
+            return
+        self.refresh_public_key()
     
     def get_announcement(self) -> Dict[str, Any]:
         """获取公告"""

+ 216 - 62
client/src/ui/login_window.py

@@ -2,12 +2,13 @@
 登录窗口
 """
 from PyQt5.QtWidgets import (
-    QWidget, QVBoxLayout, QHBoxLayout, QLabel, 
-    QStackedWidget, QTextEdit, QMessageBox, QDialog, 
-    QDialogButtonBox, QAction
+    QWidget, QVBoxLayout, QHBoxLayout, QLabel,
+    QStackedWidget, QMessageBox, QDialog,
+    QDialogButtonBox, QAction, QListWidget, QListWidgetItem,
+    QAbstractItemView, QSizePolicy, QListView, QFrame
 )
 from PyQt5.QtWidgets import QLineEdit
-from PyQt5.QtCore import Qt, pyqtSignal, QThread, QPoint
+from PyQt5.QtCore import Qt, pyqtSignal, QThread, QPoint, QEvent, QTimer
 from PyQt5.QtGui import QFont, QPixmap, QMouseEvent
 from qfluentwidgets import (
     FluentWindow, LineEdit, PasswordLineEdit,
@@ -21,6 +22,7 @@ from src.utils.logger import get_logger
 from src.api.client import api_client
 from src.utils.machine import get_machine_code
 from src.utils.storage import get_storage
+from datetime import datetime
 from PyQt5.QtWidgets import QWidget
 
 logger = get_logger(__name__)
@@ -34,7 +36,9 @@ class LoginWindow(QWidget):
     def __init__(self):
         super().__init__()
         self.machine_code = get_machine_code()
-        self.announcement_text = None
+        self.announcement_list = None
+        self.announcements = []
+        self.announcement_timer = None
         self.storage = get_storage()
         
         # 拖动相关变量
@@ -51,6 +55,7 @@ class LoginWindow(QWidget):
         
         self.init_ui()
         self.load_announcement()
+        self.start_announcement_refresh_timer()
         self.load_public_key()
         self.try_auto_login()
     
@@ -163,7 +168,7 @@ class LoginWindow(QWidget):
         announcement_label.setAlignment(Qt.AlignmentFlag.AlignLeft)
         announcement_label.setStyleSheet("""
             QLabel {
-                color: white;
+                color: #000000;
                 font-size: 18px;
                 font-weight: bold;
                 margin-bottom: 10px;
@@ -171,23 +176,37 @@ class LoginWindow(QWidget):
         """)
         announcement_layout.addWidget(announcement_label)
         
-        # 公告内容区域
-        self.announcement_text = TextEdit(announcement_widget)
-        self.announcement_text.setReadOnly(True)
-        self.announcement_text.setPlaceholderText("暂无公告")
-        # 设置公告列表高度为窗口高度,并启用纵向滚动
-        self.announcement_text.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
-        self.announcement_text.setStyleSheet("""
-            TextEdit {
-                background-color: rgba(255, 255, 255, 0.1);
-                border: 1px solid rgba(255, 255, 255, 0.2);
+        # 公告内容列表,可点击查看详情
+        self.announcement_list = QListWidget(announcement_widget)
+        self.announcement_list.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
+        self.announcement_list.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
+        self.announcement_list.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
+        self.announcement_list.setWordWrap(True)
+        self.announcement_list.setResizeMode(QListView.ResizeMode.Adjust)
+        self.announcement_list.setSpacing(0)
+        self.announcement_list.setFocusPolicy(Qt.FocusPolicy.NoFocus)
+        self.announcement_list.viewport().installEventFilter(self)
+        self.announcement_list.setStyleSheet("""
+            QListWidget {
+                background-color: rgba(255, 255, 255, 0.25);
+                border: 1px solid rgba(0, 0, 0, 0.15);
                 border-radius: 8px;
-                color: #333333;
-                font-size: 14px;
-                padding: 15px;
+                padding: 15px 10px;
+            }
+            QListWidget::item {
+                margin: 0px;
+            }
+            QListWidget::item:selected {
+                background: transparent;
             }
         """)
-        announcement_layout.addWidget(self.announcement_text, 1)  # 使用拉伸因子,让公告列表占据剩余空间
+        self.announcement_list.itemClicked.connect(self.handle_announcement_click)
+        list_container = QWidget()
+        list_container_layout = QVBoxLayout()
+        list_container_layout.setContentsMargins(15, 0, 15, 0)
+        list_container_layout.addWidget(self.announcement_list)
+        list_container.setLayout(list_container_layout)
+        announcement_layout.addWidget(list_container, 1)  # 使用拉伸因子,让公告列表占据剩余空间
         announcement_widget.setLayout(announcement_layout)
         
         # 右侧:登录卡片
@@ -248,7 +267,7 @@ class LoginWindow(QWidget):
         
         # 切换按钮
         switch_layout = QHBoxLayout()
-        switch_layout.setSpacing(10)
+        switch_layout.setSpacing(15)
         self.switch_to_register_btn = HyperlinkButton(card)
         self.switch_to_register_btn.setText("切换到注册")
         self.switch_to_login_btn = HyperlinkButton(card)
@@ -256,18 +275,17 @@ class LoginWindow(QWidget):
         self.switch_to_register_btn.clicked.connect(lambda: self.stacked_widget.setCurrentIndex(1))
         self.switch_to_login_btn.clicked.connect(lambda: self.stacked_widget.setCurrentIndex(0))
         
+        unbind_btn = HyperlinkButton(card)
+        unbind_btn.setText("机器解绑")
+        unbind_btn.clicked.connect(self.show_unbind_dialog)
+        
         switch_layout.addStretch()
         switch_layout.addWidget(self.switch_to_register_btn)
         switch_layout.addWidget(self.switch_to_login_btn)
+        switch_layout.addWidget(unbind_btn)
         switch_layout.addStretch()
         card_layout.addLayout(switch_layout)
         
-        # 机器解绑按钮
-        unbind_btn = HyperlinkButton(card)
-        unbind_btn.setText("机器解绑")
-        unbind_btn.clicked.connect(self.show_unbind_dialog)
-        card_layout.addWidget(unbind_btn)
-        
         card.setLayout(card_layout)
         
         # 组装右侧布局
@@ -275,9 +293,10 @@ class LoginWindow(QWidget):
         right_layout.addStretch()
         
         # 版权信息
-        copyright_label = QLabel("© 2024 游铛铛. All rights reserved.")
+        current_year = datetime.now().year
+        copyright_label = QLabel(f"© {current_year} 游铛铛. All rights reserved.")
         copyright_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
-        copyright_label.setStyleSheet("color: rgba(255, 255, 255, 0.8); font-size: 12px; margin-top: 20px;")
+        copyright_label.setStyleSheet("color: #000000; font-size: 12px; margin-top: 20px;")
         right_layout.addWidget(copyright_label)
         
         right_widget.setLayout(right_layout)
@@ -472,10 +491,12 @@ class LoginWindow(QWidget):
     
     def show_unbind_dialog(self):
         """显示机器解绑对话框"""
-        from qfluentwidgets import Dialog, LineEdit, PasswordLineEdit
+        from qfluentwidgets import LineEdit, PasswordLineEdit
         
-        dialog = Dialog("机器解绑", self)
-        dialog.resize(400, 200)
+        dialog = QDialog(self)
+        dialog.setWindowTitle("机器解绑")
+        dialog.setModal(True)
+        dialog.resize(400, 220)
         
         layout = QVBoxLayout()
         layout.setSpacing(12)
@@ -503,7 +524,7 @@ class LoginWindow(QWidget):
         layout.addWidget(password_input)
         layout.addWidget(button_box)
         
-        dialog.contentLayout.addLayout(layout)
+        dialog.setLayout(layout)
         dialog.exec()
     
     def handle_unbind(self, dialog: QDialog, username: str, password: str):
@@ -529,40 +550,159 @@ class LoginWindow(QWidget):
             result = api_client.get_announcement()
             if result.get("code") == 200:
                 announcements = result.get("data", [])
-                if announcements:
-                    # 格式化公告显示:每个公告显示为:公告名称、公告主题、分隔线
-                    announcement_lines = []
-                    for i, ann in enumerate(announcements):
-                        title = ann.get("title", "")
-                        content = ann.get("content", "")
-                        if title or content:
-                            if title:
-                                announcement_lines.append(title)
-                            if content:
-                                announcement_lines.append(content)
-                            # 如果不是最后一个公告,添加分隔线
-                            if i < len(announcements) - 1:
-                                announcement_lines.append("————————————")
-                    
-                    if announcement_lines:
-                        self.announcement_text.setPlainText("\n".join(announcement_lines))
-                else:
-                    self.announcement_text.setPlainText("暂无公告")
+                self.populate_announcement_list(announcements)
+            else:
+                self.populate_announcement_list([])
         except Exception as e:
             logger.error(f"加载公告失败: {e}")
-            self.announcement_text.setPlainText("加载公告失败")
+            self.populate_announcement_list([], error_text="加载公告失败")
+
+    def start_announcement_refresh_timer(self):
+        """启动公告定时刷新"""
+        if not self.announcement_timer:
+            self.announcement_timer = QTimer(self)
+            self.announcement_timer.setTimerType(Qt.TimerType.CoarseTimer)
+            self.announcement_timer.timeout.connect(self.load_announcement)
+
+        # 避免重复启动
+        if self.announcement_timer.isActive():
+            self.announcement_timer.stop()
+        self.announcement_timer.start(60 * 1000)
+
+    def stop_announcement_refresh_timer(self):
+        """停止公告定时刷新"""
+        if self.announcement_timer and self.announcement_timer.isActive():
+            self.announcement_timer.stop()
+
+    def populate_announcement_list(self, announcements: list, error_text=None):
+        """填充公告列表"""
+        self.announcement_list.clear()
+        self.announcements = announcements or []
+
+        if not self.announcements:
+            placeholder = QListWidgetItem(error_text or "暂无公告")
+            placeholder.setFlags(Qt.ItemFlag.NoItemFlags)
+            self.announcement_list.addItem(placeholder)
+            return
+
+        total = len(self.announcements)
+        for idx, ann in enumerate(self.announcements):
+            item = QListWidgetItem()
+            item.setData(Qt.ItemDataRole.UserRole, ann)
+            widget = self.create_announcement_item_widget(ann, is_last=(idx == total - 1))
+            item.setSizeHint(widget.sizeHint())
+            self.announcement_list.addItem(item)
+            self.announcement_list.setItemWidget(item, widget)
+        self.update_announcement_item_width()
+
+    def create_announcement_item_widget(self, announcement: dict, is_last: bool = False) -> QWidget:
+        """创建公告列表项"""
+        widget = QWidget()
+        widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Maximum)
+        layout = QVBoxLayout()
+        layout.setContentsMargins(5, 8, 5, 8)
+        layout.setSpacing(4)
+
+        title = announcement.get("title") or "未命名公告"
+        title_label = QLabel(title)
+        title_label.setWordWrap(True)
+        title_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
+        title_label.setStyleSheet("font-size: 16px; font-weight: bold; color: #1a1a1a;")
+
+        summary_source = announcement.get("summary") or announcement.get("content", "") or ""
+        summary = summary_source.strip()
+        if len(summary) > 120:
+            summary = summary[:120] + "..."
+        content_label = QLabel(summary or "暂无简介")
+        content_label.setWordWrap(True)
+        content_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
+        content_label.setStyleSheet("font-size: 14px; color: #4a4a4a;")
+
+        layout.addWidget(title_label)
+        layout.addWidget(content_label)
+
+        if not is_last:
+            layout.addSpacing(4)
+            separator = QFrame()
+            separator.setFrameShape(QFrame.Shape.HLine)
+            separator.setFrameShadow(QFrame.Shadow.Plain)
+            separator.setFixedHeight(1)
+            separator.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
+            separator.setStyleSheet("color: rgba(0, 0, 0, 0.15); background-color: rgba(0, 0, 0, 0.15);")
+            layout.addWidget(separator)
+            layout.addSpacing(6)
+
+        widget.setLayout(layout)
+        widget.setStyleSheet("background-color: transparent;")
+        return widget
+
+    def update_announcement_item_width(self):
+        """根据列表宽度调整公告条目内容,确保自动换行"""
+        if not self.announcement_list:
+            return
+        viewport_width = max(self.announcement_list.viewport().width() - 20, 100)
+        for i in range(self.announcement_list.count()):
+            item = self.announcement_list.item(i)
+            widget = self.announcement_list.itemWidget(item)
+            if widget:
+                widget.setFixedWidth(viewport_width)
+
+    def handle_announcement_click(self, item: QListWidgetItem):
+        """公告点击事件"""
+        announcement = item.data(Qt.ItemDataRole.UserRole)
+        if not announcement:
+            return
+        self.show_announcement_detail_dialog(announcement)
+
+    def show_announcement_detail_dialog(self, announcement: dict):
+        """弹出公告详情"""
+        dialog = QDialog(self)
+        dialog.setWindowTitle(announcement.get("title") or "公告详情")
+        dialog.setModal(True)
+        dialog.resize(500, 400)
+
+        layout = QVBoxLayout(dialog)
+        layout.setContentsMargins(20, 20, 20, 20)
+        layout.setSpacing(15)
+
+        title_label = QLabel(announcement.get("title") or "未命名公告")
+        title_label.setStyleSheet("font-size: 18px; font-weight: bold; color: #1f1f1f;")
+
+        meta = announcement.get("created_at") or announcement.get("publish_time") or ""
+        meta_label = QLabel(meta)
+        meta_label.setStyleSheet("font-size: 12px; color: #7a7a7a;")
+
+        content = announcement.get("content") or "暂无公告内容"
+        detail_text = TextEdit(dialog)
+        detail_text.setReadOnly(True)
+        detail_text.setPlainText(content)
+        detail_text.setStyleSheet("""
+            TextEdit {
+                background-color: rgba(245, 247, 250, 0.9);
+                border: 1px solid rgba(0, 0, 0, 0.05);
+                border-radius: 6px;
+                font-size: 14px;
+                color: #2d2d2d;
+            }
+        """)
+
+        button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok, parent=dialog)
+        button_box.accepted.connect(dialog.accept)
+
+        layout.addWidget(title_label)
+        if meta:
+            layout.addWidget(meta_label)
+        layout.addWidget(detail_text, 1)
+        layout.addWidget(button_box)
+
+        dialog.setLayout(layout)
+        dialog.exec()
     
     def load_public_key(self):
         """加载公钥"""
         try:
-            result = api_client.get_public_key()
-            if result.get("code") == 200:
-                data = result.get("data", {})
-                public_key = data.get("public_key")
-                if public_key:
-                    from src.api.encrypt import encryptor
-                    encryptor.load_public_key(public_key)
-                    logger.info("公钥加载成功")
+            api_client.refresh_public_key()
+            logger.info("公钥加载成功")
         except Exception as e:
             logger.error(f"加载公钥失败: {e}")
     
@@ -652,7 +792,6 @@ class LoginWindow(QWidget):
             if user_info:
                 expire_time = user_info.get("expire_time")
                 if expire_time:
-                    from datetime import datetime
                     try:
                         if isinstance(expire_time, str):
                             expire_dt = datetime.fromisoformat(expire_time.replace('Z', '+00:00'))
@@ -724,6 +863,21 @@ class LoginWindow(QWidget):
             if 'title' in child.objectName().lower() or 'TitleBar' in type(child).__name__:
                 child.hide()
                 child.setFixedHeight(0)
+        self.update_announcement_item_width()
+
+    def eventFilter(self, obj, event):
+        if (
+            hasattr(self, "announcement_list")
+            and obj == self.announcement_list.viewport()
+            and event.type() == QEvent.Type.Resize
+        ):
+            self.update_announcement_item_width()
+        return super().eventFilter(obj, event)
+
+    def closeEvent(self, event):
+        """窗口关闭事件"""
+        self.stop_announcement_refresh_timer()
+        super().closeEvent(event)
 
 
 class LoginThread(QThread):