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:
gchust
2025-10-14 14:53:08 +08:00
committed by GitHub
parent 0828f22ed9
commit 08fe81e776
19 changed files with 762 additions and 291 deletions
@@ -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%' }}
+8 -4
View File
@@ -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"
}
+8 -4
View File
@@ -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": "弹窗记录"
}
@@ -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) => {
+13 -10
View File
@@ -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;
@@ -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) });
}
}