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} 条新帖,点击刷新
+
+
+
+ }
+ onClick={onDismiss}
+ />
+
+
+
+ )}
+ >
+ );
+}
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 && (
-
- }
- appearance="primary"
- shape="circular"
- onClick={() => setShowDevTools(true)}
- aria-label="Developer Tools"
- />
-
- )}
- {showDevTools && setShowDevTools(false)} />}
-
- );
+ {/* DevTools Trigger */}
+ {isDebugMode && (
+
+ }
+ appearance="primary"
+ shape="circular"
+ onClick={() => setShowDevTools(true)}
+ aria-label="Developer Tools"
+ />
+
+ )}
+ >
+ );
}
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 };
+}