From a716e5b945556f95f003e2032f1f82ee3cac674d Mon Sep 17 00:00:00 2001 From: KenanZhu <3471685733@qq.com> Date: Wed, 24 Jun 2026 10:18:30 +0800 Subject: [PATCH] =?UTF-8?q?feat(bulletin):=20=E5=85=AC=E5=91=8A=E6=A0=8F?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=E5=AE=8C=E6=95=B4=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude --- src/gui/ALBulletinDialog.py | 382 ++++++++++ src/gui/ALBulletinPoller.py | 170 +++++ src/gui/ALMainWindow.py | 63 ++ src/gui/ALSettingsWidget.py | 93 ++- src/gui/resources/ui/ALBulletinDialog.ui | 385 ++++++++++ src/gui/resources/ui/ALSettingsWidget.ui | 865 ++++++++++++++--------- src/interfaces/ConfigProvider.py | 6 + src/managers/bulletin/BulletinManager.py | 292 ++++++++ src/managers/bulletin/__init__.py | 9 + src/managers/config/ConfigManager.py | 5 + 10 files changed, 1938 insertions(+), 332 deletions(-) create mode 100644 src/gui/ALBulletinDialog.py create mode 100644 src/gui/ALBulletinPoller.py create mode 100644 src/gui/resources/ui/ALBulletinDialog.ui create mode 100644 src/managers/bulletin/BulletinManager.py create mode 100644 src/managers/bulletin/__init__.py diff --git a/src/gui/ALBulletinDialog.py b/src/gui/ALBulletinDialog.py new file mode 100644 index 0000000..c252681 --- /dev/null +++ b/src/gui/ALBulletinDialog.py @@ -0,0 +1,382 @@ +# -*- coding: utf-8 -*- +""" +Copyright (c) 2026 KenanZhu. +All rights reserved. + +This software is provided "as is", without any warranty of any kind. +You may use, modify, and distribute this file under the terms of the MIT License. +See the LICENSE file for details. +""" +import requests + +from datetime import datetime, timedelta +from typing import Any + +from PySide6.QtCore import ( + Qt, + QThread, + Signal, + Slot, + QTimer +) +from PySide6.QtGui import ( + QFont +) +from PySide6.QtWidgets import ( + QDialog, + QHBoxLayout, + QLabel, + QListWidgetItem, + QMessageBox, + QVBoxLayout, + QWidget +) + +from gui.ALStatusLabel import ALStatusLabel +from gui.resources.ui.Ui_ALBulletinDialog import Ui_ALBulletinDialog +from managers.bulletin.BulletinManager import instance as bulletinInstance + + +class ALBulletinFetchWorker(QThread): + """ + Worker thread for fetching bulletins from the server. + """ + + fetchWorkerIsFinished = Signal(dict) + fetchWorkerFinishedWithError = Signal(str) + + def __init__( + self, + parent=None, + request_url: str = "", + params: dict = None + ): + + super().__init__(parent) + self.__request_url = request_url.rstrip("/") if request_url else "" + self.__params = params or {} + + def run( + self + ): + + try: + response = requests.get( + self.__request_url, params=self.__params, timeout=5 + ) + response.raise_for_status() + r = response.json() + if r.get("code") == 200: + data = r.get("data", {}) + self.fetchWorkerIsFinished.emit(data) + else: + raise Exception( + f"服务器返回错误: [{r.get('code', '未知代码')}] {r.get('msg', '未知错误')}" + ) + except requests.RequestException as e: + self.fetchWorkerFinishedWithError.emit(f"获取公告数据时发生网络错误: \n{e}") + except Exception as e: + self.fetchWorkerFinishedWithError.emit(f"获取公告数据时发生未知错误: \n{e}") + + +class ALBulletinItemWidget(QWidget): + """ + Single bulletin item widget for the bulletin list. + """ + + def __init__( + self, + parent=None, + bulletin: dict[str, Any] = None + ): + + super().__init__(parent) + self.__bulletin = bulletin.copy() if bulletin else {} + self.modifyUi() + + def modifyUi( + self + ): + + self.ItemWidgetLayout = QVBoxLayout() + self.ItemWidgetLayout.setSpacing(10) + self.ItemWidgetLayout.setContentsMargins(10, 5, 10, 5) + self.BulletinTitleLayout = QHBoxLayout() + self.BulletinTitleLabel = QLabel(self.__bulletin.get("title", "无标题")) + titleFont = QFont() + titleFont.setBold(True) + self.BulletinTitleLabel.setFont(titleFont) + self.BulletinTitleLayout.addWidget(self.BulletinTitleLabel) + if self.__bulletin.get("isNew", False): + self.NewIndicatorLabel = QLabel("新 !") + self.NewIndicatorLabel.setStyleSheet( + "color: #DC0000;"\ + "font-size: 10px;"\ + "font-weight: bold;"\ + "font-style: italic;" + ) + self.NewIndicatorLabel.setFixedSize(25, 25) + self.BulletinTitleLayout.addWidget(self.NewIndicatorLabel) + else: + self.NewIndicatorLabel = None + if self.__bulletin.get("isEdited", False): + self.BulletinIsEditedLabel = QLabel("(已编辑)") + self.BulletinIsEditedLabel.setStyleSheet( + "color: #FF9800;"\ + "font-size: 10px;" + ) + self.BulletinTitleLayout.addWidget(self.BulletinIsEditedLabel) + self.BulletinTitleLayout.addStretch() + self.ItemWidgetLayout.addLayout(self.BulletinTitleLayout) + self.BulletinInfoLayout = QHBoxLayout() + self.BulletinDateLabel = QLabel() + date_time = datetime.fromisoformat(self.__bulletin.get("dateTime", "")) + self.BulletinDateLabel.setText(date_time.strftime("%Y-%m-%d %H:%M:%S")) + self.BulletinDateLabel.setStyleSheet("color: #969696; font-size: 11px;") + self.BulletinInfoLayout.addWidget(self.BulletinDateLabel) + self.BulletinAuthorLabel = QLabel(self.__bulletin.get("author", "未知")) + self.BulletinAuthorLabel.setStyleSheet("color: #969696; font-size: 11px;") + self.BulletinInfoLayout.addWidget(self.BulletinAuthorLabel) + self.BulletinInfoLayout.addStretch() + self.ItemWidgetLayout.addLayout(self.BulletinInfoLayout) + self.setLayout(self.ItemWidgetLayout) + self.__bulletin = None + + def markAsRead( + self + ): + + if self.NewIndicatorLabel: + self.NewIndicatorLabel.hide() + + +class ALBulletinDialog(QDialog, Ui_ALBulletinDialog): + """ + Bulletin viewer dialog. + """ + + def __init__( + self, + parent=None + ): + super().__init__(parent) + self.__sync_timer: QTimer | None = None + self.__fetch_worker: ALBulletinFetchWorker | None = None + self.__bulletin_mgr = bulletinInstance() + + self.setupUi(self) + self.modifyUi() + self.connectSignals() + self.setupTimer() + if self.shouldAutoSync(): + self.syncBulletin() + + def modifyUi( + self + ): + + self.ALSyncStatusLabel = ALStatusLabel(self) + self.ALSyncStatusLabel.setFixedSize(30, 30) + self.SyncLayout.replaceWidget( + self.SyncStatusPlaceholder, self.ALSyncStatusLabel + ) + + titleFont = QFont() + titleFont.setBold(True) + titleFont.setPointSize(15) + self.BulletinTitleLabel.setFont(titleFont) + self.BulletinDateLabel.setStyleSheet("color: #969696;") + self.BulletinAuthorLabel.setStyleSheet("color: #969696;") + self.BulletinIsEditedLabel.setStyleSheet("color: #FF9800;") + self.BulletinIsEditedLabel.hide() + last_time = self.__bulletin_mgr.lastSyncTime() + if last_time: + self.setLastSyncTime(last_time) + self.updateBulletinList(self.__bulletin_mgr.bulletins()) + + def shouldAutoSync( + self + ) -> bool: + + last = self.__bulletin_mgr.lastSyncTime() + if last is None: + return True + try: + last_dt = datetime.fromisoformat(last) + interval_min = max(self.__bulletin_mgr.syncInterval()//2, 1) + threshold = timedelta(minutes=interval_min) + return (datetime.now().astimezone() - last_dt) > threshold + except (ValueError, TypeError): + return True + + def setLastSyncTime( + self, + iso_str: str + ): + + try: + dt = datetime.fromisoformat(iso_str) + self.LastSyncDateTimeEdit.setDateTime(dt) + except (ValueError, TypeError): + pass + + def connectSignals( + self + ): + + self.BulletinListWidget.itemClicked.connect(self.onBulletinListWidgetItemClicked) + self.SyncButton.clicked.connect(self.syncBulletin) + + def setupTimer( + self + ): + + self.__sync_timer = QTimer(self) + self.__sync_timer.timeout.connect(self.syncBulletin) + self.__sync_timer.start(self.__bulletin_mgr.syncInterval()*60*1000) + + def updateBulletinList( + self, + bulletins: list[dict] + ): + + self.BulletinListWidget.clear() + sorted_list = sorted( + bulletins, key=lambda x: x.get("dateTime", ""), reverse=True + ) + for bulletin in sorted_list: + item = QListWidgetItem() + item.setData(Qt.UserRole, bulletin) + widget = ALBulletinItemWidget(self, bulletin) + item.setSizeHint(widget.sizeHint()) + self.BulletinListWidget.addItem(item) + self.BulletinListWidget.setItemWidget(item, widget) + + def showBulletin( + self, + bulletin: dict + ): + + self.BulletinTitleLabel.setText(bulletin.get("title", "无标题")) + date_time = datetime.fromisoformat(bulletin.get("dateTime", "")) + self.BulletinDateLabel.setText(date_time.strftime("%Y-%m-%d %H:%M:%S")) + self.BulletinAuthorLabel.setText(bulletin.get("author", "未知")) + if bulletin.get("isEdited", False): + self.BulletinIsEditedLabel.show() + else: + self.BulletinIsEditedLabel.hide() + self.BulletinContentTextBrowser.setHtml( + "
{content}
".format( + content=bulletin.get("content", "无内容") + ) + ) + + def clearBulletin( + self + ): + + self.BulletinTitleLabel.setText("") + self.BulletinDateLabel.setText("") + self.BulletinAuthorLabel.setText("") + self.BulletinContentTextBrowser.setText("") + self.BulletinIsEditedLabel.hide() + + def syncBulletin( + self + ): + + if self.__fetch_worker is not None: + return + self.SyncButton.setEnabled(False) + self.SyncButton.setText("同步中...") + self.ALSyncStatusLabel.status = ALStatusLabel.Status.RUNNING + self.SyncStatusDetailLabel.setText("") + self.SyncStatusDetailLabel.setStyleSheet("") + params = self.__bulletin_mgr.getSyncDateTimeAndRange() + self.__fetch_worker = ALBulletinFetchWorker( + self, self.__bulletin_mgr.apiUrl(), params + ) + self.__fetch_worker.fetchWorkerIsFinished.connect(self.onBulletinsFetched) + self.__fetch_worker.fetchWorkerFinishedWithError.connect(self.onBulletinsFetchError) + self.__fetch_worker.start() + + @Slot(dict) + def onBulletinsFetched( + self, + data: dict + ): + + if self.__fetch_worker: + self.__fetch_worker.wait(2000) + self.__fetch_worker.fetchWorkerIsFinished.disconnect(self.onBulletinsFetched) + self.__fetch_worker.fetchWorkerFinishedWithError.disconnect(self.onBulletinsFetchError) + self.__fetch_worker.deleteLater() + self.__fetch_worker = None + bulletins = data.get("bulletins", []) + delete_ids = data.get("delete_ids", []) + merged = self.__bulletin_mgr.updateAndMergeBulletins(bulletins, delete_ids) + self.__bulletin_mgr.setLastSyncTime(datetime.now().astimezone().isoformat()) + self.SyncButton.setEnabled(True) + self.SyncButton.setText("同步") + self.ALSyncStatusLabel.status = ALStatusLabel.Status.SUCCESS + self.SyncStatusDetailLabel.setText("同步成功") + self.SyncStatusDetailLabel.setStyleSheet("color: green;") + QTimer.singleShot(3000, self.clearSyncStatus) + + self.setLastSyncTime(self.__bulletin_mgr.lastSyncTime()) + self.updateBulletinList(merged) + self.clearBulletin() + interval_ms = self.__bulletin_mgr.syncInterval()*60*1000 + if self.__sync_timer: + self.__sync_timer.start(interval_ms) + + @Slot(str) + def onBulletinsFetchError( + self, + error_message: str + ): + + if self.__fetch_worker: + self.__fetch_worker.wait(2000) + self.__fetch_worker.fetchWorkerIsFinished.disconnect(self.onBulletinsFetched) + self.__fetch_worker.fetchWorkerFinishedWithError.disconnect(self.onBulletinsFetchError) + self.__fetch_worker.deleteLater() + self.__fetch_worker = None + self.SyncButton.setEnabled(True) + self.SyncButton.setText("重试") + self.ALSyncStatusLabel.status = ALStatusLabel.Status.FAILURE + self.SyncStatusDetailLabel.setText("同步失败,请检查网络连接") + self.SyncStatusDetailLabel.setStyleSheet("color: red;") + QMessageBox.warning( + self, + "警告 - AutoLibrary", + f"同步失败:{error_message}" + ) + interval_ms = self.__bulletin_mgr.syncInterval()*60*1000 + if self.__sync_timer: + self.__sync_timer.start(interval_ms) + + def clearSyncStatus( + self + ): + + self.ALSyncStatusLabel.status = ALStatusLabel.Status.WAITING + self.SyncStatusDetailLabel.setText("") + self.SyncStatusDetailLabel.setStyleSheet("") + + @Slot(QListWidgetItem) + def onBulletinListWidgetItemClicked( + self, + item: QListWidgetItem + ): + + if item is None or item.data(Qt.UserRole) is None: + return + bulletin = item.data(Qt.UserRole) + if bulletin.get("isNew", False): + bulletin["isNew"] = False + widget = self.BulletinListWidget.itemWidget(item) + widget.markAsRead() + item.setData(Qt.UserRole, bulletin) + self.__bulletin_mgr.markBulletinAsRead(bulletin["id"]) + self.showBulletin(bulletin) diff --git a/src/gui/ALBulletinPoller.py b/src/gui/ALBulletinPoller.py new file mode 100644 index 0000000..fcf05e6 --- /dev/null +++ b/src/gui/ALBulletinPoller.py @@ -0,0 +1,170 @@ +# -*- coding: utf-8 -*- +""" +Copyright (c) 2026 KenanZhu. +All rights reserved. + +This software is provided "as is", without any warranty of any kind. +You may use, modify, and distribute this file under the terms of the MIT License. +See the LICENSE file for details. +""" +from datetime import datetime + +from PySide6.QtCore import ( + QObject, + QTimer, + Signal, + Slot +) + +from gui.ALBulletinDialog import ALBulletinFetchWorker +from managers.bulletin.BulletinManager import instance as bulletinInstance + + +class ALBulletinPoller(QObject): + """ + Background bulletin poller. + + Owns the periodic poll timer and the ALBulletinFetchWorker + lifecycle. Emits signals when new bulletins are detected so + the owner can show tray notifications without knowing the + fetch / merge details. + """ + + newBulletinsDetected = Signal(int) + + def __init__( + self, + parent=None + ): + + super().__init__(parent) + self.__timer = QTimer(self) + self.__timer.timeout.connect(self.__poll) + self.__worker = None + self.__dialog_open = False + self.__mgr = bulletinInstance() + + def start( + self + ): + + interval_ms = self.__mgr.syncInterval()*60*1000 + self.__timer.start(interval_ms) + + def stop( + self + ): + + self.__timer.stop() + self.__cleanupWorker() + + def restart( + self + ): + + self.stop() + self.start() + + def fetchNow( + self + ): + + if self.__worker is not None: + return + if self.__dialog_open: + return + self.__doFetch() + + def isPolling( + self + ) -> bool: + + return self.__timer.isActive() + + def setDialogOpen( + self, + open: bool + ): + + self.__dialog_open = open + + def __doFetch( + self + ): + + params = self.__mgr.getSyncDateTimeAndRange() + self.__worker = ALBulletinFetchWorker( + self, self.__mgr.apiUrl(), params + ) + self.__worker.fetchWorkerIsFinished.connect(self.__onFetched) + self.__worker.fetchWorkerFinishedWithError.connect(self.__onError) + self.__worker.start() + + @Slot() + def __poll( + self + ): + + if self.__worker is not None: + return + if self.__dialog_open: + return + self.__doFetch() + + @Slot(dict) + def __onFetched( + self, + data: dict + ): + + if self.__worker is None: + return + self.__worker.fetchWorkerIsFinished.disconnect(self.__onFetched) + self.__worker.fetchWorkerFinishedWithError.disconnect(self.__onError) + self.__worker.wait(2000) + self.__worker.deleteLater() + self.__worker = None + old_ids = {b["id"] for b in self.__mgr.bulletins()} + bulletins = data.get("bulletins", []) + delete_ids = data.get("delete_ids", []) + self.__mgr.updateAndMergeBulletins(bulletins, delete_ids) + self.__mgr.setLastSyncTime(datetime.now().astimezone().isoformat()) + new_ids = {b["id"] for b in bulletins if b["id"] not in old_ids} + if new_ids: + self.newBulletinsDetected.emit(len(new_ids)) + interval_ms = self.__mgr.syncInterval()*60*1000 + self.__timer.start(interval_ms) + + @Slot(str) + def __onError( + self, + error_message: str + ): + + if self.__worker is None: + return + self.__worker.fetchWorkerIsFinished.disconnect(self.__onFetched) + self.__worker.fetchWorkerFinishedWithError.disconnect(self.__onError) + self.__worker.wait(2000) + self.__worker.deleteLater() + self.__worker = None + interval_ms = self.__mgr.syncInterval()*60*1000 + self.__timer.start(interval_ms) + + def __cleanupWorker( + self + ): + + if self.__worker is None: + return + try: + self.__worker.fetchWorkerIsFinished.disconnect() + except (TypeError, RuntimeError): + pass + try: + self.__worker.fetchWorkerFinishedWithError.disconnect() + except (TypeError, RuntimeError): + pass + self.__worker.wait(2000) + self.__worker.deleteLater() + self.__worker = None diff --git a/src/gui/ALMainWindow.py b/src/gui/ALMainWindow.py index 5d805c6..96f1472 100644 --- a/src/gui/ALMainWindow.py +++ b/src/gui/ALMainWindow.py @@ -32,6 +32,8 @@ from PySide6.QtWidgets import ( from base.MsgBase import MsgBase from gui.ALAboutDialog import ALAboutDialog +from gui.ALBulletinDialog import ALBulletinDialog +from gui.ALBulletinPoller import ALBulletinPoller from gui.ALConfigWidget import ALConfigWidget from gui.ALSettingsWidget import ALSettingsWidget from gui.ALMainWorkers import ( @@ -41,6 +43,7 @@ from gui.ALMainWorkers import ( from gui.ALTimerTaskManageWidget import ALTimerTaskManageWidget from gui.resources import ALResource from gui.resources.ui.Ui_ALMainWindow import Ui_ALMainWindow +from managers.bulletin.BulletinManager import instance as bulletinInstance from managers.config.ConfigUtils import ConfigUtils @@ -62,6 +65,8 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow): self.__alTimerTaskManageWidget = None self.__alConfigWidget = None self.__alSettingsWidget = None + self.__alBulletinDialog = None + self.__bulletin_poller = ALBulletinPoller(self) self.__auto_lib_thread = None self.__current_timer_task_thread = None self.__is_running_timer_task = False @@ -72,6 +77,11 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow): self.connectSignals() self.startMsgPolling() self.startTimerTaskPolling() + + self.__bulletin_poller.start() + if bulletinInstance().autoFetch(): + QTimer.singleShot(1000, self.__bulletin_poller.fetchNow) + self._showLog("主窗口初始化完成") def modifyUi( @@ -84,6 +94,8 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow): self.ManualAction.triggered.connect(self.onManualActionTriggered) self.AboutAction.triggered.connect(self.onAboutActionTriggered) self.SettingsAction.triggered.connect(self.onSettingsActionTriggered) + if hasattr(self, 'BulletinAction'): + self.BulletinAction.triggered.connect(self.onBulletinActionTriggered) # initialize timer task widget, but not show it try: @@ -131,12 +143,14 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow): self.TrayMenu = QMenu() self.TrayMenu.addAction("显示主窗口", self.showNormal) self.TrayMenu.addAction("显示定时窗口", self.onTimerTaskManageWidgetButtonClicked) + self.TrayMenu.addAction("公告栏", self.onBulletinActionTriggered) self.TrayMenu.addAction("最小化到托盘", self.hideToTray) self.TrayMenu.addSeparator() self.TrayMenu.addAction("退出", self.close) self.TrayIcon.setContextMenu(self.TrayMenu) self.TrayIcon.activated.connect(self.onTrayIconActivated) + self.TrayIcon.messageClicked.connect(self.onBulletinActionTriggered) self.TrayIcon.show() def hideToTray( @@ -170,6 +184,10 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow): self.SendButton.clicked.connect(self.onSendButtonClicked) self.MessageEdit.returnPressed.connect(self.onSendButtonClicked) + self.__bulletin_poller.newBulletinsDetected.connect( + self._onBulletinPollerNewBulletins + ) + def closeEvent( self, event: QCloseEvent @@ -195,6 +213,11 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow): if self.__alSettingsWidget: self.__alSettingsWidget.close() # the settings widget is already deleted in the 'self.onSettingsWidgetClosed' + if self.__alBulletinDialog: + self.__alBulletinDialog.close() + # the bulletin dialog is already deleted in the 'self.onBulletinDialogClosed' + if self.__bulletin_poller: + self.__bulletin_poller.stop() self._showLog("主窗口关闭") QMainWindow.closeEvent(self, event) @@ -318,6 +341,46 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow): self.__alSettingsWidget = None self.SettingsAction.setEnabled(True) + @Slot() + def onBulletinActionTriggered( + self + ): + + self.__bulletin_poller.setDialogOpen(True) + if self.__alBulletinDialog is None: + self.__alBulletinDialog = ALBulletinDialog(self) + self.__alBulletinDialog.finished.connect(self.onBulletinDialogClosed) + self.__alBulletinDialog.show() + self.__alBulletinDialog.raise_() + self.__alBulletinDialog.activateWindow() + self._showLog("打开公告栏窗口") + + @Slot() + def onBulletinDialogClosed( + self + ): + + if self.__alBulletinDialog: + self.__alBulletinDialog.finished.disconnect(self.onBulletinDialogClosed) + self.__alBulletinDialog.deleteLater() + self.__alBulletinDialog = None + self.__bulletin_poller.setDialogOpen(False) + + @Slot(int) + def _onBulletinPollerNewBulletins( + self, + count: int + ): + + if not hasattr(self, 'TrayIcon'): + return + self.TrayIcon.showMessage( + "公告栏 - AutoLibrary", + f"有 {count} 条新公告,点击查看详情。", + QSystemTrayIcon.MessageIcon.Information, + 3000 + ) + @Slot() def onSettingsActionTriggered( self diff --git a/src/gui/ALSettingsWidget.py b/src/gui/ALSettingsWidget.py index 7dab1f1..05bf166 100644 --- a/src/gui/ALSettingsWidget.py +++ b/src/gui/ALSettingsWidget.py @@ -9,12 +9,15 @@ See the LICENSE file for details. """ import os import sys +import time +import requests import qtawesome as qta from PySide6.QtCore import ( QProcess, Qt, + QTimer, Signal, Slot ) @@ -30,6 +33,7 @@ from PySide6.QtWidgets import ( ) import managers.config.ConfigManager as ConfigManager +from managers.bulletin.BulletinManager import instance as bulletinInstance from managers.log.LogManager import instance as logInstance from managers.theme.ThemeManager import( getActiveStyle, @@ -96,6 +100,9 @@ class ALSettingsWidget(CenterOnParentMixin, QWidget, Ui_ALSettingsWidget): self.__original_theme: str = "" self.__original_custom_theme: str = "" self.__original_style: str = "" + self.__original_bulletin_url: str = "" + self.__original_bulletin_auto_fetch: bool = False + self.__original_bulletin_sync_interval: int = 10 self.setupUi(self) self.modifyUi() @@ -130,6 +137,7 @@ class ALSettingsWidget(CenterOnParentMixin, QWidget, Ui_ALSettingsWidget): "padding: 5px;" ) self.NavigationList.setCurrentRow(0) + self.NavigationList.currentRowChanged.connect(self.PageStack.setCurrentIndex) self.populateStyles() self.populateCustomThemes() @@ -139,9 +147,12 @@ class ALSettingsWidget(CenterOnParentMixin, QWidget, Ui_ALSettingsWidget): app : QApplication | None = QApplication.instance() color = app.palette().color(app.palette().ColorRole.WindowText).name() - item = self.NavigationList.item(0) - if item: - item.setIcon(qta.icon("fa6s.palette", color=color)) + item0 = self.NavigationList.item(0) + if item0: + item0.setIcon(qta.icon("fa6s.palette", color=color)) + item1 = self.NavigationList.item(1) + if item1: + item1.setIcon(qta.icon("fa6s.bullhorn", color=color)) def populateStyles( self @@ -176,6 +187,7 @@ class ALSettingsWidget(CenterOnParentMixin, QWidget, Ui_ALSettingsWidget): self.RemoveCustomThemeButton.clicked.connect(self.onRemoveCustomThemeButtonClicked) self.CustomThemeComboBox.currentIndexChanged.connect(self.onCustomThemeComboBoxChanged) self.ResetCustomThemeButton.clicked.connect(self.onResetCustomThemeButtonClicked) + self.BulletinTestButton.clicked.connect(self.onBulletinTestButtonClicked) self.CancelButton.clicked.connect(self.onCancelButtonClicked) self.ApplyButton.clicked.connect(self.onApplyButtonClicked) self.ConfirmButton.clicked.connect(self.onConfirmButtonClicked) @@ -207,6 +219,14 @@ class ALSettingsWidget(CenterOnParentMixin, QWidget, Ui_ALSettingsWidget): self.updateCustomThemeInfo() self.updateCustomThemeStatus() + bulletin_mgr = bulletinInstance() + self.__original_bulletin_url = bulletin_mgr.serverUrl() + self.__original_bulletin_auto_fetch = bulletin_mgr.autoFetch() + self.__original_bulletin_sync_interval = bulletin_mgr.syncInterval() + self.BulletinServerUrlEdit.setText(self.__original_bulletin_url) + self.BulletinAutoFetchCheck.setChecked(self.__original_bulletin_auto_fetch) + self.BulletinSyncIntervalSpin.setValue(self.__original_bulletin_sync_interval) + def updateCustomThemeInfo( self ): @@ -270,6 +290,29 @@ class ALSettingsWidget(CenterOnParentMixin, QWidget, Ui_ALSettingsWidget): custom_theme = "" return theme, style, custom_theme + def clearBulletinTestStatus( + self + ): + + self.BulletinTestStatusLabel.setText("") + self.BulletinTestStatusLabel.setStyleSheet("") + + def saveBulletinSettings( + self + ): + + url = self.BulletinServerUrlEdit.text().strip() + auto_fetch = self.BulletinAutoFetchCheck.isChecked() + sync_interval = self.BulletinSyncIntervalSpin.value() + if sync_interval < 1: + sync_interval = 5 + self.__cfg_mgr.set(CfgKey.GLOBAL.BULLETIN.SERVER_URL, url) + self.__cfg_mgr.set(CfgKey.GLOBAL.BULLETIN.AUTO_FETCH, auto_fetch) + self.__cfg_mgr.set(CfgKey.GLOBAL.BULLETIN.SYNC_INTERVAL, sync_interval) + self.__original_bulletin_url = url + self.__original_bulletin_auto_fetch = auto_fetch + self.__original_bulletin_sync_interval = sync_interval + def saveAndApply( self ): @@ -281,8 +324,6 @@ class ALSettingsWidget(CenterOnParentMixin, QWidget, Ui_ALSettingsWidget): if not _applyCustomTheme(custom_theme, theme): self.__cfg_mgr.set(CfgKey.GLOBAL.APPEARANCE.CUSTOM_THEME, "") self.syncRadioFromNeedTheme(custom_theme) - # Re-read theme after syncRadioFromNeedTheme — the radio may have - # changed to match the custom theme's need_theme theme, _, _ = self.collectSettings() self.__cfg_mgr.set(CfgKey.GLOBAL.APPEARANCE.THEME, theme) self.setNavigationIcons() @@ -292,6 +333,8 @@ class ALSettingsWidget(CenterOnParentMixin, QWidget, Ui_ALSettingsWidget): self.__original_custom_theme = custom_theme if custom_theme else "" self.__original_style = getActiveStyle() + self.saveBulletinSettings() + def maybeRestart( self ) -> bool: @@ -380,7 +423,6 @@ class ALSettingsWidget(CenterOnParentMixin, QWidget, Ui_ALSettingsWidget): ): self.updateCustomThemeInfo() - # no status update, because custom theme is not applied yet. @Slot() def onResetCustomThemeButtonClicked( @@ -406,6 +448,43 @@ class ALSettingsWidget(CenterOnParentMixin, QWidget, Ui_ALSettingsWidget): self.updateCustomThemeInfo() self.updateCustomThemeStatus() + @Slot() + def onBulletinTestButtonClicked( + self + ): + + url = self.BulletinServerUrlEdit.text().strip() + if not url: + self.BulletinTestStatusLabel.setText("请先输入服务器地址。") + self.BulletinTestStatusLabel.setStyleSheet("color: red;") + return + self.BulletinTestButton.setEnabled(False) + self.BulletinTestStatusLabel.setText("正在测试连接...") + self.BulletinTestStatusLabel.setStyleSheet("") + try: + api_url = url.rstrip("/") + "/bulletins" + t0 = time.monotonic() + response = requests.get(api_url, timeout=5) + elapsed_ms = (time.monotonic() - t0) * 1000 + response.raise_for_status() + data = response.json() + if data.get("code") == 200: + self.BulletinTestStatusLabel.setText( + f"连接成功!响应延迟 {elapsed_ms:.0f} ms" + ) + self.BulletinTestStatusLabel.setStyleSheet("color: green;") + else: + self.BulletinTestStatusLabel.setText( + f"服务器返回异常: [{data.get('code', '?')}] {data.get('msg', '未知错误')}" + ) + self.BulletinTestStatusLabel.setStyleSheet("color: red;") + except Exception as e: + self.BulletinTestStatusLabel.setText(f"连接失败:{e}") + self.BulletinTestStatusLabel.setStyleSheet("color: red;") + finally: + self.BulletinTestButton.setEnabled(True) + QTimer.singleShot(3000, self.clearBulletinTestStatus) + @Slot() def onCancelButtonClicked( self @@ -429,5 +508,5 @@ class ALSettingsWidget(CenterOnParentMixin, QWidget, Ui_ALSettingsWidget): self ): - self.onApplyButtonClicked() # virtually call apply button clicked + self.onApplyButtonClicked() self.close() diff --git a/src/gui/resources/ui/ALBulletinDialog.ui b/src/gui/resources/ui/ALBulletinDialog.ui new file mode 100644 index 0000000..40fdba5 --- /dev/null +++ b/src/gui/resources/ui/ALBulletinDialog.ui @@ -0,0 +1,385 @@ + + + ALBulletinDialog + + + Qt::WindowModality::NonModal + + + + 0 + 0 + 640 + 450 + + + + + 400 + 450 + + + + + 800 + 450 + + + + 公告栏 - AutoLibrary + + + + 5 + + + 5 + + + 5 + + + 5 + + + 5 + + + + + 10 + + + + + true + + + + 80 + 25 + + + + + 80 + 25 + + + + Qt::FocusPolicy::NoFocus + + + 同步 + + + false + + + + + + + + 30 + 30 + + + + + 30 + 30 + + + + + + + + + + + + 80 + 25 + + + + + 200 + 25 + + + + + + + + + + + + 0 + 25 + + + + + 800 + 25 + + + + QFrame::Shape::NoFrame + + + QFrame::Shadow::Plain + + + 0 + + + + + + + + 50 + 25 + + + + + 50 + 25 + + + + 上次同步: + + + + + + + + 130 + 25 + + + + + 130 + 25 + + + + false + + + true + + + QAbstractSpinBox::ButtonSymbols::NoButtons + + + false + + + yyyy/MM/dd HH:mm:ss + + + Qt::TimeSpec::LocalTime + + + + + + + + + Qt::Orientation::Horizontal + + + + QFrame::Shape::NoFrame + + + QFrame::Shadow::Plain + + + + 5 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + QFrame::Shape::NoFrame + + + QAbstractItemView::EditTrigger::NoEditTriggers + + + QAbstractItemView::SelectionMode::SingleSelection + + + + + + + + QFrame::Shape::NoFrame + + + QFrame::Shadow::Plain + + + + 5 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + + + + + 5 + + + + + + + + + + + + + + + + + + + (已编辑) + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + + + true + + + true + + + + + + + + + + + + 0 + 25 + + + + + 16777215 + 25 + + + + Qt::Orientation::Horizontal + + + QDialogButtonBox::StandardButton::Cancel|QDialogButtonBox::StandardButton::Ok + + + + + + + + + BulletinDialogButtonBox + accepted() + ALBulletinDialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + BulletinDialogButtonBox + rejected() + ALBulletinDialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/src/gui/resources/ui/ALSettingsWidget.ui b/src/gui/resources/ui/ALSettingsWidget.ui index 45ed0e8..03ac1f4 100644 --- a/src/gui/resources/ui/ALSettingsWidget.ui +++ b/src/gui/resources/ui/ALSettingsWidget.ui @@ -101,340 +101,555 @@ + + + 公告 + + + + + - - - QFrame::Shape::NoFrame - - - true - - - - - 0 - -51 - 397 - 434 - + + + + QFrame::Shape::NoFrame - - - 5 + + true + + + + + 0 + 0 + 397 + 434 + - - 3 + + + 5 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + 主题模式 + + + + 5 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + 浅色 + + + + + + + 深色 + + + + + + + 跟随系统 + + + true + + + + + + + + + + 界面风格 + + + + 5 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + 5 + + + + + + 100 + 25 + + + + + 100 + 25 + + + + 应用程序样式: + + + + + + + + 160 + 25 + + + + + + + + + + 更改样式将在下次启动应用程序时生效。 + + + + + + + + + + 自定义外观 + + + + 5 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + 选择一个主题,或导入新的主题文件: + + + true + + + + + + + 5 + + + + + + 160 + 25 + + + + + + + + + 0 + 25 + + + + false + + + 选择或输入 QSS 样式表文件路径... + + + + + + + + 25 + 25 + + + + + 25 + 25 + + + + + + + + + + + + + 25 + 25 + + + + + 25 + 25 + + + + - + + + + + + + + + + 0 + 60 + + + + + + + Qt::TextFormat::RichText + + + Qt::AlignmentFlag::AlignLeading|Qt::AlignmentFlag::AlignLeft|Qt::AlignmentFlag::AlignTop + + + true + + + + + + + 5 + + + + + + 80 + 25 + + + + 重置主题 + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + + + 当前使用程序 默认 外观。 + + + true + + + + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + + + + + QFrame::Shape::NoFrame + + + true + + + + + 0 + 0 + 397 + 434 + - - 3 - - - 3 - - - 3 - - - - - 主题模式 - - - - 5 + + + 5 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + 公告设置 - - 3 + + + 5 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + 5 + + + + + + 100 + 25 + + + + + 100 + 25 + + + + 服务器地址: + + + + + + + + 200 + 25 + + + + https://api.autolibrary.kenanzhu.com + + + + + + + + + 软件启动时自动获取公告 + + + + + + + 5 + + + + + + 100 + 25 + + + + + 100 + 25 + + + + 自动同步间隔: + + + + + + + + 80 + 25 + + + + 1 + + + 1440 + + + 分钟 + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + + + + 100 + 25 + + + + + 100 + 25 + + + + 测试连接 + + + + + + + + + + true + + + + + + + + + + Qt::Orientation::Vertical - - 3 + + + 20 + 40 + - - 3 - - - 3 - - - - - 浅色 - - - - - - - 深色 - - - - - - - 跟随系统 - - - true - - - - - - - - - - 界面风格 - - - - 5 - - - 3 - - - 3 - - - 3 - - - 3 - - - - - 5 - - - - - - 100 - 25 - - - - - 100 - 25 - - - - 应用程序样式: - - - - - - - - 160 - 25 - - - - - - - - - - 更改样式将在下次启动应用程序时生效。 - - - - - - - - - - 自定义外观 - - - - 5 - - - 3 - - - 3 - - - 3 - - - 3 - - - - - 选择一个主题,或导入新的主题文件: - - - true - - - - - - - 5 - - - - - - 160 - 25 - - - - - - - - - 0 - 25 - - - - false - - - 选择或输入 QSS 样式表文件路径... - - - - - - - - 25 - 25 - - - - - 25 - 25 - - - - + - - - - - - - - 25 - 25 - - - - - 25 - 25 - - - - - - - - - - - - - - - 0 - 60 - - - - - - - Qt::TextFormat::RichText - - - Qt::AlignmentFlag::AlignLeading|Qt::AlignmentFlag::AlignLeft|Qt::AlignmentFlag::AlignTop - - - true - - - - - - - 5 - - - - - - 80 - 25 - - - - 重置主题 - - - - - - - Qt::Orientation::Horizontal - - - - 40 - 20 - - - - - - - - - - 当前使用程序 默认 外观。 - - - true - - - - - - - - - - Qt::Orientation::Vertical - - - - 20 - 40 - - - - - + + + + diff --git a/src/interfaces/ConfigProvider.py b/src/interfaces/ConfigProvider.py index d4f180a..ef9c889 100644 --- a/src/interfaces/ConfigProvider.py +++ b/src/interfaces/ConfigProvider.py @@ -72,6 +72,12 @@ class CfgKey: STYLE = ConfigPath(ConfigType.GLOBAL, "appearance.style") CUSTOM_THEME = ConfigPath(ConfigType.GLOBAL, "appearance.custom_theme") + class BULLETIN: + ROOT = ConfigPath(ConfigType.GLOBAL, "bulletin") + AUTO_FETCH = ConfigPath(ConfigType.GLOBAL, "bulletin.auto_fetch") + SERVER_URL = ConfigPath(ConfigType.GLOBAL, "bulletin.server_url") + SYNC_INTERVAL = ConfigPath(ConfigType.GLOBAL, "bulletin.sync_interval") + class TIMERTASK: ROOT = ConfigPath(ConfigType.TIMERTASK, "") TIMER_TASKS = ConfigPath(ConfigType.TIMERTASK, "timer_tasks") diff --git a/src/managers/bulletin/BulletinManager.py b/src/managers/bulletin/BulletinManager.py new file mode 100644 index 0000000..316ff67 --- /dev/null +++ b/src/managers/bulletin/BulletinManager.py @@ -0,0 +1,292 @@ +# -*- coding: utf-8 -*- +""" +Copyright (c) 2026 KenanZhu. +All rights reserved. + +This software is provided "as is", without any warranty of any kind. +You may use, modify, and distribute this file under the terms of the MIT License. +See the LICENSE file for details. +""" +import threading + +from datetime import datetime, timedelta +from typing import Optional + +from interfaces.ConfigProvider import ( + CfgKey, + ConfigProvider +) +from managers.config.ConfigManager import instance as configInstance + + +class BulletinManager: + """ + Bulletin Manager Singleton Class + + Manages bulletin data persistence via ConfigManager and provides + merge, read-status and sync-parameter logic. The HTTP fetch and + UI concerns are handled by the GUI layer. + """ + + def __init__( + self + ): + + self.__lock = threading.Lock() + self.__cfg: ConfigProvider = configInstance() + + def bulletins( + self + ) -> list[dict]: + """ + Get cached bulletins. + + Returns: + list[dict]: Cached bulletin list. + """ + + return self.__cfg.get(CfgKey.BULLETIN.BULLETIN, []) + + def setBulletins( + self, + bulletins: list[dict] + ): + """ + Replace the bulletin cache. + + Args: + bulletins (list[dict]): Bulletin list to store. + """ + + self.__cfg.set(CfgKey.BULLETIN.BULLETIN, bulletins) + + def lastSyncTime( + self + ) -> Optional[str]: + """ + Get last sync time string. + + Returns: + Optional[str]: ISO-format last sync time, or None. + """ + + return self.__cfg.get(CfgKey.BULLETIN.LAST_SYNC_TIME, None) + + def setLastSyncTime( + self, + value: Optional[str] + ): + """ + Set last sync time. + + Args: + value (Optional[str]): ISO-format datetime string. + """ + + self.__cfg.set(CfgKey.BULLETIN.LAST_SYNC_TIME, value) + + def autoFetch( + self + ) -> bool: + """ + Get auto-fetch-on-startup setting. + + Returns: + bool: Whether to fetch bulletins on application startup. + """ + + return self.__cfg.get(CfgKey.GLOBAL.BULLETIN.AUTO_FETCH, False) + + def serverUrl( + self + ) -> str: + """ + Get bulletin server URL. + + Returns: + str: Server base URL. + """ + + return self.__cfg.get( + CfgKey.GLOBAL.BULLETIN.SERVER_URL, + "https://api.autolibrary.kenanzhu.com" + ) + + def syncInterval( + self + ) -> int: + """ + Get auto-sync interval in minutes. + + Values below 1 are clamped to 5 minutes. + + Returns: + int: Sync interval (minutes), minimum 1. + """ + + interval = self.__cfg.get(CfgKey.GLOBAL.BULLETIN.SYNC_INTERVAL, 10) + if interval < 1: + return 5 + return interval + + def apiUrl( + self + ) -> str: + """ + Build the full bulletin API endpoint URL. + + Returns: + str: Full API URL (base + /bulletins). + """ + + base = self.serverUrl().rstrip("/") + return f"{base}/bulletins" + + def isFirstSync( + self + ) -> bool: + """ + Check whether this is the first sync. + + Returns: + bool: True if no cached bulletins or no last sync time. + """ + + last = self.lastSyncTime() + bulletins = self.bulletins() + return last is None or not bulletins + + def shouldFullSync( + self + ) -> bool: + """ + Check whether a full sync is needed. + + A full sync is triggered when the last sync time is more than + one hour ago. + + Returns: + bool: True if a full sync should be performed. + """ + + last = self.lastSyncTime() + if last is None: + return True + try: + last_dt = datetime.fromisoformat(last) + return (datetime.now().astimezone() - last_dt) > timedelta(hours=1) + except (ValueError, TypeError): + return True + + def getSyncDateTimeAndRange( + self + ) -> dict: + """ + Calculate the date / time / range_hour query parameters. + + Returns: + dict: Keys "date", "time", "range_hour" for the API request. + """ + + if self.isFirstSync(): + start_date = datetime.now() - timedelta(days=7) + range_hour = str(24 * (7 + 1)) + elif self.shouldFullSync(): + bulletins = self.bulletins() + earliest = min(bulletins, key=lambda x: x.get("dateTime", "")) + start_date = datetime.fromisoformat(earliest["dateTime"]) + diff = datetime.now().astimezone() - start_date + range_hour = str(int(diff.total_seconds() / 3600) + 1) + else: + bulletins = self.bulletins() + latest = max(bulletins, key=lambda x: x.get("dateTime", "")) + start_date = datetime.fromisoformat(latest["dateTime"]) + diff = datetime.now().astimezone() - start_date + range_hour = str(int(diff.total_seconds() / 3600) + 1) + return { + "date": start_date.strftime("%Y-%m-%d"), + "time": start_date.strftime("%H:%M:%S"), + "range_hour": range_hour, + } + + def updateAndMergeBulletins( + self, + new_bulletins: list[dict], + delete_ids: list[str] + ) -> list[dict]: + """ + Merge incoming bulletins into the cache. + + New bulletins are added with isNew=True. Existing bulletins + keep their current isNew state. Entries listed in delete_ids + are removed. + + Args: + new_bulletins (list[dict]): Incoming bulletin list. + delete_ids (list[str]): IDs to remove. + + Returns: + list[dict]: Merged bulletin list sorted by id. + """ + + with self.__lock: + delete_set = set(delete_ids) + bulletins_dict = {b["id"]: b for b in self.bulletins()} + for bulletin in new_bulletins: + bid = bulletin["id"] + if bid in delete_set: + bulletins_dict.pop(bid, None) + continue + if bid not in bulletins_dict: + bulletin["isNew"] = True + else: + bulletin["isNew"] = bulletins_dict[bid].get("isNew", True) + bulletins_dict[bid] = bulletin + for bid in delete_set: + bulletins_dict.pop(bid, None) + result = list(bulletins_dict.values()) + result.sort(key=lambda x: int(x["id"])) + self.setBulletins(result) + return result + + def markBulletinAsRead( + self, + bulletin_id: str + ): + """ + Mark a bulletin as read by its id. + + Args: + bulletin_id (str): The bulletin id. + """ + + with self.__lock: + bulletins = self.bulletins() + for b in bulletins: + if b["id"] == bulletin_id: + b["isNew"] = False + self.setBulletins(bulletins) + break + + +# BulletinManager singleton instance. +_bulletin_manager_instance = None + +# Singleton instance lock. +_instance_lock = threading.Lock() + + +def instance( +) -> BulletinManager: + """ + Get the BulletinManager singleton instance. + + Returns: + BulletinManager: The singleton BulletinManager instance. + """ + + global _bulletin_manager_instance + with _instance_lock: + if _bulletin_manager_instance is None: + _bulletin_manager_instance = BulletinManager() + return _bulletin_manager_instance diff --git a/src/managers/bulletin/__init__.py b/src/managers/bulletin/__init__.py new file mode 100644 index 0000000..5cb1dac --- /dev/null +++ b/src/managers/bulletin/__init__.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +""" +Copyright (c) 2026 KenanZhu. +All rights reserved. + +This software is provided "as is", without any warranty of any kind. +You may use, modify, and distribute this file under the terms of the MIT License. +See the LICENSE file for details. +""" diff --git a/src/managers/config/ConfigManager.py b/src/managers/config/ConfigManager.py index 0f54f13..7010d46 100644 --- a/src/managers/config/ConfigManager.py +++ b/src/managers/config/ConfigManager.py @@ -59,6 +59,11 @@ class ConfigTemplate: "theme": "system", "style": "Fusion", "custom_theme": "" + }, + "bulletin": { + "auto_fetch": False, + "server_url": "https://api.autolibrary.kenanzhu.com", + "sync_interval": 10 } } case ConfigType.BULLETIN: