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: