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' }) {