修改了部分bug

main
mula.liu 2026-01-28 19:55:01 +08:00
parent 2004ccce4e
commit 4cee9414a5
8 changed files with 210 additions and 144 deletions

View File

@ -22,6 +22,7 @@ router = APIRouter()
async def get_notifications( async def get_notifications(
page: int = 1, page: int = 1,
page_size: int = 20, page_size: int = 20,
unread_only: bool = False,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
"""获取当前用户的通知列表 (从 Redis 读取)""" """获取当前用户的通知列表 (从 Redis 读取)"""
@ -30,7 +31,8 @@ async def get_notifications(
notifications = await notification_service.get_user_notifications( notifications = await notification_service.get_user_notifications(
user_id=current_user.id, user_id=current_user.id,
limit=page_size, limit=page_size,
skip=skip skip=skip,
unread_only=unread_only
) )
# 获取总数 (ZCARD) # 获取总数 (ZCARD)
@ -38,8 +40,13 @@ async def get_notifications(
redis = get_redis() redis = get_redis()
total = 0 total = 0
if redis: if redis:
if not unread_only:
key = notification_service._get_order_key(current_user.id) key = notification_service._get_order_key(current_user.id)
total = await redis.zcard(key) total = await redis.zcard(key)
else:
# 对于未读过滤,总数即为过滤后的列表长度
# 这里为了简单直接使用返回列表的长度(如果没满一页)或者重新计算
total = await notification_service.get_unread_count(current_user.id)
return { return {
"code": 200, "code": 200,

View File

@ -137,7 +137,8 @@ class NotificationService:
self, self,
user_id: int, user_id: int,
limit: int = 50, limit: int = 50,
skip: int = 0 skip: int = 0,
unread_only: bool = False
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
"""获取用户通知列表""" """获取用户通知列表"""
redis = get_redis() redis = get_redis()
@ -147,29 +148,46 @@ class NotificationService:
order_key = self._get_order_key(user_id) order_key = self._get_order_key(user_id)
content_key = self._get_content_key(user_id) content_key = self._get_content_key(user_id)
# 如果是查询全部且没有过滤,可以直接利用 ZREVRANGE 分页
if not unread_only:
ids = await redis.zrevrange(order_key, skip, skip + limit - 1) ids = await redis.zrevrange(order_key, skip, skip + limit - 1)
if not ids: if not ids:
return [] return []
json_strings = await redis.hmget(content_key, ids) json_strings = await redis.hmget(content_key, ids)
notifications = [] notifications = []
ids_to_remove = [] ids_to_remove = []
for i, json_str in enumerate(json_strings): for i, json_str in enumerate(json_strings):
if json_str: if json_str:
try: try:
notifications.append(json.loads(json_str)) notifications.append(json.loads(json_str))
except: except: continue
continue
else: else:
ids_to_remove.append(ids[i]) ids_to_remove.append(ids[i])
if ids_to_remove: if ids_to_remove:
await redis.zrem(order_key, *ids_to_remove) await redis.zrem(order_key, *ids_to_remove)
return notifications return notifications
# 如果需要过滤未读,由于 Redis Hash 不支持按值过滤,需要获取较多数据在内存中过滤
# 考虑到数据只保留 14 天,全量获取(最近几百条)在内存中处理是可行的
all_ids = await redis.zrevrange(order_key, 0, -1)
if not all_ids:
return []
all_jsons = await redis.hmget(content_key, all_ids)
notifications = []
for json_str in all_jsons:
if json_str:
try:
data = json.loads(json_str)
if data.get('is_read') is False:
notifications.append(data)
except: continue
# 手动分页
return notifications[skip : skip + limit]
async def get_unread_count(self, user_id: int) -> int: async def get_unread_count(self, user_id: int) -> int:
"""获取未读通知数量""" """获取未读通知数量"""
redis = get_redis() redis = get_redis()

View File

@ -15,6 +15,7 @@ import {
NotificationOutlined, NotificationOutlined,
} from '@ant-design/icons' } from '@ant-design/icons'
import useUserStore from '@/stores/userStore' import useUserStore from '@/stores/userStore'
import useNotificationStore from '@/stores/notificationStore'
import { getNotifications, getUnreadCount, markAsRead, markAllAsRead } from '@/api/notification' 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'
@ -33,6 +34,7 @@ const iconMap = {
function AppHeader({ collapsed, onToggle }) { function AppHeader({ collapsed, onToggle }) {
const navigate = useNavigate() const navigate = useNavigate()
const { user, logout } = useUserStore() const { user, logout } = useUserStore()
const { unreadCount, fetchUnreadCount, decrementUnreadCount, resetUnreadCount } = useNotificationStore()
// //
const userMenuItems = [ const userMenuItems = [
@ -54,7 +56,6 @@ function AppHeader({ collapsed, onToggle }) {
] ]
const [notifications, setNotifications] = useState([]) const [notifications, setNotifications] = useState([])
const [unreadCount, setUnreadCount] = useState(0)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [popoverVisible, setPopoverVisible] = useState(false) const [popoverVisible, setPopoverVisible] = useState(false)
@ -67,15 +68,6 @@ function AppHeader({ collapsed, onToggle }) {
} }
}, [user]) }, [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 () => { const fetchNotifications = async () => {
setLoading(true) setLoading(true)
try { try {
@ -92,7 +84,7 @@ function AppHeader({ collapsed, onToggle }) {
try { try {
await markAsRead(id) 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: 1 } : n))
fetchUnreadCount() decrementUnreadCount()
} catch (error) { } catch (error) {
console.error('Mark read error:', error) console.error('Mark read error:', error)
} }
@ -102,7 +94,7 @@ function AppHeader({ collapsed, onToggle }) {
try { try {
await markAllAsRead() await markAllAsRead()
setNotifications(notifications.map(n => ({ ...n, is_read: 1 }))) setNotifications(notifications.map(n => ({ ...n, is_read: 1 })))
setUnreadCount(0) resetUnreadCount()
Toast.success('操作成功', '所有通知已标记为已读') Toast.success('操作成功', '所有通知已标记为已读')
} catch (error) { } catch (error) {
console.error('Mark all read error:', error) console.error('Mark all read error:', error)
@ -110,7 +102,7 @@ function AppHeader({ collapsed, onToggle }) {
} }
const handleNotificationClick = (n) => { const handleNotificationClick = (n) => {
if (n.is_read === 0) { if (!n.is_read) {
handleMarkRead(n.id) handleMarkRead(n.id)
} }
if (n.link) { if (n.link) {
@ -145,12 +137,12 @@ function AppHeader({ collapsed, onToggle }) {
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无新消息" /> }} locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无新消息" /> }}
renderItem={(item) => ( renderItem={(item) => (
<List.Item <List.Item
className={`notification-item ${item.is_read === 0 ? 'unread' : ''}`} className={`notification-item ${!item.is_read ? 'unread' : ''}`}
onClick={() => handleNotificationClick(item)} onClick={() => handleNotificationClick(item)}
> >
<List.Item.Meta <List.Item.Meta
avatar={<Avatar icon={getCategoryIcon(item.category)} />} avatar={<Avatar icon={getCategoryIcon(item.category)} />}
title={<Text strong={item.is_read === 0}>{item.title}</Text>} title={<Text strong={!item.is_read}>{item.title}</Text>}
description={ description={
<div> <div>
<div className="content-text">{item.content}</div> <div className="content-text">{item.content}</div>

View File

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

View File

@ -67,11 +67,62 @@ function DocumentPage() {
loadFileTree() loadFileTree()
}, [projectId]) }, [projectId])
// URL
useEffect(() => { 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) => { const handleSearch = async (value) => {
@ -152,52 +203,6 @@ function DocumentPage() {
setFileTree(tree) setFileTree(tree)
setUserRole(role) setUserRole(role)
setProjectName(name) 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) { } catch (error) {
console.error('Load file tree error:', error) console.error('Load file tree error:', error)
} }

View File

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react' 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 { import {
BellOutlined, BellOutlined,
CheckOutlined, CheckOutlined,
@ -9,6 +9,7 @@ import {
} from '@ant-design/icons' } from '@ant-design/icons'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { getNotifications, markAsRead, markAllAsRead } from '@/api/notification' import { getNotifications, markAsRead, markAllAsRead } from '@/api/notification'
import useNotificationStore from '@/stores/notificationStore'
import Toast from '@/components/Toast/Toast' import Toast from '@/components/Toast/Toast'
import PageHeader from '@/components/PageHeader/PageHeader' import PageHeader from '@/components/PageHeader/PageHeader'
@ -23,6 +24,7 @@ function NotificationList() {
const [pageSize] = useState(10) const [pageSize] = useState(10)
const [activeTab, setActiveTab] = useState('all') const [activeTab, setActiveTab] = useState('all')
const navigate = useNavigate() const navigate = useNavigate()
const { decrementUnreadCount, resetUnreadCount } = useNotificationStore()
useEffect(() => { useEffect(() => {
fetchNotifications() fetchNotifications()
@ -49,7 +51,8 @@ function NotificationList() {
const handleMarkRead = async (id) => { const handleMarkRead = async (id) => {
try { try {
await markAsRead(id) 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) { } catch (error) {
console.error('Mark read error:', error) console.error('Mark read error:', error)
} }
@ -58,6 +61,7 @@ function NotificationList() {
const handleMarkAllRead = async () => { const handleMarkAllRead = async () => {
try { try {
await markAllAsRead() await markAllAsRead()
resetUnreadCount()
Toast.success('操作成功', '所有消息已标记为已读') Toast.success('操作成功', '所有消息已标记为已读')
fetchNotifications() fetchNotifications()
} catch (error) { } catch (error) {
@ -66,7 +70,7 @@ function NotificationList() {
} }
const handleNotificationClick = (n) => { const handleNotificationClick = (n) => {
if (n.is_read === 0) { if (!n.is_read) {
handleMarkRead(n.id) handleMarkRead(n.id)
} }
if (n.link) { if (n.link) {
@ -82,10 +86,12 @@ function NotificationList() {
} }
} }
const { unreadCount } = useNotificationStore()
return ( return (
<div className="notification-list-page"> <div className="notification-list-page">
<PageHeader <PageHeader
title="通知中心" title="消息中心"
icon={<BellOutlined />} icon={<BellOutlined />}
extra={ extra={
<Button icon={<CheckOutlined />} onClick={handleMarkAllRead}> <Button icon={<CheckOutlined />} onClick={handleMarkAllRead}>
@ -99,8 +105,24 @@ function NotificationList() {
activeKey={activeTab} activeKey={activeTab}
onChange={(key) => { setActiveTab(key); setPage(1); }} onChange={(key) => { setActiveTab(key); setPage(1); }}
items={[ items={[
{ key: 'all', label: '全部消息' }, {
{ key: 'unread', label: '未读消息' }, key: 'all',
label: (
<Space>
全部消息
{activeTab === 'all' && <Badge count={total} overflowCount={99} style={{ backgroundColor: '#d9d9d9' }} />}
</Space>
)
},
{
key: 'unread',
label: (
<Space>
未读消息
<Badge count={unreadCount} overflowCount={99} />
</Space>
)
},
]} ]}
/> />
@ -112,12 +134,12 @@ function NotificationList() {
renderItem={(item) => ( renderItem={(item) => (
<List.Item <List.Item
key={item.id} key={item.id}
className={`notification-list-item ${item.is_read === 0 ? 'unread' : ''}`} className={`notification-list-item ${!item.is_read ? 'unread' : ''}`}
style={{ style={{
cursor: 'pointer', cursor: 'pointer',
padding: '16px', padding: '16px',
borderBottom: '1px solid #f0f0f0', borderBottom: '1px solid #f0f0f0',
background: item.is_read === 0 ? '#f0faff' : 'transparent', background: !item.is_read ? '#f0faff' : 'transparent',
transition: 'all 0.3s' transition: 'all 0.3s'
}} }}
onClick={() => handleNotificationClick(item)} onClick={() => handleNotificationClick(item)}
@ -126,7 +148,7 @@ function NotificationList() {
<Avatar icon={getCategoryIcon(item.category)} /> <Avatar icon={getCategoryIcon(item.category)} />
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '8px' }}> <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '8px' }}>
<Text strong={item.is_read === 0} style={{ fontSize: '16px' }}> <Text strong={!item.is_read} style={{ fontSize: '16px' }}>
{item.title} {item.title}
</Text> </Text>
<Text type="secondary" style={{ fontSize: '12px' }}> <Text type="secondary" style={{ fontSize: '12px' }}>
@ -136,7 +158,7 @@ function NotificationList() {
<div style={{ color: '#595959', marginBottom: '8px' }}> <div style={{ color: '#595959', marginBottom: '8px' }}>
{item.content} {item.content}
</div> </div>
{item.is_read === 0 && <Tag color="blue">未读</Tag>} {!item.is_read && <Tag color="blue">未读</Tag>}
</div> </div>
</div> </div>
</List.Item> </List.Item>

View File

@ -106,6 +106,59 @@ function PreviewPage() {
loadProjectInfo() loadProjectInfo()
}, [projectId]) }, [projectId])
// URL
useEffect(() => {
if (fileTree.length === 0) return
const fileParam = searchParams.get('file')
const keywordParam = searchParams.get('keyword')
if (keywordParam && keywordParam !== searchKeyword) {
handleSearch(keywordParam)
}
if (fileParam) {
if (fileParam !== selectedFile) {
// Deep link to file
if (fileParam.toLowerCase().endsWith('.pdf')) {
let url = getPreviewDocumentUrl(projectId, fileParam)
const params = []
if (accessPassword) params.push(`access_pass=${encodeURIComponent(accessPassword)}`)
const token = localStorage.getItem('access_token')
if (token) params.push(`token=${encodeURIComponent(token)}`)
if (params.length > 0) url += `?${params.join('&')}`
setSelectedFile(fileParam)
setPdfUrl(url)
setPdfFilename(fileParam.split('/').pop())
setViewMode('pdf')
} else {
setSelectedFile(fileParam)
loadMarkdown(fileParam, accessPassword)
setViewMode('markdown')
}
// Expand tree to file
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)
}
setOpenKeys(prev => [...new Set([...prev, ...allParentPaths])])
}
} else {
if (!selectedFile) {
const readmeNode = findReadme(fileTree)
if (readmeNode) {
setSelectedFile(readmeNode.key)
loadMarkdown(readmeNode.key, accessPassword)
}
}
}
}, [searchParams, fileTree, accessPassword])
// //
const loadProjectInfo = async () => { const loadProjectInfo = async () => {
try { try {
@ -148,56 +201,6 @@ function PreviewPage() {
const res = await getPreviewTree(projectId, pwd || accessPassword) const res = await getPreviewTree(projectId, pwd || accessPassword)
const tree = res.data || [] const tree = res.data || []
setFileTree(tree) setFileTree(tree)
const readmeNode = findReadme(tree)
// Check query params
const fileParam = searchParams.get('file')
const keywordParam = searchParams.get('keyword')
if (keywordParam) {
handleSearch(keywordParam)
}
if (fileParam) {
// Deep link to file
if (fileParam.toLowerCase().endsWith('.pdf')) {
let url = getPreviewDocumentUrl(projectId, fileParam)
// ... params logic repeated from handleMenuClick ...
// Simplify: just call logic or set state
// Since we need token/password logic, let's reuse handleMenuClick logic if possible or copy it.
// For simplicity, just set selection and let user click? No, auto load.
// Copy logic for PDF url construction
const params = []
if (pwd || accessPassword) params.push(`access_pass=${encodeURIComponent(pwd || accessPassword)}`)
const token = localStorage.getItem('access_token')
if (token) params.push(`token=${encodeURIComponent(token)}`)
if (params.length > 0) url += `?${params.join('&')}`
setSelectedFile(fileParam)
setPdfUrl(url)
setPdfFilename(fileParam.split('/').pop())
setViewMode('pdf')
} else {
setSelectedFile(fileParam)
loadMarkdown(fileParam, pwd || accessPassword)
}
// Expand tree to file
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)
}
setOpenKeys(prev => [...new Set([...prev, ...allParentPaths])])
} else if (readmeNode) {
setSelectedFile(readmeNode.key)
loadMarkdown(readmeNode.key, pwd || accessPassword)
}
} catch (error) { } catch (error) {
console.error('Load file tree error:', error) console.error('Load file tree error:', error)
if (error.response?.status === 403) { if (error.response?.status === 403) {

View File

@ -0,0 +1,19 @@
import { create } from 'zustand'
import { getUnreadCount } from '@/api/notification'
const useNotificationStore = create((set, get) => ({
unreadCount: 0,
setUnreadCount: (count) => set({ unreadCount: count }),
decrementUnreadCount: () => set((state) => ({ unreadCount: Math.max(0, state.unreadCount - 1) })),
fetchUnreadCount: async () => {
try {
const res = await getUnreadCount()
set({ unreadCount: res.data.unread_count })
} catch (error) {
console.error('Fetch unread count error:', error)
}
},
resetUnreadCount: () => set({ unreadCount: 0 }),
}))
export default useNotificationStore