Files
timeline-frontend/src/pages/story/detail.tsx

506 lines
17 KiB
TypeScript
Raw Normal View History

2025-08-07 19:48:36 +08:00
// src/pages/story/detail.tsx
import { useIsMobile } from '@/hooks/useIsMobile';
2025-08-05 19:02:14 +08:00
import AddTimeLineItemModal from '@/pages/story/components/AddTimeLineItemModal';
2026-01-19 18:09:37 +08:00
import TimelineGridItem from '@/pages/story/components/TimelineGridItem';
import TimelineItemDrawer from '@/pages/story/components/TimelineItemDrawer';
import { StoryItem, StoryItemTimeQueryParams } from '@/pages/story/data';
2026-01-19 18:09:37 +08:00
import { queryStoryDetail, queryStoryItem, removeStoryItem } from '@/pages/story/service';
import { judgePermission } from '@/pages/story/utils/utils';
import { PlusOutlined, SyncOutlined } from '@ant-design/icons';
2025-08-05 19:02:14 +08:00
import { PageContainer } from '@ant-design/pro-components';
import { history, useParams, useRequest } from '@umijs/max';
import { Button, Empty, FloatButton, message, Spin } from 'antd';
import { PullToRefresh } from 'antd-mobile';
import { useCallback, useEffect, useRef, useState } from 'react';
2026-01-19 18:09:37 +08:00
import './detail.css';
2025-08-05 19:02:14 +08:00
// 格式化时间数组为易读格式
const formatTimeArray = (time: string | number[] | undefined): string => {
if (!time) return '';
2025-12-31 14:30:03 +08:00
// 如果是数组格式 [2025, 12, 23, 8, 55, 39]
if (Array.isArray(time)) {
const [year, month, day, hour, minute, second] = time;
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')} ${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}:${String(second).padStart(2, '0')}`;
}
2025-12-31 14:30:03 +08:00
// 如果已经是字符串格式,直接返回
return String(time);
};
2025-08-05 19:02:14 +08:00
const Index = () => {
const isMobile = useIsMobile();
2025-08-05 19:02:14 +08:00
const { id: lineId } = useParams<{ id: string }>();
const containerRef = useRef<HTMLDivElement>(null);
2025-08-07 19:48:36 +08:00
const [items, setItems] = useState<StoryItem[]>([]);
2025-08-05 19:02:14 +08:00
const [loading, setLoading] = useState(false);
2025-08-08 17:42:07 +08:00
const [hasMoreOld, setHasMoreOld] = useState(true); // 是否有更老的数据
const [hasMoreNew, setHasMoreNew] = useState(true); // 是否有更新的数据
2025-08-05 19:02:14 +08:00
const [openAddItemModal, setOpenAddItemModal] = useState(false);
const [currentItem, setCurrentItem] = useState<StoryItem>();
2025-08-07 19:48:36 +08:00
const [currentOption, setCurrentOption] = useState<
'add' | 'edit' | 'addSubItem' | 'editSubItem'
>();
2026-01-19 18:09:37 +08:00
const [openDetailDrawer, setOpenDetailDrawer] = useState(false);
const [detailItem, setDetailItem] = useState<StoryItem>();
const [pagination, setPagination] = useState({ current: 1, pageSize: 30 });
2025-08-08 17:42:07 +08:00
const [isRefreshing, setIsRefreshing] = useState(false); // 是否正在刷新最新数据
const [showScrollTop, setShowScrollTop] = useState(false); // 是否显示回到顶部按钮
const [currentTimeArr, setCurrentTimeArr] = useState<[string | undefined, string | undefined]>([
undefined,
undefined,
]);
const [loadDirection, setLoadDirection] = useState<'init' | 'older' | 'newer' | 'refresh'>(
'init',
);
const hasShownNoMoreOldRef = useRef(false);
const hasShownNoMoreNewRef = useRef(false);
type QueryParams = StoryItemTimeQueryParams & { current?: number; pageSize?: number };
2025-08-05 19:02:14 +08:00
2025-08-07 19:48:36 +08:00
const { data: response, run } = useRequest(
(params?: QueryParams) => {
return queryStoryItem({ storyInstanceId: lineId, pageSize: pagination.pageSize, ...params });
2025-08-05 19:02:14 +08:00
},
{
manual: true,
},
);
2025-08-07 19:48:36 +08:00
2025-08-05 19:02:14 +08:00
const {
data: detail,
run: queryDetail,
loading: queryDetailLoading,
} = useRequest(() => {
return queryStoryDetail(lineId ?? '');
});
2025-08-07 19:48:36 +08:00
2025-08-05 19:02:14 +08:00
// 初始化加载数据
useEffect(() => {
2025-08-08 17:42:07 +08:00
setHasMoreOld(true);
setHasMoreNew(true);
setLoadDirection('init');
setLoading(true);
queryDetail();
run();
2025-08-05 19:02:14 +08:00
}, [lineId]);
2025-08-07 19:48:36 +08:00
// 处理响应数据
2025-08-05 19:02:14 +08:00
useEffect(() => {
2025-08-07 19:48:36 +08:00
if (!response) return;
// 兼容 response.list 和 response.data.list
// @ts-ignore
const fetched = response.list || response.data?.list || [];
const pageSize = pagination.pageSize;
const noMore = !(fetched.length === pageSize);
// 若无新数据则避免触发列表重绘,只更新加载状态
if (!fetched.length) {
if (loadDirection === 'older') {
setHasMoreOld(false);
} else if (loadDirection === 'newer') {
setHasMoreNew(false);
} else if (loadDirection === 'init' || loadDirection === 'refresh') {
setItems([]);
}
setLoading(false);
setIsRefreshing(false);
setLoadDirection('init');
return;
2025-08-07 19:48:36 +08:00
}
if (loadDirection === 'older') {
setItems((prev) => {
const next = [...prev, ...fetched];
setCurrentTimeArr([next[0]?.storyItemTime, next[next.length - 1]?.storyItemTime]);
return next;
});
setHasMoreOld(!noMore);
if (noMore && !hasShownNoMoreOldRef.current) {
hasShownNoMoreOldRef.current = true;
message.info('没有更多历史内容了');
}
} else if (loadDirection === 'newer') {
setItems((prev) => {
const next = [...fetched, ...prev];
setCurrentTimeArr([next[0]?.storyItemTime, next[next.length - 1]?.storyItemTime]);
return next;
});
setHasMoreNew(!noMore);
if (noMore && !hasShownNoMoreNewRef.current) {
hasShownNoMoreNewRef.current = true;
message.info('没有更多更新内容了');
}
} else if (loadDirection === 'refresh' || loadDirection === 'init') {
setItems(fetched);
if (fetched.length > 0) {
setCurrentTimeArr([fetched[0]?.storyItemTime, fetched[fetched.length - 1]?.storyItemTime]);
}
setHasMoreOld(!noMore);
2025-08-08 17:42:07 +08:00
}
2025-08-07 19:48:36 +08:00
setLoading(false);
2025-08-08 17:42:07 +08:00
setIsRefreshing(false);
setLoadDirection('init');
}, [response, loadDirection, pagination.pageSize]);
2025-08-05 19:02:14 +08:00
// 滚动到底部加载更老的数据
const loadOlder = useCallback(() => {
if (loading || !hasMoreOld) {
return;
}
const beforeTime = items[items.length - 1]?.storyItemTime || currentTimeArr[1];
if (!beforeTime) return;
const nextPage = pagination.current + 1;
setPagination((prev) => ({ ...prev, current: nextPage }));
setLoadDirection('older');
setLoading(true);
run({ current: nextPage });
}, [loading, hasMoreOld, items, currentTimeArr, pagination.current, run]);
2026-01-19 18:09:37 +08:00
// 滚动到顶部加载更新的数据
const loadNewer = useCallback(() => {
2026-01-19 18:09:37 +08:00
if (loading || !hasMoreNew || isRefreshing) {
return;
}
const afterTime = items[0]?.storyItemTime || currentTimeArr[0];
if (!afterTime) return;
setPagination((prev) => ({ ...prev, current: 1 }));
setLoadDirection('newer');
setIsRefreshing(true);
setLoading(true);
run({ afterTime: afterTime });
}, [loading, hasMoreNew, isRefreshing, items, currentTimeArr, run]);
2025-08-08 17:42:07 +08:00
2026-01-19 18:09:37 +08:00
// 监听滚动事件
useEffect(() => {
2026-01-19 18:09:37 +08:00
const container = containerRef.current;
if (!container) return;
2025-08-08 17:42:07 +08:00
const handleScroll = () => {
2026-01-19 18:09:37 +08:00
const { scrollTop, scrollHeight, clientHeight } = container;
2026-01-19 18:09:37 +08:00
// 显示回到顶部按钮
setShowScrollTop(scrollTop > 300);
2026-01-19 18:09:37 +08:00
// 接近底部时加载更多
if (scrollHeight - scrollTop - clientHeight < 200) {
loadOlder();
}
};
2026-01-19 18:09:37 +08:00
container.addEventListener('scroll', handleScroll);
return () => container.removeEventListener('scroll', handleScroll);
}, [loadOlder]);
2025-08-08 17:42:07 +08:00
// 手动刷新最新数据
const handleRefresh = () => {
if (isRefreshing) return;
setIsRefreshing(true);
setLoadDirection('refresh');
setPagination((prev) => ({ ...prev, current: 1 }));
hasShownNoMoreOldRef.current = false;
hasShownNoMoreNewRef.current = false;
run({ current: 1 });
2025-08-08 17:42:07 +08:00
};
// 回到顶部
const scrollToTop = () => {
2026-01-19 18:09:37 +08:00
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) => {
2026-01-19 18:09:37 +08:00
let dateKey = '';
let sortValue = 0;
2026-01-19 18:09:37 +08:00
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();
}
2026-01-19 18:09:37 +08:00
if (!groups[dateKey]) {
groups[dateKey] = { dateKey, items: [], sortValue };
}
groups[dateKey].items.push(item);
});
2026-01-19 18:09:37 +08:00
// 对每个日期组内的项目按时间排序(从早到晚)
Object.keys(groups).forEach((dateKey) => {
2026-01-19 18:09:37 +08:00
groups[dateKey].items.sort((a, b) => {
const timeA = getTimeValue(a.storyItemTime);
const timeB = getTimeValue(b.storyItemTime);
return timeA - timeB;
});
});
2026-01-19 18:09:37 +08:00
return groups;
};
// 将时间转换为可比较的数值
const getTimeValue = (time: string | number[] | undefined): number => {
if (!time) return 0;
2026-01-19 18:09:37 +08:00
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();
2025-08-08 17:42:07 +08:00
}
2026-01-19 18:09:37 +08:00
return new Date(String(time)).getTime();
2025-08-08 17:42:07 +08:00
};
2026-01-19 18:09:37 +08:00
const groupedItems = groupItemsByDate(items);
2025-08-05 19:02:14 +08:00
return (
<PageContainer
onBack={() => history.push('/story')}
2025-08-07 19:48:36 +08:00
title={
queryDetailLoading ? '加载中' : `${detail?.title} ${`${detail?.itemCount ?? 0}个时刻`}`
}
2025-08-08 17:42:07 +08:00
extra={
<Button
icon={<SyncOutlined />}
onClick={() => {
setItems([]);
2026-01-19 18:09:37 +08:00
setPagination({ current: 1, pageSize: 30 });
setLoadDirection('refresh');
run({ current: 1 });
}}
2025-08-08 17:42:07 +08:00
loading={isRefreshing}
>
</Button>
}
2025-08-05 19:02:14 +08:00
>
2025-08-07 19:48:36 +08:00
<div
className="timeline"
ref={containerRef}
style={{
height: 'calc(100vh - 200px)',
2026-01-19 18:09:37 +08:00
overflow: 'auto',
position: 'relative',
2026-01-19 18:09:37 +08:00
padding: '0 8px', // 减少内边距
2025-08-07 19:48:36 +08:00
}}
>
{items.length > 0 ? (
<PullToRefresh onRefresh={loadNewer} disabled={!isMobile}>
{hasMoreNew && !isMobile && (
<div style={{ textAlign: 'center', padding: '12px 0' }}>
<Button onClick={loadNewer} loading={isRefreshing}>
</Button>
</div>
)}
2026-01-19 18:09:37 +08:00
{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>
2026-01-19 18:09:37 +08:00
</div>
))}
2026-01-19 18:09:37 +08:00
{loading && <div className="load-indicator">...</div>}
{!loading && !hasMoreOld && <div className="no-more-data"></div>}
2025-08-08 17:42:07 +08:00
{/* 回到顶部按钮 */}
{showScrollTop && (
<div
style={{
2026-01-19 18:09:37 +08:00
position: 'fixed',
bottom: 80,
right: 24,
zIndex: 10,
}}
>
2025-08-08 17:42:07 +08:00
<Button
type="primary"
shape="circle"
icon={<SyncOutlined />}
onClick={scrollToTop}
/>
2025-08-07 19:48:36 +08:00
</div>
)}
</PullToRefresh>
2025-08-07 19:48:36 +08:00
) : (
<div
style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
height: '100%',
textAlign: 'center',
}}
>
2025-08-08 17:42:07 +08:00
{loading ? (
<>
<Spin size="large" />
<div
style={{
marginTop: 16,
fontSize: '16px',
color: '#666',
2025-08-08 17:42:07 +08:00
}}
>
线...
</div>
2025-08-08 17:42:07 +08:00
</>
) : (
<>
<Empty
description="暂无时间线数据"
image={Empty.PRESENTED_IMAGE_SIMPLE}
imageStyle={{
height: 60,
}}
>
<div
style={{
fontSize: '16px',
color: '#999',
marginBottom: '16px',
}}
>
</div>
</Empty>
<Button
type="primary"
size="large"
disabled={!judgePermission(detail?.permissionType ?? null, 'edit')}
onClick={() => {
setCurrentOption('add');
setCurrentItem(undefined);
setOpenAddItemModal(true);
}}
>
</Button>
</>
2025-08-08 17:42:07 +08:00
)}
2025-08-07 19:48:36 +08:00
</div>
)}
2025-08-05 19:02:14 +08:00
</div>
2025-08-07 19:48:36 +08:00
<FloatButton
onClick={() => {
setCurrentOption('add');
setCurrentItem(undefined);
2025-08-07 19:48:36 +08:00
setOpenAddItemModal(true);
}}
icon={<PlusOutlined />}
2026-01-19 18:09:37 +08:00
disabled={!judgePermission(detail?.permissionType ?? null, 'edit')}
type="primary"
style={{
right: 24,
bottom: 24,
}}
2025-08-07 19:48:36 +08:00
/>
2025-08-05 19:02:14 +08:00
<AddTimeLineItemModal
visible={openAddItemModal}
initialValues={currentItem}
option={currentOption || 'add'}
2025-08-05 19:02:14 +08:00
onCancel={() => {
setOpenAddItemModal(false);
}}
onOk={() => {
setOpenAddItemModal(false);
2025-08-07 19:48:36 +08:00
// 添加新项后刷新数据
setPagination((prev) => ({ ...prev, current: 1 }));
setLoadDirection('refresh');
run({ current: 1 });
2025-08-08 17:42:07 +08:00
queryDetail();
2025-08-05 19:02:14 +08:00
}}
storyId={lineId}
/>
2026-01-19 18:09:37 +08:00
{/* 详情抽屉 - 在外层管理,不影响网格布局 */}
{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);
}}
/>
)}
2025-08-05 19:02:14 +08:00
</PageContainer>
);
};
2025-12-31 14:30:03 +08:00
export default Index;