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

@@ -0,0 +1,234 @@
/**
* BatchOperationToolbar - 批量操作工具栏组件
*
* 功能描述:
* 提供时间线节点的批量操作功能,包括:
* - 多选模式切换
* - 全选/取消全选
* - 批量删除
* - 批量修改时间
* - 批量移动到其他故事
*
* 设计思路:
* 1. 使用受控模式,由父组件管理选中状态
* 2. 支持移动端适配,工具栏固定在底部
* 3. 操作前进行二次确认,防止误操作
*
* @author Timeline Team
* @date 2024
*/
import { useIsMobile } from '@/hooks/useIsMobile';
import {
DeleteOutlined,
ClockCircleOutlined,
ExportOutlined,
CloseOutlined,
CheckOutlined,
} from '@ant-design/icons';
import { Button, Popconfirm, Space, message, Dropdown, MenuProps, Badge } from 'antd';
import React, { memo, useState, useCallback } from 'react';
/**
* 组件属性接口
* @property selectedCount - 已选中数量
* @property totalCount - 总数量
* @property onSelectAll - 全选回调
* @property onCancel - 取消批量操作回调
* @property onBatchDelete - 批量删除回调
* @property onBatchChangeTime - 批量修改时间回调
* @property onBatchMove - 批量移动回调
* @property loading - 加载状态
*/
interface BatchOperationToolbarProps {
selectedCount: number;
totalCount: number;
onSelectAll: () => void;
onCancel: () => void;
onBatchDelete: () => void;
onBatchChangeTime?: () => void;
onBatchMove?: () => void;
loading?: boolean;
}
/**
* BatchOperationToolbar 组件
* 提供时间线节点的批量操作功能
*/
const BatchOperationToolbar: React.FC<BatchOperationToolbarProps> = memo(({
selectedCount,
totalCount,
onSelectAll,
onCancel,
onBatchDelete,
onBatchChangeTime,
onBatchMove,
loading = false,
}) => {
const isMobile = useIsMobile();
const [deleteConfirmVisible, setDeleteConfirmVisible] = useState(false);
/**
* 处理批量删除
* 显示确认对话框,确认后执行删除
*/
const handleBatchDelete = useCallback(() => {
if (selectedCount === 0) {
message.warning('请先选择要删除的节点');
return;
}
onBatchDelete();
setDeleteConfirmVisible(false);
}, [selectedCount, onBatchDelete]);
/**
* 更多操作菜单项
* 用于移动端或空间受限时展示次要操作
*/
const moreMenuItems: MenuProps['items'] = [
{
key: 'changeTime',
label: '修改时间',
icon: <ClockCircleOutlined />,
onClick: onBatchChangeTime,
disabled: selectedCount === 0 || !onBatchChangeTime,
},
{
key: 'move',
label: '移动到...',
icon: <ExportOutlined />,
onClick: onBatchMove,
disabled: selectedCount === 0 || !onBatchMove,
},
];
// 移动端样式
const mobileStyle: React.CSSProperties = {
position: 'fixed',
bottom: 56, // 底部导航栏高度
left: 0,
right: 0,
padding: '12px 16px',
background: '#fff',
boxShadow: '0 -2px 8px rgba(0, 0, 0, 0.1)',
zIndex: 100,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
};
// 桌面端样式
const desktopStyle: React.CSSProperties = {
padding: '12px 16px',
background: '#f5f5f5',
borderRadius: 8,
marginBottom: 16,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
};
return (
<div style={isMobile ? mobileStyle : desktopStyle}>
{/* 左侧:选择状态 */}
<Space>
<Badge
count={selectedCount}
showZero
style={{ backgroundColor: '#1890ff' }}
/>
<span style={{ color: '#666' }}>
{selectedCount} / {totalCount}
</span>
{!isMobile && (
<Button
type="link"
size="small"
onClick={onSelectAll}
disabled={selectedCount === totalCount}
>
{selectedCount === totalCount ? '已全选' : '全选'}
</Button>
)}
</Space>
{/* 右侧:操作按钮 */}
<Space>
{isMobile ? (
// 移动端:精简按钮
<>
<Button
type="primary"
danger
size="small"
icon={<DeleteOutlined />}
onClick={handleBatchDelete}
disabled={selectedCount === 0}
loading={loading}
>
</Button>
<Dropdown menu={{ items: moreMenuItems }} placement="topRight">
<Button size="small"></Button>
</Dropdown>
<Button
size="small"
icon={<CloseOutlined />}
onClick={onCancel}
>
</Button>
</>
) : (
// 桌面端:完整按钮
<>
<Popconfirm
title="确认批量删除"
description={`确定要删除选中的 ${selectedCount} 个节点吗?此操作不可恢复。`}
onConfirm={handleBatchDelete}
okText="删除"
okType="danger"
cancelText="取消"
open={deleteConfirmVisible}
onOpenChange={setDeleteConfirmVisible}
>
<Button
danger
icon={<DeleteOutlined />}
disabled={selectedCount === 0}
loading={loading}
>
</Button>
</Popconfirm>
{onBatchChangeTime && (
<Button
icon={<ClockCircleOutlined />}
disabled={selectedCount === 0}
onClick={onBatchChangeTime}
>
</Button>
)}
{onBatchMove && (
<Button
icon={<ExportOutlined />}
disabled={selectedCount === 0}
onClick={onBatchMove}
>
...
</Button>
)}
<Button onClick={onCancel}>
</Button>
</>
)}
</Space>
</div>
);
});
BatchOperationToolbar.displayName = 'BatchOperationToolbar';
export default BatchOperationToolbar;

