feat(web,plugin): P1 跨插件引用 — 前端 Phase 4
- plugins.ts: PluginFieldSchema 新增 ref_plugin/ref_fallback_label, PluginEntitySchema 新增 is_public - pluginData.ts: 新增 resolveRefLabels/getPluginEntityRegistry API - EntitySelect: 支持 refPlugin 跨插件查询,目标不可用时降级为禁用 Input - PluginCRUDPage: 表格列解析引用标签(蓝色 Tag),entity_select 表单传 refPlugin/fallbackLabel
This commit is contained in:
@@ -171,3 +171,40 @@ export async function getPluginTimeseries(
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
// ─── 跨插件引用 API ──────────────────────────────────────────────────
|
||||
|
||||
export interface ResolveLabelsResult {
|
||||
labels: Record<string, Record<string, string | null>>;
|
||||
meta: Record<string, {
|
||||
target_plugin: string;
|
||||
target_entity: string;
|
||||
label_field: string;
|
||||
plugin_installed: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function resolveRefLabels(
|
||||
pluginId: string,
|
||||
entity: string,
|
||||
fields: Record<string, string[]>,
|
||||
): Promise<ResolveLabelsResult> {
|
||||
const { data } = await client.post<{ success: boolean; data: ResolveLabelsResult }>(
|
||||
`/plugins/${pluginId}/${entity}/resolve-labels`,
|
||||
{ fields },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export interface PublicEntity {
|
||||
manifest_id: string;
|
||||
entity_name: string;
|
||||
display_name: string;
|
||||
}
|
||||
|
||||
export async function getPluginEntityRegistry(): Promise<PublicEntity[]> {
|
||||
const { data } = await client.get<{ success: boolean; data: PublicEntity[] }>(
|
||||
'/plugin-registry/entities',
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
@@ -139,6 +139,8 @@ export interface PluginFieldSchema {
|
||||
ref_entity?: string;
|
||||
ref_label_field?: string;
|
||||
ref_search_fields?: string[];
|
||||
ref_plugin?: string;
|
||||
ref_fallback_label?: string;
|
||||
cascade_from?: string;
|
||||
cascade_filter?: string;
|
||||
validation?: PluginFieldValidation;
|
||||
@@ -159,6 +161,7 @@ export interface PluginEntitySchema {
|
||||
fields: PluginFieldSchema[];
|
||||
relations?: PluginRelationSchema[];
|
||||
data_scope?: boolean;
|
||||
is_public?: boolean;
|
||||
}
|
||||
|
||||
export interface PluginSchemaResponse {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Select, Spin } from 'antd';
|
||||
import { Select, Spin, Input, Tooltip } from 'antd';
|
||||
import { QuestionCircleOutlined } from '@ant-design/icons';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { listPluginData } from '../api/pluginData';
|
||||
|
||||
@@ -7,6 +8,10 @@ interface EntitySelectProps {
|
||||
entity: string;
|
||||
labelField: string;
|
||||
searchFields?: string[];
|
||||
/** 跨插件引用的目标插件 manifest ID(如 "erp-crm") */
|
||||
refPlugin?: string;
|
||||
/** 目标插件未安装时的降级显示文本 */
|
||||
fallbackLabel?: string;
|
||||
value?: string;
|
||||
onChange?: (value: string, label: string) => void;
|
||||
cascadeFrom?: string;
|
||||
@@ -20,6 +25,8 @@ export default function EntitySelect({
|
||||
entity,
|
||||
labelField,
|
||||
searchFields: _searchFields,
|
||||
refPlugin,
|
||||
fallbackLabel,
|
||||
value,
|
||||
onChange,
|
||||
cascadeFrom,
|
||||
@@ -29,6 +36,10 @@ export default function EntitySelect({
|
||||
}: EntitySelectProps) {
|
||||
const [options, setOptions] = useState<{ value: string; label: string }[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [targetUnavailable, setTargetUnavailable] = useState(false);
|
||||
|
||||
// 跨插件时使用目标插件 ID 查询
|
||||
const effectivePluginId = refPlugin || pluginId;
|
||||
|
||||
const fetchData = useCallback(
|
||||
async (keyword?: string) => {
|
||||
@@ -39,7 +50,7 @@ export default function EntitySelect({
|
||||
? { [cascadeFilter]: cascadeValue }
|
||||
: undefined;
|
||||
|
||||
const result = await listPluginData(pluginId, entity, 1, 20, {
|
||||
const result = await listPluginData(effectivePluginId, entity, 1, 20, {
|
||||
search: keyword,
|
||||
filter,
|
||||
});
|
||||
@@ -49,17 +60,39 @@ export default function EntitySelect({
|
||||
label: String(item.data?.[labelField] ?? item.id),
|
||||
}));
|
||||
setOptions(items);
|
||||
setTargetUnavailable(false);
|
||||
} catch {
|
||||
if (refPlugin) {
|
||||
setTargetUnavailable(true);
|
||||
setOptions([]);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[pluginId, entity, labelField, cascadeFrom, cascadeFilter, cascadeValue],
|
||||
[effectivePluginId, entity, labelField, cascadeFrom, cascadeFilter, cascadeValue, refPlugin],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
// 目标插件未安装 → 降级显示
|
||||
if (targetUnavailable) {
|
||||
return (
|
||||
<Input
|
||||
value={value || ''}
|
||||
placeholder={fallbackLabel || `外部引用 (${refPlugin})`}
|
||||
disabled
|
||||
suffix={
|
||||
<Tooltip title="目标插件未安装,此字段暂时不可用">
|
||||
<QuestionCircleOutlined style={{ color: '#999' }} />
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
showSearch
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
updatePluginData,
|
||||
deletePluginData,
|
||||
batchPluginData,
|
||||
resolveRefLabels,
|
||||
type PluginDataListOptions,
|
||||
} from '../api/pluginData';
|
||||
import EntitySelect from '../components/EntitySelect';
|
||||
@@ -93,6 +94,10 @@ export default function PluginCRUDPage({
|
||||
// 批量选择
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
|
||||
|
||||
// 跨插件引用标签解析
|
||||
const [resolvedLabels, setResolvedLabels] = useState<Record<string, Record<string, string | null>>>({});
|
||||
const [labelMeta, setLabelMeta] = useState<Record<string, { plugin_installed: boolean }>>({});
|
||||
|
||||
// 详情 Drawer
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [detailRecord, setDetailRecord] = useState<Record<string, unknown> | null>(null);
|
||||
@@ -190,6 +195,30 @@ export default function PluginCRUDPage({
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
// 数据加载后解析跨插件引用标签
|
||||
useEffect(() => {
|
||||
if (!pluginId || !entityName || !records.length || !fields.length) return;
|
||||
const refFields = fields.filter((f) => f.ref_entity);
|
||||
if (!refFields.length) return;
|
||||
|
||||
const fieldUuids: Record<string, string[]> = {};
|
||||
for (const f of refFields) {
|
||||
const uuids = [...new Set(
|
||||
records.map((r) => r[f.name]).filter(Boolean).map(String),
|
||||
)];
|
||||
if (uuids.length) fieldUuids[f.name] = uuids;
|
||||
}
|
||||
|
||||
if (!Object.keys(fieldUuids).length) return;
|
||||
|
||||
resolveRefLabels(pluginId, entityName, fieldUuids)
|
||||
.then((result) => {
|
||||
setResolvedLabels(result.labels);
|
||||
setLabelMeta(result.meta as Record<string, { plugin_installed: boolean }>);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [records, fields, pluginId, entityName]);
|
||||
|
||||
// 筛选变化
|
||||
const handleFilterChange = (fieldName: string, value: string | undefined) => {
|
||||
const newFilters = { ...filters };
|
||||
@@ -269,6 +298,16 @@ export default function PluginCRUDPage({
|
||||
sorter: f.sortable ? true : undefined,
|
||||
render: (val: unknown) => {
|
||||
if (typeof val === 'boolean') return val ? <Tag color="green">是</Tag> : <Tag>否</Tag>;
|
||||
// 引用字段 → 显示解析后的标签
|
||||
if (f.ref_entity) {
|
||||
const uuid = String(val ?? '');
|
||||
if (!uuid || uuid === '-') return '-';
|
||||
const label = resolvedLabels[f.name]?.[uuid];
|
||||
const installed = labelMeta[f.name]?.plugin_installed !== false;
|
||||
if (!installed) return <Tag color="default">{f.ref_fallback_label || '外部引用'}</Tag>;
|
||||
if (label === null) return <Tag color="warning">无效引用</Tag>;
|
||||
if (label) return <Tag color="blue">{label}</Tag>;
|
||||
}
|
||||
return String(val ?? '-');
|
||||
},
|
||||
})),
|
||||
@@ -345,6 +384,8 @@ export default function PluginCRUDPage({
|
||||
entity={field.ref_entity!}
|
||||
labelField={field.ref_label_field || 'name'}
|
||||
searchFields={field.ref_search_fields}
|
||||
refPlugin={field.ref_plugin}
|
||||
fallbackLabel={field.ref_fallback_label}
|
||||
value={formValues[field.name] as string | undefined}
|
||||
onChange={(v) => form.setFieldValue(field.name, v)}
|
||||
cascadeFrom={field.cascade_from}
|
||||
|
||||
Reference in New Issue
Block a user