@tmagic/form 1.8.0-beta.26 → 1.8.0-beta.28

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.
@@ -1,15 +1,15 @@
1
+ import { getGroupListHeaderVars } from "../utils/tableGroupList.js";
1
2
  import GroupListItem_default from "./GroupListItem.js";
2
- import { Fragment, computed, createBlock, createCommentVNode, createElementBlock, createElementVNode, createSlots, defineComponent, mergeProps, normalizeClass, openBlock, renderList, renderSlot, toDisplayString, withCtx } from "vue";
3
+ import { Fragment, computed, createBlock, createCommentVNode, createElementBlock, createElementVNode, createSlots, defineComponent, mergeProps, normalizeClass, normalizeStyle, openBlock, renderList, renderSlot, toDisplayString, withCtx } from "vue";
3
4
  import { cloneDeep } from "lodash-es";
4
5
  //#region packages/form/src/containers/GroupList.vue?vue&type=script&setup=true&lang.ts
5
- var _hoisted_1 = { class: "m-fields-group-list" };
6
- var _hoisted_2 = ["innerHTML"];
7
- var _hoisted_3 = {
6
+ var _hoisted_1 = ["innerHTML"];
7
+ var _hoisted_2 = {
8
8
  key: 1,
9
9
  class: "el-table__empty-block"
10
10
  };
11
- var _hoisted_4 = { class: "el-table__empty-text t-table__empty" };
12
- var _hoisted_5 = { style: {
11
+ var _hoisted_3 = { class: "el-table__empty-text t-table__empty" };
12
+ var _hoisted_4 = { style: {
13
13
  "display": "flex",
14
14
  "justify-content": "flex-end",
15
15
  "flex": "1"
@@ -54,6 +54,14 @@ var GroupList_vue_vue_type_script_setup_true_lang_default = /*@__PURE__*/ define
54
54
  };
55
55
  const onAddDiffCount = () => emit("addDiffCount");
56
56
  const asList = (value) => Array.isArray(value) ? value : [];
57
+ const headerVars = computed(() => getGroupListHeaderVars(props.config.header));
58
+ /**
59
+ * 取 `$slots` 而不是 `useSlots()`:宿主切换 `addable` 时插槽会增删,
60
+ * computed 缓存不随插槽变化失效,只有在渲染期读才拿得到最新的。
61
+ */
62
+ const hasFooter = (slots) => !props.isCompare && Boolean(slots["toggle-button"] || slots["add-button"]);
63
+ /** 只有真的渲染出吸底 footer,内层列表才需要为它让位 */
64
+ const isFooterSticky = (slots) => hasFooter(slots) && Boolean(props.config.addButtonConfig?.sticky);
57
65
  const currentList = computed(() => asList(props.model[props.name]));
58
66
  /** 对比时按当前/历史较长一侧对齐,已删除的项也能渲染出来 */
59
67
  const displayItems = computed(() => {
@@ -71,13 +79,19 @@ var GroupList_vue_vue_type_script_setup_true_lang_default = /*@__PURE__*/ define
71
79
  }));
72
80
  });
73
81
  return (_ctx, _cache) => {
74
- return openBlock(), createElementBlock("div", _hoisted_1, [
82
+ return openBlock(), createElementBlock("div", {
83
+ class: normalizeClass(["m-fields-group-list", {
84
+ "is-header-sticky": Boolean(__props.config.header?.sticky),
85
+ "is-footer-sticky": isFooterSticky(_ctx.$slots)
86
+ }]),
87
+ style: normalizeStyle(headerVars.value)
88
+ }, [
75
89
  __props.config.extra ? (openBlock(), createElementBlock("div", {
76
90
  key: 0,
77
91
  innerHTML: __props.config.extra,
78
92
  style: { "color": "rgba(0, 0, 0, 0.45)" }
79
- }, null, 8, _hoisted_2)) : createCommentVNode("v-if", true),
80
- !displayItems.value.length ? (openBlock(), createElementBlock("div", _hoisted_3, [createElementVNode("span", _hoisted_4, "暂无" + toDisplayString(__props.config.titlePrefix || "") + "数据", 1)])) : (openBlock(true), createElementBlock(Fragment, { key: 2 }, renderList(displayItems.value, (entry) => {
93
+ }, null, 8, _hoisted_1)) : createCommentVNode("v-if", true),
94
+ !displayItems.value.length ? (openBlock(), createElementBlock("div", _hoisted_2, [createElementVNode("span", _hoisted_3, "暂无" + toDisplayString(__props.config.titlePrefix || "") + "数据", 1)])) : (openBlock(true), createElementBlock(Fragment, { key: 2 }, renderList(displayItems.value, (entry) => {
81
95
  return openBlock(), createBlock(GroupListItem_default, {
82
96
  key: entry.index,
83
97
  model: entry.item,
@@ -114,11 +128,11 @@ var GroupList_vue_vue_type_script_setup_true_lang_default = /*@__PURE__*/ define
114
128
  "group-model"
115
129
  ]);
116
130
  }), 128)),
117
- !__props.isCompare && (_ctx.$slots["toggle-button"] || _ctx.$slots["add-button"]) ? (openBlock(), createElementBlock("div", {
131
+ hasFooter(_ctx.$slots) ? (openBlock(), createElementBlock("div", {
118
132
  key: 3,
119
133
  class: normalizeClass(["m-fields-group-list-footer", { "is-sticky-full": Boolean(__props.config.addButtonConfig?.sticky) }])
120
- }, [renderSlot(_ctx.$slots, "toggle-button"), createElementVNode("div", _hoisted_5, [renderSlot(_ctx.$slots, "add-button")])], 2)) : createCommentVNode("v-if", true)
121
- ]);
134
+ }, [renderSlot(_ctx.$slots, "toggle-button"), createElementVNode("div", _hoisted_4, [renderSlot(_ctx.$slots, "add-button")])], 2)) : createCommentVNode("v-if", true)
135
+ ], 6);
122
136
  };
123
137
  }
124
138
  });
@@ -199,7 +199,10 @@ var Select_vue_vue_type_script_setup_true_lang_default = /*@__PURE__*/ defineCom
199
199
  prop: props.prop,
200
200
  postOptions
201
201
  });
202
- let initData = getValueByKeyPath(initRoot || root, res);
202
+ let initData;
203
+ try {
204
+ initData = getValueByKeyPath(initRoot || root, res);
205
+ } catch {}
203
206
  if (initData) {
204
207
  if (!Array.isArray(initData)) initData = [initData];
205
208
  if (typeof option.item === "function") options = option.item(initData);
@@ -230,8 +233,12 @@ var Select_vue_vue_type_script_setup_true_lang_default = /*@__PURE__*/ defineCom
230
233
  const v = props.model[props.name];
231
234
  if (Array.isArray(v) ? !v.length : typeof v === "undefined") return;
232
235
  if (typeof v === "undefined" || hasOption(v)) return;
233
- const data = await getInitOption();
234
- setOptions(data);
236
+ try {
237
+ const data = await getInitOption();
238
+ setOptions(data);
239
+ } catch {
240
+ setOptions([]);
241
+ }
235
242
  };