View File

@@ -0,0 +1,199 @@
import { useIsMobile } from '@/hooks/useIsMobile';
import { useRequest } from '@umijs/max';
import { Button, Drawer, Input, List, message, Modal, Popconfirm, Select, Space, Tag } from 'antd';
import React, { useEffect, useState } from 'react';
import { getStoryPermissions, inviteUser, removePermission, updatePermission } from '../service';
interface CollaboratorModalProps {
visible: boolean;
onCancel: () => void;
storyId: string;
}
const PermissionTypeMap: Record<number, string> = {
1: '创建者',
2: '管理员',
3: '编辑者',
4: '仅查看',
};
const InviteStatusMap: Record<number, { text: string; color: string }> = {
0: { text: '待处理', color: 'orange' },
1: { text: '已接受', color: 'green' },
2: { text: '已拒绝', color: 'red' },
};
const CollaboratorModal: React.FC<CollaboratorModalProps> = ({ visible, onCancel, storyId }) => {
const isMobile = useIsMobile();
const [inviteUserId, setInviteUserId] = useState('');
const [invitePermissionType, setInvitePermissionType] = useState(4);
const {
data: permissions,
run: fetchPermissions,
loading,
} = useRequest(() => getStoryPermissions(storyId), {
manual: true,
formatResult: (res: any) => res?.data || [],
});
useEffect(() => {
if (visible && storyId) {
fetchPermissions();
}
}, [visible, storyId]);
const handleInvite = async () => {
if (!inviteUserId) return;
try {
await inviteUser({
userId: inviteUserId,
storyInstanceId: storyId,
permissionType: invitePermissionType,
});
message.success('邀请已发送');
setInviteUserId('');
fetchPermissions();
} catch (error) {
message.error('邀请失败');
}
};
const handleUpdatePermission = async (permissionId: string, type: number) => {
try {
await updatePermission({ permissionId, permissionType: type });
message.success('权限已更新');
fetchPermissions();
} catch (error) {
message.error('更新失败');
}
};
const handleRemove = async (permissionId: string) => {
try {
await removePermission(permissionId);
message.success('已移除');
fetchPermissions();
} catch (error) {
message.error('移除失败');
}
};
const content = (
<>
<div
style={{
marginBottom: 16,
display: 'flex',
flexDirection: isMobile ? 'column' : 'row',
gap: 8,
}}
>
<Input
placeholder="输入用户ID邀请"
value={inviteUserId}
onChange={(e) => setInviteUserId(e.target.value)}
style={{ flex: 1 }}
/>
<div style={{ display: 'flex', gap: 8 }}>
<Select
value={invitePermissionType}
onChange={setInvitePermissionType}
options={[
{ label: '管理员', value: 2 },
{ label: '编辑者', value: 3 },
{ label: '仅查看', value: 4 },
]}
style={{ width: isMobile ? '100%' : 100, flex: isMobile ? 1 : undefined }}
/>
<Button
type="primary"
onClick={handleInvite}
style={{ flex: isMobile ? '0 0 auto' : undefined }}
>
</Button>
</div>
</div>
<List
loading={loading}
dataSource={permissions}
renderItem={(item: any) => (
<List.Item
actions={[
item.permissionType !== 1 && (
<div
style={{
display: 'flex',
flexDirection: isMobile ? 'column' : 'row',
gap: 4,
alignItems: 'flex-end',
}}
>
<Select
defaultValue={item.permissionType}
onChange={(val) => handleUpdatePermission(item.permissionId, val)}
style={{ width: 100 }}
size="small"
options={[
{ label: '管理员', value: 2 },
{ label: '编辑者', value: 3 },
{ label: '仅查看', value: 4 },
]}
disabled={item.inviteStatus === 2} // Rejected
/>
<Popconfirm title="确定移除?" onConfirm={() => handleRemove(item.permissionId)}>
<Button type="link" danger size="small">
</Button>
</Popconfirm>
</div>
),
]}
>
<List.Item.Meta
title={
<div style={{ display: 'flex', flexDirection: 'column' }}>
<span>ID: {item.userId}</span>
</div>
}
description={
<Space size={4} wrap>
<Tag>{PermissionTypeMap[item.permissionType]}</Tag>
{item.inviteStatus !== undefined && InviteStatusMap[item.inviteStatus] && (
<Tag color={InviteStatusMap[item.inviteStatus].color}>
{InviteStatusMap[item.inviteStatus].text}
</Tag>
)}
</Space>
}
/>
</List.Item>
)}
/>
</>
);
if (isMobile) {
return (
<Drawer
title="协作成员管理"
open={visible}
onClose={onCancel}
placement="bottom"
height="80vh"
>
{content}
</Drawer>
);
}
return (
<Modal title="协作成员管理" open={visible} onCancel={onCancel} footer={null} width={600}>
{content}
</Modal>
);
};
export default CollaboratorModal;

