feat: 支持视频上传、预览及移动端适配
Some checks failed
test/timeline-frontend/pipeline/head There was a failure building this commit
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:
@@ -6,6 +6,7 @@ import { useIntl, useRequest } from '@umijs/max';
|
||||
import { Button, message, Popconfirm, Tag, theme } from 'antd';
|
||||
import React, { useEffect } from 'react';
|
||||
import { queryStoryItemImages, removeStoryItem } from '../service';
|
||||
import TimelineVideo from './TimelineVideo';
|
||||
|
||||
// 格式化时间数组为易读格式
|
||||
const formatTimeArray = (time: string | number[] | undefined): string => {
|
||||
@@ -24,7 +25,7 @@ const formatTimeArray = (time: string | number[] | undefined): string => {
|
||||
const [hour, minute] = timePart.split(':');
|
||||
return `${hour}:${minute}`;
|
||||
}
|
||||
|
||||
|
||||
return timeStr;
|
||||
};
|
||||
|
||||
@@ -37,11 +38,11 @@ const TimelineGridItem: React.FC<{
|
||||
}> = ({ item, handleOption, onOpenDetail, refresh, disableEdit }) => {
|
||||
const intl = useIntl();
|
||||
const { token } = theme.useToken();
|
||||
|
||||
|
||||
// 动态设置CSS变量以适配主题
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
|
||||
|
||||
// 根据Ant Design的token设置主题变量
|
||||
root.style.setProperty('--timeline-bg', token.colorBgContainer);
|
||||
root.style.setProperty('--timeline-card-bg', token.colorBgElevated);
|
||||
@@ -58,12 +59,15 @@ const TimelineGridItem: React.FC<{
|
||||
root.style.setProperty('--timeline-more-bg', token.colorBgMask);
|
||||
root.style.setProperty('--timeline-more-color', token.colorWhite);
|
||||
}, [token]);
|
||||
|
||||
const { data: imagesList } = useRequest(async () => {
|
||||
return await queryStoryItemImages(item.instanceId);
|
||||
}, {
|
||||
refreshDeps: [item.instanceId]
|
||||
});
|
||||
|
||||
const { data: imagesList } = useRequest(
|
||||
async () => {
|
||||
return await queryStoryItemImages(item.instanceId);
|
||||
},
|
||||
{
|
||||
refreshDeps: [item.instanceId],
|
||||
},
|
||||
);
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
@@ -84,7 +88,7 @@ const TimelineGridItem: React.FC<{
|
||||
const calculateCardSize = () => {
|
||||
const imageCount = imagesList?.length || 0;
|
||||
const descriptionLength = item.description?.length || 0;
|
||||
|
||||
|
||||
// 根据图片数量和描述长度决定卡片大小
|
||||
if (imageCount >= 4 || descriptionLength > 200) {
|
||||
return 4; // 占据整行
|
||||
@@ -98,15 +102,20 @@ const TimelineGridItem: React.FC<{
|
||||
};
|
||||
|
||||
const cardSize = calculateCardSize();
|
||||
|
||||
|
||||
// 统一的文本长度 - 根据卡片大小调整
|
||||
const getDescriptionMaxLength = (size: number) => {
|
||||
switch (size) {
|
||||
case 1: return 80;
|
||||
case 2: return 150;
|
||||
case 3: return 200;
|
||||
case 4: return 300;
|
||||
default: return 100;
|
||||
case 1:
|
||||
return 80;
|
||||
case 2:
|
||||
return 150;
|
||||
case 3:
|
||||
return 200;
|
||||
case 4:
|
||||
return 300;
|
||||
default:
|
||||
return 100;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -120,10 +129,7 @@ const TimelineGridItem: React.FC<{
|
||||
|
||||
// 只返回article元素,不包含任何其他元素
|
||||
return (
|
||||
<article
|
||||
className={`timeline-grid-item size-${cardSize}`}
|
||||
onClick={() => onOpenDetail(item)}
|
||||
>
|
||||
<article className={`timeline-grid-item size-${cardSize}`} onClick={() => onOpenDetail(item)}>
|
||||
{/* Action buttons */}
|
||||
{!disableEdit && (
|
||||
<div className="item-actions" onClick={(e) => e.stopPropagation()}>
|
||||
@@ -169,33 +175,33 @@ const TimelineGridItem: React.FC<{
|
||||
<p>{truncateText(item.description, descriptionMaxLength)}</p>
|
||||
|
||||
{/* Images preview - 固定间隔,单行展示,多余折叠 */}
|
||||
{imagesList && imagesList.length > 0 && (
|
||||
<div className="item-images-container">
|
||||
<div className="item-images-row">
|
||||
{imagesList
|
||||
.filter(imageInstanceId => imageInstanceId && imageInstanceId.trim() !== '')
|
||||
.slice(0, 6) // 最多显示6张图片
|
||||
.map((imageInstanceId, index) => (
|
||||
<div key={imageInstanceId + index} className="item-image-wrapper">
|
||||
<TimelineImage
|
||||
title={imageInstanceId}
|
||||
imageInstanceId={imageInstanceId}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{imagesList.length > 6 && (
|
||||
<div className="more-images-indicator">
|
||||
+{imagesList.length - 6}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{item.videoUrl ? (
|
||||
<div className="item-images-container" style={{ marginTop: '10px' }}>
|
||||
<TimelineVideo videoInstanceId={item.videoUrl} thumbnailInstanceId={item.thumbnailUrl} />
|
||||
</div>
|
||||
) : (
|
||||
imagesList &&
|
||||
imagesList.length > 0 && (
|
||||
<div className="item-images-container">
|
||||
<div className="item-images-row">
|
||||
{imagesList
|
||||
.filter((imageInstanceId) => imageInstanceId && imageInstanceId.trim() !== '')
|
||||
.slice(0, 6) // 最多显示6张图片
|
||||
.map((imageInstanceId, index) => (
|
||||
<div key={imageInstanceId + index} className="item-image-wrapper">
|
||||
<TimelineImage title={imageInstanceId} imageInstanceId={imageInstanceId} />
|
||||
</div>
|
||||
))}
|
||||
{imagesList.length > 6 && (
|
||||
<div className="more-images-indicator">+{imagesList.length - 6}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Location badge */}
|
||||
{item.location && (
|
||||
<span className="timeline-location-badge">📍 {item.location}</span>
|
||||
)}
|
||||
{item.location && <span className="timeline-location-badge">📍 {item.location}</span>}
|
||||
|
||||
{/* Creator/Updater tags */}
|
||||
<div className="item-tags">
|
||||
|
||||
Reference in New Issue
Block a user