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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -61,6 +61,14 @@ function PostListItem({ post, sort = 'latest', boardId = 0, onSelect }: Props) {
openPost();
};
const hasTypeBadge = post.post_type === 'question'
|| post.post_type === 'poll'
|| post.post_type === 'lottery'
|| (post.post_type === 'bounty' && (
(post.bounty_status === 'open' && (post.bounty_points ?? 0) > 0)
|| post.bounty_status === 'awarded'
));
const titleRow = (
<div className="post-title-row">
{post.pinned && (
@@ -81,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>
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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