fix: 软刷新齐套前保留旧画面,避免一点击就卸光

Logo/下拉静默预热后一次覆盖;commit 不再先 reset/loading;顺带会话预取与可配置 Feed 排序标签。
This commit is contained in:
2026-09-01 04:56:10 +08:00
parent ba60a9b1c4
commit 9e134916a4
47 changed files with 2446 additions and 636 deletions

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { ArrowLeft, Star } from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -7,6 +7,7 @@ import { notify } from '@/lib/notify';
import { api } from '../api/client';
import type { PostItem } from '../api/types';
import { useAuth } from '../hooks/useAuth';
import { useSessionResource } from '../hooks/useSessionResource';
import PostListItem from '../components/PostListItem';
import { loginPath } from '../utils/authRedirect';
import { useForumLimits } from '../hooks/useForumLimits';
@@ -26,16 +27,19 @@ export default function FavoritesPage() {
const { user, loading: authLoading } = useAuth();
const { limits } = useForumLimits();
useNoIndexSEO('我的收藏');
const [list, setList] = useState<FavItem[]>([]);
const [loading, setLoading] = useState(true);
const { data: list = [], loading } = useSessionResource<FavItem[]>(
user ? 'favorites' : null,
() => api.favorites().then(d => (Array.isArray(d.favorites) ? d.favorites as FavItem[] : [])),
{
enabled: !!user && !authLoading,
onError: (e) => notify.error(e instanceof Error ? e.message : '加载失败'),
},
);
useEffect(() => {
if (authLoading) return;
if (!user) { nav(loginPath('/favorites')); return; }
api.favorites()
.then(d => setList(Array.isArray(d.favorites) ? d.favorites as FavItem[] : []))
.catch(e => notify.error(e.message))
.finally(() => setLoading(false));
if (!user) nav(loginPath('/favorites'));
}, [user, authLoading, nav]);
if (authLoading || loading) return <div className="flex justify-center py-16"><Spinner size="lg" /></div>;

View File

@@ -14,19 +14,20 @@ import type { LayoutCtx } from '../layouts/MainLayout';
import VirtualPostList from '../components/VirtualPostList';
import FeedHeader from '../components/FeedHeader';
import FeedSearchFilters from '../components/search/FeedSearchFilters';
import FeedPageSkeleton from '../components/FeedPageSkeleton';
import FeedSortBar, { parseFeedSort, buildHomeUrl, type FeedSort } from '../components/FeedSortBar';
import { useForumLimits } from '../hooks/useForumLimits';
import { parseSearchFromUrl, usePostSearch } from '../hooks/usePostSearch';
import {
clearAllFeedCache,
navigateFeed,
FEED_RESET_EVENT,
FEED_PULL_REFRESH_EVENT,
type FeedNavState,
} from '../utils/feedCache';
import { PAGE_SOFT_REFRESH_COMMIT_EVENT } from '../utils/softRefresh';
import { feedCacheKey, getHomeStoreState } from '../store/homeStore';
import { enabledFeedSortTabs, getDefaultFeedSort } from '../utils/feedSortTabs';
import { openForumPost } from '../utils/openPost';
import { startTransition, doneTransition } from '../utils/spaTransition';
import { joinSEOKeywords, usePageSEO } from '../hooks/usePageSEO';
import { siteMetaDescription, useSiteBranding } from '../hooks/useSiteBranding';
import { boardPath, canonicalRedirectPath, parsePermalinkID } from '../utils/permalink';
@@ -95,7 +96,8 @@ export default function HomePage() {
const tag = params.get('tag') || '';
const author = params.get('author') || '';
const titleOnly = params.get('title_only') === '1';
const sort = parseFeedSort(params.get('sort'));
const rawSort = params.get('sort');
const sort = parseFeedSort(rawSort, limits.feed_sort_tabs);
const board = (ctx?.boards ?? []).find(b => b.id === boardId);
const isSiteHome = !boardId && !keyword && !tag && !author;
const siteIntro = siteMetaDescription(branding);
@@ -135,6 +137,27 @@ export default function HomePage() {
}
}, [queryBoardId, boardId, boardRouteId, params, nav, limits, location.pathname, location.search]);
// URL 上的 sort 已被后台关闭时,纠正为默认排序
useEffect(() => {
if (limitsLoading) return;
if (rawSort !== 'latest' && rawSort !== 'hot' && rawSort !== 'reply') return;
const stillOn = enabledFeedSortTabs(limits.feed_sort_tabs).some(t => t.id === rawSort);
if (stillOn) return;
const def = getDefaultFeedSort(limits.feed_sort_tabs);
nav(buildHomeUrl(boardId, def, { keyword, tag, author, titleOnly, permalink: limits }), { replace: true });
}, [
limitsLoading,
limits.feed_sort_tabs,
limits,
rawSort,
boardId,
keyword,
tag,
author,
titleOnly,
nav,
]);
const cacheKey = useMemo(
() => feedCacheKey({ boardId, keyword, sort, tag, author, titleOnly }),
[boardId, keyword, sort, tag, author, titleOnly],
@@ -151,25 +174,40 @@ export default function HomePage() {
const [postTotal, setPostTotal] = useState(initial.postTotal);
const [page, setPage] = useState(initial.page);
const [loading, setLoading] = useState(initial.loading);
/** 画面上已提交的筛选(URL 已变但数据未到时仍画上一份) */
const [view, setView] = useState({
cacheKey,
sort,
boardId,
keyword,
tag,
author,
titleOnly,
});
const [listPending, setListPending] = useState(false);
const [restoreScrollTop, setRestoreScrollTop] = useState<number | null>(
initial.posts.length > 0 ? initial.scrollTop : null,
);
const [listResetKey, setListResetKey] = useState(0);
const scrollTopRef = useRef(initial.scrollTop);
const loadingRef = useRef(false);
const fetchSeqRef = useRef(0);
const pageRef = useRef(initial.page);
const cacheKeyRef = useRef(cacheKey);
const viewKeyRef = useRef(view.cacheKey);
const postsRef = useRef(posts);
const scrollRafRef = useRef(0);
/** 当前筛选键是否已完成「进入页」水合(避免 effect 重跑时反复 setRestoreScrollTop) */
const hydratedKeyRef = useRef<string | null>(null);
pageRef.current = page;
cacheKeyRef.current = cacheKey;
viewKeyRef.current = view.cacheKey;
postsRef.current = posts;
// 筛选键切换:用新键的缓存重置本地 state(useMemo initial 不会自动 setState)
useEffect(() => {
const next = readHydrate(boardId, keyword, sort, tag, author, titleOnly);
hydratedKeyRef.current = null;
const commitDisplayed = useCallback((
next: FeedHydrate,
meta: { cacheKey: string; sort: FeedSort; boardId: number; keyword: string; tag: string; author: string; titleOnly: boolean },
) => {
setPosts(next.posts);
setPostTotal(next.postTotal);
setPage(next.page);
@@ -177,7 +215,30 @@ export default function HomePage() {
scrollTopRef.current = next.scrollTop;
setRestoreScrollTop(next.posts.length > 0 ? next.scrollTop : null);
setLoading(next.loading);
}, [cacheKey, boardId, keyword, sort, tag, author, titleOnly]);
setListPending(false);
setView(meta);
}, []);
// 筛选键切换:有快照则立刻换页;否则保留当前画面等请求结束
useEffect(() => {
if ((location.state as FeedNavState | null)?.refreshFeed && navType !== 'POP') {
hydratedKeyRef.current = null;
return;
}
const next = readHydrate(boardId, keyword, sort, tag, author, titleOnly);
if (next.posts.length > 0) {
hydratedKeyRef.current = cacheKey;
commitDisplayed(next, { cacheKey, sort, boardId, keyword, tag, author, titleOnly });
return;
}
hydratedKeyRef.current = null;
if (postsRef.current.length > 0) {
setListPending(true);
setLoading(false);
return;
}
commitDisplayed(next, { cacheKey, sort, boardId, keyword, tag, author, titleOnly });
}, [cacheKey, boardId, keyword, sort, tag, author, titleOnly, commitDisplayed, location.state, navType]);
const totalPages = Math.max(1, Math.ceil(Math.max(postTotal, 0) / pageSize));
const showPagination = totalPages > 1 && posts.length > 0;
@@ -189,9 +250,8 @@ export default function HomePage() {
setListResetKey(k => k + 1);
}, []);
/** 强制刷新:清空全部 Feed 缓存并滚回顶部 */
/** 同筛选强制刷新:只复位滚动,保留旧列表直到 loadFirst 覆盖 */
const beginFeedRefresh = useCallback(() => {
clearAllFeedCache();
resetFeedView();
}, [resetFeedView]);
@@ -200,9 +260,9 @@ export default function HomePage() {
nextPosts: PostItem[],
nextTotal: number,
nextPage: number,
opts?: { scrollTop?: number; touchFetchTime?: boolean },
opts?: { scrollTop?: number; touchFetchTime?: boolean; key?: string },
) => {
const key = cacheKeyRef.current;
const key = opts?.key ?? cacheKeyRef.current;
const prev = getHomeStoreState().getFeed(key);
getHomeStoreState().setFeed(key, {
posts: nextPosts,
@@ -215,12 +275,14 @@ export default function HomePage() {
});
}, []);
const fetchPage = useCallback(async (p: number, opts?: { silent?: boolean }) => {
if (loadingRef.current) return;
loadingRef.current = true;
const fetchPage = useCallback(async (p: number, opts?: { silent?: boolean; resetScroll?: boolean }) => {
const seq = ++fetchSeqRef.current;
const requestKey = cacheKeyRef.current;
const silent = !!opts?.silent;
// 静默刷新:保留现有列表,不展示加载骨架
if (!silent) setLoading(true);
const keepView = postsRef.current.length > 0;
// 有旧列表时不卸页,只标 pending
if (!silent && !keepView) setLoading(true);
if (!silent && keepView) setListPending(true);
try {
const data = await api.posts({
page: p,
@@ -232,42 +294,53 @@ export default function HomePage() {
title_only: !tag && titleOnly ? '1' : '',
sort,
});
if (seq !== fetchSeqRef.current) return;
const batch = Array.isArray(data.posts) ? data.posts : [];
const total = data.total ?? 0;
const jumpTop = opts?.resetScroll || (keepView && requestKey !== viewKeyRef.current);
const scrollTop = jumpTop ? 0 : scrollTopRef.current;
if (jumpTop) {
setRestoreScrollTop(null);
scrollTopRef.current = 0;
setListResetKey(k => k + 1);
}
setPosts(batch);
setPostTotal(total);
setPage(p);
pageRef.current = p;
persistFeed(batch, total, p, { touchFetchTime: true });
setView({ cacheKey: requestKey, sort, boardId, keyword, tag, author, titleOnly });
persistFeed(batch, total, p, { scrollTop, touchFetchTime: true, key: requestKey });
} catch (e: unknown) {
if (seq !== fetchSeqRef.current) return;
if (!silent) {
notify.error(e instanceof Error ? e.message : '加载失败');
setPosts([]);
setPostTotal(0);
setPage(1);
pageRef.current = 1;
if (!keepView) {
setPosts([]);
setPostTotal(0);
setPage(1);
pageRef.current = 1;
}
}
} finally {
loadingRef.current = false;
setLoading(false);
if (seq === fetchSeqRef.current) {
setLoading(false);
setListPending(false);
}
}
}, [boardId, keyword, tag, author, titleOnly, sort, pageSize, persistFeed]);
const loadFirst = useCallback(() => fetchPage(1), [fetchPage]);
const goToPage = useCallback((p: number) => {
if (loadingRef.current) return;
const maxPage = Math.max(1, Math.ceil(Math.max(postTotal, 0) / pageSize));
if (p < 1 || p > maxPage) return;
if (p === pageRef.current) return;
resetFeedView();
getHomeStoreState().patchScroll(cacheKeyRef.current, 0);
fetchPage(p);
}, [fetchPage, postTotal, pageSize, resetFeedView]);
void fetchPage(p, { resetScroll: true });
}, [fetchPage, postTotal, pageSize]);
const handleSelectPost = useCallback((id: number) => {
// 离开前再写一次滚动,确保详情页返回可还原
getHomeStoreState().patchScroll(cacheKeyRef.current, scrollTopRef.current);
getHomeStoreState().patchScroll(viewKeyRef.current, scrollTopRef.current);
openForumPost(nav, id, limits.open_posts_in_new_tab);
}, [nav, limits.open_posts_in_new_tab]);
@@ -278,14 +351,26 @@ export default function HomePage() {
const forceRefresh = (location.state as FeedNavState | null)?.refreshFeed;
// 浏览器后退/前进(POP)忽略 history 上残留的 refreshFeed,避免误清空缓存
if (forceRefresh && navType !== 'POP') {
fetchSeqRef.current += 1;
hydratedKeyRef.current = cacheKey;
beginFeedRefresh();
setPosts([]);
setPostTotal(0);
setPage(1);
pageRef.current = 1;
setLoading(true);
loadFirst();
// 预取已写入 store:整页替换,不卸成骨架
resetFeedView();
const warm = getHomeStoreState().getFeed(cacheKey);
if (warm && warm.posts.length > 0) {
commitDisplayed({
posts: warm.posts,
postTotal: warm.postTotal,
page: warm.page,
scrollTop: 0,
loading: false,
}, { cacheKey, sort, boardId, keyword, tag, author, titleOnly });
} else {
// 预取失败时保留旧列表并重拉
setListPending(postsRef.current.length > 0);
if (postsRef.current.length === 0) setLoading(true);
setView({ cacheKey, sort, boardId, keyword, tag, author, titleOnly });
loadFirst();
}
// 消费后清掉 state,防止该 history 条目永远带着刷新标记
nav(`${location.pathname}${location.search}${location.hash}`, { replace: true, state: null });
return;
@@ -300,26 +385,31 @@ export default function HomePage() {
if (cached && cached.posts.length > 0) {
const needRestore = hydratedKeyRef.current !== cacheKey;
hydratedKeyRef.current = cacheKey;
setPosts(cached.posts);
setPostTotal(cached.postTotal);
setPage(cached.page);
pageRef.current = cached.page;
setLoading(false);
// 仅在「首次进入该筛选」时恢复滚动,避免 limits/pageSize 变化导致 effect 重跑时打断用户滚动
if (needRestore) {
setRestoreScrollTop(cached.scrollTop);
scrollTopRef.current = cached.scrollTop;
}
// 超过 TTL:后台静默刷新,不重置滚动
if (getHomeStoreState().isStale(cacheKey)) {
void fetchPage(cached.page, { silent: true });
commitDisplayed({
posts: cached.posts,
postTotal: cached.postTotal,
page: cached.page,
scrollTop: cached.scrollTop,
loading: false,
}, { cacheKey, sort, boardId, keyword, tag, author, titleOnly });
} else {
setPosts(cached.posts);
setPostTotal(cached.postTotal);
setPage(cached.page);
pageRef.current = cached.page;
setLoading(false);
setListPending(false);
setView({ cacheKey, sort, boardId, keyword, tag, author, titleOnly });
}
return;
}
hydratedKeyRef.current = cacheKey;
setRestoreScrollTop(null);
scrollTopRef.current = 0;
if (postsRef.current.length === 0) {
setRestoreScrollTop(null);
scrollTopRef.current = 0;
}
loadFirst();
}, [
limitsLoading,
@@ -333,36 +423,68 @@ export default function HomePage() {
navType,
nav,
loadFirst,
fetchPage,
beginFeedRefresh,
commitDisplayed,
resetFeedView,
isInvalidBoardRoute,
isMissingBoard,
sort,
boardId,
keyword,
tag,
author,
titleOnly,
]);
useEffect(() => {
const onFeedReset = () => beginFeedRefresh();
// Logo 等同 URL 强制刷新:仅复位滚动,不卸列表(数据由 transitionTo 预热)
const onFeedReset = () => {
fetchSeqRef.current += 1;
resetFeedView();
};
window.addEventListener(FEED_RESET_EVENT, onFeedReset);
return () => window.removeEventListener(FEED_RESET_EVENT, onFeedReset);
}, [beginFeedRefresh]);
}, [resetFeedView]);
useEffect(() => {
// Logo / 下拉刷新 / 后台改帖:清空本地列表以露出骨架,再强制拉第 1 页
const fn = () => {
beginFeedRefresh();
setPosts([]);
setPostTotal(0);
setPage(1);
pageRef.current = 1;
// 下拉 / posts-refresh / 软刷新 commit:有预热则一次覆盖;禁止先卸列表
const applyWarmOrReload = () => {
fetchSeqRef.current += 1;
const key = cacheKeyRef.current;
const warm = getHomeStoreState().getFeed(key);
if (warm && warm.posts.length > 0) {
// 先写入新数据,再复位滚动(避免先 reset 造成空白闪一下)
commitDisplayed({
posts: warm.posts,
postTotal: warm.postTotal,
page: warm.page,
scrollTop: 0,
loading: false,
}, { cacheKey: key, sort, boardId, keyword, tag, author, titleOnly });
setListResetKey((k) => k + 1);
return;
}
// 未命中:保留旧 posts,静默重拉
if (postsRef.current.length > 0) {
setListPending(true);
setLoading(false);
void loadFirst();
return;
}
setLoading(true);
loadFirst();
void loadFirst();
};
window.addEventListener('posts-refresh', fn);
window.addEventListener(FEED_PULL_REFRESH_EVENT, fn);
window.addEventListener('posts-refresh', applyWarmOrReload);
window.addEventListener(FEED_PULL_REFRESH_EVENT, applyWarmOrReload);
window.addEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, applyWarmOrReload);
return () => {
window.removeEventListener('posts-refresh', fn);
window.removeEventListener(FEED_PULL_REFRESH_EVENT, fn);
window.removeEventListener('posts-refresh', applyWarmOrReload);
window.removeEventListener(FEED_PULL_REFRESH_EVENT, applyWarmOrReload);
window.removeEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, applyWarmOrReload);
};
}, [beginFeedRefresh, loadFirst]);
}, [
commitDisplayed, loadFirst,
sort, boardId, keyword, tag, author, titleOnly,
]);
// 卸载时取消未执行的 scroll rAF
useEffect(() => () => {
@@ -375,22 +497,23 @@ export default function HomePage() {
if (scrollRafRef.current) return;
scrollRafRef.current = requestAnimationFrame(() => {
scrollRafRef.current = 0;
getHomeStoreState().patchScroll(cacheKeyRef.current, scrollTopRef.current);
getHomeStoreState().patchScroll(viewKeyRef.current, scrollTopRef.current);
});
}, []);
const handleSortChange = (next: FeedSort) => {
if (next === sort) {
const tid = startTransition();
beginFeedRefresh();
loadFirst();
void Promise.resolve(loadFirst()).finally(() => doneTransition(tid));
return;
}
navigateFeed(nav, buildHomeUrl(boardId, next, { keyword, tag, author, titleOnly, permalink: limits }));
};
const showSortBar = !keyword && !tag && !author;
const showSortBar = !view.keyword && !view.tag && !view.author;
const searchFilters = parseSearchFromUrl(location.pathname, params);
const isSearchActive = !!(keyword || author);
const isSearchActive = !!(view.keyword || view.author);
if (isInvalidBoardRoute || isMissingBoard) {
return (
@@ -401,9 +524,9 @@ export default function HomePage() {
);
}
// 首屏用同构骨架,避免标题/列表分区先后出现造成闪动
// 冷启动由门闩 / ensureColdBootReady 挡住;有旧列表时软刷新绝不卸空
if ((loading || limitsLoading || (isBoardRoute && boardsLoading)) && posts.length === 0) {
return <FeedPageSkeleton />;
return null;
}
return (
@@ -412,14 +535,19 @@ export default function HomePage() {
<div className="feed-top">
<div className="feed-top__bar">
<FeedHeader
keyword={keyword}
tag={tag}
author={author}
keyword={view.keyword}
tag={view.tag}
author={view.author}
postTotal={postTotal}
titleAs={isSiteHome ? 'h2' : 'h1'}
/>
{showSortBar && (
<FeedSortBar value={sort} onChange={handleSortChange} postTotal={postTotal} />
<FeedSortBar
value={view.sort}
pendingValue={listPending ? sort : null}
onChange={handleSortChange}
postTotal={postTotal}
/>
)}
</div>
{isSearchActive && (
@@ -433,8 +561,8 @@ export default function HomePage() {
</div>
<VirtualPostList
posts={posts}
sort={sort}
loading={loading || limitsLoading}
sort={view.sort}
loading={listPending ? false : (loading || limitsLoading)}
hasMore={hasMore}
showPagination={showPagination}
page={page}
@@ -446,15 +574,15 @@ export default function HomePage() {
resetScrollKey={listResetKey}
onScrollTopChange={handleScrollTopChange}
onScrollRestored={() => setRestoreScrollTop(null)}
keyword={keyword || tag || author}
isSearchMode={!!(keyword || author)}
searchKeyword={keyword}
searchAuthor={author}
searchTitleOnly={titleOnly}
keyword={view.keyword || view.tag || view.author}
isSearchMode={!!(view.keyword || view.author)}
searchKeyword={view.keyword}
searchAuthor={view.author}
searchTitleOnly={view.titleOnly}
searchScopeBoardId={searchFilters.scopeBoardId}
onClearSearch={postSearch.clearSearch}
boardId={boardId}
boardName={ctx?.boards?.find(b => b.id === boardId)?.name || ''}
boardId={view.boardId}
boardName={ctx?.boards?.find(b => b.id === view.boardId)?.name || ''}
noBoards={!ctx?.boardsLoading && (ctx?.boards?.length ?? 0) === 0}
/>
</div>

View File

@@ -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<FriendLinkApply['status'], number> = {
pending: 0,
@@ -48,8 +49,6 @@ export default function LinksPage() {
const { user } = useAuth();
const [applyOpen, setApplyOpen] = useState(false);
const [editApply, setEditApply] = useState<FriendLinkApply | null>(null);
const [myApplies, setMyApplies] = useState<FriendLinkApply[]>([]);
const [myLoading, setMyLoading] = useState(false);
const [cancelingId, setCancelingId] = useState<number | null>(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<FriendLinkApply[]>(
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;

View File

@@ -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<ConvSnap>(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<NotifySnap>(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<ThreadSnap>(`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) {

View File

@@ -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<LayoutCtx>();
const [post, setPost] = useState<PostItem | null>(null);
const [poll, setPoll] = useState<PollView | null>(null);
const [lottery, setLottery] = useState<PostLotteryView | null>(null);
const [comments, setComments] = useState<Comment[]>([]);
const [liked, setLiked] = useState(false);
const [favorited, setFavorited] = useState(false);
const initialSnap = (postId && !Number.isNaN(postId))
? getSessionSnapshot<PostDetailSnapshot>(postDetailCacheKey(postId))
: undefined;
const [post, setPost] = useState<PostItem | null>(initialSnap?.post ?? null);
const [poll, setPoll] = useState<PollView | null>(initialSnap?.poll ?? null);
const [lottery, setLottery] = useState<PostLotteryView | null>(initialSnap?.lottery ?? null);
const [comments, setComments] = useState<Comment[]>(initialSnap?.comments ?? []);
const [liked, setLiked] = useState(initialSnap?.liked ?? false);
const [favorited, setFavorited] = useState(initialSnap?.favorited ?? false);
const [replyTo, setReplyTo] = useState<Comment | null>(null);
const [editingCommentId, setEditingCommentId] = useState<number | null>(null);
const [submitting, setSubmitting] = useState(false);
const [loading, setLoading] = useState(true);
const [loading, setLoading] = useState(!initialSnap);
const [highlightFloor, setHighlightFloor] = useState<number | null>(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<number | null>(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<number | null>(() => {
if (!initialSnap) return null;
if (navType === 'POP' && !location.hash) return initialSnap.scrollTop;
return 0;
});
const pageRef = useRef<HTMLDivElement>(null);
const commentSectionRef = useRef<HTMLDivElement>(null);
const commentBoxRef = useRef<HTMLDivElement>(null);
const highlightTimer = useRef<ReturnType<typeof setTimeout>>();
const postRef = useRef<PostItem | null>(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<PostDetailSnapshot>(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<PostDetailSnapshot>(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<PostDetailSnapshot>(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 <div className="post-detail-loading flex justify-center py-16"><Spinner size="lg" /></div>;
if (loading && !post) return <div className="post-detail-loading flex justify-center py-16"><Spinner size="lg" /></div>;
if (!post) {
return (
<NotFoundPage

View File

@@ -43,6 +43,7 @@ import { openForumPost } from '../utils/openPost';
import { formatDateTime } from '../utils/content';
import { InFlowSiteFooter } from '../components/SiteFooter';
import { userPath } from '../utils/userPath';
import { useSessionResource } from '../hooks/useSessionResource';
const nickSchema = z.object({
nickname: z.string().min(1, '请输入昵称').max(64),
@@ -75,7 +76,7 @@ export default function ProfilePage() {
const nav = useNavigate();
const [params, setParams] = useSearchParams();
const tab = parseTab(params.get('tab'));
const { user, loading: authLoading, refresh } = useAuth();
const { user, loading: authLoading, refresh, logout } = useAuth();
useNoIndexSEO('个人中心');
const [nickLoading, setNickLoading] = useState(false);
const [sigLoading, setSigLoading] = useState(false);
@@ -88,12 +89,6 @@ export default function ProfilePage() {
const [cropFileName, setCropFileName] = useState('');
const [dragOver, setDragOver] = useState(false);
const [idCopied, setIdCopied] = useState(false);
const [stats, setStats] = useState<UserActivityStats | null>(null);
const [statsLoading, setStatsLoading] = useState(true);
const [posts, setPosts] = useState<PostItem[]>([]);
const [postsLoading, setPostsLoading] = useState(false);
const [postPage, setPostPage] = useState(1);
const [postTotal, setPostTotal] = useState(0);
const fileRef = useRef<HTMLInputElement>(null);
const dragCounter = useRef(0);
const copyTimer = useRef<ReturnType<typeof setTimeout>>();
@@ -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<NickValues>({
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<UserActivityStats | null>(
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 : '修改失败');

View File

@@ -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<GiteaProject[]>([]);
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<ProjectsSnap>(
`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 (
<div className="page-wrap">

View File

@@ -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<CommunityShowcaseItem[]>([]);
const [loading, setLoading] = useState(true);
const { data: items = [], loading } = useSessionResource<CommunityShowcaseItem[]>(
'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 (
<div className="showcase-page">
<div className="page-wrap">
<div className="showcase-page">
<header className="showcase-head">
<div className="showcase-head-mark" aria-hidden>
<Globe2 size={22} />
@@ -82,6 +70,7 @@ export default function ShowcasePage() {
)}
<InFlowSiteFooter />
</div>
</div>
);
}

View File

@@ -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<SitePage | null>(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<SitePage | null>(
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 <NotFoundPage title="页面不存在" />;
if (loading) return <PageLoader />;
if (loading) return null;
if (notFound || !page) return <NotFoundPage title="页面不存在" description="该页面不存在或未发布" />;
return (

View File

@@ -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<UserPublic | null>(null);
const [stats, setStats] = useState<UserActivityStats | null>(null);
const [msgOpen, setMsgOpen] = useState(false);
const [loading, setLoading] = useState(true);
const [notFound, setNotFound] = useState(false);
const [posts, setPosts] = useState<PostItem[]>([]);
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<ProfileSnap>(
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<PostsSnap>(
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;

View File

@@ -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,

View File

@@ -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() {
<section className="admin-settings-section" id="settings-feed-list">
<div className="admin-settings-section-head">
<h3>列表呈现</h3>
<p>首页及帖子列表的信息密度与缩略图展示</p>
<p>首页及帖子列表的信息密度、缩略图与排序标签</p>
</div>
<div className="admin-settings-table" role="group" aria-label="列表呈现">
<div className="admin-settings-row">
@@ -946,6 +949,16 @@ export default function AdminSettingsPage() {
</span>
</div>
</div>
<div className="admin-settings-subsection">
<h4 className="admin-settings-subsection-title">首页排序标签</h4>
<p className="admin-settings-subsection-desc">
拖拽调整顺序;在「显示名称」框中改首页文案;右侧开关控制启停。第一个启用项为默认排序
</p>
<FeedSortTabList
tabs={normalizeFeedSortTabs(limits.feed_sort_tabs ?? DEFAULT_FEED_SORT_TABS)}
onChange={tabs => setLimits(prev => prev ? { ...prev, feed_sort_tabs: tabs } : prev)}
/>
</div>
</section>
<section className="admin-settings-section" id="settings-permalink">