@nocobase/flow-engine 2.1.35 → 2.1.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,7 +11,7 @@ import dayjs from 'dayjs';
11
11
 
12
12
  const CTX_DATE_REGEX = /^\{\{\s*ctx\.date(?:\.(.+?))?\s*\}\}$/;
13
13
 
14
- const PRESET_KEYS = new Set([
14
+ const PRESET_KEY_LIST = [
15
15
  'today',
16
16
  'now',
17
17
  'yesterday',
@@ -28,10 +28,28 @@ const PRESET_KEYS = new Set([
28
28
  'thisYear',
29
29
  'lastYear',
30
30
  'nextYear',
31
- ]);
31
+ ] as const;
32
+
33
+ export type CtxDatePreset = (typeof PRESET_KEY_LIST)[number];
34
+ export type CtxDateRelativeDirection = 'next' | 'past';
35
+ export type CtxDateRelativeUnit = 'day' | 'week' | 'month' | 'year';
36
+
37
+ export type CtxDateExpressionConfig =
38
+ | { kind: 'exact'; value: string | [string, string]; format?: string }
39
+ | {
40
+ kind: 'relative';
41
+ direction: CtxDateRelativeDirection;
42
+ amount: number;
43
+ unit: CtxDateRelativeUnit;
44
+ format?: string;
45
+ }
46
+ | { kind: 'preset'; preset: CtxDatePreset; format?: string };
47
+
48
+ const PRESET_KEYS = new Set<string>(PRESET_KEY_LIST);
32
49
 
33
50
  const RELATIVE_DIRECTIONS = new Set(['next', 'past']);
34
51
  const RELATIVE_UNITS = new Set(['day', 'week', 'month', 'year']);
52
+ const MAX_DATE_FORMAT_LENGTH = 128;
35
53
 
36
54
  function parseCtxDateSegments(value: string): string[] | null {
37
55
  if (typeof value !== 'string') return null;
@@ -46,8 +64,7 @@ function parseCtxDateSegments(value: string): string[] | null {
46
64
  .filter(Boolean);
47
65
  }
48
66
 
49
- export function isCtxDatePathPrefix(pathSegments: string[]): boolean {
50
- const segments = withDatePrefix((pathSegments || []).map((seg) => String(seg)));
67
+ function isBaseCtxDatePathPrefix(segments: string[]): boolean {
51
68
  if (segments[0] !== 'date') return false;
52
69
  if (segments.length === 1) return true;
53
70
 
@@ -96,6 +113,36 @@ export function isCtxDatePathPrefix(pathSegments: string[]): boolean {
96
113
  return false;
97
114
  }
98
115
 
116
+ function decodeFormatToken(token: string): string | undefined {
117
+ const raw = String(token || '');
118
+ if (!raw.startsWith('v')) return undefined;
119
+ const decoded = decodeBase64Url(raw.slice(1));
120
+ if (!decoded || decoded.length > MAX_DATE_FORMAT_LENGTH) return undefined;
121
+ return decoded;
122
+ }
123
+
124
+ function splitFormattedDateSegments(segments: string[]): { baseSegments: string[]; format?: string } | null {
125
+ if (segments[0] !== 'date') return null;
126
+ if (segments[1] !== 'format') return { baseSegments: segments };
127
+ if (segments.length < 4) return null;
128
+
129
+ const format = decodeFormatToken(segments[2]);
130
+ if (!format) return null;
131
+ return { baseSegments: ['date', ...segments.slice(3)], format };
132
+ }
133
+
134
+ export function isCtxDatePathPrefix(pathSegments: string[]): boolean {
135
+ const segments = withDatePrefix((pathSegments || []).map((seg) => String(seg)));
136
+ if (segments[0] !== 'date') return false;
137
+ if (segments.length === 1) return true;
138
+ if (segments[1] !== 'format') return isBaseCtxDatePathPrefix(segments);
139
+ if (segments.length === 2) return true;
140
+ if (segments.length === 3) return typeof decodeFormatToken(segments[2]) === 'string';
141
+
142
+ const formatted = splitFormattedDateSegments(segments);
143
+ return formatted ? isBaseCtxDatePathPrefix(formatted.baseSegments) : false;
144
+ }
145
+
99
146
  function withDatePrefix(pathSegments: string[]): string[] {
100
147
  if (pathSegments[0] === 'date') {
101
148
  return pathSegments;
@@ -210,27 +257,29 @@ export function isCtxDateExpression(value: unknown): value is string {
210
257
  export function isCompleteCtxDatePath(pathSegments: string[]): boolean {
211
258
  if (!isCtxDatePathPrefix(pathSegments)) return false;
212
259
  const segments = withDatePrefix((pathSegments || []).map((seg) => String(seg)));
213
- if (segments[0] !== 'date') return false;
260
+ const formatted = splitFormattedDateSegments(segments);
261
+ if (!formatted) return false;
262
+ const baseSegments = formatted.baseSegments;
214
263
 
215
- if (segments[1] === 'preset') {
216
- return segments.length === 3 && PRESET_KEYS.has(segments[2]);
264
+ if (baseSegments[1] === 'preset') {
265
+ return baseSegments.length === 3 && PRESET_KEYS.has(baseSegments[2]);
217
266
  }
218
267
 
219
- if (segments[1] === 'relative') {
220
- if (segments.length !== 5) return false;
268
+ if (baseSegments[1] === 'relative') {
269
+ if (baseSegments.length !== 5) return false;
221
270
  return (
222
- RELATIVE_DIRECTIONS.has(segments[2]) &&
223
- RELATIVE_UNITS.has(segments[3]) &&
224
- typeof parseNumberToken(segments[4]) === 'number'
271
+ RELATIVE_DIRECTIONS.has(baseSegments[2]) &&
272
+ RELATIVE_UNITS.has(baseSegments[3]) &&
273
+ typeof parseNumberToken(baseSegments[4]) === 'number'
225
274
  );
226
275
  }
227
276
 
228
- if (segments[1] === 'exact' && segments[2] === 'single' && segments[3] === 'date') {
229
- return segments.length === 5 && /^v.+/.test(segments[4]);
277
+ if (baseSegments[1] === 'exact' && baseSegments[2] === 'single' && baseSegments[3] === 'date') {
278
+ return baseSegments.length === 5 && /^v.+/.test(baseSegments[4]);
230
279
  }
231
280
 
232
- if (segments[1] === 'exact' && segments[2] === 'range' && segments[3] === 'date') {
233
- return segments.length === 6 && /^v.+/.test(segments[4]) && /^v.+/.test(segments[5]);
281
+ if (baseSegments[1] === 'exact' && baseSegments[2] === 'range' && baseSegments[3] === 'date') {
282
+ return baseSegments.length === 6 && /^v.+/.test(baseSegments[4]) && /^v.+/.test(baseSegments[5]);
234
283
  }
235
284
 
236
285
  return false;
@@ -238,7 +287,10 @@ export function isCompleteCtxDatePath(pathSegments: string[]): boolean {
238
287
 
239
288
  export function parseCtxDateExpression(value: unknown): any {
240
289
  if (!isCtxDateExpression(value)) return undefined;
241
- const segments = withDatePrefix(parseCtxDateSegments(value as string) || []);
290
+ const rawSegments = withDatePrefix(parseCtxDateSegments(value as string) || []);
291
+ const formatted = splitFormattedDateSegments(rawSegments);
292
+ if (!formatted) return undefined;
293
+ const segments = formatted.baseSegments;
242
294
 
243
295
  if (segments[1] === 'preset' && segments.length === 3 && PRESET_KEYS.has(segments[2])) {
244
296
  return { type: segments[2] };
@@ -276,6 +328,66 @@ export function parseCtxDateExpression(value: unknown): any {
276
328
  return undefined;
277
329
  }
278
330
 
331
+ export function parseCtxDateExpressionConfig(value: unknown): CtxDateExpressionConfig | undefined {
332
+ if (!isCtxDateExpression(value)) return undefined;
333
+ const segments = withDatePrefix(parseCtxDateSegments(value) || []);
334
+ const formatted = splitFormattedDateSegments(segments);
335
+ if (!formatted) return undefined;
336
+
337
+ const parsed = parseCtxDateExpression(value);
338
+ const formatConfig = formatted.format ? { format: formatted.format } : {};
339
+ if (typeof parsed === 'string') {
340
+ return { kind: 'exact', value: parsed, ...formatConfig };
341
+ }
342
+ if (Array.isArray(parsed) && parsed.length === 2 && typeof parsed[0] === 'string' && typeof parsed[1] === 'string') {
343
+ return { kind: 'exact', value: [parsed[0], parsed[1]], ...formatConfig };
344
+ }
345
+
346
+ if (!parsed || typeof parsed !== 'object') return undefined;
347
+ const typed = parsed as { type?: unknown; unit?: unknown; number?: unknown };
348
+ if (typed.type === 'past' || typed.type === 'next') {
349
+ if (typeof typed.unit !== 'string' || !RELATIVE_UNITS.has(typed.unit) || typeof typed.number !== 'number') {
350
+ return undefined;
351
+ }
352
+ return {
353
+ kind: 'relative',
354
+ direction: typed.type,
355
+ amount: typed.number,
356
+ unit: typed.unit as CtxDateRelativeUnit,
357
+ ...formatConfig,
358
+ };
359
+ }
360
+
361
+ if (typeof typed.type === 'string' && PRESET_KEYS.has(typed.type)) {
362
+ return { kind: 'preset', preset: typed.type as CtxDatePreset, ...formatConfig };
363
+ }
364
+ return undefined;
365
+ }
366
+
367
+ export function serializeCtxDateExpressionConfig(config: CtxDateExpressionConfig): string | undefined {
368
+ let legacyValue: unknown;
369
+
370
+ if (config.kind === 'preset') {
371
+ if (!PRESET_KEYS.has(config.preset)) return undefined;
372
+ legacyValue = { type: config.preset };
373
+ } else if (config.kind === 'relative') {
374
+ if (!RELATIVE_DIRECTIONS.has(config.direction) || !RELATIVE_UNITS.has(config.unit)) return undefined;
375
+ const amount = Math.floor(Number(config.amount));
376
+ if (!Number.isFinite(amount) || amount <= 0) return undefined;
377
+ legacyValue = { type: config.direction, unit: config.unit, number: amount };
378
+ } else {
379
+ legacyValue = config.value;
380
+ }
381
+
382
+ const expression = serializeCtxDateValue(legacyValue);
383
+ if (!expression || !config.format) return expression;
384
+
385
+ const format = String(config.format);
386
+ if (!format.trim() || format.length > MAX_DATE_FORMAT_LENGTH) return undefined;
387
+ const segments = withDatePrefix(parseCtxDateSegments(expression) || []);
388
+ return toCtxDateExpression(['date', 'format', `v${encodeBase64Url(format)}`, ...segments.slice(1)]);
389
+ }
390
+
279
391
  export function serializeCtxDateValue(value: unknown): string | undefined {
280
392
  if (isCtxDateExpression(value)) {
281
393
  return String(value).trim();
@@ -327,8 +439,23 @@ export function serializeCtxDateValue(value: unknown): string | undefined {
327
439
  return undefined;
328
440
  }
329
441
 
442
+ function formatResolvedDateValue(value: unknown, format: string): unknown {
443
+ const formatValue = (item: unknown) => {
444
+ if (typeof item !== 'string') return item;
445
+ const parsed = dayjs(item);
446
+ return parsed.isValid() ? parsed.format(format) : item;
447
+ };
448
+ return Array.isArray(value) ? value.map(formatValue) : formatValue(value);
449
+ }
450
+
330
451
  export function resolveCtxDatePath(pathSegments: string[]): any {
331
- const segments = withDatePrefix((pathSegments || []).map((seg) => String(seg)));
452
+ const rawSegments = withDatePrefix((pathSegments || []).map((seg) => String(seg)));
453
+ const formatted = splitFormattedDateSegments(rawSegments);
454
+ if (!formatted) return undefined;
455
+ if (formatted.format) {
456
+ return formatResolvedDateValue(resolveCtxDatePath(formatted.baseSegments), formatted.format);
457
+ }
458
+ const segments = formatted.baseSegments;
332
459
  if (segments[0] !== 'date') return undefined;
333
460
 
334
461
  if (segments[1] === 'preset' && segments.length === 3) {
@@ -31,7 +31,12 @@ export { defineAction } from './flow-definitions';
31
31
  export { isInheritedFrom } from './inheritance';
32
32
 
33
33
  // 参数解析器
34
- export { resolveCreateModelOptions, resolveDefaultParams, resolveExpressions } from './params-resolvers';
34
+ export {
35
+ buildFlowModelResolveDescriptor,
36
+ resolveCreateModelOptions,
37
+ resolveDefaultParams,
38
+ resolveExpressions,
39
+ } from './params-resolvers';
35
40
 
36
41
  // Schema 工具
37
42
  export {
@@ -48,7 +53,11 @@ export { setupRuntimeContextSteps } from './setupRuntimeContextSteps';
48
53
 
49
54
  // Record Proxy 工具
50
55
  export { createCollectionContextMeta } from './createCollectionContextMeta';
51
- export { createAssociationAwareObjectMetaFactory, createAssociationSubpathResolver } from './associationObjectVariable';
56
+ export {
57
+ createAssociationAwareObjectMetaFactory,
58
+ createAssociationSubpathResolver,
59
+ getAssociationFilterByTk,
60
+ } from './associationObjectVariable';
52
61
  export {
53
62
  buildRecordMeta,
54
63
  collectContextParamsForTemplate,
@@ -83,8 +92,14 @@ export {
83
92
  isCtxDatePathPrefix,
84
93
  isCtxDateExpression,
85
94
  parseCtxDateExpression,
95
+ parseCtxDateExpressionConfig,
86
96
  resolveCtxDatePath,
97
+ serializeCtxDateExpressionConfig,
87
98
  serializeCtxDateValue,
99
+ type CtxDateExpressionConfig,
100
+ type CtxDatePreset,
101
+ type CtxDateRelativeDirection,
102
+ type CtxDateRelativeUnit,
88
103
  } from './dateVariable';
89
104
 
90
105
  // 安全全局对象(window/document)
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { getValuesByPath } from '@nocobase/shared';
11
+ import { generateFlowModelRdFromToken } from '@nocobase/utils/client';
11
12
  import _ from 'lodash';
12
13
  import { FlowContext, FlowModelContext, FlowRuntimeContext } from '../flowContext';
13
14
  import type { FlowModel } from '../models';
@@ -78,6 +79,7 @@ export type JSONValue = string | { [key: string]: JSONValue } | JSONValue[];
78
79
  // =========================
79
80
 
80
81
  type BatchPayload = {
82
+ rd?: string;
81
83
  template: JSONValue;
82
84
  contextParams?: ServerContextParams | undefined;
83
85
  };
@@ -170,6 +172,7 @@ export function enqueueVariablesResolve(ctx: FlowRuntimeContext, payload: BatchP
170
172
  try {
171
173
  const batch = items.map((it) => ({
172
174
  id: it.id,
175
+ rd: it.payload.rd,
173
176
  template: it.payload.template,
174
177
  contextParams: it.payload.contextParams || {},
175
178
  }));
@@ -218,6 +221,13 @@ export function enqueueVariablesResolve(ctx: FlowRuntimeContext, payload: BatchP
218
221
  return p;
219
222
  }
220
223
 
224
+ export function buildFlowModelResolveDescriptor(
225
+ ctx: Pick<FlowModelContext, 'api'>,
226
+ flowModelUid?: string | number | null,
227
+ ) {
228
+ return generateFlowModelRdFromToken(flowModelUid, ctx?.api?.auth?.token);
229
+ }
230
+
221
231
  /**
222
232
  * 解析参数中的 {{xxx}} 表达式,自动处理异步属性访问
223
233
  */
@@ -263,5 +263,15 @@ export async function collectContextParamsForTemplate(
263
263
  input[key] = built;
264
264
  }
265
265
  }
266
+
267
+ const viewPaths = usage.view || [];
268
+ if (
269
+ !input.view &&
270
+ viewPaths.some((path) => path === 'record' || path.startsWith('record.') || path.startsWith('record['))
271
+ ) {
272
+ const recordRef = inferViewRecordRef(ctx);
273
+ if (recordRef) input.view = { record: recordRef };
274
+ }
275
+
266
276
  return buildServerContextParams(ctx, input);
267
277
  }
@@ -15,6 +15,13 @@ import type { FlowView } from './FlowView';
15
15
 
16
16
  type PopupModelLike = { getStepParams?: (a: string, b: string) => any } | undefined;
17
17
 
18
+ function buildPopupSourceRecordRef(ref?: Pick<RecordRef, 'associationName' | 'dataSourceKey' | 'sourceId'>) {
19
+ if (ref?.sourceId == null || ref.sourceId === '' || typeof ref.associationName !== 'string') return undefined;
20
+ const collection = ref.associationName.split('.')[0];
21
+ if (!collection) return undefined;
22
+ return { collection, dataSourceKey: ref.dataSourceKey || 'main', filterByTk: ref.sourceId };
23
+ }
24
+
18
25
  function isDefined(value: any) {
19
26
  return value !== undefined && value !== null;
20
27
  }
@@ -327,13 +334,15 @@ export function createPopupMeta(ctx: FlowContext, anchorView?: FlowView): Proper
327
334
  const stack = getViewStack(view);
328
335
  const currentIndex = getAnchoredViewStackIndex(view, stack);
329
336
  if (currentIndex >= 2) {
330
- let cur: Record<string, any> = params;
337
+ let cur = params;
331
338
  let level = 1;
332
339
  let parentRef = await getParentRecordRef(level, c);
333
340
  while (parentRef) {
334
- if (!cur.parent) cur.parent = {};
335
- cur.parent.record = parentRef;
336
- cur = cur.parent;
341
+ const parent: PopupVariableParams = { record: parentRef };
342
+ const sourceRecord = buildPopupSourceRecordRef(parentRef);
343
+ if (sourceRecord) parent.sourceRecord = sourceRecord;
344
+ cur.parent = parent;
345
+ cur = parent;
337
346
  level += 1;
338
347
  parentRef = await getParentRecordRef(level, c);
339
348
  }
@@ -343,20 +352,12 @@ export function createPopupMeta(ctx: FlowContext, anchorView?: FlowView): Proper
343
352
  }
344
353
 
345
354
  try {
346
- const srcId = inputArgs?.sourceId;
347
- const assoc: string | undefined = inputArgs?.associationName;
348
- const dsKey: string = inputArgs?.dataSourceKey || 'main';
349
- if (srcId != null && srcId !== '' && assoc && typeof assoc === 'string') {
350
- // associationName 形如 `posts.comments`,父级集合为 `posts`
351
- const parentCollectionName = String(assoc).split('.')[0];
352
- if (parentCollectionName) {
353
- params.sourceRecord = {
354
- collection: parentCollectionName,
355
- dataSourceKey: dsKey,
356
- filterByTk: srcId,
357
- };
358
- }
359
- }
355
+ const sourceRecord = buildPopupSourceRecordRef({
356
+ sourceId: inputArgs?.sourceId,
357
+ associationName: inputArgs?.associationName,
358
+ dataSourceKey: inputArgs?.dataSourceKey,
359
+ });
360
+ if (sourceRecord) params.sourceRecord = sourceRecord;
360
361
  } catch (err) {
361
362
  c.logger?.debug?.({ err }, '[FlowEngine] buildVariablesParams: infer sourceRecord failed');
362
363
  }
@@ -474,12 +475,14 @@ interface PopupNodeResource {
474
475
  interface PopupNode {
475
476
  uid?: string;
476
477
  resource: PopupNodeResource;
478
+ sourceRecord?: unknown;
477
479
  parent?: PopupNode;
478
480
  }
479
481
 
480
482
  export async function buildPopupRuntime(ctx: FlowContext, view: FlowView): Promise<PopupNode | undefined> {
481
483
  const stack = getViewStack(view);
482
484
  const currentIndex = getAnchoredViewStackIndex(view, stack);
485
+ const sourceRecord = view?.inputArgs?.parentItem?.value;
483
486
 
484
487
  const openerUids = view?.inputArgs?.openerUids;
485
488
  const hasOpener = Array.isArray(openerUids) && openerUids.length > 0;
@@ -502,6 +505,7 @@ export async function buildPopupRuntime(ctx: FlowContext, view: FlowView): Promi
502
505
  filterByTk: args.filterByTk,
503
506
  sourceId: args.sourceId,
504
507
  },
508
+ ...(typeof sourceRecord !== 'undefined' ? { sourceRecord } : {}),
505
509
  };
506
510
  }
507
511
 
@@ -530,6 +534,9 @@ export async function buildPopupRuntime(ctx: FlowContext, view: FlowView): Promi
530
534
  return node;
531
535
  };
532
536
  const currentNode = await buildNode(currentIndex);
537
+ if (currentNode && typeof sourceRecord !== 'undefined') {
538
+ currentNode.sourceRecord = sourceRecord;
539
+ }
533
540
  return currentNode;
534
541
  }
535
542
 
@@ -541,6 +548,30 @@ export function registerPopupVariable(ctx: FlowContext, view: FlowView) {
541
548
  // - 任意层级 parent.parent... 下的 record / sourceRecord 及其子字段
542
549
  const POPUP_SERVER_PATH_RE =
543
550
  /^(?:record|sourceRecord)(?:\.|$)|^parent(?:\.parent)*(?:\.(?:record|sourceRecord))(?:\.|$)/;
551
+ const shouldResolveSourceRecordOnServer = (path: string): boolean => {
552
+ if (path !== 'sourceRecord' && !path.startsWith('sourceRecord.')) return false;
553
+
554
+ const parentItem = view?.inputArgs?.parentItem;
555
+ if (typeof parentItem?.value === 'undefined') return true;
556
+
557
+ const sourcePath = path === 'sourceRecord' ? '' : path.slice('sourceRecord.'.length);
558
+ if (!sourcePath) return false;
559
+
560
+ const parentItemResolver = view?.inputArgs?.parentItemResolver;
561
+ if (typeof parentItemResolver === 'function') {
562
+ return parentItemResolver(`value.${sourcePath}`);
563
+ }
564
+
565
+ const segments = sourcePath.split('.').filter(Boolean);
566
+ let current = parentItem.value;
567
+ for (const segment of segments) {
568
+ if (current === null || typeof current !== 'object' || !(segment in current)) {
569
+ return true;
570
+ }
571
+ current = current[segment];
572
+ }
573
+ return false;
574
+ };
544
575
  // 始终注册 popup 变量:
545
576
  // - 若当前视图无可推断记录,仅在元信息中不呈现 record 字段;
546
577
  // - 但仍可依据 navigation 推断并展示上级弹窗信息。
@@ -549,6 +580,9 @@ export function registerPopupVariable(ctx: FlowContext, view: FlowView) {
549
580
  meta: createPopupMeta(ctx, view),
550
581
  resolveOnServer: (p: string) => {
551
582
  try {
583
+ if (p === 'sourceRecord' || p.startsWith('sourceRecord.')) {
584
+ return shouldResolveSourceRecordOnServer(p);
585
+ }
552
586
  return !!p && POPUP_SERVER_PATH_RE.test(p);
553
587
  } catch (_) {
554
588
  return false;