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:
234
src/pages/story/components/BatchOperationToolbar.tsx
Normal file
234
src/pages/story/components/BatchOperationToolbar.tsx
Normal 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;
|
||||
199
src/pages/story/components/CollaboratorModal.tsx
Normal file
199
src/pages/story/components/CollaboratorModal.tsx
Normal 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;
|
||||
288
src/pages/story/components/SortableTimelineGrid.tsx
Normal file
288
src/pages/story/components/SortableTimelineGrid.tsx
Normal 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;
|
||||
159
src/pages/story/components/SortableTimelineGridItem.tsx
Normal file
159
src/pages/story/components/SortableTimelineGridItem.tsx
Normal 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;
|
||||
145
src/pages/story/components/UndoRedoToolbar.tsx
Normal file
145
src/pages/story/components/UndoRedoToolbar.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user