cloud-web-corejs 1.0.281 → 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.
Files changed (30) hide show
  1. package/package.json +1 -1
  2. package/src/components/excelExport/exportColumnMemory.js +118 -0
  3. package/src/components/excelExport/exportFieldDialog.vue +63 -40
  4. package/src/components/excelExport/exportItemConfigUtil.js +30 -0
  5. package/src/components/excelExport/index.js +30 -13
  6. package/src/components/excelExport/mixins.js +406 -68
  7. package/src/components/excelImport/index.vue +1 -1
  8. package/src/components/excelImport/mixins.js +56 -8
  9. package/src/components/table/index.js +14 -1
  10. package/src/components/table/vxeFilter/mixin.js +28 -18
  11. package/src/components/xform/docs/2026-07 table/344/270/216excelExport/344/277/256/345/244/215/345/217/212/345/257/274/345/207/272/344/274/230/345/214/226.md" +11 -4
  12. package/src/components/xform/docs/2026-09 /346/235/241/344/273/266/345/257/274/345/207/272/345/244/247/346/225/260/346/215/256/351/207/217/345/264/251/346/272/203/346/216/222/346/237/245.md" +389 -0
  13. package/src/components/xform/docs/2026-09 /347/274/226/350/276/221/350/241/250/350/241/214/345/206/205/344/277/235/345/255/230/346/240/241/351/252/214/350/214/203/345/233/264/344/270/216/345/210/227/345/277/205/345/241/253/350/247/243/346/236/220/344/277/256/345/244/215.md" +233 -0
  14. package/src/components/xform/form-designer/form-widget/dialog/importDialogMixin.js +61 -2
  15. package/src/components/xform/form-designer/form-widget/field-widget/select-export-item-button-widget.vue +9 -0
  16. package/src/components/xform/form-designer/setting-panel/property-editor/container-data-table/exportItemColumns-dialog.vue +7 -1
  17. package/src/components/xform/form-designer/setting-panel/property-editor/field-table-export-button/select-export-item-button-editor.vue +51 -0
  18. package/src/components/xform/form-designer/widget-panel/widgetsConfig.js +4 -0
  19. package/src/components/xform/form-render/container-item/data-table-item.vue +7 -4
  20. package/src/components/xform/form-render/container-item/data-table-mixin.js +465 -128
  21. package/src/components/xform/utils/dynamicSchemaUtil.js +24 -0
  22. package/src/components/xform/utils/tableColumnHelper.js +46 -4
  23. package/src/components/xform/utils/textMaskUtil.js +6 -3
  24. package/src/layout/components/TagsView/index.vue +4 -2
  25. package/src/store/modules/tagsView.js +27 -3
  26. package/src/utils/assetUrl.js +19 -0
  27. package/src/utils/importLimit.js +72 -0
  28. package/src/utils/pdfUtil.js +6 -1
  29. package/src/utils/request.js +17 -0
  30. package/src/utils/vab.js +5 -1
@@ -1,10 +1,13 @@
1
1
  /**version-1.0*/
2
2
  let tmixins = {};
3
+ import axios from "axios";
3
4
  import { getToken } from "../../utils/auth";
4
5
  import indexUtil from "../../utils/index.js";
5
6
  import exportFieldDialog from "./exportFieldDialog.vue";
6
7
  import { getCellValue } from "@base/components/table/util/index";
7
8
  import { isItemExport, isRowExport, requiresCheckedRows } from "./exportType";
9
+ import { getExportItemColumns } from "./exportItemConfigUtil";
10
+ import { viewKey } from "@base/utils/repeatOpen";
8
11
 
