项目截图、readme更新

This commit is contained in:
2026-08-05 17:11:05 +08:00
parent cc51c49272
commit e8b28a37e4
16 changed files with 316 additions and 154 deletions

View File

@@ -10,10 +10,18 @@ const BIND_RETRY_MS = 50;
/**
* 定位当前真正滚动的容器。
* 前台:.post-list-scroll / .page-wrap / .main-content--compose
* 前台:手机 Feed 整栏 / .post-list-scroll / .page-wrap / .main-content--compose
* 后台:.admin-main
*/
function pickScrollEl(scope: ParentNode): HTMLElement | null {
// 手机首页:板块条 + 排序栏随列表滚动,滚动根是 main
if (scope instanceof HTMLElement && scope.classList.contains('main-content--feed-mobile-scroll')) {
return scope;
}
const mobileFeed = scope.querySelector?.('.main-content--feed-mobile-scroll')
?? document.querySelector('.main-content--feed-mobile-scroll');
if (mobileFeed instanceof HTMLElement) return mobileFeed;
const list = scope.querySelector<HTMLElement>('.post-list-scroll');
if (list) return list;
const page = scope.querySelector<HTMLElement>('.page-wrap');
@@ -24,7 +32,8 @@ function pickScrollEl(scope: ParentNode): HTMLElement | null {
}
function findScrollScope(): ParentNode | null {
return document.querySelector('.main-content')
return document.querySelector('.main-content--feed-mobile-scroll')
?? document.querySelector('.main-content')
?? document.querySelector('.admin-shell');
}

View File

@@ -13,6 +13,10 @@ const ARM_DELTA = 10;
* SPA 使用内部滚动body overflow:hidden原生下拉刷新不可用需挂到此容器。
*/
function pickScrollEl(): HTMLElement | null {
// 手机 Feed 整栏滚动(板块 / 排序栏可滚走)
const mobileFeed = document.querySelector<HTMLElement>('.main-content--feed-mobile-scroll');
if (mobileFeed) return mobileFeed;
const list = document.querySelector<HTMLElement>('.post-list-scroll');
if (list) return list;

View File

@@ -1,4 +1,4 @@
import { useRef, useEffect, useLayoutEffect } from 'react';
import { useRef, useEffect, useLayoutEffect, useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useVirtualizer } from '@tanstack/react-virtual';
import { Inbox, SearchX } from 'lucide-react';
@@ -8,6 +8,7 @@ import PostListSkeleton from './PostListSkeleton';
import FeedPagination from './FeedPagination';
import { InFlowSiteFooter } from './SiteFooter';
import { useAuth } from '../hooks/useAuth';
import { useMediaQuery } from '../hooks/useTheme';
import { loginPath } from '../utils/authRedirect';
import type { PostItem } from '../api/types';
import type { FeedSort } from './FeedSortBar';
@@ -39,6 +40,11 @@ interface Props {
noBoards?: boolean;
}
/** 手机 Feed 整栏滚动时的容器 */
function getMobileFeedScrollEl(): HTMLElement | null {
return document.querySelector('.main-content--feed-mobile-scroll');
}
export default function VirtualPostList({
posts,
sort = 'latest',
@@ -61,6 +67,7 @@ export default function VirtualPostList({
}: Props) {
const nav = useNavigate();
const { user } = useAuth();
const isMobile = useMediaQuery('(max-width: 768px)');
const parentRef = useRef<HTMLDivElement>(null);
const restoredRef = useRef(false);
const onScrollTopChangeRef = useRef(onScrollTopChange);
@@ -68,11 +75,50 @@ export default function VirtualPostList({
onScrollTopChangeRef.current = onScrollTopChange;
onScrollRestoredRef.current = onScrollRestored;
const [scrollMargin, setScrollMargin] = useState(0);
const getScrollElement = useCallback(() => {
if (isMobile) {
return getMobileFeedScrollEl() ?? parentRef.current;
}
return parentRef.current;
}, [isMobile]);
/** 列表相对整栏滚动容器顶部的偏移,供虚拟列表对齐 */
useLayoutEffect(() => {
if (!isMobile) {
setScrollMargin(0);
return;
}
const main = getMobileFeedScrollEl();
const list = parentRef.current;
if (!main || !list) return;
const update = () => {
// 相对 main 内容顶(含当前 scrollTop避免 offsetParent 链不准
const next = Math.max(
0,
Math.round(list.getBoundingClientRect().top - main.getBoundingClientRect().top + main.scrollTop),
);
setScrollMargin(prev => (prev === next ? prev : next));
};
update();
const ro = new ResizeObserver(update);
ro.observe(main);
const boardBar = main.querySelector('.mobile-board-bar');
const feedTop = main.querySelector('.feed-top');
if (boardBar) ro.observe(boardBar);
if (feedTop) ro.observe(feedTop);
return () => ro.disconnect();
}, [isMobile, posts.length, loading, keyword, boardId]);
const virtualizer = useVirtualizer({
count: posts.length,
getScrollElement: () => parentRef.current,
getScrollElement,
estimateSize: () => 108,
overscan: 8,
scrollMargin: isMobile ? scrollMargin : 0,
measureElement:
typeof window !== 'undefined' && !navigator.userAgent.includes('Firefox')
? (el) => el.getBoundingClientRect().height
@@ -88,35 +134,40 @@ export default function VirtualPostList({
useLayoutEffect(() => {
if (resetScrollKey <= 0) return;
const el = parentRef.current;
const el = getScrollElement();
if (el) {
el.scrollTop = 0;
virtualizer.scrollToOffset(0);
}
restoredRef.current = true;
onScrollTopChangeRef.current?.(0);
}, [resetScrollKey, virtualizer]);
}, [resetScrollKey, virtualizer, getScrollElement]);
useLayoutEffect(() => {
if (restoreScrollTop == null || restoredRef.current || posts.length === 0) return;
virtualizer.scrollToOffset(restoreScrollTop);
const el = getScrollElement();
if (el) {
el.scrollTop = restoreScrollTop;
} else {
virtualizer.scrollToOffset(restoreScrollTop);
}
restoredRef.current = true;
onScrollRestoredRef.current?.();
}, [restoreScrollTop, posts.length, virtualizer]);
}, [restoreScrollTop, posts.length, virtualizer, getScrollElement]);
useEffect(() => {
restoredRef.current = false;
}, [restoreScrollTop]);
useEffect(() => {
const el = parentRef.current;
const el = getScrollElement();
if (!el) return;
const onScroll = () => {
onScrollTopChangeRef.current?.(el.scrollTop);
};
el.addEventListener('scroll', onScroll);
el.addEventListener('scroll', onScroll, { passive: true });
return () => el.removeEventListener('scroll', onScroll);
}, []);
}, [getScrollElement, isMobile]);
const emptyActions = (
<div className="empty-feed-actions">
@@ -193,6 +244,8 @@ export default function VirtualPostList({
{virtualizer.getVirtualItems().map(vi => {
const post = posts[vi.index];
if (!post) return null;
// scrollMargin 模式下 start 含偏移,需减回才能在列表内绝对定位
const offsetY = isMobile ? vi.start - scrollMargin : vi.start;
return (
<div
key={vi.key}
@@ -203,7 +256,7 @@ export default function VirtualPostList({
top: 0,
left: 0,
width: '100%',
transform: `translateY(${vi.start}px)`,
transform: `translateY(${offsetY}px)`,
}}
>
<PostListItem post={post} sort={sort} onSelect={onSelect} />

View File

@@ -506,7 +506,12 @@ export default function MainLayout() {
isCompose && 'content-workspace--compose',
hideAside && !isCompose && 'content-workspace--aside-hidden',
)}>
<main className={`main-content${isCompose ? ' main-content--compose' : ''}`}>
<main className={cn(
'main-content',
isCompose && 'main-content--compose',
// 手机 Feed整栏滚动板块条 / 排序栏可滚出视口,多露出帖子列表
isMobile && !isCompose && isFeedHome && 'main-content--feed-mobile-scroll',
)}>
{isMobile && !isCompose && isFeedHome && (
<div
ref={boardBarRef}

View File

@@ -2131,6 +2131,47 @@ img.site-brand-logo-img {
border-radius: 0;
box-shadow: none;
}
/*
* 手机 Feed主栏整页滚动板块条 / 最新发帖·回复排序栏随列表滚走,
* 避免它们占住视口上半,给帖子列表更多空间。
*/
.main-content--feed-mobile-scroll {
overflow-x: hidden;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior-y: contain;
}
.main-content--feed-mobile-scroll .page-wrap--feed {
overflow: visible;
flex: 0 0 auto;
min-height: auto;
height: auto;
}
.main-content--feed-mobile-scroll .feed-panel {
overflow: visible;
flex: 0 0 auto;
min-height: auto;
height: auto;
}
.main-content--feed-mobile-scroll .feed-top {
flex-shrink: 0;
}
.main-content--feed-mobile-scroll .post-list-scroll {
overflow: visible;
flex: 0 0 auto;
min-height: auto;
height: auto;
}
.main-content--feed-mobile-scroll .mobile-board-bar {
flex-shrink: 0;
position: static;
}
}
.post-list-bar {