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,9 +1,9 @@
import { ClusterOutlined, ContactsOutlined, HomeOutlined, PlusOutlined, UserAddOutlined, UserDeleteOutlined, MessageOutlined } from '@ant-design/icons';
import { HomeOutlined, PlusOutlined } from '@ant-design/icons';
import { GridContent } from '@ant-design/pro-components';
import { useRequest } from '@umijs/max';
import { Avatar, Card, Col, Divider, Input, InputRef, Row, Tag, List, Button, Space, Popconfirm, message } from 'antd';
import { Avatar, Card, Col, Divider, Input, InputRef, Row, Tag } from 'antd';
import type { FC } from 'react';
import React, { useRef, useState } from 'react';
import React, { useRef, useState, useEffect } from 'react';
import useStyles from './Center.style';
import type { CurrentUser, tabKeyType, TagType } from './data.d';
import { queryCurrent } from './service';
@@ -11,6 +11,8 @@ import Dynamic from './components/Dynamic';
import Friends from './components/Friends';
import Messages from './components/Message';
import useWebSocket from '@/components/Hooks/useWebSocket';
import { useIsMobile } from '@/hooks/useIsMobile';
const operationTabList = [
{
key: 'dynamic',
@@ -128,11 +130,14 @@ const TagList: FC<{
const Center: React.FC = () => {
const { styles } = useStyles();
const isMobile = useIsMobile();
const [tabKey, setTabKey] = useState<tabKeyType>('dynamic');
const { messages: wsMessages, sendChatMessage } = useWebSocket('/user-api/ws');
// 获取用户信息
const { data: currentUser, loading } = useRequest(() => {
const { data: currentUser, loading, refresh } = useRequest(() => {
return queryCurrent();
}, {
pollingInterval: 5000, // Simple short polling for profile sync
});
// 渲染用户信息
@@ -185,7 +190,7 @@ const Center: React.FC = () => {
return (
<GridContent>
<Row gutter={24}>
<Col lg={7} md={24}>
<Col lg={7} md={24} xs={24} style={{ marginBottom: isMobile ? 24 : 0 }}>
<Card
bordered={false}
style={{
@@ -227,7 +232,7 @@ const Center: React.FC = () => {
)}
</Card>
</Col>
<Col lg={17} md={24}>
<Col lg={17} md={24} xs={24}>
<Card
className={styles.tabsCard}
bordered={false}

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

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;

View File

@@ -74,7 +74,9 @@ html[data-theme='dark'] {
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
border-bottom: 2px solid var(--timeline-header-color);
transition: color 0.3s ease, border-color 0.3s ease;
transition:
color 0.3s ease,
border-color 0.3s ease;
}
/* Grid container for timeline items - 动态大小网格布局 */
@@ -206,9 +208,9 @@ html[data-theme='dark'] {
font-weight: 600;
font-size: 15px;
line-height: 1.5;
transition: color 0.3s ease;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
transition: color 0.3s ease;
}
/* Card description */
@@ -353,18 +355,18 @@ html[data-theme='dark'] {
/* 更多图片指示器 */
.timeline-grid-item .more-images-indicator {
flex-shrink: 0;
width: 80px;
height: 80px;
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
background: var(--timeline-more-bg);
width: 80px;
height: 80px;
color: var(--timeline-more-color);
border-radius: 6px;
font-size: 12px;
font-weight: 600;
font-size: 12px;
background: var(--timeline-more-bg);
border: 1px solid var(--timeline-image-border);
border-radius: 6px;
backdrop-filter: blur(4px);
transition: all 0.3s ease;
}
@@ -388,6 +390,23 @@ html[data-theme='dark'] {
font-size: 18px;
}
/* Sortable Timeline Item Styles */
.sortable-timeline-item-wrapper {
position: relative;
}
.sortable-timeline-item-wrapper:hover .drag-handle {
opacity: 1 !important;
}
.sortable-timeline-item-wrapper .drag-handle:hover {
background: rgba(0, 0, 0, 0.8) !important;
}
.sortable-timeline-item-wrapper .drag-handle:active {
cursor: grabbing;
}
/* Loading and empty states */
.timeline .load-indicator,
.timeline .no-more-data {
@@ -474,7 +493,7 @@ html[data-theme='dark'] {
width: 60px;
height: 60px;
}
.timeline-grid-item .more-images-indicator {
width: 60px;
height: 60px;
@@ -493,6 +512,14 @@ html[data-theme='dark'] {
padding: 16px;
}
/* 拓展图片展示:允许图片容器延伸到卡片边缘 */
.timeline-grid-item .item-images-row {
margin-right: -16px;
margin-left: -16px;
padding-right: 16px;
padding-left: 16px;
}
.timeline-section-header {
font-size: 20px;
}
@@ -510,7 +537,7 @@ html[data-theme='dark'] {
width: 50px;
height: 50px;
}
.timeline-grid-item .more-images-indicator {
width: 50px;
height: 50px;

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

View File

@@ -0,0 +1,178 @@
/**
* useBatchSelection - 批量选择状态管理 Hook
*
* 功能描述:
* 提供批量选择的状态管理和操作方法,用于时间线节点的批量操作功能。
*
* 功能特性:
* - 管理选中项 ID 集合
* - 提供选择/取消选择/全选/清空等方法
* - 支持批量模式切换
* - 提供选中状态判断方法
*
* @author Timeline Team
* @date 2024
*/
import { useState, useCallback, useMemo } from 'react';
/**
* 批量选择状态接口
* @property selectedIds - 已选中的 ID 集合
* @property isBatchMode - 是否处于批量选择模式
* @property select - 选中某项
* @property deselect - 取消选中某项
* @property toggle - 切换选中状态
* @property selectAll - 全选
* @property clearSelection - 清空选择
* @property isSelected - 判断是否选中
* @property selectedCount - 已选中数量
* @property enterBatchMode - 进入批量模式
* @property exitBatchMode - 退出批量模式
*/
interface BatchSelectionState<T extends string = string> {
selectedIds: Set<T>;
isBatchMode: boolean;
select: (id: T) => void;
deselect: (id: T) => void;
toggle: (id: T) => void;
selectAll: (ids: T[]) => void;
clearSelection: () => void;
isSelected: (id: T) => boolean;
selectedCount: number;
selectedIdsArray: T[];
enterBatchMode: () => void;
exitBatchMode: () => void;
}
/**
* useBatchSelection Hook
* 管理批量选择状态
*
* @template T - ID 类型,默认为 string
* @returns 批量选择状态和操作方法
*
* @example
* const batchSelection = useBatchSelection<string>();
*
* // 进入批量模式
* batchSelection.enterBatchMode();
*
* // 选择项目
* batchSelection.toggle('item-1');
*
* // 获取选中数量
* console.log(batchSelection.selectedCount);
*
* // 退出批量模式
* batchSelection.exitBatchMode();
*/
function useBatchSelection<T extends string = string>(): BatchSelectionState<T> {
// 已选中的 ID 集合
const [selectedIds, setSelectedIds] = useState<Set<T>>(new Set());
// 是否处于批量选择模式
const [isBatchMode, setIsBatchMode] = useState(false);
/**
* 选中某项
*/
const select = useCallback((id: T) => {
setSelectedIds((prev) => {
const next = new Set(prev);
next.add(id);
return next;
});
}, []);
/**
* 取消选中某项
*/
const deselect = useCallback((id: T) => {
setSelectedIds((prev) => {
const next = new Set(prev);
next.delete(id);
return next;
});
}, []);
/**
* 切换选中状态
*/
const toggle = useCallback((id: T) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}, []);
/**
* 全选
*/
const selectAll = useCallback((ids: T[]) => {
setSelectedIds(new Set(ids));
}, []);
/**
* 清空选择
*/
const clearSelection = useCallback(() => {
setSelectedIds(new Set());
}, []);
/**
* 判断是否选中
*/
const isSelected = useCallback(
(id: T) => selectedIds.has(id),
[selectedIds]
);
/**
* 已选中数量
*/
const selectedCount = useMemo(() => selectedIds.size, [selectedIds]);
/**
* 已选中的 ID 数组
*/
const selectedIdsArray = useMemo(() => Array.from(selectedIds), [selectedIds]);
/**
* 进入批量模式
*/
const enterBatchMode = useCallback(() => {
setIsBatchMode(true);
}, []);
/**
* 退出批量模式
* 同时清空选择
*/
const exitBatchMode = useCallback(() => {
setIsBatchMode(false);
clearSelection();
}, [clearSelection]);
return {
selectedIds,
isBatchMode,
select,
deselect,
toggle,
selectAll,
clearSelection,
isSelected,
selectedCount,
selectedIdsArray,
enterBatchMode,
exitBatchMode,
};
}
export default useBatchSelection;

View File

@@ -0,0 +1,322 @@
/**
* useHistory - 撤销/重做状态管理 Hook
*
* 功能描述:
* 提供撤销和重做功能的状态管理,用于时间线编辑操作的历史记录管理。
*
* 功能特性:
* - 维护操作历史栈past和重做栈future
* - 支持 undo/redo 操作
* - 支持操作数量限制,防止内存溢出
* - 支持键盘快捷键 (Ctrl+Z / Ctrl+Y)
* - 支持操作描述,便于 UI 展示
*
* 设计思路:
* 使用两个栈分别存储历史操作和可重做操作:
* - past 栈:存储已执行的操作,用于撤销
* - future 栈:存储已撤销的操作,用于重做
*
* @author Timeline Team
* @date 2024
*/
import { useState, useCallback, useEffect, useRef } from 'react';
/**
* 历史记录项接口
* @template T - 状态类型
* @property state - 快照状态
* @property description - 操作描述
* @property timestamp - 操作时间戳
*/
interface HistoryItem<T> {
state: T;
description: string;
timestamp: number;
}
/**
* 历史记录状态接口
* @template T - 状态类型
* @property past - 历史操作栈
* @property present - 当前状态
* @property future - 重做操作栈
*/
interface HistoryState<T> {
past: HistoryItem<T>[];
present: T;
future: HistoryItem<T>[];
}
/**
* 历史记录操作接口
* @template T - 状态类型
*/
interface HistoryActions<T> {
/** 撤销操作 */
undo: () => void;
/** 重做操作 */
redo: () => void;
/** 推送新状态 */
push: (state: T, description?: string) => void;
/** 重置历史记录 */
reset: (state: T) => void;
/** 清空历史记录 */
clear: () => void;
/** 是否可撤销 */
canUndo: boolean;
/** 是否可重做 */
canRedo: boolean;
/** 历史记录数量 */
historyLength: number;
/** 重做记录数量 */
futureLength: number;
/** 当前状态 */
present: T;
/** 获取历史记录列表(用于 UI 展示) */
getHistoryList: () => HistoryItem<T>[];
}
/**
* useHistory Hook 配置选项
*/
interface UseHistoryOptions {
/** 最大历史记录数量,默认 50 */
maxHistory?: number;
/** 是否启用键盘快捷键,默认 true */
enableHotkeys?: boolean;
}
/**
* useHistory Hook
* 管理撤销/重做状态
*
* @template T - 状态类型
* @param initialState - 初始状态
* @param options - 配置选项
* @returns 历史记录状态和操作方法
*
* @example
* const history = useHistory<{ items: Item[] }>(
* { items: [] },
* { maxHistory: 30, enableHotkeys: true }
* );
*
* // 推送新状态
* history.push({ items: newItems }, '添加新节点');
*
* // 撤销
* if (history.canUndo) {
* history.undo();
* }
*
* // 重做
* if (history.canRedo) {
* history.redo();
* }
*/
function useHistory<T>(
initialState: T,
options: UseHistoryOptions = {}
): HistoryActions<T> {
const { maxHistory = 50, enableHotkeys = true } = options;
// 历史记录状态
const [state, setState] = useState<HistoryState<T>>({
past: [],
present: initialState,
future: [],
});
// 引用最新状态,用于快捷键处理
const stateRef = useRef(state);
useEffect(() => {
stateRef.current = state;
}, [state]);
/**
* 推送新状态到历史记录
*
* 实现逻辑:
* 1. 将当前状态压入 past 栈
* 2. 更新 present 为新状态
* 3. 清空 future 栈(新操作使重做失效)
* 4. 检查历史记录数量限制
*/
const push = useCallback(
(newState: T, description: string = '操作') => {
setState((prev) => {
// 创建新的历史记录项
const newItem: HistoryItem<T> = {
state: prev.present,
description,
timestamp: Date.now(),
};
// 更新 past 栈,并限制数量
const newPast = [...prev.past, newItem].slice(-maxHistory);
return {
past: newPast,
present: newState,
future: [], // 新操作清空重做栈
};
});
},
[maxHistory]
);
/**
* 撤销操作
*
* 实现逻辑:
* 1. 从 past 栈弹出最近的历史记录
* 2. 将当前状态压入 future 栈
* 3. 更新 present 为弹出的历史状态
*/
const undo = useCallback(() => {
setState((prev) => {
if (prev.past.length === 0) {
return prev;
}
// 弹出最近的历史记录
const newPast = [...prev.past];
const previous = newPast.pop()!;
// 将当前状态压入 future 栈
const newFuture: HistoryItem<T>[] = [
{
state: prev.present,
description: '当前状态',
timestamp: Date.now(),
},
...prev.future,
];
return {
past: newPast,
present: previous.state,
future: newFuture,
};
});
}, []);
/**
* 重做操作
*
* 实现逻辑:
* 1. 从 future 栈弹出最近的重做记录
* 2. 将当前状态压入 past 栈
* 3. 更新 present 为弹出的重做状态
*/
const redo = useCallback(() => {
setState((prev) => {
if (prev.future.length === 0) {
return prev;
}
// 弹出最近的重做记录
const newFuture = [...prev.future];
const next = newFuture.shift()!;
// 将当前状态压入 past 栈
const newPast: HistoryItem<T>[] = [
...prev.past,
{
state: prev.present,
description: '当前状态',
timestamp: Date.now(),
},
];
return {
past: newPast,
present: next.state,
future: newFuture,
};
});
}, []);
/**
* 重置历史记录
* 清空所有历史,设置新的当前状态
*/
const reset = useCallback((newState: T) => {
setState({
past: [],
present: newState,
future: [],
});
}, []);
/**
* 清空历史记录
* 保留当前状态,清空 past 和 future
*/
const clear = useCallback(() => {
setState((prev) => ({
past: [],
present: prev.present,
future: [],
}));
}, []);
/**
* 获取历史记录列表
* 用于 UI 展示(如历史记录面板)
*/
const getHistoryList = useCallback(() => {
return state.past;
}, [state.past]);
/**
* 键盘快捷键处理
* Ctrl+Z: 撤销
* Ctrl+Y / Ctrl+Shift+Z: 重做
*/
useEffect(() => {
if (!enableHotkeys) return;
const handleKeyDown = (e: KeyboardEvent) => {
// 检查是否按下 Ctrl 或 Cmd (Mac)
const isModKey = e.ctrlKey || e.metaKey;
if (!isModKey) return;
// 撤销: Ctrl+Z
if (e.key === 'z' && !e.shiftKey) {
e.preventDefault();
if (stateRef.current.past.length > 0) {
undo();
}
}
// 重做: Ctrl+Y 或 Ctrl+Shift+Z
else if (e.key === 'y' || (e.key === 'z' && e.shiftKey)) {
e.preventDefault();
if (stateRef.current.future.length > 0) {
redo();
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [enableHotkeys, undo, redo]);
return {
undo,
redo,
push,
reset,
clear,
canUndo: state.past.length > 0,
canRedo: state.future.length > 0,
historyLength: state.past.length,
futureLength: state.future.length,
present: state.present,
getHistoryList,
};
}
export default useHistory;

View File

@@ -1,26 +1,47 @@
import { DownOutlined, PlusOutlined } from '@ant-design/icons';
import Highlight from '@/components/Highlight';
import { useIsMobile } from '@/hooks/useIsMobile';
import { judgePermission } from '@/pages/story/utils/utils';
import { DownOutlined, MoreOutlined, PlusOutlined } from '@ant-design/icons';
import { PageContainer } from '@ant-design/pro-components';
import { history, useRequest, useSearchParams } from '@umijs/max';
import { Avatar, Button, Card, Dropdown, Input, List, message, Modal } from 'antd';
import { Avatar, Button, Card, Dropdown, Input, List, MenuProps, message, Modal } from 'antd';
import type { FC } from 'react';
import React, { useEffect, useState } from 'react';
import OperationModal from './components/OperationModal';
import type { StoryType, StoryItem } from './data.d';
import { addStory, deleteStory, queryTimelineList, updateStory, searchStoryItems } from './service';
import useStyles from './style.style';
import AuthorizeStoryModal from './components/AuthorizeStoryModal';
import {judgePermission} from "@/pages/story/utils/utils";
import Highlight from '@/components/Highlight';
import OperationModal from './components/OperationModal';
import type { StoryItem, StoryType } from './data.d';
import { addStory, deleteStory, queryTimelineList, searchStoryItems, updateStory } from './service';
import useStyles from './style.style';
const { Search } = Input;
const ListContent = ({
data: { storyTime, updateTime, updateName, ownerName, itemCount },
isMobile,
}: {
data: StoryType;
isMobile: boolean;
}) => {
const { styles } = useStyles();
if (isMobile) {
return (
<div style={{ marginTop: 8 }}>
<div
className={styles.listContentItem}
style={{ width: '100%', textAlign: 'left', marginBottom: 4 }}
>
<span>: </span>
<p>{updateTime}</p>
</div>
<div className={styles.listContentItem} style={{ width: '100%', textAlign: 'left' }}>
<span>: </span>
<p>{itemCount}</p>
</div>
</div>
);
}
return (
<div>
<div className={styles.listContentItem}>
@@ -48,6 +69,7 @@ const ListContent = ({
};
export const BasicList: FC = () => {
const { styles } = useStyles();
const isMobile = useIsMobile();
const [searchParams] = useSearchParams();
const [done, setDone] = useState<boolean>(false);
const [open, setVisible] = useState<boolean>(false);
@@ -70,7 +92,7 @@ export const BasicList: FC = () => {
page: searchPagination.current,
pageSize: searchPagination.pageSize,
};
if (!finalParams.keyword) return Promise.resolve({ list: [], total: 0 });
return searchStoryItems({
keyword: finalParams.keyword,
@@ -89,7 +111,7 @@ export const BasicList: FC = () => {
}));
}
},
}
},
);
// 监听 URL 参数变化,自动触发搜索
@@ -97,7 +119,7 @@ export const BasicList: FC = () => {
const keyword = searchParams.get('keyword');
const page = parseInt(searchParams.get('page') || '1', 10);
if (keyword) {
setSearchPagination(prev => ({ ...prev, keyword, current: page }));
setSearchPagination((prev) => ({ ...prev, keyword, current: page }));
setIsSearching(true);
searchRun({ keyword, page, pageSize: 10 });
} else {
@@ -159,18 +181,27 @@ export const BasicList: FC = () => {
cancelText: '取消',
onOk: () => deleteItem(currentItem.instanceId ?? ''),
});
} else if (key === 'authorize') {
setCurrent(currentItem);
setAuthorizeModelOpen(true);
} else if (key === 'share') {
const shareLink = `${window.location.origin}/share/${currentItem.shareId}`;
navigator.clipboard.writeText(shareLink);
message.success('分享链接已复制到剪贴板');
}
};
const extraContent = (
<div>
<div
style={{ display: 'flex', alignItems: 'center', gap: 8, width: isMobile ? '100%' : 'auto' }}
>
<Button
type="dashed"
onClick={() => {
setVisible(true);
}}
style={{
marginBottom: 8,
float: 'left'
marginBottom: isMobile ? 0 : 8,
float: isMobile ? 'none' : 'left',
}}
>
<PlusOutlined />
@@ -182,32 +213,60 @@ export const BasicList: FC = () => {
onSearch={(value) => {
history.push(`/story?keyword=${value}&page=1`);
}}
style={{ width: isMobile ? '100%' : 272 }}
/>
</div>
);
const MoreBtn: React.FC<{
item: StoryType;
}> = ({ item }) => (
<Dropdown
menu={{
onClick: ({ key }) => editAndDelete(key, item),
items: [
{
key: 'edit',
label: '编辑',
},
{
key: 'delete',
label: '删除',
},
],
}}
>
<a>
<DownOutlined />
</a>
</Dropdown>
);
}> = ({ item }) => {
const items: MenuProps['items'] = [];
if (judgePermission(item?.permissionType, 'edit')) {
items.push({
key: 'edit',
label: '编辑',
});
}
if (judgePermission(item?.permissionType, 'auth')) {
items.push({
key: 'authorize',
label: '授权',
});
}
if (judgePermission(item?.permissionType, 'delete')) {
items.push({
key: 'delete',
label: '删除',
});
}
items.push({
key: 'share',
label: '分享',
});
if (items.length === 0) return null;
return (
<Dropdown
menu={{
onClick: ({ key }) => editAndDelete(key, item),
items: items,
}}
>
{isMobile ? (
<Button type="text" icon={<MoreOutlined />} />
) : (
<a>
<DownOutlined />
</a>
)}
</Dropdown>
);
};
const handleDone = () => {
setDone(false);
setVisible(false);
@@ -221,7 +280,7 @@ export const BasicList: FC = () => {
};
return (
<div>
<PageContainer title={"Timeline"}>
<PageContainer title={'Timeline'}>
<div className={styles.standardList}>
<Card
className={styles.listCard}
@@ -230,7 +289,7 @@ export const BasicList: FC = () => {
marginTop: 24,
}}
bodyStyle={{
padding: '0 32px 40px 32px',
padding: isMobile ? '16px 16px 24px 16px' : '0 32px 40px 32px',
}}
extra={extraContent}
>
@@ -273,7 +332,9 @@ export const BasicList: FC = () => {
<Highlight text={item.title} keyword={searchPagination.keyword || ''} />
</a>
}
description={<Highlight text={item.content} keyword={searchPagination.keyword || ''} />}
description={
<Highlight text={item.content} keyword={searchPagination.keyword || ''} />
}
/>
</List.Item>
)}
@@ -288,51 +349,55 @@ export const BasicList: FC = () => {
dataSource={list}
renderItem={(item: StoryType) => (
<List.Item
actions={[
<a
key="edit"
disabled={!judgePermission(item?.permissionType, 'edit')}
onClick={(e) => {
e.preventDefault();
showEditModal(item);
}}
>
</a>,
// 增加授权操作,可以授权给其他用户
<a
key="authorize"
disabled={!judgePermission(item?.permissionType, 'auth')}
onClick={(e) => {
e.preventDefault();
setCurrent(item);
setAuthorizeModelOpen(true);
}}
>
</a>,
<a
key="delete"
disabled={!judgePermission(item?.permissionType, 'delete')}
onClick={(e) => {
e.preventDefault();
deleteItem(item.instanceId ?? '');
}}
>
</a>,
<a
key="share"
onClick={(e) => {
e.preventDefault();
const shareLink = `${window.location.origin}/share/${item.shareId}`;
navigator.clipboard.writeText(shareLink);
message.success('分享链接已复制到剪贴板');
}}
>
</a>,
]}
actions={
isMobile
? [<MoreBtn key="more" item={item} />]
: [
<a
key="edit"
disabled={!judgePermission(item?.permissionType, 'edit')}
onClick={(e) => {
e.preventDefault();
showEditModal(item);
}}
>
</a>,
// 增加授权操作,可以授权给其他用户
<a
key="authorize"
disabled={!judgePermission(item?.permissionType, 'auth')}
onClick={(e) => {
e.preventDefault();
setCurrent(item);
setAuthorizeModelOpen(true);
}}
>
</a>,
<a
key="delete"
disabled={!judgePermission(item?.permissionType, 'delete')}
onClick={(e) => {
e.preventDefault();
deleteItem(item.instanceId ?? '');
}}
>
</a>,
<a
key="share"
onClick={(e) => {
e.preventDefault();
const shareLink = `${window.location.origin}/share/${item.shareId}`;
navigator.clipboard.writeText(shareLink);
message.success('分享链接已复制到剪贴板');
}}
>
</a>,
]
}
>
<List.Item.Meta
avatar={<Avatar src={item.logo} shape="square" size="large" />}
@@ -347,7 +412,7 @@ export const BasicList: FC = () => {
}
description={item.description}
/>
<ListContent data={item} />
<ListContent data={item} isMobile={isMobile} />
</List.Item>
)}
/>
@@ -366,13 +431,13 @@ export const BasicList: FC = () => {
open={authorizeModelOpen}
current={current}
handleOk={(flag) => {
if(flag) {
if (flag) {
run();
}
setAuthorizeModelOpen(false);
setCurrent({});
}}
/>
/>
</div>
);
};

View File

@@ -114,4 +114,109 @@ export async function authorizeStoryPermission(params: {userId: string, storyIns
method: 'POST',
data: params,
});
}
export async function getStoryPermissions(storyId: string) {
return request(`/story/permission/story/${storyId}`, {
method: 'GET',
});
}
export async function inviteUser(params: {userId: string, storyInstanceId: string, permissionType: number}) {
return request('/story/permission/invite', {
method: 'POST',
data: params,
});
}
export async function acceptInvite(inviteId: string) {
return request(`/story/permission/invite/${inviteId}/accept`, {
method: 'PUT',
});
}
export async function rejectInvite(inviteId: string) {
return request(`/story/permission/invite/${inviteId}/reject`, {
method: 'PUT',
});
}
export async function updatePermission(params: {permissionId: string, permissionType: number}) {
return request('/story/permission', {
method: 'PUT',
data: params
});
}
export async function removePermission(permissionId: string) {
return request(`/story/permission/${permissionId}`, {
method: 'DELETE'
});
}
/**
* 更新时间线节点排序
*
* 功能描述:
* 批量更新节点的排序值,用于拖拽排序后保存结果。
*
* @param orderData - 排序数据数组包含节点ID和新排序值
* @returns API响应
*
* @example
* const orderData = [
* { instanceId: 'item-1', sortOrder: 0 },
* { instanceId: 'item-2', sortOrder: 1 },
* ];
* await updateStoryItemOrder(orderData);
*/
export async function updateStoryItemOrder(
orderData: Array<{ instanceId: string; sortOrder: number }>
): Promise<CommonResponse<void>> {
return request('/story/item/order', {
method: 'PUT',
data: { items: orderData },
});
}
/**
* 批量删除时间线节点
*
* 功能描述:
* 根据节点ID列表批量删除时间线节点。
* 删除操作为软删除,数据可恢复。
*
* @param instanceIds - 要删除的节点ID数组
* @returns API响应
*
* @example
* await batchDeleteStoryItems(['item-1', 'item-2', 'item-3']);
*/
export async function batchDeleteStoryItems(
instanceIds: string[]
): Promise<CommonResponse<void>> {
return request('/story/item/batch-delete', {
method: 'POST',
data: { instanceIds },
});
}
/**
* 批量修改时间线节点时间
*
* 功能描述:
* 批量修改多个节点的时间信息。
*
* @param instanceIds - 要修改的节点ID数组
* @param storyItemTime - 新的时间值
* @returns API响应
*/
export async function batchUpdateStoryItemTime(
instanceIds: string[],
storyItemTime: string
): Promise<CommonResponse<void>> {
return request('/story/item/batch-time', {
method: 'PUT',
data: { instanceIds, storyItemTime },
});
}

View File

@@ -1,5 +1,6 @@
import { Footer } from '@/components';
import { getFakeCaptcha } from '@/services/ant-design-pro/login';
import { loginUser } from '@/services/user/api';
import { CommonResponse } from '@/types/common';
import {
AlipayCircleOutlined,
LockOutlined,
@@ -14,14 +15,19 @@ import {
ProFormCheckbox,
ProFormText,
} from '@ant-design/pro-components';
import { FormattedMessage, Helmet, SelectLang, useIntl, useModel, history, useRequest } from '@umijs/max';
import { Alert, message, Tabs } from 'antd';
import {
FormattedMessage,
Helmet,
history,
SelectLang,
useIntl,
useModel,
useRequest,
} from '@umijs/max';
import { Alert, message } from 'antd';
import { createStyles } from 'antd-style';
import React, { useState } from 'react';
import { flushSync } from 'react-dom';
import Settings from '../../../../config/defaultSettings';
import { loginUser } from '@/services/user/api';
import { CommonResponse } from '@/types/common';
const useStyles = createStyles(({ token }) => {
return {
@@ -103,33 +109,35 @@ const Login: React.FC = () => {
const intl = useIntl();
// 使用元组参数签名以匹配 useRequest 重载,避免被分页重载推断
const { loading: submitting, run: login } = useRequest<CommonResponse<UserLoginResult>, [UserLoginParams]>(
(params: UserLoginParams): Promise<CommonResponse<UserLoginResult>> => loginUser(params),
{
manual: true,
formatResult: (res) => res,
onSuccess: async (response: CommonResponse<UserLoginResult>, params: [UserLoginParams]) => {
console.log('登录成功 - response:', response, 'params:', params);
const [loginParams] = params;
const logStatus = { type: loginParams.loginType };
if (response.code === 200) {
const defaultLoginSuccessMessage = intl.formatMessage({
id: 'pages.login.success',
defaultMessage: '登录成功!',
});
message.success(defaultLoginSuccessMessage);
// await fetchUserInfo();
localStorage.setItem('timeline_user', JSON.stringify(response.data))
const urlParams = new URL(window.location.href).searchParams;
window.location.href = urlParams.get('redirect')?.split('?redirect=')[1] || '/';
return;
}
console.log(response.message);
// 如果失败去设置用户错误信息
setUserLoginState(response.message as any);
const { loading: submitting, run: login } = useRequest<
CommonResponse<UserLoginResult>,
[UserLoginParams]
>((params: UserLoginParams): Promise<CommonResponse<UserLoginResult>> => loginUser(params), {
manual: true,
formatResult: (res) => res,
onSuccess: async (response: CommonResponse<UserLoginResult>, params: [UserLoginParams]) => {
console.log('登录成功 - response:', response, 'params:', params);
const [loginParams] = params;
const logStatus = { type: loginParams.loginType };
if (response.code === 200) {
const defaultLoginSuccessMessage = intl.formatMessage({
id: 'pages.login.success',
defaultMessage: '登录成功!',
});
message.success(defaultLoginSuccessMessage);
// await fetchUserInfo();
localStorage.setItem('timeline_user', JSON.stringify(response.data));
const urlParams = new URL(window.location.href).searchParams;
// 修复:直接使用 redirect 参数,如果不存在则跳转到首页
const redirect = urlParams.get('redirect');
window.location.href = redirect || '/';
return;
}
console.log(response.message);
// 如果失败去设置用户错误信息
setUserLoginState(response.message as any);
},
);
});
const handleSubmit = async (values: API.LoginParams) => {
await login(values as UserLoginParams);
@@ -174,9 +182,9 @@ const Login: React.FC = () => {
/>,
<ActionIcons key="icons" />,
]}*/
onFinish={async (values) => {
await login({ ...values, loginType: type } as UserLoginParams);
}}
onFinish={async (values) => {
await login({ ...values, loginType: type } as UserLoginParams);
}}
>
{/*<Tabs
activeKey={type}

View File

@@ -3,11 +3,15 @@ import { createStyles } from 'antd-style';
const useStyles = createStyles(() => {
return {
registerResult: {
width: '800px',
width: '100%',
maxWidth: '800px',
minHeight: '400px',
margin: 'auto',
padding: '80px',
padding: '40px 20px',
background: 'none',
'@media screen and (min-width: 768px)': {
padding: '80px',
},
},
anticon: {
fontSize: '64px',

View File

@@ -1,13 +1,11 @@
import { registerUser } from '@/services/user/api';
import { CommonResponse } from '@/types/common';
import { history, Link, useRequest } from '@umijs/max';
import { Button, Col, Form, Input, message, Popover, Progress, Row, Select, Space } from 'antd';
import { Button, Form, Input, message, Popover, Progress, Select, Space } from 'antd';
import type { Store } from 'antd/es/form/interface';
import type { FC } from 'react';
import { useEffect, useState } from 'react';
import type { StateType } from './service';
import { fakeRegister } from './service';
import useStyles from './style.style';
import { registerUser } from '@/services/user/api';
import { CommonResponse } from '@/types/common';
const FormItem = Form.Item;
const { Option } = Select;
@@ -23,6 +21,7 @@ const passwordProgressMap: {
};
const Register: FC = () => {
const { styles } = useStyles();
const isMobile = useIsMobile();
const [count, setCount]: [number, any] = useState(0);
const [open, setVisible]: [boolean, any] = useState(false);
const [prefix, setPrefix]: [string, any] = useState('86');
@@ -95,7 +94,7 @@ const Register: FC = () => {
},
},
);
const onFinish = (values: Store) => {
// 将表单数据映射为后端需要的格式
const registerParams = {
@@ -202,7 +201,7 @@ const Register: FC = () => {
overlayStyle={{
width: 240,
}}
placement="right"
placement={isMobile ? 'top' : 'right'}
open={open}
>
<FormItem