增加代码编辑器表单组件

This commit is contained in:
dengqichen 2025-11-03 22:38:26 +08:00
parent 670093e9ca
commit 3e40119f09
45 changed files with 1332 additions and 685 deletions

View File

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

View File

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

View File

@ -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 包裹了,这里直接返回控件

View File

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

View File

@ -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 {
@ -37,21 +38,13 @@ 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

View File

@ -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;
@ -20,63 +33,25 @@ 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))}

View File

@ -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="请输入默认值" />

View File

@ -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 {
/** 字段列表 */

View File

@ -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();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 静态模式使用)

View File

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

View File

@ -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';
}
}

View File

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

View File

@ -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();

View File

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

View File

@ -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();

View File

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

View 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;
}
}

View 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}`);
}

View 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: []
}
);
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,190 @@
/**
*
* DockerGitK8sJenkins
*/
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
}));
}
};

View File

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

View File

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

View File

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

View File

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