diff --git a/packages/core/client/src/flow/actions/openView.tsx b/packages/core/client/src/flow/actions/openView.tsx index 3765c3bef73..728ff822f6e 100644 --- a/packages/core/client/src/flow/actions/openView.tsx +++ b/packages/core/client/src/flow/actions/openView.tsx @@ -552,13 +552,17 @@ export const openView = defineAction({ } const inputArgs = ctx.inputArgs || {}; - const viewInputArgs = ctx.view?.inputArgs as Record | 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; diff --git a/packages/core/client/src/flow/components/filter/LinkageFilterItem.tsx b/packages/core/client/src/flow/components/filter/LinkageFilterItem.tsx index 44a11c88697..dd53326414c 100644 --- a/packages/core/client/src/flow/components/filter/LinkageFilterItem.tsx +++ b/packages/core/client/src/flow/components/filter/LinkageFilterItem.tsx @@ -198,20 +198,23 @@ export const LinkageFilterItem: React.FC = observer((pro }; }, [t]); - // 右侧变量树:追加 constant/null 根节点 - // 右侧变量树改为“稳定数组”而不是函数,避免 useRequest 反复拉取导致 ValueComponent 重建 - const rightMetaTreeRef = useRef(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(() => { @@ -302,7 +305,7 @@ export const LinkageFilterItem: React.FC = observer((pro (value.value = v)} - metaTree={rightMetaTreeRef.current as MetaTreeNode[]} + metaTree={rightMetaTreeGetter} converters={rightSideConverters} showValueComponent style={{ flex: '1 1 40%', minWidth: 160, maxWidth: '100%' }} diff --git a/packages/core/client/src/locale/en-US.json b/packages/core/client/src/locale/en-US.json index 475f92eab10..87fb344a366 100644 --- a/packages/core/client/src/locale/en-US.json +++ b/packages/core/client/src/locale/en-US.json @@ -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" } diff --git a/packages/core/client/src/locale/zh-CN.json b/packages/core/client/src/locale/zh-CN.json index aa1a8263597..a7ecde432fe 100644 --- a/packages/core/client/src/locale/zh-CN.json +++ b/packages/core/client/src/locale/zh-CN.json @@ -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": "弹窗记录" } diff --git a/packages/core/flow-engine/src/components/settings/wrappers/contextual/StepSettingsDialog.tsx b/packages/core/flow-engine/src/components/settings/wrappers/contextual/StepSettingsDialog.tsx index 13561c7df5e..d9186aedac8 100644 --- a/packages/core/flow-engine/src/components/settings/wrappers/contextual/StepSettingsDialog.tsx +++ b/packages/core/flow-engine/src/components/settings/wrappers/contextual/StepSettingsDialog.tsx @@ -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(); diff --git a/packages/core/flow-engine/src/components/variables/VariableTag.tsx b/packages/core/flow-engine/src/components/variables/VariableTag.tsx index fa2f795c50e..7a49e8d3444 100644 --- a/packages/core/flow-engine/src/components/variables/VariableTag.tsx +++ b/packages/core/flow-engine/src/components/variables/VariableTag.tsx @@ -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 = ({ value, @@ -29,14 +30,65 @@ const VariableTagComponent: React.FC = ({ 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] }, ); diff --git a/packages/core/flow-engine/src/utils/index.ts b/packages/core/flow-engine/src/utils/index.ts index 73ab4d76034..d3b13d4d8b2 100644 --- a/packages/core/flow-engine/src/utils/index.ts +++ b/packages/core/flow-engine/src/utils/index.ts @@ -48,7 +48,6 @@ export { createRecordMetaFactory, extractUsedVariableNames, extractUsedVariablePaths, - inferParentRecordRef, inferRecordRef, type RecordParamsBuilder, } from './variablesParams'; diff --git a/packages/core/flow-engine/src/utils/serverContextParams.ts b/packages/core/flow-engine/src/utils/serverContextParams.ts index 5dbb793d5a1..e730fae88ae 100644 --- a/packages/core/flow-engine/src/utils/serverContextParams.ts +++ b/packages/core/flow-engine/src/utils/serverContextParams.ts @@ -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) diff --git a/packages/core/flow-engine/src/utils/variablesParams.ts b/packages/core/flow-engine/src/utils/variablesParams.ts index ceda2c68439..b57480bc277 100644 --- a/packages/core/flow-engine/src/utils/variablesParams.ts +++ b/packages/core/flow-engine/src/utils/variablesParams.ts @@ -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 { - 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 = {}; - for (const key of varNames) { - const opt = (ctx as any).getPropertyOptions?.(key); - const meta: PropertyMeta | (() => Promise | 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 = {}; + for (const key of varNames) { + const opt = ctx.getPropertyOptions?.(key); + const meta: PropertyMeta | (() => Promise | 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); } diff --git a/packages/core/flow-engine/src/views/createViewMeta.ts b/packages/core/flow-engine/src/views/createViewMeta.ts index 7c862d97633..064e84356f9 100644 --- a/packages/core/flow-engine/src/views/createViewMeta.ts +++ b/packages/core/flow-engine/src/views/createViewMeta.ts @@ -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 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 = {}; - - // 动态 viewStack:按索引展开,并为每个项提供字段 - const stack = Array.isArray(nav?.viewStack) ? nav.viewStack : []; - const stackMeta: any = { - type: 'array', - title: t('视图堆栈'), - properties: undefined as Record | undefined, - }; - if (stack.length) { - const children: Record = {}; - 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 = {}; - 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 => { + 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 = {}; + // 弹窗 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 = { 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 = {}; + // 当前弹窗 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 { + const nav = view?.navigation; + const stack = Array.isArray(nav?.viewStack) ? nav.viewStack : []; + const buildNode = async (idx: number): Promise => { + 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.'), + }); +} diff --git a/packages/core/flow-engine/src/views/index.tsx b/packages/core/flow-engine/src/views/index.tsx index 6138af0273f..9bf43a5c8e9 100644 --- a/packages/core/flow-engine/src/views/index.tsx +++ b/packages/core/flow-engine/src/views/index.tsx @@ -12,3 +12,4 @@ export { useDrawer } from './useDrawer'; export { usePage } from './usePage'; export { usePopover } from './usePopover'; export { ViewNavigation } from './ViewNavigation'; +export { createViewMeta } from './createViewMeta'; diff --git a/packages/core/flow-engine/src/views/useDialog.tsx b/packages/core/flow-engine/src/views/useDialog.tsx index a05dfdcf421..b8e902432b3 100644 --- a/packages/core/flow-engine/src/views/useDialog.tsx +++ b/packages/core/flow-engine/src/views/useDialog.tsx @@ -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( () => { diff --git a/packages/core/flow-engine/src/views/useDrawer.tsx b/packages/core/flow-engine/src/views/useDrawer.tsx index 0a62c74e4b1..41bb1cb3a2a 100644 --- a/packages/core/flow-engine/src/views/useDrawer.tsx +++ b/packages/core/flow-engine/src/views/useDrawer.tsx @@ -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) => { diff --git a/packages/core/flow-engine/src/views/usePage.tsx b/packages/core/flow-engine/src/views/usePage.tsx index e4d9fca5919..b33c46802ea 100644 --- a/packages/core/flow-engine/src/views/usePage.tsx +++ b/packages/core/flow-engine/src/views/usePage.tsx @@ -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 diff --git a/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/test-utils.ts b/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/test-utils.ts index 032c2b3c354..a7f1a4d77a1 100644 --- a/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/test-utils.ts +++ b/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/test-utils.ts @@ -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, + ) => { + 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; diff --git a/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/variables.resolve.test.ts b/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/variables.resolve.test.ts index 18050384118..7875f4a657c 100644 --- a/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/variables.resolve.test.ts +++ b/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/variables.resolve.test.ts @@ -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: { diff --git a/packages/plugins/@nocobase/plugin-flow-engine/src/server/plugin.ts b/packages/plugins/@nocobase/plugin-flow-engine/src/server/plugin.ts index dafe5e0d9b5..e9026aea80c 100644 --- a/packages/plugins/@nocobase/plugin-flow-engine/src/server/plugin.ts +++ b/packages/plugins/@nocobase/plugin-flow-engine/src/server/plugin.ts @@ -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 }>, - ) { - try { - const groupMap = new Map< - string, - { dataSourceKey: string; collection: string; filterByTk: unknown; fields: Set; appends: Set } - >(); - 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(), appends: new Set() }; - 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; - 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; - if (stateObj && !stateObj['__varResolveBatchCache']) { - stateObj['__varResolveBatchCache'] = new Map(); - } - const cache: Map | 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; }>; - 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); diff --git a/packages/plugins/@nocobase/plugin-flow-engine/src/server/variables/registry.ts b/packages/plugins/@nocobase/plugin-flow-engine/src/server/variables/registry.ts index ee7361c4f29..e9881338e69 100644 --- a/packages/plugins/@nocobase/plugin-flow-engine/src/server/variables/registry.ts +++ b/packages/plugins/@nocobase/plugin-flow-engine/src/server/variables/registry.ts @@ -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; 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(); const fieldSet = new Set(); + // 规范化: + // - 将 ["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 { 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 }; 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; } diff --git a/packages/plugins/@nocobase/plugin-flow-engine/src/server/variables/utils.ts b/packages/plugins/@nocobase/plugin-flow-engine/src/server/variables/utils.ts new file mode 100644 index 00000000000..538ae42b660 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-flow-engine/src/server/variables/utils.ts @@ -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 }>, +) { + 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; appends: Set } + >(); + + 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(), appends: new Set() }; + 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; + 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; + if (stateObj && !stateObj['__varResolveBatchCache']) { + stateObj['__varResolveBatchCache'] = new Map(); + } + const cache: Map | 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) }); + } +}