View File

@@ -0,0 +1,288 @@
/**
* SortableTimelineGrid - 可拖拽排序的时间线网格组件
*
* 功能描述:
* 该组件基于 dnd-kit 库实现时间线节点的拖拽排序功能。
* 支持在同一日期分组内拖拽调整节点顺序,并实时保存排序结果。
*
* 设计思路:
* 1. 使用 DndContext 作为拖拽上下文容器
* 2. 使用 SortableContext 管理可排序项
* 3. 每个时间线节点包装为 SortableItem 实现独立拖拽
* 4. 拖拽结束后调用后端 API 更新排序
*
* @author Timeline Team
* @date 2024
*/
import { useIsMobile } from '@/hooks/useIsMobile';
import { StoryItem } from '@/pages/story/data';
import { updateStoryItemOrder } from '@/pages/story/service';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
DragStartEvent,
DragOverlay,
MeasuringStrategy,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
rectSortingStrategy,
} from '@dnd-kit/sortable';
import { message, Spin } from 'antd';
import React, { useState, useCallback, useMemo, memo } from 'react';
import TimelineGridItem from './TimelineGridItem';
import SortableTimelineGridItem from './SortableTimelineGridItem';
/**
* 组件属性接口
* @property items - 时间线节点数组
* @property dateKey - 日期分组键(用于标识分组)
* @property sortValue - 排序值(用于分组排序)
* @property handleOption - 操作回调函数
* @property onOpenDetail - 打开详情回调
* @property refresh - 刷新数据回调
* @property disableEdit - 是否禁用编辑
* @property onOrderChange - 排序变化回调(可选)
*/
interface SortableTimelineGridProps {
items: StoryItem[];
dateKey: string;
sortValue: number;
handleOption: (item: StoryItem, option: 'add' | 'edit' | 'addSubItem' | 'editSubItem') => void;
onOpenDetail: (item: StoryItem) => void;
refresh: () => void;
disableEdit?: boolean;
onOrderChange?: (dateKey: string, newItems: StoryItem[]) => void;
}
/**
* SortableTimelineGrid 组件
* 实现时间线节点的拖拽排序功能
*/
const SortableTimelineGrid: React.FC<SortableTimelineGridProps> = memo(({
items,
dateKey,
sortValue,
handleOption,
onOpenDetail,
refresh,
disableEdit = false,
onOrderChange,
}) => {
const isMobile = useIsMobile();
// 当前拖拽中的节点ID
const [activeId, setActiveId] = useState<string | null>(null);
// 本地排序状态用于即时UI更新
const [localItems, setLocalItems] = useState<StoryItem[]>(items);
// 保存中状态
const [saving, setSaving] = useState(false);
// 同步外部 items 变化
React.useEffect(() => {
setLocalItems(items);
}, [items]);
/**
* 配置拖拽传感器
* - PointerSensor: 鼠标/触摸拖拽
* - KeyboardSensor: 键盘拖拽(无障碍支持)
*
* 注意:需要设置 activationConstraint 防止误触发拖拽
*/
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8, // 移动8px后才开始拖拽避免误触
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
/**
* 获取当前拖拽中的节点
*/
const activeItem = useMemo(() => {
if (!activeId) return null;
return localItems.find(item => item.instanceId === activeId);
}, [activeId, localItems]);
/**
* 拖拽开始处理
* 记录当前拖拽节点ID用于显示 DragOverlay
*/
const handleDragStart = useCallback((event: DragStartEvent) => {
const { active } = event;
setActiveId(active.id as string);
}, []);
/**
* 拖拽结束处理
* 核心逻辑:
* 1. 计算新的排序顺序
* 2. 更新本地状态即时UI反馈
* 3. 调用后端API保存排序
* 4. 通知父组件排序变化
*/
const handleDragEnd = useCallback(async (event: DragEndEvent) => {
const { active, over } = event;
// 清除拖拽状态
setActiveId(null);
// 如果没有放置目标,或放置在原位置,不做处理
if (!over || active.id === over.id) {
return;
}
// 查找拖拽节点在数组中的位置
const oldIndex = localItems.findIndex(item => item.instanceId === active.id);
const newIndex = localItems.findIndex(item => item.instanceId === over.id);
// 如果位置没变,不做处理
if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) {
return;
}
// 计算新的排序数组
const newItems = arrayMove(localItems, oldIndex, newIndex);
// 即时更新本地状态(提升用户体验)
setLocalItems(newItems);
// 调用后端API保存排序
try {
setSaving(true);
// 构建排序数据节点ID -> 新排序值
const orderData = newItems.map((item, index) => ({
instanceId: item.instanceId,
sortOrder: index,
}));
const response = await updateStoryItemOrder(orderData);
if (response.code === 200) {
message.success('排序已保存');
// 通知父组件
onOrderChange?.(dateKey, newItems);
} else {
// 保存失败,恢复原顺序
setLocalItems(items);
message.error('排序保存失败');
}
} catch (error) {
// 异常处理,恢复原顺序
console.error('保存排序失败:', error);
setLocalItems(items);
message.error('排序保存失败,请重试');
} finally {
setSaving(false);
}
}, [localItems, items, dateKey, onOrderChange]);
/**
* 拖拽取消处理
*/
const handleDragCancel = useCallback(() => {
setActiveId(null);
}, []);
// 生成可排序项的ID列表
const itemIds = useMemo(() =>
localItems.map(item => item.instanceId),
[localItems]
);
return (
<div className="timeline-grid-wrapper" style={{ position: 'relative' }}>
{/* 保存中遮罩 */}
{saving && (
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(255, 255, 255, 0.7)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 100,
borderRadius: 8,
}}
>
<Spin tip="保存排序中..." />
</div>
)}
{/* 拖拽上下文容器 */}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
measuring={{
droppable: {
strategy: MeasuringStrategy.Always,
},
}}
>
{/* 可排序上下文 - 使用矩形排序策略 */}
<SortableContext items={itemIds} strategy={rectSortingStrategy}>
{localItems.map((item) => (
<SortableTimelineGridItem
key={item.instanceId}
item={item}
disabled={disableEdit || isMobile}
handleOption={handleOption}
onOpenDetail={onOpenDetail}
refresh={refresh}
disableEdit={disableEdit}
/>
))}
</SortableContext>
{/* 拖拽覆盖层 - 显示拖拽中的节点预览 */}
<DragOverlay adjustScale style={{ transformOrigin: '0 0' }}>
{activeItem ? (
<div
style={{
opacity: 0.9,
transform: 'scale(1.02)',
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.2)',
borderRadius: 8,
}}
>
<TimelineGridItem
item={activeItem}
handleOption={handleOption}
onOpenDetail={onOpenDetail}
refresh={refresh}
disableEdit={true}
/>
</div>
) : null}
</DragOverlay>
</DndContext>
</div>
);
});
SortableTimelineGrid.displayName = 'SortableTimelineGrid';
export default SortableTimelineGrid;

