表单设计器
This commit is contained in:
parent
34a5eb0ddd
commit
6c8ded573b
@ -6,7 +6,8 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useDroppable } from '@dnd-kit/core';
|
import { useDroppable } from '@dnd-kit/core';
|
||||||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||||
import { Empty, Typography } from 'antd';
|
import { Empty, Typography, Button, Tooltip } from 'antd';
|
||||||
|
import { PlusOutlined, InfoCircleOutlined } from '@ant-design/icons';
|
||||||
import FieldItem from './FieldItem';
|
import FieldItem from './FieldItem';
|
||||||
import type { FieldConfig } from '../types';
|
import type { FieldConfig } from '../types';
|
||||||
import '../styles.css';
|
import '../styles.css';
|
||||||
@ -17,16 +18,24 @@ interface DesignCanvasProps {
|
|||||||
fields: FieldConfig[];
|
fields: FieldConfig[];
|
||||||
selectedFieldId?: string;
|
selectedFieldId?: string;
|
||||||
onSelectField: (fieldId: string) => void;
|
onSelectField: (fieldId: string) => void;
|
||||||
|
onCopyField: (fieldId: string) => void;
|
||||||
|
onPasteToCanvas: () => void;
|
||||||
|
onPasteToGrid: (gridId: string, colIndex: number) => void;
|
||||||
onDeleteField: (fieldId: string) => void;
|
onDeleteField: (fieldId: string) => void;
|
||||||
labelAlign?: 'left' | 'right' | 'top';
|
labelAlign?: 'left' | 'right' | 'top';
|
||||||
|
hasClipboard?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DesignCanvas: React.FC<DesignCanvasProps> = ({
|
const DesignCanvas: React.FC<DesignCanvasProps> = ({
|
||||||
fields,
|
fields,
|
||||||
selectedFieldId,
|
selectedFieldId,
|
||||||
onSelectField,
|
onSelectField,
|
||||||
|
onCopyField,
|
||||||
|
onPasteToCanvas,
|
||||||
|
onPasteToGrid,
|
||||||
onDeleteField,
|
onDeleteField,
|
||||||
labelAlign = 'right',
|
labelAlign = 'right',
|
||||||
|
hasClipboard = false,
|
||||||
}) => {
|
}) => {
|
||||||
const { setNodeRef } = useDroppable({
|
const { setNodeRef } = useDroppable({
|
||||||
id: 'canvas',
|
id: 'canvas',
|
||||||
@ -34,8 +43,31 @@ const DesignCanvas: React.FC<DesignCanvasProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="form-designer-canvas">
|
<div className="form-designer-canvas">
|
||||||
<div className="form-designer-canvas-header">
|
<div className="form-designer-canvas-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
<Text strong style={{ fontSize: 16 }}>表单设计</Text>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<Text strong style={{ fontSize: 16 }}>表单设计</Text>
|
||||||
|
<Tooltip
|
||||||
|
title={
|
||||||
|
<div style={{ fontSize: 12 }}>
|
||||||
|
<div><kbd style={{ padding: '2px 6px', background: '#fff', color: '#333', borderRadius: 3, border: '1px solid #d9d9d9' }}>Ctrl/Cmd+C</kbd> 复制选中字段</div>
|
||||||
|
<div style={{ marginTop: 4 }}><kbd style={{ padding: '2px 6px', background: '#fff', color: '#333', borderRadius: 3, border: '1px solid #d9d9d9' }}>Ctrl/Cmd+V</kbd> 粘贴到画布</div>
|
||||||
|
<div style={{ marginTop: 4 }}><kbd style={{ padding: '2px 6px', background: '#fff', color: '#333', borderRadius: 3, border: '1px solid #d9d9d9' }}>Delete</kbd> 删除选中字段</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<InfoCircleOutlined style={{ fontSize: 14, color: '#8c8c8c', cursor: 'help' }} />
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
{hasClipboard && (
|
||||||
|
<Button
|
||||||
|
type="dashed"
|
||||||
|
size="small"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={onPasteToCanvas}
|
||||||
|
>
|
||||||
|
粘贴 <kbd style={{ fontSize: 10, opacity: 0.6 }}>Ctrl+V</kbd>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@ -61,11 +93,15 @@ const DesignCanvas: React.FC<DesignCanvasProps> = ({
|
|||||||
field={field}
|
field={field}
|
||||||
isSelected={field.id === selectedFieldId}
|
isSelected={field.id === selectedFieldId}
|
||||||
onSelect={() => onSelectField(field.id)}
|
onSelect={() => onSelectField(field.id)}
|
||||||
|
onCopy={() => onCopyField(field.id)}
|
||||||
onDelete={() => onDeleteField(field.id)}
|
onDelete={() => onDeleteField(field.id)}
|
||||||
selectedFieldId={selectedFieldId}
|
selectedFieldId={selectedFieldId}
|
||||||
onSelectField={onSelectField}
|
onSelectField={onSelectField}
|
||||||
|
onCopyField={onCopyField}
|
||||||
|
onPasteToGrid={onPasteToGrid}
|
||||||
onDeleteField={onDeleteField}
|
onDeleteField={onDeleteField}
|
||||||
labelAlign={labelAlign}
|
labelAlign={labelAlign}
|
||||||
|
hasClipboard={hasClipboard}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -16,22 +16,30 @@ interface FieldItemProps {
|
|||||||
field: FieldConfig;
|
field: FieldConfig;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
onSelect: () => void;
|
onSelect: () => void;
|
||||||
|
onCopy: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
selectedFieldId?: string; // 传递给栅格内部字段
|
selectedFieldId?: string; // 传递给栅格内部字段
|
||||||
onSelectField?: (fieldId: string) => void;
|
onSelectField?: (fieldId: string) => void;
|
||||||
|
onCopyField?: (fieldId: string) => void;
|
||||||
|
onPasteToGrid?: (gridId: string, colIndex: number) => void;
|
||||||
onDeleteField?: (fieldId: string) => void;
|
onDeleteField?: (fieldId: string) => void;
|
||||||
labelAlign?: 'left' | 'right' | 'top'; // 标签对齐方式
|
labelAlign?: 'left' | 'right' | 'top'; // 标签对齐方式
|
||||||
|
hasClipboard?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FieldItem: React.FC<FieldItemProps> = ({
|
const FieldItem: React.FC<FieldItemProps> = ({
|
||||||
field,
|
field,
|
||||||
isSelected,
|
isSelected,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
onCopy,
|
||||||
onDelete,
|
onDelete,
|
||||||
selectedFieldId,
|
selectedFieldId,
|
||||||
onSelectField,
|
onSelectField,
|
||||||
|
onCopyField,
|
||||||
|
onPasteToGrid,
|
||||||
onDeleteField,
|
onDeleteField,
|
||||||
labelAlign = 'right',
|
labelAlign = 'right',
|
||||||
|
hasClipboard = false,
|
||||||
}) => {
|
}) => {
|
||||||
const {
|
const {
|
||||||
attributes,
|
attributes,
|
||||||
@ -66,8 +74,11 @@ const FieldItem: React.FC<FieldItemProps> = ({
|
|||||||
field={field}
|
field={field}
|
||||||
selectedFieldId={selectedFieldId}
|
selectedFieldId={selectedFieldId}
|
||||||
onSelectField={onSelectField}
|
onSelectField={onSelectField}
|
||||||
|
onCopyField={onCopyField}
|
||||||
|
onPasteToGrid={onPasteToGrid}
|
||||||
onDeleteField={onDeleteField}
|
onDeleteField={onDeleteField}
|
||||||
labelAlign={labelAlign}
|
labelAlign={labelAlign}
|
||||||
|
hasClipboard={hasClipboard}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -79,7 +90,7 @@ const FieldItem: React.FC<FieldItemProps> = ({
|
|||||||
icon={<CopyOutlined />}
|
icon={<CopyOutlined />}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
// TODO: 复制功能
|
onCopy();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -40,8 +40,11 @@ interface FieldRendererProps {
|
|||||||
isPreview?: boolean; // 是否为预览模式
|
isPreview?: boolean; // 是否为预览模式
|
||||||
selectedFieldId?: string; // 当前选中的字段ID
|
selectedFieldId?: string; // 当前选中的字段ID
|
||||||
onSelectField?: (fieldId: string) => void;
|
onSelectField?: (fieldId: string) => void;
|
||||||
|
onCopyField?: (fieldId: string) => void;
|
||||||
|
onPasteToGrid?: (gridId: string, colIndex: number) => void;
|
||||||
onDeleteField?: (fieldId: string) => void;
|
onDeleteField?: (fieldId: string) => void;
|
||||||
labelAlign?: 'left' | 'right' | 'top'; // 标签对齐方式
|
labelAlign?: 'left' | 'right' | 'top'; // 标签对齐方式
|
||||||
|
hasClipboard?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FieldRenderer: React.FC<FieldRendererProps> = ({
|
const FieldRenderer: React.FC<FieldRendererProps> = ({
|
||||||
@ -51,8 +54,11 @@ const FieldRenderer: React.FC<FieldRendererProps> = ({
|
|||||||
isPreview = false,
|
isPreview = false,
|
||||||
selectedFieldId,
|
selectedFieldId,
|
||||||
onSelectField,
|
onSelectField,
|
||||||
|
onCopyField,
|
||||||
|
onPasteToGrid,
|
||||||
onDeleteField,
|
onDeleteField,
|
||||||
labelAlign = 'right',
|
labelAlign = 'right',
|
||||||
|
hasClipboard = false,
|
||||||
}) => {
|
}) => {
|
||||||
// 获取字段选项(支持静态和动态数据源)
|
// 获取字段选项(支持静态和动态数据源)
|
||||||
const options = useFieldOptions(field);
|
const options = useFieldOptions(field);
|
||||||
@ -81,8 +87,11 @@ const FieldRenderer: React.FC<FieldRendererProps> = ({
|
|||||||
isPreview={isPreview}
|
isPreview={isPreview}
|
||||||
selectedFieldId={selectedFieldId}
|
selectedFieldId={selectedFieldId}
|
||||||
onSelectField={onSelectField}
|
onSelectField={onSelectField}
|
||||||
|
onCopyField={onCopyField}
|
||||||
|
onPasteToGrid={onPasteToGrid}
|
||||||
onDeleteField={onDeleteField}
|
onDeleteField={onDeleteField}
|
||||||
labelAlign={labelAlign}
|
labelAlign={labelAlign}
|
||||||
|
hasClipboard={hasClipboard}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useDroppable } from '@dnd-kit/core';
|
import { useDroppable } from '@dnd-kit/core';
|
||||||
import { Row, Col, Button, Space } from 'antd';
|
import { Row, Col, Button, Space } from 'antd';
|
||||||
import { DeleteOutlined } from '@ant-design/icons';
|
import { DeleteOutlined, CopyOutlined, PlusOutlined } from '@ant-design/icons';
|
||||||
import type { FieldConfig } from '../types';
|
import type { FieldConfig } from '../types';
|
||||||
import FieldRenderer from './FieldRenderer';
|
import FieldRenderer from './FieldRenderer';
|
||||||
|
|
||||||
@ -15,8 +15,11 @@ interface GridFieldProps {
|
|||||||
isPreview?: boolean; // 是否为预览模式
|
isPreview?: boolean; // 是否为预览模式
|
||||||
selectedFieldId?: string; // 当前选中的字段ID
|
selectedFieldId?: string; // 当前选中的字段ID
|
||||||
onSelectField?: (fieldId: string) => void;
|
onSelectField?: (fieldId: string) => void;
|
||||||
|
onCopyField?: (fieldId: string) => void;
|
||||||
|
onPasteToGrid?: (gridId: string, colIndex: number) => void;
|
||||||
onDeleteField?: (fieldId: string) => void;
|
onDeleteField?: (fieldId: string) => void;
|
||||||
labelAlign?: 'left' | 'right' | 'top'; // 标签对齐方式
|
labelAlign?: 'left' | 'right' | 'top'; // 标签对齐方式
|
||||||
|
hasClipboard?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const GridField: React.FC<GridFieldProps> = ({
|
const GridField: React.FC<GridFieldProps> = ({
|
||||||
@ -24,8 +27,11 @@ const GridField: React.FC<GridFieldProps> = ({
|
|||||||
isPreview = false,
|
isPreview = false,
|
||||||
selectedFieldId,
|
selectedFieldId,
|
||||||
onSelectField,
|
onSelectField,
|
||||||
|
onCopyField,
|
||||||
|
onPasteToGrid,
|
||||||
onDeleteField,
|
onDeleteField,
|
||||||
labelAlign = 'right',
|
labelAlign = 'right',
|
||||||
|
hasClipboard = false,
|
||||||
}) => {
|
}) => {
|
||||||
const columns = field.columns || 2;
|
const columns = field.columns || 2;
|
||||||
const children = field.children || Array(columns).fill([]);
|
const children = field.children || Array(columns).fill([]);
|
||||||
@ -54,8 +60,13 @@ const GridField: React.FC<GridFieldProps> = ({
|
|||||||
isPreview={isPreview}
|
isPreview={isPreview}
|
||||||
selectedFieldId={selectedFieldId}
|
selectedFieldId={selectedFieldId}
|
||||||
onSelectField={onSelectField}
|
onSelectField={onSelectField}
|
||||||
|
onCopyField={onCopyField}
|
||||||
|
onPasteToGrid={onPasteToGrid}
|
||||||
onDeleteField={onDeleteField}
|
onDeleteField={onDeleteField}
|
||||||
labelAlign={labelAlign}
|
labelAlign={labelAlign}
|
||||||
|
gridId={field.id}
|
||||||
|
colIndex={colIndex}
|
||||||
|
hasClipboard={hasClipboard}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@ -72,8 +83,13 @@ interface GridColumnProps {
|
|||||||
isPreview?: boolean;
|
isPreview?: boolean;
|
||||||
selectedFieldId?: string;
|
selectedFieldId?: string;
|
||||||
onSelectField?: (fieldId: string) => void;
|
onSelectField?: (fieldId: string) => void;
|
||||||
|
onCopyField?: (fieldId: string) => void;
|
||||||
|
onPasteToGrid?: (gridId: string, colIndex: number) => void;
|
||||||
onDeleteField?: (fieldId: string) => void;
|
onDeleteField?: (fieldId: string) => void;
|
||||||
labelAlign?: 'left' | 'right' | 'top';
|
labelAlign?: 'left' | 'right' | 'top';
|
||||||
|
gridId?: string;
|
||||||
|
colIndex?: number;
|
||||||
|
hasClipboard?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const GridColumn: React.FC<GridColumnProps> = ({
|
const GridColumn: React.FC<GridColumnProps> = ({
|
||||||
@ -83,8 +99,13 @@ const GridColumn: React.FC<GridColumnProps> = ({
|
|||||||
isPreview = false,
|
isPreview = false,
|
||||||
selectedFieldId,
|
selectedFieldId,
|
||||||
onSelectField,
|
onSelectField,
|
||||||
|
onCopyField,
|
||||||
|
onPasteToGrid,
|
||||||
onDeleteField,
|
onDeleteField,
|
||||||
labelAlign = 'right',
|
labelAlign = 'right',
|
||||||
|
gridId,
|
||||||
|
colIndex,
|
||||||
|
hasClipboard = false,
|
||||||
}) => {
|
}) => {
|
||||||
const { setNodeRef, isOver } = useDroppable({
|
const { setNodeRef, isOver } = useDroppable({
|
||||||
id: dropId,
|
id: dropId,
|
||||||
@ -124,21 +145,45 @@ const GridColumn: React.FC<GridColumnProps> = ({
|
|||||||
position: 'relative',
|
position: 'relative',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* 栅格占位标签 */}
|
{/* 栅格占位标签和粘贴按钮 */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 4,
|
top: 4,
|
||||||
right: 4,
|
right: 4,
|
||||||
fontSize: 10,
|
display: 'flex',
|
||||||
color: '#bfbfbf',
|
gap: 4,
|
||||||
background: '#fff',
|
alignItems: 'center',
|
||||||
padding: '2px 6px',
|
|
||||||
borderRadius: 3,
|
|
||||||
border: '1px solid #e8e8e8',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{span}/24
|
{hasClipboard && gridId !== undefined && colIndex !== undefined && (
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onPasteToGrid?.(gridId, colIndex);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
padding: '0 4px',
|
||||||
|
height: 18,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
color: '#bfbfbf',
|
||||||
|
background: '#fff',
|
||||||
|
padding: '2px 6px',
|
||||||
|
borderRadius: 3,
|
||||||
|
border: '1px solid #e8e8e8',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{span}/24
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{fields.length === 0 ? (
|
{fields.length === 0 ? (
|
||||||
@ -183,6 +228,15 @@ const GridColumn: React.FC<GridColumnProps> = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Space size="small">
|
<Space size="small">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<CopyOutlined />}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onCopyField?.(childField.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
size="small"
|
size="small"
|
||||||
|
|||||||
@ -8,7 +8,7 @@
|
|||||||
* - 导入/导出 JSON Schema
|
* - 导入/导出 JSON Schema
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useCallback } from 'react';
|
import React, { useState, useCallback, useEffect } from 'react';
|
||||||
import { Button, Space, message, Modal } from 'antd';
|
import { Button, Space, message, Modal } from 'antd';
|
||||||
import {
|
import {
|
||||||
SaveOutlined,
|
SaveOutlined,
|
||||||
@ -33,6 +33,9 @@ const FormDesigner: React.FC = () => {
|
|||||||
// 选中的字段
|
// 选中的字段
|
||||||
const [selectedFieldId, setSelectedFieldId] = useState<string>();
|
const [selectedFieldId, setSelectedFieldId] = useState<string>();
|
||||||
|
|
||||||
|
// 剪贴板(用于复制粘贴)
|
||||||
|
const [clipboard, setClipboard] = useState<FieldConfig | null>(null);
|
||||||
|
|
||||||
// 表单配置
|
// 表单配置
|
||||||
const [formConfig, setFormConfig] = useState<FormConfig>({
|
const [formConfig, setFormConfig] = useState<FormConfig>({
|
||||||
labelAlign: 'right',
|
labelAlign: 'right',
|
||||||
@ -123,6 +126,88 @@ const FormDesigner: React.FC = () => {
|
|||||||
setSelectedFieldId(fieldId);
|
setSelectedFieldId(fieldId);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 深拷贝字段(包括嵌套的栅格子字段)
|
||||||
|
const deepCopyField = useCallback((field: FieldConfig): FieldConfig => {
|
||||||
|
const newField: FieldConfig = {
|
||||||
|
...field,
|
||||||
|
id: generateId(),
|
||||||
|
name: field.name + '_copy_' + Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 如果是栅格布局,递归复制子字段
|
||||||
|
if (field.type === 'grid' && field.children) {
|
||||||
|
newField.children = field.children.map(columnFields =>
|
||||||
|
columnFields.map(childField => deepCopyField(childField))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return newField;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 复制字段到剪贴板
|
||||||
|
const handleCopyField = useCallback((fieldId: string) => {
|
||||||
|
// 递归查找字段
|
||||||
|
const findField = (fieldList: FieldConfig[], id: string): FieldConfig | null => {
|
||||||
|
for (const field of fieldList) {
|
||||||
|
if (field.id === id) {
|
||||||
|
return field;
|
||||||
|
}
|
||||||
|
// 如果是栅格布局,递归查找子字段
|
||||||
|
if (field.type === 'grid' && field.children) {
|
||||||
|
for (const columnFields of field.children) {
|
||||||
|
const found = findField(columnFields, id);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const field = findField(fields, fieldId);
|
||||||
|
if (field) {
|
||||||
|
setClipboard(field);
|
||||||
|
message.success('已复制字段到剪贴板');
|
||||||
|
}
|
||||||
|
}, [fields]);
|
||||||
|
|
||||||
|
// 粘贴字段到主列表末尾
|
||||||
|
const handlePasteToCanvas = useCallback(() => {
|
||||||
|
if (!clipboard) {
|
||||||
|
message.warning('剪贴板为空');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newField = deepCopyField(clipboard);
|
||||||
|
setFields(prev => [...prev, newField]);
|
||||||
|
setSelectedFieldId(newField.id);
|
||||||
|
message.success('已粘贴字段');
|
||||||
|
}, [clipboard, deepCopyField]);
|
||||||
|
|
||||||
|
// 粘贴字段到栅格列
|
||||||
|
const handlePasteToGrid = useCallback((gridId: string, colIndex: number) => {
|
||||||
|
if (!clipboard) {
|
||||||
|
message.warning('剪贴板为空');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newField = deepCopyField(clipboard);
|
||||||
|
|
||||||
|
setFields(prev => prev.map(field => {
|
||||||
|
if (field.id === gridId && field.type === 'grid') {
|
||||||
|
const newChildren = [...(field.children || [])];
|
||||||
|
if (!newChildren[colIndex]) {
|
||||||
|
newChildren[colIndex] = [];
|
||||||
|
}
|
||||||
|
newChildren[colIndex] = [...newChildren[colIndex], newField];
|
||||||
|
return { ...field, children: newChildren };
|
||||||
|
}
|
||||||
|
return field;
|
||||||
|
}));
|
||||||
|
|
||||||
|
setSelectedFieldId(newField.id);
|
||||||
|
message.success('已粘贴字段到栅格');
|
||||||
|
}, [clipboard, deepCopyField]);
|
||||||
|
|
||||||
// 删除字段(支持删除栅格内的字段)
|
// 删除字段(支持删除栅格内的字段)
|
||||||
const handleDeleteField = useCallback((fieldId: string) => {
|
const handleDeleteField = useCallback((fieldId: string) => {
|
||||||
let deleted = false;
|
let deleted = false;
|
||||||
@ -301,6 +386,47 @@ const FormDesigner: React.FC = () => {
|
|||||||
|
|
||||||
const selectedField = findFieldById(fields, selectedFieldId) || null;
|
const selectedField = findFieldById(fields, selectedFieldId) || null;
|
||||||
|
|
||||||
|
// 快捷键支持
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
// 检查是否在输入框、文本域等可编辑元素中
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
const isEditable =
|
||||||
|
target.tagName === 'INPUT' ||
|
||||||
|
target.tagName === 'TEXTAREA' ||
|
||||||
|
target.isContentEditable;
|
||||||
|
|
||||||
|
// 如果在可编辑元素中,不触发快捷键
|
||||||
|
if (isEditable) return;
|
||||||
|
|
||||||
|
const isMac = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
|
||||||
|
const ctrlKey = isMac ? e.metaKey : e.ctrlKey;
|
||||||
|
|
||||||
|
// Ctrl+C / Cmd+C: 复制选中的字段
|
||||||
|
if (ctrlKey && e.key === 'c' && selectedFieldId) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleCopyField(selectedFieldId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+V / Cmd+V: 粘贴到画布
|
||||||
|
if (ctrlKey && e.key === 'v' && clipboard) {
|
||||||
|
e.preventDefault();
|
||||||
|
handlePasteToCanvas();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete: 删除选中的字段
|
||||||
|
if (e.key === 'Delete' && selectedFieldId) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleDeleteField(selectedFieldId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [selectedFieldId, clipboard, handleCopyField, handlePasteToCanvas, handleDeleteField]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ height: '100vh', width: '100%', display: 'flex', flexDirection: 'column' }}>
|
<div style={{ height: '100vh', width: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||||
{/* 顶部工具栏 */}
|
{/* 顶部工具栏 */}
|
||||||
@ -350,8 +476,12 @@ const FormDesigner: React.FC = () => {
|
|||||||
fields={fields}
|
fields={fields}
|
||||||
selectedFieldId={selectedFieldId}
|
selectedFieldId={selectedFieldId}
|
||||||
onSelectField={handleSelectField}
|
onSelectField={handleSelectField}
|
||||||
|
onCopyField={handleCopyField}
|
||||||
|
onPasteToCanvas={handlePasteToCanvas}
|
||||||
|
onPasteToGrid={handlePasteToGrid}
|
||||||
onDeleteField={handleDeleteField}
|
onDeleteField={handleDeleteField}
|
||||||
labelAlign={formConfig.labelAlign}
|
labelAlign={formConfig.labelAlign}
|
||||||
|
hasClipboard={!!clipboard}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 右侧属性面板 */}
|
{/* 右侧属性面板 */}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user