feat(web,plugin): P1 跨插件引用 — 前端 Phase 4
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

- 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:
iven
2026-04-19 00:54:34 +08:00
parent ef89ed38a1
commit 9e28d71295
4 changed files with 117 additions and 3 deletions

View File

@@ -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;
}

View File

@@ -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 {

View File

@@ -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

View File

@@ -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}