故事详情排版修改

This commit is contained in:
jiangh277 2025-08-08 17:42:07 +08:00
parent efd3f4a82c
commit 30e9e1c7b2
10 changed files with 501 additions and 172 deletions

View File

@ -16,6 +16,7 @@ interface Props {
imageInstanceId?: string; imageInstanceId?: string;
imageList?: ImageItem[]; imageList?: ImageItem[];
currentIndex?: number; currentIndex?: number;
style?: React.CSSProperties;
} }
const TimelineImage: React.FC<Props> = (props) => { const TimelineImage: React.FC<Props> = (props) => {
@ -28,6 +29,7 @@ const TimelineImage: React.FC<Props> = (props) => {
height = 200, height = 200,
imageList = [], imageList = [],
currentIndex = 0, currentIndex = 0,
style,
} = props; } = props;
const { imageUrl, loading } = useFetchImageUrl(imageInstanceId ?? ''); const { imageUrl, loading } = useFetchImageUrl(imageInstanceId ?? '');
@ -55,6 +57,7 @@ const TimelineImage: React.FC<Props> = (props) => {
}} }}
height={height} height={height}
width={width} width={width}
style={style}
alt={title} alt={title}
fallback={ fallback={
fallback ?? fallback ??

View File

@ -5,6 +5,7 @@ import pages from './zh-CN/pages';
import pwa from './zh-CN/pwa'; import pwa from './zh-CN/pwa';
import settingDrawer from './zh-CN/settingDrawer'; import settingDrawer from './zh-CN/settingDrawer';
import settings from './zh-CN/settings'; import settings from './zh-CN/settings';
import story from "@/locales/zh-CN/story";
export default { export default {
'navBar.lang': '语言', 'navBar.lang': '语言',
@ -21,4 +22,5 @@ export default {
...settings, ...settings,
...pwa, ...pwa,
...component, ...component,
...story,
}; };

View File

@ -64,13 +64,4 @@ export default {
'pages.searchTable.tenThousand': '万', 'pages.searchTable.tenThousand': '万',
'pages.searchTable.batchDeletion': '批量删除', 'pages.searchTable.batchDeletion': '批量删除',
'pages.searchTable.batchApproval': '批量审批', 'pages.searchTable.batchApproval': '批量审批',
"story.deleteSuccess": "删除成功",
"story.deleteFailed": "删除失败",
"story.deleteConfirm": "确认删除",
"story.deleteConfirmDescription": "确定要删除这个故事项吗?此操作不可撤销。",
"story.yes": "是",
"story.no": "否",
"story.edit": "编辑",
"story.addSubItem": "添加子项",
"story.delete": "删除"
}; };

View File

@ -0,0 +1,13 @@
export default {
"story.deleteSuccess": "删除成功",
"story.deleteFailed": "删除失败",
"story.deleteConfirm": "确认删除",
"story.deleteConfirmDescription": "确定要删除这个故事项吗?此操作不可撤销。",
"story.yes": "是",
"story.no": "否",
"story.edit": "编辑",
"story.addSubItem": "添加子项",
"story.delete": "删除",
'story.showLess': '更少',
'story.showMore': '更多',
}

View File

@ -41,7 +41,6 @@ const AddTimeLineItemModal: React.FC<ModalProps> = ({
onOk, onOk,
storyId, storyId,
initialValues, initialValues,
storyItemId,
option, option,
}) => { }) => {
const [form] = Form.useForm(); const [form] = Form.useForm();
@ -60,6 +59,7 @@ const AddTimeLineItemModal: React.FC<ModalProps> = ({
const searchInputRef = useRef<InputRef>(null); const searchInputRef = useRef<InputRef>(null);
useEffect(() => { useEffect(() => {
console.log(initialValues)
if (initialValues && option.startsWith('edit')) { if (initialValues && option.startsWith('edit')) {
form.setFieldsValue({ form.setFieldsValue({
title: initialValues.title, title: initialValues.title,
@ -68,7 +68,7 @@ const AddTimeLineItemModal: React.FC<ModalProps> = ({
description: initialValues.description, description: initialValues.description,
}); });
} }
}, [initialValues, option]); }, [initialValues, option, visible]);
// 获取图库图片 // 获取图库图片
const fetchGalleryImages = async (page: number = 1, keyword: string = '') => { const fetchGalleryImages = async (page: number = 1, keyword: string = '') => {
@ -80,13 +80,13 @@ const AddTimeLineItemModal: React.FC<ModalProps> = ({
pageSize: pageSize, pageSize: pageSize,
keyword: keyword, keyword: keyword,
}); });
const images = response.data.list.map((img: any) => ({ const images = response?.data?.list.map((img: any) => ({
instanceId: img.instanceId, instanceId: img.instanceId,
imageName: img.imageName, imageName: img.imageName,
url: `/file/image-low-res/${img.instanceId}`, url: `/file/image-low-res/${img.instanceId}`,
})); })) ?? [];
setGalleryImages(images); setGalleryImages(images);
setTotal(response.data.total); setTotal(response?.data?.total ?? 0);
setCurrentPage(page); setCurrentPage(page);
} catch (error) { } catch (error) {
message.error('获取图库图片失败'); message.error('获取图库图片失败');
@ -321,6 +321,7 @@ const AddTimeLineItemModal: React.FC<ModalProps> = ({
onCancel(); onCancel();
}} }}
width={800} width={800}
zIndex={1001}
footer={[ footer={[
<Button key="back" onClick={onCancel}> <Button key="back" onClick={onCancel}>

View File

@ -1,11 +1,7 @@
// TimelineItem.tsx
import TimelineImage from '@/components/TimelineImage'; import TimelineImage from '@/components/TimelineImage';
import { StoryItem } from '@/pages/story/data'; import { StoryItem } from '@/pages/story/data';
import { import { DeleteOutlined, DownOutlined, EditOutlined, UpOutlined } from '@ant-design/icons';
DeleteOutlined,
DownOutlined,
EditOutlined,
UpOutlined,
} from '@ant-design/icons';
import { useIntl, useRequest } from '@umijs/max'; import { useIntl, useRequest } from '@umijs/max';
import { Button, Card, message, Popconfirm } from 'antd'; import { Button, Card, message, Popconfirm } from 'antd';
import React, { useState } from 'react'; import React, { useState } from 'react';
@ -135,6 +131,7 @@ const TimelineItem: React.FC<{
key={imageInstanceId + index} key={imageInstanceId + index}
title={imageInstanceId} title={imageInstanceId}
imageInstanceId={imageInstanceId} imageInstanceId={imageInstanceId}
style={{ maxWidth: '100%', height: 'auto' }} // 添加响应式样式
/> />
))} ))}
</div> </div>
@ -169,7 +166,13 @@ const TimelineItem: React.FC<{
</div> </div>
)} )}
</div> </div>
<TimelineItemDrawer storyItem={item} open={openDetail} setOpen={setOpenDetail} /> <TimelineItemDrawer
storyItem={item}
open={openDetail}
setOpen={setOpenDetail}
handleDelete={handleDelete}
handOption={handleOption}
/>
</Card> </Card>
); );
}; };