View File

@@ -0,0 +1,159 @@
/**
* SortableTimelineGridItem - 可排序的时间线网格项组件
*
* 功能描述:
* 该组件包装 TimelineGridItem为其添加拖拽排序能力。
* 使用 dnd-kit 的 useSortable Hook 实现拖拽功能。
*
* 设计思路:
* 1. 使用 useSortable Hook 获取拖拽属性
* 2. 通过 CSS transform 实现拖拽动画
* 3. 拖拽时显示占位符和视觉反馈
* 4. 支持禁用拖拽(移动端默认禁用)
*
* @author Timeline Team
* @date 2024
*/
import { StoryItem } from '@/pages/story/data';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { DragOutlined } from '@ant-design/icons';
import React, { memo, CSSProperties } from 'react';
import TimelineGridItem from './TimelineGridItem';
/**
* 组件属性接口
* @property item - 时间线节点数据
* @property disabled - 是否禁用拖拽
* @property handleOption - 操作回调
* @property onOpenDetail - 打开详情回调
* @property refresh - 刷新数据回调
* @property disableEdit - 是否禁用编辑
*/
interface SortableTimelineGridItemProps {
item: StoryItem;
disabled?: boolean;
handleOption: (item: StoryItem, option: 'add' | 'edit' | 'addSubItem' | 'editSubItem') => void;
onOpenDetail: (item: StoryItem) => void;
refresh: () => void;
disableEdit?: boolean;
}
/**
* SortableTimelineGridItem 组件
* 为时间线节点添加拖拽排序能力
*/
const SortableTimelineGridItem: React.FC<SortableTimelineGridItemProps> = memo(({
item,
disabled = false,
handleOption,
onOpenDetail,
refresh,
disableEdit = false,
}) => {
/**
* useSortable Hook 核心功能:
* - attributes: 可访问性属性(如 tabindex
* - listeners: 拖拽事件监听器
* - setNodeRef: 设置 DOM 节点引用
* - transform: CSS transform 值
* - transition: CSS transition 值
* - isDragging: 是否正在拖拽
*/
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: item.instanceId,
disabled: disabled,
data: {
type: 'timeline-item',
item,
},
});
/**
* 构建拖拽样式
* - transform: 应用拖拽位移和缩放
* - transition: 平滑过渡动画
* - opacity: 拖拽时降低透明度
* - zIndex: 拖拽时提升层级
*/
const style: CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
zIndex: isDragging ? 1000 : 'auto',
position: 'relative' as const,
};
return (
<div
ref={setNodeRef}
style={style}
className="sortable-timeline-item-wrapper"
>
{/* 拖拽手柄 - 仅在非禁用状态显示 */}
{!disabled && !disableEdit && (
<div
className="drag-handle"
{...attributes}
{...listeners}
style={{
position: 'absolute',
top: 8,
left: 8,
zIndex: 20,
cursor: 'grab',
padding: '4px 8px',
background: 'rgba(0, 0, 0, 0.6)',
borderRadius: 4,
opacity: 0,
transition: 'opacity 0.2s ease',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
onClick={(e) => e.stopPropagation()}
>
<DragOutlined style={{ color: '#fff', fontSize: 14 }} />
</div>
)}
{/* 原有组件 */}
<TimelineGridItem
item={item}
handleOption={handleOption}
onOpenDetail={onOpenDetail}
refresh={refresh}
disableEdit={disableEdit}
/>
{/* 拖拽时的视觉反馈 */}
{isDragging && (
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
border: '2px dashed #1890ff',
borderRadius: 8,
pointerEvents: 'none',
background: 'rgba(24, 144, 255, 0.1)',
}}
/>
)}
</div>
);
});
SortableTimelineGridItem.displayName = 'SortableTimelineGridItem';
export default SortableTimelineGridItem;

