@neatui/vue 5.5.1 → 5.5.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neatui/vue",
3
- "version": "5.5.1",
3
+ "version": "5.5.2",
4
4
  "description": "basic component ui",
5
5
  "main": "./src/index.ts",
6
6
  "license": "MIT",
@@ -36,7 +36,7 @@
36
36
  "@fekit/scrollto": "^3.0.3",
37
37
  "@fekit/toast": "^2.7.2",
38
38
  "@fekit/utils": "^6.0.0",
39
- "@neatui/css": "^5.5.1",
39
+ "@neatui/css": "^5.5.2",
40
40
  "@rollup/plugin-commonjs": "^26.0.1",
41
41
  "@rollup/plugin-node-resolve": "^15.2.3",
42
42
  "@vue/compiler-sfc": "^3.4.30",
@@ -29,16 +29,16 @@
29
29
  :interacted="touched"
30
30
  :debug="debug"
31
31
  rows=""
32
- :class="isVertical(col) ? 'w-full' : 'flex-fixed nr-sm'"
32
+ :class="[isVertical(col) ? 'w-full' : 'flex-fixed nr-sm', titleLayer(col)]"
33
33
  :style="isVertical(col) ? undefined : labelStyle(col)"
34
34
  :ui-flex="isVertical(col) ? 'row lm' : 'row rm'"
35
35
  />
36
- <h6 v-else-if="labelText(col.label) && isVertical(col)" :style="showStar(col) ? 'text-indent: -1em' : undefined" :class="labelClass || undefined" v-bind="labelAttrs(col.label)">
36
+ <h6 v-else-if="labelText(col.label) && isVertical(col)" :style="showStar(col) ? 'text-indent: -1em' : undefined" :class="[labelClass, titleLayer(col)]" v-bind="labelAttrs(col.label)">
37
37
  <slot v-if="labelSlot(col)" :name="labelSlot(col)" :col="col" :node="col" :data="col._data" :text="formatLabel(col)"></slot>
38
38
  <template v-else-if="labelText(col.label) !== '*' && showStar(col)"><b class="co-risk pr" style="left: 0.1em">﹡</b>{{ formatLabel(col) }}</template>
39
39
  <template v-else-if="labelText(col.label) !== '*'">{{ formatLabel(col) }}</template>
40
40
  </h6>
41
- <p v-else-if="labelText(col.label)" :class="[labelClass, clh({ ...(col.model[1] || {}) })]" :style="labelStyle(col)" class="ar nr-sm flex-fixed o-ls" v-bind="labelAttrs(col.label)">
41
+ <p v-else-if="labelText(col.label)" :class="[labelClass, clh({ ...(col.model[1] || {}) }), titleLayer(col)]" :style="labelStyle(col)" class="ar nr-sm flex-fixed o-ls" v-bind="labelAttrs(col.label)">
42
42
  <slot v-if="labelSlot(col)" :name="labelSlot(col)" :col="col" :node="col" :data="col._data" :text="formatLabel(col)"></slot>
43
43
  <template v-else-if="labelText(col.label) !== '*' && showStar(col)"><b class="co-risk">﹡</b>{{ formatLabel(col) }}</template>
44
44
  <template v-else-if="labelText(col.label) !== '*'">{{ formatLabel(col) }}</template>
@@ -263,14 +263,19 @@
263
263
  const checkRules = async (value: any, rules: FormRule[] = []) => {
264
264
  if (!isArray(rules)) return null;
265
265
  if (!isEmpty(value)) {
266
+ // warn 不阻断:记下第一条警告后继续检查,遇到阻断规则立即返回
267
+ let warning: { msg: string; type: string } | null = null;
266
268
  for (const rule of rules) {
267
269
  if (rule && isFunction(rule.validator) && !(await rule.validator!(value))) {
268
- return { msg: rule.msg || '', type: rule.type || 'risk' };
270
+ const type = rule.type || 'risk';
271
+ if (type !== 'warn') return { msg: rule.msg || '', type };
272
+ warning ||= { msg: rule.msg || '', type };
269
273
  }
270
274
  }
271
- return null;
275
+ return warning;
272
276
  }
273
- const rule = rules.find((item) => item?.required);
277
+ const required = rules.filter((item) => item?.required);
278
+ const rule = required.find((item) => (item.type || 'risk') !== 'warn') || required[0];
274
279
  return rule ? { msg: rule.msg || '', type: rule.type || 'risk' } : null;
275
280
  };
276
281
 
@@ -627,6 +632,7 @@
627
632
  });
628
633
 
629
634
  // 各层级共享:Label 宽度、校验状态、隐藏字段暂存、已挂载的表单实例
