Files
timeline-frontend/src/pages/story/detail.tsx
jianghao cd752d97d8
Some checks failed
test/timeline-frontend/pipeline/head There was a failure building this commit
feat: 支持视频上传、预览及移动端适配
1. 功能增强:
- 支持视频文件的上传、存储及缩略图自动生成
- 新增视频播放组件,支持在画廊和时间线中预览视频
- 引入 STOMP 协议支持 WebSocket 实时通知功能
- 增加分享页面(SSR 友好),支持通过 shareId 访问公开内容

2. 移动端优化:
- 新增 BottomNav 底部导航组件,优化移动端交互体验
- 引入 useIsMobile 钩子,实现响应式布局切换
- 优化时间线卡片在小屏幕下的显示效果

3. 架构与组件:
- 新增 ClientOnly 组件解决 SSR 激活不一致问题
- 新增 ResponsiveGrid 响应式网格布局组件
- 完善 Nginx 配置,增加 MinIO 对象存储代理
- 优化图片懒加载组件 TimelineImage,支持低分辨率占位图
2026-02-12 16:55:05 +08:00

506 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;