9
12
  let configUtil = {
10
13
  baseUrl: process.env.VUE_APP_BASE_API,
@@ -12,6 +15,45 @@ let configUtil = {
12
15
  indexUtil,
13
16
  };
14
17
 
18
+ /** 取值转换每处理多少行让出一次主线程,见 getExcelData。 */
19
+ const EXPORT_YIELD_ROWS = 200;
20
+
21
+ /**
22
+ * 让出主线程一个宏任务,给渲染与用户交互留出机会。
23
+ * 必须是宏任务:微任务(Promise.resolve())不会让浏览器插入渲染帧,进度条照样不动。
24
+ * @returns {Promise<void>}
25
+ */
26
+ function yieldToBrowser() {
27
+ return new Promise((resolve) => setTimeout(resolve, 0));
28
+ }
29
+
30
+ /**
31
+ * 可以直接当文件存下来的响应体类型(前缀匹配)。
32
+ */
33
+ const DOWNLOAD_BINARY_TYPES = [
34
+ "application/vnd.openxmlformats",
35
+ "application/vnd.ms-excel",
36
+ "application/octet-stream",
37
+ "application/zip",
38
+ ];
39
+
40
+ /**
41
+ * 这份响应体是不是真的文件流。
42
+ * 用白名单而不是「排除 application/json」:网关拦截、登录态失效这类失败常见的是
43
+ * 200 + text/html,黑名单会把一张 HTML 错误页原样存成打不开的 .xlsx,用户看到的是
44
+ * 一个损坏文件而不是回退后的正常下载。
45
+ * 后端不给 Content-Type 时 blob.type 是空串,按二进制放行 —— 旧的表单 POST 本来也是
46
+ * 这么下的,不能因为这次改造反而下不动。
47
+ * @param {Blob} res 响应体。
48
+ * @returns {Boolean}
49
+ */
50
+ function isDownloadableBlob(res) {
51
+ if (!res) return false;
52
+ let type = (res.type || "").toLowerCase();
53
+ if (!type) return true;
54
+ return DOWNLOAD_BINARY_TYPES.some((item) => type.indexOf(item) === 0);
55
+ }
56
+
15
57
  function getGrid(that, tableRef) {
16
58
  let $grid;
17
59
  if (Array.isArray(that.$refs[tableRef])) {
@@ -40,6 +82,8 @@ function getGrid(that, tableRef) {
40
82
  CURRENT_PREFIX: "",
41
83
  option: {},
42
84
  uuid: "",
85
+ // createExcelTemplate 返回的本次导出时间快照,随每页取数请求下发(见 loopToDo)
86
+ nowDate: null,
43
87
  fileRows: [],
44
88
  title: this.$t2("导出", "components.excelExport.title"),
45
89
  tableTarget: null,
@@ -92,6 +136,17 @@ function getGrid(that, tableRef) {
92
136
  exportTimer: null,
93
137
  countTimer: null,
94
138
  exportError: false,
139
+ // 用户点「取消」/组件被销毁的终止标记。此前取消只是停掉定时器 + 销毁组件,
140
+ // 已经发出去的那几页请求照样会回调下来继续上送、继续合并,甚至最后弹出一个
141
+ // 半截文件的下载。所有异步续点都要先问 isExportStopped()。
142
+ aborted: false,
143
+ // 本次导出全部请求共用的取消令牌源,取消时一次性掐断在途请求
144
+ cancelSource: null,
145
+ // lockMenu 实际锁上的那一个页签。必须记下来:解锁若再按「当时的
146
+ // $route.path」现查,用户在导出期间切了页签(弹框可最小化,切走完全合法)
147
+ // 解的就是另一把锁 —— 原页签会永久 affix,而 tagsView 的 delOthersViews /
148
+ // delAllViews 都保留 affix,「关闭其他」「全部关闭」也删不掉它,只能刷新页面。
149
+ lockedView: null,
95
150
  };
96
151
  },
97
152
  created() {},
@@ -99,10 +154,46 @@ function getGrid(that, tableRef) {
99
154
  // this.exc();
100
155
  },
101
156
  beforeDestroy() {
157
+ this.aborted = true;
102
158
  this.clearExportTimer();
159
+ this.cancelPendingRequests();
103
160
  this.unlockMenu();
104
161
  },
105
162
  methods: {
163
+ /**
164
+ * 导出是否已终止:用户取消、组件销毁、或失败中止。
165
+ * 每一个跨越 await / .then / setTimeout 的续点都必须先问它,否则在途请求的回调
166
+ * 会在导出早已"取消"之后继续推进流程。
167
+ * @returns {Boolean}
168
+ */
169
+ isExportStopped() {
170
+ if (this.aborted === true) return true;
171
+ if (this.exportError === true) return true;
172
+ return this._isDestroyed === true;
173
+ },
174
+ /**
175
+ * 本次导出共用的取消令牌,首次取用时创建。
176
+ * 每次导出都是一个全新的组件实例(index.js 的 initInstance 会先销毁旧实例),
177
+ * 所以一个 source 覆盖一次导出的全部请求,不存在跨导出串味。
178
+ * @returns {Object} axios CancelToken
179
+ */
180
+ getCancelToken() {
181
+ if (!this.cancelSource) {
182
+ this.cancelSource = axios.CancelToken.source();
183
+ }
184
+ return this.cancelSource.token;
185
+ },
186
+ /**
187
+ * 掐断本次导出全部在途请求。
188
+ * request.js 的错误拦截器已对 axios.isCancel 短路返回,不会弹错误框;
189
+ * 各调用点的 .catch 也都会先问 isExportStopped() 再决定要不要提示。
190
+ */
191
+ cancelPendingRequests() {
192
+ if (this.cancelSource) {
193
+ this.cancelSource.cancel("export-cancelled");
194
+ this.cancelSource = null;
195
+ }
196
+ },
106
197
  exc() {
107
198
  this.option = this.param;
108
199
  let $grid = getGrid(this.option.vue, this.option.targetRef);
@@ -110,10 +201,9 @@ function getGrid(that, tableRef) {
110
201
  // 明细导出的列来自 exportItemConfig,未维护时字段树为空、表头 JSON 也无从生成,
111
202
  // 必须在此拦掉:这里是所有导出的唯一入口,放行下去会在字段选择弹框里抛异常。
112
203
  if (isItemExport(this.option.type)) {
113
- let originOption = $grid.params.originOption || {};
114
- if (!originOption.exportItemConfig?.columns?.length) {
204
+ if (!getExportItemColumns(this.option, $grid).length) {
115
205
  this.$baseAlert(
116
- this.$t1("未维护明细导出列,请先在数据表格属性中配置")
206
+ this.$t1("未维护明细导出列,请先在导出按钮或数据表格属性中配置")
117
207
  );
118
208
  this.param.destroyComponent();
119
209
  return;
@@ -149,6 +239,9 @@ function getGrid(that, tableRef) {
149
239
  },
150
240
  dialogClose2() {
151
241
  if (this.isMinimize) return;
242
+ // 最小化不是取消,只有真正关闭才置终止标记
243
+ this.aborted = true;
244
+ this.cancelPendingRequests();
152
245
  let loadingObj = window.$vueRoot.$baseLoading({
153
246
  target: document.body,
154
247
  background: "unset",
@@ -202,10 +295,13 @@ function getGrid(that, tableRef) {
202
295
  this.abortExport(
203
296
  this.$t1("无法确定导出服务前缀,请在导出配置中指定 prefix")
204
297
  );
205
- this.unlockMenu();
206
298
  return;
207
299
  }
208
300
  this.CURRENT_PREFIX = options.prefix;
301
+ // 取数走 originOption.exportAjax 时请求由 xform 侧构造,前端拿不到那个 config,
302
+ // 只能把令牌顺着导出参数带过去(data-table-mixin 的 exportAjax 会塞进
303
+ // customParam.config,最终由 formHttp 的 ...opts 透传给 axios)
304
+ options.cancelToken = this.getCancelToken();
209
305
 
210
306
  this.title = title;
211
307
  that.tTotalPage = 0;
@@ -213,6 +309,7 @@ function getGrid(that, tableRef) {
213
309
  that
214
310
  .createExcelFile()
215
311
  .then((resultMsg) => {
312
+ if (that.isExportStopped()) return;
216
313
  if (resultMsg.type === "success") {
217
314
  let aObj = resultMsg.objx;
218
315
  that.uuid = aObj.uuid;
@@ -221,6 +318,7 @@ function getGrid(that, tableRef) {
221
318
  setTimeout(function () {
222
319
  /*num = 1;
223
320
  loopToDo(0, num);*/
321
+ if (that.isExportStopped()) return;
224
322
  that.handleLoopToDo();
225
323
  }, 300);
226
324
  } else {
@@ -230,6 +328,7 @@ function getGrid(that, tableRef) {
230
328
  }
231
329
  })
232
330
  .catch((error) => {
331
+ if (that.isExportStopped()) return;
233
332
  console.error(error);
234
333
  that.abortExport(that.$t1("创建导出文件失败,导出已中止"));
235
334
  that.showImportDialog = false;
@@ -250,9 +349,18 @@ function getGrid(that, tableRef) {
250
349
  if (hasExportImage || (hasExportImage2 && showImageAtTable)) {
251
350
  pageSize = Math.min(pageSize, 150);
252
351
  }
352
+ // 单页压力取决于「行数 × 列数」,此前只按有无图片列降档,宽表(100+ 列)一页
353
+ // 的请求体与同步转换耗时是窄表的十几倍,同样的 1000 行/页天差地别。
354
+ let columnNum = this.leafColumns.length;
355
+ if (columnNum > 100) {
356
+ pageSize = Math.min(pageSize, 200);
357
+ } else if (columnNum > 50) {
358
+ pageSize = Math.min(pageSize, 500);
359
+ }
253
360
  return pageSize;
254
361
  },
255
362
  hadleMergeExcel(pPageSize) {
363
+ if (this.isExportStopped()) return;
256
364
  let that = this;
257
365
  let title = this.title;
258
366
  let options = this.option;
@@ -290,6 +398,9 @@ function getGrid(that, tableRef) {
290
398
  that
291
399
  .mergeExcel(startPage, endPage)
292
400
  .then(function (resultMsg3) {
401
+ // 取消后组件与进度弹框都已销毁,下面 $refs["exportTable"].insertAt
402
+ // 会直接抛错
403
+ if (that.isExportStopped()) return;
293
404
  if (resultMsg3.type === "success") {
294
405
  mtExcedSuccessNum++;
295
406
  let fileInfo = resultMsg3.objx;
@@ -311,25 +422,17 @@ function getGrid(that, tableRef) {
311
422
  // that.fileRows.push(dataMap);
312
423
  if (mtExcedNum < fileNum) {
313
424
  loopDo();
314
- } else if (
315
- mtExcedNum >= fileNum &&
316
- mtExcedSuccessNum >= fileNum
317
- ) {
318
- setTimeout(function () {
319
- that.percentageNum = parseInt(100);
320
- }, 100);
321
- that.clearExportTimer();
322
425
  } else if (mtExcedSuccessNum >= fileNum) {
323
- setTimeout(function () {
324
- that.percentageNum = parseInt(100);
325
- }, 100);
326
- that.clearExportTimer();
426
+ // 走到 else 分支时 mtExcedNum >= fileNum 必然成立,
427
+ // 原先拆成的两个 else-if 条件与分支体完全相同,合并为一处
428
+ that.completeExport();
327
429
  }
328
430
  } else {
329
431
  that.abortExport();
330
432
  }
331
433
  })
332
434
  .catch(function (e) {
435
+ if (that.isExportStopped()) return;
333
436
  console.error(e);
334
437
  that.abortExport(that.$t1("文件合并异常,导出已中止"));
335
438
  });
@@ -355,14 +458,28 @@ function getGrid(that, tableRef) {
355
458
  let excedNum = 0;
356
459
  let cSize = this.doneSize;
357
460
 
358
- let handleData = function (resultMsg) {
461
+ let handleData = function (resultMsg, flag) {
462
+ // 取消之后到达的在途响应直接丢弃,不再进入上送/合并流程
463
+ if (that.isExportStopped()) return;
359
464
  let pageNumber = resultMsg.objx.pageNumber || 1;
465
+ // 并发窗口在"该页上送完成"后才释放,而不是"该页拉取完成"就释放。
466
+ // addExcelData 此前完全不受闸门约束:上送慢于拉取时 pending 的 POST 会无限
467
+ // 堆积(每个都攥着一份几 MB 的 JSON body),加上浏览器每域名 6 连接会让上送
468
+ // 排到拉取后面,这种反压对调度器完全不可见 —— 大数据量导出的内存曲线因此
469
+ // 始终只涨不落。
470
+ let released = flag !== 1; // 首页(flag=0)不占窗口,无需释放
471
+ let releaseWindow = function () {
472
+ if (released) return;
473
+ released = true;
474
+ if (waitReqNum > 0) waitReqNum--;
475
+ };
360
476
  that
361
477
  .addExcelData({
362
478
  rows: resultMsg.objx.records,
363
479
  pageNumber: pageNumber,
364
480
  })
365
481
  .then(function (resultMsg2) {
482
+ releaseWindow();
366
483
  let data = resultMsg.objx;
367
484
  let total = data.total;
368
485
 
@@ -481,6 +598,8 @@ function getGrid(that, tableRef) {
481
598
  }
482
599
  })
483
600
  .catch(function (e) {
601
+ releaseWindow();
602
+ if (that.isExportStopped()) return;
484
603
  console.error(e);
485
604
  that.abortExport(that.$t1("导出数据写入失败,导出已中止"));
486
605
  });
@@ -489,8 +608,20 @@ function getGrid(that, tableRef) {
489
608
  let waitReqNum = 0;
490
609
  let promise;
491
610
  let loopToDo = function (flag, pageNumber, retryCount = 0) {
611
+ // 取消后不再发起新页,也不再重试(重试是从 .catch 里递归回来的)
612
+ if (that.isExportStopped()) return;
492
613
  let maxWaitNum = limitThreadNum;
493
- let nowDate = options.nowDate;
614
+ // 分页取数的时间快照:createExcelTemplate 返回后存在 this.nowDate(见
615
+ // startProcess),此前这里读的是 options.nowDate —— 那是调用方入参,全库
616
+ // 没有任何调用方维护它,于是每页请求的 nowDate 恒为 undefined、被
617
+ // JSON.stringify 丢掉,后端拿不到快照点。导出期间数据发生增删时,分页会
618
+ // 整体前后错位,合出来的文件既漏行又重行,且全程不报错。
619
+ // 仍保留 options.nowDate 兜底:服务端未返回快照时,调用方若显式指定了一个,
620
+ // 用它总好过不带。
621
+ // 两条取数通道都要带:通用表格随请求体平铺下发;xform 走 exportAjax,快照
622
+ // 顺 aParam 进去后由 data-table-mixin.loadDefaultQueryList 提到信封层(与
623
+ // formCode 同级),不能留在内层 data —— 那是业务查询条件的地盘。
624
+ let nowDate = that.nowDate ?? options.nowDate;
494
625
  if (flag === 1) {
495
626
  /*if (num > that.tTotalPage) {
496
627
  let size = page.records.length;
@@ -508,7 +639,11 @@ function getGrid(that, tableRef) {
508
639
  let url = originOption.exportPath || originOption.path;
509
640
 
510
641
  // let params = originOption.param() || {};
511
- let params = that.queryParam;
642
+ // 每页必须独立参数对象。此前所有页共享 that.queryParam 这一个对象,下面的
643
+ // Object.assign 把 current 写进去后,靠"派发间隔 100ms、微任务已 flush"侥幸
644
+ // 不冲突;请求拦截器一旦变成异步(token 续期等),并发的两页会带上同一个
645
+ // current —— 合并出的文件重复页 + 缺页,且全程不报错。
646
+ let params = that.$baseLodash.cloneDeep(that.queryParam);
512
647
  let aParam = {
513
648
  current: pageNumber,
514
649
  size: pPageSize,
@@ -540,6 +675,7 @@ function getGrid(that, tableRef) {
540
675
  modal: false,
541
676
  queryCreateInfo: originOption.queryCreateInfo,
542
677
  addCreateInfo: originOption.addCreateInfo,
678
+ cancelToken: that.getCancelToken(),
543
679
  });
544
680
  } else {
545
681
  promise = originOption.exportAjax(aParam, options);
@@ -592,9 +728,9 @@ function getGrid(that, tableRef) {
592
728
  clearInterval(that.exportTimer);
593
729
  that.exportTimer = null;
594
730
  }
595
- } else {
596
- if (waitReqNum > 0) waitReqNum--;
597
731
  }
732
+ // 注意:此处不再释放并发窗口。拉取完成只是这一页做完了一半,
733
+ // 窗口由 handleData 在上送(addExcelData)落地后释放,见那里的注释。
598
734
 
599
735
  setTimeout(function () {
600
736
  handleData(resultMsg, flag);
@@ -611,6 +747,8 @@ function getGrid(that, tableRef) {
611
747
  }
612
748
  })
613
749
  .catch(function (e) {
750
+ // 取消导致的 reject 不是失败,也不该重试或弹提示
751
+ if (that.isExportStopped()) return;
614
752
  console.error(e);
615
753
  if (retryCount < 1) {
616
754
  setTimeout(function () {
@@ -641,6 +779,7 @@ function getGrid(that, tableRef) {
641
779
  data: datas,
642
780
  modal: loading || false,
643
781
  isLoading: loading,
782
+ cancelToken: this.getCancelToken(),
644
783
  });
645
784
  },
646
785
  /*getExportTitleJson() {
@@ -742,11 +881,21 @@ function getGrid(that, tableRef) {
742
881
  }
743
882
  return titleArr;
744
883
  },
745
- addExcelData(opts, loading) {
884
+ async addExcelData(opts, loading) {
746
885
  let target = opts.target;
747
886
  let pageNumber = opts.pageNumber || 1;
748
887
  let callback = opts.callback;
749
- let datas = this.getExcelData(opts.rows);
888
+ // 已取消就不要再算、更不要再发。这里是所有上送的唯一入口,卡住它等于卡住
889
+ // 取消之后的全部后续动作。
890
+ if (this.isExportStopped()) {
891
+ return { type: "abort" };
892
+ }
893
+ let datas = await this.getExcelData(opts.rows);
894
+ // 取值过程中导出已被中止(getExcelData 让出主线程时会检查),不再上送这一页。
895
+ // 返回非 success 让调用方走它自己的中止分支,abortExport 幂等不会重复弹窗。
896
+ if (datas === null) {
897
+ return { type: "abort" };
898
+ }
750
899
  let cUUid = this.uuid;
751
900
  let ippaaapp = this.ippaaapp;
752
901
  let url1 =
@@ -766,6 +915,7 @@ function getGrid(that, tableRef) {
766
915
  data: datas,
767
916
  modal: loading || false,
768
917
  isLoading: loading || false,
918
+ cancelToken: this.getCancelToken(),
769
919
  });
770
920
  },
771
921
  mergeExcel(startPage, endPage, loading) {
@@ -780,6 +930,7 @@ function getGrid(that, tableRef) {
780
930
  method: "post",
781
931
  modal: loading || false,
782
932
  isLoading: loading || false,
933
+ cancelToken: this.getCancelToken(),
783
934
  });
784
935
  },
785
936
  createCountTimer() {
@@ -797,13 +948,32 @@ function getGrid(that, tableRef) {
797
948
  this.exportTimer = null;
798
949
  this.countTimer = null;
799
950
  },
951
+ /**
952
+ * 导出全部文件合并成功:进度置满、停掉计时,并**立刻解开页签锁**。
953
+ * 锁只保护取数期间。文件已经生成之后弹框还开着(用户在看列表、点下载)是完全
954
+ * 合法的状态,那时页签必须能正常关闭 —— 解锁此前只挂在 beforeDestroy 上,
955
+ * 用户不点「取消/确定」就永远解不开,正是「导出完成后页签关不掉」的来源。
956
+ */
957
+ completeExport() {
958
+ setTimeout(() => {
959
+ if (this.isExportStopped()) return;
960
+ this.percentageNum = parseInt(100);
961
+ }, 100);
962
+ this.clearExportTimer();
963
+ this.unlockMenu();
964
+ },
800
965
  /**
801
966
  * 中止导出:停止全部定时器并把进度条置为错误态。
802
967
  * @param {String} [msg] 需要额外弹给用户的提示;服务端已 toast 过的失败不传,避免重复提示。
803
968
  */
804
969
  abortExport(msg) {
805
970
  this.clearExportTimer();
971
+ // 必须先置标志再取消:取消会让其余在途请求 reject,它们的 .catch 先问
972
+ // isExportStopped() 才不会重复弹提示
806
973
  this.exportError = true;
974
+ this.cancelPendingRequests();
975
+ // 中止后弹框可能还挂在那儿(错误态进度条),页签没有理由继续锁着
976
+ this.unlockMenu();
807
977
  if (msg) this.$baseAlert(msg);
808
978
  },
809
979
  isPicture: function (suffix) {
@@ -858,7 +1028,7 @@ function getGrid(that, tableRef) {
858
1028
  }
859
1029
  return getCellValue(params);
860
1030
  },
861
- getExcelData(rows) {
1031
+ async getExcelData(rows) {
862
1032
  let that = this;
863
1033
 
864
1034
  let showImageAtTable = this.option.showImageAtTable || false;
@@ -875,8 +1045,19 @@ function getGrid(that, tableRef) {
875
1045
  let contents;
876
1046
  let params;
877
1047
  let resultStr;
878
- // 批量取值期间复用 vNode 渲染的临时实例,结束时统一销毁
879
- rows.forEach(function (row, index) {
1048
+ let index = -1;
1049
+ for (const row of rows) {
1050
+ index++;
1051
+ // 每 EXPORT_YIELD_ROWS 行让出一次主线程。取值本身是同步的双层遍历,一页
1052
+ // 默认 1000 行、渲染兜底列还要逐单元格挂载组件,整页跑完可达数秒;并发几页
1053
+ // 几乎同时到达时主线程被连续占死,进度条不动、「取消」点不动,浏览器还可能
1054
+ // 直接判定页面无响应而杀掉标签页。让出不减少总工作量,只保证 UI 活着。
1055
+ if (index > 0 && index % EXPORT_YIELD_ROWS === 0) {
1056
+ await yieldToBrowser();
1057
+ // 让出期间用户可能已取消/导出已中止,此时继续算完这一页没有意义,
1058
+ // 且合并根本不会发生,返回 null 让调用方跳过上送。
1059
+ if (that.isExportStopped()) return null;
1060
+ }
880
1061
  contents = [];
881
1062
  columns.forEach((column) => {
882
1063
  if (column.title) {
@@ -897,6 +1078,14 @@ function getGrid(that, tableRef) {
897
1078
  : null;
898
1079
  if (exportVal) {
899
1080
  resultStr = exportVal(params);
1081
+ } else if (column.params?.exportBlank && !exportType) {
1082
+ // 列保留、格留空:没配导出类型的附件列没有可写的内容,更不该为此
1083
+ // 逐单元格挂载附件组件去爬文件名。判定见 xform 的 isBlankExportColumn。
1084
+ // 放在 exportVal 之后:调用方显式写了取值函数就以它为准。
1085
+ // 必须再判一次 exportType:exportBlank 是建列时按 t.exportType 算死的,
1086
+ // 而 params.exportType 之后还会被 tableColumnConfig 脚本覆盖,只认
1087
+ // exportBlank 会把运行期才拿到导出类型的图片列错误地导成空。
1088
+ resultStr = null;
900
1089
  } else if (exportType === "Number") {
901
1090
  resultStr = that.getExportCellValue(params);
902
1091
  if (
@@ -911,7 +1100,10 @@ function getGrid(that, tableRef) {
911
1100
  exportType === "Image" ||
912
1101
  (showImageAtTable && exportType === "Image2")
913
1102
  ) {
914
- resultStr = that.getExportCellValue(params);
1103
+ // 取值刻意延到"确实用得上"的那条分支才做:附件的正常形态是数组,
1104
+ // 那条分支会用 row[column.field] 自己拼 [EXPIMG],把取值结果整个覆盖;
1105
+ // 空值分支同样直接给 null。而附件列没有 filterVal,取值会落到
1106
+ // getCellValue 的 vNode 挂载兜底——等于每个单元格白挂一次附件组件。
915
1107
  let attachments = row[column.field];
916
1108
  if (attachments) {
917
1109
  if (Array.isArray(attachments)) {
@@ -931,6 +1123,8 @@ function getGrid(that, tableRef) {
931
1123
  resultStr = null;
932
1124
  }
933
1125
  } else {
1126
+ // 单值形态(字段里直接存 URL)才需要取值来嗅后缀
1127
+ resultStr = that.getExportCellValue(params);
934
1128
  if (
935
1129
  that.isPicture(
936
1130
  that.$commonFileUtil.getFileSuffix(resultStr)
@@ -959,10 +1153,35 @@ function getGrid(that, tableRef) {
959
1153
  }
960
1154
  });
961
1155
  arr.push(contents);
962
- });
1156
+ }
963
1157
  return arr;
964
1158
  },
1159
+ /**
1160
+ * 从响应头解析下载文件名,取不到时用导出标题兜底。
1161
+ * @param {Object} response axios 原始响应。
1162
+ * @param {String} fallbackName 兜底文件名(不含扩展名)。
1163
+ * @returns {String}
1164
+ */
1165
+ resolveDownloadFileName(response, fallbackName) {
1166
+ let headers = response?.headers || {};
1167
+ let disposition = headers["content-disposition"] || headers["Content-Disposition"];
1168
+ if (disposition) {
1169
+ // filename*=UTF-8''xxx 优先,其次 filename="xxx"
1170
+ let starMatch = /filename\*=(?:UTF-8'')?([^;]+)/i.exec(disposition);
1171
+ let plainMatch = /filename="?([^";]+)"?/i.exec(disposition);
1172
+ let raw = (starMatch && starMatch[1]) || (plainMatch && plainMatch[1]);
1173
+ if (raw) {
1174
+ try {
1175
+ return decodeURIComponent(raw.trim());
1176
+ } catch (e) {
1177
+ return raw.trim();
1178
+ }
1179
+ }
1180
+ }
1181
+ return fallbackName + ".xlsx";
1182
+ },
965
1183
  exportAll(row) {
1184
+ let that = this;
966
1185
  let cUUid = encodeURIComponent(row.uuid);
967
1186
  let ippaaapp = this.ippaaapp;
968
1187
  let fileName = row.title;
@@ -1006,7 +1225,63 @@ function getGrid(that, tableRef) {
1006
1225
  document.body.removeChild(form);
1007
1226
  }
1008
1227
 
1009
- downLoadFile2();
1228
+ /**
1229
+ * 优先走 axios blob 下载:token 由 request.js 的请求拦截器注入
1230
+ * X-Token / Authorization 头,不必再把 access_token 塞进表单字段随请求体
1231
+ * 明文提交;失败时也能拿到响应体做判断,而表单 POST 打开新窗口后失败只会给
1232
+ * 用户看一个空白页或一坨错误 JSON。
1233
+ *
1234
+ * 后端若尚未放开该接口的头鉴权(此前只认表单里的 access_token),这里会拿到
1235
+ * JSON 错误体或直接报错 —— 一律回退到原来的表单 POST,保证下载不会因为这次
1236
+ * 改造而失效。同体系的 /export_ins/download 已经是 blob + 头鉴权的用法
1237
+ * (见 components/baseInputExport/mixins.js),大概率是通的,但**需联调确认**。
1238
+ */
1239
+ let fallbackToFormPost = function (reason) {
1240
+ console.warn("[excelExport] blob 下载不可用,回退表单 POST:", reason);
1241
+ downLoadFile2();
1242
+ };
1243
+
1244
+ let apiUrl = `${this.CURRENT_PREFIX}/excel/download?uuid=${cUUid}&ippaaapp=${ippaaapp}&n=${encodeURIComponent(fileName)}`;
1245
+
1246
+ this.$http({
1247
+ url: apiUrl,
1248
+ method: "post",
1249
+ data: {},
1250
+ responseType: "blob",
1251
+ resultType: "other",
1252
+ modal: false,
1253
+ // 失败要静默回退,不能先弹一个错误框
1254
+ errorMsg: false,
1255
+ // 刻意**不带**本次导出的取消令牌。下载不是导出流水线的一环,而是用户单独
1256
+ // 发起的动作:分卷文件大时点完「下载」要等几秒,此时关掉进度弹框(「取消」
1257
+ // 和「确定」都走 dialogClose2 → cancelPendingRequests)会掐断这个请求,而
1258
+ // 下面的 isCancel 分支又刻意不回退表单 POST —— 结果是文件静默地下不下来,
1259
+ // 且没有任何提示。旧的表单 POST 脱离组件生命周期,本就不受关弹框影响。
1260
+ callback: (res, response) => {
1261
+ // 后端把错误当 200 返回时,body 是 JSON / HTML 错误页而不是 xlsx 流
1262
+ if (!isDownloadableBlob(res)) {
1263
+ fallbackToFormPost("响应不是文件流:" + (res && res.type));
1264
+ return;
1265
+ }
1266
+ let blobUrl = window.URL.createObjectURL(res);
1267
+ let a = document.createElement("a");
1268
+ a.href = blobUrl;
1269
+ a.download = that.resolveDownloadFileName(response, fileName);
1270
+ document.body.appendChild(a);
1271
+ a.click();
1272
+ document.body.removeChild(a);
1273
+ // 不释放会一直占着这份文件的内存,大导出下很可观
1274
+ setTimeout(() => window.URL.revokeObjectURL(blobUrl), 0);
1275
+ },
1276
+ }).catch((e) => {
1277
+ // 只挡真正的取消:取消后回退表单 POST 会在取消之后又弹出一次下载。
1278
+ // 本方法已不带取消令牌,这里是防御性保留(组件销毁等外部因素仍可能取消)。
1279
+ // 这里**不能**用 isExportStopped()——它含 exportError,而 exportAll 同时是
1280
+ // 进度弹框里的手动下载按钮:导出中途失败后用户手动下载已生成的分卷,正是最
1281
+ // 需要回退兜底的场景,用它拦会变成点了毫无反应。
1282
+ if (axios.isCancel(e)) return;
1283
+ fallbackToFormPost(e && e.message);
1284
+ });
1010
1285
  },
1011
1286
  startProcess2(datas) {
1012
1287
  this.tableTarget = getGrid(this.option.vue, this.option.targetRef);
@@ -1035,20 +1310,49 @@ function getGrid(that, tableRef) {
1035
1310
  }
1036
1311
  this.CURRENT_PREFIX = options.prefix;
1037
1312
 
1038
- let handleData = function () {
1039
- let total = datas.length;
1040
- that
1313
+ // 勾选/当前页导出此前把全部行塞进**一个**请求上送。`getCheckboxRecords(true)`
1314
+ // 是跨页全量勾选,选得多时请求体轻易超限(后端/网关直接拒),而且整批取值转换
1315
+ // 与 JSON 序列化一次性压在主线程上。改为复用条件导出那套「分页 append + 一次
1316
+ // merge」:按 getExportPageSize() 切片顺序上送。
1317
+ // 注意页数 ≤ 1 时(勾选量不超过一页,即绝大多数场景)请求序列与旧实现完全一致,
1318
+ // 不引入任何行为变化;这里刻意保持**串行**,避免把条件导出那边"上送无并发闸门"
1319
+ // 的老问题原样复制过来。
1320
+ let pageSize = this.getExportPageSize();
1321
+ let totalPages = Math.max(Math.ceil(datas.length / pageSize), 1);
1322
+
1323
+ let appendPage = function (pageNumber) {
1324
+ let start = (pageNumber - 1) * pageSize;
1325
+ return that
1041
1326
  .addExcelData(
1042
1327
  {
1043
- rows: datas,
1044
- pageNumber: 1,
1328
+ rows: datas.slice(start, start + pageSize),
1329
+ pageNumber: pageNumber,
1045
1330
  },
1046
1331
  true
1047
1332
  )
1048
1333
  .then((resultMsg2) => {
1049
- if (resultMsg2.type === "success") {
1050
- setTimeout(() => {
1051
- that.mergeExcel(1, 1, true).then((resultMsg3) => {
1334
+ if (resultMsg2.type !== "success") {
1335
+ // "abort" 是取值过程中组件已销毁/已中止,无需再提示;
1336
+ // 其余失败服务端已 toast 过,abortExport 不带 msg 避免重复弹
1337
+ if (resultMsg2.type !== "abort") that.abortExport();
1338
+ return false;
1339
+ }
1340
+ if (pageNumber >= totalPages) return true;
1341
+ return appendPage(pageNumber + 1);
1342
+ });
1343
+ };
1344
+
1345
+ let handleData = function () {
1346
+ appendPage(1)
1347
+ .then((completed) => {
1348
+ // 缺页合并会静默丢行,任一页失败都必须停在这里
1349
+ if (!completed) return;
1350
+ setTimeout(() => {
1351
+ that
1352
+ .mergeExcel(1, totalPages, true)
1353
+ .then((resultMsg3) => {
1354
+ // 取消后不能再自动弹下载:那会是一个半截文件
1355
+ if (that.isExportStopped()) return;
1052
1356
  if (resultMsg3.type === "success") {
1053
1357
  let fileInfo = resultMsg3.objx;
1054
1358
  let fileName = fileInfo.fileName;
@@ -1056,28 +1360,49 @@ function getGrid(that, tableRef) {
1056
1360
 
1057
1361
  let dataMap = {};
1058
1362
  dataMap["startPage"] = 1;
1059
- dataMap["endPage"] = 1;
1363
+ dataMap["endPage"] = totalPages;
1060
1364
  dataMap["title"] = title;
1061
1365
  dataMap["uuid"] = fileName;
1062
1366
  dataMap["fileSize"] = fileSize;
1063
1367
  that.exportAll(dataMap);
1368
+ } else {
1369
+ that.abortExport();
1064
1370
  }
1371
+ })
1372
+ .catch((e) => {
1373
+ if (that.isExportStopped()) return;
1374
+ console.error(e);
1375
+ that.abortExport(that.$t1("文件合并异常,导出已中止"));
1065
1376
  });
1066
- }, 0);
1067
- }
1377
+ }, 0);
1378
+ })
1379
+ .catch((e) => {
1380
+ if (that.isExportStopped()) return;
1381
+ console.error(e);
1382
+ that.abortExport(that.$t1("导出数据写入失败,导出已中止"));
1068
1383
  });
1069
1384
  };
1070
- that.createExcelFile(true).then((resultMsg) => {
1071
- if (resultMsg.type === "success") {
1072
- let aObj = resultMsg.objx;
1073
- that.uuid = aObj.uuid;
1074
- that.nowDate = aObj.nowDate;
1075
- that.ippaaapp = aObj.ippaaapp;
1076
- setTimeout(() => {
1077
- handleData();
1078
- }, 0);
1079
- }
1080
- });
1385
+ that
1386
+ .createExcelFile(true)
1387
+ .then((resultMsg) => {
1388
+ if (that.isExportStopped()) return;
1389
+ if (resultMsg.type === "success") {
1390
+ let aObj = resultMsg.objx;
1391
+ that.uuid = aObj.uuid;
1392
+ that.nowDate = aObj.nowDate;
1393
+ that.ippaaapp = aObj.ippaaapp;
1394
+ setTimeout(() => {
1395
+ handleData();
1396
+ }, 0);
1397
+ } else {
1398
+ that.abortExport();
1399
+ }
1400
+ })
1401
+ .catch((error) => {
1402
+ if (that.isExportStopped()) return;
1403
+ console.error(error);
1404
+ that.abortExport(that.$t1("创建导出文件失败,导出已中止"));
1405
+ });
1081
1406
  },
1082
1407
  closeExportFieldDialog() {
1083
1408
  this.dialogClose2();
@@ -1122,29 +1447,42 @@ function getGrid(that, tableRef) {
1122
1447
  }
1123
1448
  });
1124
1449
  },
1450
+ /**
1451
+ * 导出期间锁住当前页签(不让关),并记下锁的是哪一个,见 lockedView。
1452
+ * 本就是固定页签(affix)的不接管 —— 那不是我们加的锁,解的时候也不能动它。
1453
+ *
1454
+ * 只写 user_affix,**绝不能再写 meta.affix**:isRepeatable() 见到 affix 就返回
1455
+ * false,可重复打开的页签的 viewKey 会从 fullPath 塌成 path,于是 AppMain 的
1456
+ * router-view key 与 BaseKeepAlive 的缓存名同时错位 —— 切走再切回整页重建。
1457
+ * 「不让批量关闭」的语义由 tagsView 的 isPinned 认 user_affix 兜住。
1458
+ *
1459
+ * 定位页签用 viewKey 而不是 path:可重复打开时同一 path 会有多个页签,
1460
+ * 按 path 找到的可能是另一个实例。
1461
+ */
1125
1462
  lockMenu() {
1126
1463
  let vueTarget = window.$vueRoot;
1127
- let path = vueTarget.$route.path;
1464
+ let key = viewKey(vueTarget.$route);
1128
1465
  let visitedViews = vueTarget.$store.state.tagsView.visitedViews;
1129
- if (visitedViews) {
1130
- let currentItem = visitedViews.find((item) => item.path === path);
1131
- if (currentItem && !currentItem.meta.affix) {
1132
- this.$set(currentItem.meta, "affix", true);
1133
- this.$set(currentItem.meta, "user_affix", true);
1134
- }
1466
+ if (!visitedViews) return;
1467
+ let currentItem = visitedViews.find((item) => viewKey(item) === key);
1468
+ if (currentItem && !currentItem.meta.affix) {
1469
+ this.$set(currentItem.meta, "user_affix", true);
1470
+ this.lockedView = currentItem;
1135
1471
  }
1136
1472
  },
1473
+ /**
1474
+ * 解开自己锁的那一个页签。
1475
+ * **不能**再按当前路由去查:解锁的时机是组件销毁(取消、或下一次导出把上一个
1476
+ * 实例顶掉),那时的当前路由未必还是上锁时那一个。典型漏锁路径:A 页开始条件
1477
+ * 导出 → 切到 B 页 → 在 B 页再点一次导出,initInstance 先销毁旧实例,旧实例按
1478
+ * $route.path 查到的是 B(B 还没锁,什么也没做),于是 A 永久锁死。
1479
+ * 页签可能已被别处删掉,此时改的是一个游离对象,无副作用。
1480
+ */
1137
1481
  unlockMenu() {
1138
- let vueTarget = window.$vueRoot;
1139
- let path = vueTarget.$route.path;
1140
- let visitedViews = vueTarget.$store.state.tagsView.visitedViews;
1141
- if (visitedViews) {
1142
- let currentItem = visitedViews.find((item) => item.path === path);
1143
- if (currentItem && currentItem.meta.user_affix) {
1144
- currentItem.meta.affix = false;
1145
- currentItem.meta.user_affix = false;
1146
- }
1147
- }
1482
+ let view = this.lockedView;
1483
+ this.lockedView = null;
1484
+ if (!view || !view.meta.user_affix) return;
1485
+ view.meta.user_affix = false;
1148
1486
  },
1149
1487
  },
1150
1488
  };