表单设计器

This commit is contained in:
dengqichen 2025-10-23 23:35:55 +08:00
parent 34a5eb0ddd
commit 6c8ded573b
5 changed files with 254 additions and 14 deletions

View File

@ -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>

View File

@ -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

View File

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

View File

@ -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"

View File

@ -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}
/>
{/* 右侧属性面板 */}