Feat:增加SSE新帖推送
This commit is contained in:
238
src/App.tsx
238
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 && (
|
||||
<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() {
|
||||
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,6 +245,116 @@ function App() {
|
||||
return (
|
||||
<FluentProvider theme={isDarkMode ? webDarkTheme : webLightTheme}>
|
||||
<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>
|
||||
<Route path="/" element={<MainLayout isDarkMode={isDarkMode} onToggleTheme={() => setIsDarkMode(!isDarkMode)} />}>
|
||||
<Route
|
||||
@@ -230,43 +422,6 @@ function App() {
|
||||
<Route path="/admin" element={<AdminPage isDarkMode={isDarkMode} onToggleTheme={() => setIsDarkMode(!isDarkMode)} />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</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 */}
|
||||
{isDebugMode && (
|
||||
@@ -280,8 +435,7 @@ function App() {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{showDevTools && <DevToolsModal onClose={() => setShowDevTools(false)} />}
|
||||
</FluentProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
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