diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3d07bf5..df94f87 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -17,6 +17,7 @@ import PageLoader from './components/PageLoader'; import AuthPageFallback from './components/AuthPageFallback'; import { Toaster } from './components/ui/sonner'; import PullToRefresh from './components/PullToRefresh'; +import TopProgressBar from './components/TopProgressBar'; import { lazyWithRetry } from './utils/lazyWithRetry'; const HomePage = lazyWithRetry(() => import('./pages/HomePage')); @@ -87,10 +88,10 @@ const router = createBrowserRouter( } /> } /> } /> - }>} /> + } /> } /> - }>} /> - }>} /> + } /> + } /> }>} /> , @@ -103,6 +104,7 @@ export default function App() { + diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 60adb93..19ebcf4 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -215,6 +215,20 @@ export const DEFAULT_ASIDE_WIDGETS: AsideWidget[] = [ { id: 'showcase', enabled: false }, ]; +export type FeedSortId = 'reply' | 'latest' | 'hot'; + +export interface FeedSortTab { + id: FeedSortId; + label: string; + enabled: boolean; +} + +export const DEFAULT_FEED_SORT_TABS: FeedSortTab[] = [ + { id: 'reply', label: '新评论', enabled: true }, + { id: 'latest', label: '新帖子', enabled: true }, + { id: 'hot', label: '推荐帖', enabled: true }, +]; + export interface ForumLimits { post_edit_window_hours: number; comment_edit_window_minutes: number; @@ -255,6 +269,8 @@ export interface ForumLimits { footer_show_showcase: boolean; /** 首页列表样式:title 仅标题 / thumbnail 缩略图 */ feed_list_style: 'title' | 'excerpt' | 'thumbnail'; + /** 首页排序标签:名称、顺序、启停 */ + feed_sort_tabs: FeedSortTab[]; /** 伪静态(固定链接)开关 */ permalink_enabled: boolean; /** 伪静态后缀,不含点,如 html / htm */ @@ -285,6 +301,7 @@ export interface ForumLimitsPublic { nav_show_showcase: boolean; footer_show_showcase: boolean; feed_list_style: 'title' | 'excerpt' | 'thumbnail'; + feed_sort_tabs: FeedSortTab[]; permalink_enabled: boolean; permalink_ext: string; /** 是否上报前台路由 pageview(与后台监控采集开关同步) */ diff --git a/frontend/src/components/AsideCheckInStrip.tsx b/frontend/src/components/AsideCheckInStrip.tsx index d424d06..cc22d3b 100644 --- a/frontend/src/components/AsideCheckInStrip.tsx +++ b/frontend/src/components/AsideCheckInStrip.tsx @@ -1,6 +1,5 @@ import { CalendarCheck, Check, Gift, Loader2 } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; -import { Skeleton } from '@/components/ui/skeleton'; import { useCheckIn } from '../hooks/useCheckIn'; import { useAuth } from '../hooks/useAuth'; import { loginPath } from '../utils/authRedirect'; @@ -8,8 +7,13 @@ import { loginPath } from '../utils/authRedirect'; /** 右侧栏首块底部:每日签到 */ export default function AsideCheckInStrip() { const nav = useNavigate(); - const { user } = useAuth(); - const { status, loading, busy, doCheckIn } = useCheckIn(); + const { user, loading: authLoading } = useAuth(); + const { status, loading, busy, doCheckIn } = useCheckIn(!!user && !authLoading); + + // 鉴权未完成:空白,避免「登录签到」→「今日已签到」闪一下 + if (authLoading) { + return null; + } if (!user) { return ( @@ -37,17 +41,14 @@ export default function AsideCheckInStrip() { ); } - if (loading && !status) { - return ( -
- -
- ); + // 登录用户:status 未到齐前不渲染最终态 + if (loading || !status) { + return null; } - const checkedIn = !!status?.checked_in; - const streak = status?.streak ?? 0; - const todayPoints = status?.today_points ?? 5; + const checkedIn = !!status.checked_in; + const streak = status.streak ?? 0; + const todayPoints = status.today_points ?? 5; const meta = checkedIn ? (streak > 0 ? `连续 ${streak} 天 · 今日已获得 ${todayPoints} 积分` : `今日已获得 ${todayPoints} 积分`) diff --git a/frontend/src/components/FeedPageSkeleton.tsx b/frontend/src/components/FeedPageSkeleton.tsx deleted file mode 100644 index 00afd53..0000000 --- a/frontend/src/components/FeedPageSkeleton.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { Skeleton } from '@/components/ui/skeleton'; -import PostListSkeleton from './PostListSkeleton'; -import { useForumLimits } from '../hooks/useForumLimits'; - -/** 首页 Feed 初始骨架(排序栏 + 列表) */ -export default function FeedPageSkeleton() { - const { limits } = useForumLimits(); - - return ( -
-
-
-
-
- - - - - -
-
-
-
- -
-
-
- ); -} diff --git a/frontend/src/components/FeedSortBar.tsx b/frontend/src/components/FeedSortBar.tsx index 1b8abe8..4f1a1ae 100644 --- a/frontend/src/components/FeedSortBar.tsx +++ b/frontend/src/components/FeedSortBar.tsx @@ -1,40 +1,55 @@ -import { useRef } from 'react'; +import { useMemo, useRef } from 'react'; import { boardPath, type PermalinkOpts } from '../utils/permalink'; -import { getCachedForumLimits } from '../hooks/useForumLimits'; -import { Clock, MessageCircle, BadgeCheck } from 'lucide-react'; +import { getCachedForumLimits, useForumLimits } from '../hooks/useForumLimits'; +import { Clock, MessageCircle, BadgeCheck, Loader2 } from 'lucide-react'; import { cn } from '@/lib/utils'; import { moveTabIndex } from '../hooks/useOverlayA11y'; +import { + enabledFeedSortTabs, + getDefaultFeedSort, + normalizeFeedSortTabs, +} from '../utils/feedSortTabs'; +import type { FeedSortTab } from '../api/types'; export type FeedSort = 'latest' | 'reply' | 'hot'; -const SORT_OPTIONS: { - key: FeedSort; - label: string; - hint: string; - icon: typeof Clock; -}[] = [ - { key: 'reply', label: '新评论', hint: '最近有人评论', icon: MessageCircle }, - { key: 'latest', label: '新帖子', hint: '按发帖时间', icon: Clock }, - { key: 'hot', label: '推荐帖', hint: '站内推荐', icon: BadgeCheck }, -]; +const SORT_META: Record = { + reply: { hint: '最近有人评论', icon: MessageCircle }, + latest: { hint: '按发帖时间', icon: Clock }, + hot: { hint: '站内推荐', icon: BadgeCheck }, +}; interface Props { value: FeedSort; onChange: (sort: FeedSort) => void; postTotal?: number; + /** 正在加载、尚未提交到画面的目标排序 */ + pendingValue?: FeedSort | null; } -/** 解析 URL sort;缺省为新评论(reply) */ -export function parseFeedSort(raw: string | null): FeedSort { - if (raw === 'latest' || raw === 'hot') return raw; - return 'reply'; +/** 当前配置下的默认排序(读缓存,供非 hook 路径) */ +export function getDefaultFeedSortFromCache(): FeedSort { + return getDefaultFeedSort(getCachedForumLimits().feed_sort_tabs); +} + +/** 解析 URL sort;未启用或不合法时回落默认 */ +export function parseFeedSort(raw: string | null, tabs?: FeedSortTab[] | null): FeedSort { + const list = tabs ?? getCachedForumLimits().feed_sort_tabs; + const def = getDefaultFeedSort(list); + if (raw === 'latest' || raw === 'hot' || raw === 'reply') { + const enabled = enabledFeedSortTabs(list).some(t => t.id === raw); + if (enabled) return raw; + } + return def; } export function buildHomeUrl( boardId: number, - sort: FeedSort = 'reply', + sort?: FeedSort, opts?: { keyword?: string; tag?: string; author?: string; titleOnly?: boolean; permalink?: PermalinkOpts }, ) { + const def = getDefaultFeedSortFromCache(); + const effective = sort ?? def; const p = new URLSearchParams(); const tag = opts?.tag?.trim(); const keyword = opts?.keyword?.trim(); @@ -48,8 +63,8 @@ export function buildHomeUrl( } else if (author) { p.set('author', author); } - // 默认 reply 不写进 URL;latest / hot 显式带上 - if (sort !== 'reply') p.set('sort', sort); + // 默认排序不写进 URL + if (effective !== def) p.set('sort', effective); const qs = p.toString(); if (boardId) { @@ -59,25 +74,39 @@ export function buildHomeUrl( return qs ? `/?${qs}` : '/'; } -export function feedSortLabel(sort: FeedSort): string { - return SORT_OPTIONS.find(o => o.key === sort)?.label ?? '帖子列表'; +export function feedSortLabel(sort: FeedSort, tabs?: FeedSortTab[] | null): string { + const list = normalizeFeedSortTabs(tabs ?? getCachedForumLimits().feed_sort_tabs); + return list.find(t => t.id === sort)?.label ?? '帖子列表'; } -export default function FeedSortBar({ value, onChange, postTotal }: Props) { +export default function FeedSortBar({ value, onChange, postTotal, pendingValue }: Props) { + const { limits } = useForumLimits(); + const options = useMemo( + () => enabledFeedSortTabs(limits.feed_sort_tabs).map(t => ({ + key: t.id as FeedSort, + label: t.label, + hint: SORT_META[t.id]?.hint ?? '', + icon: SORT_META[t.id]?.icon ?? Clock, + })), + [limits.feed_sort_tabs], + ); + const listRef = useRef(null); - const activeIndex = Math.max(0, SORT_OPTIONS.findIndex(o => o.key === value)); + const activeIndex = Math.max(0, options.findIndex(o => o.key === value)); const onKeyDown = (e: React.KeyboardEvent) => { - const next = moveTabIndex(e.key, activeIndex, SORT_OPTIONS.length); + const next = moveTabIndex(e.key, activeIndex, options.length); if (next == null) return; e.preventDefault(); - onChange(SORT_OPTIONS[next].key); + onChange(options[next].key); requestAnimationFrame(() => { const tabs = listRef.current?.querySelectorAll('[role="tab"]'); tabs?.[next]?.focus(); }); }; + if (options.length === 0) return null; + return (
- {SORT_OPTIONS.map(({ key, label, hint, icon: Icon }, i) => ( - - ))} + {options.map(({ key, label, hint, icon: Icon }, i) => { + const pending = pendingValue === key; + return ( + + ); + })}
{postTotal != null && ( 共 {postTotal} 条 diff --git a/frontend/src/components/PageLoader.tsx b/frontend/src/components/PageLoader.tsx index baccd6f..ab83faf 100644 --- a/frontend/src/components/PageLoader.tsx +++ b/frontend/src/components/PageLoader.tsx @@ -6,7 +6,7 @@ type PageLoaderProps = { fullScreen?: boolean; }; -/** 通用路由懒加载占位;首页请用 FeedPageSkeleton,避免非 Feed 页闪出鱼骨骨架 */ +/** 通用路由懒加载占位(后台 / 全屏独立页);前台 MainLayout 用顶栏进度条 + 空白 */ export default function PageLoader({ fullScreen = false }: PageLoaderProps) { return (
0) + || post.bounty_status === 'awarded' + )); + const titleRow = (
{post.pinned && ( @@ -81,31 +89,36 @@ function PostListItem({ post, sort = 'latest', boardId = 0, onSelect }: Props) { {post.status === 'rejected' && ( 未通过 )} - {post.post_type === 'question' && ( - - {post.question_resolved ? '已解决' : '未解决'} - - )} - {post.post_type === 'poll' && ( - 投票 - )} - {post.post_type === 'bounty' && post.bounty_status === 'open' && (post.bounty_points ?? 0) > 0 && ( - 悬赏 {post.bounty_points} - )} - {post.post_type === 'bounty' && post.bounty_status === 'awarded' && ( - 已采纳 - )} - {post.post_type === 'lottery' && ( - - {post.lottery_status === 'drawn' ? '已开奖' : '抽奖'} - - )} {post.title} + {/* 类型徽章放标题后:flex 自动扣宽,长标题省略号紧挨徽章左侧 */} + {hasTypeBadge && ( + + {post.post_type === 'question' && ( + + {post.question_resolved ? '已解决' : '未解决'} + + )} + {post.post_type === 'poll' && ( + 投票 + )} + {post.post_type === 'bounty' && post.bounty_status === 'open' && (post.bounty_points ?? 0) > 0 && ( + 悬赏 {post.bounty_points} + )} + {post.post_type === 'bounty' && post.bounty_status === 'awarded' && ( + 已采纳 + )} + {post.post_type === 'lottery' && ( + + {post.lottery_status === 'drawn' ? '已开奖' : '抽奖'} + + )} + + )}
); diff --git a/frontend/src/components/PostListSkeleton.tsx b/frontend/src/components/PostListSkeleton.tsx index 8da408a..204bac3 100644 --- a/frontend/src/components/PostListSkeleton.tsx +++ b/frontend/src/components/PostListSkeleton.tsx @@ -1,4 +1,3 @@ -import { Skeleton } from '@/components/ui/skeleton'; import type { ForumLimitsPublic } from '../api/types'; export type FeedListStyle = ForumLimitsPublic['feed_list_style']; @@ -11,69 +10,3 @@ export function feedListRowEstimate(style: FeedListStyle): number { default: return 64; } } - -interface Props { - count?: number; - listStyle?: FeedListStyle; -} - -/** 帖子列表加载骨架屏(对齐 v2 紧凑列表) */ -export default function PostListSkeleton({ count = 8, listStyle = 'title' }: Props) { - const showExcerpt = listStyle === 'excerpt' || listStyle === 'thumbnail'; - const showThumb = listStyle === 'thumbnail'; - const titleOnly = listStyle === 'title'; - - return ( -
- {Array.from({ length: count }, (_, i) => { - const hasThumb = showThumb && i % 3 === 0; - return ( -
- - {hasThumb ? ( -
-
- - {showExcerpt && ( - - )} -
- - -
-
-
- -
- -
-
-
- ) : ( -
-
- - {showExcerpt && ( - - )} -
-
-
- - -
-
- -
-
-
- )} -
- ); - })} -
- ); -} diff --git a/frontend/src/components/PullToRefresh.tsx b/frontend/src/components/PullToRefresh.tsx index e371a99..0a4dda8 100644 --- a/frontend/src/components/PullToRefresh.tsx +++ b/frontend/src/components/PullToRefresh.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react'; import { Loader2, ArrowDown } from 'lucide-react'; -import { FEED_PULL_REFRESH_EVENT } from '../utils/feedCache'; +import { softRefreshCurrentPage } from '../utils/softRefresh'; /** 触发刷新的下拉距离(px) */ const REFRESH_THRESHOLD = 68; @@ -31,6 +31,9 @@ function pickScrollEl(): HTMLElement | null { const page = document.querySelector('.page-wrap:not(.page-wrap--feed)'); if (page) return page; + const showcase = document.querySelector('.showcase-page'); + if (showcase) return showcase.closest('.main-content') ?? showcase; + const admin = document.querySelector('.admin-main'); if (admin) return admin; @@ -77,7 +80,7 @@ function isNestedScrollBlocking(target: EventTarget | null, bound: HTMLElement): } /** - * 手机端下拉刷新:在内部滚动容器顶部下拉后整页重载。 + * 手机端下拉刷新:在内部滚动容器顶部下拉后强制刷新当前页。 * (浏览器原生 PTR 依赖 document 滚动,与本站 app-shell 布局不兼容。) * * 挂在 Router 外,故用 MutationObserver 在路由切换后重绑滚动容器。 @@ -168,19 +171,22 @@ export default function PullToRefresh() { setSettling(true); setPull(REFRESH_THRESHOLD * 0.7); window.setTimeout(() => { - // Feed 页:应用内强制重拉(保留 SPA 其它状态);其它页仍整页 reload - const isFeed = !!( - document.querySelector('.main-content--feed-mobile-scroll') - || document.querySelector('.page-wrap--feed .post-list-scroll') + // 后台 / 登录页:整页 reload;前台:静默软刷新(无进度条,齐套后一次覆盖) + const isAdminOrAuth = !!( + document.querySelector('.admin-main') + || document.querySelector('.auth-page') ); - if (isFeed) { - window.dispatchEvent(new Event(FEED_PULL_REFRESH_EVENT)); - setRefreshing(false); - setSettling(true); - setPull(0); + if (isAdminOrAuth) { + window.location.reload(); return; } - window.location.reload(); + void softRefreshCurrentPage() + .catch(() => undefined) + .finally(() => { + setRefreshing(false); + setSettling(true); + setPull(0); + }); }, 180); return; } diff --git a/frontend/src/components/RightPanel.tsx b/frontend/src/components/RightPanel.tsx index 80543fb..542c46d 100644 --- a/frontend/src/components/RightPanel.tsx +++ b/frontend/src/components/RightPanel.tsx @@ -1,7 +1,6 @@ import { useMemo } from 'react'; import { ListTree, MessageCircle, Tags, Link2, UserPlus } from 'lucide-react'; import { useLocation, useSearchParams, useNavigate } from 'react-router-dom'; -import { Skeleton } from '@/components/ui/skeleton'; import { Button } from '@/components/ui/button'; import type { AsideWidget, RecentComment, RecentUser, TagCount, User, ForumStats, FriendLink } from '../api/types'; import type { PostHeading } from '../utils/postHeadings'; @@ -31,7 +30,7 @@ interface Props { tagsLoading?: boolean; stats?: ForumStats | null; onPostClick: (id: number, opts?: { floor?: number }) => void; - /** 首次拉取中,显示骨架避免空态闪烁 */ + /** 首次拉取中:不渲染该块内容(由冷启动门闩保证首屏已齐或空白) */ loading?: boolean; /** 右侧栏可选组件顺序与开关 */ asideWidgets: AsideWidget[]; @@ -39,35 +38,6 @@ interface Props { postDetail?: PostDetailAside | null; } -function CommentSkeleton() { - return ( -
- {Array.from({ length: 5 }, (_, i) => ( -
- -
- - -
-
- ))} -
- ); -} - -function UserSkeleton() { - return ( -
- {Array.from({ length: 8 }, (_, i) => ( -
- - -
- ))} -
- ); -} - export default function RightPanel({ recentComments, recentUsers, @@ -181,9 +151,7 @@ export default function RightPanel({ 最新评论
- {loading && commentList.length === 0 ? ( - - ) : commentList.length === 0 ? ( + {loading && commentList.length === 0 ? null : commentList.length === 0 ? (
暂无评论
) : commentList.map(item => (
- {loading && userList.length === 0 ? ( - - ) : userList.length === 0 ? ( + {loading && userList.length === 0 ? null : userList.length === 0 ? (
暂无用户
) : (
diff --git a/frontend/src/components/ShowcaseAsideWidget.tsx b/frontend/src/components/ShowcaseAsideWidget.tsx index 5b1b19e..5909da9 100644 --- a/frontend/src/components/ShowcaseAsideWidget.tsx +++ b/frontend/src/components/ShowcaseAsideWidget.tsx @@ -3,21 +3,40 @@ import { Globe2 } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; import { api } from '../api/client'; import type { CommunityShowcaseItem } from '../api/types'; +import { getSessionSnapshot, setSessionSnapshot } from '../utils/sessionPageCache'; +import { PAGE_FORCE_REFRESH_EVENT } from '../utils/feedCache'; +import { PAGE_SOFT_REFRESH_COMMIT_EVENT } from '../utils/softRefresh'; + +const SHOWCASE_KEY = 'showcase'; /** 右侧栏:开源展柜精简列表 */ export default function ShowcaseAsideWidget() { const nav = useNavigate(); - const [items, setItems] = useState([]); - const [loading, setLoading] = useState(true); + const cached = getSessionSnapshot(SHOWCASE_KEY); + const [items, setItems] = useState(() => cached ?? []); + const [loading, setLoading] = useState(() => cached === undefined); useEffect(() => { let cancelled = false; + const hit = getSessionSnapshot(SHOWCASE_KEY); + if (hit !== undefined) { + setItems(hit); + setLoading(false); + return; + } + setLoading(true); api.communityShowcase() .then((r) => { - if (!cancelled) setItems(Array.isArray(r.items) ? r.items : []); + if (cancelled) return; + const next = Array.isArray(r.items) ? r.items : []; + setSessionSnapshot(SHOWCASE_KEY, next); + setItems(next); }) .catch(() => { - if (!cancelled) setItems([]); + if (!cancelled) { + setSessionSnapshot(SHOWCASE_KEY, []); + setItems([]); + } }) .finally(() => { if (!cancelled) setLoading(false); @@ -25,6 +44,43 @@ export default function ShowcaseAsideWidget() { return () => { cancelled = true; }; }, []); + useEffect(() => { + const fetchShowcase = (opts: { showLoading: boolean }) => { + if (opts.showLoading) setLoading(true); + api.communityShowcase() + .then((r) => { + const next = Array.isArray(r.items) ? r.items : []; + setSessionSnapshot(SHOWCASE_KEY, next); + setItems(next); + }) + .catch(() => { + if (opts.showLoading) { + setSessionSnapshot(SHOWCASE_KEY, []); + setItems([]); + } + // 软刷新失败:保持旧 UI + }) + .finally(() => setLoading(false)); + }; + const applyHitOrReload = (showLoading: boolean) => { + const hit = getSessionSnapshot(SHOWCASE_KEY); + if (hit !== undefined) { + setItems(hit); + setLoading(false); + return; + } + fetchShowcase({ showLoading }); + }; + const onForce = () => applyHitOrReload(true); + const onCommit = () => applyHitOrReload(false); + window.addEventListener(PAGE_FORCE_REFRESH_EVENT, onForce); + window.addEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, onCommit); + return () => { + window.removeEventListener(PAGE_FORCE_REFRESH_EVENT, onForce); + window.removeEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, onCommit); + }; + }, []); + return (
diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index aa33f53..65ea55a 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -6,9 +6,9 @@ import type { Board } from '../api/types'; import type { PostHeading } from '../utils/postHeadings'; import { useAuth } from '../hooks/useAuth'; import { cn } from '@/lib/utils'; -import { Skeleton } from '@/components/ui/skeleton'; import { buildHomeUrl, parseFeedSort } from './FeedSortBar'; import { navigateFeed } from '../utils/feedCache'; +import { transitionTo } from '../utils/spaTransition'; import BoardIconDisplay from './BoardIconDisplay'; import { getBoardThemeIndex } from '../utils/boardTheme'; import ArticleOutline from './ArticleOutline'; @@ -62,11 +62,11 @@ export default function Sidebar({ const nav = useNavigate(); const loc = useLocation(); const [params] = useSearchParams(); - const sort = parseFeedSort(params.get('sort')); const { user } = useAuth(); const isAdmin = user?.role === 'admin'; const { navPages } = useSitePages(); const { limits } = useForumLimits(); + const sort = parseFeedSort(params.get('sort'), limits.feed_sort_tabs); const showFriendLinksNav = limits.nav_show_friend_links !== false; const showShowcaseNav = !!limits.nav_show_showcase; const showSiteSection = navPages.length > 0 || showFriendLinksNav || showShowcaseNav; @@ -137,22 +137,12 @@ export default function Sidebar({
浏览
{(boardsLoading && boards.length === 0) ? ( - <> -
板块
- - +
板块
) : boards.length > 0 ? ( <>
板块
@@ -198,10 +188,10 @@ export default function Sidebar({ <>
站点
diff --git a/frontend/src/components/TagCloud.tsx b/frontend/src/components/TagCloud.tsx index 8ef740f..6646cf3 100644 --- a/frontend/src/components/TagCloud.tsx +++ b/frontend/src/components/TagCloud.tsx @@ -1,7 +1,6 @@ import { useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import type { TagCount } from '../api/types'; -import { Skeleton } from '@/components/ui/skeleton'; import { cn } from '@/lib/utils'; interface Props { @@ -63,20 +62,7 @@ export default function TagCloud({ tags, loading = false, activeTag = '' }: Prop }, [tags]); if (loading && tags.length === 0) { - return ( -
- {Array.from({ length: 10 }, (_, i) => ( - - ))} -
- ); + return null; } if (items.length === 0) { diff --git a/frontend/src/components/TopProgressBar.tsx b/frontend/src/components/TopProgressBar.tsx new file mode 100644 index 0000000..47f67c0 --- /dev/null +++ b/frontend/src/components/TopProgressBar.tsx @@ -0,0 +1,75 @@ +import { useEffect, useRef, useState } from 'react'; +import { subscribeSpaTransition } from '../utils/spaTransition'; + +/** + * 视口顶部 2px 细进度条(类 Next.js / YouTube)。 + * 由 spaTransition start/done 驱动,假进度爬升至约 80%,结束冲到 100%。 + */ +export default function TopProgressBar() { + const [visible, setVisible] = useState(false); + const [width, setWidth] = useState(0); + const [fading, setFading] = useState(false); + const timerRef = useRef | null>(null); + const fadeRef = useRef | null>(null); + const activeRef = useRef(false); + + useEffect(() => { + const clearTick = () => { + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + }; + const clearFade = () => { + if (fadeRef.current) { + clearTimeout(fadeRef.current); + fadeRef.current = null; + } + }; + + return subscribeSpaTransition((active) => { + if (active) { + activeRef.current = true; + clearFade(); + setFading(false); + setVisible(true); + setWidth(12); + clearTick(); + timerRef.current = setInterval(() => { + setWidth((w) => { + if (w >= 80) return w; + // 越接近 80 越慢 + const step = Math.max(0.4, (80 - w) * 0.045); + return Math.min(80, w + step); + }); + }, 120); + return; + } + + if (!activeRef.current) return; + activeRef.current = false; + clearTick(); + setWidth(100); + setFading(true); + clearFade(); + fadeRef.current = setTimeout(() => { + setVisible(false); + setFading(false); + setWidth(0); + }, 220); + }); + }, []); + + if (!visible && width <= 0) return null; + + return ( +
+ ); +} diff --git a/frontend/src/components/VirtualPostList.tsx b/frontend/src/components/VirtualPostList.tsx index 6d8b9d6..b2a3d06 100644 --- a/frontend/src/components/VirtualPostList.tsx +++ b/frontend/src/components/VirtualPostList.tsx @@ -4,7 +4,7 @@ import { useVirtualizer } from '@tanstack/react-virtual'; import { Inbox, SearchX } from 'lucide-react'; import { Button } from '@/components/ui/button'; import PostListItem from './PostListItem'; -import PostListSkeleton, { feedListRowEstimate } from './PostListSkeleton'; +import { feedListRowEstimate } from './PostListSkeleton'; import FeedPagination from './FeedPagination'; import { InFlowSiteFooter } from './SiteFooter'; import { useAuth } from '../hooks/useAuth'; @@ -14,6 +14,7 @@ import { loginPath } from '../utils/authRedirect'; import { dispatchOpenPostSearch } from '../hooks/usePostSearch'; import type { PostItem } from '../api/types'; import type { FeedSort } from './FeedSortBar'; +import { getDefaultFeedSortFromCache } from './FeedSortBar'; interface Props { posts: PostItem[]; @@ -56,7 +57,7 @@ function getMobileFeedScrollEl(): HTMLElement | null { export default function VirtualPostList({ posts, - sort = 'reply', + sort = getDefaultFeedSortFromCache(), loading, hasMore, showPagination, @@ -242,9 +243,7 @@ export default function VirtualPostList({ return (
- {isInitialLoad ? ( - - ) : isEmpty ? ( + {isInitialLoad ? null : isEmpty ? (
{isSearchEmpty ? diff --git a/frontend/src/components/admin/FeedSortTabList.tsx b/frontend/src/components/admin/FeedSortTabList.tsx new file mode 100644 index 0000000..97c5ec1 --- /dev/null +++ b/frontend/src/components/admin/FeedSortTabList.tsx @@ -0,0 +1,89 @@ +import { Pencil } from 'lucide-react'; +import { Input } from '@/components/ui/input'; +import AdminSortableList, { SortableDragHandle } from './AdminSortableList'; +import type { FeedSortId, FeedSortTab } from '../../api/types'; +import { normalizeFeedSortTabs } from '../../utils/feedSortTabs'; + +const TAB_META: Record = { + reply: { hint: '按最后评论时间', placeholder: '新评论' }, + latest: { hint: '按发帖时间', placeholder: '新帖子' }, + hot: { hint: '推荐优先,再按互动', placeholder: '推荐帖' }, +}; + +type Props = { + tabs: FeedSortTab[]; + onChange: (next: FeedSortTab[]) => void; +}; + +export default function FeedSortTabList({ tabs, onChange }: Props) { + const items = normalizeFeedSortTabs(tabs); + + const handleToggle = (id: FeedSortId, enabled: boolean) => { + const next = items.map(t => (t.id === id ? { ...t, enabled } : t)); + // 不允许全部关闭:关掉最后一项时忽略 + if (!enabled && !next.some(t => t.enabled)) return; + onChange(normalizeFeedSortTabs(next)); + }; + + const handleLabel = (id: FeedSortId, label: string) => { + onChange(normalizeFeedSortTabs(items.map(t => (t.id === id ? { ...t, label } : t)))); + }; + + return ( + tab.id} + onReorder={next => onChange(normalizeFeedSortTabs(next))} + showMoveButtons={false} + className="admin-sortable-list admin-sortable-list--boxed" + ariaLabel="首页排序标签" + renderItem={(tab, _index, controls) => { + const meta = TAB_META[tab.id]; + return ( +
+ +
+ + + {meta.hint} + +
+ +
+ ); + }} + /> + ); +} diff --git a/frontend/src/hooks/useAuth.tsx b/frontend/src/hooks/useAuth.tsx index 24a037a..13c4729 100644 --- a/frontend/src/hooks/useAuth.tsx +++ b/frontend/src/hooks/useAuth.tsx @@ -1,6 +1,8 @@ -import { createContext, useContext, useEffect, useState, useCallback, ReactNode } from 'react'; +import { createContext, useContext, useEffect, useState, useCallback, useRef, ReactNode } from 'react'; import { api } from '../api/client'; import type { User } from '../api/types'; +import { clearAllFeedCache } from '../utils/feedCache'; +import { clearSessionSnapshots } from '../utils/sessionPageCache'; interface AuthCtx { user: User | null; @@ -32,6 +34,21 @@ export function AuthProvider({ children }: { children: ReactNode }) { // 初始化只拉一次用户信息 useEffect(() => { refresh(); }, [refresh]); + const prevUserId = useRef('init'); + useEffect(() => { + if (loading) return; + const id = user?.id ?? null; + if (prevUserId.current === 'init') { + prevUserId.current = id; + return; + } + if (prevUserId.current !== id) { + prevUserId.current = id; + clearSessionSnapshots(); + clearAllFeedCache(); + } + }, [user, loading]); + const logout = async () => { await api.logout(); setUser(null); diff --git a/frontend/src/hooks/useCheckIn.ts b/frontend/src/hooks/useCheckIn.ts index 5cd1e11..bd5465d 100644 --- a/frontend/src/hooks/useCheckIn.ts +++ b/frontend/src/hooks/useCheckIn.ts @@ -2,38 +2,115 @@ import { useCallback, useEffect, useState } from 'react'; import { notify } from '@/lib/notify'; import { api } from '../api/client'; import type { CheckInStatus } from '../api/types'; +import { getSessionSnapshot, setSessionSnapshot } from '../utils/sessionPageCache'; +import { PAGE_FORCE_REFRESH_EVENT } from '../utils/feedCache'; +import { PAGE_SOFT_REFRESH_COMMIT_EVENT } from '../utils/softRefresh'; import { useAuth } from './useAuth'; +/** 签到会话快照键(与 prefetchRoute 共用) */ +export function checkInCacheKey(userId: number) { + return `checkin:${userId}`; +} + /** 每日签到状态与操作(侧栏、积分页等复用) */ export function useCheckIn(enabled = true) { const { user, refresh } = useAuth(); - const [status, setStatus] = useState(null); - const [loading, setLoading] = useState(false); + const userId = user?.id; + + const [status, setStatus] = useState(() => { + if (!userId || !enabled) return null; + return getSessionSnapshot(checkInCacheKey(userId)) ?? null; + }); + const [loading, setLoading] = useState(() => { + if (!userId || !enabled) return false; + return getSessionSnapshot(checkInCacheKey(userId)) === undefined; + }); const [busy, setBusy] = useState(false); - const load = useCallback(() => { - if (!user || !enabled) { + const load = useCallback((opts?: { force?: boolean }) => { + if (!userId || !enabled) { setStatus(null); setLoading(false); return; } + const key = checkInCacheKey(userId); + if (!opts?.force) { + const hit = getSessionSnapshot(key); + if (hit !== undefined) { + setStatus(hit); + setLoading(false); + return; + } + } setLoading(true); api.checkInStatus() - .then(d => setStatus(d.check_in)) - .catch(e => notify.error(e instanceof Error ? e.message : '加载签到状态失败')) + .then((d) => { + setSessionSnapshot(key, d.check_in); + setStatus(d.check_in); + }) + .catch((e) => notify.error(e instanceof Error ? e.message : '加载签到状态失败')) .finally(() => setLoading(false)); - }, [user, enabled]); + }, [userId, enabled]); useEffect(() => { - load(); - }, [load]); + if (!userId || !enabled) { + setStatus(null); + setLoading(false); + return; + } + const key = checkInCacheKey(userId); + const hit = getSessionSnapshot(key); + if (hit !== undefined) { + setStatus(hit); + setLoading(false); + return; + } + setStatus(null); + setLoading(true); + load({ force: true }); + }, [userId, enabled, load]); + + // 软刷新 commit:仅快照命中才盖;未命中保持旧 UI,后台静默再拉 + useEffect(() => { + const applyHitOrSilent = (forceLoading: boolean) => { + if (!userId || !enabled) return; + const key = checkInCacheKey(userId); + const hit = getSessionSnapshot(key); + if (hit !== undefined) { + setStatus(hit); + setLoading(false); + return; + } + if (forceLoading) { + load({ force: true }); + return; + } + // 软刷新未命中:不 setLoading,静默覆盖 + api.checkInStatus() + .then((d) => { + setSessionSnapshot(key, d.check_in); + setStatus(d.check_in); + setLoading(false); + }) + .catch(() => { /* 保持旧 UI */ }); + }; + const onForce = () => applyHitOrSilent(true); + const onCommit = () => applyHitOrSilent(false); + window.addEventListener(PAGE_FORCE_REFRESH_EVENT, onForce); + window.addEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, onCommit); + return () => { + window.removeEventListener(PAGE_FORCE_REFRESH_EVENT, onForce); + window.removeEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, onCommit); + }; + }, [userId, enabled, load]); const doCheckIn = useCallback(async () => { - if (!user) return; + if (!userId) return; setBusy(true); try { const r = await api.checkIn(); notify.success(`签到成功,+${r.check_in.today_points} 积分`); + setSessionSnapshot(checkInCacheKey(userId), r.check_in); setStatus(r.check_in); await refresh(); } catch (e: unknown) { @@ -41,13 +118,13 @@ export function useCheckIn(enabled = true) { } finally { setBusy(false); } - }, [user, refresh]); + }, [userId, refresh]); return { status, loading, busy, doCheckIn, - reload: load, + reload: () => load({ force: true }), }; } diff --git a/frontend/src/hooks/useForumLimits.ts b/frontend/src/hooks/useForumLimits.ts index 01e58b7..1a550c1 100644 --- a/frontend/src/hooks/useForumLimits.ts +++ b/frontend/src/hooks/useForumLimits.ts @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import { api } from '../api/client'; import type { ForumLimitsPublic } from '../api/types'; -import { DEFAULT_ASIDE_WIDGETS } from '../api/types'; +import { DEFAULT_ASIDE_WIDGETS, DEFAULT_FEED_SORT_TABS } from '../api/types'; const DEFAULT_LIMITS: ForumLimitsPublic = { post_title_max: 128, @@ -27,6 +27,7 @@ const DEFAULT_LIMITS: ForumLimitsPublic = { nav_show_showcase: false, footer_show_showcase: false, feed_list_style: 'title', + feed_sort_tabs: DEFAULT_FEED_SORT_TABS, permalink_enabled: false, permalink_ext: 'html', monitor_pageview: false, @@ -79,6 +80,11 @@ export function useForumLimits() { return { limits, loading }; } +/** 冷启动 / 预热:确保 limits 已写入模块缓存 */ +export function ensureForumLimitsLoaded(): Promise { + return fetchLimits(); +} + /** 清除缓存并通知已挂载的 hook 重新拉取 */ export function invalidateForumLimitsCache() { cached = null; diff --git a/frontend/src/hooks/usePostSearch.ts b/frontend/src/hooks/usePostSearch.ts index 7d62d64..c0be654 100644 --- a/frontend/src/hooks/usePostSearch.ts +++ b/frontend/src/hooks/usePostSearch.ts @@ -2,6 +2,7 @@ import { useCallback, useMemo } from 'react'; import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'; import { buildHomeUrl } from '../components/FeedSortBar'; import { navigateFeed } from '../utils/feedCache'; +import { transitionTo } from '../utils/spaTransition'; import { notify } from '@/lib/notify'; import { parsePermalinkID, type PermalinkOpts } from '../utils/permalink'; @@ -190,7 +191,7 @@ export function usePostSearch( } saveRecentSearch({ keyword: kw, author, titleOnly, scopeBoardId }); - nav(target); + void transitionTo(nav, target); return true; }, [nav, params, loc.pathname, limits, buildUrl]); diff --git a/frontend/src/hooks/useSessionResource.ts b/frontend/src/hooks/useSessionResource.ts new file mode 100644 index 0000000..422c4de --- /dev/null +++ b/frontend/src/hooks/useSessionResource.ts @@ -0,0 +1,144 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { PAGE_FORCE_REFRESH_EVENT } from '../utils/feedCache'; +import { PAGE_SOFT_REFRESH_COMMIT_EVENT } from '../utils/softRefresh'; +import { + getSessionSnapshot, + setSessionSnapshot, + deleteSessionSnapshot, +} from '../utils/sessionPageCache'; + +type Fetcher = () => Promise; + +/** + * 会话快照数据源:有缓存则跳过请求;无缓存时保留上一份画面直到新数据返回。 + * 手机下拉软刷新 commit / PAGE_FORCE_REFRESH_EVENT 会应用新快照。 + */ +export function useSessionResource( + key: string | null, + fetcher: Fetcher, + opts?: { + enabled?: boolean; + onError?: (e: unknown) => void; + }, +) { + const enabled = opts?.enabled !== false; + const fetcherRef = useRef(fetcher); + fetcherRef.current = fetcher; + const onErrorRef = useRef(opts?.onError); + onErrorRef.current = opts?.onError; + + const [data, setData] = useState(() => + key && enabled ? getSessionSnapshot(key) : undefined, + ); + const [loading, setLoading] = useState(() => { + if (!key || !enabled) return false; + return getSessionSnapshot(key) === undefined; + }); + const [pending, setPending] = useState(false); + const seqRef = useRef(0); + const dataRef = useRef(data); + dataRef.current = data; + + useEffect(() => { + if (!key || !enabled) { + if (!enabled) { + setLoading(false); + setPending(false); + } + return; + } + + const hit = getSessionSnapshot(key); + if (hit !== undefined) { + setData(hit); + setLoading(false); + setPending(false); + return; + } + + const seq = ++seqRef.current; + const keep = dataRef.current !== undefined; + if (keep) setPending(true); + else setLoading(true); + + fetcherRef.current() + .then((next) => { + if (seq !== seqRef.current) return; + setSessionSnapshot(key, next); + setData(next); + }) + .catch((e: unknown) => { + if (seq !== seqRef.current) return; + onErrorRef.current?.(e); + if (!keep) setData(undefined); + }) + .finally(() => { + if (seq !== seqRef.current) return; + setLoading(false); + setPending(false); + }); + + return () => { + seqRef.current += 1; + }; + }, [key, enabled]); + + const replace = useCallback((next: T | ((prev: T | undefined) => T)) => { + setData((prev) => { + const value = typeof next === 'function' ? (next as (p: T | undefined) => T)(prev) : next; + if (key) setSessionSnapshot(key, value); + return value; + }); + }, [key]); + + const invalidate = useCallback(() => { + if (key) deleteSessionSnapshot(key); + }, [key]); + + useEffect(() => { + const reload = (opts: { allowLoading: boolean }) => { + if (!key || !enabled) return; + const warm = getSessionSnapshot(key); + if (warm !== undefined) { + setData(warm); + setLoading(false); + setPending(false); + return; + } + const seq = ++seqRef.current; + const keep = dataRef.current !== undefined; + if (opts.allowLoading) { + deleteSessionSnapshot(key); + if (keep) setPending(true); + else setLoading(true); + } + fetcherRef.current() + .then((next) => { + if (seq !== seqRef.current) return; + setSessionSnapshot(key, next); + setData(next); + }) + .catch((e: unknown) => { + if (seq !== seqRef.current) return; + onErrorRef.current?.(e); + if (opts.allowLoading && !keep) setData(undefined); + }) + .finally(() => { + if (seq !== seqRef.current) return; + setLoading(false); + setPending(false); + }); + }; + const onForce = () => reload({ allowLoading: true }); + // 软刷新 commit:未命中不卸 UI,后台静默再拉 + const onCommit = () => reload({ allowLoading: false }); + window.addEventListener(PAGE_FORCE_REFRESH_EVENT, onForce); + window.addEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, onCommit); + return () => { + window.removeEventListener(PAGE_FORCE_REFRESH_EVENT, onForce); + window.removeEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, onCommit); + }; + }, [key, enabled]); + + return { data, loading, pending, replace, invalidate }; +} diff --git a/frontend/src/hooks/useSiteBranding.ts b/frontend/src/hooks/useSiteBranding.ts index 12d6adb..793ed71 100644 --- a/frontend/src/hooks/useSiteBranding.ts +++ b/frontend/src/hooks/useSiteBranding.ts @@ -123,6 +123,13 @@ export function useSiteBranding() { return { branding, loading }; } +/** 保留当前画面,后台重拉品牌配置(下拉刷新等) */ +export function refetchSiteBranding() { + inflight = null; + cacheEpoch += 1; + listeners.forEach(fn => fn()); +} + /** 清除缓存并通知已挂载的 hook 重新拉取 */ export function invalidateSiteBrandingCache() { cached = null; diff --git a/frontend/src/hooks/useSitePages.ts b/frontend/src/hooks/useSitePages.ts index 504b103..4e41226 100644 --- a/frontend/src/hooks/useSitePages.ts +++ b/frontend/src/hooks/useSitePages.ts @@ -1,32 +1,60 @@ import { useEffect, useState } from 'react'; import { api } from '../api/client'; import type { SitePageSummary } from '../api/types'; +import { PAGE_FORCE_REFRESH_EVENT } from '../utils/feedCache'; +import { PAGE_SOFT_REFRESH_COMMIT_EVENT } from '../utils/softRefresh'; let cache: SitePageSummary[] | null = null; let pending: Promise | null = null; +/** 拉取或复用站点页摘要缓存(供冷启动门闩与 hook 共用) */ +export function ensureSitePagesLoaded(opts?: { force?: boolean }): Promise { + if (opts?.force) { + cache = null; + pending = null; + } + if (cache) return Promise.resolve(cache); + if (!pending) { + pending = api.pages() + .then(d => { + cache = d.pages ?? []; + return cache; + }) + .catch(() => { + cache = []; + return cache; + }) + .finally(() => { pending = null; }); + } + return pending; +} + /** 已发布单页摘要(页脚/侧栏导航) */ export function useSitePages() { const [pages, setPages] = useState(cache ?? []); useEffect(() => { - if (cache) { - setPages(cache); - return; - } - if (!pending) { - pending = api.pages() - .then(d => { - cache = d.pages ?? []; - return cache; - }) - .catch(() => { - cache = []; - return cache; - }) - .finally(() => { pending = null; }); - } - pending.then(setPages); + const load = () => { + void ensureSitePagesLoaded().then(setPages); + }; + if (cache) setPages(cache); + else load(); + + const onForce = () => { + cache = null; + pending = null; + load(); + }; + const onCommit = () => { + // 软刷新:cache 已由 prefetch 写好,同步进 state + void ensureSitePagesLoaded().then(setPages); + }; + window.addEventListener(PAGE_FORCE_REFRESH_EVENT, onForce); + window.addEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, onCommit); + return () => { + window.removeEventListener(PAGE_FORCE_REFRESH_EVENT, onForce); + window.removeEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, onCommit); + }; }, []); return { diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx index ac39070..8c1d755 100644 --- a/frontend/src/layouts/MainLayout.tsx +++ b/frontend/src/layouts/MainLayout.tsx @@ -1,6 +1,4 @@ import { useState, useEffect, useCallback, useRef, useMemo, Suspense } from 'react'; -import PageLoader from '../components/PageLoader'; -import FeedPageSkeleton from '../components/FeedPageSkeleton'; import { Outlet, useNavigate, useSearchParams, useLocation } from 'react-router-dom'; import { Menu, Moon, Sun, Search, Plus, PanelRight, X, Mail, SlidersHorizontal } from 'lucide-react'; import { @@ -23,7 +21,12 @@ import BackToTop from '../components/BackToTop'; import { useForumLimits } from '../hooks/useForumLimits'; import { resolveAsideWidgets } from '../utils/asideWidgets'; import { buildHomeUrl, parseFeedSort } from '../components/FeedSortBar'; -import { navigateFeed } from '../utils/feedCache'; +import { navigateFeed, PAGE_FORCE_REFRESH_EVENT } from '../utils/feedCache'; +import { PAGE_SOFT_REFRESH_COMMIT_EVENT } from '../utils/softRefresh'; +import { prefetchRoute, wasColdBootEnsured } from '../utils/prefetchRoute'; +import { + transitionTo, +} from '../utils/spaTransition'; import PostSearchPanel from '../components/search/PostSearchPanel'; import { POST_SEARCH_OPEN_EVENT, @@ -33,12 +36,13 @@ import { cn } from '@/lib/utils'; import { getBoardThemeIndex } from '../utils/boardTheme'; import { loginPath } from '../utils/authRedirect'; import { openForumPost } from '../utils/openPost'; -import { useSiteBranding } from '../hooks/useSiteBranding'; +import { refetchSiteBranding, useSiteBranding } from '../hooks/useSiteBranding'; import { useMonitorPageview } from '../hooks/useMonitorPageview'; import SiteBrandMark from '../components/SiteBrandMark'; import SiteFooter from '../components/SiteFooter'; import { userPath } from '../utils/userPath'; import { parsePermalinkID } from '../utils/permalink'; +import { ensureSitePagesLoaded } from '../hooks/useSitePages'; export default function MainLayout() { const { user, loading: authLoading, logout } = useAuth(); @@ -73,15 +77,20 @@ export default function MainLayout() { const searchInputRef = useRef(null); const [asideLoading, setAsideLoading] = useState(() => !hasCachedAside()); const [boardsLoading, setBoardsLoading] = useState(() => getCachedBoards().length === 0); + const [layoutRefreshTick, setLayoutRefreshTick] = useState(0); const asideEverLoaded = useRef(false); + /** 冷启动门闩:main.tsx 已预热则可同步放行;否则等齐再呈现 */ + const [shellReady, setShellReady] = useState(() => isCompose || wasColdBootEnsured()); + const coldBootDone = useRef(isCompose || wasColdBootEnsured()); + const bootGen = useRef(0); const [boardId, setBoardId] = useState(() => { const m = loc.pathname.match(/^\/board\/(\d+(?:\.[A-Za-z0-9]{1,16})?)$/); if (m) return parsePermalinkID(m[1]) || 0; return Number(params.get('board')) || 0; }); const [keywordDraft, setKeywordDraft] = useState(params.get('keyword') || ''); - const feedSort = parseFeedSort(params.get('sort')); const { limits: forumLimits } = useForumLimits(); + const feedSort = parseFeedSort(params.get('sort'), forumLimits.feed_sort_tabs); const postSearch = usePostSearch(forumLimits); const asideWidgets = useMemo(() => resolveAsideWidgets(forumLimits), [forumLimits]); const showTagCloud = asideWidgets.some(w => w.id === 'tag_cloud' && w.enabled); @@ -195,6 +204,94 @@ export default function MainLayout() { return () => window.removeEventListener('boards-refresh', onRefresh); }, [refreshBoards]); + // 冷启动兜底:main 未预热时静默等齐(不打进度条);站内已预热则保持打开 + useEffect(() => { + if (isCompose) { + coldBootDone.current = true; + setShellReady(true); + return; + } + if (coldBootDone.current || wasColdBootEnsured()) { + coldBootDone.current = true; + setShellReady(true); + setBoardsLoading(false); + setAsideLoading(false); + setTagsLoading(false); + asideEverLoaded.current = true; + return; + } + + const gen = ++bootGen.current; + let cancelled = false; + + (async () => { + try { + const path = `${loc.pathname}${loc.search}`; + const tasks: Promise[] = [ + prefetchRoute(path, { force: false }), + refreshBoards(), + ensureSitePagesLoaded(), + ]; + + if (!hideAside) { + if (showRecentComments) { + tasks.push( + api.recentComments().then((d) => { + const next = Array.isArray(d.comments) ? d.comments : []; + setRecentComments(next); + setCachedRecentComments(next); + }).catch(() => undefined), + ); + } + if (showRecentUsers) { + tasks.push( + api.recentUsers().then((d) => { + const next = Array.isArray(d.users) ? d.users : []; + setRecentUsers(next); + setCachedRecentUsers(next); + }).catch(() => undefined), + ); + } + if (showTagCloud) { + tasks.push( + api.tags(40).then((d) => { + const next = Array.isArray(d.tags) ? d.tags : []; + setTags(next); + setCachedTags(next); + }).catch(() => undefined), + ); + } + } + + await Promise.all(tasks); + } catch { + // 失败也放行 + } finally { + if (!cancelled && bootGen.current === gen) { + asideEverLoaded.current = true; + setAsideLoading(false); + setTagsLoading(false); + setBoardsLoading(false); + coldBootDone.current = true; + setShellReady(true); + } + } + })(); + + return () => { + cancelled = true; + }; + }, [ + isCompose, + loc.pathname, + loc.search, + hideAside, + showRecentComments, + showRecentUsers, + showTagCloud, + refreshBoards, + ]); + const refreshUnreadMessages = useCallback(() => { if (!user) { setUnreadMessages(0); @@ -216,10 +313,50 @@ export default function MainLayout() { }; }, [refreshUnreadMessages]); + useEffect(() => { + const syncFromCache = () => { + // 仅当 cache 有内容时覆盖,避免空数组盖住已渲染的非空 UI + const nextBoards = getCachedBoards(); + if (nextBoards.length > 0) setBoards(nextBoards); + setBoardsLoading(false); + const nextStats = getCachedStats(); + if (nextStats) setStats(nextStats); + const nextComments = getCachedRecentComments(); + if (nextComments.length > 0 || hasCachedAside()) setRecentComments(nextComments); + const nextUsers = getCachedRecentUsers(); + if (nextUsers.length > 0 || hasCachedAside()) setRecentUsers(nextUsers); + const nextTags = getCachedTags(); + if (nextTags.length > 0) setTags(nextTags); + setTagsLoading(false); + setAsideLoading(false); + asideEverLoaded.current = true; + refetchSiteBranding(); + refreshUnreadMessages(); + }; + const onForce = () => { + // 旧路径:仍可能被别处派发;尽量静默刷新壳层 + refreshBoards(); + refreshUnreadMessages(); + refetchSiteBranding(); + setLayoutRefreshTick(n => n + 1); + }; + const onCommit = () => { + // 软刷新:预热已写入 cache,同一拍同步进 state,不触发分批 loading + syncFromCache(); + }; + window.addEventListener(PAGE_FORCE_REFRESH_EVENT, onForce); + window.addEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, onCommit); + return () => { + window.removeEventListener(PAGE_FORCE_REFRESH_EVENT, onForce); + window.removeEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, onCommit); + }; + }, [refreshBoards, refreshUnreadMessages]); + // 标签云:进页/离开发帖页时拉取;不跟 posts-refresh 联动(置顶/推荐等不改标签) useEffect(() => { if (isCompose || !showTagCloud) return; let cancelled = false; + // 有 session 缓存则静默刷新(软刷新不卸空白) if (getCachedTags().length === 0) setTagsLoading(true); api.tags(40).then(d => { if (cancelled) return; @@ -232,7 +369,7 @@ export default function MainLayout() { return () => { cancelled = true; }; - }, [isCompose, showTagCloud]); + }, [isCompose, showTagCloud, layoutRefreshTick]); const needAsideData = !isCompose && (!hideAside || asideOpen); const needRecentComments = needAsideData && showRecentComments; @@ -259,7 +396,7 @@ export default function MainLayout() { return () => { cancelled = true; }; - }, [needRecentComments]); + }, [needRecentComments, layoutRefreshTick]); const needRecentUsers = needAsideData && showRecentUsers; useEffect(() => { @@ -284,7 +421,7 @@ export default function MainLayout() { return () => { cancelled = true; }; - }, [needRecentUsers]); + }, [needRecentUsers, layoutRefreshTick]); const doQuickSearch = () => { const { author, titleOnly, scopeBoardId } = postSearch.filters; @@ -386,8 +523,8 @@ export default function MainLayout() { )} - {/* 点 Logo:回首页并强制刷新列表(已在首页时也会重拉) */} - @@ -470,7 +607,7 @@ export default function MainLayout() {
+ {shellReady && ( + )}
diff --git a/frontend/src/pages/LinksPage.tsx b/frontend/src/pages/LinksPage.tsx index 6fb093a..2cf0a20 100644 --- a/frontend/src/pages/LinksPage.tsx +++ b/frontend/src/pages/LinksPage.tsx @@ -15,6 +15,7 @@ import { loginPath } from '../utils/authRedirect'; import { resolveFriendLinkLogo, isReciprocalChecking, reciprocalStatusLabel } from '../utils/friendLink'; import { InFlowSiteFooter } from '../components/SiteFooter'; import FriendLinkApplyDialog from '../components/FriendLinkApplyDialog'; +import { useSessionResource } from '../hooks/useSessionResource'; const APPLY_STATUS_ORDER: Record = { pending: 0, @@ -48,8 +49,6 @@ export default function LinksPage() { const { user } = useAuth(); const [applyOpen, setApplyOpen] = useState(false); const [editApply, setEditApply] = useState(null); - const [myApplies, setMyApplies] = useState([]); - const [myLoading, setMyLoading] = useState(false); const [cancelingId, setCancelingId] = useState(null); usePageSEO({ @@ -63,6 +62,15 @@ export default function LinksPage() { (l: FriendLink) => l.name?.trim() && l.url?.trim(), ); + const { data: myApplies = [], loading: myLoading, replace: setMyApplies } = useSessionResource( + user ? 'links:applies' : null, + () => api.myFriendLinkApplies().then(r => r.applies ?? []), + { + enabled: !!user, + onError: (e) => notify.error(e instanceof Error ? e.message : '加载失败'), + }, + ); + const sortedApplies = useMemo( () => [...myApplies].sort((a, b) => { const byStatus = APPLY_STATUS_ORDER[a.status] - APPLY_STATUS_ORDER[b.status]; @@ -98,16 +106,10 @@ export default function LinksPage() { setMyApplies([]); return; } - setMyLoading(true); api.myFriendLinkApplies() .then(r => setMyApplies(r.applies ?? [])) - .catch(e => notify.error(e instanceof Error ? e.message : '加载失败')) - .finally(() => setMyLoading(false)); - }, [user]); - - useEffect(() => { - loadMyApplies(); - }, [loadMyApplies]); + .catch(e => notify.error(e instanceof Error ? e.message : '加载失败')); + }, [user, setMyApplies]); useEffect(() => { if (!user || !myApplies.some(isReciprocalChecking)) return; diff --git a/frontend/src/pages/MessagesPage.tsx b/frontend/src/pages/MessagesPage.tsx index 32bb46a..2364e1c 100644 --- a/frontend/src/pages/MessagesPage.tsx +++ b/frontend/src/pages/MessagesPage.tsx @@ -14,8 +14,14 @@ import { postPath } from '../utils/permalink'; import { userPath } from '../utils/userPath'; import { InFlowSiteFooter } from '../components/SiteFooter'; import { cn } from '@/lib/utils'; +import { getSessionSnapshot, setSessionSnapshot, deleteSessionSnapshot } from '../utils/sessionPageCache'; +import { PAGE_FORCE_REFRESH_EVENT } from '../utils/feedCache'; +import { PAGE_SOFT_REFRESH_COMMIT_EVENT } from '../utils/softRefresh'; type MsgTab = 'dm' | 'notify'; +type ConvSnap = { conversations: MessageConversation[]; total: number; page: number }; +type NotifySnap = { notifications: PrivateMessage[]; total: number; page: number }; +type ThreadSnap = { messages: PrivateMessage[]; total: number; peerUser: User | null }; const NOTIFY_KINDS = [ { key: 'all', label: '全部' }, @@ -136,11 +142,28 @@ export default function MessagesPage() { }, []); const loadConversations = useCallback(async (page = 1, append = false) => { + const key = `messages:conv:${page}`; + if (!append) { + const hit = getSessionSnapshot(key); + if (hit) { + setConversations(hit.conversations); + setConvTotal(hit.total); + setConvPage(hit.page); + setListLoading(false); + return; + } + } setListLoading(true); try { const r = await api.messageConversations({ page, size: 30 }); const next = r.conversations || []; - setConversations((prev) => (append ? [...prev, ...next] : next)); + setConversations((prev) => { + const merged = append ? [...prev, ...next] : next; + if (!append) { + setSessionSnapshot(key, { conversations: merged, total: r.total || 0, page: r.page || page }); + } + return merged; + }); setConvTotal(r.total || 0); setConvPage(r.page || page); } catch (e: unknown) { @@ -151,6 +174,17 @@ export default function MessagesPage() { }, []); const loadNotifications = useCallback(async (page = 1, append = false, kind = 'all') => { + const key = `messages:notify:${kind}:${page}`; + if (!append) { + const hit = getSessionSnapshot(key); + if (hit) { + setNotifications(hit.notifications); + setNotifyTotal(hit.total); + setNotifyPage(hit.page); + setNotifyLoading(false); + return; + } + } setNotifyLoading(true); try { const r = await api.messageNotifications({ @@ -164,7 +198,9 @@ export default function MessagesPage() { // 打开通知页时标已读(首屏) if (!append && page === 1) { await api.markNotificationsRead().catch(() => undefined); - setNotifications(next.map((m) => ({ ...m, is_read: true }))); + const marked = next.map((m) => ({ ...m, is_read: true })); + setNotifications(marked); + setSessionSnapshot(key, { notifications: marked, total: r.total || 0, page: r.page || page }); setNotifyUnread(0); window.dispatchEvent(new Event('messages-unread-refresh')); } else { @@ -177,20 +213,47 @@ export default function MessagesPage() { } }, []); + const [threadEpoch, setThreadEpoch] = useState(0); + useEffect(() => { if (authLoading) return; if (!user) { nav(loginPath('/messages')); return; } - void refreshUnreadSplit(); if (tab === 'dm') { + if (!getSessionSnapshot('messages:conv:1')) void refreshUnreadSplit(); loadConversations(1); } else { + if (!getSessionSnapshot(`messages:notify:${notifyKind}:1`)) void refreshUnreadSplit(); loadNotifications(1, false, notifyKind); } }, [user, authLoading, nav, tab, notifyKind, loadConversations, loadNotifications, refreshUnreadSplit]); + useEffect(() => { + const onForce = () => { + // 下拉预热会话列表后直接重读;线程快照需作废以便重拉 + void refreshUnreadSplit(); + if (tab === 'dm') { + void loadConversations(1); + if (peerSelected && selectedPeer !== null) { + deleteSessionSnapshot(`messages:thread:${selectedPeer}`); + setThreadEpoch(n => n + 1); + } + } else { + // 通知列表:预热未覆盖 kind,作废后重拉 + deleteSessionSnapshot(`messages:notify:${notifyKind}:1`); + void loadNotifications(1, false, notifyKind); + } + }; + window.addEventListener(PAGE_FORCE_REFRESH_EVENT, onForce); + window.addEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, onForce); + return () => { + window.removeEventListener(PAGE_FORCE_REFRESH_EVENT, onForce); + window.removeEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, onForce); + }; + }, [tab, notifyKind, peerSelected, selectedPeer, loadConversations, loadNotifications, refreshUnreadSplit]); + const scrollToBottom = useCallback((smooth = false) => { requestAnimationFrame(() => { threadEndRef.current?.scrollIntoView({ behavior: smooth ? 'smooth' : 'auto', block: 'end' }); @@ -204,15 +267,30 @@ export default function MessagesPage() { setMsgTotal(0); return; } + const cached = getSessionSnapshot(`messages:thread:${selectedPeer}`); + if (cached) { + setMessages(cached.messages); + setMsgTotal(cached.total); + setPeerUser(cached.peerUser); + setThreadLoading(false); + return; + } let cancelled = false; setThreadLoading(true); stickToBottomRef.current = true; api.conversationMessages(selectedPeer, { size: 50 }) .then((r) => { if (cancelled) return; - setMessages(r.messages || []); + const messages = r.messages || []; + const peer = r.peer_user || null; + setMessages(messages); setMsgTotal(r.total || 0); - setPeerUser(r.peer_user || null); + setPeerUser(peer); + setSessionSnapshot(`messages:thread:${selectedPeer}`, { + messages, + total: r.total || 0, + peerUser: peer, + }); setConversations((prev) => prev.map((c) => ( c.peer_user_id === selectedPeer ? { ...c, unread_count: 0 } : c ))); @@ -226,7 +304,7 @@ export default function MessagesPage() { if (!cancelled) setThreadLoading(false); }); return () => { cancelled = true; }; - }, [user, peerSelected, selectedPeer, refreshUnreadSplit]); + }, [user, peerSelected, selectedPeer, refreshUnreadSplit, threadEpoch]); useEffect(() => { if (!threadLoading && stickToBottomRef.current) { @@ -321,7 +399,17 @@ export default function MessagesPage() { try { const r = await api.sendMessage({ to_user_id: selectedPeer, content }); stickToBottomRef.current = true; - setMessages((prev) => [...prev, r.message]); + setMessages((prev) => { + const next = [...prev, r.message]; + if (selectedPeer != null) { + setSessionSnapshot(`messages:thread:${selectedPeer}`, { + messages: next, + total: msgTotal + 1, + peerUser, + }); + } + return next; + }); setMsgTotal((n) => n + 1); setDraft(''); setConversations((prev) => { @@ -335,7 +423,9 @@ export default function MessagesPage() { unread_count: 0, updated_at: r.message.created_at, }; - return [next, ...rest]; + const merged = [next, ...rest]; + setSessionSnapshot('messages:conv:1', { conversations: merged, total: convTotal, page: 1 }); + return merged; }); scrollToBottom(true); } catch (e: unknown) { diff --git a/frontend/src/pages/PostDetailPage.tsx b/frontend/src/pages/PostDetailPage.tsx index 4e49643..b9d02c8 100644 --- a/frontend/src/pages/PostDetailPage.tsx +++ b/frontend/src/pages/PostDetailPage.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react'; -import { useParams, useNavigate, useOutletContext, useLocation } from 'react-router-dom'; +import { useParams, useNavigate, useOutletContext, useLocation, useNavigationType } from 'react-router-dom'; import { ArrowLeft, ThumbsUp, Star, Lock, MessageSquare, MessageSquareOff, Flag, MoreHorizontal } from 'lucide-react'; import FeaturedIcon from '@/components/FeaturedIcon'; import { Button } from '@/components/ui/button'; @@ -51,7 +51,9 @@ import { getCachedSiteBranding } from '../hooks/useSiteBranding'; import { formatDateTime, isTimeDiffSignificant } from '../utils/content'; import { collectCommentSubtreeIds } from '../utils/comment'; import { loadMyCommentIds } from '../utils/guest'; -import { clearAllFeedCache } from '../utils/feedCache'; +import { clearAllFeedCache, PAGE_FORCE_REFRESH_EVENT } from '../utils/feedCache'; +import { PAGE_SOFT_REFRESH_COMMIT_EVENT } from '../utils/softRefresh'; +import { deleteSessionSnapshot, getSessionSnapshot, setSessionSnapshot } from '../utils/sessionPageCache'; import { useGlobalWheelScroll } from '../hooks/useGlobalWheelScroll'; import { loginPath } from '../utils/authRedirect'; import { excerptFromHTML, firstImageFromHTML } from '../utils/seoText'; @@ -62,6 +64,27 @@ import type { PostHeading } from '../utils/postHeadings'; import { InFlowSiteFooter } from '../components/SiteFooter'; import NotFoundPage from './NotFoundPage'; +type PostDetailSnapshot = { + post: PostItem; + comments: Comment[]; + poll: PollView | null; + lottery: PostLotteryView | null; + liked: boolean; + favorited: boolean; + canEdit: boolean; + isEdited: boolean; + editBlockReason: string; + editWindowHours: number; + bountyCanRefund: boolean; + bountyRefundBlockReason: string; + bountyEligibleReplyCount: number; + scrollTop: number; +}; + +function postDetailCacheKey(id: number) { + return `post:${id}`; +} + /** 格式化剩余可编辑时间 */ function formatEditRemaining(createdAt: string, windowHours: number): string { if (windowHours <= 0) return ''; @@ -80,26 +103,31 @@ export default function PostDetailPage() { const postId = parsePermalinkID(id); const nav = useNavigate(); const location = useLocation(); + const navType = useNavigationType(); const { user, refresh } = useAuth(); const { limits } = useForumLimits(); const { setPostOutline, isMobile } = useOutletContext(); - const [post, setPost] = useState(null); - const [poll, setPoll] = useState(null); - const [lottery, setLottery] = useState(null); - const [comments, setComments] = useState([]); - const [liked, setLiked] = useState(false); - const [favorited, setFavorited] = useState(false); + const initialSnap = (postId && !Number.isNaN(postId)) + ? getSessionSnapshot(postDetailCacheKey(postId)) + : undefined; + + const [post, setPost] = useState(initialSnap?.post ?? null); + const [poll, setPoll] = useState(initialSnap?.poll ?? null); + const [lottery, setLottery] = useState(initialSnap?.lottery ?? null); + const [comments, setComments] = useState(initialSnap?.comments ?? []); + const [liked, setLiked] = useState(initialSnap?.liked ?? false); + const [favorited, setFavorited] = useState(initialSnap?.favorited ?? false); const [replyTo, setReplyTo] = useState(null); const [editingCommentId, setEditingCommentId] = useState(null); const [submitting, setSubmitting] = useState(false); - const [loading, setLoading] = useState(true); + const [loading, setLoading] = useState(!initialSnap); const [highlightFloor, setHighlightFloor] = useState(null); const [submitCount, setSubmitCount] = useState(0); - const [canEdit, setCanEdit] = useState(false); - const [isEdited, setIsEdited] = useState(false); - const [editBlockReason, setEditBlockReason] = useState(''); - const [editWindowHours, setEditWindowHours] = useState(0); + const [canEdit, setCanEdit] = useState(initialSnap?.canEdit ?? false); + const [isEdited, setIsEdited] = useState(initialSnap?.isEdited ?? false); + const [editBlockReason, setEditBlockReason] = useState(initialSnap?.editBlockReason ?? ''); + const [editWindowHours, setEditWindowHours] = useState(initialSnap?.editWindowHours ?? 0); const [showRevisions, setShowRevisions] = useState(false); const [deletingPost, setDeletingPost] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false); @@ -114,14 +142,25 @@ export default function PostDetailPage() { const [rejecting, setRejecting] = useState(false); const [bountyAwardTarget, setBountyAwardTarget] = useState(null); const [bountyAwarding, setBountyAwarding] = useState(false); - const [bountyCanRefund, setBountyCanRefund] = useState(true); - const [bountyRefundBlockReason, setBountyRefundBlockReason] = useState(''); - const [bountyEligibleReplyCount, setBountyEligibleReplyCount] = useState(0); + const [bountyCanRefund, setBountyCanRefund] = useState(initialSnap?.bountyCanRefund ?? true); + const [bountyRefundBlockReason, setBountyRefundBlockReason] = useState(initialSnap?.bountyRefundBlockReason ?? ''); + const [bountyEligibleReplyCount, setBountyEligibleReplyCount] = useState(initialSnap?.bountyEligibleReplyCount ?? 0); + /** 仅浏览器后退/前进还原滚动;从列表再次点进帖子从顶部开始 */ + const [restoreScrollTop, setRestoreScrollTop] = useState(() => { + if (!initialSnap) return null; + if (navType === 'POP' && !location.hash) return initialSnap.scrollTop; + return 0; + }); const pageRef = useRef(null); const commentSectionRef = useRef(null); const commentBoxRef = useRef(null); const highlightTimer = useRef>(); + const postRef = useRef(null); + const scrollTopRef = useRef( + initialSnap && navType === 'POP' && !location.hash ? initialSnap.scrollTop : 0, + ); + postRef.current = post; useGlobalWheelScroll(pageRef, !loading && !!post); @@ -180,19 +219,64 @@ export default function PostDetailPage() { const loadSeq = useRef(0); const detailPath = postPath(postId, limits); - useEffect(() => { + const applySnapshot = useCallback((snap: PostDetailSnapshot, opts?: { restoreScroll?: boolean }) => { + setPost(snap.post); + setPoll(snap.poll); + setLottery(snap.lottery); + setLiked(snap.liked); + setFavorited(snap.favorited); + setCanEdit(snap.canEdit); + setIsEdited(snap.isEdited); + setEditBlockReason(snap.editBlockReason); + setEditWindowHours(snap.editWindowHours); + setBountyCanRefund(snap.bountyCanRefund); + setBountyRefundBlockReason(snap.bountyRefundBlockReason); + setBountyEligibleReplyCount(snap.bountyEligibleReplyCount); + setComments(snap.comments); + if (opts?.restoreScroll) { + scrollTopRef.current = snap.scrollTop; + setRestoreScrollTop(snap.scrollTop); + } else { + scrollTopRef.current = 0; + // 同组件换帖时容器可能仍停在上一帖位置;初次进入已是 0 则不动,避免覆盖 #floor-N + const el = pageRef.current; + if (el && el.scrollTop !== 0) setRestoreScrollTop(0); + } + }, []); + + const loadPostRef = useRef<(mode: 'auto' | 'force') => void>(() => {}); + loadPostRef.current = (mode: 'auto' | 'force') => { if (!postId || Number.isNaN(postId)) { setPost(null); setLoading(false); return; } + if (mode === 'force') { + deleteSessionSnapshot(postDetailCacheKey(postId)); + } + const cached = mode === 'force' + ? undefined + : getSessionSnapshot(postDetailCacheKey(postId)); + if (cached) { + loadSeq.current += 1; + applySnapshot(cached, { restoreScroll: navType === 'POP' && !location.hash }); + setReplyTo(null); + setEditingCommentId(null); + setComposerOpen(false); + setHeadings([]); + setLoading(false); + return; + } + const seq = ++loadSeq.current; + const keep = !!postRef.current; setReplyTo(null); setEditingCommentId(null); setComposerOpen(false); setHeadings([]); - const seq = ++loadSeq.current; - setLoading(true); - setPost(null); + if (!keep) { + setLoading(true); + setPost(null); + } (async () => { try { @@ -202,30 +286,118 @@ export default function PostDetailPage() { api.comments(postId, myIds), ]); if (seq !== loadSeq.current) return; - setPost(detail.post); - setPoll(detail.poll ?? null); - setLottery(detail.lottery ?? null); - setLiked(detail.liked); - setFavorited(detail.favorited); - setCanEdit(detail.can_edit ?? false); - setIsEdited(detail.is_edited ?? isTimeDiffSignificant(detail.post.created_at, detail.post.updated_at ?? detail.post.created_at)); - setEditBlockReason(detail.edit_block_reason ?? ''); - setEditWindowHours(detail.post_edit_window_hours ?? 0); - setBountyCanRefund(detail.bounty_can_refund ?? true); - setBountyRefundBlockReason(detail.bounty_refund_block_reason ?? ''); - setBountyEligibleReplyCount(detail.bounty_eligible_reply_count ?? 0); - setComments(Array.isArray(comm.comments) ? comm.comments : []); + const commentsList = Array.isArray(comm.comments) ? comm.comments : []; + const snap: PostDetailSnapshot = { + post: detail.post, + comments: commentsList, + poll: detail.poll ?? null, + lottery: detail.lottery ?? null, + liked: detail.liked, + favorited: detail.favorited, + canEdit: detail.can_edit ?? false, + isEdited: detail.is_edited ?? isTimeDiffSignificant(detail.post.created_at, detail.post.updated_at ?? detail.post.created_at), + editBlockReason: detail.edit_block_reason ?? '', + editWindowHours: detail.post_edit_window_hours ?? 0, + bountyCanRefund: detail.bounty_can_refund ?? true, + bountyRefundBlockReason: detail.bounty_refund_block_reason ?? '', + bountyEligibleReplyCount: detail.bounty_eligible_reply_count ?? 0, + scrollTop: 0, + }; + setSessionSnapshot(postDetailCacheKey(postId), snap); + applySnapshot(snap, { restoreScroll: false }); + if (mode === 'force') { + const el = pageRef.current; + if (el) el.scrollTop = 0; + scrollTopRef.current = 0; + } void refresh(); } catch { if (seq !== loadSeq.current) return; - setPost(null); + if (!keep) setPost(null); } finally { if (seq === loadSeq.current) setLoading(false); } })(); + }; + + useEffect(() => { + loadPostRef.current('auto'); // eslint-disable-next-line react-hooks/exhaustive-deps -- 仅 postId 变化时加载 }, [postId]); + useEffect(() => { + // 下拉已预热则应用快照;否则强制重拉 + const onForce = () => { + if (!postId || Number.isNaN(postId)) return; + const warm = getSessionSnapshot(postDetailCacheKey(postId)); + if (warm) { + loadSeq.current += 1; + applySnapshot(warm, { restoreScroll: false }); + setLoading(false); + const el = pageRef.current; + if (el) el.scrollTop = 0; + scrollTopRef.current = 0; + return; + } + loadPostRef.current('force'); + }; + window.addEventListener(PAGE_FORCE_REFRESH_EVENT, onForce); + window.addEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, onForce); + return () => { + window.removeEventListener(PAGE_FORCE_REFRESH_EVENT, onForce); + window.removeEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, onForce); + }; + }, [postId, applySnapshot]); + + useEffect(() => { + const el = pageRef.current; + if (!el || loading || !post) return; + const onScroll = () => { + scrollTopRef.current = el.scrollTop; + }; + el.addEventListener('scroll', onScroll, { passive: true }); + return () => el.removeEventListener('scroll', onScroll); + }, [loading, post]); + + useLayoutEffect(() => { + if (restoreScrollTop == null || !pageRef.current || loading || !post) return; + pageRef.current.scrollTop = restoreScrollTop; + scrollTopRef.current = restoreScrollTop; + setRestoreScrollTop(null); + }, [restoreScrollTop, loading, post]); + + useEffect(() => { + if (!post || post.id !== postId || loading) return; + setSessionSnapshot(postDetailCacheKey(post.id), { + post, + comments, + poll, + lottery, + liked, + favorited, + canEdit, + isEdited, + editBlockReason, + editWindowHours, + bountyCanRefund, + bountyRefundBlockReason, + bountyEligibleReplyCount, + scrollTop: scrollTopRef.current, + }); + }, [ + post, comments, poll, lottery, liked, favorited, canEdit, isEdited, + editBlockReason, editWindowHours, bountyCanRefund, bountyRefundBlockReason, + bountyEligibleReplyCount, postId, loading, + ]); + + useEffect(() => () => { + const p = postRef.current; + if (!p) return; + const prev = getSessionSnapshot(postDetailCacheKey(p.id)); + if (!prev) return; + setSessionSnapshot(postDetailCacheKey(p.id), { ...prev, scrollTop: scrollTopRef.current }); + }, []); + const reloadComments = useCallback(async () => { const myIds = user ? [] : loadMyCommentIds(); const comm = await api.comments(postId, myIds); @@ -573,6 +745,7 @@ export default function PostDetailPage() { setDeletingPost(true); try { await api.deletePost(postId); + deleteSessionSnapshot(postDetailCacheKey(postId)); clearAllFeedCache(); window.dispatchEvent(new Event('posts-refresh')); notify.success('帖子已删除'); @@ -592,7 +765,7 @@ export default function PostDetailPage() { onCancelReply: () => setReplyTo(null), }; - if (loading) return
; + if (loading && !post) return
; if (!post) { return ( (null); - const [statsLoading, setStatsLoading] = useState(true); - const [posts, setPosts] = useState([]); - const [postsLoading, setPostsLoading] = useState(false); - const [postPage, setPostPage] = useState(1); - const [postTotal, setPostTotal] = useState(0); const fileRef = useRef(null); const dragCounter = useRef(0); const copyTimer = useRef>(); @@ -102,7 +97,6 @@ export default function ProfilePage() { const { limits } = useForumLimits(); const pageSize = limits.page_size_default > 0 ? limits.page_size_default : 20; - const totalPages = Math.max(1, Math.ceil(postTotal / pageSize)); const nickForm = useForm({ resolver: zodResolver(nickSchema), @@ -181,37 +175,26 @@ export default function ProfilePage() { if (copyTimer.current) clearTimeout(copyTimer.current); }, []); - const loadStats = useCallback(() => { - setStatsLoading(true); - api.profileStats() - .then(d => setStats(d.stats)) - .catch(() => setStats(null)) - .finally(() => setStatsLoading(false)); - }, []); + const { data: stats = null, loading: statsLoading } = useSessionResource( + user ? `profile:stats:${user.id}` : null, + () => api.profileStats().then(d => d.stats ?? null), + { enabled: !!user }, + ); - useEffect(() => { - if (!user) return; - loadStats(); - }, [user, loadStats]); - - useEffect(() => { - if (!user || tab !== 'posts') return; - let cancelled = false; - setPostsLoading(true); - api.posts({ user_id: user.id, page: postPage, size: pageSize, sort: 'latest' }) - .then(d => { - if (cancelled) return; - setPosts(Array.isArray(d.posts) ? d.posts : []); - setPostTotal(d.total ?? 0); - }) - .catch(e => { - if (!cancelled) notify.error(e instanceof Error ? e.message : '加载帖子失败'); - }) - .finally(() => { - if (!cancelled) setPostsLoading(false); - }); - return () => { cancelled = true; }; - }, [user, tab, postPage, pageSize]); + const [postPage, setPostPage] = useState(1); + const postsKey = user && tab === 'posts' ? `profile:posts:${user.id}:${postPage}:${pageSize}` : null; + const { data: postsSnap, loading: postsLoading } = useSessionResource<{ posts: PostItem[]; total: number }>( + postsKey, + () => api.posts({ user_id: user!.id, page: postPage, size: pageSize, sort: 'latest' }) + .then(d => ({ posts: Array.isArray(d.posts) ? d.posts : [], total: d.total ?? 0 })), + { + enabled: !!postsKey, + onError: (e) => notify.error(e instanceof Error ? e.message : '加载帖子失败'), + }, + ); + const posts = postsSnap?.posts ?? []; + const postTotal = postsSnap?.total ?? 0; + const totalPages = Math.max(1, Math.ceil(postTotal / pageSize)); const closeCropDialog = useCallback((open: boolean) => { if (!open) { @@ -263,7 +246,7 @@ export default function ProfilePage() { await api.updatePassword(values.old_password, values.new_password); notify.success('密码已修改,请重新登录'); pwdForm.reset(); - await api.logout(); + await logout(); nav('/login'); } catch (e: unknown) { notify.error(e instanceof Error ? e.message : '修改失败'); diff --git a/frontend/src/pages/ProjectsPage.tsx b/frontend/src/pages/ProjectsPage.tsx index a1adf44..66555fd 100644 --- a/frontend/src/pages/ProjectsPage.tsx +++ b/frontend/src/pages/ProjectsPage.tsx @@ -10,14 +10,13 @@ import ProjectListItem from '../components/ProjectListItem'; import { InFlowSiteFooter } from '../components/SiteFooter'; import { joinSEOKeywords, usePageSEO } from '../hooks/usePageSEO'; import { getCachedSiteBranding } from '../hooks/useSiteBranding'; +import { useSessionResource } from '../hooks/useSessionResource'; + +type ProjectsSnap = { list: GiteaProject[]; total: number; totalPages: number }; export default function ProjectsPage() { const nav = useNavigate(); - const [list, setList] = useState([]); - const [total, setTotal] = useState(0); const [page, setPage] = useState(1); - const [totalPages, setTotalPages] = useState(0); - const [loading, setLoading] = useState(true); const [queryInput, setQueryInput] = useState(''); const [query, setQuery] = useState(''); @@ -40,24 +39,20 @@ export default function ProjectsPage() { return () => window.clearTimeout(t); }, [queryInput]); - useEffect(() => { - let cancelled = false; - setLoading(true); - api.projects({ page, limit: 30, q: query || undefined }) - .then(d => { - if (cancelled) return; - setList(Array.isArray(d.projects) ? d.projects : []); - setTotal(d.total ?? 0); - setTotalPages(d.total_pages ?? 0); - }) - .catch(e => { - if (!cancelled) notify.error(e instanceof Error ? e.message : '加载失败'); - }) - .finally(() => { - if (!cancelled) setLoading(false); - }); - return () => { cancelled = true; }; - }, [page, query]); + const { data, loading } = useSessionResource( + `projects:${page}:${query}`, + () => api.projects({ page, limit: 30, q: query || undefined }).then(d => ({ + list: Array.isArray(d.projects) ? d.projects : [], + total: d.total ?? 0, + totalPages: d.total_pages ?? 0, + })), + { + onError: (e) => notify.error(e instanceof Error ? e.message : '加载失败'), + }, + ); + const list = data?.list ?? []; + const total = data?.total ?? 0; + const totalPages = data?.totalPages ?? 0; return (
diff --git a/frontend/src/pages/ShowcasePage.tsx b/frontend/src/pages/ShowcasePage.tsx index ad36857..12bbe7b 100644 --- a/frontend/src/pages/ShowcasePage.tsx +++ b/frontend/src/pages/ShowcasePage.tsx @@ -1,4 +1,3 @@ -import { useEffect, useState } from 'react'; import { ExternalLink, Globe2 } from 'lucide-react'; import { Spinner } from '@/components/ui/spinner'; import { api } from '../api/client'; @@ -6,12 +5,15 @@ import type { CommunityShowcaseItem } from '../api/types'; import { joinSEOKeywords, usePageSEO } from '../hooks/usePageSEO'; import { getCachedSiteBranding, useSiteBranding } from '../hooks/useSiteBranding'; import { InFlowSiteFooter } from '../components/SiteFooter'; +import { useSessionResource } from '../hooks/useSessionResource'; /** 官方精选的公网部署展柜(只读;仅人工精选条目) */ export default function ShowcasePage() { const { branding } = useSiteBranding(); - const [items, setItems] = useState([]); - const [loading, setLoading] = useState(true); + const { data: items = [], loading } = useSessionResource( + 'showcase', + () => api.communityShowcase().then((r) => (Array.isArray(r.items) ? r.items : [])), + ); usePageSEO({ title: '开源部署展柜', @@ -20,23 +22,9 @@ export default function ShowcasePage() { canonicalPath: '/showcase', }); - useEffect(() => { - let cancelled = false; - api.communityShowcase() - .then((r) => { - if (!cancelled) setItems(Array.isArray(r.items) ? r.items : []); - }) - .catch(() => { - if (!cancelled) setItems([]); - }) - .finally(() => { - if (!cancelled) setLoading(false); - }); - return () => { cancelled = true; }; - }, []); - return ( -
+
+
@@ -82,6 +70,7 @@ export default function ShowcasePage() { )} +
); } diff --git a/frontend/src/pages/SitePageView.tsx b/frontend/src/pages/SitePageView.tsx index 1a1069f..e7d28d0 100644 --- a/frontend/src/pages/SitePageView.tsx +++ b/frontend/src/pages/SitePageView.tsx @@ -1,14 +1,13 @@ -import { useEffect, useState } from 'react'; import { useParams } from 'react-router-dom'; import NotFoundPage from './NotFoundPage'; import { api } from '../api/client'; import type { SitePage } from '../api/types'; import PostContent from '../components/PostContent'; -import PageLoader from '../components/PageLoader'; import { usePageSEO } from '../hooks/usePageSEO'; import { parsePermalinkSlug, pagePath } from '../utils/permalink'; import { useForumLimits } from '../hooks/useForumLimits'; import { useAuth } from '../hooks/useAuth'; +import { useSessionResource } from '../hooks/useSessionResource'; /** 自定义单页(关于我们、版规等) */ export default function SitePageView() { @@ -16,22 +15,12 @@ export default function SitePageView() { const slug = parsePermalinkSlug(rawSlug); const { limits } = useForumLimits(); const { user } = useAuth(); - const [page, setPage] = useState(null); - const [loading, setLoading] = useState(true); - const [notFound, setNotFound] = useState(false); - - useEffect(() => { - if (!slug) { - setNotFound(true); - setLoading(false); - return; - } - setLoading(true); - api.page(slug) - .then(d => setPage(d.page)) - .catch(() => setNotFound(true)) - .finally(() => setLoading(false)); - }, [slug]); + const { data: page, loading } = useSessionResource( + slug ? `sitepage:${slug}` : null, + () => api.page(slug).then(d => d.page), + { enabled: !!slug }, + ); + const notFound = !slug || (!loading && !page); usePageSEO({ title: page?.title, @@ -41,7 +30,7 @@ export default function SitePageView() { }); if (!slug) return ; - if (loading) return ; + if (loading) return null; if (notFound || !page) return ; return ( diff --git a/frontend/src/pages/UserProfilePage.tsx b/frontend/src/pages/UserProfilePage.tsx index c04caf9..e3b9f8e 100644 --- a/frontend/src/pages/UserProfilePage.tsx +++ b/frontend/src/pages/UserProfilePage.tsx @@ -20,6 +20,7 @@ import { api } from '../api/client'; import type { PostItem, UserActivityStats, UserPublic } from '../api/types'; import { useAuth } from '../hooks/useAuth'; import { useForumLimits } from '../hooks/useForumLimits'; +import { useSessionResource } from '../hooks/useSessionResource'; import PostListItem from '../components/PostListItem'; import FeedPagination from '../components/FeedPagination'; import ComposeMessageDialog from '../components/ComposeMessageDialog'; @@ -31,6 +32,9 @@ import { canonicalRedirectPath, parsePermalinkID, userPath } from '../utils/perm import NotFoundPage from './NotFoundPage'; import { InFlowSiteFooter } from '../components/SiteFooter'; +type ProfileSnap = { profile: UserPublic; stats: UserActivityStats | null }; +type PostsSnap = { posts: PostItem[]; total: number }; + export default function UserProfilePage() { const { id: idParam } = useParams(); const userId = parsePermalinkID(idParam); @@ -40,15 +44,34 @@ export default function UserProfilePage() { const { limits } = useForumLimits(); const pageSize = limits.page_size_default > 0 ? limits.page_size_default : 20; - const [profile, setProfile] = useState(null); - const [stats, setStats] = useState(null); const [msgOpen, setMsgOpen] = useState(false); - const [loading, setLoading] = useState(true); const [notFound, setNotFound] = useState(false); - const [posts, setPosts] = useState([]); - const [postsLoading, setPostsLoading] = useState(false); const [postPage, setPostPage] = useState(1); - const [postTotal, setPostTotal] = useState(0); + + const profileKey = userId && !Number.isNaN(userId) ? `user:${userId}` : null; + const { data: profileSnap, loading } = useSessionResource( + profileKey, + () => api.userProfile(userId).then(d => ({ profile: d.user, stats: d.stats ?? null })), + { + enabled: !!profileKey, + onError: () => setNotFound(true), + }, + ); + const profile = profileSnap?.profile ?? null; + const stats = profileSnap?.stats ?? null; + + const postsKey = profileKey && profile ? `${profileKey}:posts:${postPage}:${pageSize}` : null; + const { data: postsSnap, loading: postsLoading } = useSessionResource( + postsKey, + () => api.posts({ user_id: userId, page: postPage, size: pageSize, sort: 'latest' }) + .then(d => ({ posts: Array.isArray(d.posts) ? d.posts : [], total: d.total ?? 0 })), + { + enabled: !!postsKey, + onError: (e) => notify.error(e instanceof Error ? e.message : '加载帖子失败'), + }, + ); + const posts = postsSnap?.posts ?? []; + const postTotal = postsSnap?.total ?? 0; const isSelf = !!me && me.id === userId; const totalPages = Math.max(1, Math.ceil(postTotal / pageSize)); @@ -56,43 +79,13 @@ export default function UserProfilePage() { useEffect(() => { if (!userId || Number.isNaN(userId)) { setNotFound(true); - setLoading(false); - return; } - setLoading(true); - setNotFound(false); - setPostPage(1); - api.userProfile(userId) - .then(d => { - setProfile(d.user); - setStats(d.stats); - }) - .catch(() => { - setProfile(null); - setStats(null); - setNotFound(true); - }) - .finally(() => setLoading(false)); }, [userId]); useEffect(() => { - if (!userId || Number.isNaN(userId) || !profile) return; - let cancelled = false; - setPostsLoading(true); - api.posts({ user_id: userId, page: postPage, size: pageSize, sort: 'latest' }) - .then(d => { - if (cancelled) return; - setPosts(Array.isArray(d.posts) ? d.posts : []); - setPostTotal(d.total ?? 0); - }) - .catch(e => { - if (!cancelled) notify.error(e instanceof Error ? e.message : '加载帖子失败'); - }) - .finally(() => { - if (!cancelled) setPostsLoading(false); - }); - return () => { cancelled = true; }; - }, [userId, profile, postPage, pageSize]); + setNotFound(false); + setPostPage(1); + }, [userId]); useEffect(() => { if (!userId || Number.isNaN(userId)) return; diff --git a/frontend/src/pages/admin/AdminLinksPage.tsx b/frontend/src/pages/admin/AdminLinksPage.tsx index 5993652..66a4cff 100644 --- a/frontend/src/pages/admin/AdminLinksPage.tsx +++ b/frontend/src/pages/admin/AdminLinksPage.tsx @@ -22,6 +22,7 @@ import { cn } from '@/lib/utils'; import { api } from '../../api/client'; import { useAdminGuard } from '../../layouts/AdminLayout'; import type { ForumLimits, FriendLink, FriendLinkApply, SiteBranding } from '../../api/types'; +import { DEFAULT_FEED_SORT_TABS } from '../../api/types'; import { useSiteBranding, seedSiteBrandingCache, invalidateSiteBrandingCache } from '../../hooks/useSiteBranding'; import { invalidateForumLimitsCache } from '../../hooks/useForumLimits'; import { formatTime } from '../../utils/content'; @@ -185,6 +186,7 @@ export default function AdminLinksPage() { nav_show_showcase: false, footer_show_showcase: false, feed_list_style: 'title', + feed_sort_tabs: DEFAULT_FEED_SORT_TABS, permalink_enabled: false, permalink_ext: 'html', ...s.limits, @@ -366,6 +368,7 @@ export default function AdminLinksPage() { nav_show_showcase: false, footer_show_showcase: false, feed_list_style: 'title', + feed_sort_tabs: DEFAULT_FEED_SORT_TABS, permalink_enabled: false, permalink_ext: 'html', post_edit_window_hours: 24, diff --git a/frontend/src/pages/admin/AdminSettingsPage.tsx b/frontend/src/pages/admin/AdminSettingsPage.tsx index 529a85b..1d316f7 100644 --- a/frontend/src/pages/admin/AdminSettingsPage.tsx +++ b/frontend/src/pages/admin/AdminSettingsPage.tsx @@ -11,9 +11,11 @@ import { invalidateForumLimitsCache } from '../../hooks/useForumLimits'; import { DEFAULT_BRANDING, seedSiteBrandingCache } from '../../hooks/useSiteBranding'; import { clearAllFeedCache } from '../../utils/feedCache'; import { normalizeAsideWidgets, resolveAsideWidgets, mergeForumLimitsWithAsideWidgets, resolveSavedAsideWidgets } from '../../utils/asideWidgets'; +import { normalizeFeedSortTabs } from '../../utils/feedSortTabs'; import AsideWidgetList from '../../components/admin/AsideWidgetList'; +import FeedSortTabList from '../../components/admin/FeedSortTabList'; import type { AdminSettings, ForumLimits, MailConfig, OIDCConfig, OAuthClient, GiteaSyncConfig, StorageConfig, SiteBranding, AsideWidget } from '../../api/types'; -import { DEFAULT_ASIDE_WIDGETS } from '../../api/types'; +import { DEFAULT_ASIDE_WIDGETS, DEFAULT_FEED_SORT_TABS } from '../../api/types'; type TabId = 'branding' | 'limits' | 'mail' | 'oidc' | 'gitea' | 'storage' | 'filter' | 'system'; @@ -305,6 +307,7 @@ export default function AdminSettingsPage() { nav_show_showcase: false, footer_show_showcase: false, feed_list_style: 'title', + feed_sort_tabs: normalizeFeedSortTabs(s.limits?.feed_sort_tabs ?? DEFAULT_FEED_SORT_TABS), permalink_enabled: false, permalink_ext: 'html', ...s.limits, @@ -916,7 +919,7 @@ export default function AdminSettingsPage() {

列表呈现

-

首页及帖子列表的信息密度与缩略图展示

+

首页及帖子列表的信息密度、缩略图与排序标签

@@ -946,6 +949,16 @@ export default function AdminSettingsPage() {
+
+

首页排序标签

+

+ 拖拽调整顺序;在「显示名称」框中改首页文案;右侧开关控制启停。第一个启用项为默认排序 +

+ setLimits(prev => prev ? { ...prev, feed_sort_tabs: tabs } : prev)} + /> +