View File

@ -1,3 +1,4 @@
// index.style.ts
import { createStyles } from 'antd-style'; import { createStyles } from 'antd-style';
const useStyles = createStyles(({ token }) => { const useStyles = createStyles(({ token }) => {
@ -11,6 +12,17 @@ const useStyles = createStyles(({ token }) => {
'&:hover': { '&:hover': {
boxShadow: token.boxShadowSecondary, boxShadow: token.boxShadowSecondary,
}, },
position: 'relative',
padding: '20px',
maxWidth: '100%',
textAlign: 'left',
maxHeight: '80vh',
scrollBehavior: 'smooth',
scrollbarWidth: 'none',
borderBottom: '1px solid #eee',
[`@media (max-width: 768px)`]: {
padding: '10px',
},
}, },
actions: { actions: {
display: 'flex', display: 'flex',
@ -84,6 +96,23 @@ const useStyles = createStyles(({ token }) => {
flex: 1, flex: 1,
color: token.colorText, color: token.colorText,
}, },
timelineItemImages: {
display: 'flex',
flexWrap: 'wrap',
gap: '10px',
marginBottom: '20px',
},
timelineImage: {
maxWidth: '100%',
height: 'auto',
borderRadius: '4px',
overflow: 'hidden',
img: {
width: '100%',
height: 'auto',
objectFit: 'cover',
},
},
}; };
}); });

View File

