fix: 软刷新齐套前保留旧画面,避免一点击就卸光
Logo/下拉静默预热后一次覆盖;commit 不再先 reset/loading;顺带会话预取与可配置 Feed 排序标签。
This commit is contained in:
@@ -17,6 +17,7 @@ import PageLoader from './components/PageLoader';
|
|||||||
import AuthPageFallback from './components/AuthPageFallback';
|
import AuthPageFallback from './components/AuthPageFallback';
|
||||||
import { Toaster } from './components/ui/sonner';
|
import { Toaster } from './components/ui/sonner';
|
||||||
import PullToRefresh from './components/PullToRefresh';
|
import PullToRefresh from './components/PullToRefresh';
|
||||||
|
import TopProgressBar from './components/TopProgressBar';
|
||||||
import { lazyWithRetry } from './utils/lazyWithRetry';
|
import { lazyWithRetry } from './utils/lazyWithRetry';
|
||||||
|
|
||||||
const HomePage = lazyWithRetry(() => import('./pages/HomePage'));
|
const HomePage = lazyWithRetry(() => import('./pages/HomePage'));
|
||||||
@@ -87,10 +88,10 @@ const router = createBrowserRouter(
|
|||||||
<Route path="/favorites" element={<FavoritesPage />} />
|
<Route path="/favorites" element={<FavoritesPage />} />
|
||||||
<Route path="/projects" element={<ProjectsPage />} />
|
<Route path="/projects" element={<ProjectsPage />} />
|
||||||
<Route path="/links" element={<LinksPage />} />
|
<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="/messages" element={<MessagesPage />} />
|
||||||
<Route path="/page/:slug" element={<Suspense fallback={<PageLoader />}><SitePageView /></Suspense>} />
|
<Route path="/page/:slug" element={<Suspense fallback={null}><SitePageView /></Suspense>} />
|
||||||
<Route path="*" element={<Suspense fallback={<PageLoader />}><NotFoundPage /></Suspense>} />
|
<Route path="*" element={<Suspense fallback={null}><NotFoundPage /></Suspense>} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Suspense fallback={<PageLoader fullScreen />}><NotFoundPage standalone /></Suspense>} />
|
<Route path="*" element={<Suspense fallback={<PageLoader fullScreen />}><NotFoundPage standalone /></Suspense>} />
|
||||||
</Route>,
|
</Route>,
|
||||||
@@ -103,6 +104,7 @@ export default function App() {
|
|||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<RouterProvider router={router} />
|
<RouterProvider router={router} />
|
||||||
|
<TopProgressBar />
|
||||||
<PullToRefresh />
|
<PullToRefresh />
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
|||||||
@@ -215,6 +215,20 @@ export const DEFAULT_ASIDE_WIDGETS: AsideWidget[] = [
|
|||||||
{ id: 'showcase', enabled: false },
|
{ 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 {
|
export interface ForumLimits {
|
||||||
post_edit_window_hours: number;
|
post_edit_window_hours: number;
|
||||||
comment_edit_window_minutes: number;
|
comment_edit_window_minutes: number;
|
||||||
@@ -255,6 +269,8 @@ export interface ForumLimits {
|
|||||||
footer_show_showcase: boolean;
|
footer_show_showcase: boolean;
|
||||||
/** 首页列表样式:title 仅标题 / thumbnail 缩略图 */
|
/** 首页列表样式:title 仅标题 / thumbnail 缩略图 */
|
||||||
feed_list_style: 'title' | 'excerpt' | 'thumbnail';
|
feed_list_style: 'title' | 'excerpt' | 'thumbnail';
|
||||||
|
/** 首页排序标签:名称、顺序、启停 */
|
||||||
|
feed_sort_tabs: FeedSortTab[];
|
||||||
/** 伪静态(固定链接)开关 */
|
/** 伪静态(固定链接)开关 */
|
||||||
permalink_enabled: boolean;
|
permalink_enabled: boolean;
|
||||||
/** 伪静态后缀,不含点,如 html / htm */
|
/** 伪静态后缀,不含点,如 html / htm */
|
||||||
@@ -285,6 +301,7 @@ export interface ForumLimitsPublic {
|
|||||||
nav_show_showcase: boolean;
|
nav_show_showcase: boolean;
|
||||||
footer_show_showcase: boolean;
|
footer_show_showcase: boolean;
|
||||||
feed_list_style: 'title' | 'excerpt' | 'thumbnail';
|
feed_list_style: 'title' | 'excerpt' | 'thumbnail';
|
||||||
|
feed_sort_tabs: FeedSortTab[];
|
||||||
permalink_enabled: boolean;
|
permalink_enabled: boolean;
|
||||||
permalink_ext: string;
|
permalink_ext: string;
|
||||||
/** 是否上报前台路由 pageview(与后台监控采集开关同步) */
|
/** 是否上报前台路由 pageview(与后台监控采集开关同步) */
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { CalendarCheck, Check, Gift, Loader2 } from 'lucide-react';
|
import { CalendarCheck, Check, Gift, Loader2 } from 'lucide-react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
|
||||||
import { useCheckIn } from '../hooks/useCheckIn';
|
import { useCheckIn } from '../hooks/useCheckIn';
|
||||||
import { useAuth } from '../hooks/useAuth';
|
import { useAuth } from '../hooks/useAuth';
|
||||||
import { loginPath } from '../utils/authRedirect';
|
import { loginPath } from '../utils/authRedirect';
|
||||||
@@ -8,8 +7,13 @@ import { loginPath } from '../utils/authRedirect';
|
|||||||
/** 右侧栏首块底部:每日签到 */
|
/** 右侧栏首块底部:每日签到 */
|
||||||
export default function AsideCheckInStrip() {
|
export default function AsideCheckInStrip() {
|
||||||
const nav = useNavigate();
|
const nav = useNavigate();
|
||||||
const { user } = useAuth();
|
const { user, loading: authLoading } = useAuth();
|
||||||
const { status, loading, busy, doCheckIn } = useCheckIn();
|
const { status, loading, busy, doCheckIn } = useCheckIn(!!user && !authLoading);
|
||||||
|
|
||||||
|
// 鉴权未完成:空白,避免「登录签到」→「今日已签到」闪一下
|
||||||
|
if (authLoading) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return (
|
return (
|
||||||
@@ -37,17 +41,14 @@ export default function AsideCheckInStrip() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading && !status) {
|
// 登录用户:status 未到齐前不渲染最终态
|
||||||
return (
|
if (loading || !status) {
|
||||||
<div className="widget-checkin" aria-busy="true" aria-label="签到加载中">
|
return null;
|
||||||
<Skeleton className="widget-checkin-skeleton" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const checkedIn = !!status?.checked_in;
|
const checkedIn = !!status.checked_in;
|
||||||
const streak = status?.streak ?? 0;
|
const streak = status.streak ?? 0;
|
||||||
const todayPoints = status?.today_points ?? 5;
|
const todayPoints = status.today_points ?? 5;
|
||||||
|
|
||||||
const meta = checkedIn
|
const meta = checkedIn
|
||||||
? (streak > 0 ? `连续 ${streak} 天 · 今日已获得 ${todayPoints} 积分` : `今日已获得 ${todayPoints} 积分`)
|
? (streak > 0 ? `连续 ${streak} 天 · 今日已获得 ${todayPoints} 积分` : `今日已获得 ${todayPoints} 积分`)
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,40 +1,55 @@
|
|||||||
import { useRef } from 'react';
|
import { useMemo, useRef } from 'react';
|
||||||
import { boardPath, type PermalinkOpts } from '../utils/permalink';
|
import { boardPath, type PermalinkOpts } from '../utils/permalink';
|
||||||
import { getCachedForumLimits } from '../hooks/useForumLimits';
|
import { getCachedForumLimits, useForumLimits } from '../hooks/useForumLimits';
|
||||||
import { Clock, MessageCircle, BadgeCheck } from 'lucide-react';
|
import { Clock, MessageCircle, BadgeCheck, Loader2 } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { moveTabIndex } from '../hooks/useOverlayA11y';
|
import { moveTabIndex } from '../hooks/useOverlayA11y';
|
||||||
|
import {
|
||||||
|
enabledFeedSortTabs,
|
||||||
|
getDefaultFeedSort,
|
||||||
|
normalizeFeedSortTabs,
|
||||||
|
} from '../utils/feedSortTabs';
|
||||||
|
import type { FeedSortTab } from '../api/types';
|
||||||
|
|
||||||
export type FeedSort = 'latest' | 'reply' | 'hot';
|
export type FeedSort = 'latest' | 'reply' | 'hot';
|
||||||
|
|
||||||
const SORT_OPTIONS: {
|
const SORT_META: Record<FeedSort, { hint: string; icon: typeof Clock }> = {
|
||||||
key: FeedSort;
|
reply: { hint: '最近有人评论', icon: MessageCircle },
|
||||||
label: string;
|
latest: { hint: '按发帖时间', icon: Clock },
|
||||||
hint: string;
|
hot: { hint: '站内推荐', icon: BadgeCheck },
|
||||||
icon: typeof Clock;
|
};
|
||||||
}[] = [
|
|
||||||
{ key: 'reply', label: '新评论', hint: '最近有人评论', icon: MessageCircle },
|
|
||||||
{ key: 'latest', label: '新帖子', hint: '按发帖时间', icon: Clock },
|
|
||||||
{ key: 'hot', label: '推荐帖', hint: '站内推荐', icon: BadgeCheck },
|
|
||||||
];
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
value: FeedSort;
|
value: FeedSort;
|
||||||
onChange: (sort: FeedSort) => void;
|
onChange: (sort: FeedSort) => void;
|
||||||
postTotal?: number;
|
postTotal?: number;
|
||||||
|
/** 正在加载、尚未提交到画面的目标排序 */
|
||||||
|
pendingValue?: FeedSort | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 解析 URL sort;缺省为新评论(reply) */
|
/** 当前配置下的默认排序(读缓存,供非 hook 路径) */
|
||||||
export function parseFeedSort(raw: string | null): FeedSort {
|
export function getDefaultFeedSortFromCache(): FeedSort {
|
||||||
if (raw === 'latest' || raw === 'hot') return raw;
|
return getDefaultFeedSort(getCachedForumLimits().feed_sort_tabs);
|
||||||
return 'reply';
|
}
|
||||||
|
|
||||||
|
/** 解析 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(
|
export function buildHomeUrl(
|
||||||
boardId: number,
|
boardId: number,
|
||||||
sort: FeedSort = 'reply',
|
sort?: FeedSort,
|
||||||
opts?: { keyword?: string; tag?: string; author?: string; titleOnly?: boolean; permalink?: PermalinkOpts },
|
opts?: { keyword?: string; tag?: string; author?: string; titleOnly?: boolean; permalink?: PermalinkOpts },
|
||||||
) {
|
) {
|
||||||
|
const def = getDefaultFeedSortFromCache();
|
||||||
|
const effective = sort ?? def;
|
||||||
const p = new URLSearchParams();
|
const p = new URLSearchParams();
|
||||||
const tag = opts?.tag?.trim();
|
const tag = opts?.tag?.trim();
|
||||||
const keyword = opts?.keyword?.trim();
|
const keyword = opts?.keyword?.trim();
|
||||||
@@ -48,8 +63,8 @@ export function buildHomeUrl(
|
|||||||
} else if (author) {
|
} else if (author) {
|
||||||
p.set('author', author);
|
p.set('author', author);
|
||||||
}
|
}
|
||||||
// 默认 reply 不写进 URL;latest / hot 显式带上
|
// 默认排序不写进 URL
|
||||||
if (sort !== 'reply') p.set('sort', sort);
|
if (effective !== def) p.set('sort', effective);
|
||||||
const qs = p.toString();
|
const qs = p.toString();
|
||||||
|
|
||||||
if (boardId) {
|
if (boardId) {
|
||||||
@@ -59,25 +74,39 @@ export function buildHomeUrl(
|
|||||||
return qs ? `/?${qs}` : '/';
|
return qs ? `/?${qs}` : '/';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function feedSortLabel(sort: FeedSort): string {
|
export function feedSortLabel(sort: FeedSort, tabs?: FeedSortTab[] | null): string {
|
||||||
return SORT_OPTIONS.find(o => o.key === sort)?.label ?? '帖子列表';
|
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 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 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;
|
if (next == null) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onChange(SORT_OPTIONS[next].key);
|
onChange(options[next].key);
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const tabs = listRef.current?.querySelectorAll<HTMLElement>('[role="tab"]');
|
const tabs = listRef.current?.querySelectorAll<HTMLElement>('[role="tab"]');
|
||||||
tabs?.[next]?.focus();
|
tabs?.[next]?.focus();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (options.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="feed-toolbar">
|
<div className="feed-toolbar">
|
||||||
<div
|
<div
|
||||||
@@ -87,21 +116,27 @@ export default function FeedSortBar({ value, onChange, postTotal }: Props) {
|
|||||||
aria-label="帖子排序"
|
aria-label="帖子排序"
|
||||||
onKeyDown={onKeyDown}
|
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
|
<button
|
||||||
key={key}
|
key={key}
|
||||||
type="button"
|
type="button"
|
||||||
role="tab"
|
role="tab"
|
||||||
tabIndex={activeIndex === i ? 0 : -1}
|
tabIndex={activeIndex === i ? 0 : -1}
|
||||||
aria-selected={value === key}
|
aria-selected={value === key}
|
||||||
|
aria-busy={pending || undefined}
|
||||||
title={`${label} · ${hint}`}
|
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)}
|
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>
|
<span className="feed-sort-tab__label">{label}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
{postTotal != null && (
|
{postTotal != null && (
|
||||||
<span className="feed-toolbar__count">共 {postTotal} 条</span>
|
<span className="feed-toolbar__count">共 {postTotal} 条</span>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ type PageLoaderProps = {
|
|||||||
fullScreen?: boolean;
|
fullScreen?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 通用路由懒加载占位;首页请用 FeedPageSkeleton,避免非 Feed 页闪出鱼骨骨架 */
|
/** 通用路由懒加载占位(后台 / 全屏独立页);前台 MainLayout 用顶栏进度条 + 空白 */
|
||||||
export default function PageLoader({ fullScreen = false }: PageLoaderProps) {
|
export default function PageLoader({ fullScreen = false }: PageLoaderProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -61,6 +61,14 @@ function PostListItem({ post, sort = 'latest', boardId = 0, onSelect }: Props) {
|
|||||||
openPost();
|
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 = (
|
const titleRow = (
|
||||||
<div className="post-title-row">
|
<div className="post-title-row">
|
||||||
{post.pinned && (
|
{post.pinned && (
|
||||||
@@ -81,6 +89,12 @@ function PostListItem({ post, sort = 'latest', boardId = 0, onSelect }: Props) {
|
|||||||
{post.status === 'rejected' && (
|
{post.status === 'rejected' && (
|
||||||
<span className="post-status-badge post-status-badge--rejected" title="未通过">未通过</span>
|
<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' && (
|
{post.post_type === 'question' && (
|
||||||
<span
|
<span
|
||||||
className={`post-qa-badge${post.question_resolved ? ' post-qa-badge--resolved' : ' post-qa-badge--open'}`}
|
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' ? '已开奖' : '抽奖'}
|
{post.lottery_status === 'drawn' ? '已开奖' : '抽奖'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<a href={href} className="post-title" onClick={onTitleClick}>
|
</span>
|
||||||
{post.title}
|
)}
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { Skeleton } from '@/components/ui/skeleton';
|
|
||||||
import type { ForumLimitsPublic } from '../api/types';
|
import type { ForumLimitsPublic } from '../api/types';
|
||||||
|
|
||||||
export type FeedListStyle = ForumLimitsPublic['feed_list_style'];
|
export type FeedListStyle = ForumLimitsPublic['feed_list_style'];
|
||||||
@@ -11,69 +10,3 @@ export function feedListRowEstimate(style: FeedListStyle): number {
|
|||||||
default: return 64;
|
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Loader2, ArrowDown } from 'lucide-react';
|
import { Loader2, ArrowDown } from 'lucide-react';
|
||||||
import { FEED_PULL_REFRESH_EVENT } from '../utils/feedCache';
|
import { softRefreshCurrentPage } from '../utils/softRefresh';
|
||||||
|
|
||||||
/** 触发刷新的下拉距离(px) */
|
/** 触发刷新的下拉距离(px) */
|
||||||
const REFRESH_THRESHOLD = 68;
|
const REFRESH_THRESHOLD = 68;
|
||||||
@@ -31,6 +31,9 @@ function pickScrollEl(): HTMLElement | null {
|
|||||||
const page = document.querySelector<HTMLElement>('.page-wrap:not(.page-wrap--feed)');
|
const page = document.querySelector<HTMLElement>('.page-wrap:not(.page-wrap--feed)');
|
||||||
if (page) return page;
|
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');
|
const admin = document.querySelector<HTMLElement>('.admin-main');
|
||||||
if (admin) return admin;
|
if (admin) return admin;
|
||||||
|
|
||||||
@@ -77,7 +80,7 @@ function isNestedScrollBlocking(target: EventTarget | null, bound: HTMLElement):
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 手机端下拉刷新:在内部滚动容器顶部下拉后整页重载。
|
* 手机端下拉刷新:在内部滚动容器顶部下拉后强制刷新当前页。
|
||||||
* (浏览器原生 PTR 依赖 document 滚动,与本站 app-shell 布局不兼容。)
|
* (浏览器原生 PTR 依赖 document 滚动,与本站 app-shell 布局不兼容。)
|
||||||
*
|
*
|
||||||
* 挂在 Router 外,故用 MutationObserver 在路由切换后重绑滚动容器。
|
* 挂在 Router 外,故用 MutationObserver 在路由切换后重绑滚动容器。
|
||||||
@@ -168,19 +171,22 @@ export default function PullToRefresh() {
|
|||||||
setSettling(true);
|
setSettling(true);
|
||||||
setPull(REFRESH_THRESHOLD * 0.7);
|
setPull(REFRESH_THRESHOLD * 0.7);
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
// Feed 页:应用内强制重拉(保留 SPA 其它状态);其它页仍整页 reload
|
// 后台 / 登录页:整页 reload;前台:静默软刷新(无进度条,齐套后一次覆盖)
|
||||||
const isFeed = !!(
|
const isAdminOrAuth = !!(
|
||||||
document.querySelector('.main-content--feed-mobile-scroll')
|
document.querySelector('.admin-main')
|
||||||
|| document.querySelector('.page-wrap--feed .post-list-scroll')
|
|| document.querySelector('.auth-page')
|
||||||
);
|
);
|
||||||
if (isFeed) {
|
if (isAdminOrAuth) {
|
||||||
window.dispatchEvent(new Event(FEED_PULL_REFRESH_EVENT));
|
window.location.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void softRefreshCurrentPage()
|
||||||
|
.catch(() => undefined)
|
||||||
|
.finally(() => {
|
||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
setSettling(true);
|
setSettling(true);
|
||||||
setPull(0);
|
setPull(0);
|
||||||
return;
|
});
|
||||||
}
|
|
||||||
window.location.reload();
|
|
||||||
}, 180);
|
}, 180);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { ListTree, MessageCircle, Tags, Link2, UserPlus } from 'lucide-react';
|
import { ListTree, MessageCircle, Tags, Link2, UserPlus } from 'lucide-react';
|
||||||
import { useLocation, useSearchParams, useNavigate } from 'react-router-dom';
|
import { useLocation, useSearchParams, useNavigate } from 'react-router-dom';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import type { AsideWidget, RecentComment, RecentUser, TagCount, User, ForumStats, FriendLink } from '../api/types';
|
import type { AsideWidget, RecentComment, RecentUser, TagCount, User, ForumStats, FriendLink } from '../api/types';
|
||||||
import type { PostHeading } from '../utils/postHeadings';
|
import type { PostHeading } from '../utils/postHeadings';
|
||||||
@@ -31,7 +30,7 @@ interface Props {
|
|||||||
tagsLoading?: boolean;
|
tagsLoading?: boolean;
|
||||||
stats?: ForumStats | null;
|
stats?: ForumStats | null;
|
||||||
onPostClick: (id: number, opts?: { floor?: number }) => void;
|
onPostClick: (id: number, opts?: { floor?: number }) => void;
|
||||||
/** 首次拉取中,显示骨架避免空态闪烁 */
|
/** 首次拉取中:不渲染该块内容(由冷启动门闩保证首屏已齐或空白) */
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
/** 右侧栏可选组件顺序与开关 */
|
/** 右侧栏可选组件顺序与开关 */
|
||||||
asideWidgets: AsideWidget[];
|
asideWidgets: AsideWidget[];
|
||||||
@@ -39,35 +38,6 @@ interface Props {
|
|||||||
postDetail?: PostDetailAside | null;
|
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({
|
export default function RightPanel({
|
||||||
recentComments,
|
recentComments,
|
||||||
recentUsers,
|
recentUsers,
|
||||||
@@ -181,9 +151,7 @@ export default function RightPanel({
|
|||||||
最新评论
|
最新评论
|
||||||
</div>
|
</div>
|
||||||
<div className="widget-card-body">
|
<div className="widget-card-body">
|
||||||
{loading && commentList.length === 0 ? (
|
{loading && commentList.length === 0 ? null : commentList.length === 0 ? (
|
||||||
<CommentSkeleton />
|
|
||||||
) : commentList.length === 0 ? (
|
|
||||||
<div className="widget-empty">暂无评论</div>
|
<div className="widget-empty">暂无评论</div>
|
||||||
) : commentList.map(item => (
|
) : commentList.map(item => (
|
||||||
<div
|
<div
|
||||||
@@ -241,9 +209,7 @@ export default function RightPanel({
|
|||||||
最新注册
|
最新注册
|
||||||
</div>
|
</div>
|
||||||
<div className="widget-card-body widget-card-body--users">
|
<div className="widget-card-body widget-card-body--users">
|
||||||
{loading && userList.length === 0 ? (
|
{loading && userList.length === 0 ? null : userList.length === 0 ? (
|
||||||
<UserSkeleton />
|
|
||||||
) : userList.length === 0 ? (
|
|
||||||
<div className="widget-empty">暂无用户</div>
|
<div className="widget-empty">暂无用户</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="widget-recent-users-grid">
|
<div className="widget-recent-users-grid">
|
||||||
|
|||||||
@@ -3,21 +3,40 @@ import { Globe2 } from 'lucide-react';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import type { CommunityShowcaseItem } from '../api/types';
|
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() {
|
export default function ShowcaseAsideWidget() {
|
||||||
const nav = useNavigate();
|
const nav = useNavigate();
|
||||||
const [items, setItems] = useState<CommunityShowcaseItem[]>([]);
|
const cached = getSessionSnapshot<CommunityShowcaseItem[]>(SHOWCASE_KEY);
|
||||||
const [loading, setLoading] = useState(true);
|
const [items, setItems] = useState<CommunityShowcaseItem[]>(() => cached ?? []);
|
||||||
|
const [loading, setLoading] = useState(() => cached === undefined);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
const hit = getSessionSnapshot<CommunityShowcaseItem[]>(SHOWCASE_KEY);
|
||||||
|
if (hit !== undefined) {
|
||||||
|
setItems(hit);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
api.communityShowcase()
|
api.communityShowcase()
|
||||||
.then((r) => {
|
.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(() => {
|
.catch(() => {
|
||||||
if (!cancelled) setItems([]);
|
if (!cancelled) {
|
||||||
|
setSessionSnapshot(SHOWCASE_KEY, []);
|
||||||
|
setItems([]);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (!cancelled) setLoading(false);
|
if (!cancelled) setLoading(false);
|
||||||
@@ -25,6 +44,43 @@ export default function ShowcaseAsideWidget() {
|
|||||||
return () => { cancelled = true; };
|
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 (
|
return (
|
||||||
<div className="widget-card widget-card--showcase">
|
<div className="widget-card widget-card--showcase">
|
||||||
<div className="widget-card-head widget-card-head--split">
|
<div className="widget-card-head widget-card-head--split">
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import type { Board } from '../api/types';
|
|||||||
import type { PostHeading } from '../utils/postHeadings';
|
import type { PostHeading } from '../utils/postHeadings';
|
||||||
import { useAuth } from '../hooks/useAuth';
|
import { useAuth } from '../hooks/useAuth';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
|
||||||
import { buildHomeUrl, parseFeedSort } from './FeedSortBar';
|
import { buildHomeUrl, parseFeedSort } from './FeedSortBar';
|
||||||
import { navigateFeed } from '../utils/feedCache';
|
import { navigateFeed } from '../utils/feedCache';
|
||||||
|
import { transitionTo } from '../utils/spaTransition';
|
||||||
import BoardIconDisplay from './BoardIconDisplay';
|
import BoardIconDisplay from './BoardIconDisplay';
|
||||||
import { getBoardThemeIndex } from '../utils/boardTheme';
|
import { getBoardThemeIndex } from '../utils/boardTheme';
|
||||||
import ArticleOutline from './ArticleOutline';
|
import ArticleOutline from './ArticleOutline';
|
||||||
@@ -62,11 +62,11 @@ export default function Sidebar({
|
|||||||
const nav = useNavigate();
|
const nav = useNavigate();
|
||||||
const loc = useLocation();
|
const loc = useLocation();
|
||||||
const [params] = useSearchParams();
|
const [params] = useSearchParams();
|
||||||
const sort = parseFeedSort(params.get('sort'));
|
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const isAdmin = user?.role === 'admin';
|
const isAdmin = user?.role === 'admin';
|
||||||
const { navPages } = useSitePages();
|
const { navPages } = useSitePages();
|
||||||
const { limits } = useForumLimits();
|
const { limits } = useForumLimits();
|
||||||
|
const sort = parseFeedSort(params.get('sort'), limits.feed_sort_tabs);
|
||||||
const showFriendLinksNav = limits.nav_show_friend_links !== false;
|
const showFriendLinksNav = limits.nav_show_friend_links !== false;
|
||||||
const showShowcaseNav = !!limits.nav_show_showcase;
|
const showShowcaseNav = !!limits.nav_show_showcase;
|
||||||
const showSiteSection = navPages.length > 0 || showFriendLinksNav || showShowcaseNav;
|
const showSiteSection = navPages.length > 0 || showFriendLinksNav || showShowcaseNav;
|
||||||
@@ -137,22 +137,12 @@ export default function Sidebar({
|
|||||||
<div className="sidebar-section">浏览</div>
|
<div className="sidebar-section">浏览</div>
|
||||||
<nav className="sidebar-nav">
|
<nav className="sidebar-nav">
|
||||||
{feedNavLink('all', buildHomeUrl(0, sort, permalinkOpts), '全部帖子', <Home aria-hidden />, 0)}
|
{feedNavLink('all', buildHomeUrl(0, sort, permalinkOpts), '全部帖子', <Home aria-hidden />, 0)}
|
||||||
{user && navItem('favorites', '我的收藏', <Star aria-hidden />, () => nav('/favorites'))}
|
{user && navItem('favorites', '我的收藏', <Star aria-hidden />, () => void transitionTo(nav, '/favorites'))}
|
||||||
{navItem('projects', '开源码桶', <FolderGit2 aria-hidden />, () => nav('/projects'))}
|
{navItem('projects', '开源码桶', <FolderGit2 aria-hidden />, () => void transitionTo(nav, '/projects'))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{(boardsLoading && boards.length === 0) ? (
|
{(boardsLoading && boards.length === 0) ? (
|
||||||
<>
|
|
||||||
<div className="sidebar-section sidebar-section--boards">板块</div>
|
<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 ? (
|
) : boards.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<div className="sidebar-section sidebar-section--boards">板块</div>
|
<div className="sidebar-section sidebar-section--boards">板块</div>
|
||||||
@@ -198,10 +188,10 @@ export default function Sidebar({
|
|||||||
<>
|
<>
|
||||||
<div className="sidebar-section sidebar-section--spaced">站点</div>
|
<div className="sidebar-section sidebar-section--spaced">站点</div>
|
||||||
<nav className="sidebar-nav">
|
<nav className="sidebar-nav">
|
||||||
{showFriendLinksNav && navItem('links', '友情链接', <Link2 aria-hidden />, () => nav('/links'))}
|
{showFriendLinksNav && navItem('links', '友情链接', <Link2 aria-hidden />, () => void transitionTo(nav, '/links'))}
|
||||||
{showShowcaseNav && navItem('showcase', '开源展柜', <Globe2 aria-hidden />, () => nav('/showcase'))}
|
{showShowcaseNav && navItem('showcase', '开源展柜', <Globe2 aria-hidden />, () => void transitionTo(nav, '/showcase'))}
|
||||||
{navPages.map(p => (
|
{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>
|
</nav>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import type { TagCount } from '../api/types';
|
import type { TagCount } from '../api/types';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -63,20 +62,7 @@ export default function TagCloud({ tags, loading = false, activeTag = '' }: Prop
|
|||||||
}, [tags]);
|
}, [tags]);
|
||||||
|
|
||||||
if (loading && tags.length === 0) {
|
if (loading && tags.length === 0) {
|
||||||
return (
|
return null;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
|
|||||||
75
frontend/src/components/TopProgressBar.tsx
Normal file
75
frontend/src/components/TopProgressBar.tsx
Normal 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
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import { useVirtualizer } from '@tanstack/react-virtual';
|
|||||||
import { Inbox, SearchX } from 'lucide-react';
|
import { Inbox, SearchX } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import PostListItem from './PostListItem';
|
import PostListItem from './PostListItem';
|
||||||
import PostListSkeleton, { feedListRowEstimate } from './PostListSkeleton';
|
import { feedListRowEstimate } from './PostListSkeleton';
|
||||||
import FeedPagination from './FeedPagination';
|
import FeedPagination from './FeedPagination';
|
||||||
import { InFlowSiteFooter } from './SiteFooter';
|
import { InFlowSiteFooter } from './SiteFooter';
|
||||||
import { useAuth } from '../hooks/useAuth';
|
import { useAuth } from '../hooks/useAuth';
|
||||||
@@ -14,6 +14,7 @@ import { loginPath } from '../utils/authRedirect';
|
|||||||
import { dispatchOpenPostSearch } from '../hooks/usePostSearch';
|
import { dispatchOpenPostSearch } from '../hooks/usePostSearch';
|
||||||
import type { PostItem } from '../api/types';
|
import type { PostItem } from '../api/types';
|
||||||
import type { FeedSort } from './FeedSortBar';
|
import type { FeedSort } from './FeedSortBar';
|
||||||
|
import { getDefaultFeedSortFromCache } from './FeedSortBar';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
posts: PostItem[];
|
posts: PostItem[];
|
||||||
@@ -56,7 +57,7 @@ function getMobileFeedScrollEl(): HTMLElement | null {
|
|||||||
|
|
||||||
export default function VirtualPostList({
|
export default function VirtualPostList({
|
||||||
posts,
|
posts,
|
||||||
sort = 'reply',
|
sort = getDefaultFeedSortFromCache(),
|
||||||
loading,
|
loading,
|
||||||
hasMore,
|
hasMore,
|
||||||
showPagination,
|
showPagination,
|
||||||
@@ -242,9 +243,7 @@ export default function VirtualPostList({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="post-list-scroll" ref={parentRef}>
|
<div className="post-list-scroll" ref={parentRef}>
|
||||||
{isInitialLoad ? (
|
{isInitialLoad ? null : isEmpty ? (
|
||||||
<PostListSkeleton listStyle={feedStyle} />
|
|
||||||
) : isEmpty ? (
|
|
||||||
<div className="empty-feed" role="status">
|
<div className="empty-feed" role="status">
|
||||||
{isSearchEmpty
|
{isSearchEmpty
|
||||||
? <SearchX className="empty-feed-icon" aria-hidden size={36} strokeWidth={1.5} />
|
? <SearchX className="empty-feed-icon" aria-hidden size={36} strokeWidth={1.5} />
|
||||||
|
|||||||
89
frontend/src/components/admin/FeedSortTabList.tsx
Normal file
89
frontend/src/components/admin/FeedSortTabList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 { api } from '../api/client';
|
||||||
import type { User } from '../api/types';
|
import type { User } from '../api/types';
|
||||||
|
import { clearAllFeedCache } from '../utils/feedCache';
|
||||||
|
import { clearSessionSnapshots } from '../utils/sessionPageCache';
|
||||||
|
|
||||||
interface AuthCtx {
|
interface AuthCtx {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
@@ -32,6 +34,21 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
// 初始化只拉一次用户信息
|
// 初始化只拉一次用户信息
|
||||||
useEffect(() => { refresh(); }, [refresh]);
|
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 () => {
|
const logout = async () => {
|
||||||
await api.logout();
|
await api.logout();
|
||||||
setUser(null);
|
setUser(null);
|
||||||
|
|||||||
@@ -2,38 +2,115 @@ import { useCallback, useEffect, useState } from 'react';
|
|||||||
import { notify } from '@/lib/notify';
|
import { notify } from '@/lib/notify';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import type { CheckInStatus } from '../api/types';
|
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';
|
import { useAuth } from './useAuth';
|
||||||
|
|
||||||
|
/** 签到会话快照键(与 prefetchRoute 共用) */
|
||||||
|
export function checkInCacheKey(userId: number) {
|
||||||
|
return `checkin:${userId}`;
|
||||||
|
}
|
||||||
|
|
||||||
/** 每日签到状态与操作(侧栏、积分页等复用) */
|
/** 每日签到状态与操作(侧栏、积分页等复用) */
|
||||||
export function useCheckIn(enabled = true) {
|
export function useCheckIn(enabled = true) {
|
||||||
const { user, refresh } = useAuth();
|
const { user, refresh } = useAuth();
|
||||||
const [status, setStatus] = useState<CheckInStatus | null>(null);
|
const userId = user?.id;
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
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 [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
const load = useCallback(() => {
|
const load = useCallback((opts?: { force?: boolean }) => {
|
||||||
if (!user || !enabled) {
|
if (!userId || !enabled) {
|
||||||
setStatus(null);
|
setStatus(null);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const key = checkInCacheKey(userId);
|
||||||
|
if (!opts?.force) {
|
||||||
|
const hit = getSessionSnapshot<CheckInStatus>(key);
|
||||||
|
if (hit !== undefined) {
|
||||||
|
setStatus(hit);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
api.checkInStatus()
|
api.checkInStatus()
|
||||||
.then(d => setStatus(d.check_in))
|
.then((d) => {
|
||||||
.catch(e => notify.error(e instanceof Error ? e.message : '加载签到状态失败'))
|
setSessionSnapshot(key, d.check_in);
|
||||||
|
setStatus(d.check_in);
|
||||||
|
})
|
||||||
|
.catch((e) => notify.error(e instanceof Error ? e.message : '加载签到状态失败'))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [user, enabled]);
|
}, [userId, enabled]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
if (!userId || !enabled) {
|
||||||
}, [load]);
|
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 () => {
|
const doCheckIn = useCallback(async () => {
|
||||||
if (!user) return;
|
if (!userId) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
const r = await api.checkIn();
|
const r = await api.checkIn();
|
||||||
notify.success(`签到成功,+${r.check_in.today_points} 积分`);
|
notify.success(`签到成功,+${r.check_in.today_points} 积分`);
|
||||||
|
setSessionSnapshot(checkInCacheKey(userId), r.check_in);
|
||||||
setStatus(r.check_in);
|
setStatus(r.check_in);
|
||||||
await refresh();
|
await refresh();
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
@@ -41,13 +118,13 @@ export function useCheckIn(enabled = true) {
|
|||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
}, [user, refresh]);
|
}, [userId, refresh]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status,
|
status,
|
||||||
loading,
|
loading,
|
||||||
busy,
|
busy,
|
||||||
doCheckIn,
|
doCheckIn,
|
||||||
reload: load,
|
reload: () => load({ force: true }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import type { ForumLimitsPublic } from '../api/types';
|
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 = {
|
const DEFAULT_LIMITS: ForumLimitsPublic = {
|
||||||
post_title_max: 128,
|
post_title_max: 128,
|
||||||
@@ -27,6 +27,7 @@ const DEFAULT_LIMITS: ForumLimitsPublic = {
|
|||||||
nav_show_showcase: false,
|
nav_show_showcase: false,
|
||||||
footer_show_showcase: false,
|
footer_show_showcase: false,
|
||||||
feed_list_style: 'title',
|
feed_list_style: 'title',
|
||||||
|
feed_sort_tabs: DEFAULT_FEED_SORT_TABS,
|
||||||
permalink_enabled: false,
|
permalink_enabled: false,
|
||||||
permalink_ext: 'html',
|
permalink_ext: 'html',
|
||||||
monitor_pageview: false,
|
monitor_pageview: false,
|
||||||
@@ -79,6 +80,11 @@ export function useForumLimits() {
|
|||||||
return { limits, loading };
|
return { limits, loading };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 冷启动 / 预热:确保 limits 已写入模块缓存 */
|
||||||
|
export function ensureForumLimitsLoaded(): Promise<ForumLimitsPublic> {
|
||||||
|
return fetchLimits();
|
||||||
|
}
|
||||||
|
|
||||||
/** 清除缓存并通知已挂载的 hook 重新拉取 */
|
/** 清除缓存并通知已挂载的 hook 重新拉取 */
|
||||||
export function invalidateForumLimitsCache() {
|
export function invalidateForumLimitsCache() {
|
||||||
cached = null;
|
cached = null;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useCallback, useMemo } from 'react';
|
|||||||
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { buildHomeUrl } from '../components/FeedSortBar';
|
import { buildHomeUrl } from '../components/FeedSortBar';
|
||||||
import { navigateFeed } from '../utils/feedCache';
|
import { navigateFeed } from '../utils/feedCache';
|
||||||
|
import { transitionTo } from '../utils/spaTransition';
|
||||||
import { notify } from '@/lib/notify';
|
import { notify } from '@/lib/notify';
|
||||||
import { parsePermalinkID, type PermalinkOpts } from '../utils/permalink';
|
import { parsePermalinkID, type PermalinkOpts } from '../utils/permalink';
|
||||||
|
|
||||||
@@ -190,7 +191,7 @@ export function usePostSearch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
saveRecentSearch({ keyword: kw, author, titleOnly, scopeBoardId });
|
saveRecentSearch({ keyword: kw, author, titleOnly, scopeBoardId });
|
||||||
nav(target);
|
void transitionTo(nav, target);
|
||||||
return true;
|
return true;
|
||||||
}, [nav, params, loc.pathname, limits, buildUrl]);
|
}, [nav, params, loc.pathname, limits, buildUrl]);
|
||||||
|
|
||||||
|
|||||||
144
frontend/src/hooks/useSessionResource.ts
Normal file
144
frontend/src/hooks/useSessionResource.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -123,6 +123,13 @@ export function useSiteBranding() {
|
|||||||
return { branding, loading };
|
return { branding, loading };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 保留当前画面,后台重拉品牌配置(下拉刷新等) */
|
||||||
|
export function refetchSiteBranding() {
|
||||||
|
inflight = null;
|
||||||
|
cacheEpoch += 1;
|
||||||
|
listeners.forEach(fn => fn());
|
||||||
|
}
|
||||||
|
|
||||||
/** 清除缓存并通知已挂载的 hook 重新拉取 */
|
/** 清除缓存并通知已挂载的 hook 重新拉取 */
|
||||||
export function invalidateSiteBrandingCache() {
|
export function invalidateSiteBrandingCache() {
|
||||||
cached = null;
|
cached = null;
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import type { SitePageSummary } from '../api/types';
|
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 cache: SitePageSummary[] | null = null;
|
||||||
let pending: Promise<SitePageSummary[]> | null = null;
|
let pending: Promise<SitePageSummary[]> | null = null;
|
||||||
|
|
||||||
/** 已发布单页摘要(页脚/侧栏导航) */
|
/** 拉取或复用站点页摘要缓存(供冷启动门闩与 hook 共用) */
|
||||||
export function useSitePages() {
|
export function ensureSitePagesLoaded(opts?: { force?: boolean }): Promise<SitePageSummary[]> {
|
||||||
const [pages, setPages] = useState<SitePageSummary[]>(cache ?? []);
|
if (opts?.force) {
|
||||||
|
cache = null;
|
||||||
useEffect(() => {
|
pending = null;
|
||||||
if (cache) {
|
|
||||||
setPages(cache);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
if (cache) return Promise.resolve(cache);
|
||||||
if (!pending) {
|
if (!pending) {
|
||||||
pending = api.pages()
|
pending = api.pages()
|
||||||
.then(d => {
|
.then(d => {
|
||||||
@@ -26,7 +26,35 @@ export function useSitePages() {
|
|||||||
})
|
})
|
||||||
.finally(() => { pending = null; });
|
.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 {
|
return {
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback, useRef, useMemo, Suspense } from 'react';
|
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 { Outlet, useNavigate, useSearchParams, useLocation } from 'react-router-dom';
|
||||||
import { Menu, Moon, Sun, Search, Plus, PanelRight, X, Mail, SlidersHorizontal } from 'lucide-react';
|
import { Menu, Moon, Sun, Search, Plus, PanelRight, X, Mail, SlidersHorizontal } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
@@ -23,7 +21,12 @@ import BackToTop from '../components/BackToTop';
|
|||||||
import { useForumLimits } from '../hooks/useForumLimits';
|
import { useForumLimits } from '../hooks/useForumLimits';
|
||||||
import { resolveAsideWidgets } from '../utils/asideWidgets';
|
import { resolveAsideWidgets } from '../utils/asideWidgets';
|
||||||
import { buildHomeUrl, parseFeedSort } from '../components/FeedSortBar';
|
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 PostSearchPanel from '../components/search/PostSearchPanel';
|
||||||
import {
|
import {
|
||||||
POST_SEARCH_OPEN_EVENT,
|
POST_SEARCH_OPEN_EVENT,
|
||||||
@@ -33,12 +36,13 @@ import { cn } from '@/lib/utils';
|
|||||||
import { getBoardThemeIndex } from '../utils/boardTheme';
|
import { getBoardThemeIndex } from '../utils/boardTheme';
|
||||||
import { loginPath } from '../utils/authRedirect';
|
import { loginPath } from '../utils/authRedirect';
|
||||||
import { openForumPost } from '../utils/openPost';
|
import { openForumPost } from '../utils/openPost';
|
||||||
import { useSiteBranding } from '../hooks/useSiteBranding';
|
import { refetchSiteBranding, useSiteBranding } from '../hooks/useSiteBranding';
|
||||||
import { useMonitorPageview } from '../hooks/useMonitorPageview';
|
import { useMonitorPageview } from '../hooks/useMonitorPageview';
|
||||||
import SiteBrandMark from '../components/SiteBrandMark';
|
import SiteBrandMark from '../components/SiteBrandMark';
|
||||||
import SiteFooter from '../components/SiteFooter';
|
import SiteFooter from '../components/SiteFooter';
|
||||||
import { userPath } from '../utils/userPath';
|
import { userPath } from '../utils/userPath';
|
||||||
import { parsePermalinkID } from '../utils/permalink';
|
import { parsePermalinkID } from '../utils/permalink';
|
||||||
|
import { ensureSitePagesLoaded } from '../hooks/useSitePages';
|
||||||
|
|
||||||
export default function MainLayout() {
|
export default function MainLayout() {
|
||||||
const { user, loading: authLoading, logout } = useAuth();
|
const { user, loading: authLoading, logout } = useAuth();
|
||||||
@@ -73,15 +77,20 @@ export default function MainLayout() {
|
|||||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [asideLoading, setAsideLoading] = useState(() => !hasCachedAside());
|
const [asideLoading, setAsideLoading] = useState(() => !hasCachedAside());
|
||||||
const [boardsLoading, setBoardsLoading] = useState(() => getCachedBoards().length === 0);
|
const [boardsLoading, setBoardsLoading] = useState(() => getCachedBoards().length === 0);
|
||||||
|
const [layoutRefreshTick, setLayoutRefreshTick] = useState(0);
|
||||||
const asideEverLoaded = useRef(false);
|
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 [boardId, setBoardId] = useState(() => {
|
||||||
const m = loc.pathname.match(/^\/board\/(\d+(?:\.[A-Za-z0-9]{1,16})?)$/);
|
const m = loc.pathname.match(/^\/board\/(\d+(?:\.[A-Za-z0-9]{1,16})?)$/);
|
||||||
if (m) return parsePermalinkID(m[1]) || 0;
|
if (m) return parsePermalinkID(m[1]) || 0;
|
||||||
return Number(params.get('board')) || 0;
|
return Number(params.get('board')) || 0;
|
||||||
});
|
});
|
||||||
const [keywordDraft, setKeywordDraft] = useState(params.get('keyword') || '');
|
const [keywordDraft, setKeywordDraft] = useState(params.get('keyword') || '');
|
||||||
const feedSort = parseFeedSort(params.get('sort'));
|
|
||||||
const { limits: forumLimits } = useForumLimits();
|
const { limits: forumLimits } = useForumLimits();
|
||||||
|
const feedSort = parseFeedSort(params.get('sort'), forumLimits.feed_sort_tabs);
|
||||||
const postSearch = usePostSearch(forumLimits);
|
const postSearch = usePostSearch(forumLimits);
|
||||||
const asideWidgets = useMemo(() => resolveAsideWidgets(forumLimits), [forumLimits]);
|
const asideWidgets = useMemo(() => resolveAsideWidgets(forumLimits), [forumLimits]);
|
||||||
const showTagCloud = asideWidgets.some(w => w.id === 'tag_cloud' && w.enabled);
|
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);
|
return () => window.removeEventListener('boards-refresh', onRefresh);
|
||||||
}, [refreshBoards]);
|
}, [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(() => {
|
const refreshUnreadMessages = useCallback(() => {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
setUnreadMessages(0);
|
setUnreadMessages(0);
|
||||||
@@ -216,10 +313,50 @@ export default function MainLayout() {
|
|||||||
};
|
};
|
||||||
}, [refreshUnreadMessages]);
|
}, [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 联动(置顶/推荐等不改标签)
|
// 标签云:进页/离开发帖页时拉取;不跟 posts-refresh 联动(置顶/推荐等不改标签)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isCompose || !showTagCloud) return;
|
if (isCompose || !showTagCloud) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
// 有 session 缓存则静默刷新(软刷新不卸空白)
|
||||||
if (getCachedTags().length === 0) setTagsLoading(true);
|
if (getCachedTags().length === 0) setTagsLoading(true);
|
||||||
api.tags(40).then(d => {
|
api.tags(40).then(d => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
@@ -232,7 +369,7 @@ export default function MainLayout() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [isCompose, showTagCloud]);
|
}, [isCompose, showTagCloud, layoutRefreshTick]);
|
||||||
|
|
||||||
const needAsideData = !isCompose && (!hideAside || asideOpen);
|
const needAsideData = !isCompose && (!hideAside || asideOpen);
|
||||||
const needRecentComments = needAsideData && showRecentComments;
|
const needRecentComments = needAsideData && showRecentComments;
|
||||||
@@ -259,7 +396,7 @@ export default function MainLayout() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [needRecentComments]);
|
}, [needRecentComments, layoutRefreshTick]);
|
||||||
|
|
||||||
const needRecentUsers = needAsideData && showRecentUsers;
|
const needRecentUsers = needAsideData && showRecentUsers;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -284,7 +421,7 @@ export default function MainLayout() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [needRecentUsers]);
|
}, [needRecentUsers, layoutRefreshTick]);
|
||||||
|
|
||||||
const doQuickSearch = () => {
|
const doQuickSearch = () => {
|
||||||
const { author, titleOnly, scopeBoardId } = postSearch.filters;
|
const { author, titleOnly, scopeBoardId } = postSearch.filters;
|
||||||
@@ -386,8 +523,8 @@ export default function MainLayout() {
|
|||||||
<Menu size={18} aria-hidden />
|
<Menu size={18} aria-hidden />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{/* 点 Logo:回首页并强制刷新列表(已在首页时也会重拉) */}
|
{/* 任意页点 Logo:回首页并强制刷新,不展示会话缓存 */}
|
||||||
<button type="button" className="header-brand" onClick={() => navigateFeed(nav, '/')}>
|
<button type="button" className="header-brand" onClick={() => navigateFeed(nav, '/', { refresh: true })}>
|
||||||
<SiteBrandMark branding={branding} className="header-logo-mark" />
|
<SiteBrandMark branding={branding} className="header-logo-mark" />
|
||||||
{!isMobile && <span className="header-logo-text">{branding.name}</span>}
|
{!isMobile && <span className="header-logo-text">{branding.name}</span>}
|
||||||
</button>
|
</button>
|
||||||
@@ -470,7 +607,7 @@ export default function MainLayout() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="header-compose-btn"
|
className="header-compose-btn"
|
||||||
onClick={() => user ? nav('/compose') : nav(loginPath('/compose'))}
|
onClick={() => user ? void transitionTo(nav, '/compose') : nav(loginPath('/compose'))}
|
||||||
aria-label="发帖"
|
aria-label="发帖"
|
||||||
>
|
>
|
||||||
<Plus size={16} aria-hidden />
|
<Plus size={16} aria-hidden />
|
||||||
@@ -515,7 +652,7 @@ export default function MainLayout() {
|
|||||||
className="header-icon-btn header-msg-btn"
|
className="header-icon-btn header-msg-btn"
|
||||||
title={unreadMessages > 0 ? `${unreadMessages} 条未读消息` : '站内消息'}
|
title={unreadMessages > 0 ? `${unreadMessages} 条未读消息` : '站内消息'}
|
||||||
aria-label={unreadMessages > 0 ? `站内消息,${unreadMessages} 条未读` : '站内消息'}
|
aria-label={unreadMessages > 0 ? `站内消息,${unreadMessages} 条未读` : '站内消息'}
|
||||||
onClick={() => nav('/messages')}
|
onClick={() => void transitionTo(nav, '/messages')}
|
||||||
>
|
>
|
||||||
<Mail size={18} aria-hidden />
|
<Mail size={18} aria-hidden />
|
||||||
{unreadMessages > 0 && (
|
{unreadMessages > 0 && (
|
||||||
@@ -535,14 +672,14 @@ export default function MainLayout() {
|
|||||||
className="w-40"
|
className="w-40"
|
||||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||||
>
|
>
|
||||||
<DropdownMenuItem onClick={() => nav(userPath(user.id))}>个人主页</DropdownMenuItem>
|
<DropdownMenuItem onClick={() => void transitionTo(nav, userPath(user.id))}>个人主页</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => nav('/profile')}>
|
<DropdownMenuItem onClick={() => void transitionTo(nav, '/profile')}>
|
||||||
账号设置{typeof user.points === 'number' ? ` · ${user.points} 积分` : ''}
|
账号设置{typeof user.points === 'number' ? ` · ${user.points} 积分` : ''}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => nav('/messages')}>
|
<DropdownMenuItem onClick={() => void transitionTo(nav, '/messages')}>
|
||||||
站内消息{unreadMessages > 0 ? ` (${unreadMessages})` : ''}
|
站内消息{unreadMessages > 0 ? ` (${unreadMessages})` : ''}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => nav('/favorites')}>我的收藏</DropdownMenuItem>
|
<DropdownMenuItem onClick={() => void transitionTo(nav, '/favorites')}>我的收藏</DropdownMenuItem>
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
<DropdownMenuItem onClick={toggle}>
|
<DropdownMenuItem onClick={toggle}>
|
||||||
{theme === 'light' ? '切换暗色模式' : '切换亮色模式'}
|
{theme === 'light' ? '切换暗色模式' : '切换亮色模式'}
|
||||||
@@ -588,7 +725,7 @@ export default function MainLayout() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={`app-body${isCompose ? ' app-body--compose' : ''}`}>
|
<div className={`app-body${isCompose ? ' app-body--compose' : ''}`}>
|
||||||
{!isCompose && (
|
{!isCompose && shellReady && (
|
||||||
<Sidebar
|
<Sidebar
|
||||||
boards={boards}
|
boards={boards}
|
||||||
activeBoard={boardId}
|
activeBoard={boardId}
|
||||||
@@ -602,13 +739,16 @@ export default function MainLayout() {
|
|||||||
isCompose && 'content-workspace--compose',
|
isCompose && 'content-workspace--compose',
|
||||||
hideAside && !isCompose && 'content-workspace--aside-hidden',
|
hideAside && !isCompose && 'content-workspace--aside-hidden',
|
||||||
)}>
|
)}>
|
||||||
<main className={cn(
|
<main
|
||||||
|
className={cn(
|
||||||
'main-content',
|
'main-content',
|
||||||
isCompose && 'main-content--compose',
|
isCompose && 'main-content--compose',
|
||||||
// 手机 Feed:整栏滚动,板块条 / 排序栏可滚出视口,多露出帖子列表
|
// 手机 Feed:整栏滚动,板块条 / 排序栏可滚出视口,多露出帖子列表
|
||||||
isMobile && !isCompose && isFeedHome && 'main-content--feed-mobile-scroll',
|
isMobile && !isCompose && isFeedHome && 'main-content--feed-mobile-scroll',
|
||||||
)}>
|
)}
|
||||||
{isMobile && !isCompose && isFeedHome && (
|
aria-busy={!shellReady}
|
||||||
|
>
|
||||||
|
{shellReady && isMobile && !isCompose && isFeedHome && (
|
||||||
<div
|
<div
|
||||||
ref={boardBarRef}
|
ref={boardBarRef}
|
||||||
className="mobile-board-bar"
|
className="mobile-board-bar"
|
||||||
@@ -646,13 +786,16 @@ export default function MainLayout() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Suspense fallback={isFeedHome ? <FeedPageSkeleton /> : <PageLoader />}>
|
{shellReady ? (
|
||||||
|
<Suspense fallback={null}>
|
||||||
<Outlet context={layoutCtx} />
|
<Outlet context={layoutCtx} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
) : null}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{!isCompose && (
|
{!isCompose && (
|
||||||
<aside className="aside-panel">
|
<aside className="aside-panel" aria-busy={!shellReady}>
|
||||||
|
{shellReady && (
|
||||||
<RightPanel
|
<RightPanel
|
||||||
recentComments={recentComments}
|
recentComments={recentComments}
|
||||||
recentUsers={recentUsers}
|
recentUsers={recentUsers}
|
||||||
@@ -671,6 +814,7 @@ export default function MainLayout() {
|
|||||||
outlineTitle: postOutline?.title,
|
outlineTitle: postOutline?.title,
|
||||||
} : null}
|
} : null}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -710,12 +854,14 @@ export default function MainLayout() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="aside-drawer-body sidebar-drawer-body">
|
<div className="aside-drawer-body sidebar-drawer-body">
|
||||||
|
{shellReady && (
|
||||||
<Sidebar
|
<Sidebar
|
||||||
boards={boards}
|
boards={boards}
|
||||||
activeBoard={boardId}
|
activeBoard={boardId}
|
||||||
onSelectBoard={setBoardId}
|
onSelectBoard={setBoardId}
|
||||||
boardsLoading={boardsLoading}
|
boardsLoading={boardsLoading}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
<div className="sidebar-drawer-extras">
|
<div className="sidebar-drawer-extras">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -2,11 +2,21 @@ import React from 'react';
|
|||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import { applyTheme, getStoredTheme } from './utils/theme';
|
import { applyTheme, getStoredTheme } from './utils/theme';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
|
import { ensureColdBootReady, isMainLayoutPath } from './utils/prefetchRoute';
|
||||||
|
|
||||||
applyTheme(getStoredTheme());
|
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(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<App />
|
||||||
</React.StrictMode>
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void boot();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { ArrowLeft, Star } from 'lucide-react';
|
import { ArrowLeft, Star } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -7,6 +7,7 @@ import { notify } from '@/lib/notify';
|
|||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import type { PostItem } from '../api/types';
|
import type { PostItem } from '../api/types';
|
||||||
import { useAuth } from '../hooks/useAuth';
|
import { useAuth } from '../hooks/useAuth';
|
||||||
|
import { useSessionResource } from '../hooks/useSessionResource';
|
||||||
import PostListItem from '../components/PostListItem';
|
import PostListItem from '../components/PostListItem';
|
||||||
import { loginPath } from '../utils/authRedirect';
|
import { loginPath } from '../utils/authRedirect';
|
||||||
import { useForumLimits } from '../hooks/useForumLimits';
|
import { useForumLimits } from '../hooks/useForumLimits';
|
||||||
@@ -26,16 +27,19 @@ export default function FavoritesPage() {
|
|||||||
const { user, loading: authLoading } = useAuth();
|
const { user, loading: authLoading } = useAuth();
|
||||||
const { limits } = useForumLimits();
|
const { limits } = useForumLimits();
|
||||||
useNoIndexSEO('我的收藏');
|
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(() => {
|
useEffect(() => {
|
||||||
if (authLoading) return;
|
if (authLoading) return;
|
||||||
if (!user) { nav(loginPath('/favorites')); return; }
|
if (!user) nav(loginPath('/favorites'));
|
||||||
api.favorites()
|
|
||||||
.then(d => setList(Array.isArray(d.favorites) ? d.favorites as FavItem[] : []))
|
|
||||||
.catch(e => notify.error(e.message))
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, [user, authLoading, nav]);
|
}, [user, authLoading, nav]);
|
||||||
|
|
||||||
if (authLoading || loading) return <div className="flex justify-center py-16"><Spinner size="lg" /></div>;
|
if (authLoading || loading) return <div className="flex justify-center py-16"><Spinner size="lg" /></div>;
|
||||||
|
|||||||
@@ -14,19 +14,20 @@ import type { LayoutCtx } from '../layouts/MainLayout';
|
|||||||
import VirtualPostList from '../components/VirtualPostList';
|
import VirtualPostList from '../components/VirtualPostList';
|
||||||
import FeedHeader from '../components/FeedHeader';
|
import FeedHeader from '../components/FeedHeader';
|
||||||
import FeedSearchFilters from '../components/search/FeedSearchFilters';
|
import FeedSearchFilters from '../components/search/FeedSearchFilters';
|
||||||
import FeedPageSkeleton from '../components/FeedPageSkeleton';
|
|
||||||
import FeedSortBar, { parseFeedSort, buildHomeUrl, type FeedSort } from '../components/FeedSortBar';
|
import FeedSortBar, { parseFeedSort, buildHomeUrl, type FeedSort } from '../components/FeedSortBar';
|
||||||
import { useForumLimits } from '../hooks/useForumLimits';
|
import { useForumLimits } from '../hooks/useForumLimits';
|
||||||
import { parseSearchFromUrl, usePostSearch } from '../hooks/usePostSearch';
|
import { parseSearchFromUrl, usePostSearch } from '../hooks/usePostSearch';
|
||||||
import {
|
import {
|
||||||
clearAllFeedCache,
|
|
||||||
navigateFeed,
|
navigateFeed,
|
||||||
FEED_RESET_EVENT,
|
FEED_RESET_EVENT,
|
||||||
FEED_PULL_REFRESH_EVENT,
|
FEED_PULL_REFRESH_EVENT,
|
||||||
type FeedNavState,
|
type FeedNavState,
|
||||||
} from '../utils/feedCache';
|
} from '../utils/feedCache';
|
||||||
|
import { PAGE_SOFT_REFRESH_COMMIT_EVENT } from '../utils/softRefresh';
|
||||||
import { feedCacheKey, getHomeStoreState } from '../store/homeStore';
|
import { feedCacheKey, getHomeStoreState } from '../store/homeStore';
|
||||||
|
import { enabledFeedSortTabs, getDefaultFeedSort } from '../utils/feedSortTabs';
|
||||||
import { openForumPost } from '../utils/openPost';
|
import { openForumPost } from '../utils/openPost';
|
||||||
|
import { startTransition, doneTransition } from '../utils/spaTransition';
|
||||||
import { joinSEOKeywords, usePageSEO } from '../hooks/usePageSEO';
|
import { joinSEOKeywords, usePageSEO } from '../hooks/usePageSEO';
|
||||||
import { siteMetaDescription, useSiteBranding } from '../hooks/useSiteBranding';
|
import { siteMetaDescription, useSiteBranding } from '../hooks/useSiteBranding';
|
||||||
import { boardPath, canonicalRedirectPath, parsePermalinkID } from '../utils/permalink';
|
import { boardPath, canonicalRedirectPath, parsePermalinkID } from '../utils/permalink';
|
||||||
@@ -95,7 +96,8 @@ export default function HomePage() {
|
|||||||
const tag = params.get('tag') || '';
|
const tag = params.get('tag') || '';
|
||||||
const author = params.get('author') || '';
|
const author = params.get('author') || '';
|
||||||
const titleOnly = params.get('title_only') === '1';
|
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 board = (ctx?.boards ?? []).find(b => b.id === boardId);
|
||||||
const isSiteHome = !boardId && !keyword && !tag && !author;
|
const isSiteHome = !boardId && !keyword && !tag && !author;
|
||||||
const siteIntro = siteMetaDescription(branding);
|
const siteIntro = siteMetaDescription(branding);
|
||||||
@@ -135,6 +137,27 @@ export default function HomePage() {
|
|||||||
}
|
}
|
||||||
}, [queryBoardId, boardId, boardRouteId, params, nav, limits, location.pathname, location.search]);
|
}, [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(
|
const cacheKey = useMemo(
|
||||||
() => feedCacheKey({ boardId, keyword, sort, tag, author, titleOnly }),
|
() => feedCacheKey({ boardId, keyword, sort, tag, author, titleOnly }),
|
||||||
[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 [postTotal, setPostTotal] = useState(initial.postTotal);
|
||||||
const [page, setPage] = useState(initial.page);
|
const [page, setPage] = useState(initial.page);
|
||||||
const [loading, setLoading] = useState(initial.loading);
|
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>(
|
const [restoreScrollTop, setRestoreScrollTop] = useState<number | null>(
|
||||||
initial.posts.length > 0 ? initial.scrollTop : null,
|
initial.posts.length > 0 ? initial.scrollTop : null,
|
||||||
);
|
);
|
||||||
const [listResetKey, setListResetKey] = useState(0);
|
const [listResetKey, setListResetKey] = useState(0);
|
||||||
|
|
||||||
const scrollTopRef = useRef(initial.scrollTop);
|
const scrollTopRef = useRef(initial.scrollTop);
|
||||||
const loadingRef = useRef(false);
|
const fetchSeqRef = useRef(0);
|
||||||
const pageRef = useRef(initial.page);
|
const pageRef = useRef(initial.page);
|
||||||
const cacheKeyRef = useRef(cacheKey);
|
const cacheKeyRef = useRef(cacheKey);
|
||||||
|
const viewKeyRef = useRef(view.cacheKey);
|
||||||
|
const postsRef = useRef(posts);
|
||||||
const scrollRafRef = useRef(0);
|
const scrollRafRef = useRef(0);
|
||||||
/** 当前筛选键是否已完成「进入页」水合(避免 effect 重跑时反复 setRestoreScrollTop) */
|
/** 当前筛选键是否已完成「进入页」水合(避免 effect 重跑时反复 setRestoreScrollTop) */
|
||||||
const hydratedKeyRef = useRef<string | null>(null);
|
const hydratedKeyRef = useRef<string | null>(null);
|
||||||
pageRef.current = page;
|
pageRef.current = page;
|
||||||
cacheKeyRef.current = cacheKey;
|
cacheKeyRef.current = cacheKey;
|
||||||
|
viewKeyRef.current = view.cacheKey;
|
||||||
|
postsRef.current = posts;
|
||||||
|
|
||||||
// 筛选键切换:用新键的缓存重置本地 state(useMemo initial 不会自动 setState)
|
const commitDisplayed = useCallback((
|
||||||
useEffect(() => {
|
next: FeedHydrate,
|
||||||
const next = readHydrate(boardId, keyword, sort, tag, author, titleOnly);
|
meta: { cacheKey: string; sort: FeedSort; boardId: number; keyword: string; tag: string; author: string; titleOnly: boolean },
|
||||||
hydratedKeyRef.current = null;
|
) => {
|
||||||
setPosts(next.posts);
|
setPosts(next.posts);
|
||||||
setPostTotal(next.postTotal);
|
setPostTotal(next.postTotal);
|
||||||
setPage(next.page);
|
setPage(next.page);
|
||||||
@@ -177,7 +215,30 @@ export default function HomePage() {
|
|||||||
scrollTopRef.current = next.scrollTop;
|
scrollTopRef.current = next.scrollTop;
|
||||||
setRestoreScrollTop(next.posts.length > 0 ? next.scrollTop : null);
|
setRestoreScrollTop(next.posts.length > 0 ? next.scrollTop : null);
|
||||||
setLoading(next.loading);
|
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 totalPages = Math.max(1, Math.ceil(Math.max(postTotal, 0) / pageSize));
|
||||||
const showPagination = totalPages > 1 && posts.length > 0;
|
const showPagination = totalPages > 1 && posts.length > 0;
|
||||||
@@ -189,9 +250,8 @@ export default function HomePage() {
|
|||||||
setListResetKey(k => k + 1);
|
setListResetKey(k => k + 1);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/** 强制刷新:清空全部 Feed 缓存并滚回顶部 */
|
/** 同筛选强制刷新:只复位滚动,保留旧列表直到 loadFirst 覆盖 */
|
||||||
const beginFeedRefresh = useCallback(() => {
|
const beginFeedRefresh = useCallback(() => {
|
||||||
clearAllFeedCache();
|
|
||||||
resetFeedView();
|
resetFeedView();
|
||||||
}, [resetFeedView]);
|
}, [resetFeedView]);
|
||||||
|
|
||||||
@@ -200,9 +260,9 @@ export default function HomePage() {
|
|||||||
nextPosts: PostItem[],
|
nextPosts: PostItem[],
|
||||||
nextTotal: number,
|
nextTotal: number,
|
||||||
nextPage: 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);
|
const prev = getHomeStoreState().getFeed(key);
|
||||||
getHomeStoreState().setFeed(key, {
|
getHomeStoreState().setFeed(key, {
|
||||||
posts: nextPosts,
|
posts: nextPosts,
|
||||||
@@ -215,12 +275,14 @@ export default function HomePage() {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchPage = useCallback(async (p: number, opts?: { silent?: boolean }) => {
|
const fetchPage = useCallback(async (p: number, opts?: { silent?: boolean; resetScroll?: boolean }) => {
|
||||||
if (loadingRef.current) return;
|
const seq = ++fetchSeqRef.current;
|
||||||
loadingRef.current = true;
|
const requestKey = cacheKeyRef.current;
|
||||||
const silent = !!opts?.silent;
|
const silent = !!opts?.silent;
|
||||||
// 静默刷新:保留现有列表,不展示加载骨架
|
const keepView = postsRef.current.length > 0;
|
||||||
if (!silent) setLoading(true);
|
// 有旧列表时不卸页,只标 pending
|
||||||
|
if (!silent && !keepView) setLoading(true);
|
||||||
|
if (!silent && keepView) setListPending(true);
|
||||||
try {
|
try {
|
||||||
const data = await api.posts({
|
const data = await api.posts({
|
||||||
page: p,
|
page: p,
|
||||||
@@ -232,42 +294,53 @@ export default function HomePage() {
|
|||||||
title_only: !tag && titleOnly ? '1' : '',
|
title_only: !tag && titleOnly ? '1' : '',
|
||||||
sort,
|
sort,
|
||||||
});
|
});
|
||||||
|
if (seq !== fetchSeqRef.current) return;
|
||||||
const batch = Array.isArray(data.posts) ? data.posts : [];
|
const batch = Array.isArray(data.posts) ? data.posts : [];
|
||||||
const total = data.total ?? 0;
|
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);
|
setPosts(batch);
|
||||||
setPostTotal(total);
|
setPostTotal(total);
|
||||||
setPage(p);
|
setPage(p);
|
||||||
pageRef.current = 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) {
|
} catch (e: unknown) {
|
||||||
|
if (seq !== fetchSeqRef.current) return;
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
notify.error(e instanceof Error ? e.message : '加载失败');
|
notify.error(e instanceof Error ? e.message : '加载失败');
|
||||||
|
if (!keepView) {
|
||||||
setPosts([]);
|
setPosts([]);
|
||||||
setPostTotal(0);
|
setPostTotal(0);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
pageRef.current = 1;
|
pageRef.current = 1;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
loadingRef.current = false;
|
if (seq === fetchSeqRef.current) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
setListPending(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [boardId, keyword, tag, author, titleOnly, sort, pageSize, persistFeed]);
|
}, [boardId, keyword, tag, author, titleOnly, sort, pageSize, persistFeed]);
|
||||||
|
|
||||||
const loadFirst = useCallback(() => fetchPage(1), [fetchPage]);
|
const loadFirst = useCallback(() => fetchPage(1), [fetchPage]);
|
||||||
|
|
||||||
const goToPage = useCallback((p: number) => {
|
const goToPage = useCallback((p: number) => {
|
||||||
if (loadingRef.current) return;
|
|
||||||
const maxPage = Math.max(1, Math.ceil(Math.max(postTotal, 0) / pageSize));
|
const maxPage = Math.max(1, Math.ceil(Math.max(postTotal, 0) / pageSize));
|
||||||
if (p < 1 || p > maxPage) return;
|
if (p < 1 || p > maxPage) return;
|
||||||
if (p === pageRef.current) return;
|
if (p === pageRef.current) return;
|
||||||
resetFeedView();
|
void fetchPage(p, { resetScroll: true });
|
||||||
getHomeStoreState().patchScroll(cacheKeyRef.current, 0);
|
}, [fetchPage, postTotal, pageSize]);
|
||||||
fetchPage(p);
|
|
||||||
}, [fetchPage, postTotal, pageSize, resetFeedView]);
|
|
||||||
|
|
||||||
const handleSelectPost = useCallback((id: number) => {
|
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);
|
openForumPost(nav, id, limits.open_posts_in_new_tab);
|
||||||
}, [nav, 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;
|
const forceRefresh = (location.state as FeedNavState | null)?.refreshFeed;
|
||||||
// 浏览器后退/前进(POP)忽略 history 上残留的 refreshFeed,避免误清空缓存
|
// 浏览器后退/前进(POP)忽略 history 上残留的 refreshFeed,避免误清空缓存
|
||||||
if (forceRefresh && navType !== 'POP') {
|
if (forceRefresh && navType !== 'POP') {
|
||||||
|
fetchSeqRef.current += 1;
|
||||||
hydratedKeyRef.current = cacheKey;
|
hydratedKeyRef.current = cacheKey;
|
||||||
beginFeedRefresh();
|
// 预取已写入 store:整页替换,不卸成骨架
|
||||||
setPosts([]);
|
resetFeedView();
|
||||||
setPostTotal(0);
|
const warm = getHomeStoreState().getFeed(cacheKey);
|
||||||
setPage(1);
|
if (warm && warm.posts.length > 0) {
|
||||||
pageRef.current = 1;
|
commitDisplayed({
|
||||||
setLoading(true);
|
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();
|
loadFirst();
|
||||||
|
}
|
||||||
// 消费后清掉 state,防止该 history 条目永远带着刷新标记
|
// 消费后清掉 state,防止该 history 条目永远带着刷新标记
|
||||||
nav(`${location.pathname}${location.search}${location.hash}`, { replace: true, state: null });
|
nav(`${location.pathname}${location.search}${location.hash}`, { replace: true, state: null });
|
||||||
return;
|
return;
|
||||||
@@ -300,26 +385,31 @@ export default function HomePage() {
|
|||||||
if (cached && cached.posts.length > 0) {
|
if (cached && cached.posts.length > 0) {
|
||||||
const needRestore = hydratedKeyRef.current !== cacheKey;
|
const needRestore = hydratedKeyRef.current !== cacheKey;
|
||||||
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);
|
setPosts(cached.posts);
|
||||||
setPostTotal(cached.postTotal);
|
setPostTotal(cached.postTotal);
|
||||||
setPage(cached.page);
|
setPage(cached.page);
|
||||||
pageRef.current = cached.page;
|
pageRef.current = cached.page;
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
// 仅在「首次进入该筛选」时恢复滚动,避免 limits/pageSize 变化导致 effect 重跑时打断用户滚动
|
setListPending(false);
|
||||||
if (needRestore) {
|
setView({ cacheKey, sort, boardId, keyword, tag, author, titleOnly });
|
||||||
setRestoreScrollTop(cached.scrollTop);
|
|
||||||
scrollTopRef.current = cached.scrollTop;
|
|
||||||
}
|
|
||||||
// 超过 TTL:后台静默刷新,不重置滚动
|
|
||||||
if (getHomeStoreState().isStale(cacheKey)) {
|
|
||||||
void fetchPage(cached.page, { silent: true });
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
hydratedKeyRef.current = cacheKey;
|
hydratedKeyRef.current = cacheKey;
|
||||||
|
if (postsRef.current.length === 0) {
|
||||||
setRestoreScrollTop(null);
|
setRestoreScrollTop(null);
|
||||||
scrollTopRef.current = 0;
|
scrollTopRef.current = 0;
|
||||||
|
}
|
||||||
loadFirst();
|
loadFirst();
|
||||||
}, [
|
}, [
|
||||||
limitsLoading,
|
limitsLoading,
|
||||||
@@ -333,36 +423,68 @@ export default function HomePage() {
|
|||||||
navType,
|
navType,
|
||||||
nav,
|
nav,
|
||||||
loadFirst,
|
loadFirst,
|
||||||
fetchPage,
|
commitDisplayed,
|
||||||
beginFeedRefresh,
|
resetFeedView,
|
||||||
isInvalidBoardRoute,
|
isInvalidBoardRoute,
|
||||||
isMissingBoard,
|
isMissingBoard,
|
||||||
|
sort,
|
||||||
|
boardId,
|
||||||
|
keyword,
|
||||||
|
tag,
|
||||||
|
author,
|
||||||
|
titleOnly,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onFeedReset = () => beginFeedRefresh();
|
// Logo 等同 URL 强制刷新:仅复位滚动,不卸列表(数据由 transitionTo 预热)
|
||||||
|
const onFeedReset = () => {
|
||||||
|
fetchSeqRef.current += 1;
|
||||||
|
resetFeedView();
|
||||||
|
};
|
||||||
window.addEventListener(FEED_RESET_EVENT, onFeedReset);
|
window.addEventListener(FEED_RESET_EVENT, onFeedReset);
|
||||||
return () => window.removeEventListener(FEED_RESET_EVENT, onFeedReset);
|
return () => window.removeEventListener(FEED_RESET_EVENT, onFeedReset);
|
||||||
}, [beginFeedRefresh]);
|
}, [resetFeedView]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Logo / 下拉刷新 / 后台改帖:清空本地列表以露出骨架,再强制拉第 1 页
|
// 下拉 / posts-refresh / 软刷新 commit:有预热则一次覆盖;禁止先卸列表
|
||||||
const fn = () => {
|
const applyWarmOrReload = () => {
|
||||||
beginFeedRefresh();
|
fetchSeqRef.current += 1;
|
||||||
setPosts([]);
|
const key = cacheKeyRef.current;
|
||||||
setPostTotal(0);
|
const warm = getHomeStoreState().getFeed(key);
|
||||||
setPage(1);
|
if (warm && warm.posts.length > 0) {
|
||||||
pageRef.current = 1;
|
// 先写入新数据,再复位滚动(避免先 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);
|
setLoading(true);
|
||||||
loadFirst();
|
void loadFirst();
|
||||||
};
|
};
|
||||||
window.addEventListener('posts-refresh', fn);
|
window.addEventListener('posts-refresh', applyWarmOrReload);
|
||||||
window.addEventListener(FEED_PULL_REFRESH_EVENT, fn);
|
window.addEventListener(FEED_PULL_REFRESH_EVENT, applyWarmOrReload);
|
||||||
|
window.addEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, applyWarmOrReload);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('posts-refresh', fn);
|
window.removeEventListener('posts-refresh', applyWarmOrReload);
|
||||||
window.removeEventListener(FEED_PULL_REFRESH_EVENT, fn);
|
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
|
// 卸载时取消未执行的 scroll rAF
|
||||||
useEffect(() => () => {
|
useEffect(() => () => {
|
||||||
@@ -375,22 +497,23 @@ export default function HomePage() {
|
|||||||
if (scrollRafRef.current) return;
|
if (scrollRafRef.current) return;
|
||||||
scrollRafRef.current = requestAnimationFrame(() => {
|
scrollRafRef.current = requestAnimationFrame(() => {
|
||||||
scrollRafRef.current = 0;
|
scrollRafRef.current = 0;
|
||||||
getHomeStoreState().patchScroll(cacheKeyRef.current, scrollTopRef.current);
|
getHomeStoreState().patchScroll(viewKeyRef.current, scrollTopRef.current);
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSortChange = (next: FeedSort) => {
|
const handleSortChange = (next: FeedSort) => {
|
||||||
if (next === sort) {
|
if (next === sort) {
|
||||||
|
const tid = startTransition();
|
||||||
beginFeedRefresh();
|
beginFeedRefresh();
|
||||||
loadFirst();
|
void Promise.resolve(loadFirst()).finally(() => doneTransition(tid));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
navigateFeed(nav, buildHomeUrl(boardId, next, { keyword, tag, author, titleOnly, permalink: limits }));
|
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 searchFilters = parseSearchFromUrl(location.pathname, params);
|
||||||
const isSearchActive = !!(keyword || author);
|
const isSearchActive = !!(view.keyword || view.author);
|
||||||
|
|
||||||
if (isInvalidBoardRoute || isMissingBoard) {
|
if (isInvalidBoardRoute || isMissingBoard) {
|
||||||
return (
|
return (
|
||||||
@@ -401,9 +524,9 @@ export default function HomePage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 首屏用同构骨架,避免标题/列表分区先后出现造成闪动
|
// 冷启动由门闩 / ensureColdBootReady 挡住;有旧列表时软刷新绝不卸空
|
||||||
if ((loading || limitsLoading || (isBoardRoute && boardsLoading)) && posts.length === 0) {
|
if ((loading || limitsLoading || (isBoardRoute && boardsLoading)) && posts.length === 0) {
|
||||||
return <FeedPageSkeleton />;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -412,14 +535,19 @@ export default function HomePage() {
|
|||||||
<div className="feed-top">
|
<div className="feed-top">
|
||||||
<div className="feed-top__bar">
|
<div className="feed-top__bar">
|
||||||
<FeedHeader
|
<FeedHeader
|
||||||
keyword={keyword}
|
keyword={view.keyword}
|
||||||
tag={tag}
|
tag={view.tag}
|
||||||
author={author}
|
author={view.author}
|
||||||
postTotal={postTotal}
|
postTotal={postTotal}
|
||||||
titleAs={isSiteHome ? 'h2' : 'h1'}
|
titleAs={isSiteHome ? 'h2' : 'h1'}
|
||||||
/>
|
/>
|
||||||
{showSortBar && (
|
{showSortBar && (
|
||||||
<FeedSortBar value={sort} onChange={handleSortChange} postTotal={postTotal} />
|
<FeedSortBar
|
||||||
|
value={view.sort}
|
||||||
|
pendingValue={listPending ? sort : null}
|
||||||
|
onChange={handleSortChange}
|
||||||
|
postTotal={postTotal}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isSearchActive && (
|
{isSearchActive && (
|
||||||
@@ -433,8 +561,8 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
<VirtualPostList
|
<VirtualPostList
|
||||||
posts={posts}
|
posts={posts}
|
||||||
sort={sort}
|
sort={view.sort}
|
||||||
loading={loading || limitsLoading}
|
loading={listPending ? false : (loading || limitsLoading)}
|
||||||
hasMore={hasMore}
|
hasMore={hasMore}
|
||||||
showPagination={showPagination}
|
showPagination={showPagination}
|
||||||
page={page}
|
page={page}
|
||||||
@@ -446,15 +574,15 @@ export default function HomePage() {
|
|||||||
resetScrollKey={listResetKey}
|
resetScrollKey={listResetKey}
|
||||||
onScrollTopChange={handleScrollTopChange}
|
onScrollTopChange={handleScrollTopChange}
|
||||||
onScrollRestored={() => setRestoreScrollTop(null)}
|
onScrollRestored={() => setRestoreScrollTop(null)}
|
||||||
keyword={keyword || tag || author}
|
keyword={view.keyword || view.tag || view.author}
|
||||||
isSearchMode={!!(keyword || author)}
|
isSearchMode={!!(view.keyword || view.author)}
|
||||||
searchKeyword={keyword}
|
searchKeyword={view.keyword}
|
||||||
searchAuthor={author}
|
searchAuthor={view.author}
|
||||||
searchTitleOnly={titleOnly}
|
searchTitleOnly={view.titleOnly}
|
||||||
searchScopeBoardId={searchFilters.scopeBoardId}
|
searchScopeBoardId={searchFilters.scopeBoardId}
|
||||||
onClearSearch={postSearch.clearSearch}
|
onClearSearch={postSearch.clearSearch}
|
||||||
boardId={boardId}
|
boardId={view.boardId}
|
||||||
boardName={ctx?.boards?.find(b => b.id === boardId)?.name || ''}
|
boardName={ctx?.boards?.find(b => b.id === view.boardId)?.name || ''}
|
||||||
noBoards={!ctx?.boardsLoading && (ctx?.boards?.length ?? 0) === 0}
|
noBoards={!ctx?.boardsLoading && (ctx?.boards?.length ?? 0) === 0}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { loginPath } from '../utils/authRedirect';
|
|||||||
import { resolveFriendLinkLogo, isReciprocalChecking, reciprocalStatusLabel } from '../utils/friendLink';
|
import { resolveFriendLinkLogo, isReciprocalChecking, reciprocalStatusLabel } from '../utils/friendLink';
|
||||||
import { InFlowSiteFooter } from '../components/SiteFooter';
|
import { InFlowSiteFooter } from '../components/SiteFooter';
|
||||||
import FriendLinkApplyDialog from '../components/FriendLinkApplyDialog';
|
import FriendLinkApplyDialog from '../components/FriendLinkApplyDialog';
|
||||||
|
import { useSessionResource } from '../hooks/useSessionResource';
|
||||||
|
|
||||||
const APPLY_STATUS_ORDER: Record<FriendLinkApply['status'], number> = {
|
const APPLY_STATUS_ORDER: Record<FriendLinkApply['status'], number> = {
|
||||||
pending: 0,
|
pending: 0,
|
||||||
@@ -48,8 +49,6 @@ export default function LinksPage() {
|
|||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [applyOpen, setApplyOpen] = useState(false);
|
const [applyOpen, setApplyOpen] = useState(false);
|
||||||
const [editApply, setEditApply] = useState<FriendLinkApply | null>(null);
|
const [editApply, setEditApply] = useState<FriendLinkApply | null>(null);
|
||||||
const [myApplies, setMyApplies] = useState<FriendLinkApply[]>([]);
|
|
||||||
const [myLoading, setMyLoading] = useState(false);
|
|
||||||
const [cancelingId, setCancelingId] = useState<number | null>(null);
|
const [cancelingId, setCancelingId] = useState<number | null>(null);
|
||||||
|
|
||||||
usePageSEO({
|
usePageSEO({
|
||||||
@@ -63,6 +62,15 @@ export default function LinksPage() {
|
|||||||
(l: FriendLink) => l.name?.trim() && l.url?.trim(),
|
(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(
|
const sortedApplies = useMemo(
|
||||||
() => [...myApplies].sort((a, b) => {
|
() => [...myApplies].sort((a, b) => {
|
||||||
const byStatus = APPLY_STATUS_ORDER[a.status] - APPLY_STATUS_ORDER[b.status];
|
const byStatus = APPLY_STATUS_ORDER[a.status] - APPLY_STATUS_ORDER[b.status];
|
||||||
@@ -98,16 +106,10 @@ export default function LinksPage() {
|
|||||||
setMyApplies([]);
|
setMyApplies([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setMyLoading(true);
|
|
||||||
api.myFriendLinkApplies()
|
api.myFriendLinkApplies()
|
||||||
.then(r => setMyApplies(r.applies ?? []))
|
.then(r => setMyApplies(r.applies ?? []))
|
||||||
.catch(e => notify.error(e instanceof Error ? e.message : '加载失败'))
|
.catch(e => notify.error(e instanceof Error ? e.message : '加载失败'));
|
||||||
.finally(() => setMyLoading(false));
|
}, [user, setMyApplies]);
|
||||||
}, [user]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadMyApplies();
|
|
||||||
}, [loadMyApplies]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user || !myApplies.some(isReciprocalChecking)) return;
|
if (!user || !myApplies.some(isReciprocalChecking)) return;
|
||||||
|
|||||||
@@ -14,8 +14,14 @@ import { postPath } from '../utils/permalink';
|
|||||||
import { userPath } from '../utils/userPath';
|
import { userPath } from '../utils/userPath';
|
||||||
import { InFlowSiteFooter } from '../components/SiteFooter';
|
import { InFlowSiteFooter } from '../components/SiteFooter';
|
||||||
import { cn } from '@/lib/utils';
|
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 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 = [
|
const NOTIFY_KINDS = [
|
||||||
{ key: 'all', label: '全部' },
|
{ key: 'all', label: '全部' },
|
||||||
@@ -136,11 +142,28 @@ export default function MessagesPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadConversations = useCallback(async (page = 1, append = false) => {
|
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);
|
setListLoading(true);
|
||||||
try {
|
try {
|
||||||
const r = await api.messageConversations({ page, size: 30 });
|
const r = await api.messageConversations({ page, size: 30 });
|
||||||
const next = r.conversations || [];
|
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);
|
setConvTotal(r.total || 0);
|
||||||
setConvPage(r.page || page);
|
setConvPage(r.page || page);
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
@@ -151,6 +174,17 @@ export default function MessagesPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadNotifications = useCallback(async (page = 1, append = false, kind = 'all') => {
|
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);
|
setNotifyLoading(true);
|
||||||
try {
|
try {
|
||||||
const r = await api.messageNotifications({
|
const r = await api.messageNotifications({
|
||||||
@@ -164,7 +198,9 @@ export default function MessagesPage() {
|
|||||||
// 打开通知页时标已读(首屏)
|
// 打开通知页时标已读(首屏)
|
||||||
if (!append && page === 1) {
|
if (!append && page === 1) {
|
||||||
await api.markNotificationsRead().catch(() => undefined);
|
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);
|
setNotifyUnread(0);
|
||||||
window.dispatchEvent(new Event('messages-unread-refresh'));
|
window.dispatchEvent(new Event('messages-unread-refresh'));
|
||||||
} else {
|
} else {
|
||||||
@@ -177,20 +213,47 @@ export default function MessagesPage() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const [threadEpoch, setThreadEpoch] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (authLoading) return;
|
if (authLoading) return;
|
||||||
if (!user) {
|
if (!user) {
|
||||||
nav(loginPath('/messages'));
|
nav(loginPath('/messages'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void refreshUnreadSplit();
|
|
||||||
if (tab === 'dm') {
|
if (tab === 'dm') {
|
||||||
|
if (!getSessionSnapshot('messages:conv:1')) void refreshUnreadSplit();
|
||||||
loadConversations(1);
|
loadConversations(1);
|
||||||
} else {
|
} else {
|
||||||
|
if (!getSessionSnapshot(`messages:notify:${notifyKind}:1`)) void refreshUnreadSplit();
|
||||||
loadNotifications(1, false, notifyKind);
|
loadNotifications(1, false, notifyKind);
|
||||||
}
|
}
|
||||||
}, [user, authLoading, nav, tab, notifyKind, loadConversations, loadNotifications, refreshUnreadSplit]);
|
}, [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) => {
|
const scrollToBottom = useCallback((smooth = false) => {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
threadEndRef.current?.scrollIntoView({ behavior: smooth ? 'smooth' : 'auto', block: 'end' });
|
threadEndRef.current?.scrollIntoView({ behavior: smooth ? 'smooth' : 'auto', block: 'end' });
|
||||||
@@ -204,15 +267,30 @@ export default function MessagesPage() {
|
|||||||
setMsgTotal(0);
|
setMsgTotal(0);
|
||||||
return;
|
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;
|
let cancelled = false;
|
||||||
setThreadLoading(true);
|
setThreadLoading(true);
|
||||||
stickToBottomRef.current = true;
|
stickToBottomRef.current = true;
|
||||||
api.conversationMessages(selectedPeer, { size: 50 })
|
api.conversationMessages(selectedPeer, { size: 50 })
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setMessages(r.messages || []);
|
const messages = r.messages || [];
|
||||||
|
const peer = r.peer_user || null;
|
||||||
|
setMessages(messages);
|
||||||
setMsgTotal(r.total || 0);
|
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) => (
|
setConversations((prev) => prev.map((c) => (
|
||||||
c.peer_user_id === selectedPeer ? { ...c, unread_count: 0 } : c
|
c.peer_user_id === selectedPeer ? { ...c, unread_count: 0 } : c
|
||||||
)));
|
)));
|
||||||
@@ -226,7 +304,7 @@ export default function MessagesPage() {
|
|||||||
if (!cancelled) setThreadLoading(false);
|
if (!cancelled) setThreadLoading(false);
|
||||||
});
|
});
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [user, peerSelected, selectedPeer, refreshUnreadSplit]);
|
}, [user, peerSelected, selectedPeer, refreshUnreadSplit, threadEpoch]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!threadLoading && stickToBottomRef.current) {
|
if (!threadLoading && stickToBottomRef.current) {
|
||||||
@@ -321,7 +399,17 @@ export default function MessagesPage() {
|
|||||||
try {
|
try {
|
||||||
const r = await api.sendMessage({ to_user_id: selectedPeer, content });
|
const r = await api.sendMessage({ to_user_id: selectedPeer, content });
|
||||||
stickToBottomRef.current = true;
|
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);
|
setMsgTotal((n) => n + 1);
|
||||||
setDraft('');
|
setDraft('');
|
||||||
setConversations((prev) => {
|
setConversations((prev) => {
|
||||||
@@ -335,7 +423,9 @@ export default function MessagesPage() {
|
|||||||
unread_count: 0,
|
unread_count: 0,
|
||||||
updated_at: r.message.created_at,
|
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);
|
scrollToBottom(true);
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
|
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 { ArrowLeft, ThumbsUp, Star, Lock, MessageSquare, MessageSquareOff, Flag, MoreHorizontal } from 'lucide-react';
|
||||||
import FeaturedIcon from '@/components/FeaturedIcon';
|
import FeaturedIcon from '@/components/FeaturedIcon';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -51,7 +51,9 @@ import { getCachedSiteBranding } from '../hooks/useSiteBranding';
|
|||||||
import { formatDateTime, isTimeDiffSignificant } from '../utils/content';
|
import { formatDateTime, isTimeDiffSignificant } from '../utils/content';
|
||||||
import { collectCommentSubtreeIds } from '../utils/comment';
|
import { collectCommentSubtreeIds } from '../utils/comment';
|
||||||
import { loadMyCommentIds } from '../utils/guest';
|
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 { useGlobalWheelScroll } from '../hooks/useGlobalWheelScroll';
|
||||||
import { loginPath } from '../utils/authRedirect';
|
import { loginPath } from '../utils/authRedirect';
|
||||||
import { excerptFromHTML, firstImageFromHTML } from '../utils/seoText';
|
import { excerptFromHTML, firstImageFromHTML } from '../utils/seoText';
|
||||||
@@ -62,6 +64,27 @@ import type { PostHeading } from '../utils/postHeadings';
|
|||||||
import { InFlowSiteFooter } from '../components/SiteFooter';
|
import { InFlowSiteFooter } from '../components/SiteFooter';
|
||||||
import NotFoundPage from './NotFoundPage';
|
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 {
|
function formatEditRemaining(createdAt: string, windowHours: number): string {
|
||||||
if (windowHours <= 0) return '';
|
if (windowHours <= 0) return '';
|
||||||
@@ -80,26 +103,31 @@ export default function PostDetailPage() {
|
|||||||
const postId = parsePermalinkID(id);
|
const postId = parsePermalinkID(id);
|
||||||
const nav = useNavigate();
|
const nav = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const navType = useNavigationType();
|
||||||
const { user, refresh } = useAuth();
|
const { user, refresh } = useAuth();
|
||||||
const { limits } = useForumLimits();
|
const { limits } = useForumLimits();
|
||||||
const { setPostOutline, isMobile } = useOutletContext<LayoutCtx>();
|
const { setPostOutline, isMobile } = useOutletContext<LayoutCtx>();
|
||||||
|
|
||||||
const [post, setPost] = useState<PostItem | null>(null);
|
const initialSnap = (postId && !Number.isNaN(postId))
|
||||||
const [poll, setPoll] = useState<PollView | null>(null);
|
? getSessionSnapshot<PostDetailSnapshot>(postDetailCacheKey(postId))
|
||||||
const [lottery, setLottery] = useState<PostLotteryView | null>(null);
|
: undefined;
|
||||||
const [comments, setComments] = useState<Comment[]>([]);
|
|
||||||
const [liked, setLiked] = useState(false);
|
const [post, setPost] = useState<PostItem | null>(initialSnap?.post ?? null);
|
||||||
const [favorited, setFavorited] = useState(false);
|
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 [replyTo, setReplyTo] = useState<Comment | null>(null);
|
||||||
const [editingCommentId, setEditingCommentId] = useState<number | null>(null);
|
const [editingCommentId, setEditingCommentId] = useState<number | null>(null);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(!initialSnap);
|
||||||
const [highlightFloor, setHighlightFloor] = useState<number | null>(null);
|
const [highlightFloor, setHighlightFloor] = useState<number | null>(null);
|
||||||
const [submitCount, setSubmitCount] = useState(0);
|
const [submitCount, setSubmitCount] = useState(0);
|
||||||
const [canEdit, setCanEdit] = useState(false);
|
const [canEdit, setCanEdit] = useState(initialSnap?.canEdit ?? false);
|
||||||
const [isEdited, setIsEdited] = useState(false);
|
const [isEdited, setIsEdited] = useState(initialSnap?.isEdited ?? false);
|
||||||
const [editBlockReason, setEditBlockReason] = useState('');
|
const [editBlockReason, setEditBlockReason] = useState(initialSnap?.editBlockReason ?? '');
|
||||||
const [editWindowHours, setEditWindowHours] = useState(0);
|
const [editWindowHours, setEditWindowHours] = useState(initialSnap?.editWindowHours ?? 0);
|
||||||
const [showRevisions, setShowRevisions] = useState(false);
|
const [showRevisions, setShowRevisions] = useState(false);
|
||||||
const [deletingPost, setDeletingPost] = useState(false);
|
const [deletingPost, setDeletingPost] = useState(false);
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
@@ -114,14 +142,25 @@ export default function PostDetailPage() {
|
|||||||
const [rejecting, setRejecting] = useState(false);
|
const [rejecting, setRejecting] = useState(false);
|
||||||
const [bountyAwardTarget, setBountyAwardTarget] = useState<number | null>(null);
|
const [bountyAwardTarget, setBountyAwardTarget] = useState<number | null>(null);
|
||||||
const [bountyAwarding, setBountyAwarding] = useState(false);
|
const [bountyAwarding, setBountyAwarding] = useState(false);
|
||||||
const [bountyCanRefund, setBountyCanRefund] = useState(true);
|
const [bountyCanRefund, setBountyCanRefund] = useState(initialSnap?.bountyCanRefund ?? true);
|
||||||
const [bountyRefundBlockReason, setBountyRefundBlockReason] = useState('');
|
const [bountyRefundBlockReason, setBountyRefundBlockReason] = useState(initialSnap?.bountyRefundBlockReason ?? '');
|
||||||
const [bountyEligibleReplyCount, setBountyEligibleReplyCount] = useState(0);
|
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 pageRef = useRef<HTMLDivElement>(null);
|
||||||
const commentSectionRef = useRef<HTMLDivElement>(null);
|
const commentSectionRef = useRef<HTMLDivElement>(null);
|
||||||
const commentBoxRef = useRef<HTMLDivElement>(null);
|
const commentBoxRef = useRef<HTMLDivElement>(null);
|
||||||
const highlightTimer = useRef<ReturnType<typeof setTimeout>>();
|
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);
|
useGlobalWheelScroll(pageRef, !loading && !!post);
|
||||||
|
|
||||||
@@ -180,19 +219,64 @@ export default function PostDetailPage() {
|
|||||||
const loadSeq = useRef(0);
|
const loadSeq = useRef(0);
|
||||||
const detailPath = postPath(postId, limits);
|
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)) {
|
if (!postId || Number.isNaN(postId)) {
|
||||||
setPost(null);
|
setPost(null);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
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);
|
setReplyTo(null);
|
||||||
setEditingCommentId(null);
|
setEditingCommentId(null);
|
||||||
setComposerOpen(false);
|
setComposerOpen(false);
|
||||||
setHeadings([]);
|
setHeadings([]);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const seq = ++loadSeq.current;
|
const seq = ++loadSeq.current;
|
||||||
|
const keep = !!postRef.current;
|
||||||
|
setReplyTo(null);
|
||||||
|
setEditingCommentId(null);
|
||||||
|
setComposerOpen(false);
|
||||||
|
setHeadings([]);
|
||||||
|
if (!keep) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setPost(null);
|
setPost(null);
|
||||||
|
}
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -202,30 +286,118 @@ export default function PostDetailPage() {
|
|||||||
api.comments(postId, myIds),
|
api.comments(postId, myIds),
|
||||||
]);
|
]);
|
||||||
if (seq !== loadSeq.current) return;
|
if (seq !== loadSeq.current) return;
|
||||||
setPost(detail.post);
|
const commentsList = Array.isArray(comm.comments) ? comm.comments : [];
|
||||||
setPoll(detail.poll ?? null);
|
const snap: PostDetailSnapshot = {
|
||||||
setLottery(detail.lottery ?? null);
|
post: detail.post,
|
||||||
setLiked(detail.liked);
|
comments: commentsList,
|
||||||
setFavorited(detail.favorited);
|
poll: detail.poll ?? null,
|
||||||
setCanEdit(detail.can_edit ?? false);
|
lottery: detail.lottery ?? null,
|
||||||
setIsEdited(detail.is_edited ?? isTimeDiffSignificant(detail.post.created_at, detail.post.updated_at ?? detail.post.created_at));
|
liked: detail.liked,
|
||||||
setEditBlockReason(detail.edit_block_reason ?? '');
|
favorited: detail.favorited,
|
||||||
setEditWindowHours(detail.post_edit_window_hours ?? 0);
|
canEdit: detail.can_edit ?? false,
|
||||||
setBountyCanRefund(detail.bounty_can_refund ?? true);
|
isEdited: detail.is_edited ?? isTimeDiffSignificant(detail.post.created_at, detail.post.updated_at ?? detail.post.created_at),
|
||||||
setBountyRefundBlockReason(detail.bounty_refund_block_reason ?? '');
|
editBlockReason: detail.edit_block_reason ?? '',
|
||||||
setBountyEligibleReplyCount(detail.bounty_eligible_reply_count ?? 0);
|
editWindowHours: detail.post_edit_window_hours ?? 0,
|
||||||
setComments(Array.isArray(comm.comments) ? comm.comments : []);
|
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();
|
void refresh();
|
||||||
} catch {
|
} catch {
|
||||||
if (seq !== loadSeq.current) return;
|
if (seq !== loadSeq.current) return;
|
||||||
setPost(null);
|
if (!keep) setPost(null);
|
||||||
} finally {
|
} finally {
|
||||||
if (seq === loadSeq.current) setLoading(false);
|
if (seq === loadSeq.current) setLoading(false);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadPostRef.current('auto');
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- 仅 postId 变化时加载
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- 仅 postId 变化时加载
|
||||||
}, [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 reloadComments = useCallback(async () => {
|
||||||
const myIds = user ? [] : loadMyCommentIds();
|
const myIds = user ? [] : loadMyCommentIds();
|
||||||
const comm = await api.comments(postId, myIds);
|
const comm = await api.comments(postId, myIds);
|
||||||
@@ -573,6 +745,7 @@ export default function PostDetailPage() {
|
|||||||
setDeletingPost(true);
|
setDeletingPost(true);
|
||||||
try {
|
try {
|
||||||
await api.deletePost(postId);
|
await api.deletePost(postId);
|
||||||
|
deleteSessionSnapshot(postDetailCacheKey(postId));
|
||||||
clearAllFeedCache();
|
clearAllFeedCache();
|
||||||
window.dispatchEvent(new Event('posts-refresh'));
|
window.dispatchEvent(new Event('posts-refresh'));
|
||||||
notify.success('帖子已删除');
|
notify.success('帖子已删除');
|
||||||
@@ -592,7 +765,7 @@ export default function PostDetailPage() {
|
|||||||
onCancelReply: () => setReplyTo(null),
|
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) {
|
if (!post) {
|
||||||
return (
|
return (
|
||||||
<NotFoundPage
|
<NotFoundPage
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import { openForumPost } from '../utils/openPost';
|
|||||||
import { formatDateTime } from '../utils/content';
|
import { formatDateTime } from '../utils/content';
|
||||||
import { InFlowSiteFooter } from '../components/SiteFooter';
|
import { InFlowSiteFooter } from '../components/SiteFooter';
|
||||||
import { userPath } from '../utils/userPath';
|
import { userPath } from '../utils/userPath';
|
||||||
|
import { useSessionResource } from '../hooks/useSessionResource';
|
||||||
|
|
||||||
const nickSchema = z.object({
|
const nickSchema = z.object({
|
||||||
nickname: z.string().min(1, '请输入昵称').max(64),
|
nickname: z.string().min(1, '请输入昵称').max(64),
|
||||||
@@ -75,7 +76,7 @@ export default function ProfilePage() {
|
|||||||
const nav = useNavigate();
|
const nav = useNavigate();
|
||||||
const [params, setParams] = useSearchParams();
|
const [params, setParams] = useSearchParams();
|
||||||
const tab = parseTab(params.get('tab'));
|
const tab = parseTab(params.get('tab'));
|
||||||
const { user, loading: authLoading, refresh } = useAuth();
|
const { user, loading: authLoading, refresh, logout } = useAuth();
|
||||||
useNoIndexSEO('个人中心');
|
useNoIndexSEO('个人中心');
|
||||||
const [nickLoading, setNickLoading] = useState(false);
|
const [nickLoading, setNickLoading] = useState(false);
|
||||||
const [sigLoading, setSigLoading] = useState(false);
|
const [sigLoading, setSigLoading] = useState(false);
|
||||||
@@ -88,12 +89,6 @@ export default function ProfilePage() {
|
|||||||
const [cropFileName, setCropFileName] = useState('');
|
const [cropFileName, setCropFileName] = useState('');
|
||||||
const [dragOver, setDragOver] = useState(false);
|
const [dragOver, setDragOver] = useState(false);
|
||||||
const [idCopied, setIdCopied] = 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 fileRef = useRef<HTMLInputElement>(null);
|
||||||
const dragCounter = useRef(0);
|
const dragCounter = useRef(0);
|
||||||
const copyTimer = useRef<ReturnType<typeof setTimeout>>();
|
const copyTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||||
@@ -102,7 +97,6 @@ export default function ProfilePage() {
|
|||||||
|
|
||||||
const { limits } = useForumLimits();
|
const { limits } = useForumLimits();
|
||||||
const pageSize = limits.page_size_default > 0 ? limits.page_size_default : 20;
|
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>({
|
const nickForm = useForm<NickValues>({
|
||||||
resolver: zodResolver(nickSchema),
|
resolver: zodResolver(nickSchema),
|
||||||
@@ -181,37 +175,26 @@ export default function ProfilePage() {
|
|||||||
if (copyTimer.current) clearTimeout(copyTimer.current);
|
if (copyTimer.current) clearTimeout(copyTimer.current);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadStats = useCallback(() => {
|
const { data: stats = null, loading: statsLoading } = useSessionResource<UserActivityStats | null>(
|
||||||
setStatsLoading(true);
|
user ? `profile:stats:${user.id}` : null,
|
||||||
api.profileStats()
|
() => api.profileStats().then(d => d.stats ?? null),
|
||||||
.then(d => setStats(d.stats))
|
{ enabled: !!user },
|
||||||
.catch(() => setStats(null))
|
);
|
||||||
.finally(() => setStatsLoading(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const [postPage, setPostPage] = useState(1);
|
||||||
if (!user) return;
|
const postsKey = user && tab === 'posts' ? `profile:posts:${user.id}:${postPage}:${pageSize}` : null;
|
||||||
loadStats();
|
const { data: postsSnap, loading: postsLoading } = useSessionResource<{ posts: PostItem[]; total: number }>(
|
||||||
}, [user, loadStats]);
|
postsKey,
|
||||||
|
() => api.posts({ user_id: user!.id, page: postPage, size: pageSize, sort: 'latest' })
|
||||||
useEffect(() => {
|
.then(d => ({ posts: Array.isArray(d.posts) ? d.posts : [], total: d.total ?? 0 })),
|
||||||
if (!user || tab !== 'posts') return;
|
{
|
||||||
let cancelled = false;
|
enabled: !!postsKey,
|
||||||
setPostsLoading(true);
|
onError: (e) => notify.error(e instanceof Error ? e.message : '加载帖子失败'),
|
||||||
api.posts({ user_id: user.id, page: postPage, size: pageSize, sort: 'latest' })
|
},
|
||||||
.then(d => {
|
);
|
||||||
if (cancelled) return;
|
const posts = postsSnap?.posts ?? [];
|
||||||
setPosts(Array.isArray(d.posts) ? d.posts : []);
|
const postTotal = postsSnap?.total ?? 0;
|
||||||
setPostTotal(d.total ?? 0);
|
const totalPages = Math.max(1, Math.ceil(postTotal / pageSize));
|
||||||
})
|
|
||||||
.catch(e => {
|
|
||||||
if (!cancelled) notify.error(e instanceof Error ? e.message : '加载帖子失败');
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (!cancelled) setPostsLoading(false);
|
|
||||||
});
|
|
||||||
return () => { cancelled = true; };
|
|
||||||
}, [user, tab, postPage, pageSize]);
|
|
||||||
|
|
||||||
const closeCropDialog = useCallback((open: boolean) => {
|
const closeCropDialog = useCallback((open: boolean) => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
@@ -263,7 +246,7 @@ export default function ProfilePage() {
|
|||||||
await api.updatePassword(values.old_password, values.new_password);
|
await api.updatePassword(values.old_password, values.new_password);
|
||||||
notify.success('密码已修改,请重新登录');
|
notify.success('密码已修改,请重新登录');
|
||||||
pwdForm.reset();
|
pwdForm.reset();
|
||||||
await api.logout();
|
await logout();
|
||||||
nav('/login');
|
nav('/login');
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
notify.error(e instanceof Error ? e.message : '修改失败');
|
notify.error(e instanceof Error ? e.message : '修改失败');
|
||||||
|
|||||||
@@ -10,14 +10,13 @@ import ProjectListItem from '../components/ProjectListItem';
|
|||||||
import { InFlowSiteFooter } from '../components/SiteFooter';
|
import { InFlowSiteFooter } from '../components/SiteFooter';
|
||||||
import { joinSEOKeywords, usePageSEO } from '../hooks/usePageSEO';
|
import { joinSEOKeywords, usePageSEO } from '../hooks/usePageSEO';
|
||||||
import { getCachedSiteBranding } from '../hooks/useSiteBranding';
|
import { getCachedSiteBranding } from '../hooks/useSiteBranding';
|
||||||
|
import { useSessionResource } from '../hooks/useSessionResource';
|
||||||
|
|
||||||
|
type ProjectsSnap = { list: GiteaProject[]; total: number; totalPages: number };
|
||||||
|
|
||||||
export default function ProjectsPage() {
|
export default function ProjectsPage() {
|
||||||
const nav = useNavigate();
|
const nav = useNavigate();
|
||||||
const [list, setList] = useState<GiteaProject[]>([]);
|
|
||||||
const [total, setTotal] = useState(0);
|
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [totalPages, setTotalPages] = useState(0);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [queryInput, setQueryInput] = useState('');
|
const [queryInput, setQueryInput] = useState('');
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
|
|
||||||
@@ -40,24 +39,20 @@ export default function ProjectsPage() {
|
|||||||
return () => window.clearTimeout(t);
|
return () => window.clearTimeout(t);
|
||||||
}, [queryInput]);
|
}, [queryInput]);
|
||||||
|
|
||||||
useEffect(() => {
|
const { data, loading } = useSessionResource<ProjectsSnap>(
|
||||||
let cancelled = false;
|
`projects:${page}:${query}`,
|
||||||
setLoading(true);
|
() => api.projects({ page, limit: 30, q: query || undefined }).then(d => ({
|
||||||
api.projects({ page, limit: 30, q: query || undefined })
|
list: Array.isArray(d.projects) ? d.projects : [],
|
||||||
.then(d => {
|
total: d.total ?? 0,
|
||||||
if (cancelled) return;
|
totalPages: d.total_pages ?? 0,
|
||||||
setList(Array.isArray(d.projects) ? d.projects : []);
|
})),
|
||||||
setTotal(d.total ?? 0);
|
{
|
||||||
setTotalPages(d.total_pages ?? 0);
|
onError: (e) => notify.error(e instanceof Error ? e.message : '加载失败'),
|
||||||
})
|
},
|
||||||
.catch(e => {
|
);
|
||||||
if (!cancelled) notify.error(e instanceof Error ? e.message : '加载失败');
|
const list = data?.list ?? [];
|
||||||
})
|
const total = data?.total ?? 0;
|
||||||
.finally(() => {
|
const totalPages = data?.totalPages ?? 0;
|
||||||
if (!cancelled) setLoading(false);
|
|
||||||
});
|
|
||||||
return () => { cancelled = true; };
|
|
||||||
}, [page, query]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-wrap">
|
<div className="page-wrap">
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { ExternalLink, Globe2 } from 'lucide-react';
|
import { ExternalLink, Globe2 } from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
@@ -6,12 +5,15 @@ import type { CommunityShowcaseItem } from '../api/types';
|
|||||||
import { joinSEOKeywords, usePageSEO } from '../hooks/usePageSEO';
|
import { joinSEOKeywords, usePageSEO } from '../hooks/usePageSEO';
|
||||||
import { getCachedSiteBranding, useSiteBranding } from '../hooks/useSiteBranding';
|
import { getCachedSiteBranding, useSiteBranding } from '../hooks/useSiteBranding';
|
||||||
import { InFlowSiteFooter } from '../components/SiteFooter';
|
import { InFlowSiteFooter } from '../components/SiteFooter';
|
||||||
|
import { useSessionResource } from '../hooks/useSessionResource';
|
||||||
|
|
||||||
/** 官方精选的公网部署展柜(只读;仅人工精选条目) */
|
/** 官方精选的公网部署展柜(只读;仅人工精选条目) */
|
||||||
export default function ShowcasePage() {
|
export default function ShowcasePage() {
|
||||||
const { branding } = useSiteBranding();
|
const { branding } = useSiteBranding();
|
||||||
const [items, setItems] = useState<CommunityShowcaseItem[]>([]);
|
const { data: items = [], loading } = useSessionResource<CommunityShowcaseItem[]>(
|
||||||
const [loading, setLoading] = useState(true);
|
'showcase',
|
||||||
|
() => api.communityShowcase().then((r) => (Array.isArray(r.items) ? r.items : [])),
|
||||||
|
);
|
||||||
|
|
||||||
usePageSEO({
|
usePageSEO({
|
||||||
title: '开源部署展柜',
|
title: '开源部署展柜',
|
||||||
@@ -20,22 +22,8 @@ export default function ShowcasePage() {
|
|||||||
canonicalPath: '/showcase',
|
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 (
|
return (
|
||||||
|
<div className="page-wrap">
|
||||||
<div className="showcase-page">
|
<div className="showcase-page">
|
||||||
<header className="showcase-head">
|
<header className="showcase-head">
|
||||||
<div className="showcase-head-mark" aria-hidden>
|
<div className="showcase-head-mark" aria-hidden>
|
||||||
@@ -83,5 +71,6 @@ export default function ShowcasePage() {
|
|||||||
|
|
||||||
<InFlowSiteFooter />
|
<InFlowSiteFooter />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import NotFoundPage from './NotFoundPage';
|
import NotFoundPage from './NotFoundPage';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import type { SitePage } from '../api/types';
|
import type { SitePage } from '../api/types';
|
||||||
import PostContent from '../components/PostContent';
|
import PostContent from '../components/PostContent';
|
||||||
import PageLoader from '../components/PageLoader';
|
|
||||||
import { usePageSEO } from '../hooks/usePageSEO';
|
import { usePageSEO } from '../hooks/usePageSEO';
|
||||||
import { parsePermalinkSlug, pagePath } from '../utils/permalink';
|
import { parsePermalinkSlug, pagePath } from '../utils/permalink';
|
||||||
import { useForumLimits } from '../hooks/useForumLimits';
|
import { useForumLimits } from '../hooks/useForumLimits';
|
||||||
import { useAuth } from '../hooks/useAuth';
|
import { useAuth } from '../hooks/useAuth';
|
||||||
|
import { useSessionResource } from '../hooks/useSessionResource';
|
||||||
|
|
||||||
/** 自定义单页(关于我们、版规等) */
|
/** 自定义单页(关于我们、版规等) */
|
||||||
export default function SitePageView() {
|
export default function SitePageView() {
|
||||||
@@ -16,22 +15,12 @@ export default function SitePageView() {
|
|||||||
const slug = parsePermalinkSlug(rawSlug);
|
const slug = parsePermalinkSlug(rawSlug);
|
||||||
const { limits } = useForumLimits();
|
const { limits } = useForumLimits();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [page, setPage] = useState<SitePage | null>(null);
|
const { data: page, loading } = useSessionResource<SitePage | null>(
|
||||||
const [loading, setLoading] = useState(true);
|
slug ? `sitepage:${slug}` : null,
|
||||||
const [notFound, setNotFound] = useState(false);
|
() => api.page(slug).then(d => d.page),
|
||||||
|
{ enabled: !!slug },
|
||||||
useEffect(() => {
|
);
|
||||||
if (!slug) {
|
const notFound = !slug || (!loading && !page);
|
||||||
setNotFound(true);
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setLoading(true);
|
|
||||||
api.page(slug)
|
|
||||||
.then(d => setPage(d.page))
|
|
||||||
.catch(() => setNotFound(true))
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, [slug]);
|
|
||||||
|
|
||||||
usePageSEO({
|
usePageSEO({
|
||||||
title: page?.title,
|
title: page?.title,
|
||||||
@@ -41,7 +30,7 @@ export default function SitePageView() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!slug) return <NotFoundPage title="页面不存在" />;
|
if (!slug) return <NotFoundPage title="页面不存在" />;
|
||||||
if (loading) return <PageLoader />;
|
if (loading) return null;
|
||||||
if (notFound || !page) return <NotFoundPage title="页面不存在" description="该页面不存在或未发布" />;
|
if (notFound || !page) return <NotFoundPage title="页面不存在" description="该页面不存在或未发布" />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { api } from '../api/client';
|
|||||||
import type { PostItem, UserActivityStats, UserPublic } from '../api/types';
|
import type { PostItem, UserActivityStats, UserPublic } from '../api/types';
|
||||||
import { useAuth } from '../hooks/useAuth';
|
import { useAuth } from '../hooks/useAuth';
|
||||||
import { useForumLimits } from '../hooks/useForumLimits';
|
import { useForumLimits } from '../hooks/useForumLimits';
|
||||||
|
import { useSessionResource } from '../hooks/useSessionResource';
|
||||||
import PostListItem from '../components/PostListItem';
|
import PostListItem from '../components/PostListItem';
|
||||||
import FeedPagination from '../components/FeedPagination';
|
import FeedPagination from '../components/FeedPagination';
|
||||||
import ComposeMessageDialog from '../components/ComposeMessageDialog';
|
import ComposeMessageDialog from '../components/ComposeMessageDialog';
|
||||||
@@ -31,6 +32,9 @@ import { canonicalRedirectPath, parsePermalinkID, userPath } from '../utils/perm
|
|||||||
import NotFoundPage from './NotFoundPage';
|
import NotFoundPage from './NotFoundPage';
|
||||||
import { InFlowSiteFooter } from '../components/SiteFooter';
|
import { InFlowSiteFooter } from '../components/SiteFooter';
|
||||||
|
|
||||||
|
type ProfileSnap = { profile: UserPublic; stats: UserActivityStats | null };
|
||||||
|
type PostsSnap = { posts: PostItem[]; total: number };
|
||||||
|
|
||||||
export default function UserProfilePage() {
|
export default function UserProfilePage() {
|
||||||
const { id: idParam } = useParams();
|
const { id: idParam } = useParams();
|
||||||
const userId = parsePermalinkID(idParam);
|
const userId = parsePermalinkID(idParam);
|
||||||
@@ -40,15 +44,34 @@ export default function UserProfilePage() {
|
|||||||
const { limits } = useForumLimits();
|
const { limits } = useForumLimits();
|
||||||
const pageSize = limits.page_size_default > 0 ? limits.page_size_default : 20;
|
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 [msgOpen, setMsgOpen] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [notFound, setNotFound] = useState(false);
|
const [notFound, setNotFound] = useState(false);
|
||||||
const [posts, setPosts] = useState<PostItem[]>([]);
|
|
||||||
const [postsLoading, setPostsLoading] = useState(false);
|
|
||||||
const [postPage, setPostPage] = useState(1);
|
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 isSelf = !!me && me.id === userId;
|
||||||
const totalPages = Math.max(1, Math.ceil(postTotal / pageSize));
|
const totalPages = Math.max(1, Math.ceil(postTotal / pageSize));
|
||||||
@@ -56,43 +79,13 @@ export default function UserProfilePage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!userId || Number.isNaN(userId)) {
|
if (!userId || Number.isNaN(userId)) {
|
||||||
setNotFound(true);
|
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]);
|
}, [userId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!userId || Number.isNaN(userId) || !profile) return;
|
setNotFound(false);
|
||||||
let cancelled = false;
|
setPostPage(1);
|
||||||
setPostsLoading(true);
|
}, [userId]);
|
||||||
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]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!userId || Number.isNaN(userId)) return;
|
if (!userId || Number.isNaN(userId)) return;
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { cn } from '@/lib/utils';
|
|||||||
import { api } from '../../api/client';
|
import { api } from '../../api/client';
|
||||||
import { useAdminGuard } from '../../layouts/AdminLayout';
|
import { useAdminGuard } from '../../layouts/AdminLayout';
|
||||||
import type { ForumLimits, FriendLink, FriendLinkApply, SiteBranding } from '../../api/types';
|
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 { useSiteBranding, seedSiteBrandingCache, invalidateSiteBrandingCache } from '../../hooks/useSiteBranding';
|
||||||
import { invalidateForumLimitsCache } from '../../hooks/useForumLimits';
|
import { invalidateForumLimitsCache } from '../../hooks/useForumLimits';
|
||||||
import { formatTime } from '../../utils/content';
|
import { formatTime } from '../../utils/content';
|
||||||
@@ -185,6 +186,7 @@ export default function AdminLinksPage() {
|
|||||||
nav_show_showcase: false,
|
nav_show_showcase: false,
|
||||||
footer_show_showcase: false,
|
footer_show_showcase: false,
|
||||||
feed_list_style: 'title',
|
feed_list_style: 'title',
|
||||||
|
feed_sort_tabs: DEFAULT_FEED_SORT_TABS,
|
||||||
permalink_enabled: false,
|
permalink_enabled: false,
|
||||||
permalink_ext: 'html',
|
permalink_ext: 'html',
|
||||||
...s.limits,
|
...s.limits,
|
||||||
@@ -366,6 +368,7 @@ export default function AdminLinksPage() {
|
|||||||
nav_show_showcase: false,
|
nav_show_showcase: false,
|
||||||
footer_show_showcase: false,
|
footer_show_showcase: false,
|
||||||
feed_list_style: 'title',
|
feed_list_style: 'title',
|
||||||
|
feed_sort_tabs: DEFAULT_FEED_SORT_TABS,
|
||||||
permalink_enabled: false,
|
permalink_enabled: false,
|
||||||
permalink_ext: 'html',
|
permalink_ext: 'html',
|
||||||
post_edit_window_hours: 24,
|
post_edit_window_hours: 24,
|
||||||
|
|||||||
@@ -11,9 +11,11 @@ import { invalidateForumLimitsCache } from '../../hooks/useForumLimits';
|
|||||||
import { DEFAULT_BRANDING, seedSiteBrandingCache } from '../../hooks/useSiteBranding';
|
import { DEFAULT_BRANDING, seedSiteBrandingCache } from '../../hooks/useSiteBranding';
|
||||||
import { clearAllFeedCache } from '../../utils/feedCache';
|
import { clearAllFeedCache } from '../../utils/feedCache';
|
||||||
import { normalizeAsideWidgets, resolveAsideWidgets, mergeForumLimitsWithAsideWidgets, resolveSavedAsideWidgets } from '../../utils/asideWidgets';
|
import { normalizeAsideWidgets, resolveAsideWidgets, mergeForumLimitsWithAsideWidgets, resolveSavedAsideWidgets } from '../../utils/asideWidgets';
|
||||||
|
import { normalizeFeedSortTabs } from '../../utils/feedSortTabs';
|
||||||
import AsideWidgetList from '../../components/admin/AsideWidgetList';
|
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 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';
|
type TabId = 'branding' | 'limits' | 'mail' | 'oidc' | 'gitea' | 'storage' | 'filter' | 'system';
|
||||||
|
|
||||||
@@ -305,6 +307,7 @@ export default function AdminSettingsPage() {
|
|||||||
nav_show_showcase: false,
|
nav_show_showcase: false,
|
||||||
footer_show_showcase: false,
|
footer_show_showcase: false,
|
||||||
feed_list_style: 'title',
|
feed_list_style: 'title',
|
||||||
|
feed_sort_tabs: normalizeFeedSortTabs(s.limits?.feed_sort_tabs ?? DEFAULT_FEED_SORT_TABS),
|
||||||
permalink_enabled: false,
|
permalink_enabled: false,
|
||||||
permalink_ext: 'html',
|
permalink_ext: 'html',
|
||||||
...s.limits,
|
...s.limits,
|
||||||
@@ -916,7 +919,7 @@ export default function AdminSettingsPage() {
|
|||||||
<section className="admin-settings-section" id="settings-feed-list">
|
<section className="admin-settings-section" id="settings-feed-list">
|
||||||
<div className="admin-settings-section-head">
|
<div className="admin-settings-section-head">
|
||||||
<h3>列表呈现</h3>
|
<h3>列表呈现</h3>
|
||||||
<p>首页及帖子列表的信息密度与缩略图展示</p>
|
<p>首页及帖子列表的信息密度、缩略图与排序标签</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-settings-table" role="group" aria-label="列表呈现">
|
<div className="admin-settings-table" role="group" aria-label="列表呈现">
|
||||||
<div className="admin-settings-row">
|
<div className="admin-settings-row">
|
||||||
@@ -946,6 +949,16 @@ export default function AdminSettingsPage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
<section className="admin-settings-section" id="settings-permalink">
|
<section className="admin-settings-section" id="settings-permalink">
|
||||||
|
|||||||
@@ -227,6 +227,30 @@ a:hover { text-decoration: underline; }
|
|||||||
border-bottom: 1px solid var(--j13-border);
|
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 {
|
.site-footer {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -2751,6 +2775,14 @@ body:has(.admin-topbar) .ptr-indicator {
|
|||||||
flex-shrink: 0;
|
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) {
|
@media (max-width: 768px) {
|
||||||
.feed-head {
|
.feed-head {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -3501,6 +3533,21 @@ body:has(.admin-topbar) .ptr-indicator {
|
|||||||
flex-shrink: 0;
|
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 {
|
.post-title {
|
||||||
display: block;
|
display: block;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -3513,8 +3560,9 @@ body:has(.admin-topbar) .ptr-indicator {
|
|||||||
color: var(--color-text-1);
|
color: var(--color-text-1);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: color 0.15s;
|
transition: color 0.15s;
|
||||||
|
/* 不抢满行宽:短标题时类型徽章紧挨末字;过长时在剩余空间内省略 */
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex: 1;
|
flex: 0 1 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
a.post-title:visited {
|
a.post-title:visited {
|
||||||
@@ -11110,7 +11158,7 @@ button.profile-stat:hover strong {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 路由懒加载占位已改为 FeedPageSkeleton,保留类名兼容 */
|
/* 路由懒加载占位类名保留兼容(前台已改顶栏进度 + 空白) */
|
||||||
|
|
||||||
/* ========== React 管理后台 ========== */
|
/* ========== React 管理后台 ========== */
|
||||||
.admin-shell { min-height: 100vh; background: hsl(var(--background)); }
|
.admin-shell { min-height: 100vh; background: hsl(var(--background)); }
|
||||||
@@ -11572,6 +11620,21 @@ button.profile-stat:hover strong {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: hsl(var(--muted-foreground));
|
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-mail-switch,其绝对定位会破坏后台布局) */
|
||||||
.admin-settings-switch {
|
.admin-settings-switch {
|
||||||
@@ -14521,6 +14584,51 @@ button.post-poll__option,
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--foreground, #0f172a);
|
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 {
|
.admin-sortable-row__hint {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import {
|
|||||||
getHomeStoreState,
|
getHomeStoreState,
|
||||||
type FeedCacheEntry,
|
type FeedCacheEntry,
|
||||||
} from '../store/homeStore';
|
} from '../store/homeStore';
|
||||||
|
import { clearSessionSnapshots } from './sessionPageCache';
|
||||||
|
import { softRefreshCurrentPage } from './softRefresh';
|
||||||
|
import { transitionTo } from './spaTransition';
|
||||||
|
|
||||||
/** 导航到帖子列表时附带的状态,用于同 URL 重复点击时强制刷新 */
|
/** 导航到帖子列表时附带的状态,用于同 URL 重复点击时强制刷新 */
|
||||||
export type FeedNavState = { refreshFeed?: boolean };
|
export type FeedNavState = { refreshFeed?: boolean };
|
||||||
@@ -55,9 +58,10 @@ export function setFeedCache(
|
|||||||
getHomeStoreState().setFeed(key, data);
|
getHomeStoreState().setFeed(key, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 清除所有帖子列表缓存 */
|
/** 清除所有帖子列表缓存(手动刷新 / 帖子变更时连详情快照一起作废) */
|
||||||
export function clearAllFeedCache() {
|
export function clearAllFeedCache() {
|
||||||
getHomeStoreState().clearAll();
|
getHomeStoreState().clearAll();
|
||||||
|
clearSessionSnapshots('post:');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 主动刷新帖子列表时派发,用于同页内立即回到顶部 */
|
/** 主动刷新帖子列表时派发,用于同页内立即回到顶部 */
|
||||||
@@ -66,6 +70,9 @@ export const FEED_RESET_EVENT = 'feed-reset';
|
|||||||
/** 手机下拉刷新(Feed 页):强制重拉列表并重置滚动,不整页 reload */
|
/** 手机下拉刷新(Feed 页):强制重拉列表并重置滚动,不整页 reload */
|
||||||
export const FEED_PULL_REFRESH_EVENT = 'feed-pull-refresh';
|
export const FEED_PULL_REFRESH_EVENT = 'feed-pull-refresh';
|
||||||
|
|
||||||
|
/** 手机下拉:强制刷新当前前台页(绕过会话快照,非整页 reload) */
|
||||||
|
export const PAGE_FORCE_REFRESH_EVENT = 'page-force-refresh';
|
||||||
|
|
||||||
/** 当前浏览器地址是否已是目标 Feed URL(忽略 hash) */
|
/** 当前浏览器地址是否已是目标 Feed URL(忽略 hash) */
|
||||||
function isSameFeedUrl(url: string): boolean {
|
function isSameFeedUrl(url: string): boolean {
|
||||||
try {
|
try {
|
||||||
@@ -80,15 +87,23 @@ function isSameFeedUrl(url: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 清除缓存并导航到帖子列表。
|
* 导航到帖子列表。
|
||||||
* 已在目标 URL(如首页再点 Logo)时额外派发强制重拉,不依赖 RR 是否换 key。
|
* 默认:等待预热后再换页;同 URL 或 `refresh: true` 时静默软刷新(无进度条)。
|
||||||
*/
|
*/
|
||||||
export function navigateFeed(nav: NavigateFunction, url: string) {
|
export function navigateFeed(nav: NavigateFunction, url: string, opts?: { refresh?: boolean }) {
|
||||||
clearAllFeedCache();
|
const same = isSameFeedUrl(url);
|
||||||
window.dispatchEvent(new Event(FEED_RESET_EVENT));
|
const refresh = opts?.refresh ?? same;
|
||||||
// 同 URL 再点(典型:左上角 Logo):必须立刻重拉,否则可能只清缓存、界面仍显示旧列表
|
if (refresh) {
|
||||||
if (isSameFeedUrl(url)) {
|
if (same) {
|
||||||
window.dispatchEvent(new Event(FEED_PULL_REFRESH_EVENT));
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
41
frontend/src/utils/feedSortTabs.ts
Normal file
41
frontend/src/utils/feedSortTabs.ts
Normal 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';
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { NavigateFunction } from 'react-router-dom';
|
import type { NavigateFunction } from 'react-router-dom';
|
||||||
import { postPath, type PermalinkOpts } from './permalink';
|
import { postPath, type PermalinkOpts } from './permalink';
|
||||||
|
import { transitionTo } from './spaTransition';
|
||||||
|
|
||||||
export type OpenForumPostOpts = PermalinkOpts & {
|
export type OpenForumPostOpts = PermalinkOpts & {
|
||||||
/** 跳转到指定楼层(#floor-N) */
|
/** 跳转到指定楼层(#floor-N) */
|
||||||
@@ -18,5 +19,5 @@ export function openForumPost(
|
|||||||
window.open(path, '_blank', 'noopener,noreferrer');
|
window.open(path, '_blank', 'noopener,noreferrer');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
nav(path);
|
void transitionTo(nav, path);
|
||||||
}
|
}
|
||||||
|
|||||||
421
frontend/src/utils/prefetchRoute.ts
Normal file
421
frontend/src/utils/prefetchRoute.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
45
frontend/src/utils/sessionPageCache.ts
Normal file
45
frontend/src/utils/sessionPageCache.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
20
frontend/src/utils/softRefresh.ts
Normal file
20
frontend/src/utils/softRefresh.ts
Normal 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));
|
||||||
|
}
|
||||||
110
frontend/src/utils/spaTransition.ts
Normal file
110
frontend/src/utils/spaTransition.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -967,6 +967,11 @@ func (h *Handlers) APIPosts(c *gin.Context) {
|
|||||||
author := strings.TrimSpace(c.Query("author"))
|
author := strings.TrimSpace(c.Query("author"))
|
||||||
titleOnly := c.Query("title_only") == "1" || strings.EqualFold(c.Query("title_only"), "true")
|
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{
|
q := service.PostListQuery{
|
||||||
BoardID: uint(boardID),
|
BoardID: uint(boardID),
|
||||||
UserID: uint(userID),
|
UserID: uint(userID),
|
||||||
@@ -976,7 +981,7 @@ func (h *Handlers) APIPosts(c *gin.Context) {
|
|||||||
Tag: tag,
|
Tag: tag,
|
||||||
Author: author,
|
Author: author,
|
||||||
TitleOnly: titleOnly,
|
TitleOnly: titleOnly,
|
||||||
Sort: c.DefaultQuery("sort", "reply"),
|
Sort: sort,
|
||||||
ViewerID: h.currentUserID(c),
|
ViewerID: h.currentUserID(c),
|
||||||
ViewerIsAdmin: h.isAdmin(c),
|
ViewerIsAdmin: h.isAdmin(c),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ const (
|
|||||||
SettingNavShowShowcase = "nav_show_showcase"
|
SettingNavShowShowcase = "nav_show_showcase"
|
||||||
SettingFooterShowShowcase = "footer_show_showcase"
|
SettingFooterShowShowcase = "footer_show_showcase"
|
||||||
SettingFeedListStyle = "feed_list_style"
|
SettingFeedListStyle = "feed_list_style"
|
||||||
|
SettingFeedSortTabs = "feed_sort_tabs"
|
||||||
|
|
||||||
// 伪静态键名见 permalink.go:SettingPermalinkEnabled / SettingPermalinkExt
|
// 伪静态键名见 permalink.go:SettingPermalinkEnabled / SettingPermalinkExt
|
||||||
|
|
||||||
@@ -159,6 +160,7 @@ type ForumLimits struct {
|
|||||||
FooterShowShowcase bool `json:"footer_show_showcase"`
|
FooterShowShowcase bool `json:"footer_show_showcase"`
|
||||||
|
|
||||||
FeedListStyle string `json:"feed_list_style"`
|
FeedListStyle string `json:"feed_list_style"`
|
||||||
|
FeedSortTabs []FeedSortTab `json:"feed_sort_tabs"`
|
||||||
|
|
||||||
PermalinkEnabled bool `json:"permalink_enabled"`
|
PermalinkEnabled bool `json:"permalink_enabled"`
|
||||||
PermalinkExt string `json:"permalink_ext"`
|
PermalinkExt string `json:"permalink_ext"`
|
||||||
@@ -170,12 +172,23 @@ type AsideWidget struct {
|
|||||||
Enabled bool `json:"enabled"`
|
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 (
|
const (
|
||||||
AsideWidgetTagCloud = "tag_cloud"
|
AsideWidgetTagCloud = "tag_cloud"
|
||||||
AsideWidgetRecentComments = "recent_comments"
|
AsideWidgetRecentComments = "recent_comments"
|
||||||
AsideWidgetRecentUsers = "recent_users"
|
AsideWidgetRecentUsers = "recent_users"
|
||||||
AsideWidgetFriendLinks = "friend_links"
|
AsideWidgetFriendLinks = "friend_links"
|
||||||
AsideWidgetShowcase = "showcase"
|
AsideWidgetShowcase = "showcase"
|
||||||
|
|
||||||
|
FeedSortReply = "reply"
|
||||||
|
FeedSortLatest = "latest"
|
||||||
|
FeedSortHot = "hot"
|
||||||
)
|
)
|
||||||
|
|
||||||
var asideWidgetDefaultOrder = []string{
|
var asideWidgetDefaultOrder = []string{
|
||||||
@@ -186,6 +199,18 @@ var asideWidgetDefaultOrder = []string{
|
|||||||
AsideWidgetShowcase,
|
AsideWidgetShowcase,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var feedSortTabDefaultOrder = []string{
|
||||||
|
FeedSortReply,
|
||||||
|
FeedSortLatest,
|
||||||
|
FeedSortHot,
|
||||||
|
}
|
||||||
|
|
||||||
|
var feedSortTabDefaultLabels = map[string]string{
|
||||||
|
FeedSortReply: "新评论",
|
||||||
|
FeedSortLatest: "新帖子",
|
||||||
|
FeedSortHot: "推荐帖",
|
||||||
|
}
|
||||||
|
|
||||||
// ForumLimitsPublic 前台可见的限制(不含限流等内部配置)
|
// ForumLimitsPublic 前台可见的限制(不含限流等内部配置)
|
||||||
type ForumLimitsPublic struct {
|
type ForumLimitsPublic struct {
|
||||||
PostTitleMax int `json:"post_title_max"`
|
PostTitleMax int `json:"post_title_max"`
|
||||||
@@ -215,6 +240,7 @@ type ForumLimitsPublic struct {
|
|||||||
FooterShowShowcase bool `json:"footer_show_showcase"`
|
FooterShowShowcase bool `json:"footer_show_showcase"`
|
||||||
|
|
||||||
FeedListStyle string `json:"feed_list_style"`
|
FeedListStyle string `json:"feed_list_style"`
|
||||||
|
FeedSortTabs []FeedSortTab `json:"feed_sort_tabs"`
|
||||||
|
|
||||||
PermalinkEnabled bool `json:"permalink_enabled"`
|
PermalinkEnabled bool `json:"permalink_enabled"`
|
||||||
PermalinkExt string `json:"permalink_ext"`
|
PermalinkExt string `json:"permalink_ext"`
|
||||||
@@ -261,6 +287,7 @@ var forumSettingDefs = []settingDef{
|
|||||||
|
|
||||||
var feedSettingDefaults = map[string]string{
|
var feedSettingDefaults = map[string]string{
|
||||||
SettingFeedListStyle: "title",
|
SettingFeedListStyle: "title",
|
||||||
|
SettingFeedSortTabs: `[{"id":"reply","label":"新评论","enabled":true},{"id":"latest","label":"新帖子","enabled":true},{"id":"hot","label":"推荐帖","enabled":true}]`,
|
||||||
}
|
}
|
||||||
|
|
||||||
var asideSettingDefaults = map[string]string{
|
var asideSettingDefaults = map[string]string{
|
||||||
@@ -656,6 +683,7 @@ func (s *ForumSettingsService) Limits() ForumLimits {
|
|||||||
FooterShowShowcase: s.FooterShowShowcase(),
|
FooterShowShowcase: s.FooterShowShowcase(),
|
||||||
|
|
||||||
FeedListStyle: s.FeedListStyle(),
|
FeedListStyle: s.FeedListStyle(),
|
||||||
|
FeedSortTabs: s.FeedSortTabs(),
|
||||||
|
|
||||||
PermalinkEnabled: permalink.Enabled,
|
PermalinkEnabled: permalink.Enabled,
|
||||||
PermalinkExt: permalink.Ext,
|
PermalinkExt: permalink.Ext,
|
||||||
@@ -692,6 +720,7 @@ func (s *ForumSettingsService) PublicLimits() ForumLimitsPublic {
|
|||||||
FooterShowShowcase: limits.FooterShowShowcase,
|
FooterShowShowcase: limits.FooterShowShowcase,
|
||||||
|
|
||||||
FeedListStyle: limits.FeedListStyle,
|
FeedListStyle: limits.FeedListStyle,
|
||||||
|
FeedSortTabs: limits.FeedSortTabs,
|
||||||
|
|
||||||
PermalinkEnabled: limits.PermalinkEnabled,
|
PermalinkEnabled: limits.PermalinkEnabled,
|
||||||
PermalinkExt: limits.PermalinkExt,
|
PermalinkExt: limits.PermalinkExt,
|
||||||
@@ -776,6 +805,14 @@ func (s *ForumSettingsService) UpdateLimits(in ForumLimits) error {
|
|||||||
if err := s.setString(SettingFeedListStyle, style); err != nil {
|
if err := s.setString(SettingFeedListStyle, style); err != nil {
|
||||||
return err
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1064,6 +1101,76 @@ func (s *ForumSettingsService) FeedListStyle() string {
|
|||||||
return v
|
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 配置(密码不回显明文)
|
// MailConfig 读取 SMTP 配置(密码不回显明文)
|
||||||
func (s *ForumSettingsService) MailConfig() MailConfig {
|
func (s *ForumSettingsService) MailConfig() MailConfig {
|
||||||
port, _ := strconv.Atoi(s.getString(SettingSMTPPort, "465"))
|
port, _ := strconv.Atoi(s.getString(SettingSMTPPort, "465"))
|
||||||
|
|||||||
Reference in New Issue
Block a user