@nocobase/flow-engine 2.0.0-alpha.35 → 2.0.0-alpha.36

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.
@@ -24,7 +24,7 @@ export interface FlowModelRendererProps {
24
24
  /**
25
25
  * @default 'inside'
26
26
  */
27
- toolbarPosition?: 'inside' | 'above';
27
+ toolbarPosition?: 'inside' | 'above' | 'below';
28
28
  };
29
29
  /** 流程设置的交互风格 */
30
30
  flowSettingsVariant?: 'dropdown' | 'contextMenu' | 'modal' | 'drawer';
@@ -45,7 +45,7 @@ interface ModelProvidedProps {
45
45
  /**
46
46
  * @default 'inside'
47
47
  */
48
- toolbarPosition?: 'inside' | 'above';
48
+ toolbarPosition?: 'inside' | 'above' | 'below';
49
49
  }
50
50
  interface ModelByIdProps {
51
51
  uid: string;
@@ -79,7 +79,7 @@ interface ModelByIdProps {
79
79
  /**
80
80
  * @default 'inside'
81
81
  */
82
- toolbarPosition?: 'inside' | 'above';
82
+ toolbarPosition?: 'inside' | 'above' | 'below';
83
83
  }
84
84
  type FlowsFloatContextMenuProps = ModelProvidedProps | ModelByIdProps;
85
85
  /**
@@ -83,7 +83,24 @@ const renderToolbarItems = /* @__PURE__ */ __name((model, showDeleteButton, show
83
83
  return /* @__PURE__ */ import_react.default.createElement(ItemComponent, { key: itemConfig.key, model });
84
84
  });
85
85
  }, "renderToolbarItems");
