Feat:增加SSE新帖推送

This commit is contained in:
LeonspaceX
2025-12-16 22:04:35 +08:00
parent e86a5dd4fa
commit 396713263d
2 changed files with 311 additions and 57 deletions

View File

@@ -1,8 +1,8 @@
// 你好感谢你愿意看源代码但是悄悄告诉你代码其实是AI写的所以质量很差喵。抱歉呜呜呜😭。 // 你好感谢你愿意看源代码但是悄悄告诉你代码其实是AI写的所以质量很差喵。抱歉呜呜呜😭。
import React, { useState, useEffect, useRef, useCallback } from 'react'; import React, { useState, useEffect, useRef, useCallback } from 'react';
import { FluentProvider, webLightTheme, webDarkTheme, tokens, Button } from '@fluentui/react-components'; import { FluentProvider, webLightTheme, webDarkTheme, tokens, Button, MessageBar, MessageBarBody, MessageBarActions } from '@fluentui/react-components';
import { BrowserRouter, Routes, Route } from 'react-router-dom'; import { BrowserRouter, Routes, Route, useLocation, useNavigate } from 'react-router-dom';
import PostCard from './components/PostCard'; import PostCard from './components/PostCard';
import MainLayout from './layouts/MainLayout'; import MainLayout from './layouts/MainLayout';
import './App.css'; import './App.css';
@@ -21,7 +21,74 @@ import ImageViewer from './components/ImageViewer';
import NoticeModal from './components/NoticeModal'; import NoticeModal from './components/NoticeModal';
import type { NoticeData } from './components/NoticeModal'; import type { NoticeData } from './components/NoticeModal';
import DevToolsModal from './components/DevToolsModal'; 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 && (
<div style={{
position: 'fixed',
top: '80px',
left: '50%',
transform: 'translateX(-50%)',
zIndex: 2000,
maxWidth: '600px',
width: 'calc(100% - 40px)',
}}>
<MessageBar intent="info">
<MessageBarBody>
<div onClick={onRefreshClick} style={{ cursor: 'pointer' }}>
{newPostCount}
</div>
</MessageBarBody>
<MessageBarActions>
<Button
aria-label="dismiss"
appearance="transparent"
icon={<Dismiss24Regular />}
onClick={onDismiss}
/>
</MessageBarActions>
</MessageBar>
</div>
)}
</>
);
}
function App() { function App() {
const [isDarkMode, setIsDarkMode] = React.useState(() => { const [isDarkMode, setIsDarkMode] = React.useState(() => {
@@ -58,6 +125,8 @@ function App() {
const [showNotice, setShowNotice] = useState(false); const [showNotice, setShowNotice] = useState(false);
const [showDevTools, setShowDevTools] = useState(false); const [showDevTools, setShowDevTools] = useState(false);
const [isDebugMode, setIsDebugMode] = useState(false); const [isDebugMode, setIsDebugMode] = useState(false);
const [newPostCount, setNewPostCount] = useState(0);
const [showNewPostBar, setShowNewPostBar] = useState(false);
useEffect(() => { useEffect(() => {
const searchParams = new URLSearchParams(window.location.search); const searchParams = new URLSearchParams(window.location.search);
@@ -114,8 +183,21 @@ function App() {
setPage(1); setPage(1);
setHomeRefreshTick((t) => t + 1); setHomeRefreshTick((t) => t + 1);
if (containerRef.current) containerRef.current.scrollTop = 0; 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 逻辑 // 撤销 Pointer 事件回退,恢复为纯 Touch 逻辑
@@ -163,6 +245,116 @@ function App() {
return ( return (
<FluentProvider theme={isDarkMode ? webDarkTheme : webLightTheme}> <FluentProvider theme={isDarkMode ? webDarkTheme : webLightTheme}>
<BrowserRouter> <BrowserRouter>
<AppContent
isDarkMode={isDarkMode}
setIsDarkMode={setIsDarkMode}
articles={articles}
page={page}
loading={loading}
hasMore={hasMore}
refreshing={refreshing}
containerRef={containerRef}
lastArticleRef={lastArticleRef}
onWheel={onWheel}
openImageViewer={openImageViewer}
doRefresh={doRefresh}
newPostCount={newPostCount}
showNewPostBar={showNewPostBar}
handleNewPost={handleNewPost}
handleNewPostBarDismiss={handleNewPostBarDismiss}
isDebugMode={isDebugMode}
showDevTools={showDevTools}
setShowDevTools={setShowDevTools}
/>
<ToastContainer theme={isDarkMode ? 'dark' : 'light'} />
<Toaster
position="top-center"
toastOptions={{
style: {
background: isDarkMode ? '#333' : '#fff',
color: isDarkMode ? '#fff' : '#333',
},
}}
/>
{imageViewer.open && imageViewer.src && (
<ImageViewer src={imageViewer.src!} alt={imageViewer.alt} onClose={closeImageViewer} />
)}
{showNotice && noticeData && (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
zIndex: 2000,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
<NoticeModal
data={noticeData}
onClose={() => setShowNotice(false)}
onNeverShow={(version) => {
localStorage.setItem('notice_version', String(version));
setShowNotice(false);
}}
/>
</div>
)}
{showDevTools && <DevToolsModal onClose={() => setShowDevTools(false)} />}
</BrowserRouter>
</FluentProvider>
);
}
// 内部组件,在 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 (
<>
<GlobalSSEManager
onNewPost={handleNewPost}
newPostCount={newPostCount}
showNewPostBar={showNewPostBar}
onRefreshClick={handleNewPostBarClick}
onDismiss={handleNewPostBarDismiss}
/>
<Routes> <Routes>
<Route path="/" element={<MainLayout isDarkMode={isDarkMode} onToggleTheme={() => setIsDarkMode(!isDarkMode)} />}> <Route path="/" element={<MainLayout isDarkMode={isDarkMode} onToggleTheme={() => setIsDarkMode(!isDarkMode)} />}>
<Route <Route
@@ -230,43 +422,6 @@ function App() {
<Route path="/admin" element={<AdminPage isDarkMode={isDarkMode} onToggleTheme={() => setIsDarkMode(!isDarkMode)} />} /> <Route path="/admin" element={<AdminPage isDarkMode={isDarkMode} onToggleTheme={() => setIsDarkMode(!isDarkMode)} />} />
<Route path="*" element={<NotFound />} /> <Route path="*" element={<NotFound />} />
</Routes> </Routes>
</BrowserRouter>
<ToastContainer theme={isDarkMode ? 'dark' : 'light'} />
<Toaster
position="top-center"
toastOptions={{
style: {
background: isDarkMode ? '#333' : '#fff',
color: isDarkMode ? '#fff' : '#333',
},
}}
/>
{imageViewer.open && imageViewer.src && (
<ImageViewer src={imageViewer.src!} alt={imageViewer.alt} onClose={closeImageViewer} />
)}
{showNotice && noticeData && (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
zIndex: 2000,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
<NoticeModal
data={noticeData}
onClose={() => setShowNotice(false)}
onNeverShow={(version) => {
localStorage.setItem('notice_version', String(version));
setShowNotice(false);
}}
/>
</div>
)}
{/* DevTools Trigger */} {/* DevTools Trigger */}
{isDebugMode && ( {isDebugMode && (
@@ -280,8 +435,7 @@ function App() {
/> />
</div> </div>
)} )}
{showDevTools && <DevToolsModal onClose={() => setShowDevTools(false)} />} </>
</FluentProvider>
); );
} }

100
src/hooks/useSSE.ts Normal file
View File

@@ -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<EventSource | null>(null);
const retriesRef = useRef(0);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(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 };
}