新增 OIDC/SSO、邮件验证码与 Gitea 项目同步,并强化 Feed 与管理后台。

作为 OIDC Provider 对接 Gitea;注册支持邮件验证码/验证码;侧栏同步公开仓库;Feed 分页、文章大纲、标签云与站点品牌设置。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-31 16:58:22 +08:00
parent 9487c8ab02
commit 822eef96be
83 changed files with 8578 additions and 1222 deletions

View File

@@ -1,9 +1,13 @@
import { useRef, useEffect, useLayoutEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useVirtualizer } from '@tanstack/react-virtual';
import { Inbox } from 'lucide-react';
import { Button } from '@/components/ui/button';
import PostListItem from './PostListItem';
import PostListSkeleton from './PostListSkeleton';
import FeedPagination from './FeedPagination';
import { useAuth } from '../hooks/useAuth';
import { loginPath } from '../utils/authRedirect';
import type { PostItem } from '../api/types';
import type { FeedSort } from './FeedSortBar';
@@ -11,15 +15,16 @@ interface Props {
posts: PostItem[];
sort?: FeedSort;
loading: boolean;
/** 当前页之后是否还有更多 */
hasMore: boolean;
/** 是否允许滚动触底自动加载(达到上限后为 false */
canAutoLoad: boolean;
/** 是否显示底部分页控件 */
showPagination: boolean;
page: number;
totalPages: number;
postTotal: number;
onLoadMore: () => void;
onPageChange: (page: number) => void;
onSelect: (id: number) => void;
/** 返回列表时恢复的滚动位置 */
restoreScrollTop?: number | null;
/** 递增时强制回到列表顶部(主动刷新导航) */
resetScrollKey?: number;
onScrollTopChange?: (top: number) => void;
onScrollRestored?: () => void;
@@ -30,17 +35,25 @@ export default function VirtualPostList({
sort = 'latest',
loading,
hasMore,
canAutoLoad,
showPagination,
page,
totalPages,
postTotal,
onLoadMore,
onPageChange,
onSelect,
restoreScrollTop,
resetScrollKey = 0,
onScrollTopChange,
onScrollRestored,
}: Props) {
const nav = useNavigate();
const { user } = useAuth();
const parentRef = useRef<HTMLDivElement>(null);
const restoredRef = useRef(false);
const onScrollTopChangeRef = useRef(onScrollTopChange);
const onScrollRestoredRef = useRef(onScrollRestored);
onScrollTopChangeRef.current = onScrollTopChange;
onScrollRestoredRef.current = onScrollRestored;
const virtualizer = useVirtualizer({
count: posts.length,
@@ -53,10 +66,8 @@ export default function VirtualPostList({
: undefined,
});
const showHistoryPrompt = hasMore && !canAutoLoad && !loading;
const showEnd = !hasMore && posts.length > 0 && !loading;
const showEnd = !hasMore && !showPagination && posts.length > 0 && !loading;
const isInitialLoad = loading && posts.length === 0;
const isLoadingMore = loading && posts.length > 0;
const isEmpty = !loading && posts.length === 0;
useLayoutEffect(() => {
@@ -67,15 +78,15 @@ export default function VirtualPostList({
virtualizer.scrollToOffset(0);
}
restoredRef.current = true;
onScrollTopChange?.(0);
}, [resetScrollKey, virtualizer, onScrollTopChange]);
onScrollTopChangeRef.current?.(0);
}, [resetScrollKey, virtualizer]);
useLayoutEffect(() => {
if (restoreScrollTop == null || restoredRef.current || posts.length === 0) return;
virtualizer.scrollToOffset(restoreScrollTop);
restoredRef.current = true;
onScrollRestored?.();
}, [restoreScrollTop, posts.length, virtualizer, onScrollRestored]);
onScrollRestoredRef.current?.();
}, [restoreScrollTop, posts.length, virtualizer]);
useEffect(() => {
restoredRef.current = false;
@@ -85,33 +96,42 @@ export default function VirtualPostList({
const el = parentRef.current;
if (!el) return;
const onScroll = () => {
onScrollTopChange?.(el.scrollTop);
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 120 && canAutoLoad && hasMore && !loading) {
onLoadMore();
}
onScrollTopChangeRef.current?.(el.scrollTop);
};
el.addEventListener('scroll', onScroll);
return () => el.removeEventListener('scroll', onScroll);
}, [canAutoLoad, hasMore, loading, onLoadMore, onScrollTopChange]);
}, []);
return (
<div className="post-list-scroll" ref={parentRef}>
{isInitialLoad ? (
<PostListSkeleton />
) : isEmpty ? (
<div className="empty-feed">
<div className="empty-feed" role="status">
<Inbox className="empty-feed-icon" aria-hidden size={36} strokeWidth={1.5} />
<p></p>
<p className="empty-feed-hint"></p>
<div className="empty-feed-actions">
{user ? (
<Button type="button" size="sm" onClick={() => nav('/compose')}>
</Button>
) : (
<Button type="button" size="sm" onClick={() => nav(loginPath('/compose'))}>
</Button>
)}
</div>
</div>
) : (
<>
<div className="content-surface" style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map(vi => {
const post = posts[vi.index];
if (!post) return null;
return (
<div
key={post.id}
key={vi.key}
data-index={vi.index}
ref={virtualizer.measureElement}
style={{
@@ -122,20 +142,20 @@ export default function VirtualPostList({
transform: `translateY(${vi.start}px)`,
}}
>
<PostListItem post={post} sort={sort} onClick={() => onSelect(post.id)} />
<PostListItem post={post} sort={sort} onSelect={onSelect} />
</div>
);
})}
</div>
{isLoadingMore && <PostListSkeleton count={2} />}
{showHistoryPrompt && (
<div className="feed-list-footer feed-list-footer--history">
<p className="feed-list-footer__hint">
{posts.length} / {postTotal}
</p>
<Button type="button" variant="outline" size="sm" onClick={onLoadMore}>
</Button>
{showPagination && (
<div className="feed-list-footer feed-list-footer--pagination">
<FeedPagination
page={page}
totalPages={totalPages}
postTotal={postTotal}
loading={loading}
onPageChange={onPageChange}
/>
</div>
)}
{showEnd && (