增加通知
parent
a6e2e95cc3
commit
0af0f2331d
|
|
@ -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=["项目预览"])
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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="系统通知发送成功")
|
||||
|
|
@ -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})")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"<Notification(id={self.id}, user_id={self.user_id}, title='{self.title}')>"
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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() {
|
|||
<Route path="/projects/:projectId/*" element={<RedirectToDocs />} />
|
||||
<Route path="/constructing" element={<Constructing />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
<Route path="/notifications" element={<NotificationList />} />
|
||||
<Route path="/system/permissions" element={<Permissions />} />
|
||||
<Route path="/system/users" element={<Users />} />
|
||||
<Route path="/system/roles" element={<Roles />} />
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
})
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 <ProjectOutlined style={{ color: '#1890ff' }} />
|
||||
case 'collaboration': return <TeamOutlined style={{ color: '#52c41a' }} />
|
||||
default: return <NotificationOutlined style={{ color: '#faad14' }} />
|
||||
}
|
||||
}
|
||||
|
||||
const notificationContent = (
|
||||
<div className="notification-popover">
|
||||
<div className="popover-header">
|
||||
<span className="title">消息通知</span>
|
||||
{unreadCount > 0 && (
|
||||
<Button type="link" size="small" onClick={handleMarkAllRead}>
|
||||
全部已读
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<List
|
||||
className="notification-list"
|
||||
loading={loading}
|
||||
itemLayout="horizontal"
|
||||
dataSource={notifications}
|
||||
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无新消息" /> }}
|
||||
renderItem={(item) => (
|
||||
<List.Item
|
||||
className={`notification-item ${item.is_read === 0 ? 'unread' : ''}`}
|
||||
onClick={() => handleNotificationClick(item)}
|
||||
>
|
||||
<List.Item.Meta
|
||||
avatar={<Avatar icon={getCategoryIcon(item.category)} />}
|
||||
title={<Text strong={item.is_read === 0}>{item.title}</Text>}
|
||||
description={
|
||||
<div>
|
||||
<div className="content-text">{item.content}</div>
|
||||
<div className="time">{new Date(item.created_at).toLocaleString('zh-CN')}</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
<div className="popover-footer">
|
||||
<Button type="link" block onClick={() => { navigate('/notifications'); setPopoverVisible(false); }}>
|
||||
查看全部消息
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const handleUserMenuClick = ({ key }) => {
|
||||
if (key === 'logout') {
|
||||
logout()
|
||||
|
|
@ -98,16 +221,26 @@ function AppHeader({ collapsed, onToggle }) {
|
|||
))}
|
||||
|
||||
{/* 消息中心 */}
|
||||
<div
|
||||
className="header-link"
|
||||
title="消息中心"
|
||||
onClick={() => handleHeaderMenuClick('messages')}
|
||||
<Popover
|
||||
content={notificationContent}
|
||||
trigger="click"
|
||||
open={popoverVisible}
|
||||
onOpenChange={(visible) => {
|
||||
setPopoverVisible(visible)
|
||||
if (visible) {
|
||||
fetchNotifications()
|
||||
}
|
||||
}}
|
||||
placement="bottomRight"
|
||||
overlayClassName="header-notification-popover"
|
||||
>
|
||||
<Badge count={5} size="small" offset={[4, -2]}>
|
||||
<div className="header-link" title="消息中心">
|
||||
<Badge count={unreadCount} size="small" offset={[4, -2]}>
|
||||
<BellOutlined />
|
||||
</Badge>
|
||||
<span className="ml-1">消息</span>
|
||||
</div>
|
||||
</Popover>
|
||||
|
||||
{/* 用户下拉菜单 */}
|
||||
<Dropdown
|
||||
|
|
|
|||
|
|
@ -0,0 +1,160 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Layout, List, Avatar, Button, Tabs, Space, Tag, Typography, Empty, Pagination } from 'antd'
|
||||
import {
|
||||
BellOutlined,
|
||||
CheckOutlined,
|
||||
ProjectOutlined,
|
||||
TeamOutlined,
|
||||
NotificationOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { getNotifications, markAsRead, markAllAsRead } from '@/api/notification'
|
||||
import Toast from '@/components/Toast/Toast'
|
||||
import PageHeader from '@/components/PageHeader/PageHeader'
|
||||
|
||||
const { Content } = Layout
|
||||
const { Text } = Typography
|
||||
|
||||
function NotificationList() {
|
||||
const [notifications, setNotifications] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize] = useState(10)
|
||||
const [activeTab, setActiveTab] = useState('all')
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
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 <ProjectOutlined style={{ color: '#1890ff' }} />
|
||||
case 'collaboration': return <TeamOutlined style={{ color: '#52c41a' }} />
|
||||
default: return <NotificationOutlined style={{ color: '#faad14' }} />
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="notification-list-page">
|
||||
<PageHeader
|
||||
title="通知中心"
|
||||
icon={<BellOutlined />}
|
||||
extra={
|
||||
<Button icon={<CheckOutlined />} onClick={handleMarkAllRead}>
|
||||
全部标记为已读
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Content style={{ padding: '24px', background: '#fff', margin: '24px', borderRadius: '8px' }}>
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={(key) => { setActiveTab(key); setPage(1); }}
|
||||
items={[
|
||||
{ key: 'all', label: '全部消息' },
|
||||
{ key: 'unread', label: '未读消息' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<List
|
||||
loading={loading}
|
||||
itemLayout="vertical"
|
||||
dataSource={notifications}
|
||||
locale={{ emptyText: <Empty description="暂无消息" /> }}
|
||||
renderItem={(item) => (
|
||||
<List.Item
|
||||
key={item.id}
|
||||
className={`notification-list-item ${item.is_read === 0 ? 'unread' : ''}`}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
padding: '16px',
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
background: item.is_read === 0 ? '#f0faff' : 'transparent',
|
||||
transition: 'all 0.3s'
|
||||
}}
|
||||
onClick={() => handleNotificationClick(item)}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: '16px' }}>
|
||||
<Avatar icon={getCategoryIcon(item.category)} />
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '8px' }}>
|
||||
<Text strong={item.is_read === 0} style={{ fontSize: '16px' }}>
|
||||
{item.title}
|
||||
</Text>
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||
{new Date(item.created_at).toLocaleString('zh-CN')}
|
||||
</Text>
|
||||
</div>
|
||||
<div style={{ color: '#595959', marginBottom: '8px' }}>
|
||||
{item.content}
|
||||
</div>
|
||||
{item.is_read === 0 && <Tag color="blue">未读</Tag>}
|
||||
</div>
|
||||
</div>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div style={{ marginTop: '24px', textAlign: 'right' }}>
|
||||
<Pagination
|
||||
current={page}
|
||||
total={total}
|
||||
pageSize={pageSize}
|
||||
onChange={setPage}
|
||||
showSizeChanger={false}
|
||||
/>
|
||||
</div>
|
||||
</Content>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default NotificationList
|
||||
Loading…
Reference in New Issue