From 0af0f2331d29d49b64cc5f0450282e8e08e4d337 Mon Sep 17 00:00:00 2001 From: "mula.liu" Date: Tue, 6 Jan 2026 18:04:06 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E9=80=9A=E7=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/v1/__init__.py | 3 +- backend/app/api/v1/files.py | 69 ++++++++ backend/app/api/v1/notifications.py | 144 ++++++++++++++++ backend/app/api/v1/projects.py | 25 +++ backend/app/models/notification.py | 26 +++ backend/app/schemas/notification.py | 37 ++++ backend/app/services/notification_service.py | 90 ++++++++++ frontend/src/App.jsx | 2 + frontend/src/api/notification.js | 45 +++++ .../src/components/MainLayout/AppHeader.css | 67 ++++++++ .../src/components/MainLayout/AppHeader.jsx | 153 +++++++++++++++-- .../pages/Notifications/NotificationList.jsx | 160 ++++++++++++++++++ 12 files changed, 810 insertions(+), 11 deletions(-) create mode 100644 backend/app/api/v1/notifications.py create mode 100644 backend/app/models/notification.py create mode 100644 backend/app/schemas/notification.py create mode 100644 backend/app/services/notification_service.py create mode 100644 frontend/src/api/notification.js create mode 100644 frontend/src/pages/Notifications/NotificationList.jsx diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py index 4725a28..b8088e9 100644 --- a/backend/app/api/v1/__init__.py +++ b/backend/app/api/v1/__init__.py @@ -2,7 +2,7 @@ API v1 路由汇总 """ from fastapi import APIRouter -from app.api.v1 import auth, projects, files, menu, dashboard, preview, role_permissions, users, roles, search, logs, git_repos +from app.api.v1 import auth, projects, files, menu, dashboard, preview, role_permissions, users, roles, search, logs, git_repos, notifications api_router = APIRouter() @@ -11,6 +11,7 @@ api_router.include_router(auth.router, prefix="/auth", tags=["认证"]) api_router.include_router(projects.router, prefix="/projects", tags=["项目管理"]) api_router.include_router(git_repos.router, tags=["Git仓库管理"]) # git_repos internal paths start with /projects/{id}/git-repos api_router.include_router(files.router, prefix="/files", tags=["文件系统"]) +api_router.include_router(notifications.router, prefix="/notifications", tags=["通知管理"]) api_router.include_router(menu.router, prefix="/menu", tags=["权限菜单"]) api_router.include_router(dashboard.router, prefix="/dashboard", tags=["管理员仪表盘"]) api_router.include_router(preview.router, prefix="/preview", tags=["项目预览"]) diff --git a/backend/app/api/v1/files.py b/backend/app/api/v1/files.py index 580487b..dbdbdce 100644 --- a/backend/app/api/v1/files.py +++ b/backend/app/api/v1/files.py @@ -27,6 +27,7 @@ from app.schemas.file import ( from app.schemas.response import success_response from app.services.storage import storage_service from app.services.log_service import log_service +from app.services.notification_service import notification_service from app.core.enums import OperationType router = APIRouter() @@ -150,6 +151,18 @@ async def save_file( request=request, ) + # 发送通知给其他成员 + await notification_service.notify_project_members( + db=db, + project_id=project_id, + exclude_user_id=current_user.id, + title=f"项目文档更新", + content=f"项目 [{project.name}] 中的文档 [{file_data.path}] 已被 {current_user.nickname or current_user.username} 更新。", + link=f"/projects/{project_id}/docs?file={file_data.path}", + category="project" + ) + await db.commit() + return success_response(message="文件保存成功") @@ -179,6 +192,17 @@ async def operate_file( user=current_user, request=request, ) + + # 发送通知 + await notification_service.notify_project_members( + db=db, + project_id=project_id, + exclude_user_id=current_user.id, + title=f"项目文档删除", + content=f"项目 [{project.name}] 中的文档/目录 [{operation.path}] 已被 {current_user.nickname or current_user.username} 删除。", + category="project" + ) + await db.commit() return success_response(message="删除成功") elif operation.action == "rename": @@ -197,6 +221,17 @@ async def operate_file( detail={"new_path": operation.new_path}, request=request, ) + + # 发送通知 + await notification_service.notify_project_members( + db=db, + project_id=project_id, + exclude_user_id=current_user.id, + title=f"项目文档重命名", + content=f"项目 [{project.name}] 中的文档 [{operation.path}] 已被重命名为 [{operation.new_path}]。", + category="project" + ) + await db.commit() return success_response(message="重命名成功") elif operation.action == "move": @@ -215,6 +250,17 @@ async def operate_file( detail={"new_path": operation.new_path}, request=request, ) + + # 发送通知 + await notification_service.notify_project_members( + db=db, + project_id=project_id, + exclude_user_id=current_user.id, + title=f"项目文档移动", + content=f"项目 [{project.name}] 中的文档 [{operation.path}] 已移动到 [{operation.new_path}]。", + category="project" + ) + await db.commit() return success_response(message="移动成功") elif operation.action == "create_dir": @@ -229,6 +275,17 @@ async def operate_file( user=current_user, request=request, ) + + # 发送通知 + await notification_service.notify_project_members( + db=db, + project_id=project_id, + exclude_user_id=current_user.id, + title=f"创建新目录", + content=f"{current_user.nickname or current_user.username} 在项目 [{project.name}] 中创建了新目录 [{operation.path}]。", + category="project" + ) + await db.commit() return success_response(message="目录创建成功") elif operation.action == "create_file": @@ -244,6 +301,18 @@ async def operate_file( user=current_user, request=request, ) + + # 发送通知 + await notification_service.notify_project_members( + db=db, + project_id=project_id, + exclude_user_id=current_user.id, + title=f"创建新文档", + content=f"{current_user.nickname or current_user.username} 在项目 [{project.name}] 中创建了新文档 [{operation.path}]。", + link=f"/projects/{project_id}/docs?file={operation.path}", + category="project" + ) + await db.commit() return success_response(message="文件创建成功") else: diff --git a/backend/app/api/v1/notifications.py b/backend/app/api/v1/notifications.py new file mode 100644 index 0000000..8a36101 --- /dev/null +++ b/backend/app/api/v1/notifications.py @@ -0,0 +1,144 @@ +""" +通知管理 API +""" +from fastapi import APIRouter, Depends, HTTPException, Request +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, update, func, desc +from typing import List, Optional +from datetime import datetime + +from app.core.database import get_db +from app.core.deps import get_current_user +from app.models.user import User +from app.models.notification import Notification +from app.schemas.notification import ( + NotificationResponse, + NotificationUpdate, + UnreadCountResponse, + NotificationCreate +) +from app.schemas.response import success_response +from app.services.notification_service import notification_service + +router = APIRouter() + + +@router.get("/", response_model=dict) +async def get_notifications( + page: int = 1, + page_size: int = 20, + unread_only: bool = False, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """获取当前用户的通知列表""" + query = select(Notification).where(Notification.user_id == current_user.id) + + if unread_only: + query = query.where(Notification.is_read == 0) + + query = query.order_by(desc(Notification.created_at)) + + # 分页 + offset = (page - 1) * page_size + query = query.offset(offset).limit(page_size) + + result = await db.execute(query) + notifications = result.scalars().all() + + # 获取总数 + count_query = select(func.count()).select_from(Notification).where(Notification.user_id == current_user.id) + if unread_only: + count_query = count_query.where(Notification.is_read == 0) + total_result = await db.execute(count_query) + total = total_result.scalar() + + data = [NotificationResponse.from_orm(n).dict() for n in notifications] + return { + "code": 200, + "message": "success", + "data": data, + "total": total, + "page": page, + "page_size": page_size + } + + +@router.get("/unread-count", response_model=dict) +async def get_unread_count( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """获取未读通知数量""" + result = await db.execute( + select(func.count()).select_from(Notification).where( + Notification.user_id == current_user.id, + Notification.is_read == 0 + ) + ) + count = result.scalar() + return success_response(data={"unread_count": count}) + + +@router.put("/{notification_id}/read", response_model=dict) +async def mark_as_read( + notification_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """标记单条通知为已读""" + result = await db.execute( + select(Notification).where( + Notification.id == notification_id, + Notification.user_id == current_user.id + ) + ) + notification = result.scalar_one_or_none() + + if not notification: + raise HTTPException(status_code=404, detail="通知不存在") + + if notification.is_read == 0: + notification.is_read = 1 + notification.read_at = datetime.now() + await db.commit() + + return success_response(message="已标记为已读") + + +@router.put("/read-all", response_model=dict) +async def mark_all_as_read( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """标记所有通知为已读""" + await db.execute( + update(Notification) + .where(Notification.user_id == current_user.id, Notification.is_read == 0) + .values(is_read=1, read_at=datetime.now()) + ) + await db.commit() + return success_response(message="全部标记为已读") + + +@router.post("/system", response_model=dict) +async def send_system_notification( + notification_in: NotificationCreate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """发送系统通知(仅限超级管理员)""" + if not current_user.is_superuser: + raise HTTPException(status_code=403, detail="只有管理员可以发送系统通知") + + await notification_service.create_notification( + db=db, + user_id=notification_in.user_id, + title=notification_in.title, + content=notification_in.content, + type=notification_in.type, + category="system", + link=notification_in.link + ) + await db.commit() + return success_response(message="系统通知发送成功") diff --git a/backend/app/api/v1/projects.py b/backend/app/api/v1/projects.py index 5dbdaec..3d4cc50 100644 --- a/backend/app/api/v1/projects.py +++ b/backend/app/api/v1/projects.py @@ -26,6 +26,7 @@ from app.schemas.response import success_response from app.services.storage import storage_service from app.services.log_service import log_service from app.services.git_service import git_service +from app.services.notification_service import notification_service from app.core.enums import OperationType, ResourceType router = APIRouter() @@ -405,6 +406,18 @@ async def add_project_member( await db.commit() await db.refresh(db_member) + # 发送通知给被邀请人 + await notification_service.create_notification( + db=db, + user_id=member_in.user_id, + title=f"新项目协作邀请", + content=f"用户 {current_user.nickname or current_user.username} 邀请您参与项目 [{project.name}] 的协作。", + category="collaboration", + link=f"/projects/{project_id}/docs", + type="info" + ) + await db.commit() + # 记录操作日志 await log_service.log_member_operation( db=db, @@ -634,6 +647,18 @@ async def git_pull( request=request, ) + # 发送通知给其他成员 + await notification_service.notify_project_members( + db=db, + project_id=project_id, + exclude_user_id=current_user.id, + title=f"项目文档已通过 Git 同步", + content=f"{current_user.nickname or current_user.username} 执行了 Git Pull,项目 [{project.name}] 的内容已从远程仓库同步更新。", + link=f"/projects/{project_id}/docs", + category="project" + ) + await db.commit() + return success_response(message=f"Git Pull 成功 ({target_repo.name})") diff --git a/backend/app/models/notification.py b/backend/app/models/notification.py new file mode 100644 index 0000000..3a9f20b --- /dev/null +++ b/backend/app/models/notification.py @@ -0,0 +1,26 @@ +""" +通知模型 +""" +from sqlalchemy import Column, BigInteger, String, DateTime, SmallInteger, Text, ForeignKey +from sqlalchemy.sql import func +from app.core.database import Base + + +class Notification(Base): + """用户通知表模型""" + + __tablename__ = "notifications" + + id = Column(BigInteger, primary_key=True, autoincrement=True, comment="通知ID") + user_id = Column(BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True, comment="接收用户ID") + type = Column(String(20), default="info", comment="类型:info, success, warning, error") + category = Column(String(50), default="system", comment="分类:system, project, collaboration") + title = Column(String(200), nullable=False, comment="标题") + content = Column(Text, comment="内容") + link = Column(String(255), comment="跳转链接") + is_read = Column(SmallInteger, default=0, comment="是否已读:0-未读 1-已读") + created_at = Column(DateTime, server_default=func.now(), comment="创建时间") + read_at = Column(DateTime, comment="阅读时间") + + def __repr__(self): + return f"" diff --git a/backend/app/schemas/notification.py b/backend/app/schemas/notification.py new file mode 100644 index 0000000..f3acabe --- /dev/null +++ b/backend/app/schemas/notification.py @@ -0,0 +1,37 @@ +""" +通知相关的 Pydantic Schema +""" +from pydantic import BaseModel, Field +from typing import Optional, List +from datetime import datetime + + +class NotificationBase(BaseModel): + type: str = "info" + category: str = "system" + title: str + content: Optional[str] = None + link: Optional[str] = None + + +class NotificationCreate(NotificationBase): + user_id: int + + +class NotificationUpdate(BaseModel): + is_read: Optional[int] = None + + +class NotificationResponse(NotificationBase): + id: int + user_id: int + is_read: int + created_at: datetime + read_at: Optional[datetime] = None + + class Config: + from_attributes = True + + +class UnreadCountResponse(BaseModel): + unread_count: int diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py new file mode 100644 index 0000000..0c21536 --- /dev/null +++ b/backend/app/services/notification_service.py @@ -0,0 +1,90 @@ +import logging +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, update, insert +from app.models.notification import Notification +from app.models.project import ProjectMember +from typing import List, Optional + +logger = logging.getLogger(__name__) + +class NotificationService: + async def create_notification( + self, + db: AsyncSession, + user_id: int, + title: str, + content: str = None, + type: str = "info", + category: str = "system", + link: str = None + ) -> Notification: + """创建单条通知""" + db_notification = Notification( + user_id=user_id, + title=title, + content=content, + type=type, + category=category, + link=link + ) + db.add(db_notification) + await db.flush() + return db_notification + + async def broadcast_system_notification( + self, + db: AsyncSession, + title: str, + content: str, + user_ids: List[int], + link: str = None + ): + """向指定多个用户发送系统通知""" + for uid in user_ids: + db_notification = Notification( + user_id=uid, + title=title, + content=content, + type="info", + category="system", + link=link + ) + db.add(db_notification) + await db.flush() + + async def notify_project_members( + self, + db: AsyncSession, + project_id: int, + exclude_user_id: int, + title: str, + content: str, + link: str = None, + category: str = "project" + ): + """通知项目中除指定用户外的所有成员""" + result = await db.execute( + select(ProjectMember.user_id).where( + ProjectMember.project_id == project_id, + ProjectMember.user_id != exclude_user_id + ) + ) + member_ids = result.scalars().all() + + logger.info(f"Notifying members of project {project_id}. Found {len(member_ids)} members to notify (excluding user {exclude_user_id}).") + + if member_ids: + for uid in member_ids: + db_notification = Notification( + user_id=uid, + title=title, + content=content, + type="info", + category=category, + link=link + ) + db.add(db_notification) + await db.flush() + + +notification_service = NotificationService() diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 8107eea..6d573fc 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -14,6 +14,7 @@ import Permissions from '@/pages/System/Permissions' import Users from '@/pages/System/Users' import Roles from '@/pages/System/Roles' import SystemLogs from '@/pages/SystemLogs/SystemLogs' +import NotificationList from '@/pages/Notifications/NotificationList' import ProtectedRoute from '@/components/ProtectedRoute' import MainLayout from '@/components/MainLayout/MainLayout' import '@/App.css' @@ -54,6 +55,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/api/notification.js b/frontend/src/api/notification.js new file mode 100644 index 0000000..0a3a7bf --- /dev/null +++ b/frontend/src/api/notification.js @@ -0,0 +1,45 @@ +/** + * 通知管理相关 API + */ +import request from '@/utils/request' + +/** + * 获取通知列表 + */ +export function getNotifications(params) { + return request({ + url: '/notifications/', + method: 'get', + params, + }) +} + +/** + * 获取未读通知数量 + */ +export function getUnreadCount() { + return request({ + url: '/notifications/unread-count', + method: 'get', + }) +} + +/** + * 标记通知为已读 + */ +export function markAsRead(notificationId) { + return request({ + url: `/notifications/${notificationId}/read`, + method: 'put', + }) +} + +/** + * 全部标记为已读 + */ +export function markAllAsRead() { + return request({ + url: '/notifications/read-all', + method: 'put', + }) +} diff --git a/frontend/src/components/MainLayout/AppHeader.css b/frontend/src/components/MainLayout/AppHeader.css index deed13b..10b0574 100644 --- a/frontend/src/components/MainLayout/AppHeader.css +++ b/frontend/src/components/MainLayout/AppHeader.css @@ -124,6 +124,73 @@ font-weight: 500; } +/* 通知面板样式 */ +.header-notification-popover .ant-popover-inner-content { + padding: 0; +} + +.notification-popover { + width: 320px; +} + +.popover-header { + padding: 12px 16px; + border-bottom: 1px solid #f0f0f0; + display: flex; + justify-content: space-between; + align-items: center; +} + +.popover-header .title { + font-weight: 600; + font-size: 16px; +} + +.notification-list { + max-height: 400px; + overflow-y: auto; +} + +.notification-item { + padding: 12px 16px !important; + cursor: pointer; + transition: background 0.3s; +} + +.notification-item:hover { + background: #f5f5f5; +} + +.notification-item.unread { + background: #e6f7ff; +} + +.notification-item.unread:hover { + background: #bae7ff; +} + +.content-text { + font-size: 13px; + color: #595959; + margin-top: 4px; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.time { + font-size: 12px; + color: #bfbfbf; + margin-top: 4px; +} + +.popover-footer { + padding: 8px; + border-top: 1px solid #f0f0f0; + text-align: center; +} + .ml-1 { margin-left: 4px; } \ No newline at end of file diff --git a/frontend/src/components/MainLayout/AppHeader.jsx b/frontend/src/components/MainLayout/AppHeader.jsx index 0c38442..0cff937 100644 --- a/frontend/src/components/MainLayout/AppHeader.jsx +++ b/frontend/src/components/MainLayout/AppHeader.jsx @@ -1,4 +1,5 @@ -import { Layout, Badge, Avatar, Dropdown, Space } from 'antd' +import { useState, useEffect } from 'react' +import { Layout, Badge, Avatar, Dropdown, Space, Popover, List, Tabs, Button, Empty, Typography } from 'antd' import { useNavigate } from 'react-router-dom' import { MenuFoldOutlined, @@ -8,13 +9,19 @@ import { FileTextOutlined, CustomerServiceOutlined, UserOutlined, + CheckOutlined, + ProjectOutlined, + TeamOutlined, + NotificationOutlined, } from '@ant-design/icons' import useUserStore from '@/stores/userStore' +import { getNotifications, getUnreadCount, markAsRead, markAllAsRead } from '@/api/notification' import Toast from '@/components/Toast/Toast' import headerMenuData from '../../data/headerMenuData.json' import './AppHeader.css' const { Header } = Layout +const { Text } = Typography // 图标映射 const iconMap = { @@ -46,6 +53,122 @@ function AppHeader({ collapsed, onToggle }) { }, ] + const [notifications, setNotifications] = useState([]) + const [unreadCount, setUnreadCount] = useState(0) + const [loading, setLoading] = useState(false) + const [popoverVisible, setPopoverVisible] = useState(false) + + useEffect(() => { + if (user) { + fetchUnreadCount() + // 每 2 分钟轮询一次 + const timer = setInterval(fetchUnreadCount, 120000) + return () => clearInterval(timer) + } + }, [user]) + + const fetchUnreadCount = async () => { + try { + const res = await getUnreadCount() + setUnreadCount(res.data.unread_count) + } catch (error) { + console.error('Fetch unread count error:', error) + } + } + + const fetchNotifications = async () => { + setLoading(true) + try { + const res = await getNotifications({ page: 1, page_size: 5 }) + setNotifications(res.data || []) + } catch (error) { + console.error('Fetch notifications error:', error) + } finally { + setLoading(false) + } + } + + const handleMarkRead = async (id) => { + try { + await markAsRead(id) + setNotifications(notifications.map(n => n.id === id ? { ...n, is_read: 1 } : n)) + fetchUnreadCount() + } catch (error) { + console.error('Mark read error:', error) + } + } + + const handleMarkAllRead = async () => { + try { + await markAllAsRead() + setNotifications(notifications.map(n => ({ ...n, is_read: 1 }))) + setUnreadCount(0) + Toast.success('操作成功', '所有通知已标记为已读') + } catch (error) { + console.error('Mark all read error:', error) + } + } + + const handleNotificationClick = (n) => { + if (n.is_read === 0) { + handleMarkRead(n.id) + } + if (n.link) { + navigate(n.link) + setPopoverVisible(false) + } + } + + const getCategoryIcon = (category) => { + switch (category) { + case 'project': return + case 'collaboration': return + default: return + } + } + + const notificationContent = ( +
+
+ 消息通知 + {unreadCount > 0 && ( + + )} +
+ }} + renderItem={(item) => ( + handleNotificationClick(item)} + > + } + title={{item.title}} + description={ +
+
{item.content}
+
{new Date(item.created_at).toLocaleString('zh-CN')}
+
+ } + /> +
+ )} + /> +
+ +
+
+ ) + const handleUserMenuClick = ({ key }) => { if (key === 'logout') { logout() @@ -98,16 +221,26 @@ function AppHeader({ collapsed, onToggle }) { ))} {/* 消息中心 */} -
handleHeaderMenuClick('messages')} + { + setPopoverVisible(visible) + if (visible) { + fetchNotifications() + } + }} + placement="bottomRight" + overlayClassName="header-notification-popover" > - - - - 消息 -
+
+ + + + 消息 +
+ {/* 用户下拉菜单 */} { + fetchNotifications() + }, [page, activeTab]) + + const fetchNotifications = async () => { + setLoading(true) + try { + const res = await getNotifications({ + page, + page_size: pageSize, + unread_only: activeTab === 'unread' + }) + setNotifications(res.data || []) + setTotal(res.meta?.total || 0) + } catch (error) { + console.error('Fetch notifications error:', error) + Toast.error('加载失败', '无法获取通知列表') + } finally { + setLoading(false) + } + } + + const handleMarkRead = async (id) => { + try { + await markAsRead(id) + setNotifications(notifications.map(n => n.id === id ? { ...n, is_read: 1 } : n)) + } catch (error) { + console.error('Mark read error:', error) + } + } + + const handleMarkAllRead = async () => { + try { + await markAllAsRead() + Toast.success('操作成功', '所有消息已标记为已读') + fetchNotifications() + } catch (error) { + console.error('Mark all read error:', error) + } + } + + const handleNotificationClick = (n) => { + if (n.is_read === 0) { + handleMarkRead(n.id) + } + if (n.link) { + navigate(n.link) + } + } + + const getCategoryIcon = (category) => { + switch (category) { + case 'project': return + case 'collaboration': return + default: return + } + } + + return ( +
+ } + extra={ + + } + /> + + + { setActiveTab(key); setPage(1); }} + items={[ + { key: 'all', label: '全部消息' }, + { key: 'unread', label: '未读消息' }, + ]} + /> + + }} + renderItem={(item) => ( + handleNotificationClick(item)} + > +
+ +
+
+ + {item.title} + + + {new Date(item.created_at).toLocaleString('zh-CN')} + +
+
+ {item.content} +
+ {item.is_read === 0 && 未读} +
+
+
+ )} + /> + +
+ +
+
+
+ ) +} + +export default NotificationList