From 4ef3c92d657c34247a617caa1edda0be6dfbe7b0 Mon Sep 17 00:00:00 2001 From: "mula.liu" Date: Wed, 19 Aug 2026 00:58:02 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E4=BA=86=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/v1/notifications.py | 27 ++++++ backend/app/api/v1/projects.py | 46 +++++++++- backend/app/services/notification_service.py | 89 ++++++++++++++++++- frontend/src/api/notification.js | 20 +++++ frontend/src/pages/Document/DocumentPage.jsx | 10 +++ .../src/pages/ProjectList/ProjectList.css | 37 +++++++- .../src/pages/ProjectList/ProjectList.jsx | 58 +++++++++++- 7 files changed, 272 insertions(+), 15 deletions(-) diff --git a/backend/app/api/v1/notifications.py b/backend/app/api/v1/notifications.py index 8343bef..96499a6 100644 --- a/backend/app/api/v1/notifications.py +++ b/backend/app/api/v1/notifications.py @@ -82,6 +82,33 @@ async def get_unread_count( return success_response(data={"unread_count": 0}) +@router.get("/unread-by-project", response_model=dict) +async def get_unread_by_project( + current_user: User = Depends(get_current_user), +): + """按项目统计未读通知数量(与消息通知一致),返回 {project_id: count}""" + try: + counts = await notification_service.get_unread_count_by_project(current_user.id) + return success_response(data={"unread_by_project": counts}) + except Exception as e: + print(f"Error fetching unread by project: {e}") + return success_response(data={"unread_by_project": {}}) + + +@router.put("/read-project/{project_id}", response_model=dict) +async def mark_project_read( + project_id: int, + current_user: User = Depends(get_current_user), +): + """将指定项目的所有未读通知标记为已读(打开项目时调用)""" + try: + count = await notification_service.mark_project_read(current_user.id, project_id) + return success_response(data={"marked": count}, message="项目通知已标记为已读") + except Exception as e: + print(f"Error marking project read: {e}") + return success_response(message="操作完成") + + @router.put("/{notification_id}/read", response_model=dict) async def mark_as_read( notification_id: str, diff --git a/backend/app/api/v1/projects.py b/backend/app/api/v1/projects.py index 3fb5c38..c48f453 100644 --- a/backend/app/api/v1/projects.py +++ b/backend/app/api/v1/projects.py @@ -54,6 +54,40 @@ def get_document_count(storage_key: str) -> int: return 0 +def get_project_document_stats(storage_key: str): + """ + 计算项目文档统计信息:返回 (doc_count, last_activity_at) + last_activity 取项目内 .md/.pdf 文档的最新文件修改时间(排除 _assets), + 反映文档被编辑、导入、Git 拉取等真实活动时间,无需额外数据库字段。 + """ + from datetime import datetime + try: + project_path = storage_service.get_secure_path(storage_key) + if not project_path.exists(): + return 0, None + assets_dir = project_path / "_assets" + count = 0 + last_ts = None + for p in project_path.rglob("*"): + if not p.is_file(): + continue + if p.suffix.lower() not in (".md", ".pdf"): + continue + if assets_dir in p.parents: + continue + count += 1 + try: + ts = p.stat().st_mtime + except OSError: + continue + if last_ts is None or ts > last_ts: + last_ts = ts + last_activity = datetime.fromtimestamp(last_ts).isoformat() if last_ts else None + return count, last_activity + except Exception: + return 0, None + + async def attach_member_counts(db: AsyncSession, projects) -> dict: """批量查询项目的参与人数量(含所有者),返回 {project_id: count}""" if not projects: @@ -96,7 +130,9 @@ async def get_my_projects( projects_data = [] for p in all_projects: p_dict = ProjectResponse.from_orm(p).dict() - p_dict['doc_count'] = get_document_count(p.storage_key) + doc_count, last_activity_at = get_project_document_stats(p.storage_key) + p_dict['doc_count'] = doc_count + p_dict['last_activity_at'] = last_activity_at p_dict['member_count'] = member_counts.get(p.id, 0) projects_data.append(p_dict) @@ -117,7 +153,9 @@ async def get_owned_projects( projects_data = [] for p in projects: p_dict = ProjectResponse.from_orm(p).dict() - p_dict['doc_count'] = get_document_count(p.storage_key) + doc_count, last_activity_at = get_project_document_stats(p.storage_key) + p_dict['doc_count'] = doc_count + p_dict['last_activity_at'] = last_activity_at p_dict['member_count'] = member_counts.get(p.id, 0) projects_data.append(p_dict) return success_response(data=projects_data) @@ -150,7 +188,9 @@ async def get_shared_projects( project_dict['owner_name'] = owner.username project_dict['owner_nickname'] = owner.nickname project_dict['user_role'] = member.role # 添加用户角色 - project_dict['doc_count'] = get_document_count(project.storage_key) + doc_count, last_activity_at = get_project_document_stats(project.storage_key) + project_dict['doc_count'] = doc_count + project_dict['last_activity_at'] = last_activity_at project_dict['member_count'] = member_counts.get(project.id, 0) projects_data.append(project_dict) diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py index 87e3773..b020a4b 100644 --- a/backend/app/services/notification_service.py +++ b/backend/app/services/notification_service.py @@ -1,5 +1,6 @@ import logging import json +import re import time import uuid from sqlalchemy.ext.asyncio import AsyncSession @@ -20,6 +21,24 @@ class NotificationService: def _get_content_key(self, user_id: int) -> str: return f"notifications:content:{user_id}" + @staticmethod + def _extract_project_id_from_link(link: Optional[str]) -> Optional[int]: + """从通知链接中解析项目ID(兼容旧通知没有 project_id 字段的情况)""" + if not link: + return None + match = re.search(r"/projects/(\d+)", link) + return int(match.group(1)) if match else None + + def _get_project_id(self, data: Dict[str, Any]) -> Optional[int]: + """从通知数据中获取项目ID""" + pid = data.get("project_id") + if pid is not None: + try: + return int(pid) + except (TypeError, ValueError): + pass + return self._extract_project_id_from_link(data.get("link")) + async def create_notification( self, db: AsyncSession, @@ -28,7 +47,8 @@ class NotificationService: content: str = None, type: str = "info", category: str = "system", - link: str = None + link: str = None, + project_id: Optional[int] = None ) -> Dict[str, Any]: """创建单条通知 (写入 Redis)""" redis = get_redis() @@ -49,6 +69,8 @@ class NotificationService: "is_read": False, "created_at": timestamp } + if project_id is not None: + notification_data["project_id"] = project_id json_data = json.dumps(notification_data, ensure_ascii=False) order_key = self._get_order_key(user_id) @@ -71,7 +93,9 @@ class NotificationService: title: str, content: str, user_ids: List[int], - link: str = None + link: str = None, + project_id: Optional[int] = None, + category: str = "system" ): """向指定多个用户发送系统通知""" redis = get_redis() @@ -89,11 +113,13 @@ class NotificationService: "title": title, "content": content, "type": "info", - "category": "system", + "category": category, "link": link, "is_read": False, "created_at": timestamp } + if project_id is not None: + notification_data["project_id"] = project_id json_data = json.dumps(notification_data, ensure_ascii=False) order_key = self._get_order_key(uid) @@ -130,7 +156,9 @@ class NotificationService: title=title, content=content, user_ids=member_ids, - link=link + link=link, + project_id=project_id, + category=category ) async def get_user_notifications( @@ -224,6 +252,59 @@ class NotificationService: except: pass + async def get_unread_count_by_project(self, user_id: int) -> Dict[int, int]: + """按项目统计未读通知数量(仅项目类通知),返回 {project_id: count}""" + redis = get_redis() + if not redis: + return {} + + content_key = self._get_content_key(user_id) + all_jsons = await redis.hvals(content_key) + result: Dict[int, int] = {} + for js in all_jsons: + if not js: + continue + try: + data = json.loads(js) + except Exception: + continue + if data.get("is_read"): + continue + # 关联到项目的未读通知即计入该项目(兼容旧数据 category=system) + pid = self._get_project_id(data) + if pid: + result[pid] = result.get(pid, 0) + 1 + return result + + async def mark_project_read(self, user_id: int, project_id: int) -> int: + """将指定项目的未读通知全部标记为已读,返回标记数量""" + redis = get_redis() + if not redis: + return 0 + + content_key = self._get_content_key(user_id) + all_jsons = await redis.hvals(content_key) + updates = {} + count = 0 + for js in all_jsons: + if not js: + continue + try: + data = json.loads(js) + except Exception: + continue + if data.get("is_read"): + continue + # 关联到该项目的未读通知全部标记已读(兼容旧数据 category=system) + if self._get_project_id(data) == project_id: + data["is_read"] = True + updates[data.get("id")] = json.dumps(data, ensure_ascii=False) + count += 1 + + if updates: + await redis.hset(content_key, mapping=updates) + return count + async def mark_all_read(self, user_id: int): """标记所有已读""" redis = get_redis() diff --git a/frontend/src/api/notification.js b/frontend/src/api/notification.js index 0a3a7bf..b4b50c2 100644 --- a/frontend/src/api/notification.js +++ b/frontend/src/api/notification.js @@ -43,3 +43,23 @@ export function markAllAsRead() { method: 'put', }) } + +/** + * 按项目统计未读通知数量(与消息通知一致) + */ +export function getUnreadByProject() { + return request({ + url: '/notifications/unread-by-project', + method: 'get', + }) +} + +/** + * 将指定项目的所有未读通知标记为已读(打开项目时调用) + */ +export function markProjectNotificationsRead(projectId) { + return request({ + url: `/notifications/read-project/${projectId}`, + method: 'put', + }) +} diff --git a/frontend/src/pages/Document/DocumentPage.jsx b/frontend/src/pages/Document/DocumentPage.jsx index 2e18d5a..21fdb0b 100644 --- a/frontend/src/pages/Document/DocumentPage.jsx +++ b/frontend/src/pages/Document/DocumentPage.jsx @@ -15,6 +15,8 @@ import { getProjectTree, getFileContent, getDocumentUrl, getExportPdfUrl } from import { gitPull, gitPush, getGitRepos } from '@/api/project' import { getFileShareInfo, createOrUpdateFileShare, deleteFileShare } from '@/api/share' import { searchDocuments } from '@/api/search' +import { markProjectNotificationsRead } from '@/api/notification' +import useNotificationStore from '@/stores/notificationStore' import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer' import FloatingToc from '@/components/FloatingToc/FloatingToc' import Toast from '@/components/Toast/Toast' @@ -196,6 +198,14 @@ function DocumentPage() { loadFileTree() }, [projectId]) + // 打开项目后,将该项目的未读更新通知标记为已读(与消息通知联动) + useEffect(() => { + if (!projectId) return + markProjectNotificationsRead(projectId) + .then(() => useNotificationStore.getState().fetchUnreadCount()) + .catch(() => {}) + }, [projectId]) + const handleClose = () => { Modal.confirm({ title: '确认退出', diff --git a/frontend/src/pages/ProjectList/ProjectList.css b/frontend/src/pages/ProjectList/ProjectList.css index 0d04f33..c9612fb 100644 --- a/frontend/src/pages/ProjectList/ProjectList.css +++ b/frontend/src/pages/ProjectList/ProjectList.css @@ -64,6 +64,37 @@ z-index: 1; } +/* 参与项目右上角:未读更新通知数角标 */ +.project-card-update-badge { + position: absolute; + top: 12px; + right: 12px; + z-index: 2; + min-width: 20px; + height: 20px; + padding: 0 6px; + border-radius: 999px; + background: linear-gradient(135deg, #ff4d4f 0%, #f5222d 100%); + color: #fff; + font-size: 11px; + font-weight: 600; + line-height: 20px; + text-align: center; + box-shadow: 0 2px 6px rgba(245, 34, 45, 0.45); + animation: project-badge-pop 0.25s ease-out; +} + +@keyframes project-badge-pop { + from { + transform: scale(0.6); + opacity: 0; + } + to { + transform: scale(1); + opacity: 1; + } +} + /* 卡片主体:标题在上、描述占中间弹性空间、元信息贴底 */ .project-card-body { flex: 1; @@ -249,20 +280,18 @@ body.dark .project-card-role-badge.role-editor { color: var(--text-color-secondary); } -/* ========== 我的项目卡片:蓝色管理风 ========== */ +/* ========== 我的项目卡片:蓝色管理风(无顶边) ========== */ .project-card-my { text-align: left; - border-top: 3px solid var(--link-color); } .project-card-my .ant-card-actions { background: var(--bg-color-secondary); } -/* ========== 参与的项目卡片:绿色协作风 ========== */ +/* ========== 参与的项目卡片:绿色协作风(无顶边) ========== */ .project-card-share { text-align: left; - border-top: 3px solid #52c41a; } .project-card-share .ant-card-actions { diff --git a/frontend/src/pages/ProjectList/ProjectList.jsx b/frontend/src/pages/ProjectList/ProjectList.jsx index 13ded7e..f2d6f08 100644 --- a/frontend/src/pages/ProjectList/ProjectList.jsx +++ b/frontend/src/pages/ProjectList/ProjectList.jsx @@ -6,6 +6,8 @@ import { getMyProjects, getOwnedProjects, getSharedProjects, createProject, dele import { getProjectShareInfo, updateProjectShareSettings } from '@/api/share' import { getUserList } from '@/api/users' import { searchDocuments } from '@/api/search' +import { getUnreadByProject, markProjectNotificationsRead } from '@/api/notification' +import useNotificationStore from '@/stores/notificationStore' import ListActionBar from '@/components/ListActionBar/ListActionBar' import Toast from '@/components/Toast/Toast' import useProjectKnowledge from './useProjectKnowledge' @@ -39,6 +41,7 @@ function ProjectList({ type = 'my' }) { const navigate = useNavigate() const { user: currentUser } = useUserStore() const [currentPage, setCurrentPage] = useState(1) + const [unreadByProject, setUnreadByProject] = useState({}) const pageSize = 8 // 参与项目角色 -> 显示名称 @@ -59,6 +62,22 @@ function ProjectList({ type = 'my' }) { } } + // 相对时间(用于“最后更新”) + const formatRelativeTime = (dateStr) => { + if (!dateStr) return '' + const d = new Date(dateStr) + if (Number.isNaN(d.getTime())) return '' + const diff = Date.now() - d.getTime() + if (diff < 60 * 1000) return '刚刚' + const minutes = Math.floor(diff / (60 * 1000)) + if (minutes < 60) return `${minutes} 分钟前` + const hours = Math.floor(minutes / 60) + if (hours < 24) return `${hours} 小时前` + const days = Math.floor(hours / 24) + if (days < 30) return `${days} 天前` + return formatDate(dateStr) + } + // 卡片归属用户显示名(我的项目 -> 当前用户;参与项目 -> 项目所有者) const cardOwnerName = (project) => { if (type === 'my') { @@ -166,6 +185,14 @@ function ProjectList({ type = 'my' }) { res = await getMyProjects() } setProjects(res.data || []) + // 参与项目:加载每个项目的未读更新通知数(与消息通知保持一致) + if (type === 'share') { + getUnreadByProject() + .then((unreadRes) => setUnreadByProject(unreadRes.data?.unread_by_project || {})) + .catch(() => setUnreadByProject({})) + } else { + setUnreadByProject({}) + } } catch (error) { console.error('Fetch projects error:', error) } finally { @@ -394,6 +421,20 @@ function ProjectList({ type = 'my' }) { } const handleOpenProject = (projectId) => { + // 打开参与项目时,将其未读更新通知标记为已读(与消息通知联动) + if (type === 'share' && unreadByProject[projectId]) { + markProjectNotificationsRead(projectId) + .then(() => { + setUnreadByProject((prev) => { + if (!prev[projectId]) return prev + const next = { ...prev } + delete next[projectId] + return next + }) + useNotificationStore.getState().fetchUnreadCount() + }) + .catch(() => {}) + } navigate(`/projects/${projectId}/docs`) } @@ -685,10 +726,19 @@ function ProjectList({ type = 'my' }) { , ]} > - {/* 公开项目标识 */} - {project.is_public === 1 && ( + {/* 公开项目标识(仅我的项目显示,参与项目不显示) */} + {type === 'my' && project.is_public === 1 && (
公开
)} + {/* 参与项目:右上角显示未读更新通知数(打开项目后已读) */} + {type === 'share' && (unreadByProject[project.id] || 0) > 0 && ( +
+ {unreadByProject[project.id] > 99 ? '99+' : unreadByProject[project.id]} +
+ )} {/* 统一的卡片骨架:标题 + 描述(主要信息)+ 底部统计/归属/角色 */}
@@ -706,8 +756,8 @@ function ProjectList({ type = 'my' }) { {project.doc_count || 0} 文档 - - {formatDate(project.created_at) || '—'} + + {formatRelativeTime(project.last_activity_at) || formatDate(project.created_at) || '—'} {project.member_count ?? 0} 人参与