feat(web): EntitySelect 关联选择器 — 远程搜索 + 级联过滤

- 新增 EntitySelect 组件,支持远程搜索和级联过滤
- PluginCRUDPage 表单渲染新增 entity_select widget 支持
- 通过 ref_entity/ref_label_field 配置关联实体
- 通过 cascade_from/cascade_filter 实现级联过滤
This commit is contained in:
iven
2026-04-17 10:56:17 +08:00
parent 5b2ae16ffb
commit e2e58d3a00
2 changed files with 100 additions and 0 deletions

View File

@@ -0,0 +1,80 @@
import { Select, Spin } from 'antd';
import { useState, useEffect, useCallback } from 'react';
import { listPluginData } from '../api/pluginData';
interface EntitySelectProps {
pluginId: string;
entity: string;
labelField: string;
searchFields?: string[];
value?: string;
onChange?: (value: string, label: string) => void;
cascadeFrom?: string;
cascadeFilter?: string;
cascadeValue?: string;
placeholder?: string;
}
export default function EntitySelect({
pluginId,
entity,
labelField,
searchFields,
value,
onChange,
cascadeFrom,
cascadeFilter,
cascadeValue,
placeholder,
}: EntitySelectProps) {
const [options, setOptions] = useState<{ value: string; label: string }[]>([]);
const [loading, setLoading] = useState(false);
const fetchData = useCallback(
async (keyword?: string) => {
setLoading(true);
try {
const filter: Record<string, string> | undefined =
cascadeFrom && cascadeFilter && cascadeValue
? { [cascadeFilter]: cascadeValue }
: undefined;
const result = await listPluginData(pluginId, entity, 1, 20, {
search: keyword,
filter,
});
const items = (result.data || []).map((item) => ({
value: item.id,
label: String(item.data?.[labelField] ?? item.id),
}));
setOptions(items);
} finally {
setLoading(false);
}
},
[pluginId, entity, labelField, cascadeFrom, cascadeFilter, cascadeValue],
);
useEffect(() => {
fetchData();
}, [fetchData]);
return (
<Select
showSearch
value={value}
placeholder={placeholder || '请选择'}
loading={loading}
options={options}
onSearch={(v) => fetchData(v)}
onChange={(v) => {
const opt = options.find((o) => o.value === v);
onChange?.(v, opt?.label || '');
}}
filterOption={false}
notFoundContent={loading ? <Spin size="small" /> : '无数据'}
allowClear
/>
);
}

View File

@@ -33,6 +33,7 @@ import {
deletePluginData, deletePluginData,
type PluginDataListOptions, type PluginDataListOptions,
} from '../api/pluginData'; } from '../api/pluginData';
import EntitySelect from '../components/EntitySelect';
import { import {
getPluginSchema, getPluginSchema,
type PluginFieldSchema, type PluginFieldSchema,
@@ -336,6 +337,25 @@ export default function PluginCRUDPage({
); );
case 'textarea': case 'textarea':
return <TextArea rows={3} />; return <TextArea rows={3} />;
case 'entity_select':
return (
<EntitySelect
pluginId={pluginId}
entity={field.ref_entity!}
labelField={field.ref_label_field || 'name'}
searchFields={field.ref_search_fields}
value={formValues[field.name] as string | undefined}
onChange={(v) => form.setFieldValue(field.name, v)}
cascadeFrom={field.cascade_from}
cascadeFilter={field.cascade_filter}
cascadeValue={
field.cascade_from
? (formValues[field.cascade_from] as string | undefined)
: undefined
}
placeholder={field.display_name}
/>
);
default: default:
return <Input />; return <Input />;
} }