cloud-web-corejs 1.0.54-dev.805 → 1.0.54-dev.807

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,7 +1,7 @@
1
1
  {
2
2
  "name": "cloud-web-corejs",
3
3
  "private": false,
4
- "version": "1.0.54-dev.805",
4
+ "version": "1.0.54-dev.807",
5
5
  "scripts": {
6
6
  "dev": "vue-cli-service serve",
7
7
  "dev:micro": "vue-cli-service serve --port 17527",
@@ -7,6 +7,7 @@ import {
7
7
  getQueryParam,
8
8
  traverseAllWidgets,
9
9
  traverseAllWidgetsNew,
10
+ flattenLeafColumns,
10
11
  } from "../../../components/xform/utils/util";
11
12
  import {
12
13
  MOCK_CASE_URL,
@@ -561,9 +562,12 @@ modules = {
561
562
  widget.tableField &&
562
563
  (widget.type === "data-table" || widget.type === "list-h5")
563
564
  ) {
564
- let vailColumns = widget.options.tableColumns.filter(
565
- (item) => item.prop && item.label
566
- );
565
+ // form-render/indexMixin 的 getFormTemplateTableDTOs 同口径:
566
+ // 分组表头自身 prop 为空,业务字段挂在 children 上,必须先展平叶子列,
567
+ // 否则「列全部分组」的表格算不出字段,子表不会写进模板表结构。
568
+ let vailColumns = flattenLeafColumns(
569
+ widget.options.tableColumns
570
+ ).filter((item) => item.prop && item.label);
567
571
  let itemFields = [];
568
572
  vailColumns.forEach((item) => {
569
573
  if (item.formatS === "editSearch") {
@@ -25,6 +25,7 @@ import {
25
25
  traverseAllWidgetsNew,
26
26
  columnFormatMap,
27
27
  getColumnWidgetOptions,
28
+ flattenLeafColumns,
28
29
  applyColumnMetaToFieldWidget,
29
30
  applyColumnMetaToColumnRow,
30
31
  cloneColumnFieldWidget,
@@ -3888,9 +3889,12 @@ modules = {
3888
3889
  if (widget.type === "data-table" || widget.type === "list-h5") {
3889
3890
  let isTreeTable = widget.options.isTreeTable || false;
3890
3891
  if (submitFlag) {
3891
- let vailColumns = widget.options.tableColumns.filter(
3892
- (item) => item.prop && item.label
3893
- );
3892
+ // 必须先展平叶子列:分组表头自身 prop 为空,业务字段全挂在 children 上。
3893
+ // 只过滤顶层时,「列全部分组」的表格会算出 0 个字段,被下面的
3894
+ // itemFields.length 判空拦掉,整张子表不进 DTO,保存报文里直接没有这张表。
3895
+ let vailColumns = flattenLeafColumns(
3896
+ widget.options.tableColumns
3897
+ ).filter((item) => item.prop && item.label);
3894
3898
  let itemFields = [];
3895
3899
  vailColumns.forEach((item) => {
3896
3900
  if (item.formatS === "editSearch") {
@@ -878,6 +878,29 @@ export function cloneColumnFieldWidget(fieldWidget) {
878
878
  return newWidget;
879
879
  }
880
880
 
881
+ /**
882
+ * 展平数据表格列配置,只返回叶子列。
883
+ * tableColumns 里带 children 的是分组表头(自身 prop 为空),真正的业务字段挂在 children 上,
884
+ * 因此凡是按 prop 取字段的逻辑都必须先展平,不能只看顶层。
885
+ */
886
+ export function flattenLeafColumns(columns) {
887
+ let result = [];
888
+ let loopDo = (cols) => {
889
+ (cols || []).forEach((item) => {
890
+ if (!item) {
891
+ return;
892
+ }
893
+ if (item.children && item.children.length) {
894
+ loopDo(item.children);
895
+ } else {
896
+ result.push(item);
897
+ }
898
+ });
899
+ };
900
+ loopDo(columns);
901
+ return result;
902
+ }
903
+
881
904
  /** 将列级 label / keyName / required 同步到列内 field widget(动态列 label 在列顶层) */
882
905
  export function applyColumnMetaToFieldWidget(fieldWidget, row, isEdit = false) {
883
906
  if (!fieldWidget || !row) {
@@ -1,71 +1,301 @@
1
- import html2canvas from "html2canvas";
2
- import jspdf from "jspdf";
3
- export async function exportPDF(option) {
4
- const dom = option.dom;
5
- let fileName = option.fileName;
6
- if (!dom) return
7
- let height = "";
8
- // 获取dom元素
9
- let dom1 = dom.querySelector(".d-cont");
10
- try {
11
- if (dom1) {
12
- height = dom1.style.height;
13
- dom1.style.height = "auto";
14
- }
15
-
16
- if (dom) {
17
- html2canvas(dom).then(async (canvas) => {
18
- // A4纸,纵向
19
- let pdf = new jspdf("p", "mm", "a4");
20
- let ctx = canvas.getContext("2d");
21
- let a4w = 190;
22
- // A4大小,210mm x 297mm,四边各保留10mm的边距,显示区域190x277
23
- let a4h = 277;
24
- // 按A4显示比例换算一页图像的像素高度
25
- let imgHeight = Math.floor((a4h * canvas.width) / a4w);
26
- let renderedHeight = 0;
27
- while (renderedHeight < canvas.height) {
28
- let page = document.createElement("canvas");
29
- page.width = canvas.width;
30
- // 可能内容不足一页
31
- page.height = Math.min(imgHeight, canvas.height - renderedHeight);
32
- // 用getImageData剪裁指定区域,并画到前面创建的canvas对象中
33
- page
34
- .getContext("2d")
35
- .putImageData(
36
- ctx.getImageData(
37
- 0,
38
- renderedHeight,
39
- canvas.width,
40
- Math.min(imgHeight, canvas.height - renderedHeight)
41
- ),
42
- 0,
43
- 0
44
- );
45
- // 添加图像到页面,保留10mm边距
46
- pdf.addImage(
47
- page.toDataURL("image/jpeg", 1.0),
48
- "JPEG",
49
- 10,
50
- 10,
51
- a4w,
52
- Math.min(a4h, (a4w * page.height) / page.width)
53
- );
54
-
55
- renderedHeight += imgHeight;
56
- if (renderedHeight < canvas.height) {
57
- // 如果后面还有内容,添加一个空页
58
- pdf.addPage();
59
- }
60
- // delete page;
61
- }
62
- // 保存文件
63
- pdf.save(`${fileName}.pdf`);
64
- });
65
- }
66
- } catch (err) {
67
- console.log(err);
68
- } finally {
69
- if (dom1) dom1.style.height = height;
70
- }
1
+ import html2canvas from "html2canvas";
2
+ import jspdf from "jspdf";
3
+ import { Message } from "element-ui";
4
+
5
+ //A4 纵向 210mm x 297mm,四边各留 10mm 边距,正文区 190 x 277
6
+ const PAGE_WIDTH = 190;
7
+ const PAGE_HEIGHT = 277;
8
+ const PAGE_MARGIN = 10;
9
+
10
+ //浏览器对 canvas 有硬限制(Chrome:单边 16384px、总面积 2^28 像素),
11
+ //超过之后 toDataURL 直接返回空白图——长详情页很容易撞上,这里按内容尺寸回退 scale。
12
+ const MAX_CANVAS_SIDE = 16384;
13
+ const MAX_CANVAS_AREA = 268435456;
14
+
15
+ const JPEG_QUALITY = 0.92;
16
+
17
+ const OVERFLOW_CLIP = /auto|scroll|hidden|overlay/;
18
+
19
+ //遮罩挂在导出根元素的父级上,正常不会进截图范围;这条是兜底,
20
+ //防止根元素本身就没有父级(游离节点)时遮罩落进截图里
21
+ const LOADING_MASK = ".el-loading-mask";
22
+
23
+ function nextFrame() {
24
+ //改完内联样式要等浏览器重排完再截图,一帧不够(vxe-table 的 resize 监听在下一帧才补高度)
25
+ return new Promise((resolve) => {
26
+ requestAnimationFrame(() => requestAnimationFrame(resolve));
27
+ });
28
+ }
29
+
30
+ //纵向被裁掉内容的容器:详情页的 .d-cont、vxe-table 的 body-wrapper/fixed-wrapper 都属于这一类。
31
+ //只放开纵向:横向滚动的宽表放开也没用——截图区域按根元素的宽度取,
32
+ //溢出到根元素外面的列照样画不进去,反而会把版面撑乱。
33
+ function isVerticallyClipped(el, style) {
34
+ return (
35
+ OVERFLOW_CLIP.test(style.overflowY) && el.scrollHeight > el.clientHeight + 1
36
+ );
37
+ }
38
+
39
+ //把 dom 自身、它的祖先、以及内部所有带纵向滚动/裁剪的容器统一放开高度,
40
+ //返回一个还原函数。原实现只处理了 .d-cont 一层,且在 html2canvas 真正克隆 DOM 之前
41
+ //就把高度还原了(没 await),所以放开高度这一步等于没做,导出的永远只有可视区那一屏。
42
+ export function expandForCapture(dom) {
43
+ //读写严格分成两趟:先只读地挑出要放开的元素,再统一写内联样式。
44
+ //边读 getComputedStyle/scrollHeight 边写样式,会让浏览器每个元素强制重排一次
45
+ //(layout thrashing)——详情页几千个节点就是几千次整页重排,点完按钮会明显卡住。
46
+ const targets = [];
47
+
48
+ //祖先:#containt / .app-main 这类布局层用 100vh、calc(100vh - n) 定死了高度,
49
+ //不放开的话 dom 自己 height:auto 也会被父级的滚动容器裁掉。
50
+ let parent = dom.parentElement;
51
+ while (parent && parent !== document.body && parent !== document.documentElement) {
52
+ const style = window.getComputedStyle(parent);
53
+ if (isVerticallyClipped(parent, style) || style.height !== "auto") {
54
+ targets.push(parent);
55
+ }
56
+ parent = parent.parentElement;
57
+ }
58
+
59
+ targets.push(dom);
60
+
61
+ const children = dom.querySelectorAll("*");
62
+ for (let i = 0; i < children.length; i++) {
63
+ const el = children[i];
64
+ const style = window.getComputedStyle(el);
65
+ if (isVerticallyClipped(el, style)) targets.push(el);
66
+ }
67
+
68
+ //读:scrollTop 也是会触发重排的读操作,必须留在这一趟里
69
+ const records = targets.map((el) => ({
70
+ el: el,
71
+ style: el.getAttribute("style"),
72
+ scrollTop: el.scrollTop,
73
+ }));
74
+
75
+ //写
76
+ for (let i = 0; i < records.length; i++) {
77
+ const el = records[i].el;
78
+ el.style.height = "auto";
79
+ el.style.maxHeight = "none";
80
+ el.style.overflow = "visible";
81
+ }
82
+
83
+ return function restore() {
84
+ //倒序还原,先还原内层再还原外层,避免中间态触发多余的重排
85
+ for (let i = records.length - 1; i >= 0; i--) {
86
+ const record = records[i];
87
+ if (record.style === null) {
88
+ record.el.removeAttribute("style");
89
+ } else {
90
+ record.el.setAttribute("style", record.style);
91
+ }
92
+ record.el.scrollTop = record.scrollTop;
93
+ }
94
+ };
95
+ }
96
+
97
+ function pickScale(width, height, expected) {
98
+ let scale = expected || Math.min(window.devicePixelRatio || 1, 2);
99
+ if (!width || !height) return scale;
100
+ scale = Math.min(
101
+ scale,
102
+ MAX_CANVAS_SIDE / width,
103
+ MAX_CANVAS_SIDE / height,
104
+ Math.sqrt(MAX_CANVAS_AREA / (width * height))
105
+ );
106
+ return Math.max(scale, 1);
107
+ }
108
+
109
+ function buildIgnore(selectors) {
110
+ let list = [LOADING_MASK];
111
+ if (selectors) list = list.concat(selectors);
112
+ const selector = list.join(",");
113
+ return function (el) {
114
+ return typeof el.matches === "function" && el.matches(selector);
115
+ };
116
+ }
117
+
118
+ //手写一层 element 样式的遮罩,而不是用 Loading.service,两个原因都躲不掉:
119
+ //1. `Loading.service({ fullscreen: true })` 是全局单例,会被 request.js 里的请求收尾顺手关掉,
120
+ // 而导出这几秒里页面上别的请求随时可能回来;
121
+ //2. `Loading.service({ target: dom })` 会给 dom 加 el-loading-parent--relative(position:relative !important),
122
+ // 页面里 .slide-nav 这类绝对定位元素的包含块跟着变,位置会挪——而且是连截进 PDF 里的。
123
+ //自己建的 DOM 节点两头都不沾,也不用担心被别处关掉。
124
+ //挂载点取根元素的父级,不挂 body:微前端下 body 是主应用的,往上加节点算越界;
125
+ //挂父级也让这个节点跟着页面走,页面被销毁时不会剩一层孤儿遮罩。
126
+ //只遮导出的那块区域,不铺满视口:左侧菜单、页签这些跟导出无关的地方不该被压住。
127
+ //做法是 fixed + 按导出元素的 getBoundingClientRect 圈定范围(取展开之前的可视位置),
128
+ //这样既不用给挂载点加 position:relative(会挪走 .slide-nav 这类绝对定位元素,
129
+ //也正是不能用 Loading.service({ target }) 的原因),也不会盖到别人头上。
130
+ //为什么不挂根元素自己:加子节点会改变根元素下 :last-child 的匹配,
131
+ //而那种样式差异是会被截进 PDF 的;挂在父级上则完全在截图范围之外。
132
+ function openLoading(dom, option) {
133
+ if (option.loading === false) return null;
134
+ const host = dom.parentElement || dom;
135
+ //必须在 expandForCapture 之前量:展开之后元素会被撑到完整内容高度,量出来是没用的
136
+ const rect = dom.getBoundingClientRect();
137
+ const top = Math.max(rect.top, 0);
138
+ const left = Math.max(rect.left, 0);
139
+ const height = Math.min(rect.bottom, window.innerHeight) - top;
140
+ const width = Math.min(rect.right, window.innerWidth) - left;
141
+ const mask = document.createElement("div");
142
+ mask.className = "el-loading-mask";
143
+ //.el-loading-mask 自带 top/right/bottom/left:0,right/bottom 要显式清掉,
144
+ //否则宽高会被拉回视口边缘,又变成全屏
145
+ mask.style.position = "fixed";
146
+ mask.style.top = top + "px";
147
+ mask.style.left = left + "px";
148
+ mask.style.right = "auto";
149
+ mask.style.bottom = "auto";
150
+ mask.style.width = width + "px";
151
+ mask.style.height = height + "px";
152
+ mask.innerHTML = [
153
+ '<div class="el-loading-spinner">',
154
+ '<svg class="circular" viewBox="25 25 50 50">',
155
+ '<circle class="path" cx="50" cy="50" r="20" fill="none"></circle>',
156
+ "</svg>",
157
+ '<p class="el-loading-text"></p>',
158
+ "</div>",
159
+ ].join("");
160
+ const label = mask.querySelector(".el-loading-text");
161
+ label.textContent = option.loadingText || "正在生成PDF,请稍候…";
162
+ host.appendChild(mask);
163
+ return {
164
+ //分阶段换文案:截图那段主线程是整块占住的,转圈会定住,
165
+ //有个往前走的文案才看得出是在干活而不是卡死。调用方自带文案时不覆盖。
166
+ setText(next) {
167
+ if (!option.loadingText) label.textContent = next;
168
+ },
169
+ close() {
170
+ if (mask.parentNode) mask.parentNode.removeChild(mask);
171
+ },
172
+ };
173
+ }
174
+
175
+ //toDataURL 是同步编码,一整页 JPEG 能顶住主线程几百毫秒;
176
+ //toBlob 是异步的,浏览器可以把编码挪到主线程之外,转圈动画就不会整段定住。
177
+ //jsdom 和老浏览器没有 toBlob,退回同步版本。
178
+ function encodeJpeg(canvas) {
179
+ return new Promise((resolve) => {
180
+ const sync = () => resolve(canvas.toDataURL("image/jpeg", JPEG_QUALITY));
181
+ if (typeof canvas.toBlob !== "function") return sync();
182
+ canvas.toBlob(
183
+ (blob) => {
184
+ if (!blob) return sync();
185
+ const reader = new FileReader();
186
+ reader.onload = () => resolve(reader.result);
187
+ reader.onerror = sync;
188
+ reader.readAsDataURL(blob);
189
+ },
190
+ "image/jpeg",
191
+ JPEG_QUALITY
192
+ );
193
+ });
194
+ }
195
+
196
+ async function canvasToPdf(canvas, fileName, loading) {
197
+ const pdf = new jspdf("p", "mm", "a4");
198
+ //按 A4 正文区的宽高比换算一页对应的像素高度
199
+ const pageImgHeight = Math.floor((PAGE_HEIGHT * canvas.width) / PAGE_WIDTH);
200
+ const total = Math.ceil(canvas.height / pageImgHeight);
201
+ let page$index = 0;
202
+ let rendered = 0;
203
+ while (rendered < canvas.height) {
204
+ page$index++;
205
+ if (loading) loading.setText(`正在生成第 ${page$index}/${total} 页…`);
206
+ //每页之前让出一帧:切图+编码是大块同步活,不让出的话文案和转圈都刷不出来
207
+ await nextFrame();
208
+ const sliceHeight = Math.min(pageImgHeight, canvas.height - rendered);
209
+ const page = document.createElement("canvas");
210
+ page.width = canvas.width;
211
+ page.height = sliceHeight;
212
+ const ctx = page.getContext("2d");
213
+ //JPEG 没有透明通道,不先铺白底的话,页面上透明的地方会变成黑块
214
+ ctx.fillStyle = "#ffffff";
215
+ ctx.fillRect(0, 0, page.width, page.height);
216
+ //用 drawImage 而不是 getImageData/putImageData:后者逐像素拷贝,长页面上很慢
217
+ ctx.drawImage(
218
+ canvas,
219
+ 0,
220
+ rendered,
221
+ canvas.width,
222
+ sliceHeight,
223
+ 0,
224
+ 0,
225
+ canvas.width,
226
+ sliceHeight
227
+ );
228
+ const dataUrl = await encodeJpeg(page);
229
+ pdf.addImage(
230
+ dataUrl,
231
+ "JPEG",
232
+ PAGE_MARGIN,
233
+ PAGE_MARGIN,
234
+ PAGE_WIDTH,
235
+ Math.min(PAGE_HEIGHT, (PAGE_WIDTH * sliceHeight) / page.width)
236
+ );
237
+ rendered += sliceHeight;
238
+ if (rendered < canvas.height) pdf.addPage();
239
+ }
240
+ pdf.save(`${fileName}.pdf`);
241
+ }
242
+
243
+ /**
244
+ * 把 dom 整体(含被滚动容器裁掉的部分)截图并按 A4 分页导出 PDF
245
+ * @param {Object} option
246
+ * @param {HTMLElement} option.dom 要导出的根元素
247
+ * @param {String} option.fileName 文件名(不带扩展名)
248
+ * @param {Number} [option.scale] 截图倍率,默认取 devicePixelRatio(上限 2),会按 canvas 上限自动回退
249
+ * @param {String|String[]} [option.ignoreSelectors] 不参与截图的元素选择器,如操作按钮区
250
+ * @param {Boolean} [option.loading] 是否在导出期间盖加载遮罩,默认 true
251
+ * @param {String} [option.loadingText] 遮罩文案
252
+ * @returns {Promise<Boolean>} 是否导出成功。内部已经提示过失败,调用方可以直接不管返回值
253
+ */
254
+ export async function exportPDF(option) {
255
+ const dom = option && option.dom;
256
+ if (!dom) return false;
257
+ const fileName = option.fileName || "export";
258
+ const loading = openLoading(dom, option);
259
+ //展开放进 try 里:万一它自己抛了,遮罩也得摘掉,不能把页面永远糊住
260
+ let restore = null;
261
+ try {
262
+ //先让浏览器把遮罩画出来,再做展开和截图——这两步都是同步卡主线程的,
263
+ //不先出让一帧的话遮罩要等截图结束才显示,等于没有。
264
+ await nextFrame();
265
+ restore = expandForCapture(dom);
266
+ if (loading) loading.setText("正在展开页面内容…");
267
+ await nextFrame();
268
+ const fullWidth = Math.max(dom.scrollWidth, dom.offsetWidth);
269
+ const fullHeight = Math.max(dom.scrollHeight, dom.offsetHeight);
270
+ const scale = pickScale(fullWidth, fullHeight, option.scale);
271
+ //html2canvas 这一段是整块同步活:它对每个节点都要 getComputedStyle 三次(自身/:before/:after)
272
+ //克隆一遍,再整棵树画进 canvas,中途没有让出主线程的机会,页面必然定住几秒。
273
+ //只能靠文案说明进度,真要更快就得降 scale 或少截点内容。
274
+ if (loading) loading.setText("正在渲染页面,请稍候…");
275
+ await nextFrame();
276
+ const canvas = await html2canvas(dom, {
277
+ backgroundColor: "#ffffff",
278
+ useCORS: true,
279
+ logging: false,
280
+ scale: scale,
281
+ //克隆出来的 iframe 默认只有一屏高,页面里的 100vh / height:100% 会在克隆里重新把内容压回一屏,
282
+ //所以要把它撑到完整内容高度;scrollX/scrollY 归零避免按当前滚动位置偏移取景。
283
+ scrollX: 0,
284
+ scrollY: 0,
285
+ windowWidth: Math.max(document.documentElement.clientWidth, fullWidth),
286
+ windowHeight: fullHeight + 200,
287
+ ignoreElements: buildIgnore(option.ignoreSelectors),
288
+ });
289
+ await canvasToPdf(canvas, fileName, loading);
290
+ return true;
291
+ } catch (err) {
292
+ //不往外抛:调用方多是 @click 直接调用,抛出去只会变成一条 unhandledrejection
293
+ console.error("[exportPDF] 导出失败", err);
294
+ Message.error("导出PDF失败");
295
+ return false;
296
+ } finally {
297
+ //必须等 html2canvas 真正 resolve 之后再还原:它的 DOM 克隆是异步做的
298
+ if (restore) restore();
299
+ if (loading) loading.close();
300
+ }
71
301
  }