Files
timeline-frontend/src/pages/gallery/index.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

609 lines
18 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/gallery/index.tsx
import { ImageItem } from '@/pages/gallery/typings';
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';
import { Empty, Image, message, Modal, Spin } from 'antd';
import type { UploadFile } from 'antd/es/upload/interface';
import React, { FC, useCallback, useEffect, useMemo, useState } from 'react';
import GalleryTable from './components/GalleryTable';
import GalleryToolbar from './components/GalleryToolbar';
import GridView from './components/GridView';
import ListView from './components/ListView';
import './index.css';
const Gallery: FC = () => {
const [viewMode, setViewMode] = useState<'small' | 'large' | 'list' | 'table'>('small');
const [page, setPage] = useState(1);
const [previewVisible, setPreviewVisible] = useState(false);
const [previewCurrent, setPreviewCurrent] = useState(0);
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 };
// 表格模式使用不同的分页状态
const [tablePagination, setTablePagination] = useState(initPagination);
// 非表格模式的数据请求
const { data, loading, loadingMore, refresh } = useRequest(
() => {
return getImagesList({ current: page, pageSize });
},
{
loadMore: true,
refreshDeps: [page],
ready: viewMode !== 'table', // 非表格模式才启用
},
);
// 表格模式的数据请求
const {
data: tableData,
loading: tableLoading,
run: fetchTableData,
} = useRequest(async (params) => await getImagesList(params), {
manual: true,
});
// 当视图模式改变时重置分页
const handleViewModeChange = (e: RadioChangeEvent) => {
const newViewMode = e.target.value;
setViewMode(newViewMode);
// 重置分页
if (newViewMode === 'table') {
setTablePagination(initPagination);
fetchTableData(initPagination);
} else {
setPage(1);
}
// 清除选择
setSelectedRowKeys([]);
setBatchMode(false);
};
// 处理表格分页变化
const handleTableChange = useCallback(
(pagination: any) => {
const { current, pageSize } = pagination;
setTablePagination({ current, pageSize });
fetchTableData({ current: current, pageSize });
},
[fetchTableData],
);
// 当表格分页变化时重新获取数据
useEffect(() => {
if (viewMode === 'table') {
fetchTableData({
current: tablePagination.current,
pageSize: tablePagination.pageSize,
});
}
}, [viewMode, tablePagination, fetchTableData]);
const imageList = useMemo((): ImageItem[] => {
if (viewMode === 'table') {
return tableData?.list || [];
}
return data?.list || [];
}, [data, tableData, viewMode]);
const handleScroll = useCallback(
(e: React.UIEvent<HTMLDivElement>) => {
// 表格模式不需要滚动加载
if (viewMode === 'table') return;
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
if (scrollTop + clientHeight >= scrollHeight - 10 && !loading && !loadingMore) {
if (data?.total && imageList.length < data.total) {
setPage((prev) => prev + 1);
}
}
},
[loading, loadingMore, data?.total, imageList.length, viewMode],
);
// 处理图片预览
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(() => {
setPreviewVisible(false);
}, []);
// 切换图片
const handlePreviewChange = useCallback((current: number) => {
setPreviewCurrent(current);
}, []);
// 下载图片
const handleDownload = useCallback(async (instanceId: string, imageName?: string) => {
try {
// 使用项目中已有的fetchImage API它会自动通过请求拦截器添加认证token
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);
}
} catch (error) {
console.error('Failed to download image:', error);
message.error('下载失败,请重试');
}
}, []);
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('只能上传图片或视频文件!');
return;
}
setUploading(true);
try {
const formData = new FormData();
formData.append('image', file as any);
// 调用上传API
await uploadImage(formData);
message.success(`${file.name} 上传成功`);
// 上传成功后刷新列表
if (viewMode === 'table') {
fetchTableData({
current: tablePagination.current,
pageSize: tablePagination.pageSize,
});
} else {
refresh();
}
} catch (error) {
message.error(`${file.name} 上传失败`);
} finally {
setUploading(false);
}
},
[viewMode, tablePagination, fetchTableData, refresh],
);
// 删除图片确认
const handleDelete = useCallback(
(instanceId: string, imageName: string) => {
Modal.confirm({
title: '确认删除',
content: `确定要删除图片 "${imageName}" 吗?此操作不可恢复。`,
okText: '删除',
okType: 'danger',
cancelText: '取消',
onOk: async () => {
try {
await deleteImage({ instanceId });
message.success('删除成功');
// 表格模式也需要刷新数据
if (viewMode === 'table') {
fetchTableData({
current: tablePagination.current,
pageSize: tablePagination.pageSize,
});
} else {
refresh();
}
// 如果在批量选择中,也需要移除
setSelectedRowKeys((prev) => prev.filter((key) => key !== instanceId));
} catch (error) {
message.error('删除失败');
}
},
});
},
[refresh, viewMode, tablePagination, fetchTableData],
);
// 批量删除
const handleBatchDelete = useCallback(() => {
if (selectedRowKeys.length === 0) {
message.warning('请先选择要删除的图片');
return;
}
Modal.confirm({
title: '确认批量删除',
content: `确定要删除选中的 ${selectedRowKeys.length} 张图片吗?此操作不可恢复。`,
okText: '删除',
okType: 'danger',
cancelText: '取消',
onOk: async () => {
try {
const promises = selectedRowKeys.map((id) => deleteImage({ instanceId: id }));
await Promise.all(promises);
message.success(`成功删除 ${selectedRowKeys.length} 张图片`);
setSelectedRowKeys([]);
setBatchMode(false);
// 表格模式也需要刷新数据
if (viewMode === 'table') {
fetchTableData({
current: tablePagination.current,
pageSize: tablePagination.pageSize,
});
} else {
refresh();
}
} catch (error) {
message.error('批量删除失败');
}
},
});
}, [selectedRowKeys, refresh, viewMode, tablePagination, fetchTableData]);
// 批量下载
const handleBatchDownload = useCallback(() => {
if (selectedRowKeys.length === 0) {
message.warning('请先选择要下载的图片');
return;
}
selectedRowKeys.forEach((id, index) => {
// 添加延迟避免浏览器限制
setTimeout(async () => {
const item = imageList.find((img) => img.instanceId === id);
if (item) {
await handleDownload(id, item.imageName);
}
}, index * 100);
});
message.success(`开始下载 ${selectedRowKeys.length} 张图片`);
}, [selectedRowKeys, imageList, handleDownload]);
// 切换选择
const handleSelect = useCallback((instanceId: string, checked: boolean) => {
setSelectedRowKeys((prev) => {
if (checked) {
return [...prev, instanceId];
} else {
return prev.filter((key) => key !== instanceId);
}
});
}, []);
// 全选/取消全选
const handleSelectAll = useCallback(
(checked: boolean) => {
if (checked) {
setSelectedRowKeys(imageList.map((item) => item.instanceId));
} else {
setSelectedRowKeys([]);
}
},
[imageList],
);
// 取消批量操作
const handleCancelBatch = useCallback(() => {
setSelectedRowKeys([]);
setBatchMode(false);
}, []);
// 根据视图模式获取当前数据和加载状态
const getCurrentDataAndLoading = () => {
if (viewMode === 'table') {
return { currentData: tableData, currentLoading: tableLoading };
} else {
return { currentData: data, currentLoading: loading };
}
};
const { currentLoading } = getCurrentDataAndLoading();
// 渲染视图
const renderView = () => {
switch (viewMode) {
case 'table':
return (
<GalleryTable
imageList={imageList}
loading={tableLoading}
pagination={{
current: tablePagination.current,
pageSize: tablePagination.pageSize,
total: tableData?.total,
}}
selectedRowKeys={selectedRowKeys}
onChange={handleTableChange}
onSelectedRowsChange={setSelectedRowKeys}
onPreview={handlePreview}
onDownload={handleDownload}
onDelete={handleDelete}
/>
);
case 'list':
return (
<ListView
imageList={imageList}
batchMode={batchMode}
selectedRowKeys={selectedRowKeys}
onPreview={handlePreview}
onSelect={handleSelect}
onDownload={handleDownload}
onDelete={handleDelete}
loadingMore={loadingMore}
onScroll={handleScroll}
/>
);
default:
return (
<GridView
imageList={imageList}
viewMode={viewMode}
batchMode={batchMode}
selectedRowKeys={selectedRowKeys}
onPreview={handlePreview}
onSelect={handleSelect}
onDownload={handleDownload}
onDelete={handleDelete}
loadingMore={loadingMore}
onScroll={handleScroll}
/>
);
}
};
return (
<div>
<PageContainer
title="我的照片"
extra={
<GalleryToolbar
viewMode={viewMode}
batchMode={batchMode}
selectedCount={selectedRowKeys.length}
onViewModeChange={handleViewModeChange}
onBatchModeToggle={() => setBatchMode(true)}
onCancelBatch={handleCancelBatch}
onBatchDownload={handleBatchDownload}
onBatchDelete={handleBatchDelete}
onUpload={handleUpload}
uploading={uploading}
/>
}
>
{currentLoading &&
((viewMode === 'table' && tablePagination.current === 1) ||
(viewMode !== 'table' && page === 1)) ? (
<div style={{ textAlign: 'center', padding: '50px 0' }}>
<Spin size="large" />
</div>
) : imageList.length === 0 ? (
<Empty description="暂无图片" />
) : (
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={{
visible: previewVisible,
current: previewCurrent,
onVisibleChange: (visible) => setPreviewVisible(visible),
onChange: handlePreviewChange,
}}
>
{imageList.map((item: ImageItem) => (
<Image
key={item.instanceId}
src={
viewMode === 'small'
? `/file/image/${item.instanceId}?Authorization=${getAuthization()}`
: `/file/image-low-res/${item.instanceId}?Authorization=${getAuthization()}`
}
style={{ display: 'none' }}
alt={item.imageName}
/>
))}
</Image.PreviewGroup>
</PageContainer>
</div>
);
};
export default Gallery;