cloud-web-corejs 1.0.282 → 1.0.283

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.
@@ -0,0 +1,233 @@
1
+ # 编辑表行内保存的校验范围 & 列必填解析 —— 修复文档(2026-09-03)
2
+
3
+ > 状态:**两项均已编码 + 单测过,待联调**。
4
+ > 改动文件:`src/components/xform/form-render/container-item/data-table-mixin.js`、
5
+ > `src/components/xform/utils/tableColumnHelper.js`。
6
+ > 新增单测:`tests/unit/components/xform/editRowValidateScope.spec.js`(6 条)、
7
+ > `tests/unit/components/xform/columnRequiredFlag.spec.js`(7 条)。
8
+ > 行号均为 2026-09-03 工作区快照,后续会漂移,以函数名为准。
9
+ > 同步:dev 与 stdxx 两分支均**未搬**。
10
+
11
+ ---
12
+
13
+ ## 一、两个问题
14
+
15
+ | # | 现象 | 根因一句话 |
16
+ |---|------|-----------|
17
+ | A | 编辑(树)表点行内保存 ✓,别的行缺必填、甚至表格外的字段没填,都会把这一行的保存拦住 | `saveEditRow` 走的是整表 + 全表单的 `formRef.validate` |
18
+ | B | 某列 `required` 与 `editWidget.options.required` 都是 `false`,运行起来却必填(表头星号 + 拦保存) | 列在设计器里配过展示组件后又改回空,残留的 `columnOption.required` 仍被 `resolveColumnRequiredFlag` 无条件读取 |
19
+
20
+ 两者独立,但都落在"必填校验"这条线上,一起记。
21
+
22
+ ---
23
+
24
+ ## 二、问题 A:行内保存跑了全表校验
25
+
26
+ ### 2.1 原链路
27
+
28
+ ```
29
+ data-table-item.vue #editTreeButtonGroup 插槽的「保存」a-link
30
+ → saveEditRow(obj) [data-table-mixin.js:4469]
31
+ → formRef.validate(resolve) ← 整表 + 表外字段
32
+ → el-form.$baseValidate 原生规则(只覆盖已渲染的 form-item)
33
+ → runExtraFormValidations() [indexMixin.js:3862]
34
+ ├─ validateArrayValueFieldsRequired() 全表单数组类值字段
35
+ ├─ validateDataTableCellsRequired() **每个数据表格逐行**必填
36
+ └─ validateDataTableDatasetRequired() 数据表整体必填
37
+ → $baseConfirm → clearActived/setActiveRow → formHttp(只发当前行)
38
+ ```
39
+
40
+ 保存粒度是**行**(`requestRow = obj.row`,报文里只有这一行),校验粒度却是**整表单**。
41
+
42
+ ### 2.2 改动
43
+
44
+ `validateCellsRequired` 抽出核心循环,让"校验哪些行"与"prop 里的行下标"分离:
45
+
46
+ ```js
47
+ // data-table-mixin.js:1045
48
+ validateCellsRequired() {
49
+ ...
50
+ return this.runCellsRequiredCheck(rows, rows); // 整体保存:全表逐行
51
+ },
52
+ // data-table-mixin.js:1063
53
+ runCellsRequiredCheck(targetRows, indexRows) { ... }
54
+ ```
55
+
56
+ - `targetRows` —— 本次要校验的行;
57
+ - `indexRows` —— 求行下标用的**完整数据集**。
58
+ 单行校验时**必须**传全量数据集,否则 `getValidationRowIndex` 拿到的是 0,
59
+ 拼出来的 `items.0.f_code` 会定位到错误的行。
60
+
61
+ 新增三个方法:
62
+
63
+ | 方法 | 位置 | 职责 |
64
+ |------|------|------|
65
+ | `validateEditRowRequired(row)` | `data-table-mixin.js:1130` | 只对该行跑必填(内部 `runCellsRequiredCheck([row], 全量)`) |
66
+ | `collectRowValidateProps(row)` | `data-table-mixin.js:1141` | 收该行的单元格 prop,按真实行下标拼,并**按 `renderForm.fields` 过滤掉未注册的** |
67
+ | `validateEditRow(row)` | `data-table-mixin.js:1184` | 入口:原生规则 → 必填兜底,任一失败弹消息返回 false |
68
+
69
+ `saveEditRow` 的第一步随之改为:
70
+
71
+ ```js
72
+ // 行内保存只提交当前行,校验范围同步收敛到当前行:其他行与表外字段
73
+ // 的必填不该拦住单行保存(整体保存仍走 formRef.validate 的全量校验)
74
+ if (!this.validateEditRow(obj.row)) return false;
75
+ ```
76
+
77
+ ### 2.3 行为对照
78
+
79
+ | 通道 | 原生规则(正则 / 自定义 validator) | 必填 |
80
+ |------|--------------------------------|------|
81
+ | 整体保存(`formRef.validate`) | 全表单已渲染的 form-item | 全表逐行 + 数据集必填 + 数组类字段 |
82
+ | 行内保存(`validateEditRow`) | **仅当前行**已注册的 prop | **仅当前行** |
83
+
84
+ ### 2.4 两个坑(已在实现里处理)
85
+
86
+ 1. **prop 必须先按 `renderForm.fields` 过滤**。element 的 `validateField(props, cb)` 在传入的 prop
87
+ 一个都没注册时会打 `[Element Warn] please pass correct props!`。编辑表用的是
88
+ vxe `editConfig: { trigger: "manual", mode: "row" }`,`edit` 插槽只在**正在编辑的那一行**渲染,
89
+ 未编辑行的 prop 本就不在 `fields` 里。
90
+ 2. **`getValidationRows` 读的是 computed `fieldKeyName`**,不是 `getFieldKeyName(this.widget)`。
91
+ 写单测造上下文时必须直给 `fieldKeyName`,否则会掉进 `$grid.getTableData()` 兜底分支。
92
+
93
+ ---
94
+
95
+ ## 三、问题 B:残留 `columnOption` 让列凭空必填
96
+
97
+ ### 3.1 判据
98
+
99
+ 问题列的模板 JSON(节选):
100
+
101
+ | 位置 | 值 |
102
+ |------|-----|
103
+ | 列级 `required` | `false` |
104
+ | `formatS` / `widget` | `""` / `null` ← 展示组件已改回空 |
105
+ | `editFormatS` / `editWidget.options.required` | `"editInput"` / `false` |
106
+ | **`columnOption.required`** | **`true`** ← 残留快照 |
107
+
108
+ ### 3.2 根因:两条读取路径口径不一致
109
+
110
+ | 路径 | 读什么 | 结果 |
111
+ |------|--------|------|
112
+ | 加载时 `applyColumnMetaToFieldWidget`(`util.js:919`,由 `handleTableColumnWidget`(`indexMixin.js:674`)调用) | **只读对应侧**:编辑侧读 `editWidgetOptions ?? editColumnOption` | `editWidget.options.required` = `false` ✅ |
113
+ | 运行时 `resolveColumnRequiredFlag`(`tableColumnHelper.js:90`) | 改动前**两侧都读、且不看 `formatS`**:`widgetOptions ?? columnOption` | `true` ❌ |
114
+
115
+ 该 flag 有**两个出口**,所以现象是"星号 + 拦保存"一起来:
116
+
117
+ - `applyColumnLabelIcon`(`tableColumnHelper.js:126`)→ 表头画 `vxe-cell--required-icon`;
118
+ - `isCellFieldRequired`(`tableCellValidateUtil.js:52`)→ 组件自身 `required` 为 false 时**落到**该 flag → 进必填校验。
119
+
120
+ ### 3.3 改动
121
+
122
+ 新增两个内部函数,给静态列的列级 option 加门控:
123
+
124
+ ```js
125
+ // tableColumnHelper.js:12
126
+ function hasColumnWidgetSide(columnConfig, isEdit) {
127
+ if (isEdit) {
128
+ return !!(columnConfig.editWidget || columnFormatMap[columnConfig.editFormatS]);
129
+ }
130
+ return !!(columnConfig.widget || columnFormatMap[columnConfig.formatS]);
131
+ }
132
+ // tableColumnHelper.js:32
133
+ function getEffectiveColumnOptions(columnConfig, isEdit) { ... }
134
+ ```
135
+
136
+ `resolveColumnRequiredFlag` 的兜底改为:
137
+
138
+ ```js
139
+ const widgetOptions = getEffectiveColumnOptions(columnConfig, false);
140
+ const editWidgetOptions = getEffectiveColumnOptions(columnConfig, true);
141
+ return !!(widgetOptions?.required || editWidgetOptions?.required);
142
+ ```
143
+
144
+ 口径:
145
+
146
+ - **动态列**的 `widgetOptions` / `editWidgetOptions` 直接生效(它们本就没有 `formatS`,不门控);
147
+ - **设计器静态列**的 `columnOption` / `editColumnOption` 只在该侧
148
+ `columnFormatMap[formatS]` 能映射出组件类型(或 widget 已实例化)时才读。
149
+
150
+ **门控判据必须用 `columnFormatMap`,不能用"`formatS` 非空"** —— 纯格式化列(`formatS: "d1"` 这类格式码)
151
+ 同样不建 widget,那份残留 option 也不该作数;用 `columnFormatMap` 才与加载时的实例化口径完全一致。
152
+
153
+ ### 3.4 排除项:展示态不会被误渲染成组件
154
+
155
+ 展示侧组件的实例化在三条路径上都以 `columnFormatMap[formatS]` 为门:
156
+
157
+ | 路径 | 位置 |
158
+ |------|------|
159
+ | 运行态 | `form-render/indexMixin.js:690` |
160
+ | 设计器 | `form-designer/form-widget/container-widget/data-table-mixin.js:220` |
161
+ | H5 列表 | `form-render/container-item/list-h5-item.vue:1692` |
162
+
163
+ `formatS` 为空 → widget 保持 null → `createColumn` 的展示插槽 if 链(`data-table-mixin.js:2158`)
164
+ 一条都不命中 → 单元格走 `formatter: formatterValue` 纯文本。
165
+ `addColumProperty`(`data-table-mixin.js:2011`)展开的 `columnOption` 取自 **widget 实例**的 options,
166
+ 不是 JSON 里那份残留。
167
+
168
+ 所以残留 `columnOption` 在改动前唯一漏出来的地方就是 `resolveColumnRequiredFlag` ——
169
+ 全仓读 `columnOption` 的点里只有它不看 `formatS`。
170
+
171
+ ### 3.5 老数据不做清理
172
+
173
+ 渲染路径对这类残留**只有补齐、没有校验、也没有清理**:`handleTableColumnWidget` 是单向的
174
+ (`formatS` 映射得出组件才建 widget),不会比对"widget 不存在但 columnOption 还在",
175
+ 全仓也没有任何删除 `columnOption` 的写法;设计器侧 `cloneTableColumnsForEdit`
176
+ (`table-column-dialog.vue:1107`)还会在 `!row.widget` 时把它深拷贝带进编辑态再写回模板。
177
+
178
+ 结论:**DB 里的残留不必清** —— 代码侧已经兜住,清理反而要动存量模板。
179
+ 若要早发现,最轻的一档是在 `handleTableColumnWidget` 里对
180
+ "该侧无组件却带着 columnOption"仅开发环境 `console.warn`(未实施)。
181
+
182
+ ---
183
+
184
+ ## 四、改动清单
185
+
186
+ | 文件 | 改动 |
187
+ |------|------|
188
+ | `form-render/container-item/data-table-mixin.js` | `validateCellsRequired` 抽出 `runCellsRequiredCheck(targetRows, indexRows)`;新增 `validateEditRowRequired` / `collectRowValidateProps` / `validateEditRow`;`saveEditRow` 首步由 `formRef.validate` 换成 `validateEditRow(obj.row)` |
189
+ | `utils/tableColumnHelper.js` | 新增 `hasColumnWidgetSide` / `getEffectiveColumnOptions`(并 `import { columnFormatMap } from "./util"`);`resolveColumnRequiredFlag` 兜底改走门控 |
190
+ | `tests/unit/components/xform/editRowValidateScope.spec.js` | 新增,6 条 |
191
+ | `tests/unit/components/xform/columnRequiredFlag.spec.js` | 新增,7 条 |
192
+
193
+ 未改动、行为保持不变:`validateForm` / `validate` / `runExtraFormValidations` 三个整体保存入口,
194
+ `validateCheckRow`(勾选行操作的行级校验),H5 列表的必填口径(只读列级 `t.required`)。
195
+
196
+ ---
197
+
198
+ ## 五、验证
199
+
200
+ - `npx jest tests/unit/components/xform` —— 28 套全过(含两个新增 spec)。
201
+ - `npx jest tests/unit/components` —— 49 套 712 条,711 通过;唯一失败
202
+ `mobile/h5PopDialog.spec.js`(`selectUserDialog.vue` 缺 `custom-class="h5-pop-dialog"`)
203
+ 属 H5 wf 弹层那条线的既有状态,**与本次改动无关**。
204
+ - eslint:`data-table-mixin.js` 无新增报错(51 条 operator-linebreak 均为既有欠账,
205
+ 行号不落在新增段);`tableColumnHelper.js` 由 4 条降至 2 条。
206
+
207
+ 单测覆盖点:
208
+
209
+ | spec | 覆盖 |
210
+ |------|------|
211
+ | editRowValidateScope | 整体保存仍全表拦 / 别的行缺必填不拦当前行 / 当前行自己缺必填仍拦 / prop 带真实行下标且只发当前行 / 未注册 prop 不下发 / 原生错优先于必填兜底 |
212
+ | columnRequiredFlag | 残留 columnOption 不再必填 / 老数据(formatS 有值、widget 未实例化)仍必填 / 纯格式化列不读 / editColumnOption 按 editFormatS 门控 / 动态列不受门控 / 列级与实例 required 照旧 / 空配置 |
213
+
214
+ ---
215
+
216
+ ## 六、影响面与联调点
217
+
218
+ | 变更 | 影响面 | 联调重点 |
219
+ |------|--------|----------|
220
+ | A | 仅编辑树表的 `editTreeButtonGroup` 行内保存 | 当前行必填仍拦、错误能定位到该行;他行/表外字段缺失不再误拦;原生正则规则在当前行仍生效 |
221
+ | B | 全量表格的必填标记(表头星号)与单元格必填校验 | 抽查若干正常配置的必填列,星号与校验都还在;问题列星号消失、不再拦保存 |
222
+
223
+ B 的潜在风险:若有表单**依赖**这份残留 `columnOption.required` 来实现必填(即列级和组件级都没配、
224
+ 全靠残留生效),改后会变成非必填。属于"配置本就不合规"的情况,联调时留意用户反馈的
225
+ "某列突然不必填了",正解是去列配置或组件上把 required 补回。
226
+
227
+ ---
228
+
229
+ ## 七、待办
230
+
231
+ 1. 真机联调(两项均未联调)。
232
+ 2. **同步 dev 与 stdxx 两分支**(按既有同步规则:JS 可整体覆盖、样式不覆盖)。
233
+ 3. 可选:`handleTableColumnWidget` 的 dev-only `console.warn`(残留列级 option 早发现),未实施。
@@ -8,6 +8,14 @@ let XLSX = window.XLSX || { read, utils };
8
8
  import { getToken } from "../../../../../utils/auth";
