feat: 实现时间线拖拽排序功能及PWA支持
Some checks failed
test/timeline-frontend/pipeline/head Something is wrong with the build of this commit
Some checks failed
test/timeline-frontend/pipeline/head Something is wrong with the build of this commit
新增时间线节点的拖拽排序功能,使用dnd-kit库实现可排序网格布局。添加PWA支持,包括Service Worker注册和manifest配置。优化移动端适配,改进批量操作工具栏和撤销/重做功能。 重构用户登录和注册页面,修复登录跳转逻辑。调整画廊视图在不同设备上的显示效果。新增协作成员管理功能,支持批量修改权限。 修复请求错误处理中的跳转逻辑问题,避免重复跳转登录页。优化样式表,增强时间线卡片和图片展示的响应式布局。 新增多个API接口支持批量操作,包括排序、删除和时间修改。引入useBatchSelection和useHistory自定义Hook管理状态。添加UndoRedoToolbar组件提供撤销/重做功能。 实现Service Worker离线缓存策略,支持静态资源和API请求的缓存。新增PWA工具函数处理安装提示和更新检测。优化移动端交互,调整组件布局和操作按钮。
This commit is contained in:
@@ -4,12 +4,14 @@ import {
|
||||
DeleteOutlined,
|
||||
DownloadOutlined,
|
||||
SmallDashOutlined,
|
||||
UploadOutlined
|
||||
UploadOutlined,
|
||||
MoreOutlined
|
||||
} from '@ant-design/icons';
|
||||
import type { RadioChangeEvent } from 'antd';
|
||||
import { Button, Radio, Space, Upload } from 'antd';
|
||||
import { Button, Radio, Space, Upload, Dropdown, Menu } from 'antd';
|
||||
import { FC } from 'react';
|
||||
import type { UploadFile } from 'antd/es/upload/interface';
|
||||
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||
|
||||
interface GalleryToolbarProps {
|
||||
viewMode: 'small' | 'large' | 'list' | 'table';
|
||||
@@ -36,6 +38,8 @@ const GalleryToolbar: FC<GalleryToolbarProps> = ({
|
||||
onUpload,
|
||||
uploading
|
||||
}) => {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const beforeUpload = (file: UploadFile) => {
|
||||
// 允许上传图片和视频
|
||||
const isImageOrVideo = file.type?.startsWith('image/') || file.type?.startsWith('video/');
|
||||
@@ -46,26 +50,41 @@ const GalleryToolbar: FC<GalleryToolbarProps> = ({
|
||||
return true;
|
||||
};
|
||||
|
||||
const mobileMenu = (
|
||||
<Menu>
|
||||
<Menu.Item key="batch" onClick={onBatchModeToggle}>
|
||||
批量操作
|
||||
</Menu.Item>
|
||||
<Menu.SubMenu key="view" title="视图切换">
|
||||
<Menu.Item key="small" onClick={() => onViewModeChange({ target: { value: 'small' } } as any)}>小图</Menu.Item>
|
||||
<Menu.Item key="large" onClick={() => onViewModeChange({ target: { value: 'large' } } as any)}>大图</Menu.Item>
|
||||
<Menu.Item key="list" onClick={() => onViewModeChange({ target: { value: 'list' } } as any)}>列表</Menu.Item>
|
||||
</Menu.SubMenu>
|
||||
</Menu>
|
||||
);
|
||||
|
||||
return (
|
||||
<Space>
|
||||
<Space wrap={isMobile}>
|
||||
{batchMode ? (
|
||||
<>
|
||||
<Button onClick={onCancelBatch}>取消</Button>
|
||||
<Button onClick={onCancelBatch} size={isMobile ? "small" : "middle"}>取消</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={onBatchDownload}
|
||||
disabled={selectedCount === 0}
|
||||
size={isMobile ? "small" : "middle"}
|
||||
>
|
||||
下载({selectedCount})
|
||||
{isMobile ? `下载(${selectedCount})` : `下载(${selectedCount})`}
|
||||
</Button>
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={onBatchDelete}
|
||||
disabled={selectedCount === 0}
|
||||
size={isMobile ? "small" : "middle"}
|
||||
>
|
||||
删除({selectedCount})
|
||||
{isMobile ? `删除(${selectedCount})` : `删除(${selectedCount})`}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
@@ -80,30 +99,40 @@ const GalleryToolbar: FC<GalleryToolbarProps> = ({
|
||||
<Button
|
||||
icon={<UploadOutlined />}
|
||||
loading={uploading}
|
||||
type="primary"
|
||||
>
|
||||
上传
|
||||
</Button>
|
||||
</Upload>
|
||||
<Button onClick={onBatchModeToggle}>批量操作</Button>
|
||||
|
||||
{isMobile ? (
|
||||
<Dropdown overlay={mobileMenu} trigger={['click']}>
|
||||
<Button icon={<MoreOutlined />} />
|
||||
</Dropdown>
|
||||
) : (
|
||||
<>
|
||||
<Button onClick={onBatchModeToggle}>批量操作</Button>
|
||||
<Radio.Group
|
||||
value={viewMode}
|
||||
onChange={onViewModeChange}
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
>
|
||||
<Radio.Button value="small">
|
||||
<SmallDashOutlined /> 小图
|
||||
</Radio.Button>
|
||||
<Radio.Button value="large">
|
||||
<BorderOutlined /> 大图
|
||||
</Radio.Button>
|
||||
<Radio.Button value="list">
|
||||
<BarsOutlined /> 列表
|
||||
</Radio.Button>
|
||||
<Radio.Button value="table">表格</Radio.Button>
|
||||
</Radio.Group>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Radio.Group
|
||||
value={viewMode}
|
||||
onChange={onViewModeChange}
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
>
|
||||
<Radio.Button value="small">
|
||||
<SmallDashOutlined /> 小图
|
||||
</Radio.Button>
|
||||
<Radio.Button value="large">
|
||||
<BorderOutlined /> 大图
|
||||
</Radio.Button>
|
||||
<Radio.Button value="list">
|
||||
<BarsOutlined /> 列表
|
||||
</Radio.Button>
|
||||
<Radio.Button value="table">表格</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
PlayCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Button, Checkbox, Dropdown, Menu, Spin } from 'antd';
|
||||
import React, { FC, useCallback } from 'react';
|
||||
import React, { FC, useCallback, useEffect, useState } from 'react';
|
||||
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||
import '../index.css';
|
||||
|
||||
interface GridViewProps {
|
||||
@@ -38,7 +39,29 @@ const GridView: FC<GridViewProps> = ({
|
||||
loadingMore,
|
||||
onScroll,
|
||||
}) => {
|
||||
const isMobile = useIsMobile();
|
||||
const [windowWidth, setWindowWidth] = useState(window.innerWidth);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => setWindowWidth(window.innerWidth);
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
const getImageSize = useCallback(() => {
|
||||
if (isMobile) {
|
||||
// Mobile: 3 columns for small, 2 columns for large
|
||||
const padding = 16; // page padding
|
||||
const gap = 8; // grid gap
|
||||
if (viewMode === 'large') {
|
||||
const size = (windowWidth - padding * 2 - gap) / 2;
|
||||
return { width: size, height: size };
|
||||
}
|
||||
// default small
|
||||
const size = (windowWidth - padding * 2 - gap * 2) / 3;
|
||||
return { width: size, height: size };
|
||||
}
|
||||
|
||||
switch (viewMode) {
|
||||
case 'small':
|
||||
return { width: 150, height: 150 };
|
||||
@@ -47,7 +70,7 @@ const GridView: FC<GridViewProps> = ({
|
||||
default:
|
||||
return { width: 150, height: 150 };
|
||||
}
|
||||
}, [viewMode]);
|
||||
}, [viewMode, isMobile, windowWidth]);
|
||||
|
||||
const imageSize = getImageSize();
|
||||
|
||||
@@ -103,9 +126,19 @@ const GridView: FC<GridViewProps> = ({
|
||||
<div
|
||||
className={viewMode === 'small' ? 'small-grid-view' : 'large-grid-view'}
|
||||
onScroll={onScroll}
|
||||
style={isMobile ? {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: viewMode === 'large' ? 'repeat(2, 1fr)' : 'repeat(3, 1fr)',
|
||||
gap: '8px',
|
||||
paddingBottom: '20px'
|
||||
} : {}}
|
||||
>
|
||||
{imageList.map((item: ImageItem, index: number) => (
|
||||
<div key={item.instanceId} className="image-card">
|
||||
<div
|
||||
key={item.instanceId}
|
||||
className="image-card"
|
||||
style={isMobile ? { width: '100%', margin: 0 } : {}}
|
||||
>
|
||||
{batchMode && (
|
||||
<Checkbox
|
||||
className="image-checkbox"
|
||||
@@ -116,7 +149,7 @@ const GridView: FC<GridViewProps> = ({
|
||||
<div
|
||||
className="image-wrapper"
|
||||
style={{
|
||||
width: imageSize.width,
|
||||
width: isMobile ? '100%' : imageSize.width,
|
||||
height: imageSize.height,
|
||||
backgroundImage: `url(${getImageUrl(item, false)})`,
|
||||
backgroundSize: 'cover',
|
||||
@@ -130,7 +163,7 @@ const GridView: FC<GridViewProps> = ({
|
||||
onClick={() => !batchMode && onPreview(index)}
|
||||
>
|
||||
{(item.duration || item.thumbnailInstanceId) && (
|
||||
<PlayCircleOutlined style={{ fontSize: '32px', color: 'rgba(255,255,255,0.8)' }} />
|
||||
<PlayCircleOutlined style={{ fontSize: isMobile ? '24px' : '32px', color: 'rgba(255,255,255,0.8)' }} />
|
||||
)}
|
||||
{item.duration && (
|
||||
<span
|
||||
@@ -154,7 +187,7 @@ const GridView: FC<GridViewProps> = ({
|
||||
{item.imageName}
|
||||
</div>
|
||||
<Dropdown overlay={getImageMenu(item)} trigger={['click']}>
|
||||
<Button type="text" icon={<MoreOutlined />} />
|
||||
<Button type="text" icon={<MoreOutlined />} size={isMobile ? "small" : "middle"} />
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -51,6 +51,7 @@ const Gallery: FC = () => {
|
||||
loadMore: true,
|
||||
refreshDeps: [page],
|
||||
ready: viewMode !== 'table', // 非表格模式才启用
|
||||
pollingInterval: 5000, // Sync gallery updates
|
||||
},
|
||||
);
|
||||
|
||||
@@ -61,6 +62,7 @@ const Gallery: FC = () => {
|
||||
run: fetchTableData,
|
||||
} = useRequest(async (params) => await getImagesList(params), {
|
||||
manual: true,
|
||||
pollingInterval: viewMode === 'table' ? 5000 : 0, // Sync when in table mode
|
||||
});
|
||||
|
||||
// 当视图模式改变时重置分页
|
||||
|
||||
Reference in New Issue
Block a user