fix: 软刷新齐套前保留旧画面,避免一点击就卸光
Logo/下拉静默预热后一次覆盖;commit 不再先 reset/loading;顺带会话预取与可配置 Feed 排序标签。
This commit is contained in:
@@ -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>;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 : '修改失败');
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user