表单设计器
This commit is contained in:
parent
93adb8e71e
commit
90d0792f0d
@ -25,6 +25,7 @@ import {
|
||||
import { UploadOutlined } from '@ant-design/icons';
|
||||
import type { FieldConfig } from '../types';
|
||||
import GridField from './GridField';
|
||||
import { useFieldOptions } from '../hooks/useFieldOptions';
|
||||
import '../styles.css';
|
||||
|
||||
const { Text } = Typography;
|
||||
@ -52,6 +53,9 @@ const FieldRenderer: React.FC<FieldRendererProps> = ({
|
||||
onDeleteField,
|
||||
labelAlign = 'right',
|
||||
}) => {
|
||||
// 获取字段选项(支持静态和动态数据源)
|
||||
const options = useFieldOptions(field);
|
||||
|
||||
// 布局组件特殊处理
|
||||
if (field.type === 'divider') {
|
||||
return <Divider>{field.label}</Divider>;
|
||||
@ -119,7 +123,7 @@ const FieldRenderer: React.FC<FieldRendererProps> = ({
|
||||
<Select
|
||||
placeholder={field.placeholder}
|
||||
disabled={field.disabled}
|
||||
options={field.options}
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
style={{ width: '100%' }}
|
||||
@ -129,7 +133,7 @@ const FieldRenderer: React.FC<FieldRendererProps> = ({
|
||||
case 'radio':
|
||||
return (
|
||||
<Radio.Group
|
||||
options={field.options}
|
||||
options={options}
|
||||
disabled={field.disabled}
|
||||
value={value}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
@ -139,7 +143,7 @@ const FieldRenderer: React.FC<FieldRendererProps> = ({
|
||||
case 'checkbox':
|
||||
return (
|
||||
<Checkbox.Group
|
||||
options={field.options}
|
||||
options={options}
|
||||
disabled={field.disabled}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
@ -225,7 +229,7 @@ const FieldRenderer: React.FC<FieldRendererProps> = ({
|
||||
style={{ width: '100%' }}
|
||||
placeholder={field.placeholder}
|
||||
disabled={field.disabled}
|
||||
options={field.options as any}
|
||||
options={options as any}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
|
||||
@ -15,6 +15,7 @@ import {
|
||||
Tabs,
|
||||
Typography,
|
||||
Divider,
|
||||
Select,
|
||||
} from 'antd';
|
||||
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import type { FieldConfig, FormConfig } from '../types';
|
||||
@ -39,13 +40,60 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({
|
||||
|
||||
React.useEffect(() => {
|
||||
if (selectedField) {
|
||||
form.setFieldsValue(selectedField);
|
||||
// 确保 apiDataSource 被正确设置到表单
|
||||
const formValues = {
|
||||
...selectedField,
|
||||
apiDataSource: selectedField.apiDataSource || {
|
||||
url: '',
|
||||
method: 'GET',
|
||||
dataPath: '',
|
||||
labelField: '',
|
||||
valueField: '',
|
||||
},
|
||||
};
|
||||
form.setFieldsValue(formValues);
|
||||
}
|
||||
}, [selectedField, form]);
|
||||
|
||||
const handleFieldUpdate = (changedValues: any) => {
|
||||
if (selectedField) {
|
||||
onFieldChange({ ...selectedField, ...changedValues });
|
||||
// 深度合并,特别处理 apiDataSource 嵌套对象
|
||||
let updatedField = { ...selectedField };
|
||||
|
||||
// 如果改变的是数据源类型
|
||||
if ('dataSourceType' in changedValues) {
|
||||
updatedField.dataSourceType = changedValues.dataSourceType;
|
||||
|
||||
// 切换到 API 数据源时,初始化 apiDataSource 对象
|
||||
if (changedValues.dataSourceType === 'api' && !updatedField.apiDataSource) {
|
||||
updatedField.apiDataSource = {
|
||||
url: '',
|
||||
method: 'GET',
|
||||
dataPath: '',
|
||||
labelField: '',
|
||||
valueField: '',
|
||||
};
|
||||
console.log('🔄 切换到 API 数据源,已初始化配置对象');
|
||||
}
|
||||
}
|
||||
|
||||
// 如果改变的是 apiDataSource 的子字段
|
||||
if ('apiDataSource' in changedValues) {
|
||||
updatedField.apiDataSource = {
|
||||
...updatedField.apiDataSource,
|
||||
...changedValues.apiDataSource,
|
||||
};
|
||||
console.log('💾 API 数据源配置已更新:', updatedField.apiDataSource);
|
||||
}
|
||||
|
||||
// 合并其他字段
|
||||
Object.keys(changedValues).forEach(key => {
|
||||
if (key !== 'dataSourceType' && key !== 'apiDataSource') {
|
||||
(updatedField as any)[key] = changedValues[key];
|
||||
}
|
||||
});
|
||||
|
||||
onFieldChange(updatedField);
|
||||
}
|
||||
};
|
||||
|
||||
@ -260,41 +308,104 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasOptions && selectedField.options && (
|
||||
{hasOptions && (
|
||||
<div>
|
||||
<Divider style={{ margin: '16px 0' }} />
|
||||
<div style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Text strong>选项配置</Text>
|
||||
<Button
|
||||
type="dashed"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={addOption}
|
||||
>
|
||||
添加选项
|
||||
</Button>
|
||||
</div>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="small">
|
||||
{selectedField.options.map((option, index) => (
|
||||
<div key={index} style={{ display: 'flex', gap: 8 }}>
|
||||
<Input
|
||||
placeholder="标签"
|
||||
value={option.label}
|
||||
onChange={(e) => updateOption(index, 'label', e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
placeholder="值"
|
||||
value={option.value}
|
||||
onChange={(e) => updateOption(index, 'value', e.target.value)}
|
||||
/>
|
||||
|
||||
{/* 数据源类型选择 */}
|
||||
<Form.Item label="数据源类型" name="dataSourceType">
|
||||
<Radio.Group buttonStyle="solid">
|
||||
<Radio.Button value="static">静态数据</Radio.Button>
|
||||
<Radio.Button value="api">接口数据</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
|
||||
{/* 静态数据配置 */}
|
||||
{selectedField.dataSourceType === 'static' && selectedField.options && (
|
||||
<div>
|
||||
<div style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Text strong>选项配置</Text>
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => deleteOption(index)}
|
||||
/>
|
||||
type="dashed"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={addOption}
|
||||
>
|
||||
添加选项
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="small">
|
||||
{selectedField.options.map((option, index) => (
|
||||
<div key={index} style={{ display: 'flex', gap: 8 }}>
|
||||
<Input
|
||||
placeholder="标签"
|
||||
value={option.label}
|
||||
onChange={(e) => updateOption(index, 'label', e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
placeholder="值"
|
||||
value={option.value}
|
||||
onChange={(e) => updateOption(index, 'value', e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => deleteOption(index)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API 数据源配置 */}
|
||||
{selectedField.dataSourceType === 'api' && (
|
||||
<div>
|
||||
<Text strong>接口配置</Text>
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Form.Item
|
||||
label="接口地址"
|
||||
name={['apiDataSource', 'url']}
|
||||
rules={[{ required: true, message: '请输入接口地址' }]}
|
||||
>
|
||||
<Input placeholder="如:/api/v1/dict/status" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="请求方法" name={['apiDataSource', 'method']}>
|
||||
<Select placeholder="选择请求方法" defaultValue="GET">
|
||||
<Select.Option value="GET">GET</Select.Option>
|
||||
<Select.Option value="POST">POST</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="数据路径"
|
||||
name={['apiDataSource', 'dataPath']}
|
||||
tooltip="从接口响应中提取数组的路径。如响应为 {data: [...]},则填 'data';如为 {result: {list: [...]}},则填 'result.list'。留空表示直接返回数组"
|
||||
>
|
||||
<Input placeholder="如:data 或 result.list(可选)" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="标签字段"
|
||||
name={['apiDataSource', 'labelField']}
|
||||
tooltip="数组中每个对象用作显示文本的字段名"
|
||||
rules={[{ required: true, message: '请输入标签字段' }]}
|
||||
>
|
||||
<Input placeholder="如:name" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="值字段"
|
||||
name={['apiDataSource', 'valueField']}
|
||||
tooltip="数组中每个对象用作实际值的字段名"
|
||||
rules={[{ required: true, message: '请输入值字段' }]}
|
||||
>
|
||||
<Input placeholder="如:id" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
|
||||
@ -90,6 +90,7 @@ export const COMPONENT_LIST: ComponentMeta[] = [
|
||||
category: '基础字段',
|
||||
defaultConfig: {
|
||||
placeholder: '请选择',
|
||||
dataSourceType: 'static',
|
||||
options: [
|
||||
{ label: '选项1', value: '1' },
|
||||
{ label: '选项2', value: '2' },
|
||||
@ -103,6 +104,7 @@ export const COMPONENT_LIST: ComponentMeta[] = [
|
||||
icon: CheckCircleOutlined,
|
||||
category: '基础字段',
|
||||
defaultConfig: {
|
||||
dataSourceType: 'static',
|
||||
options: [
|
||||
{ label: '选项1', value: '1' },
|
||||
{ label: '选项2', value: '2' },
|
||||
@ -116,6 +118,7 @@ export const COMPONENT_LIST: ComponentMeta[] = [
|
||||
icon: CheckSquareOutlined,
|
||||
category: '基础字段',
|
||||
defaultConfig: {
|
||||
dataSourceType: 'static',
|
||||
options: [
|
||||
{ label: '选项1', value: '1' },
|
||||
{ label: '选项2', value: '2' },
|
||||
@ -201,6 +204,7 @@ export const COMPONENT_LIST: ComponentMeta[] = [
|
||||
category: '高级字段',
|
||||
defaultConfig: {
|
||||
placeholder: '请选择',
|
||||
dataSourceType: 'static',
|
||||
options: [
|
||||
{
|
||||
label: '浙江',
|
||||
|
||||
105
frontend/src/pages/FormDesigner/hooks/useFieldOptions.ts
Normal file
105
frontend/src/pages/FormDesigner/hooks/useFieldOptions.ts
Normal file
@ -0,0 +1,105 @@
|
||||
/**
|
||||
* 自定义 Hook:处理字段选项数据
|
||||
* 支持静态数据和动态API数据
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { FieldConfig, FieldOption } from '../types';
|
||||
import request from '../../../utils/request';
|
||||
|
||||
/**
|
||||
* 从对象中根据路径提取数据
|
||||
* @param obj 对象
|
||||
* @param path 路径,如 'data' 或 'result.list'
|
||||
*/
|
||||
const getValueByPath = (obj: any, path?: string): any => {
|
||||
if (!path) return obj;
|
||||
|
||||
const keys = path.split('.');
|
||||
let result = obj;
|
||||
|
||||
for (const key of keys) {
|
||||
if (result && typeof result === 'object' && key in result) {
|
||||
result = result[key];
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const useFieldOptions = (field: FieldConfig): FieldOption[] => {
|
||||
const [options, setOptions] = useState<FieldOption[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadOptions = async () => {
|
||||
// 静态数据源
|
||||
if (field.dataSourceType === 'static' || !field.dataSourceType) {
|
||||
setOptions(field.options || []);
|
||||
return;
|
||||
}
|
||||
|
||||
// API 数据源
|
||||
if (field.dataSourceType === 'api' && field.apiDataSource) {
|
||||
const { url, method = 'GET', dataPath, labelField, valueField } = field.apiDataSource;
|
||||
|
||||
// 验证必填字段(空字符串也视为缺失)
|
||||
if (!url || !url.trim() || !labelField || !labelField.trim() || !valueField || !valueField.trim()) {
|
||||
console.warn('⚠️ API 数据源配置不完整,缺少必填字段', {
|
||||
fieldId: field.id,
|
||||
fieldName: field.name,
|
||||
配置信息: field.apiDataSource,
|
||||
缺失项: {
|
||||
url: !url || !url.trim() ? '缺少 URL' : '✓',
|
||||
labelField: !labelField || !labelField.trim() ? '缺少 labelField' : '✓',
|
||||
valueField: !valueField || !valueField.trim() ? '缺少 valueField' : '✓',
|
||||
},
|
||||
});
|
||||
setOptions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let response: any;
|
||||
|
||||
// 根据请求方法调用接口
|
||||
if (method === 'POST') {
|
||||
response = await request.post(url, field.apiDataSource.params || {});
|
||||
} else {
|
||||
response = await request.get(url, { params: field.apiDataSource.params || {} });
|
||||
}
|
||||
|
||||
// 根据 dataPath 提取数据数组
|
||||
let dataArray = dataPath ? getValueByPath(response, dataPath) : response;
|
||||
|
||||
// 转换数据格式
|
||||
if (Array.isArray(dataArray)) {
|
||||
const transformedOptions: FieldOption[] = dataArray.map((item: any) => ({
|
||||
label: item[labelField],
|
||||
value: item[valueField],
|
||||
}));
|
||||
setOptions(transformedOptions);
|
||||
console.log('✅ API 数据加载成功', { url, count: transformedOptions.length });
|
||||
} else {
|
||||
console.error('❌ API 返回的数据格式不正确,期望数组', {
|
||||
url,
|
||||
dataPath,
|
||||
actualData: dataArray,
|
||||
fullResponse: response,
|
||||
});
|
||||
setOptions([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 加载 API 数据失败', { url, error });
|
||||
setOptions([]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadOptions();
|
||||
}, [field.dataSourceType, field.options, field.apiDataSource]);
|
||||
|
||||
return options;
|
||||
};
|
||||
|
||||
@ -28,6 +28,19 @@ export interface FieldOption {
|
||||
value: string | number;
|
||||
}
|
||||
|
||||
// 数据源类型
|
||||
export type DataSourceType = 'static' | 'api';
|
||||
|
||||
// API 数据源配置
|
||||
export interface ApiDataSource {
|
||||
url: string; // 接口地址
|
||||
method?: 'GET' | 'POST'; // 请求方法,默认 GET
|
||||
params?: Record<string, any>; // 请求参数
|
||||
dataPath?: string; // 数据路径(如 'data', 'result.list'),用于从响应中提取数组
|
||||
labelField: string; // 标签字段名(如 'name', 'label', 'text')
|
||||
valueField: string; // 值字段名(如 'id', 'code', 'value')
|
||||
}
|
||||
|
||||
// 字段配置
|
||||
export interface FieldConfig {
|
||||
id: string;
|
||||
@ -38,7 +51,9 @@ export interface FieldConfig {
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
defaultValue?: any;
|
||||
options?: FieldOption[];
|
||||
options?: FieldOption[]; // 静态选项数据
|
||||
dataSourceType?: DataSourceType; // 数据源类型:static(静态)或 api(接口)
|
||||
apiDataSource?: ApiDataSource; // API 数据源配置(当 dataSourceType 为 'api' 时使用)
|
||||
min?: number;
|
||||
max?: number;
|
||||
rows?: number;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user