feat: 实现时间线拖拽排序功能及PWA支持
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:
2026-02-24 10:33:10 +08:00
parent 5139817b3c
commit 97a5ad3a00
24 changed files with 3012 additions and 247 deletions

View File

@@ -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>
);
};

View File

@@ -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>

View File

@@ -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
});
// 当视图模式改变时重置分页