refactor: define and compute rules for field interface, field type, and data type

This commit is contained in:
aaaaaajie 2025-06-27 00:03:28 +08:00
parent ed57a43ec9
commit f6982f5dfe
6 changed files with 321 additions and 225 deletions

View File

@ -27,7 +27,7 @@ import * as components from './components';
import { useFieldInterfaceOptions } from './interfaces';
import { ItemType, MenuItemType } from 'antd/es/menu/interface';
const getSchema = (schema: CollectionFieldInterface, record: any, compile, fieldTypeOptions) => {
const getSchema = (schema: CollectionFieldInterface, record: any, compile) => {
if (!schema) {
return;
}
@ -43,6 +43,22 @@ const getSchema = (schema: CollectionFieldInterface, record: any, compile, field
initialValue.reverseField.name = `f_${uid()}`;
}
// 基于当前选中的 fieldInterface 构建 fieldType 选项
const fieldTypeOptions = [];
const fieldType = schema.default?.type;
const availableFieldTypes = schema.availableOptions?.all[schema.name][fieldType] || [];
if (fieldType && availableFieldTypes.length > 0) {
fieldTypeOptions.push({
label: fieldType,
value: fieldType,
children: availableFieldTypes.map((availableType) => ({
label: availableType,
value: availableType,
})),
});
}
// 设置 fieldType 默认值为第一组的最深层级路径
if (fieldTypeOptions && fieldTypeOptions.length > 0) {
const getFirstLeafPath = (options) => {
@ -69,60 +85,62 @@ const getSchema = (schema: CollectionFieldInterface, record: any, compile, field
// initialValue.uiSchema.title = schema.title;
return {
type: 'object',
properties: {
[uid()]: {
type: 'void',
'x-component': 'Action.Drawer',
'x-component-props': {
getContainer: '{{ getContainer }}',
},
'x-decorator': 'Form',
'x-decorator-props': {
useValues(options) {
return useRequest(
() =>
Promise.resolve({
data: initialValue,
}),
options,
);
schema: {
type: 'object',
properties: {
[uid()]: {
type: 'void',
'x-component': 'Action.Drawer',
'x-component-props': {
getContainer: '{{ getContainer }}',
},
},
title: `${compile(record.title)} - ${compile('{{ t("Add field") }}')}`,
properties: {
summary: {
type: 'void',
'x-component': 'FieldSummary',
'x-component-props': {
schemaKey: schema.name,
'x-decorator': 'Form',
'x-decorator-props': {
useValues(options) {
return useRequest(
() =>
Promise.resolve({
data: initialValue,
}),
options,
);
},
},
// @ts-ignore
...properties,
description: {
type: 'string',
title: '{{t("Description")}}',
'x-decorator': 'FormItem',
'x-component': 'Input.TextArea',
},
footer: {
type: 'void',
'x-component': 'Action.Drawer.Footer',
properties: {
action1: {
title: '{{ t("Cancel") }}',
'x-component': 'Action',
'x-component-props': {
useAction: '{{ useCancelAction }}',
},
title: `${compile(record.title)} - ${compile('{{ t("Add field") }}')}`,
properties: {
summary: {
type: 'void',
'x-component': 'FieldSummary',
'x-component-props': {
schemaKey: schema.name,
},
action2: {
title: '{{ t("Submit") }}',
'x-component': 'Action',
'x-component-props': {
type: 'primary',
useAction: '{{ useCreateCollectionField }}',
},
// @ts-ignore
...properties,
description: {
type: 'string',
title: '{{t("Description")}}',
'x-decorator': 'FormItem',
'x-component': 'Input.TextArea',
},
footer: {
type: 'void',
'x-component': 'Action.Drawer.Footer',
properties: {
action1: {
title: '{{ t("Cancel") }}',
'x-component': 'Action',
'x-component-props': {
useAction: '{{ useCancelAction }}',
},
},
action2: {
title: '{{ t("Submit") }}',
'x-component': 'Action',
'x-component-props': {
type: 'primary',
useAction: '{{ useCreateCollectionField }}',
},
},
},
},
@ -130,6 +148,7 @@ const getSchema = (schema: CollectionFieldInterface, record: any, compile, field
},
},
},
fieldTypeOptions,
};
};
@ -193,6 +212,7 @@ export const AddFieldAction = (props) => {
const [visible, setVisible] = useState(false);
const [targetScope, setTargetScope] = useState();
const [schema, setSchema] = useState({});
const [fieldTypeOptions, setFieldTypeOptions] = useState([]);
const compile = useCompile();
const { t } = useTranslation();
const { isDialect } = useDialect();
@ -210,50 +230,6 @@ export const AddFieldAction = (props) => {
const dm = useDataSourceManager();
const interfaces = dm.collectionFieldInterfaceManager.getFieldInterfaces();
const fieldTypeOptions = useMemo(() => {
const { availableFieldInterfaces } = getTemplate(record.template) || {};
const { exclude, include } = (availableFieldInterfaces || {}) as any;
// 根据 fieldType 分组 interfaces
const fieldTypeGroups = {};
interfaces.forEach((fieldInterface) => {
// 检查是否符合模板限制
if (include?.length && !include.includes(fieldInterface.name)) {
return;
}
if (exclude?.length && exclude.includes(fieldInterface.name)) {
return;
}
const fieldType = fieldInterface.default?.type;
const availableFieldTypes = fieldInterface.availableFieldTypes || [];
if (fieldType && availableFieldTypes.length > 0) {
if (!fieldTypeGroups[fieldType]) {
fieldTypeGroups[fieldType] = [];
}
// 为每个 availableFieldType 创建选项
availableFieldTypes.forEach((availableType) => {
if (!fieldTypeGroups[fieldType].find((item) => item.value === availableType)) {
fieldTypeGroups[fieldType].push({
label: availableType,
value: availableType,
});
}
});
}
});
// 转换为 antd 级联组件需要的数据结构
return Object.keys(fieldTypeGroups).map((fieldType) => ({
label: fieldType,
value: fieldType,
children: fieldTypeGroups[fieldType],
}));
}, [interfaces, getTemplate, record, compile]);
const getFieldOptions = useCallback(() => {
const { availableFieldInterfaces } = getTemplate(record.template) || {};
const { exclude, include } = (availableFieldInterfaces || {}) as any;
@ -355,9 +331,10 @@ export const AddFieldAction = (props) => {
//@ts-ignore
const targetScope = e.item.props['data-targetScope'];
targetScope && setTargetScope(targetScope);
const schema = getSchema(getInterface(e.key), record, compile, fieldTypeOptions);
if (schema) {
setSchema(schema);
const result = getSchema(getInterface(e.key), record, compile);
if (result) {
setSchema(result.schema);
setFieldTypeOptions(result.fieldTypeOptions);
setVisible(true);
}
},

View File

@ -10,7 +10,10 @@
import { ISchema } from '@formily/react';
import { isArr, isEmpty, isValid } from '@formily/shared';
import { registerValidateRules } from '@formily/validator';
import { CollectionFieldInterface } from '../../data-source/collection-field-interface/CollectionFieldInterface';
import {
AvailableFieldOptions,
CollectionFieldInterface,
} from '../../data-source/collection-field-interface/CollectionFieldInterface';
import { i18n } from '../../i18n';
import { defaultProps, operators, primaryKey, unique } from './properties';
@ -59,7 +62,29 @@ export class InputFieldInterface extends CollectionFieldInterface {
},
};
fieldType = 'string';
availableFieldTypes = ['varchar', 'char'];
availableOptions: AvailableFieldOptions = {
all: {
input: {
string: ['varchar', 'char'],
},
textarea: {
text: ['text'],
},
},
available: {
input: {
string: {
varchar: ['varchar'],
char: ['varchar', 'char'],
},
},
textarea: {
text: {
text: ['text'],
},
},
},
};
availableTypes = ['varchar', 'char'];
hasDefaultValue = true;
properties = {

View File

@ -10,7 +10,10 @@
import { registerValidateFormats } from '@formily/core';
import { i18n } from '../../i18n';
import { defaultProps, operators, unique, autoIncrement, primaryKey } from './properties';
import { CollectionFieldInterface } from '../../data-source/collection-field-interface/CollectionFieldInterface';
import {
AvailableFieldOptions,
CollectionFieldInterface,
} from '../../data-source/collection-field-interface/CollectionFieldInterface';
registerValidateFormats({
odd: /^-?\d*[13579]$/,
@ -37,6 +40,35 @@ export class IntegerFieldInterface extends CollectionFieldInterface {
},
};
availableTypes = ['bigInt', 'integer', 'sort'];
availableOptions: AvailableFieldOptions = {
all: {
integer: {
bigInt: ['bigInt', 'tinyint', 'integer'],
},
number: {
double: ['double', 'float', 'decimal'],
},
input: {
string: ['varchar', 'char'],
},
textarea: {
text: ['text'],
},
},
available: {
input: {
string: {
varchar: ['varchar'],
char: ['varchar', 'char'],
},
},
textarea: {
text: {
text: ['text'],
},
},
},
};
hasDefaultValue = true;
properties = {
...defaultProps,

View File

@ -10,7 +10,10 @@
import { ISchema } from '@formily/react';
import { i18n } from '../../i18n';
import { defaultProps, operators } from './properties';
import { CollectionFieldInterface } from '../../data-source/collection-field-interface/CollectionFieldInterface';
import {
AvailableFieldOptions,
CollectionFieldInterface,
} from '../../data-source/collection-field-interface/CollectionFieldInterface';
export class TextareaFieldInterface extends CollectionFieldInterface {
name = 'textarea';
@ -27,6 +30,20 @@ export class TextareaFieldInterface extends CollectionFieldInterface {
},
};
availableTypes = ['text', 'json', 'string'];
availableOptions: AvailableFieldOptions = {
all: {
textarea: {
text: ['text'],
},
},
available: {
textarea: {
text: {
text: ['text'],
},
},
},
};
hasDefaultValue = true;
titleUsable = true;
properties = {

View File

@ -13,6 +13,8 @@ import type { CollectionFieldOptions } from '../collection';
import { CollectionFieldInterfaceManager } from './CollectionFieldInterfaceManager';
import { defaultProps } from '../../collection-manager/interfaces/properties';
import { tval } from '@nocobase/utils/client';
import type { DefaultOptionType as CascaderOptionType } from 'antd/lib/cascader';
import _ from 'lodash';
export type CollectionFieldInterfaceFactory = new (
collectionFieldInterfaceManager: CollectionFieldInterfaceManager,
) => CollectionFieldInterface;
@ -24,7 +26,23 @@ export interface CollectionFieldInterfaceComponentOption {
useProps?: () => any;
}
export type AvailableFieldTypes = string[];
type FieldInterfaceName = string;
type FieldTypeName = string;
type FieldDataType = string;
export interface AvailableFieldOptions {
all: {
[key: FieldInterfaceName]: {
[key: FieldTypeName]: FieldDataType[];
};
};
available: {
[key: FieldInterfaceName]: {
[key: FieldTypeName]: {
[key: FieldDataType]: FieldDataType[];
};
};
};
}
export abstract class CollectionFieldInterface {
constructor(public collectionFieldInterfaceManager: CollectionFieldInterfaceManager) {}
@ -33,7 +51,7 @@ export abstract class CollectionFieldInterface {
title?: string;
description?: string;
order?: number;
availableFieldTypes?: AvailableFieldTypes;
availableOptions?: AvailableFieldOptions;
default?: {
type: string;
uiSchema?: ISchema;
@ -174,4 +192,99 @@ export abstract class CollectionFieldInterface {
this.filterable.operators.push(operatorOption);
}
getAllAvailableTypes(): string[] {
if (!this.availableOptions?.all || !this.name) {
return [];
}
const interfaceOptions = this.availableOptions.all[this.name];
if (!interfaceOptions) {
return [];
}
const allTypes: string[] = [];
Object.values(interfaceOptions).forEach((typeArray) => {
if (Array.isArray(typeArray)) {
allTypes.push(...typeArray);
}
});
return [...new Set(allTypes)];
}
getAvailableOptions(options: {
currentValue: [FieldInterfaceName, ...FieldTypeName[], FieldDataType];
interfaces: Record<string, CollectionFieldInterface>;
compile: (v: string) => string;
}): CascaderOptionType[] {
const { currentValue, interfaces, compile } = options;
const dataType = currentValue[currentValue.length - 1];
const allAvailableOptions = this.availableOptions?.all || {};
const availableOptions = this.availableOptions?.available || {};
const result = this.buildTree({
all: allAvailableOptions,
available: availableOptions,
dataType,
interfaceMap: interfaces,
compile,
});
return result;
}
private buildTree(options: {
all;
available;
dataType;
compile: (v: string) => string;
interfaceMap?: Record<string, CollectionFieldInterface>;
}) {
const { all, available, dataType, compile, interfaceMap } = options;
if (Array.isArray(all)) {
const allowed = Array.isArray(available?.[dataType]) ? available[dataType] : [];
return all.map((item) => ({
label: item,
value: item,
disabled: !allowed.includes(item),
}));
}
return Object.entries(all).map(([key, value]) => {
const next = available?.[key];
const children = this.buildTree({ all: value, available: next, dataType, compile });
const disabled = Array.isArray(value) ? children.every((child) => child.disabled) : !next;
const label = interfaceMap ? compile(interfaceMap[key]?.title) : key;
return {
label,
value: key,
disabled,
children,
};
});
}
// validateFieldType<T extends AvailableFieldOptions>(value: string): keyof T {
// const fieldTypes = this.availableOptions[this.name];
// if (!(value in Object.keys(fieldTypes))) {
// throw new Error(`Field type "${value}" is not supported by interface "${this.name}".`);
// }
// return value;
// }
validateFieldDataType(fieldType: string, value: string) {
const fieldTypes = this.availableOptions[this.name];
if (!fieldTypes || !Array.isArray(fieldTypes)) {
throw new Error(`Field type "${value}" is not supported by interface "${this.name}".`);
}
const dataTypes = Object.keys(fieldTypes).find((x) => x === fieldType);
if (!dataTypes || !Array.isArray(dataTypes)) {
throw new Error(`Field type "${dataTypes}" is not supported by interface "${this.name}".`);
}
const newValue = dataTypes.find((x) => x === value);
newValue;
return value;
}
}

View File

@ -13,6 +13,7 @@ import { FieldContext, FormContext, useField } from '@formily/react';
import {
Action,
AddCollectionField,
CollectionFieldInterface,
EditCollectionField,
Input,
isDeleteButtonDisabled,
@ -79,15 +80,12 @@ const tableContainer = css`
flex: 1.5;
width: 0;
&:nth-child(4) {
flex: 1.8; /* Field interface column */
flex: 2.5; /* Combined Field interface + type column */
}
&:nth-child(5) {
flex: 1.8; /* Field type column */
}
&:nth-child(6) {
flex: 1.2; /* Title field column */
}
&:nth-child(7) {
&:nth-child(6) {
flex: 2; /* Description column */
}
&:last-child {
@ -108,48 +106,17 @@ const tableContainer = css`
const titlePrompt = 'Default title for each record';
const convertFieldTypeToOptions = (fieldType: string[]) => {
if (!fieldType || !Array.isArray(fieldType) || fieldType.length === 0) {
return [];
}
const fieldTypeHierarchy = {
string: [
{ label: 'varchar', value: 'varchar' },
{ label: 'char', value: 'char' },
{ label: 'text', value: 'text' },
],
integer: [
{ label: 'int', value: 'int' },
{ label: 'bigint', value: 'bigint' },
{ label: 'smallint', value: 'smallint' },
],
float: [
{ label: 'decimal', value: 'decimal' },
{ label: 'double', value: 'double' },
],
boolean: [{ label: 'boolean', value: 'boolean' }],
date: [
{ label: 'date', value: 'date' },
{ label: 'datetime', value: 'datetime' },
{ label: 'timestamp', value: 'timestamp' },
],
};
if (fieldType.length >= 1) {
const firstLevel = fieldType[0];
const children = fieldTypeHierarchy[firstLevel] || [];
return [
{
label: firstLevel,
value: firstLevel,
children: children.length > 0 ? children : undefined,
},
];
}
return [];
// Field Interface -> Field Type -> Data Type
const createFieldTypeOptions = (
currentValue: [string, ...string[], string],
getInterface: (name: string) => CollectionFieldInterface,
interfaces: Record<string, CollectionFieldInterface>,
compile,
) => {
const [interfaceName] = currentValue;
const currentInterface = getInterface(interfaceName);
const options = currentInterface.getAvailableOptions({ currentValue, interfaces, compile });
return options;
};
const CurrentFields = (props) => {
@ -178,60 +145,23 @@ const CurrentFields = (props) => {
{
dataIndex: 'interface',
title: t('Field interface'),
render: (value, record) => {
const handleChange = async (selectedInterface) => {
try {
await api.request({
url: `collections.fields:update?filterByTk=${record.name}&associatedIndex=${filterByTk}`,
method: 'post',
data: { interface: selectedInterface },
});
ctx?.refresh?.();
await props.refreshAsync();
refreshCM();
message.success(t('Saved successfully'));
} catch (error) {
console.error('Failed to update field interface:', error);
message.error(t('Save failed'));
}
};
return (
<Select
value={value}
onChange={handleChange}
placeholder={t('Select field interface')}
size="small"
style={{ width: '100%', minWidth: 120 }}
disabled={targetTemplate?.forbidDeletion}
showSearch
filterOption={(input, option) => {
const label = option?.children || '';
return String(label).toLowerCase().includes(input.toLowerCase());
}}
>
{Object.keys(interfaces).map((interfaceKey) => {
const interfaceItem = interfaces[interfaceKey];
return (
<Select.Option key={interfaceKey} value={interfaceKey}>
{compile(interfaceItem.title)}
</Select.Option>
);
})}
</Select>
);
},
},
{
dataIndex: 'fieldType',
title: t('Field type'),
render: (value, record) => {
const handleChange = async (selectedValues) => {
try {
const [interfaceValue, fieldTypeValue, dataTypeValue] = selectedValues || [];
const updateData: any = {};
if (interfaceValue) {
updateData.interface = interfaceValue;
}
if (fieldTypeValue || dataTypeValue) {
updateData.fieldType = selectedValues;
}
// await api.request({
// url: `collections.fields:update?filterByTk=${record.name}&associatedIndex=${filterByTk}`,
// method: 'post',
// data: { fieldType: selectedValues },
// data: updateData,
// });
ctx?.refresh?.();
await props.refreshAsync();
@ -243,20 +173,26 @@ const CurrentFields = (props) => {
}
};
return value && Array.isArray(value) && value.length > 0 ? (
const currentValue: any = []; // [interface, fieldType, dataType]
if (value) {
currentValue.push(value);
if (record.fieldType && Array.isArray(record.fieldType) && record.fieldType.length > 0) {
currentValue.push(...record.fieldType);
}
}
return (
<Cascader
value={value}
options={convertFieldTypeToOptions(value)}
value={currentValue.length > 0 ? currentValue : undefined}
options={createFieldTypeOptions(currentValue, getInterface, interfaces, compile)}
onChange={handleChange}
placeholder={t('Select field type')}
expandTrigger="hover"
changeOnSelect={true}
size="small"
style={{ width: '100%', minWidth: 120 }}
changeOnSelect={false}
style={{ width: '100%', minWidth: 100 }}
disabled={targetTemplate?.forbidDeletion}
showSearch
/>
) : (
<span style={{ color: '#ccc' }}>-</span>
);
},
},
@ -351,7 +287,7 @@ const CurrentFields = (props) => {
const InheritFields = (props) => {
const compile = useCompile();
const { getInterface } = useCollectionManager_deprecated();
const { getInterface, interfaces } = useCollectionManager_deprecated();
const { targetKey } = props.collectionResource || {};
const parentRecord = useRecord();
const [loadingRecord, setLoadingRecord] = React.useState(null);
@ -371,29 +307,29 @@ const InheritFields = (props) => {
dataIndex: 'name',
title: t('Field name'),
},
{
dataIndex: 'interface',
title: t('Field interface'),
render: (value) => <Tag>{compile(getInterface(value)?.title)}</Tag>,
},
{
dataIndex: 'fieldType',
title: t('Field type'),
render: (value) => {
return value && Array.isArray(value) && value.length > 0 ? (
render: (value, record) => {
const currentValue: any = []; // [interface, fieldType, dataType]
if (record.interface) {
currentValue.push(record.interface);
if (value && Array.isArray(value) && value.length > 0) {
currentValue.push(...value);
}
}
return (
<Cascader
value={value}
options={convertFieldTypeToOptions(value)}
value={currentValue.length > 0 ? currentValue : undefined}
options={createFieldTypeOptions(currentValue, getInterface, interfaces, compile)}
placeholder={t('Select field type')}
expandTrigger="hover"
changeOnSelect={true}
changeOnSelect={false}
size="small"
style={{ width: '100%', minWidth: 120 }}
style={{ width: '100%', minWidth: 150 }}
disabled={true}
open={false}
/>
) : (
<span style={{ color: '#ccc' }}>-</span>
);
},
},
@ -498,10 +434,6 @@ const CollectionFieldsInternal = () => {
dataIndex: 'name',
title: t('Field name'),
},
{
dataIndex: 'interface',
title: t('Field interface'),
},
{
dataIndex: 'fieldType',
title: t('Field type'),