635
+ type ServerError = { msg: string; type: string; input: any };
630
636
  interface Shared {
631
637
  state: { lw: number; validated: boolean };
632
638
  cache: ClearCache;
@@ -640,8 +646,9 @@
640
646
  validations: WeakMap<object, { input: any; epoch: number; pending: boolean; result: Promise<any> }>;
641
647
  // 根数据任意变化时递增,供 watch: true 的联动共用
642
648
  dataVersion: ShallowRef<number>;
643
- // 外部回填的错误(如服务端校验),按字段路径保存;字段值变化后自动失效
644
- serverErrors: Map<string, { msg: string; type: string; input: any }>;
649
+ // 外部回填的错误(如服务端校验),按「所在记录 → 行内路径」保存;字段值变化后自动失效。
650
+ // 外层 WeakMap 以记录对象为键,记录被删除并回收后错误随之释放;清空时整体替换,shallowRef 负责通知已渲染字段
651
+ serverErrors: ShallowRef<WeakMap<object, Map<string, ServerError>>>;
645
652
  }
646
653
  const FORM_SHARED = Symbol.for('neatui:form-loader');
647
654
  const shared: Shared = (props.root && inject<Shared | null>(FORM_SHARED, null)) || {
@@ -656,7 +663,7 @@
656
663
  cancelChecks: new Set(),
657
664
  validations: new WeakMap(),
658
665
  dataVersion: shallowRef(0),
659
- serverErrors: reactive(new Map()),
666
+ serverErrors: shallowRef(reactive(new WeakMap())),
660
667
  };
661
668
  provide(FORM_SHARED, shared);
662
669
 
@@ -795,6 +802,8 @@
795
802
  if (isCleared(col)) out.style = joinClass(own.style, { display: 'none' });
796
803
  return out;
797
804
  };
805
+ // 分组内容区的栅格负边距会伸进标题区域,标题提一层以保证按钮可点
806
+ const titleLayer = (col: any) => (isGroup(col) || col._array ? 'pr z-xs' : undefined);
798
807
  // 有标题分组的子节点容器
799
808
  const boxAttrs = (col: any) => {
800
809
  const { attrs } = levelOf(col);
@@ -1024,7 +1033,7 @@
1024
1033
  }
1025
1034
 
1026
1035
  // 添加ID
1027
- item._fid = item.field ? [baseIdx.value, fieldPath(item).replace(/\./g, '-')].filter(Boolean).join('-') : '';
1036
+ item._fid = fieldIdOf(item);
1028
1037
 
1029
1038
  // 数据路径处理
1030
1039
  item._data = resolvePath(props.data, item.paths);
@@ -1042,6 +1051,9 @@
1042
1051
  if (props.debug) console.debug(`[FormLoader] ${props.path || 'root'}`, deepcopy(toRaw(state.form)));
1043
1052
  };
1044
1053
 
1054
+ // 字段 ID 随数据路径变化;行实例复用时由下方监听单独刷新,不触发重建
1055
+ const fieldIdOf = (item: any) => (item.field ? [baseIdx.value, fieldPath(item).replace(/\./g, '-')].filter(Boolean).join('-') : '');
1056
+
1045
1057
  // 调试信息
1046
1058
  const debugInfo = (col: any = {}) => [fieldPath(col) || '(group)', col.model?.[0] || (isGroup(col) ? 'child' : col._array ? 'array' : 'Input')].join(' · ');
1047
1059
  watch(
@@ -1052,9 +1064,16 @@
1052
1064
  { deep: true, immediate: true },
1053
1065
  );
1054
1066
  watch([() => props.data, () => props.idx], ([data], [previous]) => {
1055
- if (!props.root && data !== previous) captureInitial();
1067
+ if (!props.root && data !== previous) {
1068
+ captureInitial();
1069
+ clearValidate(); // 换了一条记录:上一条的服务端错误和校验状态不再适用
1070
+ }
1056
1071
  rebuild(props.form);
1057
1072
  });
1073
+ // 数组行增删、排序后行实例被复用,只刷新字段 ID,保留校验状态和联动
1074
+ watch(() => props.path, () => {
1075
+ state.form.forEach((item: any) => { item._fid = fieldIdOf(item); });
1076
+ });
1058
1077
 
1059
1078
  // 隐藏的字段清空数据并暂存,重新显示时还原(restore: false 时只清空)
1060
1079
  // 只在「隐藏 → 显示」的那一次和首次运行时还原,避免和分组内的子表单来回争抢同一个值
@@ -1112,8 +1131,19 @@
1112
1131
  return keys.length === Object.keys(b).length && keys.every(key => Object.prototype.hasOwnProperty.call(b, key) && sameInput(a[key], b[key]));
