增加通知

main
mula.liu 2026-01-06 18:04:06 +08:00
parent a6e2e95cc3
commit 0af0f2331d
12 changed files with 810 additions and 11 deletions

View File

@ -2,7 +2,7 @@
API v1 路由汇总 API v1 路由汇总
""" """
from fastapi import APIRouter 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() 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(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(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(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(menu.router, prefix="/menu", tags=["权限菜单"])
api_router.include_router(dashboard.router, prefix="/dashboard", tags=["管理员仪表盘"]) api_router.include_router(dashboard.router, prefix="/dashboard", tags=["管理员仪表盘"])
api_router.include_router(preview.router, prefix="/preview", tags=["项目预览"]) api_router.include_router(preview.router, prefix="/preview", tags=["项目预览"])

View File

@ -27,6 +27,7 @@ from app.schemas.file import (
from app.schemas.response import success_response from app.schemas.response import success_response
from app.services.storage import storage_service from app.services.storage import storage_service
from app.services.log_service import log_service from app.services.log_service import log_service
from app.services.notification_service import notification_service
from app.core.enums import OperationType from app.core.enums import OperationType
router = APIRouter() router = APIRouter()
@ -150,6 +151,18 @@ async def save_file(
request=request, 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="文件保存成功") return success_response(message="文件保存成功")
@ -179,6 +192,17 @@ async def operate_file(
user=current_user, user=current_user,
request=request, 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="删除成功") return success_response(message="删除成功")
elif operation.action == "rename": elif operation.action == "rename":
@ -197,6 +221,17 @@ async def operate_file(
detail={"new_path": operation.new_path}, detail={"new_path": operation.new_path},
request=request, 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="重命名成功") return success_response(message="重命名成功")
elif operation.action == "move": elif operation.action == "move":
@ -215,6 +250,17 @@ async def operate_file(
detail={"new_path": operation.new_path}, detail={"new_path": operation.new_path},
request=request, 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="移动成功") return success_response(message="移动成功")
elif operation.action == "create_dir": elif operation.action == "create_dir":
@ -229,6 +275,17 @@ async def operate_file(
user=current_user, user=current_user,
request=request, 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="目录创建成功") return success_response(message="目录创建成功")
elif operation.action == "create_file": elif operation.action == "create_file":
@ -244,6 +301,18 @@ async def operate_file(
user=current_user, user=current_user,
request=request, 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="文件创建成功") return success_response(message="文件创建成功")
else: else:

View File

@ -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="系统通知发送成功")

View File

@ -26,6 +26,7 @@ from app.schemas.response import success_response
from app.services.storage import storage_service from app.services.storage import storage_service
from app.services.log_service import log_service from app.services.log_service import log_service
from app.services.git_service import git_service from app.services.git_service import git_service
from app.services.notification_service import notification_service
from app.core.enums import OperationType, ResourceType from app.core.enums import OperationType, ResourceType
router = APIRouter() router = APIRouter()
@ -405,6 +406,18 @@ async def add_project_member(
await db.commit() await db.commit()
await db.refresh(db_member) 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( await log_service.log_member_operation(
db=db, db=db,
@ -634,6 +647,18 @@ async def git_pull(
request=request, 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})") return success_response(message=f"Git Pull 成功 ({target_repo.name})")

View File

@ -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}')>"

View File

@ -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

View File

@ -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()

View File

@ -14,6 +14,7 @@ import Permissions from '@/pages/System/Permissions'
import Users from '@/pages/System/Users' import Users from '@/pages/System/Users'
import Roles from '@/pages/System/Roles' import Roles from '@/pages/System/Roles'
import SystemLogs from '@/pages/SystemLogs/SystemLogs' import SystemLogs from '@/pages/SystemLogs/SystemLogs'
import NotificationList from '@/pages/Notifications/NotificationList'
import ProtectedRoute from '@/components/ProtectedRoute' import ProtectedRoute from '@/components/ProtectedRoute'
import MainLayout from '@/components/MainLayout/MainLayout' import MainLayout from '@/components/MainLayout/MainLayout'
import '@/App.css' import '@/App.css'
@ -54,6 +55,7 @@ function App() {
<Route path="/projects/:projectId/*" element={<RedirectToDocs />} /> <Route path="/projects/:projectId/*" element={<RedirectToDocs />} />
<Route path="/constructing" element={<Constructing />} /> <Route path="/constructing" element={<Constructing />} />
<Route path="/profile" element={<ProfilePage />} /> <Route path="/profile" element={<ProfilePage />} />
<Route path="/notifications" element={<NotificationList />} />
<Route path="/system/permissions" element={<Permissions />} /> <Route path="/system/permissions" element={<Permissions />} />
<Route path="/system/users" element={<Users />} /> <Route path="/system/users" element={<Users />} />
<Route path="/system/roles" element={<Roles />} /> <Route path="/system/roles" element={<Roles />} />

View File

@ -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',
})
}

View File

@ -124,6 +124,73 @@
font-weight: 500; 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 { .ml-1 {
margin-left: 4px; margin-left: 4px;
} }

View File

@ -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 { useNavigate } from 'react-router-dom'
import { import {
MenuFoldOutlined, MenuFoldOutlined,
@ -8,13 +9,19 @@ import {
FileTextOutlined, FileTextOutlined,
CustomerServiceOutlined, CustomerServiceOutlined,
UserOutlined, UserOutlined,
CheckOutlined,
ProjectOutlined,
TeamOutlined,
NotificationOutlined,
} from '@ant-design/icons' } from '@ant-design/icons'
import useUserStore from '@/stores/userStore' import useUserStore from '@/stores/userStore'
import { getNotifications, getUnreadCount, markAsRead, markAllAsRead } from '@/api/notification'
import Toast from '@/components/Toast/Toast' import Toast from '@/components/Toast/Toast'
import headerMenuData from '../../data/headerMenuData.json' import headerMenuData from '../../data/headerMenuData.json'
import './AppHeader.css' import './AppHeader.css'
const { Header } = Layout const { Header } = Layout
const { Text } = Typography
// //
const iconMap = { 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 }) => { const handleUserMenuClick = ({ key }) => {
if (key === 'logout') { if (key === 'logout') {
logout() logout()
@ -98,16 +221,26 @@ function AppHeader({ collapsed, onToggle }) {
))} ))}
{/* 消息中心 */} {/* 消息中心 */}
<div <Popover
className="header-link" content={notificationContent}
title="消息中心" trigger="click"
onClick={() => handleHeaderMenuClick('messages')} 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 /> <BellOutlined />
</Badge> </Badge>
<span className="ml-1">消息</span> <span className="ml-1">消息</span>
</div> </div>
</Popover>
{/* 用户下拉菜单 */} {/* 用户下拉菜单 */}
<Dropdown <Dropdown

View File

@ -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