@ -1,24 +1,24 @@
// src/pages/story/components/TimelineItemDrawer.tsx
import TimelineImage from '@/components/TimelineImage'; import TimelineImage from '@/components/TimelineImage';
import AddTimeLineItemModal from '@/pages/story/components/AddTimeLineItemModal';
import SubTimeLineItemModal from '@/pages/story/components/SubTimeLineItemModal';
import { StoryItem } from '@/pages/story/data'; import { StoryItem } from '@/pages/story/data';
import { queryStoryItemImages } from '@/pages/story/service'; import { queryStoryItemImages } from '@/pages/story/service';
import { EditOutlined, PlusCircleOutlined } from '@ant-design/icons'; import { DeleteOutlined, EditOutlined } from '@ant-design/icons';
import { useRequest } from '@umijs/max'; import { useIntl, useRequest } from '@umijs/max';
import { Button, Drawer, Space } from 'antd'; import { Button, Divider, Drawer, Popconfirm, Space } from 'antd';
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
interface Props { interface Props {
storyItem: StoryItem; storyItem: StoryItem;
open: boolean; open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>; setOpen: React.Dispatch<React.SetStateAction<boolean>>;
handleDelete: () => void;
handOption: (item: StoryItem, option: 'add' | 'edit' | 'addSubItem' | 'editSubItem') => void;
} }
const TimelineItemDrawer = (props: Props) => { const TimelineItemDrawer: React.FC<Props> = (props) => {
const { storyItem, open, setOpen } = props; const { storyItem, open, setOpen, handleDelete, handOption } = props;
const [editModalVisible, setEditModalVisible] = React.useState(false); const intl = useIntl();
const [openAddSubItemModal, setOpenAddSubItemModal] = React.useState(false);
const [openEditMainItemModal, setOpenEditMainItemModal] = React.useState(false);
const { data: imagesList, run } = useRequest( const { data: imagesList, run } = useRequest(
async (itemId) => { async (itemId) => {
return await queryStoryItemImages(itemId); return await queryStoryItemImages(itemId);
@ -27,107 +27,144 @@ const TimelineItemDrawer = (props: Props) => {
manual: true, manual: true,
}, },
); );
useEffect(() => { useEffect(() => {
if (open) { if (open) {
run(storyItem.instanceId); run(storyItem.instanceId);
} }
}, [open]); }, [open]);
const closeDrawer = () => { const closeDrawer = () => {
setOpen(false); setOpen(false);
}; };
const handleEditMainItem = (updatedItem: any) => {
const mergedEvent = { // 格式化日期显示
...storyItem, const formatDate = (dateString: string) => {
...updatedItem, if (!dateString) return '';
}; const date = new Date(dateString);
setOpenEditMainItemModal(false); return date.toLocaleString('zh-CN', {
}; year: 'numeric',
const handleAddSubItem = () => { month: '2-digit',
setOpenAddSubItemModal(false); day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}; };
return ( return (
<div> <div>
{/* 主时间点详情抽屉 */} {/* 主时间点详情抽屉 */}
<Drawer <Drawer
width={1000} width={800}
placement="right" placement="right"
onClose={closeDrawer} onClose={closeDrawer}
open={open} open={open}
title={storyItem.title} zIndex={1000}
title={
<div>
<h2 style={{ margin: 0 }}>{storyItem.title}</h2>
<div style={{ fontSize: '14px', color: '#888', marginTop: '4px' }}>
{storyItem.storyItemTime} {storyItem.location ? `${storyItem.location}` : ''}
</div>
</div>
}
footer={ footer={
<div style={{ textAlign: 'right' }}> <div style={{ textAlign: 'right' }}>
<Space> <Space>
<Button <Button
icon={<PlusCircleOutlined />} icon={<EditOutlined />}
type="primary" onClick={() => {
onClick={() => setOpenAddSubItemModal(true)} return handOption(storyItem, 'edit');
}}
> >
</Button>
<Button icon={<EditOutlined />} onClick={() => setEditModalVisible(true)}>
</Button> </Button>
<Popconfirm
title={intl.formatMessage({ id: 'story.deleteConfirm' })}
description={intl.formatMessage({ id: 'story.deleteConfirmDescription' })}
onConfirm={(e) => {
e?.stopPropagation();
handleDelete();
}}
okText={intl.formatMessage({ id: 'story.yes' })}
cancelText={intl.formatMessage({ id: 'story.no' })}
>
<Button
icon={<DeleteOutlined />}
danger
onClick={(e) => e.stopPropagation()}
aria-label={intl.formatMessage({ id: 'story.delete' })}
>
</Button>
</Popconfirm>
<Button onClick={closeDrawer}></Button> <Button onClick={closeDrawer}></Button>
</Space> </Space>
</div> </div>
} }
> >
<p> <div style={{ padding: '0 24px' }}>
<strong></strong> {storyItem.description} <div style={{ marginBottom: '24px' }}>
</p> <h3></h3>
<p> <p style={{ fontSize: '16px', lineHeight: '1.6' }}>{storyItem.description}</p>
<strong></strong> {storyItem.storyItemTime} </div>
</p>
<p>
<strong></strong> {storyItem.location}
</p>
{/* 封面图 */}
{storyItem.coverInstanceId && (
<>
<p>
<strong></strong>
</p>
<TimelineImage
title={storyItem.title + 'cover'}
imageInstanceId={storyItem.coverInstanceId}
/>
</>
)}
{/* 时刻图库 */} <Divider />
{imagesList && imagesList.length > 0 && (
<> {/* 时刻图库 */}
<p> {imagesList && imagesList.length > 0 && (
<strong></strong> <div style={{ marginBottom: '24px' }}>
</p> <h3></h3>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '10px', marginBottom: '20px' }}> <div
{imagesList.map((imageInstanceId, index) => ( style={{
<TimelineImage display: 'grid',
key={imageInstanceId + index} gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))',
title={imageInstanceId} gap: '16px',
imageInstanceId={imageInstanceId} marginTop: '16px',
/> }}
))} >
{imagesList.map((imageInstanceId, index) => (
<div
key={imageInstanceId + index}
style={{
borderRadius: '8px',
overflow: 'hidden',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
aspectRatio: '1/1',
}}
>
<TimelineImage
title={imageInstanceId}
imageInstanceId={imageInstanceId}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
</div>
))}
</div>
</div> </div>
</> )}
)} <Divider />
{/* 添加子时间点模态框 */} {/* 创建和更新信息 */}
<AddTimeLineItemModal <div style={{ marginBottom: '24px' }}>
visible={openAddSubItemModal} <h3></h3>
onOk={handleAddSubItem} <div style={{ display: 'flex', flexWrap: 'wrap', gap: '16px', marginTop: '16px' }}>
onCancel={() => setOpenAddSubItemModal(false)} <div style={{ flex: '1', minWidth: '200px' }}>
lineId={storyItem.storyInstanceId} <div style={{ color: '#888', marginBottom: '4px' }}></div>
storyItemId={storyItem.instanceId} <div style={{ fontSize: '16px' }}>{formatDate(storyItem.createTime)}</div>
/> </div>
<div style={{ flex: '1', minWidth: '200px' }}>
{/* 编辑主时间点模态框 */} <div style={{ color: '#888', marginBottom: '4px' }}></div>
<SubTimeLineItemModal <div style={{ fontSize: '16px' }}>{formatDate(storyItem.updateTime)}</div>
visible={openEditMainItemModal} </div>
onOk={handleEditMainItem} <div style={{ flex: '1', minWidth: '200px' }}>
onCancel={() => setOpenEditMainItemModal(false)} <div style={{ color: '#888', marginBottom: '4px' }}></div>
initialValues={storyItem} <div style={{ fontSize: '16px' }}></div>
/> </div>
</div>
</div>
</div>
</Drawer> </Drawer>
</div> </div>
); );

View File

@ -4,33 +4,35 @@ import TimelineItem from '@/pages/story/components/TimelineItem/TimelineItem';
import { StoryItem } from '@/pages/story/data'; import { StoryItem } from '@/pages/story/data';
import { queryStoryDetail, queryStoryItem } from '@/pages/story/service'; import { queryStoryDetail, queryStoryItem } from '@/pages/story/service';
import { PageContainer } from '@ant-design/pro-components'; import { PageContainer } from '@ant-design/pro-components';
import { history, useIntl, useRequest } from '@umijs/max'; import { history, useRequest } from '@umijs/max';
import { FloatButton, Spin } from 'antd'; import { FloatButton, Spin, Empty, Button } from 'antd';
import React, { useCallback, useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useParams } from 'react-router'; import { useParams } from 'react-router';
import AutoSizer from 'react-virtualized-auto-sizer'; 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 './index.css';
import useStyles from './style.style';
interface TimelineItemProps {
children: React.ReactNode;
}
const Index = () => { const Index = () => {
const { id: lineId } = useParams<{ id: string }>(); const { id: lineId } = useParams<{ id: string }>();
const { styles } = useStyles();
const intl = useIntl();
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const listRef = useRef<any>(null);
const [items, setItems] = useState<StoryItem[]>([]); const [items, setItems] = useState<StoryItem[]>([]);
const [loading, setLoading] = useState(false); 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 [openAddItemModal, setOpenAddItemModal] = useState(false);
const [currentItem, setCurrentItem] = useState<StoryItem>(); const [currentItem, setCurrentItem] = useState<StoryItem>();
const [currentOption, setCurrentOption] = useState< const [currentOption, setCurrentOption] = useState<
'add' | 'edit' | 'addSubItem' | 'editSubItem' 'add' | 'edit' | 'addSubItem' | 'editSubItem'
>(); >();
const [pagination, setPagination] = useState({ current: 1, pageSize: 10 }); 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( const { data: response, run } = useRequest(
() => { () => {
@ -53,7 +55,11 @@ const Index = () => {
useEffect(() => { useEffect(() => {
setItems([]); setItems([]);
setPagination({ current: 1, pageSize: 10 }); setPagination({ current: 1, pageSize: 10 });
setHasMore(true); setHasMoreOld(true);
setHasMoreNew(true);
// 重置高度缓存
setItemSizes({});
measuredItemsRef.current = new Set();
run(); run();
}, [lineId]); }, [lineId]);
@ -64,74 +70,219 @@ const Index = () => {
if (pagination.current === 1) { if (pagination.current === 1) {
// 首页数据 // 首页数据
setItems(response.list || []); setItems(response.list || []);
} else { } else if (pagination.current > 1) {
// 追加数据 // 追加更老的数据
setItems(prev => [...prev, ...(response.list || [])]); 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); setLoading(false);
setIsRefreshing(false);
}, [response, pagination]); }, [response, pagination]);
// 滚动到底部加载更多 // 滚动到底部加载更老的数据
const loadMore = useCallback(() => { const loadOlder = useCallback(() => {
if (loading || !hasMore) return; if (loading || !hasMoreOld || pagination.current < 1) return;
setLoading(true); setLoading(true);
setPagination(prev => ({ setPagination(prev => ({
...prev, ...prev,
current: prev.current + 1 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(() => { useEffect(() => {
if (pagination.current > 1) { if (pagination.current !== 1) {
console.log('分页变化')
run(); run();
} }
}, [pagination, 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( const renderTimelineItem = useCallback(
({ index, style }: { index: number; style: React.CSSProperties }) => { ({ 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]; const item = items[index];
if (!item) return null; if (!item) return null;
return ( return (
<div style={style}> <div style={style}>
<TimelineItem <div
item={item} ref={(el) => {
handleOption={( // 当元素被渲染时测量其实际高度
item: StoryItem, if (el && !measuredItemsRef.current.has(item.id)) {
option: 'add' | 'edit' | 'addSubItem' | 'editSubItem', measuredItemsRef.current.add(item.id);
) => { // 使用requestAnimationFrame确保DOM已经渲染完成
setCurrentItem(item); requestAnimationFrame(() => {
setCurrentOption(option); if (el) {
setOpenAddItemModal(true); const height = el.getBoundingClientRect().height;
onItemResize(item.id, height);
}
});
}
}} }}
refresh={() => { >
// 刷新当前页数据 <TimelineItem
setPagination(prev => ({ ...prev, current: 1 })); item={item}
run(); handleOption={(
queryDetail(); 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> </div>
); );
}, },
[items, run, queryDetail], [items, hasMoreOld, hasMoreNew, pagination, isRefreshing, onItemResize, run, queryDetail],
); );
// 处理滚动事件 // 处理滚动事件
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => { const handleItemsRendered = useCallback(({ visibleStartIndex, visibleStopIndex }) => {
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget; // 当可视区域接近列表顶部时加载更新的数据
// 当滚动到底部时加载更多 if (visibleStartIndex <= 3 && hasMoreNew && !isRefreshing && pagination.current >= 1) {
if (scrollTop + clientHeight >= scrollHeight - 10) { loadNewer();
loadMore();
} }
}, [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 ( return (
<PageContainer <PageContainer
@ -139,50 +290,126 @@ const Index = () => {
title={ title={
queryDetailLoading ? '加载中' : `${detail?.title} ${`${detail?.itemCount ?? 0}个时刻`}` queryDetailLoading ? '加载中' : `${detail?.title} ${`${detail?.itemCount ?? 0}个时刻`}`
} }
extra={
<Button
icon={<SyncOutlined />}
onClick={handleRefresh}
loading={isRefreshing}
>
</Button>
}
> >
<div <div
className="timeline" className="timeline"
ref={containerRef} ref={containerRef}
style={{ style={{
height: 'calc(100vh - 200px)', height: 'calc(100vh - 200px)',
overflowY: 'auto', overflow: 'hidden',
position: 'relative'
}} }}
> >
{items.length > 0 ? ( {items.length > 0 ? (
<AutoSizer> <>
{({ height, width }) => ( {/* 顶部提示信息 */}
<div {!hasMoreNew && pagination.current <= 1 && (
style={{ height, width, position: 'relative' }} <div style={{
onScroll={handleScroll} textAlign: 'center',
className="timeline-hide-scrollbar" padding: '12px',
> color: '#999',
fontSize: '14px',
position: 'sticky',
top: 0,
backgroundColor: '#fff',
zIndex: 10,
borderBottom: '1px solid #f0f0f0'
}}>
</div>
)}
<AutoSizer>
{({ height, width }) => (
<List <List
height={height} ref={listRef}
itemCount={items.length} height={height - (hasMoreNew && pagination.current <= 1 ? 40 : 0) - (hasMoreOld && pagination.current >= 1 ? 40 : 0)}
itemSize={300} // 根据实际项高度调整 itemCount={items.length + (hasMoreOld && pagination.current >= 1 ? 1 : 0) + (hasMoreNew && pagination.current < 1 && isRefreshing ? 1 : 0)}
itemSize={getItemSize}
width={width} width={width}
onItemsRendered={handleItemsRendered}
> >
{renderTimelineItem} {renderTimelineItem}
</List> </List>
)}
</AutoSizer>
{/* 加载更多指示器 */} {/* 底部提示信息 */}
{loading && ( {!hasMoreOld && pagination.current >= 1 && (
<div style={{ textAlign: 'center', padding: '20px' }}> <div style={{
<Spin /> textAlign: 'center',
</div> padding: '12px',
)} color: '#999',
fontSize: '14px',
{!hasMore && items.length > 0 && ( position: 'sticky',
<div style={{ textAlign: 'center', color: '#999', padding: '20px' }}> bottom: 0,
backgroundColor: '#fff',
</div> zIndex: 10,
)} borderTop: '1px solid #f0f0f0'
}}>
</div> </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' }}> <div style={{
{loading ? <Spin /> : '暂无时间线数据'} 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>
)} )}
</div> </div>
@ -206,8 +433,10 @@ const Index = () => {
setOpenAddItemModal(false); setOpenAddItemModal(false);
// 添加新项后刷新数据 // 添加新项后刷新数据
setPagination(prev => ({ ...prev, current: 1 })); setPagination(prev => ({ ...prev, current: 1 }));
// 重置高度测量
measuredItemsRef.current = new Set();
run(); run();
queryDetail() queryDetail();
}} }}
storyId={lineId} storyId={lineId}
/> />

View File

@ -9,12 +9,33 @@
transition: transform 0.3s ease; transition: transform 0.3s ease;
scrollbar-width: none; scrollbar-width: none;
-ms-overflow-style: none; -ms-overflow-style: none;
border-bottom: 1px solid #eee;
} }
.timeline::-webkit-scrollbar { .timeline::-webkit-scrollbar {
display: none; display: none;
} }
.timeline-item-header {
margin-bottom: 10px;
}
.timeline-item-content p {
line-height: 1.6;
margin-bottom: 10px;
}
.timeline-item-images {
display: flex;
gap: 10px;
}
.timeline-image {
width: 100px;
height: 100px;
object-fit: cover;
border-radius: 4px;
}
/* 主时间线容器 */ /* 主时间线容器 */
.timeline-event-container { .timeline-event-container {
position: relative; position: relative;