236
243
  watch(() => props.model?.[props.name], () => {
237
244
  initOption();
@@ -326,7 +333,7 @@ var Select_vue_vue_type_script_setup_true_lang_default = /*@__PURE__*/ defineCom
326
333
  }), 128)) : (openBlock(true), createElementBlock(Fragment, { key: 1 }, renderList(options.value, (option) => {
327
334
  return openBlock(), createBlock(resolveDynamicComponent(unref(optionComponent)?.component || "el-option"), mergeProps({
328
335
  class: "tmagic-design-option",
329
- key: __props.config.valueKey ? option.value[__props.config.valueKey] : option.value
336
+ key: __props.config.valueKey ? option.value?.[__props.config.valueKey] : option.value
330
337
  }, { ref_for: true }, unref(optionComponent)?.props({
331
338
  label: option.text,
332
339
  value: option.value,
package/dist/es/style.css CHANGED
@@ -333,18 +333,48 @@ fieldset.m-fieldset .m-form-tip {
333
333
  flex-shrink: 0;
334
334
  gap: 8px;
335
335
  }
336
- .m-fields-group-list .m-fields-group-list-item > .el-card__header,
337
- .m-fields-group-list .m-fields-group-list-item > .t-card__header {
336
+ .m-fields-group-list > .m-fields-group-list-item {
337
+ --m-group-list-item-footer-bottom: var(
338
+ --m-group-list-nested-footer-bottom,
339
+ 0px
340
+ );
341
+ }
342
+ .m-fields-group-list.is-footer-sticky > .m-fields-group-list-item {
343
+ --m-group-list-item-footer-bottom: calc(
344
+ var(--m-group-list-nested-footer-bottom, 0px) +
345
+ var(--m-group-list-footer-height)
346
+ );
347
+ }
348
+ .m-fields-group-list .m-fields-group-list-item .m-fields-group-list {
349
+ --m-group-list-nested-footer-bottom: var(
350
+ --m-group-list-item-footer-bottom,
351
+ 0px
352
+ );
353
+ }
354
+ .m-fields-group-list.is-header-sticky {
355
+ --m-group-list-nested-sticky-top: var(--m-group-list-child-list-sticky-top);
356
+ --m-group-list-nested-header-z: var(--m-group-list-child-list-header-z);
357
+ }
358
+ .m-fields-group-list.is-header-sticky > .m-fields-group-list-item {
359
+ --m-group-list-item-sticky-top: var(
360
+ --m-group-list-nested-sticky-top,
361
+ var(--m-group-list-header-sticky-top, 0px)
362
+ );
363
+ --m-group-list-item-header-z: var(--m-group-list-nested-header-z, 7);
364
+ --m-group-list-child-list-sticky-top: calc(
365
+ var(--m-group-list-item-sticky-top) + var(--m-group-list-header-height)
366
+ );
367
+ --m-group-list-child-list-header-z: calc(
368
+ var(--m-group-list-item-header-z) - 1
369
+ );
370
+ }
371
+ .m-fields-group-list.is-header-sticky > .m-fields-group-list-item > .el-card__header,
372
+ .m-fields-group-list.is-header-sticky > .m-fields-group-list-item > .t-card__header {
338
373
  position: sticky;
339
- top: var(--m-group-list-header-sticky-top, 0px);
340
- z-index: 7;
374
+ top: var(--m-group-list-item-sticky-top);
375
+ z-index: var(--m-group-list-item-header-z);
341
376
  background-color: var(--m-group-list-header-bg, #fff);
342
377
  }
343
- .m-fields-group-list .m-fields-group-list-item .m-fields-group-list-item > .el-card__header,
344
- .m-fields-group-list .m-fields-group-list-item .m-fields-group-list-item > .t-card__header {
345
- top: calc(var(--m-group-list-header-sticky-top, 0px) + var(--m-group-list-header-height));
346
- z-index: 6;
347
- }
348
378
  .m-fields-group-list .el-table__empty-block {
349
379
  width: 100%;
350
380
  min-height: 0;
@@ -362,7 +392,7 @@ fieldset.m-fieldset .m-form-tip {
362
392
  }
363
393
  .m-fields-group-list > .m-fields-group-list-footer.is-sticky-full {
364
394
  position: sticky;
365
- bottom: 0;
395
+ bottom: var(--m-group-list-nested-footer-bottom, 0px);
366
396
  z-index: 7;
367
397
  margin-bottom: 0;
368
398
  padding: var(--m-group-list-footer-padding-top) 0 var(--m-group-list-footer-padding-bottom);
@@ -376,10 +406,6 @@ fieldset.m-fieldset .m-form-tip {
376
406
  width: 100%;
377
407
  height: var(--m-group-list-footer-button-height);
378
408
  }
379
- .m-fields-group-list .m-fields-group-list-item .m-fields-group-list > .m-fields-group-list-footer.is-sticky-full {
380
- bottom: var(--m-group-list-footer-height);
381
- z-index: 7;
382
- }
383
409
 
384
410
  /** 最外层的groupList需要每个item需要增加空白区域界限时,增加outer-gorup_list可以实现 */
385
411
  .m-container-group-list.outer-gorup_list > .m-fields-group-list > .m-fields-group-list-item {
@@ -30,6 +30,36 @@ var mergeFormContexts = (...layers) => {
30
30
  });
31
31
  };
32
32
  /**
33
+ * 存量下发配置沿用 Vue2 时代的写法,把回调第一个参数当组件实例用,靠 `vm.mForm.xxx`
34
+ * 取表单状态。这类配置除了读,还会往上面挂方法做跨字段通信,例如:
35
+ *
36
+ * ```js
37
+ * vm.mForm.checkPropertyLimit = async (...) => { ... } // 一个字段的 validator 里挂
38
+ * await vm.mForm.checkPropertyLimit(...) // 另一处再取出来调用
39
+ * ```
40
+ *
41
+ * 所以 `mForm` 必须能指回某个 formState:读能落到 core / context,写能经 `set` trap
42
+ * 落到 coreState 并持久化。
43
+ *
44
+ * 优先级是 core > context > 合成自引用,三者都是刻意的:
45
+ *
46
+ * - **core**:`formState.mForm = formState` 这类直写(如 FormPreview 用自建 services 时)
47
+ * 必须最优先。
48
+ * - **context**:唯一的生产者是把父 formState 整体当 context 传下来的嵌套表单
49
+ * (ComponentForm)。此时 `mForm` 命中 context 指向**父表单**,与 `extendState` 时代
50
+ * 把父 formState 并入子状态的结果一致,跨字段通信仍落在同一份对象上。父 formState
51
+ * 本身也是 Proxy,写入照样持久化,所以这里让 context 赢是对的,别「修正」成指向子表单。
52
+ * - **合成自引用**:前两者都没有时才兜底,让 `vm.mForm.xxx = fn` 落到自己的 coreState。
53
+ *
54
+ * 由此推出一条约束:**不要往 context 里塞普通对象充当 `mForm`**。context 通常是 computed
55
+ * 产物,依赖一变就重建,挂上去的方法会静默丢失。要么直写 core,要么什么都不放交给兜底。
56
+ *
57
+ * 枚举语义上合成的自引用表现得像原型链上的属性:`'mForm' in state` 为真,但不出现在
58
+ * `Object.keys(state)` 里,`getOwnPropertyDescriptor` 也返回 undefined。这是故意的——
59
+ * 按扩展字段打包 formState 的调用方(如发给 AI 的逻辑)会因循环引用炸掉。
60
+ */
61
+ var SELF_REF_KEY = "mForm";
62
+ /**
33
63
  * 将 coreState 与宿主业务上下文关联:读取时优先 core,miss 再读穿到 context。
34
64
  *
35
65
  * - `get` 用属性访问而非 `Reflect.get(t, k, receiver)`,避免破坏 Vue reactive 的 `__v_raw`;
@@ -42,19 +72,24 @@ var mergeFormContexts = (...layers) => {
42
72
  * 独立成文件,避免与 `form.ts` ↔ `typeMatch.ts` 形成循环依赖。
43
73
  */
44
74
  var createFormStateProxy = (coreState, getContext) => {
45
- const resolve = () => (typeof getContext === "function" ? getContext() : unref(getContext)) || EMPTY_CONTEXT;
46
- return new Proxy(coreState, {
75
+ const resolve = () => {
76
+ const ctx = (typeof getContext === "function" ? getContext() : unref(getContext)) || EMPTY_CONTEXT;
77
+ return ctx === proxy ? EMPTY_CONTEXT : ctx;
78
+ };
79
+ const proxy = new Proxy(coreState, {
47
80
  get(t, k) {
48
81
  if (typeof k === "symbol") return Reflect.get(t, k);
49
82
  const v = t[k];
50
83
  if (v !== void 0 || Reflect.has(t, k)) return v;
51
- return resolve()[k];
84
+ const ctx = resolve();
85
+ if (k in ctx) return ctx[k];
86
+ return k === SELF_REF_KEY ? proxy : void 0;
52
87
  },
53
88
  set(t, k, value) {
54
89
  t[k] = value;
55
90
  return true;
56
91
  },
57
- has: (t, k) => Reflect.has(t, k) || typeof k !== "symbol" && k in resolve(),
92
+ has: (t, k) => Reflect.has(t, k) || typeof k !== "symbol" && (k in resolve() || k === SELF_REF_KEY),
58
93
  ownKeys: (t) => [.../* @__PURE__ */ new Set([...Reflect.ownKeys(t), ...Reflect.ownKeys(resolve()).filter((k) => typeof k !== "symbol")])],
59
94
  getOwnPropertyDescriptor: (t, k) => {
60
95
  const own = Reflect.getOwnPropertyDescriptor(t, k);
@@ -70,6 +105,7 @@ var createFormStateProxy = (coreState, getContext) => {
70
105
  };
71
106
  }
72
107
  });
108
+ return proxy;
73
109
  };
74
110
  //#endregion
75
111
  export { createFormStateProxy, mergeFormContexts };
@@ -1,4 +1,5 @@
1
1
  import { cloneDeep } from "lodash-es";
2
+ import { isNumber } from "@tmagic/utils";
2
3
  //#region packages/form/src/utils/tableGroupList.ts
3
4
  /**
4
5
  * table / group-list 是同一份配置的两种展示形态,可以互相切换(`TableGroupList.vue`)。
@@ -9,6 +10,24 @@ import { cloneDeep } from "lodash-es";
9
10
  */
10
11
  /** group-list 形态的 type(兼容驼峰与中划线两种写法) */
11
12
  var isGroupListType = (type) => type === "groupList" || type === "group-list";
13
+ /**
14
+ * 归一成带单位的 CSS 长度。
15
+ *
16
+ * 配置常常来自 JSON 或表单输入,`'48'` 这类无单位数字串很常见;直接写进 CSS 变量会让
17
+ * 嵌套层的 `calc()` 整体失效、吸顶静默失灵,所以纯数字一律补 `px`。
18
+ */
19
+ var toCssLength = (height) => {
20
+ const value = `${height ?? ""}`.trim();
21
+ if (!value) return void 0;
22
+ if (isNumber(value)) return `${value}px`;
23
+ return typeof height === "number" ? void 0 : value;
24
+ };
25
+ /** header.sticky 开启时把 height 写成 CSS 变量,给嵌套吸顶偏移用 */
26
+ var getGroupListHeaderVars = (header) => {
27
+ if (!header?.sticky) return void 0;
28
+ const height = toCssLength(header.height);
29
+ return height ? { "--m-group-list-header-height": height } : void 0;
30
+ };
12
31
  /** 按 label 文案长度估算 label 宽度(中文按 20px、其他按 8px,最小 80px) */
13
32
  var calcLabelWidth = (label) => {
14
33
  if (!label) return "0px";
@@ -82,4 +101,4 @@ var makeTableColumnConfig = (column, row) => {
82
101
  return newConfig;
83
102
  };
84
103
  //#endregion
85
- export { calcLabelWidth, getGroupListRowConfig, isGroupListType, isTableColumnRendered, makeTableColumnConfig, toGroupListConfig, toTableConfig };
104
+ export { calcLabelWidth, getGroupListHeaderVars, getGroupListRowConfig, isGroupListType, isTableColumnRendered, makeTableColumnConfig, toGroupListConfig, toTableConfig };
package/dist/style.css CHANGED
@@ -333,18 +333,48 @@ fieldset.m-fieldset .m-form-tip {
333
333
  flex-shrink: 0;
334
334
  gap: 8px;
335
335
  }
336
- .m-fields-group-list .m-fields-group-list-item > .el-card__header,
337
- .m-fields-group-list .m-fields-group-list-item > .t-card__header {
336
+ .m-fields-group-list > .m-fields-group-list-item {
337
+ --m-group-list-item-footer-bottom: var(
338
+ --m-group-list-nested-footer-bottom,
339
+ 0px
340
+ );
341
+ }
342
+ .m-fields-group-list.is-footer-sticky > .m-fields-group-list-item {
343
+ --m-group-list-item-footer-bottom: calc(
344
+ var(--m-group-list-nested-footer-bottom, 0px) +
345
+ var(--m-group-list-footer-height)
346
+ );
347
+ }
348
+ .m-fields-group-list .m-fields-group-list-item .m-fields-group-list {
349
+ --m-group-list-nested-footer-bottom: var(
350
+ --m-group-list-item-footer-bottom,
351
+ 0px
352
+ );
353
+ }
354
+ .m-fields-group-list.is-header-sticky {
355
+ --m-group-list-nested-sticky-top: var(--m-group-list-child-list-sticky-top);
356
+ --m-group-list-nested-header-z: var(--m-group-list-child-list-header-z);
357
+ }
358
+ .m-fields-group-list.is-header-sticky > .m-fields-group-list-item {
359
+ --m-group-list-item-sticky-top: var(
360
+ --m-group-list-nested-sticky-top,
361
+ var(--m-group-list-header-sticky-top, 0px)
362
+ );
363
+ --m-group-list-item-header-z: var(--m-group-list-nested-header-z, 7);
364
+ --m-group-list-child-list-sticky-top: calc(
365
+ var(--m-group-list-item-sticky-top) + var(--m-group-list-header-height)
366
+ );
367
+ --m-group-list-child-list-header-z: calc(
368
+ var(--m-group-list-item-header-z) - 1
369
+ );
370
+ }
371
+ .m-fields-group-list.is-header-sticky > .m-fields-group-list-item > .el-card__header,
372
+ .m-fields-group-list.is-header-sticky > .m-fields-group-list-item > .t-card__header {
338
373
  position: sticky;
339
- top: var(--m-group-list-header-sticky-top, 0px);
340
- z-index: 7;
374
+ top: var(--m-group-list-item-sticky-top);
375
+ z-index: var(--m-group-list-item-header-z);
341
376
  background-color: var(--m-group-list-header-bg, #fff);
342
377
  }
343
- .m-fields-group-list .m-fields-group-list-item .m-fields-group-list-item > .el-card__header,
344
- .m-fields-group-list .m-fields-group-list-item .m-fields-group-list-item > .t-card__header {
345
- top: calc(var(--m-group-list-header-sticky-top, 0px) + var(--m-group-list-header-height));
346
- z-index: 6;
347
- }
348
378
  .m-fields-group-list .el-table__empty-block {
349
379
  width: 100%;
350
380
  min-height: 0;
@@ -362,7 +392,7 @@ fieldset.m-fieldset .m-form-tip {
362
392
  }
363
393
  .m-fields-group-list > .m-fields-group-list-footer.is-sticky-full {
364
394
  position: sticky;
365
- bottom: 0;
395
+ bottom: var(--m-group-list-nested-footer-bottom, 0px);
366
396
  z-index: 7;
367
397
  margin-bottom: 0;
368
398
  padding: var(--m-group-list-footer-padding-top) 0 var(--m-group-list-footer-padding-bottom);
@@ -376,10 +406,6 @@ fieldset.m-fieldset .m-form-tip {
376
406
  width: 100%;
377
407
  height: var(--m-group-list-footer-button-height);
378
408
  }
379
- .m-fields-group-list .m-fields-group-list-item .m-fields-group-list > .m-fields-group-list-footer.is-sticky-full {
380
- bottom: var(--m-group-list-footer-height);
381
- z-index: 7;
382
- }
383
409
 
384
410
  /** 最外层的groupList需要每个item需要增加空白区域界限时,增加outer-gorup_list可以实现 */
385
411
  .m-container-group-list.outer-gorup_list > .m-fields-group-list > .m-fields-group-list-item {
@@ -333,18 +333,48 @@ fieldset.m-fieldset .m-form-tip {
333
333
  flex-shrink: 0;
334
334
  gap: 8px;
335
335
  }
336
- .m-fields-group-list .m-fields-group-list-item > .el-card__header,
337
- .m-fields-group-list .m-fields-group-list-item > .t-card__header {
336
+ .m-fields-group-list > .m-fields-group-list-item {
337
+ --m-group-list-item-footer-bottom: var(
338
+ --m-group-list-nested-footer-bottom,
339
+ 0px
340
+ );
341
+ }
342
+ .m-fields-group-list.is-footer-sticky > .m-fields-group-list-item {
343
+ --m-group-list-item-footer-bottom: calc(
344
+ var(--m-group-list-nested-footer-bottom, 0px) +
345
+ var(--m-group-list-footer-height)
346
+ );
347
+ }
348
+ .m-fields-group-list .m-fields-group-list-item .m-fields-group-list {
349
+ --m-group-list-nested-footer-bottom: var(
350
+ --m-group-list-item-footer-bottom,
351
+ 0px
352
+ );
353
+ }
354
+ .m-fields-group-list.is-header-sticky {
355
+ --m-group-list-nested-sticky-top: var(--m-group-list-child-list-sticky-top);
356
+ --m-group-list-nested-header-z: var(--m-group-list-child-list-header-z);
357
+ }
358
+ .m-fields-group-list.is-header-sticky > .m-fields-group-list-item {
359
+ --m-group-list-item-sticky-top: var(
360
+ --m-group-list-nested-sticky-top,
361
+ var(--m-group-list-header-sticky-top, 0px)
362
+ );
363
+ --m-group-list-item-header-z: var(--m-group-list-nested-header-z, 7);
364
+ --m-group-list-child-list-sticky-top: calc(
365
+ var(--m-group-list-item-sticky-top) + var(--m-group-list-header-height)
366
+ );
367
+ --m-group-list-child-list-header-z: calc(
368
+ var(--m-group-list-item-header-z) - 1
369
+ );
370
+ }
371
+ .m-fields-group-list.is-header-sticky > .m-fields-group-list-item > .el-card__header,
372
+ .m-fields-group-list.is-header-sticky > .m-fields-group-list-item > .t-card__header {
338
373
  position: sticky;
339
- top: var(--m-group-list-header-sticky-top, 0px);
340
- z-index: 7;
374
+ top: var(--m-group-list-item-sticky-top);
375
+ z-index: var(--m-group-list-item-header-z);
341
376
  background-color: var(--m-group-list-header-bg, #fff);
342
377
  }
343
- .m-fields-group-list .m-fields-group-list-item .m-fields-group-list-item > .el-card__header,
344
- .m-fields-group-list .m-fields-group-list-item .m-fields-group-list-item > .t-card__header {
345
- top: calc(var(--m-group-list-header-sticky-top, 0px) + var(--m-group-list-header-height));
346
- z-index: 6;
347
- }
348
378
  .m-fields-group-list .el-table__empty-block {
349
379
  width: 100%;
350
380
  min-height: 0;
@@ -362,7 +392,7 @@ fieldset.m-fieldset .m-form-tip {
362
392
  }
363
393
  .m-fields-group-list > .m-fields-group-list-footer.is-sticky-full {
364
394
  position: sticky;
365
- bottom: 0;
395
+ bottom: var(--m-group-list-nested-footer-bottom, 0px);
366
396
  z-index: 7;
367
397
  margin-bottom: 0;
368
398
  padding: var(--m-group-list-footer-padding-top) 0 var(--m-group-list-footer-padding-bottom);
@@ -376,10 +406,6 @@ fieldset.m-fieldset .m-form-tip {
376
406
  width: 100%;
377
407
  height: var(--m-group-list-footer-button-height);
378
408
  }
379
- .m-fields-group-list .m-fields-group-list-item .m-fields-group-list > .m-fields-group-list-footer.is-sticky-full {
380
- bottom: var(--m-group-list-footer-height);
381
- z-index: 7;
382
- }
383
409
 
384
410
  /** 最外层的groupList需要每个item需要增加空白区域界限时,增加outer-gorup_list可以实现 */
385
411
  .m-container-group-list.outer-gorup_list > .m-fields-group-list > .m-fields-group-list-item {
@@ -2557,6 +2557,36 @@
2557
2557
  });
2558
2558
  };
2559
2559
  /**
2560
+ * 存量下发配置沿用 Vue2 时代的写法,把回调第一个参数当组件实例用,靠 `vm.mForm.xxx`
2561
+ * 取表单状态。这类配置除了读,还会往上面挂方法做跨字段通信,例如:
2562
+ *
2563
+ * ```js
2564
+ * vm.mForm.checkPropertyLimit = async (...) => { ... } // 一个字段的 validator 里挂
2565
+ * await vm.mForm.checkPropertyLimit(...) // 另一处再取出来调用
2566
+ * ```
2567
+ *
2568
+ * 所以 `mForm` 必须能指回某个 formState:读能落到 core / context,写能经 `set` trap
2569
+ * 落到 coreState 并持久化。
2570
+ *
2571
+ * 优先级是 core > context > 合成自引用,三者都是刻意的:
2572
+ *
2573
+ * - **core**:`formState.mForm = formState` 这类直写(如 FormPreview 用自建 services 时)
2574
+ * 必须最优先。
2575
+ * - **context**:唯一的生产者是把父 formState 整体当 context 传下来的嵌套表单
2576
+ * (ComponentForm)。此时 `mForm` 命中 context 指向**父表单**,与 `extendState` 时代
2577
+ * 把父 formState 并入子状态的结果一致,跨字段通信仍落在同一份对象上。父 formState
2578
+ * 本身也是 Proxy,写入照样持久化,所以这里让 context 赢是对的,别「修正」成指向子表单。
2579
+ * - **合成自引用**:前两者都没有时才兜底,让 `vm.mForm.xxx = fn` 落到自己的 coreState。
2580
+ *
2581
+ * 由此推出一条约束:**不要往 context 里塞普通对象充当 `mForm`**。context 通常是 computed
2582
+ * 产物,依赖一变就重建,挂上去的方法会静默丢失。要么直写 core,要么什么都不放交给兜底。
2583
+ *
2584
+ * 枚举语义上合成的自引用表现得像原型链上的属性:`'mForm' in state` 为真,但不出现在
2585
+ * `Object.keys(state)` 里,`getOwnPropertyDescriptor` 也返回 undefined。这是故意的——
2586
+ * 按扩展字段打包 formState 的调用方(如发给 AI 的逻辑)会因循环引用炸掉。
2587
+ */
2588
+ var SELF_REF_KEY = "mForm";
2589
+ /**
2560
2590
  * 将 coreState 与宿主业务上下文关联:读取时优先 core,miss 再读穿到 context。
2561
2591
  *
2562
2592
  * - `get` 用属性访问而非 `Reflect.get(t, k, receiver)`,避免破坏 Vue reactive 的 `__v_raw`;
@@ -2569,19 +2599,24 @@
2569
2599
  * 独立成文件,避免与 `form.ts` ↔ `typeMatch.ts` 形成循环依赖。
2570
2600
  */
2571
2601
  var createFormStateProxy = (coreState, getContext) => {
2572
- const resolve = () => (typeof getContext === "function" ? getContext() : (0, vue.unref)(getContext)) || EMPTY_CONTEXT;
2573
- return new Proxy(coreState, {
2602
+ const resolve = () => {
2603
+ const ctx = (typeof getContext === "function" ? getContext() : (0, vue.unref)(getContext)) || EMPTY_CONTEXT;
2604
+ return ctx === proxy ? EMPTY_CONTEXT : ctx;
2605
+ };
2606
+ const proxy = new Proxy(coreState, {
2574
2607
  get(t, k) {
2575
2608
  if (typeof k === "symbol") return Reflect.get(t, k);
2576
2609
  const v = t[k];
2577
2610
  if (v !== void 0 || Reflect.has(t, k)) return v;
2578
- return resolve()[k];
2611
+ const ctx = resolve();
2612
+ if (k in ctx) return ctx[k];
2613
+ return k === SELF_REF_KEY ? proxy : void 0;
2579
2614
  },
2580
2615
  set(t, k, value) {
2581
2616
  t[k] = value;
2582
2617
  return true;
2583
2618
  },
2584
- has: (t, k) => Reflect.has(t, k) || typeof k !== "symbol" && k in resolve(),
2619
+ has: (t, k) => Reflect.has(t, k) || typeof k !== "symbol" && (k in resolve() || k === SELF_REF_KEY),
2585
2620
  ownKeys: (t) => [.../* @__PURE__ */ new Set([...Reflect.ownKeys(t), ...Reflect.ownKeys(resolve()).filter((k) => typeof k !== "symbol")])],
2586
2621
  getOwnPropertyDescriptor: (t, k) => {
2587
2622
  const own = Reflect.getOwnPropertyDescriptor(t, k);
@@ -2597,6 +2632,7 @@
2597
2632
  };
2598
2633
  }
2599
2634
  });
2635
+ return proxy;
2600
2636
  };
2601
2637
  //#endregion
2602
2638
  //#region packages/form/src/utils/form.ts
@@ -3411,6 +3411,36 @@
3411
3411
  });
3412
3412
  };
3413
3413
  /**
3414
+ * 存量下发配置沿用 Vue2 时代的写法,把回调第一个参数当组件实例用,靠 `vm.mForm.xxx`
3415
+ * 取表单状态。这类配置除了读,还会往上面挂方法做跨字段通信,例如:
3416
+ *
3417
+ * ```js
3418
+ * vm.mForm.checkPropertyLimit = async (...) => { ... } // 一个字段的 validator 里挂
3419
+ * await vm.mForm.checkPropertyLimit(...) // 另一处再取出来调用
3420
+ * ```
3421
+ *
3422
+ * 所以 `mForm` 必须能指回某个 formState:读能落到 core / context,写能经 `set` trap
3423
+ * 落到 coreState 并持久化。
3424
+ *
3425
+ * 优先级是 core > context > 合成自引用,三者都是刻意的:
3426
+ *
3427
+ * - **core**:`formState.mForm = formState` 这类直写(如 FormPreview 用自建 services 时)
3428
+ * 必须最优先。
3429
+ * - **context**:唯一的生产者是把父 formState 整体当 context 传下来的嵌套表单
3430
+ * (ComponentForm)。此时 `mForm` 命中 context 指向**父表单**,与 `extendState` 时代
3431
+ * 把父 formState 并入子状态的结果一致,跨字段通信仍落在同一份对象上。父 formState
3432
+ * 本身也是 Proxy,写入照样持久化,所以这里让 context 赢是对的,别「修正」成指向子表单。
3433
+ * - **合成自引用**:前两者都没有时才兜底,让 `vm.mForm.xxx = fn` 落到自己的 coreState。
3434
+ *
3435
+ * 由此推出一条约束:**不要往 context 里塞普通对象充当 `mForm`**。context 通常是 computed
3436
+ * 产物,依赖一变就重建,挂上去的方法会静默丢失。要么直写 core,要么什么都不放交给兜底。
3437
+ *
3438
+ * 枚举语义上合成的自引用表现得像原型链上的属性:`'mForm' in state` 为真,但不出现在
3439
+ * `Object.keys(state)` 里,`getOwnPropertyDescriptor` 也返回 undefined。这是故意的——
3440
+ * 按扩展字段打包 formState 的调用方(如发给 AI 的逻辑)会因循环引用炸掉。
3441
+ */
3442
+ var SELF_REF_KEY = "mForm";
3443
+ /**
3414
3444
  * 将 coreState 与宿主业务上下文关联:读取时优先 core,miss 再读穿到 context。
3415
3445
  *
3416
3446
  * - `get` 用属性访问而非 `Reflect.get(t, k, receiver)`,避免破坏 Vue reactive 的 `__v_raw`;
@@ -3423,19 +3453,24 @@
3423
3453
  * 独立成文件,避免与 `form.ts` ↔ `typeMatch.ts` 形成循环依赖。
3424
3454
  */
3425
3455
  var createFormStateProxy = (coreState, getContext) => {
3426
- const resolve = () => (typeof getContext === "function" ? getContext() : (0, vue.unref)(getContext)) || EMPTY_CONTEXT;
3427
- return new Proxy(coreState, {
3456
+ const resolve = () => {
3457
+ const ctx = (typeof getContext === "function" ? getContext() : (0, vue.unref)(getContext)) || EMPTY_CONTEXT;
3458
+ return ctx === proxy ? EMPTY_CONTEXT : ctx;
3459
+ };
3460
+ const proxy = new Proxy(coreState, {
3428
3461
  get(t, k) {
3429
3462
  if (typeof k === "symbol") return Reflect.get(t, k);
3430
3463
  const v = t[k];
3431
3464
  if (v !== void 0 || Reflect.has(t, k)) return v;
3432
- return resolve()[k];
3465
+ const ctx = resolve();
3466
+ if (k in ctx) return ctx[k];
3467
+ return k === SELF_REF_KEY ? proxy : void 0;
3433
3468
  },
3434
3469
  set(t, k, value) {
3435
3470
  t[k] = value;
3436
3471
  return true;
3437
3472
  },
3438
- has: (t, k) => Reflect.has(t, k) || typeof k !== "symbol" && k in resolve(),
3473
+ has: (t, k) => Reflect.has(t, k) || typeof k !== "symbol" && (k in resolve() || k === SELF_REF_KEY),
3439
3474
  ownKeys: (t) => [.../* @__PURE__ */ new Set([...Reflect.ownKeys(t), ...Reflect.ownKeys(resolve()).filter((k) => typeof k !== "symbol")])],
3440
3475
  getOwnPropertyDescriptor: (t, k) => {
3441
3476
  const own = Reflect.getOwnPropertyDescriptor(t, k);
@@ -3451,6 +3486,7 @@
3451
3486
  };
3452
3487
  }
3453
3488
  });
3489
+ return proxy;
3454
3490
  };
3455
3491
  //#endregion
3456
3492
  //#region packages/form/src/utils/form.ts
@@ -3759,6 +3795,24 @@
3759
3795
  */
3760
3796
  /** group-list 形态的 type(兼容驼峰与中划线两种写法) */
3761
3797
  var isGroupListType = (type) => type === "groupList" || type === "group-list";
3798
+ /**
3799
+ * 归一成带单位的 CSS 长度。
3800
+ *
3801
+ * 配置常常来自 JSON 或表单输入,`'48'` 这类无单位数字串很常见;直接写进 CSS 变量会让
3802
+ * 嵌套层的 `calc()` 整体失效、吸顶静默失灵,所以纯数字一律补 `px`。
3803
+ */
3804
+ var toCssLength = (height) => {
3805
+ const value = `${height ?? ""}`.trim();
3806
+ if (!value) return void 0;
3807
+ if ((0, _tmagic_utils.isNumber)(value)) return `${value}px`;
3808
+ return typeof height === "number" ? void 0 : value;
3809
+ };
3810
+ /** header.sticky 开启时把 height 写成 CSS 变量,给嵌套吸顶偏移用 */
3811
+ var getGroupListHeaderVars = (header) => {
3812
+ if (!header?.sticky) return void 0;
3813
+ const height = toCssLength(header.height);
3814
+ return height ? { "--m-group-list-header-height": height } : void 0;
3815
+ };
3762
3816
  /** 按 label 文案长度估算 label 宽度(中文按 20px、其他按 8px,最小 80px) */
3763
3817
  var calcLabelWidth = (label) => {
3764
3818
  if (!label) return "0px";
@@ -4689,7 +4743,7 @@
4689
4743
  };
4690
4744
  var _hoisted_3$7 = ["innerHTML"];
4691
4745
  var _hoisted_4$6 = ["innerHTML"];
4692
- var _hoisted_5$5 = ["innerHTML"];
4746
+ var _hoisted_5$4 = ["innerHTML"];
4693
4747
  var _hoisted_6$3 = ["innerHTML"];
4694
4748
  var _hoisted_7$2 = ["innerHTML"];
4695
4749
  var _hoisted_8$2 = ["innerHTML"];
@@ -5044,7 +5098,7 @@
5044
5098
  key: 0,
5045
5099
  placement: tooltip.value.placement
5046
5100
  }, {
5047
- content: (0, vue.withCtx)(() => [(0, vue.createElementVNode)("div", { innerHTML: tooltip.value.text }, null, 8, _hoisted_5$5)]),
5101
+ content: (0, vue.withCtx)(() => [(0, vue.createElementVNode)("div", { innerHTML: tooltip.value.text }, null, 8, _hoisted_5$4)]),
5048
5102
  default: (0, vue.withCtx)(() => [((0, vue.openBlock)(), (0, vue.createBlock)((0, vue.resolveDynamicComponent)(tagName.value), (0, vue.mergeProps)(fieldsProps.value, {
5049
5103
  model: __props.model,
5050
5104
  "last-values": __props.lastValues,
@@ -6442,7 +6496,7 @@
6442
6496
  var _hoisted_2$7 = ["innerHTML"];
6443
6497
  var _hoisted_3$6 = { key: 1 };
6444
6498
  var _hoisted_4$5 = ["innerHTML"];
6445
- var _hoisted_5$4 = ["innerHTML"];
6499
+ var _hoisted_5$3 = ["innerHTML"];
6446
6500
  var _hoisted_6$2 = {
6447
6501
  key: 2,
6448
6502
  style: { "display": "flex" }
@@ -6533,7 +6587,7 @@
6533
6587
  key: 0,
6534
6588
  innerHTML: __props.config.extra,
6535
6589
  class: "m-form-tip"
6536
- }, null, 8, _hoisted_5$4)) : (0, vue.createCommentVNode)("v-if", true)])), __props.config.schematic && show.value ? ((0, vue.openBlock)(), (0, vue.createElementBlock)("div", _hoisted_6$2, [(0, vue.createElementVNode)("div", _hoisted_7$1, [((0, vue.openBlock)(true), (0, vue.createElementBlock)(vue.Fragment, null, (0, vue.renderList)(__props.config.items, (item, index) => {
6590
+ }, null, 8, _hoisted_5$3)) : (0, vue.createCommentVNode)("v-if", true)])), __props.config.schematic && show.value ? ((0, vue.openBlock)(), (0, vue.createElementBlock)("div", _hoisted_6$2, [(0, vue.createElementVNode)("div", _hoisted_7$1, [((0, vue.openBlock)(true), (0, vue.createElementBlock)(vue.Fragment, null, (0, vue.renderList)(__props.config.items, (item, index) => {
6537
6591
  return (0, vue.openBlock)(), (0, vue.createBlock)(Container_default, {
6538
6592
  key: key(item, index),
6539
6593
  model: name.value ? __props.model[name.value] : __props.model,
@@ -6671,7 +6725,7 @@
6671
6725
  style: { "display": "flex" }
6672
6726
  };
6673
6727
  var _hoisted_4$4 = { style: { "flex": "1" } };
6674
- var _hoisted_5$3 = ["src"];
6728
+ var _hoisted_5$2 = ["src"];
6675
6729
  //#endregion
6676
6730
  //#region packages/form/src/containers/Panel.vue
6677
6731
  var Panel_default = /* @__PURE__ */ (0, vue.defineComponent)({
@@ -6764,7 +6818,7 @@
6764
6818
  }), 128))]), (0, vue.createElementVNode)("img", {
6765
6819
  class: "m-form-schematic",
6766
6820
  src: __props.config.schematic
6767
- }, null, 8, _hoisted_5$3)])) : ((0, vue.openBlock)(true), (0, vue.createElementBlock)(vue.Fragment, { key: 1 }, (0, vue.renderList)(items.value, (item, index) => {
6821
+ }, null, 8, _hoisted_5$2)])) : ((0, vue.openBlock)(true), (0, vue.createElementBlock)(vue.Fragment, { key: 1 }, (0, vue.renderList)(items.value, (item, index) => {
6768
6822
  return (0, vue.openBlock)(), (0, vue.createBlock)(Container_default, {
6769
6823
  key: item[(0, vue.unref)(mForm)?.keyProp || "__key"] ?? index,
6770
6824
  config: item,
@@ -7122,7 +7176,7 @@
7122
7176
  var _hoisted_2$5 = { class: "m-fields-group-list-item-title" };
7123
7177
  var _hoisted_3$4 = ["innerHTML"];
7124
7178
  var _hoisted_4$3 = { class: "m-fields-group-list-item-actions" };
7125
- var _hoisted_5$2 = { style: {
7179
+ var _hoisted_5$1 = { style: {
7126
7180
  "text-align": "right",
7127
7181
  "margin-top": "20px"
7128
7182
  } };
@@ -7287,7 +7341,7 @@
7287
7341
  disabled: __props.disabled
7288
7342
  }, null, 8, ["modelValue", "disabled"]),
7289
7343
  _cache[11] || (_cache[11] = (0, vue.createTextVNode)("行 ", -1))
7290
- ]), (0, vue.createElementVNode)("div", _hoisted_5$2, [(0, vue.createVNode)((0, vue.unref)(_tmagic_design.TMagicButton), {
7344
+ ]), (0, vue.createElementVNode)("div", _hoisted_5$1, [(0, vue.createVNode)((0, vue.unref)(_tmagic_design.TMagicButton), {
7291
7345
  size: "small",
7292
7346
  text: "",
7293
7347
  onClick: _cache[4] || (_cache[4] = ($event) => moveSpecifyLocationVisible.value = false)
@@ -7357,14 +7411,13 @@
7357
7411
  });
7358
7412
  //#endregion
7359
7413
  //#region packages/form/src/containers/GroupList.vue?vue&type=script&setup=true&lang.ts
7360
- var _hoisted_1$8 = { class: "m-fields-group-list" };
7361
- var _hoisted_2$4 = ["innerHTML"];
7362
- var _hoisted_3$3 = {
7414
+ var _hoisted_1$8 = ["innerHTML"];
7415
+ var _hoisted_2$4 = {
7363
7416
  key: 1,
7364
7417
  class: "el-table__empty-block"
7365
7418
  };
7366
- var _hoisted_4$2 = { class: "el-table__empty-text t-table__empty" };
7367
- var _hoisted_5$1 = { style: {
7419
+ var _hoisted_3$3 = { class: "el-table__empty-text t-table__empty" };
7420
+ var _hoisted_4$2 = { style: {
7368
7421
  "display": "flex",
7369
7422
  "justify-content": "flex-end",
7370
7423
  "flex": "1"
@@ -7411,6 +7464,14 @@
7411
7464
  };
7412
7465
  const onAddDiffCount = () => emit("addDiffCount");
7413
7466
  const asList = (value) => Array.isArray(value) ? value : [];
7467
+ const headerVars = (0, vue.computed)(() => getGroupListHeaderVars(props.config.header));
7468
+ /**
7469
+ * 取 `$slots` 而不是 `useSlots()`:宿主切换 `addable` 时插槽会增删,
7470
+ * computed 缓存不随插槽变化失效,只有在渲染期读才拿得到最新的。
7471
+ */
7472
+ const hasFooter = (slots) => !props.isCompare && Boolean(slots["toggle-button"] || slots["add-button"]);
7473
+ /** 只有真的渲染出吸底 footer,内层列表才需要为它让位 */
7474
+ const isFooterSticky = (slots) => hasFooter(slots) && Boolean(props.config.addButtonConfig?.sticky);
7414
7475
  const currentList = (0, vue.computed)(() => asList(props.model[props.name]));
7415
7476
  /** 对比时按当前/历史较长一侧对齐,已删除的项也能渲染出来 */
7416
7477
  const displayItems = (0, vue.computed)(() => {
@@ -7428,13 +7489,19 @@
7428
7489
  }));
7429
7490
  });
7430
7491
  return (_ctx, _cache) => {
7431
- return (0, vue.openBlock)(), (0, vue.createElementBlock)("div", _hoisted_1$8, [
7492
+ return (0, vue.openBlock)(), (0, vue.createElementBlock)("div", {
7493
+ class: (0, vue.normalizeClass)(["m-fields-group-list", {
7494
+ "is-header-sticky": Boolean(__props.config.header?.sticky),
7495
+ "is-footer-sticky": isFooterSticky(_ctx.$slots)
7496
+ }]),
7497
+ style: (0, vue.normalizeStyle)(headerVars.value)
7498
+ }, [
7432
7499
  __props.config.extra ? ((0, vue.openBlock)(), (0, vue.createElementBlock)("div", {
7433
7500
  key: 0,
7434
7501
  innerHTML: __props.config.extra,
7435
7502
  style: { "color": "rgba(0, 0, 0, 0.45)" }
7436
- }, null, 8, _hoisted_2$4)) : (0, vue.createCommentVNode)("v-if", true),
7437
- !displayItems.value.length ? ((0, vue.openBlock)(), (0, vue.createElementBlock)("div", _hoisted_3$3, [(0, vue.createElementVNode)("span", _hoisted_4$2, "暂无" + (0, vue.toDisplayString)(__props.config.titlePrefix || "") + "数据", 1)])) : ((0, vue.openBlock)(true), (0, vue.createElementBlock)(vue.Fragment, { key: 2 }, (0, vue.renderList)(displayItems.value, (entry) => {
7503
+ }, null, 8, _hoisted_1$8)) : (0, vue.createCommentVNode)("v-if", true),
7504
+ !displayItems.value.length ? ((0, vue.openBlock)(), (0, vue.createElementBlock)("div", _hoisted_2$4, [(0, vue.createElementVNode)("span", _hoisted_3$3, "暂无" + (0, vue.toDisplayString)(__props.config.titlePrefix || "") + "数据", 1)])) : ((0, vue.openBlock)(true), (0, vue.createElementBlock)(vue.Fragment, { key: 2 }, (0, vue.renderList)(displayItems.value, (entry) => {
7438
7505
  return (0, vue.openBlock)(), (0, vue.createBlock)(GroupListItem_default, {
7439
7506
  key: entry.index,
7440
7507
  model: entry.item,
@@ -7471,11 +7538,11 @@
7471
7538
  "group-model"
7472
7539
  ]);
7473
7540
  }), 128)),
7474
- !__props.isCompare && (_ctx.$slots["toggle-button"] || _ctx.$slots["add-button"]) ? ((0, vue.openBlock)(), (0, vue.createElementBlock)("div", {
7541
+ hasFooter(_ctx.$slots) ? ((0, vue.openBlock)(), (0, vue.createElementBlock)("div", {
7475
7542
  key: 3,
7476
7543
  class: (0, vue.normalizeClass)(["m-fields-group-list-footer", { "is-sticky-full": Boolean(__props.config.addButtonConfig?.sticky) }])
7477
- }, [(0, vue.renderSlot)(_ctx.$slots, "toggle-button"), (0, vue.createElementVNode)("div", _hoisted_5$1, [(0, vue.renderSlot)(_ctx.$slots, "add-button")])], 2)) : (0, vue.createCommentVNode)("v-if", true)
7478
- ]);
7544
+ }, [(0, vue.renderSlot)(_ctx.$slots, "toggle-button"), (0, vue.createElementVNode)("div", _hoisted_4$2, [(0, vue.renderSlot)(_ctx.$slots, "add-button")])], 2)) : (0, vue.createCommentVNode)("v-if", true)
7545
+ ], 6);
7479
7546
  };
7480
7547
  }
7481
7548
  });
@@ -9882,7 +9949,10 @@
9882
9949
  prop: props.prop,
9883
9950
  postOptions
9884
9951
  });
9885
- let initData = (0, _tmagic_utils.getValueByKeyPath)(initRoot || root, res);
9952
+ let initData;
9953
+ try {
9954
+ initData = (0, _tmagic_utils.getValueByKeyPath)(initRoot || root, res);
9955
+ } catch {}
9886
9956
  if (initData) {
9887
9957
  if (!Array.isArray(initData)) initData = [initData];
9888
9958
  if (typeof option.item === "function") options = option.item(initData);
@@ -9913,8 +9983,12 @@
9913
9983
  const v = props.model[props.name];
9914
9984
  if (Array.isArray(v) ? !v.length : typeof v === "undefined") return;
9915
9985
  if (typeof v === "undefined" || hasOption(v)) return;
9916
- const data = await getInitOption();
9917
- setOptions(data);
9986
+ try {
9987
+ const data = await getInitOption();
9988
+ setOptions(data);
9989
+ } catch {
9990
+ setOptions([]);
9991
+ }
9918
9992
  };
9919
9993
  (0, vue.watch)(() => props.model?.[props.name], () => {
9920
9994
  initOption();
@@ -10009,7 +10083,7 @@
10009
10083
  }), 128)) : ((0, vue.openBlock)(true), (0, vue.createElementBlock)(vue.Fragment, { key: 1 }, (0, vue.renderList)(options.value, (option) => {
10010
10084
  return (0, vue.openBlock)(), (0, vue.createBlock)((0, vue.resolveDynamicComponent)((0, vue.unref)(optionComponent)?.component || "el-option"), (0, vue.mergeProps)({
10011
10085
  class: "tmagic-design-option",
10012
- key: __props.config.valueKey ? option.value[__props.config.valueKey] : option.value
10086
+ key: __props.config.valueKey ? option.value?.[__props.config.valueKey] : option.value
10013
10087
  }, { ref_for: true }, (0, vue.unref)(optionComponent)?.props({
10014
10088
  label: option.text,
10015
10089
  value: option.value,
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.8.0-beta.26",
2
+ "version": "1.8.0-beta.28",
3
3
  "name": "@tmagic/form",
4
4
  "type": "module",
5
5
  "sideEffects": [
@@ -58,9 +58,9 @@
58
58
  "peerDependencies": {
59
59
  "typescript": "^6.0.3",
60
60
  "vue": "^3.5.41",
61
- "@tmagic/design": "1.8.0-beta.26",
62
- "@tmagic/utils": "1.8.0-beta.26",
63
- "@tmagic/form-schema": "1.8.0-beta.26"
61
+ "@tmagic/design": "1.8.0-beta.28",
62
+ "@tmagic/utils": "1.8.0-beta.28",
63
+ "@tmagic/form-schema": "1.8.0-beta.28"
64
64
  },
65
65
  "peerDependenciesMeta": {
66
66
  "typescript": {
@@ -1,5 +1,12 @@
1
1
  <template>
2
- <div class="m-fields-group-list">
2
+ <div
3
+ class="m-fields-group-list"
4
+ :class="{
5
+ 'is-header-sticky': Boolean(config.header?.sticky),
6
+ 'is-footer-sticky': isFooterSticky($slots),
7
+ }"
8
+ :style="headerVars"
9
+ >
3
10
  <div v-if="config.extra" v-html="config.extra" style="color: rgba(0, 0, 0, 0.45)"></div>
4
11
  <div v-if="!displayItems.length" class="el-table__empty-block">
5
12
  <span class="el-table__empty-text t-table__empty">暂无{{ config.titlePrefix || '' }}数据</span>
@@ -34,7 +41,7 @@
34
41
  <div
35
42
  class="m-fields-group-list-footer"
36
43
  :class="{ 'is-sticky-full': Boolean(config.addButtonConfig?.sticky) }"
37
- v-if="!isCompare && ($slots['toggle-button'] || $slots['add-button'])"
44
+ v-if="hasFooter($slots)"
38
45
  >
39
46
  <slot name="toggle-button"></slot>
40
47
  <div style="display: flex; justify-content: flex-end; flex: 1">
@@ -49,6 +56,7 @@ import { computed } from 'vue';
49
56
  import { cloneDeep } from 'lodash-es';
50
57
 
51
58
  import type { ContainerChangeEventData, GroupListConfig } from '../schema';
59
+ import { getGroupListHeaderVars } from '../utils/tableGroupList';
52
60
 
53
61
  import MFieldsGroupListItem from './GroupListItem.vue';
54
62
 
@@ -104,6 +112,19 @@ const onAddDiffCount = () => emit('addDiffCount');
104
112
 
105
113
  const asList = (value: unknown): any[] => (Array.isArray(value) ? value : []);
106
114
 
115
+ const headerVars = computed(() => getGroupListHeaderVars(props.config.header));
116
+
117
+ /**
118
+ * 取 `$slots` 而不是 `useSlots()`:宿主切换 `addable` 时插槽会增删,
119
+ * computed 缓存不随插槽变化失效,只有在渲染期读才拿得到最新的。
120
+ */
121
+ const hasFooter = (slots: Record<string, unknown>) =>
122
+ !props.isCompare && Boolean(slots['toggle-button'] || slots['add-button']);
123
+
124
+ /** 只有真的渲染出吸底 footer,内层列表才需要为它让位 */
125
+ const isFooterSticky = (slots: Record<string, unknown>) =>
126
+ hasFooter(slots) && Boolean(props.config.addButtonConfig?.sticky);
127
+
107
128
  const currentList = computed(() => asList(props.model[props.name]));
108
129
 
109
130
  /** 对比时按当前/历史较长一侧对齐,已删除的项也能渲染出来 */
@@ -57,7 +57,7 @@
57
57
  <component
58
58
  v-for="option in options as SelectOption[]"
59
59
  class="tmagic-design-option"
60
- :key="config.valueKey ? option.value[config.valueKey] : option.value"
60
+ :key="config.valueKey ? option.value?.[config.valueKey] : option.value"
61
61
  :is="optionComponent?.component || 'el-option'"
62
62
  v-bind="
63
63
  optionComponent?.props({
@@ -372,7 +372,12 @@ const getInitOption = async () => {
372
372
  });
373
373
  }
374
374
 
375
- let initData = getValueByKeyPath(initRoot || root, res);
375
+ let initData: any;
376
+ try {
377
+ initData = getValueByKeyPath(initRoot || root, res);
378
+ } catch {
379
+ // 响应中不存在 initRoot/root 路径时按无数据处理
380
+ }
376
381
  if (initData) {
377
382
  if (!Array.isArray(initData)) {
378
383
  initData = [initData];
@@ -417,8 +422,12 @@ if (typeof props.config.options === 'function') {
417
422
  const v = props.model[props.name];
418
423
  if (Array.isArray(v) ? !v.length : typeof v === 'undefined') return;
419
424
  if (typeof v === 'undefined' || hasOption(v)) return;
420
- const data = await getInitOption();
421
- setOptions(data);
425
+ try {
426
+ const data = await getInitOption();
427
+ setOptions(data);
428
+ } catch {
429
+ setOptions([]);
430
+ }
422
431
  };
423
432
 
424
433
  watch(
@@ -70,27 +70,60 @@
70
70
  flex-shrink: 0;
71
71
  gap: 8px;
72
72
  }
73
+ }
73
74
 
74
- // 卡片标题吸顶;补不透明背景,避免滚动时正文透出。
75
- // z-index 必须低于吸底 footer,否则标题会盖住「新增」按钮。
76
- > .el-card__header,
77
- > .t-card__header {
78
- position: sticky;
79
- top: var(--m-group-list-header-sticky-top, 0px);
80
- z-index: 7;
81
- background-color: var(--m-group-list-header-bg, #fff);
82
- }
75
+ // 嵌套吸底按钮叠在外层 footer 之上(如「新增条件」叠在「新增条件组」上面)。
76
+ // 每穿过一层「自己渲染了吸底 footer」的列表才多让出一个 footer 高度,外层没有吸底按钮时
77
+ // 内层不能凭空抬高。自定义属性不能自增,所以用 item / list 两个变量交替接力。
78
+ > .m-fields-group-list-item {
79
+ --m-group-list-item-footer-bottom: var(
80
+ --m-group-list-nested-footer-bottom,
81
+ 0px
82
+ );
83
+ }
84
+
85
+ &.is-footer-sticky > .m-fields-group-list-item {
86
+ --m-group-list-item-footer-bottom: calc(
87
+ var(--m-group-list-nested-footer-bottom, 0px) +
88
+ var(--m-group-list-footer-height)
89
+ );
83
90
  }
84
91
 
85
- // 嵌套标题叠在外层标题之下,层级更低以免盖住外层底部分隔线
86
- .m-fields-group-list-item .m-fields-group-list-item {
87
- > .el-card__header,
88
- > .t-card__header {
89
- top: calc(
90
- var(--m-group-list-header-sticky-top, 0px) +
91
- var(--m-group-list-header-height)
92
+ .m-fields-group-list-item .m-fields-group-list {
93
+ --m-group-list-nested-footer-bottom: var(
94
+ --m-group-list-item-footer-bottom,
95
+ 0px
96
+ );
97
+ }
98
+
99
+ // 仅 header.sticky 时标题吸顶;直接子 item,避免外层吸顶带动未开启的内层
100
+ &.is-header-sticky {
101
+ // 从外层 item 接过已累加的偏移;最外层没有该变量时走 fallback
102
+ --m-group-list-nested-sticky-top: var(--m-group-list-child-list-sticky-top);
103
+ --m-group-list-nested-header-z: var(--m-group-list-child-list-header-z);
104
+
105
+ > .m-fields-group-list-item {
106
+ --m-group-list-item-sticky-top: var(
107
+ --m-group-list-nested-sticky-top,
108
+ var(--m-group-list-header-sticky-top, 0px)
92
109
  );
93
- z-index: 6;
110
+ --m-group-list-item-header-z: var(--m-group-list-nested-header-z, 7);
111
+ --m-group-list-child-list-sticky-top: calc(
112
+ var(--m-group-list-item-sticky-top) + var(--m-group-list-header-height)
113
+ );
114
+ --m-group-list-child-list-header-z: calc(
115
+ var(--m-group-list-item-header-z) - 1
116
+ );
117
+
118
+ // 卡片标题吸顶;补不透明背景,避免滚动时正文透出。
119
+ // z-index 必须低于吸底 footer,否则标题会盖住「新增」按钮。
120
+ > .el-card__header,
121
+ > .t-card__header {
122
+ position: sticky;
123
+ top: var(--m-group-list-item-sticky-top);
124
+ z-index: var(--m-group-list-item-header-z);
125
+ background-color: var(--m-group-list-header-bg, #fff);
126
+ }
94
127
  }
95
128
  }
96
129
 
@@ -113,10 +146,10 @@
113
146
  }
114
147
 
115
148
  // 吸底全宽主按钮(event-select / code-select / display-conds 外层列表)
116
- // z-index 高于 item header,避免吸顶标题挡住新增按钮
149
+ // z-index 高于 item header,避免吸顶标题挡住新增按钮;多层时 bottom 由内层 list 累加
117
150
  > .m-fields-group-list-footer.is-sticky-full {
118
151
  position: sticky;
119
- bottom: 0;
152
+ bottom: var(--m-group-list-nested-footer-bottom, 0px);
120
153
  z-index: 7;
121
154
  margin-bottom: 0;
122
155
  padding: var(--m-group-list-footer-padding-top) 0
@@ -133,14 +166,6 @@
133
166
  height: var(--m-group-list-footer-button-height);
134
167
  }
135
168
  }
136
-
137
- // 嵌套吸底按钮叠在外层 footer 之上(如「新增条件」叠在「新增条件组」上面)
138
- .m-fields-group-list-item
139
- .m-fields-group-list
140
- > .m-fields-group-list-footer.is-sticky-full {
141
- bottom: var(--m-group-list-footer-height);
142
- z-index: 7;
143
- }
144
169
  }
145
170
 
146
171
  /** 最外层的groupList需要每个item需要增加空白区域界限时,增加outer-gorup_list可以实现 */
@@ -56,6 +56,37 @@ export const mergeFormContexts = (...layers: (FormContext | undefined | null)[])
56
56
  }) as FormContext;
57
57
  };
58
58
 
59
+ /**
60
+ * 存量下发配置沿用 Vue2 时代的写法,把回调第一个参数当组件实例用,靠 `vm.mForm.xxx`
61
+ * 取表单状态。这类配置除了读,还会往上面挂方法做跨字段通信,例如:
62
+ *
63
+ * ```js
64
+ * vm.mForm.checkPropertyLimit = async (...) => { ... } // 一个字段的 validator 里挂
65
+ * await vm.mForm.checkPropertyLimit(...) // 另一处再取出来调用
66
+ * ```
67
+ *
68
+ * 所以 `mForm` 必须能指回某个 formState:读能落到 core / context,写能经 `set` trap
69
+ * 落到 coreState 并持久化。
70
+ *
71
+ * 优先级是 core > context > 合成自引用,三者都是刻意的:
72
+ *
73
+ * - **core**:`formState.mForm = formState` 这类直写(如 FormPreview 用自建 services 时)
74
+ * 必须最优先。
75
+ * - **context**:唯一的生产者是把父 formState 整体当 context 传下来的嵌套表单
76
+ * (ComponentForm)。此时 `mForm` 命中 context 指向**父表单**,与 `extendState` 时代
77
+ * 把父 formState 并入子状态的结果一致,跨字段通信仍落在同一份对象上。父 formState
78
+ * 本身也是 Proxy,写入照样持久化,所以这里让 context 赢是对的,别「修正」成指向子表单。
79
+ * - **合成自引用**:前两者都没有时才兜底,让 `vm.mForm.xxx = fn` 落到自己的 coreState。
80
+ *
81
+ * 由此推出一条约束:**不要往 context 里塞普通对象充当 `mForm`**。context 通常是 computed
82
+ * 产物,依赖一变就重建,挂上去的方法会静默丢失。要么直写 core,要么什么都不放交给兜底。
83
+ *
84
+ * 枚举语义上合成的自引用表现得像原型链上的属性:`'mForm' in state` 为真,但不出现在
85
+ * `Object.keys(state)` 里,`getOwnPropertyDescriptor` 也返回 undefined。这是故意的——
86
+ * 按扩展字段打包 formState 的调用方(如发给 AI 的逻辑)会因循环引用炸掉。
87
+ */
88
+ const SELF_REF_KEY = 'mForm';
89
+
59
90
  /**
60
91
  * 将 coreState 与宿主业务上下文关联:读取时优先 core,miss 再读穿到 context。
61
92
  *
@@ -72,21 +103,33 @@ export const createFormStateProxy = (
72
103
  coreState: FormState,
73
104
  getContext: (() => FormContext) | Ref<FormContext>,
74
105
  ): FormState => {
75
- const resolve = (): Record<string | symbol, any> =>
76
- (typeof getContext === 'function' ? getContext() : unref(getContext)) || EMPTY_CONTEXT;
106
+ const resolve = (): Record<string | symbol, any> => {
107
+ const ctx = (typeof getContext === 'function' ? getContext() : unref(getContext)) || EMPTY_CONTEXT;
108
+ // 把 formState 自己当 context 传回来(`:context="formState"`)会让 get / has 无限递归,
109
+ // 直接爆栈。这种自引用本就提供不了任何额外字段,断掉即可。
110
+ return ctx === proxy ? EMPTY_CONTEXT : ctx;
111
+ };
77
112
 
78
- return new Proxy(coreState as object, {
113
+ const proxy = new Proxy(coreState as object, {
79
114
  get(t, k) {
80
115
  if (typeof k === 'symbol') return Reflect.get(t, k);
81
116
  const v = (t as any)[k];
82
117
  if (v !== undefined || Reflect.has(t, k)) return v;
83
- return resolve()[k];
118
+
119
+ const ctx = resolve();
120
+ if (k in ctx) return ctx[k];
121
+ // 自引用不进 ownKeys:枚举 formState 的调用方(如按扩展字段打包发给 AI 的逻辑)
122
+ // 会因为循环引用炸掉,这里只在显式读取时才合成
123
+ return k === SELF_REF_KEY ? proxy : undefined;
84
124
  },
85
125
  set(t, k, value) {
86
126
  (t as any)[k] = value;
87
127
  return true;
88
128
  },
89
- has: (t, k) => Reflect.has(t, k) || (typeof k !== 'symbol' && k in resolve()),
129
+ // `mForm` 在这里为真,但不进 ownKeys、getOwnPropertyDescriptor 也返回 undefined
130
+ // (见 SELF_REF_KEY 注释)。因此展开 / Object.entries 不会循环引用,
131
+ // 但对 proxy 直接做递归遍历(`for...in`、深拷贝、直接 JSON.stringify(formState))仍会。
132
+ has: (t, k) => Reflect.has(t, k) || (typeof k !== 'symbol' && (k in resolve() || k === SELF_REF_KEY)),
90
133
  ownKeys: (t) => [
91
134
  ...new Set([...Reflect.ownKeys(t), ...Reflect.ownKeys(resolve()).filter((k) => typeof k !== 'symbol')]),
92
135
  ],
@@ -101,4 +144,6 @@ export const createFormStateProxy = (
101
144
  return { configurable: true, enumerable: true, writable: true, value: ctx[k] };
102
145
  },
103
146
  }) as FormState;
147
+
148
+ return proxy;
104
149
  };
@@ -18,7 +18,9 @@
18
18
 
19
19
  import { cloneDeep } from 'lodash-es';
20
20
 
21
- import type { GroupListConfig, TableColumnConfig, TableConfig } from '../schema';
21
+ import { isNumber } from '@tmagic/utils';
22
+
23
+ import type { GroupListConfig, GroupListHeaderConfig, TableColumnConfig, TableConfig } from '../schema';
22
24
 
23
25
  /**
24
26
  * table / group-list 是同一份配置的两种展示形态,可以互相切换(`TableGroupList.vue`)。
@@ -31,6 +33,31 @@ import type { GroupListConfig, TableColumnConfig, TableConfig } from '../schema'
31
33
  /** group-list 形态的 type(兼容驼峰与中划线两种写法) */
32
34
  export const isGroupListType = (type: unknown): boolean => type === 'groupList' || type === 'group-list';
33
35
 
36
+ /**
37
+ * 归一成带单位的 CSS 长度。
38
+ *
39
+ * 配置常常来自 JSON 或表单输入,`'48'` 这类无单位数字串很常见;直接写进 CSS 变量会让
40
+ * 嵌套层的 `calc()` 整体失效、吸顶静默失灵,所以纯数字一律补 `px`。
41
+ */
42
+ const toCssLength = (height: number | string | undefined): string | undefined => {
43
+ const value = `${height ?? ''}`.trim();
44
+ if (!value) return undefined;
45
+
46
+ if (isNumber(value)) return `${value}px`;
47
+ // 走到这里还是 number 的只剩 NaN / Infinity,不写变量,交给样式默认值兜底
48
+ return typeof height === 'number' ? undefined : value;
49
+ };
50
+
51
+ /** header.sticky 开启时把 height 写成 CSS 变量,给嵌套吸顶偏移用 */
52
+ export const getGroupListHeaderVars = (
53
+ header: GroupListHeaderConfig | undefined,
54
+ ): Record<string, string> | undefined => {
55
+ if (!header?.sticky) return undefined;
56
+
57
+ const height = toCssLength(header.height);
58
+ return height ? { '--m-group-list-header-height': height } : undefined;
59
+ };
60
+
34
61
  /** 按 label 文案长度估算 label 宽度(中文按 20px、其他按 8px,最小 80px) */
35
62
  export const calcLabelWidth = (label: string): string => {
36
63
  if (!label) return '0px';