86
- const floatContainerStyles = /* @__PURE__ */ __name(({ showBackground, showBorder, ctx, toolbarPosition }) => import_css.css`
86
+ const toolbarPositionToCSS = {
87
+ inside: `
88
+ top: 2px;
89
+ `,
90
+ above: `
91
+ top: 0px;
92
+ transform: translateY(-100%);
93
+ padding-bottom: 2px;
94
+ margin-bottom: -2px;
95
+ `,
96
+ below: `
97
+ top: 0px;
98
+ transform: translateY(100%);
99
+ padding-top: 2px;
100
+ margin-top: -2px;
101
+ `
102
+ };
103
+ const floatContainerStyles = /* @__PURE__ */ __name(({ showBackground, showBorder, ctx, toolbarPosition = "inside" }) => import_css.css`
87
104
  position: relative;
88
105
  display: inline;
89
106
 
@@ -149,10 +166,7 @@ const floatContainerStyles = /* @__PURE__ */ __name(({ showBackground, showBorde
149
166
  display: none; // 防止遮挡其它 icons
150
167
  position: absolute;
151
168
  right: 2px;
152
- top: ${toolbarPosition === "above" ? "0px" : "2px"};
153
- ${toolbarPosition === "above" ? "transform: translateY(-100%);" : ""}
154
- ${toolbarPosition === "above" ? "padding-bottom: 2px;" : ""}
155
- ${toolbarPosition === "above" ? "margin-bottom: -2px;" : ""}
169
+ ${toolbarPositionToCSS[toolbarPosition] || ""}
156
170
  line-height: 16px;
157
171
  pointer-events: all;
158
172
 
@@ -1054,7 +1054,7 @@ const _FlowModelContext = class _FlowModelContext extends BaseFlowModelContext {
1054
1054
  }, "get")
1055
1055
  });
1056
1056
  this.defineMethod("openView", async function(uid, options) {
1057
- var _a, _b, _c, _d, _e, _f, _g;
1057
+ var _a, _b, _c, _d, _e, _f;
1058
1058
  const opts = { ...options };
1059
1059
  if (opts.defineProperties || opts.defineMethod) {
1060
1060
  opts.navigation = false;
@@ -1072,28 +1072,29 @@ const _FlowModelContext = class _FlowModelContext extends BaseFlowModelContext {
1072
1072
  stepParams: {
1073
1073
  popupSettings: {
1074
1074
  openView: {
1075
- ...import_lodash.default.pick(opts, ["dataSourceKey", "collectionName", "associationName"])
1075
+ // 持久化首次传入的关键展示/数据参数,供后续路由与再次打开复用
1076
+ ...import_lodash.default.pick(opts, ["dataSourceKey", "collectionName", "associationName", "mode", "size"])
1076
1077
  }
1077
1078
  }
1078
1079
  }
1079
1080
  });
1080
1081
  await model2.save();
1081
1082
  }
1082
- if ((_b = (_a = model2.getStepParams("popupSettings")) == null ? void 0 : _a.openView) == null ? void 0 : _b.dataSourceKey) {
1083
- model2.setStepParams("popupSettings", {
1084
- openView: {
1085
- ...(_c = model2.getStepParams("popupSettings")) == null ? void 0 : _c.openView,
1086
- ...import_lodash.default.pick(opts, ["dataSourceKey", "collectionName", "associationName"])
1087
- }
1088
- });
1089
- await model2.save();
1083
+ if ((_a = model2.getStepParams("popupSettings")) == null ? void 0 : _a.openView) {
1084
+ const prevOpenView = ((_b = model2.getStepParams("popupSettings")) == null ? void 0 : _b.openView) || {};
1085
+ const incoming = import_lodash.default.pick(opts, ["dataSourceKey", "collectionName", "associationName", "mode", "size"]);
1086
+ const nextOpenView = { ...prevOpenView, ...incoming };
1087
+ if (!import_lodash.default.isEqual(prevOpenView, nextOpenView)) {
1088
+ model2.setStepParams("popupSettings", { openView: nextOpenView });
1089
+ await model2.save();
1090
+ }
1090
1091
  }
1091
- const viewUid = (opts == null ? void 0 : opts.routeViewUid) ?? (opts == null ? void 0 : opts.viewUid) ?? (((_e = (_d = model2.stepParams) == null ? void 0 : _d.popupSettings) == null ? void 0 : _e.openView) ? model2.uid : this.model.uid);
1092
+ const viewUid = (opts == null ? void 0 : opts.routeViewUid) ?? (opts == null ? void 0 : opts.viewUid) ?? (((_d = (_c = model2.stepParams) == null ? void 0 : _c.popupSettings) == null ? void 0 : _d.openView) ? model2.uid : this.model.uid);
1092
1093
  const parentView = this.view;
1093
1094
  const pendingType = (opts == null ? void 0 : opts.isMobileLayout) ? "embed" : (opts == null ? void 0 : opts.mode) || "drawer";
1094
1095
  const pendingInputArgs = { ...opts, viewUid, navigation: opts.navigation };
1095
- pendingInputArgs.filterByTk = pendingInputArgs.filterByTk || ((_f = this.inputArgs) == null ? void 0 : _f.filterByTk);
1096
- pendingInputArgs.sourceId = pendingInputArgs.sourceId || ((_g = this.inputArgs) == null ? void 0 : _g.sourceId);
1096
+ pendingInputArgs.filterByTk = pendingInputArgs.filterByTk || ((_e = this.inputArgs) == null ? void 0 : _e.filterByTk);
1097
+ pendingInputArgs.sourceId = pendingInputArgs.sourceId || ((_f = this.inputArgs) == null ? void 0 : _f.sourceId);
1097
1098
  const pendingView = {
1098
1099
  type: pendingType,
1099
1100
  inputArgs: pendingInputArgs,
@@ -145,12 +145,28 @@ const _MultiRecordResource = class _MultiRecordResource extends import_baseRecor
145
145
  return data;
146
146
  }
147
147
  async update(filterByTk, data, options) {
148
+ const collection = this.context.collection;
149
+ const filterTargetKey = collection.filterTargetKey;
150
+ let result = data;
151
+ const tkData = collection == null ? void 0 : collection.getFilterByTK(this.context.record);
152
+ if (Array.isArray(filterTargetKey)) {
153
+ result = {
154
+ ...data,
155
+ ...tkData || {}
156
+ };
157
+ } else {
158
+ result = {
159
+ ...data,
160
+ [filterTargetKey]: tkData
161
+ };
162
+ }
163
+ console.log(result);
148
164
  const config = this.mergeRequestConfig(
149
165
  {
150
166
  params: {
151
167
  filterByTk
152
168
  },
153
- data
169
+ data: result
154
170
  },
155
171
  this.updateActionOptions,
156
172
  options
@@ -60,14 +60,29 @@ const _SingleRecordResource = class _SingleRecordResource extends import_baseRec
60
60
  async save(data, options) {
61
61
  const config = this.mergeRequestConfig(this.saveActionOptions, import_lodash.default.omit(options, ["refresh"]));
62
62
  let actionName = "create";
63
+ let result = data;
63
64
  if (!this.isNewRecord) {
64
65
  config.params = config.params || {};
65
66
  config.params.filterByTk = this.getFilterByTk();
66
67
  actionName = "update";
68
+ const collection = this.context.collection;
69
+ const filterTargetKey = collection.filterTargetKey;
70
+ const tkData = collection == null ? void 0 : collection.getFilterByTK(this.context.record);
71
+ if (Array.isArray(filterTargetKey)) {
72
+ result = {
73
+ ...data,
74
+ ...tkData || {}
75
+ };
76
+ } else {
77
+ result = {
78
+ ...data,
79
+ [filterTargetKey]: tkData
80
+ };
81
+ }
67
82
  }
68
83
  await this.runAction(actionName, {
69
84
  ...config,
70
- data
85
+ data: result
71
86
  });
72
87
  this.emit("saved", data);
73
88
  if ((options == null ? void 0 : options.refresh) !== false) {
@@ -13,10 +13,11 @@ import type { FlowContext, PropertyMetaFactory } from '../flowContext';
13
13
  * 仅当访问路径以“关联字段名”开头(且继续访问其子属性)时,返回 true 交由服务端解析;
14
14
  * 否则在前端解析即可。
15
15
  *
16
- * @param collectionAccessor 返回当前对象所在collection
16
+ * @param collectionAccessor 返回当前对象所在 collection
17
+ * @param valueAccessor 可选,本地值访问器。若本地已存在目标子路径的值,则认为无需走后端,优先使用本地值。
17
18
  * @returns `(subPath) => boolean` 判断是否需要服务端解析
18
19
  */
19
- export declare function createAssociationSubpathResolver(collectionAccessor: () => Collection | null): (subPath: string) => boolean;
20
+ export declare function createAssociationSubpathResolver(collectionAccessor: () => Collection | null, valueAccessor?: () => unknown): (subPath: string) => boolean;
20
21
  /**
21
22
  * 构建“对象类变量”的 PropertyMetaFactory:
22
23
  * - 暴露集合字段结构(通过 createCollectionContextMeta)用于变量选择器;
@@ -7,9 +7,11 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
+ var __create = Object.create;
10
11
  var __defProp = Object.defineProperty;
11
12
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
12
13
  var __getOwnPropNames = Object.getOwnPropertyNames;
14
+ var __getProtoOf = Object.getPrototypeOf;
13
15
  var __hasOwnProp = Object.prototype.hasOwnProperty;
14
16
  var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
15
17
  var __export = (target, all) => {
@@ -24,6 +26,14 @@ var __copyProps = (to, from, except, desc) => {
24
26
  }
25
27
  return to;
26
28
  };
29
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
30
+ // If the importer is in node compatibility mode or this is not an ESM
31
+ // file that has been converted to a CommonJS file using a Babel-
32
+ // compatible transform (i.e. "__esModule" has not been set), then set
33
+ // "default" to the CommonJS "module.exports" for node compatibility.
34
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
35
+ mod
36
+ ));
27
37
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
28
38
  var associationObjectVariable_exports = {};
29
39
  __export(associationObjectVariable_exports, {
@@ -31,6 +41,7 @@ __export(associationObjectVariable_exports, {
31
41
  createAssociationSubpathResolver: () => createAssociationSubpathResolver
32
42
  });
33
43
  module.exports = __toCommonJS(associationObjectVariable_exports);
44
+ var import_lodash = __toESM(require("lodash"));
34
45
  var import_createCollectionContextMeta = require("./createCollectionContextMeta");
35
46
  function baseFieldNameOf(subPath) {
36
47
  if (!subPath) return void 0;
@@ -66,14 +77,23 @@ function toFilterByTk(value, primaryKey) {
66
77
  return void 0;
67
78
  }
68
79
  __name(toFilterByTk, "toFilterByTk");
69
- function createAssociationSubpathResolver(collectionAccessor) {
80
+ function createAssociationSubpathResolver(collectionAccessor, valueAccessor) {
70
81
  return (p) => {
71
82
  if (!p || !p.includes(".")) return false;
72
83
  const base = baseFieldNameOf(p);
73
84
  if (!base) return false;
74
85
  const collection = collectionAccessor();
75
86
  const field = findFieldByName(collection, base);
76
- return !!(field == null ? void 0 : field.isAssociationField());
87
+ const isAssoc = !!(field == null ? void 0 : field.isAssociationField());
88
+ if (!isAssoc) return false;
89
+ if (typeof valueAccessor === "function") {
90
+ const local = valueAccessor();
91
+ if (local && typeof local === "object") {
92
+ const v = import_lodash.default.get(local, p);
93
+ if (typeof v !== "undefined") return false;
94
+ }
95
+ }
96
+ return true;
77
97
  };
78
98
  }
79
99
  __name(createAssociationSubpathResolver, "createAssociationSubpathResolver");
@@ -12,7 +12,7 @@ export interface ViewParam {
12
12
  /** 标签页唯一标识符 */
13
13
  tabUid?: string;
14
14
  /** 弹窗记录的 id */
15
- filterByTk?: string;
15
+ filterByTk?: string | Record<string, string | number>;
16
16
  /** source Id */
17
17
  sourceId?: string;
18
18
  }
@@ -54,16 +54,52 @@ const parsePathnameToViewParams = /* @__PURE__ */ __name((pathname) => {
54
54
  break;
55
55
  }
56
56
  } else if (currentView && i + 1 < segments.length) {
57
- const value = segments[i + 1];
57
+ const rawValue = segments[i + 1];
58
+ let decoded = rawValue;
59
+ try {
60
+ decoded = decodeURIComponent(rawValue);
61
+ } catch (_) {
62
+ }
58
63
  switch (segment) {
59
64
  case "tab":
60
- currentView.tabUid = value;
65
+ currentView.tabUid = decoded;
61
66
  break;
62
- case "filterbytk":
63
- currentView.filterByTk = value;
67
+ case "filterbytk": {
68
+ const parseKeyValuePairs = /* @__PURE__ */ __name((s) => {
69
+ const obj = {};
70
+ s.split("&").forEach((pair) => {
71
+ const [k, v = ""] = pair.split("=");
72
+ if (!k) return;
73
+ try {
74
+ const key = decodeURIComponent(k);
75
+ const val = decodeURIComponent(v);
76
+ obj[key] = val;
77
+ } catch (_) {
78
+ obj[k] = v;
79
+ }
80
+ });
81
+ return obj;
82
+ }, "parseKeyValuePairs");
83
+ let parsed = decoded;
84
+ if (decoded && (decoded.startsWith("{") || decoded.startsWith("["))) {
85
+ try {
86
+ const maybe = JSON.parse(decoded);
87
+ if (maybe && typeof maybe === "object" && !Array.isArray(maybe)) {
88
+ parsed = maybe;
89
+ } else {
90
+ parsed = decoded;
91
+ }
92
+ } catch (_) {
93
+ parsed = decoded;
94
+ }
95
+ } else if (decoded && decoded.includes("=") && decoded.includes("&")) {
96
+ parsed = parseKeyValuePairs(decoded);
97
+ }
98
+ currentView.filterByTk = parsed;
64
99
  break;
100
+ }
65
101
  case "sourceid":
66
- currentView.sourceId = value;
102
+ currentView.sourceId = decoded;
67
103
  break;
68
104
  default:
69
105
  break;
@@ -7,16 +7,10 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
  import { FlowEngineContext } from '../flowContext';
10
- interface ViewParam {
11
- /** 视图唯一标识符,一般为某个 Model 实例的 uid */
10
+ import { ViewParam as SharedViewParam } from '../utils';
11
+ type ViewParam = Omit<SharedViewParam, 'viewUid'> & {
12
12
  viewUid?: string;
13
- /** 标签页唯一标识符 */
14
- tabUid?: string;
15
- /** 弹窗记录的 id */
16
- filterByTk?: string;
17
- /** source Id */
18
- sourceId?: string;
19
- }
13
+ };
20
14
  /**
21
15
  * 将 ViewParam 数组转换为 pathname
22
16
  *
@@ -31,6 +31,17 @@ __export(ViewNavigation_exports, {
31
31
  generatePathnameFromViewParams: () => generatePathnameFromViewParams
32
32
  });
33
33
  module.exports = __toCommonJS(ViewNavigation_exports);
34
+ function encodeFilterByTk(val) {
35
+ if (val === void 0 || val === null) return "";
36
+ if (val && typeof val === "object" && !Array.isArray(val)) {
37
+ const pairs = Object.entries(val).map(([k, v]) => {
38
+ return `${encodeURIComponent(String(k))}=${encodeURIComponent(String(v))}`;
39
+ });
40
+ return encodeURIComponent(pairs.join("&"));
41
+ }
42
+ return encodeURIComponent(String(val));
43
+ }
44
+ __name(encodeFilterByTk, "encodeFilterByTk");
34
45
  function generatePathnameFromViewParams(viewParams) {
35
46
  if (!viewParams || viewParams.length === 0) {
36
47
  return "/admin";
@@ -44,8 +55,11 @@ function generatePathnameFromViewParams(viewParams) {
44
55
  if (viewParam.tabUid) {
45
56
  segments.push("tab", viewParam.tabUid);
46
57
  }
47
- if (viewParam.filterByTk) {
48
- segments.push("filterbytk", viewParam.filterByTk);
58
+ if (viewParam.filterByTk != null) {
59
+ const encoded = encodeFilterByTk(viewParam.filterByTk);
60
+ if (encoded) {
61
+ segments.push("filterbytk", encoded);
62
+ }
49
63
  }
50
64
  if (viewParam.sourceId) {
51
65
  segments.push("sourceid", viewParam.sourceId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nocobase/flow-engine",
3
- "version": "2.0.0-alpha.35",
3
+ "version": "2.0.0-alpha.36",
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.0.0-alpha.35",
12
- "@nocobase/shared": "2.0.0-alpha.35",
11
+ "@nocobase/sdk": "2.0.0-alpha.36",
12
+ "@nocobase/shared": "2.0.0-alpha.36",
13
13
  "ahooks": "^3.7.2",
14
14
  "dompurify": "^3.0.2",
15
15
  "lodash": "^4.x",
@@ -35,5 +35,5 @@
35
35
  ],
36
36
  "author": "NocoBase Team",
37
37
  "license": "AGPL-3.0",
38
- "gitHead": "e60da9bab9fd4c8ba9457e9806d5428054cefa7a"
38
+ "gitHead": "c1170bac3ba2325b7420ce65e732c1ba8bbb7767"
39
39
  }
@@ -400,4 +400,65 @@ describe('objectVariable utilities', () => {
400
400
  expect(resolved).toEqual({ a: 7 });
401
401
  expect((ctx as any).api.request).not.toHaveBeenCalled();
402
402
  });
403
+
404
+ it('local-first: toOne association subpath uses local value and skips server', async () => {
405
+ const { engine, collection } = setupEngineWithCollections();
406
+ const obj = { author: { id: 7, name: 'LocalName' } };
407
+ const ctx = engine.context as any;
408
+
409
+ (ctx as any).api = { request: vi.fn() };
410
+
411
+ const metaFactory = createAssociationAwareObjectMetaFactory(
412
+ () => collection,
413
+ 'Current object',
414
+ () => obj,
415
+ );
416
+
417
+ ctx.defineProperty('obj', {
418
+ get: () => obj,
419
+ cache: false,
420
+ meta: metaFactory,
421
+ // 启用“本地优先”:传入 valueAccessor,以便当本地已有该子路径时跳过服务端
422
+ resolveOnServer: createAssociationSubpathResolver(
423
+ () => collection,
424
+ () => obj,
425
+ ),
426
+ serverOnlyWhenContextParams: true,
427
+ });
428
+
429
+ const template = { x: '{{ ctx.obj.author.name }}' } as any;
430
+ const resolved = await (ctx as any).resolveJsonTemplate(template);
431
+ expect(resolved).toEqual({ x: 'LocalName' });
432
+ expect((ctx as any).api.request).not.toHaveBeenCalled();
433
+ });
434
+
435
+ it('local-first: toMany association subpath (index + dot) uses local value and skips server', async () => {
436
+ const { engine, collection } = setupEngineWithCollections();
437
+ const obj = { tags: [{ id: 11, name: 'T1' }] };
438
+ const ctx = engine.context as any;
439
+
440
+ (ctx as any).api = { request: vi.fn() };
441
+
442
+ const metaFactory = createAssociationAwareObjectMetaFactory(
443
+ () => collection,
444
+ 'Current object',
445
+ () => obj,
446
+ );
447
+
448
+ ctx.defineProperty('obj', {
449
+ get: () => obj,
450
+ cache: false,
451
+ meta: metaFactory,
452
+ resolveOnServer: createAssociationSubpathResolver(
453
+ () => collection,
454
+ () => obj,
455
+ ),
456
+ serverOnlyWhenContextParams: true,
457
+ });
458
+
459
+ const template = { x: '{{ ctx.obj.tags[0].name }}' } as any;
460
+ const resolved = await (ctx as any).resolveJsonTemplate(template);
461
+ expect(resolved).toEqual({ x: 'T1' });
462
+ expect((ctx as any).api.request).not.toHaveBeenCalled();
463
+ });
403
464
  });
@@ -74,7 +74,7 @@ export interface FlowModelRendererProps {
74
74
  /**
75
75
  * @default 'inside'
76
76
  */
77
- toolbarPosition?: 'inside' | 'above';
77
+ toolbarPosition?: 'inside' | 'above' | 'below';
78
78
  }; // 默认 false
79
79
 
80
80
  /** 流程设置的交互风格 */
@@ -114,7 +114,7 @@ const FlowModelRendererWithAutoFlows: React.FC<{
114
114
  /**
115
115
  * @default 'inside'
116
116
  */
117
- toolbarPosition?: 'inside' | 'above';
117
+ toolbarPosition?: 'inside' | 'above' | 'below';
118
118
  };
119
119
  flowSettingsVariant: string;
120
120
  hideRemoveInSettings: boolean;
@@ -181,7 +181,7 @@ const FlowModelRendererCore: React.FC<{
181
181
  /**
182
182
  * @default 'inside'
183
183
  */
184
- toolbarPosition?: 'inside' | 'above';
184
+ toolbarPosition?: 'inside' | 'above' | 'below';
185
185
  };
186
186
  flowSettingsVariant: string;
187
187
  hideRemoveInSettings: boolean;
@@ -80,8 +80,26 @@ const renderToolbarItems = (
80
80
  });
81
81
  };
82
82
 
83
+ const toolbarPositionToCSS = {
84
+ inside: `
85
+ top: 2px;
86
+ `,
87
+ above: `
88
+ top: 0px;
89
+ transform: translateY(-100%);
90
+ padding-bottom: 2px;
91
+ margin-bottom: -2px;
92
+ `,
93
+ below: `
94
+ top: 0px;
95
+ transform: translateY(100%);
96
+ padding-top: 2px;
97
+ margin-top: -2px;
98
+ `,
99
+ };
100
+
83
101
  // 使用与 NocoBase 一致的悬浮工具栏样式
84
- const floatContainerStyles = ({ showBackground, showBorder, ctx, toolbarPosition }) => css`
102
+ const floatContainerStyles = ({ showBackground, showBorder, ctx, toolbarPosition = 'inside' }) => css`
85
103
  position: relative;
86
104
  display: inline;
87
105
 
@@ -147,10 +165,7 @@ const floatContainerStyles = ({ showBackground, showBorder, ctx, toolbarPosition
147
165
  display: none; // 防止遮挡其它 icons
148
166
  position: absolute;
149
167
  right: 2px;
150
- top: ${toolbarPosition === 'above' ? '0px' : '2px'};
151
- ${toolbarPosition === 'above' ? 'transform: translateY(-100%);' : ''}
152
- ${toolbarPosition === 'above' ? 'padding-bottom: 2px;' : ''}
153
- ${toolbarPosition === 'above' ? 'margin-bottom: -2px;' : ''}
168
+ ${toolbarPositionToCSS[toolbarPosition] || ''}
154
169
  line-height: 16px;
155
170
  pointer-events: all;
156
171
 
@@ -297,7 +312,7 @@ interface ModelProvidedProps {
297
312
  /**
298
313
  * @default 'inside'
299
314
  */
300
- toolbarPosition?: 'inside' | 'above';
315
+ toolbarPosition?: 'inside' | 'above' | 'below';
301
316
  }
302
317
 
303
318
  interface ModelByIdProps {
@@ -332,7 +347,7 @@ interface ModelByIdProps {
332
347
  /**
333
348
  * @default 'inside'
334
349
  */
335
- toolbarPosition?: 'inside' | 'above';
350
+ toolbarPosition?: 'inside' | 'above' | 'below';
336
351
  }
337
352
 
338
353
  type FlowsFloatContextMenuProps = ModelProvidedProps | ModelByIdProps;
@@ -1422,21 +1422,24 @@ export class FlowModelContext extends BaseFlowModelContext {
1422
1422
  stepParams: {
1423
1423
  popupSettings: {
1424
1424
  openView: {
1425
- ..._.pick(opts, ['dataSourceKey', 'collectionName', 'associationName']),
1425
+ // 持久化首次传入的关键展示/数据参数,供后续路由与再次打开复用
1426
+ ..._.pick(opts, ['dataSourceKey', 'collectionName', 'associationName', 'mode', 'size']),
1426
1427
  },
1427
1428
  },
1428
1429
  },
1429
1430
  });
1430
1431
  await model.save();
1431
1432
  }
1432
- if (model.getStepParams('popupSettings')?.openView?.dataSourceKey) {
1433
- model.setStepParams('popupSettings', {
1434
- openView: {
1435
- ...model.getStepParams('popupSettings')?.openView,
1436
- ..._.pick(opts, ['dataSourceKey', 'collectionName', 'associationName']),
1437
- },
1438
- });
1439
- await model.save();
1433
+ // 若已存在 openView 配置,则按需合并更新(仅覆盖本次显式传入的字段)
1434
+ if (model.getStepParams('popupSettings')?.openView) {
1435
+ const prevOpenView = model.getStepParams('popupSettings')?.openView || {};
1436
+ const incoming = _.pick(opts, ['dataSourceKey', 'collectionName', 'associationName', 'mode', 'size']);
1437
+ const nextOpenView = { ...prevOpenView, ...incoming };
1438
+ // 仅当有变更时才保存,避免不必要的写入
1439
+ if (!_.isEqual(prevOpenView, nextOpenView)) {
1440
+ model.setStepParams('popupSettings', { openView: nextOpenView });
1441
+ await model.save();
1442
+ }
1440
1443
  }
1441
1444
 
1442
1445
  // 路由层级的 viewUid:优先使用 routeViewUid(仅用于路由展示);
@@ -130,12 +130,29 @@ export class MultiRecordResource<TDataItem = any> extends BaseRecordResource<TDa
130
130
  }
131
131
 
132
132
  async update(filterByTk: string | number, data: Partial<TDataItem>, options?: AxiosRequestConfig): Promise<void> {
133
+ const collection = this.context.collection;
134
+ const filterTargetKey = collection.filterTargetKey;
135
+ let result = data;
136
+ const tkData = collection?.getFilterByTK(this.context.record);
137
+ if (Array.isArray(filterTargetKey)) {
138
+ result = {
139
+ ...data,
140
+ ...(tkData || {}),
141
+ };
142
+ } else {
143
+ result = {
144
+ ...data,
145
+ [filterTargetKey]: tkData,
146
+ };
147
+ }
148
+ console.log(result);
149
+
133
150
  const config = this.mergeRequestConfig(
134
151
  {
135
152
  params: {
136
153
  filterByTk,
137
154
  },
138
- data,
155
+ data: result,
139
156
  },
140
157
  this.updateActionOptions,
141
158
  options,
@@ -33,15 +33,30 @@ export class SingleRecordResource<TData = any> extends BaseRecordResource<TData>
33
33
  async save(data: TData, options?: AxiosRequestConfig & { refresh?: boolean }): Promise<void> {
34
34
  const config = this.mergeRequestConfig(this.saveActionOptions, _.omit(options, ['refresh']));
35
35
  let actionName = 'create';
36
+ let result = data;
36
37
  // 如果有 filterByTk,则表示是更新操作
37
38
  if (!this.isNewRecord) {
38
39
  config.params = config.params || {};
39
40
  config.params.filterByTk = this.getFilterByTk();
40
41
  actionName = 'update';
42
+ const collection = this.context.collection;
43
+ const filterTargetKey = collection.filterTargetKey;
44
+ const tkData = collection?.getFilterByTK(this.context.record);
45
+ if (Array.isArray(filterTargetKey)) {
46
+ result = {
47
+ ...data,
48
+ ...(tkData || {}),
49
+ };
50
+ } else {
51
+ result = {
52
+ ...data,
53
+ [filterTargetKey]: tkData,
54
+ };
55
+ }
41
56
  }
42
57
  await this.runAction(actionName, {
43
58
  ...config,
44
- data,
59
+ data: result,
45
60
  });
46
61
  this.emit('saved', data);
47
62
  if (options?.refresh !== false) {
@@ -101,4 +101,29 @@ describe('parsePathnameToViewParams', () => {
101
101
  const result = parsePathnameToViewParams('///admin//xxx//tab//yyy//');
102
102
  expect(result).toEqual([{ viewUid: 'xxx', tabUid: 'yyy' }]);
103
103
  });
104
+
105
+ test('should parse filterByTk from key-value encoded segment into object', () => {
106
+ const kv = encodeURIComponent('id=1&tenant=ac');
107
+ const path = `/admin/xxx/filterbytk/${kv}`;
108
+ const result = parsePathnameToViewParams(path);
109
+ expect(result).toEqual([{ viewUid: 'xxx', filterByTk: { id: '1', tenant: 'ac' } }]);
110
+ });
111
+
112
+ test('should parse filterByTk from JSON object segment', () => {
113
+ const json = encodeURIComponent('{"id":"1","tenant":"ac"}');
114
+ const path = `/admin/xxx/filterbytk/${json}`;
115
+ const result = parsePathnameToViewParams(path);
116
+ expect(result).toEqual([{ viewUid: 'xxx', filterByTk: { id: '1', tenant: 'ac' } }]);
117
+ });
118
+
119
+ test('should keep non-object JSON (array/number) as string for filterByTk', () => {
120
+ const arr = encodeURIComponent('["a"]');
121
+ const num = encodeURIComponent('123');
122
+ const t = encodeURIComponent('true');
123
+ expect(parsePathnameToViewParams(`/admin/xxx/filterbytk/${arr}`)).toEqual([
124
+ { viewUid: 'xxx', filterByTk: '["a"]' },
125
+ ]);
126
+ expect(parsePathnameToViewParams(`/admin/xxx/filterbytk/${num}`)).toEqual([{ viewUid: 'xxx', filterByTk: '123' }]);
127
+ expect(parsePathnameToViewParams(`/admin/xxx/filterbytk/${t}`)).toEqual([{ viewUid: 'xxx', filterByTk: 'true' }]);
128
+ });
104
129
  });
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import type { Collection, CollectionField } from '../data-source';
11
+ import _ from 'lodash';
11
12
  import type { FlowContext, PropertyMeta, PropertyMetaFactory } from '../flowContext';
12
13
  import { createCollectionContextMeta } from './createCollectionContextMeta';
13
14
 
@@ -74,19 +75,34 @@ function toFilterByTk(value: unknown, primaryKey: string | string[]) {
74
75
  * 仅当访问路径以“关联字段名”开头(且继续访问其子属性)时,返回 true 交由服务端解析;
75
76
  * 否则在前端解析即可。
76
77
  *
77
- * @param collectionAccessor 返回当前对象所在collection
78
+ * @param collectionAccessor 返回当前对象所在 collection
79
+ * @param valueAccessor 可选,本地值访问器。若本地已存在目标子路径的值,则认为无需走后端,优先使用本地值。
78
80
  * @returns `(subPath) => boolean` 判断是否需要服务端解析
79
81
  */
80
82
  export function createAssociationSubpathResolver(
81
83
  collectionAccessor: () => Collection | null,
84
+ valueAccessor?: () => unknown,
82
85
  ): (subPath: string) => boolean {
83
86
  return (p: string) => {
84
- if (!p || !p.includes('.')) return false; // 只在访问子属性时才需要后端
87
+ // 仅在访问子属性时才考虑后端
88
+ if (!p || !p.includes('.')) return false;
85
89
  const base = baseFieldNameOf(p);
86
90
  if (!base) return false;
87
91
  const collection = collectionAccessor();
88
92
  const field = findFieldByName(collection, base);
89
- return !!field?.isAssociationField();
93
+ const isAssoc = !!field?.isAssociationField();
94
+ if (!isAssoc) return false;
95
+
96
+ // 可选:本地优先。当提供 valueAccessor 时,若本地已有该子路径值,则不走后端
97
+ if (typeof valueAccessor === 'function') {
98
+ const local = valueAccessor();
99
+ if (local && typeof local === 'object') {
100
+ const v = _.get(local as Record<string, unknown>, p);
101
+ if (typeof v !== 'undefined') return false;
102
+ }
103
+ }
104
+
105
+ return true;
90
106
  };
91
107
  }
92
108
 
@@ -13,7 +13,7 @@ export interface ViewParam {
13
13
  /** 标签页唯一标识符 */
14
14
  tabUid?: string;
15
15
  /** 弹窗记录的 id */
16
- filterByTk?: string;
16
+ filterByTk?: string | Record<string, string | number>;
17
17
  /** source Id */
18
18
  sourceId?: string;
19
19
  }
@@ -70,17 +70,61 @@ export const parsePathnameToViewParams = (pathname: string): ViewParam[] => {
70
70
  }
71
71
  // 处理参数
72
72
  else if (currentView && i + 1 < segments.length) {
73
- const value = segments[i + 1];
73
+ const rawValue = segments[i + 1];
74
+ // 尝试对路径段进行解码
75
+ let decoded: string = rawValue;
76
+ try {
77
+ decoded = decodeURIComponent(rawValue);
78
+ } catch (_) {
79
+ // ignore
80
+ }
74
81
 
75
82
  switch (segment) {
76
83
  case 'tab':
77
- currentView.tabUid = value;
84
+ // tab/sourceId 仅作为字符串处理
85
+ currentView.tabUid = decoded;
78
86
  break;
79
- case 'filterbytk':
80
- currentView.filterByTk = value;
87
+ case 'filterbytk': {
88
+ // 仅在 filterByTk 分支支持对象/JSON/键值对解析
89
+ const parseKeyValuePairs = (s: string): Record<string, string | number> => {
90
+ const obj: Record<string, string | number> = {};
91
+ s.split('&').forEach((pair) => {
92
+ const [k, v = ''] = pair.split('=');
93
+ if (!k) return;
94
+ try {
95
+ const key = decodeURIComponent(k);
96
+ const val = decodeURIComponent(v);
97
+ obj[key] = val;
98
+ } catch (_) {
99
+ obj[k] = v;
100
+ }
101
+ });
102
+ return obj;
103
+ };
104
+
105
+ let parsed: string | Record<string, string | number> = decoded;
106
+ if (decoded && (decoded.startsWith('{') || decoded.startsWith('['))) {
107
+ try {
108
+ const maybe = JSON.parse(decoded);
109
+ if (maybe && typeof maybe === 'object' && !Array.isArray(maybe)) {
110
+ parsed = maybe as Record<string, string | number>;
111
+ } else {
112
+ // 非对象 JSON(如数组/数字)按字符串保留
113
+ parsed = decoded;
114
+ }
115
+ } catch (_) {
116
+ // 解析失败,按字符串保留
117
+ parsed = decoded;
118
+ }
119
+ } else if (decoded && decoded.includes('=') && decoded.includes('&')) {
120
+ // 形如 a=b&c=d 的整体段
121
+ parsed = parseKeyValuePairs(decoded);
122
+ }
123
+ currentView.filterByTk = parsed;
81
124
  break;
125
+ }
82
126
  case 'sourceid':
83
- currentView.sourceId = value;
127
+ currentView.sourceId = decoded;
84
128
  break;
85
129
  default:
86
130
  // 未知参数,跳过
@@ -8,16 +8,21 @@
8
8
  */
9
9
 
10
10
  import { FlowEngineContext } from '../flowContext';
11
-
12
- interface ViewParam {
13
- /** 视图唯一标识符,一般为某个 Model 实例的 uid */
14
- viewUid?: string;
15
- /** 标签页唯一标识符 */
16
- tabUid?: string;
17
- /** 弹窗记录的 id */
18
- filterByTk?: string;
19
- /** source Id */
20
- sourceId?: string;
11
+ import { ViewParam as SharedViewParam } from '../utils';
12
+
13
+ type ViewParam = Omit<SharedViewParam, 'viewUid'> & { viewUid?: string };
14
+
15
+ function encodeFilterByTk(val: SharedViewParam['filterByTk']): string {
16
+ if (val === undefined || val === null) return '';
17
+ // 1.x 兼容:对象按 key1=v1&key2=v2 拼接后整体 encodeURIComponent
18
+ if (val && typeof val === 'object' && !Array.isArray(val)) {
19
+ const pairs = Object.entries(val).map(([k, v]) => {
20
+ return `${encodeURIComponent(String(k))}=${encodeURIComponent(String(v))}`;
21
+ });
22
+ return encodeURIComponent(pairs.join('&'));
23
+ }
24
+ // 其它情况按原单值编码(字符串)
25
+ return encodeURIComponent(String(val));
21
26
  }
22
27
 
23
28
  /**
@@ -53,8 +58,11 @@ export function generatePathnameFromViewParams(viewParams: ViewParam[]): string
53
58
  if (viewParam.tabUid) {
54
59
  segments.push('tab', viewParam.tabUid);
55
60
  }
56
- if (viewParam.filterByTk) {
57
- segments.push('filterbytk', viewParam.filterByTk);
61
+ if (viewParam.filterByTk != null) {
62
+ const encoded = encodeFilterByTk(viewParam.filterByTk);
63
+ if (encoded) {
64
+ segments.push('filterbytk', encoded);
65
+ }
58
66
  }
59
67
  if (viewParam.sourceId) {
60
68
  segments.push('sourceid', viewParam.sourceId);
@@ -173,6 +173,16 @@ describe('generatePathnameFromViewParams', () => {
173
173
  );
174
174
  });
175
175
 
176
+ it('should encode object filterByTk as encoded key-value string', () => {
177
+ const path = generatePathnameFromViewParams([{ viewUid: 'xxx', filterByTk: { id: 1, tenant: 'ac' } }]);
178
+ expect(path).toBe('/admin/xxx/filterbytk/' + encodeURIComponent('id=1&tenant=ac'));
179
+ });
180
+
181
+ it('should omit filterByTk segment for empty string or when absent', () => {
182
+ expect(generatePathnameFromViewParams([{ viewUid: 'xxx' }])).toBe('/admin/xxx');
183
+ expect(generatePathnameFromViewParams([{ viewUid: 'xxx', filterByTk: '' }])).toBe('/admin/xxx');
184
+ });
185
+
176
186
  it('should match parsePathnameToViewParams test cases', () => {
177
187
  // Test cases from parsePathnameToViewParams.test.ts
178
188
  expect(generatePathnameFromViewParams([{ viewUid: 'xxx' }])).toBe('/admin/xxx');