@nocobase/flow-engine 2.2.0-beta.15 → 2.2.0-beta.16

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.
package/lib/acl/Acl.d.ts CHANGED
@@ -5,7 +5,7 @@ interface CheckOptions {
5
5
  actionName: string;
6
6
  fields?: string[];
7
7
  recordPkValue?: string | number;
8
- allowedActions: any[];
8
+ allowedActions?: Record<string, Array<string | number>>;
9
9
  }
10
10
  export declare class ACL {
11
11
  private flowEngine;
@@ -26,6 +26,7 @@ export declare class ACL {
26
26
  verifyScope: (actionName: string, recordPkValue: any, allowedActions: any) => boolean;
27
27
  parseAction(options: CheckOptions): any;
28
28
  parseField(options: CheckOptions): boolean;
29
+ can(options: CheckOptions): boolean;
29
30
  aclCheck(options: CheckOptions): Promise<boolean>;
30
31
  }
31
32
  export {};
package/lib/acl/Acl.js CHANGED
@@ -154,6 +154,34 @@ const _ACL = class _ACL {
154
154
  const allowed = whitelist.includes(fields[0]);
155
155
  return allowed;
156
156
  }
157
+ can(options) {
158
+ var _a;
159
+ const { allowAll } = this.data;
160
+ if (allowAll) {
161
+ return true;
162
+ }
163
+ const { actionName, allowedActions, recordPkValue } = options;
164
+ const hasRecordPkValue = recordPkValue !== void 0 && recordPkValue !== null;
165
+ const recordPermission = hasRecordPkValue && allowedActions ? this.verifyScope(actionName, recordPkValue, allowedActions) : null;
166
+ if (hasRecordPkValue && allowedActions && recordPermission !== true) {
167
+ return false;
168
+ }
169
+ const params = this.parseAction(options);
170
+ if (!params) {
171
+ return false;
172
+ }
173
+ if (!import_lodash.default.isEmpty(params.filter) && recordPermission !== true) {
174
+ return false;
175
+ }
176
+ if (!((_a = options.fields) == null ? void 0 : _a.length)) {
177
+ return true;
178
+ }
179
+ const allowedFields = [].concat(params.whitelist || []).concat(params.fields || []).concat(params.appends || []);
180
+ if (!allowedFields.length) {
181
+ return true;
182
+ }
183
+ return options.fields.every((field) => allowedFields.includes(field));
184
+ }
157
185
  async aclCheck(options) {
158
186
  const { allowAll } = this.data;
159
187
  if (allowAll) {
@@ -47,25 +47,36 @@ var import_react_i18next = require("react-i18next");
47
47
  var import_lazy_helper = require("../lazy-helper");
48
48
  const { Popup } = (0, import_lazy_helper.lazy)(() => import("antd-mobile"), "Popup");
49
49
  const { CloseOutline } = (0, import_lazy_helper.lazy)(() => import("antd-mobile-icons"), "CloseOutline");
50
+ const getMobilePopupMaxHeight = /* @__PURE__ */ __name(() => {
51
+ var _a;
52
+ if (typeof CSS !== "undefined" && ((_a = CSS.supports) == null ? void 0 : _a.call(CSS, "height", "100dvh"))) {
53
+ return "calc(100dvh - var(--nb-mobile-page-header-height, 46px))";
54
+ }
55
+ return "calc(100vh - var(--nb-mobile-page-header-height, 46px))";
56
+ }, "getMobilePopupMaxHeight");
50
57
  const MobilePopup = /* @__PURE__ */ __name((props) => {
51
58
  var _a;
52
59
  const { title, visible, onClose: closePopup, children, minHeight, className, footer } = props;
53
60
  const { t } = (0, import_react_i18next.useTranslation)();
54
61
  const { componentCls, hashId } = (0, import_MobilePopup.useMobileActionDrawerStyle)();
55
62
  const bodyStyles = (_a = props.styles) == null ? void 0 : _a.body;
63
+ const defaultMaxHeight = getMobilePopupMaxHeight();
56
64
  const popupStyle = (0, import_react.useMemo)(() => {
57
65
  return {
58
66
  minHeight: (bodyStyles == null ? void 0 : bodyStyles.minHeight) ?? minHeight,
59
67
  height: bodyStyles == null ? void 0 : bodyStyles.height,
60
- maxHeight: bodyStyles == null ? void 0 : bodyStyles.maxHeight
68
+ maxHeight: (bodyStyles == null ? void 0 : bodyStyles.maxHeight) ?? defaultMaxHeight
61
69
  };
62
- }, [bodyStyles == null ? void 0 : bodyStyles.height, bodyStyles == null ? void 0 : bodyStyles.maxHeight, bodyStyles == null ? void 0 : bodyStyles.minHeight, minHeight]);
70
+ }, [bodyStyles == null ? void 0 : bodyStyles.height, bodyStyles == null ? void 0 : bodyStyles.maxHeight, bodyStyles == null ? void 0 : bodyStyles.minHeight, defaultMaxHeight, minHeight]);
63
71
  const bodyStyle = (0, import_react.useMemo)(() => {
64
72
  return {
65
73
  padding: 0,
74
+ maxHeight: defaultMaxHeight,
75
+ overflowY: "auto",
76
+ overflowX: "hidden",
66
77
  ...bodyStyles
67
78
  };
68
- }, [bodyStyles]);
79
+ }, [bodyStyles, defaultMaxHeight]);
69
80
  const handleCloseKeyDown = (0, import_react.useCallback)(
70
81
  (event) => {
71
82
  if (event.key !== "Enter" && event.key !== " ") {
@@ -347,19 +347,15 @@ const createSearchItem = /* @__PURE__ */ __name((item, searchKey, currentSearchV
347
347
  e.stopPropagation();
348
348
  },
349
349
  onChange: (e) => {
350
- var _a;
351
350
  e.stopPropagation();
352
351
  const value = e.target.value;
353
352
  if (shouldActivateSearchSubmenu) {
354
353
  activateSearchSubmenu(searchKey);
355
354
  }
356
- if (((_a = e.nativeEvent) == null ? void 0 : _a.isComposing) || searchHandlers.isComposing(searchKey)) {
355
+ if (e.nativeEvent.isComposing || searchHandlers.isComposing(searchKey)) {
357
356
  searchHandlers.updateInputValue(searchKey, value);
358
357
  return;
359
358
  }
360
- if (!value && shouldActivateSearchSubmenu) {
361
- deactivateSearchSubmenu(searchKey);
362
- }
363
359
  searchHandlers.updateSearchValue(searchKey, value);
364
360
  },
365
361
  onCompositionStart: (e) => {
@@ -373,11 +369,7 @@ const createSearchItem = /* @__PURE__ */ __name((item, searchKey, currentSearchV
373
369
  e.stopPropagation();
374
370
  const value = e.currentTarget.value;
375
371
  if (shouldActivateSearchSubmenu) {
376
- if (value) {
377
- activateSearchSubmenu(searchKey);
378
- } else {
379
- deactivateSearchSubmenu(searchKey);
380
- }
372
+ activateSearchSubmenu(searchKey);
381
373
  }
382
374
  searchHandlers.endComposition(searchKey, value);
383
375
  },
@@ -385,10 +377,20 @@ const createSearchItem = /* @__PURE__ */ __name((item, searchKey, currentSearchV
385
377
  e.stopPropagation();
386
378
  },
387
379
  onKeyDown: (e) => {
380
+ if (e.key === "Escape" || e.key === "Tab") {
381
+ deactivateSearchSubmenu(searchKey);
382
+ return;
383
+ }
384
+ if (shouldActivateSearchSubmenu) {
385
+ activateSearchSubmenu(searchKey);
386
+ }
388
387
  e.stopPropagation();
389
388
  },
390
389
  onMouseDown: (e) => {
391
390
  e.stopPropagation();
391
+ if (shouldActivateSearchSubmenu) {
392
+ activateSearchSubmenu(searchKey);
393
+ }
392
394
  },
393
395
  size: "small",
394
396
  style: {
@@ -409,7 +411,7 @@ const KEEP_OPEN_LABEL_STYLE = {
409
411
  width: "100%"
410
412
  };
411
413
  const DROPDOWN_PERSIST_TTL_MS = 350;
412
- const SUBMENU_CLOSE_DELAY = 0.05;
414
+ const MENU_CLOSE_DELAY = 0.3;
413
415
  const SUBMENU_MOTION_DISABLED = {
414
416
  motionEnter: false,
415
417
  motionLeave: false
@@ -417,13 +419,18 @@ const SUBMENU_MOTION_DISABLED = {
417
419
  const dropdownPersistRegistry = /* @__PURE__ */ new Map();
418
420
  const LazyDropdown = /* @__PURE__ */ __name(({ menu, ...props }) => {
419
421
  const engine = (0, import_provider.useFlowEngine)();
422
+ const { getPrefixCls } = import_react.default.useContext(import_antd.ConfigProvider.ConfigContext);
423
+ const triggerId = import_react.default.useId();
420
424
  const [menuVisible, setMenuVisible] = (0, import_react.useState)(false);
421
425
  const [openKeys, setOpenKeys] = (0, import_react.useState)(/* @__PURE__ */ new Set());
422
- const [activeSearchKey, setActiveSearchKey] = (0, import_react.useState)(null);
423
426
  const [rootItems, setRootItems] = (0, import_react.useState)([]);
424
427
  const [rootLoading, setRootLoading] = (0, import_react.useState)(false);
428
+ const activeSearchKeyRef = (0, import_react.useRef)(null);
425
429
  const closeByOutsideClickRef = (0, import_react.useRef)(false);
426
430
  const skipPreserveActiveSearchRef = (0, import_react.useRef)(false);
431
+ const triggerOpenClassName = `nb-lazy-dropdown-trigger-${triggerId.replace(/[^a-zA-Z0-9_-]/g, "")}`;
432
+ const defaultOpenClassName = `${getPrefixCls("dropdown", props.prefixCls)}-open`;
433
+ const mergedOpenClassName = [props.openClassName ?? defaultOpenClassName, triggerOpenClassName].filter(Boolean).join(" ");
427
434
  const dropdownMaxHeight = useNiceDropdownMaxHeight();
428
435
  const t = engine.translate.bind(engine);
429
436
  const { items: menuItems, keepDropdownOpen, persistKey, stateVersion, refreshKeys, ...dropdownMenuProps } = menu;
@@ -440,12 +447,12 @@ const LazyDropdown = /* @__PURE__ */ __name(({ menu, ...props }) => {
440
447
  useSubmenuStyles(menuVisible, dropdownMaxHeight);
441
448
  const closeMenu = (0, import_react.useCallback)(() => {
442
449
  setMenuVisible(false);
443
- setActiveSearchKey(null);
450
+ activeSearchKeyRef.current = null;
444
451
  setOpenKeys(/* @__PURE__ */ new Set());
445
452
  clearAllSearchValues();
446
453
  }, [clearAllSearchValues]);
447
454
  const activateSearchSubmenu = (0, import_react.useCallback)((key) => {
448
- setActiveSearchKey(key);
455
+ activeSearchKeyRef.current = key;
449
456
  setOpenKeys((prev) => {
450
457
  if (prev.has(key)) return prev;
451
458
  const next = new Set(prev);
@@ -454,34 +461,38 @@ const LazyDropdown = /* @__PURE__ */ __name(({ menu, ...props }) => {
454
461
  });
455
462
  }, []);
456
463
  const deactivateSearchSubmenu = (0, import_react.useCallback)((key) => {
457
- setActiveSearchKey((prev) => prev === key ? null : prev);
464
+ if (activeSearchKeyRef.current === key) {
465
+ activeSearchKeyRef.current = null;
466
+ }
458
467
  }, []);
459
468
  const closeActiveSearchForPath = (0, import_react.useCallback)(
460
469
  (keyPath) => {
470
+ const activeSearchKey = activeSearchKeyRef.current;
461
471
  if (!activeSearchKey || keyPath === activeSearchKey || keyPath.startsWith(`${activeSearchKey}/`) || activeSearchKey.startsWith(`${keyPath}/`)) {
462
472
  return;
463
473
  }
464
474
  skipPreserveActiveSearchRef.current = true;
465
475
  clearSearchValue(activeSearchKey);
466
- setActiveSearchKey(null);
476
+ activeSearchKeyRef.current = null;
467
477
  setOpenKeys((prev) => {
468
478
  const next = new Set(prev);
469
479
  next.delete(activeSearchKey);
470
480
  return next;
471
481
  });
472
482
  },
473
- [activeSearchKey, clearSearchValue]
483
+ [clearSearchValue]
474
484
  );
475
485
  const handleMenuOpenChange = (0, import_react.useCallback)(
476
486
  (nextOpenKeys) => {
477
487
  var _a, _b;
478
488
  let normalized = normalizeOpenKeys(nextOpenKeys);
479
- if (activeSearchKey && openKeys.has(activeSearchKey) && !normalized.includes(activeSearchKey)) {
480
- if (normalized.length || skipPreserveActiveSearchRef.current) {
489
+ const activeSearchKey = activeSearchKeyRef.current;
490
+ if (activeSearchKey && !normalized.includes(activeSearchKey)) {
491
+ if (skipPreserveActiveSearchRef.current) {
481
492
  clearSearchValue(activeSearchKey);
482
- setActiveSearchKey(null);
493
+ activeSearchKeyRef.current = null;
483
494
  } else {
484
- normalized = [activeSearchKey];
495
+ normalized = Array.from(openKeys);
485
496
  }
486
497
  }
487
498
  if (!normalized.length && shouldPreventClose()) {
@@ -498,13 +509,15 @@ const LazyDropdown = /* @__PURE__ */ __name(({ menu, ...props }) => {
498
509
  (_b = dropdownMenuProps.onOpenChange) == null ? void 0 : _b.call(dropdownMenuProps, normalized);
499
510
  skipPreserveActiveSearchRef.current = false;
500
511
  },
501
- [activeSearchKey, clearSearchValue, dropdownMenuProps, openKeys, shouldPreventClose]
512
+ [clearSearchValue, dropdownMenuProps, openKeys, shouldPreventClose]
502
513
  );
503
514
  (0, import_react.useEffect)(() => {
504
515
  if (!menuVisible) return;
505
516
  const markOutsideClick = /* @__PURE__ */ __name((event) => {
506
517
  const target = event.target;
507
- const isOutside = !(target == null ? void 0 : target.closest(".ant-dropdown, .ant-dropdown-menu, .ant-dropdown-menu-submenu-popup"));
518
+ const isInsidePopup = target == null ? void 0 : target.closest(".ant-dropdown, .ant-dropdown-menu, .ant-dropdown-menu-submenu-popup");
519
+ const isInsideCurrentTrigger = target == null ? void 0 : target.closest(`.${triggerOpenClassName}`);
520
+ const isOutside = !isInsidePopup && !isInsideCurrentTrigger;
508
521
  closeByOutsideClickRef.current = isOutside;
509
522
  if (isOutside) {
510
523
  closeMenu();
@@ -516,7 +529,7 @@ const LazyDropdown = /* @__PURE__ */ __name(({ menu, ...props }) => {
516
529
  document.removeEventListener("pointerdown", markOutsideClick, true);
517
530
  document.removeEventListener("mousedown", markOutsideClick, true);
518
531
  };
519
- }, [closeMenu, menuVisible]);
532
+ }, [closeMenu, menuVisible, triggerOpenClassName]);
520
533
  (0, import_react.useEffect)(() => {
521
534
  if (!persistKey) return;
522
535
  const until = dropdownPersistRegistry.get(persistKey) || 0;
@@ -734,13 +747,15 @@ const LazyDropdown = /* @__PURE__ */ __name(({ menu, ...props }) => {
734
747
  ...props,
735
748
  open: menuVisible,
736
749
  destroyPopupOnHide: true,
750
+ mouseLeaveDelay: props.mouseLeaveDelay ?? MENU_CLOSE_DELAY,
751
+ openClassName: mergedOpenClassName,
737
752
  overlayClassName,
738
753
  placement: "bottomLeft",
739
754
  menu: {
740
755
  ...dropdownMenuProps,
741
756
  openKeys: Array.from(openKeys),
742
757
  items,
743
- subMenuCloseDelay: dropdownMenuProps.subMenuCloseDelay ?? SUBMENU_CLOSE_DELAY,
758
+ subMenuCloseDelay: dropdownMenuProps.subMenuCloseDelay ?? MENU_CLOSE_DELAY,
744
759
  motion: dropdownMenuProps.motion ?? SUBMENU_MOTION_DISABLED,
745
760
  onClick: /* @__PURE__ */ __name(() => {
746
761
  }, "onClick"),
@@ -752,7 +767,7 @@ const LazyDropdown = /* @__PURE__ */ __name(({ menu, ...props }) => {
752
767
  }
753
768
  },
754
769
  onOpenChange: (visible, info) => {
755
- if (!visible && activeSearchKey && (info == null ? void 0 : info.source) === "trigger" && !closeByOutsideClickRef.current) {
770
+ if (!visible && activeSearchKeyRef.current && (info == null ? void 0 : info.source) === "trigger" && !closeByOutsideClickRef.current) {
756
771
  return;
757
772
  }
758
773
  if (!visible && shouldPreventClose()) {
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "Add": "Add",
3
+ "Are you sure you want to perform the action?": "Are you sure you want to perform the action?",
3
4
  "Are you sure you want to delete this item? This action cannot be undone.": "Are you sure you want to delete this item? This action cannot be undone.",
4
5
  "Are you sure to convert this template block to copy mode?": "Are you sure you want to convert this template block to copy mode?",
5
6
  "Array index out of bounds": "Array index {{index}} out of bounds for '{{subKey}}'",
@@ -53,6 +54,7 @@
53
54
  "Other blocks": "Other blocks",
54
55
  "Parent not found, cannot replace block": "Parent not found, cannot replace block",
55
56
  "Previous step": "Previous step",
57
+ "Please Confirm": "Please Confirm",
56
58
  "Replace current block with template?": "Replace current block with template?",
57
59
  "Replaced with template block": "Replaced with template block",
58
60
  "Render failed": "Render failed",
@@ -9,6 +9,7 @@
9
9
  export declare const locales: {
10
10
  'en-US': {
11
11
  Add: string;
12
+ "Are you sure you want to perform the action?": string;
12
13
  "Are you sure you want to delete this item? This action cannot be undone.": string;
13
14
  "Are you sure to convert this template block to copy mode?": string;
14
15
  "Array index out of bounds": string;
@@ -62,6 +63,7 @@ export declare const locales: {
62
63
  "Other blocks": string;
63
64
  "Parent not found, cannot replace block": string;
64
65
  "Previous step": string;
66
+ "Please Confirm": string;
65
67
  "Replace current block with template?": string;
66
68
  "Replaced with template block": string;
67
69
  "Render failed": string;
@@ -91,6 +93,7 @@ export declare const locales: {
91
93
  };
92
94
  'zh-CN': {
93
95
  Add: string;
96
+ "Are you sure you want to perform the action?": string;
94
97
  "Are you sure you want to delete this item? This action cannot be undone.": string;
95
98
  "Are you sure to convert this template block to copy mode?": string;
96
99
  "Array index out of bounds": string;
@@ -150,6 +153,7 @@ export declare const locales: {
150
153
  OK: string;
151
154
  "Other blocks": string;
152
155
  "Previous step": string;
156
+ "Please Confirm": string;
153
157
  "Render failed": string;
154
158
  "Response record": string;
155
159
  "Step configuration": string;
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "Add": "添加",
3
+ "Are you sure you want to perform the action?": "确定要执行此操作吗?",
3
4
  "Are you sure you want to delete this item? This action cannot be undone.": "确定要删除此项吗?此操作不可撤销。",
4
5
  "Are you sure to convert this template block to copy mode?": "确定将该模板区块转换为复制模式吗?",
5
6
  "Array index out of bounds": "数组索引 {{index}} 超出 '{{subKey}}' 的边界",
@@ -59,6 +60,7 @@
59
60
  "OK": "确定",
60
61
  "Other blocks": "其他区块",
61
62
  "Previous step": "上一步",
63
+ "Please Confirm": "请确认",
62
64
  "Render failed": "渲染失败",
63
65
  "Response record": "响应结果记录",
64
66
  "Step configuration": "步骤配置",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nocobase/flow-engine",
3
- "version": "2.2.0-beta.15",
3
+ "version": "2.2.0-beta.16",
4
4
  "private": false,
5
5
  "description": "A standalone flow engine for NocoBase, managing workflows, models, and actions.",
6
6
  "main": "lib/index.js",
@@ -8,8 +8,8 @@
8
8
  "dependencies": {
9
9
  "@formily/antd-v5": "1.x",
10
10
  "@formily/reactive": "2.x",
11
- "@nocobase/sdk": "2.2.0-beta.15",
12
- "@nocobase/shared": "2.2.0-beta.15",
11
+ "@nocobase/sdk": "2.2.0-beta.16",
12
+ "@nocobase/shared": "2.2.0-beta.16",
13
13
  "ahooks": "^3.7.2",
14
14
  "axios": "^1.7.0",
15
15
  "dayjs": "^1.11.9",
@@ -37,5 +37,5 @@
37
37
  ],
38
38
  "author": "NocoBase Team",
39
39
  "license": "Apache-2.0",
40
- "gitHead": "4c471f6b7d26adc412b2cc1d91264572296ca873"
40
+ "gitHead": "49d9bd2e21122eab9435f128375828b8c5bb4c07"
41
41
  }
package/src/acl/Acl.tsx CHANGED
@@ -16,7 +16,7 @@ interface CheckOptions {
16
16
  actionName: string;
17
17
  fields?: string[];
18
18
  recordPkValue?: string | number;
19
- allowedActions: any[];
19
+ allowedActions?: Record<string, Array<string | number>>;
20
20
  }
21
21
 
22
22
  export class ACL {
@@ -149,6 +149,41 @@ export class ACL {
149
149
  return allowed;
150
150
  }
151
151
 
152
+ can(options: CheckOptions): boolean {
153
+ const { allowAll } = this.data;
154
+ if (allowAll) {
155
+ return true;
156
+ }
157
+
158
+ const { actionName, allowedActions, recordPkValue } = options;
159
+ const hasRecordPkValue = recordPkValue !== undefined && recordPkValue !== null;
160
+ const recordPermission =
161
+ hasRecordPkValue && allowedActions ? this.verifyScope(actionName, recordPkValue, allowedActions) : null;
162
+ if (hasRecordPkValue && allowedActions && recordPermission !== true) {
163
+ return false;
164
+ }
165
+
166
+ const params = this.parseAction(options);
167
+ if (!params) {
168
+ return false;
169
+ }
170
+ if (!_.isEmpty(params.filter) && recordPermission !== true) {
171
+ return false;
172
+ }
173
+ if (!options.fields?.length) {
174
+ return true;
175
+ }
176
+
177
+ const allowedFields: string[] = []
178
+ .concat(params.whitelist || [])
179
+ .concat(params.fields || [])
180
+ .concat(params.appends || []);
181
+ if (!allowedFields.length) {
182
+ return true;
183
+ }
184
+ return options.fields.every((field) => allowedFields.includes(field));
185
+ }
186
+
152
187
  async aclCheck(options: CheckOptions): Promise<boolean> {
153
188
  // await this.load();
154
189
  const { allowAll } = this.data;
@@ -71,6 +71,76 @@ describe('ACL', () => {
71
71
  expect(notOk).toBe(false);
72
72
  });
73
73
 
74
+ it('checks record update scope before field permission', () => {
75
+ const payload = {
76
+ data: {
77
+ allowAll: false,
78
+ actionAlias: {},
79
+ resources: ['posts'],
80
+ actions: { 'posts:update': { whitelist: ['title'] } },
81
+ strategy: { actions: [] },
82
+ },
83
+ };
84
+ const engine = makeEngine(payload);
85
+ const acl = new ACL(engine);
86
+ acl.setData(payload.data);
87
+
88
+ const options = {
89
+ dataSourceKey: 'main',
90
+ resourceName: 'posts',
91
+ actionName: 'update',
92
+ allowedActions: {
93
+ update: [1],
94
+ },
95
+ };
96
+
97
+ expect(acl.can({ ...options, recordPkValue: 1, fields: ['title'] })).toBe(true);
98
+ expect(acl.can({ ...options, recordPkValue: 2, fields: ['title'] })).toBe(false);
99
+ expect(acl.can({ ...options, recordPkValue: 1, fields: ['body'] })).toBe(false);
100
+ expect(acl.can({ ...options, recordPkValue: 0, fields: ['title'] })).toBe(false);
101
+ expect(
102
+ acl.can({
103
+ dataSourceKey: 'main',
104
+ resourceName: 'posts',
105
+ actionName: 'update',
106
+ fields: ['title'],
107
+ }),
108
+ ).toBe(true);
109
+ });
110
+
111
+ it('allows every field when a scoped action has no field restriction', () => {
112
+ const payload = {
113
+ data: {
114
+ allowAll: false,
115
+ actionAlias: {},
116
+ resources: ['posts'],
117
+ actions: {
118
+ 'posts:update': {
119
+ filter: { createdById: '{{ ctx.state.currentUser.id }}' },
120
+ },
121
+ },
122
+ strategy: { actions: [] },
123
+ },
124
+ };
125
+ const engine = makeEngine(payload);
126
+ const acl = new ACL(engine);
127
+ acl.setData(payload.data);
128
+
129
+ const options = {
130
+ dataSourceKey: 'main',
131
+ resourceName: 'posts',
132
+ actionName: 'update',
133
+ allowedActions: {
134
+ update: [1],
135
+ },
136
+ fields: ['title'],
137
+ };
138
+
139
+ expect(acl.can({ ...options, recordPkValue: 1 })).toBe(true);
140
+ expect(acl.can({ ...options, recordPkValue: 2 })).toBe(false);
141
+ expect(acl.can({ ...options, allowedActions: undefined, recordPkValue: undefined })).toBe(false);
142
+ });
143
+
74
144
  it('reloads permissions when auth token changes', async () => {
75
145
  const payload1 = {
76
146
  data: {
@@ -26,26 +26,38 @@ interface MobilePopupProps {
26
26
  footer?: ReactNode;
27
27
  }
28
28
 
29
+ const getMobilePopupMaxHeight = () => {
30
+ if (typeof CSS !== 'undefined' && CSS.supports?.('height', '100dvh')) {
31
+ return 'calc(100dvh - var(--nb-mobile-page-header-height, 46px))';
32
+ }
33
+
34
+ return 'calc(100vh - var(--nb-mobile-page-header-height, 46px))';
35
+ };
36
+
29
37
  export const MobilePopup: FC<MobilePopupProps> = (props) => {
30
38
  const { title, visible, onClose: closePopup, children, minHeight, className, footer } = props;
31
39
  const { t } = useTranslation();
32
40
  const { componentCls, hashId } = useMobileActionDrawerStyle();
33
41
 
34
42
  const bodyStyles = (props as MobilePopupProps & { styles?: { body?: React.CSSProperties } }).styles?.body;
43
+ const defaultMaxHeight = getMobilePopupMaxHeight();
35
44
  const popupStyle = useMemo(() => {
36
45
  return {
37
46
  minHeight: bodyStyles?.minHeight ?? minHeight,
38
47
  height: bodyStyles?.height,
39
- maxHeight: bodyStyles?.maxHeight,
48
+ maxHeight: bodyStyles?.maxHeight ?? defaultMaxHeight,
40
49
  };
41
- }, [bodyStyles?.height, bodyStyles?.maxHeight, bodyStyles?.minHeight, minHeight]);
50
+ }, [bodyStyles?.height, bodyStyles?.maxHeight, bodyStyles?.minHeight, defaultMaxHeight, minHeight]);
42
51
 
43
- const bodyStyle = useMemo(() => {
52
+ const bodyStyle = useMemo<React.CSSProperties>(() => {
44
53
  return {
45
54
  padding: 0,
55
+ maxHeight: defaultMaxHeight,
56
+ overflowY: 'auto',
57
+ overflowX: 'hidden',
46
58
  ...bodyStyles,
47
59
  };
48
- }, [bodyStyles]);
60
+ }, [bodyStyles, defaultMaxHeight]);
49
61
 
50
62
  const handleCloseKeyDown = useCallback(
51
63
  (event: React.KeyboardEvent<HTMLSpanElement>) => {
@@ -9,7 +9,7 @@
9
9
 
10
10
  import React from 'react';
11
11
  import { fireEvent, render, screen } from '@testing-library/react';
12
- import { describe, expect, it, vi } from 'vitest';
12
+ import { afterEach, describe, expect, it, vi } from 'vitest';
13
13
  import { MobilePopup } from '../MobilePopup';
14
14
 
15
15
  vi.mock('react-i18next', () => ({
@@ -61,6 +61,47 @@ describe('MobilePopup', () => {
61
61
  React.ComponentProps<typeof MobilePopup> & { styles?: { body?: React.CSSProperties } }
62
62
  >;
63
63
 
64
+ afterEach(() => {
65
+ vi.unstubAllGlobals();
66
+ });
67
+
68
+ it('constrains the default popup to the dynamic viewport and keeps the body scrollable', () => {
69
+ vi.stubGlobal('CSS', {
70
+ supports: vi.fn((property: string, value: string) => property === 'height' && value === '100dvh'),
71
+ });
72
+
73
+ render(
74
+ <MobilePopup visible title="Title" onClose={vi.fn()}>
75
+ body
76
+ </MobilePopup>,
77
+ );
78
+
79
+ const maxHeight = 'calc(100dvh - var(--nb-mobile-page-header-height, 46px))';
80
+
81
+ expect(screen.getByTestId('mobile-popup')).toHaveStyle({ maxHeight });
82
+ expect(screen.getByTestId('mobile-popup-body')).toHaveStyle({
83
+ maxHeight,
84
+ overflowY: 'auto',
85
+ overflowX: 'hidden',
86
+ });
87
+ });
88
+
89
+ it('falls back to the layout viewport when dynamic viewport units are unavailable', () => {
90
+ vi.stubGlobal('CSS', {
91
+ supports: vi.fn(() => false),
92
+ });
93
+
94
+ render(
95
+ <MobilePopup visible title="Title" onClose={vi.fn()}>
96
+ body
97
+ </MobilePopup>,
98
+ );
99
+
100
+ expect(screen.getByTestId('mobile-popup-body')).toHaveStyle({
101
+ maxHeight: 'calc(100vh - var(--nb-mobile-page-header-height, 46px))',
102
+ });
103
+ });
104
+
64
105
  it('applies drawer body styles as max bounds without forcing fixed half-window height', () => {
65
106
  render(
66
107
  <MobilePopupWithDrawerStyles visible title="Title" styles={{ body: { maxHeight: '50vh' } }} onClose={vi.fn()}>
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { css } from '@emotion/css';
11
- import { Dropdown, DropdownProps, Empty, Input, InputProps, Spin } from 'antd';
11
+ import { ConfigProvider, Dropdown, DropdownProps, Empty, Input, InputProps, Spin } from 'antd';
12
12
  import React, { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
13
13
  import { useFlowEngine } from '../../provider';
14
14
 
@@ -484,13 +484,10 @@ const createSearchItem = (
484
484
  if (shouldActivateSearchSubmenu) {
485
485
  activateSearchSubmenu(searchKey);
486
486
  }
487
- if ((e.nativeEvent as any)?.isComposing || searchHandlers.isComposing(searchKey)) {
487
+ if ((e.nativeEvent as InputEvent).isComposing || searchHandlers.isComposing(searchKey)) {
488
488
  searchHandlers.updateInputValue(searchKey, value);
489
489
  return;
490
490
  }
491
- if (!value && shouldActivateSearchSubmenu) {
492
- deactivateSearchSubmenu(searchKey);
493
- }
494
491
  searchHandlers.updateSearchValue(searchKey, value);
495
492
  }}
496
493
  onCompositionStart={(e) => {
@@ -504,11 +501,7 @@ const createSearchItem = (
504
501
  e.stopPropagation();
505
502
  const value = e.currentTarget.value;
506
503
  if (shouldActivateSearchSubmenu) {
507
- if (value) {
508
- activateSearchSubmenu(searchKey);
509
- } else {
510
- deactivateSearchSubmenu(searchKey);
511
- }
504
+ activateSearchSubmenu(searchKey);
512
505
  }
513
506
  searchHandlers.endComposition(searchKey, value);
514
507
  }}
@@ -516,11 +509,21 @@ const createSearchItem = (
516
509
  e.stopPropagation();
517
510
  }}
518
511
  onKeyDown={(e) => {
512
+ if (e.key === 'Escape' || e.key === 'Tab') {
513
+ deactivateSearchSubmenu(searchKey);
514
+ return;
515
+ }
516
+ if (shouldActivateSearchSubmenu) {
517
+ activateSearchSubmenu(searchKey);
518
+ }
519
519
  e.stopPropagation();
520
520
  }}
521
521
  onMouseDown={(e) => {
522
522
  // 防止菜单聚焦丢失或页面滚动
523
523
  e.stopPropagation();
524
+ if (shouldActivateSearchSubmenu) {
525
+ activateSearchSubmenu(searchKey);
526
+ }
524
527
  }}
525
528
  size="small"
526
529
  style={{
@@ -552,7 +555,7 @@ const KEEP_OPEN_LABEL_STYLE: React.CSSProperties = {
552
555
 
553
556
  // 短暂保持打开状态的注册表(用于跨父节点快速重建时的恢复)
554
557
  const DROPDOWN_PERSIST_TTL_MS = 350;
555
- const SUBMENU_CLOSE_DELAY = 0.05;
558
+ const MENU_CLOSE_DELAY = 0.3;
556
559
  const SUBMENU_MOTION_DISABLED = {
557
560
  motionEnter: false,
558
561
  motionLeave: false,
@@ -561,13 +564,20 @@ const dropdownPersistRegistry: Map<string, number> = new Map();
561
564
 
562
565
  const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownMenuProps }> = ({ menu, ...props }) => {
563
566
  const engine = useFlowEngine();
567
+ const { getPrefixCls } = React.useContext(ConfigProvider.ConfigContext);
568
+ const triggerId = React.useId();
564
569
  const [menuVisible, setMenuVisible] = useState(false);
565
570
  const [openKeys, setOpenKeys] = useState<Set<string>>(new Set());
566
- const [activeSearchKey, setActiveSearchKey] = useState<string | null>(null);
567
571
  const [rootItems, setRootItems] = useState<Item[]>([]);
568
572
  const [rootLoading, setRootLoading] = useState(false);
573
+ const activeSearchKeyRef = useRef<string | null>(null);
569
574
  const closeByOutsideClickRef = useRef(false);
570
575
  const skipPreserveActiveSearchRef = useRef(false);
576
+ const triggerOpenClassName = `nb-lazy-dropdown-trigger-${triggerId.replace(/[^a-zA-Z0-9_-]/g, '')}`;
577
+ const defaultOpenClassName = `${getPrefixCls('dropdown', props.prefixCls)}-open`;
578
+ const mergedOpenClassName = [props.openClassName ?? defaultOpenClassName, triggerOpenClassName]
579
+ .filter(Boolean)
580
+ .join(' ');
571
581
  const dropdownMaxHeight = useNiceDropdownMaxHeight();
572
582
  const t = engine.translate.bind(engine);
573
583
 
@@ -589,13 +599,13 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
589
599
 
590
600
  const closeMenu = useCallback(() => {
591
601
  setMenuVisible(false);
592
- setActiveSearchKey(null);
602
+ activeSearchKeyRef.current = null;
593
603
  setOpenKeys(new Set());
594
604
  clearAllSearchValues();
595
605
  }, [clearAllSearchValues]);
596
606
 
597
607
  const activateSearchSubmenu = useCallback((key: string) => {
598
- setActiveSearchKey(key);
608
+ activeSearchKeyRef.current = key;
599
609
  setOpenKeys((prev) => {
600
610
  if (prev.has(key)) return prev;
601
611
  const next = new Set(prev);
@@ -605,11 +615,14 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
605
615
  }, []);
606
616
 
607
617
  const deactivateSearchSubmenu = useCallback((key: string) => {
608
- setActiveSearchKey((prev) => (prev === key ? null : prev));
618
+ if (activeSearchKeyRef.current === key) {
619
+ activeSearchKeyRef.current = null;
620
+ }
609
621
  }, []);
610
622
 
611
623
  const closeActiveSearchForPath = useCallback(
612
624
  (keyPath: string) => {
625
+ const activeSearchKey = activeSearchKeyRef.current;
613
626
  if (
614
627
  !activeSearchKey ||
615
628
  keyPath === activeSearchKey ||
@@ -621,25 +634,26 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
621
634
 
622
635
  skipPreserveActiveSearchRef.current = true;
623
636
  clearSearchValue(activeSearchKey);
624
- setActiveSearchKey(null);
637
+ activeSearchKeyRef.current = null;
625
638
  setOpenKeys((prev) => {
626
639
  const next = new Set(prev);
627
640
  next.delete(activeSearchKey);
628
641
  return next;
629
642
  });
630
643
  },
631
- [activeSearchKey, clearSearchValue],
644
+ [clearSearchValue],
632
645
  );
633
646
 
634
647
  const handleMenuOpenChange = useCallback(
635
648
  (nextOpenKeys: string[]) => {
636
649
  let normalized = normalizeOpenKeys(nextOpenKeys);
637
- if (activeSearchKey && openKeys.has(activeSearchKey) && !normalized.includes(activeSearchKey)) {
638
- if (normalized.length || skipPreserveActiveSearchRef.current) {
650
+ const activeSearchKey = activeSearchKeyRef.current;
651
+ if (activeSearchKey && !normalized.includes(activeSearchKey)) {
652
+ if (skipPreserveActiveSearchRef.current) {
639
653
  clearSearchValue(activeSearchKey);
640
- setActiveSearchKey(null);
654
+ activeSearchKeyRef.current = null;
641
655
  } else {
642
- normalized = [activeSearchKey];
656
+ normalized = Array.from(openKeys);
643
657
  }
644
658
  }
645
659
 
@@ -658,7 +672,7 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
658
672
  dropdownMenuProps.onOpenChange?.(normalized);
659
673
  skipPreserveActiveSearchRef.current = false;
660
674
  },
661
- [activeSearchKey, clearSearchValue, dropdownMenuProps, openKeys, shouldPreventClose],
675
+ [clearSearchValue, dropdownMenuProps, openKeys, shouldPreventClose],
662
676
  );
663
677
 
664
678
  useEffect(() => {
@@ -666,7 +680,9 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
666
680
 
667
681
  const markOutsideClick = (event: MouseEvent | PointerEvent) => {
668
682
  const target = event.target as HTMLElement | null;
669
- const isOutside = !target?.closest('.ant-dropdown, .ant-dropdown-menu, .ant-dropdown-menu-submenu-popup');
683
+ const isInsidePopup = target?.closest('.ant-dropdown, .ant-dropdown-menu, .ant-dropdown-menu-submenu-popup');
684
+ const isInsideCurrentTrigger = target?.closest(`.${triggerOpenClassName}`);
685
+ const isOutside = !isInsidePopup && !isInsideCurrentTrigger;
670
686
  closeByOutsideClickRef.current = isOutside;
671
687
  if (isOutside) {
672
688
  closeMenu();
@@ -679,7 +695,7 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
679
695
  document.removeEventListener('pointerdown', markOutsideClick, true);
680
696
  document.removeEventListener('mousedown', markOutsideClick, true);
681
697
  };
682
- }, [closeMenu, menuVisible]);
698
+ }, [closeMenu, menuVisible, triggerOpenClassName]);
683
699
 
684
700
  // 在挂载时,若存在 persistKey 且仍在持久期内,则尝试恢复打开状态
685
701
  useEffect(() => {
@@ -957,13 +973,15 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
957
973
  {...props}
958
974
  open={menuVisible}
959
975
  destroyPopupOnHide
976
+ mouseLeaveDelay={props.mouseLeaveDelay ?? MENU_CLOSE_DELAY}
977
+ openClassName={mergedOpenClassName}
960
978
  overlayClassName={overlayClassName}
961
979
  placement="bottomLeft"
962
980
  menu={{
963
981
  ...dropdownMenuProps,
964
982
  openKeys: Array.from(openKeys),
965
983
  items: items,
966
- subMenuCloseDelay: dropdownMenuProps.subMenuCloseDelay ?? SUBMENU_CLOSE_DELAY,
984
+ subMenuCloseDelay: dropdownMenuProps.subMenuCloseDelay ?? MENU_CLOSE_DELAY,
967
985
  motion: dropdownMenuProps.motion ?? SUBMENU_MOTION_DISABLED,
968
986
  onClick: () => {},
969
987
  onOpenChange: handleMenuOpenChange,
@@ -974,7 +992,7 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
974
992
  },
975
993
  }}
976
994
  onOpenChange={(visible, info) => {
977
- if (!visible && activeSearchKey && info?.source === 'trigger' && !closeByOutsideClickRef.current) {
995
+ if (!visible && activeSearchKeyRef.current && info?.source === 'trigger' && !closeByOutsideClickRef.current) {
978
996
  return;
979
997
  }
980
998
 
@@ -411,7 +411,7 @@ describe('transformItems - searchable flags', () => {
411
411
  await waitFor(() => expect(screen.getAllByText('No data').length).toBeGreaterThan(0));
412
412
  });
413
413
 
414
- it('closes searchable submenu after focus without input', async () => {
414
+ it('keeps searchable submenu open while interacting with its input', async () => {
415
415
  const engine = new FlowEngine();
416
416
  await engine.flowSettings.forceEnable();
417
417
  class Parent extends FlowModel {}
@@ -451,7 +451,11 @@ describe('transformItems - searchable flags', () => {
451
451
  await user.click(screen.getByRole('textbox'));
452
452
  fireEvent.mouseLeave(screen.getByText('Fields'));
453
453
 
454
- await waitFor(() => expect(screen.queryByText('Field 1')).not.toBeInTheDocument());
454
+ await act(async () => {
455
+ await new Promise((resolve) => setTimeout(resolve, 350));
456
+ });
457
+
458
+ expect(screen.getByText('Field 1')).toBeInTheDocument();
455
459
  });
456
460
 
457
461
  it('closes active searchable submenu after outside click', async () => {
@@ -499,6 +503,85 @@ describe('transformItems - searchable flags', () => {
499
503
  await waitFor(() => expect(screen.queryByText('Fields')).not.toBeInTheDocument());
500
504
  });
501
505
 
506
+ it('keeps the dropdown open when clicking its already-hovered trigger', async () => {
507
+ const engine = new FlowEngine();
508
+ await engine.flowSettings.forceEnable();
509
+ class Parent extends FlowModel {}
510
+ engine.registerModels({ Parent });
511
+ const parent = engine.createModel<FlowModel>({ use: 'Parent' });
512
+ const user = userEvent.setup();
513
+
514
+ render(
515
+ <FlowEngineProvider engine={engine}>
516
+ <ConfigProvider>
517
+ <App>
518
+ <AddSubModelButton
519
+ model={parent}
520
+ subModelKey="items"
521
+ items={[
522
+ {
523
+ key: 'child',
524
+ label: 'Child',
525
+ createModelOptions: { use: 'Parent' },
526
+ },
527
+ ]}
528
+ >
529
+ Open
530
+ </AddSubModelButton>
531
+ </App>
532
+ </ConfigProvider>
533
+ </FlowEngineProvider>,
534
+ );
535
+
536
+ const trigger = screen.getByText('Open');
537
+ await user.hover(trigger);
538
+ await waitFor(() => expect(screen.getByText('Child')).toBeInTheDocument());
539
+ expect(trigger).toHaveClass('ant-dropdown-open');
540
+
541
+ await user.click(trigger);
542
+
543
+ expect(screen.getByText('Child')).toBeInTheDocument();
544
+ });
545
+
546
+ it('closes the dropdown when clicking another dropdown trigger', async () => {
547
+ const engine = new FlowEngine();
548
+ await engine.flowSettings.forceEnable();
549
+ class Parent extends FlowModel {}
550
+ engine.registerModels({ Parent });
551
+ const parent = engine.createModel<FlowModel>({ use: 'Parent' });
552
+ const user = userEvent.setup();
553
+
554
+ render(
555
+ <FlowEngineProvider engine={engine}>
556
+ <ConfigProvider>
557
+ <App>
558
+ <AddSubModelButton
559
+ model={parent}
560
+ subModelKey="firstItems"
561
+ items={[{ key: 'first-child', label: 'First child', createModelOptions: { use: 'Parent' } }]}
562
+ >
563
+ Open first
564
+ </AddSubModelButton>
565
+ <AddSubModelButton
566
+ model={parent}
567
+ subModelKey="secondItems"
568
+ items={[{ key: 'second-child', label: 'Second child', createModelOptions: { use: 'Parent' } }]}
569
+ >
570
+ Open second
571
+ </AddSubModelButton>
572
+ </App>
573
+ </ConfigProvider>
574
+ </FlowEngineProvider>,
575
+ );
576
+
577
+ await user.hover(screen.getByText('Open first'));
578
+ await waitFor(() => expect(screen.getByText('First child')).toBeInTheDocument());
579
+
580
+ fireEvent.pointerDown(screen.getByText('Open second'));
581
+
582
+ await waitFor(() => expect(screen.queryByText('First child')).not.toBeInTheDocument());
583
+ });
584
+
502
585
  it('switches away from active searchable submenu and resets its input', async () => {
503
586
  const engine = new FlowEngine();
504
587
  await engine.flowSettings.forceEnable();
@@ -0,0 +1,21 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ import { describe, expect, it } from 'vitest';
11
+
12
+ import { getFlowEngineTranslation } from '../index';
13
+
14
+ describe('flow engine locale', () => {
15
+ it('translates the default secondary confirmation text into Chinese', () => {
16
+ expect(getFlowEngineTranslation('Please Confirm', 'zh-CN')).toBe('请确认');
17
+ expect(getFlowEngineTranslation('Are you sure you want to perform the action?', 'zh-CN')).toBe(
18
+ '确定要执行此操作吗?',
19
+ );
20
+ });
21
+ });
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "Add": "Add",
3
+ "Are you sure you want to perform the action?": "Are you sure you want to perform the action?",
3
4
  "Are you sure you want to delete this item? This action cannot be undone.": "Are you sure you want to delete this item? This action cannot be undone.",
4
5
  "Are you sure to convert this template block to copy mode?": "Are you sure you want to convert this template block to copy mode?",
5
6
  "Array index out of bounds": "Array index {{index}} out of bounds for '{{subKey}}'",
@@ -53,6 +54,7 @@
53
54
  "Other blocks": "Other blocks",
54
55
  "Parent not found, cannot replace block": "Parent not found, cannot replace block",
55
56
  "Previous step": "Previous step",
57
+ "Please Confirm": "Please Confirm",
56
58
  "Replace current block with template?": "Replace current block with template?",
57
59
  "Replaced with template block": "Replaced with template block",
58
60
  "Render failed": "Render failed",
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "Add": "添加",
3
+ "Are you sure you want to perform the action?": "确定要执行此操作吗?",
3
4
  "Are you sure you want to delete this item? This action cannot be undone.": "确定要删除此项吗?此操作不可撤销。",
4
5
  "Are you sure to convert this template block to copy mode?": "确定将该模板区块转换为复制模式吗?",
5
6
  "Array index out of bounds": "数组索引 {{index}} 超出 '{{subKey}}' 的边界",
@@ -59,6 +60,7 @@
59
60
  "OK": "确定",
60
61
  "Other blocks": "其他区块",
61
62
  "Previous step": "上一步",
63
+ "Please Confirm": "请确认",
62
64
  "Render failed": "渲染失败",
63
65
  "Response record": "响应结果记录",
64
66
  "Step configuration": "步骤配置",