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,6 +1,15 @@
// src/pages/gallery/index.tsx
import { ImageItem } from '@/pages/gallery/typings';
import { deleteImage, getImagesList, uploadImage, fetchImage } from '@/services/file/api';
import {
deleteImage,
fetchImage,
getImagesList,
getUploadUrl,
getVideoUrl,
saveFileMetadata,
uploadImage,
} from '@/services/file/api';
import { getAuthization } from '@/utils/userUtils';
import { PageContainer } from '@ant-design/pro-components';
import { useRequest } from '@umijs/max';
import type { RadioChangeEvent } from 'antd';
@@ -12,7 +21,6 @@ import GalleryToolbar from './components/GalleryToolbar';
import GridView from './components/GridView';
import ListView from './components/ListView';
import './index.css';
import { getAuthization } from '@/utils/userUtils';
const Gallery: FC = () => {
const [viewMode, setViewMode] = useState<'small' | 'large' | 'list' | 'table'>('small');
@@ -22,6 +30,12 @@ const Gallery: FC = () => {
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
const [batchMode, setBatchMode] = useState(false);
const [uploading, setUploading] = useState(false);
const [videoPreviewVisible, setVideoPreviewVisible] = useState(false);
const [currentVideo, setCurrentVideo] = useState<{
videoUrl: string;
thumbnailUrl?: string;
} | null>(null);
const pageSize = 50;
const initPagination = { current: 1, pageSize: 5 };
@@ -110,10 +124,33 @@ const Gallery: FC = () => {
);
// 处理图片预览
const handlePreview = useCallback((index: number) => {
setPreviewCurrent(index);
setPreviewVisible(true);
}, []);
const handlePreview = useCallback(
async (index: number) => {
const item = imageList[index];
if (item.duration || item.thumbnailInstanceId) {
// Check if it's a video
try {
const res = await getVideoUrl(item.instanceId);
if (res.code === 200 && res.data) {
setCurrentVideo({
videoUrl: res.data,
thumbnailUrl: item.thumbnailInstanceId,
});
setVideoPreviewVisible(true);
} else {
message.error('获取视频地址失败');
}
} catch (e) {
console.error(e);
message.error('获取视频地址失败');
}
} else {
setPreviewCurrent(index);
setPreviewVisible(true);
}
},
[imageList],
);
// 关闭预览
const handlePreviewClose = useCallback(() => {
@@ -129,19 +166,19 @@ const Gallery: FC = () => {
const handleDownload = useCallback(async (instanceId: string, imageName?: string) => {
try {
// 使用项目中已有的fetchImage API它会自动通过请求拦截器添加认证token
const {data: response} = await fetchImage(instanceId);
const { data: response } = await fetchImage(instanceId);
if (response) {
// 创建一个临时的URL用于下载
const blob = response;
const url = URL.createObjectURL(blob);
// 创建一个临时的下载链接
const link = document.createElement('a');
link.href = url;
link.download = imageName ?? 'image';
link.click();
// 清理临时URL
URL.revokeObjectURL(url);
}
@@ -151,12 +188,128 @@ const Gallery: FC = () => {
}
}, []);
// 上传图片
const handleVideoUpload = async (file: UploadFile) => {
setUploading(true);
const hide = message.loading('正在处理视频...', 0);
try {
// 1. 获取上传 URL
const fileName = `${Date.now()}-${file.name}`;
const uploadUrlRes = await getUploadUrl(fileName);
if (uploadUrlRes.code !== 200) throw new Error('获取上传链接失败');
const uploadUrl = uploadUrlRes.data;
// 2. 上传视频到 MinIO
await fetch(uploadUrl, {
method: 'PUT',
body: file as any,
});
// 3. 生成缩略图
let thumbnailInstanceId = '';
let duration = 0;
try {
const videoEl = document.createElement('video');
videoEl.preload = 'metadata';
videoEl.src = URL.createObjectURL(file as any);
videoEl.muted = true;
videoEl.playsInline = true;
await new Promise((resolve, reject) => {
videoEl.onloadedmetadata = () => {
// 尝试跳转到第1秒以获取更好的缩略图
videoEl.currentTime = Math.min(1, videoEl.duration);
};
videoEl.onseeked = () => resolve(true);
videoEl.onerror = reject;
});
duration = Math.floor(videoEl.duration);
const canvas = document.createElement('canvas');
canvas.width = videoEl.videoWidth;
canvas.height = videoEl.videoHeight;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.drawImage(videoEl, 0, 0, canvas.width, canvas.height);
const thumbnailBlob = await new Promise<Blob | null>((r) =>
canvas.toBlob(r, 'image/jpeg', 0.7),
);
if (thumbnailBlob) {
const thumbName = `thumb-${Date.now()}.jpg`;
const thumbUrlRes = await getUploadUrl(thumbName);
if (thumbUrlRes.code === 200) {
await fetch(thumbUrlRes.data, { method: 'PUT', body: thumbnailBlob });
const thumbMetaRes = await saveFileMetadata({
imageName: thumbName,
objectKey: thumbName,
size: thumbnailBlob.size,
contentType: 'image/jpeg',
});
if (thumbMetaRes.code === 200) {
thumbnailInstanceId = thumbMetaRes.data;
}
}
}
}
URL.revokeObjectURL(videoEl.src);
} catch (e) {
console.warn('缩略图生成失败', e);
}
// 4. 保存视频元数据
await saveFileMetadata({
imageName: file.name,
objectKey: fileName,
size: file.size,
contentType: file.type || 'video/mp4',
thumbnailInstanceId: thumbnailInstanceId,
duration: duration,
});
message.success('视频上传成功');
// 刷新列表
if (viewMode === 'table') {
fetchTableData({
current: tablePagination.current,
pageSize: tablePagination.pageSize,
});
} else {
refresh();
}
} catch (error) {
console.error(error);
message.error('视频上传失败');
} finally {
hide();
setUploading(false);
}
};
// 上传图片/视频
const handleUpload = useCallback(
async (file: UploadFile) => {
// 检查文件类型
// 优先检查 mime type
let isVideo = file.type?.startsWith('video/');
// 如果 mime type 不明确,检查扩展名
if (!isVideo && file.name) {
const ext = file.name.split('.').pop()?.toLowerCase();
if (ext && ['mp4', 'mov', 'avi', 'mkv', 'webm', 'flv', 'wmv'].includes(ext)) {
isVideo = true;
}
}
if (isVideo) {
await handleVideoUpload(file);
return;
}
if (!file.type?.startsWith('image/')) {
message.error('只能上传图片文件!');
message.error('只能上传图片或视频文件!');
return;
}
@@ -403,6 +556,28 @@ const Gallery: FC = () => {
renderView()
)}
{/* 视频预览 Modal */}
<Modal
open={videoPreviewVisible}
footer={null}
onCancel={() => {
setVideoPreviewVisible(false);
setCurrentVideo(null);
}}
width={800}
destroyOnClose
centered
>
{currentVideo && (
<video
src={currentVideo.videoUrl}
controls
autoPlay
style={{ width: '100%', maxHeight: '80vh' }}
/>
)}
</Modal>
{/* 预览组件 - 使用认证后的图像URL */}
<Image.PreviewGroup
preview={{
@@ -430,4 +605,4 @@ const Gallery: FC = () => {
);
};
export default Gallery;
export default Gallery;