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;
|
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_entity?: string;
|
||||||
ref_label_field?: string;
|
ref_label_field?: string;
|
||||||
ref_search_fields?: string[];
|
ref_search_fields?: string[];
|
||||||
|
ref_plugin?: string;
|
||||||
|
ref_fallback_label?: string;
|
||||||
cascade_from?: string;
|
cascade_from?: string;
|
||||||
cascade_filter?: string;
|
cascade_filter?: string;
|
||||||
validation?: PluginFieldValidation;
|
validation?: PluginFieldValidation;
|
||||||
@@ -159,6 +161,7 @@ export interface PluginEntitySchema {
|
|||||||
fields: PluginFieldSchema[];
|
fields: PluginFieldSchema[];
|
||||||
relations?: PluginRelationSchema[];
|
relations?: PluginRelationSchema[];
|
||||||
data_scope?: boolean;
|
data_scope?: boolean;
|
||||||
|
is_public?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PluginSchemaResponse {
|
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 { useState, useEffect, useCallback } from 'react';
|
||||||
import { listPluginData } from '../api/pluginData';
|
import { listPluginData } from '../api/pluginData';
|
||||||
|
|
||||||
@@ -7,6 +8,10 @@ interface EntitySelectProps {
|
|||||||
entity: string;
|
entity: string;
|
||||||
labelField: string;
|
labelField: string;
|
||||||
searchFields?: string[];
|
searchFields?: string[];
|
||||||
|
/** 跨插件引用的目标插件 manifest ID(如 "erp-crm") */
|
||||||
|
refPlugin?: string;
|
||||||
|
/** 目标插件未安装时的降级显示文本 */
|
||||||
|
fallbackLabel?: string;
|
||||||
value?: string;
|
value?: string;
|
||||||
onChange?: (value: string, label: string) => void;
|
onChange?: (value: string, label: string) => void;
|
||||||
cascadeFrom?: string;
|
cascadeFrom?: string;
|
||||||
@@ -20,6 +25,8 @@ export default function EntitySelect({
|
|||||||
entity,
|
entity,
|
||||||
labelField,
|
labelField,
|
||||||
searchFields: _searchFields,
|
searchFields: _searchFields,
|
||||||
|
refPlugin,
|
||||||
|
fallbackLabel,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
cascadeFrom,
|
cascadeFrom,
|
||||||
@@ -29,6 +36,10 @@ export default function EntitySelect({
|
|||||||
}: EntitySelectProps) {
|
}: EntitySelectProps) {
|
||||||
const [options, setOptions] = useState<{ value: string; label: string }[]>([]);
|
const [options, setOptions] = useState<{ value: string; label: string }[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [targetUnavailable, setTargetUnavailable] = useState(false);
|
||||||
|
|
||||||
|
// 跨插件时使用目标插件 ID 查询
|
||||||
|
const effectivePluginId = refPlugin || pluginId;
|
||||||
|
|
||||||
const fetchData = useCallback(
|
const fetchData = useCallback(
|
||||||
async (keyword?: string) => {
|
async (keyword?: string) => {
|
||||||
@@ -39,7 +50,7 @@ export default function EntitySelect({
|
|||||||
? { [cascadeFilter]: cascadeValue }
|
? { [cascadeFilter]: cascadeValue }
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const result = await listPluginData(pluginId, entity, 1, 20, {
|
const result = await listPluginData(effectivePluginId, entity, 1, 20, {
|
||||||
search: keyword,
|
search: keyword,
|
||||||
filter,
|
filter,
|
||||||
});
|
});
|
||||||
@@ -49,17 +60,39 @@ export default function EntitySelect({
|
|||||||
label: String(item.data?.[labelField] ?? item.id),
|
label: String(item.data?.[labelField] ?? item.id),
|
||||||
}));
|
}));
|
||||||
setOptions(items);
|
setOptions(items);
|
||||||
|
setTargetUnavailable(false);
|
||||||
|
} catch {
|
||||||
|
if (refPlugin) {
|
||||||
|
setTargetUnavailable(true);
|
||||||
|
setOptions([]);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[pluginId, entity, labelField, cascadeFrom, cascadeFilter, cascadeValue],
|
[effectivePluginId, entity, labelField, cascadeFrom, cascadeFilter, cascadeValue, refPlugin],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData();
|
fetchData();
|
||||||
}, [fetchData]);
|
}, [fetchData]);
|
||||||
|
|
||||||
|
// 目标插件未安装 → 降级显示
|
||||||
|
if (targetUnavailable) {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
value={value || ''}
|
||||||
|
placeholder={fallbackLabel || `外部引用 (${refPlugin})`}
|
||||||
|
disabled
|
||||||
|
suffix={
|
||||||
|
<Tooltip title="目标插件未安装,此字段暂时不可用">
|
||||||
|
<QuestionCircleOutlined style={{ color: '#999' }} />
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select
|
<Select
|
||||||
showSearch
|
showSearch
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
updatePluginData,
|
updatePluginData,
|
||||||
deletePluginData,
|
deletePluginData,
|
||||||
batchPluginData,
|
batchPluginData,
|
||||||
|
resolveRefLabels,
|
||||||
type PluginDataListOptions,
|
type PluginDataListOptions,
|
||||||
} from '../api/pluginData';
|
} from '../api/pluginData';
|
||||||
import EntitySelect from '../components/EntitySelect';
|
import EntitySelect from '../components/EntitySelect';
|
||||||
@@ -93,6 +94,10 @@ export default function PluginCRUDPage({
|
|||||||
// 批量选择
|
// 批量选择
|
||||||
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
|
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
|
// 详情 Drawer
|
||||||
const [detailOpen, setDetailOpen] = useState(false);
|
const [detailOpen, setDetailOpen] = useState(false);
|
||||||
const [detailRecord, setDetailRecord] = useState<Record<string, unknown> | null>(null);
|
const [detailRecord, setDetailRecord] = useState<Record<string, unknown> | null>(null);
|
||||||
@@ -190,6 +195,30 @@ export default function PluginCRUDPage({
|
|||||||
fetchData();
|
fetchData();
|
||||||
}, [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 handleFilterChange = (fieldName: string, value: string | undefined) => {
|
||||||
const newFilters = { ...filters };
|
const newFilters = { ...filters };
|
||||||
@@ -269,6 +298,16 @@ export default function PluginCRUDPage({
|
|||||||
sorter: f.sortable ? true : undefined,
|
sorter: f.sortable ? true : undefined,
|
||||||
render: (val: unknown) => {
|
render: (val: unknown) => {
|
||||||
if (typeof val === 'boolean') return val ? <Tag color="green">是</Tag> : <Tag>否</Tag>;
|
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 ?? '-');
|
return String(val ?? '-');
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
@@ -345,6 +384,8 @@ export default function PluginCRUDPage({
|
|||||||
entity={field.ref_entity!}
|
entity={field.ref_entity!}
|
||||||
labelField={field.ref_label_field || 'name'}
|
labelField={field.ref_label_field || 'name'}
|
||||||
searchFields={field.ref_search_fields}
|
searchFields={field.ref_search_fields}
|
||||||
|
refPlugin={field.ref_plugin}
|
||||||
|
fallbackLabel={field.ref_fallback_label}
|
||||||
value={formValues[field.name] as string | undefined}
|
value={formValues[field.name] as string | undefined}
|
||||||
onChange={(v) => form.setFieldValue(field.name, v)}
|
onChange={(v) => form.setFieldValue(field.name, v)}
|
||||||
cascadeFrom={field.cascade_from}
|
cascadeFrom={field.cascade_from}
|
||||||
|
|||||||
Reference in New Issue
Block a user