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