1113
1132
  };
1114
1133
  // path 由收集字段的那一层计算(validate 在根表单调用时,嵌套字段的路径不能用根表单拼)
1134
+ // 回填错误以「所在记录(数组行)+ 行内路径」为键保存,不依赖完整路径:
1135
+ // 排序、删除前面的行后错误随原记录走;路径上换成别的记录时不显示;记录被删除后不再匹配
1136
+ const localPathOf = (col: any = {}) => [col.paths, col.field].filter(Boolean).join('.');
1137
+ const serverErrorOf = (col: any, data: any) => shared.serverErrors.value.get(toRaw(data))?.get(localPathOf(col));
1138
+ const deleteServerError = (col: any, data: any) => shared.serverErrors.value.get(toRaw(data))?.delete(localPathOf(col));
1139
+ const setServerError = (col: any, data: any, error: ServerError) => {
1140
+ const store = shared.serverErrors.value;
1141
+ const owner = toRaw(data);
1142
+ if (!store.has(owner)) store.set(owner, new Map());
1143
+ store.get(owner)!.set(localPathOf(col), error);
1144
+ };
1115
1145
  const validateField = (col: any, ctx: FormContext, path: string, force = false): Promise<any> => {
1116
- const server = shared.serverErrors.get(path);
1146
+ const server = serverErrorOf(col, ctx.data);
1117
1147
  if (server) return Promise.resolve({ msg: server.msg, type: server.type });
1118
1148
  const input = deepcopy([displayOf(col, ctx), rulesOf(col), col.model]);
1119
1149
  const previous = shared.validations.get(col);
@@ -1127,7 +1157,7 @@
1127
1157
  error = message ? { msg: message, type: 'risk' } : await checkRules(input[0], input[1]);
1128
1158
  } catch { error = { msg: '校验失败,请重试', type: 'risk' }; }
1129
1159
  job.pending = false;
1130
- if (shared.validations.get(col) === job && shared.validationEpoch === job.epoch && !shared.serverErrors.has(path)) { col._error = error; col._checked = true; }
1160
+ if (shared.validations.get(col) === job && shared.validationEpoch === job.epoch && !serverErrorOf(col, ctx.data)) { col._error = error; col._checked = true; }
1131
1161
  return error;
1132
1162
  })();
1133
1163
  return job.result;
@@ -1140,14 +1170,14 @@
1140
1170
  deepcopy(col.model);
1141
1171
  // 回填的错误在字段值变化前一直显示,值一变就交回正常校验
1142
1172
  const path = col.field ? fieldPath(col) : '';
1143
- const server = path ? shared.serverErrors.get(path) : undefined;
1173
+ const server = path ? serverErrorOf(col, props.data) : undefined;
1144
1174
  if (server && !isCleared(col)) {
1145
1175
  if (sameInput(server.input, value)) {
1146
1176
  col._error = { msg: server.msg, type: server.type };
1147
1177
  col._checked = true;
1148
1178
  continue;
1149
1179
  }
1150
- shared.serverErrors.delete(path);
1180
+ deleteServerError(col, props.data);
1151
1181
  }
1152
1182
  if (col.field && !isGroup(col) && !isCleared(col) && (touched.value || col._touched)) {
1153
1183
  void validateField(col, context(), path);
@@ -1202,11 +1232,17 @@
1202
1232
  if (shared.pending.size) await Promise.all([...shared.pending]);
1203
1233
  } while (revision !== shared.revision || shared.pending.size);
1204
1234
  revision = shared.revision;
