feat: 支持视频上传、预览及移动端适配
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,支持低分辨率占位图
This commit is contained in:
2026-02-12 16:55:05 +08:00
parent 336208b7ce
commit cd752d97d8
39 changed files with 1729 additions and 537 deletions

View File

@@ -1,17 +1,18 @@
// 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, useRequest } from '@umijs/max';
import { history, useParams, useRequest } from '@umijs/max';
import { Button, Empty, FloatButton, message, Spin } from 'antd';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useParams } from '@umijs/max';
import { PullToRefresh } from 'antd-mobile';
import { useCallback, useEffect, useRef, useState } from 'react';
import './detail.css';
import {judgePermission} from "@/pages/story/utils/utils";
// 格式化时间数组为易读格式
const formatTimeArray = (time: string | number[] | undefined): string => {
@@ -28,6 +29,7 @@ const formatTimeArray = (time: string | number[] | undefined): string => {
};
const Index = () => {
const isMobile = useIsMobile();
const { id: lineId } = useParams<{ id: string }>();
const containerRef = useRef<HTMLDivElement>(null);
const [items, setItems] = useState<StoryItem[]>([]);
@@ -79,12 +81,15 @@ const Index = () => {
setHasMoreNew(true);
setLoadDirection('init');
setLoading(true);
run({ current: 1 });
queryDetail();
run();
}, [lineId]);
// 处理响应数据
useEffect(() => {
if (!response) return;
const fetched = response.list || [];
// 兼容 response.list 和 response.data.list
// @ts-ignore
const fetched = response.list || response.data?.list || [];
const pageSize = pagination.pageSize;
const noMore = !(fetched.length === pageSize);
@@ -94,6 +99,8 @@ const Index = () => {
setHasMoreOld(false);
} else if (loadDirection === 'newer') {
setHasMoreNew(false);
} else if (loadDirection === 'init' || loadDirection === 'refresh') {
setItems([]);
}
setLoading(false);
setIsRefreshing(false);
@@ -123,13 +130,12 @@ const Index = () => {
hasShownNoMoreNewRef.current = true;
message.info('没有更多更新内容了');
}
} else if (loadDirection === 'refresh') {
// 刷新操作
} else {
} else if (loadDirection === 'refresh' || loadDirection === 'init') {
setItems(fetched);
setCurrentTimeArr([fetched[0]?.storyItemTime, fetched[fetched.length - 1]?.storyItemTime]);
if (fetched.length > 0) {
setCurrentTimeArr([fetched[0]?.storyItemTime, fetched[fetched.length - 1]?.storyItemTime]);
}
setHasMoreOld(!noMore);
setHasMoreNew(true);
}
setLoading(false);
@@ -163,7 +169,7 @@ const Index = () => {
setLoadDirection('newer');
setIsRefreshing(true);
setLoading(true);
run({ current: 1 });
run({ afterTime: afterTime });
}, [loading, hasMoreNew, isRefreshing, items, currentTimeArr, run]);
// 监听滚动事件
@@ -173,10 +179,10 @@ const Index = () => {
const handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = container;
// 显示回到顶部按钮
setShowScrollTop(scrollTop > 300);
// 接近底部时加载更多
if (scrollHeight - scrollTop - clientHeight < 200) {
loadOlder();
@@ -208,12 +214,13 @@ const Index = () => {
// 按日期分组items并在每个组内按时间排序
const groupItemsByDate = (items: StoryItem[]) => {
const groups: { [key: string]: { dateKey: string; items: StoryItem[]; sortValue: number } } = {};
items.forEach(item => {
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}`;
@@ -224,34 +231,34 @@ const Index = () => {
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 => {
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();
};
@@ -289,54 +296,61 @@ const Index = () => {
}}
>
{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 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>
</div>
))}
))}
{loading && <div className="load-indicator">...</div>}
{!loading && !hasMoreOld && <div className="no-more-data"></div>}
{/* 回到顶部按钮 */}
{showScrollTop && (
<div
@@ -355,15 +369,15 @@ const Index = () => {
/>
</div>
)}
</>
</PullToRefresh>
) : (
<div
style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
height: '100%',
textAlign: 'center',
}}
>
@@ -372,8 +386,8 @@ const Index = () => {
<Spin size="large" />
<div
style={{
marginTop: 16,
fontSize: '16px',
marginTop: 16,
fontSize: '16px',
color: '#666',
}}
>
@@ -381,14 +395,14 @@ const Index = () => {
</div>
</>
) : (
<>
<Empty
description="暂无时间线数据"
image={Empty.PRESENTED_IMAGE_SIMPLE}
imageStyle={{
height: 60,
}}
>
<>
<Empty
description="暂无时间线数据"
image={Empty.PRESENTED_IMAGE_SIMPLE}
imageStyle={{
height: 60,
}}
>
<div
style={{
fontSize: '16px',
@@ -396,22 +410,22 @@ const Index = () => {
marginBottom: '16px',
}}
>
</div>
</Empty>
<Button
type="primary"
size="large"
disabled={!judgePermission(detail?.permissionType ?? null, 'edit')}
onClick={() => {
setCurrentOption('add');
setCurrentItem(undefined);
setOpenAddItemModal(true);
}}
>
</Button>
</>
</div>
</Empty>
<Button
type="primary"
size="large"
disabled={!judgePermission(detail?.permissionType ?? null, 'edit')}
onClick={() => {
setCurrentOption('add');
setCurrentItem(undefined);
setOpenAddItemModal(true);
}}
>
</Button>
</>
)}
</div>
)}