View File

@@ -0,0 +1,145 @@
/**
* UndoRedoToolbar - 撤销/重做工具栏组件
*
* 功能描述:
* 提供撤销和重做操作的工具栏,支持按钮点击和键盘快捷键。
*
* 功能特性:
* - 撤销/重做按钮,根据状态自动禁用
* - 显示历史记录数量
* - 支持移动端适配
* - 支持历史记录面板(可选)
*
* @author Timeline Team
* @date 2024
*/
import { UndoOutlined, RedoOutlined, HistoryOutlined } from '@ant-design/icons';
import { Button, Space, Tooltip, Dropdown, MenuProps, Badge } from 'antd';
import React, { memo } from 'react';
/**
* 组件属性接口
* @property canUndo - 是否可撤销
* @property canRedo - 是否可重做
* @property historyLength - 历史记录数量
* @property futureLength - 重做记录数量
* @property onUndo - 撤销回调
* @property onRedo - 重做回调
* @property getHistoryList - 获取历史记录列表
* @property compact - 是否紧凑模式(移动端)
*/
interface UndoRedoToolbarProps {
canUndo: boolean;
canRedo: boolean;
historyLength: number;
futureLength: number;
onUndo: () => void;
onRedo: () => void;
getHistoryList?: () => Array<{ description: string; timestamp: number }>;
compact?: boolean;
}
/**
* UndoRedoToolbar 组件
* 提供撤销/重做操作界面
*/
const UndoRedoToolbar: React.FC<UndoRedoToolbarProps> = memo(({
canUndo,
canRedo,
historyLength,
futureLength,
onUndo,
onRedo,
getHistoryList,
compact = false,
}) => {
/**
* 历史记录菜单项
* 用于展示最近的操作历史
*/
const historyMenuItems: MenuProps['items'] = getHistoryList
? getHistoryList()
.slice(-10)
.reverse()
.map((item, index) => ({
key: index,
label: (
<div style={{ display: 'flex', justifyContent: 'space-between', minWidth: 150 }}>
<span>{item.description}</span>
<span style={{ color: '#999', fontSize: 12 }}>
{new Date(item.timestamp).toLocaleTimeString()}
</span>
</div>
),
}))
: [];
// 紧凑模式(移动端)
if (compact) {
return (
<Space size={4}>
<Tooltip title={`撤销 (Ctrl+Z)${historyLength > 0 ? ` - ${historyLength}步可用` : ''}`}>
<Button
type="text"
size="small"
icon={<UndoOutlined />}
onClick={onUndo}
disabled={!canUndo}
/>
</Tooltip>
<Tooltip title={`重做 (Ctrl+Y)${futureLength > 0 ? ` - ${futureLength}步可用` : ''}`}>
<Button
type="text"
size="small"
icon={<RedoOutlined />}
onClick={onRedo}
disabled={!canRedo}
/>
</Tooltip>
</Space>
);
}
// 完整模式(桌面端)
return (
<Space size={8}>
<Tooltip title={`撤销 (Ctrl+Z)`}>
<Badge count={historyLength} size="small" offset={[-5, 5]}>
<Button
icon={<UndoOutlined />}
onClick={onUndo}
disabled={!canUndo}
>
</Button>
</Badge>
</Tooltip>
<Tooltip title={`重做 (Ctrl+Y)`}>
<Badge count={futureLength} size="small" offset={[-5, 5]}>
<Button
icon={<RedoOutlined />}
onClick={onRedo}
disabled={!canRedo}
>
</Button>
</Badge>
</Tooltip>
{/* 历史记录下拉菜单 */}
{getHistoryList && historyMenuItems.length > 0 && (
<Dropdown menu={{ items: historyMenuItems }} placement="bottomRight">
<Button icon={<HistoryOutlined />}>
</Button>
</Dropdown>
)}
</Space>
);
});
UndoRedoToolbar.displayName = 'UndoRedoToolbar';
export default UndoRedoToolbar;