1205
- results = await Promise.all(fieldsOf().filter(({ path }) => withinPaths(path, paths)).map(async ({ col, path, ctx }) => {
1235
+ const collected = fieldsOf().filter(({ path }) => withinPaths(path, paths));
1236
+ results = await Promise.all(collected.map(async ({ col, ctx, path }) => {
1206
1237
  if (paths) col._touched = true;
1207
- const res = await validateField(col, ctx, path, true);
1208
- return res ? { field: col.field, path, label: labelText(col.label), msg: res.msg, type: res.type, fid: col._fid } : null;
1238
+ return validateField(col, ctx, path, true);
1209
1239
  }));
1240
+ // 校验期间 setErrors 回填的错误优先于本轮规则结果
1241
+ results = collected.map(({ col, path, ctx }, i) => {
1242
+ const server = serverErrorOf(col, ctx.data);
1243
+ const res = server ? { msg: server.msg, type: server.type } : results[i];
1244
+ return res ? { field: col.field, path, label: labelText(col.label), msg: res.msg, type: res.type, fid: col._fid } : null;
1245
+ });
1210
1246
  if (epoch !== shared.validationEpoch) return cancelledResult();
1211
1247
  } while (revision !== shared.revision);
1212
1248
  const found = (results.filter(Boolean) as any[]).sort(byPosition);
@@ -1234,7 +1270,7 @@
1234
1270
 
1235
1271
  const clearValidate = () => {
1236
1272
  cancelValidation();
1237
- shared.serverErrors.clear();
1273
+ shared.serverErrors.value = reactive(new WeakMap());
1238
1274
  shared.validations = new WeakMap();
1239
1275
  shared.state.validated = false;
1240
1276
  fieldsOf().forEach(({ col }) => { col._touched = false; col._error = null; col._checked = false; });
@@ -1251,7 +1287,7 @@
1251
1287
  list.forEach(({ path, msg, type = 'risk' }) => {
1252
1288
  const found = fields.find((item) => item.path === path);
1253
1289
  if (!found) return missing.push(path);
1254
- shared.serverErrors.set(path, { msg, type, input: deepcopy(displayOf(found.col, found.ctx)) });
1290
+ setServerError(found.col, found.ctx.data, { msg, type, input: deepcopy(displayOf(found.col, found.ctx)) });
1255
1291
  });
1256
1292
  const first = list.find((item) => !missing.includes(item.path));
1257
1293
  if (scroll && first) scrollToField(first.path);
@@ -210,7 +210,7 @@
210
210
  </p>
211
211
  </li>
212
212
  <template v-if="treeEnabled && !item.__folding">
213
- <template v-for="(child, cidx) in childrenOf(item)" :key="`${rowKeyOf(item, idx)}-${cidx}`">
213
+ <template v-for="(child, cidx) in childrenOf(item)" :key="childKeyOf(rowKeyOf(item, idx), child, cidx)">
214
214
  <li v-if="childVisible(child)" class="table-tree-item bg-fore r-sm my-sl n-ms">
215
215
  <table>
216
216
  <tbody>
@@ -232,7 +232,7 @@
232
232
  </p>
233
233
  </li>
234
234
  <template v-if="depth > 2 && !child.__folding">
235
- <template v-for="(grand, gidx) in childrenOf(child)" :key="`${rowKeyOf(item, idx)}-${cidx}-${gidx}`">
235
+ <template v-for="(grand, gidx) in childrenOf(child)" :key="childKeyOf(childKeyOf(rowKeyOf(item, idx), child, cidx), grand, gidx)">
236
236
  <li v-if="childVisible(grand)" class="table-tree-item table-tree-item-3 bg-fore r-sm my-sl n-ms">
237
237
  <table>
238
238
  <tbody>
@@ -652,6 +652,12 @@
652
652
  if (key && item && typeof item === 'object') return item[key] ?? idx;
653
653
  return idx;
654
654
  };
655
+ // 子行标识:编码完整层级路径(设了 rowKey 时排序、增删后状态跟着记录走)。
656
+ // 用 JSON 数组而不是连字符拼接,避免「父 a + 子 b」与顶层 id「a-b」撞 key
657
+ const childKeyOf = (parentKey: any, item: any, idx: number) => {
658
+ const parent = typeof parentKey === 'string' && parentKey.startsWith('\u0000') ? JSON.parse(parentKey.slice(1)) : [parentKey];
659
+ return `\u0000${JSON.stringify([...parent, rowKeyOf(item, idx)])}`;
660
+ };
655
661
 
656
662
  /**
657
663
  * 虚拟滚动:上下用占位行撑出滚动高度,只渲染可视区附近的行。
@@ -705,11 +711,12 @@
705
711
  const kids = childrenOf(item);
706
712
  kids.forEach((child, cidx) => {
707
713
  const last = cidx === kids.length - 1;
708
- out.push({ item: child, idx: cidx, level: 2, parent: item, key: `${key}-${cidx}`, last, parentLast: false });
714
+ const childKey = childKeyOf(key, child, cidx);
715
+ out.push({ item: child, idx: cidx, level: 2, parent: item, key: childKey, last, parentLast: false });
709
716
  if (props.depth < 3 || child?.__folding) return;
710
717
  const grand = childrenOf(child);
711
718
  grand.forEach((g, gidx) => {
712
- out.push({ item: g, idx: gidx, level: 3, parent: child, key: `${key}-${cidx}-${gidx}`, last: gidx === grand.length - 1, parentLast: last });
719
+ out.push({ item: g, idx: gidx, level: 3, parent: child, key: childKeyOf(childKey, g, gidx), last: gidx === grand.length - 1, parentLast: last });
713
720
  });
714
721
  });
715
722
  });