Feat:增加SSE新帖推送
This commit is contained in:
268
src/App.tsx
268
src/App.tsx
@@ -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,7 +245,117 @@ function App() {
|
|||||||
return (
|
return (
|
||||||
<FluentProvider theme={isDarkMode ? webDarkTheme : webLightTheme}>
|
<FluentProvider theme={isDarkMode ? webDarkTheme : webLightTheme}>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<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>
|
||||||
<Route path="/" element={<MainLayout isDarkMode={isDarkMode} onToggleTheme={() => setIsDarkMode(!isDarkMode)} />}>
|
<Route path="/" element={<MainLayout isDarkMode={isDarkMode} onToggleTheme={() => setIsDarkMode(!isDarkMode)} />}>
|
||||||
<Route
|
<Route
|
||||||
index
|
index
|
||||||
@@ -228,61 +420,23 @@ function App() {
|
|||||||
</Route>
|
</Route>
|
||||||
<Route path="/init" element={<InitPage />} />
|
<Route path="/init" element={<InitPage />} />
|
||||||
<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 && (
|
||||||
<div style={{ position: 'fixed', bottom: '20px', left: '20px', zIndex: 9999 }}>
|
<div style={{ position: 'fixed', bottom: '20px', left: '20px', zIndex: 9999 }}>
|
||||||
<Button
|
<Button
|
||||||
icon={<Bug24Regular />}
|
icon={<Bug24Regular />}
|
||||||
appearance="primary"
|
appearance="primary"
|
||||||
shape="circular"
|
shape="circular"
|
||||||
onClick={() => setShowDevTools(true)}
|
onClick={() => setShowDevTools(true)}
|
||||||
aria-label="Developer Tools"
|
aria-label="Developer Tools"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{showDevTools && <DevToolsModal onClose={() => setShowDevTools(false)} />}
|
</>
|
||||||
</FluentProvider>
|
);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
100
src/hooks/useSSE.ts
Normal file
100
src/hooks/useSSE.ts
Normal 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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user