- manifest.rs: DateTime 类型 generated column 改为 TEXT 存储(PostgreSQL TIMESTAMPTZ cast 非 immutable) - data_handler.rs: resolve-labels 查询参数从 String 改为 UUID 类型避免类型不匹配 - data_dto.rs: PublicEntityResp 新增 plugin_id 字段 - EntitySelect.tsx: 跨插件查询先通过 registry 解析 manifest_id→plugin UUID - pluginData.ts: PublicEntity 接口增加 plugin_id - plugin_tests.rs: 适配 PluginField/PluginEntity 新增字段
142 lines
4.0 KiB
TypeScript
142 lines
4.0 KiB
TypeScript
import { Select, Spin, Input, Tooltip } from 'antd';
|
||
import { QuestionCircleOutlined } from '@ant-design/icons';
|
||
import { useState, useEffect, useCallback } from 'react';
|
||
import { listPluginData, getPluginEntityRegistry } from '../api/pluginData';
|
||
|
||
interface EntitySelectProps {
|
||
pluginId: string;
|
||
entity: string;
|
||
labelField: string;
|
||
searchFields?: string[];
|
||
/** 跨插件引用的目标插件 manifest ID(如 "erp-crm") */
|
||
refPlugin?: string;
|
||
/** 目标插件未安装时的降级显示文本 */
|
||
fallbackLabel?: 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: _searchFields,
|
||
refPlugin,
|
||
fallbackLabel,
|
||
value,
|
||
onChange,
|
||
cascadeFrom,
|
||
cascadeFilter,
|
||
cascadeValue,
|
||
placeholder,
|
||
}: EntitySelectProps) {
|
||
const [options, setOptions] = useState<{ value: string; label: string }[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [targetUnavailable, setTargetUnavailable] = useState(false);
|
||
const [resolvedPluginId, setResolvedPluginId] = useState<string | null>(null);
|
||
|
||
// 跨插件时:先解析 manifest_id → plugin UUID
|
||
useEffect(() => {
|
||
if (!refPlugin) {
|
||
setResolvedPluginId(pluginId);
|
||
return;
|
||
}
|
||
let cancelled = false;
|
||
(async () => {
|
||
try {
|
||
const registry = await getPluginEntityRegistry();
|
||
const match = registry.find((e) => e.manifest_id === refPlugin && e.entity_name === entity);
|
||
if (!cancelled) {
|
||
setResolvedPluginId(match ? match.plugin_id : null);
|
||
if (!match) setTargetUnavailable(true);
|
||
}
|
||
} catch {
|
||
if (!cancelled) {
|
||
setTargetUnavailable(true);
|
||
setResolvedPluginId(null);
|
||
}
|
||
}
|
||
})();
|
||
return () => { cancelled = true; };
|
||
}, [refPlugin, pluginId, entity]);
|
||
|
||
const effectivePluginId = resolvedPluginId || pluginId;
|
||
|
||
const fetchData = useCallback(
|
||
async (keyword?: string) => {
|
||
if (!resolvedPluginId && refPlugin) return;
|
||
setLoading(true);
|
||
try {
|
||
const filter: Record<string, string> | undefined =
|
||
cascadeFrom && cascadeFilter && cascadeValue
|
||
? { [cascadeFilter]: cascadeValue }
|
||
: undefined;
|
||
|
||
const result = await listPluginData(effectivePluginId, entity, 1, 20, {
|
||
search: keyword,
|
||
filter,
|
||
});
|
||
|
||
const items = (result.data || []).map((item) => ({
|
||
value: item.id,
|
||
label: String(item.data?.[labelField] ?? item.id),
|
||
}));
|
||
setOptions(items);
|
||
setTargetUnavailable(false);
|
||
} catch {
|
||
if (refPlugin) {
|
||
setTargetUnavailable(true);
|
||
setOptions([]);
|
||
}
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
},
|
||
[effectivePluginId, entity, labelField, cascadeFrom, cascadeFilter, cascadeValue, refPlugin, resolvedPluginId],
|
||
);
|
||
|
||
useEffect(() => {
|
||
if (resolvedPluginId || !refPlugin) {
|
||
fetchData();
|
||
}
|
||
}, [fetchData, resolvedPluginId, refPlugin]);
|
||
|
||
// 目标插件未安装 → 降级显示
|
||
if (targetUnavailable) {
|
||
return (
|
||
<Input
|
||
value={value || ''}
|
||
placeholder={fallbackLabel || `外部引用 (${refPlugin})`}
|
||
disabled
|
||
suffix={
|
||
<Tooltip title="目标插件未安装,此字段暂时不可用">
|
||
<QuestionCircleOutlined style={{ color: '#999' }} />
|
||
</Tooltip>
|
||
}
|
||
/>
|
||
);
|
||
}
|
||
|
||
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
|
||
/>
|
||
);
|
||
}
|