9
9
  import XEUtils from "xe-utils";
10
10
  import { getErrorMsg } from "@base/utils/index.js";
11
+ import {
12
+ getImportCellText,
13
+ getImportFileSizeLimit,
14
+ getImportRowLimit,
15
+ isImportFileOversize,
16
+ isImportRowsOverLimit,
17
+ toFileSizeMb,
18
+ } from "../../../../../utils/importLimit";
11
19
 
12
20
  let configUtil = {
13
21
  baseUrl: process.env.VUE_APP_BASE_API,
@@ -372,6 +380,25 @@ tmixins = {
372
380
  let that = this;
373
381
  let option = this.option;
374
382
 
383
+ // 业务侧的 importFileLimitSize 允许到 200MB,而这里是整文件 readAsBinaryString +
384
+ // XLSX.read 同步解析,占用约为文件体积的 5~20 倍 —— 那个量级必然把标签页撑爆,
385
+ // 且崩在库内部,连提示都给不出来。这里再收一道解析安全上限,可用
386
+ // importOption.maxFileSize 覆盖(0 表示不限制)。
387
+ if (isImportFileOversize(file, option)) {
388
+ this.$baseAlert(
389
+ this.$t1(
390
+ "导入文件过大({size}MB),当前最多支持 {limit}MB,请拆分后分批导入",
391
+ {
392
+ size: toFileSizeMb(file.size),
393
+ limit: toFileSizeMb(getImportFileSizeLimit(option)),
394
+ }
395
+ )
396
+ );
397
+ // 与上游那道 importFileLimitSize 校验保持一致:只提示、不关弹窗,
398
+ // 用户可以直接重选一个小文件。
399
+ return false;
400
+ }
401
+
375
402
  if (option.importOption.multipleSheet) {
376
403
  this.readExcel2(file, callback);
377
404
  return;
@@ -403,6 +430,22 @@ tmixins = {
403
430
  let range = configUtil.XLSX.utils.decode_range(sheet["!ref"]);
404
431
  let dataSize = range.e.r + 1; //行数
405
432
  that.dataSize = dataSize - 3 >= 0 ? dataSize - 3 : 0;
433
+ // 行数闸门:下面逐格取值、建行对象、再整批发包全是同步的,行数失控时主线程会被
434
+ // 占死甚至 OOM。文件体积不大但行极多(窄表)时只有这一道能拦。
435
+ if (isImportRowsOverLimit(that.dataSize, option)) {
436
+ that.clearImportTimer();
437
+ that.$baseAlert(
438
+ that.$t1(
439
+ "导入数据 {size} 行,超过单次上限 {limit} 行,请拆分后分批导入",
440
+ {
441
+ size: that.dataSize,
442
+ limit: getImportRowLimit(option),
443
+ }
444
+ )
445
+ );
446
+ that.handleClose2();
447
+ return false;
448
+ }
406
449
  let titles = [];
407
450
  let fields = [];
408
451
 
@@ -428,7 +471,7 @@ tmixins = {
428
471
  if (typeof nextCell === "undefined") {
429
472
  row.push(void 0);
430
473
  } else {
431
- row.push(nextCell.w.trim());
474
+ row.push(getImportCellText(nextCell));
432
475
  }
433
476
  }
434
477
  if (rowNum === 1) {
@@ -1483,6 +1526,22 @@ tmixins = {
1483
1526
  let range = configUtil.XLSX.utils.decode_range(sheet["!ref"]);
1484
1527
  let dataSize = range.e.r + 1; //行数
1485
1528
  that.dataSize = dataSize - 3 >= 0 ? dataSize - 3 : 0;
1529
+ // 行数闸门:下面逐格取值、建行对象、再整批发包全是同步的,行数失控时主线程会被
1530
+ // 占死甚至 OOM。文件体积不大但行极多(窄表)时只有这一道能拦。
1531
+ if (isImportRowsOverLimit(that.dataSize, option)) {
1532
+ that.clearImportTimer();
1533
+ that.$baseAlert(
1534
+ that.$t1(
1535
+ "导入数据 {size} 行,超过单次上限 {limit} 行,请拆分后分批导入",
1536
+ {
1537
+ size: that.dataSize,
1538
+ limit: getImportRowLimit(option),
1539
+ }
1540
+ )
1541
+ );
1542
+ that.handleClose2();
1543
+ return false;
1544
+ }
1486
1545
  let titles = [];
1487
1546
  let fields = [];
1488
1547
 
@@ -1508,7 +1567,7 @@ tmixins = {
1508
1567
  if (typeof nextCell === "undefined") {
1509
1568
  row.push(void 0);
1510
1569
  } else {
1511
- row.push(nextCell.w.trim());
1570
+ row.push(getImportCellText(nextCell));
1512
1571
  }
1513
1572
  }
1514
1573
  if (rowNum === 1) {
@@ -1051,6 +1051,16 @@ modules = {
1051
1051
  return null;
1052
1052
  }
1053
1053
 
1054
+ return this.runCellsRequiredCheck(rows, rows);
1055
+ },
1056
+ /**
1057
+ * 逐单元格必填校验的核心循环。
1058
+ * @param {Array} targetRows 本次要校验的行。
1059
+ * @param {Array} indexRows 求行下标用的完整数据集(决定校验 prop 里的行下标)。
1060
+ * @returns {String|null} 首个错误提示,全部通过返回 null。
1061
+ */
1062
+ runCellsRequiredCheck(targetRows, indexRows) {
1063
+ const rows = indexRows || [];
1054
1064
  const isEditTable = this.widget.options.isEditTable || false;
1055
1065
  const tableColumns = this.widget.options.tableColumns || [];
1056
1066
  const tableFieldKeyName = this.getFieldKeyName(this.widget);
@@ -1060,8 +1070,8 @@ modules = {
1060
1070
  this.collectColumnValidateEntries(column, isEditTable, columnEntries);
1061
1071
  });
1062
1072
 
1063
- for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {
1064
- const row = rows[rowIndex];
1073
+ for (let rowIndex = 0; rowIndex < targetRows.length; rowIndex++) {
1074
+ const row = targetRows[rowIndex];
1065
1075
  if (!row) {
1066
1076
  continue;
1067
1077
  }
@@ -1112,6 +1122,87 @@ modules = {
1112
1122
 
1113
1123
  return null;
1114
1124
  },
1125
+ /**
1126
+ * 行内保存用:只校验当前行的必填,不牵动其他行。
1127
+ * @param {Object} row 当前行。@returns {String|null} 错误提示,通过为 null。
1128
+ */
1129
+ validateEditRowRequired(row) {
1130
+ if (!row || this.widget.options.hidden || this.designState) {
1131
+ return null;
1132
+ }
1133
+ const rows = this.getValidationRows();
1134
+ return this.runCellsRequiredCheck([row], rows.length ? rows : [row]);
1135
+ },
1136
+ /**
1137
+ * 收集当前行所有参与校验的单元格 prop(用于原生规则的行级校验)。
1138
+ * @param {Object} row 当前行。@returns {Array<String>} 已在表单里注册的 prop 列表。
1139
+ */
1140
+ collectRowValidateProps(row) {
1141
+ const formRef = this.getFormRef?.();
1142
+ const renderForm = formRef?.$refs?.renderForm;
1143
+ if (!row || !renderForm) {
1144
+ return [];
1145
+ }
1146
+ const isEditTable = this.widget.options.isEditTable || false;
1147
+ const tableColumns = this.widget.options.tableColumns || [];
1148
+ const tableFieldKeyName = this.getFieldKeyName(this.widget);
1149
+ const rows = this.getValidationRows();
1150
+ const rowIndex = this.getValidationRowIndex(row, rows, 0);
1151
+ const columnEntries = [];
1152
+ this.loodHandleColumns(tableColumns, (column) => {
1153
+ this.collectColumnValidateEntries(column, isEditTable, columnEntries);
1154
+ });
1155
+
1156
+ // 只保留真正注册进 el-form 的 prop:validateField 传入全部未注册的 prop 会打警告
1157
+ const registered = new Set(
1158
+ (renderForm.fields || []).map((item) => item.prop)
1159
+ );
1160
+ const props = [];
1161
+ columnEntries.forEach(({ column, templateWidget }) => {
1162
+ const fieldWidget = this.resolveRowFieldWidget(row, templateWidget);
1163
+ if (shouldSkipCellValidation(fieldWidget)) {
1164
+ return;
1165
+ }
1166
+ const property = getCellFieldKey(fieldWidget, column);
1167
+ const propName = buildTableCellFormProp(
1168
+ tableFieldKeyName,
1169
+ rowIndex,
1170
+ property
1171
+ );
1172
+ if (propName && registered.has(propName) && !props.includes(propName)) {
1173
+ props.push(propName);
1174
+ }
1175
+ });
1176
+ return props;
1177
+ },
1178
+ /**
1179
+ * 行内保存的校验入口:范围收敛到当前行(原生规则 + 必填兜底),
1180
+ * 不校验其他行、也不校验表格外的表单字段。
1181
+ * @param {Object} row 当前行。@returns {Boolean} 通过为 true。
1182
+ */
1183
+ validateEditRow(row) {
1184
+ const formRef = this.getFormRef?.();
1185
+ const renderForm = formRef?.$refs?.renderForm;
1186
+ const props = this.collectRowValidateProps(row);
1187
+ let nativeError = null;
1188
+ if (renderForm && props.length) {
1189
+ renderForm.validateField(props, (errorMsg) => {
1190
+ if (!nativeError && errorMsg) {
1191
+ nativeError = errorMsg;
1192
+ }
1193
+ });
1194
+ }
1195
+ if (nativeError) {
1196
+ this.$message.error(nativeError);
1197
+ return false;
1198
+ }
1199
+ const requiredError = this.validateEditRowRequired(row);
1200
+ if (requiredError) {
1201
+ this.$message.error(requiredError);
1202
+ return false;
1203
+ }
1204
+ return true;
1205
+ },
1115
1206
  /** @param {Boolean} required 是否将整个数据表设为必填。 */
1116
1207
  setRequired(required) {
1117
1208
  this.$set(this.widget.options, "required", !!required);
@@ -4428,14 +4519,9 @@ modules = {
4428
4519
  let logicalRowKey = obj.row?._X_ROW_KEY || null;
4429
4520
  let originalPhysicalId = null;
4430
4521
  try {
4431
- let valid = await new Promise((resolve, reject) => {
4432
- try {
4433
- formRef.validate(resolve);
4434
- } catch (error) {
4435
- reject(error);
4436
- }
4437
- });
4438
- if (!valid) return false;
4522
+ // 行内保存只提交当前行,校验范围同步收敛到当前行:其他行与表外字段
4523
+ // 的必填不该拦住单行保存(整体保存仍走 formRef.validate 的全量校验)
4524
+ if (!this.validateEditRow(obj.row)) return false;
4439
4525
 
4440
4526
  let requestRow = obj.row;
4441
4527
  if (treeContext) {
@@ -1,3 +1,47 @@
1
+ import { columnFormatMap } from "./util";
2
+
3
+ /**
4
+ * 列的某一侧(展示 / 编辑)是否真的配了组件。
5
+ *
6
+ * 判据与表单加载时的实例化口径一致(见 form-render/indexMixin 的 columnLoopDo):
7
+ * 只有 formatS / editFormatS 能映射出组件类型时该侧才会建 widget。纯格式化列
8
+ * (formatS 是 d1 这类格式码)与被改回空的列都不算。
9
+ * @param {Object} columnConfig 列配置。@param {Boolean} isEdit 是否编辑侧。
10
+ * @returns {Boolean}
11
+ */
12
+ function hasColumnWidgetSide(columnConfig, isEdit) {
13
+ if (isEdit) {
14
+ return !!(
15
+ columnConfig.editWidget || columnFormatMap[columnConfig.editFormatS]
16
+ );
17
+ }
18
+ return !!(columnConfig.widget || columnFormatMap[columnConfig.formatS]);
19
+ }
20
+
21
+ /**
22
+ * 取该侧生效的列级组件配置。
23
+ *
24
+ * 动态列走 widgetOptions / editWidgetOptions(本来就没有 formatS),直接生效;
25
+ * 设计器静态列的 columnOption / editColumnOption 必须门控:展示组件被改回空
26
+ * 之后那份 columnOption 仍会留在模板 JSON 里,无条件读会让这列凭空变必填
27
+ * (表头星号 + 拦保存),而加载时的 applyColumnMetaToFieldWidget 只读对应侧,
28
+ * 两边口径不一致正是该现象的根因。
29
+ * @param {Object} columnConfig 列配置。@param {Boolean} isEdit 是否编辑侧。
30
+ * @returns {Object|null}
31
+ */
32
+ function getEffectiveColumnOptions(columnConfig, isEdit) {
33
+ const dynamicOptions = isEdit
34
+ ? columnConfig.editWidgetOptions
35
+ : columnConfig.widgetOptions;
36
+ if (dynamicOptions) {
37
+ return dynamicOptions;
38
+ }
39
+ if (!hasColumnWidgetSide(columnConfig, isEdit)) {
40
+ return null;
41
+ }
42
+ return isEdit ? columnConfig.editColumnOption : columnConfig.columnOption;
43
+ }
44
+
1
45
  function buildColumnConfigMap(tableColumns, map = {}) {
2
46
  if (!tableColumns || !tableColumns.length) {
3
47
  return map;
@@ -56,10 +100,8 @@ export function resolveColumnRequiredFlag(columnConfig) {
56
100
  if (columnConfig.widget?.options?.required) {
57
101
  return true;
58
102
  }
59
- const widgetOptions =
60
- columnConfig.widgetOptions ?? columnConfig.columnOption;
61
- const editWidgetOptions =
62
- columnConfig.editWidgetOptions ?? columnConfig.editColumnOption;
103
+ const widgetOptions = getEffectiveColumnOptions(columnConfig, false);
104
+ const editWidgetOptions = getEffectiveColumnOptions(columnConfig, true);
63
105
  return !!(widgetOptions?.required || editWidgetOptions?.required);
64
106
  }
65
107
 
@@ -0,0 +1,19 @@
1
+ /**
2
+ * 取出 require(图片) 的真实 URL。
3
+ *
4
+ * webpack4(url-loader,vue-cli 设了 esModule:false) 下 require 直接返回 URL 字符串;
5
+ * webpack5(vue-cli5 的 asset modules) 下返回的是 ES Module 命名空间对象,且该对象
6
+ * 由 Object.create(null) 生成——没有原型链就没有 toString,直接绑到 :src 上时
7
+ * setAttribute 会抛 "Cannot convert object to primitive value" 整棵子树渲染失败
8
+ * (webpack5 的 asset modules 没有 esModule 开关,只能在取值处兜底)。
9
+ *
10
+ * 消费应用的构建配置不受本库控制,凡是把 require 结果往 DOM 属性上放的地方都要过这里。
11
+ */
12
+ export function assetUrl(mod) {
13
+ if (mod && typeof mod === "object") {
14
+ return mod.default || "";
15
+ }
16
+ return mod || "";
17
+ }
18
+
19
+ export default assetUrl;
@@ -0,0 +1,72 @@
1
+ /**
2
+ * 导入文件闸门。
3
+ *
4
+ * 导入侧是整文件 readAsBinaryString 读进内存 → XLSX.read(占用通常是文件体积的
5
+ * 5~20 倍)→ 再逐格建行数组,全程同步且没有任何让步。此前没有大小与行数校验,
6
+ * 几十 MB 的 xlsx 会直接把标签页撑爆,且崩在 FileReader / XLSX 内部,用户看到的
7
+ * 同样是"页面崩溃"而不是一句提示。
8
+ *
9
+ * 阈值都可以由导入配置覆盖(maxFileSize / maxRowNum,也支持写在 importOption 上),
10
+ * 传 0 表示不限制。
11
+ */
12
+
13
+ /** 导入文件大小上限(字节)。 */
14
+ export const DEFAULT_IMPORT_FILE_SIZE = 20 * 1024 * 1024;
15
+
16
+ /** 导入数据行数上限(不含模板前 3 行表头)。 */
17
+ export const DEFAULT_IMPORT_ROW_NUM = 20000;
18
+
19
+ const readOption = (option, key) => {
20
+ if (option && option[key] !== undefined) return option[key];
21
+ if (option && option.importOption && option.importOption[key] !== undefined) {
22
+ return option.importOption[key];
23
+ }
24
+ return undefined;
25
+ };
26
+
27
+ const resolveLimit = (value, defaultValue) => {
28
+ let num = Number(value);
29
+ if (!isFinite(num) || num < 0) return defaultValue;
30
+ return num;
31
+ };
32
+
33
+ /** @param {Object} option 导入配置。@returns {Number} 文件大小上限,0 表示不限制。 */
34
+ export function getImportFileSizeLimit(option) {
35
+ return resolveLimit(readOption(option, "maxFileSize"), DEFAULT_IMPORT_FILE_SIZE);
36
+ }
37
+
38
+ /** @param {Object} option 导入配置。@returns {Number} 行数上限,0 表示不限制。 */
39
+ export function getImportRowLimit(option) {
40
+ return resolveLimit(readOption(option, "maxRowNum"), DEFAULT_IMPORT_ROW_NUM);
41
+ }
42
+
43
+ /** @param {File} file 待导入文件。@param {Object} option 导入配置。@returns {Boolean} */
44
+ export function isImportFileOversize(file, option) {
45
+ let limit = getImportFileSizeLimit(option);
46
+ return limit > 0 && !!file && file.size > limit;
47
+ }
48
+
49
+ /** @param {Number} rowNum 数据行数。@param {Object} option 导入配置。@returns {Boolean} */
50
+ export function isImportRowsOverLimit(rowNum, option) {
51
+ let limit = getImportRowLimit(option);
52
+ return limit > 0 && rowNum > limit;
53
+ }
54
+
55
+ /** @param {Number} bytes 字节数。@returns {Number} 保留一位小数的 MB 数。 */
56
+ export function toFileSizeMb(bytes) {
57
+ return Math.round(((bytes || 0) / 1024 / 1024) * 10) / 10;
58
+ }
59
+
60
+ /**
61
+ * 取单元格文本:w(格式化文本)缺失时回退 v(原始值)。
62
+ * 模板里手工粘贴或公式生成的数值格常常只有 v,直接 w.trim() 会 TypeError,
63
+ * 而调用点外层的 catch 会把它吞成"点了导入没反应"。
64
+ * @param {Object} cell xlsx 单元格。
65
+ * @returns {String|undefined}
66
+ */
67
+ export function getImportCellText(cell) {
68
+ if (cell === undefined || cell === null) return undefined;
69
+ let text = cell.w !== undefined && cell.w !== null ? cell.w : cell.v;
70
+ if (text === undefined || text === null) return undefined;
71
+ return String(text).trim();
72
+ }
package/src/utils/vab.js CHANGED
@@ -13,6 +13,7 @@ import XEUtils from "xe-utils";
13
13
  import prefixConfig from "@/prefixConfig.js";
14
14
  import moment from "moment";
15
15
  import { openView } from "./repeatOpen";
16
+ import { assetUrl } from "./assetUrl";
16
17
 
17
18
  let imFun = {
18
19
  request,
@@ -28,7 +29,8 @@ let imFun = {
28
29
  };
29
30
 
30
31
  function reqImg(a) {
31
- return require("@/resources/images/" + a);
32
+ /* webpack5 下 require 返回的是无原型的 Module 对象,直接当 src 用会崩,统一取真实 URL */
33
+ return assetUrl(require("@/resources/images/" + a));
32
34
  }
33
35
 
34
36
  const FILE_ICON_NAME_MAP = {
@@ -398,6 +400,8 @@ install = (Vue, opts = {}) => {
398
400
  Vue.prototype.$baseLodash = imFun.lodash;
399
401
  /* 全局事件总线 */
400
402
  Vue.prototype.$baseEventBus = new Vue();
403
+ /* 模板里 :src="$assetUrl(require(...))" 用,见 utils/assetUrl.js */
404
+ Vue.prototype.$assetUrl = assetUrl;
401
405
 
402
406
  Vue.prototype.$commonFileUtil = {
403
407
  // 文件类型获取与判断接口