时间线详情展示重构
This commit is contained in:
@@ -1,17 +1,16 @@
|
||||
// src/pages/story/detail.tsx
|
||||
import AddTimeLineItemModal from '@/pages/story/components/AddTimeLineItemModal';
|
||||
import TimelineItem from '@/pages/story/components/TimelineItem/TimelineItem';
|
||||
import TimelineGridItem from '@/pages/story/components/TimelineGridItem';
|
||||
import TimelineItemDrawer from '@/pages/story/components/TimelineItemDrawer';
|
||||
import { StoryItem, StoryItemTimeQueryParams } from '@/pages/story/data';
|
||||
import { queryStoryDetail, queryStoryItem } from '@/pages/story/service';
|
||||
import { queryStoryDetail, queryStoryItem, removeStoryItem } from '@/pages/story/service';
|
||||
import { PlusOutlined, SyncOutlined } from '@ant-design/icons';
|
||||
import { PageContainer } from '@ant-design/pro-components';
|
||||
import { history, useRequest } from '@umijs/max';
|
||||
import { Button, Empty, FloatButton, message, Spin } from 'antd';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import { VariableSizeList as List } from 'react-window';
|
||||
import './index.css';
|
||||
import { useParams } from '@umijs/max';
|
||||
import './detail.css';
|
||||
import {judgePermission} from "@/pages/story/utils/utils";
|
||||
|
||||
// 格式化时间数组为易读格式
|
||||
@@ -31,10 +30,6 @@ const formatTimeArray = (time: string | number[] | undefined): string => {
|
||||
const Index = () => {
|
||||
const { id: lineId } = useParams<{ id: string }>();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const listRef = useRef<any>(null);
|
||||
const outerRef = useRef<HTMLDivElement>(null);
|
||||
const topSentinelRef = useRef<HTMLDivElement>(null);
|
||||
const bottomSentinelRef = useRef<HTMLDivElement>(null);
|
||||
const [items, setItems] = useState<StoryItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hasMoreOld, setHasMoreOld] = useState(true); // 是否有更老的数据
|
||||
@@ -44,11 +39,9 @@ const Index = () => {
|
||||
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 [openDetailDrawer, setOpenDetailDrawer] = useState(false);
|
||||
const [detailItem, setDetailItem] = useState<StoryItem>();
|
||||
const [pagination, setPagination] = useState({ current: 1, pageSize: 30 });
|
||||
const [isRefreshing, setIsRefreshing] = useState(false); // 是否正在刷新最新数据
|
||||
const [showScrollTop, setShowScrollTop] = useState(false); // 是否显示回到顶部按钮
|
||||
const [currentTimeArr, setCurrentTimeArr] = useState<[string | undefined, string | undefined]>([
|
||||
@@ -58,10 +51,8 @@ const Index = () => {
|
||||
const [loadDirection, setLoadDirection] = useState<'init' | 'older' | 'newer' | 'refresh'>(
|
||||
'init',
|
||||
);
|
||||
// 添加在其他 useRef 之后
|
||||
const hasShownNoMoreOldRef = useRef(false);
|
||||
const hasShownNoMoreNewRef = useRef(false);
|
||||
const topLoadingRef = useRef(false); // 防止顶部循环加载
|
||||
|
||||
type QueryParams = StoryItemTimeQueryParams & { current?: number; pageSize?: number };
|
||||
|
||||
@@ -86,9 +77,6 @@ const Index = () => {
|
||||
useEffect(() => {
|
||||
setHasMoreOld(true);
|
||||
setHasMoreNew(true);
|
||||
// 重置高度缓存
|
||||
setItemSizes({});
|
||||
measuredItemsRef.current = new Set();
|
||||
setLoadDirection('init');
|
||||
setLoading(true);
|
||||
run({ current: 1 });
|
||||
@@ -106,7 +94,6 @@ const Index = () => {
|
||||
setHasMoreOld(false);
|
||||
} else if (loadDirection === 'newer') {
|
||||
setHasMoreNew(false);
|
||||
topLoadingRef.current = false;
|
||||
}
|
||||
setLoading(false);
|
||||
setIsRefreshing(false);
|
||||
@@ -129,9 +116,6 @@ const Index = () => {
|
||||
setItems((prev) => {
|
||||
const next = [...fetched, ...prev];
|
||||
setCurrentTimeArr([next[0]?.storyItemTime, next[next.length - 1]?.storyItemTime]);
|
||||
if (listRef.current && fetched.length) {
|
||||
requestAnimationFrame(() => listRef.current?.scrollToItem(fetched.length + 1, 'start'));
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setHasMoreNew(!noMore);
|
||||
@@ -139,9 +123,8 @@ const Index = () => {
|
||||
hasShownNoMoreNewRef.current = true;
|
||||
message.info('没有更多更新内容了');
|
||||
}
|
||||
topLoadingRef.current = false;
|
||||
} else if (loadDirection === 'refresh') {
|
||||
topLoadingRef.current = false;
|
||||
// 刷新操作
|
||||
} else {
|
||||
setItems(fetched);
|
||||
setCurrentTimeArr([fetched[0]?.storyItemTime, fetched[fetched.length - 1]?.storyItemTime]);
|
||||
@@ -154,46 +137,9 @@ const Index = () => {
|
||||
setLoadDirection('init');
|
||||
}, [response, loadDirection, pagination.pageSize]);
|
||||
|
||||
// 获取item高度的函数
|
||||
const getItemSize = useCallback(
|
||||
(index: number) => {
|
||||
const item = items[index];
|
||||
if (!item) return 400; // 默认高度
|
||||
const key = String(item.id ?? item.instanceId);
|
||||
|
||||
if (itemSizes[key]) {
|
||||
return itemSizes[key];
|
||||
}
|
||||
|
||||
return 400;
|
||||
},
|
||||
[items, itemSizes, loadDirection, loading],
|
||||
);
|
||||
|
||||
// 当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 loadOlder = useCallback(() => {
|
||||
if (loading || !hasMoreOld) {
|
||||
if (!hasMoreOld && !loading) {
|
||||
message.info('没有更多历史内容了');
|
||||
}
|
||||
return;
|
||||
}
|
||||
const beforeTime = items[items.length - 1]?.storyItemTime || currentTimeArr[1];
|
||||
@@ -205,12 +151,9 @@ const Index = () => {
|
||||
run({ current: nextPage });
|
||||
}, [loading, hasMoreOld, items, currentTimeArr, pagination.current, run]);
|
||||
|
||||
// 滚动到顶部加载更新的数据
|
||||
// 滚动到顶部加载更新的数据
|
||||
const loadNewer = useCallback(() => {
|
||||
if (loading || !hasMoreNew || isRefreshing || topLoadingRef.current) {
|
||||
if (!hasMoreNew && !loading && !isRefreshing) {
|
||||
message.info('没有更多更新内容了');
|
||||
}
|
||||
if (loading || !hasMoreNew || isRefreshing) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -220,122 +163,29 @@ const Index = () => {
|
||||
setLoadDirection('newer');
|
||||
setIsRefreshing(true);
|
||||
setLoading(true);
|
||||
topLoadingRef.current = true;
|
||||
run({ current: 1 });
|
||||
}, [loading, hasMoreNew, isRefreshing, items, currentTimeArr, run]);
|
||||
|
||||
// 渲染单个时间线项的函数
|
||||
const renderTimelineItem = useCallback(
|
||||
({ index, style }: { index: number; style: React.CSSProperties }) => {
|
||||
const item = items[index];
|
||||
if (!item) return null;
|
||||
const key = String(item.id ?? item.instanceId);
|
||||
|
||||
return (
|
||||
<div style={style}>
|
||||
<div
|
||||
ref={(el) => {
|
||||
// 当元素被渲染时测量其实际高度
|
||||
if (el && !measuredItemsRef.current.has(key)) {
|
||||
measuredItemsRef.current.add(key);
|
||||
// 使用requestAnimationFrame确保DOM已经渲染完成
|
||||
requestAnimationFrame(() => {
|
||||
if (el) {
|
||||
const height = el.getBoundingClientRect().height;
|
||||
onItemResize(key, height);
|
||||
}
|
||||
});
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
margin: '12px 0',
|
||||
padding: '16px',
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 1px 4px rgba(0,0,0,0.05)',
|
||||
border: '1px solid #f0f0f0',
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLDivElement).style.boxShadow = '0 2px 8px rgba(0,0,0,0.1)';
|
||||
(e.currentTarget as HTMLDivElement).style.transform = 'translateY(-2px)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLDivElement).style.boxShadow = '0 1px 4px rgba(0,0,0,0.05)';
|
||||
(e.currentTarget as HTMLDivElement).style.transform = 'translateY(0)';
|
||||
}}
|
||||
>
|
||||
<TimelineItem
|
||||
item={item}
|
||||
handleOption={(
|
||||
item: StoryItem,
|
||||
option: 'add' | 'edit' | 'addSubItem' | 'editSubItem',
|
||||
) => {
|
||||
setCurrentItem(item);
|
||||
setCurrentOption(option);
|
||||
setOpenAddItemModal(true);
|
||||
}}
|
||||
disableEdit={!judgePermission(detail?.permissionType, 'edit')}
|
||||
refresh={() => {
|
||||
// 刷新当前页数据
|
||||
setPagination((prev) => ({ ...prev, current: 1 }));
|
||||
// 重置高度测量
|
||||
measuredItemsRef.current = new Set();
|
||||
hasShownNoMoreOldRef.current = false;
|
||||
hasShownNoMoreNewRef.current = false;
|
||||
setLoadDirection('refresh');
|
||||
run({ current: 1 });
|
||||
queryDetail();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[items, hasMoreOld, loadDirection, loading, onItemResize, run, queryDetail],
|
||||
);
|
||||
|
||||
// 使用 IntersectionObserver 监听顶部/底部哨兵实现无限滚动
|
||||
// 监听滚动事件
|
||||
useEffect(() => {
|
||||
const root = outerRef.current;
|
||||
const topEl = topSentinelRef.current;
|
||||
const bottomEl = bottomSentinelRef.current;
|
||||
if (!root || !topEl || !bottomEl) return;
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const topObserver = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((e) => e.isIntersecting)) {
|
||||
loadNewer();
|
||||
}
|
||||
},
|
||||
{ root, rootMargin: '120px 0px 0px 0px', threshold: 0 },
|
||||
);
|
||||
|
||||
const bottomObserver = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((e) => e.isIntersecting)) {
|
||||
loadOlder();
|
||||
}
|
||||
},
|
||||
{ root, rootMargin: '0px 0px 120px 0px', threshold: 0 },
|
||||
);
|
||||
|
||||
topObserver.observe(topEl);
|
||||
bottomObserver.observe(bottomEl);
|
||||
|
||||
// 仅用于显示"回到顶部"按钮
|
||||
const handleScroll = () => {
|
||||
const { scrollTop } = root;
|
||||
setShowScrollTop(scrollTop > 200);
|
||||
const { scrollTop, scrollHeight, clientHeight } = container;
|
||||
|
||||
// 显示回到顶部按钮
|
||||
setShowScrollTop(scrollTop > 300);
|
||||
|
||||
// 接近底部时加载更多
|
||||
if (scrollHeight - scrollTop - clientHeight < 200) {
|
||||
loadOlder();
|
||||
}
|
||||
};
|
||||
root.addEventListener('scroll', handleScroll);
|
||||
|
||||
return () => {
|
||||
topObserver.disconnect();
|
||||
bottomObserver.disconnect();
|
||||
root.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
}, [loadNewer, loadOlder]);
|
||||
container.addEventListener('scroll', handleScroll);
|
||||
return () => container.removeEventListener('scroll', handleScroll);
|
||||
}, [loadOlder]);
|
||||
|
||||
// 手动刷新最新数据
|
||||
const handleRefresh = () => {
|
||||
@@ -351,11 +201,62 @@ const Index = () => {
|
||||
|
||||
// 回到顶部
|
||||
const scrollToTop = () => {
|
||||
if (listRef.current) {
|
||||
listRef.current.scrollToItem(0, 'start');
|
||||
if (containerRef.current) {
|
||||
containerRef.current.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
};
|
||||
|
||||
// 按日期分组items,并在每个组内按时间排序
|
||||
const groupItemsByDate = (items: StoryItem[]) => {
|
||||
const groups: { [key: string]: { dateKey: string; items: StoryItem[]; sortValue: number } } = {};
|
||||
|
||||
items.forEach(item => {
|
||||
let dateKey = '';
|
||||
let sortValue = 0;
|
||||
|
||||
if (Array.isArray(item.storyItemTime)) {
|
||||
const [year, month, day] = item.storyItemTime;
|
||||
dateKey = `${year}年${month}月${day}日`;
|
||||
sortValue = new Date(year, month - 1, day).getTime();
|
||||
} else if (item.storyItemTime) {
|
||||
const dateStr = String(item.storyItemTime);
|
||||
const datePart = dateStr.split(' ')[0];
|
||||
dateKey = datePart;
|
||||
sortValue = new Date(datePart).getTime();
|
||||
}
|
||||
|
||||
if (!groups[dateKey]) {
|
||||
groups[dateKey] = { dateKey, items: [], sortValue };
|
||||
}
|
||||
groups[dateKey].items.push(item);
|
||||
});
|
||||
|
||||
// 对每个日期组内的项目按时间排序(从早到晚)
|
||||
Object.keys(groups).forEach(dateKey => {
|
||||
groups[dateKey].items.sort((a, b) => {
|
||||
const timeA = getTimeValue(a.storyItemTime);
|
||||
const timeB = getTimeValue(b.storyItemTime);
|
||||
return timeA - timeB;
|
||||
});
|
||||
});
|
||||
|
||||
return groups;
|
||||
};
|
||||
|
||||
// 将时间转换为可比较的数值
|
||||
const getTimeValue = (time: string | number[] | undefined): number => {
|
||||
if (!time) return 0;
|
||||
|
||||
if (Array.isArray(time)) {
|
||||
const [year, month, day, hour = 0, minute = 0, second = 0] = time;
|
||||
return new Date(year, month - 1, day, hour, minute, second).getTime();
|
||||
}
|
||||
|
||||
return new Date(String(time)).getTime();
|
||||
};
|
||||
|
||||
const groupedItems = groupItemsByDate(items);
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
onBack={() => history.push('/story')}
|
||||
@@ -367,7 +268,7 @@ const Index = () => {
|
||||
icon={<SyncOutlined />}
|
||||
onClick={() => {
|
||||
setItems([]);
|
||||
setPagination({ current: 1, pageSize: 10 });
|
||||
setPagination({ current: 1, pageSize: 30 });
|
||||
setLoadDirection('refresh');
|
||||
run({ current: 1 });
|
||||
}}
|
||||
@@ -382,49 +283,68 @@ const Index = () => {
|
||||
ref={containerRef}
|
||||
style={{
|
||||
height: 'calc(100vh - 200px)',
|
||||
overflow: 'hidden',
|
||||
overflow: 'auto',
|
||||
position: 'relative',
|
||||
padding: '0 16px', // 添加一些内边距
|
||||
padding: '0 8px', // 减少内边距
|
||||
}}
|
||||
>
|
||||
{items.length > 0 ? (
|
||||
<>
|
||||
<AutoSizer>
|
||||
{({ height, width }) => (
|
||||
<>
|
||||
<List
|
||||
ref={listRef}
|
||||
outerRef={outerRef}
|
||||
height={height}
|
||||
itemCount={items.length}
|
||||
itemSize={getItemSize}
|
||||
width={width}
|
||||
innerElementType={React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>((props, ref) => (
|
||||
<div ref={ref} {...props}>
|
||||
<div ref={topSentinelRef} style={{ height: 1 }} />
|
||||
{props.children}
|
||||
<div ref={bottomSentinelRef} style={{ height: 1 }} />
|
||||
</div>
|
||||
))}
|
||||
>
|
||||
{renderTimelineItem}
|
||||
</List>
|
||||
<div className="load-indicator">{loading && '加载中...'}</div>
|
||||
<div className="no-more-data">{!loading && '已加载全部历史数据'}</div>
|
||||
</>
|
||||
)}
|
||||
</AutoSizer>
|
||||
{Object.values(groupedItems)
|
||||
.sort((a, b) => b.sortValue - a.sortValue) // 按日期降序排列(最新的在前)
|
||||
.map(({ dateKey, items: dateItems }) => (
|
||||
<div key={dateKey}>
|
||||
<h2 className="timeline-section-header">{dateKey}</h2>
|
||||
<div className="timeline-grid-wrapper">
|
||||
{dateItems.map((item, index) => {
|
||||
// 调试:确保每个item都有有效的数据
|
||||
if (!item || (!item.id && !item.instanceId)) {
|
||||
console.warn('发现无效的item:', item, 'at index:', index);
|
||||
return null; // 不渲染无效的item
|
||||
}
|
||||
|
||||
return (
|
||||
<TimelineGridItem
|
||||
key={item.id ?? item.instanceId}
|
||||
item={item}
|
||||
handleOption={(
|
||||
item: StoryItem,
|
||||
option: 'add' | 'edit' | 'addSubItem' | 'editSubItem',
|
||||
) => {
|
||||
setCurrentItem(item);
|
||||
setCurrentOption(option);
|
||||
setOpenAddItemModal(true);
|
||||
}}
|
||||
onOpenDetail={(item: StoryItem) => {
|
||||
setDetailItem(item);
|
||||
setOpenDetailDrawer(true);
|
||||
}}
|
||||
disableEdit={!judgePermission(detail?.permissionType ?? null, 'edit')}
|
||||
refresh={() => {
|
||||
setPagination((prev) => ({ ...prev, current: 1 }));
|
||||
hasShownNoMoreOldRef.current = false;
|
||||
hasShownNoMoreNewRef.current = false;
|
||||
setLoadDirection('refresh');
|
||||
run({ current: 1 });
|
||||
queryDetail();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{loading && <div className="load-indicator">加载中...</div>}
|
||||
{!loading && !hasMoreOld && <div className="no-more-data">已加载全部历史数据</div>}
|
||||
|
||||
{/* 回到顶部按钮 */}
|
||||
{showScrollTop && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 20,
|
||||
right: 20,
|
||||
zIndex: 10,
|
||||
position: 'fixed',
|
||||
bottom: 80,
|
||||
right: 24,
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
@@ -482,7 +402,7 @@ const Index = () => {
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
disabled={!judgePermission(detail?.permissionType, 'edit')}
|
||||
disabled={!judgePermission(detail?.permissionType ?? null, 'edit')}
|
||||
onClick={() => {
|
||||
setCurrentOption('add');
|
||||
setCurrentItem(undefined);
|
||||
@@ -503,7 +423,7 @@ const Index = () => {
|
||||
setOpenAddItemModal(true);
|
||||
}}
|
||||
icon={<PlusOutlined />}
|
||||
disabled={!judgePermission(detail?.permissionType, 'edit')}
|
||||
disabled={!judgePermission(detail?.permissionType ?? null, 'edit')}
|
||||
type="primary"
|
||||
style={{
|
||||
right: 24,
|
||||
@@ -522,14 +442,48 @@ const Index = () => {
|
||||
setOpenAddItemModal(false);
|
||||
// 添加新项后刷新数据
|
||||
setPagination((prev) => ({ ...prev, current: 1 }));
|
||||
// 重置高度测量
|
||||
measuredItemsRef.current = new Set();
|
||||
setLoadDirection('refresh');
|
||||
run({ current: 1 });
|
||||
queryDetail();
|
||||
}}
|
||||
storyId={lineId}
|
||||
/>
|
||||
|
||||
{/* 详情抽屉 - 在外层管理,不影响网格布局 */}
|
||||
{detailItem && (
|
||||
<TimelineItemDrawer
|
||||
storyItem={detailItem}
|
||||
open={openDetailDrawer}
|
||||
setOpen={setOpenDetailDrawer}
|
||||
handleDelete={async () => {
|
||||
// 这里需要实现删除逻辑
|
||||
try {
|
||||
if (!detailItem.instanceId) return;
|
||||
const response = await removeStoryItem(detailItem.instanceId);
|
||||
if (response.code === 200) {
|
||||
message.success('删除成功');
|
||||
setOpenDetailDrawer(false);
|
||||
// 刷新数据
|
||||
setPagination((prev) => ({ ...prev, current: 1 }));
|
||||
setLoadDirection('refresh');
|
||||
run({ current: 1 });
|
||||
queryDetail();
|
||||
} else {
|
||||
message.error('删除失败');
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
}
|
||||
}}
|
||||
disableEdit={!judgePermission(detail?.permissionType ?? null, 'edit')}
|
||||
handOption={(item: StoryItem, option: 'add' | 'edit' | 'addSubItem' | 'editSubItem') => {
|
||||
setCurrentItem(item);
|
||||
setCurrentOption(option);
|
||||
setOpenDetailDrawer(false);
|
||||
setOpenAddItemModal(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user