mirror of
https://github.com/nocobase/nocobase.git
synced 2026-09-24 16:02:20 +08:00
feat: popup variables support (#7583)
* fix: nested variables not resolved correctly * fix: view context meta * fix: openView bug * feat: add popup context * fix: parent popup not visible * fix: popup parent record * fix: un defined vars * fix: tests error * fix: test error
This commit is contained in:
@@ -552,13 +552,17 @@ export const openView = defineAction({
|
||||
}
|
||||
const inputArgs = ctx.inputArgs || {};
|
||||
|
||||
const viewInputArgs = ctx.view?.inputArgs as Record<string, any> | undefined;
|
||||
if (inputArgs.filterByTk === undefined && params.filterByTk !== undefined) {
|
||||
inputArgs.filterByTk = params.filterByTk;
|
||||
}
|
||||
|
||||
inputArgs.filterByTk = inputArgs.filterByTk || params.filterByTk || viewInputArgs?.filterByTk;
|
||||
if (inputArgs.sourceId === undefined && params.sourceId !== undefined) {
|
||||
inputArgs.sourceId = params.sourceId;
|
||||
}
|
||||
|
||||
inputArgs.sourceId = inputArgs.sourceId || params.sourceId || viewInputArgs?.sourceId;
|
||||
|
||||
inputArgs.tabUid = inputArgs.tabuid || params.tabUid || viewInputArgs?.tabUid;
|
||||
if (inputArgs.tabUid === undefined && params.tabUid !== undefined) {
|
||||
inputArgs.tabUid = params.tabUid;
|
||||
}
|
||||
|
||||
const navigation = inputArgs.navigation ?? params.navigation;
|
||||
|
||||
|
||||
@@ -198,20 +198,23 @@ export const LinkageFilterItem: React.FC<LinkageFilterItemProps> = observer((pro
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
// 右侧变量树:追加 constant/null 根节点
|
||||
// 右侧变量树改为“稳定数组”而不是函数,避免 useRequest 反复拉取导致 ValueComponent 重建
|
||||
const rightMetaTreeRef = useRef<MetaTreeNode[] | null>(null);
|
||||
if (!rightMetaTreeRef.current) {
|
||||
const baseMetaTree = ctx.getPropertyMetaTree() as MetaTreeNode[];
|
||||
rightMetaTreeRef.current = [
|
||||
{ title: t('Constant'), name: 'constant', type: 'string', paths: ['constant'] },
|
||||
{ title: t('Null'), name: 'null', type: 'object', paths: ['null'] },
|
||||
...(Array.isArray(baseMetaTree) ? baseMetaTree : []),
|
||||
];
|
||||
}
|
||||
// 右侧变量树:动态函数获取,保证能拿到最新的 ctx(含 view.record 等)
|
||||
const rightMetaTreeGetter = useMemo(() => {
|
||||
return async () => {
|
||||
const baseMetaTree = (model?.context.getPropertyMetaTree() || ctx.getPropertyMetaTree()) as MetaTreeNode[];
|
||||
return [
|
||||
{ title: t('Constant'), name: 'constant', type: 'string', paths: ['constant'] },
|
||||
{ title: t('Null'), name: 'null', type: 'object', paths: ['null'] },
|
||||
...(Array.isArray(baseMetaTree) ? baseMetaTree : []),
|
||||
];
|
||||
};
|
||||
}, [ctx, model, t]);
|
||||
|
||||
// 左侧变量树:默认整棵 ctx(不追加 Constant/Null),确保可获取字段 interface
|
||||
const leftMetaTreeGetter = useCallback(() => ctx.getPropertyMetaTree(), [ctx]);
|
||||
const leftMetaTreeGetter = useCallback(() => {
|
||||
const tree = model?.context?.getPropertyMetaTree?.() || ctx.getPropertyMetaTree();
|
||||
return tree;
|
||||
}, [ctx, model]);
|
||||
|
||||
// 右侧 converters:常量/空值特殊处理;变量沿用默认(表达式)
|
||||
const rightSideConverters = useMemo<Converters>(() => {
|
||||
@@ -302,7 +305,7 @@ export const LinkageFilterItem: React.FC<LinkageFilterItemProps> = observer((pro
|
||||
<VariableInput
|
||||
value={rightOperandValue}
|
||||
onChange={(v) => (value.value = v)}
|
||||
metaTree={rightMetaTreeRef.current as MetaTreeNode[]}
|
||||
metaTree={rightMetaTreeGetter}
|
||||
converters={rightSideConverters}
|
||||
showValueComponent
|
||||
style={{ flex: '1 1 40%', minWidth: 160, maxWidth: '100%' }}
|
||||
|
||||
@@ -660,6 +660,8 @@
|
||||
"Parent popup record": "Parent popup record",
|
||||
"Associated records": "Associated records",
|
||||
"Parent record": "Parent record",
|
||||
"Current popup parent record": "Current popup parent record",
|
||||
"Popup uid": "Popup UID",
|
||||
"Current time": "Current time",
|
||||
"System variables": "System variables",
|
||||
"Date variables": "Date variables",
|
||||
@@ -956,9 +958,7 @@
|
||||
"Stay on the current popup or page": "Stay on the current popup or page",
|
||||
"Return to the previous popup or page": "Return to the previous popup or page",
|
||||
"Actions column": "Actions column",
|
||||
"Data Source Key": "Data Source Key",
|
||||
"Enter data source key": "Enter data source key",
|
||||
"Collection Name": "Collection Name",
|
||||
"Data source key": "Data source key",
|
||||
"Enter collection name": "Enter collection name",
|
||||
"Edit page size": "Edit page size",
|
||||
"Set data scope": "Set data scope",
|
||||
@@ -993,5 +993,9 @@
|
||||
"No form available for submission.": "No form available for submission.",
|
||||
"Collapse button": "Collapse",
|
||||
"Expand button": "Expand",
|
||||
"No assigned fields configured": "No assigned fields configured"
|
||||
"No assigned fields configured": "No assigned fields configured",
|
||||
"Current popup": "Current popup",
|
||||
"Parent popup": "Parent popup",
|
||||
"No parent popup": "No parent popup",
|
||||
"Popup record": "Popup record"
|
||||
}
|
||||
|
||||
@@ -687,6 +687,8 @@
|
||||
"Parent popup record": "上级弹窗记录",
|
||||
"Associated records": "关联记录",
|
||||
"Parent record": "上级记录",
|
||||
"Current popup parent record": "当前弹窗上级记录",
|
||||
"Popup uid": "弹窗UID",
|
||||
"Current time": "当前时间",
|
||||
"Now": "现在",
|
||||
"Message popup close method": "消息弹窗关闭方式",
|
||||
@@ -1285,9 +1287,7 @@
|
||||
"Timeout config": "超时配置",
|
||||
"Response type": "响应类型",
|
||||
"Actions column": "操作列",
|
||||
"Data Source Key": "数据源键",
|
||||
"Enter data source key": "输入数据源键",
|
||||
"Collection Name": "数据表名称",
|
||||
"Data source key": "数据源键",
|
||||
"Enter collection name": "输入数据表名称",
|
||||
"Edit page size": "编辑页面大小",
|
||||
"Set data scope": "设置数据范围",
|
||||
@@ -1401,5 +1401,9 @@
|
||||
"SmartTv": "智能电视",
|
||||
"Console": "游戏机",
|
||||
"Wearable": "可穿戴设备",
|
||||
"Embedded": "嵌入式设备"
|
||||
"Embedded": "嵌入式设备",
|
||||
"Current popup": "当前弹窗",
|
||||
"Parent popup": "上级弹窗",
|
||||
"No parent popup": "无上级弹窗",
|
||||
"Popup record": "弹窗记录"
|
||||
}
|
||||
|
||||
+6
@@ -135,6 +135,12 @@ const openStepSettingsDialog = async ({
|
||||
width: dialogWidth,
|
||||
destroyOnClose: true,
|
||||
...toJS(uiModeProps),
|
||||
// 透传 navigation,便于变量元信息根据真实视图栈推断父级弹窗
|
||||
inputArgs: {
|
||||
...(toJS(uiModeProps)?.inputArgs || {}),
|
||||
navigation: ctx?.view?.navigation ?? toJS(uiModeProps)?.inputArgs?.navigation,
|
||||
__isSettingsPopup: true,
|
||||
},
|
||||
onClose: () => {
|
||||
if (cleanup) {
|
||||
cleanup();
|
||||
|
||||
@@ -15,6 +15,7 @@ import { parseValueToPath } from './utils';
|
||||
import { useResolvedMetaTree } from './useResolvedMetaTree';
|
||||
import { useRequest } from 'ahooks';
|
||||
import { useFlowContext } from '../../FlowContextProvider';
|
||||
import type { MetaTreeNode } from '../../flowContext';
|
||||
|
||||
const VariableTagComponent: React.FC<VariableTagProps> = ({
|
||||
value,
|
||||
@@ -29,14 +30,65 @@ const VariableTagComponent: React.FC<VariableTagProps> = ({
|
||||
|
||||
const { data: displayedValue } = useRequest(
|
||||
async () => {
|
||||
// 1) 优先使用已解析到的节点(包含完整父标题链)
|
||||
if (metaTreeNode) {
|
||||
return metaTreeNode.parentTitles
|
||||
? [...metaTreeNode.parentTitles, metaTreeNode.title].map(ctx.t).join('/')
|
||||
: ctx.t(metaTreeNode.title) || '';
|
||||
}
|
||||
|
||||
// 2) 无 metaTreeNode 时,尝试从值还原路径,并在 metaTree 中寻找“最深前缀节点”,
|
||||
// 即便最后一段无效,也能显示前缀的翻译标题。
|
||||
if (!value) return String(value);
|
||||
const path = parseValueToPath(value);
|
||||
return path ? path.join('/') : String(value);
|
||||
const rawPath = parseValueToPath(value);
|
||||
if (!rawPath || !Array.isArray(resolvedMetaTree)) {
|
||||
return Array.isArray(rawPath) ? rawPath.join('/') : String(value);
|
||||
}
|
||||
|
||||
// 兼容 metaTree 为子树:顶层不含首段时,裁剪首段
|
||||
const topNames = new Set((resolvedMetaTree || []).map((n: any) => String(n?.name)));
|
||||
const path = !topNames.has(String(rawPath[0])) ? rawPath.slice(1) : rawPath;
|
||||
if (!path.length) return '';
|
||||
|
||||
let nodes: MetaTreeNode[] | undefined = resolvedMetaTree as MetaTreeNode[];
|
||||
let deepest: MetaTreeNode | null = null;
|
||||
let matchedCount = 0;
|
||||
for (let i = 0; i < path.length; i++) {
|
||||
if (!nodes) break;
|
||||
const seg = String(path[i]);
|
||||
const node = nodes.find((n) => String(n?.name) === seg) as MetaTreeNode | undefined;
|
||||
if (!node) break; // 停在第一个无效段之前
|
||||
deepest = node;
|
||||
matchedCount = i + 1;
|
||||
if (i < path.length - 1) {
|
||||
if (Array.isArray(node.children)) {
|
||||
nodes = node.children as any;
|
||||
} else if (typeof node.children === 'function') {
|
||||
try {
|
||||
const childNodes = await (node.children as any)();
|
||||
(node as any).children = childNodes;
|
||||
nodes = childNodes as any;
|
||||
} catch {
|
||||
nodes = undefined;
|
||||
}
|
||||
} else {
|
||||
nodes = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (deepest) {
|
||||
const titles = deepest.parentTitles ? [...deepest.parentTitles, deepest.title] : [deepest.title];
|
||||
let label = titles.map(ctx.t).join('/');
|
||||
if (matchedCount < path.length) {
|
||||
const tail = path.slice(matchedCount).join('/');
|
||||
label = tail ? `${label}/${tail}` : label;
|
||||
}
|
||||
return label;
|
||||
}
|
||||
|
||||
// 3) 完全找不到任何前缀时,退回原始路径字符串
|
||||
return path.join('/');
|
||||
},
|
||||
{ refreshDeps: [resolvedMetaTree, value, metaTreeNode] },
|
||||
);
|
||||
|
||||
@@ -48,7 +48,6 @@ export {
|
||||
createRecordMetaFactory,
|
||||
extractUsedVariableNames,
|
||||
extractUsedVariablePaths,
|
||||
inferParentRecordRef,
|
||||
inferRecordRef,
|
||||
type RecordParamsBuilder,
|
||||
} from './variablesParams';
|
||||
|
||||
@@ -14,6 +14,7 @@ export type RecordRef = {
|
||||
id?: any; // primary key value
|
||||
filterByTk?: any; // alias for id
|
||||
dataSourceKey?: string;
|
||||
sourceId?: any;
|
||||
fields?: string[];
|
||||
appends?: string[];
|
||||
record?: any; // full record object (try to infer pk from it)
|
||||
|
||||
@@ -45,6 +45,7 @@ function getCollection(ctx: FlowContext): CollectionShape | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// 从 FlowContext 中推断当前记录的 context params 信息
|
||||
export function inferRecordRef(ctx: FlowContext): RecordRef | undefined {
|
||||
const resource = getResource(ctx);
|
||||
const collectionObj = getCollection(ctx);
|
||||
@@ -59,13 +60,26 @@ export function inferRecordRef(ctx: FlowContext): RecordRef | undefined {
|
||||
return { collection, dataSourceKey, filterByTk };
|
||||
}
|
||||
|
||||
// 从 FlowContext.view 中推断记录的 context params 信息
|
||||
export function inferViewRecordRef(ctx: FlowContext): RecordRef | undefined {
|
||||
const view = ctx.view;
|
||||
if (!view) return undefined;
|
||||
const inputArgs = (view as any)?.inputArgs || {};
|
||||
const collection = inputArgs?.collectionName || (view as any)?.record?.collection;
|
||||
const dataSourceKey = inputArgs?.dataSourceKey || (view as any)?.record?.dataSourceKey || 'main';
|
||||
const filterByTk = inputArgs?.filterByTk || (view as any)?.record?.filterByTk;
|
||||
const sourceId = inputArgs?.sourceId;
|
||||
if (!collection || typeof filterByTk === 'undefined' || filterByTk === null) return undefined;
|
||||
return { collection, dataSourceKey, filterByTk, sourceId };
|
||||
}
|
||||
|
||||
export function inferParentRecordRef(ctx: FlowContext): RecordRef | undefined {
|
||||
const resource = getResource(ctx);
|
||||
const dataSourceKey = getCollection(ctx)?.dataSourceKey || resource?.getDataSourceKey?.();
|
||||
const rn = resource?.getResourceName?.();
|
||||
const resourceName = resource?.getResourceName?.();
|
||||
const sourceId = resource?.getSourceId?.();
|
||||
if (!rn || typeof sourceId === 'undefined' || sourceId === null) return undefined;
|
||||
const parts = String(rn).split('.');
|
||||
if (!resourceName || typeof sourceId === 'undefined' || sourceId === null) return undefined;
|
||||
const parts = String(resourceName).split('.');
|
||||
if (parts.length < 2) return undefined;
|
||||
return { collection: parts[0], dataSourceKey, filterByTk: sourceId };
|
||||
}
|
||||
@@ -111,7 +125,7 @@ export function createCurrentRecordMetaFactory(
|
||||
ctx: FlowContext,
|
||||
collectionAccessor: () => Collection | null,
|
||||
): PropertyMetaFactory {
|
||||
const title = (ctx as any)?.t?.('Current record') || 'Current record';
|
||||
const title = ctx.t?.('Current record') || 'Current record';
|
||||
return createRecordMetaFactory(collectionAccessor, title, (c) => inferRecordRef(c));
|
||||
}
|
||||
|
||||
@@ -141,35 +155,30 @@ export async function collectContextParamsForTemplate(
|
||||
ctx: FlowContext,
|
||||
template: JSONValue,
|
||||
): Promise<ServerContextParams | undefined> {
|
||||
try {
|
||||
const usage = _extractUsedVariablePaths(template) || {};
|
||||
const varNames = Object.keys(usage);
|
||||
if (!varNames.length) return undefined;
|
||||
const usage = _extractUsedVariablePaths(template) || {};
|
||||
const varNames = Object.keys(usage);
|
||||
if (!varNames.length) return undefined;
|
||||
|
||||
const input: Record<string, any> = {};
|
||||
for (const key of varNames) {
|
||||
const opt = (ctx as any).getPropertyOptions?.(key);
|
||||
const meta: PropertyMeta | (() => Promise<PropertyMeta | null> | PropertyMeta | null) | undefined = opt?.meta;
|
||||
if (!meta) continue;
|
||||
let built: any;
|
||||
try {
|
||||
const resolvedMeta = typeof meta === 'function' ? await (meta as any)() : (meta as any);
|
||||
const builder = resolvedMeta?.buildVariablesParams;
|
||||
if (typeof builder === 'function') {
|
||||
built = await builder(ctx);
|
||||
}
|
||||
} catch (err) {
|
||||
// 忽略构建变量参数时的异常,继续处理其他变量
|
||||
// 使用 void 引用以避免 no-unused-vars,同时避免空代码块触发 no-empty
|
||||
void err;
|
||||
continue;
|
||||
}
|
||||
if (built) {
|
||||
input[key] = built;
|
||||
const input: Record<string, any> = {};
|
||||
for (const key of varNames) {
|
||||
const opt = ctx.getPropertyOptions?.(key);
|
||||
const meta: PropertyMeta | (() => Promise<PropertyMeta | null> | PropertyMeta | null) | undefined = opt?.meta;
|
||||
if (!meta) continue;
|
||||
let built: any;
|
||||
try {
|
||||
const resolvedMeta = typeof meta === 'function' ? await (meta as any)() : (meta as any);
|
||||
const builder = resolvedMeta?.buildVariablesParams;
|
||||
if (typeof builder === 'function') {
|
||||
built = await builder(ctx);
|
||||
}
|
||||
} catch (err) {
|
||||
// 忽略构建变量参数时的异常,继续处理其他变量
|
||||
void err;
|
||||
continue;
|
||||
}
|
||||
if (built) {
|
||||
input[key] = built;
|
||||
}
|
||||
return buildServerContextParams(ctx, input);
|
||||
} catch (_) {
|
||||
return undefined;
|
||||
}
|
||||
return buildServerContextParams(ctx, input);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
*/
|
||||
|
||||
import type { FlowContext, PropertyMeta, PropertyMetaFactory } from '../flowContext';
|
||||
import { inferRecordRef, buildRecordMeta } from '../utils/variablesParams';
|
||||
import { buildRecordMeta, inferViewRecordRef } from '../utils/variablesParams';
|
||||
import type { RecordRef } from '../utils/serverContextParams';
|
||||
import type { Collection } from '../data-source';
|
||||
import type { FlowView } from './FlowView';
|
||||
|
||||
// 判断是否为普通对象(Plain Object),避免对类实例/代理等进行深度遍历
|
||||
function isPlainObject(val: any) {
|
||||
@@ -65,88 +68,57 @@ function makeMetaFromValue(value: any, title?: string, seen?: WeakSet<any>): any
|
||||
return { type: 'any', title };
|
||||
}
|
||||
|
||||
function buildNavigationMeta(ctx: FlowContext, getView: () => any): any {
|
||||
// 与运行时对象保持一致:无 current,仅暴露 viewStack;并动态展开 viewStack 项
|
||||
const t = (key: string) => (ctx as any)?.t?.(key) || key;
|
||||
return {
|
||||
type: 'object',
|
||||
title: t('导航'),
|
||||
properties: async () => {
|
||||
const view = getView();
|
||||
const nav = view?.navigation;
|
||||
const props: Record<string, any> = {};
|
||||
|
||||
// 动态 viewStack:按索引展开,并为每个项提供字段
|
||||
const stack = Array.isArray(nav?.viewStack) ? nav.viewStack : [];
|
||||
const stackMeta: any = {
|
||||
type: 'array',
|
||||
title: t('视图堆栈'),
|
||||
properties: undefined as Record<string, any> | undefined,
|
||||
};
|
||||
if (stack.length) {
|
||||
const children: Record<string, any> = {};
|
||||
const max = Math.min(stack.length, 20);
|
||||
for (let i = 0; i < max; i++) {
|
||||
const item = stack[i] || {};
|
||||
children[String(i)] = {
|
||||
type: 'object',
|
||||
title: `#${i}`,
|
||||
properties: {
|
||||
viewUid: { type: 'string', title: 'viewUid' },
|
||||
tabUid: { type: 'string', title: 'tabUid' },
|
||||
filterByTk: { type: 'string', title: 'filterByTk' },
|
||||
sourceId: { type: 'string', title: 'sourceId' },
|
||||
},
|
||||
};
|
||||
}
|
||||
stackMeta.properties = children;
|
||||
}
|
||||
|
||||
props.viewStack = stackMeta;
|
||||
return props;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a meta factory for ctx.view that includes:
|
||||
* - buildVariablesParams: { record } via inferRecordRef
|
||||
* - properties.record: full collection meta via buildRecordMeta
|
||||
* - type/preventClose/inputArgs/navigation fields for better variable selection UX
|
||||
*/
|
||||
export function createViewMeta(ctx: FlowContext, getView: () => any): PropertyMetaFactory {
|
||||
const viewTitle = (ctx as any)?.t?.('当前视图') || '当前视图';
|
||||
export function createViewMeta(ctx: FlowContext): PropertyMetaFactory {
|
||||
const viewTitle = ctx.t('当前视图');
|
||||
const factory: PropertyMetaFactory = async () => {
|
||||
const recordMeta = await buildRecordMeta(
|
||||
() => {
|
||||
try {
|
||||
const ref = inferRecordRef(ctx);
|
||||
if (!ref?.collection) return null;
|
||||
const ds = ctx.dataSourceManager?.getDataSource?.(ref.dataSourceKey || 'main');
|
||||
return ds?.collectionManager?.getCollection?.(ref.collection) || null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
(ctx as any)?.t?.('当前视图记录') || '当前视图记录',
|
||||
(c) => inferRecordRef(c),
|
||||
);
|
||||
|
||||
const view = getView();
|
||||
const view = ctx.view;
|
||||
return {
|
||||
type: 'object',
|
||||
title: viewTitle,
|
||||
title: ctx.t('当前视图'),
|
||||
buildVariablesParams: (c) => {
|
||||
const ref = inferRecordRef(c);
|
||||
return ref ? { record: ref } : undefined;
|
||||
const params = inferViewRecordRef(c);
|
||||
if (params) {
|
||||
return {
|
||||
record: params,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
properties: async () => {
|
||||
const props: Record<string, any> = {};
|
||||
if (recordMeta) props.record = recordMeta;
|
||||
props.type = { type: 'string', title: (ctx as any)?.t?.('类型') || '类型' };
|
||||
props.preventClose = { type: 'boolean', title: (ctx as any)?.t?.('是否允许关闭') || '是否允许关闭' };
|
||||
props.inputArgs = makeMetaFromValue(view?.inputArgs, (ctx as any)?.t?.('输入参数') || '输入参数');
|
||||
props.navigation = buildNavigationMeta(ctx, getView);
|
||||
// 仅当能推断到当前记录引用时,才暴露“当前视图记录”,避免出现空子菜单
|
||||
const refNow = inferViewRecordRef(ctx);
|
||||
if (refNow && refNow.collection) {
|
||||
const recordFactory: PropertyMetaFactory = async () => {
|
||||
try {
|
||||
const ref = inferViewRecordRef(ctx);
|
||||
if (!ref?.collection) return null;
|
||||
const dsKey = ref.dataSourceKey || 'main';
|
||||
const ds = ctx.dataSourceManager?.getDataSource?.(dsKey);
|
||||
const col = ds?.collectionManager?.getCollection?.(ref.collection);
|
||||
if (!col) return null;
|
||||
return (await buildRecordMeta(
|
||||
() => col,
|
||||
ctx.t('当前视图记录'),
|
||||
(c) => inferViewRecordRef(c),
|
||||
)) as PropertyMeta;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
recordFactory.title = ctx.t('当前视图记录');
|
||||
recordFactory.hasChildren = true;
|
||||
props.record = recordFactory;
|
||||
}
|
||||
props.type = { type: 'string', title: ctx.t?.('类型') || '类型' };
|
||||
props.preventClose = { type: 'boolean', title: ctx.t?.('是否允许关闭') || '是否允许关闭' };
|
||||
props.inputArgs = makeMetaFromValue(view?.inputArgs, ctx.t?.('输入参数') || '输入参数');
|
||||
return props;
|
||||
},
|
||||
} as PropertyMeta;
|
||||
@@ -155,3 +127,276 @@ export function createViewMeta(ctx: FlowContext, getView: () => any): PropertyMe
|
||||
factory.title = viewTitle;
|
||||
return factory;
|
||||
}
|
||||
|
||||
/**
|
||||
* 为 ctx.popup 构建元信息:
|
||||
* - popup.record:当前弹窗记录(服务端解析)
|
||||
* - popup.resource:数据源信息(前端解析)
|
||||
* - popup.parent:上级弹窗(无限级,前端解析;不存在则禁用/为空)
|
||||
*/
|
||||
export function createPopupMeta(ctx: FlowContext): PropertyMetaFactory {
|
||||
const t = (k: string) => ctx.t(k);
|
||||
|
||||
const getCurrentCollection = (): Collection | null => {
|
||||
try {
|
||||
const ref = inferViewRecordRef(ctx);
|
||||
if (!ref?.filterByTk) return null;
|
||||
const ds = ctx.dataSourceManager?.getDataSource?.(ref.dataSourceKey || 'main');
|
||||
return ds?.collectionManager?.getCollection?.(ref.collection) || null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// 从视图堆栈推断 level 级父弹窗(level=1 上一层)
|
||||
const getParentRecordRef = async (level: number): Promise<RecordRef | undefined> => {
|
||||
try {
|
||||
const nav = ctx.view?.navigation;
|
||||
const stack = Array.isArray(nav?.viewStack) ? nav.viewStack : [];
|
||||
if (stack.length < 2 || level < 1) return undefined;
|
||||
const idx = stack.length - 1 - level;
|
||||
if (idx < 0) return undefined;
|
||||
const parent = stack[idx];
|
||||
if (!parent?.viewUid) return undefined;
|
||||
|
||||
let model: any = ctx.engine?.getModel?.(parent.viewUid);
|
||||
if (!model && typeof ctx.engine?.loadModel === 'function') {
|
||||
try {
|
||||
model = await ctx.engine.loadModel({ uid: parent.viewUid });
|
||||
} catch (e) {
|
||||
console.warn('[FlowEngine] popup.getParentRecordRef loadModel failed:', e);
|
||||
}
|
||||
}
|
||||
const params = model?.getStepParams?.('popupSettings', 'openView') || {};
|
||||
const collection = params?.collectionName;
|
||||
const dataSourceKey = params?.dataSourceKey || 'main';
|
||||
const filterByTk = parent?.filterByTk ?? parent?.sourceId;
|
||||
if (!collection || typeof filterByTk === 'undefined' || filterByTk === null) return undefined;
|
||||
return { collection, dataSourceKey, filterByTk };
|
||||
} catch (e) {
|
||||
console.warn('[FlowEngine] popup.getParentRecordRef failed:', e);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const hasParentNow = (level: number): boolean => {
|
||||
try {
|
||||
const nav = ctx.view?.navigation;
|
||||
const stack = Array.isArray(nav?.viewStack) ? nav.viewStack : [];
|
||||
return stack.length >= level + 1; // level=1 需要至少2层
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const createParentFactory = (level: number): PropertyMetaFactory => {
|
||||
const factory: PropertyMetaFactory = () => {
|
||||
const meta: PropertyMeta = {
|
||||
type: 'object',
|
||||
title: t('Parent popup'),
|
||||
disabled: () => !hasParentNow(level),
|
||||
disabledReason: () => (!hasParentNow(level) ? t('No parent popup') : undefined),
|
||||
properties: async () => {
|
||||
const parentRef = await getParentRecordRef(level);
|
||||
const props: Record<string, any> = {};
|
||||
// 弹窗 UID(纯前端变量)
|
||||
props.uid = { type: 'string', title: t('Popup uid') };
|
||||
|
||||
// 记录分组
|
||||
if (parentRef) {
|
||||
const parentCollection = (() => {
|
||||
try {
|
||||
const ds = ctx.dataSourceManager?.getDataSource?.(parentRef.dataSourceKey || 'main');
|
||||
return ds?.collectionManager?.getCollection?.(parentRef.collection || '') || null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
const base = await buildRecordMeta(
|
||||
() => parentCollection,
|
||||
t('Popup record'),
|
||||
() => parentRef,
|
||||
);
|
||||
props.record = base || { type: 'object', title: t('Popup record') };
|
||||
} else {
|
||||
props.record = { type: 'object', title: t('Popup record') };
|
||||
}
|
||||
|
||||
// 数据源分组(前端显示用)
|
||||
const resourceMeta: PropertyMeta = {
|
||||
type: 'object',
|
||||
title: t('Data source'),
|
||||
properties: async () => ({
|
||||
dataSourceKey: { type: 'string', title: t('Data source key') },
|
||||
collectionName: { type: 'string', title: t('Collection name') },
|
||||
filterByTk: { type: 'string', title: t('filterByTk') },
|
||||
sourceId: { type: 'string', title: t('sourceId') },
|
||||
}),
|
||||
};
|
||||
props.resource = resourceMeta;
|
||||
|
||||
// 不在 "上级弹窗" 节点内继续渲染下一层,以避免列表中出现重复的“上级弹窗”层级
|
||||
// 仍然保留运行时值中的 parent.parent... 链(buildPopupRuntime 会构建完整链),
|
||||
// 以便用户手动输入时可以访问更深层级。
|
||||
return props;
|
||||
},
|
||||
};
|
||||
return meta;
|
||||
};
|
||||
factory.title = t('Parent popup');
|
||||
return factory;
|
||||
};
|
||||
|
||||
const factory: PropertyMetaFactory = () => {
|
||||
const meta: PropertyMeta = {
|
||||
type: 'object',
|
||||
title: t('Current popup'),
|
||||
buildVariablesParams: (c) => {
|
||||
const ref = inferViewRecordRef(c);
|
||||
const inputArgs = (c?.view as any)?.inputArgs || {};
|
||||
const out: Record<string, any> = { record: ref };
|
||||
try {
|
||||
const srcId = inputArgs?.sourceId;
|
||||
const assoc: string | undefined = inputArgs?.associationName;
|
||||
const dsKey: string = inputArgs?.dataSourceKey || 'main';
|
||||
if (srcId != null && srcId !== '' && assoc && typeof assoc === 'string') {
|
||||
// associationName 形如 `posts.comments`,父级集合为 `posts`
|
||||
const parentCollectionName = String(assoc).split('.')[0];
|
||||
if (parentCollectionName) {
|
||||
out.sourceRecord = {
|
||||
collection: parentCollectionName,
|
||||
dataSourceKey: dsKey,
|
||||
filterByTk: srcId,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// 忽略异常,保持 record 正常返回
|
||||
}
|
||||
return out;
|
||||
},
|
||||
properties: async () => {
|
||||
const props: Record<string, any> = {};
|
||||
// 当前弹窗 UID(纯前端变量)
|
||||
props.uid = { type: 'string', title: t('Popup uid') };
|
||||
const base = await buildRecordMeta(getCurrentCollection, t('Current popup record'), (c) =>
|
||||
inferViewRecordRef(c),
|
||||
);
|
||||
if (base) props.record = base;
|
||||
// 当 view.inputArgs 带有 sourceId + associationName 时,提供“上级记录”变量(基于 sourceId 推断)
|
||||
try {
|
||||
const inputArgs = (ctx.view as any)?.inputArgs || {};
|
||||
const srcId = inputArgs?.sourceId;
|
||||
const assoc: string | undefined = inputArgs?.associationName;
|
||||
const dsKey: string = inputArgs?.dataSourceKey || 'main';
|
||||
if (srcId != null && srcId !== '' && assoc && typeof assoc === 'string') {
|
||||
const parentCollectionName = String(assoc).includes('.') ? String(assoc).split('.')[0] : undefined;
|
||||
if (parentCollectionName) {
|
||||
const parentCollectionAccessor = () => {
|
||||
try {
|
||||
const ds = ctx.dataSourceManager?.getDataSource?.(dsKey);
|
||||
return ds?.collectionManager?.getCollection?.(parentCollectionName) || null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const srcMeta = await buildRecordMeta(parentCollectionAccessor, t('Current popup parent record'), () => ({
|
||||
collection: parentCollectionName,
|
||||
dataSourceKey: dsKey,
|
||||
filterByTk: srcId,
|
||||
}));
|
||||
if (srcMeta) {
|
||||
props.sourceRecord = srcMeta;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
const resourceMeta: PropertyMeta = {
|
||||
type: 'object',
|
||||
title: t('Data source'),
|
||||
properties: async () => ({
|
||||
dataSourceKey: { type: 'string', title: t('Data source key') },
|
||||
collectionName: { type: 'string', title: t('Collection name') },
|
||||
filterByTk: { type: 'string', title: t('filterByTk') },
|
||||
sourceId: { type: 'string', title: t('sourceId') },
|
||||
}),
|
||||
};
|
||||
props.resource = resourceMeta;
|
||||
// 是否展示“上级弹窗”应依据实际视图层级,而不是简单以配置态隐藏。
|
||||
// 当存在父级弹窗(不含当前配置弹窗自身)时,展示“上级弹窗”节点。
|
||||
const parentRef1 = await getParentRecordRef(1);
|
||||
if (parentRef1) {
|
||||
props.parent = createParentFactory(1);
|
||||
}
|
||||
return props;
|
||||
},
|
||||
};
|
||||
return meta;
|
||||
};
|
||||
factory.title = t('Current popup');
|
||||
factory.sort = 975;
|
||||
return factory;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据视图堆栈构建 popup 运行时值(resource + parent 链)
|
||||
*/
|
||||
interface PopupNodeResource {
|
||||
dataSourceKey: string;
|
||||
collectionName?: string;
|
||||
filterByTk?: any;
|
||||
sourceId?: any;
|
||||
}
|
||||
|
||||
interface PopupNode {
|
||||
uid?: string;
|
||||
resource: PopupNodeResource;
|
||||
parent?: PopupNode;
|
||||
}
|
||||
|
||||
export async function buildPopupRuntime(ctx: FlowContext, view: FlowView): Promise<PopupNode> {
|
||||
const nav = view?.navigation;
|
||||
const stack = Array.isArray(nav?.viewStack) ? nav.viewStack : [];
|
||||
const buildNode = async (idx: number): Promise<PopupNode | undefined> => {
|
||||
if (idx < 0 || !stack[idx]?.viewUid) return undefined;
|
||||
const viewUid = stack[idx].viewUid;
|
||||
let model: any = ctx.engine?.getModel?.(viewUid);
|
||||
if (!model && typeof ctx.engine?.loadModel === 'function') {
|
||||
model = await ctx.engine?.loadModel({ uid: viewUid });
|
||||
}
|
||||
const p = model?.getStepParams?.('popupSettings', 'openView') || {};
|
||||
const collectionName = p?.collectionName;
|
||||
const dataSourceKey = p?.dataSourceKey || 'main';
|
||||
const node: PopupNode = {
|
||||
uid: viewUid,
|
||||
resource: {
|
||||
dataSourceKey,
|
||||
collectionName,
|
||||
filterByTk: stack[idx]?.filterByTk,
|
||||
sourceId: stack[idx]?.sourceId,
|
||||
},
|
||||
};
|
||||
const parentNode = await buildNode(idx - 1);
|
||||
if (parentNode) node.parent = parentNode;
|
||||
return node;
|
||||
};
|
||||
const currentNode = await buildNode((view?.navigation?.viewStack?.length || 1) - 1);
|
||||
return currentNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在视图上下文中注册 popup 变量(统一消除重复)
|
||||
*/
|
||||
export function registerPopupVariable(ctx: FlowContext, view: FlowView) {
|
||||
// 始终注册 popup 变量:
|
||||
// - 若当前视图无可推断记录,仅在元信息中不呈现 record 字段;
|
||||
// - 但仍可依据 navigation 推断并展示上级弹窗信息。
|
||||
ctx.defineProperty('popup', {
|
||||
get: async () => buildPopupRuntime(ctx, view),
|
||||
meta: createPopupMeta(ctx),
|
||||
resolveOnServer: (p: string) =>
|
||||
p === 'record' || p?.startsWith('record.') || p === 'sourceRecord' || p?.startsWith('sourceRecord.'),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,3 +12,4 @@ export { useDrawer } from './useDrawer';
|
||||
export { usePage } from './usePage';
|
||||
export { usePopover } from './usePopover';
|
||||
export { ViewNavigation } from './ViewNavigation';
|
||||
export { createViewMeta } from './createViewMeta';
|
||||
|
||||
@@ -12,7 +12,7 @@ import DOMPurify from 'dompurify';
|
||||
import { observer } from '..';
|
||||
import { FlowContext } from '../flowContext';
|
||||
import { FlowViewContextProvider } from '../FlowContextProvider';
|
||||
import { createViewMeta } from './createViewMeta';
|
||||
import { registerPopupVariable } from './createViewMeta';
|
||||
import DialogComponent from './DialogComponent';
|
||||
import usePatchElement from './usePatchElement';
|
||||
import { FlowEngineProvider } from '../provider';
|
||||
@@ -44,7 +44,7 @@ export function useDialog() {
|
||||
let currentHeader: any = null;
|
||||
|
||||
// Footer 组件实现
|
||||
const FooterComponent = ({ children, ...props }) => {
|
||||
const FooterComponent: React.FC<{ children?: React.ReactNode }> = ({ children }) => {
|
||||
React.useEffect(() => {
|
||||
currentFooter = children;
|
||||
dialogRef.current?.setFooter(children);
|
||||
@@ -59,10 +59,10 @@ export function useDialog() {
|
||||
};
|
||||
|
||||
// Header 组件实现
|
||||
const HeaderComponent = ({ ...props }) => {
|
||||
const HeaderComponent: React.FC<{ title?: React.ReactNode; extra?: React.ReactNode }> = (props) => {
|
||||
React.useEffect(() => {
|
||||
currentHeader = props;
|
||||
dialogRef.current?.setHeader(props as any);
|
||||
dialogRef.current?.setHeader(props);
|
||||
|
||||
return () => {
|
||||
currentHeader = null;
|
||||
@@ -75,7 +75,7 @@ export function useDialog() {
|
||||
|
||||
// 构造 currentDialog 实例
|
||||
const currentDialog = {
|
||||
type: 'dialog',
|
||||
type: 'dialog' as const,
|
||||
inputArgs: config.inputArgs || {},
|
||||
preventClose: !!config.preventClose,
|
||||
destroy: () => dialogRef.current?.destroy(),
|
||||
@@ -102,11 +102,6 @@ export function useDialog() {
|
||||
};
|
||||
|
||||
const ctx = new FlowContext();
|
||||
ctx.defineProperty('view', {
|
||||
get: () => currentDialog,
|
||||
meta: createViewMeta(ctx, () => currentDialog),
|
||||
resolveOnServer: (p: string) => p === 'record' || p.startsWith('record.'),
|
||||
});
|
||||
// 为当前视图创建作用域引擎(隔离实例与缓存)
|
||||
const scopedEngine = createViewScopedEngine(flowContext.engine);
|
||||
ctx.defineProperty('engine', { value: scopedEngine });
|
||||
@@ -117,6 +112,13 @@ export function useDialog() {
|
||||
ctx.addDelegate(flowContext.engine.context);
|
||||
}
|
||||
|
||||
ctx.defineProperty('view', {
|
||||
get: () => currentDialog,
|
||||
// meta: createViewMeta(ctx),
|
||||
resolveOnServer: (p: string) => p === 'record' || p.startsWith('record.'),
|
||||
});
|
||||
// 顶层 popup 变量:弹窗记录/数据源/上级弹窗链(去重封装)
|
||||
registerPopupVariable(ctx, currentDialog);
|
||||
// 内部组件,在 Provider 内部计算 content
|
||||
const DialogWithContext = observer(
|
||||
() => {
|
||||
|
||||
@@ -12,7 +12,7 @@ import DOMPurify from 'dompurify';
|
||||
import { observer } from '..';
|
||||
import { FlowContext, FlowEngineContext } from '../flowContext';
|
||||
import { FlowViewContextProvider } from '../FlowContextProvider';
|
||||
import { createViewMeta } from './createViewMeta';
|
||||
import { registerPopupVariable } from './createViewMeta';
|
||||
import DrawerComponent from './DrawerComponent';
|
||||
import usePatchElement from './usePatchElement';
|
||||
import { FlowEngineProvider } from '../provider';
|
||||
@@ -44,7 +44,7 @@ export function useDrawer() {
|
||||
let currentHeader: any = null;
|
||||
|
||||
// Footer 组件实现
|
||||
const FooterComponent = ({ children, ...props }) => {
|
||||
const FooterComponent: React.FC<{ children?: React.ReactNode }> = ({ children }) => {
|
||||
React.useEffect(() => {
|
||||
currentFooter = children;
|
||||
drawerRef.current?.setFooter(children);
|
||||
@@ -59,10 +59,10 @@ export function useDrawer() {
|
||||
};
|
||||
|
||||
// Header 组件实现
|
||||
const HeaderComponent = ({ ...props }) => {
|
||||
const HeaderComponent: React.FC<{ title?: React.ReactNode; extra?: React.ReactNode }> = (props) => {
|
||||
React.useEffect(() => {
|
||||
currentHeader = props;
|
||||
drawerRef.current?.setHeader(props as any);
|
||||
drawerRef.current?.setHeader(props);
|
||||
|
||||
return () => {
|
||||
currentHeader = null;
|
||||
@@ -75,7 +75,7 @@ export function useDrawer() {
|
||||
|
||||
// 构造 currentDrawer 实例
|
||||
const currentDrawer = {
|
||||
type: 'drawer',
|
||||
type: 'drawer' as const,
|
||||
inputArgs: config.inputArgs || {},
|
||||
preventClose: !!config.preventClose,
|
||||
destroy: () => drawerRef.current?.destroy(),
|
||||
@@ -102,11 +102,6 @@ export function useDrawer() {
|
||||
};
|
||||
|
||||
const ctx = new FlowContext();
|
||||
ctx.defineProperty('view', {
|
||||
get: () => currentDrawer,
|
||||
meta: createViewMeta(ctx, () => currentDrawer),
|
||||
resolveOnServer: (p: string) => p === 'record' || p.startsWith('record.'),
|
||||
});
|
||||
// 为当前视图创建作用域引擎(隔离实例与缓存)
|
||||
const scopedEngine = createViewScopedEngine(flowContext.engine);
|
||||
// 先将引擎暴露给视图上下文,再按需继承父上下文
|
||||
@@ -118,6 +113,14 @@ export function useDrawer() {
|
||||
ctx.addDelegate(flowContext.engine.context);
|
||||
}
|
||||
|
||||
ctx.defineProperty('view', {
|
||||
get: () => currentDrawer,
|
||||
// meta: createViewMeta(ctx),
|
||||
resolveOnServer: (p: string) => p === 'record' || p.startsWith('record.'),
|
||||
});
|
||||
// 顶层 popup 变量:弹窗记录/数据源/上级弹窗链(去重封装)
|
||||
registerPopupVariable(ctx, currentDrawer);
|
||||
|
||||
// 内部组件,在 Provider 内部计算 content
|
||||
const DrawerWithContext: React.FC = observer(
|
||||
(props) => {
|
||||
|
||||
@@ -12,7 +12,7 @@ import ReactDOM from 'react-dom';
|
||||
import { observer } from '..';
|
||||
import { FlowContext } from '../flowContext';
|
||||
import { FlowViewContextProvider } from '../FlowContextProvider';
|
||||
import { createViewMeta } from './createViewMeta';
|
||||
import { registerPopupVariable } from './createViewMeta';
|
||||
import { PageComponent } from './PageComponent';
|
||||
import usePatchElement from './usePatchElement';
|
||||
import { FlowEngineProvider } from '../provider';
|
||||
@@ -48,7 +48,7 @@ export function usePage() {
|
||||
});
|
||||
|
||||
// Footer 组件实现
|
||||
const FooterComponent = ({ children, ...props }) => {
|
||||
const FooterComponent: React.FC<{ children?: React.ReactNode }> = ({ children }) => {
|
||||
React.useEffect(() => {
|
||||
pageRef.current?.setFooter(children);
|
||||
|
||||
@@ -61,9 +61,9 @@ export function usePage() {
|
||||
};
|
||||
|
||||
// Header 组件实现
|
||||
const HeaderComponent = ({ ...props }) => {
|
||||
const HeaderComponent: React.FC<{ title?: React.ReactNode; extra?: React.ReactNode }> = (props) => {
|
||||
React.useEffect(() => {
|
||||
pageRef.current?.setHeader(props as any);
|
||||
pageRef.current?.setHeader(props);
|
||||
|
||||
return () => {
|
||||
pageRef.current?.setHeader(null);
|
||||
@@ -77,7 +77,7 @@ export function usePage() {
|
||||
|
||||
// 构造 currentPage 实例
|
||||
const currentPage = {
|
||||
type: 'embed',
|
||||
type: 'embed' as const,
|
||||
inputArgs: config.inputArgs || {},
|
||||
preventClose: !!config.preventClose,
|
||||
destroy: () => pageRef.current?.destroy(),
|
||||
@@ -102,11 +102,6 @@ export function usePage() {
|
||||
};
|
||||
|
||||
const ctx = new FlowContext();
|
||||
ctx.defineProperty('view', {
|
||||
get: () => currentPage,
|
||||
meta: createViewMeta(ctx, () => currentPage),
|
||||
resolveOnServer: (p: string) => p === 'record' || p.startsWith('record.'),
|
||||
});
|
||||
// 为当前视图创建作用域引擎(隔离实例与缓存)
|
||||
const scopedEngine = createViewScopedEngine(flowContext.engine);
|
||||
ctx.defineProperty('engine', { value: scopedEngine });
|
||||
@@ -117,6 +112,14 @@ export function usePage() {
|
||||
ctx.addDelegate(flowContext.engine.context);
|
||||
}
|
||||
|
||||
ctx.defineProperty('view', {
|
||||
get: () => currentPage,
|
||||
// meta: createViewMeta(ctx),
|
||||
resolveOnServer: (p: string) => p === 'record' || p.startsWith('record.'),
|
||||
});
|
||||
// 顶层 popup 变量:弹窗记录/数据源/上级弹窗链(去重封装)
|
||||
registerPopupVariable(ctx, currentPage);
|
||||
|
||||
const PageWithContext = observer(
|
||||
() => {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
*/
|
||||
import type { ResourcerContext } from '@nocobase/resourcer';
|
||||
import type { HttpRequestContext } from '../template/contexts';
|
||||
import { variables } from '../variables/registry';
|
||||
import { variables, inferSelectsFromUsage } from '../variables/registry';
|
||||
|
||||
/**
|
||||
* 重置变量注册表:
|
||||
@@ -31,7 +31,14 @@ export function resetVariablesRegistryForTest() {
|
||||
reg.register({
|
||||
name: 'user',
|
||||
scope: 'request',
|
||||
attach: (flowCtx: HttpRequestContext, koaCtx: ResourcerContext) => {
|
||||
attach: (
|
||||
flowCtx: HttpRequestContext,
|
||||
koaCtx: ResourcerContext,
|
||||
_params?: unknown,
|
||||
usage?: Record<string, string[]>,
|
||||
) => {
|
||||
const paths = usage?.['user'] || [];
|
||||
const { generatedAppends, generatedFields } = inferSelectsFromUsage(paths);
|
||||
flowCtx.defineProperty('user', {
|
||||
get: async () => {
|
||||
try {
|
||||
@@ -40,7 +47,11 @@ export function resetVariablesRegistryForTest() {
|
||||
const ds = (koaCtx as any).app.dataSourceManager.get('main');
|
||||
const cm = ds.collectionManager;
|
||||
const repo = cm.db.getRepository('users');
|
||||
const rec = await repo.findOne({ filterByTk: uid });
|
||||
const rec = await repo.findOne({
|
||||
filterByTk: uid,
|
||||
fields: generatedFields,
|
||||
appends: generatedAppends,
|
||||
});
|
||||
return rec ? rec.toJSON?.() : undefined;
|
||||
} catch (_) {
|
||||
return undefined;
|
||||
|
||||
+47
-1
@@ -158,7 +158,7 @@ describe('plugin-flow-engine variables:resolve (no HTTP)', () => {
|
||||
// missing contextParams for view.record -> keep placeholder
|
||||
{ id: 't2', template: { id: '{{ ctx.view.record.id }}' } },
|
||||
],
|
||||
} as any;
|
||||
};
|
||||
const res = await execResolve(payload, 1);
|
||||
const results = res.body?.results || [];
|
||||
const r1 = results.find((r: any) => r.id === 't1');
|
||||
@@ -188,6 +188,52 @@ describe('plugin-flow-engine variables:resolve (no HTTP)', () => {
|
||||
expect(data.role.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should resolve multi-level appends for deep associations (user.roles.users.nickname)', async () => {
|
||||
// Ensure there is at least one role which includes current user (id=1)
|
||||
const roleName = 'r_test_multi_appends';
|
||||
const rolesRepo = app.db.getRepository('roles');
|
||||
const existing = await rolesRepo.findOne({ filter: { name: roleName } }).catch(() => null);
|
||||
if (!existing) {
|
||||
await rolesRepo.create({
|
||||
values: {
|
||||
name: roleName,
|
||||
title: 'Test Multi Appends',
|
||||
allowConfigure: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
const userRolesRepo: any = app.db.getRepository('users.roles', 1);
|
||||
try {
|
||||
await userRolesRepo.add(roleName);
|
||||
} catch (_) {
|
||||
// ignore if already added
|
||||
}
|
||||
|
||||
const payload = {
|
||||
template: {
|
||||
nicks: '{{ ctx.user.roles.users.nickname }}',
|
||||
},
|
||||
};
|
||||
const res = await execResolve(payload, 1);
|
||||
const data = res.body?.data ?? res.body;
|
||||
|
||||
expect(Array.isArray(data.nicks)).toBe(true);
|
||||
expect(data.nicks.length).toBeGreaterThan(0);
|
||||
|
||||
// Validate it contains current user's nickname when available
|
||||
const u = await app.db
|
||||
.getRepository('users')
|
||||
.findOne({ filterByTk: 1 })
|
||||
.catch(() => null);
|
||||
const hasGetter = !!(u && typeof (u as { get?: (k: string) => unknown }).get === 'function');
|
||||
const nick = hasGetter
|
||||
? (u as { get: (k: string) => unknown }).get('nickname')
|
||||
: (u as { nickname?: unknown } | null)?.nickname;
|
||||
if (nick) {
|
||||
expect(data.nicks).toContain(nick);
|
||||
}
|
||||
});
|
||||
|
||||
it('should resolve array-indexed dynamic record via flattened key (list.0)', async () => {
|
||||
const payload = {
|
||||
template: {
|
||||
|
||||
@@ -14,6 +14,7 @@ import PluginUISchemaStorageServer from './server';
|
||||
import { GlobalContext, HttpRequestContext } from './template/contexts';
|
||||
import { JSONValue, resolveJsonTemplate } from './template/resolver';
|
||||
import { variables } from './variables/registry';
|
||||
import { prefetchRecordsForResolve } from './variables/utils';
|
||||
|
||||
export class PluginFlowEngineServer extends PluginUISchemaStorageServer {
|
||||
private globalContext!: GlobalContext;
|
||||
@@ -32,94 +33,6 @@ export class PluginFlowEngineServer extends PluginUISchemaStorageServer {
|
||||
return cm.db;
|
||||
}
|
||||
|
||||
// 预取:构建“同记录”的字段/关联并集,一次查询写入 ctx.state.__varResolveBatchCache,供后续解析复用
|
||||
private async prefetchRecordsForResolve(
|
||||
koaCtx: ResourcerContext,
|
||||
items: Array<{ template: JSONValue; contextParams?: Record<string, unknown> }>,
|
||||
) {
|
||||
try {
|
||||
const groupMap = new Map<
|
||||
string,
|
||||
{ dataSourceKey: string; collection: string; filterByTk: unknown; fields: Set<string>; appends: Set<string> }
|
||||
>();
|
||||
const ensureGroup = (dataSourceKey: string, collection: string, filterByTk: unknown) => {
|
||||
const groupKey = JSON.stringify({ ds: dataSourceKey, collection, tk: filterByTk });
|
||||
let group = groupMap.get(groupKey);
|
||||
if (!group) {
|
||||
group = { dataSourceKey, collection, filterByTk, fields: new Set<string>(), appends: new Set<string>() };
|
||||
groupMap.set(groupKey, group);
|
||||
}
|
||||
return group;
|
||||
};
|
||||
const normalizeNestedSeg = (segment: string): string => (/^\d+$/.test(segment) ? `[${segment}]` : segment);
|
||||
const toFirstSeg = (path: string): { segment: string; hasDeep: boolean } => {
|
||||
const m = path.match(/^([^.[]+|\[[^\]]+\])([\s\S]*)$/);
|
||||
const segment = m ? m[1] : '';
|
||||
const rest = m ? m[2] : '';
|
||||
return { segment, hasDeep: rest.includes('.') || rest.includes('[') || rest.length > 0 };
|
||||
};
|
||||
for (const it of items) {
|
||||
const template = it?.template ?? {};
|
||||
const contextParams = (it?.contextParams || {}) as Record<string, any>;
|
||||
const usage = variables.extractUsage(template);
|
||||
for (const [cpKey, recordParams] of Object.entries(contextParams)) {
|
||||
const parts = String(cpKey).split('.');
|
||||
const varName = parts[0];
|
||||
const nestedSeg = parts.slice(1).join('.');
|
||||
const paths = usage?.[varName] || [];
|
||||
if (!paths.length) continue;
|
||||
const segNorm = nestedSeg ? normalizeNestedSeg(nestedSeg) : '';
|
||||
const remainders: string[] = [];
|
||||
for (const p of paths) {
|
||||
if (!segNorm) remainders.push(p);
|
||||
else if (p === segNorm) remainders.push('');
|
||||
else if (p.startsWith(`${segNorm}.`) || p.startsWith(`${segNorm}[`))
|
||||
remainders.push(p.slice(segNorm.length + 1));
|
||||
}
|
||||
if (!remainders.length) continue;
|
||||
const dataSourceKey = (recordParams as any)?.dataSourceKey || 'main';
|
||||
const collection = (recordParams as any)?.collection;
|
||||
const filterByTk = (recordParams as any)?.filterByTk;
|
||||
if (!collection || typeof filterByTk === 'undefined') continue;
|
||||
const group = ensureGroup(dataSourceKey, collection, filterByTk);
|
||||
for (const r of remainders) {
|
||||
const { segment, hasDeep } = toFirstSeg(r);
|
||||
if (!segment) continue;
|
||||
const key = segment.replace(/^\[(.+)\]$/, '$1');
|
||||
if (hasDeep) group.appends.add(key);
|
||||
else group.fields.add(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!groupMap.size) return;
|
||||
const stateObj = (koaCtx as any).state as Record<string, any>;
|
||||
if (stateObj && !stateObj['__varResolveBatchCache']) {
|
||||
stateObj['__varResolveBatchCache'] = new Map<string, unknown>();
|
||||
}
|
||||
const cache: Map<string, unknown> | undefined = (koaCtx as any).state?.['__varResolveBatchCache'];
|
||||
for (const { dataSourceKey, collection, filterByTk, fields, appends } of groupMap.values()) {
|
||||
try {
|
||||
const dataSource = this.app.dataSourceManager.get(dataSourceKey);
|
||||
const cm = dataSource.collectionManager as SequelizeCollectionManager;
|
||||
if (!cm?.db) continue;
|
||||
const repo = cm.db.getRepository(collection);
|
||||
const fld = fields.size ? Array.from(fields) : undefined;
|
||||
const app = appends.size ? Array.from(appends) : undefined;
|
||||
const rec = await repo.findOne({ filterByTk: filterByTk as any, fields: fld, appends: app });
|
||||
const json = rec ? rec.toJSON() : undefined;
|
||||
if (cache) {
|
||||
const key = JSON.stringify({ ds: dataSourceKey, c: collection, tk: filterByTk, f: fld, a: app });
|
||||
cache.set(key, json);
|
||||
}
|
||||
} catch {
|
||||
// ignore prefetch error
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore prefetch failure entirely
|
||||
}
|
||||
}
|
||||
|
||||
transformSQL(template: string) {
|
||||
let index = 1;
|
||||
const bind = {};
|
||||
@@ -161,7 +74,7 @@ export class PluginFlowEngineServer extends PluginUISchemaStorageServer {
|
||||
template: JSONValue;
|
||||
contextParams?: Record<string, unknown>;
|
||||
}>;
|
||||
await this.prefetchRecordsForResolve(
|
||||
await prefetchRecordsForResolve(
|
||||
ctx as ResourcerContext,
|
||||
batchItems.map((it) => ({
|
||||
template: it.template,
|
||||
@@ -192,7 +105,7 @@ export class PluginFlowEngineServer extends PluginUISchemaStorageServer {
|
||||
}
|
||||
const template = values.template as JSONValue;
|
||||
const contextParams = values?.contextParams || {};
|
||||
await this.prefetchRecordsForResolve(ctx as ResourcerContext, [{ template, contextParams }]);
|
||||
await prefetchRecordsForResolve(ctx as ResourcerContext, [{ template, contextParams }]);
|
||||
const requestCtx = new HttpRequestContext(ctx);
|
||||
requestCtx.delegate(this.globalContext);
|
||||
await variables.attachUsedVariables(requestCtx, ctx, template, contextParams);
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import _ from 'lodash';
|
||||
import { HttpRequestContext, ServerBaseContext } from '../template/contexts';
|
||||
import { SequelizeCollectionManager } from '@nocobase/data-source-manager';
|
||||
import type { TargetKey } from '@nocobase/database';
|
||||
import { ResourcerContext } from '@nocobase/resourcer';
|
||||
import { extractUsedVariablePaths } from '@nocobase/utils';
|
||||
|
||||
@@ -101,7 +102,7 @@ class VariableRegistry {
|
||||
|
||||
/** 变量注册表(全局单例,确保 src/dist 共享同一实例) */
|
||||
const GLOBAL_KEY = '__ncbVarRegistry__';
|
||||
const g: any = typeof globalThis !== 'undefined' ? globalThis : (global as any);
|
||||
const g = (typeof globalThis !== 'undefined' ? globalThis : (global as unknown)) as Record<string, unknown>;
|
||||
if (!g[GLOBAL_KEY]) {
|
||||
g[GLOBAL_KEY] = new VariableRegistry();
|
||||
}
|
||||
@@ -115,7 +116,10 @@ export const variables: VariableRegistry = g[GLOBAL_KEY] as VariableRegistry;
|
||||
* @param paths 使用到的子路径数组
|
||||
* @param params 显式参数(仅用于兼容签名)
|
||||
*/
|
||||
function inferSelectsFromUsage(paths: string[] = [], params?: any) {
|
||||
export function inferSelectsFromUsage(
|
||||
paths: string[] = [],
|
||||
_params?: unknown,
|
||||
): { generatedAppends?: string[]; generatedFields?: string[] } {
|
||||
if (!Array.isArray(paths) || paths.length === 0) {
|
||||
return { generatedAppends: undefined, generatedFields: undefined };
|
||||
}
|
||||
@@ -123,31 +127,50 @@ function inferSelectsFromUsage(paths: string[] = [], params?: any) {
|
||||
const appendSet = new Set<string>();
|
||||
const fieldSet = new Set<string>();
|
||||
|
||||
// 规范化:
|
||||
// - 将 ["name"] / ['name'] 转成 .name
|
||||
// - 去除任意位置的数字索引 [0]
|
||||
// - 折叠重复 '.' 并去除首尾 '.'
|
||||
const normalizePath = (raw: string): string => {
|
||||
if (!raw) return '';
|
||||
let s = String(raw);
|
||||
// 去掉所有数字索引(包括中间)
|
||||
s = s.replace(/\[(?:\d+)\]/g, '');
|
||||
// 将字符串索引标准化为点路径
|
||||
s = s.replace(/\[(?:"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)')\]/g, (_m, g1, g2) => `.${(g1 || g2) as string}`);
|
||||
// 折叠多余点
|
||||
s = s.replace(/\.\.+/g, '.');
|
||||
// 去除起始/结尾点
|
||||
s = s.replace(/^\./, '').replace(/\.$/, '');
|
||||
return s;
|
||||
};
|
||||
|
||||
for (let path of paths) {
|
||||
if (!path) continue;
|
||||
// 若以数字索引开头(如 [0].name),去掉前导的 [n].,用于推断字段/关联
|
||||
// 这样 record[0].name 的子路径(传入本函数时为 [0].name)会被识别为字段 name,而非关联
|
||||
// 兼容开头是数字索引(如 [0].name):移除前导 [n].
|
||||
while (/^\[(\d+)\](\.|$)/.test(path)) {
|
||||
path = path.replace(/^\[(\d+)\]\.?/, '');
|
||||
}
|
||||
if (!path) continue;
|
||||
// 若以字符串括号开头(如 ['name'] 或 ["name"]),将其作为首段字段名处理
|
||||
let first = '';
|
||||
let rest = '';
|
||||
const mStr = path.match(/^\[(?:"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)')\](.*)$/);
|
||||
if (mStr) {
|
||||
first = (mStr[1] ?? mStr[2]) || '';
|
||||
rest = mStr[3] || '';
|
||||
} else {
|
||||
// 字符类中 '.' 和 '[' 无需转义,避免 no-useless-escape
|
||||
const m = path.match(/^([^.[]+)([\s\S]*)$/);
|
||||
first = m?.[1] ?? '';
|
||||
rest = m?.[2] ?? '';
|
||||
const norm = normalizePath(path);
|
||||
if (!norm) continue;
|
||||
const segments = norm.split('.').filter(Boolean);
|
||||
if (segments.length === 0) continue;
|
||||
|
||||
if (segments.length === 1) {
|
||||
// 只有一段:表示顶层字段,加入 fields
|
||||
fieldSet.add(segments[0]);
|
||||
continue;
|
||||
}
|
||||
if (!first) continue;
|
||||
const hasDeep = rest.includes('.') || rest.includes('[');
|
||||
if (hasDeep) appendSet.add(first);
|
||||
else fieldSet.add(first);
|
||||
|
||||
// 多段:逐级生成 appends(不包含最后一个字段段)
|
||||
// 例:roles.users.nickname -> ['roles', 'roles.users']
|
||||
for (let i = 0; i < segments.length - 1; i++) {
|
||||
appendSet.add(segments.slice(0, i + 1).join('.'));
|
||||
}
|
||||
|
||||
// 同时将叶子字段完整路径加入 fields,减少关联载荷
|
||||
// 例:roles.users.nickname -> fields: ['roles.users.nickname']
|
||||
fieldSet.add(segments.join('.'));
|
||||
}
|
||||
|
||||
const generatedAppends = appendSet.size ? Array.from(appendSet) : undefined;
|
||||
@@ -168,6 +191,11 @@ async function fetchRecordWithRequestCache(
|
||||
appends?: string[],
|
||||
): Promise<unknown> {
|
||||
try {
|
||||
const log = koaCtx.app?.logger?.child({
|
||||
module: 'plugin-flow-engine',
|
||||
submodule: 'variables.resolve',
|
||||
method: 'fetchRecordWithRequestCache',
|
||||
});
|
||||
// 确保 state 与 __varResolveBatchCache 始终存在
|
||||
const kctx = koaCtx as ResourcerContext & { state?: Record<string, unknown> };
|
||||
if (!kctx.state) kctx.state = {};
|
||||
@@ -190,39 +218,66 @@ async function fetchRecordWithRequestCache(
|
||||
const key = JSON.stringify(keyObj);
|
||||
if (cache) {
|
||||
// 精确命中
|
||||
if (cache.has(key)) return cache.get(key);
|
||||
// 仅当缓存项是本次请求所需 selects 的“超集”时才复用(避免缺字段/关联)
|
||||
const needFields = Array.isArray(fields) ? new Set(fields) : undefined;
|
||||
if (cache.has(key)) {
|
||||
return cache.get(key);
|
||||
}
|
||||
// 仅当缓存项是本次请求所需 selects 的“超集”时才复用(避免缺字段/关联)。
|
||||
// 注意:若 needFields 中某路径已被 cachedAppends 的前缀覆盖(例如 needFields: ['roles.name'] 且 cachedAppends: ['roles']),
|
||||
// 则认为该字段已被关联载入,可视为满足。
|
||||
const needFields = Array.isArray(fields) ? [...new Set(fields)] : undefined;
|
||||
const needAppends = Array.isArray(appends) ? new Set(appends) : undefined;
|
||||
let fallbackAny: unknown = undefined;
|
||||
for (const [cacheKey, cacheVal] of cache.entries()) {
|
||||
const parsed = JSON.parse(cacheKey) as { ds: string; c: string; tk: unknown; f?: string[]; a?: string[] };
|
||||
if (!parsed || parsed.ds !== keyObj.ds || parsed.c !== keyObj.c || parsed.tk !== keyObj.tk) continue;
|
||||
const cachedFields = Array.isArray(parsed.f) ? new Set(parsed.f) : undefined;
|
||||
const cachedAppends = Array.isArray(parsed.a) ? new Set(parsed.a) : undefined;
|
||||
const fieldsOk = !needFields || (cachedFields && [...needFields].every((x) => cachedFields.has(x)));
|
||||
const appendsOk = !needAppends || (cachedAppends && [...needAppends].every((x) => cachedAppends.has(x)));
|
||||
if (fieldsOk && appendsOk) return cacheVal;
|
||||
// 兜底:记住同 ds/c/tk 的任意缓存,若无“超集”匹配则使用它(prefetch 已保证并集)
|
||||
if (typeof fallbackAny === 'undefined') fallbackAny = cacheVal;
|
||||
const cachedFields = new Set(parsed.f || []);
|
||||
const cachedAppends = new Set(parsed.a || []);
|
||||
|
||||
const fieldCoveredByAppends = (fieldPath: string) => {
|
||||
// 归一化,防御空串
|
||||
const p = String(fieldPath || '');
|
||||
// 若某个 append 是字段路径的前缀,则认为覆盖
|
||||
// 例如 append: 'roles' 覆盖 'roles.name' / 'roles.users.id'
|
||||
for (const a of cachedAppends) {
|
||||
if (!a) continue;
|
||||
if (p === a || p.startsWith(a + '.')) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const fieldsOk = !needFields || needFields.every((f) => cachedFields.has(f) || fieldCoveredByAppends(f));
|
||||
const appendsOk = !needAppends || [...needAppends].every((a) => cachedAppends.has(a));
|
||||
if (fieldsOk && appendsOk) {
|
||||
return cacheVal;
|
||||
}
|
||||
}
|
||||
if (typeof fallbackAny !== 'undefined') return fallbackAny;
|
||||
}
|
||||
const tk: any = filterByTk as any;
|
||||
const rec = await repo.findOne({
|
||||
filterByTk: tk,
|
||||
filterByTk: filterByTk as TargetKey,
|
||||
fields,
|
||||
appends,
|
||||
});
|
||||
const json = rec ? rec.toJSON() : undefined;
|
||||
if (cache) cache.set(key, json);
|
||||
return json;
|
||||
} catch (_) {
|
||||
} catch (e: any) {
|
||||
const log = koaCtx.app?.logger?.child({
|
||||
module: 'plugin-flow-engine',
|
||||
submodule: 'variables.resolve',
|
||||
method: 'fetchRecordWithRequestCache',
|
||||
});
|
||||
log?.debug('[variables.resolve] fetchRecordWithRequestCache error', {
|
||||
ds: dataSourceKey,
|
||||
collection,
|
||||
tk: filterByTk,
|
||||
fields,
|
||||
appends,
|
||||
error: e?.message || String(e),
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function isRecordParams(val: any): val is { collection: string; filterByTk: any; dataSourceKey?: string } {
|
||||
function isRecordParams(val: unknown): val is { collection: string; filterByTk: unknown; dataSourceKey?: string } {
|
||||
return val && typeof val === 'object' && 'collection' in val && 'filterByTk' in val;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import type { ResourcerContext } from '@nocobase/resourcer';
|
||||
import { SequelizeCollectionManager } from '@nocobase/data-source-manager';
|
||||
import type { JSONValue } from '../template/resolver';
|
||||
import { variables, inferSelectsFromUsage } from './registry';
|
||||
|
||||
/**
|
||||
* 预取:构建“同记录”的字段/关联并集,一次查询写入 ctx.state.__varResolveBatchCache,供后续解析复用
|
||||
*/
|
||||
export async function prefetchRecordsForResolve(
|
||||
koaCtx: ResourcerContext,
|
||||
items: Array<{ template: JSONValue; contextParams?: Record<string, unknown> }>,
|
||||
) {
|
||||
try {
|
||||
const log = koaCtx.app?.logger?.child({ module: 'plugin-flow-engine', submodule: 'variables.prefetch' });
|
||||
const groupMap = new Map<
|
||||
string,
|
||||
{ dataSourceKey: string; collection: string; filterByTk: unknown; fields: Set<string>; appends: Set<string> }
|
||||
>();
|
||||
|
||||
const ensureGroup = (dataSourceKey: string, collection: string, filterByTk: unknown) => {
|
||||
const groupKey = JSON.stringify({ ds: dataSourceKey, collection, tk: filterByTk });
|
||||
let group = groupMap.get(groupKey);
|
||||
if (!group) {
|
||||
group = { dataSourceKey, collection, filterByTk, fields: new Set<string>(), appends: new Set<string>() };
|
||||
groupMap.set(groupKey, group);
|
||||
}
|
||||
return group;
|
||||
};
|
||||
|
||||
const normalizeNestedSeg = (segment: string): string => (/^\d+$/.test(segment) ? `[${segment}]` : segment);
|
||||
|
||||
for (const it of items) {
|
||||
const template = it?.template ?? {};
|
||||
const contextParams = (it?.contextParams || {}) as Record<string, any>;
|
||||
const usage = variables.extractUsage(template);
|
||||
for (const [cpKey, recordParams] of Object.entries(contextParams)) {
|
||||
const parts = String(cpKey).split('.');
|
||||
const varName = parts[0];
|
||||
const nestedSeg = parts.slice(1).join('.');
|
||||
const paths = usage?.[varName] || [];
|
||||
if (!paths.length) continue;
|
||||
const segNorm = nestedSeg ? normalizeNestedSeg(nestedSeg) : '';
|
||||
const remainders: string[] = [];
|
||||
for (const p of paths) {
|
||||
if (!segNorm) remainders.push(p);
|
||||
else if (p === segNorm) remainders.push('');
|
||||
else if (p.startsWith(`${segNorm}.`) || p.startsWith(`${segNorm}[`))
|
||||
remainders.push(p.slice(segNorm.length + 1));
|
||||
}
|
||||
if (!remainders.length) continue;
|
||||
const dataSourceKey = (recordParams as any)?.dataSourceKey || 'main';
|
||||
const collection = (recordParams as any)?.collection;
|
||||
const filterByTk = (recordParams as any)?.filterByTk;
|
||||
if (!collection || typeof filterByTk === 'undefined') continue;
|
||||
const group = ensureGroup(dataSourceKey, collection, filterByTk);
|
||||
const { generatedAppends, generatedFields } = inferSelectsFromUsage(remainders);
|
||||
if (generatedFields?.length) generatedFields.forEach((f) => group.fields.add(f));
|
||||
if (generatedAppends?.length) generatedAppends.forEach((a) => group.appends.add(a));
|
||||
}
|
||||
}
|
||||
|
||||
if (!groupMap.size) return;
|
||||
|
||||
// 确保请求级缓存存在
|
||||
const stateObj = (koaCtx as any).state as Record<string, any>;
|
||||
if (stateObj && !stateObj['__varResolveBatchCache']) {
|
||||
stateObj['__varResolveBatchCache'] = new Map<string, unknown>();
|
||||
}
|
||||
const cache: Map<string, unknown> | undefined = (koaCtx as any).state?.['__varResolveBatchCache'];
|
||||
|
||||
for (const { dataSourceKey, collection, filterByTk, fields, appends } of groupMap.values()) {
|
||||
try {
|
||||
const ds = koaCtx.app.dataSourceManager.get(dataSourceKey);
|
||||
const cm = ds.collectionManager as SequelizeCollectionManager;
|
||||
if (!cm?.db) continue;
|
||||
const repo = cm.db.getRepository(collection);
|
||||
const fld = fields.size ? Array.from(fields).sort() : undefined;
|
||||
const app = appends.size ? Array.from(appends).sort() : undefined;
|
||||
const rec = await repo.findOne({ filterByTk: filterByTk as any, fields: fld, appends: app });
|
||||
const json = rec ? rec.toJSON() : undefined;
|
||||
if (cache) {
|
||||
const key = JSON.stringify({ ds: dataSourceKey, c: collection, tk: filterByTk, f: fld, a: app });
|
||||
cache.set(key, json);
|
||||
}
|
||||
} catch (e: any) {
|
||||
// 忽略预取失败,但记录为 debug
|
||||
log?.debug('[variables.resolve] prefetch query error', {
|
||||
ds: dataSourceKey,
|
||||
collection,
|
||||
tk: filterByTk,
|
||||
error: e?.message || String(e),
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
// 忽略预取失败,但记录为 debug
|
||||
koaCtx.app?.logger
|
||||
?.child({ module: 'plugin-flow-engine', submodule: 'variables.prefetch' })
|
||||
?.debug('[variables.resolve] prefetch fatal error', { error: e?.message || String(e) });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user