From 4cee9414a53a8de3e86ec3d817c17b29764b84fd Mon Sep 17 00:00:00 2001 From: "mula.liu" Date: Wed, 28 Jan 2026 19:55:01 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E4=BA=86=E9=83=A8=E5=88=86bu?= =?UTF-8?q?g?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/v1/notifications.py | 13 ++- backend/app/services/notification_service.py | 50 ++++++--- .../src/components/MainLayout/AppHeader.jsx | 22 ++-- frontend/src/pages/Desktop.jsx | 2 +- frontend/src/pages/Document/DocumentPage.jsx | 103 +++++++++--------- .../pages/Notifications/NotificationList.jsx | 42 +++++-- frontend/src/pages/Preview/PreviewPage.jsx | 103 +++++++++--------- frontend/src/stores/notificationStore.js | 19 ++++ 8 files changed, 210 insertions(+), 144 deletions(-) create mode 100644 frontend/src/stores/notificationStore.js diff --git a/backend/app/api/v1/notifications.py b/backend/app/api/v1/notifications.py index 4903bdc..8343bef 100644 --- a/backend/app/api/v1/notifications.py +++ b/backend/app/api/v1/notifications.py @@ -22,6 +22,7 @@ router = APIRouter() async def get_notifications( page: int = 1, page_size: int = 20, + unread_only: bool = False, current_user: User = Depends(get_current_user), ): """获取当前用户的通知列表 (从 Redis 读取)""" @@ -30,7 +31,8 @@ async def get_notifications( notifications = await notification_service.get_user_notifications( user_id=current_user.id, limit=page_size, - skip=skip + skip=skip, + unread_only=unread_only ) # 获取总数 (ZCARD) @@ -38,8 +40,13 @@ async def get_notifications( redis = get_redis() total = 0 if redis: - key = notification_service._get_order_key(current_user.id) - total = await redis.zcard(key) + if not unread_only: + key = notification_service._get_order_key(current_user.id) + total = await redis.zcard(key) + else: + # 对于未读过滤,总数即为过滤后的列表长度 + # 这里为了简单直接使用返回列表的长度(如果没满一页)或者重新计算 + total = await notification_service.get_unread_count(current_user.id) return { "code": 200, diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py index 73982aa..87e3773 100644 --- a/backend/app/services/notification_service.py +++ b/backend/app/services/notification_service.py @@ -137,7 +137,8 @@ class NotificationService: self, user_id: int, limit: int = 50, - skip: int = 0 + skip: int = 0, + unread_only: bool = False ) -> List[Dict[str, Any]]: """获取用户通知列表""" redis = get_redis() @@ -147,28 +148,45 @@ class NotificationService: order_key = self._get_order_key(user_id) content_key = self._get_content_key(user_id) - ids = await redis.zrevrange(order_key, skip, skip + limit - 1) - if not ids: + # 如果是查询全部且没有过滤,可以直接利用 ZREVRANGE 分页 + if not unread_only: + ids = await redis.zrevrange(order_key, skip, skip + limit - 1) + if not ids: + return [] + json_strings = await redis.hmget(content_key, ids) + + notifications = [] + ids_to_remove = [] + for i, json_str in enumerate(json_strings): + if json_str: + try: + notifications.append(json.loads(json_str)) + except: continue + else: + ids_to_remove.append(ids[i]) + if ids_to_remove: + await redis.zrem(order_key, *ids_to_remove) + return notifications + + # 如果需要过滤未读,由于 Redis Hash 不支持按值过滤,需要获取较多数据在内存中过滤 + # 考虑到数据只保留 14 天,全量获取(最近几百条)在内存中处理是可行的 + all_ids = await redis.zrevrange(order_key, 0, -1) + if not all_ids: return [] - json_strings = await redis.hmget(content_key, ids) + all_jsons = await redis.hmget(content_key, all_ids) notifications = [] - ids_to_remove = [] - - for i, json_str in enumerate(json_strings): + for json_str in all_jsons: if json_str: try: - notifications.append(json.loads(json_str)) - except: - continue - else: - ids_to_remove.append(ids[i]) + data = json.loads(json_str) + if data.get('is_read') is False: + notifications.append(data) + except: continue - if ids_to_remove: - await redis.zrem(order_key, *ids_to_remove) - - return notifications + # 手动分页 + return notifications[skip : skip + limit] async def get_unread_count(self, user_id: int) -> int: """获取未读通知数量""" diff --git a/frontend/src/components/MainLayout/AppHeader.jsx b/frontend/src/components/MainLayout/AppHeader.jsx index 3c22038..c5b88ad 100644 --- a/frontend/src/components/MainLayout/AppHeader.jsx +++ b/frontend/src/components/MainLayout/AppHeader.jsx @@ -15,6 +15,7 @@ import { NotificationOutlined, } from '@ant-design/icons' import useUserStore from '@/stores/userStore' +import useNotificationStore from '@/stores/notificationStore' import { getNotifications, getUnreadCount, markAsRead, markAllAsRead } from '@/api/notification' import Toast from '@/components/Toast/Toast' import headerMenuData from '../../data/headerMenuData.json' @@ -33,6 +34,7 @@ const iconMap = { function AppHeader({ collapsed, onToggle }) { const navigate = useNavigate() const { user, logout } = useUserStore() + const { unreadCount, fetchUnreadCount, decrementUnreadCount, resetUnreadCount } = useNotificationStore() // 用户下拉菜单 const userMenuItems = [ @@ -54,7 +56,6 @@ function AppHeader({ collapsed, onToggle }) { ] const [notifications, setNotifications] = useState([]) - const [unreadCount, setUnreadCount] = useState(0) const [loading, setLoading] = useState(false) const [popoverVisible, setPopoverVisible] = useState(false) @@ -67,15 +68,6 @@ function AppHeader({ collapsed, onToggle }) { } }, [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 { @@ -92,7 +84,7 @@ function AppHeader({ collapsed, onToggle }) { try { await markAsRead(id) setNotifications(notifications.map(n => n.id === id ? { ...n, is_read: 1 } : n)) - fetchUnreadCount() + decrementUnreadCount() } catch (error) { console.error('Mark read error:', error) } @@ -102,7 +94,7 @@ function AppHeader({ collapsed, onToggle }) { try { await markAllAsRead() setNotifications(notifications.map(n => ({ ...n, is_read: 1 }))) - setUnreadCount(0) + resetUnreadCount() Toast.success('操作成功', '所有通知已标记为已读') } catch (error) { console.error('Mark all read error:', error) @@ -110,7 +102,7 @@ function AppHeader({ collapsed, onToggle }) { } const handleNotificationClick = (n) => { - if (n.is_read === 0) { + if (!n.is_read) { handleMarkRead(n.id) } if (n.link) { @@ -145,12 +137,12 @@ function AppHeader({ collapsed, onToggle }) { locale={{ emptyText: }} renderItem={(item) => ( handleNotificationClick(item)} > } - title={{item.title}} + title={{item.title}} description={
{item.content}
diff --git a/frontend/src/pages/Desktop.jsx b/frontend/src/pages/Desktop.jsx index 11aed7a..0afd05b 100644 --- a/frontend/src/pages/Desktop.jsx +++ b/frontend/src/pages/Desktop.jsx @@ -98,7 +98,7 @@ function Desktop() { } // 跳转到文档页面 - navigate(`/projects/${log.project_id}/documents?path=${encodeURIComponent(log.file_path)}`) + navigate(`/projects/${log.project_id}/docs?file=${encodeURIComponent(log.file_path)}`) } return ( diff --git a/frontend/src/pages/Document/DocumentPage.jsx b/frontend/src/pages/Document/DocumentPage.jsx index f353826..6d7ffe1 100644 --- a/frontend/src/pages/Document/DocumentPage.jsx +++ b/frontend/src/pages/Document/DocumentPage.jsx @@ -67,11 +67,62 @@ function DocumentPage() { loadFileTree() }, [projectId]) + // 监听 URL 参数变化,处理文件导航和搜索 useEffect(() => { - if (userRole && userRole !== 'viewer') { - loadGitRepos() + // 只有当文件树加载完成后才处理导航,否则无法正确展开目录 + if (fileTree.length === 0) return + + const fileParam = searchParams.get('file') + const keywordParam = searchParams.get('keyword') + + // 处理搜索 + if (keywordParam && keywordParam !== searchKeyword) { + handleSearch(keywordParam) } - }, [projectId, userRole]) + + // 处理文件加载 + if (fileParam) { + if (fileParam !== selectedFile) { + setSelectedFile(fileParam) + + // 展开父目录 + const parts = fileParam.split('/') + const allParentPaths = [] + let currentPath = '' + for (let i = 0; i < parts.length - 1; i++) { + currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i] + allParentPaths.push(currentPath) + } + if (allParentPaths.length > 0) { + setOpenKeys(prev => [...new Set([...prev, ...allParentPaths])]) + } + + // 处理 PDF 或 Markdown + if (fileParam.toLowerCase().endsWith('.pdf')) { + let url = getDocumentUrl(projectId, fileParam) + const token = localStorage.getItem('access_token') + if (token) { + url += `?token=${encodeURIComponent(token)}` + } + setPdfUrl(url) + setPdfFilename(fileParam.split('/').pop()) + setViewMode('pdf') + } else { + loadMarkdown(fileParam) + setViewMode('markdown') + } + } + } else { + // 如果没有指定文件,且当前没有选中文件,默认打开 README.md + if (!selectedFile) { + const readmeNode = findReadme(fileTree) + if (readmeNode) { + setSelectedFile(readmeNode.key) + loadMarkdown(readmeNode.key) + } + } + } + }, [searchParams, fileTree]) // 处理搜索 const handleSearch = async (value) => { @@ -152,52 +203,6 @@ function DocumentPage() { setFileTree(tree) setUserRole(role) setProjectName(name) - - // 检查 URL 是否指定了文件 - const fileParam = searchParams.get('file') - const keywordParam = searchParams.get('keyword') - - if (keywordParam) { - handleSearch(keywordParam) - } - - if (fileParam) { - setSelectedFile(fileParam) - - // 展开父目录 - const parts = fileParam.split('/') - const allParentPaths = [] - let currentPath = '' - for (let i = 0; i < parts.length - 1; i++) { - const part = parts[i] - currentPath = currentPath ? `${currentPath}/${part}` : part - allParentPaths.push(currentPath) - } - if (allParentPaths.length > 0) { - setOpenKeys(prev => [...new Set([...prev, ...allParentPaths])]) - } - - // 处理 PDF 或 Markdown - if (fileParam.toLowerCase().endsWith('.pdf')) { - let url = getDocumentUrl(projectId, fileParam) - const token = localStorage.getItem('access_token') - if (token) { - url += `?token=${encodeURIComponent(token)}` - } - setPdfUrl(url) - setPdfFilename(fileParam.split('/').pop()) - setViewMode('pdf') - } else { - loadMarkdown(fileParam) - } - } else { - // 默认打开 README.md - const readmeNode = findReadme(tree) - if (readmeNode) { - setSelectedFile(readmeNode.key) - loadMarkdown(readmeNode.key) - } - } } catch (error) { console.error('Load file tree error:', error) } diff --git a/frontend/src/pages/Notifications/NotificationList.jsx b/frontend/src/pages/Notifications/NotificationList.jsx index 03cadde..7282c53 100644 --- a/frontend/src/pages/Notifications/NotificationList.jsx +++ b/frontend/src/pages/Notifications/NotificationList.jsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react' -import { Layout, List, Avatar, Button, Tabs, Space, Tag, Typography, Empty, Pagination } from 'antd' +import { Layout, List, Avatar, Button, Tabs, Space, Tag, Typography, Empty, Pagination, Badge } from 'antd' import { BellOutlined, CheckOutlined, @@ -9,6 +9,7 @@ import { } from '@ant-design/icons' import { useNavigate } from 'react-router-dom' import { getNotifications, markAsRead, markAllAsRead } from '@/api/notification' +import useNotificationStore from '@/stores/notificationStore' import Toast from '@/components/Toast/Toast' import PageHeader from '@/components/PageHeader/PageHeader' @@ -23,6 +24,7 @@ function NotificationList() { const [pageSize] = useState(10) const [activeTab, setActiveTab] = useState('all') const navigate = useNavigate() + const { decrementUnreadCount, resetUnreadCount } = useNotificationStore() useEffect(() => { fetchNotifications() @@ -49,7 +51,8 @@ function NotificationList() { const handleMarkRead = async (id) => { try { await markAsRead(id) - setNotifications(notifications.map(n => n.id === id ? { ...n, is_read: 1 } : n)) + setNotifications(notifications.map(n => n.id === id ? { ...n, is_read: true } : n)) + decrementUnreadCount() } catch (error) { console.error('Mark read error:', error) } @@ -58,6 +61,7 @@ function NotificationList() { const handleMarkAllRead = async () => { try { await markAllAsRead() + resetUnreadCount() Toast.success('操作成功', '所有消息已标记为已读') fetchNotifications() } catch (error) { @@ -66,7 +70,7 @@ function NotificationList() { } const handleNotificationClick = (n) => { - if (n.is_read === 0) { + if (!n.is_read) { handleMarkRead(n.id) } if (n.link) { @@ -82,10 +86,12 @@ function NotificationList() { } } + const { unreadCount } = useNotificationStore() + return (
} extra={