wuzilong před 11 měsíci
rodič
revize
d7781a5caa

+ 1 - 1
admin/src/api/admin.ts

@@ -61,7 +61,7 @@ export const logApi = {
 
 // 公告管理
 export const announcementApi = {
-  getList: () => apiClient.get('/admin/announcements'),
+  getList: (params?: any) => apiClient.get('/admin/announcements', { params }),
   getDetail: (id: number) => apiClient.get(`/admin/announcements/${id}`),
   create: (data: any) => apiClient.post('/admin/announcements', data),
   update: (id: number, data: any) =>

+ 51 - 3
admin/src/pages/Announcements/index.tsx

@@ -35,6 +35,12 @@ interface Announcement {
 export default function Announcements() {
   const [loading, setLoading] = useState(false)
   const [data, setData] = useState<Announcement[]>([])
+  const [keyword, setKeyword] = useState<string>('')
+  const [pagination, setPagination] = useState({
+    current: 1,
+    pageSize: 10,
+    total: 0,
+  })
   const [modalVisible, setModalVisible] = useState(false)
   const [editingId, setEditingId] = useState<number | null>(null)
   const [form] = Form.useForm()
@@ -130,12 +136,25 @@ export default function Announcements() {
     },
   ]
 
-  const fetchData = async () => {
+  const fetchData = async (
+    page: number = pagination.current,
+    pageSize: number = pagination.pageSize,
+    searchKeyword: string = keyword
+  ) => {
     setLoading(true)
     try {
-      const response = await announcementApi.getList()
+      const response = await announcementApi.getList({
+        page,
+        page_size: pageSize,
+        keyword: searchKeyword || undefined,
+      })
       if (response.code === 200) {
         setData(response.data || [])
+        setPagination({
+          current: response.page || page,
+          pageSize: response.page_size || pageSize,
+          total: response.total || 0,
+        })
       }
     } catch (error) {
       message.error('获取公告列表失败')
@@ -148,6 +167,16 @@ export default function Announcements() {
     fetchData()
   }, [])
 
+  const handleTableChange = (pag: any) => {
+    const { current, pageSize } = pag
+    fetchData(current, pageSize)
+  }
+
+  const handleSearch = () => {
+    // 搜索时从第一页开始
+    fetchData(1, pagination.pageSize, keyword)
+  }
+
   const handleCreate = async (values: any) => {
     try {
       const submitData = {
@@ -231,7 +260,17 @@ export default function Announcements() {
   return (
     <div>
       <div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
-        <h2>公告管理</h2>
+        <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
+          <h2 style={{ marginBottom: 0 }}>公告管理</h2>
+          <Input.Search
+            allowClear
+            placeholder="按标题或内容搜索"
+            value={keyword}
+            onChange={(e) => setKeyword(e.target.value)}
+            onSearch={handleSearch}
+            style={{ width: 260 }}
+          />
+        </div>
         <Button
           type="primary"
           onClick={() => {
@@ -253,6 +292,15 @@ export default function Announcements() {
         dataSource={data}
         loading={loading}
         rowKey="id"
+        pagination={{
+          current: pagination.current,
+          pageSize: pagination.pageSize,
+          total: pagination.total,
+          showSizeChanger: true,
+          pageSizeOptions: [10, 20, 50],
+          showTotal: (total) => `共 ${total} 条`,
+        }}
+        onChange={handleTableChange}
       />
       <Modal
         title={editingId ? '编辑公告' : '创建公告'}

+ 42 - 11
backend/app/api/admin/announcements.py

@@ -1,32 +1,60 @@
 """
 公告管理接口
 """
-from fastapi import APIRouter, Depends
-from sqlalchemy.orm import Session
+from datetime import datetime
+from typing import Optional
+
+from fastapi import APIRouter, Depends, Query
 from pydantic import BaseModel
-from app.database import get_db
+from sqlalchemy import or_
+from sqlalchemy.orm import Session
+
 from app.api.deps import get_current_admin
+from app.database import get_db
 from app.models.announcement import Announcement
-from datetime import datetime
 
 router = APIRouter()
 
+
 class AnnouncementCreate(BaseModel):
     """创建公告请求"""
     title: str
     content: str
     priority: int = 0
     is_active: bool = True
-    publish_time: str = None  # ISO格式时间字符串
+    publish_time: Optional[str] = None  # ISO格式时间字符串
+
 
 @router.get("")
 async def get_announcements(
+    page: int = Query(1, ge=1),
+    page_size: int = Query(10, ge=1, le=100),
+    keyword: Optional[str] = Query(None, description="搜索关键词(标题、内容)"),
     db: Session = Depends(get_db),
-    current_admin = Depends(get_current_admin)
+    current_admin=Depends(get_current_admin),
 ):
-    """获取公告列表"""
-    announcements = db.query(Announcement).all()
-    
+    """获取公告列表(支持检索与分页),按发布时间倒序"""
+    offset = (page - 1) * page_size
+
+    query = db.query(Announcement)
+
+    # 关键字搜索:标题或内容模糊匹配
+    if keyword:
+        like_pattern = f"%{keyword}%"
+        query = query.filter(
+            or_(
+                Announcement.title.like(like_pattern),
+                Announcement.content.like(like_pattern),
+            )
+        )
+
+    # 按发布时间倒序,其次按ID倒序,确保最新的数据在前
+    # 这里不使用 nullslast,以避免部分数据库方言不支持导致的错误
+    query = query.order_by(Announcement.publish_time.desc(), Announcement.id.desc())
+
+    total = query.count()
+    announcements = query.offset(offset).limit(page_size).all()
+
     return {
         "code": 200,
         "msg": "成功",
@@ -39,10 +67,13 @@ async def get_announcements(
                 "is_active": bool(a.is_active),
                 "publish_time": a.publish_time.isoformat() if a.publish_time else None,
                 "created_at": a.created_at.isoformat() if a.created_at else None,
-                "updated_at": a.updated_at.isoformat() if a.updated_at else None
+                "updated_at": a.updated_at.isoformat() if a.updated_at else None,
             }
             for a in announcements
-        ]
+        ],
+        "total": total,
+        "page": page,
+        "page_size": page_size,
     }
 
 @router.get("/{announcement_id}")

+ 43 - 20
backend/app/api/client/announcement.py

@@ -1,37 +1,60 @@
 """
-公告接口
+公告接口(客户端使用)
 """
-from fastapi import APIRouter, Depends
+from typing import Optional
+
+from fastapi import APIRouter, Depends, Query
 from sqlalchemy.orm import Session
+from sqlalchemy import or_
+
 from app.database import get_db
 from app.models.announcement import Announcement
 
 router = APIRouter()
 
+
 @router.get("")
-async def get_announcement(db: Session = Depends(get_db)):
-    """获取公告列表"""
-    # 获取所有启用的公告,按优先级和创建时间排序
-    announcements = db.query(Announcement).filter(
-        Announcement.is_active == 1
-    ).order_by(Announcement.priority.desc(), Announcement.created_at.desc()).all()
-    
-    if not announcements:
-        return {
-            "code": 200,
-            "msg": "成功",
-            "data": []
-        }
-    
+async def get_announcement(
+    page: int = Query(1, ge=1),
+    page_size: int = Query(10, ge=1, le=100),
+    keyword: Optional[str] = Query(None, description="搜索关键词(标题、内容)"),
+    db: Session = Depends(get_db),
+):
+    """获取公告列表(支持检索与分页),按发布时间倒序"""
+    offset = (page - 1) * page_size
+
+    query = db.query(Announcement).filter(Announcement.is_active == 1)
+
+    if keyword:
+        like_pattern = f"%{keyword}%"
+        query = query.filter(
+            or_(
+                Announcement.title.like(like_pattern),
+                Announcement.content.like(like_pattern),
+            )
+        )
+
+    # 按发布时间倒序,其次按ID倒序,确保最新的在最前面
+    # 这里不使用 nullslast,以避免部分数据库方言不支持导致的错误
+    query = query.order_by(Announcement.publish_time.desc(), Announcement.id.desc())
+
+    total = query.count()
+    announcements = query.offset(offset).limit(page_size).all()
+
     return {
         "code": 200,
         "msg": "成功",
         "data": [
             {
-                "title": announcement.title,
-                "content": announcement.content
+                "id": a.id,
+                "title": a.title,
+                "content": a.content,
+                "publish_time": a.publish_time.isoformat() if a.publish_time else None,
             }
-            for announcement in announcements
-        ]
+            for a in announcements
+        ],
+        "total": total,
+        "page": page,
+        "page_size": page_size,
     }
 

+ 1 - 1
client/config/user_data.enc

@@ -1 +1 @@
-gAAAAABpH8WyW_NQdim3LiJSN48n9B4hjeUwfLGXXujl5EiSqj5017Nppb2l2RG3uWAmVHrT7zXxMDXoWvqB1QS2hFVL1v4NOweeQpcYGQx8DW67X9M8qD_fGEZv8jjMmaLz7iqe8P4nc43vrl9PPce_so8pT-rnnjTUEXEEpdQaNkcRRO1Mh3z1SkA01HTAMpkDHcg1SgDgAofsPTjJcXlyiWxWzDkfFonzR0XnNHz9uH8HoKnnA4bANtui9ShA6_zEpGyajf2gggcpsotM1C1lINl_oho1mDzeh3eUaHYSD2dZxieBUY4FbA2jBfId5C9Y0oIOPNEf5Geh29KbTNmZ_MoOR01KLgwRG0V5YXlFr7jzgQCgC4DmMx3u4aQVGeHysCJjif5TOZrPcOlDpjdRrIsN1owIPTUoG72B9AYAYgyFuv-JOnir3ziLIHkuyFgUJAVARUb0tuiSZtQm7JM8jQ_PBHwIuy1kmYcoLvr-tSIJ60Pk2o1HFEBZvgnnmpOoSGjuZ-AK
+gAAAAABpH9UWFqO6M6WZCbn-TSBMqo_9KcKh7VMWxMBzEcBgZRTSB8oG0OURiU9prccYqVSH8KgjGf3UAgomJgzCf_jO_eQ1VMWDwQqZ8JtfA9WFxQQ4k-oro15PW143meQzGbHD9gHcKdwM-76zWqQr50Cd3wg0e4qSKtMY_4TMMz37dZDt5vpGUxVPgaBTETS9MnhiRvrKe1rrPYZB6rr1QBPvLSUnEpt39o8JbWjnBGckdrqQVwRPsTjYxyRRxPfBDK5y1G9I3CCTr6E4lu8j3DbhWddjUM5pPOwi1iafMLQ8ITJ-u_6Br2vqNYglUb_ck4VEoF39Knc3x3vXTvtulflCTn6m0uMfiZLtcXzeBh-M0DVxOPYBjI41S7wjz0HvqcYy3FBSusncwfyYhY1OPm1ECMvJ9AUSwztbJ3FQkcTY1cFNcaU0KTJS7eHmFpk6uqiEe5wYBK0Ab9KUyBu5GGRKqp7HYkR4Iwhyyk4rjj2IrQ7FQD1EGNTD3VKdC99HIxiVsm17

+ 10 - 4
client/src/api/client.py

@@ -179,11 +179,17 @@ class APIClient:
             return
         self.refresh_public_key()
     
-    def get_announcement(self) -> Dict[str, Any]:
-        """获取公告"""
-        # 公告接口不需要加密
+    def get_announcement(self, page: int = 1, page_size: int = 10, keyword: Optional[str] = None) -> Dict[str, Any]:
+        """获取公告(支持分页与检索)"""
+        # 公告接口不需要加密,使用查询参数
         url = f"{self.base_url}/api/announcement"
-        response = self.session.get(url, timeout=API_TIMEOUT)
+        params: Dict[str, Any] = {
+            "page": page,
+            "page_size": page_size,
+        }
+        if keyword:
+            params["keyword"] = keyword
+        response = self.session.get(url, params=params, timeout=API_TIMEOUT)
         response.raise_for_status()
         return response.json()
     

+ 159 - 17
client/src/ui/main_window.py

@@ -53,7 +53,7 @@ class MainWindow(FluentWindow):
         self.setMinimumSize(WINDOW_MIN_WIDTH, WINDOW_MIN_HEIGHT)
         self.resize(WINDOW_DEFAULT_WIDTH, WINDOW_DEFAULT_HEIGHT)
         
-        # 配置导航界面(使用 FluentWindow 自带的 navigationInterface,关闭返回/菜单/折叠)
+        # 配置导航界面(使用 FluentWindow 自带的 navigationInterface,关闭返回/菜单/折叠,但不参与布局展示)
         self.navigationInterface.setMenuButtonVisible(False)
         self.navigationInterface.setReturnButtonVisible(False)
         self.navigationInterface.setCollapsible(False)
@@ -77,8 +77,18 @@ class MainWindow(FluentWindow):
         
         self.settingsInterface = self.create_settings_interface()
         self.settingsInterface.setObjectName("设置")
-        
-        # 添加导航项
+
+        # 路由 key 对应的实际界面组件,用于侧边菜单切换
+        self.route_widgets = {
+            "充值": self.rechargeInterface,
+            "Steam账号": self.steamInterface,
+            "选品": self.selectionInterface,
+            "扫货": self.sweepInterface,
+            "公告": self.announcementInterface,
+            "设置": self.settingsInterface,
+        }
+        
+        # 添加导航项(隐藏导航栏用作路由)
         self.addSubInterface(
             self.rechargeInterface,
             qta.icon('fa5s.money-bill-wave', color='#666'),
@@ -127,6 +137,7 @@ class MainWindow(FluentWindow):
         
         # 设置初始界面
         self.navigationInterface.setCurrentItem("充值")
+        self.set_active_menu("充值")
     
     def setup_shortcuts(self):
         """设置快捷键"""
@@ -136,7 +147,7 @@ class MainWindow(FluentWindow):
 
     def setup_layout_structure(self):
         """自定义上下+左右布局结构"""
-        # 将导航从基础布局中移除,便于重新布置
+        # 将导航从基础布局中移除,便于重新布置(导航只用于路由,不直接展示)
         self.hBoxLayout.removeWidget(self.navigationInterface)
         self.widgetLayout.removeWidget(self.stackedWidget)
         self.widgetLayout.setContentsMargins(0, 0, 0, 0)
@@ -151,15 +162,16 @@ class MainWindow(FluentWindow):
         self.headerBar = self.create_header_bar()
         self.mainContainerLayout.addWidget(self.headerBar, 0)
 
-        # 主体区域:左侧菜单 + 右侧内容
+        # 主体区域:左侧自定义菜单 + 右侧内容
         self.bodyContainer = QWidget(self)
         self.bodyLayout = QHBoxLayout(self.bodyContainer)
         self.bodyLayout.setContentsMargins(0, 0, 0, 0)
         self.bodyLayout.setSpacing(0)
 
-        self.navigationInterface.setMinimumWidth(220)
-        self.navigationInterface.setMaximumWidth(260)
-        self.bodyLayout.addWidget(self.navigationInterface, 0)
+        # 左侧自定义菜单
+        self.sideMenu = self.create_side_menu()
+        self.bodyLayout.addWidget(self.sideMenu, 0)
+
         self.bodyLayout.addWidget(self.stackedWidget, 1)
 
         self.mainContainerLayout.addWidget(self.bodyContainer, 1)
@@ -201,12 +213,102 @@ class MainWindow(FluentWindow):
         
         return logo_label
 
+    def create_side_menu(self) -> QWidget:
+        """创建左侧自定义菜单栏"""
+        from PyQt5.QtWidgets import QPushButton
+
+        menu = QWidget(self)
+        menu.setObjectName("SideMenu")
+        layout = QVBoxLayout(menu)
+        layout.setContentsMargins(8, 16, 8, 16)
+        layout.setSpacing(4)
+
+        self._side_menu_buttons = {}
+
+        def add_menu_button(route_key: str, text: str, icon):
+            btn = QPushButton(text, menu)
+            btn.setCheckable(True)
+            btn.setCursor(Qt.PointingHandCursor)
+            btn.setIcon(icon)
+            btn.clicked.connect(lambda: self.on_side_menu_clicked(route_key))
+            layout.addWidget(btn)
+            self._side_menu_buttons[route_key] = btn
+
+        add_menu_button("充值", "充值", qta.icon('fa5s.money-bill-wave', color='#666'))
+        add_menu_button("Steam账号", "Steam账号", qta.icon('fa5s.gamepad', color='#666'))
+        add_menu_button("选品", "选品", qta.icon('fa5s.shopping-cart', color='#666'))
+        add_menu_button("扫货", "扫货", FluentIcon.SEARCH.icon())
+        add_menu_button("公告", "公告", qta.icon('fa5s.bullhorn', color='#666'))
+
+        layout.addStretch(1)
+
+        # 底部设置按钮
+        settings_btn = QPushButton("设置", menu)
+        settings_btn.setCheckable(True)
+        settings_btn.setCursor(Qt.PointingHandCursor)
+        settings_btn.setIcon(FluentIcon.SETTING.icon())
+        settings_btn.clicked.connect(lambda: self.on_side_menu_clicked("设置"))
+        layout.addWidget(settings_btn)
+        self._side_menu_buttons["设置"] = settings_btn
+
+        menu.setFixedWidth(140)
+        menu.setStyleSheet("""
+            QWidget#SideMenu {
+                background-color: #f5f7fb;
+                border-right: 1px solid #e5e7eb;
+            }
+            QWidget#SideMenu QPushButton {
+                border: none;
+                text-align: left;
+                padding: 6px 12px;
+                border-radius: 6px;
+                color: #374151;
+            }
+            QWidget#SideMenu QPushButton:hover {
+                background-color: #e5f3ff;
+            }
+            QWidget#SideMenu QPushButton:checked {
+                background-color: #dbeafe;
+                color: #1d4ed8;
+                font-weight: 600;
+            }
+        """)
+
+        return menu
+
+    def on_side_menu_clicked(self, route_key: str):
+        """侧边菜单点击,切换到对应子界面"""
+        # 通过隐藏的 NavigationInterface 和路由映射切换实际页面
+        try:
+            self.navigationInterface.setCurrentItem(route_key)
+        except Exception:
+            pass
+
+        widget = getattr(self, "route_widgets", {}).get(route_key)
+        if widget is not None:
+            # 使用 FluentWindow 自带的切换方法,保持过渡效果一致
+            try:
+                self.switchTo(widget)
+            except Exception:
+                # 兜底:直接切换 QStackedWidget 当前页
+                self.stackedWidget.setCurrentWidget(widget)
+
+        self.set_active_menu(route_key)
+
+    def set_active_menu(self, route_key: str):
+        """高亮当前菜单按钮"""
+        if not hasattr(self, "_side_menu_buttons"):
+            return
+        for key, btn in self._side_menu_buttons.items():
+            btn.setChecked(key == route_key)
+
     def create_header_bar(self) -> QWidget:
         """创建顶部标题栏"""
         header = QWidget(self)
         header.setObjectName("HeaderBar")
         header_layout = QHBoxLayout(header)
-        header_layout.setContentsMargins(24, 12, 24, 12)
+        # 右侧整体留白 10px
+        header_layout.setContentsMargins(24, 12, 10, 12)
         header_layout.setSpacing(16)
 
         # 左侧:logo + 软件名称
@@ -229,29 +331,69 @@ class MainWindow(FluentWindow):
 
         header_layout.addWidget(left_container, 1)
 
-        # 右侧:倒计时 + 用户名 + 退出按钮
+        # 右侧:窗口控制按钮 + 倒计时 + 用户名 + 退出按钮
         right_container = QWidget(header)
-        right_layout = QHBoxLayout(right_container)
+        right_layout = QVBoxLayout(right_container)
         right_layout.setContentsMargins(0, 0, 0, 0)
-        right_layout.setSpacing(12)
+        right_layout.setSpacing(4)
+
+        # 顶部:窗口控制按钮(最小化 / 全屏 / 关闭)
+        window_ctrl_layout = QHBoxLayout()
+        window_ctrl_layout.setContentsMargins(0, 0, 0, 0)
+        window_ctrl_layout.setSpacing(6)
+
+        # 先添加弹性空间,将按钮整体推到最右侧(距离右边 10px 由 header_layout 控制)
+        window_ctrl_layout.addStretch(1)
+
+        minimize_btn = QPushButton(right_container)
+        minimize_btn.setIcon(qta.icon('fa5s.window-minimize', color='#6b7280'))
+        minimize_btn.setFixedSize(26, 26)
+        minimize_btn.setFlat(True)
+        minimize_btn.setCursor(Qt.PointingHandCursor)
+        minimize_btn.clicked.connect(self.showMinimized)
+        window_ctrl_layout.addWidget(minimize_btn)
+
+        fullscreen_btn = QPushButton(right_container)
+        fullscreen_btn.setIcon(qta.icon('fa5s.expand-arrows-alt', color='#6b7280'))
+        fullscreen_btn.setFixedSize(26, 26)
+        fullscreen_btn.setFlat(True)
+        fullscreen_btn.setCursor(Qt.PointingHandCursor)
+        fullscreen_btn.clicked.connect(self.toggle_fullscreen)
+        window_ctrl_layout.addWidget(fullscreen_btn)
+
+        close_btn = QPushButton(right_container)
+        close_btn.setIcon(qta.icon('fa5s.times', color='#ef4444'))
+        close_btn.setFixedSize(26, 26)
+        close_btn.setFlat(True)
+        close_btn.setCursor(Qt.PointingHandCursor)
+        close_btn.clicked.connect(self.close)
+        window_ctrl_layout.addWidget(close_btn)
+        right_layout.addLayout(window_ctrl_layout)
+
+        # 底部:倒计时 + 用户名 + 退出按钮
+        info_layout = QHBoxLayout()
+        info_layout.setContentsMargins(0, 0, 0, 0)
+        info_layout.setSpacing(12)
 
         expire_label = QLabel("卡密倒计时: 加载中...", right_container)
         expire_label.setObjectName("HeaderExpireLabel")
         self.register_expire_label(expire_label, prefix="卡密倒计时")
-        right_layout.addWidget(expire_label, 0, Qt.AlignmentFlag.AlignVCenter)
+        info_layout.addWidget(expire_label, 0, Qt.AlignmentFlag.AlignVCenter)
 
         username = self.user_info.get("username", "未知")
         self.header_user_label = QLabel(f"当前用户: {username}", right_container)
         self.header_user_label.setStyleSheet("color: #4b5563; font-size: 14px;")
-        right_layout.addWidget(self.header_user_label, 0, Qt.AlignmentFlag.AlignVCenter)
+        info_layout.addWidget(self.header_user_label, 0, Qt.AlignmentFlag.AlignVCenter)
 
         logout_btn = PrimaryPushButton("退出登录", right_container)
         logout_btn.setIcon(qta.icon('fa5s.sign-out-alt', color='white'))
-        logout_btn.setFixedHeight(36)
+        logout_btn.setFixedHeight(32)
         logout_btn.clicked.connect(self.handle_logout)
-        right_layout.addWidget(logout_btn, 0, Qt.AlignmentFlag.AlignVCenter)
+        info_layout.addWidget(logout_btn, 0, Qt.AlignmentFlag.AlignVCenter)
+
+        right_layout.addLayout(info_layout)
 
-        header_layout.addWidget(right_container, 0)
+        header_layout.addWidget(right_container, 0, Qt.AlignmentFlag.AlignVCenter)
         header_layout.setStretchFactor(left_container, 1)
 
         header.setStyleSheet("""

+ 173 - 63
client/src/ui/tabs/announcement_tab.py

@@ -1,9 +1,19 @@
 """
 公告Tab
 """
-from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel, QTextEdit
+from PyQt5.QtWidgets import (
+    QWidget,
+    QVBoxLayout,
+    QLabel,
+    QHBoxLayout,
+    QLineEdit,
+    QPushButton,
+    QTableWidget,
+    QTableWidgetItem,
+    QHeaderView,
+)
 from PyQt5.QtCore import Qt, QThread, pyqtSignal
-from qfluentwidgets import CardWidget
+from qfluentwidgets import CardWidget, PrimaryPushButton
 import qtawesome as qta
 from src.utils.logger import get_logger
 from src.api.client import api_client
@@ -12,16 +22,25 @@ logger = get_logger(__name__)
 
 class LoadAnnouncementThread(QThread):
     """加载公告线程"""
-    success = pyqtSignal(list)  # 改为列表类型
+    success = pyqtSignal(dict)  # 返回完整结果:列表 + 分页信息
     error = pyqtSignal(str)
-    
+
+    def __init__(self, page: int = 1, page_size: int = 10, keyword: str = ""):
+        super().__init__()
+        self.page = page
+        self.page_size = page_size
+        self.keyword = keyword
+
     def run(self):
         """执行加载"""
         try:
-            result = api_client.get_announcement()
+            result = api_client.get_announcement(
+                page=self.page,
+                page_size=self.page_size,
+                keyword=self.keyword or None,
+            )
             if result.get("code") == 200:
-                data = result.get("data", [])  # 改为列表
-                self.success.emit(data)
+                self.success.emit(result)
             else:
                 self.error.emit(result.get("msg", "获取公告失败"))
         except Exception as e:
@@ -33,6 +52,10 @@ class AnnouncementTab(QWidget):
     
     def __init__(self):
         super().__init__()
+        self.current_page = 1
+        self.page_size = 10
+        self.total = 0
+        self.keyword = ""
         self.init_ui()
         self.load_announcement()
     
@@ -42,107 +65,194 @@ class AnnouncementTab(QWidget):
         layout.setSpacing(20)
         layout.setContentsMargins(30, 30, 30, 30)
         
-        # 标题
+        # 顶部:标题 + 搜索
+        header_layout = QHBoxLayout()
+        header_layout.setContentsMargins(0, 0, 0, 0)
+        header_layout.setSpacing(12)
+
         title = QLabel("产品公告")
         title.setStyleSheet("font-size: 24px; font-weight: bold; color: #333;")
-        layout.addWidget(title)
+        header_layout.addWidget(title, 0, Qt.AlignmentFlag.AlignVCenter)
+
+        header_layout.addStretch(1)
+
+        self.search_input = QLineEdit()
+        self.search_input.setPlaceholderText("按标题或内容搜索公告")
+        self.search_input.setFixedWidth(260)
+        header_layout.addWidget(self.search_input, 0, Qt.AlignmentFlag.AlignVCenter)
+
+        search_btn = PrimaryPushButton("搜索")
+        search_btn.clicked.connect(self.on_search_clicked)
+        header_layout.addWidget(search_btn, 0, Qt.AlignmentFlag.AlignVCenter)
+
+        layout.addLayout(header_layout)
         
-        # 公告内容卡片
+        # 公告列表卡片(表格形式,每行一个公告)
         announcement_card = CardWidget(self)
         announcement_card_layout = QVBoxLayout()
         announcement_card_layout.setContentsMargins(20, 20, 20, 20)
-        
+
         # 加载提示
         self.loading_label = QLabel("加载中...")
         self.loading_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
-        self.loading_label.setStyleSheet("color: #999; padding: 40px; font-size: 14px;")
+        self.loading_label.setStyleSheet("color: #999; padding: 24px; font-size: 14px;")
         announcement_card_layout.addWidget(self.loading_label)
-        
-        self.content = QTextEdit()
-        self.content.setReadOnly(True)
-        self.content.setStyleSheet("""
-            QTextEdit {
-                border: 1px solid #f0f0f0;
-                border-radius: 4px;
-                padding: 16px;
-                font-size: 14px;
-                background-color: #fff;
-                line-height: 1.6;
-            }
-        """)
-        self.content.hide()  # 初始隐藏
-        announcement_card_layout.addWidget(self.content)
-        
+
+        # 公告表格
+        self.table = QTableWidget()
+        self.table.setColumnCount(3)
+        self.table.setHorizontalHeaderLabels(["标题", "内容", "发布时间"])
+        self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
+        self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
+        self.table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
+        self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
+        self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
+        self.table.setAlternatingRowColors(True)
+        self.table.hide()  # 初始隐藏
+        announcement_card_layout.addWidget(self.table)
+
         # 空状态提示
         self.empty_label = QLabel("暂无公告")
         self.empty_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
-        self.empty_label.setStyleSheet("color: #999; padding: 40px; font-size: 14px;")
+        self.empty_label.setStyleSheet("color: #999; padding: 24px; font-size: 14px;")
         self.empty_label.hide()
         announcement_card_layout.addWidget(self.empty_label)
-        
+
         # 错误提示
         self.error_label = QLabel("")
         self.error_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
-        self.error_label.setStyleSheet("color: #ff4d4f; padding: 40px; font-size: 14px;")
+        self.error_label.setStyleSheet("color: #ff4d4f; padding: 24px; font-size: 14px;")
         self.error_label.hide()
         announcement_card_layout.addWidget(self.error_label)
-        
+
         announcement_card.setLayout(announcement_card_layout)
         layout.addWidget(announcement_card)
+
+        # 分页区域
+        pagination_layout = QHBoxLayout()
+        pagination_layout.setContentsMargins(4, 0, 4, 0)
+        pagination_layout.setSpacing(8)
+
+        self.page_info_label = QLabel("")
+        self.page_info_label.setStyleSheet("color: #6b7280; font-size: 13px;")
+        pagination_layout.addWidget(self.page_info_label)
+
+        pagination_layout.addStretch(1)
+
+        self.prev_btn = QPushButton("上一页")
+        self.prev_btn.clicked.connect(self.on_prev_page)
+        self.prev_btn.setEnabled(False)
+        pagination_layout.addWidget(self.prev_btn)
+
+        self.next_btn = QPushButton("下一页")
+        self.next_btn.clicked.connect(self.on_next_page)
+        self.next_btn.setEnabled(False)
+        pagination_layout.addWidget(self.next_btn)
+
+        layout.addLayout(pagination_layout)
         
         layout.addStretch()
         self.setLayout(layout)
     
-    def load_announcement(self):
+    def load_announcement(self, page: int = None):
         """加载公告(异步)"""
+        if page is not None:
+            self.current_page = page
+
         # 显示加载状态
         self.loading_label.show()
-        self.content.hide()
+        self.table.hide()
         self.empty_label.hide()
         self.error_label.hide()
-        
+
         # 创建线程执行加载
-        self.load_thread = LoadAnnouncementThread()
+        self.load_thread = LoadAnnouncementThread(
+            page=self.current_page,
+            page_size=self.page_size,
+            keyword=self.keyword,
+        )
         self.load_thread.success.connect(self.on_load_success)
         self.load_thread.error.connect(self.on_load_error)
         self.load_thread.start()
-    
-    def on_load_success(self, announcements: list):
+
+    def on_load_success(self, result: dict):
         """加载公告成功"""
         self.loading_label.hide()
         self.error_label.hide()
-        
+
+        announcements = result.get("data", []) or []
+        self.total = result.get("total", len(announcements))
+        self.current_page = result.get("page", self.current_page)
+        self.page_size = result.get("page_size", self.page_size)
+
+        # 更新分页按钮状态
+        self.update_pagination_controls()
+
+        # 填充表格,每行一个公告
         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:
-                text = "\n".join(announcement_lines)
-                self.content.setPlainText(text)
-                self.content.show()
-                self.empty_label.hide()
-            else:
-                self.content.hide()
-                self.empty_label.show()
+            self.table.setRowCount(len(announcements))
+            for row, ann in enumerate(announcements):
+                title = ann.get("title", "") or "-"
+                content = ann.get("content", "") or "-"
+                publish_time = ann.get("publish_time") or "-"
+
+                # 标题列
+                title_item = QTableWidgetItem(title)
+                self.table.setItem(row, 0, title_item)
+
+                # 内容列(只显示前 80 字符,完整内容放到 tooltip)
+                preview = content if len(content) <= 80 else content[:80] + "…"
+                content_item = QTableWidgetItem(preview)
+                if content:
+                    content_item.setToolTip(content)
+                self.table.setItem(row, 1, content_item)
+
+                # 发布时间列
+                publish_item = QTableWidgetItem(publish_time.replace("T", " ")[:19] if publish_time != "-" else "-")
+                self.table.setItem(row, 2, publish_item)
+
+            self.table.show()
+            self.empty_label.hide()
         else:
-            self.content.hide()
+            self.table.setRowCount(0)
+            self.table.hide()
             self.empty_label.show()
     
+    def update_pagination_controls(self):
+        """根据总数和当前页更新分页控件"""
+        if self.total <= 0:
+            self.page_info_label.setText("暂无公告")
+            self.prev_btn.setEnabled(False)
+            self.next_btn.setEnabled(False)
+            return
+
+        total_pages = (self.total + self.page_size - 1) // self.page_size
+        self.page_info_label.setText(f"共 {self.total} 条 · 第 {self.current_page} / {total_pages} 页")
+
+        self.prev_btn.setEnabled(self.current_page > 1)
+        self.next_btn.setEnabled(self.current_page < total_pages)
+
+    def on_prev_page(self):
+        """上一页"""
+        if self.current_page > 1:
+            self.load_announcement(page=self.current_page - 1)
+
+    def on_next_page(self):
+        """下一页"""
+        total_pages = (self.total + self.page_size - 1) // self.page_size if self.total > 0 else 1
+        if self.current_page < total_pages:
+            self.load_announcement(page=self.current_page + 1)
+
+    def on_search_clicked(self):
+        """点击搜索"""
+        self.keyword = (self.search_input.text() or "").strip()
+        # 搜索从第一页开始
+        self.load_announcement(page=1)
+
     def on_load_error(self, msg: str):
         """加载公告失败"""
         self.loading_label.hide()
-        self.content.hide()
+        self.table.hide()
         self.empty_label.hide()
         self.error_label.setText(f"加载失败: {msg}")
         self.error_label.show()