From 1d11bf9fc39cd43a9c512c93dd1033aa9a16012e Mon Sep 17 00:00:00 2001 From: chengkai3 Date: Sat, 13 Jun 2026 23:27:12 +0800 Subject: [PATCH] =?UTF-8?q?[feat]:[FL-109][=E5=A2=9E=E5=8A=A0=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E6=B6=88=E6=81=AF=E5=8F=91=E9=80=81=E5=8A=9F=E8=83=BD?= =?UTF-8?q?]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端:创建system_messages表模型和Schema - 后端:实现消息创建、查询、标记已读的服务层 - 后端:新增REST API接口(需admin.system_message权限) - 前端:完善系统消息抽屉弹窗,显示消息列表 - 前端:自动加载未读数量,支持标记已读 - 数据库:新增迁移脚本建表 Co-authored-by: multica-agent --- api/app/api/router.py | 2 + api/app/api/v1/system_messages.py | 74 ++++++++++++ api/app/models/__init__.py | 3 +- api/app/models/system_message.py | 49 ++++++++ api/app/schemas/system_message.py | 32 +++++ api/app/services/system_message_service.py | 96 +++++++++++++++ migrations/add_system_messages.sql | 25 ++++ web/src/app/admin/layout.tsx | 131 ++++++++++++++++++++- 8 files changed, 407 insertions(+), 5 deletions(-) create mode 100644 api/app/api/v1/system_messages.py create mode 100644 api/app/models/system_message.py create mode 100644 api/app/schemas/system_message.py create mode 100644 api/app/services/system_message_service.py create mode 100644 migrations/add_system_messages.sql diff --git a/api/app/api/router.py b/api/app/api/router.py index c62de62..f3ca111 100644 --- a/api/app/api/router.py +++ b/api/app/api/router.py @@ -12,6 +12,7 @@ from .v1.flower_monitor import router as flower_monitor_router from .v1.lightning import router as lightning_router from .v1.lines import router as lines_router from .v1.scheduled_tasks import router as scheduled_tasks_router +from .v1.system_messages import router as system_messages_router from .v1.system_params import router as system_params_router from .v1.task_monitor import router as task_monitor_router from .v1.tower_models import router as tower_models_router @@ -29,6 +30,7 @@ v1_router.include_router(atp_assets_router) v1_router.include_router(atp_models_router) v1_router.include_router(task_monitor_router) v1_router.include_router(scheduled_tasks_router) +v1_router.include_router(system_messages_router) v1_router.include_router(system_params_router) v1_router.include_router(elevation_router) v1_router.include_router(fault_recurrence_router) diff --git a/api/app/api/v1/system_messages.py b/api/app/api/v1/system_messages.py new file mode 100644 index 0000000..ae72c76 --- /dev/null +++ b/api/app/api/v1/system_messages.py @@ -0,0 +1,74 @@ +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session + +from ...core.database import get_db +from ...core.dependencies import CurrentUser, get_current_user, require_permission +from ...schemas.system_message import ( + SystemMessageCreateRequest, + SystemMessageListResponse, + SystemMessageMarkReadRequest, + SystemMessagePublic, +) +from ...services.system_message_service import ( + create_system_message, + get_unread_count, + list_user_messages, + mark_messages_as_read, +) + +router = APIRouter(prefix="/system-messages", tags=["system_messages"]) + + +@router.post("", response_model=SystemMessagePublic, status_code=status.HTTP_201_CREATED) +def create_message( + payload: SystemMessageCreateRequest, + _: CurrentUser = Depends(require_permission("admin.system_message")), + db: Session = Depends(get_db), +) -> SystemMessagePublic: + """创建系统消息(需要admin.system_message权限)""" + message = create_system_message(db, payload) + return SystemMessagePublic.model_validate(message) + + +@router.get("/me", response_model=SystemMessageListResponse) +def get_my_messages( + limit: int = Query(default=50, ge=1, le=200), + offset: int = Query(default=0, ge=0), + unread_only: bool = Query(default=False), + current_user: CurrentUser = Depends(get_current_user), + db: Session = Depends(get_db), +) -> SystemMessageListResponse: + """获取当前用户的系统消息""" + messages, total, unread_count = list_user_messages( + db, + user_id=current_user.user.id, + limit=limit, + offset=offset, + unread_only=unread_only, + ) + return SystemMessageListResponse( + items=[SystemMessagePublic.model_validate(m) for m in messages], + total=total, + unread_count=unread_count, + ) + + +@router.get("/me/unread-count") +def get_my_unread_count( + current_user: CurrentUser = Depends(get_current_user), + db: Session = Depends(get_db), +) -> dict[str, int]: + """获取当前用户未读消息数量""" + count = get_unread_count(db, current_user.user.id) + return {"unread_count": count} + + +@router.post("/me/mark-read") +def mark_my_messages_read( + payload: SystemMessageMarkReadRequest, + current_user: CurrentUser = Depends(get_current_user), + db: Session = Depends(get_db), +) -> dict[str, int]: + """标记消息为已读""" + affected = mark_messages_as_read(db, current_user.user.id, payload.message_ids) + return {"affected": affected} diff --git a/api/app/models/__init__.py b/api/app/models/__init__.py index f3bee2e..f1d8db4 100644 --- a/api/app/models/__init__.py +++ b/api/app/models/__init__.py @@ -4,7 +4,7 @@ Import all model modules during package initialization so SQLAlchemy can resolve string-based relationships regardless of route/service import order. """ -from . import atp_asset, atp_model, audit_log, auth_session, elevation, file_storage, fl_analysis, lightning_event, lightning_sample, line, line_tower, menu, object_group, rbac, scheduled_task, system_param, tower_model, tower_profile, user, wine, worker_registry +from . import atp_asset, atp_model, audit_log, auth_session, elevation, file_storage, fl_analysis, lightning_event, lightning_sample, line, line_tower, menu, object_group, rbac, scheduled_task, system_message, system_param, tower_model, tower_profile, user, wine, worker_registry __all__ = [ "atp_asset", @@ -22,6 +22,7 @@ __all__ = [ "object_group", "rbac", "scheduled_task", + "system_message", "system_param", "tower_model", "tower_profile", diff --git a/api/app/models/system_message.py b/api/app/models/system_message.py new file mode 100644 index 0000000..cadf9d5 --- /dev/null +++ b/api/app/models/system_message.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from datetime import datetime +from typing import TYPE_CHECKING +from uuid import uuid4 + +from sqlalchemy import Boolean, DateTime, String, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from ..core.database import Base +from .base import utcnow + +if TYPE_CHECKING: + from .user import User + + +class SystemMessage(Base): + __tablename__ = "system_messages" + + id: Mapped[str] = mapped_column( + "message_id", + String(36), + primary_key=True, + default=lambda: uuid4().hex, + ) + title: Mapped[str] = mapped_column(String(255)) + content: Mapped[str] = mapped_column(Text) + message_type: Mapped[str] = mapped_column( + String(32), + default="info", + index=True, + ) + target_user_id: Mapped[str | None] = mapped_column( + String(36), + index=True, + nullable=True, + ) + is_read: Mapped[bool] = mapped_column(Boolean, default=False, index=True) + created_at: Mapped[datetime] = mapped_column( + "created_at", + DateTime(timezone=False), + default=utcnow, + index=True, + ) + read_at: Mapped[datetime | None] = mapped_column( + "read_at", + DateTime(timezone=False), + nullable=True, + ) diff --git a/api/app/schemas/system_message.py b/api/app/schemas/system_message.py new file mode 100644 index 0000000..f32c73f --- /dev/null +++ b/api/app/schemas/system_message.py @@ -0,0 +1,32 @@ +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, Field + + +class SystemMessagePublic(BaseModel): + id: str + title: str + content: str + message_type: str + target_user_id: str | None + is_read: bool + created_at: datetime + read_at: datetime | None + + +class SystemMessageListResponse(BaseModel): + items: list[SystemMessagePublic] + total: int + unread_count: int + + +class SystemMessageCreateRequest(BaseModel): + title: str = Field(min_length=1, max_length=255) + content: str = Field(min_length=1) + message_type: Literal["info", "warning", "error", "success"] = Field(default="info") + target_user_id: str | None = Field(default=None, description="发送给特定用户,为空则全员广播") + + +class SystemMessageMarkReadRequest(BaseModel): + message_ids: list[str] = Field(min_length=1, description="要标记为已读的消息ID列表") diff --git a/api/app/services/system_message_service.py b/api/app/services/system_message_service.py new file mode 100644 index 0000000..76119b5 --- /dev/null +++ b/api/app/services/system_message_service.py @@ -0,0 +1,96 @@ +from datetime import datetime +from typing import TYPE_CHECKING + +from sqlalchemy import select, update +from sqlalchemy.orm import Session + +from ..models.system_message import SystemMessage +from ..schemas.system_message import SystemMessageCreateRequest + +if TYPE_CHECKING: + from ..schemas.system_message import SystemMessagePublic + + +def create_system_message( + db: Session, + payload: SystemMessageCreateRequest, +) -> SystemMessage: + """创建系统消息""" + message = SystemMessage( + title=payload.title, + content=payload.content, + message_type=payload.message_type, + target_user_id=payload.target_user_id, + ) + db.add(message) + db.commit() + db.refresh(message) + return message + + +def list_user_messages( + db: Session, + user_id: str, + limit: int = 50, + offset: int = 0, + unread_only: bool = False, +) -> tuple[list[SystemMessage], int, int]: + """获取用户的系统消息列表""" + # 构建查询条件:全员广播或者特定用户 + query = select(SystemMessage).where( + (SystemMessage.target_user_id == user_id) | (SystemMessage.target_user_id.is_(None)) + ) + + if unread_only: + query = query.where(SystemMessage.is_read == False) + + # 获取总数 + total_query = select(SystemMessage).where( + (SystemMessage.target_user_id == user_id) | (SystemMessage.target_user_id.is_(None)) + ) + total = db.scalar(select(len(total_query.subquery().c.message_id))) + + # 获取未读数 + unread_query = select(SystemMessage).where( + (SystemMessage.target_user_id == user_id) | (SystemMessage.target_user_id.is_(None)), + SystemMessage.is_read == False, + ) + unread_count = db.scalar(select(len(unread_query.subquery().c.message_id))) + + # 按创建时间倒序 + query = query.order_by(SystemMessage.created_at.desc()) + query = query.limit(limit).offset(offset) + + messages = list(db.scalars(query).all()) + + return messages, total or 0, unread_count or 0 + + +def mark_messages_as_read( + db: Session, + user_id: str, + message_ids: list[str], +) -> int: + """标记消息为已读""" + stmt = ( + update(SystemMessage) + .where( + SystemMessage.id.in_(message_ids), + (SystemMessage.target_user_id == user_id) | (SystemMessage.target_user_id.is_(None)), + SystemMessage.is_read == False, + ) + .values(is_read=True, read_at=datetime.utcnow()) + ) + result = db.execute(stmt) + db.commit() + return result.rowcount or 0 + + +def get_unread_count(db: Session, user_id: str) -> int: + """获取用户未读消息数量""" + query = select(SystemMessage).where( + (SystemMessage.target_user_id == user_id) | (SystemMessage.target_user_id.is_(None)), + SystemMessage.is_read == False, + ) + count = db.scalar(select(len(query.subquery().c.message_id))) + return count or 0 diff --git a/migrations/add_system_messages.sql b/migrations/add_system_messages.sql new file mode 100644 index 0000000..c23dc1d --- /dev/null +++ b/migrations/add_system_messages.sql @@ -0,0 +1,25 @@ +-- Migration: Add system_messages table for system notifications +-- Date: 2026-06-13 +-- Description: Create system_messages table to support sending notifications to users + +CREATE TABLE IF NOT EXISTS system_messages ( + message_id VARCHAR(36) PRIMARY KEY, + title VARCHAR(255) NOT NULL, + content TEXT NOT NULL, + message_type VARCHAR(32) NOT NULL DEFAULT 'info', + target_user_id VARCHAR(36), + is_read BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC'), + read_at TIMESTAMP WITHOUT TIME ZONE +); + +CREATE INDEX idx_system_messages_target_user ON system_messages(target_user_id); +CREATE INDEX idx_system_messages_is_read ON system_messages(is_read); +CREATE INDEX idx_system_messages_created_at ON system_messages(created_at); +CREATE INDEX idx_system_messages_type ON system_messages(message_type); + +-- Notes: +-- - target_user_id is NULL for broadcast messages (sent to all users) +-- - target_user_id references users.user_id for user-specific messages +-- - message_type values: 'info', 'warning', 'error', 'success' +-- - is_read tracks whether the message has been read by the target user diff --git a/web/src/app/admin/layout.tsx b/web/src/app/admin/layout.tsx index 351be34..709d6b5 100644 --- a/web/src/app/admin/layout.tsx +++ b/web/src/app/admin/layout.tsx @@ -39,12 +39,15 @@ import { Button, Drawer, Dropdown, + Empty, Grid, Layout as AntLayout, + List, Menu as AntMenu, Result, Space, Spin, + Tag, Tooltip, Typography, type MenuProps, @@ -368,11 +371,65 @@ export default function AdminLayout({ children }: { children: ReactNode }) { [logout], ); + const [messageDrawerOpen, setMessageDrawerOpen] = useState(false); + const [messages, setMessages] = useState([]); + const [loadingMessages, setLoadingMessages] = useState(false); + + const loadMessages = useCallback(async () => { + setLoadingMessages(true); + try { + const response = await fetchWithAuth("/api/v1/system-messages/me?limit=50"); + if (!response.ok) { + return; + } + const data = await response.json(); + setMessages(data.items || []); + setUnreadMessageCount(data.unread_count || 0); + } catch (error) { + console.error("Failed to load messages:", error); + } finally { + setLoadingMessages(false); + } + }, [fetchWithAuth]); + + const loadUnreadCount = useCallback(async () => { + try { + const response = await fetchWithAuth("/api/v1/system-messages/me/unread-count"); + if (!response.ok) { + return; + } + const data = await response.json(); + setUnreadMessageCount(data.unread_count || 0); + } catch (error) { + console.error("Failed to load unread count:", error); + } + }, [fetchWithAuth]); + + useEffect(() => { + if (user) { + void loadUnreadCount(); + } + }, [loadUnreadCount, user]); + const onSystemMessageClick = useCallback(() => { - // TODO: Implement system message modal/drawer - // For now, just reset the unread count - setUnreadMessageCount(0); - }, []); + setMessageDrawerOpen(true); + void loadMessages(); + }, [loadMessages]); + + const markAsRead = useCallback(async (messageIds: string[]) => { + try { + const response = await fetchWithAuth("/api/v1/system-messages/me/mark-read", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message_ids: messageIds }), + }); + if (response.ok) { + await loadMessages(); + } + } catch (error) { + console.error("Failed to mark messages as read:", error); + } + }, [fetchWithAuth, loadMessages]); const navigationMenu = ( + setMessageDrawerOpen(false)} + > + {loadingMessages ? ( +
+ +
+ ) : messages.length === 0 ? ( + + ) : ( + ( + markAsRead([item.id])} + > + 标记已读 + , + ] + : undefined + } + > + + {item.title} + + {item.message_type} + + + } + description={ + + {item.content} + + {new Date(item.created_at).toLocaleString("zh-CN")} + + + } + /> + + )} + /> + )} +
+