表单设计器
This commit is contained in:
parent
34a5eb0ddd
commit
6c8ded573b
@ -6,7 +6,8 @@
|
||||
import React from 'react';
|
||||
import { useDroppable } from '@dnd-kit/core';
|
||||
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 type { FieldConfig } from '../types';
|
||||
import '../styles.css';
|
||||
@ -17,16 +18,24 @@ interface DesignCanvasProps {
|
||||
fields: FieldConfig[];
|
||||
selectedFieldId?: string;
|
||||
onSelectField: (fieldId: string) => void;
|
||||
onCopyField: (fieldId: string) => void;
|
||||
onPasteToCanvas: () => void;
|
||||
onPasteToGrid: (gridId: string, colIndex: number) => void;
|
||||
onDeleteField: (fieldId: string) => void;
|
||||
labelAlign?: 'left' | 'right' | 'top';
|
||||
hasClipboard?: boolean;
|
||||
}
|
||||
|
||||
const DesignCanvas: React.FC<DesignCanvasProps> = ({
|
||||
fields,
|
||||
selectedFieldId,
|
||||
onSelectField,
|
||||
onCopyField,
|
||||
onPasteToCanvas,
|
||||
onPasteToGrid,
|
||||
onDeleteField,
|
||||
labelAlign = 'right',
|
||||
hasClipboard = false,
|
||||
}) => {
|
||||
const { setNodeRef } = useDroppable({
|
||||
id: 'canvas',
|
||||
@ -34,8 +43,31 @@ const DesignCanvas: React.FC<DesignCanvasProps> = ({
|
||||
|
||||
return (
|
||||
<div className="form-designer-canvas">
|
||||
<div className="form-designer-canvas-header">
|
||||
<Text strong style={{ fontSize: 16 }}>表单设计</Text>
|
||||
<div className="form-designer-canvas-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<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
|
||||
@ -61,11 +93,15 @@ const DesignCanvas: React.FC<DesignCanvasProps> = ({
|
||||
field={field}
|
||||
isSelected={field.id === selectedFieldId}
|
||||
onSelect={() => onSelectField(field.id)}
|
||||
onCopy={() => onCopyField(field.id)}
|
||||
onDelete={() => onDeleteField(field.id)}
|
||||
selectedFieldId={selectedFieldId}
|
||||
onSelectField={onSelectField}
|
||||
onCopyField={onCopyField}
|
||||
onPasteToGrid={onPasteToGrid}
|
||||
onDeleteField={onDeleteField}
|
||||
labelAlign={labelAlign}
|
||||
hasClipboard={hasClipboard}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -16,22 +16,30 @@ interface FieldItemProps {
|
||||
field: FieldConfig;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
onCopy: () => void;
|
||||
onDelete: () => void;
|
||||
selectedFieldId?: string; // 传递给栅格内部字段
|
||||
onSelectField?: (fieldId: string) => void;
|
||||
onCopyField?: (fieldId: string) => void;
|
||||
onPasteToGrid?: (gridId: string, colIndex: number) => void;
|
||||
onDeleteField?: (fieldId: string) => void;
|
||||
labelAlign?: 'left' | 'right' | 'top'; // 标签对齐方式
|
||||
hasClipboard?: boolean;
|
||||
}
|
||||
|
||||
const FieldItem: React.FC<FieldItemProps> = ({
|
||||
field,
|
||||
isSelected,
|
||||
onSelect,
|
||||
onCopy,
|
||||
onDelete,
|
||||
selectedFieldId,
|
||||
onSelectField,
|
||||
onCopyField,
|
||||
onPasteToGrid,
|
||||
onDeleteField,
|
||||
labelAlign = 'right',
|
||||
hasClipboard = false,
|
||||
}) => {
|
||||
const {
|
||||
attributes,
|
||||
@ -66,8 +74,11 @@ const FieldItem: React.FC<FieldItemProps> = ({
|
||||
field={field}
|
||||
selectedFieldId={selectedFieldId}
|
||||
onSelectField={onSelectField}
|
||||
onCopyField={onCopyField}
|
||||
onPasteToGrid={onPasteToGrid}
|
||||
onDeleteField={onDeleteField}
|
||||
labelAlign={labelAlign}
|
||||
hasClipboard={hasClipboard}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -79,7 +90,7 @@ const FieldItem: React.FC<FieldItemProps> = ({
|
||||
icon={<CopyOutlined />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
// TODO: 复制功能
|
||||
onCopy();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
|
||||
@ -40,8 +40,11 @@ interface FieldRendererProps {
|
||||
isPreview?: boolean; // 是否为预览模式
|
||||
selectedFieldId?: string; // 当前选中的字段ID
|
||||
onSelectField?: (fieldId: string) => void;
|
||||
onCopyField?: (fieldId: string) => void;
|
||||
onPasteToGrid?: (gridId: string, colIndex: number) => void;
|
||||
onDeleteField?: (fieldId: string) => void;
|
||||
labelAlign?: 'left' | 'right' | 'top'; // 标签对齐方式
|
||||
hasClipboard?: boolean;
|
||||
}
|
||||
|
||||
const FieldRenderer: React.FC<FieldRendererProps> = ({
|
||||
@ -51,8 +54,11 @@ const FieldRenderer: React.FC<FieldRendererProps> = ({
|
||||
isPreview = false,
|
||||
selectedFieldId,
|
||||
onSelectField,
|
||||
onCopyField,
|
||||
onPasteToGrid,
|
||||
onDeleteField,
|
||||
labelAlign = 'right',
|
||||
hasClipboard = false,
|
||||
}) => {
|
||||
// 获取字段选项(支持静态和动态数据源)
|
||||
const options = useFieldOptions(field);
|
||||
@ -81,8 +87,11 @@ const FieldRenderer: React.FC<FieldRendererProps> = ({
|
||||
isPreview={isPreview}
|
||||
selectedFieldId={selectedFieldId}
|
||||
onSelectField={onSelectField}
|
||||
onCopyField={onCopyField}
|
||||
onPasteToGrid={onPasteToGrid}
|
||||
onDeleteField={onDeleteField}
|
||||
labelAlign={labelAlign}
|
||||
hasClipboard={hasClipboard}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
import React from 'react';
|
||||
import { useDroppable } from '@dnd-kit/core';
|
||||
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 FieldRenderer from './FieldRenderer';
|
||||
|
||||
@ -15,8 +15,11 @@ interface GridFieldProps {
|
||||
isPreview?: boolean; // 是否为预览模式
|
||||
selectedFieldId?: string; // 当前选中的字段ID
|
||||
onSelectField?: (fieldId: string) => void;
|
||||
onCopyField?: (fieldId: string) => void;
|
||||
onPasteToGrid?: (gridId: string, colIndex: number) => void;
|
||||
onDeleteField?: (fieldId: string) => void;
|
||||
labelAlign?: 'left' | 'right' | 'top'; // 标签对齐方式
|
||||
hasClipboard?: boolean;
|
||||
}
|
||||
|
||||
const GridField: React.FC<GridFieldProps> = ({
|
||||
@ -24,8 +27,11 @@ const GridField: React.FC<GridFieldProps> = ({
|
||||
isPreview = false,
|
||||
selectedFieldId,
|
||||
onSelectField,
|
||||
onCopyField,
|
||||
onPasteToGrid,
|
||||
onDeleteField,
|
||||
labelAlign = 'right',
|
||||
hasClipboard = false,
|
||||
}) => {
|
||||
const columns = field.columns || 2;
|
||||
const children = field.children || Array(columns).fill([]);
|
||||
@ -54,8 +60,13 @@ const GridField: React.FC<GridFieldProps> = ({
|
||||
isPreview={isPreview}
|
||||
selectedFieldId={selectedFieldId}
|
||||
onSelectField={onSelectField}
|
||||
onCopyField={onCopyField}
|
||||
onPasteToGrid={onPasteToGrid}
|
||||
onDeleteField={onDeleteField}
|
||||
labelAlign={labelAlign}
|
||||
gridId={field.id}
|
||||
colIndex={colIndex}
|
||||
hasClipboard={hasClipboard}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@ -72,8 +83,13 @@ interface GridColumnProps {
|
||||
isPreview?: boolean;
|
||||
selectedFieldId?: string;
|
||||
onSelectField?: (fieldId: string) => void;
|
||||
onCopyField?: (fieldId: string) => void;
|
||||
onPasteToGrid?: (gridId: string, colIndex: number) => void;
|
||||
onDeleteField?: (fieldId: string) => void;
|
||||
labelAlign?: 'left' | 'right' | 'top';
|
||||
gridId?: string;
|
||||
colIndex?: number;
|
||||
hasClipboard?: boolean;
|
||||
}
|
||||
|
||||
const GridColumn: React.FC<GridColumnProps> = ({
|
||||
@ -83,8 +99,13 @@ const GridColumn: React.FC<GridColumnProps> = ({
|
||||
isPreview = false,
|
||||
selectedFieldId,
|
||||
onSelectField,
|
||||
onCopyField,
|
||||
onPasteToGrid,
|
||||
onDeleteField,
|
||||
labelAlign = 'right',
|
||||
gridId,
|
||||
colIndex,
|
||||
hasClipboard = false,
|
||||
}) => {
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
id: dropId,
|
||||
@ -124,21 +145,45 @@ const GridColumn: React.FC<GridColumnProps> = ({
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* 栅格占位标签 */}
|
||||
{/* 栅格占位标签和粘贴按钮 */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
right: 4,
|
||||
fontSize: 10,
|
||||
color: '#bfbfbf',
|
||||
background: '#fff',
|
||||
padding: '2px 6px',
|
||||
borderRadius: 3,
|
||||
border: '1px solid #e8e8e8',
|
||||
display: 'flex',
|
||||
gap: 4,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{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>
|
||||
|
||||
{fields.length === 0 ? (
|
||||
@ -183,6 +228,15 @@ const GridColumn: React.FC<GridColumnProps> = ({
|
||||
}}
|
||||
>
|
||||
<Space size="small">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCopyField?.(childField.id);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
* - 导入/导出 JSON Schema
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { Button, Space, message, Modal } from 'antd';
|
||||
import {
|
||||
SaveOutlined,
|
||||
@ -33,6 +33,9 @@ const FormDesigner: React.FC = () => {
|
||||
// 选中的字段
|
||||
const [selectedFieldId, setSelectedFieldId] = useState<string>();
|
||||
|
||||
// 剪贴板(用于复制粘贴)
|
||||
const [clipboard, setClipboard] = useState<FieldConfig | null>(null);
|
||||
|
||||
// 表单配置
|
||||
const [formConfig, setFormConfig] = useState<FormConfig>({
|
||||
labelAlign: 'right',
|
||||
@ -123,6 +126,88 @@ const FormDesigner: React.FC = () => {
|
||||
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) => {
|
||||
let deleted = false;
|
||||
@ -301,6 +386,47 @@ const FormDesigner: React.FC = () => {
|
||||
|
||||
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 (
|
||||
<div style={{ height: '100vh', width: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
{/* 顶部工具栏 */}
|
||||
@ -350,8 +476,12 @@ const FormDesigner: React.FC = () => {
|
||||
fields={fields}
|
||||
selectedFieldId={selectedFieldId}
|
||||
onSelectField={handleSelectField}
|
||||
onCopyField={handleCopyField}
|
||||
onPasteToCanvas={handlePasteToCanvas}
|
||||
onPasteToGrid={handlePasteToGrid}
|
||||
onDeleteField={handleDeleteField}
|
||||
labelAlign={formConfig.labelAlign}
|
||||
hasClipboard={!!clipboard}
|
||||
/>
|
||||
|
||||
{/* 右侧属性面板 */}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user