修改了部分bug
parent
2004ccce4e
commit
4cee9414a5
|
|
@ -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:
|
||||||
key = notification_service._get_order_key(current_user.id)
|
if not unread_only:
|
||||||
total = await redis.zcard(key)
|
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 {
|
return {
|
||||||
"code": 200,
|
"code": 200,
|
||||||
|
|
|
||||||
|
|
@ -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,28 +148,45 @@ 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)
|
||||||
|
|
||||||
ids = await redis.zrevrange(order_key, skip, skip + limit - 1)
|
# 如果是查询全部且没有过滤,可以直接利用 ZREVRANGE 分页
|
||||||
if not ids:
|
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 []
|
return []
|
||||||
|
|
||||||
json_strings = await redis.hmget(content_key, ids)
|
all_jsons = await redis.hmget(content_key, all_ids)
|
||||||
|
|
||||||
notifications = []
|
notifications = []
|
||||||
ids_to_remove = []
|
for json_str in all_jsons:
|
||||||
|
|
||||||
for i, json_str in enumerate(json_strings):
|
|
||||||
if json_str:
|
if json_str:
|
||||||
try:
|
try:
|
||||||
notifications.append(json.loads(json_str))
|
data = json.loads(json_str)
|
||||||
except:
|
if data.get('is_read') is False:
|
||||||
continue
|
notifications.append(data)
|
||||||
else:
|
except: continue
|
||||||
ids_to_remove.append(ids[i])
|
|
||||||
|
|
||||||
if ids_to_remove:
|
# 手动分页
|
||||||
await redis.zrem(order_key, *ids_to_remove)
|
return notifications[skip : skip + limit]
|
||||||
|
|
||||||
return notifications
|
|
||||||
|
|
||||||
async def get_unread_count(self, user_id: int) -> int:
|
async def get_unread_count(self, user_id: int) -> int:
|
||||||
"""获取未读通知数量"""
|
"""获取未读通知数量"""
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
Loading…
Reference in New Issue