diff --git a/src/App.tsx b/src/App.tsx index d7a297b..0a5362d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,8 +1,8 @@ // 你好,感谢你愿意看源代码,但是悄悄告诉你,代码其实是AI写的所以质量很差喵。抱歉呜呜呜😭。 import React, { useState, useEffect, useRef, useCallback } from 'react'; -import { FluentProvider, webLightTheme, webDarkTheme, tokens, Button } from '@fluentui/react-components'; -import { BrowserRouter, Routes, Route } from 'react-router-dom'; +import { FluentProvider, webLightTheme, webDarkTheme, tokens, Button, MessageBar, MessageBarBody, MessageBarActions } from '@fluentui/react-components'; +import { BrowserRouter, Routes, Route, useLocation, useNavigate } from 'react-router-dom'; import PostCard from './components/PostCard'; import MainLayout from './layouts/MainLayout'; import './App.css'; @@ -21,7 +21,74 @@ import ImageViewer from './components/ImageViewer'; import NoticeModal from './components/NoticeModal'; import type { NoticeData } from './components/NoticeModal'; import DevToolsModal from './components/DevToolsModal'; -import { Bug24Regular } from '@fluentui/react-icons'; +import { Bug24Regular, Dismiss24Regular } from '@fluentui/react-icons'; +import { useSSE } from './hooks/useSSE'; + +// 全局组件,管理 SSE 和新帖提示 +function GlobalSSEManager({ + onNewPost, + newPostCount, + showNewPostBar, + onRefreshClick, + onDismiss +}: { + onNewPost: () => void; + newPostCount: number; + showNewPostBar: boolean; + onRefreshClick: () => void; + onDismiss: () => void; +}) { + const [sseEnabled, setSseEnabled] = useState(false); + + // 延迟启动 SSE,等待其他内容加载完成 + useEffect(() => { + const timer = setTimeout(() => { + setSseEnabled(true); + }, 1000); // 延迟 1 秒后启动 SSE + + return () => clearTimeout(timer); + }, []); + + // 使用 SSE hook,始终启用(不限制页面) + useSSE({ + enabled: sseEnabled, + onNewPost, + maxRetries: 3, + }); + + return ( + <> + {/* 新帖提示 MessageBar - 全局显示 */} + {showNewPostBar && ( +
+ + +
+ 有 {newPostCount} 条新帖,点击刷新 +
+
+ +
+ )} + + ); +} function App() { const [isDarkMode, setIsDarkMode] = React.useState(() => { @@ -58,6 +125,8 @@ function App() { const [showNotice, setShowNotice] = useState(false); const [showDevTools, setShowDevTools] = useState(false); const [isDebugMode, setIsDebugMode] = useState(false); + const [newPostCount, setNewPostCount] = useState(0); + const [showNewPostBar, setShowNewPostBar] = useState(false); useEffect(() => { const searchParams = new URLSearchParams(window.location.search); @@ -114,8 +183,21 @@ function App() { setPage(1); setHomeRefreshTick((t) => t + 1); if (containerRef.current) containerRef.current.scrollTop = 0; + // 重置新帖数量 + setNewPostCount(0); + setShowNewPostBar(false); }; + const handleNewPostBarDismiss = useCallback(() => { + setShowNewPostBar(false); + setNewPostCount(0); + }, []); + + const handleNewPost = useCallback(() => { + setNewPostCount((prev) => prev + 1); + setShowNewPostBar(true); + }, []); + // 移除触摸下拉刷新逻辑 // 撤销 Pointer 事件回退,恢复为纯 Touch 逻辑 @@ -163,7 +245,117 @@ function App() { return ( - + + + + {imageViewer.open && imageViewer.src && ( + + )} + {showNotice && noticeData && ( +
+ setShowNotice(false)} + onNeverShow={(version) => { + localStorage.setItem('notice_version', String(version)); + setShowNotice(false); + }} + /> +
+ )} + {showDevTools && setShowDevTools(false)} />} +
+
+ ); +} + +// 内部组件,在 BrowserRouter 内部,可以使用 useNavigate +function AppContent({ + isDarkMode, + setIsDarkMode, + articles, + loading, + hasMore, + refreshing, + containerRef, + lastArticleRef, + onWheel, + openImageViewer, + doRefresh, + newPostCount, + showNewPostBar, + handleNewPost, + handleNewPostBarDismiss, + isDebugMode, + showDevTools, + setShowDevTools, +}: any) { + const navigate = useNavigate(); + const location = useLocation(); + + const handleNewPostBarClick = () => { + // 如果不在首页,先跳转到首页 + if (location.pathname !== '/') { + navigate('/'); + // 等待导航完成后刷新 + setTimeout(() => { + doRefresh(); + }, 100); + } else { + // 已经在首页,直接刷新 + doRefresh(); + } + }; + + return ( + <> + + setIsDarkMode(!isDarkMode)} />}> } /> setIsDarkMode(!isDarkMode)} />} /> - } /> + } /> - - - - {imageViewer.open && imageViewer.src && ( - - )} - {showNotice && noticeData && ( -
- setShowNotice(false)} - onNeverShow={(version) => { - localStorage.setItem('notice_version', String(version)); - setShowNotice(false); - }} - /> -
- )} - {/* DevTools Trigger */} - {isDebugMode && ( -
-
- )} - {showDevTools && setShowDevTools(false)} />} - - ); + {/* DevTools Trigger */} + {isDebugMode && ( +
+
+ )} + + ); } export default App; diff --git a/src/hooks/useSSE.ts b/src/hooks/useSSE.ts new file mode 100644 index 0000000..fd78962 --- /dev/null +++ b/src/hooks/useSSE.ts @@ -0,0 +1,100 @@ +import { useEffect, useRef, useState } from 'react'; +import { API_CONFIG } from '../config'; + +interface UseSSEOptions { + onNewPost?: () => void; + maxRetries?: number; + enabled?: boolean; +} + +export function useSSE(options: UseSSEOptions = {}) { + const { + onNewPost, + maxRetries = 3, + enabled = true, + } = options; + + const [isConnected, setIsConnected] = useState(false); + const eventSourceRef = useRef(null); + const retriesRef = useRef(0); + const reconnectTimeoutRef = useRef(null); + const onNewPostRef = useRef(onNewPost); + + // 更新 ref 以获取最新的回调 + useEffect(() => { + onNewPostRef.current = onNewPost; + }, [onNewPost]); + + useEffect(() => { + if (!enabled) { + return; + } + + const connect = () => { + // 如果已经达到最大重连次数,放弃连接 + if (retriesRef.current >= maxRetries) { + return; + } + + try { + const eventSource = new EventSource(`${API_CONFIG.BASE_URL}/stream`); + eventSourceRef.current = eventSource; + + eventSource.onopen = () => { + setIsConnected(true); + retriesRef.current = 0; // 重置重试计数 + }; + + eventSource.onmessage = (event) => { + const data = event.data; + + if (data === 'heartbeat') { + // 心跳消息,不做处理 + return; + } + + if (data === 'new_post') { + // 新投稿通知 + onNewPostRef.current?.(); + } + }; + + eventSource.onerror = () => { + setIsConnected(false); + eventSource.close(); + + // 如果还没达到最大重连次数,尝试重连 + if (retriesRef.current < maxRetries) { + retriesRef.current += 1; + + // 使用指数退避策略重连 + const delay = Math.min(1000 * Math.pow(2, retriesRef.current - 1), 10000); + reconnectTimeoutRef.current = setTimeout(() => { + connect(); + }, delay); + } + }; + } catch { + setIsConnected(false); + } + }; + + // 初始连接 + connect(); + + // 清理函数 + return () => { + if (eventSourceRef.current) { + eventSourceRef.current.close(); + eventSourceRef.current = null; + } + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } + setIsConnected(false); + }; + }, [enabled, maxRetries]); + + return { isConnected }; +}