增加代码编辑器表单组件
This commit is contained in:
parent
670093e9ca
commit
3e40119f09
@ -35,6 +35,7 @@ import { useImperativeHandle, forwardRef } from 'react';
|
||||
import { Form, message } from 'antd';
|
||||
import type { FieldConfig, FormConfig } from './types';
|
||||
import { useFormCore } from './hooks/useFormCore';
|
||||
import { computeFormLayout } from './utils/formLayoutHelper';
|
||||
import FormFieldsRenderer from './components/FormFieldsRenderer';
|
||||
import './styles.css';
|
||||
|
||||
@ -96,20 +97,18 @@ const FormPreview = forwardRef<FormPreviewRef, FormPreviewProps>(({ fields, form
|
||||
);
|
||||
}
|
||||
|
||||
const formLayout = formConfig.labelAlign === 'top' ? 'vertical' : 'horizontal';
|
||||
const labelColSpan = formConfig.labelAlign === 'left' ? 6 : formConfig.labelAlign === 'right' ? 6 : undefined;
|
||||
const wrapperColSpan = formConfig.labelAlign === 'left' || formConfig.labelAlign === 'right' ? 18 : undefined;
|
||||
const layoutConfig = computeFormLayout(formConfig);
|
||||
|
||||
return (
|
||||
<div className="form-container-padding form-preview-compact">
|
||||
<Form
|
||||
form={form}
|
||||
layout={formLayout}
|
||||
layout={layoutConfig.layout}
|
||||
size={formConfig.size}
|
||||
colon={true}
|
||||
labelAlign={formConfig.labelAlign === 'top' ? undefined : formConfig.labelAlign}
|
||||
labelCol={labelColSpan ? { span: labelColSpan } : undefined}
|
||||
wrapperCol={wrapperColSpan ? { span: wrapperColSpan } : undefined}
|
||||
labelAlign={layoutConfig.labelAlign}
|
||||
labelCol={layoutConfig.labelCol}
|
||||
wrapperCol={layoutConfig.wrapperCol}
|
||||
onValuesChange={handleValuesChange}
|
||||
>
|
||||
{formConfig.title && (
|
||||
|
||||
@ -50,6 +50,7 @@ import type { ComponentMeta } from './config';
|
||||
import { COMPONENT_LIST } from './config';
|
||||
import { ComponentsContext } from './Designer';
|
||||
import { useFormCore } from './hooks/useFormCore';
|
||||
import { computeFormLayout } from './utils/formLayoutHelper';
|
||||
import FormFieldsRenderer from './components/FormFieldsRenderer';
|
||||
import './styles.css';
|
||||
|
||||
@ -230,21 +231,19 @@ const FormRenderer = forwardRef<FormRendererRef, FormRendererProps>((props, ref)
|
||||
);
|
||||
}
|
||||
|
||||
const formLayout = formConfig.labelAlign === 'top' ? 'vertical' : 'horizontal';
|
||||
const labelColSpan = formConfig.labelAlign === 'left' ? 6 : formConfig.labelAlign === 'right' ? 6 : undefined;
|
||||
const wrapperColSpan = formConfig.labelAlign === 'left' || formConfig.labelAlign === 'right' ? 18 : undefined;
|
||||
const layoutConfig = computeFormLayout(formConfig);
|
||||
|
||||
return (
|
||||
<ComponentsContext.Provider value={allComponents}>
|
||||
<div className="form-container-padding form-renderer-compact">
|
||||
<Form
|
||||
form={form}
|
||||
layout={formLayout}
|
||||
layout={layoutConfig.layout}
|
||||
size={formConfig.size}
|
||||
colon={true}
|
||||
labelAlign={formConfig.labelAlign === 'top' ? undefined : formConfig.labelAlign}
|
||||
labelCol={labelColSpan ? { span: labelColSpan } : undefined}
|
||||
wrapperCol={wrapperColSpan ? { span: wrapperColSpan } : undefined}
|
||||
labelAlign={layoutConfig.labelAlign}
|
||||
labelCol={layoutConfig.labelCol}
|
||||
wrapperCol={layoutConfig.wrapperCol}
|
||||
onValuesChange={handleValuesChange}
|
||||
>
|
||||
{formConfig.title && (
|
||||
@ -260,7 +259,10 @@ const FormRenderer = forwardRef<FormRendererRef, FormRendererProps>((props, ref)
|
||||
{(showSubmit || showCancel) && (
|
||||
<Form.Item
|
||||
style={{ marginTop: 32, marginBottom: 0 }}
|
||||
wrapperCol={{ offset: labelColSpan || 0, span: wrapperColSpan || 24 }}
|
||||
wrapperCol={{
|
||||
offset: layoutConfig.labelCol?.span || 0,
|
||||
span: layoutConfig.wrapperCol?.span || 24
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
{showSubmit && (
|
||||
|
||||
@ -8,32 +8,19 @@ import { ComponentsContext } from '../Designer';
|
||||
import {
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Select,
|
||||
Radio,
|
||||
Checkbox,
|
||||
DatePicker,
|
||||
TimePicker,
|
||||
Switch,
|
||||
Slider,
|
||||
Rate,
|
||||
Upload,
|
||||
Cascader,
|
||||
Divider,
|
||||
Button,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { UploadOutlined } from '@ant-design/icons';
|
||||
import type { FieldConfig } from '../types';
|
||||
import GridField from './GridField';
|
||||
import { useFieldOptions } from '../hooks/useFieldOptions';
|
||||
import { useCascaderOptions, useCascaderLoadData } from '../hooks/useCascaderOptions';
|
||||
import { fieldRenderStrategyRegistry } from '../renderers/FieldRenderStrategyRegistry';
|
||||
import type { FieldRenderContext } from '../renderers/IFieldRenderStrategy';
|
||||
import '../styles.css';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
interface FieldRendererProps {
|
||||
field: FieldConfig;
|
||||
value?: any;
|
||||
@ -63,40 +50,27 @@ const FieldRenderer: React.FC<FieldRendererProps> = ({
|
||||
hasClipboard = false,
|
||||
duplicateFieldIds = new Set(),
|
||||
}) => {
|
||||
// 获取字段选项(支持静态和动态数据源)
|
||||
// 获取字段选项和级联选项(支持静态和动态数据源)
|
||||
const options = useFieldOptions(field);
|
||||
|
||||
// 获取级联选择器选项和懒加载函数
|
||||
const cascadeOptions = useCascaderOptions(field);
|
||||
const loadData = useCascaderLoadData(field);
|
||||
|
||||
// 🔌 检查是否有自定义渲染组件(插件式扩展)
|
||||
// 🔌 优先检查自定义渲染组件(插件式扩展)
|
||||
const allComponents = useContext(ComponentsContext);
|
||||
const componentMeta = allComponents.find(c => c.type === field.type);
|
||||
|
||||
if (componentMeta?.FieldRendererComponent) {
|
||||
const CustomRenderer = componentMeta.FieldRendererComponent;
|
||||
console.log('✅ FieldRenderer: 使用自定义渲染组件', {
|
||||
fieldType: field.type,
|
||||
isPreview,
|
||||
hasRenderer: !!componentMeta.FieldRendererComponent
|
||||
});
|
||||
return <CustomRenderer field={field} value={value} onChange={onChange} isPreview={isPreview} />;
|
||||
}
|
||||
|
||||
// 布局组件特殊处理
|
||||
// 布局组件特殊处理(这些组件不通过策略模式)
|
||||
if (field.type === 'divider') {
|
||||
const showText = field.showText !== false; // 默认显示文字
|
||||
const showText = field.showText !== false;
|
||||
const dividerText = field.dividerText || field.label || '分割线';
|
||||
const dividerColor = field.dividerColor || 'rgba(5, 5, 5, 0.06)';
|
||||
|
||||
return (
|
||||
<Divider
|
||||
style={{
|
||||
borderColor: dividerColor,
|
||||
color: dividerColor,
|
||||
}}
|
||||
>
|
||||
<Divider style={{ borderColor: dividerColor, color: dividerColor }}>
|
||||
{showText ? dividerText : null}
|
||||
</Divider>
|
||||
);
|
||||
@ -104,10 +78,7 @@ const FieldRenderer: React.FC<FieldRendererProps> = ({
|
||||
|
||||
if (field.type === 'text') {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '8px 0',
|
||||
textAlign: field.textAlign || 'left',
|
||||
}}>
|
||||
<div style={{ padding: '8px 0', textAlign: field.textAlign || 'left' }}>
|
||||
<Text style={{
|
||||
fontSize: field.fontSize ? `${field.fontSize}px` : '14px',
|
||||
color: field.textColor || '#000000',
|
||||
@ -135,171 +106,44 @@ const FieldRenderer: React.FC<FieldRendererProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
// 使用策略模式渲染字段(工厂模式获取策略)
|
||||
const strategy = fieldRenderStrategyRegistry.get(field.type);
|
||||
|
||||
// 计算只读样式
|
||||
const isReadonly = field.readonly || false;
|
||||
const readonlyStyle = isReadonly ? {
|
||||
backgroundColor: '#f5f5f5',
|
||||
cursor: 'not-allowed',
|
||||
color: '#00000040',
|
||||
} : undefined;
|
||||
|
||||
// 构造渲染上下文
|
||||
const renderContext: FieldRenderContext = {
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
isDisabled: field.disabled || false,
|
||||
isReadonly,
|
||||
readonlyStyle,
|
||||
options,
|
||||
cascadeOptions,
|
||||
loadData,
|
||||
};
|
||||
|
||||
// 渲染字段组件
|
||||
const renderField = () => {
|
||||
switch (field.type) {
|
||||
case 'input':
|
||||
if (strategy) {
|
||||
return strategy.render(renderContext);
|
||||
}
|
||||
|
||||
// 兜底策略:未注册的字段类型使用默认 Input
|
||||
return (
|
||||
<Input
|
||||
placeholder={field.placeholder}
|
||||
disabled={field.disabled}
|
||||
value={value}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
readOnly={isReadonly}
|
||||
disabled={field.disabled && !isReadonly}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'textarea':
|
||||
return (
|
||||
<TextArea
|
||||
rows={field.rows || 4}
|
||||
placeholder={field.placeholder}
|
||||
disabled={field.disabled}
|
||||
value={value}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'number':
|
||||
return (
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
placeholder={field.placeholder}
|
||||
disabled={field.disabled}
|
||||
min={field.min}
|
||||
max={field.max}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'select':
|
||||
return (
|
||||
<Select
|
||||
placeholder={field.placeholder}
|
||||
disabled={field.disabled}
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'radio':
|
||||
return (
|
||||
<Radio.Group
|
||||
options={options}
|
||||
disabled={field.disabled}
|
||||
value={value}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'checkbox':
|
||||
return (
|
||||
<Checkbox.Group
|
||||
options={options}
|
||||
disabled={field.disabled}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: field.checkboxLayout === 'vertical' ? 'column' : 'row',
|
||||
gap: field.checkboxLayout === 'vertical' ? '8px' : '16px',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'date':
|
||||
return (
|
||||
<DatePicker
|
||||
style={{ width: '100%' }}
|
||||
placeholder={field.placeholder}
|
||||
disabled={field.disabled}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'datetime':
|
||||
return (
|
||||
<DatePicker
|
||||
showTime
|
||||
style={{ width: '100%' }}
|
||||
placeholder={field.placeholder}
|
||||
disabled={field.disabled}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'time':
|
||||
return (
|
||||
<TimePicker
|
||||
style={{ width: '100%' }}
|
||||
placeholder={field.placeholder}
|
||||
disabled={field.disabled}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'switch':
|
||||
return (
|
||||
<Switch
|
||||
disabled={field.disabled}
|
||||
checked={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'slider':
|
||||
return (
|
||||
<Slider
|
||||
min={field.min}
|
||||
max={field.max}
|
||||
disabled={field.disabled}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'rate':
|
||||
return (
|
||||
<Rate
|
||||
disabled={field.disabled}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'upload':
|
||||
return (
|
||||
<Upload
|
||||
maxCount={field.maxCount}
|
||||
disabled={field.disabled}
|
||||
>
|
||||
<Button icon={<UploadOutlined />}>上传文件</Button>
|
||||
</Upload>
|
||||
);
|
||||
|
||||
// approver-selector 等扩展字段已通过插件机制处理,不在此硬编码
|
||||
|
||||
case 'cascader':
|
||||
return (
|
||||
<Cascader
|
||||
style={{ width: '100%' }}
|
||||
placeholder={field.placeholder}
|
||||
disabled={field.disabled}
|
||||
options={cascadeOptions as any}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
loadData={loadData as any}
|
||||
changeOnSelect={field.dataSourceType === 'predefined'}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return <Input placeholder={field.placeholder} />;
|
||||
}
|
||||
};
|
||||
|
||||
// 预览模式下,外部已经用 Form.Item 包裹了,这里直接返回控件
|
||||
|
||||
@ -8,9 +8,22 @@ import { Form } from 'antd';
|
||||
import type { FieldConfig, FormConfig } from '../types';
|
||||
import type { FieldState } from '../utils/linkageHelper';
|
||||
import { mergeValidationRules } from '../utils/validationHelper';
|
||||
import { computeFieldState, FieldClassifier } from '../utils/fieldStateHelper';
|
||||
import FieldRenderer from './FieldRenderer';
|
||||
import GridFieldPreview from './GridFieldPreview';
|
||||
|
||||
/**
|
||||
* 隐藏字段组件(内部使用)
|
||||
* 用于渲染隐藏字段(不显示任何 UI,但参与表单提交)
|
||||
*/
|
||||
const HiddenField: React.FC<{ fieldName: string }> = ({ fieldName }) => {
|
||||
return (
|
||||
<Form.Item name={fieldName} noStyle>
|
||||
<input type="hidden" />
|
||||
</Form.Item>
|
||||
);
|
||||
};
|
||||
|
||||
export interface FormFieldsRendererProps {
|
||||
/** 字段列表 */
|
||||
fields: FieldConfig[];
|
||||
@ -37,8 +50,11 @@ const FormFieldsRenderer: React.FC<FormFieldsRendererProps> = ({
|
||||
}) => {
|
||||
const renderFields = (fieldList: FieldConfig[]): React.ReactNode => {
|
||||
return fieldList.map((field) => {
|
||||
// 使用访问者模式:根据字段类型分类处理
|
||||
const fieldType = FieldClassifier.classify(field);
|
||||
|
||||
// 布局组件:文本、分割线
|
||||
if (field.type === 'text' || field.type === 'divider') {
|
||||
if (fieldType === 'layout') {
|
||||
return (
|
||||
<div key={field.id} style={{ marginBottom: 8 }}>
|
||||
<FieldRenderer field={field} isPreview={true} />
|
||||
@ -47,7 +63,7 @@ const FormFieldsRenderer: React.FC<FormFieldsRendererProps> = ({
|
||||
}
|
||||
|
||||
// 栅格布局:使用专门的预览组件
|
||||
if (field.type === 'grid') {
|
||||
if (fieldType === 'grid') {
|
||||
return (
|
||||
<GridFieldPreview
|
||||
key={field.id}
|
||||
@ -57,19 +73,22 @@ const FormFieldsRenderer: React.FC<FormFieldsRendererProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
// 获取字段状态(联动规则影响)
|
||||
// 计算字段最终状态(统一处理 hidden, readonly, disabled, visible, required)
|
||||
const fieldState = fieldStates[field.name] || {};
|
||||
const isVisible = fieldState.visible !== false; // 默认显示
|
||||
const isDisabled = fieldState.disabled || field.disabled || false;
|
||||
const isRequired = fieldState.required !== undefined ? fieldState.required : field.required;
|
||||
const computedState = computeFieldState(field, fieldState);
|
||||
|
||||
// 如果字段被隐藏,不渲染
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
// 如果字段被隐藏,使用隐藏字段组件
|
||||
if (!computedState.isVisible) {
|
||||
return (
|
||||
<HiddenField
|
||||
key={field.id}
|
||||
fieldName={field.name}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 合并验证规则
|
||||
const allRules = mergeValidationRules(field, isRequired);
|
||||
const allRules = mergeValidationRules(field, computedState.isRequired);
|
||||
|
||||
// 普通表单组件使用 Form.Item 包裹
|
||||
return (
|
||||
@ -81,7 +100,11 @@ const FormFieldsRenderer: React.FC<FormFieldsRendererProps> = ({
|
||||
rules={allRules}
|
||||
>
|
||||
<FieldRenderer
|
||||
field={{ ...field, disabled: isDisabled }}
|
||||
field={{
|
||||
...field,
|
||||
disabled: computedState.isDisabled,
|
||||
readonly: computedState.isReadonly
|
||||
}}
|
||||
isPreview={true}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@ -8,6 +8,7 @@ import { useDroppable } from '@dnd-kit/core';
|
||||
import { Row, Col, Button, Space, Tooltip } from 'antd';
|
||||
import { DeleteOutlined, CopyOutlined, PlusOutlined, ExclamationCircleOutlined } from '@ant-design/icons';
|
||||
import type { FieldConfig } from '../types';
|
||||
import { computeGridColSpan } from '../utils/formLayoutHelper';
|
||||
import FieldRenderer from './FieldRenderer';
|
||||
|
||||
interface GridFieldProps {
|
||||
@ -38,20 +39,12 @@ const GridField: React.FC<GridFieldProps> = ({
|
||||
const columns = field.columns || 2;
|
||||
const children = field.children || Array(columns).fill([]);
|
||||
|
||||
// 使用自定义列宽度或平均分配
|
||||
const getColSpan = (colIndex: number) => {
|
||||
if (field.columnSpans && field.columnSpans.length > colIndex) {
|
||||
return field.columnSpans[colIndex];
|
||||
}
|
||||
return 24 / columns; // 平均分配
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '8px 0', width: '100%' }}>
|
||||
<Row gutter={field.gutter || 16}>
|
||||
{children.map((columnFields, colIndex) => {
|
||||
const dropId = `grid-${field.id}-col-${colIndex}`;
|
||||
const colSpan = getColSpan(colIndex);
|
||||
const colSpan = computeGridColSpan(field.columnSpans, columns, colIndex);
|
||||
|
||||
return (
|
||||
<GridColumn
|
||||
|
||||
@ -5,10 +5,23 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Row, Col, Form } from 'antd';
|
||||
import type { Rule } from 'antd/es/form';
|
||||
import type { FieldConfig, FormConfig, ValidationRule } from '../types';
|
||||
import type { FieldConfig, FormConfig } from '../types';
|
||||
import { mergeValidationRules } from '../utils/validationHelper';
|
||||
import { computeFieldState, FieldClassifier } from '../utils/fieldStateHelper';
|
||||
import { computeGridColSpan } from '../utils/formLayoutHelper';
|
||||
import FieldRenderer from './FieldRenderer';
|
||||
|
||||
/**
|
||||
* 隐藏字段组件(内部使用)
|
||||
*/
|
||||
const HiddenField: React.FC<{ fieldName: string }> = ({ fieldName }) => {
|
||||
return (
|
||||
<Form.Item name={fieldName} noStyle>
|
||||
<input type="hidden" />
|
||||
</Form.Item>
|
||||
);
|
||||
};
|
||||
|
||||
interface GridFieldPreviewProps {
|
||||
field: FieldConfig;
|
||||
formConfig?: FormConfig;
|
||||
@ -21,62 +34,24 @@ const GridFieldPreview: React.FC<GridFieldPreviewProps> = ({
|
||||
const columns = field.columns || 2;
|
||||
const children = field.children || Array(columns).fill([]);
|
||||
|
||||
// 使用自定义列宽度或平均分配
|
||||
const getColSpan = (colIndex: number) => {
|
||||
if (field.columnSpans && field.columnSpans.length > colIndex) {
|
||||
return field.columnSpans[colIndex];
|
||||
}
|
||||
return 24 / columns; // 平均分配
|
||||
};
|
||||
|
||||
// 将验证规则转换为Ant Design的Rule数组(和FormPreview保持一致)
|
||||
const convertValidationRules = (validationRules?: ValidationRule[]): Rule[] => {
|
||||
if (!validationRules || validationRules.length === 0) return [];
|
||||
|
||||
return validationRules.map(rule => {
|
||||
const antdRule: Rule = {
|
||||
type: rule.type === 'email' ? 'email' : rule.type === 'url' ? 'url' : undefined,
|
||||
message: rule.message || `请输入正确的${rule.type}`,
|
||||
};
|
||||
|
||||
switch (rule.type) {
|
||||
case 'required':
|
||||
antdRule.required = true;
|
||||
break;
|
||||
case 'pattern':
|
||||
if (rule.value) {
|
||||
antdRule.pattern = new RegExp(rule.value);
|
||||
}
|
||||
break;
|
||||
case 'min':
|
||||
antdRule.type = 'number';
|
||||
antdRule.min = rule.value;
|
||||
break;
|
||||
case 'max':
|
||||
antdRule.type = 'number';
|
||||
antdRule.max = rule.value;
|
||||
break;
|
||||
case 'minLength':
|
||||
antdRule.min = rule.value;
|
||||
break;
|
||||
case 'maxLength':
|
||||
antdRule.max = rule.value;
|
||||
break;
|
||||
case 'phone':
|
||||
antdRule.pattern = /^1[3-9]\d{9}$/;
|
||||
break;
|
||||
case 'idCard':
|
||||
antdRule.pattern = /^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dX]$/;
|
||||
break;
|
||||
}
|
||||
|
||||
return antdRule;
|
||||
});
|
||||
};
|
||||
|
||||
const renderFieldItem = (childField: FieldConfig) => {
|
||||
// 布局组件直接渲染,不需要 Form.Item
|
||||
if (['text', 'divider', 'grid'].includes(childField.type)) {
|
||||
// 计算字段最终状态(这里联动规则状态需要从外层传入,暂时只使用字段自身属性)
|
||||
// TODO: 如果需要支持栅格内的联动规则,需要将 fieldStates 作为 props 传入
|
||||
const computedState = computeFieldState(childField);
|
||||
|
||||
// 如果字段被隐藏,使用隐藏字段组件
|
||||
if (!computedState.isVisible) {
|
||||
return (
|
||||
<HiddenField
|
||||
key={childField.id}
|
||||
fieldName={childField.name}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 使用统一的字段分类器判断布局组件
|
||||
const fieldType = FieldClassifier.classify(childField);
|
||||
if (fieldType === 'layout' || fieldType === 'grid') {
|
||||
return (
|
||||
<div key={childField.id} style={{ marginBottom: 8 }}>
|
||||
<FieldRenderer field={childField} isPreview={true} />
|
||||
@ -87,25 +62,10 @@ const GridFieldPreview: React.FC<GridFieldPreviewProps> = ({
|
||||
// 根据表单配置决定布局方式
|
||||
const isVertical = formConfig?.labelAlign === 'top';
|
||||
|
||||
// 合并基础验证规则和自定义验证规则
|
||||
const customRules = convertValidationRules(childField.validationRules);
|
||||
|
||||
// 检查自定义验证规则中是否已经包含必填验证
|
||||
const hasRequiredRule = childField.validationRules?.some(rule => rule.type === 'required');
|
||||
|
||||
// 基础规则:只有在没有自定义必填验证时,才使用字段属性中的"是否必填"
|
||||
const baseRules: Rule[] = [];
|
||||
if (childField.required && !hasRequiredRule) {
|
||||
baseRules.push({
|
||||
required: true,
|
||||
message: `请输入${childField.label}`,
|
||||
});
|
||||
}
|
||||
|
||||
const allRules = [...baseRules, ...customRules];
|
||||
// 合并验证规则(使用统一的工具函数)
|
||||
const allRules = mergeValidationRules(childField, computedState.isRequired);
|
||||
|
||||
// 普通表单字段用 Form.Item 包裹
|
||||
// Form.Item 会自动管理 value 和 onChange,不需要手动传递
|
||||
return (
|
||||
<Form.Item
|
||||
key={childField.id}
|
||||
@ -117,7 +77,11 @@ const GridFieldPreview: React.FC<GridFieldPreviewProps> = ({
|
||||
rules={allRules}
|
||||
>
|
||||
<FieldRenderer
|
||||
field={childField}
|
||||
field={{
|
||||
...childField,
|
||||
disabled: computedState.isDisabled,
|
||||
readonly: computedState.isReadonly,
|
||||
}}
|
||||
isPreview={true}
|
||||
/>
|
||||
</Form.Item>
|
||||
@ -128,7 +92,7 @@ const GridFieldPreview: React.FC<GridFieldPreviewProps> = ({
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<Row gutter={field.gutter || 16}>
|
||||
{children.map((columnFields, colIndex) => {
|
||||
const colSpan = getColSpan(colIndex);
|
||||
const colSpan = computeGridColSpan(field.columnSpans, columns, colIndex);
|
||||
return (
|
||||
<Col key={colIndex} span={colSpan}>
|
||||
{columnFields.map((childField: FieldConfig) => renderFieldItem(childField))}
|
||||
|
||||
@ -278,6 +278,14 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="是否隐藏" name="hidden" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="是否只读" name="readonly" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="默认值" name="defaultValue">
|
||||
{selectedField.type === 'number' ? (
|
||||
<InputNumber style={{ width: '100%' }} placeholder="请输入默认值" />
|
||||
|
||||
@ -5,9 +5,40 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { FormInstance } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import type { FieldConfig } from '../types';
|
||||
import { calculateLinkageStates, evaluateCondition, flattenFields, type FieldState } from '../utils/linkageHelper';
|
||||
import { collectDefaultValues } from '../utils/defaultValueHelper';
|
||||
|
||||
/**
|
||||
* 收集字段的默认值(内部使用)
|
||||
*/
|
||||
const collectDefaultValues = (fieldList: FieldConfig[]): Record<string, any> => {
|
||||
const defaultValues: Record<string, any> = {};
|
||||
|
||||
const collect = (fields: FieldConfig[]) => {
|
||||
fields.forEach(field => {
|
||||
// 处理有默认值的字段
|
||||
if (field.defaultValue !== undefined && field.name) {
|
||||
// 日期字段需要转换为 dayjs 对象
|
||||
if (field.type === 'date' && typeof field.defaultValue === 'string') {
|
||||
defaultValues[field.name] = dayjs(field.defaultValue);
|
||||
} else {
|
||||
defaultValues[field.name] = field.defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
// 递归处理栅格布局内的字段
|
||||
if (field.type === 'grid' && field.children) {
|
||||
field.children.forEach(columnFields => {
|
||||
collect(columnFields);
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
collect(fieldList);
|
||||
return defaultValues;
|
||||
};
|
||||
|
||||
export interface UseFormCoreOptions {
|
||||
/** 字段列表 */
|
||||
|
||||
@ -0,0 +1,71 @@
|
||||
/**
|
||||
* 字段渲染策略注册表
|
||||
* 工厂模式:统一管理所有字段类型的渲染策略
|
||||
*/
|
||||
|
||||
import type { FieldType } from '../types';
|
||||
import type { IFieldRenderStrategy } from './IFieldRenderStrategy';
|
||||
import { InputStrategy } from './strategies/InputStrategy';
|
||||
import { TextAreaStrategy } from './strategies/TextAreaStrategy';
|
||||
import { NumberStrategy } from './strategies/NumberStrategy';
|
||||
import { SelectStrategy } from './strategies/SelectStrategy';
|
||||
import { RadioStrategy } from './strategies/RadioStrategy';
|
||||
import { CheckboxStrategy } from './strategies/CheckboxStrategy';
|
||||
import { DateStrategy } from './strategies/DateStrategy';
|
||||
import { DateTimeStrategy } from './strategies/DateTimeStrategy';
|
||||
import { TimeStrategy } from './strategies/TimeStrategy';
|
||||
import { SwitchStrategy } from './strategies/SwitchStrategy';
|
||||
import { SliderStrategy } from './strategies/SliderStrategy';
|
||||
import { RateStrategy } from './strategies/RateStrategy';
|
||||
import { UploadStrategy } from './strategies/UploadStrategy';
|
||||
import { CascaderStrategy } from './strategies/CascaderStrategy';
|
||||
|
||||
/**
|
||||
* 策略注册表
|
||||
*/
|
||||
class FieldRenderStrategyRegistry {
|
||||
private strategies = new Map<FieldType, IFieldRenderStrategy>();
|
||||
|
||||
constructor() {
|
||||
// 注册所有内置策略
|
||||
this.register('input', new InputStrategy());
|
||||
this.register('textarea', new TextAreaStrategy());
|
||||
this.register('number', new NumberStrategy());
|
||||
this.register('select', new SelectStrategy());
|
||||
this.register('radio', new RadioStrategy());
|
||||
this.register('checkbox', new CheckboxStrategy());
|
||||
this.register('date', new DateStrategy());
|
||||
this.register('datetime', new DateTimeStrategy());
|
||||
this.register('time', new TimeStrategy());
|
||||
this.register('switch', new SwitchStrategy());
|
||||
this.register('slider', new SliderStrategy());
|
||||
this.register('rate', new RateStrategy());
|
||||
this.register('upload', new UploadStrategy());
|
||||
this.register('cascader', new CascaderStrategy());
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册策略
|
||||
*/
|
||||
register(fieldType: FieldType, strategy: IFieldRenderStrategy): void {
|
||||
this.strategies.set(fieldType, strategy);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取策略(工厂方法)
|
||||
*/
|
||||
get(fieldType: FieldType): IFieldRenderStrategy | undefined {
|
||||
return this.strategies.get(fieldType);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否已注册
|
||||
*/
|
||||
has(fieldType: FieldType): boolean {
|
||||
return this.strategies.has(fieldType);
|
||||
}
|
||||
}
|
||||
|
||||
// 单例模式:全局唯一的注册表实例
|
||||
export const fieldRenderStrategyRegistry = new FieldRenderStrategyRegistry();
|
||||
|
||||
@ -0,0 +1,34 @@
|
||||
/**
|
||||
* 字段渲染策略接口
|
||||
* 策略模式:每种字段类型有独立的渲染策略
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type { FieldConfig } from '../types';
|
||||
|
||||
/**
|
||||
* 字段渲染上下文
|
||||
*/
|
||||
export interface FieldRenderContext {
|
||||
field: FieldConfig;
|
||||
value?: any;
|
||||
onChange?: (value: any) => void;
|
||||
isDisabled: boolean;
|
||||
isReadonly: boolean;
|
||||
readonlyStyle?: React.CSSProperties;
|
||||
options?: any[];
|
||||
cascadeOptions?: any[];
|
||||
loadData?: (selectedOptions: any[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 字段渲染策略接口
|
||||
*/
|
||||
export interface IFieldRenderStrategy {
|
||||
/** 渲染字段组件 */
|
||||
render(context: FieldRenderContext): React.ReactNode;
|
||||
|
||||
/** 是否支持只读属性(如果不支持,将使用 disabled) */
|
||||
supportsReadonly?: boolean;
|
||||
}
|
||||
|
||||
@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 基础字段策略基类
|
||||
* 提供通用的只读和禁用处理逻辑
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type { IFieldRenderStrategy, FieldRenderContext } from '../IFieldRenderStrategy';
|
||||
|
||||
export abstract class BaseFieldStrategy implements IFieldRenderStrategy {
|
||||
supportsReadonly = false;
|
||||
|
||||
/**
|
||||
* 获取组件的禁用/只读状态
|
||||
*/
|
||||
protected getDisabledProps(context: FieldRenderContext): { disabled?: boolean; readOnly?: boolean } {
|
||||
const { isDisabled, isReadonly } = context;
|
||||
const supportsReadonly = this.supportsReadonly ?? false;
|
||||
|
||||
if (supportsReadonly && isReadonly) {
|
||||
return { readOnly: true, disabled: false };
|
||||
}
|
||||
|
||||
return { disabled: isDisabled || isReadonly };
|
||||
}
|
||||
|
||||
/**
|
||||
* 抽象方法:子类实现具体的渲染逻辑
|
||||
*/
|
||||
abstract render(context: FieldRenderContext): React.ReactNode;
|
||||
}
|
||||
|
||||
@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Cascader 字段渲染策略
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Cascader } from 'antd';
|
||||
import { BaseFieldStrategy } from './BaseFieldStrategy';
|
||||
import type { FieldRenderContext } from '../IFieldRenderStrategy';
|
||||
|
||||
export class CascaderStrategy extends BaseFieldStrategy {
|
||||
supportsReadonly = false;
|
||||
|
||||
render(context: FieldRenderContext): React.ReactNode {
|
||||
const { field, value, onChange, cascadeOptions, loadData } = context;
|
||||
const disabledProps = this.getDisabledProps(context);
|
||||
|
||||
return (
|
||||
<Cascader
|
||||
style={{ width: '100%' }}
|
||||
placeholder={field.placeholder}
|
||||
{...disabledProps}
|
||||
options={cascadeOptions as any}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
loadData={loadData as any}
|
||||
changeOnSelect={field.dataSourceType === 'predefined'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Checkbox 字段渲染策略
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Checkbox } from 'antd';
|
||||
import { BaseFieldStrategy } from './BaseFieldStrategy';
|
||||
import type { FieldRenderContext } from '../IFieldRenderStrategy';
|
||||
|
||||
export class CheckboxStrategy extends BaseFieldStrategy {
|
||||
supportsReadonly = false;
|
||||
|
||||
render(context: FieldRenderContext): React.ReactNode {
|
||||
const { field, value, onChange, options } = context;
|
||||
const disabledProps = this.getDisabledProps(context);
|
||||
|
||||
return (
|
||||
<Checkbox.Group
|
||||
{...disabledProps}
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: field.checkboxLayout === 'vertical' ? 'column' : 'row',
|
||||
gap: field.checkboxLayout === 'vertical' ? '8px' : '16px',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Date 字段渲染策略
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { DatePicker } from 'antd';
|
||||
import { BaseFieldStrategy } from './BaseFieldStrategy';
|
||||
import type { FieldRenderContext } from '../IFieldRenderStrategy';
|
||||
|
||||
export class DateStrategy extends BaseFieldStrategy {
|
||||
supportsReadonly = false;
|
||||
|
||||
render(context: FieldRenderContext): React.ReactNode {
|
||||
const { field, value, onChange } = context;
|
||||
const disabledProps = this.getDisabledProps(context);
|
||||
|
||||
return (
|
||||
<DatePicker
|
||||
style={{ width: '100%' }}
|
||||
placeholder={field.placeholder}
|
||||
{...disabledProps}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,29 @@
|
||||
/**
|
||||
* DateTime 字段渲染策略
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { DatePicker } from 'antd';
|
||||
import { BaseFieldStrategy } from './BaseFieldStrategy';
|
||||
import type { FieldRenderContext } from '../IFieldRenderStrategy';
|
||||
|
||||
export class DateTimeStrategy extends BaseFieldStrategy {
|
||||
supportsReadonly = false;
|
||||
|
||||
render(context: FieldRenderContext): React.ReactNode {
|
||||
const { field, value, onChange } = context;
|
||||
const disabledProps = this.getDisabledProps(context);
|
||||
|
||||
return (
|
||||
<DatePicker
|
||||
showTime
|
||||
style={{ width: '100%' }}
|
||||
placeholder={field.placeholder}
|
||||
{...disabledProps}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Input 字段渲染策略
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Input } from 'antd';
|
||||
import { BaseFieldStrategy } from './BaseFieldStrategy';
|
||||
import type { FieldRenderContext } from '../IFieldRenderStrategy';
|
||||
|
||||
export class InputStrategy extends BaseFieldStrategy {
|
||||
supportsReadonly = true;
|
||||
|
||||
render(context: FieldRenderContext): React.ReactNode {
|
||||
const { field, value, onChange, isReadonly, readonlyStyle } = context;
|
||||
const disabledProps = this.getDisabledProps(context);
|
||||
|
||||
return (
|
||||
<Input
|
||||
placeholder={field.placeholder}
|
||||
{...disabledProps}
|
||||
style={isReadonly ? readonlyStyle : undefined}
|
||||
value={value}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Number 字段渲染策略
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { InputNumber } from 'antd';
|
||||
import { BaseFieldStrategy } from './BaseFieldStrategy';
|
||||
import type { FieldRenderContext } from '../IFieldRenderStrategy';
|
||||
|
||||
export class NumberStrategy extends BaseFieldStrategy {
|
||||
supportsReadonly = false; // InputNumber 不支持 readOnly
|
||||
|
||||
render(context: FieldRenderContext): React.ReactNode {
|
||||
const { field, value, onChange } = context;
|
||||
const disabledProps = this.getDisabledProps(context);
|
||||
|
||||
return (
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
placeholder={field.placeholder}
|
||||
{...disabledProps}
|
||||
min={field.min}
|
||||
max={field.max}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Radio 字段渲染策略
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Radio } from 'antd';
|
||||
import { BaseFieldStrategy } from './BaseFieldStrategy';
|
||||
import type { FieldRenderContext } from '../IFieldRenderStrategy';
|
||||
|
||||
export class RadioStrategy extends BaseFieldStrategy {
|
||||
supportsReadonly = false;
|
||||
|
||||
render(context: FieldRenderContext): React.ReactNode {
|
||||
const { value, onChange, options } = context;
|
||||
const disabledProps = this.getDisabledProps(context);
|
||||
|
||||
return (
|
||||
<Radio.Group
|
||||
{...disabledProps}
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Rate 字段渲染策略
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Rate } from 'antd';
|
||||
import { BaseFieldStrategy } from './BaseFieldStrategy';
|
||||
import type { FieldRenderContext } from '../IFieldRenderStrategy';
|
||||
|
||||
export class RateStrategy extends BaseFieldStrategy {
|
||||
supportsReadonly = false;
|
||||
|
||||
render(context: FieldRenderContext): React.ReactNode {
|
||||
const { value, onChange } = context;
|
||||
const disabledProps = this.getDisabledProps(context);
|
||||
|
||||
return (
|
||||
<Rate
|
||||
{...disabledProps}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Select 字段渲染策略
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Select } from 'antd';
|
||||
import { BaseFieldStrategy } from './BaseFieldStrategy';
|
||||
import type { FieldRenderContext } from '../IFieldRenderStrategy';
|
||||
|
||||
export class SelectStrategy extends BaseFieldStrategy {
|
||||
supportsReadonly = false;
|
||||
|
||||
render(context: FieldRenderContext): React.ReactNode {
|
||||
const { field, value, onChange, options } = context;
|
||||
const disabledProps = this.getDisabledProps(context);
|
||||
|
||||
return (
|
||||
<Select
|
||||
placeholder={field.placeholder}
|
||||
{...disabledProps}
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Slider 字段渲染策略
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Slider } from 'antd';
|
||||
import { BaseFieldStrategy } from './BaseFieldStrategy';
|
||||
import type { FieldRenderContext } from '../IFieldRenderStrategy';
|
||||
|
||||
export class SliderStrategy extends BaseFieldStrategy {
|
||||
supportsReadonly = false;
|
||||
|
||||
render(context: FieldRenderContext): React.ReactNode {
|
||||
const { field, value, onChange } = context;
|
||||
const disabledProps = this.getDisabledProps(context);
|
||||
|
||||
return (
|
||||
<Slider
|
||||
{...disabledProps}
|
||||
min={field.min}
|
||||
max={field.max}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Switch 字段渲染策略
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Switch } from 'antd';
|
||||
import { BaseFieldStrategy } from './BaseFieldStrategy';
|
||||
import type { FieldRenderContext } from '../IFieldRenderStrategy';
|
||||
|
||||
export class SwitchStrategy extends BaseFieldStrategy {
|
||||
supportsReadonly = false;
|
||||
|
||||
render(context: FieldRenderContext): React.ReactNode {
|
||||
const { value, onChange } = context;
|
||||
const disabledProps = this.getDisabledProps(context);
|
||||
|
||||
return (
|
||||
<Switch
|
||||
{...disabledProps}
|
||||
checked={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,31 @@
|
||||
/**
|
||||
* TextArea 字段渲染策略
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Input } from 'antd';
|
||||
import { BaseFieldStrategy } from './BaseFieldStrategy';
|
||||
import type { FieldRenderContext } from '../IFieldRenderStrategy';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
export class TextAreaStrategy extends BaseFieldStrategy {
|
||||
supportsReadonly = true;
|
||||
|
||||
render(context: FieldRenderContext): React.ReactNode {
|
||||
const { field, value, onChange, isReadonly, readonlyStyle } = context;
|
||||
const disabledProps = this.getDisabledProps(context);
|
||||
|
||||
return (
|
||||
<TextArea
|
||||
rows={field.rows || 4}
|
||||
placeholder={field.placeholder}
|
||||
{...disabledProps}
|
||||
style={isReadonly ? readonlyStyle : undefined}
|
||||
value={value}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Time 字段渲染策略
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { TimePicker } from 'antd';
|
||||
import { BaseFieldStrategy } from './BaseFieldStrategy';
|
||||
import type { FieldRenderContext } from '../IFieldRenderStrategy';
|
||||
|
||||
export class TimeStrategy extends BaseFieldStrategy {
|
||||
supportsReadonly = false;
|
||||
|
||||
render(context: FieldRenderContext): React.ReactNode {
|
||||
const { field, value, onChange } = context;
|
||||
const disabledProps = this.getDisabledProps(context);
|
||||
|
||||
return (
|
||||
<TimePicker
|
||||
style={{ width: '100%' }}
|
||||
placeholder={field.placeholder}
|
||||
{...disabledProps}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Upload 字段渲染策略
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Upload, Button } from 'antd';
|
||||
import { UploadOutlined } from '@ant-design/icons';
|
||||
import { BaseFieldStrategy } from './BaseFieldStrategy';
|
||||
import type { FieldRenderContext } from '../IFieldRenderStrategy';
|
||||
|
||||
export class UploadStrategy extends BaseFieldStrategy {
|
||||
supportsReadonly = false;
|
||||
|
||||
render(context: FieldRenderContext): React.ReactNode {
|
||||
const { field } = context;
|
||||
const disabledProps = this.getDisabledProps(context);
|
||||
|
||||
return (
|
||||
<Upload
|
||||
{...disabledProps}
|
||||
maxCount={field.maxCount}
|
||||
>
|
||||
<Button icon={<UploadOutlined />}>上传文件</Button>
|
||||
</Upload>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -363,3 +363,24 @@
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 只读字段样式:灰色背景,不可编辑 */
|
||||
.ant-input[readonly],
|
||||
.ant-input:read-only {
|
||||
background-color: #f5f5f5 !important;
|
||||
cursor: not-allowed !important;
|
||||
color: #00000040 !important;
|
||||
}
|
||||
|
||||
.ant-input-number-disabled {
|
||||
background-color: #f5f5f5 !important;
|
||||
cursor: not-allowed !important;
|
||||
color: #00000040 !important;
|
||||
}
|
||||
|
||||
textarea.ant-input[readonly],
|
||||
textarea.ant-input:read-only {
|
||||
background-color: #f5f5f5 !important;
|
||||
cursor: not-allowed !important;
|
||||
color: #00000040 !important;
|
||||
}
|
||||
|
||||
|
||||
@ -115,6 +115,8 @@ export interface FieldConfig {
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
hidden?: boolean; // 是否隐藏字段(隐藏字段不显示,但仍参与表单提交)
|
||||
readonly?: boolean; // 是否只读(只读字段显示但不可编辑)
|
||||
defaultValue?: any;
|
||||
options?: FieldOption[]; // 静态选项数据(select, radio, checkbox 使用)
|
||||
cascadeOptions?: CascadeFieldOption[]; // 静态级联选项数据(cascader 静态模式使用)
|
||||
|
||||
@ -1,41 +0,0 @@
|
||||
/**
|
||||
* 表单默认值处理工具
|
||||
* 处理字段的默认值,包括日期类型的特殊处理
|
||||
*/
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
import type { FieldConfig } from '../types';
|
||||
|
||||
/**
|
||||
* 收集字段的默认值
|
||||
* @param fieldList 字段列表
|
||||
* @returns 默认值对象
|
||||
*/
|
||||
export const collectDefaultValues = (fieldList: FieldConfig[]): Record<string, any> => {
|
||||
const defaultValues: Record<string, any> = {};
|
||||
|
||||
const collect = (fields: FieldConfig[]) => {
|
||||
fields.forEach(field => {
|
||||
// 处理有默认值的字段
|
||||
if (field.defaultValue !== undefined && field.name) {
|
||||
// 日期字段需要转换为 dayjs 对象
|
||||
if (field.type === 'date' && typeof field.defaultValue === 'string') {
|
||||
defaultValues[field.name] = dayjs(field.defaultValue);
|
||||
} else {
|
||||
defaultValues[field.name] = field.defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
// 递归处理栅格布局内的字段
|
||||
if (field.type === 'grid' && field.children) {
|
||||
field.children.forEach(columnFields => {
|
||||
collect(columnFields);
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
collect(fieldList);
|
||||
return defaultValues;
|
||||
};
|
||||
|
||||
@ -0,0 +1,73 @@
|
||||
/**
|
||||
* 字段状态工具
|
||||
* 统一计算和处理字段的显示状态、禁用状态、只读状态等
|
||||
*/
|
||||
|
||||
import type { FieldConfig } from '../types';
|
||||
import type { FieldState } from './linkageHelper';
|
||||
|
||||
/**
|
||||
* 计算字段的最终状态
|
||||
* @param field 字段配置
|
||||
* @param fieldState 联动规则计算的字段状态
|
||||
* @returns 最终字段状态
|
||||
*/
|
||||
export interface ComputedFieldState {
|
||||
isVisible: boolean; // 是否可见(考虑 hidden 和联动规则)
|
||||
isDisabled: boolean; // 是否禁用(考虑 disabled 和联动规则)
|
||||
isReadonly: boolean; // 是否只读
|
||||
isRequired: boolean; // 是否必填(考虑 required 和联动规则)
|
||||
}
|
||||
|
||||
export const computeFieldState = (
|
||||
field: FieldConfig,
|
||||
fieldState: FieldState = {}
|
||||
): ComputedFieldState => {
|
||||
// 可见性:联动规则的 visible 优先级高于字段的 hidden
|
||||
// 如果联动规则设置为 visible=false,则隐藏;否则检查字段的 hidden 属性
|
||||
const isVisible = fieldState.visible !== false && !field.hidden;
|
||||
|
||||
// 禁用状态:联动规则的 disabled 或字段的 disabled
|
||||
const isDisabled = fieldState.disabled || field.disabled || false;
|
||||
|
||||
// 只读状态:字段的 readonly(不受联动规则影响)
|
||||
const isReadonly = field.readonly || false;
|
||||
|
||||
// 必填状态:联动规则的 required 优先,否则使用字段的 required
|
||||
const isRequired = fieldState.required !== undefined
|
||||
? fieldState.required
|
||||
: field.required || false;
|
||||
|
||||
return {
|
||||
isVisible,
|
||||
isDisabled,
|
||||
isReadonly,
|
||||
isRequired,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 字段分类器:根据字段类型判断应该使用哪个渲染方式
|
||||
*/
|
||||
export class FieldClassifier {
|
||||
/**
|
||||
* 判断字段类型
|
||||
* @returns 'layout' | 'grid' | 'field'
|
||||
* 注意:hidden 字段的判断在 FormFieldsRenderer 中通过 computeFieldState 完成
|
||||
*/
|
||||
static classify(field: FieldConfig): 'layout' | 'grid' | 'field' {
|
||||
// 布局字段:文本、分割线
|
||||
if (field.type === 'text' || field.type === 'divider') {
|
||||
return 'layout';
|
||||
}
|
||||
|
||||
// 栅格布局
|
||||
if (field.type === 'grid') {
|
||||
return 'grid';
|
||||
}
|
||||
|
||||
// 普通表单字段
|
||||
return 'field';
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,60 @@
|
||||
/**
|
||||
* 表单布局工具
|
||||
* 统一处理表单布局相关的计算逻辑
|
||||
*/
|
||||
|
||||
import type { FormConfig } from '../types';
|
||||
|
||||
/**
|
||||
* 计算表单布局配置
|
||||
* @param formConfig 表单配置
|
||||
* @returns 表单布局相关配置
|
||||
*/
|
||||
export interface FormLayoutConfig {
|
||||
/** Form layout 属性 */
|
||||
layout: 'vertical' | 'horizontal';
|
||||
/** labelCol 配置 */
|
||||
labelCol?: { span: number };
|
||||
/** wrapperCol 配置 */
|
||||
wrapperCol?: { span: number };
|
||||
/** labelAlign 属性 */
|
||||
labelAlign?: 'left' | 'right';
|
||||
}
|
||||
|
||||
export const computeFormLayout = (formConfig: FormConfig): FormLayoutConfig => {
|
||||
const layout = formConfig.labelAlign === 'top' ? 'vertical' : 'horizontal';
|
||||
|
||||
let labelCol: { span: number } | undefined;
|
||||
let wrapperCol: { span: number } | undefined;
|
||||
|
||||
if (formConfig.labelAlign === 'left' || formConfig.labelAlign === 'right') {
|
||||
labelCol = { span: 6 };
|
||||
wrapperCol = { span: 18 };
|
||||
}
|
||||
|
||||
return {
|
||||
layout,
|
||||
labelCol,
|
||||
wrapperCol,
|
||||
labelAlign: formConfig.labelAlign === 'top' ? undefined : formConfig.labelAlign,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 计算栅格列的宽度
|
||||
* @param columnSpans 自定义列宽度数组
|
||||
* @param columns 列数
|
||||
* @param colIndex 当前列索引
|
||||
* @returns 列宽度(0-24)
|
||||
*/
|
||||
export const computeGridColSpan = (
|
||||
columnSpans: number[] | undefined,
|
||||
columns: number,
|
||||
colIndex: number
|
||||
): number => {
|
||||
if (columnSpans && columnSpans.length > colIndex) {
|
||||
return columnSpans[colIndex];
|
||||
}
|
||||
return Math.floor(24 / columns);
|
||||
};
|
||||
|
||||
@ -2,7 +2,8 @@
|
||||
* 级联数据源注册表
|
||||
* 集中管理所有预设级联数据源配置
|
||||
*/
|
||||
import { CascadeDataSourceType, type CascadeDataSourceRegistry } from './types';
|
||||
import { CascadeDataSourceType, type CascadeDataSourceConfig, type CascadeDataSourceRegistry } from './types';
|
||||
import { BaseRegistry } from './core/BaseRegistry';
|
||||
import {
|
||||
environmentProjectsConfig,
|
||||
jenkinsServerViewsJobsConfig,
|
||||
@ -12,39 +13,46 @@ import {
|
||||
} from './presets/cascade';
|
||||
|
||||
/**
|
||||
* 级联数据源配置注册表
|
||||
* 级联数据源注册表实例
|
||||
*/
|
||||
export const CASCADE_DATA_SOURCE_REGISTRY: CascadeDataSourceRegistry = {
|
||||
[CascadeDataSourceType.ENVIRONMENT_PROJECTS]: environmentProjectsConfig,
|
||||
[CascadeDataSourceType.JENKINS_SERVER_VIEWS_JOBS]: jenkinsServerViewsJobsConfig,
|
||||
[CascadeDataSourceType.DEPARTMENT_USERS]: departmentUsersConfig,
|
||||
[CascadeDataSourceType.DEPARTMENT_TREE]: departmentTreeConfig,
|
||||
[CascadeDataSourceType.PROJECT_GROUP_APPS]: projectGroupAppsConfig
|
||||
};
|
||||
class CascadeDataSourceRegistryImpl extends BaseRegistry<CascadeDataSourceType, CascadeDataSourceConfig> {
|
||||
constructor() {
|
||||
super();
|
||||
// 注册所有预设配置
|
||||
this.register(CascadeDataSourceType.ENVIRONMENT_PROJECTS, environmentProjectsConfig);
|
||||
this.register(CascadeDataSourceType.JENKINS_SERVER_VIEWS_JOBS, jenkinsServerViewsJobsConfig);
|
||||
this.register(CascadeDataSourceType.DEPARTMENT_USERS, departmentUsersConfig);
|
||||
this.register(CascadeDataSourceType.DEPARTMENT_TREE, departmentTreeConfig);
|
||||
this.register(CascadeDataSourceType.PROJECT_GROUP_APPS, projectGroupAppsConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取注册表对象(向后兼容)
|
||||
*/
|
||||
toObject(): CascadeDataSourceRegistry {
|
||||
return this['registry'] as CascadeDataSourceRegistry;
|
||||
}
|
||||
}
|
||||
|
||||
const registryInstance = new CascadeDataSourceRegistryImpl();
|
||||
|
||||
/**
|
||||
* 级联数据源配置注册表(向后兼容)
|
||||
*/
|
||||
export const CASCADE_DATA_SOURCE_REGISTRY: CascadeDataSourceRegistry = registryInstance.toObject();
|
||||
|
||||
/**
|
||||
* 获取级联数据源配置
|
||||
* @param type 级联数据源类型
|
||||
* @returns 级联数据源配置,如果不存在则返回 undefined
|
||||
*/
|
||||
export const getCascadeDataSourceConfig = (type: CascadeDataSourceType) => {
|
||||
return CASCADE_DATA_SOURCE_REGISTRY[type];
|
||||
};
|
||||
export const getCascadeDataSourceConfig = (type: CascadeDataSourceType) => registryInstance.get(type);
|
||||
|
||||
/**
|
||||
* 检查级联数据源类型是否已注册
|
||||
* @param type 级联数据源类型
|
||||
* @returns 是否已注册
|
||||
*/
|
||||
export const hasCascadeDataSource = (type: CascadeDataSourceType): boolean => {
|
||||
return type in CASCADE_DATA_SOURCE_REGISTRY;
|
||||
};
|
||||
export const hasCascadeDataSource = (type: CascadeDataSourceType): boolean => registryInstance.has(type);
|
||||
|
||||
/**
|
||||
* 获取所有已注册的级联数据源类型
|
||||
* @returns 级联数据源类型列表
|
||||
*/
|
||||
export const getAllCascadeDataSourceTypes = (): CascadeDataSourceType[] => {
|
||||
return Object.keys(CASCADE_DATA_SOURCE_REGISTRY) as CascadeDataSourceType[];
|
||||
};
|
||||
export const getAllCascadeDataSourceTypes = (): CascadeDataSourceType[] => registryInstance.getAllKeys();
|
||||
|
||||
|
||||
@ -2,9 +2,10 @@
|
||||
* 级联数据源服务
|
||||
* 提供懒加载级联数据功能
|
||||
*/
|
||||
import request from '@/utils/request';
|
||||
import {CascadeDataSourceType, type CascadeOption, type CascadeLevelConfig} from './types';
|
||||
import {getCascadeDataSourceConfig} from './CascadeDataSourceRegistry';
|
||||
import { loadData } from './core/requestHelper';
|
||||
import { warn } from './core/errorHandler';
|
||||
|
||||
/**
|
||||
* 级联数据源服务类
|
||||
@ -26,7 +27,7 @@ class CascadeDataSourceService {
|
||||
const config = getCascadeDataSourceConfig(type);
|
||||
|
||||
if (!config) {
|
||||
console.error(`❌ 级联数据源类型 ${type} 未配置`);
|
||||
warn(`级联数据源类型 ${type} 未配置`);
|
||||
return [];
|
||||
}
|
||||
|
||||
@ -37,7 +38,7 @@ class CascadeDataSourceService {
|
||||
|
||||
// 固定层级模式
|
||||
if (!config.levels || config.levels.length === 0) {
|
||||
console.error(`❌ 级联数据源类型 ${type} 未配置层级`);
|
||||
warn(`级联数据源类型 ${type} 未配置层级`);
|
||||
return [];
|
||||
}
|
||||
|
||||
@ -57,7 +58,7 @@ class CascadeDataSourceService {
|
||||
const config = getCascadeDataSourceConfig(type);
|
||||
|
||||
if (!config) {
|
||||
console.error(`❌ 级联数据源类型 ${type} 未配置`);
|
||||
warn(`级联数据源类型 ${type} 未配置`);
|
||||
return [];
|
||||
}
|
||||
|
||||
@ -76,7 +77,7 @@ class CascadeDataSourceService {
|
||||
const levelIndex = selectedOptions.length;
|
||||
|
||||
if (levelIndex >= config.levels.length) {
|
||||
console.warn(`⚠️ 级联层级 ${levelIndex} 超出配置范围`);
|
||||
warn(`级联层级 ${levelIndex} 超出配置范围`);
|
||||
return [];
|
||||
}
|
||||
|
||||
@ -97,47 +98,45 @@ class CascadeDataSourceService {
|
||||
recursiveConfig: any,
|
||||
parentValue: any
|
||||
): Promise<CascadeOption[]> {
|
||||
try {
|
||||
// 构建请求参数
|
||||
const params: Record<string, any> = {...recursiveConfig.params};
|
||||
// 构建请求参数
|
||||
const params: Record<string, any> = {...recursiveConfig.params};
|
||||
|
||||
// 添加父级参数
|
||||
if (parentValue !== null) {
|
||||
params[recursiveConfig.parentParam] = parentValue;
|
||||
// 添加父级参数
|
||||
if (parentValue !== null) {
|
||||
params[recursiveConfig.parentParam] = parentValue;
|
||||
}
|
||||
|
||||
// 使用统一的请求工具加载数据
|
||||
const data = await loadData<any>(
|
||||
recursiveConfig.url,
|
||||
params,
|
||||
`递归级联数据 (${recursiveConfig.url})`
|
||||
);
|
||||
|
||||
// 转换为级联选项格式
|
||||
return data.map((item: any) => {
|
||||
const option: CascadeOption = {
|
||||
label: item[recursiveConfig.labelField],
|
||||
value: item[recursiveConfig.valueField]
|
||||
};
|
||||
|
||||
// 判断是否为叶子节点
|
||||
if (recursiveConfig.hasChildren) {
|
||||
// 使用自定义判断函数
|
||||
option.isLeaf = !recursiveConfig.hasChildren(item);
|
||||
} else if ('isLeaf' in item) {
|
||||
// 使用后端返回的 isLeaf 字段
|
||||
option.isLeaf = item.isLeaf;
|
||||
} else if ('hasChildren' in item) {
|
||||
// 使用后端返回的 hasChildren 字段
|
||||
option.isLeaf = !item.hasChildren;
|
||||
} else {
|
||||
// 默认不是叶子节点(允许继续展开)
|
||||
option.isLeaf = false;
|
||||
}
|
||||
|
||||
// 发起请求
|
||||
const response = await request.get(recursiveConfig.url, {params});
|
||||
const data = response || [];
|
||||
|
||||
// 转换为级联选项格式
|
||||
return data.map((item: any) => {
|
||||
const option: CascadeOption = {
|
||||
label: item[recursiveConfig.labelField],
|
||||
value: item[recursiveConfig.valueField]
|
||||
};
|
||||
|
||||
// 判断是否为叶子节点
|
||||
if (recursiveConfig.hasChildren) {
|
||||
// 使用自定义判断函数
|
||||
option.isLeaf = !recursiveConfig.hasChildren(item);
|
||||
} else if ('isLeaf' in item) {
|
||||
// 使用后端返回的 isLeaf 字段
|
||||
option.isLeaf = item.isLeaf;
|
||||
} else if ('hasChildren' in item) {
|
||||
// 使用后端返回的 hasChildren 字段
|
||||
option.isLeaf = !item.hasChildren;
|
||||
} else {
|
||||
// 默认不是叶子节点(允许继续展开)
|
||||
option.isLeaf = false;
|
||||
}
|
||||
|
||||
return option;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`❌ 加载递归级联数据失败:`, error);
|
||||
return [];
|
||||
}
|
||||
return option;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@ -152,40 +151,38 @@ class CascadeDataSourceService {
|
||||
parentValue: any,
|
||||
hasNextLevel: boolean
|
||||
): Promise<CascadeOption[]> {
|
||||
try {
|
||||
// 构建请求参数
|
||||
const params: Record<string, any> = {...levelConfig.params};
|
||||
// 构建请求参数
|
||||
const params: Record<string, any> = {...levelConfig.params};
|
||||
|
||||
// 如果有父级值且配置了父级参数名,添加到请求参数中
|
||||
if (parentValue !== null && levelConfig.parentParam) {
|
||||
params[levelConfig.parentParam] = parentValue;
|
||||
// 如果有父级值且配置了父级参数名,添加到请求参数中
|
||||
if (parentValue !== null && levelConfig.parentParam) {
|
||||
params[levelConfig.parentParam] = parentValue;
|
||||
}
|
||||
|
||||
// 使用统一的请求工具加载数据
|
||||
const data = await loadData<any>(
|
||||
levelConfig.url,
|
||||
params,
|
||||
`级联数据 (${levelConfig.url})`
|
||||
);
|
||||
|
||||
// 转换为级联选项格式
|
||||
return data.map((item: any) => {
|
||||
const option: CascadeOption = {
|
||||
label: item[levelConfig.labelField],
|
||||
value: item[levelConfig.valueField]
|
||||
};
|
||||
|
||||
// 判断是否为叶子节点
|
||||
if (levelConfig.isLeaf) {
|
||||
option.isLeaf = levelConfig.isLeaf(item);
|
||||
} else {
|
||||
// 如果没有下一级配置,则为叶子节点
|
||||
option.isLeaf = !hasNextLevel;
|
||||
}
|
||||
|
||||
// 发起请求
|
||||
const response = await request.get(levelConfig.url, {params});
|
||||
const data = response || [];
|
||||
|
||||
// 转换为级联选项格式
|
||||
return data.map((item: any) => {
|
||||
const option: CascadeOption = {
|
||||
label: item[levelConfig.labelField],
|
||||
value: item[levelConfig.valueField]
|
||||
};
|
||||
|
||||
// 判断是否为叶子节点
|
||||
if (levelConfig.isLeaf) {
|
||||
option.isLeaf = levelConfig.isLeaf(item);
|
||||
} else {
|
||||
// 如果没有下一级配置,则为叶子节点
|
||||
option.isLeaf = !hasNextLevel;
|
||||
}
|
||||
|
||||
return option;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`❌ 加载级联数据失败:`, error);
|
||||
return [];
|
||||
}
|
||||
return option;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -2,56 +2,71 @@
|
||||
* 数据源注册表
|
||||
* 集中管理所有预设数据源配置
|
||||
*/
|
||||
import { DataSourceType, type DataSourceRegistry } from './types';
|
||||
import { jenkinsServersConfig } from './presets/jenkins';
|
||||
import { k8sClustersConfig } from './presets/k8s';
|
||||
import { gitRepositoriesConfig } from './presets/git';
|
||||
import { dockerRegistriesConfig } from './presets/docker';
|
||||
import { notificationChannelTypesConfig, notificationChannelsConfig } from './presets/notification';
|
||||
import { usersConfig, rolesConfig, departmentsConfig } from './presets/user';
|
||||
import { environmentsConfig, projectGroupsConfig, applicationsConfig } from './presets/deploy';
|
||||
import { DataSourceType, type DataSourceConfig, type DataSourceRegistry } from './types';
|
||||
import { BaseRegistry } from './core/BaseRegistry';
|
||||
import {
|
||||
jenkinsServersConfig,
|
||||
k8sClustersConfig,
|
||||
gitRepositoriesConfig,
|
||||
dockerRegistriesConfig,
|
||||
environmentsConfig,
|
||||
projectGroupsConfig,
|
||||
applicationsConfig,
|
||||
notificationChannelTypesConfig,
|
||||
notificationChannelsConfig,
|
||||
usersConfig,
|
||||
rolesConfig,
|
||||
departmentsConfig
|
||||
} from './presets/infrastructure';
|
||||
|
||||
/**
|
||||
* 数据源配置注册表
|
||||
* 数据源注册表实例
|
||||
*/
|
||||
export const DATA_SOURCE_REGISTRY: DataSourceRegistry = {
|
||||
[DataSourceType.JENKINS_SERVERS]: jenkinsServersConfig,
|
||||
[DataSourceType.K8S_CLUSTERS]: k8sClustersConfig,
|
||||
[DataSourceType.GIT_REPOSITORIES]: gitRepositoriesConfig,
|
||||
[DataSourceType.DOCKER_REGISTRIES]: dockerRegistriesConfig,
|
||||
[DataSourceType.NOTIFICATION_CHANNEL_TYPES]: notificationChannelTypesConfig,
|
||||
[DataSourceType.NOTIFICATION_CHANNELS]: notificationChannelsConfig,
|
||||
[DataSourceType.USERS]: usersConfig,
|
||||
[DataSourceType.ROLES]: rolesConfig,
|
||||
[DataSourceType.DEPARTMENTS]: departmentsConfig,
|
||||
[DataSourceType.ENVIRONMENTS]: environmentsConfig,
|
||||
[DataSourceType.PROJECT_GROUPS]: projectGroupsConfig,
|
||||
[DataSourceType.APPLICATIONS]: applicationsConfig
|
||||
};
|
||||
class DataSourceRegistryImpl extends BaseRegistry<DataSourceType, DataSourceConfig> {
|
||||
constructor() {
|
||||
super();
|
||||
// 注册所有预设配置
|
||||
this.register(DataSourceType.JENKINS_SERVERS, jenkinsServersConfig);
|
||||
this.register(DataSourceType.K8S_CLUSTERS, k8sClustersConfig);
|
||||
this.register(DataSourceType.GIT_REPOSITORIES, gitRepositoriesConfig);
|
||||
this.register(DataSourceType.DOCKER_REGISTRIES, dockerRegistriesConfig);
|
||||
this.register(DataSourceType.NOTIFICATION_CHANNEL_TYPES, notificationChannelTypesConfig);
|
||||
this.register(DataSourceType.NOTIFICATION_CHANNELS, notificationChannelsConfig);
|
||||
this.register(DataSourceType.USERS, usersConfig);
|
||||
this.register(DataSourceType.ROLES, rolesConfig);
|
||||
this.register(DataSourceType.DEPARTMENTS, departmentsConfig);
|
||||
this.register(DataSourceType.ENVIRONMENTS, environmentsConfig);
|
||||
this.register(DataSourceType.PROJECT_GROUPS, projectGroupsConfig);
|
||||
this.register(DataSourceType.APPLICATIONS, applicationsConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取注册表对象(向后兼容)
|
||||
*/
|
||||
toObject(): DataSourceRegistry {
|
||||
return this['registry'] as DataSourceRegistry;
|
||||
}
|
||||
}
|
||||
|
||||
const registryInstance = new DataSourceRegistryImpl();
|
||||
|
||||
/**
|
||||
* 数据源配置注册表(向后兼容)
|
||||
*/
|
||||
export const DATA_SOURCE_REGISTRY: DataSourceRegistry = registryInstance.toObject();
|
||||
|
||||
/**
|
||||
* 获取数据源配置
|
||||
* @param type 数据源类型
|
||||
* @returns 数据源配置,如果不存在则返回 undefined
|
||||
*/
|
||||
export const getDataSourceConfig = (type: DataSourceType) => {
|
||||
return DATA_SOURCE_REGISTRY[type];
|
||||
};
|
||||
export const getDataSourceConfig = (type: DataSourceType) => registryInstance.get(type);
|
||||
|
||||
/**
|
||||
* 检查数据源类型是否已注册
|
||||
* @param type 数据源类型
|
||||
* @returns 是否已注册
|
||||
*/
|
||||
export const hasDataSource = (type: DataSourceType): boolean => {
|
||||
return type in DATA_SOURCE_REGISTRY;
|
||||
};
|
||||
export const hasDataSource = (type: DataSourceType): boolean => registryInstance.has(type);
|
||||
|
||||
/**
|
||||
* 获取所有已注册的数据源类型
|
||||
* @returns 数据源类型列表
|
||||
*/
|
||||
export const getAllDataSourceTypes = (): DataSourceType[] => {
|
||||
return Object.keys(DATA_SOURCE_REGISTRY) as DataSourceType[];
|
||||
};
|
||||
export const getAllDataSourceTypes = (): DataSourceType[] => registryInstance.getAllKeys();
|
||||
|
||||
|
||||
@ -2,9 +2,10 @@
|
||||
* 数据源服务
|
||||
* 提供数据源加载和管理功能
|
||||
*/
|
||||
import request from '@/utils/request';
|
||||
import {DataSourceType, type DataSourceOption} from './types';
|
||||
import {getDataSourceConfig, getAllDataSourceTypes} from './DataSourceRegistry';
|
||||
import { loadData } from './core/requestHelper';
|
||||
import { warn } from './core/errorHandler';
|
||||
|
||||
/**
|
||||
* 数据源服务类
|
||||
@ -20,7 +21,7 @@ class DataSourceService {
|
||||
|
||||
if (!config) {
|
||||
const registeredTypes = getAllDataSourceTypes();
|
||||
console.warn(`⚠️ 数据源类型 "${type}" 未配置,请检查:
|
||||
warn(`数据源类型 "${type}" 未配置,请检查:
|
||||
|
||||
📋 可能的原因:
|
||||
1. 节点定义中使用了字符串而非 DataSourceType 枚举(如 "jenkins-servers" 应改为 DataSourceType.JENKINS_SERVERS)
|
||||
@ -34,14 +35,9 @@ ${registeredTypes.map(t => ` - ${t}`).join('\n')}
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await request.get(config.url, {params: config.params});
|
||||
// request 拦截器已经提取了 data 字段,response 直接是数组
|
||||
return config.transform(response || []);
|
||||
} catch (error) {
|
||||
console.error(`❌ 加载数据源 ${type} 失败:`, error);
|
||||
return [];
|
||||
}
|
||||
// 使用统一的请求工具
|
||||
const data = await loadData<any>(config.url, config.params, `数据源 ${type}`);
|
||||
return config.transform(data);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
47
frontend/src/domain/dataSource/core/BaseRegistry.ts
Normal file
47
frontend/src/domain/dataSource/core/BaseRegistry.ts
Normal file
@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 注册表基类
|
||||
* 统一管理注册表的通用操作
|
||||
*/
|
||||
|
||||
/**
|
||||
* 通用注册表基类
|
||||
*/
|
||||
export class BaseRegistry<TKey extends string | number | symbol, TValue> {
|
||||
protected registry: Record<TKey, TValue> = {} as Record<TKey, TValue>;
|
||||
|
||||
/**
|
||||
* 注册配置
|
||||
*/
|
||||
register(key: TKey, value: TValue): void {
|
||||
this.registry[key] = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取配置
|
||||
*/
|
||||
get(key: TKey): TValue | undefined {
|
||||
return this.registry[key];
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否已注册
|
||||
*/
|
||||
has(key: TKey): boolean {
|
||||
return key in this.registry;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有已注册的键
|
||||
*/
|
||||
getAllKeys(): TKey[] {
|
||||
return Object.keys(this.registry) as TKey[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取注册表大小
|
||||
*/
|
||||
size(): number {
|
||||
return Object.keys(this.registry).length;
|
||||
}
|
||||
}
|
||||
|
||||
37
frontend/src/domain/dataSource/core/errorHandler.ts
Normal file
37
frontend/src/domain/dataSource/core/errorHandler.ts
Normal file
@ -0,0 +1,37 @@
|
||||
/**
|
||||
* 统一错误处理工具
|
||||
*/
|
||||
|
||||
/**
|
||||
* 错误处理选项
|
||||
*/
|
||||
export interface ErrorHandlerOptions {
|
||||
context: string;
|
||||
fallbackValue?: any;
|
||||
silent?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一错误处理函数
|
||||
*/
|
||||
export async function handleDataSourceError<T>(
|
||||
operation: () => Promise<T>,
|
||||
options: ErrorHandlerOptions
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
if (!options.silent) {
|
||||
console.error(`❌ ${options.context}失败:`, error);
|
||||
}
|
||||
return options.fallbackValue ?? ([] as T);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 警告处理函数
|
||||
*/
|
||||
export function warn(message: string): void {
|
||||
console.warn(`⚠️ ${message}`);
|
||||
}
|
||||
|
||||
33
frontend/src/domain/dataSource/core/requestHelper.ts
Normal file
33
frontend/src/domain/dataSource/core/requestHelper.ts
Normal file
@ -0,0 +1,33 @@
|
||||
/**
|
||||
* 请求工具函数
|
||||
* 统一处理数据源请求逻辑
|
||||
*/
|
||||
|
||||
import request from '@/utils/request';
|
||||
import { handleDataSourceError } from './errorHandler';
|
||||
|
||||
/**
|
||||
* 通用请求加载函数
|
||||
* @param url 请求地址
|
||||
* @param params 请求参数
|
||||
* @param context 错误上下文(用于错误日志)
|
||||
* @returns 数据数组
|
||||
*/
|
||||
export async function loadData<T>(
|
||||
url: string,
|
||||
params?: Record<string, any>,
|
||||
context?: string
|
||||
): Promise<T[]> {
|
||||
return handleDataSourceError(
|
||||
async () => {
|
||||
const response = await request.get(url, { params });
|
||||
// request 拦截器已经提取了 data 字段,response 直接是数组
|
||||
return (response || []) as T[];
|
||||
},
|
||||
{
|
||||
context: context || `加载数据 (${url})`,
|
||||
fallbackValue: []
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,55 +0,0 @@
|
||||
/**
|
||||
* 部署相关数据源
|
||||
*/
|
||||
import type { DataSourceConfig } from '../types';
|
||||
|
||||
/**
|
||||
* 环境列表数据源
|
||||
*/
|
||||
export const environmentsConfig: DataSourceConfig = {
|
||||
url: '/api/v1/environments/list',
|
||||
params: { enabled: true },
|
||||
transform: (data: any[]) => {
|
||||
return data.map((item: any) => ({
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
code: item.code,
|
||||
type: item.type,
|
||||
description: item.description
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 项目组列表数据源
|
||||
*/
|
||||
export const projectGroupsConfig: DataSourceConfig = {
|
||||
url: '/api/v1/project-groups/list',
|
||||
params: { enabled: true },
|
||||
transform: (data: any[]) => {
|
||||
return data.map((item: any) => ({
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
code: item.code,
|
||||
description: item.description
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 应用列表数据源
|
||||
*/
|
||||
export const applicationsConfig: DataSourceConfig = {
|
||||
url: '/api/v1/applications/list',
|
||||
params: { enabled: true },
|
||||
transform: (data: any[]) => {
|
||||
return data.map((item: any) => ({
|
||||
label: item.appName, // ✅ 修正:使用 appName
|
||||
value: item.appCode,
|
||||
code: item.appCode, // ✅ 修正:使用 appCode
|
||||
projectGroupId: item.projectGroupId,
|
||||
description: item.appDesc || item.description // ✅ 修正:使用 appDesc
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
@ -1,17 +0,0 @@
|
||||
/**
|
||||
* Docker 镜像仓库数据源
|
||||
*/
|
||||
import type { DataSourceConfig } from '../types';
|
||||
|
||||
export const dockerRegistriesConfig: DataSourceConfig = {
|
||||
url: '/api/v1/docker-registry/list',
|
||||
params: { enabled: true },
|
||||
transform: (data: any[]) => {
|
||||
return data.map((item: any) => ({
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
url: item.url
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
@ -1,17 +0,0 @@
|
||||
/**
|
||||
* Git 仓库数据源
|
||||
*/
|
||||
import type { DataSourceConfig } from '../types';
|
||||
|
||||
export const gitRepositoriesConfig: DataSourceConfig = {
|
||||
url: '/api/v1/git-repo/list',
|
||||
params: { enabled: true },
|
||||
transform: (data: any[]) => {
|
||||
return data.map((item: any) => ({
|
||||
label: `${item.name} (${item.url})`,
|
||||
value: item.id,
|
||||
url: item.url
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
190
frontend/src/domain/dataSource/presets/infrastructure.ts
Normal file
190
frontend/src/domain/dataSource/presets/infrastructure.ts
Normal file
@ -0,0 +1,190 @@
|
||||
/**
|
||||
* 基础设施、部署和用户相关数据源
|
||||
* 包含:基础设施(Docker、Git、K8s、Jenkins)、部署相关(环境、项目组、应用)、通知渠道、用户相关(用户、角色、部门)
|
||||
*/
|
||||
import type { DataSourceConfig } from '../types';
|
||||
|
||||
/**
|
||||
* Docker 镜像仓库数据源
|
||||
*/
|
||||
export const dockerRegistriesConfig: DataSourceConfig = {
|
||||
url: '/api/v1/docker-registry/list',
|
||||
params: { enabled: true },
|
||||
transform: (data: any[]) => {
|
||||
return data.map((item: any) => ({
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
url: item.url
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Git 仓库数据源
|
||||
*/
|
||||
export const gitRepositoriesConfig: DataSourceConfig = {
|
||||
url: '/api/v1/git-repo/list',
|
||||
params: { enabled: true },
|
||||
transform: (data: any[]) => {
|
||||
return data.map((item: any) => ({
|
||||
label: `${item.name} (${item.url})`,
|
||||
value: item.id,
|
||||
url: item.url
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Kubernetes 集群数据源
|
||||
*/
|
||||
export const k8sClustersConfig: DataSourceConfig = {
|
||||
url: '/api/v1/k8s-cluster/list',
|
||||
params: { enabled: true },
|
||||
transform: (data: any[]) => {
|
||||
return data.map((item: any) => ({
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
apiServer: item.apiServer
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Jenkins 服务器数据源
|
||||
*/
|
||||
export const jenkinsServersConfig: DataSourceConfig = {
|
||||
url: '/api/v1/external-system/list',
|
||||
params: { type: 'JENKINS', enabled: true },
|
||||
transform: (data: any[]) => {
|
||||
return data.map((item: any) => ({
|
||||
label: `${item.name} (${item.url})`,
|
||||
value: item.id,
|
||||
url: item.url,
|
||||
name: item.name
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 环境列表数据源
|
||||
*/
|
||||
export const environmentsConfig: DataSourceConfig = {
|
||||
url: '/api/v1/environments/list',
|
||||
params: { enabled: true },
|
||||
transform: (data: any[]) => {
|
||||
return data.map((item: any) => ({
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
code: item.code,
|
||||
type: item.type,
|
||||
description: item.description
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 项目组列表数据源
|
||||
*/
|
||||
export const projectGroupsConfig: DataSourceConfig = {
|
||||
url: '/api/v1/project-groups/list',
|
||||
params: { enabled: true },
|
||||
transform: (data: any[]) => {
|
||||
return data.map((item: any) => ({
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
code: item.code,
|
||||
description: item.description
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 应用列表数据源
|
||||
*/
|
||||
export const applicationsConfig: DataSourceConfig = {
|
||||
url: '/api/v1/applications/list',
|
||||
params: { enabled: true },
|
||||
transform: (data: any[]) => {
|
||||
return data.map((item: any) => ({
|
||||
label: item.appName, // ✅ 修正:使用 appName
|
||||
value: item.appCode,
|
||||
code: item.appCode, // ✅ 修正:使用 appCode
|
||||
projectGroupId: item.projectGroupId,
|
||||
description: item.appDesc || item.description // ✅ 修正:使用 appDesc
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 通知渠道类型数据源
|
||||
*/
|
||||
export const notificationChannelTypesConfig: DataSourceConfig = {
|
||||
url: '/api/v1/notification-channel/types',
|
||||
transform: (data: any[]) => {
|
||||
return data.map((item: any) => ({
|
||||
label: `${item.label}`,
|
||||
value: item.code
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 通知渠道数据源
|
||||
*/
|
||||
export const notificationChannelsConfig: DataSourceConfig = {
|
||||
url: '/api/v1/notification-channel/list',
|
||||
transform: (data: any[]) => {
|
||||
return data.map((item: any) => ({
|
||||
label: `(${item.channelType})-${item.name}`,
|
||||
value: item.id
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 用户数据源
|
||||
*/
|
||||
export const usersConfig: DataSourceConfig = {
|
||||
url: '/api/v1/user/list',
|
||||
transform: (data: any[]) => {
|
||||
return data.map((item: any) => ({
|
||||
label: `${item.nickname} (${item.username})`,
|
||||
value: item.username, // 后台使用 username 进行审批
|
||||
id: item.id,
|
||||
email: item.email,
|
||||
departmentName: item.departmentName
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 角色数据源
|
||||
*/
|
||||
export const rolesConfig: DataSourceConfig = {
|
||||
url: '/api/v1/role/list',
|
||||
transform: (data: any[]) => {
|
||||
return data.map((item: any) => ({
|
||||
label: `${item.name} (${item.code})`,
|
||||
value: item.code, // 使用 code 作为值
|
||||
id: item.id,
|
||||
description: item.description
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 部门数据源
|
||||
*/
|
||||
export const departmentsConfig: DataSourceConfig = {
|
||||
url: '/api/v1/department/list',
|
||||
transform: (data: any[]) => {
|
||||
return data.map((item: any) => ({
|
||||
label: `${item.name} (${item.code})`,
|
||||
value: item.code, // 使用 code 作为值
|
||||
id: item.id,
|
||||
description: item.description,
|
||||
parentId: item.parentId
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
@ -1,18 +0,0 @@
|
||||
/**
|
||||
* Jenkins 服务器数据源
|
||||
*/
|
||||
import type {DataSourceConfig} from '../types';
|
||||
|
||||
export const jenkinsServersConfig: DataSourceConfig = {
|
||||
url: '/api/v1/external-system/list',
|
||||
params: {type: 'JENKINS', enabled: true},
|
||||
transform: (data: any[]) => {
|
||||
return data.map((item: any) => ({
|
||||
label: `${item.name} (${item.url})`,
|
||||
value: item.id,
|
||||
url: item.url,
|
||||
name: item.name
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
@ -1,17 +0,0 @@
|
||||
/**
|
||||
* Kubernetes 集群数据源
|
||||
*/
|
||||
import type { DataSourceConfig } from '../types';
|
||||
|
||||
export const k8sClustersConfig: DataSourceConfig = {
|
||||
url: '/api/v1/k8s-cluster/list',
|
||||
params: { enabled: true },
|
||||
transform: (data: any[]) => {
|
||||
return data.map((item: any) => ({
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
apiServer: item.apiServer
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
@ -1,25 +0,0 @@
|
||||
/**
|
||||
* 通知渠道数据源
|
||||
*/
|
||||
import type { DataSourceConfig } from '../types';
|
||||
|
||||
export const notificationChannelTypesConfig: DataSourceConfig = {
|
||||
url: '/api/v1/notification-channel/types',
|
||||
transform: (data: any[]) => {
|
||||
return data.map((item: any) => ({
|
||||
label: `${item.label}`,
|
||||
value: item.code
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
export const notificationChannelsConfig: DataSourceConfig = {
|
||||
url: '/api/v1/notification-channel/list',
|
||||
transform: (data: any[]) => {
|
||||
return data.map((item: any) => ({
|
||||
label: `(${item.channelType})-${item.name}`,
|
||||
value: item.id
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
@ -1,43 +0,0 @@
|
||||
/**
|
||||
* 用户、角色、部门数据源
|
||||
*/
|
||||
import type { DataSourceConfig } from '../types';
|
||||
|
||||
export const usersConfig: DataSourceConfig = {
|
||||
url: '/api/v1/user/list',
|
||||
transform: (data: any[]) => {
|
||||
return data.map((item: any) => ({
|
||||
label: `${item.nickname} (${item.username})`,
|
||||
value: item.username, // 后台使用 username 进行审批
|
||||
id: item.id,
|
||||
email: item.email,
|
||||
departmentName: item.departmentName
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
export const rolesConfig: DataSourceConfig = {
|
||||
url: '/api/v1/role/list',
|
||||
transform: (data: any[]) => {
|
||||
return data.map((item: any) => ({
|
||||
label: `${item.name} (${item.code})`,
|
||||
value: item.code, // 使用 code 作为值
|
||||
id: item.id,
|
||||
description: item.description
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
export const departmentsConfig: DataSourceConfig = {
|
||||
url: '/api/v1/department/list',
|
||||
transform: (data: any[]) => {
|
||||
return data.map((item: any) => ({
|
||||
label: `${item.name} (${item.code})`,
|
||||
value: item.code, // 使用 code 作为值
|
||||
id: item.id,
|
||||
description: item.description,
|
||||
parentId: item.parentId
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user