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

@@ -1,32 +1,19 @@
// src/pages/story/detail.tsx
import { useIsMobile } from '@/hooks/useIsMobile';
import AddTimeLineItemModal from '@/pages/story/components/AddTimeLineItemModal';
import TimelineGridItem from '@/pages/story/components/TimelineGridItem';
import SortableTimelineGrid from '@/pages/story/components/SortableTimelineGrid';
import TimelineItemDrawer from '@/pages/story/components/TimelineItemDrawer';
import { StoryItem, StoryItemTimeQueryParams } from '@/pages/story/data';
import { queryStoryDetail, queryStoryItem, removeStoryItem } from '@/pages/story/service';
import { judgePermission } from '@/pages/story/utils/utils';
import { PlusOutlined, SyncOutlined } from '@ant-design/icons';
import { MoreOutlined, PlusOutlined, SyncOutlined, TeamOutlined } from '@ant-design/icons';
import { PageContainer } from '@ant-design/pro-components';
import { history, useParams, useRequest } from '@umijs/max';
import { Button, Empty, FloatButton, message, Spin } from 'antd';
import { Button, Dropdown, Empty, FloatButton, MenuProps, message, Space, Spin } from 'antd';
import { PullToRefresh } from 'antd-mobile';
import { useCallback, useEffect, useRef, useState } from 'react';
import './detail.css';
// 格式化时间数组为易读格式
const formatTimeArray = (time: string | number[] | undefined): string => {
if (!time) return '';
// 如果是数组格式 [2025, 12, 23, 8, 55, 39]
if (Array.isArray(time)) {
const [year, month, day, hour, minute, second] = time;
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')} ${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}:${String(second).padStart(2, '0')}`;
}
// 如果已经是字符串格式,直接返回
return String(time);
};
import CollaboratorModal from './components/CollaboratorModal';
const Index = () => {
const isMobile = useIsMobile();
@@ -42,6 +29,7 @@ const Index = () => {
'add' | 'edit' | 'addSubItem' | 'editSubItem'
>();
const [openDetailDrawer, setOpenDetailDrawer] = useState(false);
const [openCollaboratorModal, setOpenCollaboratorModal] = useState(false);
const [detailItem, setDetailItem] = useState<StoryItem>();
const [pagination, setPagination] = useState({ current: 1, pageSize: 30 });
const [isRefreshing, setIsRefreshing] = useState(false); // 是否正在刷新最新数据
@@ -264,13 +252,45 @@ const Index = () => {
const groupedItems = groupItemsByDate(items);
return (
<PageContainer
onBack={() => history.push('/story')}
title={
queryDetailLoading ? '加载中' : `${detail?.title} ${`${detail?.itemCount ?? 0}个时刻`}`
const getExtraContent = () => {
if (isMobile) {
const menuItems: MenuProps['items'] = [
{
key: 'refresh',
label: '刷新',
icon: <SyncOutlined />,
onClick: () => {
setItems([]);
setPagination({ current: 1, pageSize: 30 });
setLoadDirection('refresh');
run({ current: 1 });
},
},
];
if (judgePermission(detail?.permissionType ?? null, 'auth')) {
menuItems.unshift({
key: 'collaborators',
label: '协作成员',
icon: <TeamOutlined />,
onClick: () => setOpenCollaboratorModal(true),
});
}
extra={
return (
<Dropdown menu={{ items: menuItems }} placement="bottomRight">
<Button icon={<MoreOutlined />} type="text" />
</Dropdown>
);
}
return (
<Space>
{judgePermission(detail?.permissionType ?? null, 'auth') && (
<Button icon={<TeamOutlined />} onClick={() => setOpenCollaboratorModal(true)}>
</Button>
)}
<Button
icon={<SyncOutlined />}
onClick={() => {
@@ -283,7 +303,17 @@ const Index = () => {
>
</Button>
</Space>
);
};
return (
<PageContainer
onBack={() => history.push('/story')}
title={
queryDetailLoading ? '加载中' : `${detail?.title} ${`${detail?.itemCount ?? 0}个时刻`}`
}
extra={getExtraContent()}
>
<div
className="timeline"
@@ -305,47 +335,48 @@ const Index = () => {
</div>
)}
{Object.values(groupedItems)
.sort((a, b) => b.sortValue - a.sortValue) // 按日期降序排列(最新的在前)
.map(({ dateKey, items: dateItems }) => (
.sort((a, b) => b.sortValue - a.sortValue)
.map(({ dateKey, items: dateItems, sortValue }) => (
<div key={dateKey}>
<h2 className="timeline-section-header">{dateKey}</h2>
<div className="timeline-grid-wrapper">
{dateItems.map((item, index) => {
// 调试确保每个item都有有效的数据
if (!item || (!item.id && !item.instanceId)) {
console.warn('发现无效的item:', item, 'at index:', index);
return null; // 不渲染无效的item
}
return (
<TimelineGridItem
key={item.id ?? item.instanceId}
item={item}
handleOption={(
item: StoryItem,
option: 'add' | 'edit' | 'addSubItem' | 'editSubItem',
) => {
setCurrentItem(item);
setCurrentOption(option);
setOpenAddItemModal(true);
}}
onOpenDetail={(item: StoryItem) => {
setDetailItem(item);
setOpenDetailDrawer(true);
}}
disableEdit={!judgePermission(detail?.permissionType ?? null, 'edit')}
refresh={() => {
setPagination((prev) => ({ ...prev, current: 1 }));
hasShownNoMoreOldRef.current = false;
hasShownNoMoreNewRef.current = false;
setLoadDirection('refresh');
run({ current: 1 });
queryDetail();
}}
/>
);
})}
</div>
<SortableTimelineGrid
items={dateItems}
dateKey={dateKey}
sortValue={sortValue}
handleOption={(
item: StoryItem,
option: 'add' | 'edit' | 'addSubItem' | 'editSubItem',
) => {
setCurrentItem(item);
setCurrentOption(option);
setOpenAddItemModal(true);
}}
onOpenDetail={(item: StoryItem) => {
setDetailItem(item);
setOpenDetailDrawer(true);
}}
disableEdit={!judgePermission(detail?.permissionType ?? null, 'edit')}
refresh={() => {
setPagination((prev) => ({ ...prev, current: 1 }));
hasShownNoMoreOldRef.current = false;
hasShownNoMoreNewRef.current = false;
setLoadDirection('refresh');
run({ current: 1 });
queryDetail();
}}
onOrderChange={(changedDateKey, newItems) => {
setItems((prev) => {
const updated = [...prev];
const startIdx = updated.findIndex(
(item) => item.storyItemTime === newItems[0]?.storyItemTime
);
if (startIdx !== -1) {
updated.splice(startIdx, newItems.length, ...newItems);
}
return updated;
});
}}
/>
</div>
))}
{loading && <div className="load-indicator">...</div>}
@@ -498,6 +529,11 @@ const Index = () => {
}}
/>
)}
<CollaboratorModal
visible={openCollaboratorModal}
onCancel={() => setOpenCollaboratorModal(false)}
storyId={lineId ?? ''}
/>
</PageContainer>
);
};