Some checks failed
test/timeline-frontend/pipeline/head There was a failure building this commit
1. 功能增强: - 支持视频文件的上传、存储及缩略图自动生成 - 新增视频播放组件,支持在画廊和时间线中预览视频 - 引入 STOMP 协议支持 WebSocket 实时通知功能 - 增加分享页面(SSR 友好),支持通过 shareId 访问公开内容 2. 移动端优化: - 新增 BottomNav 底部导航组件,优化移动端交互体验 - 引入 useIsMobile 钩子,实现响应式布局切换 - 优化时间线卡片在小屏幕下的显示效果 3. 架构与组件: - 新增 ClientOnly 组件解决 SSR 激活不一致问题 - 新增 ResponsiveGrid 响应式网格布局组件 - 完善 Nginx 配置,增加 MinIO 对象存储代理 - 优化图片懒加载组件 TimelineImage,支持低分辨率占位图
506 lines
17 KiB
TypeScript
506 lines
17 KiB
TypeScript
// src/pages/story/detail.tsx
|
||
import { useIsMobile } from '@/hooks/useIsMobile';
|
||
import AddTimeLineItemModal from '@/pages/story/components/AddTimeLineItemModal';
|
||
import TimelineGridItem from '@/pages/story/components/TimelineGridItem';
|
||
import TimelineItemDrawer from '@/pages/story/components/TimelineItemDrawer';
|
||
import { StoryItem, StoryItemTimeQueryParams } from '@/pages/story/data';
|
||
import { queryStoryDetail, queryStoryItem, removeStoryItem } from '@/pages/story/service';
|
||
import { judgePermission } from '@/pages/story/utils/utils';
|
||
import { PlusOutlined, SyncOutlined } from '@ant-design/icons';
|
||
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';
|
||
import './detail.css';
|
||
|
||
// 格式化时间数组为易读格式
|
||
const formatTimeArray = (time: string | number[] | undefined): string => {
|
||
if (!time) return '';
|
||
|
||
// 如果是数组格式 [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')}`;
|
||
}
|
||
|
||
// 如果已经是字符串格式,直接返回
|
||
return String(time);
|
||
};
|
||
|
||
const Index = () => {
|
||
const isMobile = useIsMobile();
|
||
const { id: lineId } = useParams<{ id: string }>();
|
||
const containerRef = useRef<HTMLDivElement>(null);
|
||
const [items, setItems] = useState<StoryItem[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
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 [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]>([
|
||
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 };
|
||
|
||
const { data: response, run } = useRequest(
|
||
(params?: QueryParams) => {
|
||
return queryStoryItem({ storyInstanceId: lineId, pageSize: pagination.pageSize, ...params });
|
||
},
|
||
{
|
||
manual: true,
|
||
},
|
||
);
|
||
|
||
const {
|
||
data: detail,
|
||
run: queryDetail,
|
||
loading: queryDetailLoading,
|
||
} = useRequest(() => {
|
||
return queryStoryDetail(lineId ?? '');
|
||
});
|
||
|
||
// 初始化加载数据
|
||
useEffect(() => {
|
||
setHasMoreOld(true);
|
||
setHasMoreNew(true);
|
||
setLoadDirection('init');
|
||
setLoading(true);
|
||
queryDetail();
|
||
run();
|
||
}, [lineId]);
|
||
// 处理响应数据
|
||
useEffect(() => {
|
||
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;
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
setLoading(false);
|
||
setIsRefreshing(false);
|
||
setLoadDirection('init');
|
||
}, [response, loadDirection, pagination.pageSize]);
|
||
|
||
// 滚动到底部加载更老的数据
|
||
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]);
|
||
|
||
// 滚动到顶部加载更新的数据
|
||
const loadNewer = useCallback(() => {
|
||
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]);
|
||
|
||
// 监听滚动事件
|
||
useEffect(() => {
|
||
const container = containerRef.current;
|
||
if (!container) return;
|
||
|
||
const handleScroll = () => {
|
||
const { scrollTop, scrollHeight, clientHeight } = container;
|
||
|
||
// 显示回到顶部按钮
|
||
setShowScrollTop(scrollTop > 300);
|
||
|
||
// 接近底部时加载更多
|
||
if (scrollHeight - scrollTop - clientHeight < 200) {
|
||
loadOlder();
|
||
}
|
||
};
|
||
|
||
container.addEventListener('scroll', handleScroll);
|
||
return () => container.removeEventListener('scroll', handleScroll);
|
||
}, [loadOlder]);
|
||
|
||
// 手动刷新最新数据
|
||
const handleRefresh = () => {
|
||
if (isRefreshing) return;
|
||
|
||
setIsRefreshing(true);
|
||
setLoadDirection('refresh');
|
||
setPagination((prev) => ({ ...prev, current: 1 }));
|
||
hasShownNoMoreOldRef.current = false;
|
||
hasShownNoMoreNewRef.current = false;
|
||
run({ current: 1 });
|
||
};
|
||
|
||
// 回到顶部
|
||
const scrollToTop = () => {
|
||
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')}
|
||
title={
|
||
queryDetailLoading ? '加载中' : `${detail?.title} ${`共${detail?.itemCount ?? 0}个时刻`}`
|
||
}
|
||
extra={
|
||
<Button
|
||
icon={<SyncOutlined />}
|
||
onClick={() => {
|
||
setItems([]);
|
||
setPagination({ current: 1, pageSize: 30 });
|
||
setLoadDirection('refresh');
|
||
run({ current: 1 });
|
||
}}
|
||
loading={isRefreshing}
|
||
>
|
||
刷新
|
||
</Button>
|
||
}
|
||
>
|
||
<div
|
||
className="timeline"
|
||
ref={containerRef}
|
||
style={{
|
||
height: 'calc(100vh - 200px)',
|
||
overflow: 'auto',
|
||
position: 'relative',
|
||
padding: '0 8px', // 减少内边距
|
||
}}
|
||
>
|
||
{items.length > 0 ? (
|
||
<PullToRefresh onRefresh={loadNewer} disabled={!isMobile}>
|
||
{hasMoreNew && !isMobile && (
|
||
<div style={{ textAlign: 'center', padding: '12px 0' }}>
|
||
<Button onClick={loadNewer} loading={isRefreshing}>
|
||
加载新内容
|
||
</Button>
|
||
</div>
|
||
)}
|
||
{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: 'fixed',
|
||
bottom: 80,
|
||
right: 24,
|
||
zIndex: 10,
|
||
}}
|
||
>
|
||
<Button
|
||
type="primary"
|
||
shape="circle"
|
||
icon={<SyncOutlined />}
|
||
onClick={scrollToTop}
|
||
/>
|
||
</div>
|
||
)}
|
||
</PullToRefresh>
|
||
) : (
|
||
<div
|
||
style={{
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
justifyContent: 'center',
|
||
alignItems: 'center',
|
||
height: '100%',
|
||
textAlign: 'center',
|
||
}}
|
||
>
|
||
{loading ? (
|
||
<>
|
||
<Spin size="large" />
|
||
<div
|
||
style={{
|
||
marginTop: 16,
|
||
fontSize: '16px',
|
||
color: '#666',
|
||
}}
|
||
>
|
||
正在加载时间线数据...
|
||
</div>
|
||
</>
|
||
) : (
|
||
<>
|
||
<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>
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<FloatButton
|
||
onClick={() => {
|
||
setCurrentOption('add');
|
||
setCurrentItem(undefined);
|
||
setOpenAddItemModal(true);
|
||
}}
|
||
icon={<PlusOutlined />}
|
||
disabled={!judgePermission(detail?.permissionType ?? null, 'edit')}
|
||
type="primary"
|
||
style={{
|
||
right: 24,
|
||
bottom: 24,
|
||
}}
|
||
/>
|
||
|
||
<AddTimeLineItemModal
|
||
visible={openAddItemModal}
|
||
initialValues={currentItem}
|
||
option={currentOption || 'add'}
|
||
onCancel={() => {
|
||
setOpenAddItemModal(false);
|
||
}}
|
||
onOk={() => {
|
||
setOpenAddItemModal(false);
|
||
// 添加新项后刷新数据
|
||
setPagination((prev) => ({ ...prev, current: 1 }));
|
||
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>
|
||
);
|
||
};
|
||
|
||
export default Index;
|