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