优化了显示

main
mula.liu 2026-08-19 00:58:02 +08:00
parent 2dbff78698
commit 4ef3c92d65
7 changed files with 272 additions and 15 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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: '确认退出',

View File

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

View File

@ -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' }) {
<Tooltip key="view" title="进入项目"><EyeOutlined /></Tooltip>,
]}
>
{/* 公开项目标识 */}
{project.is_public === 1 && (
{/* 公开项目标识(仅我的项目显示,参与项目不显示) */}
{type === 'my' && project.is_public === 1 && (
<div className="project-card-public-badge">公开</div>
)}
{/* 参与项目:右上角显示未读更新通知数(打开项目后已读) */}
{type === 'share' && (unreadByProject[project.id] || 0) > 0 && (
<div
className="project-card-update-badge"
title={`${unreadByProject[project.id]} 条项目更新通知`}
>
{unreadByProject[project.id] > 99 ? '99+' : unreadByProject[project.id]}
</div>
)}
{/* 统一的卡片骨架:标题 + 描述(主要信息)+ 底部统计/归属/角色 */}
<div className="project-card-body">
@ -706,8 +756,8 @@ function ProjectList({ type = 'my' }) {
<span className="project-stat-item" title="文档数量">
<FileTextOutlined /> {project.doc_count || 0} 文档
</span>
<span className="project-stat-item" title="创建时间">
<CalendarOutlined /> {formatDate(project.created_at) || '—'}
<span className="project-stat-item" title="最后更新时间">
<CalendarOutlined /> {formatRelativeTime(project.last_activity_at) || formatDate(project.created_at) || '—'}
</span>
<span className="project-stat-item" title="项目参与人数量">
<TeamOutlined /> {project.member_count ?? 0} 人参与