故事详情排版修改
This commit is contained in:
@@ -4,33 +4,35 @@ import TimelineItem from '@/pages/story/components/TimelineItem/TimelineItem';
|
||||
import { StoryItem } from '@/pages/story/data';
|
||||
import { queryStoryDetail, queryStoryItem } from '@/pages/story/service';
|
||||
import { PageContainer } from '@ant-design/pro-components';
|
||||
import { history, useIntl, useRequest } from '@umijs/max';
|
||||
import { FloatButton, Spin } from 'antd';
|
||||
import { history, useRequest } from '@umijs/max';
|
||||
import { FloatButton, Spin, Empty, Button } from 'antd';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import { FixedSizeList as List } from 'react-window';
|
||||
import { VariableSizeList as List } from 'react-window';
|
||||
import { SyncOutlined } from '@ant-design/icons';
|
||||
import './index.css';
|
||||
import useStyles from './style.style';
|
||||
|
||||
interface TimelineItemProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const Index = () => {
|
||||
const { id: lineId } = useParams<{ id: string }>();
|
||||
const { styles } = useStyles();
|
||||
const intl = useIntl();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const listRef = useRef<any>(null);
|
||||
const [items, setItems] = useState<StoryItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [hasMoreOld, setHasMoreOld] = useState(true); // 是否有更老的数据
|
||||
const [hasMoreNew, setHasMoreNew] = useState(true); // 是否有更新的数据
|
||||
const [openAddItemModal, setOpenAddItemModal] = useState(false);
|
||||
const [currentItem, setCurrentItem] = useState<StoryItem>();
|
||||
const [currentOption, setCurrentOption] = useState<
|
||||
'add' | 'edit' | 'addSubItem' | 'editSubItem'
|
||||
>();
|
||||
const [pagination, setPagination] = useState({ current: 1, pageSize: 10 });
|
||||
// 存储每个item的高度
|
||||
const [itemSizes, setItemSizes] = useState<Record<string, number>>({});
|
||||
// 存储已测量高度的item ID集合
|
||||
const measuredItemsRef = useRef<Set<string>>(new Set());
|
||||
const [isRefreshing, setIsRefreshing] = useState(false); // 是否正在刷新最新数据
|
||||
const [showScrollTop, setShowScrollTop] = useState(false); // 是否显示回到顶部按钮
|
||||
|
||||
const { data: response, run } = useRequest(
|
||||
() => {
|
||||
@@ -53,7 +55,11 @@ const Index = () => {
|
||||
useEffect(() => {
|
||||
setItems([]);
|
||||
setPagination({ current: 1, pageSize: 10 });
|
||||
setHasMore(true);
|
||||
setHasMoreOld(true);
|
||||
setHasMoreNew(true);
|
||||
// 重置高度缓存
|
||||
setItemSizes({});
|
||||
measuredItemsRef.current = new Set();
|
||||
run();
|
||||
}, [lineId]);
|
||||
|
||||
@@ -64,74 +70,219 @@ const Index = () => {
|
||||
if (pagination.current === 1) {
|
||||
// 首页数据
|
||||
setItems(response.list || []);
|
||||
} else {
|
||||
// 追加数据
|
||||
} else if (pagination.current > 1) {
|
||||
// 追加更老的数据
|
||||
setItems(prev => [...prev, ...(response.list || [])]);
|
||||
} else if (pagination.current < 1) {
|
||||
// 在前面插入更新的数据
|
||||
setItems(prev => [...(response.list || []), ...prev]);
|
||||
// 保持滚动位置
|
||||
setTimeout(() => {
|
||||
if (listRef.current && response.list) {
|
||||
listRef.current.scrollToItem(response.list.length, 'start');
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
|
||||
// 检查是否还有更多数据
|
||||
setHasMore(response.list && response.list.length === pagination.pageSize);
|
||||
if (pagination.current >= 1) {
|
||||
// 检查是否有更老的数据
|
||||
setHasMoreOld(response.list && response.list.length === pagination.pageSize);
|
||||
} else if (pagination.current < 1) {
|
||||
// 检查是否有更新的数据
|
||||
setHasMoreNew(response.list && response.list.length === pagination.pageSize);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
setIsRefreshing(false);
|
||||
}, [response, pagination]);
|
||||
|
||||
// 滚动到底部加载更多
|
||||
const loadMore = useCallback(() => {
|
||||
if (loading || !hasMore) return;
|
||||
// 滚动到底部加载更老的数据
|
||||
const loadOlder = useCallback(() => {
|
||||
if (loading || !hasMoreOld || pagination.current < 1) return;
|
||||
|
||||
setLoading(true);
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
current: prev.current + 1
|
||||
}));
|
||||
}, [loading, hasMore]);
|
||||
}, [loading, hasMoreOld, pagination]);
|
||||
|
||||
// 滚动到顶部加载更新的数据
|
||||
const loadNewer = useCallback(() => {
|
||||
if (loading || !hasMoreNew || pagination.current > 1 || isRefreshing) return;
|
||||
|
||||
setIsRefreshing(true);
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
current: prev.current - 1
|
||||
}));
|
||||
}, [loading, hasMoreNew, pagination, isRefreshing]);
|
||||
|
||||
// 当分页变化时重新请求数据
|
||||
useEffect(() => {
|
||||
if (pagination.current > 1) {
|
||||
console.log('分页变化')
|
||||
if (pagination.current !== 1) {
|
||||
run();
|
||||
}
|
||||
}, [pagination, run]);
|
||||
|
||||
// 获取item高度的函数
|
||||
const getItemSize = useCallback((index: number) => {
|
||||
const item = items[index];
|
||||
if (!item) return 300; // 默认高度
|
||||
|
||||
// 如果已经测量过该item的高度,则使用缓存的值
|
||||
if (itemSizes[item.id]) {
|
||||
return itemSizes[item.id];
|
||||
}
|
||||
|
||||
// 返回默认高度
|
||||
return 300;
|
||||
}, [items, itemSizes]);
|
||||
|
||||
// 当item尺寸发生变化时调用
|
||||
const onItemResize = useCallback((itemId: string, height: number) => {
|
||||
// 只有当高度发生变化时才更新
|
||||
if (itemSizes[itemId] !== height) {
|
||||
setItemSizes(prev => ({
|
||||
...prev,
|
||||
[itemId]: height
|
||||
}));
|
||||
|
||||
// 通知List组件重新计算尺寸
|
||||
if (listRef.current) {
|
||||
listRef.current.resetAfterIndex(0);
|
||||
}
|
||||
}
|
||||
}, [itemSizes]);
|
||||
|
||||
// 渲染单个时间线项的函数
|
||||
const renderTimelineItem = useCallback(
|
||||
({ index, style }: { index: number; style: React.CSSProperties }) => {
|
||||
// 显示加载指示器的条件
|
||||
const showOlderLoading = index === items.length && hasMoreOld && pagination.current >= 1;
|
||||
const showNewerLoading = index === 0 && hasMoreNew && pagination.current < 1 && isRefreshing;
|
||||
|
||||
if (showOlderLoading || showNewerLoading) {
|
||||
return (
|
||||
<div style={style}>
|
||||
<div style={{ textAlign: 'center', padding: '20px' }}>
|
||||
<Spin />
|
||||
<div style={{ marginTop: 8 }}>
|
||||
{showNewerLoading ? '正在加载更新的内容...' : '正在加载更多内容...'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const item = items[index];
|
||||
if (!item) return null;
|
||||
|
||||
return (
|
||||
<div style={style}>
|
||||
<TimelineItem
|
||||
item={item}
|
||||
handleOption={(
|
||||
item: StoryItem,
|
||||
option: 'add' | 'edit' | 'addSubItem' | 'editSubItem',
|
||||
) => {
|
||||
setCurrentItem(item);
|
||||
setCurrentOption(option);
|
||||
setOpenAddItemModal(true);
|
||||
<div
|
||||
ref={(el) => {
|
||||
// 当元素被渲染时测量其实际高度
|
||||
if (el && !measuredItemsRef.current.has(item.id)) {
|
||||
measuredItemsRef.current.add(item.id);
|
||||
// 使用requestAnimationFrame确保DOM已经渲染完成
|
||||
requestAnimationFrame(() => {
|
||||
if (el) {
|
||||
const height = el.getBoundingClientRect().height;
|
||||
onItemResize(item.id, height);
|
||||
}
|
||||
});
|
||||
}
|
||||
}}
|
||||
refresh={() => {
|
||||
// 刷新当前页数据
|
||||
setPagination(prev => ({ ...prev, current: 1 }));
|
||||
run();
|
||||
queryDetail();
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<TimelineItem
|
||||
item={item}
|
||||
handleOption={(
|
||||
item: StoryItem,
|
||||
option: 'add' | 'edit' | 'addSubItem' | 'editSubItem',
|
||||
) => {
|
||||
setCurrentItem(item);
|
||||
setCurrentOption(option);
|
||||
setOpenAddItemModal(true);
|
||||
}}
|
||||
refresh={() => {
|
||||
// 刷新当前页数据
|
||||
setPagination(prev => ({ ...prev, current: 1 }));
|
||||
// 重置高度测量
|
||||
measuredItemsRef.current = new Set();
|
||||
run();
|
||||
queryDetail();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[items, run, queryDetail],
|
||||
[items, hasMoreOld, hasMoreNew, pagination, isRefreshing, onItemResize, run, queryDetail],
|
||||
);
|
||||
|
||||
// 处理滚动事件
|
||||
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
|
||||
// 当滚动到底部时加载更多
|
||||
if (scrollTop + clientHeight >= scrollHeight - 10) {
|
||||
loadMore();
|
||||
const handleItemsRendered = useCallback(({ visibleStartIndex, visibleStopIndex }) => {
|
||||
// 当可视区域接近列表顶部时加载更新的数据
|
||||
if (visibleStartIndex <= 3 && hasMoreNew && !isRefreshing && pagination.current >= 1) {
|
||||
loadNewer();
|
||||
}
|
||||
}, [loadMore]);
|
||||
|
||||
// 当可视区域接近列表底部时加载更老的数据
|
||||
if (visibleStopIndex >= items.length - 3 && hasMoreOld && !loading && pagination.current >= 1) {
|
||||
loadOlder();
|
||||
}
|
||||
|
||||
// 控制回到顶部按钮的显示
|
||||
setShowScrollTop(visibleStartIndex > 5);
|
||||
}, [hasMoreNew, hasMoreOld, isRefreshing, loading, items.length, pagination, loadNewer, loadOlder]);
|
||||
|
||||
// 手动刷新最新数据
|
||||
const handleRefresh = () => {
|
||||
if (isRefreshing) return;
|
||||
|
||||
setIsRefreshing(true);
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
current: 0 // 使用0作为刷新标识
|
||||
}));
|
||||
};
|
||||
|
||||
// 回到顶部
|
||||
const scrollToTop = () => {
|
||||
if (listRef.current) {
|
||||
listRef.current.scrollToItem(0, 'start');
|
||||
}
|
||||
};
|
||||
|
||||
// 监听滚动事件,动态显示/隐藏提示信息
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
if (containerRef.current) {
|
||||
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
|
||||
|
||||
// 判断是否在顶部
|
||||
const isTop = scrollTop === 0;
|
||||
// 判断是否在底部
|
||||
const isBottom = scrollTop + clientHeight >= scrollHeight;
|
||||
|
||||
setHasMoreNew(isTop && hasMoreNew); // 更新顶部提示的显示状态
|
||||
setHasMoreOld(isBottom && hasMoreOld); // 更新底部提示的显示状态
|
||||
}
|
||||
};
|
||||
|
||||
const timelineContainer = containerRef.current;
|
||||
if (timelineContainer) {
|
||||
timelineContainer.addEventListener('scroll', handleScroll);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timelineContainer) {
|
||||
timelineContainer.removeEventListener('scroll', handleScroll);
|
||||
}
|
||||
};
|
||||
}, [hasMoreNew, hasMoreOld]);
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
@@ -139,50 +290,126 @@ const Index = () => {
|
||||
title={
|
||||
queryDetailLoading ? '加载中' : `${detail?.title} ${`共${detail?.itemCount ?? 0}个时刻`}`
|
||||
}
|
||||
extra={
|
||||
<Button
|
||||
icon={<SyncOutlined />}
|
||||
onClick={handleRefresh}
|
||||
loading={isRefreshing}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="timeline"
|
||||
ref={containerRef}
|
||||
style={{
|
||||
height: 'calc(100vh - 200px)',
|
||||
overflowY: 'auto',
|
||||
overflow: 'hidden',
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
{items.length > 0 ? (
|
||||
<AutoSizer>
|
||||
{({ height, width }) => (
|
||||
<div
|
||||
style={{ height, width, position: 'relative' }}
|
||||
onScroll={handleScroll}
|
||||
className="timeline-hide-scrollbar"
|
||||
>
|
||||
<>
|
||||
{/* 顶部提示信息 */}
|
||||
{!hasMoreNew && pagination.current <= 1 && (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '12px',
|
||||
color: '#999',
|
||||
fontSize: '14px',
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
backgroundColor: '#fff',
|
||||
zIndex: 10,
|
||||
borderBottom: '1px solid #f0f0f0'
|
||||
}}>
|
||||
已加载全部更新内容
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AutoSizer>
|
||||
{({ height, width }) => (
|
||||
<List
|
||||
height={height}
|
||||
itemCount={items.length}
|
||||
itemSize={300} // 根据实际项高度调整
|
||||
ref={listRef}
|
||||
height={height - (hasMoreNew && pagination.current <= 1 ? 40 : 0) - (hasMoreOld && pagination.current >= 1 ? 40 : 0)}
|
||||
itemCount={items.length + (hasMoreOld && pagination.current >= 1 ? 1 : 0) + (hasMoreNew && pagination.current < 1 && isRefreshing ? 1 : 0)}
|
||||
itemSize={getItemSize}
|
||||
width={width}
|
||||
onItemsRendered={handleItemsRendered}
|
||||
>
|
||||
{renderTimelineItem}
|
||||
</List>
|
||||
)}
|
||||
</AutoSizer>
|
||||
|
||||
{/* 加载更多指示器 */}
|
||||
{loading && (
|
||||
<div style={{ textAlign: 'center', padding: '20px' }}>
|
||||
<Spin />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasMore && items.length > 0 && (
|
||||
<div style={{ textAlign: 'center', color: '#999', padding: '20px' }}>
|
||||
没有更多内容了
|
||||
</div>
|
||||
)}
|
||||
{/* 底部提示信息 */}
|
||||
{!hasMoreOld && pagination.current >= 1 && (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '12px',
|
||||
color: '#999',
|
||||
fontSize: '14px',
|
||||
position: 'sticky',
|
||||
bottom: 0,
|
||||
backgroundColor: '#fff',
|
||||
zIndex: 10,
|
||||
borderTop: '1px solid #f0f0f0'
|
||||
}}>
|
||||
已加载全部历史内容
|
||||
</div>
|
||||
)}
|
||||
</AutoSizer>
|
||||
|
||||
{/* 回到顶部按钮 */}
|
||||
{showScrollTop && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: 20,
|
||||
right: 20,
|
||||
zIndex: 10
|
||||
}}>
|
||||
<Button
|
||||
type="primary"
|
||||
shape="circle"
|
||||
icon={<SyncOutlined />}
|
||||
onClick={scrollToTop}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', padding: '50px 0' }}>
|
||||
{loading ? <Spin /> : '暂无时间线数据'}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100%',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
{loading ? (
|
||||
<>
|
||||
<Spin size="large" />
|
||||
<div style={{ marginTop: 16 }}>正在加载时间线数据...</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Empty
|
||||
description="暂无时间线数据"
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
style={{ marginTop: 16 }}
|
||||
onClick={() => {
|
||||
setCurrentOption('add');
|
||||
setCurrentItem();
|
||||
setOpenAddItemModal(true);
|
||||
}}
|
||||
>
|
||||
添加第一个时刻
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -206,8 +433,10 @@ const Index = () => {
|
||||
setOpenAddItemModal(false);
|
||||
// 添加新项后刷新数据
|
||||
setPagination(prev => ({ ...prev, current: 1 }));
|
||||
// 重置高度测量
|
||||
measuredItemsRef.current = new Set();
|
||||
run();
|
||||
queryDetail()
|
||||
queryDetail();
|
||||
}}
|
||||
storyId={lineId}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user