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

@@ -17,6 +17,7 @@ import PageLoader from './components/PageLoader';
import AuthPageFallback from './components/AuthPageFallback';
import { Toaster } from './components/ui/sonner';
import PullToRefresh from './components/PullToRefresh';
import TopProgressBar from './components/TopProgressBar';
import { lazyWithRetry } from './utils/lazyWithRetry';
const HomePage = lazyWithRetry(() => import('./pages/HomePage'));
@@ -87,10 +88,10 @@ const router = createBrowserRouter(
<Route path="/favorites" element={<FavoritesPage />} />
<Route path="/projects" element={<ProjectsPage />} />
<Route path="/links" element={<LinksPage />} />
<Route path="/showcase" element={<Suspense fallback={<PageLoader />}><ShowcasePage /></Suspense>} />
<Route path="/showcase" element={<Suspense fallback={null}><ShowcasePage /></Suspense>} />
<Route path="/messages" element={<MessagesPage />} />
<Route path="/page/:slug" element={<Suspense fallback={<PageLoader />}><SitePageView /></Suspense>} />
<Route path="*" element={<Suspense fallback={<PageLoader />}><NotFoundPage /></Suspense>} />
<Route path="/page/:slug" element={<Suspense fallback={null}><SitePageView /></Suspense>} />
<Route path="*" element={<Suspense fallback={null}><NotFoundPage /></Suspense>} />
</Route>
<Route path="*" element={<Suspense fallback={<PageLoader fullScreen />}><NotFoundPage standalone /></Suspense>} />
</Route>,
@@ -103,6 +104,7 @@ export default function App() {
<AuthProvider>
<ErrorBoundary>
<RouterProvider router={router} />
<TopProgressBar />
<PullToRefresh />
<Toaster />
</ErrorBoundary>

View File

@@ -215,6 +215,20 @@ export const DEFAULT_ASIDE_WIDGETS: AsideWidget[] = [
{ id: 'showcase', enabled: false },
];
export type FeedSortId = 'reply' | 'latest' | 'hot';
export interface FeedSortTab {
id: FeedSortId;
label: string;
enabled: boolean;
}
export const DEFAULT_FEED_SORT_TABS: FeedSortTab[] = [
{ id: 'reply', label: '新评论', enabled: true },
{ id: 'latest', label: '新帖子', enabled: true },
{ id: 'hot', label: '推荐帖', enabled: true },
];
export interface ForumLimits {
post_edit_window_hours: number;
comment_edit_window_minutes: number;
@@ -255,6 +269,8 @@ export interface ForumLimits {
footer_show_showcase: boolean;
/** 首页列表样式title 仅标题 / thumbnail 缩略图 */
feed_list_style: 'title' | 'excerpt' | 'thumbnail';
/** 首页排序标签:名称、顺序、启停 */
feed_sort_tabs: FeedSortTab[];
/** 伪静态(固定链接)开关 */
permalink_enabled: boolean;
/** 伪静态后缀,不含点,如 html / htm */
@@ -285,6 +301,7 @@ export interface ForumLimitsPublic {
nav_show_showcase: boolean;
footer_show_showcase: boolean;
feed_list_style: 'title' | 'excerpt' | 'thumbnail';
feed_sort_tabs: FeedSortTab[];
permalink_enabled: boolean;
permalink_ext: string;
/** 是否上报前台路由 pageview与后台监控采集开关同步 */

View File

@@ -1,6 +1,5 @@
import { CalendarCheck, Check, Gift, Loader2 } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { Skeleton } from '@/components/ui/skeleton';
import { useCheckIn } from '../hooks/useCheckIn';
import { useAuth } from '../hooks/useAuth';
import { loginPath } from '../utils/authRedirect';
@@ -8,8 +7,13 @@ import { loginPath } from '../utils/authRedirect';
/** 右侧栏首块底部:每日签到 */
export default function AsideCheckInStrip() {
const nav = useNavigate();
const { user } = useAuth();
const { status, loading, busy, doCheckIn } = useCheckIn();
const { user, loading: authLoading } = useAuth();
const { status, loading, busy, doCheckIn } = useCheckIn(!!user && !authLoading);
// 鉴权未完成:空白,避免「登录签到」→「今日已签到」闪一下
if (authLoading) {
return null;
}
if (!user) {
return (
@@ -37,17 +41,14 @@ export default function AsideCheckInStrip() {
);
}
if (loading && !status) {
return (
<div className="widget-checkin" aria-busy="true" aria-label="签到加载中">
<Skeleton className="widget-checkin-skeleton" />
</div>
);
// 登录用户status 未到齐前不渲染最终态
if (loading || !status) {
return null;
}
const checkedIn = !!status?.checked_in;
const streak = status?.streak ?? 0;
const todayPoints = status?.today_points ?? 5;
const checkedIn = !!status.checked_in;
const streak = status.streak ?? 0;
const todayPoints = status.today_points ?? 5;
const meta = checkedIn
? (streak > 0 ? `连续 ${streak} 天 · 今日已获得 ${todayPoints} 积分` : `今日已获得 ${todayPoints} 积分`)

View File

@@ -1,29 +0,0 @@
import { Skeleton } from '@/components/ui/skeleton';
import PostListSkeleton from './PostListSkeleton';
import { useForumLimits } from '../hooks/useForumLimits';
/** 首页 Feed 初始骨架(排序栏 + 列表) */
export default function FeedPageSkeleton() {
const { limits } = useForumLimits();
return (
<div className="page-wrap page-wrap--feed" aria-busy="true" aria-label="内容加载中">
<div className="feed-panel">
<div className="feed-top">
<div className="feed-top__bar">
<div className="feed-toolbar feed-toolbar--skeleton" aria-hidden>
<Skeleton className="skeleton--sort-tab" />
<Skeleton className="skeleton--sort-tab" />
<Skeleton className="skeleton--sort-tab" />
<span className="feed-toolbar__spacer" />
<Skeleton className="skeleton--count" />
</div>
</div>
</div>
<div className="post-list-scroll">
<PostListSkeleton listStyle={limits.feed_list_style ?? 'title'} />
</div>
</div>
</div>
);
}

View File

@@ -1,40 +1,55 @@
import { useRef } from 'react';
import { useMemo, useRef } from 'react';
import { boardPath, type PermalinkOpts } from '../utils/permalink';
import { getCachedForumLimits } from '../hooks/useForumLimits';
import { Clock, MessageCircle, BadgeCheck } from 'lucide-react';
import { getCachedForumLimits, useForumLimits } from '../hooks/useForumLimits';
import { Clock, MessageCircle, BadgeCheck, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { moveTabIndex } from '../hooks/useOverlayA11y';
import {
enabledFeedSortTabs,
getDefaultFeedSort,
normalizeFeedSortTabs,
} from '../utils/feedSortTabs';
import type { FeedSortTab } from '../api/types';
export type FeedSort = 'latest' | 'reply' | 'hot';
const SORT_OPTIONS: {
key: FeedSort;
label: string;
hint: string;
icon: typeof Clock;
}[] = [
{ key: 'reply', label: '新评论', hint: '最近有人评论', icon: MessageCircle },
{ key: 'latest', label: '新帖子', hint: '按发帖时间', icon: Clock },
{ key: 'hot', label: '推荐帖', hint: '站内推荐', icon: BadgeCheck },
];
const SORT_META: Record<FeedSort, { hint: string; icon: typeof Clock }> = {
reply: { hint: '最近有人评论', icon: MessageCircle },
latest: { hint: '按发帖时间', icon: Clock },
hot: { hint: '站内推荐', icon: BadgeCheck },
};
interface Props {
value: FeedSort;
onChange: (sort: FeedSort) => void;
postTotal?: number;
/** 正在加载、尚未提交到画面的目标排序 */
pendingValue?: FeedSort | null;
}
/** 解析 URL sort缺省为新评论reply */
export function parseFeedSort(raw: string | null): FeedSort {
if (raw === 'latest' || raw === 'hot') return raw;
return 'reply';
/** 当前配置下的默认排序(读缓存,供非 hook 路径 */
export function getDefaultFeedSortFromCache(): FeedSort {
return getDefaultFeedSort(getCachedForumLimits().feed_sort_tabs);
}
/** 解析 URL sort未启用或不合法时回落默认 */
export function parseFeedSort(raw: string | null, tabs?: FeedSortTab[] | null): FeedSort {
const list = tabs ?? getCachedForumLimits().feed_sort_tabs;
const def = getDefaultFeedSort(list);
if (raw === 'latest' || raw === 'hot' || raw === 'reply') {
const enabled = enabledFeedSortTabs(list).some(t => t.id === raw);
if (enabled) return raw;
}
return def;
}
export function buildHomeUrl(
boardId: number,
sort: FeedSort = 'reply',
sort?: FeedSort,
opts?: { keyword?: string; tag?: string; author?: string; titleOnly?: boolean; permalink?: PermalinkOpts },
) {
const def = getDefaultFeedSortFromCache();
const effective = sort ?? def;
const p = new URLSearchParams();
const tag = opts?.tag?.trim();
const keyword = opts?.keyword?.trim();
@@ -48,8 +63,8 @@ export function buildHomeUrl(
} else if (author) {
p.set('author', author);
}
// 默认 reply 不写进 URLlatest / hot 显式带上
if (sort !== 'reply') p.set('sort', sort);
// 默认排序不写进 URL
if (effective !== def) p.set('sort', effective);
const qs = p.toString();
if (boardId) {
@@ -59,25 +74,39 @@ export function buildHomeUrl(
return qs ? `/?${qs}` : '/';
}
export function feedSortLabel(sort: FeedSort): string {
return SORT_OPTIONS.find(o => o.key === sort)?.label ?? '帖子列表';
export function feedSortLabel(sort: FeedSort, tabs?: FeedSortTab[] | null): string {
const list = normalizeFeedSortTabs(tabs ?? getCachedForumLimits().feed_sort_tabs);
return list.find(t => t.id === sort)?.label ?? '帖子列表';
}
export default function FeedSortBar({ value, onChange, postTotal }: Props) {
export default function FeedSortBar({ value, onChange, postTotal, pendingValue }: Props) {
const { limits } = useForumLimits();
const options = useMemo(
() => enabledFeedSortTabs(limits.feed_sort_tabs).map(t => ({
key: t.id as FeedSort,
label: t.label,
hint: SORT_META[t.id]?.hint ?? '',
icon: SORT_META[t.id]?.icon ?? Clock,
})),
[limits.feed_sort_tabs],
);
const listRef = useRef<HTMLDivElement>(null);
const activeIndex = Math.max(0, SORT_OPTIONS.findIndex(o => o.key === value));
const activeIndex = Math.max(0, options.findIndex(o => o.key === value));
const onKeyDown = (e: React.KeyboardEvent) => {
const next = moveTabIndex(e.key, activeIndex, SORT_OPTIONS.length);
const next = moveTabIndex(e.key, activeIndex, options.length);
if (next == null) return;
e.preventDefault();
onChange(SORT_OPTIONS[next].key);
onChange(options[next].key);
requestAnimationFrame(() => {
const tabs = listRef.current?.querySelectorAll<HTMLElement>('[role="tab"]');
tabs?.[next]?.focus();
});
};
if (options.length === 0) return null;
return (
<div className="feed-toolbar">
<div
@@ -87,21 +116,27 @@ export default function FeedSortBar({ value, onChange, postTotal }: Props) {
aria-label="帖子排序"
onKeyDown={onKeyDown}
>
{SORT_OPTIONS.map(({ key, label, hint, icon: Icon }, i) => (
{options.map(({ key, label, hint, icon: Icon }, i) => {
const pending = pendingValue === key;
return (
<button
key={key}
type="button"
role="tab"
tabIndex={activeIndex === i ? 0 : -1}
aria-selected={value === key}
aria-busy={pending || undefined}
title={`${label} · ${hint}`}
className={cn('feed-sort-tab', value === key && 'active')}
className={cn('feed-sort-tab', value === key && 'active', pending && 'is-pending')}
onClick={() => onChange(key)}
>
<Icon aria-hidden />
{pending
? <Loader2 className="feed-sort-tab__spin" aria-hidden />
: <Icon aria-hidden />}
<span className="feed-sort-tab__label">{label}</span>
</button>
))}
);
})}
</div>
{postTotal != null && (
<span className="feed-toolbar__count"> {postTotal} </span>

View File

@@ -6,7 +6,7 @@ type PageLoaderProps = {
fullScreen?: boolean;
};
/** 通用路由懒加载占位;首页请用 FeedPageSkeleton避免非 Feed 页闪出鱼骨骨架 */
/** 通用路由懒加载占位(后台 / 全屏独立页);前台 MainLayout 用顶栏进度条 + 空白 */
export default function PageLoader({ fullScreen = false }: PageLoaderProps) {
return (
<div

View File

@@ -61,6 +61,14 @@ function PostListItem({ post, sort = 'latest', boardId = 0, onSelect }: Props) {
openPost();
};
const hasTypeBadge = post.post_type === 'question'
|| post.post_type === 'poll'
|| post.post_type === 'lottery'
|| (post.post_type === 'bounty' && (
(post.bounty_status === 'open' && (post.bounty_points ?? 0) > 0)
|| post.bounty_status === 'awarded'
));
const titleRow = (
<div className="post-title-row">
{post.pinned && (
@@ -81,6 +89,12 @@ function PostListItem({ post, sort = 'latest', boardId = 0, onSelect }: Props) {
{post.status === 'rejected' && (
<span className="post-status-badge post-status-badge--rejected" title="未通过"></span>
)}
<a href={href} className="post-title" onClick={onTitleClick}>
{post.title}
</a>
{/* 类型徽章放标题后flex 自动扣宽,长标题省略号紧挨徽章左侧 */}
{hasTypeBadge && (
<span className="post-title-type-badges">
{post.post_type === 'question' && (
<span
className={`post-qa-badge${post.question_resolved ? ' post-qa-badge--resolved' : ' post-qa-badge--open'}`}
@@ -103,9 +117,8 @@ function PostListItem({ post, sort = 'latest', boardId = 0, onSelect }: Props) {
{post.lottery_status === 'drawn' ? '已开奖' : '抽奖'}
</span>
)}
<a href={href} className="post-title" onClick={onTitleClick}>
{post.title}
</a>
</span>
)}
</div>
);

View File

@@ -1,4 +1,3 @@
import { Skeleton } from '@/components/ui/skeleton';
import type { ForumLimitsPublic } from '../api/types';
export type FeedListStyle = ForumLimitsPublic['feed_list_style'];
@@ -11,69 +10,3 @@ export function feedListRowEstimate(style: FeedListStyle): number {
default: return 64;
}
}
interface Props {
count?: number;
listStyle?: FeedListStyle;
}
/** 帖子列表加载骨架屏(对齐 v2 紧凑列表) */
export default function PostListSkeleton({ count = 8, listStyle = 'title' }: Props) {
const showExcerpt = listStyle === 'excerpt' || listStyle === 'thumbnail';
const showThumb = listStyle === 'thumbnail';
const titleOnly = listStyle === 'title';
return (
<div className="post-list-skeleton" aria-busy="true" aria-label="加载中">
{Array.from({ length: count }, (_, i) => {
const hasThumb = showThumb && i % 3 === 0;
return (
<div
key={i}
className={`post-row post-row--v2 post-row--skeleton${titleOnly ? ' post-row--title-only' : ''}${hasThumb ? ' post-row--has-thumb' : ''}`}
>
<Skeleton className="skeleton--avatar skeleton--avatar-v2" />
{hasThumb ? (
<div className="post-main post-main--with-thumb">
<div className="post-content">
<Skeleton className="skeleton--title skeleton--title-v2" style={{ width: `${58 + (i % 4) * 9}%` }} />
{showExcerpt && (
<Skeleton className="skeleton--excerpt skeleton--excerpt-v2" style={{ width: `${72 + (i % 3) * 8}%` }} />
)}
<div className="post-meta-left post-meta-left--skeleton">
<Skeleton className="skeleton--board-badge" />
<Skeleton className="skeleton--meta-line" style={{ width: `${45 + (i % 3) * 10}%` }} />
</div>
</div>
<div className="post-aside">
<Skeleton className="skeleton--thumb skeleton--thumb-tall" />
<div className="post-stats">
<Skeleton className="skeleton--stat" />
</div>
</div>
</div>
) : (
<div className="post-main">
<div className="post-text">
<Skeleton className="skeleton--title skeleton--title-v2" style={{ width: `${58 + (i % 4) * 9}%` }} />
{showExcerpt && (
<Skeleton className="skeleton--excerpt skeleton--excerpt-v2" style={{ width: `${72 + (i % 3) * 8}%` }} />
)}
</div>
<div className="post-meta">
<div className="post-meta-left post-meta-left--skeleton">
<Skeleton className="skeleton--board-badge" />
<Skeleton className="skeleton--meta-line" style={{ width: `${45 + (i % 3) * 10}%` }} />
</div>
<div className="post-stats">
<Skeleton className="skeleton--stat" />
</div>
</div>
</div>
)}
</div>
);
})}
</div>
);
}

View File

@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { Loader2, ArrowDown } from 'lucide-react';
import { FEED_PULL_REFRESH_EVENT } from '../utils/feedCache';
import { softRefreshCurrentPage } from '../utils/softRefresh';
/** 触发刷新的下拉距离px */
const REFRESH_THRESHOLD = 68;
@@ -31,6 +31,9 @@ function pickScrollEl(): HTMLElement | null {
const page = document.querySelector<HTMLElement>('.page-wrap:not(.page-wrap--feed)');
if (page) return page;
const showcase = document.querySelector<HTMLElement>('.showcase-page');
if (showcase) return showcase.closest<HTMLElement>('.main-content') ?? showcase;
const admin = document.querySelector<HTMLElement>('.admin-main');
if (admin) return admin;
@@ -77,7 +80,7 @@ function isNestedScrollBlocking(target: EventTarget | null, bound: HTMLElement):
}
/**
* 手机端下拉刷新:在内部滚动容器顶部下拉后整页重载
* 手机端下拉刷新:在内部滚动容器顶部下拉后强制刷新当前页
* (浏览器原生 PTR 依赖 document 滚动,与本站 app-shell 布局不兼容。)
*
* 挂在 Router 外,故用 MutationObserver 在路由切换后重绑滚动容器。
@@ -168,19 +171,22 @@ export default function PullToRefresh() {
setSettling(true);
setPull(REFRESH_THRESHOLD * 0.7);
window.setTimeout(() => {
// Feed 页:应用内强制重拉(保留 SPA 其它状态);其它页仍整页 reload
const isFeed = !!(
document.querySelector('.main-content--feed-mobile-scroll')
|| document.querySelector('.page-wrap--feed .post-list-scroll')
// 后台 / 登录页:整页 reload前台静默软刷新无进度条齐套后一次覆盖
const isAdminOrAuth = !!(
document.querySelector('.admin-main')
|| document.querySelector('.auth-page')
);
if (isFeed) {
window.dispatchEvent(new Event(FEED_PULL_REFRESH_EVENT));
if (isAdminOrAuth) {
window.location.reload();
return;
}
void softRefreshCurrentPage()
.catch(() => undefined)
.finally(() => {
setRefreshing(false);
setSettling(true);
setPull(0);
return;
}
window.location.reload();
});
}, 180);
return;
}

View File

@@ -1,7 +1,6 @@
import { useMemo } from 'react';
import { ListTree, MessageCircle, Tags, Link2, UserPlus } from 'lucide-react';
import { useLocation, useSearchParams, useNavigate } from 'react-router-dom';
import { Skeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button';
import type { AsideWidget, RecentComment, RecentUser, TagCount, User, ForumStats, FriendLink } from '../api/types';
import type { PostHeading } from '../utils/postHeadings';
@@ -31,7 +30,7 @@ interface Props {
tagsLoading?: boolean;
stats?: ForumStats | null;
onPostClick: (id: number, opts?: { floor?: number }) => void;
/** 首次拉取中,显示骨架避免空态闪烁 */
/** 首次拉取中:不渲染该块内容(由冷启动门闩保证首屏已齐或空白) */
loading?: boolean;
/** 右侧栏可选组件顺序与开关 */
asideWidgets: AsideWidget[];
@@ -39,35 +38,6 @@ interface Props {
postDetail?: PostDetailAside | null;
}
function CommentSkeleton() {
return (
<div className="widget-skeleton" aria-busy="true" aria-label="评论加载中">
{Array.from({ length: 5 }, (_, i) => (
<div key={i} className="widget-item widget-item--comment widget-item--skeleton">
<Skeleton className="skeleton--widget-avatar" />
<div className="widget-item-comment-main">
<Skeleton className="skeleton--widget-title" style={{ width: `${72 + (i % 3) * 8}%` }} />
<Skeleton className="skeleton--widget-meta" />
</div>
</div>
))}
</div>
);
}
function UserSkeleton() {
return (
<div className="widget-recent-users-grid" aria-busy="true" aria-label="用户加载中">
{Array.from({ length: 8 }, (_, i) => (
<div key={i} className="widget-recent-user-cell widget-recent-user-cell--skeleton">
<Skeleton className="skeleton--recent-user-avatar" />
<Skeleton className="skeleton--recent-user-name" style={{ width: `${48 + (i % 3) * 10}%` }} />
</div>
))}
</div>
);
}
export default function RightPanel({
recentComments,
recentUsers,
@@ -181,9 +151,7 @@ export default function RightPanel({
</div>
<div className="widget-card-body">
{loading && commentList.length === 0 ? (
<CommentSkeleton />
) : commentList.length === 0 ? (
{loading && commentList.length === 0 ? null : commentList.length === 0 ? (
<div className="widget-empty"></div>
) : commentList.map(item => (
<div
@@ -241,9 +209,7 @@ export default function RightPanel({
</div>
<div className="widget-card-body widget-card-body--users">
{loading && userList.length === 0 ? (
<UserSkeleton />
) : userList.length === 0 ? (
{loading && userList.length === 0 ? null : userList.length === 0 ? (
<div className="widget-empty"></div>
) : (
<div className="widget-recent-users-grid">

View File

@@ -3,21 +3,40 @@ import { Globe2 } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { api } from '../api/client';
import type { CommunityShowcaseItem } from '../api/types';
import { getSessionSnapshot, setSessionSnapshot } from '../utils/sessionPageCache';
import { PAGE_FORCE_REFRESH_EVENT } from '../utils/feedCache';
import { PAGE_SOFT_REFRESH_COMMIT_EVENT } from '../utils/softRefresh';
const SHOWCASE_KEY = 'showcase';
/** 右侧栏:开源展柜精简列表 */
export default function ShowcaseAsideWidget() {
const nav = useNavigate();
const [items, setItems] = useState<CommunityShowcaseItem[]>([]);
const [loading, setLoading] = useState(true);
const cached = getSessionSnapshot<CommunityShowcaseItem[]>(SHOWCASE_KEY);
const [items, setItems] = useState<CommunityShowcaseItem[]>(() => cached ?? []);
const [loading, setLoading] = useState(() => cached === undefined);
useEffect(() => {
let cancelled = false;
const hit = getSessionSnapshot<CommunityShowcaseItem[]>(SHOWCASE_KEY);
if (hit !== undefined) {
setItems(hit);
setLoading(false);
return;
}
setLoading(true);
api.communityShowcase()
.then((r) => {
if (!cancelled) setItems(Array.isArray(r.items) ? r.items : []);
if (cancelled) return;
const next = Array.isArray(r.items) ? r.items : [];
setSessionSnapshot(SHOWCASE_KEY, next);
setItems(next);
})
.catch(() => {
if (!cancelled) setItems([]);
if (!cancelled) {
setSessionSnapshot(SHOWCASE_KEY, []);
setItems([]);
}
})
.finally(() => {
if (!cancelled) setLoading(false);
@@ -25,6 +44,43 @@ export default function ShowcaseAsideWidget() {
return () => { cancelled = true; };
}, []);
useEffect(() => {
const fetchShowcase = (opts: { showLoading: boolean }) => {
if (opts.showLoading) setLoading(true);
api.communityShowcase()
.then((r) => {
const next = Array.isArray(r.items) ? r.items : [];
setSessionSnapshot(SHOWCASE_KEY, next);
setItems(next);
})
.catch(() => {
if (opts.showLoading) {
setSessionSnapshot(SHOWCASE_KEY, []);
setItems([]);
}
// 软刷新失败:保持旧 UI
})
.finally(() => setLoading(false));
};
const applyHitOrReload = (showLoading: boolean) => {
const hit = getSessionSnapshot<CommunityShowcaseItem[]>(SHOWCASE_KEY);
if (hit !== undefined) {
setItems(hit);
setLoading(false);
return;
}
fetchShowcase({ showLoading });
};
const onForce = () => applyHitOrReload(true);
const onCommit = () => applyHitOrReload(false);
window.addEventListener(PAGE_FORCE_REFRESH_EVENT, onForce);
window.addEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, onCommit);
return () => {
window.removeEventListener(PAGE_FORCE_REFRESH_EVENT, onForce);
window.removeEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, onCommit);
};
}, []);
return (
<div className="widget-card widget-card--showcase">
<div className="widget-card-head widget-card-head--split">

View File

@@ -6,9 +6,9 @@ import type { Board } from '../api/types';
import type { PostHeading } from '../utils/postHeadings';
import { useAuth } from '../hooks/useAuth';
import { cn } from '@/lib/utils';
import { Skeleton } from '@/components/ui/skeleton';
import { buildHomeUrl, parseFeedSort } from './FeedSortBar';
import { navigateFeed } from '../utils/feedCache';
import { transitionTo } from '../utils/spaTransition';
import BoardIconDisplay from './BoardIconDisplay';
import { getBoardThemeIndex } from '../utils/boardTheme';
import ArticleOutline from './ArticleOutline';
@@ -62,11 +62,11 @@ export default function Sidebar({
const nav = useNavigate();
const loc = useLocation();
const [params] = useSearchParams();
const sort = parseFeedSort(params.get('sort'));
const { user } = useAuth();
const isAdmin = user?.role === 'admin';
const { navPages } = useSitePages();
const { limits } = useForumLimits();
const sort = parseFeedSort(params.get('sort'), limits.feed_sort_tabs);
const showFriendLinksNav = limits.nav_show_friend_links !== false;
const showShowcaseNav = !!limits.nav_show_showcase;
const showSiteSection = navPages.length > 0 || showFriendLinksNav || showShowcaseNav;
@@ -137,22 +137,12 @@ export default function Sidebar({
<div className="sidebar-section"></div>
<nav className="sidebar-nav">
{feedNavLink('all', buildHomeUrl(0, sort, permalinkOpts), '全部帖子', <Home aria-hidden />, 0)}
{user && navItem('favorites', '我的收藏', <Star aria-hidden />, () => nav('/favorites'))}
{navItem('projects', '开源码桶', <FolderGit2 aria-hidden />, () => nav('/projects'))}
{user && navItem('favorites', '我的收藏', <Star aria-hidden />, () => void transitionTo(nav, '/favorites'))}
{navItem('projects', '开源码桶', <FolderGit2 aria-hidden />, () => void transitionTo(nav, '/projects'))}
</nav>
{(boardsLoading && boards.length === 0) ? (
<>
<div className="sidebar-section sidebar-section--boards"></div>
<nav className="sidebar-nav sidebar-nav--skeleton" aria-busy="true" aria-label="板块加载中">
{Array.from({ length: 4 }, (_, i) => (
<div key={i} className="sidebar-nav-item sidebar-nav-item--skeleton">
<Skeleton className="skeleton--sidebar-icon" />
<Skeleton className="skeleton--sidebar-label" style={{ width: `${58 + (i % 3) * 12}%` }} />
</div>
))}
</nav>
</>
) : boards.length > 0 ? (
<>
<div className="sidebar-section sidebar-section--boards"></div>
@@ -198,10 +188,10 @@ export default function Sidebar({
<>
<div className="sidebar-section sidebar-section--spaced"></div>
<nav className="sidebar-nav">
{showFriendLinksNav && navItem('links', '友情链接', <Link2 aria-hidden />, () => nav('/links'))}
{showShowcaseNav && navItem('showcase', '开源展柜', <Globe2 aria-hidden />, () => nav('/showcase'))}
{showFriendLinksNav && navItem('links', '友情链接', <Link2 aria-hidden />, () => void transitionTo(nav, '/links'))}
{showShowcaseNav && navItem('showcase', '开源展柜', <Globe2 aria-hidden />, () => void transitionTo(nav, '/showcase'))}
{navPages.map(p => (
navItem(`page-${p.slug}`, p.title, <FileText aria-hidden />, () => nav(pagePath(p.slug, limits)))
navItem(`page-${p.slug}`, p.title, <FileText aria-hidden />, () => void transitionTo(nav, pagePath(p.slug, limits)))
))}
</nav>
</>

View File

@@ -1,7 +1,6 @@
import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import type { TagCount } from '../api/types';
import { Skeleton } from '@/components/ui/skeleton';
import { cn } from '@/lib/utils';
interface Props {
@@ -63,20 +62,7 @@ export default function TagCloud({ tags, loading = false, activeTag = '' }: Prop
}, [tags]);
if (loading && tags.length === 0) {
return (
<div className="tag-cloud tag-cloud--skeleton" aria-busy="true" aria-label="标签加载中">
{Array.from({ length: 10 }, (_, i) => (
<Skeleton
key={i}
className="skeleton--tag-cloud"
style={{
width: `${42 + (i % 5) * 16}px`,
height: `${20 + (i % 3) * 4}px`,
}}
/>
))}
</div>
);
return null;
}
if (items.length === 0) {

View File

@@ -0,0 +1,75 @@
import { useEffect, useRef, useState } from 'react';
import { subscribeSpaTransition } from '../utils/spaTransition';
/**
* 视口顶部 2px 细进度条(类 Next.js / YouTube
* 由 spaTransition start/done 驱动,假进度爬升至约 80%,结束冲到 100%。
*/
export default function TopProgressBar() {
const [visible, setVisible] = useState(false);
const [width, setWidth] = useState(0);
const [fading, setFading] = useState(false);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const fadeRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const activeRef = useRef(false);
useEffect(() => {
const clearTick = () => {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
};
const clearFade = () => {
if (fadeRef.current) {
clearTimeout(fadeRef.current);
fadeRef.current = null;
}
};
return subscribeSpaTransition((active) => {
if (active) {
activeRef.current = true;
clearFade();
setFading(false);
setVisible(true);
setWidth(12);
clearTick();
timerRef.current = setInterval(() => {
setWidth((w) => {
if (w >= 80) return w;
// 越接近 80 越慢
const step = Math.max(0.4, (80 - w) * 0.045);
return Math.min(80, w + step);
});
}, 120);
return;
}
if (!activeRef.current) return;
activeRef.current = false;
clearTick();
setWidth(100);
setFading(true);
clearFade();
fadeRef.current = setTimeout(() => {
setVisible(false);
setFading(false);
setWidth(0);
}, 220);
});
}, []);
if (!visible && width <= 0) return null;
return (
<div
className={[
'top-progress',
fading ? 'top-progress--done' : '',
].filter(Boolean).join(' ')}
style={{ width: `${width}%` }}
aria-hidden
/>
);
}

View File

@@ -4,7 +4,7 @@ import { useVirtualizer } from '@tanstack/react-virtual';
import { Inbox, SearchX } from 'lucide-react';
import { Button } from '@/components/ui/button';
import PostListItem from './PostListItem';
import PostListSkeleton, { feedListRowEstimate } from './PostListSkeleton';
import { feedListRowEstimate } from './PostListSkeleton';
import FeedPagination from './FeedPagination';
import { InFlowSiteFooter } from './SiteFooter';
import { useAuth } from '../hooks/useAuth';
@@ -14,6 +14,7 @@ import { loginPath } from '../utils/authRedirect';
import { dispatchOpenPostSearch } from '../hooks/usePostSearch';
import type { PostItem } from '../api/types';
import type { FeedSort } from './FeedSortBar';
import { getDefaultFeedSortFromCache } from './FeedSortBar';
interface Props {
posts: PostItem[];
@@ -56,7 +57,7 @@ function getMobileFeedScrollEl(): HTMLElement | null {
export default function VirtualPostList({
posts,
sort = 'reply',
sort = getDefaultFeedSortFromCache(),
loading,
hasMore,
showPagination,
@@ -242,9 +243,7 @@ export default function VirtualPostList({
return (
<div className="post-list-scroll" ref={parentRef}>
{isInitialLoad ? (
<PostListSkeleton listStyle={feedStyle} />
) : isEmpty ? (
{isInitialLoad ? null : isEmpty ? (
<div className="empty-feed" role="status">
{isSearchEmpty
? <SearchX className="empty-feed-icon" aria-hidden size={36} strokeWidth={1.5} />

View File

@@ -0,0 +1,89 @@
import { Pencil } from 'lucide-react';
import { Input } from '@/components/ui/input';
import AdminSortableList, { SortableDragHandle } from './AdminSortableList';
import type { FeedSortId, FeedSortTab } from '../../api/types';
import { normalizeFeedSortTabs } from '../../utils/feedSortTabs';
const TAB_META: Record<FeedSortId, { hint: string; placeholder: string }> = {
reply: { hint: '按最后评论时间', placeholder: '新评论' },
latest: { hint: '按发帖时间', placeholder: '新帖子' },
hot: { hint: '推荐优先,再按互动', placeholder: '推荐帖' },
};
type Props = {
tabs: FeedSortTab[];
onChange: (next: FeedSortTab[]) => void;
};
export default function FeedSortTabList({ tabs, onChange }: Props) {
const items = normalizeFeedSortTabs(tabs);
const handleToggle = (id: FeedSortId, enabled: boolean) => {
const next = items.map(t => (t.id === id ? { ...t, enabled } : t));
// 不允许全部关闭:关掉最后一项时忽略
if (!enabled && !next.some(t => t.enabled)) return;
onChange(normalizeFeedSortTabs(next));
};
const handleLabel = (id: FeedSortId, label: string) => {
onChange(normalizeFeedSortTabs(items.map(t => (t.id === id ? { ...t, label } : t))));
};
return (
<AdminSortableList
items={items}
getId={tab => tab.id}
onReorder={next => onChange(normalizeFeedSortTabs(next))}
showMoveButtons={false}
className="admin-sortable-list admin-sortable-list--boxed"
ariaLabel="首页排序标签"
renderItem={(tab, _index, controls) => {
const meta = TAB_META[tab.id];
return (
<div
ref={controls.setNodeRef}
style={controls.style}
className={`admin-sortable-row admin-sortable-row--widget${controls.isDragging ? ' is-dragging' : ''}`}
>
<SortableDragHandle
label={`拖拽调整「${tab.label || meta.placeholder}」顺序`}
{...controls.dragHandleProps}
/>
<div className="admin-sortable-row__main">
<label className="admin-sortable-row__field" htmlFor={`feed-sort-label-${tab.id}`}>
<span className="admin-sortable-row__field-name"></span>
<span className="admin-sortable-row__name-wrap">
<Input
id={`feed-sort-label-${tab.id}`}
type="text"
className="admin-sortable-row__name-input"
value={tab.label}
maxLength={16}
placeholder={meta.placeholder}
onChange={e => handleLabel(tab.id, e.target.value)}
aria-describedby={`feed-sort-hint-${tab.id}`}
/>
<Pencil className="admin-sortable-row__name-icon" size={14} aria-hidden />
</span>
</label>
<span className="admin-sortable-row__hint" id={`feed-sort-hint-${tab.id}`}>
{meta.hint}
</span>
</div>
<button
type="button"
id={`feed-sort-${tab.id}`}
role="switch"
aria-checked={tab.enabled}
aria-label={`启用「${tab.label || meta.placeholder}`}
className={`admin-settings-switch${tab.enabled ? ' is-on' : ''}`}
onClick={() => handleToggle(tab.id, !tab.enabled)}
>
<span className="admin-settings-switch-ui" aria-hidden />
</button>
</div>
);
}}
/>
);
}

View File

@@ -1,6 +1,8 @@
import { createContext, useContext, useEffect, useState, useCallback, ReactNode } from 'react';
import { createContext, useContext, useEffect, useState, useCallback, useRef, ReactNode } from 'react';
import { api } from '../api/client';
import type { User } from '../api/types';
import { clearAllFeedCache } from '../utils/feedCache';
import { clearSessionSnapshots } from '../utils/sessionPageCache';
interface AuthCtx {
user: User | null;
@@ -32,6 +34,21 @@ export function AuthProvider({ children }: { children: ReactNode }) {
// 初始化只拉一次用户信息
useEffect(() => { refresh(); }, [refresh]);
const prevUserId = useRef<number | null | 'init'>('init');
useEffect(() => {
if (loading) return;
const id = user?.id ?? null;
if (prevUserId.current === 'init') {
prevUserId.current = id;
return;
}
if (prevUserId.current !== id) {
prevUserId.current = id;
clearSessionSnapshots();
clearAllFeedCache();
}
}, [user, loading]);
const logout = async () => {
await api.logout();
setUser(null);

View File

@@ -2,38 +2,115 @@ import { useCallback, useEffect, useState } from 'react';
import { notify } from '@/lib/notify';
import { api } from '../api/client';
import type { CheckInStatus } from '../api/types';
import { getSessionSnapshot, setSessionSnapshot } from '../utils/sessionPageCache';
import { PAGE_FORCE_REFRESH_EVENT } from '../utils/feedCache';
import { PAGE_SOFT_REFRESH_COMMIT_EVENT } from '../utils/softRefresh';
import { useAuth } from './useAuth';
/** 签到会话快照键(与 prefetchRoute 共用) */
export function checkInCacheKey(userId: number) {
return `checkin:${userId}`;
}
/** 每日签到状态与操作(侧栏、积分页等复用) */
export function useCheckIn(enabled = true) {
const { user, refresh } = useAuth();
const [status, setStatus] = useState<CheckInStatus | null>(null);
const [loading, setLoading] = useState(false);
const userId = user?.id;
const [status, setStatus] = useState<CheckInStatus | null>(() => {
if (!userId || !enabled) return null;
return getSessionSnapshot<CheckInStatus>(checkInCacheKey(userId)) ?? null;
});
const [loading, setLoading] = useState(() => {
if (!userId || !enabled) return false;
return getSessionSnapshot<CheckInStatus>(checkInCacheKey(userId)) === undefined;
});
const [busy, setBusy] = useState(false);
const load = useCallback(() => {
if (!user || !enabled) {
const load = useCallback((opts?: { force?: boolean }) => {
if (!userId || !enabled) {
setStatus(null);
setLoading(false);
return;
}
const key = checkInCacheKey(userId);
if (!opts?.force) {
const hit = getSessionSnapshot<CheckInStatus>(key);
if (hit !== undefined) {
setStatus(hit);
setLoading(false);
return;
}
}
setLoading(true);
api.checkInStatus()
.then(d => setStatus(d.check_in))
.catch(e => notify.error(e instanceof Error ? e.message : '加载签到状态失败'))
.then((d) => {
setSessionSnapshot(key, d.check_in);
setStatus(d.check_in);
})
.catch((e) => notify.error(e instanceof Error ? e.message : '加载签到状态失败'))
.finally(() => setLoading(false));
}, [user, enabled]);
}, [userId, enabled]);
useEffect(() => {
load();
}, [load]);
if (!userId || !enabled) {
setStatus(null);
setLoading(false);
return;
}
const key = checkInCacheKey(userId);
const hit = getSessionSnapshot<CheckInStatus>(key);
if (hit !== undefined) {
setStatus(hit);
setLoading(false);
return;
}
setStatus(null);
setLoading(true);
load({ force: true });
}, [userId, enabled, load]);
// 软刷新 commit仅快照命中才盖未命中保持旧 UI后台静默再拉
useEffect(() => {
const applyHitOrSilent = (forceLoading: boolean) => {
if (!userId || !enabled) return;
const key = checkInCacheKey(userId);
const hit = getSessionSnapshot<CheckInStatus>(key);
if (hit !== undefined) {
setStatus(hit);
setLoading(false);
return;
}
if (forceLoading) {
load({ force: true });
return;
}
// 软刷新未命中:不 setLoading静默覆盖
api.checkInStatus()
.then((d) => {
setSessionSnapshot(key, d.check_in);
setStatus(d.check_in);
setLoading(false);
})
.catch(() => { /* 保持旧 UI */ });
};
const onForce = () => applyHitOrSilent(true);
const onCommit = () => applyHitOrSilent(false);
window.addEventListener(PAGE_FORCE_REFRESH_EVENT, onForce);
window.addEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, onCommit);
return () => {
window.removeEventListener(PAGE_FORCE_REFRESH_EVENT, onForce);
window.removeEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, onCommit);
};
}, [userId, enabled, load]);
const doCheckIn = useCallback(async () => {
if (!user) return;
if (!userId) return;
setBusy(true);
try {
const r = await api.checkIn();
notify.success(`签到成功,+${r.check_in.today_points} 积分`);
setSessionSnapshot(checkInCacheKey(userId), r.check_in);
setStatus(r.check_in);
await refresh();
} catch (e: unknown) {
@@ -41,13 +118,13 @@ export function useCheckIn(enabled = true) {
} finally {
setBusy(false);
}
}, [user, refresh]);
}, [userId, refresh]);
return {
status,
loading,
busy,
doCheckIn,
reload: load,
reload: () => load({ force: true }),
};
}

View File

@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import { api } from '../api/client';
import type { ForumLimitsPublic } from '../api/types';
import { DEFAULT_ASIDE_WIDGETS } from '../api/types';
import { DEFAULT_ASIDE_WIDGETS, DEFAULT_FEED_SORT_TABS } from '../api/types';
const DEFAULT_LIMITS: ForumLimitsPublic = {
post_title_max: 128,
@@ -27,6 +27,7 @@ const DEFAULT_LIMITS: ForumLimitsPublic = {
nav_show_showcase: false,
footer_show_showcase: false,
feed_list_style: 'title',
feed_sort_tabs: DEFAULT_FEED_SORT_TABS,
permalink_enabled: false,
permalink_ext: 'html',
monitor_pageview: false,
@@ -79,6 +80,11 @@ export function useForumLimits() {
return { limits, loading };
}
/** 冷启动 / 预热:确保 limits 已写入模块缓存 */
export function ensureForumLimitsLoaded(): Promise<ForumLimitsPublic> {
return fetchLimits();
}
/** 清除缓存并通知已挂载的 hook 重新拉取 */
export function invalidateForumLimitsCache() {
cached = null;

View File

@@ -2,6 +2,7 @@ import { useCallback, useMemo } from 'react';
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import { buildHomeUrl } from '../components/FeedSortBar';
import { navigateFeed } from '../utils/feedCache';
import { transitionTo } from '../utils/spaTransition';
import { notify } from '@/lib/notify';
import { parsePermalinkID, type PermalinkOpts } from '../utils/permalink';
@@ -190,7 +191,7 @@ export function usePostSearch(
}
saveRecentSearch({ keyword: kw, author, titleOnly, scopeBoardId });
nav(target);
void transitionTo(nav, target);
return true;
}, [nav, params, loc.pathname, limits, buildUrl]);

View File

@@ -0,0 +1,144 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { PAGE_FORCE_REFRESH_EVENT } from '../utils/feedCache';
import { PAGE_SOFT_REFRESH_COMMIT_EVENT } from '../utils/softRefresh';
import {
getSessionSnapshot,
setSessionSnapshot,
deleteSessionSnapshot,
} from '../utils/sessionPageCache';
type Fetcher<T> = () => Promise<T>;
/**
* 会话快照数据源:有缓存则跳过请求;无缓存时保留上一份画面直到新数据返回。
* 手机下拉软刷新 commit / PAGE_FORCE_REFRESH_EVENT 会应用新快照。
*/
export function useSessionResource<T>(
key: string | null,
fetcher: Fetcher<T>,
opts?: {
enabled?: boolean;
onError?: (e: unknown) => void;
},
) {
const enabled = opts?.enabled !== false;
const fetcherRef = useRef(fetcher);
fetcherRef.current = fetcher;
const onErrorRef = useRef(opts?.onError);
onErrorRef.current = opts?.onError;
const [data, setData] = useState<T | undefined>(() =>
key && enabled ? getSessionSnapshot<T>(key) : undefined,
);
const [loading, setLoading] = useState(() => {
if (!key || !enabled) return false;
return getSessionSnapshot<T>(key) === undefined;
});
const [pending, setPending] = useState(false);
const seqRef = useRef(0);
const dataRef = useRef(data);
dataRef.current = data;
useEffect(() => {
if (!key || !enabled) {
if (!enabled) {
setLoading(false);
setPending(false);
}
return;
}
const hit = getSessionSnapshot<T>(key);
if (hit !== undefined) {
setData(hit);
setLoading(false);
setPending(false);
return;
}
const seq = ++seqRef.current;
const keep = dataRef.current !== undefined;
if (keep) setPending(true);
else setLoading(true);
fetcherRef.current()
.then((next) => {
if (seq !== seqRef.current) return;
setSessionSnapshot(key, next);
setData(next);
})
.catch((e: unknown) => {
if (seq !== seqRef.current) return;
onErrorRef.current?.(e);
if (!keep) setData(undefined);
})
.finally(() => {
if (seq !== seqRef.current) return;
setLoading(false);
setPending(false);
});
return () => {
seqRef.current += 1;
};
}, [key, enabled]);
const replace = useCallback((next: T | ((prev: T | undefined) => T)) => {
setData((prev) => {
const value = typeof next === 'function' ? (next as (p: T | undefined) => T)(prev) : next;
if (key) setSessionSnapshot(key, value);
return value;
});
}, [key]);
const invalidate = useCallback(() => {
if (key) deleteSessionSnapshot(key);
}, [key]);
useEffect(() => {
const reload = (opts: { allowLoading: boolean }) => {
if (!key || !enabled) return;
const warm = getSessionSnapshot<T>(key);
if (warm !== undefined) {
setData(warm);
setLoading(false);
setPending(false);
return;
}
const seq = ++seqRef.current;
const keep = dataRef.current !== undefined;
if (opts.allowLoading) {
deleteSessionSnapshot(key);
if (keep) setPending(true);
else setLoading(true);
}
fetcherRef.current()
.then((next) => {
if (seq !== seqRef.current) return;
setSessionSnapshot(key, next);
setData(next);
})
.catch((e: unknown) => {
if (seq !== seqRef.current) return;
onErrorRef.current?.(e);
if (opts.allowLoading && !keep) setData(undefined);
})
.finally(() => {
if (seq !== seqRef.current) return;
setLoading(false);
setPending(false);
});
};
const onForce = () => reload({ allowLoading: true });
// 软刷新 commit未命中不卸 UI后台静默再拉
const onCommit = () => reload({ allowLoading: false });
window.addEventListener(PAGE_FORCE_REFRESH_EVENT, onForce);
window.addEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, onCommit);
return () => {
window.removeEventListener(PAGE_FORCE_REFRESH_EVENT, onForce);
window.removeEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, onCommit);
};
}, [key, enabled]);
return { data, loading, pending, replace, invalidate };
}

View File

@@ -123,6 +123,13 @@ export function useSiteBranding() {
return { branding, loading };
}
/** 保留当前画面,后台重拉品牌配置(下拉刷新等) */
export function refetchSiteBranding() {
inflight = null;
cacheEpoch += 1;
listeners.forEach(fn => fn());
}
/** 清除缓存并通知已挂载的 hook 重新拉取 */
export function invalidateSiteBrandingCache() {
cached = null;

View File

@@ -1,19 +1,19 @@
import { useEffect, useState } from 'react';
import { api } from '../api/client';
import type { SitePageSummary } from '../api/types';
import { PAGE_FORCE_REFRESH_EVENT } from '../utils/feedCache';
import { PAGE_SOFT_REFRESH_COMMIT_EVENT } from '../utils/softRefresh';
let cache: SitePageSummary[] | null = null;
let pending: Promise<SitePageSummary[]> | null = null;
/** 已发布单页摘要(页脚/侧栏导航 */
export function useSitePages() {
const [pages, setPages] = useState<SitePageSummary[]>(cache ?? []);
useEffect(() => {
if (cache) {
setPages(cache);
return;
/** 拉取或复用站点页摘要缓存(供冷启动门闩与 hook 共用 */
export function ensureSitePagesLoaded(opts?: { force?: boolean }): Promise<SitePageSummary[]> {
if (opts?.force) {
cache = null;
pending = null;
}
if (cache) return Promise.resolve(cache);
if (!pending) {
pending = api.pages()
.then(d => {
@@ -26,7 +26,35 @@ export function useSitePages() {
})
.finally(() => { pending = null; });
}
pending.then(setPages);
return pending;
}
/** 已发布单页摘要(页脚/侧栏导航) */
export function useSitePages() {
const [pages, setPages] = useState<SitePageSummary[]>(cache ?? []);
useEffect(() => {
const load = () => {
void ensureSitePagesLoaded().then(setPages);
};
if (cache) setPages(cache);
else load();
const onForce = () => {
cache = null;
pending = null;
load();
};
const onCommit = () => {
// 软刷新cache 已由 prefetch 写好,同步进 state
void ensureSitePagesLoaded().then(setPages);
};
window.addEventListener(PAGE_FORCE_REFRESH_EVENT, onForce);
window.addEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, onCommit);
return () => {
window.removeEventListener(PAGE_FORCE_REFRESH_EVENT, onForce);
window.removeEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, onCommit);
};
}, []);
return {

View File

@@ -1,6 +1,4 @@
import { useState, useEffect, useCallback, useRef, useMemo, Suspense } from 'react';
import PageLoader from '../components/PageLoader';
import FeedPageSkeleton from '../components/FeedPageSkeleton';
import { Outlet, useNavigate, useSearchParams, useLocation } from 'react-router-dom';
import { Menu, Moon, Sun, Search, Plus, PanelRight, X, Mail, SlidersHorizontal } from 'lucide-react';
import {
@@ -23,7 +21,12 @@ import BackToTop from '../components/BackToTop';
import { useForumLimits } from '../hooks/useForumLimits';
import { resolveAsideWidgets } from '../utils/asideWidgets';
import { buildHomeUrl, parseFeedSort } from '../components/FeedSortBar';
import { navigateFeed } from '../utils/feedCache';
import { navigateFeed, PAGE_FORCE_REFRESH_EVENT } from '../utils/feedCache';
import { PAGE_SOFT_REFRESH_COMMIT_EVENT } from '../utils/softRefresh';
import { prefetchRoute, wasColdBootEnsured } from '../utils/prefetchRoute';
import {
transitionTo,
} from '../utils/spaTransition';
import PostSearchPanel from '../components/search/PostSearchPanel';
import {
POST_SEARCH_OPEN_EVENT,
@@ -33,12 +36,13 @@ import { cn } from '@/lib/utils';
import { getBoardThemeIndex } from '../utils/boardTheme';
import { loginPath } from '../utils/authRedirect';
import { openForumPost } from '../utils/openPost';
import { useSiteBranding } from '../hooks/useSiteBranding';
import { refetchSiteBranding, useSiteBranding } from '../hooks/useSiteBranding';
import { useMonitorPageview } from '../hooks/useMonitorPageview';
import SiteBrandMark from '../components/SiteBrandMark';
import SiteFooter from '../components/SiteFooter';
import { userPath } from '../utils/userPath';
import { parsePermalinkID } from '../utils/permalink';
import { ensureSitePagesLoaded } from '../hooks/useSitePages';
export default function MainLayout() {
const { user, loading: authLoading, logout } = useAuth();
@@ -73,15 +77,20 @@ export default function MainLayout() {
const searchInputRef = useRef<HTMLInputElement>(null);
const [asideLoading, setAsideLoading] = useState(() => !hasCachedAside());
const [boardsLoading, setBoardsLoading] = useState(() => getCachedBoards().length === 0);
const [layoutRefreshTick, setLayoutRefreshTick] = useState(0);
const asideEverLoaded = useRef(false);
/** 冷启动门闩main.tsx 已预热则可同步放行;否则等齐再呈现 */
const [shellReady, setShellReady] = useState(() => isCompose || wasColdBootEnsured());
const coldBootDone = useRef(isCompose || wasColdBootEnsured());
const bootGen = useRef(0);
const [boardId, setBoardId] = useState(() => {
const m = loc.pathname.match(/^\/board\/(\d+(?:\.[A-Za-z0-9]{1,16})?)$/);
if (m) return parsePermalinkID(m[1]) || 0;
return Number(params.get('board')) || 0;
});
const [keywordDraft, setKeywordDraft] = useState(params.get('keyword') || '');
const feedSort = parseFeedSort(params.get('sort'));
const { limits: forumLimits } = useForumLimits();
const feedSort = parseFeedSort(params.get('sort'), forumLimits.feed_sort_tabs);
const postSearch = usePostSearch(forumLimits);
const asideWidgets = useMemo(() => resolveAsideWidgets(forumLimits), [forumLimits]);
const showTagCloud = asideWidgets.some(w => w.id === 'tag_cloud' && w.enabled);
@@ -195,6 +204,94 @@ export default function MainLayout() {
return () => window.removeEventListener('boards-refresh', onRefresh);
}, [refreshBoards]);
// 冷启动兜底main 未预热时静默等齐(不打进度条);站内已预热则保持打开
useEffect(() => {
if (isCompose) {
coldBootDone.current = true;
setShellReady(true);
return;
}
if (coldBootDone.current || wasColdBootEnsured()) {
coldBootDone.current = true;
setShellReady(true);
setBoardsLoading(false);
setAsideLoading(false);
setTagsLoading(false);
asideEverLoaded.current = true;
return;
}
const gen = ++bootGen.current;
let cancelled = false;
(async () => {
try {
const path = `${loc.pathname}${loc.search}`;
const tasks: Promise<unknown>[] = [
prefetchRoute(path, { force: false }),
refreshBoards(),
ensureSitePagesLoaded(),
];
if (!hideAside) {
if (showRecentComments) {
tasks.push(
api.recentComments().then((d) => {
const next = Array.isArray(d.comments) ? d.comments : [];
setRecentComments(next);
setCachedRecentComments(next);
}).catch(() => undefined),
);
}
if (showRecentUsers) {
tasks.push(
api.recentUsers().then((d) => {
const next = Array.isArray(d.users) ? d.users : [];
setRecentUsers(next);
setCachedRecentUsers(next);
}).catch(() => undefined),
);
}
if (showTagCloud) {
tasks.push(
api.tags(40).then((d) => {
const next = Array.isArray(d.tags) ? d.tags : [];
setTags(next);
setCachedTags(next);
}).catch(() => undefined),
);
}
}
await Promise.all(tasks);
} catch {
// 失败也放行
} finally {
if (!cancelled && bootGen.current === gen) {
asideEverLoaded.current = true;
setAsideLoading(false);
setTagsLoading(false);
setBoardsLoading(false);
coldBootDone.current = true;
setShellReady(true);
}
}
})();
return () => {
cancelled = true;
};
}, [
isCompose,
loc.pathname,
loc.search,
hideAside,
showRecentComments,
showRecentUsers,
showTagCloud,
refreshBoards,
]);
const refreshUnreadMessages = useCallback(() => {
if (!user) {
setUnreadMessages(0);
@@ -216,10 +313,50 @@ export default function MainLayout() {
};
}, [refreshUnreadMessages]);
useEffect(() => {
const syncFromCache = () => {
// 仅当 cache 有内容时覆盖,避免空数组盖住已渲染的非空 UI
const nextBoards = getCachedBoards();
if (nextBoards.length > 0) setBoards(nextBoards);
setBoardsLoading(false);
const nextStats = getCachedStats();
if (nextStats) setStats(nextStats);
const nextComments = getCachedRecentComments();
if (nextComments.length > 0 || hasCachedAside()) setRecentComments(nextComments);
const nextUsers = getCachedRecentUsers();
if (nextUsers.length > 0 || hasCachedAside()) setRecentUsers(nextUsers);
const nextTags = getCachedTags();
if (nextTags.length > 0) setTags(nextTags);
setTagsLoading(false);
setAsideLoading(false);
asideEverLoaded.current = true;
refetchSiteBranding();
refreshUnreadMessages();
};
const onForce = () => {
// 旧路径:仍可能被别处派发;尽量静默刷新壳层
refreshBoards();
refreshUnreadMessages();
refetchSiteBranding();
setLayoutRefreshTick(n => n + 1);
};
const onCommit = () => {
// 软刷新:预热已写入 cache同一拍同步进 state不触发分批 loading
syncFromCache();
};
window.addEventListener(PAGE_FORCE_REFRESH_EVENT, onForce);
window.addEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, onCommit);
return () => {
window.removeEventListener(PAGE_FORCE_REFRESH_EVENT, onForce);
window.removeEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, onCommit);
};
}, [refreshBoards, refreshUnreadMessages]);
// 标签云:进页/离开发帖页时拉取;不跟 posts-refresh 联动(置顶/推荐等不改标签)
useEffect(() => {
if (isCompose || !showTagCloud) return;
let cancelled = false;
// 有 session 缓存则静默刷新(软刷新不卸空白)
if (getCachedTags().length === 0) setTagsLoading(true);
api.tags(40).then(d => {
if (cancelled) return;
@@ -232,7 +369,7 @@ export default function MainLayout() {
return () => {
cancelled = true;
};
}, [isCompose, showTagCloud]);
}, [isCompose, showTagCloud, layoutRefreshTick]);
const needAsideData = !isCompose && (!hideAside || asideOpen);
const needRecentComments = needAsideData && showRecentComments;
@@ -259,7 +396,7 @@ export default function MainLayout() {
return () => {
cancelled = true;
};
}, [needRecentComments]);
}, [needRecentComments, layoutRefreshTick]);
const needRecentUsers = needAsideData && showRecentUsers;
useEffect(() => {
@@ -284,7 +421,7 @@ export default function MainLayout() {
return () => {
cancelled = true;
};
}, [needRecentUsers]);
}, [needRecentUsers, layoutRefreshTick]);
const doQuickSearch = () => {
const { author, titleOnly, scopeBoardId } = postSearch.filters;
@@ -386,8 +523,8 @@ export default function MainLayout() {
<Menu size={18} aria-hidden />
</button>
)}
{/* 点 Logo回首页并强制刷新列表(已在首页时也会重拉) */}
<button type="button" className="header-brand" onClick={() => navigateFeed(nav, '/')}>
{/* 任意页点 Logo回首页并强制刷新,不展示会话缓存 */}
<button type="button" className="header-brand" onClick={() => navigateFeed(nav, '/', { refresh: true })}>
<SiteBrandMark branding={branding} className="header-logo-mark" />
{!isMobile && <span className="header-logo-text">{branding.name}</span>}
</button>
@@ -470,7 +607,7 @@ export default function MainLayout() {
<button
type="button"
className="header-compose-btn"
onClick={() => user ? nav('/compose') : nav(loginPath('/compose'))}
onClick={() => user ? void transitionTo(nav, '/compose') : nav(loginPath('/compose'))}
aria-label="发帖"
>
<Plus size={16} aria-hidden />
@@ -515,7 +652,7 @@ export default function MainLayout() {
className="header-icon-btn header-msg-btn"
title={unreadMessages > 0 ? `${unreadMessages} 条未读消息` : '站内消息'}
aria-label={unreadMessages > 0 ? `站内消息,${unreadMessages} 条未读` : '站内消息'}
onClick={() => nav('/messages')}
onClick={() => void transitionTo(nav, '/messages')}
>
<Mail size={18} aria-hidden />
{unreadMessages > 0 && (
@@ -535,14 +672,14 @@ export default function MainLayout() {
className="w-40"
onCloseAutoFocus={(e) => e.preventDefault()}
>
<DropdownMenuItem onClick={() => nav(userPath(user.id))}></DropdownMenuItem>
<DropdownMenuItem onClick={() => nav('/profile')}>
<DropdownMenuItem onClick={() => void transitionTo(nav, userPath(user.id))}></DropdownMenuItem>
<DropdownMenuItem onClick={() => void transitionTo(nav, '/profile')}>
{typeof user.points === 'number' ? ` · ${user.points} 积分` : ''}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => nav('/messages')}>
<DropdownMenuItem onClick={() => void transitionTo(nav, '/messages')}>
{unreadMessages > 0 ? ` (${unreadMessages})` : ''}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => nav('/favorites')}></DropdownMenuItem>
<DropdownMenuItem onClick={() => void transitionTo(nav, '/favorites')}></DropdownMenuItem>
{isMobile && (
<DropdownMenuItem onClick={toggle}>
{theme === 'light' ? '切换暗色模式' : '切换亮色模式'}
@@ -588,7 +725,7 @@ export default function MainLayout() {
)}
<div className={`app-body${isCompose ? ' app-body--compose' : ''}`}>
{!isCompose && (
{!isCompose && shellReady && (
<Sidebar
boards={boards}
activeBoard={boardId}
@@ -602,13 +739,16 @@ export default function MainLayout() {
isCompose && 'content-workspace--compose',
hideAside && !isCompose && 'content-workspace--aside-hidden',
)}>
<main className={cn(
<main
className={cn(
'main-content',
isCompose && 'main-content--compose',
// 手机 Feed整栏滚动板块条 / 排序栏可滚出视口,多露出帖子列表
isMobile && !isCompose && isFeedHome && 'main-content--feed-mobile-scroll',
)}>
{isMobile && !isCompose && isFeedHome && (
)}
aria-busy={!shellReady}
>
{shellReady && isMobile && !isCompose && isFeedHome && (
<div
ref={boardBarRef}
className="mobile-board-bar"
@@ -646,13 +786,16 @@ export default function MainLayout() {
})}
</div>
)}
<Suspense fallback={isFeedHome ? <FeedPageSkeleton /> : <PageLoader />}>
{shellReady ? (
<Suspense fallback={null}>
<Outlet context={layoutCtx} />
</Suspense>
) : null}
</main>
{!isCompose && (
<aside className="aside-panel">
<aside className="aside-panel" aria-busy={!shellReady}>
{shellReady && (
<RightPanel
recentComments={recentComments}
recentUsers={recentUsers}
@@ -671,6 +814,7 @@ export default function MainLayout() {
outlineTitle: postOutline?.title,
} : null}
/>
)}
</aside>
)}
</div>
@@ -710,12 +854,14 @@ export default function MainLayout() {
</button>
</div>
<div className="aside-drawer-body sidebar-drawer-body">
{shellReady && (
<Sidebar
boards={boards}
activeBoard={boardId}
onSelectBoard={setBoardId}
boardsLoading={boardsLoading}
/>
)}
<div className="sidebar-drawer-extras">
<button
type="button"

View File

@@ -2,11 +2,21 @@ import React from 'react';
import ReactDOM from 'react-dom/client';
import { applyTheme, getStoredTheme } from './utils/theme';
import App from './App';
import { ensureColdBootReady, isMainLayoutPath } from './utils/prefetchRoute';
applyTheme(getStoredTheme());
async function boot() {
const path = `${window.location.pathname}${window.location.search}`;
// 前台:齐套前不挂载;完成后一次 createRoot
if (isMainLayoutPath(window.location.pathname)) {
await ensureColdBootReady(path);
}
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
</React.StrictMode>,
);
}
void boot();

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;
// 筛选键切换:用新键的缓存重置本地 stateuseMemo 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 : '加载失败');
if (!keepView) {
setPosts([]);
setPostTotal(0);
setPage(1);
pageRef.current = 1;
}
}
} finally {
loadingRef.current = 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);
// 预取已写入 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;
if (needRestore) {
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);
// 仅在「首次进入该筛选」时恢复滚动,避免 limits/pageSize 变化导致 effect 重跑时打断用户滚动
if (needRestore) {
setRestoreScrollTop(cached.scrollTop);
scrollTopRef.current = cached.scrollTop;
}
// 超过 TTL后台静默刷新不重置滚动
if (getHomeStoreState().isStale(cacheKey)) {
void fetchPage(cached.page, { silent: true });
setListPending(false);
setView({ cacheKey, sort, boardId, keyword, tag, author, titleOnly });
}
return;
}
hydratedKeyRef.current = cacheKey;
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([]);
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,22 +22,8 @@ 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="page-wrap">
<div className="showcase-page">
<header className="showcase-head">
<div className="showcase-head-mark" aria-hidden>
@@ -83,5 +71,6 @@ 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">

View File

@@ -227,6 +227,30 @@ a:hover { text-decoration: underline; }
border-bottom: 1px solid var(--j13-border);
}
/* 站内跳转顶栏细进度条2px类 Next.js / YouTube */
.top-progress {
position: fixed;
top: 0;
left: 0;
height: 2px;
z-index: 9999;
pointer-events: none;
background: var(--j13-green);
box-shadow: 0 0 8px rgba(24, 160, 88, 0.55), 0 0 2px rgba(24, 160, 88, 0.9);
transition: width 0.2s ease-out;
border-radius: 0 1px 1px 0;
}
.top-progress--done {
opacity: 0;
transition: width 0.12s ease-out, opacity 0.2s ease-out 0.05s;
}
html.dark .top-progress {
background: #36d399;
box-shadow: 0 0 8px rgba(54, 211, 153, 0.5), 0 0 2px rgba(54, 211, 153, 0.85);
}
/* 站点页脚 */
.site-footer {
flex-shrink: 0;
@@ -2751,6 +2775,14 @@ body:has(.admin-topbar) .ptr-indicator {
flex-shrink: 0;
}
.feed-sort-tab.is-pending {
color: var(--j13-green);
}
.feed-sort-tab__spin {
animation: ptr-spin 0.7s linear infinite;
}
@media (max-width: 768px) {
.feed-head {
flex-wrap: wrap;
@@ -3501,6 +3533,21 @@ body:has(.admin-topbar) .ptr-indicator {
flex-shrink: 0;
}
/* 类型徽章缀在标题后:不参与收缩,标题在剩余宽度内 ellipsis */
.post-title-type-badges {
display: inline-flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
max-width: none;
}
.post-title-type-badges .post-type-badge,
.post-title-type-badges .post-qa-badge,
.post-title-type-badges .post-bounty-badge {
margin-right: 0;
}
.post-title {
display: block;
overflow: hidden;
@@ -3513,8 +3560,9 @@ body:has(.admin-topbar) .ptr-indicator {
color: var(--color-text-1);
text-decoration: none;
transition: color 0.15s;
/* 不抢满行宽:短标题时类型徽章紧挨末字;过长时在剩余空间内省略 */
min-width: 0;
flex: 1;
flex: 0 1 auto;
}
a.post-title:visited {
@@ -11110,7 +11158,7 @@ button.profile-stat:hover strong {
}
}
/* 路由懒加载占位已改为 FeedPageSkeleton保留类名兼容 */
/* 路由懒加载占位类名保留兼容(前台已改顶栏进度 + 空白) */
/* ========== React 管理后台 ========== */
.admin-shell { min-height: 100vh; background: hsl(var(--background)); }
@@ -11572,6 +11620,21 @@ button.profile-stat:hover strong {
font-size: 12px;
color: hsl(var(--muted-foreground));
}
.admin-settings-subsection {
margin-top: 1.25rem;
}
.admin-settings-subsection-title {
margin: 0 0 0.25rem;
font-size: 14px;
font-weight: 600;
color: var(--foreground, #0f172a);
}
.admin-settings-subsection-desc {
margin: 0 0 0.75rem;
font-size: 12px;
line-height: 1.45;
color: hsl(var(--muted-foreground));
}
/* 设置表内紧凑开关(勿复用 .admin-mail-switch其绝对定位会破坏后台布局 */
.admin-settings-switch {
@@ -14521,6 +14584,51 @@ button.post-poll__option,
font-weight: 600;
color: var(--foreground, #0f172a);
}
.admin-sortable-row__field {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 0.28rem;
min-width: 0;
}
.admin-sortable-row__field-name {
font-size: 11px;
font-weight: 500;
line-height: 1.2;
color: hsl(var(--muted-foreground));
}
.admin-sortable-row__name-wrap {
position: relative;
display: block;
width: 100%;
max-width: 16rem;
}
.admin-sortable-row__name-wrap .admin-sortable-row__name-input {
width: 100%;
height: 36px;
padding-right: 2rem;
font-weight: 500;
color: hsl(var(--foreground));
background: hsl(var(--muted) / 0.55);
border: 1px solid hsl(var(--foreground) / 0.16);
box-shadow: inset 0 1px 1px hsl(var(--foreground) / 0.04);
}
.admin-sortable-row__name-wrap .admin-sortable-row__name-input:hover {
border-color: hsl(var(--foreground) / 0.28);
}
.admin-sortable-row__name-wrap .admin-sortable-row__name-input:focus-visible {
background: hsl(var(--background));
border-color: var(--j13-green);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--j13-green) 22%, transparent);
}
.admin-sortable-row__name-icon {
position: absolute;
right: 0.55rem;
top: 50%;
transform: translateY(-50%);
color: hsl(var(--muted-foreground));
pointer-events: none;
}
.admin-sortable-row__hint {
font-size: 12px;
line-height: 1.45;

View File

@@ -6,6 +6,9 @@ import {
getHomeStoreState,
type FeedCacheEntry,
} from '../store/homeStore';
import { clearSessionSnapshots } from './sessionPageCache';
import { softRefreshCurrentPage } from './softRefresh';
import { transitionTo } from './spaTransition';
/** 导航到帖子列表时附带的状态,用于同 URL 重复点击时强制刷新 */
export type FeedNavState = { refreshFeed?: boolean };
@@ -55,9 +58,10 @@ export function setFeedCache(
getHomeStoreState().setFeed(key, data);
}
/** 清除所有帖子列表缓存 */
/** 清除所有帖子列表缓存(手动刷新 / 帖子变更时连详情快照一起作废) */
export function clearAllFeedCache() {
getHomeStoreState().clearAll();
clearSessionSnapshots('post:');
}
/** 主动刷新帖子列表时派发,用于同页内立即回到顶部 */
@@ -66,6 +70,9 @@ export const FEED_RESET_EVENT = 'feed-reset';
/** 手机下拉刷新Feed 页):强制重拉列表并重置滚动,不整页 reload */
export const FEED_PULL_REFRESH_EVENT = 'feed-pull-refresh';
/** 手机下拉:强制刷新当前前台页(绕过会话快照,非整页 reload */
export const PAGE_FORCE_REFRESH_EVENT = 'page-force-refresh';
/** 当前浏览器地址是否已是目标 Feed URL忽略 hash */
function isSameFeedUrl(url: string): boolean {
try {
@@ -80,15 +87,23 @@ function isSameFeedUrl(url: string): boolean {
}
/**
* 清除缓存并导航到帖子列表。
* 已在目标 URL如首页再点 Logo时额外派发强制重拉不依赖 RR 是否换 key
* 导航到帖子列表。
* 默认:等待预热后再换页;同 URL 或 `refresh: true` 时静默软刷新(无进度条)
*/
export function navigateFeed(nav: NavigateFunction, url: string) {
clearAllFeedCache();
window.dispatchEvent(new Event(FEED_RESET_EVENT));
// 同 URL 再点(典型:左上角 Logo必须立刻重拉否则可能只清缓存、界面仍显示旧列表
if (isSameFeedUrl(url)) {
window.dispatchEvent(new Event(FEED_PULL_REFRESH_EVENT));
export function navigateFeed(nav: NavigateFunction, url: string, opts?: { refresh?: boolean }) {
const same = isSameFeedUrl(url);
const refresh = opts?.refresh ?? same;
if (refresh) {
if (same) {
void softRefreshCurrentPage(url);
return;
}
nav(url, { state: { refreshFeed: true } satisfies FeedNavState });
void transitionTo(nav, url, {
force: true,
silent: true,
state: { refreshFeed: true } satisfies FeedNavState,
});
return;
}
void transitionTo(nav, url);
}

View File

@@ -0,0 +1,41 @@
import type { FeedSortId, FeedSortTab } from '../api/types';
import { DEFAULT_FEED_SORT_TABS } from '../api/types';
const FEED_SORT_IDS: FeedSortId[] = ['reply', 'latest', 'hot'];
const DEFAULT_LABELS: Record<FeedSortId, string> = {
reply: '新评论',
latest: '新帖子',
hot: '推荐帖',
};
/** 校验并补全 Feed 排序标签;至少保留一项启用 */
export function normalizeFeedSortTabs(tabs?: FeedSortTab[] | null): FeedSortTab[] {
const seen = new Set<FeedSortId>();
const out: FeedSortTab[] = [];
for (const t of tabs ?? []) {
if (!FEED_SORT_IDS.includes(t.id) || seen.has(t.id)) continue;
seen.add(t.id);
const label = (t.label || '').trim() || DEFAULT_LABELS[t.id];
out.push({ id: t.id, label, enabled: !!t.enabled });
}
for (const id of FEED_SORT_IDS) {
if (seen.has(id)) continue;
const fallback = DEFAULT_FEED_SORT_TABS.find(t => t.id === id)!;
out.push({ ...fallback });
}
if (!out.some(t => t.enabled) && out.length > 0) {
out[0] = { ...out[0], enabled: true };
}
return out;
}
/** 启用中的排序标签(按配置顺序) */
export function enabledFeedSortTabs(tabs?: FeedSortTab[] | null): FeedSortTab[] {
return normalizeFeedSortTabs(tabs).filter(t => t.enabled);
}
/** 默认排序 = 第一个启用项 */
export function getDefaultFeedSort(tabs?: FeedSortTab[] | null): FeedSortId {
return enabledFeedSortTabs(tabs)[0]?.id ?? 'reply';
}

View File

@@ -1,5 +1,6 @@
import type { NavigateFunction } from 'react-router-dom';
import { postPath, type PermalinkOpts } from './permalink';
import { transitionTo } from './spaTransition';
export type OpenForumPostOpts = PermalinkOpts & {
/** 跳转到指定楼层(#floor-N */
@@ -18,5 +19,5 @@ export function openForumPost(
window.open(path, '_blank', 'noopener,noreferrer');
return;
}
nav(path);
void transitionTo(nav, path);
}

View File

@@ -0,0 +1,421 @@
import type { To } from 'react-router-dom';
import { api } from '../api/client';
import type {
CheckInStatus,
Comment,
CommunityShowcaseItem,
FriendLinkApply,
PollView,
PostItem,
PostLotteryView,
SitePage,
} from '../api/types';
import { parseFeedSort } from '../components/FeedSortBar';
import { checkInCacheKey } from '../hooks/useCheckIn';
import { ensureForumLimitsLoaded, getCachedForumLimits } from '../hooks/useForumLimits';
import { ensureSitePagesLoaded } from '../hooks/useSitePages';
import { feedCacheKey, getHomeStoreState } from '../store/homeStore';
import { resolveAsideWidgets } from './asideWidgets';
import { isTimeDiffSignificant } from './content';
import { loadMyCommentIds } from './guest';
import {
setCachedBoards,
setCachedStats,
setCachedRecentComments,
setCachedRecentUsers,
setCachedTags,
} from './layoutCache';
import { parsePermalinkID, parsePermalinkSlug } from './permalink';
import { getSessionSnapshot, setSessionSnapshot } from './sessionPageCache';
/** 与 PostDetailPage 会话快照同形,供预取写入 */
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 resolveUrl(to: To): URL {
if (typeof to === 'string') return new URL(to, window.location.origin);
const path = to.pathname ?? '/';
const search = to.search ?? '';
const hash = to.hash ?? '';
return new URL(path + search + hash, window.location.origin);
}
/** 预加载对应路由的 lazy chunk与 App.tsx lazyWithRetry 对齐) */
function preloadChunk(pathname: string): Promise<unknown> {
if (pathname === '/' || /^\/board\//.test(pathname)) {
return import('../pages/HomePage');
}
if (/^\/post\/[^/]+\/edit$/.test(pathname) || pathname === '/compose') {
return import('../pages/ComposePage');
}
if (/^\/post\//.test(pathname)) {
return import('../pages/PostDetailPage');
}
if (pathname === '/profile') return import('../pages/ProfilePage');
if (/^\/user\//.test(pathname)) return import('../pages/UserProfilePage');
if (pathname === '/favorites') return import('../pages/FavoritesPage');
if (pathname === '/projects') return import('../pages/ProjectsPage');
if (pathname === '/links') return import('../pages/LinksPage');
if (pathname === '/showcase') return import('../pages/ShowcasePage');
if (pathname === '/messages') return import('../pages/MessagesPage');
if (/^\/page\//.test(pathname)) return import('../pages/SitePageView');
if (pathname === '/login') return import('../pages/LoginPage');
if (pathname === '/register') return import('../pages/RegisterPage');
if (pathname === '/forgot-password') return import('../pages/ForgotPasswordPage');
return Promise.resolve();
}
async function prefetchFeed(url: URL, force: boolean): Promise<void> {
const limits = getCachedForumLimits();
const pageSize = Math.max(1, limits.page_size_default);
const boardMatch = url.pathname.match(/^\/board\/([^/]+)/);
const boardId = boardMatch ? (parsePermalinkID(boardMatch[1]) || 0) : 0;
const keyword = url.searchParams.get('keyword') || '';
const tag = url.searchParams.get('tag') || '';
const author = url.searchParams.get('author') || '';
const titleOnly = url.searchParams.get('title_only') === '1';
const sort = parseFeedSort(url.searchParams.get('sort'), limits.feed_sort_tabs);
const key = feedCacheKey({ boardId, keyword, sort, tag, author, titleOnly });
if (!force) {
const hit = getHomeStoreState().getFeed(key);
if (hit && hit.posts.length > 0) return;
}
// force不提前清空 store等新数据写入时覆盖避免软刷新中间态读到空列表
const data = await api.posts({
page: 1,
size: pageSize,
board_id: boardId || '',
keyword: tag ? '' : keyword,
tag: tag || '',
author: tag ? '' : author,
title_only: !tag && titleOnly ? '1' : '',
sort,
});
getHomeStoreState().setFeed(key, {
posts: Array.isArray(data.posts) ? data.posts : [],
postTotal: data.total ?? 0,
page: 1,
scrollTop: 0,
lastFetchTime: Date.now(),
});
}
async function prefetchPost(id: number, force: boolean): Promise<void> {
const key = `post:${id}`;
if (!force && getSessionSnapshot(key) !== undefined) return;
// force保留旧快照直到新数据写回避免软刷新中间态空白
const myIds = loadMyCommentIds();
const [detail, comm] = await Promise.all([
api.post(id),
api.comments(id, myIds),
]);
const snap: PostDetailSnapshot = {
post: detail.post,
comments: Array.isArray(comm.comments) ? comm.comments : [],
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(key, snap);
}
async function prefetchUser(id: number, force: boolean): Promise<void> {
const profileKey = `user:${id}`;
if (!force && getSessionSnapshot(profileKey) !== undefined) return;
const limits = getCachedForumLimits();
const pageSize = limits.page_size_default > 0 ? limits.page_size_default : 20;
const [profileRes, postsRes] = await Promise.all([
api.userProfile(id),
api.posts({ user_id: id, page: 1, size: pageSize, sort: 'latest' }),
]);
setSessionSnapshot(profileKey, {
profile: profileRes.user,
stats: profileRes.stats ?? null,
});
setSessionSnapshot(`${profileKey}:posts:1:${pageSize}`, {
posts: Array.isArray(postsRes.posts) ? postsRes.posts : [],
total: postsRes.total ?? 0,
});
}
async function prefetchKeyed<T>(
key: string,
force: boolean,
fetcher: () => Promise<T>,
): Promise<void> {
if (!force && getSessionSnapshot(key) !== undefined) return;
const data = await fetcher();
setSessionSnapshot(key, data);
}
/** 静默预热签到401 / 失败不阻断跳转) */
async function prefetchCheckIn(force: boolean): Promise<void> {
try {
const me = await api.me();
const id = me.user?.id;
if (!id) return;
const key = checkInCacheKey(id);
if (!force && getSessionSnapshot<CheckInStatus>(key) !== undefined) return;
const d = await api.checkInStatus();
setSessionSnapshot(key, d.check_in);
} catch {
// 未登录或接口失败:忽略
}
}
/** 右栏展柜与全页共用 showcase 快照 */
async function prefetchShowcaseIfEnabled(force: boolean): Promise<void> {
const limits = getCachedForumLimits();
const enabled = resolveAsideWidgets(limits).some((w) => w.id === 'showcase' && w.enabled);
if (!enabled) return;
await prefetchKeyed<CommunityShowcaseItem[]>('showcase', force, () =>
api.communityShowcase().then((r) => (Array.isArray(r.items) ? r.items : [])),
).catch(() => undefined);
}
/** MainLayout 壳层:签到 +(可选)展柜,与主内容并行 */
function prefetchShell(force: boolean): Promise<void> {
return Promise.all([
prefetchCheckIn(force),
prefetchShowcaseIfEnabled(force),
]).then(() => undefined);
}
/** 是否会挂载前台 MainLayout非纯 auth / 后台页) */
export function isMainLayoutPath(pathname: string): boolean {
if (pathname.startsWith('/admin')) return false;
if (pathname === '/login' || pathname === '/register' || pathname === '/forgot-password') return false;
return true;
}
/** 按目标 URL 预热会话快照 / Feed 缓存(不改 fetch 缓存策略) */
export async function prefetchData(to: To, opts?: { force?: boolean }): Promise<void> {
const force = !!opts?.force;
const url = resolveUrl(to);
const { pathname } = url;
const shell = isMainLayoutPath(pathname) ? prefetchShell(force) : Promise.resolve();
if (pathname === '/' || /^\/board\//.test(pathname)) {
await Promise.all([prefetchFeed(url, force), shell]);
return;
}
const postEdit = pathname.match(/^\/post\/([^/]+)\/edit$/);
if (postEdit) {
await shell;
return;
}
const postMatch = pathname.match(/^\/post\/([^/]+)/);
if (postMatch) {
const id = parsePermalinkID(postMatch[1]);
await Promise.all([
id && !Number.isNaN(id) ? prefetchPost(id, force) : Promise.resolve(),
shell,
]);
return;
}
const userMatch = pathname.match(/^\/user\/([^/]+)/);
if (userMatch) {
const id = parsePermalinkID(userMatch[1]);
await Promise.all([
id && !Number.isNaN(id) ? prefetchUser(id, force) : Promise.resolve(),
shell,
]);
return;
}
if (pathname === '/favorites') {
await Promise.all([
prefetchKeyed('favorites', force, () =>
api.favorites().then((d) => (Array.isArray(d.favorites) ? d.favorites : [])),
),
shell,
]);
return;
}
if (pathname === '/projects') {
await Promise.all([
prefetchKeyed(`projects:1:`, force, () =>
api.projects({ page: 1, limit: 30 }).then((d) => ({
list: Array.isArray(d.projects) ? d.projects : [],
total: d.total ?? 0,
totalPages: d.total_pages ?? 0,
})),
),
shell,
]);
return;
}
if (pathname === '/links') {
await Promise.all([
prefetchKeyed<FriendLinkApply[]>('links:applies', force, () =>
api.myFriendLinkApplies().then((r) => r.applies ?? []).catch(() => []),
),
shell,
]);
return;
}
if (pathname === '/showcase') {
await Promise.all([
prefetchKeyed('showcase', force, () =>
api.communityShowcase().then((r) => (Array.isArray(r.items) ? r.items : [])),
),
shell,
]);
return;
}
if (pathname === '/messages') {
await Promise.all([
prefetchKeyed('messages:conv:1', force, () =>
api.messageConversations({ page: 1, size: 30 }).then((r) => ({
conversations: r.conversations || [],
total: r.total || 0,
page: r.page || 1,
})),
),
shell,
]);
return;
}
if (pathname === '/profile' || pathname === '/compose') {
await shell;
return;
}
const pageMatch = pathname.match(/^\/page\/([^/]+)/);
if (pageMatch) {
const slug = parsePermalinkSlug(pageMatch[1]);
await Promise.all([
slug
? prefetchKeyed<SitePage | null>(`sitepage:${slug}`, force, () =>
api.page(slug).then((d) => d.page),
)
: Promise.resolve(),
shell,
]);
}
}
/** chunk + 数据并行预热 */
export async function prefetchRoute(to: To, opts?: { force?: boolean }): Promise<void> {
const url = resolveUrl(to);
await Promise.all([
preloadChunk(url.pathname),
prefetchData(to, opts),
]);
}
/** 壳层数据boards/stats/站点页/右栏(与冷启动、软刷新共用) */
export async function prefetchLayoutShell(opts?: { force?: boolean }): Promise<void> {
await ensureForumLimitsLoaded();
const limits = getCachedForumLimits();
const widgets = resolveAsideWidgets(limits);
const showRecentComments = widgets.some((w) => w.id === 'recent_comments' && w.enabled);
const showRecentUsers = widgets.some((w) => w.id === 'recent_users' && w.enabled);
const showTagCloud = widgets.some((w) => w.id === 'tag_cloud' && w.enabled);
const hideAside = typeof window !== 'undefined'
&& window.matchMedia('(max-width: 1100px)').matches;
const tasks: Promise<unknown>[] = [
api.boards().then((d) => {
setCachedBoards(d.boards ?? []);
}).catch(() => undefined),
api.stats().then((next) => {
if (next) setCachedStats(next);
}).catch(() => undefined),
ensureSitePagesLoaded({ force: !!opts?.force }),
];
if (!hideAside) {
if (showRecentComments) {
tasks.push(
api.recentComments().then((d) => {
setCachedRecentComments(Array.isArray(d.comments) ? d.comments : []);
}).catch(() => undefined),
);
}
if (showRecentUsers) {
tasks.push(
api.recentUsers().then((d) => {
setCachedRecentUsers(Array.isArray(d.users) ? d.users : []);
}).catch(() => undefined),
);
}
if (showTagCloud) {
tasks.push(
api.tags(40).then((d) => {
setCachedTags(Array.isArray(d.tags) ? d.tags : []);
}).catch(() => undefined),
);
}
}
await Promise.all(tasks);
}
let coldBootEnsured = false;
/** main.tsx 是否已完成前台冷启动预热MainLayout 可同步放行) */
export function wasColdBootEnsured(): boolean {
return coldBootEnsured;
}
/**
* 冷启动:在 createRoot 之前静默预热当前前台路由与壳层。
* 不触发顶栏进度条;失败也放行,避免永久空白。
*/
export async function ensureColdBootReady(to?: string): Promise<void> {
const path = to ?? `${window.location.pathname}${window.location.search}`;
const url = resolveUrl(path);
if (!isMainLayoutPath(url.pathname)) {
coldBootEnsured = true;
return;
}
try {
await Promise.all([
prefetchRoute(path, { force: false }),
prefetchLayoutShell(),
]);
} catch {
// 忽略:仍挂载 App
} finally {
coldBootEnsured = true;
}
}

View File

@@ -0,0 +1,45 @@
/** 会话内页面快照:前进后退命中则不再请求,手动刷新或登录态变化时清空 */
const MAX_ENTRIES = 48;
const order: string[] = [];
const store = new Map<string, unknown>();
function touch(key: string) {
const i = order.indexOf(key);
if (i >= 0) order.splice(i, 1);
order.push(key);
while (order.length > MAX_ENTRIES) {
const old = order.shift();
if (old) store.delete(old);
}
}
export function getSessionSnapshot<T>(key: string): T | undefined {
if (!store.has(key)) return undefined;
touch(key);
return store.get(key) as T;
}
export function setSessionSnapshot<T>(key: string, data: T): void {
store.set(key, data);
touch(key);
}
export function deleteSessionSnapshot(key: string): void {
if (!store.delete(key)) return;
const i = order.indexOf(key);
if (i >= 0) order.splice(i, 1);
}
/** 不传 prefix 则清空全部;否则只删该前缀的 key */
export function clearSessionSnapshots(prefix?: string): void {
if (!prefix) {
store.clear();
order.length = 0;
return;
}
for (const key of [...store.keys()]) {
if (key.startsWith(prefix)) deleteSessionSnapshot(key);
}
}

View File

@@ -0,0 +1,20 @@
import { prefetchLayoutShell, prefetchRoute } from './prefetchRoute';
/** 软刷新齐套后的单一提交:各组件同一拍从 cache/快照同步 UI禁止分批闪烁 */
export const PAGE_SOFT_REFRESH_COMMIT_EVENT = 'page-soft-refresh-commit';
/**
* 静默刷新当前页:不改画面、无进度条,预热齐套后派发一次 commit。
*/
export async function softRefreshCurrentPage(to?: string): Promise<void> {
const path = to ?? `${window.location.pathname}${window.location.search}`;
try {
await Promise.all([
prefetchRoute(path, { force: true }),
prefetchLayoutShell({ force: true }),
]);
} catch {
// 仍派发 commit让界面有机会用已有缓存自愈
}
window.dispatchEvent(new Event(PAGE_SOFT_REFRESH_COMMIT_EVENT));
}

View File

@@ -0,0 +1,110 @@
import type { NavigateFunction, NavigateOptions, To } from 'react-router-dom';
import { notify } from '@/lib/notify';
import { prefetchRoute } from './prefetchRoute';
type Listener = (active: boolean) => void;
const listeners = new Set<Listener>();
let activeCount = 0;
let seq = 0;
function emit() {
const active = activeCount > 0;
listeners.forEach((fn) => fn(active));
}
/** 订阅顶栏进度条显隐 */
export function subscribeSpaTransition(fn: Listener): () => void {
listeners.add(fn);
fn(activeCount > 0);
return () => { listeners.delete(fn); };
}
/** 开始一次过渡(可嵌套,全部结束才收条) */
export function startTransition(): number {
const id = ++seq;
activeCount += 1;
emit();
return id;
}
/** 结束过渡;若 id 已过期则忽略 */
export function doneTransition(id?: number) {
if (id != null && id !== seq && activeCount > 0) {
// 仍允许减计数,避免卡死;连续点击用 seq 作废预取即可
}
activeCount = Math.max(0, activeCount - 1);
emit();
}
export function isSpaTransitionActive(): boolean {
return activeCount > 0;
}
export type TransitionToOpts = NavigateOptions & {
/** 强制重拉Logo / 同 URL 刷新),跳过会话快照 */
force?: boolean;
/** 跳过等待立即导航纠偏、POP 等) */
immediate?: boolean;
/** 不显示顶栏进度条(软刷新换页) */
silent?: boolean;
};
function toPathname(to: To): string {
if (typeof to === 'number') return '';
if (typeof to === 'string') {
try {
return new URL(to, window.location.origin).pathname;
} catch {
return to.split('?')[0].split('#')[0];
}
}
return to.pathname ?? '';
}
function isOnlyHashChange(to: To): boolean {
if (typeof to !== 'string') return false;
if (!to.startsWith('#')) return false;
return true;
}
/**
* 站内点击:顶栏进度条 + 预热 chunk/接口后再跳转。
* `nav(-1)`、仅 hash、immediate、外链风格路径直接走。
*/
export async function transitionTo(
nav: NavigateFunction,
to: To | number,
opts?: TransitionToOpts,
): Promise<void> {
const { force, immediate, silent, ...navOpts } = opts ?? {};
if (typeof to === 'number' || immediate || isOnlyHashChange(to as To)) {
nav(to as To, navOpts);
return;
}
const target = to as To;
const path = toPathname(target);
// 后台第一期不做等待跳转
if (path.startsWith('/admin')) {
nav(target, navOpts);
return;
}
const id = silent ? undefined : startTransition();
const mySeq = silent ? seq : (id as number);
try {
await prefetchRoute(target, { force: !!force });
if (!silent && mySeq !== seq) return;
nav(target, navOpts);
} catch (e: unknown) {
if (!silent && mySeq !== seq) return;
notify.error(e instanceof Error ? e.message : '加载失败');
} finally {
if (id != null) {
if (mySeq === seq) doneTransition(id);
else doneTransition();
}
}
}

View File

@@ -967,6 +967,11 @@ func (h *Handlers) APIPosts(c *gin.Context) {
author := strings.TrimSpace(c.Query("author"))
titleOnly := c.Query("title_only") == "1" || strings.EqualFold(c.Query("title_only"), "true")
sort := strings.TrimSpace(c.Query("sort"))
if sort == "" {
sort = h.Settings.DefaultFeedSort()
}
q := service.PostListQuery{
BoardID: uint(boardID),
UserID: uint(userID),
@@ -976,7 +981,7 @@ func (h *Handlers) APIPosts(c *gin.Context) {
Tag: tag,
Author: author,
TitleOnly: titleOnly,
Sort: c.DefaultQuery("sort", "reply"),
Sort: sort,
ViewerID: h.currentUserID(c),
ViewerIsAdmin: h.isAdmin(c),
}

View File

@@ -50,6 +50,7 @@ const (
SettingNavShowShowcase = "nav_show_showcase"
SettingFooterShowShowcase = "footer_show_showcase"
SettingFeedListStyle = "feed_list_style"
SettingFeedSortTabs = "feed_sort_tabs"
// 伪静态键名见 permalink.goSettingPermalinkEnabled / SettingPermalinkExt
@@ -159,6 +160,7 @@ type ForumLimits struct {
FooterShowShowcase bool `json:"footer_show_showcase"`
FeedListStyle string `json:"feed_list_style"`
FeedSortTabs []FeedSortTab `json:"feed_sort_tabs"`
PermalinkEnabled bool `json:"permalink_enabled"`
PermalinkExt string `json:"permalink_ext"`
@@ -170,12 +172,23 @@ type AsideWidget struct {
Enabled bool `json:"enabled"`
}
// FeedSortTab 首页 Feed 排序标签(名称 / 顺序 / 启停)
type FeedSortTab struct {
ID string `json:"id"` // reply | latest | hot
Label string `json:"label"` // 展示名
Enabled bool `json:"enabled"`
}
const (
AsideWidgetTagCloud = "tag_cloud"
AsideWidgetRecentComments = "recent_comments"
AsideWidgetRecentUsers = "recent_users"
AsideWidgetFriendLinks = "friend_links"
AsideWidgetShowcase = "showcase"
FeedSortReply = "reply"
FeedSortLatest = "latest"
FeedSortHot = "hot"
)
var asideWidgetDefaultOrder = []string{
@@ -186,6 +199,18 @@ var asideWidgetDefaultOrder = []string{
AsideWidgetShowcase,
}
var feedSortTabDefaultOrder = []string{
FeedSortReply,
FeedSortLatest,
FeedSortHot,
}
var feedSortTabDefaultLabels = map[string]string{
FeedSortReply: "新评论",
FeedSortLatest: "新帖子",
FeedSortHot: "推荐帖",
}
// ForumLimitsPublic 前台可见的限制(不含限流等内部配置)
type ForumLimitsPublic struct {
PostTitleMax int `json:"post_title_max"`
@@ -215,6 +240,7 @@ type ForumLimitsPublic struct {
FooterShowShowcase bool `json:"footer_show_showcase"`
FeedListStyle string `json:"feed_list_style"`
FeedSortTabs []FeedSortTab `json:"feed_sort_tabs"`
PermalinkEnabled bool `json:"permalink_enabled"`
PermalinkExt string `json:"permalink_ext"`
@@ -261,6 +287,7 @@ var forumSettingDefs = []settingDef{
var feedSettingDefaults = map[string]string{
SettingFeedListStyle: "title",
SettingFeedSortTabs: `[{"id":"reply","label":"新评论","enabled":true},{"id":"latest","label":"新帖子","enabled":true},{"id":"hot","label":"推荐帖","enabled":true}]`,
}
var asideSettingDefaults = map[string]string{
@@ -656,6 +683,7 @@ func (s *ForumSettingsService) Limits() ForumLimits {
FooterShowShowcase: s.FooterShowShowcase(),
FeedListStyle: s.FeedListStyle(),
FeedSortTabs: s.FeedSortTabs(),
PermalinkEnabled: permalink.Enabled,
PermalinkExt: permalink.Ext,
@@ -692,6 +720,7 @@ func (s *ForumSettingsService) PublicLimits() ForumLimitsPublic {
FooterShowShowcase: limits.FooterShowShowcase,
FeedListStyle: limits.FeedListStyle,
FeedSortTabs: limits.FeedSortTabs,
PermalinkEnabled: limits.PermalinkEnabled,
PermalinkExt: limits.PermalinkExt,
@@ -776,6 +805,14 @@ func (s *ForumSettingsService) UpdateLimits(in ForumLimits) error {
if err := s.setString(SettingFeedListStyle, style); err != nil {
return err
}
tabs := NormalizeFeedSortTabs(in.FeedSortTabs)
tabsJSON, err := json.Marshal(tabs)
if err != nil {
return err
}
if err := s.setString(SettingFeedSortTabs, string(tabsJSON)); err != nil {
return err
}
return nil
}
@@ -1064,6 +1101,76 @@ func (s *ForumSettingsService) FeedListStyle() string {
return v
}
func isValidFeedSortTabID(id string) bool {
switch id {
case FeedSortReply, FeedSortLatest, FeedSortHot:
return true
default:
return false
}
}
// NormalizeFeedSortTabs 校验并补全 Feed 排序标签(顺序保留,至少一项启用)
func NormalizeFeedSortTabs(in []FeedSortTab) []FeedSortTab {
seen := make(map[string]bool, len(feedSortTabDefaultOrder))
out := make([]FeedSortTab, 0, len(feedSortTabDefaultOrder))
for _, t := range in {
id := strings.TrimSpace(t.ID)
if !isValidFeedSortTabID(id) || seen[id] {
continue
}
seen[id] = true
label := strings.TrimSpace(t.Label)
if label == "" {
label = feedSortTabDefaultLabels[id]
}
out = append(out, FeedSortTab{ID: id, Label: label, Enabled: t.Enabled})
}
for _, id := range feedSortTabDefaultOrder {
if seen[id] {
continue
}
out = append(out, FeedSortTab{
ID: id,
Label: feedSortTabDefaultLabels[id],
Enabled: true,
})
}
hasEnabled := false
for _, t := range out {
if t.Enabled {
hasEnabled = true
break
}
}
if !hasEnabled && len(out) > 0 {
out[0].Enabled = true
}
return out
}
func (s *ForumSettingsService) FeedSortTabs() []FeedSortTab {
raw := strings.TrimSpace(s.getString(SettingFeedSortTabs, ""))
if raw == "" {
raw = feedSettingDefaults[SettingFeedSortTabs]
}
var tabs []FeedSortTab
if raw != "" {
_ = json.Unmarshal([]byte(raw), &tabs)
}
return NormalizeFeedSortTabs(tabs)
}
// DefaultFeedSort 返回配置中第一个启用的排序键
func (s *ForumSettingsService) DefaultFeedSort() string {
for _, t := range s.FeedSortTabs() {
if t.Enabled {
return t.ID
}
}
return FeedSortReply
}
// MailConfig 读取 SMTP 配置(密码不回显明文)
func (s *ForumSettingsService) MailConfig() MailConfig {
port, _ := strconv.Atoi(s.getString(SettingSMTPPort, "465"))